feat: add attachment management support (#600)

<!--  Thanks for sending a pull request!  Here are some tips for you:
1. 如果这是你的第一次,请阅读我们的贡献指南:<https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>。
1. If this is your first time, please read our contributor guidelines: <https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>.
2. 请根据你解决问题的类型为 Pull Request 添加合适的标签。
2. Please label this pull request according to what type of issue you are addressing, especially if this is a release targeted pull request.
3. 请确保你已经添加并运行了适当的测试。
3. Ensure you have added or ran the appropriate tests for your PR.
-->

#### What type of PR is this?

/kind feature
/milestone 2.0

<!--
添加其中一个类别:
Add one of the following kinds:

/kind bug
/kind cleanup
/kind documentation
/kind feature
/kind optimization

适当添加其中一个或多个类别(可选):
Optionally add one or more of the following kinds if applicable:

/kind api-change
/kind deprecation
/kind failing-test
/kind flake
/kind regression
-->

#### What this PR does / why we need it:

增加附件管理的功能,适配 https://github.com/halo-dev/halo/pull/2354

#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/2330

<!--
PR 合并时自动关闭 issue。
Automatically closes linked issue when PR is merged.

用法:`Fixes #<issue 号>`,或者 `Fixes (粘贴 issue 完整链接)`
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
-->

#### Screenshots:

None

<!--
如果此 PR 有 UI 的改动,最好截图说明这个 PR 的改动。
If there are UI changes to this PR, it is best to take a screenshot to illustrate the changes to this PR.

eg.

Before:

![screenshot-before](https://user-images.githubusercontent.com/screenshot.png)

After:

![screenshot-after](https://user-images.githubusercontent.com/screenshot.png)
-->

#### Special notes for your reviewer:

todo list:

- [x] 根据分组筛选附件列表。
- [x] 非图片文件支持显示占位图。
- [x] 完善选择附件组件。
- [ ] ~~附件引用关系查询。~~

#### Does this PR introduce a user-facing change?

<!--
如果当前 Pull Request 的修改不会造成用户侧的任何变更,在 `release-note` 代码块儿中填写 `NONE`。
否则请填写用户侧能够理解的 Release Note。如果当前 Pull Request 包含破坏性更新(Break Change),
Release Note 需要以 `action required` 开头。
If no, just write "NONE" in the release-note block below.
If yes, a release note is required:
Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".
-->

```release-note
None
```
pull/603/head
Ryan Wang 2022-09-05 01:06:11 +08:00 committed by GitHub
parent a5e9eba231
commit e3fd9e8709
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 2765 additions and 867 deletions

View File

@ -33,7 +33,7 @@
"@formkit/themes": "1.0.0-beta.10",
"@formkit/vue": "1.0.0-beta.10",
"@halo-dev/admin-shared": "workspace:*",
"@halo-dev/api-client": "^0.0.12",
"@halo-dev/api-client": "^0.0.13",
"@halo-dev/components": "workspace:*",
"@halo-dev/richtext-editor": "^0.0.0-alpha.5",
"@tiptap/extension-character-count": "2.0.0-beta.31",
@ -44,12 +44,14 @@
"colorjs.io": "^0.4.0",
"dayjs": "^1.11.5",
"filepond": "^4.30.4",
"filepond-plugin-image-preview": "^4.6.11",
"floating-vue": "2.0.0-beta.19",
"lodash.clonedeep": "^4.5.0",
"lodash.isequal": "^4.5.0",
"path-browserify": "^1.0.1",
"pinia": "^2.0.20",
"pretty-bytes": "^6.0.0",
"qs": "^6.11.0",
"unsplash-js": "^7.0.15",
"uuid": "^8.3.2",
"vue": "^3.2.37",
"vue-filepond": "^7.0.3",
@ -60,6 +62,7 @@
},
"devDependencies": {
"@changesets/cli": "^2.24.3",
"@iconify-json/vscode-icons": "^1.1.11",
"@rushstack/eslint-patch": "^1.1.4",
"@tailwindcss/aspect-ratio": "^0.4.0",
"@types/jsdom": "^20.0.0",
@ -93,6 +96,7 @@
"tailwindcss-safe-area": "^0.2.2",
"tailwindcss-themer": "^2.0.1",
"typescript": "~4.7.4",
"unplugin-icons": "^0.14.8",
"vite": "^3.0.9",
"vite-compression-plugin": "^0.0.4",
"vite-plugin-externals": "^0.5.1",

View File

@ -7,15 +7,19 @@ const props = withDefaults(
visible?: boolean;
title?: string;
width?: number;
height?: string;
fullscreen?: boolean;
bodyClass?: string[];
mountToBody?: boolean;
}>(),
{
visible: false,
title: undefined,
width: 500,
height: undefined,
fullscreen: false,
bodyClass: undefined,
mountToBody: false,
}
);
@ -36,6 +40,7 @@ const wrapperClasses = computed(() => {
const contentStyles = computed(() => {
return {
maxWidth: props.width + "px",
height: props.height,
};
});
@ -56,7 +61,7 @@ watch(
);
</script>
<template>
<Teleport :disabled="true" to="body">
<Teleport :disabled="!mountToBody" to="body">
<div
v-show="rootVisible"
ref="modelWrapper"
@ -65,7 +70,7 @@ watch(
class="modal-wrapper"
role="dialog"
tabindex="0"
@keyup.esc="handleClose()"
@keyup.esc.stop="handleClose()"
>
<transition
enter-active-class="ease-out duration-200"
@ -77,7 +82,7 @@ watch(
@before-enter="rootVisible = true"
@after-leave="rootVisible = false"
>
<div v-show="visible" class="modal-layer" @click="handleClose()" />
<div v-show="visible" class="modal-layer" @click.stop="handleClose()" />
</transition>
<transition
enter-active-class="ease-out duration-200"

View File

@ -38,7 +38,7 @@
"homepage": "https://github.com/halo-dev/halo-admin/tree/next/shared/components#readme",
"license": "MIT",
"dependencies": {
"@halo-dev/api-client": "^0.0.12",
"@halo-dev/api-client": "^0.0.13",
"@halo-dev/components": "workspace:*",
"axios": "^0.27.2"
},

View File

@ -1,12 +1,11 @@
// core libs
// types
import type { Ref } from "vue";
import { ref } from "vue";
import { apiClient } from "../utils/api-client";
// libs
import cloneDeep from "lodash.clonedeep";
// types
import type { Ref } from "vue";
import type { FormKitSetting, FormKitSettingSpec } from "../types/formkit";
import type { ConfigMap } from "@halo-dev/api-client";
@ -19,10 +18,21 @@ const initialConfigMap: ConfigMap = {
data: {},
};
interface useSettingFormReturn {
settings: Ref<FormKitSetting | undefined>;
configMap: Ref<ConfigMap>;
configMapFormData: Ref<Record<string, Record<string, string>> | undefined>;
saving: Ref<boolean>;
handleFetchSettings: () => void;
handleFetchConfigMap: () => void;
handleSaveConfigMap: () => void;
handleReset: () => void;
}
export function useSettingForm(
settingName: Ref<string | undefined>,
configMapName: Ref<string | undefined>
) {
): useSettingFormReturn {
const settings = ref<FormKitSetting | undefined>();
const configMap = ref<ConfigMap>(cloneDeep(initialConfigMap));
const configMapFormData = ref<
@ -113,6 +123,12 @@ export function useSettingForm(
}
};
const handleReset = () => {
settings.value = undefined;
configMap.value = cloneDeep(initialConfigMap);
configMapFormData.value = undefined;
};
return {
settings,
configMap,
@ -121,5 +137,6 @@ export function useSettingForm(
handleFetchSettings,
handleFetchConfigMap,
handleSaveConfigMap,
handleReset,
};
}

View File

@ -3,6 +3,7 @@ export * from "./types/menus";
export * from "./types/formkit";
export * from "./core/plugins";
export * from "./states/pages";
export * from "./states/attachment-selector";
export * from "./layouts";
export * from "./utils/api-client";

View File

@ -0,0 +1,21 @@
import type { Attachment } from "@halo-dev/api-client";
import type { Component } from "vue";
export interface AttachmentSelectorPublicState {
providers: AttachmentProvider[];
}
export type AttachmentLike =
| Attachment
| string
| {
url: string;
type: string;
};
export interface AttachmentProvider {
id: string;
label: string;
component: Component | string;
callback?: (attachments: AttachmentLike[]) => void;
}

View File

@ -2,10 +2,13 @@ import type { Component, Ref } from "vue";
import type { RouteRecordRaw, RouteRecordName } from "vue-router";
import type { MenuGroupType } from "./menus";
import type { PagesPublicState } from "../states/pages";
import type { AttachmentSelectorPublicState } from "../states/attachment-selector";
export type ExtensionPointName = "PAGES" | "POSTS";
export type ExtensionPointName = "PAGES" | "POSTS" | "ATTACHMENT_SELECTOR";
export type ExtensionPointState = PagesPublicState;
export type ExtensionPointState =
| PagesPublicState
| AttachmentSelectorPublicState;
interface RouteRecordAppend {
parentName: RouteRecordName;

View File

@ -12,6 +12,10 @@ import {
ContentHaloRunV1alpha1TagApi,
PluginHaloRunV1alpha1PluginApi,
PluginHaloRunV1alpha1ReverseProxyApi,
StorageHaloRunV1alpha1AttachmentApi,
StorageHaloRunV1alpha1GroupApi,
StorageHaloRunV1alpha1PolicyApi,
StorageHaloRunV1alpha1PolicyTemplateApi,
ThemeHaloRunV1alpha1ThemeApi,
V1alpha1ConfigMapApi,
V1alpha1MenuApi,
@ -79,6 +83,20 @@ function setupApiClient(axios: AxiosInstance) {
snapshot: new ContentHaloRunV1alpha1SnapshotApi(undefined, apiUrl, axios),
comment: new ContentHaloRunV1alpha1CommentApi(undefined, apiUrl, axios),
reply: new ContentHaloRunV1alpha1ReplyApi(undefined, apiUrl, axios),
storage: {
group: new StorageHaloRunV1alpha1GroupApi(undefined, apiUrl, axios),
attachment: new StorageHaloRunV1alpha1AttachmentApi(
undefined,
apiUrl,
axios
),
policy: new StorageHaloRunV1alpha1PolicyApi(undefined, apiUrl, axios),
policyTemplate: new StorageHaloRunV1alpha1PolicyTemplateApi(
undefined,
apiUrl,
axios
),
},
},
// custom endpoints
user: new ApiHaloRunV1alpha1UserApi(undefined, apiUrl, axios),

View File

@ -13,9 +13,10 @@ importers:
'@formkit/themes': 1.0.0-beta.10
'@formkit/vue': 1.0.0-beta.10
'@halo-dev/admin-shared': workspace:*
'@halo-dev/api-client': ^0.0.12
'@halo-dev/api-client': ^0.0.13
'@halo-dev/components': workspace:*
'@halo-dev/richtext-editor': ^0.0.0-alpha.5
'@iconify-json/vscode-icons': ^1.1.11
'@rushstack/eslint-patch': ^1.1.4
'@tailwindcss/aspect-ratio': ^0.4.0
'@tiptap/extension-character-count': 2.0.0-beta.31
@ -46,16 +47,17 @@ importers:
eslint-plugin-cypress: ^2.12.1
eslint-plugin-vue: ^9.3.0
filepond: ^4.30.4
filepond-plugin-image-preview: ^4.6.11
floating-vue: 2.0.0-beta.19
husky: ^8.0.1
jsdom: ^20.0.0
lodash.clonedeep: ^4.5.0
lodash.isequal: ^4.5.0
path-browserify: ^1.0.1
pinia: ^2.0.20
postcss: ^8.4.16
prettier: ^2.7.1
prettier-plugin-tailwindcss: ^0.1.13
pretty-bytes: ^6.0.0
qs: ^6.11.0
sass: ^1.54.5
start-server-and-test: ^1.14.0
@ -63,6 +65,8 @@ importers:
tailwindcss-safe-area: ^0.2.2
tailwindcss-themer: ^2.0.1
typescript: ~4.7.4
unplugin-icons: ^0.14.8
unsplash-js: ^7.0.15
uuid: ^8.3.2
vite: ^3.0.9
vite-compression-plugin: ^0.0.4
@ -88,7 +92,7 @@ importers:
'@formkit/themes': 1.0.0-beta.10_tailwindcss@3.1.8
'@formkit/vue': 1.0.0-beta.10_wwmyxdjqen5bmh3tr2meig5lki
'@halo-dev/admin-shared': link:packages/shared
'@halo-dev/api-client': 0.0.12
'@halo-dev/api-client': 0.0.13
'@halo-dev/components': link:packages/components
'@halo-dev/richtext-editor': 0.0.0-alpha.5_vue@3.2.37
'@tiptap/extension-character-count': 2.0.0-beta.31
@ -99,12 +103,14 @@ importers:
colorjs.io: 0.4.0
dayjs: 1.11.5
filepond: 4.30.4
filepond-plugin-image-preview: 4.6.11_filepond@4.30.4
floating-vue: 2.0.0-beta.19_vue@3.2.37
lodash.clonedeep: 4.5.0
lodash.isequal: 4.5.0
path-browserify: 1.0.1
pinia: 2.0.20_j6bzmzd4ujpabbp5objtwxyjp4
pretty-bytes: 6.0.0
qs: 6.11.0
unsplash-js: 7.0.15
uuid: 8.3.2
vue: 3.2.37
vue-filepond: 7.0.3_filepond@4.30.4+vue@3.2.37
@ -114,6 +120,7 @@ importers:
yaml: 2.1.1
devDependencies:
'@changesets/cli': 2.24.3
'@iconify-json/vscode-icons': 1.1.11
'@rushstack/eslint-patch': 1.1.4
'@tailwindcss/aspect-ratio': 0.4.0_tailwindcss@3.1.8
'@types/jsdom': 20.0.0
@ -147,6 +154,7 @@ importers:
tailwindcss-safe-area: 0.2.2
tailwindcss-themer: 2.0.1_tailwindcss@3.1.8
typescript: 4.7.4
unplugin-icons: 0.14.8_jz6tpbhhn2upnbiwxxr6wx7age
vite: 3.0.9_sass@1.54.5
vite-compression-plugin: 0.0.4
vite-plugin-externals: 0.5.1_vite@3.0.9
@ -186,12 +194,12 @@ importers:
packages/shared:
specifiers:
'@halo-dev/api-client': ^0.0.12
'@halo-dev/api-client': ^0.0.13
'@halo-dev/components': workspace:*
axios: ^0.27.2
vite-plugin-dts: ^1.4.1
dependencies:
'@halo-dev/api-client': 0.0.12
'@halo-dev/api-client': 0.0.13
'@halo-dev/components': link:../components
axios: 0.27.2
devDependencies:
@ -2117,8 +2125,8 @@ packages:
- windicss
dev: false
/@halo-dev/api-client/0.0.12:
resolution: {integrity: sha512-fOI3DB9rOA1Z+h1aKiEQ+2kWkNSmdWIDvd+39dR5b3X0DmKH+zWrNmygA5Qe2gBPX28TNt/zr2qCKUjGjb99CA==}
/@halo-dev/api-client/0.0.13:
resolution: {integrity: sha512-RP7f8OaB2JS9y6diJpjozhxo/tx5CaQD2FpWj9udoSsWheySR7Tc+wqOMgkhP51xQskhTXRmV9m2pOg6qzFKwA==}
dev: false
/@halo-dev/richtext-editor/0.0.0-alpha.5_vue@3.2.37:
@ -2243,6 +2251,12 @@ packages:
'@iconify/types': 1.1.0
dev: true
/@iconify-json/vscode-icons/1.1.11:
resolution: {integrity: sha512-2HducOcHAGEY5fD9NRilS4BeLTu72/JEjKy9/HsUzst9VlXT0TGH6qaU1m7X2bSFZHV+CGsisG1lq6M5XxzytQ==}
dependencies:
'@iconify/types': 1.1.0
dev: true
/@iconify/types/1.1.0:
resolution: {integrity: sha512-Jh0llaK2LRXQoYsorIH8maClebsnzTcve+7U3rQUSnC11X4jtPnFuyatqFLvMxZ8MLG8dB4zfHsbPfuvxluONw==}
dev: true
@ -3120,6 +3134,10 @@ packages:
'@types/node': 17.0.45
dev: true
/@types/content-type/1.1.5:
resolution: {integrity: sha512-dgMN+syt1xb7Hk8LU6AODOfPlvz5z1CbXpPuJE5ZrX9STfBOIXF09pEB8N7a97WT9dbngt3ksDCm6GW6yMrxfQ==}
dev: false
/@types/estree/0.0.39:
resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==}
dev: true
@ -4425,6 +4443,11 @@ packages:
resolution: {integrity: sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==}
dev: true
/content-type/1.0.4:
resolution: {integrity: sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==}
engines: {node: '>= 0.6'}
dev: false
/convert-source-map/1.8.0:
resolution: {integrity: sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==}
dependencies:
@ -5527,14 +5550,6 @@ packages:
minimatch: 5.0.1
dev: true
/filepond-plugin-image-preview/4.6.11_filepond@4.30.4:
resolution: {integrity: sha512-0EmQ9HnOb/X0xc5rLcNRhhmdUbp7oiicRwQrcr90ZfVmPJOOZoX3ZGUEsEPj7luMI55huguhcVozdESxtqnuRw==}
peerDependencies:
filepond: '>=4.x <5.x'
dependencies:
filepond: 4.30.4
dev: false
/filepond/4.30.4:
resolution: {integrity: sha512-FCwsMvG9iiEs6uobdDrTaKsCgsqys0NuLgPPD8n37AYVYBiiDkrPkk9MSIU5rT2FahYcL1bScYI9huIPtlzqyA==}
dev: false
@ -7243,7 +7258,6 @@ packages:
/path-browserify/1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
dev: true
/path-exists/4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
@ -7462,7 +7476,6 @@ packages:
/pretty-bytes/6.0.0:
resolution: {integrity: sha512-6UqkYefdogmzqAZWzJ7laYeJnaXDy2/J+ZqiiMtS7t7OfpXWTlaeGMwX8U6EFvPV/YWWEKRkS8hKS4k60WHTOg==}
engines: {node: ^14.13.1 || >=16.0.0}
dev: true
/process-nextick-args/2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
@ -8724,6 +8737,39 @@ packages:
- webpack
dev: true
/unplugin-icons/0.14.8_jz6tpbhhn2upnbiwxxr6wx7age:
resolution: {integrity: sha512-YxLC0Uxec+ayl8ju3CXmRX4Jg7IF8Tu2cRyq/okXwMK6fM140SPae332ByTlul1E/I7I0PXYSVVn8SlGunM/2g==}
peerDependencies:
'@svgr/core': '>=5.5.0'
'@vue/compiler-sfc': ^3.0.2
vue-template-compiler: ^2.6.12
vue-template-es2015-compiler: ^1.9.0
peerDependenciesMeta:
'@svgr/core':
optional: true
'@vue/compiler-sfc':
optional: true
vue-template-compiler:
optional: true
vue-template-es2015-compiler:
optional: true
dependencies:
'@antfu/install-pkg': 0.1.0
'@antfu/utils': 0.5.2
'@iconify/utils': 1.0.33
'@vue/compiler-sfc': 3.2.37
debug: 4.3.4
kolorist: 1.5.1
local-pkg: 0.4.2
unplugin: 0.8.0_vite@3.0.9
transitivePeerDependencies:
- esbuild
- rollup
- supports-color
- vite
- webpack
dev: true
/unplugin/0.8.0:
resolution: {integrity: sha512-OzOkJ9XOPlD1Cph6qy/p4i/KSUbs76GToXjH/STHpfo6D7y+EqpfAL6G6HaoOw5QLkt9+KWwcxYUmPFkDf1upQ==}
peerDependencies:
@ -8747,6 +8793,38 @@ packages:
webpack-virtual-modules: 0.4.4
dev: true
/unplugin/0.8.0_vite@3.0.9:
resolution: {integrity: sha512-OzOkJ9XOPlD1Cph6qy/p4i/KSUbs76GToXjH/STHpfo6D7y+EqpfAL6G6HaoOw5QLkt9+KWwcxYUmPFkDf1upQ==}
peerDependencies:
esbuild: '>=0.13'
rollup: ^2.50.0
vite: ^2.3.0 || ^3.0.0-0
webpack: 4 || 5
peerDependenciesMeta:
esbuild:
optional: true
rollup:
optional: true
vite:
optional: true
webpack:
optional: true
dependencies:
acorn: 8.8.0
chokidar: 3.5.3
vite: 3.0.9_sass@1.54.5
webpack-sources: 3.2.3
webpack-virtual-modules: 0.4.4
dev: true
/unsplash-js/7.0.15:
resolution: {integrity: sha512-WGqKp9wl2m2tAUPyw2eMZs/KICR+A52tCaRapzVXWxkA4pjHqsaGwiJXTEW7hBy4Pu0QmP6KxTt2jST3tluawA==}
engines: {node: '>=10'}
dependencies:
'@types/content-type': 1.1.5
content-type: 1.0.4
dev: false
/untildify/4.0.0:
resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==}
engines: {node: '>=8'}

View File

@ -0,0 +1,27 @@
<script lang="ts" setup>
import { useImage } from "@vueuse/core";
const props = withDefaults(
defineProps<{
src: string;
alt?: string;
classes?: string | string[];
}>(),
{
src: "",
alt: "",
classes: "",
}
);
const { isLoading, error } = useImage({ src: props.src });
</script>
<template>
<template v-if="isLoading">
<slot name="loading"> loading... </slot>
</template>
<template v-else-if="error">
<slot name="error"> error </slot>
</template>
<img v-else :src="src" :alt="alt" :class="classes" />
</template>

View File

@ -0,0 +1,88 @@
<script lang="ts" setup>
import VueFilePond from "vue-filepond";
import "filepond/dist/filepond.min.css";
import type { AxiosRequestConfig, AxiosResponse } from "axios";
import { ref } from "vue";
const props = withDefaults(
defineProps<{
allowMultiple?: boolean;
labelIdle?: string;
maxFiles?: number | null;
maxParallelUploads?: number;
name?: string;
disabled?: boolean;
handler: (
// eslint-disable-next-line
file: any,
config: AxiosRequestConfig
// eslint-disable-next-line
) => Promise<AxiosResponse<any, any>>;
}>(),
{
allowMultiple: false,
labelIdle: "Drop file here",
maxFiles: null,
maxParallelUploads: 3,
name: "file",
disabled: false,
}
);
const emit = defineEmits<{
(event: "uploaded", response: AxiosResponse): void;
}>();
const FilePond = VueFilePond();
const FilePondRef = ref();
const server = {
process: async (fieldName, file, metadata, load, error, progress, abort) => {
try {
const response = await props.handler(file, {
onUploadProgress: (progressEvent) => {
if (progressEvent.total > 0) {
progress(
progressEvent.lengthComputable,
progressEvent.loaded,
progressEvent.total
);
}
},
});
emit("uploaded", response);
load(response);
} catch (e) {
error(e);
}
return {
abort: () => {
abort();
},
};
},
};
const handleRemoveFiles = () => {
FilePondRef.value.removeFiles();
};
defineExpose({
handleRemoveFiles,
});
</script>
<template>
<FilePond
ref="FilePondRef"
:allow-multiple="allowMultiple"
:allow-revert="false"
:label-idle="labelIdle"
:max-files="maxFiles"
:max-parallel-uploads="maxParallelUploads"
:name="name"
:server="server"
:disabled="disabled"
></FilePond>
</template>

View File

@ -1,13 +1,12 @@
<script lang="ts" setup>
import {
IconAddCircle,
IconArrowDown,
IconArrowLeft,
IconArrowRight,
IconCheckboxFill,
IconDatabase2Line,
IconGrid,
IconList,
IconMore,
IconPalette,
IconSettings,
IconUpload,
VButton,
@ -15,16 +14,143 @@ import {
VPageHeader,
VPagination,
VSpace,
VTag,
VEmpty,
IconCloseCircle,
IconFolder,
} from "@halo-dev/components";
import LazyImage from "@/components/image/LazyImage.vue";
import AttachmentDetailModal from "./components/AttachmentDetailModal.vue";
import AttachmentUploadModal from "./components/AttachmentUploadModal.vue";
import AttachmentSelectModal from "./components/AttachmentSelectModal.vue";
import AttachmentStrategiesModal from "./components/AttachmentStrategiesModal.vue";
import AttachmentGroupEditingModal from "./components/AttachmentGroupEditingModal.vue";
import { ref } from "vue";
import AttachmentPoliciesModal from "./components/AttachmentPoliciesModal.vue";
import AttachmentGroupList from "./components/AttachmentGroupList.vue";
import { onMounted, ref } from "vue";
import { useUserFetch } from "@/modules/system/users/composables/use-user";
import type { Attachment, Group, Policy, User } from "@halo-dev/api-client";
import { formatDatetime } from "@/utils/date";
import prettyBytes from "pretty-bytes";
import { useFetchAttachmentPolicy } from "./composables/use-attachment-policy";
import { useAttachmentControl } from "./composables/use-attachment";
import AttachmentSelectorModal from "@/modules/contents/attachments/components/AttachmentSelectorModal.vue";
import AttachmentFileTypeIcon from "./components/AttachmentFileTypeIcon.vue";
import { apiClient } from "@halo-dev/admin-shared";
import cloneDeep from "lodash.clonedeep";
import { isImage } from "@/utils/image";
import { useRouteQuery } from "@vueuse/router";
import { useFetchAttachmentGroup } from "./composables/use-attachment-group";
const policyVisible = ref(false);
const uploadVisible = ref(false);
const detailVisible = ref(false);
const selectVisible = ref(false);
const { users } = useUserFetch();
const { policies } = useFetchAttachmentPolicy({ fetchOnMounted: true });
const { groups, handleFetchGroups } = useFetchAttachmentGroup({
fetchOnMounted: true,
});
const selectedGroup = ref<Group>();
// Filter
const selectedPolicy = ref<Policy>();
const selectedUser = ref<User>();
const keyword = ref<string>("");
function handleSelectPolicy(policy: Policy | undefined) {
selectedPolicy.value = policy;
handleFetchAttachments();
}
function handleSelectUser(user: User | undefined) {
selectedUser.value = user;
handleFetchAttachments();
}
const {
attachments,
selectedAttachment,
selectedAttachments,
checkedAll,
loading,
handleFetchAttachments,
handleSelectNext,
handleSelectPrevious,
handlePaginationChange,
handleDeleteInBatch,
handleCheckAll,
handleSelect,
isChecked,
handleReset,
} = useAttachmentControl({
group: selectedGroup,
policy: selectedPolicy,
user: selectedUser,
keyword: keyword,
});
const handleMove = async (group: Group) => {
try {
const promises = Array.from(selectedAttachments.value).map((attachment) => {
const attachmentToUpdate = cloneDeep(attachment);
attachmentToUpdate.spec.groupRef = {
name: group.metadata.name,
};
return apiClient.extension.storage.attachment.updatestorageHaloRunV1alpha1Attachment(
attachment.metadata.name,
attachmentToUpdate
);
});
await Promise.all(promises);
selectedAttachments.value.clear();
} catch (e) {
console.error(e);
} finally {
handleFetchAttachments();
}
};
const handleClickItem = (attachment: Attachment) => {
if (attachment.metadata.deletionTimestamp) {
return;
}
if (selectedAttachments.value.size > 0) {
handleSelect(attachment);
return;
}
selectedAttachment.value = attachment;
selectedAttachments.value.clear();
detailVisible.value = true;
};
const handleCheckAllChange = (e: Event) => {
const { checked } = e.target as HTMLInputElement;
handleCheckAll(checked);
};
const onDetailModalClose = () => {
selectedAttachment.value = undefined;
handleFetchAttachments();
};
const onUploadModalClose = () => {
routeQueryAction.value = undefined;
handleFetchAttachments();
};
const onGroupChange = () => {
handleReset();
handleFetchAttachments();
};
const getPolicyName = (name: string | undefined) => {
const policy = policies.value.find((p) => p.metadata.name === name);
return policy?.spec.displayName;
};
// View type
const viewTypes = [
{
name: "list",
@ -36,78 +162,55 @@ const viewTypes = [
},
];
const viewType = ref("grid");
const viewType = useRouteQuery<string>("view", "grid");
const strategyVisible = ref(false);
const selectVisible = ref(false);
const uploadVisible = ref(false);
const detailVisible = ref(false);
const groupEditingModal = ref(false);
const checkAll = ref(false);
// Route query action
const routeQueryAction = useRouteQuery<string | undefined>("action");
const { users } = useUserFetch();
const attachments = Array.from(new Array(50), (_, index) => index).map(
(index) => {
return {
id: index,
name: `attachment-${index}`,
url: `https://picsum.photos/1000/700?random=${index}`,
size: "1.2MB",
type: "image/png",
strategy: "本地存储",
};
onMounted(() => {
if (!routeQueryAction.value) {
return;
}
);
const folders = [
{
name: "2022",
},
{
name: "2021",
},
{
name: "Photos",
},
{
name: "Documents",
},
{
name: "Videos",
},
{
name: "Pictures",
},
{
name: "Developer",
},
];
if (routeQueryAction.value === "upload") {
uploadVisible.value = true;
}
});
</script>
<template>
<AttachmentDetailModal v-model:visible="detailVisible" />
<AttachmentUploadModal v-model:visible="uploadVisible" />
<AttachmentSelectModal v-model:visible="selectVisible" />
<AttachmentStrategiesModal v-model:visible="strategyVisible" />
<AttachmentGroupEditingModal v-model:visible="groupEditingModal" />
<AttachmentSelectorModal v-model:visible="selectVisible" />
<AttachmentDetailModal
v-model:visible="detailVisible"
:attachment="selectedAttachment"
@close="onDetailModalClose"
>
<template #actions>
<div class="modal-header-action" @click="handleSelectPrevious">
<IconArrowLeft />
</div>
<div class="modal-header-action" @click="handleSelectNext">
<IconArrowRight />
</div>
</template>
</AttachmentDetailModal>
<AttachmentUploadModal
v-model:visible="uploadVisible"
:group="selectedGroup"
@close="onUploadModalClose"
/>
<AttachmentPoliciesModal v-model:visible="policyVisible" />
<VPageHeader title="附件库">
<template #icon>
<IconPalette class="mr-2 self-center" />
<IconFolder class="mr-2 self-center" />
</template>
<template #actions>
<VSpace>
<VButton size="sm" @click="strategyVisible = true">
<VButton size="sm" @click="selectVisible = true"> 选择附件</VButton>
<VButton size="sm" @click="policyVisible = true">
<template #icon>
<IconDatabase2Line class="h-full w-full" />
</template>
存储策略
</VButton>
<VButton size="sm">
<template #icon>
<IconSettings class="h-full w-full" />
</template>
设置
</VButton>
<VButton type="secondary" @click="uploadVisible = true">
<template #icon>
<IconUpload class="h-full w-full" />
@ -129,20 +232,81 @@ const folders = [
>
<div class="mr-4 hidden items-center sm:flex">
<input
v-model="checkAll"
v-model="checkedAll"
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
type="checkbox"
@change="handleCheckAllChange"
/>
</div>
<div class="flex w-full flex-1 sm:w-auto">
<FormKit
v-if="!checkAll"
placeholder="输入关键词搜索"
type="text"
></FormKit>
<div class="flex w-full flex-1 items-center sm:w-auto">
<div
v-if="!selectedAttachments.size"
class="flex items-center gap-2"
>
<FormKit
v-model="keyword"
placeholder="输入关键词搜索"
type="text"
@keyup.enter="handleFetchAttachments()"
></FormKit>
<div
v-if="selectedPolicy"
class="group flex cursor-pointer items-center justify-center gap-1 rounded-full bg-gray-200 px-2 py-1 hover:bg-gray-300"
>
<span
class="text-xs text-gray-600 group-hover:text-gray-900"
>
存储策略{{ selectedPolicy?.spec.displayName }}
</span>
<IconCloseCircle
class="h-4 w-4 text-gray-600"
@click="handleSelectPolicy(undefined)"
/>
</div>
<div
v-if="selectedUser"
class="group flex cursor-pointer items-center justify-center gap-1 rounded-full bg-gray-200 px-2 py-1 hover:bg-gray-300"
>
<span
class="text-xs text-gray-600 group-hover:text-gray-900"
>
上传者{{ selectedUser?.spec.displayName }}
</span>
<IconCloseCircle
class="h-4 w-4 text-gray-600"
@click="handleSelectUser(undefined)"
/>
</div>
</div>
<VSpace v-else>
<VButton type="default">设置</VButton>
<VButton type="danger">删除</VButton>
<VButton type="danger" @click="handleDeleteInBatch">
删除
</VButton>
<VButton @click="selectedAttachments.clear()">
取消选择
</VButton>
<FloatingDropdown>
<VButton>移动</VButton>
<template #popper>
<div class="w-72 p-4">
<ul class="space-y-1">
<li
v-for="(group, index) in groups"
:key="index"
v-close-popper
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
@click="handleMove(group)"
>
<span class="truncate">
{{ group.spec.displayName }}
</span>
</li>
</ul>
</div>
</template>
</FloatingDropdown>
</VSpace>
</div>
<div class="mt-4 flex sm:mt-0">
@ -160,22 +324,20 @@ const folders = [
<div class="w-72 p-4">
<ul class="space-y-1">
<li
v-for="(policy, index) in policies"
:key="index"
v-close-popper
:class="{
'bg-gray-100':
selectedPolicy?.metadata.name ===
policy.metadata.name,
}"
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
@click="handleSelectPolicy(policy)"
>
<span class="truncate">本地</span>
</li>
<li
v-close-popper
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
<span class="truncate">阿里云 OSS</span>
</li>
<li
v-close-popper
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
<span class="truncate">Amazon S3</span>
<span class="truncate">
{{ policy.spec.displayName }}
</span>
</li>
</ul>
</div>
@ -191,8 +353,8 @@ const folders = [
</span>
</div>
<template #popper>
<div class="h-96 w-80 p-4">
<div class="bg-white">
<div class="h-96 w-80">
<div class="bg-white p-4">
<!--TODO: Auto Focus-->
<FormKit
placeholder="输入关键词搜索"
@ -205,15 +367,17 @@ const folders = [
v-for="(user, index) in users"
:key="index"
v-close-popper
class="cursor-pointer py-4 hover:bg-gray-50"
class="cursor-pointer hover:bg-gray-50"
:class="{
'bg-gray-100':
selectedUser?.metadata.name ===
user.metadata.name,
}"
@click="handleSelectUser(user)"
>
<div class="flex items-center space-x-4">
<div class="flex items-center">
<input
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
type="checkbox"
/>
</div>
<div
class="flex items-center space-x-4 px-4 py-3"
>
<div class="flex-shrink-0">
<img
:alt="user.spec.displayName"
@ -231,9 +395,6 @@ const folders = [
@{{ user.metadata.name }}
</p>
</div>
<div>
<VTag>{{ index + 1 }} </VTag>
</div>
</div>
</li>
</ul>
@ -269,14 +430,6 @@ const folders = [
</div>
</template>
</FloatingDropdown>
<div
class="flex cursor-pointer items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5">标签</span>
<span>
<IconArrowDown />
</span>
</div>
<FloatingDropdown>
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
@ -337,162 +490,229 @@ const folders = [
</div>
</template>
<div v-if="viewType === 'grid'">
<div class="mb-5 grid grid-cols-2 gap-x-2 gap-y-3 sm:grid-cols-6">
<div
class="flex cursor-pointer items-center rounded-base bg-gray-200 p-2 text-gray-900 transition-all"
>
<div class="flex flex-1 items-center">
<span class="text-sm">全部212</span>
</div>
</div>
<div
class="flex cursor-pointer items-center rounded-base bg-gray-100 p-2 text-gray-500 transition-all hover:bg-gray-200 hover:text-gray-900 hover:shadow-sm"
>
<div class="flex flex-1 items-center">
<span class="text-sm">未分组18</span>
</div>
</div>
<div
v-for="(folder, index) in folders"
:key="index"
class="flex cursor-pointer items-center rounded-base bg-gray-100 p-2 text-gray-500 transition-all hover:bg-gray-200 hover:text-gray-900 hover:shadow-sm"
>
<div class="flex flex-1 items-center">
<span class="text-sm">
{{ folder.name }}{{ index * 20 }}
</span>
</div>
<FloatingDropdown>
<IconMore />
<template #popper>
<div class="w-48 p-2">
<VSpace class="w-full" direction="column">
<VButton
v-close-popper
block
type="secondary"
@click="groupEditingModal = true"
>
重命名
</VButton>
<VButton v-close-popper block type="danger">
删除
</VButton>
</VSpace>
</div>
</template>
</FloatingDropdown>
</div>
<div
class="flex cursor-pointer items-center rounded-base bg-gray-100 p-2 text-gray-500 transition-all hover:bg-gray-200 hover:text-gray-900 hover:shadow-sm"
@click="groupEditingModal = true"
>
<div class="flex flex-1 items-center">
<span class="text-sm">添加分组</span>
</div>
<IconAddCircle />
</div>
</div>
<div
class="mt-2 grid grid-cols-3 gap-x-2 gap-y-3 sm:grid-cols-3 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12"
role="list"
>
<VCard
v-for="(attachment, index) in attachments"
:key="index"
:body-class="['!p-0']"
class="hover:shadow"
@click="detailVisible = true"
>
<div class="relative bg-white">
<div
class="group aspect-w-10 aspect-h-8 block h-full w-full cursor-pointer overflow-hidden bg-gray-100"
>
<img
:src="attachment.url"
alt=""
class="pointer-events-none object-cover group-hover:opacity-75"
/>
</div>
<p
class="pointer-events-none block truncate px-2 py-1 text-center text-xs font-medium text-gray-700"
>
{{ attachment.name }}
</p>
<IconCheckboxFill
v-if="checkAll"
class="absolute top-0.5 right-0.5"
/>
</div>
</VCard>
</div>
<div :style="`${viewType === 'list' ? 'padding:12px 16px 0' : ''}`">
<AttachmentGroupList
v-model:selected-group="selectedGroup"
@select="onGroupChange"
@update="handleFetchGroups"
/>
</div>
<ul
v-if="viewType === 'list'"
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
<VEmpty
v-if="!attachments.total && !loading"
message="当前分组没有附件,你可以尝试刷新或者上传附件"
title="当前分组没有附件"
>
<li v-for="(attachment, index) in attachments" :key="index">
<template #actions>
<VSpace>
<VButton @click="handleFetchAttachments"></VButton>
<VButton type="secondary" @click="uploadVisible = true">
<template #icon>
<IconUpload class="h-full w-full" />
</template>
上传附件
</VButton>
</VSpace>
</template>
</VEmpty>
<div v-else>
<div v-if="viewType === 'grid'">
<div
:class="{
'bg-gray-100': checkAll,
}"
class="relative block cursor-pointer px-4 py-3 transition-all hover:bg-gray-50"
class="mt-2 grid grid-cols-3 gap-x-2 gap-y-3 sm:grid-cols-3 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12"
role="list"
>
<div
v-show="checkAll"
class="absolute inset-y-0 left-0 w-0.5 bg-primary"
></div>
<div class="relative flex flex-row items-center">
<div class="mr-4 hidden items-center sm:flex">
<input
v-model="checkAll"
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
type="checkbox"
/>
</div>
<div class="flex-1">
<div class="flex flex-col sm:flex-row">
<span
class="mr-0 truncate text-sm font-medium text-gray-900 sm:mr-2"
>
{{ attachment.name }}
</span>
</div>
<div class="mt-1 flex">
<VSpace>
<span class="text-xs text-gray-500">image/png</span>
<span class="text-xs text-gray-500">1.2 MB</span>
</VSpace>
</div>
</div>
<div class="flex">
<VCard
v-for="(attachment, index) in attachments.items"
:key="index"
:body-class="['!p-0']"
:class="{
'ring-1 ring-primary': isChecked(attachment),
'ring-1 ring-red-600':
attachment.metadata.deletionTimestamp,
}"
class="hover:shadow"
@click="handleClickItem(attachment)"
>
<div class="group relative bg-white">
<div
class="inline-flex flex-col flex-col-reverse items-end gap-4 sm:flex-row sm:items-center sm:gap-6"
class="aspect-w-10 aspect-h-8 block h-full w-full cursor-pointer overflow-hidden bg-gray-100"
>
<img
class="hidden h-6 w-6 rounded-full ring-2 ring-white sm:inline-block"
src="https://ryanc.cc/avatar"
<LazyImage
v-if="isImage(attachment.spec.mediaType)"
:key="attachment.metadata.name"
:alt="attachment.spec.displayName"
:src="attachment.status?.permalink"
classes="pointer-events-none object-cover group-hover:opacity-75"
>
<template #loading>
<div
class="flex h-full items-center justify-center object-cover"
>
<span class="text-xs text-gray-400">加载中...</span>
</div>
</template>
<template #error>
<div
class="flex h-full items-center justify-center object-cover"
>
<span class="text-xs text-red-400">加载异常</span>
</div>
</template>
</LazyImage>
<AttachmentFileTypeIcon
v-else
:file-name="attachment.spec.displayName"
/>
<time class="text-sm text-gray-500" datetime="2020-01-07">
2020-01-07
</time>
<span class="cursor-pointer">
<IconSettings @click.stop="detailVisible = true" />
</span>
</div>
<p
v-tooltip="attachment.spec.displayName"
class="block cursor-pointer truncate px-2 py-1 text-center text-xs font-medium text-gray-700"
>
{{ attachment.spec.displayName }}
</p>
<div
v-if="attachment.metadata.deletionTimestamp"
class="absolute top-1 right-1 text-xs text-red-300"
>
删除中...
</div>
<div
v-if="!attachment.metadata.deletionTimestamp"
:class="{ '!flex': selectedAttachments.has(attachment) }"
class="absolute top-0 left-0 hidden h-1/3 w-full cursor-pointer justify-end bg-gradient-to-b from-gray-300 to-transparent ease-in-out group-hover:flex"
>
<IconCheckboxFill
:class="{
'!text-primary': selectedAttachments.has(attachment),
}"
class="mt-1 mr-1 h-6 w-6 cursor-pointer text-white transition-all hover:text-primary"
@click.stop="handleSelect(attachment)"
/>
</div>
</div>
</VCard>
</div>
</div>
<ul
v-if="viewType === 'list'"
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li v-for="(attachment, index) in attachments.items" :key="index">
<div
:class="{
'bg-gray-100': isChecked(attachment),
}"
class="relative block cursor-pointer px-4 py-3 transition-all hover:bg-gray-50"
>
<div
v-show="isChecked(attachment)"
class="absolute inset-y-0 left-0 w-0.5 bg-primary"
></div>
<div class="relative flex flex-row items-center">
<div class="mr-4 hidden items-center sm:flex">
<input
:checked="selectedAttachments.has(attachment)"
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
type="checkbox"
@click="handleSelect(attachment)"
/>
</div>
<div class="mr-4">
<div
class="h-12 w-12 rounded border bg-white p-1 hover:shadow-sm"
>
<AttachmentFileTypeIcon
:display-ext="false"
:file-name="attachment.spec.displayName"
:width="8"
:height="8"
/>
</div>
</div>
<div class="flex-1">
<div class="flex flex-col sm:flex-row">
<span
class="mr-0 truncate text-sm font-medium text-gray-900 sm:mr-2"
@click="handleClickItem(attachment)"
>
{{ attachment.spec.displayName }}
</span>
</div>
<div class="mt-1 flex">
<VSpace>
<span class="text-xs text-gray-500">
{{ attachment.spec.mediaType }}
</span>
<span class="text-xs text-gray-500">
{{ prettyBytes(attachment.spec.size || 0) }}
</span>
</VSpace>
</div>
</div>
<div class="flex">
<div
class="inline-flex flex-col items-end gap-4 sm:flex-row sm:items-center sm:gap-6"
>
<span class="text-sm text-gray-500">
{{ getPolicyName(attachment.spec.policyRef?.name) }}
</span>
<RouterLink
:to="{
name: 'UserDetail',
params: { name: attachment.spec.uploadedBy?.name },
}"
>
<span class="text-sm text-gray-500">
{{ attachment.spec.uploadedBy?.name }}
</span>
</RouterLink>
<FloatingTooltip
v-if="attachment.metadata.deletionTimestamp"
class="hidden items-center sm:flex"
>
<div
class="inline-flex h-1.5 w-1.5 rounded-full bg-red-600"
>
<span
class="inline-block h-1.5 w-1.5 animate-ping rounded-full bg-red-600"
></span>
</div>
<template #popper> 删除中</template>
</FloatingTooltip>
<time class="text-sm text-gray-500">
{{
formatDatetime(
attachment.metadata.creationTimestamp
)
}}
</time>
<span class="cursor-pointer">
<IconSettings
@click.stop="handleClickItem(attachment)"
/>
</span>
</div>
</div>
</div>
</div>
</div>
</li>
</ul>
</li>
</ul>
</div>
<template #footer>
<div class="bg-white sm:flex sm:items-center sm:justify-end">
<VPagination :page="1" :size="10" :total="20" />
<VPagination
:page="attachments.page"
:size="attachments.size"
:total="attachments.total"
@change="handlePaginationChange"
/>
</div>
</template>
</VCard>

View File

@ -1,99 +0,0 @@
<script lang="ts" setup>
import { VAlert, VButton, VModal, VSpace } from "@halo-dev/components";
withDefaults(
defineProps<{
visible: boolean;
}>(),
{
visible: false,
}
);
const emit = defineEmits<{
(event: "update:visible", visible: boolean): void;
(event: "close"): void;
}>();
const onVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {
emit("close");
}
};
</script>
<template>
<VModal
:visible="visible"
:width="600"
title="阿里云 OSS 存储策略编辑"
@update:visible="onVisibleChange"
>
<VAlert class="mb-4" title="提示">
<template #description>
<p class="my-3 text-sm text-gray-600">
阿里云 OSS 产品文档<a
href="https://help.aliyun.com/product/31815.html"
target="_blank"
>https://help.aliyun.com/product/31815.html</a
>
</p>
</template>
</VAlert>
<FormKit id="alioss-strategy-form" type="form">
<FormKit label="名称" type="text" validation="required"></FormKit>
<FormKit label="Bucket" type="text" validation="required"></FormKit>
<FormKit label="EndPoint" type="text" validation="required"></FormKit>
<FormKit label="Access Key" type="text" validation="required"></FormKit>
<FormKit
label="Access Secret"
type="text"
validation="required"
></FormKit>
<FormKit
label="上传目录"
placeholder="如不填写,则默认上传到根目录"
type="text"
></FormKit>
<FormKit
:options="[
{ label: 'HTTPS', value: 'https' },
{ label: 'HTTP', value: 'http' },
]"
label="绑定域名协议"
type="select"
></FormKit>
<FormKit
label="绑定域名"
placeholder="如不设置,那么将使用 Bucket + EndPoint 作为域名"
type="text"
></FormKit>
<FormKit
:options="[
{ label: '服务端上传', value: 'server' },
{ label: '客户端上传', value: 'client' },
]"
label="上传方式"
type="select"
></FormKit>
<FormKit
help="使用半角逗号分隔"
label="允许上传的文件类型"
type="textarea"
value="jpg,png,gif"
></FormKit>
</FormKit>
<template #footer>
<VSpace>
<VButton
type="secondary"
@click="$formkit.submit('alioss-strategy-form')"
>
保存 +
</VButton>
<VButton @click="onVisibleChange(false)"> Esc</VButton>
</VSpace>
</template>
</VModal>
</template>

View File

@ -1,19 +1,24 @@
<script lang="ts" setup>
import {
IconArrowLeft,
IconArrowRight,
VButton,
VModal,
VSpace,
VTag,
} from "@halo-dev/components";
import { VButton, VModal, VSpace, VTag } from "@halo-dev/components";
import LazyImage from "@/components/image/LazyImage.vue";
import type { Attachment, Policy } from "@halo-dev/api-client";
import prettyBytes from "pretty-bytes";
import { ref, watch, watchEffect } from "vue";
import { apiClient } from "@halo-dev/admin-shared";
import { isImage } from "@/utils/image";
import { formatDatetime } from "@/utils/date";
import { useFetchAttachmentGroup } from "../composables/use-attachment-group";
withDefaults(
const props = withDefaults(
defineProps<{
visible: boolean;
attachment: Attachment | null;
mountToBody?: boolean;
}>(),
{
visible: false,
attachment: null,
mountToBody: false,
}
);
@ -22,6 +27,39 @@ const emit = defineEmits<{
(event: "close"): void;
}>();
const { groups, handleFetchGroups } = useFetchAttachmentGroup();
const policy = ref<Policy>();
const onlyPreview = ref(false);
watchEffect(async () => {
if (props.attachment) {
const { policyRef } = props.attachment.spec;
if (!policyRef) {
return;
}
const { data } =
await apiClient.extension.storage.policy.getstorageHaloRunV1alpha1Policy(
policyRef.name
);
policy.value = data;
}
});
watch(
() => props.visible,
(newValue) => {
if (newValue) {
handleFetchGroups();
}
}
);
const getGroupName = (name: string | undefined) => {
const group = groups.value.find((group) => group.metadata.name === name);
return group?.spec.displayName || name;
};
const onVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {
@ -31,149 +69,159 @@ const onVisibleChange = (visible: boolean) => {
</script>
<template>
<VModal
:title="`附件:${attachment?.spec.displayName || ''}`"
:visible="visible"
:width="1000"
title="attachment-0"
:mount-to-body="mountToBody"
height="calc(100vh - 20px)"
@update:visible="onVisibleChange"
>
<template #actions>
<div class="modal-header-action">
<IconArrowLeft />
</div>
<div class="modal-header-action">
<IconArrowRight />
</div>
<slot name="actions"></slot>
</template>
<div class="overflow-hidden bg-white">
<div>
<dl>
<div
class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">原始内容</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<img
class="w-full rounded sm:w-1/2"
src="https://picsum.photos/1000/700?random=1"
/>
</dd>
</div>
<div
class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">存储策略</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
阿里云/bucket/blog-attachments
</dd>
</div>
<div
class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">所在分组</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
Photos
</dd>
</div>
<div
class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">文件名称</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
attachment-0
</dd>
</div>
<div
class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">文件类型</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
image/jpeg
</dd>
</div>
<div
class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">文件大小</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
1.2 MB
</dd>
</div>
<div
class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">上传者</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
Ryan Wang
</dd>
</div>
<div
class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">上传时间</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
2020-01-01 12:00:00
</dd>
</div>
<div
class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">原始链接</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
https://picsum.photos/1000/700?random=1
</dd>
</div>
<div
class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">引用位置</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<ul class="mt-2 space-y-2">
<li>
<div
class="inline-flex w-96 cursor-pointer flex-row gap-x-3 rounded border p-3 hover:border-primary"
>
<RouterLink
:to="{
name: 'Posts',
}"
class="font-medium text-gray-900 hover:text-blue-400"
>
Halo 1.5.3 发布了
</RouterLink>
<div class="text-xs">
<VSpace>
<VTag>文章</VTag>
</VSpace>
</div>
</div>
</li>
<li>
<div
class="inline-flex w-96 cursor-pointer flex-row gap-x-3 rounded border p-3 hover:border-primary"
>
<RouterLink
:to="{
name: 'Posts',
}"
class="font-medium text-gray-900 hover:text-blue-400"
>
Halo 1.5.2 发布
</RouterLink>
<div class="text-xs">
<VSpace>
<VTag>文章</VTag>
</VSpace>
</div>
</div>
</li>
</ul>
</dd>
</div>
</dl>
<div
v-if="onlyPreview && isImage(attachment?.spec.mediaType)"
class="flex justify-center"
>
<img
v-tooltip.bottom="`点击退出预览`"
:alt="attachment?.spec.displayName"
:src="attachment?.status?.permalink"
class="w-auto cursor-pointer rounded"
@click="onlyPreview = !onlyPreview"
/>
</div>
<dl v-else>
<div
class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"
>
<dt class="text-sm font-medium text-gray-900">预览</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
<LazyImage
v-if="isImage(attachment?.spec.mediaType)"
:alt="attachment?.spec.displayName"
:src="attachment?.status?.permalink"
class="max-w-full cursor-pointer rounded sm:max-w-[50%]"
@click="onlyPreview = !onlyPreview"
>
<template #loading>
<span class="text-gray-400">加载中...</span>
</template>
<template #error>
<span class="text-red-400">加载异常</span>
</template>
</LazyImage>
<span v-else> </span>
</dd>
</div>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-900">存储策略</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
{{ policy?.spec.displayName }}
</dd>
</div>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-900">所在分组</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
{{ getGroupName(attachment?.spec.groupRef?.name) || "未分组" }}
</dd>
</div>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-900">文件名称</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
{{ attachment?.spec.displayName }}
</dd>
</div>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-900">文件类型</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
{{ attachment?.spec.mediaType }}
</dd>
</div>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-900">文件大小</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
{{ prettyBytes(attachment?.spec.size || 0) }}
</dd>
</div>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-900">上传者</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
{{ attachment?.spec.uploadedBy?.name }}
</dd>
</div>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-900">上传时间</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
{{ formatDatetime(attachment?.metadata.creationTimestamp) }}
</dd>
</div>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-900">原始链接</dt>
<dd
class="mt-1 text-sm text-gray-900 hover:text-blue-600 sm:col-span-2 sm:mt-0"
>
<a target="_blank" :href="attachment?.status?.permalink">
{{ attachment?.status?.permalink }}
</a>
</dd>
</div>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-900">引用位置</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0">
// TODO
<ul v-if="false" class="mt-2 space-y-2">
<li>
<div
class="inline-flex w-96 cursor-pointer flex-row gap-x-3 rounded border p-3 hover:border-primary"
>
<RouterLink
:to="{
name: 'Posts',
}"
class="font-medium text-gray-900 hover:text-blue-400"
>
Halo 1.5.3 发布了
</RouterLink>
<div class="text-xs">
<VSpace>
<VTag>文章</VTag>
</VSpace>
</div>
</div>
</li>
<li>
<div
class="inline-flex w-96 cursor-pointer flex-row gap-x-3 rounded border p-3 hover:border-primary"
>
<RouterLink
:to="{
name: 'Posts',
}"
class="font-medium text-gray-900 hover:text-blue-400"
>
Halo 1.5.2 发布
</RouterLink>
<div class="text-xs">
<VSpace>
<VTag>文章</VTag>
</VSpace>
</div>
</div>
</li>
</ul>
</dd>
</div>
</dl>
</div>
<template #footer>
<VButton type="default" @click="onVisibleChange(false)"></VButton>
<VSpace>
<VButton type="default" @click="onVisibleChange(false)"
>关闭 Esc</VButton
>
<slot name="footer" />
</VSpace>
</template>
</VModal>
</template>

View File

@ -0,0 +1,117 @@
<script lang="ts" setup>
import VscodeIconsDefaultFile from "~icons/vscode-icons/default-file";
import VscodeIconsFileTypeImage from "~icons/vscode-icons/file-type-image";
import VscodeIconsFileTypePhotoshop from "~icons/vscode-icons/file-type-photoshop";
import VscodeIconsFileTypeAi from "~icons/vscode-icons/file-type-ai";
import VscodeIconsFileTypeWord from "~icons/vscode-icons/file-type-word";
import VscodeIconsFileTypePowerpoint from "~icons/vscode-icons/file-type-powerpoint";
import VscodeIconsFileTypeExcel from "~icons/vscode-icons/file-type-excel";
import VscodeIconsFileTypeText from "~icons/vscode-icons/file-type-text";
import VscodeIconsFileTypeJson from "~icons/vscode-icons/file-type-json";
import VscodeIconsFileTypeHtml from "~icons/vscode-icons/file-type-html";
import VscodeIconsFileTypeYaml from "~icons/vscode-icons/file-type-yaml";
import VscodeIconsFileTypeXml from "~icons/vscode-icons/file-type-xml";
import VscodeIconsFileTypeJava from "~icons/vscode-icons/file-type-java";
import VscodeIconsFileTypeJar from "~icons/vscode-icons/file-type-jar";
import VscodeIconsFileTypeClass from "~icons/vscode-icons/file-type-class";
import VscodeIconsFileTypeJsOfficial from "~icons/vscode-icons/file-type-js-official";
import VscodeIconsFileTypeTypescriptOfficial from "~icons/vscode-icons/file-type-typescript-official";
import VscodeIconsFileTypeVue from "~icons/vscode-icons/file-type-vue";
import VscodeIconsFileTypeGo from "~icons/vscode-icons/file-type-go";
import VscodeIconsFileTypeC from "~icons/vscode-icons/file-type-c";
import VscodeIconsFileTypeCpp from "~icons/vscode-icons/file-type-cpp";
import VscodeIconsFileTypeAstro from "~icons/vscode-icons/file-type-astro";
import VscodeIconsFileTypeBat from "~icons/vscode-icons/file-type-bat";
import VscodeIconsFileTypeCss from "~icons/vscode-icons/file-type-css";
import VscodeIconsFileTypeDb from "~icons/vscode-icons/file-type-db";
import VscodeIconsFileTypeGradle from "~icons/vscode-icons/file-type-gradle";
import VscodeIconsFileTypeMarkdown from "~icons/vscode-icons/file-type-markdown";
import VscodeIconsFileTypePython from "~icons/vscode-icons/file-type-python";
import VscodeIconsFileTypeShell from "~icons/vscode-icons/file-type-shell";
import VscodeIconsFileTypePhp3 from "~icons/vscode-icons/file-type-php3";
import { extname } from "path-browserify";
import { computed, markRaw } from "vue";
const FileTypeIconsMap = {
// image
".jpg": markRaw(VscodeIconsFileTypeImage),
".png": markRaw(VscodeIconsFileTypeImage),
".gif": markRaw(VscodeIconsFileTypeImage),
".webp": markRaw(VscodeIconsFileTypeImage),
".svg": markRaw(VscodeIconsFileTypeImage),
// documnet
".docx": markRaw(VscodeIconsFileTypeWord),
".pptx": markRaw(VscodeIconsFileTypePowerpoint),
".xlsx": markRaw(VscodeIconsFileTypeExcel),
".psd": markRaw(VscodeIconsFileTypePhotoshop),
".ai": markRaw(VscodeIconsFileTypeAi),
".txt": markRaw(VscodeIconsFileTypeText),
// programming languages or frameworks
".json": markRaw(VscodeIconsFileTypeJson),
".html": markRaw(VscodeIconsFileTypeHtml),
".yaml": markRaw(VscodeIconsFileTypeYaml),
".xml": markRaw(VscodeIconsFileTypeXml),
".java": markRaw(VscodeIconsFileTypeJava),
".jar": markRaw(VscodeIconsFileTypeJar),
".class": markRaw(VscodeIconsFileTypeClass),
".js": markRaw(VscodeIconsFileTypeJsOfficial),
".ts": markRaw(VscodeIconsFileTypeTypescriptOfficial),
".vue": markRaw(VscodeIconsFileTypeVue),
".go": markRaw(VscodeIconsFileTypeGo),
".c": markRaw(VscodeIconsFileTypeC),
".cpp": markRaw(VscodeIconsFileTypeCpp),
".astro": markRaw(VscodeIconsFileTypeAstro),
".bat": markRaw(VscodeIconsFileTypeBat),
".css": markRaw(VscodeIconsFileTypeCss),
".db": markRaw(VscodeIconsFileTypeDb),
".gradle": markRaw(VscodeIconsFileTypeGradle),
".md": markRaw(VscodeIconsFileTypeMarkdown),
".py": markRaw(VscodeIconsFileTypePython),
".sh": markRaw(VscodeIconsFileTypeShell),
".php": markRaw(VscodeIconsFileTypePhp3),
};
const props = withDefaults(
defineProps<{
fileName: string | undefined;
displayExt?: boolean;
width?: number;
height?: number;
}>(),
{
fileName: undefined,
displayExt: true,
width: 10,
height: 10,
}
);
const getExtname = computed(() => {
const ext = extname(props.fileName);
if (ext) return ext.toLowerCase();
return undefined;
});
const getIcon = computed(() => {
const icon = FileTypeIconsMap[getExtname.value];
if (icon) return icon;
return markRaw(VscodeIconsDefaultFile);
});
const iconClass = computed(() => {
return [`w-${props.width}`, `h-${props.height}`];
});
</script>
<template>
<div class="flex h-full w-full flex-col items-center justify-center gap-1">
<component :is="getIcon" :class="iconClass" />
<span
v-if="getExtname && displayExt"
class="font-sans text-xs text-gray-500"
>
{{ getExtname }}
</span>
</div>
</template>

View File

@ -1,10 +1,17 @@
<script lang="ts" setup>
import { VButton, VModal, VSpace } from "@halo-dev/components";
import type { Group } from "@halo-dev/api-client";
import { v4 as uuid } from "uuid";
import { computed, ref, watch, watchEffect } from "vue";
import cloneDeep from "lodash.clonedeep";
import { apiClient } from "@halo-dev/admin-shared";
import { reset, submitForm } from "@formkit/core";
import { useMagicKeys } from "@vueuse/core";
withDefaults(
const props = withDefaults(
defineProps<{
visible: boolean;
group: object | null;
group: Group | null;
}>(),
{
visible: false,
@ -17,26 +24,109 @@ const emit = defineEmits<{
(event: "close"): void;
}>();
const initialFormState: Group = {
spec: {
displayName: "",
},
apiVersion: "storage.halo.run/v1alpha1",
kind: "Group",
metadata: {
name: uuid(),
},
};
const formState = ref<Group>(cloneDeep(initialFormState));
const saving = ref(false);
const isUpdateMode = computed(() => {
return !!formState.value.metadata.creationTimestamp;
});
const modalTitle = computed(() => {
return isUpdateMode.value ? "编辑附件分组" : "新增附件分组";
});
const handleSave = async () => {
try {
saving.value = true;
if (isUpdateMode.value) {
await apiClient.extension.storage.group.updatestorageHaloRunV1alpha1Group(
formState.value.metadata.name,
formState.value
);
} else {
await apiClient.extension.storage.group.createstorageHaloRunV1alpha1Group(
formState.value
);
}
onVisibleChange(false);
} catch (e) {
console.error("Failed to save attachment group", e);
} finally {
saving.value = false;
}
};
const onVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {
emit("close");
}
};
const handleResetForm = () => {
formState.value = cloneDeep(initialFormState);
formState.value.metadata.name = uuid();
reset("attachment-group-form");
};
const { Command_Enter } = useMagicKeys();
watchEffect(() => {
if (Command_Enter.value && props.visible) {
submitForm("attachment-group-form");
}
});
watch(
() => props.visible,
(visible) => {
if (!visible) {
handleResetForm();
}
}
);
watch(
() => props.group,
(group) => {
if (group) {
formState.value = cloneDeep(group);
} else {
handleResetForm();
}
}
);
</script>
<template>
<VModal
:title="modalTitle"
:visible="visible"
:width="500"
title="附件分组"
@update:visible="onVisibleChange"
>
<FormKit id="attachment-group-form" type="form">
<FormKit label="名称" type="text" validation="required"></FormKit>
<FormKit id="attachment-group-form" type="form" @submit="handleSave">
<FormKit
v-model="formState.spec.displayName"
label="名称"
type="text"
validation="required"
></FormKit>
</FormKit>
<template #footer>
<VSpace>
<VButton
:loading="saving"
type="secondary"
@click="$formkit.submit('attachment-group-form')"
>

View File

@ -0,0 +1,165 @@
<script lang="ts" setup>
// core libs
import { onMounted, ref } from "vue";
// components
import { IconAddCircle, IconMore, VButton, VSpace } from "@halo-dev/components";
import AttachmentGroupEditingModal from "./AttachmentGroupEditingModal.vue";
// types
import type { Group } from "@halo-dev/api-client";
import { useRouteQuery } from "@vueuse/router";
import { useFetchAttachmentGroup } from "../composables/use-attachment-group";
const props = withDefaults(
defineProps<{
selectedGroup: Group | undefined;
readonly?: boolean;
}>(),
{
selectedGroup: undefined,
readonly: false,
}
);
const emit = defineEmits<{
(event: "update:selectedGroup", group: Group): void;
(event: "select", group: Group): void;
(event: "update"): void;
}>();
const defaultGroups: Group[] = [
{
spec: {
displayName: "全部",
},
apiVersion: "",
kind: "",
metadata: {
name: "",
},
},
{
spec: {
displayName: "未分组",
},
apiVersion: "",
kind: "",
metadata: {
name: "none",
},
},
];
const { groups, handleFetchGroups } = useFetchAttachmentGroup();
const groupToUpdate = ref<Group | null>(null);
const loading = ref<boolean>(false);
const editingModal = ref(false);
const routeQuery = useRouteQuery("group");
const handleSelectGroup = (group: Group) => {
emit("update:selectedGroup", group);
emit("select", group);
if (!props.readonly) {
routeQuery.value = group.metadata.name;
}
};
const handleOpenEditingModal = (group: Group) => {
groupToUpdate.value = group;
editingModal.value = true;
};
const onEditingModalClose = () => {
emit("update");
handleFetchGroups();
};
onMounted(async () => {
await handleFetchGroups();
if (routeQuery.value && !props.readonly) {
const allGroups = [...defaultGroups, ...groups.value];
const group = allGroups.find(
(group) => group.metadata.name === routeQuery.value
);
if (group) {
handleSelectGroup(group);
}
return;
}
handleSelectGroup(defaultGroups[0]);
});
</script>
<template>
<AttachmentGroupEditingModal
v-model:visible="editingModal"
:group="groupToUpdate"
@close="onEditingModalClose"
/>
<div class="mb-5 grid grid-cols-2 gap-x-2 gap-y-3 sm:grid-cols-6">
<div
v-for="(defaultGroup, index) in defaultGroups"
:key="index"
:class="{
'!bg-gray-200 !text-gray-900':
defaultGroup.metadata.name === selectedGroup?.metadata.name,
}"
class="flex cursor-pointer items-center rounded-base bg-gray-100 p-2 text-gray-500 transition-all hover:bg-gray-200 hover:text-gray-900 hover:shadow-sm"
@click="handleSelectGroup(defaultGroup)"
>
<div class="flex flex-1 items-center">
<span class="text-sm">{{ defaultGroup.spec.displayName }}</span>
</div>
</div>
<div
v-for="(group, index) in groups"
:key="index"
:class="{
'!bg-gray-200 !text-gray-900':
group.metadata.name === selectedGroup?.metadata.name,
}"
class="flex cursor-pointer items-center rounded-base bg-gray-100 p-2 text-gray-500 transition-all hover:bg-gray-200 hover:text-gray-900 hover:shadow-sm"
@click="handleSelectGroup(group)"
>
<div class="flex flex-1 items-center truncate">
<span class="truncate text-sm">
{{ group.spec.displayName }}
</span>
</div>
<FloatingDropdown v-if="!readonly">
<IconMore />
<template #popper>
<div class="w-48 p-2">
<VSpace class="w-full" direction="column">
<VButton
v-close-popper
block
type="secondary"
@click="handleOpenEditingModal(group)"
>
重命名
</VButton>
<VButton v-close-popper block type="danger"> 删除</VButton>
</VSpace>
</div>
</template>
</FloatingDropdown>
</div>
<div
v-if="!loading && !readonly"
class="flex cursor-pointer items-center rounded-base bg-gray-100 p-2 text-gray-500 transition-all hover:bg-gray-200 hover:text-gray-900 hover:shadow-sm"
@click="editingModal = true"
>
<div class="flex flex-1 items-center truncate">
<span class="truncate text-sm">添加分组</span>
</div>
<IconAddCircle />
</div>
</div>
</template>

View File

@ -1,55 +0,0 @@
<script lang="ts" setup>
import { VButton, VModal, VSpace } from "@halo-dev/components";
withDefaults(
defineProps<{
visible: boolean;
}>(),
{
visible: false,
}
);
const emit = defineEmits<{
(event: "update:visible", visible: boolean): void;
(event: "close"): void;
}>();
const onVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {
emit("close");
}
};
</script>
<template>
<VModal
:visible="visible"
:width="600"
title="本地存储策略编辑"
@update:visible="onVisibleChange"
>
<FormKit id="local-strategy-form" type="form">
<FormKit label="名称" type="text" validation="required"></FormKit>
<FormKit label="存储位置" type="text" validation="required"></FormKit>
<FormKit
help="使用半角逗号分隔"
label="允许上传的文件类型"
type="textarea"
value="jpg,png,gif"
></FormKit>
</FormKit>
<template #footer>
<VSpace>
<VButton
type="secondary"
@click="$formkit.submit('local-strategy-form')"
>
保存 +
</VButton>
<VButton @click="onVisibleChange(false)"> Esc</VButton>
</VSpace>
</template>
</VModal>
</template>

View File

@ -0,0 +1,233 @@
<script lang="ts" setup>
import {
IconAddCircle,
IconMore,
VButton,
VModal,
VSpace,
VEmpty,
} from "@halo-dev/components";
import AttachmentPolicyEditingModal from "./AttachmentPolicyEditingModal.vue";
import { ref, watch } from "vue";
import type { Policy, PolicyTemplate } from "@halo-dev/api-client";
import { apiClient } from "@halo-dev/admin-shared";
import { v4 as uuid } from "uuid";
import { formatDatetime } from "@/utils/date";
import { useFetchAttachmentPolicy } from "../composables/use-attachment-policy";
const props = withDefaults(
defineProps<{
visible: boolean;
}>(),
{
visible: false,
}
);
const emit = defineEmits<{
(event: "update:visible", visible: boolean): void;
(event: "close"): void;
}>();
const { policies, loading, handleFetchPolicies } = useFetchAttachmentPolicy();
const selectedPolicy = ref<Policy | null>(null);
const policyTemplates = ref<PolicyTemplate[]>([] as PolicyTemplate[]);
const policyEditingModal = ref(false);
const handleFetchPolicyTemplates = async () => {
try {
const { data } =
await apiClient.extension.storage.policyTemplate.liststorageHaloRunV1alpha1PolicyTemplate();
policyTemplates.value = data.items;
} catch (e) {
console.error("Failed to fetch attachment policy templates", e);
}
};
function onVisibleChange(visible: boolean) {
emit("update:visible", visible);
if (!visible) {
emit("close");
}
}
const handleOpenEditingModal = (policy: Policy) => {
selectedPolicy.value = policy;
policyEditingModal.value = true;
};
const handleOpenCreateNewPolicyModal = (policyTemplate: PolicyTemplate) => {
selectedPolicy.value = {
spec: {
displayName: "",
templateRef: {
name: policyTemplate.metadata.name,
},
configMapRef: {
name: uuid(),
},
},
apiVersion: "storage.halo.run/v1alpha1",
kind: "Policy",
metadata: {
name: uuid(),
},
};
policyEditingModal.value = true;
};
const onEditingModalClose = () => {
selectedPolicy.value = null;
handleFetchPolicies();
};
watch(
() => props.visible,
(newValue) => {
if (newValue) {
handleFetchPolicyTemplates();
handleFetchPolicies();
}
}
);
</script>
<template>
<VModal
:visible="visible"
:width="750"
title="存储策略"
:body-class="['!p-0']"
@update:visible="onVisibleChange"
>
<template #actions>
<FloatingDropdown>
<div v-tooltip="`添加存储策略`" class="modal-header-action">
<IconAddCircle />
</div>
<template #popper>
<div class="w-72 p-4">
<ul class="space-y-1">
<li
v-for="(policyTemplate, index) in policyTemplates"
:key="index"
v-close-popper
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
@click="handleOpenCreateNewPolicyModal(policyTemplate)"
>
<span class="truncate">
{{ policyTemplate.spec?.displayName }}
</span>
</li>
</ul>
</div>
</template>
</FloatingDropdown>
</template>
<VEmpty
v-if="!policies.length && !loading"
message="当前没有可用的存储策略,你可以尝试刷新或者新建策略"
title="当前没有可用的存储策略"
>
<template #actions>
<VSpace>
<VButton @click="handleFetchPolicies"></VButton>
<FloatingDropdown>
<VButton type="secondary">
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建策略
</VButton>
<template #popper>
<div class="w-72 p-4">
<ul class="space-y-1">
<li
v-for="(policyTemplate, index) in policyTemplates"
:key="index"
v-close-popper
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
@click="handleOpenCreateNewPolicyModal(policyTemplate)"
>
<span class="truncate">
{{ policyTemplate.spec?.displayName }}
</span>
</li>
</ul>
</div>
</template>
</FloatingDropdown>
</VSpace>
</template>
</VEmpty>
<ul
v-else
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li v-for="(policy, index) in policies" :key="index">
<div
class="relative block cursor-pointer px-4 py-3 transition-all hover:bg-gray-50"
>
<div class="relative flex flex-row items-center">
<div class="flex-1">
<div class="flex flex-col sm:flex-row">
<span
class="mr-0 truncate text-sm font-medium text-gray-900 sm:mr-2"
>
{{ policy.spec.displayName }}
</span>
</div>
<div class="mt-1 flex">
<span class="text-xs text-gray-500">
{{ policy.spec.templateRef?.name }}
</span>
</div>
</div>
<div class="flex">
<div
class="inline-flex flex-col items-end gap-4 sm:flex-row sm:items-center sm:gap-6"
>
<time class="text-sm text-gray-500">
{{ formatDatetime(policy.metadata.creationTimestamp) }}
</time>
<span class="cursor-pointer">
<FloatingDropdown>
<IconMore />
<template #popper>
<div class="w-48 p-2">
<VSpace class="w-full" direction="column">
<VButton
v-close-popper
block
type="secondary"
@click="handleOpenEditingModal(policy)"
>
编辑
</VButton>
<VButton v-close-popper block type="danger">
删除
</VButton>
</VSpace>
</div>
</template>
</FloatingDropdown>
</span>
</div>
</div>
</div>
</div>
</li>
</ul>
<template #footer>
<VButton @click="onVisibleChange(false)"> Esc</VButton>
</template>
</VModal>
<AttachmentPolicyEditingModal
v-model:visible="policyEditingModal"
:policy="selectedPolicy"
@close="onEditingModalClose"
/>
</template>

View File

@ -0,0 +1,213 @@
<script lang="ts" setup>
import { VButton, VModal, VSpace } from "@halo-dev/components";
import type { Policy, PolicyTemplate } from "@halo-dev/api-client";
import cloneDeep from "lodash.clonedeep";
import { computed, ref, watch, watchEffect } from "vue";
import { apiClient, useSettingForm } from "@halo-dev/admin-shared";
import { v4 as uuid } from "uuid";
import { reset, submitForm } from "@formkit/core";
import { useMagicKeys } from "@vueuse/core";
const props = withDefaults(
defineProps<{
visible: boolean;
policy: Policy | null;
}>(),
{
visible: false,
policy: null,
}
);
const emit = defineEmits<{
(event: "update:visible", visible: boolean): void;
(event: "close"): void;
}>();
const initialFormState: Policy = {
spec: {
displayName: "",
templateRef: {
name: "",
},
configMapRef: {
name: "",
},
},
apiVersion: "storage.halo.run/v1alpha1",
kind: "Policy",
metadata: {
name: uuid(),
},
};
const formState = ref<Policy>(cloneDeep(initialFormState));
const policyTemplate = ref<PolicyTemplate | undefined>();
const settingName = computed(
() => policyTemplate.value?.spec?.settingRef?.name
);
const configMapName = computed(() => formState.value.spec.configMapRef?.name);
const {
settings,
configMapFormData,
saving,
handleFetchConfigMap,
handleFetchSettings,
handleSaveConfigMap,
handleReset: handleResetSettingForm,
} = useSettingForm(settingName, configMapName);
const formSchema = computed(() => {
if (!settings?.value?.spec) {
return undefined;
}
return settings.value.spec.find((item) => item.group === "default")
?.formSchema;
});
watchEffect(() => {
if (settingName.value) {
handleFetchSettings();
}
});
watchEffect(() => {
if (configMapName.value && settings.value) {
handleFetchConfigMap();
}
});
const isUpdateMode = computed(() => {
return !!formState.value.metadata.creationTimestamp;
});
const modalTitle = computed(() => {
return isUpdateMode.value
? `编辑策略:${props.policy?.spec.displayName}`
: `新增策略:${policyTemplate.value?.spec?.displayName}`;
});
const handleSave = async () => {
try {
saving.value = true;
await handleSaveConfigMap();
if (isUpdateMode.value) {
await apiClient.extension.storage.policy.updatestorageHaloRunV1alpha1Policy(
formState.value.metadata.name,
formState.value
);
} else {
await apiClient.extension.storage.policy.createstorageHaloRunV1alpha1Policy(
formState.value
);
}
onVisibleChange(false);
} catch (e) {
console.error("Failed to save attachment policy", e);
} finally {
saving.value = false;
}
};
const handleResetForm = () => {
formState.value = cloneDeep(initialFormState);
formState.value.metadata.name = uuid();
reset("local-policy-form");
};
const { Command_Enter } = useMagicKeys();
watchEffect(() => {
if (Command_Enter.value && props.visible) {
submitForm("local-policy-form");
}
});
watch(
() => props.visible,
(visible) => {
if (!visible) {
setTimeout(() => {
policyTemplate.value = undefined;
handleResetForm();
handleResetSettingForm();
}, 100);
}
}
);
watch(
() => props.policy,
async (policy) => {
if (policy) {
formState.value = cloneDeep(policy);
// Get policy template
if (formState.value.spec.templateRef?.name) {
const { data } =
await apiClient.extension.storage.policyTemplate.getstorageHaloRunV1alpha1PolicyTemplate(
formState.value.spec.templateRef.name
);
policyTemplate.value = data;
}
} else {
setTimeout(() => {
policyTemplate.value = undefined;
handleResetForm();
handleResetSettingForm();
}, 100);
}
}
);
const onVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {
emit("close");
}
};
</script>
<template>
<VModal
:title="modalTitle"
:visible="visible"
:width="600"
@update:visible="onVisibleChange"
>
<FormKit
v-if="formSchema && configMapFormData"
id="local-policy-form"
v-model="configMapFormData['default']"
:actions="false"
:preserve="true"
type="form"
@submit="handleSave"
>
<FormKit
v-model="formState.spec.displayName"
label="名称"
type="text"
validation="required"
></FormKit>
<FormKitSchema :schema="formSchema" />
</FormKit>
<template #footer>
<VSpace>
<VButton
:loading="saving"
type="secondary"
@click="$formkit.submit('local-policy-form')"
>
保存 +
</VButton>
<VButton @click="onVisibleChange(false)"> Esc</VButton>
</VSpace>
</template>
</VModal>
</template>

View File

@ -1,78 +0,0 @@
<script lang="ts" setup>
import { VButton, VCard, VModal } from "@halo-dev/components";
withDefaults(
defineProps<{
visible: boolean;
}>(),
{
visible: false,
}
);
const emit = defineEmits<{
(event: "update:visible", visible: boolean): void;
(event: "close"): void;
}>();
const attachments = Array.from(new Array(50), (_, index) => index).map(
(index) => {
return {
id: index,
name: `attachment-${index}`,
url: `https://picsum.photos/1000/700?random=${index}`,
size: "1.2MB",
type: "image/png",
strategy: "本地存储",
};
}
);
const onVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {
emit("close");
}
};
</script>
<template>
<VModal
:visible="visible"
:width="1240"
title="选择附件"
@update:visible="onVisibleChange"
>
<div class="w-full">
<ul
class="grid grid-cols-2 gap-x-2 gap-y-3 sm:grid-cols-3 md:grid-cols-2 xl:grid-cols-8 2xl:grid-cols-8"
role="list"
>
<li
v-for="(attachment, index) in attachments"
:key="index"
class="relative"
>
<VCard :body-class="['!p-0']">
<div
class="group aspect-w-10 aspect-h-7 block w-full cursor-pointer overflow-hidden bg-gray-100"
>
<img
:src="attachment.url"
alt=""
class="pointer-events-none object-cover group-hover:opacity-75"
/>
</div>
<p
class="pointer-events-none block truncate px-2 py-1 text-sm font-medium text-gray-700"
>
{{ attachment.name }}
</p>
</VCard>
</li>
</ul>
</div>
<template #footer>
<VButton type="secondary" @click="onVisibleChange(false)"></VButton>
</template>
</VModal>
</template>

View File

@ -0,0 +1,100 @@
<script lang="ts" setup>
import { VButton, VModal, VTabbar } from "@halo-dev/components";
import { ref, markRaw } from "vue";
import CoreSelectorProvider from "./selector-providers/CoreSelectorProvider.vue";
import UploadSelectorProvider from "./selector-providers/UploadSelectorProvider.vue";
import type {
AttachmentLike,
AttachmentSelectorPublicState,
} from "@halo-dev/admin-shared";
import { useExtensionPointsState } from "@/composables/usePlugins";
withDefaults(
defineProps<{
visible: boolean;
}>(),
{
visible: false,
}
);
const emit = defineEmits<{
(event: "update:visible", visible: boolean): void;
(event: "close"): void;
(event: "select", attachments: AttachmentLike[]): void;
}>();
const selected = ref<AttachmentLike[]>([] as AttachmentLike[]);
const attachmentSelectorPublicState = ref<AttachmentSelectorPublicState>({
providers: [
{
id: "core",
label: "附件库",
component: markRaw(CoreSelectorProvider),
},
{
id: "upload",
label: "上传",
component: markRaw(UploadSelectorProvider),
},
],
});
useExtensionPointsState("ATTACHMENT_SELECTOR", attachmentSelectorPublicState);
const activeId = ref(attachmentSelectorPublicState.value.providers[0].id);
const onVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {
emit("close");
}
};
const handleConfirm = () => {
console.log(Array.from(selected.value));
emit("select", Array.from(selected.value));
onVisibleChange(false);
};
</script>
<template>
<VModal
:visible="visible"
:width="1240"
title="选择附件"
height="calc(100vh - 20px)"
@update:visible="onVisibleChange"
>
<VTabbar
v-model:active-id="activeId"
:items="attachmentSelectorPublicState.providers"
class="w-full !rounded-none"
type="outline"
></VTabbar>
<div v-if="visible" class="mt-2">
<template
v-for="(provider, index) in attachmentSelectorPublicState.providers"
:key="index"
>
<Suspense>
<component
:is="provider.component"
v-if="activeId === provider.id"
v-model:selected="selected"
></component>
<template #fallback> 加载中 </template>
</Suspense>
</template>
</div>
<template #footer>
<VButton type="secondary" @click="handleConfirm">
确定
<span v-if="selected.length">
已选择 {{ selected.length }}
</span>
</VButton>
</template>
</VModal>
</template>

View File

@ -1,154 +0,0 @@
<script lang="ts" setup>
import {
IconAddCircle,
IconMore,
VButton,
VModal,
VSpace,
} from "@halo-dev/components";
import AttachmentLocalStrategyEditingModal from "./AttachmentLocalStrategyEditingModal.vue";
import AttachmentAliOSSStrategyEditingModal from "./AttachmentAliOSSStrategyEditingModal.vue";
import { ref } from "vue";
withDefaults(
defineProps<{
visible: boolean;
}>(),
{
visible: false,
}
);
const emit = defineEmits<{
(event: "update:visible", visible: boolean): void;
(event: "close"): void;
}>();
const strategies = ref([
{
id: "1",
name: "本地存储",
description: "~/.halo/uploads",
},
{
id: "2",
name: "阿里云 OSS",
description: "bucket/blog-attachments",
},
{
id: "3",
name: "阿里云 OSS",
description: "bucket/blog-photos",
},
]);
const localStrategyVisible = ref(false);
const aliOSSStrategyVisible = ref(false);
function onVisibleChange(visible: boolean) {
emit("update:visible", visible);
if (!visible) {
emit("close");
}
}
</script>
<template>
<VModal
:visible="visible"
:width="750"
title="存储策略"
@update:visible="onVisibleChange"
>
<template #actions>
<FloatingDropdown>
<div v-tooltip="`添加存储策略`" class="modal-header-action">
<IconAddCircle />
</div>
<template #popper>
<div class="w-72 p-4">
<ul class="space-y-1">
<li
v-close-popper
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
@click="localStrategyVisible = true"
>
<span class="truncate">本地</span>
</li>
<li
v-close-popper
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
@click="aliOSSStrategyVisible = true"
>
<span class="truncate">阿里云 OSS</span>
</li>
<li
v-close-popper
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
<span class="truncate">Amazon S3</span>
</li>
</ul>
</div>
</template>
</FloatingDropdown>
</template>
<ul class="box-border h-full w-full divide-y divide-gray-100" role="list">
<li v-for="(strategy, index) in strategies" :key="index">
<div
class="relative block cursor-pointer px-4 py-3 transition-all hover:bg-gray-50"
>
<div class="relative flex flex-row items-center">
<div class="flex-1">
<div class="flex flex-col sm:flex-row">
<span
class="mr-0 truncate text-sm font-medium text-gray-900 sm:mr-2"
>
{{ strategy.name }}
</span>
</div>
<div class="mt-1 flex">
<span class="text-xs text-gray-500">
{{ strategy.description }}
</span>
</div>
</div>
<div class="flex">
<div
class="inline-flex flex-col flex-col-reverse items-end gap-4 sm:flex-row sm:items-center sm:gap-6"
>
<time class="text-sm text-gray-500" datetime="2020-01-07">
2020-01-07
</time>
<span class="cursor-pointer">
<FloatingDropdown>
<IconMore />
<template #popper>
<div class="w-48 p-2">
<VSpace class="w-full" direction="column">
<VButton v-close-popper block type="secondary">
编辑
</VButton>
<VButton v-close-popper block type="danger">
删除
</VButton>
</VSpace>
</div>
</template>
</FloatingDropdown>
</span>
</div>
</div>
</div>
</div>
</li>
</ul>
<template #footer>
<VButton @click="onVisibleChange(false)"> Esc</VButton>
</template>
</VModal>
<AttachmentLocalStrategyEditingModal v-model:visible="localStrategyVisible" />
<AttachmentAliOSSStrategyEditingModal
v-model:visible="aliOSSStrategyVisible"
/>
</template>

View File

@ -1,19 +1,20 @@
<script lang="ts" setup>
import { VModal } from "@halo-dev/components";
import vueFilePond from "vue-filepond";
import "filepond/dist/filepond.min.css";
import FilePondPluginImagePreview from "filepond-plugin-image-preview";
import "filepond-plugin-image-preview/dist/filepond-plugin-image-preview.min.css";
import { ref } from "vue";
import FilePondUpload from "@/components/upload/FilePondUpload.vue";
import { computed, ref, watch, watchEffect } from "vue";
import { apiClient } from "@halo-dev/admin-shared";
import type { Policy, Group } from "@halo-dev/api-client";
import { useFetchAttachmentPolicy } from "../composables/use-attachment-policy";
import AttachmentPoliciesModal from "./AttachmentPoliciesModal.vue";
const FilePond = vueFilePond(FilePondPluginImagePreview);
withDefaults(
const props = withDefaults(
defineProps<{
visible: boolean;
group?: Group;
}>(),
{
visible: false,
group: undefined,
}
);
@ -22,64 +23,95 @@ const emit = defineEmits<{
(event: "close"): void;
}>();
const strategies = ref([
{
id: "1",
name: "本地存储",
description: "~/.halo/uploads",
},
{
id: "2",
name: "阿里云 OSS",
description: "bucket/blog-attachments",
},
{
id: "3",
name: "阿里云 OSS",
description: "bucket/blog-photos",
},
]);
const { policies, handleFetchPolicies } = useFetchAttachmentPolicy();
const selectedPolicy = ref<Policy | null>(null);
const policyVisible = ref(false);
const FilePondUploadRef = ref();
const modalTitle = computed(() => {
if (props.group && props.group.metadata.name) {
return `上传附件:${props.group.spec.displayName}`;
}
return "上传附件";
});
watchEffect(() => {
if (policies.value.length) {
selectedPolicy.value = policies.value[0];
}
});
const onVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {
emit("close");
policyVisible.value = false;
FilePondUploadRef.value.handleRemoveFiles();
}
};
const uploadHandler = computed(() => {
return (file, config) =>
apiClient.extension.storage.attachment.uploadAttachment(
file,
selectedPolicy.value?.metadata.name as string,
props.group?.metadata.name as string,
config
);
});
watch(
() => props.visible,
(newValue) => {
if (newValue) {
handleFetchPolicies();
}
}
);
</script>
<template>
<VModal
:body-class="['!p-0']"
:visible="visible"
:width="600"
title="上传附件"
:title="modalTitle"
@update:visible="onVisibleChange"
>
<template #actions>
<FloatingDropdown>
<div v-tooltip="`选择存储策略`" class="modal-header-action">
<span class="text-sm">本地存储</span>
<span class="text-sm">
{{ selectedPolicy?.spec.displayName || "无存储策略" }}
</span>
</div>
<template #popper>
<div class="w-72 p-4">
<ul class="space-y-1">
<li
v-for="(strategy, index) in strategies"
v-for="(policy, index) in policies"
:key="index"
v-close-popper
class="flex cursor-pointer flex-col rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
:class="{
'!bg-gray-100 !text-gray-900':
selectedPolicy?.metadata.name === policy.metadata.name,
}"
class="flex cursor-pointer flex-col rounded px-2 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
@click="selectedPolicy = policy"
>
<span class="truncate">
{{ strategy.name }}
{{ policy.spec.displayName }}
</span>
<span class="text-xs">
{{ strategy.description }}
{{ policy.spec.templateRef?.name }}
</span>
</li>
<li
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
v-close-popper
class="flex cursor-pointer flex-col rounded px-2 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
@click="policyVisible = true"
>
<span class="truncate">新增存储策略</span>
<span class="truncate"> 新增存储策略 </span>
</li>
</ul>
</div>
@ -87,14 +119,21 @@ const onVisibleChange = (visible: boolean) => {
</FloatingDropdown>
</template>
<div class="w-full p-4">
<file-pond
ref="pond"
accepted-file-types="image/jpeg, image/png"
label-idle="Drop files here..."
name="test"
server="/api"
<FilePondUpload
ref="FilePondUploadRef"
:allow-multiple="true"
:handler="uploadHandler"
:disabled="!selectedPolicy"
:max-parallel-uploads="5"
:label-idle="
selectedPolicy ? '点击选择文件或者拖拽文件到此处' : '请先选择存储策略'
"
/>
</div>
</VModal>
<AttachmentPoliciesModal
v-model:visible="policyVisible"
@close="handleFetchPolicies"
/>
</template>

View File

@ -0,0 +1,205 @@
<script lang="ts" setup>
import {
IconCheckboxFill,
IconArrowLeft,
IconArrowRight,
VCard,
VEmpty,
VSpace,
VButton,
IconUpload,
VPagination,
IconEye,
IconCheckboxCircle,
} from "@halo-dev/components";
import { watchEffect, ref } from "vue";
import { isImage } from "@/utils/image";
import { useAttachmentControl } from "../../composables/use-attachment";
import LazyImage from "@/components/image/LazyImage.vue";
import type { AttachmentLike } from "@halo-dev/admin-shared";
import type { Attachment, Group } from "@halo-dev/api-client";
import AttachmentUploadModal from "../AttachmentUploadModal.vue";
import AttachmentFileTypeIcon from "../AttachmentFileTypeIcon.vue";
import AttachmentDetailModal from "../AttachmentDetailModal.vue";
import AttachmentGroupList from "../AttachmentGroupList.vue";
withDefaults(
defineProps<{
selected: AttachmentLike[];
}>(),
{
selected: () => [],
}
);
const emit = defineEmits<{
(event: "update:selected", attachments: AttachmentLike[]): void;
}>();
const selectedGroup = ref<Group>();
const {
attachments,
loading,
selectedAttachment,
selectedAttachments,
handleFetchAttachments,
handlePaginationChange,
handleSelect,
handleSelectPrevious,
handleSelectNext,
handleReset,
isChecked,
} = useAttachmentControl({ group: selectedGroup });
const uploadVisible = ref(false);
const detailVisible = ref(false);
watchEffect(() => {
emit("update:selected", Array.from(selectedAttachments.value));
});
const handleOpenDetail = (attachment: Attachment) => {
selectedAttachment.value = attachment;
detailVisible.value = true;
};
const onGroupChange = () => {
handleReset();
handleFetchAttachments();
};
await handleFetchAttachments();
</script>
<template>
<AttachmentGroupList
v-model:selected-group="selectedGroup"
readonly
@select="onGroupChange"
/>
<VEmpty
v-if="!attachments.total && !loading"
message="当前没有附件,你可以尝试刷新或者上传附件"
title="当前没有附件"
>
<template #actions>
<VSpace>
<VButton @click="handleFetchAttachments"></VButton>
<VButton type="secondary" @click="uploadVisible = true">
<template #icon>
<IconUpload class="h-full w-full" />
</template>
上传附件
</VButton>
</VSpace>
</template>
</VEmpty>
<div
v-else
class="mt-2 grid grid-cols-3 gap-x-2 gap-y-3 sm:grid-cols-3 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10"
role="list"
>
<VCard
v-for="(attachment, index) in attachments.items"
:key="index"
:body-class="['!p-0']"
:class="{
'ring-1 ring-primary': isChecked(attachment),
}"
class="hover:shadow"
@click.stop="handleSelect(attachment)"
>
<div class="group relative bg-white">
<div
class="aspect-w-10 aspect-h-8 block h-full w-full cursor-pointer overflow-hidden bg-gray-100"
>
<LazyImage
v-if="isImage(attachment.spec.mediaType)"
:key="attachment.metadata.name"
:alt="attachment.spec.displayName"
:src="attachment.status?.permalink"
class="pointer-events-none object-cover group-hover:opacity-75"
>
<template #loading>
<div class="flex h-full items-center justify-center object-cover">
<span class="text-xs text-gray-400">加载中...</span>
</div>
</template>
<template #error>
<div class="flex h-full items-center justify-center object-cover">
<span class="text-xs text-red-400">加载异常</span>
</div>
</template>
</LazyImage>
<AttachmentFileTypeIcon
v-else
:file-name="attachment.spec.displayName"
/>
</div>
<p
class="pointer-events-none block truncate px-2 py-1 text-center text-xs font-medium text-gray-700"
>
{{ attachment.spec.displayName }}
</p>
<div
:class="{ '!flex': selectedAttachments.has(attachment) }"
class="absolute top-0 left-0 hidden h-1/3 w-full justify-end bg-gradient-to-b from-gray-300 to-transparent ease-in-out group-hover:flex"
>
<IconEye
class="mt-1 mr-1 hidden h-6 w-6 cursor-pointer text-white transition-all hover:text-primary group-hover:block"
@click.stop="handleOpenDetail(attachment)"
/>
<IconCheckboxFill
:class="{
'!text-primary': selectedAttachments.has(attachment),
}"
class="mt-1 mr-1 h-6 w-6 cursor-pointer text-white transition-all hover:text-primary"
/>
</div>
</div>
</VCard>
</div>
<div class="mt-4 bg-white sm:flex sm:items-center sm:justify-end">
<VPagination
:page="attachments.page"
:size="attachments.size"
:total="attachments.total"
@change="handlePaginationChange"
/>
</div>
<AttachmentUploadModal
v-model:visible="uploadVisible"
@close="handleFetchAttachments"
/>
<AttachmentDetailModal
v-model:visible="detailVisible"
:mount-to-body="true"
:attachment="selectedAttachment"
@close="selectedAttachment = undefined"
>
<template #actions>
<div
v-if="selectedAttachment && selectedAttachments.has(selectedAttachment)"
class="modal-header-action"
@click="handleSelect(selectedAttachment)"
>
<IconCheckboxFill />
</div>
<div
v-else
class="modal-header-action"
@click="handleSelect(selectedAttachment)"
>
<IconCheckboxCircle />
</div>
<div class="modal-header-action" @click="handleSelectPrevious">
<IconArrowLeft />
</div>
<div class="modal-header-action" @click="handleSelectNext">
<IconArrowRight />
</div>
</template>
</AttachmentDetailModal>
</template>

View File

@ -0,0 +1,234 @@
<script lang="ts" setup>
import {
VEmpty,
IconCheckboxFill,
VCard,
IconDeleteBin,
useDialog,
} from "@halo-dev/components";
import { apiClient, type AttachmentLike } from "@halo-dev/admin-shared";
import LazyImage from "@/components/image/LazyImage.vue";
import type { Attachment } from "@halo-dev/api-client";
import FilePondUpload from "@/components/upload/FilePondUpload.vue";
import AttachmentFileTypeIcon from "../AttachmentFileTypeIcon.vue";
import { computed, ref, watchEffect } from "vue";
import type { AxiosResponse } from "axios";
import { isImage } from "@/utils/image";
import { useFetchAttachmentPolicy } from "../../composables/use-attachment-policy";
import { useFetchAttachmentGroup } from "../../composables/use-attachment-group";
withDefaults(
defineProps<{
selected: AttachmentLike[];
}>(),
{
selected: () => [],
}
);
const emit = defineEmits<{
(event: "update:selected", attachments: AttachmentLike[]): void;
}>();
const { policies } = useFetchAttachmentPolicy({ fetchOnMounted: true });
const policyMap = computed(() => {
return [
{
label: "选择存储策略",
value: "",
},
...policies.value.map((policy) => {
return {
label: policy.spec.displayName,
value: policy.metadata.name,
};
}),
];
});
const selectedPolicy = ref("");
const { groups } = useFetchAttachmentGroup({ fetchOnMounted: true });
const groupMap = computed(() => {
return [
{
label: "选择分组",
value: "",
},
...groups.value.map((group) => {
return {
label: group.spec.displayName,
value: group.metadata.name,
};
}),
];
});
const selectedGroup = ref("");
const attachments = ref<Set<Attachment>>(new Set<Attachment>());
const selectedAttachments = ref<Set<Attachment>>(new Set<Attachment>());
const uploadHandler = computed(() => {
return (file, config) =>
apiClient.extension.storage.attachment.uploadAttachment(
file,
selectedPolicy.value,
selectedGroup.value,
config
);
});
const onUploaded = async (response: AxiosResponse) => {
const attachment = response.data as Attachment;
const { data } =
await apiClient.extension.storage.attachment.getstorageHaloRunV1alpha1Attachment(
attachment.metadata.name
);
attachments.value.add(data);
selectedAttachments.value.add(data);
};
const handleSelect = async (attachment: Attachment | undefined) => {
if (!attachment) return;
if (selectedAttachments.value.has(attachment)) {
selectedAttachments.value.delete(attachment);
return;
}
selectedAttachments.value.add(attachment);
};
const dialog = useDialog();
const handleDelete = async (attachment: Attachment) => {
dialog.warning({
title: "确定要删除当前的附件吗?",
confirmType: "danger",
onConfirm: async () => {
try {
await apiClient.extension.storage.attachment.deletestorageHaloRunV1alpha1Attachment(
attachment.metadata.name
);
attachments.value.delete(attachment);
selectedAttachments.value.delete(attachment);
} catch (e) {
console.error("Failed to delete attachment", e);
}
},
});
};
watchEffect(() => {
emit("update:selected", Array.from(selectedAttachments.value));
});
</script>
<template>
<div class="flex h-full flex-col gap-4 sm:flex-row">
<div class="h-full w-full space-y-4 overflow-auto sm:w-96">
<FormKit type="form">
<FormKit
v-model="selectedPolicy"
type="select"
:options="policyMap"
label="存储策略"
></FormKit>
<FormKit
v-model="selectedGroup"
:options="groupMap"
type="select"
label="分组"
></FormKit>
</FormKit>
<FilePondUpload
ref="FilePondUploadRef"
:allow-multiple="true"
:handler="uploadHandler"
:max-parallel-uploads="5"
:disabled="!selectedPolicy"
:label-idle="
selectedPolicy ? '点击选择文件或者拖拽文件到此处' : '请先选择存储策略'
"
@uploaded="onUploaded"
/>
</div>
<div class="h-full flex-1 overflow-auto">
<VEmpty
v-if="!attachments.size"
message="当前没有上传的文件,你可以点击左侧区域上传文件"
title="当前没有上传的文件"
>
</VEmpty>
<div
v-else
class="grid grid-cols-3 gap-x-2 gap-y-3 p-0.5 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-6"
role="list"
>
<VCard
v-for="(attachment, index) in Array.from(attachments)"
:key="index"
:body-class="['!p-0']"
:class="{
'ring-1 ring-primary': selectedAttachments.has(attachment),
}"
class="hover:shadow"
@click="handleSelect(attachment)"
>
<div class="group relative bg-white">
<div
class="aspect-w-10 aspect-h-8 block h-full w-full cursor-pointer overflow-hidden bg-gray-100"
>
<LazyImage
v-if="isImage(attachment.spec.mediaType)"
:key="attachment.metadata.name"
:alt="attachment.spec.displayName"
:src="attachment.status?.permalink"
class="pointer-events-none object-cover group-hover:opacity-75"
>
<template #loading>
<div
class="flex h-full items-center justify-center object-cover"
>
<span class="text-xs text-gray-400">加载中...</span>
</div>
</template>
<template #error>
<div
class="flex h-full items-center justify-center object-cover"
>
<span class="text-xs text-red-400">加载异常</span>
</div>
</template>
</LazyImage>
<AttachmentFileTypeIcon
v-else
:file-name="attachment.spec.displayName"
/>
</div>
<p
class="pointer-events-none block truncate px-2 py-1 text-center text-xs font-medium text-gray-700"
>
{{ attachment.spec.displayName }}
</p>
<div
:class="{ '!flex': selectedAttachments.has(attachment) }"
class="absolute top-0 left-0 hidden h-1/3 w-full justify-end bg-gradient-to-b from-gray-300 to-transparent ease-in-out group-hover:flex"
>
<IconDeleteBin
class="mt-1 mr-1 hidden h-5 w-5 cursor-pointer text-red-400 transition-all hover:text-red-600 group-hover:block"
@click.stop="handleDelete(attachment)"
/>
<IconCheckboxFill
:class="{
'!text-primary': selectedAttachments.has(attachment),
}"
class="mt-1 mr-1 h-5 w-5 cursor-pointer text-white transition-all hover:text-primary"
/>
</div>
</div>
</VCard>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,41 @@
import { onMounted, ref, type Ref } from "vue";
import type { Group } from "@halo-dev/api-client";
import { apiClient } from "@halo-dev/admin-shared";
interface useFetchAttachmentGroupReturn {
groups: Ref<Group[]>;
loading: Ref<boolean>;
handleFetchGroups: () => void;
}
export function useFetchAttachmentGroup(options?: {
fetchOnMounted: boolean;
}): useFetchAttachmentGroupReturn {
const { fetchOnMounted } = options || {};
const groups = ref<Group[]>([] as Group[]);
const loading = ref<boolean>(false);
const handleFetchGroups = async () => {
try {
loading.value = true;
const { data } =
await apiClient.extension.storage.group.liststorageHaloRunV1alpha1Group();
groups.value = data.items;
} catch (e) {
console.error("Failed to fetch attachment groups", e);
} finally {
loading.value = false;
}
};
onMounted(() => {
fetchOnMounted && handleFetchGroups();
});
return {
groups,
loading,
handleFetchGroups,
};
}

View File

@ -0,0 +1,42 @@
import { onMounted, ref } from "vue";
import type { Ref } from "vue";
import type { Policy } from "@halo-dev/api-client";
import { apiClient } from "@halo-dev/admin-shared";
interface useFetchAttachmentPolicyReturn {
policies: Ref<Policy[]>;
loading: Ref<boolean>;
handleFetchPolicies: () => void;
}
export function useFetchAttachmentPolicy(options?: {
fetchOnMounted: boolean;
}): useFetchAttachmentPolicyReturn {
const { fetchOnMounted } = options || {};
const policies = ref<Policy[]>([] as Policy[]);
const loading = ref<boolean>(false);
const handleFetchPolicies = async () => {
try {
loading.value = true;
const { data } =
await apiClient.extension.storage.policy.liststorageHaloRunV1alpha1Policy();
policies.value = data.items;
} catch (e) {
console.error("Failed to fetch attachment policies", e);
} finally {
loading.value = false;
}
};
onMounted(() => {
fetchOnMounted && handleFetchPolicies();
});
return {
policies,
loading,
handleFetchPolicies,
};
}

View File

@ -0,0 +1,214 @@
import type {
Attachment,
AttachmentList,
Group,
Policy,
User,
} from "@halo-dev/api-client";
import type { Ref } from "vue";
import { ref, watch } from "vue";
import { apiClient } from "@halo-dev/admin-shared";
import { useDialog } from "@halo-dev/components";
interface useAttachmentControlReturn {
attachments: Ref<AttachmentList>;
loading: Ref<boolean>;
selectedAttachment: Ref<Attachment | undefined>;
selectedAttachments: Ref<Set<Attachment>>;
checkedAll: Ref<boolean>;
handleFetchAttachments: () => void;
handlePaginationChange: ({
page,
size,
}: {
page: number;
size: number;
}) => void;
handleSelectPrevious: () => void;
handleSelectNext: () => void;
handleDeleteInBatch: () => void;
handleCheckAll: (checkAll: boolean) => void;
handleSelect: (attachment: Attachment | undefined) => void;
isChecked: (attachment: Attachment) => boolean;
handleReset: () => void;
}
export function useAttachmentControl(filterOptions?: {
policy?: Ref<Policy | undefined>;
group?: Ref<Group | undefined>;
user?: Ref<User | undefined>;
keyword?: Ref<string | undefined>;
}): useAttachmentControlReturn {
const { user, policy, group, keyword } = filterOptions || {};
const attachments = ref<AttachmentList>({
page: 1,
size: 60,
total: 0,
items: [],
first: true,
last: false,
hasNext: false,
hasPrevious: false,
});
const loading = ref<boolean>(false);
const selectedAttachment = ref<Attachment>();
const selectedAttachments = ref<Set<Attachment>>(new Set<Attachment>());
const checkedAll = ref(false);
const dialog = useDialog();
const handleFetchAttachments = async () => {
try {
loading.value = true;
const { data } =
await apiClient.extension.storage.attachment.searchAttachments(
policy?.value?.metadata.name,
keyword?.value,
group?.value?.metadata.name,
user?.value?.metadata.name,
attachments.value.size,
attachments.value.page,
[],
[]
);
attachments.value = data;
} catch (e) {
console.error("Failed to fetch attachments", e);
} finally {
loading.value = false;
}
};
const handlePaginationChange = async ({
page,
size,
}: {
page: number;
size: number;
}) => {
attachments.value.page = page;
attachments.value.size = size;
await handleFetchAttachments();
};
const handleSelectPrevious = async () => {
const { items, hasPrevious } = attachments.value;
const index = items.findIndex(
(attachment) =>
attachment.metadata.name === selectedAttachment.value?.metadata.name
);
if (index > 0) {
selectedAttachment.value = items[index - 1];
return;
}
if (index === 0 && hasPrevious) {
attachments.value.page--;
await handleFetchAttachments();
selectedAttachment.value =
attachments.value.items[attachments.value.items.length - 1];
}
};
const handleSelectNext = async () => {
const { items, hasNext } = attachments.value;
const index = items.findIndex(
(attachment) =>
attachment.metadata.name === selectedAttachment.value?.metadata.name
);
if (index < items.length - 1) {
selectedAttachment.value = items[index + 1];
return;
}
if (index === items.length - 1 && hasNext) {
attachments.value.page++;
await handleFetchAttachments();
selectedAttachment.value = attachments.value.items[0];
}
};
const handleDeleteInBatch = () => {
dialog.warning({
title: "确定要删除所选的附件吗?",
description: "其中 20 个附件包含关联关系,删除之后将无法恢复",
confirmType: "danger",
onConfirm: async () => {
try {
const promises = Array.from(selectedAttachments.value).map(
(attachment) => {
return apiClient.extension.storage.attachment.deletestorageHaloRunV1alpha1Attachment(
attachment.metadata.name
);
}
);
await Promise.all(promises);
selectedAttachments.value.clear();
} catch (e) {
console.error("Failed to delete attachments", e);
} finally {
await handleFetchAttachments();
}
},
});
};
const handleCheckAll = (checkAll: boolean) => {
if (checkAll) {
attachments.value.items.forEach((attachment) => {
selectedAttachments.value.add(attachment);
});
} else {
selectedAttachments.value.clear();
}
};
const handleSelect = async (attachment: Attachment | undefined) => {
if (!attachment) return;
if (selectedAttachments.value.has(attachment)) {
selectedAttachments.value.delete(attachment);
return;
}
selectedAttachments.value.add(attachment);
};
watch(
() => selectedAttachments.value.size,
(newValue) => {
checkedAll.value = newValue === attachments.value.items?.length;
}
);
const isChecked = (attachment: Attachment) => {
return (
attachment.metadata.name === selectedAttachment.value?.metadata.name ||
Array.from(selectedAttachments.value)
.map((item) => item.metadata.name)
.includes(attachment.metadata.name)
);
};
const handleReset = () => {
attachments.value.page = 1;
selectedAttachment.value = undefined;
selectedAttachments.value.clear();
checkedAll.value = false;
};
return {
attachments,
loading,
selectedAttachment,
selectedAttachments,
checkedAll,
handleFetchAttachments,
handlePaginationChange,
handleSelectPrevious,
handleSelectNext,
handleDeleteInBatch,
handleCheckAll,
handleSelect,
isChecked,
handleReset,
};
}

