1.5.1原版
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user