1.0.2原版
This commit is contained in:
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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?
|
||||
Vendored
+14
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
Generated
+5784
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- vue-demi
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
printWidth: 120,
|
||||
tabWidth: 2,
|
||||
useTabs: false,
|
||||
endOfLine: "lf",
|
||||
};
|
||||
@@ -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 = "equipment";
|
||||
|
||||
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,148 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Equipment } from "@/types";
|
||||
import { submitForm } from "@formkit/core";
|
||||
import { axiosInstance } from "@halo-dev/api-client";
|
||||
import { VSpace, VButton, VModal } from "@halo-dev/components";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
import {computed, nextTick, onMounted, ref, useTemplateRef} from "vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
equipment?: Equipment;
|
||||
group?: string;
|
||||
}>(),
|
||||
{
|
||||
equipment: undefined,
|
||||
group: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "close"): void;
|
||||
(event: "saved", equipment: Equipment): void;
|
||||
}>();
|
||||
|
||||
const initialFormState: Equipment = {
|
||||
metadata: {
|
||||
name: "",
|
||||
generateName: "equipment-",
|
||||
},
|
||||
spec: {
|
||||
displayName: "",
|
||||
cover: "",
|
||||
groupName: props.group || "",
|
||||
},
|
||||
kind: "Equipment",
|
||||
apiVersion: "equipment.kunkunyu.com/v1alpha1",
|
||||
} as Equipment;
|
||||
|
||||
const formState = ref<Equipment>(cloneDeep(initialFormState));
|
||||
const isSubmitting = ref(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.equipment) {
|
||||
formState.value = cloneDeep(props.equipment);
|
||||
}
|
||||
});
|
||||
|
||||
const annotationsFormRef = ref();
|
||||
|
||||
const handleSaveEquipment = 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<Equipment>(
|
||||
`/apis/equipment.kunkunyu.com/v1alpha1/equipments/${formState.value.metadata.name}`,
|
||||
formState.value
|
||||
);
|
||||
} else {
|
||||
if (props.group) {
|
||||
formState.value.spec.groupName = props.group;
|
||||
}
|
||||
const { data } = await axiosInstance.post<Equipment>(`/apis/equipment.kunkunyu.com/v1alpha1/equipments`, 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="equipment-form"
|
||||
v-model="formState.spec"
|
||||
name="equipment-form"
|
||||
:actions="false"
|
||||
:config="{ validationVisibility: 'submit' }"
|
||||
type="form"
|
||||
@submit="handleSaveEquipment"
|
||||
>
|
||||
<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="cover" label="封面" type="attachment" :accepts="['image/*']"></FormKit>
|
||||
<FormKit name="url" label="装备地址" type="text" :accepts="['image/*']"></FormKit>
|
||||
<FormKit name="specification" label="装备规格" type="text" ></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="Equipment"
|
||||
group="equipment.kunkunyu.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<VSpace>
|
||||
<VButton :loading="isSubmitting" type="secondary" @click="submitForm('equipment-form')"> 保存 </VButton>
|
||||
<VButton @click="modal?.close()">取消</VButton>
|
||||
</VSpace>
|
||||
</template>
|
||||
</VModal>
|
||||
</template>
|
||||
@@ -0,0 +1,161 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EquipmentGroup } 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?: EquipmentGroup;
|
||||
}>(),
|
||||
{
|
||||
group: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "close"): void;
|
||||
}>();
|
||||
|
||||
const initialFormState: EquipmentGroup = {
|
||||
apiVersion: "equipment.kunkunyu.com/v1alpha1",
|
||||
kind: "EquipmentGroup",
|
||||
metadata: {
|
||||
name: "",
|
||||
generateName: "equipment-group-",
|
||||
},
|
||||
spec: {
|
||||
displayName: "",
|
||||
priority: 0,
|
||||
},
|
||||
status: {
|
||||
equipmentCount: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const formState = ref<EquipmentGroup>(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/equipment.kunkunyu.com/v1alpha1/equipmentgroups/${formState.value.metadata.name}`,
|
||||
formState.value
|
||||
);
|
||||
} else {
|
||||
await axiosInstance.post("/apis/equipment.kunkunyu.com/v1alpha1/equipmentgroups", formState.value);
|
||||
}
|
||||
modal.value?.close();
|
||||
} catch (e) {
|
||||
console.error("Failed to create equipment 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("equipment-group-form");
|
||||
}
|
||||
});
|
||||
|
||||
watch(Meta_Enter, (v) => {
|
||||
if (v && isMac) {
|
||||
submitForm("equipment-group-form");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<VModal ref="modal" :width="600" :title="modalTitle" @close="emit('close')">
|
||||
<FormKit
|
||||
id="equipment-group-form"
|
||||
v-model="formState.spec"
|
||||
name="equipment-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>
|
||||
<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="annotationsGroupFormRef"
|
||||
:value="formState.metadata.annotations"
|
||||
kind="EquipmentGroup"
|
||||
group="equipment.kunkunyu.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<VSpace>
|
||||
<VButton :loading="isSubmitting" type="secondary" @click="submitForm('equipment-group-form')">
|
||||
提交 {{ `${isMac ? "⌘" : "Ctrl"} + ↵` }}
|
||||
</VButton>
|
||||
<VButton @click="emit('close')">取消 Esc</VButton>
|
||||
</VSpace>
|
||||
</template>
|
||||
</VModal>
|
||||
</template>
|
||||
@@ -0,0 +1,207 @@
|
||||
<script lang="ts" setup>
|
||||
import type { EquipmentGroup, EquipmentGroupList } 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<EquipmentGroup>();
|
||||
|
||||
const selectedGroup = useRouteQuery<string>("equipment-group");
|
||||
|
||||
const groups = ref<EquipmentGroup[]>([]);
|
||||
|
||||
const { refetch, isLoading } = useQuery<EquipmentGroup[]>({
|
||||
queryKey: ["plugin:equipment:groups"],
|
||||
queryFn: async () => {
|
||||
const { data } = await axiosInstance.get<EquipmentGroupList>("/apis/console.api.equipment.kunkunyu.com/v1alpha1/equipmentgroups");
|
||||
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: EquipmentGroup, index) => {
|
||||
if (group.spec) {
|
||||
group.spec.priority = index;
|
||||
}
|
||||
return axiosInstance.put(`/apis/equipment.kunkunyu.com/v1alpha1/equipmentgroups/${group.metadata.name}`, group);
|
||||
});
|
||||
if (promises) {
|
||||
await Promise.all(promises);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (group: EquipmentGroup) => {
|
||||
Dialog.warning({
|
||||
title: "确定要删除该分组吗?",
|
||||
description: "将同时删除该分组下的所有设备,该操作不可恢复。",
|
||||
confirmType: "danger",
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await axiosInstance.delete(`/apis/console.api.equipment.kunkunyu.com/v1alpha1/equipmentgroups/${group.metadata.name}`);
|
||||
refetch();
|
||||
} catch (e) {
|
||||
console.error("Failed to delete equipment group", e);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenEditingModal = (group?: EquipmentGroup) => {
|
||||
groupEditingModal.value = true;
|
||||
updateGroup.value = group;
|
||||
};
|
||||
|
||||
const handleSelectedClick = (group: EquipmentGroup) => {
|
||||
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.equipmentCount || 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>
|
||||
<Transition appear name="fade">
|
||||
<!-- @unocss-skip-start -->
|
||||
<VButton
|
||||
v-permission="['plugin:equipment:manage']"
|
||||
block
|
||||
type="secondary"
|
||||
@click="handleOpenEditingModal(undefined)"
|
||||
>
|
||||
新增分组
|
||||
</VButton>
|
||||
<!-- @unocss-skip-end -->
|
||||
</Transition>
|
||||
</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,29 @@
|
||||
import { definePlugin } from "@halo-dev/console-shared";
|
||||
import { defineAsyncComponent, markRaw } from "vue";
|
||||
import TablerDeviceGamepad3 from '~icons/tabler/device-gamepad-3'
|
||||
import "uno.css";
|
||||
import { VLoading } from "@halo-dev/components";
|
||||
|
||||
export default definePlugin({
|
||||
routes: [
|
||||
{
|
||||
parentName: "Root",
|
||||
route: {
|
||||
path: "/equipments",
|
||||
name: "Equipments",
|
||||
component: defineAsyncComponent({
|
||||
loader: () => import("@/views/EquipmentList.vue"),
|
||||
loadingComponent: VLoading,
|
||||
}),
|
||||
meta: {
|
||||
permissions: ["plugin:equipment:view"],
|
||||
menu: {
|
||||
name: "装备",
|
||||
group: "content",
|
||||
icon: markRaw(TablerDeviceGamepad3),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
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 EquipmentGroupSpec {
|
||||
displayName: string;
|
||||
description?: string;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface EquipmentGroupStatus {
|
||||
equipmentCount: number;
|
||||
}
|
||||
|
||||
export interface EquipmentSpec {
|
||||
displayName: string;
|
||||
specification?: string;
|
||||
description?: string;
|
||||
url: string;
|
||||
cover?: string;
|
||||
priority?: number;
|
||||
groupName: string;
|
||||
}
|
||||
|
||||
export interface Equipment {
|
||||
spec: EquipmentSpec;
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
metadata: Metadata;
|
||||
}
|
||||
|
||||
export interface EquipmentGroup {
|
||||
spec: EquipmentGroupSpec;
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
metadata: Metadata;
|
||||
status: EquipmentGroupStatus;
|
||||
}
|
||||
|
||||
export interface EquipmentList {
|
||||
page: number;
|
||||
size: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
items: Array<Equipment>;
|
||||
first: boolean;
|
||||
last: boolean;
|
||||
hasNext: boolean;
|
||||
hasPrevious: boolean;
|
||||
}
|
||||
|
||||
export interface EquipmentGroupList {
|
||||
page: number;
|
||||
size: number;
|
||||
total: number;
|
||||
items: Array<EquipmentGroup>;
|
||||
first: boolean;
|
||||
last: boolean;
|
||||
hasNext: boolean;
|
||||
hasPrevious: boolean;
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
<script lang="ts" setup>
|
||||
import LazyImage from "@/components/LazyImage.vue";
|
||||
import EquipmentEditingModal from "@/components/EquipmentEditingModal.vue";
|
||||
import type {Equipment, EquipmentList} 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 TablerDeviceGamepad3 from '~icons/tabler/device-gamepad-3'
|
||||
import GroupList from "../components/GroupList.vue";
|
||||
import {VueDraggable} from "vue-draggable-plus";
|
||||
|
||||
const removeFileExtension = (filename: string) => {
|
||||
return filename.replace(/\.[^/.]+$/, "");
|
||||
};
|
||||
|
||||
const selectedEquipment = ref<Equipment | undefined>();
|
||||
const selectedEquipments = ref<Set<Equipment>>(new Set<Equipment>());
|
||||
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 equipments = ref<Equipment[]>([]);
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery<Equipment[]>({
|
||||
queryKey: ["plugin:equipment:data", page, size, keyword, selectedGroup],
|
||||
queryFn: async () => {
|
||||
if (!selectedGroup.value) {
|
||||
return [];
|
||||
}
|
||||
const { data } = await axiosInstance.get<EquipmentList>("/apis/console.api.equipment.kunkunyu.com/v1alpha1/equipments", {
|
||||
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;
|
||||
},
|
||||
onSuccess(data) {
|
||||
equipments.value = data;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const handleSelectPrevious = () => {
|
||||
if (!equipments.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = equipments.value.findIndex((equipment) => equipment.metadata.name === selectedEquipment.value?.metadata.name);
|
||||
|
||||
if (currentIndex > 0) {
|
||||
selectedEquipment.value = equipments.value[currentIndex - 1];
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentIndex <= 0) {
|
||||
selectedEquipment.value = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectNext = () => {
|
||||
if (!equipments.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedEquipment.value) {
|
||||
selectedEquipment.value = equipments.value[0];
|
||||
return;
|
||||
}
|
||||
const currentIndex = equipments.value.findIndex((equipment) => equipment.metadata.name === selectedEquipment.value?.metadata.name);
|
||||
if (currentIndex !== equipments.value.length - 1) {
|
||||
selectedEquipment.value = equipments.value[currentIndex + 1];
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenEditingModal = (equipment?: Equipment) => {
|
||||
selectedEquipment.value = equipment;
|
||||
editingModal.value = true;
|
||||
};
|
||||
|
||||
const handleDeleteInBatch = () => {
|
||||
Dialog.warning({
|
||||
title: "是否确认删除所选的装备?",
|
||||
description: "删除之后将无法恢复。",
|
||||
confirmType: "danger",
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const promises = Array.from(selectedEquipments.value).map((equipment) => {
|
||||
return axiosInstance.delete(`/apis/equipment.kunkunyu.com/v1alpha1/equipments/${equipment.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) {
|
||||
equipments.value?.forEach((equipment) => {
|
||||
selectedEquipments.value.add(equipment);
|
||||
});
|
||||
} else {
|
||||
selectedEquipments.value.clear();
|
||||
}
|
||||
};
|
||||
|
||||
const isChecked = (equipment: Equipment) => {
|
||||
return (
|
||||
equipment.metadata.name === selectedEquipment.value?.metadata.name ||
|
||||
Array.from(selectedEquipments.value)
|
||||
.map((item) => item.metadata.name)
|
||||
.includes(equipment.metadata.name)
|
||||
);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => selectedEquipments.value.size,
|
||||
(newValue) => {
|
||||
checkedAll.value = newValue === equipments.value?.length;
|
||||
}
|
||||
);
|
||||
|
||||
// search
|
||||
let fuse: Fuse<Equipment> | undefined = undefined;
|
||||
|
||||
watch(
|
||||
() => equipments.value,
|
||||
() => {
|
||||
if (!equipments.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
fuse = new Fuse(equipments.value, {
|
||||
keys: ["spec.displayName", "metadata.name", "spec.description", "spec.url"],
|
||||
useExtendedSearch: true,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const searchResults = computed({
|
||||
get() {
|
||||
if (!fuse || !keyword.value) {
|
||||
return equipments.value || [];
|
||||
}
|
||||
|
||||
return fuse?.search(keyword.value).map((item) => item.item);
|
||||
},
|
||||
set(value) {
|
||||
equipments.value = value;
|
||||
},
|
||||
});
|
||||
|
||||
// create by attachments
|
||||
const attachmentModal = ref(false);
|
||||
|
||||
const onAttachmentsSelect = async (attachments: AttachmentLike[]) => {
|
||||
const equipments: {
|
||||
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,
|
||||
cover: attachment.status?.permalink,
|
||||
displayName: attachment.spec.displayName ? removeFileExtension(attachment.spec.displayName) : undefined,
|
||||
type: attachment.spec.mediaType,
|
||||
};
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as {
|
||||
url: string;
|
||||
cover?: string;
|
||||
displayName?: string;
|
||||
type?: string;
|
||||
}[];
|
||||
|
||||
for (const equipment of equipments) {
|
||||
const type = equipment.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 = equipments.map((equipment) => {
|
||||
return axiosInstance.post<Equipment>("/apis/equipment.kunkunyu.com/v1alpha1/equipments", {
|
||||
metadata: {
|
||||
name: "",
|
||||
generateName: "equipment-",
|
||||
},
|
||||
spec: equipment,
|
||||
kind: "Equipment",
|
||||
apiVersion: "equipment.kunkunyu.com/v1alpha1",
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(createRequests);
|
||||
|
||||
Toast.success(`新建成功,一共创建了 ${equipments.length} 个装备。`);
|
||||
pageRefetch();
|
||||
};
|
||||
|
||||
const handleSaveInBatch = async () => {
|
||||
try {
|
||||
const promises = equipments.value?.map((equipment: Equipment, index) => {
|
||||
if (equipment.spec) {
|
||||
equipment.spec.priority = index;
|
||||
}
|
||||
return axiosInstance.put(`/apis/equipment.kunkunyu.com/v1alpha1/equipments/${equipment.metadata.name}`, equipment);
|
||||
});
|
||||
if (promises) {
|
||||
await Promise.all(promises);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
const groupSelectHandle = (group?: string) => {
|
||||
selectedGroup.value = group;
|
||||
};
|
||||
|
||||
const pageRefetch = async () => {
|
||||
await groupListRef.value.refetch();
|
||||
await refetch();
|
||||
selectedEquipments.value = new Set<Equipment>();
|
||||
};
|
||||
|
||||
const onEditingModalClose = () => {
|
||||
editingModal.value = false;
|
||||
refetch();
|
||||
};
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<EquipmentEditingModal
|
||||
v-if="editingModal"
|
||||
:equipment="selectedEquipment"
|
||||
:group="selectedGroup"
|
||||
@close="onEditingModalClose"
|
||||
@saved="pageRefetch"
|
||||
>
|
||||
<template #append-actions>
|
||||
<span @click="handleSelectPrevious">
|
||||
<IconArrowLeft />
|
||||
</span>
|
||||
<span @click="handleSelectNext">
|
||||
<IconArrowRight />
|
||||
</span>
|
||||
</template>
|
||||
</EquipmentEditingModal>
|
||||
<AttachmentSelectorModal v-model:visible="attachmentModal" :accepts="['image/*']" @select="onAttachmentsSelect" />
|
||||
<VPageHeader title="装备">
|
||||
<template #icon>
|
||||
<TablerDeviceGamepad3 />
|
||||
</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="!selectedEquipments.size" v-model="keyword" />
|
||||
<VSpace v-else>
|
||||
<VButton type="danger" @click="handleDeleteInBatch"> 删除 </VButton>
|
||||
</VSpace>
|
||||
</div>
|
||||
<div v-if="selectedGroup" v-permission="['plugin:equipment: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:equipment:manage']" type="primary" @click="handleOpenEditingModal()">
|
||||
<template #icon>
|
||||
<IconAddCircle class=":uno: size-full" />
|
||||
</template>
|
||||
新增装备
|
||||
</VButton>
|
||||
</VSpace>
|
||||
</template>
|
||||
</VEmpty>
|
||||
</Transition>
|
||||
<Transition v-else appear name="fade">
|
||||
<VueDraggable
|
||||
v-model="equipments"
|
||||
class=":uno: grid grid-cols-1 mt-2 gap-x-2 gap-y-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5"
|
||||
group="equipment"
|
||||
handle=".drag-element"
|
||||
item-key="metadata.name"
|
||||
tag="div"
|
||||
role="list"
|
||||
@update="handleSaveInBatch"
|
||||
>
|
||||
<VCard
|
||||
v-for="equipment in equipments"
|
||||
:key="equipment.metadata.name"
|
||||
:body-class="[':uno: !p-0']"
|
||||
:class="{
|
||||
':uno: ring-primary ring-1': isChecked(equipment),
|
||||
':uno: ring-1 ring-red-600': equipment.metadata.deletionTimestamp,
|
||||
}"
|
||||
class=":uno: hover:shadow drag-element "
|
||||
@click="handleOpenEditingModal(equipment)"
|
||||
>
|
||||
<div class=":uno: group relative bg-white">
|
||||
<div class=":uno: block aspect-16/9 size-full cursor-pointer overflow-hidden bg-gray-100 relative">
|
||||
<LazyImage
|
||||
:key="equipment.metadata.name"
|
||||
:alt="equipment.spec.displayName"
|
||||
:src="equipment.spec.cover || equipment.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="equipment.spec.displayName"
|
||||
class=":uno: block cursor-pointer truncate px-2 py-1 text-center text-xs text-gray-700 font-medium"
|
||||
>
|
||||
{{ equipment.spec.displayName }}
|
||||
</p>
|
||||
|
||||
<div v-if="equipment.metadata.deletionTimestamp" class=":uno: absolute top-1 right-1 text-xs text-red-300">
|
||||
删除中...
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!equipment.metadata.deletionTimestamp"
|
||||
v-permission="['plugin:equipment:manage']"
|
||||
:class="{ ':uno: !flex': selectedEquipments.has(equipment) }"
|
||||
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="selectedEquipments.has(equipment) ? selectedEquipments.delete(equipment) : selectedEquipments.add(equipment)"
|
||||
>
|
||||
<IconCheckboxFill
|
||||
:class="{
|
||||
':uno: !text-primary': selectedEquipments.has(equipment),
|
||||
}"
|
||||
class=":uno: hover:text-primary mr-1 mt-1 h-6 w-6 cursor-pointer text-white transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
</VueDraggable>
|
||||
</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>
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
import { defineConfig, presetWind3, transformerCompileClass } from "unocss";
|
||||
|
||||
export default defineConfig({
|
||||
presets: [presetWind3()],
|
||||
transformers: [transformerCompileClass()],
|
||||
});
|
||||
Reference in New Issue
Block a user