mirror of https://github.com/halo-dev/halo-admin
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
parent
a5e9eba231
commit
e3fd9e8709
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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),
|
||||
|
|
114
pnpm-lock.yaml
114
pnpm-lock.yaml
|
@ -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'}
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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')"
|
||||
>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -8,6 +8,7 @@
|
|||
"noImplicitAny": false,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"types": ["unplugin-icons/types/vue"]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue