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
@@ -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>