View File

@ -1,10 +1,11 @@
import { BasicLayout, definePlugin } from "@halo-dev/admin-shared";
import AttachmentList from "./AttachmentList.vue";
import AttachmentSelectorModal from "./components/AttachmentSelectorModal.vue";
import { IconFolder } from "@halo-dev/components";
export default definePlugin({
name: "attachmentModule",
components: [],
components: [AttachmentSelectorModal],
routes: [
{
path: "/attachments",

View File

@ -8,33 +8,57 @@ import {
IconUserSettings,
VCard,
} from "@halo-dev/components";
import { markRaw, type Component } from "vue";
import { useRouter } from "vue-router";
import type { RouteLocationRaw } from "vue-router";
const actions = [
interface Action {
icon: Component;
title: string;
route: RouteLocationRaw;
}
const actions: Action[] = [
{
icon: IconBookRead,
icon: markRaw(IconBookRead),
title: "创建文章",
route: "PostEditor",
route: {
name: "PostEditor",
},
},
{
icon: IconFolder,
icon: markRaw(IconFolder),
title: "附件上传",
route: "Attachments",
route: {
name: "Attachments",
query: {
action: "upload",
},
},
},
{
icon: IconPalette,
icon: markRaw(IconPalette),
title: "外观编辑",
route: "ThemeVisual",
route: {
name: "ThemeVisual",
},
},
{
icon: IconPlug,
icon: markRaw(IconPlug),
title: "插件管理",
route: "Plugins",
route: {
name: "Plugins",
},
},
{
icon: IconUserSettings,
icon: markRaw(IconUserSettings),
title: "新建用户",
route: "Users",
route: {
name: "Users",
query: {
action: "create",
},
},
},
];
@ -53,7 +77,7 @@ const router = useRouter();
v-for="(action, index) in actions"
:key="index"
class="group relative cursor-pointer bg-white p-6 hover:bg-gray-50"
@click="router.push({ name: action.route })"
@click="router.push(action.route)"
>
<div>
<span

View File

@ -19,6 +19,7 @@ import { apiClient } from "@halo-dev/admin-shared";
import type { User, UserList } from "@halo-dev/api-client";
import { rbacAnnotations } from "@/constants/annotations";
import { formatDatetime } from "@/utils/date";
import { useRouteQuery } from "@vueuse/router";
const checkAll = ref(false);
const editingModal = ref<boolean>(false);
@ -66,6 +67,11 @@ const handleOpenCreateModal = (user: User) => {
editingModal.value = true;
};
const onEditingModalClose = () => {
routeQueryAction.value = undefined;
handleFetchUsers();
};
const handleOpenPasswordChangeModal = (user: User) => {
selectedUser.value = user;
passwordChangeModal.value = true;
@ -80,13 +86,25 @@ const getRoles = (user: User) => {
onMounted(() => {
handleFetchUsers();
});
// Route query action
const routeQueryAction = useRouteQuery<string | undefined>("action");
onMounted(() => {
if (!routeQueryAction.value) {
return;
}
if (routeQueryAction.value === "create") {
editingModal.value = true;
}
});
</script>
<template>
<UserEditingModal
v-model:visible="editingModal"
v-permission="['system:users:manage']"
:user="selectedUser"
@close="handleFetchUsers"
@close="onEditingModalClose"
/>
<UserPasswordChangeModal

15
src/utils/image.ts Normal file
View File

@ -0,0 +1,15 @@
export const imageTypes: string[] = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/webp",
"image/svg+xml",
];
export function isImage(mediaType: string | undefined): boolean {
if (!mediaType) {
return false;
}
return imageTypes.includes(mediaType);
}

View File

@ -8,6 +8,7 @@
"noImplicitAny": false,
"paths": {
"@/*": ["./src/*"]
}
},
"types": ["unplugin-icons/types/vue"]
}
}

View File

@ -8,6 +8,7 @@ import { VitePWA } from "vite-plugin-pwa";
import { viteExternalsPlugin as ViteExternals } from "vite-plugin-externals";
import { viteStaticCopy as ViteStaticCopy } from "vite-plugin-static-copy";
import { createHtmlPlugin as VitePluginHtml } from "vite-plugin-html";
import Icons from "unplugin-icons/vite";
export default ({ mode }: { mode: string }) => {
const env = loadEnv(mode, process.cwd(), "");
@ -20,6 +21,7 @@ export default ({ mode }: { mode: string }) => {
VueJsx(),
VueSetupExtend(),
Compression(),
Icons({ compiler: "vue3" }),
ViteExternals({
vue: "Vue",
"vue-router": "VueRouter",