1.5.1原版

This commit is contained in:
anian
2026-03-09 21:13:30 +08:00
commit 65174cb3df
55 changed files with 10364 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = true
+19
View File
@@ -0,0 +1,19 @@
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution");
module.exports = {
root: true,
extends: [
"plugin:vue/vue3-recommended",
"eslint:recommended",
"@vue/eslint-config-typescript/recommended",
"@vue/eslint-config-prettier",
"@unocss",
],
env: {
"vue/setup-compiler-macros": true,
},
rules: {
"@unocss/enforce-class-compile": 1,
},
};
+28
View File
@@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+14
View File
@@ -0,0 +1,14 @@
/// <reference types="unplugin-icons/types/vue" />
export {};
declare module "*.vue" {
import Vue from "vue";
export default Vue;
}
declare module "axios" {
export interface AxiosRequestConfig {
mute?: boolean;
}
}
+49
View File
@@ -0,0 +1,49 @@
{
"type": "module",
"scripts": {
"build": "NODE_ENV=production rspack build",
"dev": "NODE_ENV=development rspack build --watch",
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"prettier": "prettier --write './src/**/*.{vue,js,jsx,ts,tsx,css,scss,json,yml,yaml,html}'",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"@formkit/core": "1.0.0-beta.12",
"@halo-dev/api-client": "^2.17.0",
"@halo-dev/components": "https://pkg.pr.new/@halo-dev/components@7383",
"@halo-dev/console-shared": "^2.17.0",
"@tanstack/vue-query": "^4.37.1",
"@vueuse/core": "^10.11.0",
"@vueuse/router": "^10.11.0",
"axios": "^1.7.2",
"fuse.js": "^6.6.2",
"lodash-es": "^4.17.21",
"vue": "^3.5.16",
"vue-draggable-plus": "^0.6.0"
},
"devDependencies": {
"@iconify/json": "^2.2.229",
"@rspack/cli": "1.4.0-beta.0",
"@rspack/core": "1.4.0-beta.0",
"@rushstack/eslint-patch": "^1.10.3",
"@types/lodash-es": "^4.17.12",
"@types/node": "^16.18.103",
"@unocss/eslint-config": "^66.2.0",
"@unocss/webpack": "^66.2.0",
"@vue/compiler-sfc": "^3.5.16",
"@vue/eslint-config-prettier": "^7.1.0",
"@vue/eslint-config-typescript": "^11.0.3",
"@vue/tsconfig": "^0.1.3",
"css-loader": "^7.1.2",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.27.0",
"prettier": "^2.8.8",
"style-loader": "^4.0.0",
"typescript": "~5.5.3",
"unocss": "^66.2.0",
"unplugin-icons": "^22.1.0",
"vue-loader": "^17.4.2",
"vue-tsc": "^1.8.27"
},
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
}
+5896
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
onlyBuiltDependencies:
- esbuild
- vue-demi
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
printWidth: 120,
tabWidth: 2,
useTabs: false,
endOfLine: "lf",
};
+99
View File
@@ -0,0 +1,99 @@
// ⚠️ IMPORTANT: Using rspack to build Halo plugins is just an attempt by the Halo team.
// Currently, it is still recommended to refer to https://github.com/halo-dev/plugin-starter
// Using Vite
import { defineConfig } from "@rspack/cli";
import { UnoCSSRspackPlugin } from "@unocss/webpack/rspack";
import path from "path";
import process from "process";
import Icons from "unplugin-icons/rspack";
import { fileURLToPath } from "url";
import { VueLoaderPlugin } from "vue-loader";
const PLUGIN_NAME = "PluginPhotos";
const isProduction = process.env.NODE_ENV === "production";
const dirname = path.dirname(fileURLToPath(import.meta.url));
const outDir = isProduction ? "../src/main/resources/console" : "../build/resources/main/console";
export default defineConfig({
mode: isProduction ? "production" : "development",
entry: {
main: "./src/index.ts",
},
plugins: [
new VueLoaderPlugin(),
Icons({
compiler: "vue3",
}),
UnoCSSRspackPlugin(),
],
resolve: {
alias: {
"@": path.resolve(dirname, "src"),
},
extensions: [".ts", ".js"],
},
output: {
publicPath: `/plugins/${PLUGIN_NAME}/assets/console/`,
chunkFilename: "[id]-[hash:8].js",
cssFilename: "style.css",
path: path.resolve(outDir),
library: {
type: "window",
export: "default",
name: PLUGIN_NAME,
},
clean: true,
iife: true,
},
optimization: {
providedExports: false,
realContentHash: true,
},
devtool: false,
experiments: {
css: true,
},
module: {
rules: [
{
test: /\.ts$/,
exclude: [/node_modules/],
loader: "builtin:swc-loader",
options: {
jsc: {
parser: {
syntax: "typescript",
},
},
},
type: "javascript/auto",
},
{
test: /\.vue$/,
loader: "vue-loader",
options: {
experimentalInlineMatchResource: true,
},
},
{
test: /\.css$/i,
use: ["style-loader", "css-loader"],
type: "javascript/auto",
},
],
},
externals: {
vue: "Vue",
"vue-router": "VueRouter",
"@vueuse/core": "VueUse",
"@vueuse/components": "VueUse",
"@vueuse/router": "VueUse",
"@halo-dev/console-shared": "HaloConsoleShared",
"@halo-dev/components": "HaloComponents",
"@halo-dev/api-client": "HaloApiClient",
"@halo-dev/richtext-editor": "RichTextEditor",
axios: "axios",
},
});
@@ -0,0 +1,158 @@
<script lang="ts" setup>
import type { PhotoGroup } from "@/types";
import { submitForm } from "@formkit/core";
import { axiosInstance } from "@halo-dev/api-client";
import { VButton, VModal, VSpace } from "@halo-dev/components";
import { useMagicKeys } from "@vueuse/core";
import { cloneDeep } from "lodash-es";
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from "vue";
const props = withDefaults(
defineProps<{
group?: PhotoGroup;
}>(),
{
group: undefined,
}
);
const emit = defineEmits<{
(event: "close"): void;
}>();
const initialFormState: PhotoGroup = {
apiVersion: "core.halo.run/v1alpha1",
kind: "PhotoGroup",
metadata: {
name: "",
generateName: "photo-group-",
},
spec: {
displayName: "",
priority: 0,
},
status: {
photoCount: 0,
},
};
const formState = ref<PhotoGroup>(initialFormState);
const isSubmitting = ref(false);
const modal = useTemplateRef<InstanceType<typeof VModal> | null>("modal");
const isUpdateMode = computed(() => {
return !!formState.value.metadata.creationTimestamp;
});
const isMac = /macintosh|mac os x/i.test(navigator.userAgent);
const modalTitle = computed(() => {
return isUpdateMode.value ? "编辑分组" : "新建分组";
});
const annotationsGroupFormRef = ref();
const handleCreateOrUpdateGroup = async () => {
annotationsGroupFormRef.value?.handleSubmit();
await nextTick();
const { customAnnotations, annotations, customFormInvalid, specFormInvalid } = annotationsGroupFormRef.value || {};
if (customFormInvalid || specFormInvalid) {
return;
}
formState.value.metadata.annotations = {
...annotations,
...customAnnotations,
};
try {
isSubmitting.value = true;
if (isUpdateMode.value) {
await axiosInstance.put(
`/apis/core.halo.run/v1alpha1/photogroups/${formState.value.metadata.name}`,
formState.value
);
} else {
await axiosInstance.post("/apis/core.halo.run/v1alpha1/photogroups", formState.value);
}
modal.value?.close();
} catch (e) {
console.error("Failed to create photo group", e);
} finally {
isSubmitting.value = false;
}
};
onMounted(() => {
if (props.group) {
formState.value = cloneDeep(props.group);
}
});
const { ControlLeft_Enter, Meta_Enter } = useMagicKeys();
watch(ControlLeft_Enter, (v) => {
if (v && !isMac) {
submitForm("photo-group-form");
}
});
watch(Meta_Enter, (v) => {
if (v && isMac) {
submitForm("photo-group-form");
}
});
</script>
<template>
<VModal ref="modal" :width="600" :title="modalTitle" @close="emit('close')">
<FormKit
id="photo-group-form"
v-model="formState.spec"
name="photo-group-form"
type="form"
@submit="handleCreateOrUpdateGroup"
>
<div class=":uno: md:grid md:grid-cols-4 md:gap-6">
<div class=":uno: md:col-span-1">
<div class=":uno: sticky top-0">
<span class=":uno: text-base text-gray-900 font-medium"> 常规 </span>
</div>
</div>
<div class=":uno: mt-5 md:col-span-3 md:mt-0 divide-y divide-gray-100">
<FormKit
name="displayName"
label="分组名称"
type="text"
validation="required"
help="可根据此名称查询图片"
></FormKit>
</div>
</div>
</FormKit>
<div class=":uno: py-5">
<div class=":uno: border-t border-gray-200"></div>
</div>
<div class=":uno: md:grid md:grid-cols-4 md:gap-6">
<div class=":uno: md:col-span-1">
<div class=":uno: sticky top-0">
<span class=":uno: text-base text-gray-900 font-medium"> 元数据 </span>
</div>
</div>
<div class=":uno: mt-5 md:col-span-3 md:mt-0 divide-y divide-gray-100">
<AnnotationsForm
:key="formState.metadata.name"
ref="annotationsGroupFormRef"
:value="formState.metadata.annotations"
kind="PhotoGroup"
group="core.halo.run"
/>
</div>
</div>
<template #footer>
<VSpace>
<VButton :loading="isSubmitting" type="secondary" @click="submitForm('photo-group-form')">
提交 {{ `${isMac ? "" : "Ctrl"} + ` }}
</VButton>
<VButton @click="emit('close')">取消 Esc</VButton>
</VSpace>
</template>
</VModal>
</template>
+205
View File
@@ -0,0 +1,205 @@
<script lang="ts" setup>
import type { PhotoGroup, PhotoGroupList } from "../types/index";
import { axiosInstance } from "@halo-dev/api-client";
import {
Dialog,
IconList,
VButton,
VCard,
VDropdownItem,
VEmpty,
VEntity,
VEntityField,
VLoading,
VStatusDot,
} from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
import { useRouteQuery } from "@vueuse/router";
import { ref } from "vue";
import { VueDraggable } from "vue-draggable-plus";
import GroupEditingModal from "./GroupEditingModal.vue";
const emit = defineEmits<{
(event: "select", group?: string): void;
}>();
const groupEditingModal = ref(false);
const updateGroup = ref<PhotoGroup>();
const selectedGroup = useRouteQuery<string>("photo-group");
const groups = ref<PhotoGroup[]>([]);
const { refetch, isLoading } = useQuery<PhotoGroup[]>({
queryKey: ["plugin:photos:groups"],
queryFn: async () => {
const { data } = await axiosInstance.get<PhotoGroupList>("/apis/console.api.photo.halo.run/v1alpha1/photogroups");
return data.items
.map((group) => {
if (group.spec) {
group.spec.priority = group.spec.priority || 0;
}
return group;
})
.sort((a, b) => {
return (a.spec?.priority || 0) - (b.spec?.priority || 0);
});
},
refetchInterval(data) {
const hasDeletingGroup = data?.some((group) => !!group.metadata.deletionTimestamp);
return hasDeletingGroup ? 1000 : false;
},
onSuccess(data) {
groups.value = data;
if (selectedGroup.value) {
const groupNames = data.map((group) => group.metadata.name);
if (groupNames.includes(selectedGroup.value)) {
emit("select", selectedGroup.value);
return;
}
}
if (data.length) {
handleSelectedClick(data[0]);
} else {
selectedGroup.value = "";
emit("select", "");
}
},
refetchOnWindowFocus: false,
});
const handleSaveInBatch = async () => {
try {
const promises = groups.value?.map((group: PhotoGroup, index) => {
if (group.spec) {
group.spec.priority = index;
}
return axiosInstance.put(`/apis/core.halo.run/v1alpha1/photogroups/${group.metadata.name}`, group);
});
if (promises) {
await Promise.all(promises);
}
} catch (e) {
console.error(e);
} finally {
refetch();
}
};
const handleDelete = async (group: PhotoGroup) => {
Dialog.warning({
title: "确定要删除该分组吗?",
description: "将同时删除该分组下的所有图片,该操作不可恢复。",
confirmType: "danger",
onConfirm: async () => {
try {
await axiosInstance.delete(`/apis/console.api.photo.halo.run/v1alpha1/photogroups/${group.metadata.name}`);
refetch();
} catch (e) {
console.error("Failed to delete photo group", e);
}
},
});
};
const handleOpenEditingModal = (group?: PhotoGroup) => {
groupEditingModal.value = true;
updateGroup.value = group;
};
const handleSelectedClick = (group: PhotoGroup) => {
selectedGroup.value = group.metadata.name;
emit("select", group.metadata.name);
};
defineExpose({
refetch,
});
function onGroupEditingModalClose() {
groupEditingModal.value = false;
refetch();
}
</script>
<template>
<GroupEditingModal v-if="groupEditingModal" :group="updateGroup" @close="onGroupEditingModalClose" />
<VCard :body-class="[':uno: !p-0']" title="分组">
<VLoading v-if="isLoading" />
<Transition v-else-if="!groups || !groups.length" appear name="fade">
<VEmpty message="你可以尝试刷新或者新建分组" title="当前没有分组">
<template #actions>
<VSpace>
<VButton size="sm" @click="refetch()"> 刷新</VButton>
</VSpace>
</template>
</VEmpty>
</Transition>
<Transition v-else appear name="fade">
<div class=":uno: w-full overflow-x-auto">
<table class=":uno: w-full border-spacing-0">
<VueDraggable
v-model="groups"
class=":uno: divide-y divide-gray-100"
group="group"
handle=".drag-element"
item-key="metadata.name"
tag="tbody"
@update="handleSaveInBatch"
>
<VEntity
v-for="group in groups"
:key="group.metadata.name"
:is-selected="selectedGroup === group.metadata.name"
class=":uno: group"
@click="handleSelectedClick(group)"
>
<template #prepend>
<div
class=":uno: drag-element absolute inset-y-0 left-0 hidden w-3.5 cursor-move items-center bg-gray-100 transition-all group-hover:flex hover:bg-gray-200"
>
<IconList class=":uno: size-3.5" />
</div>
</template>
<template #start>
<VEntityField
:title="group.spec?.displayName"
:description="`${group.status.photoCount || 0} 个图片`"
></VEntityField>
</template>
<template #end>
<VEntityField v-if="group.metadata.deletionTimestamp">
<template #description>
<VStatusDot v-tooltip="`删除中`" state="warning" animate />
</template>
</VEntityField>
</template>
<template #dropdownItems>
<VDropdownItem @click="handleOpenEditingModal(group)"> 修改 </VDropdownItem>
<VDropdownItem type="danger" @click="handleDelete(group)"> 删除 </VDropdownItem>
</template>
</VEntity>
</VueDraggable>
</table>
</div>
</Transition>
<template v-if="!isLoading" #footer>
<!-- @unocss-skip-start -->
<VButton
v-permission="['plugin:photos:manage']"
block
type="secondary"
@click="handleOpenEditingModal(undefined)"
>
新增分组
</VButton>
<!-- @unocss-skip-end -->
</template>
</VCard>
</template>
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts" setup>
import { onMounted, ref } from "vue";
const props = withDefaults(
defineProps<{
src: string;
alt?: string;
classes?: string | string[];
}>(),
{
src: "",
alt: "",
classes: "",
}
);
const isLoading = ref(false);
const error = ref(false);
const loadImage = async () => {
const image = new Image();
image.src = props.src;
return new Promise((resolve, reject) => {
image.onload = () => resolve(image);
image.onerror = (err) => reject(err);
});
};
onMounted(async () => {
isLoading.value = true;
try {
await loadImage();
} catch (e) {
error.value = true;
} finally {
isLoading.value = false;
}
});
</script>
<template>
<div :class="classes">
<template v-if="isLoading">
<slot name="loading"> loading... </slot>
</template>
<template v-else-if="error">
<slot name="error"> error </slot>
</template>
<img v-else class=":uno: size-full object-cover" :src="src" :alt="alt" />
</div>
</template>
@@ -0,0 +1,148 @@
<script lang="ts" setup>
import type { Photo } from "@/types";
import { submitForm } from "@formkit/core";
import { axiosInstance } from "@halo-dev/api-client";
import { VButton, VModal, VSpace } from "@halo-dev/components";
import { cloneDeep } from "lodash-es";
import { computed, nextTick, onMounted, ref, useTemplateRef } from "vue";
const props = withDefaults(
defineProps<{
photo?: Photo;
group?: string;
}>(),
{
photo: undefined,
group: undefined,
}
);
const emit = defineEmits<{
(event: "close"): void;
(event: "saved", photo: Photo): void;
}>();
const initialFormState: Photo = {
metadata: {
name: "",
generateName: "photo-",
},
spec: {
displayName: "",
url: "",
cover: "",
groupName: props.group || "",
},
kind: "Photo",
apiVersion: "core.halo.run/v1alpha1",
} as Photo;
const formState = ref<Photo>(cloneDeep(initialFormState));
const isSubmitting = ref<boolean>(false);
const modal = useTemplateRef<InstanceType<typeof VModal> | null>("modal");
const isUpdateMode = computed(() => {
return !!formState.value.metadata.creationTimestamp;
});
const modalTitle = computed(() => {
return isUpdateMode.value ? "编辑图片" : "添加图片";
});
onMounted(() => {
if (props.photo) {
formState.value = cloneDeep(props.photo);
}
});
const annotationsFormRef = ref();
const handleSavePhoto = async () => {
annotationsFormRef.value?.handleSubmit();
await nextTick();
const { customAnnotations, annotations, customFormInvalid, specFormInvalid } = annotationsFormRef.value || {};
if (customFormInvalid || specFormInvalid) {
return;
}
formState.value.metadata.annotations = {
...annotations,
...customAnnotations,
};
try {
isSubmitting.value = true;
if (isUpdateMode.value) {
await axiosInstance.put<Photo>(
`/apis/core.halo.run/v1alpha1/photos/${formState.value.metadata.name}`,
formState.value
);
} else {
if (props.group) {
formState.value.spec.groupName = props.group;
}
const { data } = await axiosInstance.post<Photo>(`/apis/core.halo.run/v1alpha1/photos`, formState.value);
emit("saved", data);
}
modal.value?.close();
} catch (e) {
console.error(e);
} finally {
isSubmitting.value = false;
}
};
</script>
<template>
<VModal ref="modal" :title="modalTitle" :width="650" @close="emit('close')">
<template #actions>
<slot name="append-actions" />
</template>
<FormKit
id="photo-form"
v-model="formState.spec"
name="photo-form"
:actions="false"
:config="{ validationVisibility: 'submit' }"
type="form"
@submit="handleSavePhoto"
>
<div class=":uno: md:grid md:grid-cols-4 md:gap-6">
<div class=":uno: md:col-span-1">
<div class=":uno: sticky top-0">
<span class=":uno: text-base text-gray-900 font-medium"> 常规 </span>
</div>
</div>
<div class=":uno: mt-5 md:col-span-3 md:mt-0 divide-y divide-gray-100">
<FormKit name="displayName" label="名称" type="text" validation="required"></FormKit>
<FormKit name="url" label="图片地址" type="attachment" :accepts="['image/*']" validation="required"></FormKit>
<FormKit name="cover" label="封面" type="attachment" :accepts="['image/*']"></FormKit>
<FormKit name="description" label="描述" type="textarea"></FormKit>
</div>
</div>
</FormKit>
<div class=":uno: py-5">
<div class=":uno: border-t border-gray-200"></div>
</div>
<div class=":uno: md:grid md:grid-cols-4 md:gap-6">
<div class=":uno: md:col-span-1">
<div class=":uno: sticky top-0">
<span class=":uno: text-base text-gray-900 font-medium"> 元数据 </span>
</div>
</div>
<div class=":uno: mt-5 md:col-span-3 md:mt-0 divide-y divide-gray-100">
<AnnotationsForm
:key="formState.metadata.name"
ref="annotationsFormRef"
:value="formState.metadata.annotations"
kind="Photo"
group="core.halo.run"
/>
</div>
</div>
<template #footer>
<VSpace>
<VButton :loading="isSubmitting" type="secondary" @click="submitForm('photo-form')"> 保存 </VButton>
<VButton @click="modal?.close()">取消</VButton>
</VSpace>
</template>
</VModal>
</template>
+29
View File
@@ -0,0 +1,29 @@
import { definePlugin } from "@halo-dev/console-shared";
import { defineAsyncComponent, markRaw } from "vue";
import RiImage2Line from "~icons/ri/image-2-line";
import "uno.css";
import { VLoading } from "@halo-dev/components";
export default definePlugin({
routes: [
{
parentName: "Root",
route: {
path: "/photos",
name: "Photos",
component: defineAsyncComponent({
loader: () => import("@/views/PhotoList.vue"),
loadingComponent: VLoading,
}),
meta: {
permissions: ["plugin:photos:view"],
menu: {
name: "图库",
group: "content",
icon: markRaw(RiImage2Line),
},
},
},
},
],
});
+69
View File
@@ -0,0 +1,69 @@
export interface Metadata {
name: string;
generateName?: string;
labels?: {
[key: string]: string;
} | null;
annotations?: {
[key: string]: string;
} | null;
version?: number | null;
creationTimestamp?: string | null;
deletionTimestamp?: string | null;
}
export interface PhotoGroupSpec {
displayName: string;
priority?: number;
}
export interface PostGroupStatus {
photoCount: number;
}
export interface PhotoSpec {
displayName: string;
description?: string;
url: string;
cover?: string;
priority?: number;
groupName: string;
}
export interface Photo {
spec: PhotoSpec;
apiVersion: string;
kind: string;
metadata: Metadata;
}
export interface PhotoGroup {
spec: PhotoGroupSpec;
apiVersion: string;
kind: string;
metadata: Metadata;
status: PostGroupStatus;
}
export interface PhotoList {
page: number;
size: number;
total: number;
totalPages: number;
items: Array<Photo>;
first: boolean;
last: boolean;
hasNext: boolean;
hasPrevious: boolean;
}
export interface PhotoGroupList {
page: number;
size: number;
total: number;
items: Array<PhotoGroup>;
first: boolean;
last: boolean;
hasNext: boolean;
hasPrevious: boolean;
}
+446
View File
@@ -0,0 +1,446 @@
<script lang="ts" setup>
import LazyImage from "@/components/LazyImage.vue";
import PhotoEditingModal from "@/components/PhotoEditingModal.vue";
import type { Photo, PhotoList } from "@/types";
import { axiosInstance } from "@halo-dev/api-client";
import {
Dialog,
IconAddCircle,
IconArrowLeft,
IconArrowRight,
IconCheckboxFill,
Toast,
VButton,
VCard,
VDropdown,
VDropdownItem,
VEmpty,
VLoading,
VPageHeader,
VPagination,
VSpace,
} from "@halo-dev/components";
import type { AttachmentLike } from "@halo-dev/console-shared";
import { useQuery } from "@tanstack/vue-query";
import Fuse from "fuse.js";
import { computed, nextTick, ref, watch } from "vue";
import RiImage2Line from "~icons/ri/image-2-line";
import GroupList from "../components/GroupList.vue";
const selectedPhoto = ref<Photo | undefined>();
const selectedPhotos = ref<Set<Photo>>(new Set<Photo>());
const selectedGroup = ref<string>();
const editingModal = ref(false);
const checkedAll = ref(false);
const groupListRef = ref();
const page = ref(1);
const size = ref(20);
const total = ref(0);
const keyword = ref("");
const {
data: photos,
isLoading,
refetch,
} = useQuery<Photo[]>({
queryKey: ["plugin:photos:data", page, size, keyword, selectedGroup],
queryFn: async () => {
if (!selectedGroup.value) {
return [];
}
const { data } = await axiosInstance.get<PhotoList>("/apis/console.api.photo.halo.run/v1alpha1/photos", {
params: {
page: page.value,
size: size.value,
keyword: keyword.value,
group: selectedGroup.value,
},
});
total.value = data.total;
return data.items
.map((group) => {
if (group.spec) {
group.spec.priority = group.spec.priority || 0;
}
return group;
})
.sort((a, b) => {
return (a.spec?.priority || 0) - (b.spec?.priority || 0);
});
},
refetchInterval(data) {
const hasDeletingGroup = data?.some((group) => !!group.metadata.deletionTimestamp);
return hasDeletingGroup ? 1000 : false;
},
refetchOnWindowFocus: false,
});
const handleSelectPrevious = () => {
if (!photos.value) {
return;
}
const currentIndex = photos.value.findIndex((photo) => photo.metadata.name === selectedPhoto.value?.metadata.name);
if (currentIndex > 0) {
selectedPhoto.value = photos.value[currentIndex - 1];
return;
}
if (currentIndex <= 0) {
selectedPhoto.value = undefined;
}
};
const handleSelectNext = () => {
if (!photos.value) {
return;
}
if (!selectedPhoto.value) {
selectedPhoto.value = photos.value[0];
return;
}
const currentIndex = photos.value.findIndex((photo) => photo.metadata.name === selectedPhoto.value?.metadata.name);
if (currentIndex !== photos.value.length - 1) {
selectedPhoto.value = photos.value[currentIndex + 1];
}
};
const handleOpenEditingModal = (photo?: Photo) => {
selectedPhoto.value = photo;
editingModal.value = true;
};
const handleDeleteInBatch = () => {
Dialog.warning({
title: "是否确认删除所选的图片?",
description: "删除之后将无法恢复。",
confirmType: "danger",
onConfirm: async () => {
try {
const promises = Array.from(selectedPhotos.value).map((photo) => {
return axiosInstance.delete(`/apis/core.halo.run/v1alpha1/photos/${photo.metadata.name}`);
});
await Promise.all(promises);
} catch (e) {
console.error(e);
} finally {
pageRefetch();
}
},
});
};
const handleCheckAllChange = (e: Event) => {
const { checked } = e.target as HTMLInputElement;
handleCheckAll(checked);
};
const handleCheckAll = (checkAll: boolean) => {
if (checkAll) {
photos.value?.forEach((photo) => {
selectedPhotos.value.add(photo);
});
} else {
selectedPhotos.value.clear();
}
};
const isChecked = (photo: Photo) => {
return (
photo.metadata.name === selectedPhoto.value?.metadata.name ||
Array.from(selectedPhotos.value)
.map((item) => item.metadata.name)
.includes(photo.metadata.name)
);
};
watch(
() => selectedPhotos.value.size,
(newValue) => {
checkedAll.value = newValue === photos.value?.length;
}
);
// search
let fuse: Fuse<Photo> | undefined = undefined;
watch(
() => photos.value,
() => {
if (!photos.value) {
return;
}
fuse = new Fuse(photos.value, {
keys: ["spec.displayName", "metadata.name", "spec.description", "spec.url"],
useExtendedSearch: true,
});
}
);
const searchResults = computed({
get() {
if (!fuse || !keyword.value) {
return photos.value || [];
}
return fuse?.search(keyword.value).map((item) => item.item);
},
set(value) {
photos.value = value;
},
});
// create by attachments
const attachmentModal = ref(false);
const onAttachmentsSelect = async (attachments: AttachmentLike[]) => {
const photos: {
url: string;
cover?: string;
displayName?: string;
type?: string;
}[] = attachments
.map((attachment) => {
const post = {
groupName: selectedGroup.value || "",
};
if (typeof attachment === "string") {
return {
...post,
url: attachment,
cover: attachment,
};
}
if ("url" in attachment) {
return {
...post,
url: attachment.url,
cover: attachment.url,
};
}
if ("spec" in attachment) {
return {
...post,
url: attachment.status?.permalink,
cover: attachment.status?.permalink,
displayName: attachment.spec.displayName,
type: attachment.spec.mediaType,
};
}
})
.filter(Boolean) as {
url: string;
cover?: string;
displayName?: string;
type?: string;
}[];
for (const photo of photos) {
const type = photo.type;
if (!type) {
Toast.error("只支持选择图片");
nextTick(() => {
attachmentModal.value = true;
});
return;
}
const fileType = type.split("/")[0];
if (fileType !== "image") {
Toast.error("只支持选择图片");
nextTick(() => {
attachmentModal.value = true;
});
return;
}
}
const createRequests = photos.map((photo) => {
return axiosInstance.post<Photo>("/apis/core.halo.run/v1alpha1/photos", {
metadata: {
name: "",
generateName: "photo-",
},
spec: photo,
kind: "Photo",
apiVersion: "core.halo.run/v1alpha1",
});
});
await Promise.all(createRequests);
Toast.success(`新建成功,一共创建了 ${photos.length} 张图片。`);
pageRefetch();
};
const groupSelectHandle = (group?: string) => {
selectedGroup.value = group;
};
const pageRefetch = async () => {
await groupListRef.value.refetch();
await refetch();
selectedPhotos.value = new Set<Photo>();
};
const onEditingModalClose = () => {
editingModal.value = false;
refetch();
};
</script>
<template>
<PhotoEditingModal
v-if="editingModal"
:photo="selectedPhoto"
:group="selectedGroup"
@close="onEditingModalClose"
@saved="pageRefetch"
>
<template #append-actions>
<span @click="handleSelectPrevious">
<IconArrowLeft />
</span>
<span @click="handleSelectNext">
<IconArrowRight />
</span>
</template>
</PhotoEditingModal>
<AttachmentSelectorModal v-model:visible="attachmentModal" :accepts="['image/*']" @select="onAttachmentsSelect" />
<VPageHeader title="图库">
<template #icon>
<RiImage2Line />
</template>
</VPageHeader>
<div class=":uno: p-4">
<div class=":uno: flex flex-col gap-2 lg:flex-row">
<div class=":uno: w-full flex-none lg:w-96">
<GroupList ref="groupListRef" @select="groupSelectHandle" />
</div>
<div class=":uno: min-w-0 flex-1 shrink">
<VCard>
<template #header>
<div class=":uno: block w-full bg-gray-50 px-4 py-3">
<div class=":uno: relative flex flex-col items-start sm:flex-row sm:items-center">
<div class=":uno: mr-4 hidden items-center sm:flex">
<input v-model="checkedAll" type="checkbox" @change="handleCheckAllChange" />
</div>
<div class=":uno: w-full flex flex-1 sm:w-auto">
<SearchInput v-if="!selectedPhotos.size" v-model="keyword" />
<VSpace v-else>
<VButton type="danger" @click="handleDeleteInBatch"> 删除 </VButton>
</VSpace>
</div>
<div v-if="selectedGroup" v-permission="['plugin:photos:manage']" class=":uno: mt-4 flex sm:mt-0">
<VDropdown>
<VButton size="xs"> 新增 </VButton>
<template #popper>
<VDropdownItem @click="handleOpenEditingModal()"> 新增 </VDropdownItem>
<VDropdownItem @click="attachmentModal = true"> 从附件库选择 </VDropdownItem>
</template>
</VDropdown>
</div>
</div>
</div>
</template>
<VLoading v-if="isLoading" />
<Transition v-else-if="!selectedGroup" appear name="fade">
<VEmpty message="请选择或新建分组" title="未选择分组"></VEmpty>
</Transition>
<Transition v-else-if="!searchResults.length" appear name="fade">
<VEmpty message="你可以尝试刷新或者新建图片" title="当前没有图片">
<template #actions>
<VSpace>
<VButton @click="refetch"> 刷新</VButton>
<VButton v-permission="['plugin:photos:manage']" type="primary" @click="handleOpenEditingModal()">
<template #icon>
<IconAddCircle class=":uno: size-full" />
</template>
新增图片
</VButton>
</VSpace>
</template>
</VEmpty>
</Transition>
<Transition v-else appear name="fade">
<div
class=":uno: grid grid-cols-1 mt-2 gap-x-2 gap-y-3 lg:grid-cols-3 sm:grid-cols-2 xl:grid-cols-5"
role="list"
>
<VCard
v-for="photo in photos"
:key="photo.metadata.name"
:body-class="[':uno: !p-0']"
:class="{
':uno: ring-primary ring-1': isChecked(photo),
':uno: ring-1 ring-red-600': photo.metadata.deletionTimestamp,
}"
class=":uno: hover:shadow"
@click="handleOpenEditingModal(photo)"
>
<div class=":uno: group relative bg-white">
<div class=":uno: block aspect-16/9 size-full cursor-pointer overflow-hidden bg-gray-100">
<LazyImage
:key="photo.metadata.name"
:alt="photo.spec.displayName"
:src="photo.spec.cover || photo.spec.url"
classes="size-full pointer-events-none group-hover:opacity-75"
>
<template #loading>
<div class=":uno: h-full flex justify-center">
<VLoading></VLoading>
</div>
</template>
<template #error>
<div class=":uno: h-full flex items-center justify-center object-cover">
<span class=":uno: text-xs text-red-400"> 加载异常 </span>
</div>
</template>
</LazyImage>
</div>
<p
v-tooltip="photo.spec.displayName"
class=":uno: block cursor-pointer truncate px-2 py-1 text-center text-xs text-gray-700 font-medium"
>
{{ photo.spec.displayName }}
</p>
<div
v-if="photo.metadata.deletionTimestamp"
class=":uno: absolute right-1 top-1 text-xs text-red-300"
>
删除中...
</div>
<div
v-if="!photo.metadata.deletionTimestamp"
v-permission="['plugin:photos:manage']"
:class="{ ':uno: !flex': selectedPhotos.has(photo) }"
class=":uno: absolute left-0 top-0 hidden h-1/3 w-full cursor-pointer justify-end from-gray-300 to-transparent bg-gradient-to-b ease-in-out group-hover:flex"
@click.stop="selectedPhotos.has(photo) ? selectedPhotos.delete(photo) : selectedPhotos.add(photo)"
>
<IconCheckboxFill
:class="{
':uno: !text-primary': selectedPhotos.has(photo),
}"
class=":uno: hover:text-primary mr-1 mt-1 h-6 w-6 cursor-pointer text-white transition-all"
/>
</div>
</div>
</VCard>
</div>
</Transition>
<template #footer>
<VPagination v-model:page="page" v-model:size="size" :total="total" :size-options="[20, 30, 50, 100]" />
</template>
</VCard>
</div>
</div>
</div>
</template>
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "@vue/tsconfig/tsconfig.web.json",
"include": ["./env.d.ts", "./src/**/*", "./src/**/*.vue"],
"exclude": ["./src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"types": ["unplugin-icons/types/vue"]
}
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig, presetWind3, transformerCompileClass } from "unocss";
export default defineConfig({
presets: [presetWind3()],
transformers: [transformerCompileClass()],
});