mirror of
https://github.com/MCSManager/MCSManager.git
synced 2025-01-24 15:14:01 +08:00
Feat: create instance
This commit is contained in:
parent
888ea09759
commit
99702f4146
1
frontend/components.d.ts
vendored
1
frontend/components.d.ts
vendored
@ -45,6 +45,7 @@ declare module 'vue' {
|
||||
ATypographyParagraph: typeof import('ant-design-vue/es')['TypographyParagraph']
|
||||
ATypographyText: typeof import('ant-design-vue/es')['TypographyText']
|
||||
ATypographyTitle: typeof import('ant-design-vue/es')['TypographyTitle']
|
||||
AUpload: typeof import('ant-design-vue/es')['Upload']
|
||||
BetweenMenus: typeof import('./src/components/BetweenMenus.vue')['default']
|
||||
CardError: typeof import('./src/components/CardError.vue')['default']
|
||||
CardOperator: typeof import('./src/components/CardOperator.vue')['default']
|
||||
|
@ -11,12 +11,18 @@ import {
|
||||
IdcardTwoTone,
|
||||
NodeIndexOutlined,
|
||||
ShoppingCartOutlined,
|
||||
TransactionOutlined
|
||||
TransactionOutlined,
|
||||
SmileTwoTone,
|
||||
CodeOutlined,
|
||||
FolderOpenOutlined,
|
||||
HomeOutlined
|
||||
} from "@ant-design/icons-vue";
|
||||
import { computed, onMounted, reactive, ref, type FunctionalComponent } from "vue";
|
||||
import { router } from "@/config/router";
|
||||
|
||||
export enum QUICKSTART_ACTION_TYPE {
|
||||
Minecraft = "Minecraft",
|
||||
Bedrock = "Bedrock",
|
||||
SteamGameServer = "SteamGameServer",
|
||||
AnyApp = "AnyApp"
|
||||
}
|
||||
@ -36,14 +42,20 @@ export function useQuickStartFlow() {
|
||||
title: string;
|
||||
key: string;
|
||||
icon: any;
|
||||
click?: () => void;
|
||||
}
|
||||
|
||||
const step1: ActionButtons[] = [
|
||||
{
|
||||
title: "Minecraft 游戏服务器",
|
||||
title: "Minecraft Java版游戏服务器",
|
||||
key: QUICKSTART_ACTION_TYPE.Minecraft,
|
||||
icon: AppstoreAddOutlined
|
||||
},
|
||||
{
|
||||
title: "Minecraft 基岩版游戏服务器",
|
||||
key: QUICKSTART_ACTION_TYPE.Bedrock,
|
||||
icon: AppstoreAddOutlined
|
||||
},
|
||||
{
|
||||
title: "Steam 游戏服务器",
|
||||
key: QUICKSTART_ACTION_TYPE.SteamGameServer,
|
||||
@ -62,6 +74,7 @@ export function useQuickStartFlow() {
|
||||
actions?: ActionButtons[];
|
||||
appType?: QUICKSTART_ACTION_TYPE;
|
||||
createMethod?: QUICKSTART_METHOD;
|
||||
remoteUuid?: string;
|
||||
}>({
|
||||
title: t("您想部署一个什么应用实例?"),
|
||||
step: 1,
|
||||
@ -83,16 +96,19 @@ export function useQuickStartFlow() {
|
||||
formData.title = t("新的程序部署在哪台机器?");
|
||||
};
|
||||
|
||||
const toStep3 = () => {
|
||||
const toStep3 = (remoteUuid: string) => {
|
||||
formData.step = 3;
|
||||
formData.title = t("请选择部署应用实例的方式?");
|
||||
formData.remoteUuid = remoteUuid;
|
||||
currentIcon.value = CalculatorTwoTone;
|
||||
formData.actions = arrayFilter<ActionButtons>([
|
||||
{
|
||||
title: "Minecraft 快速部署",
|
||||
key: QUICKSTART_METHOD.FAST,
|
||||
icon: AppstoreAddOutlined,
|
||||
condition: () => formData.appType === QUICKSTART_ACTION_TYPE.Minecraft
|
||||
condition: () =>
|
||||
formData.appType === QUICKSTART_ACTION_TYPE.Minecraft ||
|
||||
formData.appType === QUICKSTART_ACTION_TYPE.Bedrock
|
||||
},
|
||||
{
|
||||
title: "上传服务端文件压缩包",
|
||||
@ -103,11 +119,6 @@ export function useQuickStartFlow() {
|
||||
title: "选择服务器现有目录",
|
||||
key: QUICKSTART_METHOD.SELECT,
|
||||
icon: TransactionOutlined
|
||||
},
|
||||
{
|
||||
title: "无需额外文件",
|
||||
key: QUICKSTART_METHOD.SELECT,
|
||||
icon: TransactionOutlined
|
||||
}
|
||||
]);
|
||||
};
|
||||
@ -118,6 +129,55 @@ export function useQuickStartFlow() {
|
||||
currentIcon.value = IdcardTwoTone;
|
||||
};
|
||||
|
||||
const toStep5 = (instanceId?: string) => {
|
||||
formData.step = 5;
|
||||
formData.title = t("恭喜,实例创建成功");
|
||||
currentIcon.value = SmileTwoTone;
|
||||
|
||||
formData.actions = arrayFilter<ActionButtons>([
|
||||
{
|
||||
title: t("前往实例控制台"),
|
||||
key: "console",
|
||||
icon: CodeOutlined,
|
||||
click: () => {
|
||||
const daemonId = formData.remoteUuid;
|
||||
router.push({
|
||||
path: "/instances/terminal",
|
||||
query: {
|
||||
daemonId,
|
||||
instanceId
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t("前往实例文件管理"),
|
||||
key: "files",
|
||||
icon: FolderOpenOutlined,
|
||||
click: () => {
|
||||
const daemonId = formData.remoteUuid;
|
||||
router.push({
|
||||
path: "/instances/terminal/files",
|
||||
query: {
|
||||
daemonId,
|
||||
instanceId
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t("返回面板首页"),
|
||||
key: "main",
|
||||
icon: HomeOutlined,
|
||||
click: () => {
|
||||
router.push({
|
||||
path: "/"
|
||||
});
|
||||
}
|
||||
}
|
||||
]);
|
||||
};
|
||||
|
||||
const isFormStep = computed(() => {
|
||||
return formData.step === 4;
|
||||
});
|
||||
@ -131,6 +191,7 @@ export function useQuickStartFlow() {
|
||||
toStep2,
|
||||
toStep3,
|
||||
toStep4,
|
||||
toStep5,
|
||||
isReady,
|
||||
isLoading,
|
||||
isFormStep,
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { useDefineApi } from "@/stores/useDefineApi";
|
||||
import type { InstanceDetail } from "@/types";
|
||||
import type { InstanceDetail, NewInstanceForm } from "@/types";
|
||||
import type { IGlobalInstanceConfig } from "../../../../common/global";
|
||||
|
||||
// 此处 API 接口可以用中文写注释,后期再统一翻译成英语。
|
||||
@ -117,3 +117,55 @@ export const updateAnyInstanceConfig = useDefineApi<
|
||||
url: "/api/instance",
|
||||
method: "PUT"
|
||||
});
|
||||
|
||||
// 获取上传地址
|
||||
export const uploadAddress = useDefineApi<
|
||||
{
|
||||
params: {
|
||||
upload_dir: string;
|
||||
remote_uuid: string;
|
||||
};
|
||||
data: NewInstanceForm;
|
||||
},
|
||||
{
|
||||
instanceUuid: string;
|
||||
password: string;
|
||||
addr: string;
|
||||
}
|
||||
>({
|
||||
url: "/api/instance/upload",
|
||||
method: "POST"
|
||||
});
|
||||
|
||||
// 上传实例文件
|
||||
export const uploadInstanceFile = useDefineApi<
|
||||
{
|
||||
params: {
|
||||
unzip: number;
|
||||
code: string;
|
||||
};
|
||||
url: string;
|
||||
onUploadProgress: Function;
|
||||
},
|
||||
{}
|
||||
>({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "multipart/form-data" }
|
||||
});
|
||||
|
||||
// 新建实例
|
||||
export const createInstance = useDefineApi<
|
||||
{
|
||||
params: {
|
||||
remote_uuid: string;
|
||||
};
|
||||
data: NewInstanceForm;
|
||||
},
|
||||
{
|
||||
instanceUuid: string;
|
||||
config: IGlobalInstanceConfig;
|
||||
}
|
||||
>({
|
||||
method: "POST",
|
||||
url: "/api/instance"
|
||||
});
|
||||
|
@ -160,3 +160,18 @@ export interface DockerNetworkModes {
|
||||
[propName: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NewInstanceForm {
|
||||
nickname: string;
|
||||
startCommand: string;
|
||||
stopCommand: string;
|
||||
cwd: string;
|
||||
ie: string;
|
||||
oe: string;
|
||||
createDatetime: string;
|
||||
lastDatetime: string;
|
||||
type: string;
|
||||
tag: never[];
|
||||
maxSpace: null;
|
||||
endTime: string;
|
||||
}
|
||||
|
@ -1,16 +1,286 @@
|
||||
<script setup lang="ts">
|
||||
import type { QUICKSTART_ACTION_TYPE, QUICKSTART_METHOD } from "@/hooks/widgets/quickStartFlow";
|
||||
import { ref, reactive, createVNode } from "vue";
|
||||
import { t } from "@/lang/i18n";
|
||||
import { QUICKSTART_ACTION_TYPE, QUICKSTART_METHOD } from "@/hooks/widgets/quickStartFlow";
|
||||
import type { FormInstance } from "ant-design-vue";
|
||||
import type { Rule } from "ant-design-vue/es/form";
|
||||
import type { NewInstanceForm } from "@/types";
|
||||
import { UploadOutlined, InfoCircleOutlined } from "@ant-design/icons-vue";
|
||||
import { message, Modal, type UploadProps } from "ant-design-vue";
|
||||
import {
|
||||
TYPE_MINECRAFT_JAVA,
|
||||
TYPE_MINECRAFT_BEDROCK,
|
||||
TYPE_STEAM_SERVER_UNIVERSAL,
|
||||
TYPE_UNIVERSAL
|
||||
} from "@/hooks/useInstance";
|
||||
import SelectUnzipCode from "../instance/dialogs/SelectUnzipCode.vue";
|
||||
import {
|
||||
uploadAddress,
|
||||
uploadInstanceFile,
|
||||
createInstance as createInstanceApi
|
||||
} from "@/services/apis/instance";
|
||||
import { parseForwardAddress } from "@/tools/protocol";
|
||||
|
||||
const selectUnzipCodeDialog = ref<InstanceType<typeof SelectUnzipCode>>();
|
||||
const emit = defineEmits(["nextStep"]);
|
||||
|
||||
// 这里需要根据创建的APP类型+创建方式两种来决定这个表单该如何实现
|
||||
// 具体参考 MCSM 9 的创建实例界面
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
appType: QUICKSTART_ACTION_TYPE;
|
||||
createMethod: QUICKSTART_METHOD;
|
||||
remoteUuid: string;
|
||||
}>();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const formData = reactive<NewInstanceForm>({
|
||||
nickname: "",
|
||||
startCommand: "",
|
||||
stopCommand: "^c",
|
||||
cwd: "",
|
||||
ie: "GBK",
|
||||
oe: "GBK",
|
||||
createDatetime: new Date().toDateString(),
|
||||
lastDatetime: "",
|
||||
type: TYPE_UNIVERSAL,
|
||||
tag: [],
|
||||
maxSpace: null,
|
||||
endTime: ""
|
||||
});
|
||||
|
||||
const zipCode = ref("gbk");
|
||||
|
||||
if (props.appType === QUICKSTART_ACTION_TYPE.Minecraft) {
|
||||
formData.startCommand =
|
||||
props.createMethod === QUICKSTART_METHOD.IMPORT ? "" : "java -jar ${ProgramName}";
|
||||
formData.stopCommand = "stop";
|
||||
formData.type = TYPE_MINECRAFT_JAVA;
|
||||
}
|
||||
|
||||
if (props.appType === QUICKSTART_ACTION_TYPE.Bedrock) {
|
||||
formData.startCommand = props.createMethod === QUICKSTART_METHOD.IMPORT ? "" : "${ProgramName}";
|
||||
formData.stopCommand = "stop";
|
||||
formData.type = TYPE_MINECRAFT_BEDROCK;
|
||||
}
|
||||
|
||||
if (props.appType === QUICKSTART_ACTION_TYPE.SteamGameServer) {
|
||||
formData.startCommand = "${ProgramName}";
|
||||
formData.type = TYPE_STEAM_SERVER_UNIVERSAL;
|
||||
}
|
||||
|
||||
const rules: Record<string, Rule[]> = {
|
||||
nickname: [{ required: true, message: t("请输入实例名称") }],
|
||||
startCommand: [
|
||||
{
|
||||
required: true,
|
||||
validator: async (_rule: Rule, value: string) => {
|
||||
if (value === "") throw new Error(t("请输入启动命令"));
|
||||
if (value.includes("\n"))
|
||||
throw new Error(t("启动命令中不可包含换行,这并非脚本文件,不可执行多条命令"));
|
||||
},
|
||||
trigger: "change"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const uFile = ref<File>();
|
||||
|
||||
const beforeUpload: UploadProps["beforeUpload"] = async (file) => {
|
||||
if (file.type !== "application/x-zip-compressed") return message.error(t("只能上传zip压缩文件"));
|
||||
uFile.value = file;
|
||||
selectUnzipCodeDialog.value?.openDialog();
|
||||
return false;
|
||||
};
|
||||
|
||||
// 设置解压缩编码
|
||||
const setUnzipCode = async (code: string) => {
|
||||
zipCode.value = code;
|
||||
finalConfirm();
|
||||
};
|
||||
|
||||
// 最终确认
|
||||
const finalConfirm = async () => {
|
||||
const thisModal = Modal.confirm({
|
||||
title: t("最终确认"),
|
||||
icon: createVNode(InfoCircleOutlined),
|
||||
content:
|
||||
props.createMethod === QUICKSTART_METHOD.IMPORT
|
||||
? t("上传文件时将同时创建实例,此操作不可逆,是否继续?")
|
||||
: t("实例将创建,是否继续?"),
|
||||
okText: t("确定"),
|
||||
async onOk() {
|
||||
try {
|
||||
await formRef.value?.validateFields();
|
||||
thisModal.destroy();
|
||||
props.createMethod === QUICKSTART_METHOD.IMPORT
|
||||
? await selectedFile()
|
||||
: await createInstance();
|
||||
} catch {
|
||||
return message.error(t("请先完善基本参数再进行上传文件操作"));
|
||||
}
|
||||
},
|
||||
onCancel() {}
|
||||
});
|
||||
};
|
||||
|
||||
// 通过上传压缩包创建实例
|
||||
const { state: cfg, execute: getCfg } = uploadAddress();
|
||||
const { execute: uploadFile } = uploadInstanceFile();
|
||||
const percentComplete = ref(0);
|
||||
const selectedFile = async () => {
|
||||
try {
|
||||
if (!formData.cwd) formData.cwd = ".";
|
||||
await getCfg({
|
||||
params: {
|
||||
upload_dir: ".",
|
||||
remote_uuid: props.remoteUuid
|
||||
},
|
||||
data: formData
|
||||
});
|
||||
if (!cfg.value) return;
|
||||
|
||||
const uploadFormData = new FormData();
|
||||
uploadFormData.append("file", uFile.value as any);
|
||||
|
||||
await uploadFile({
|
||||
params: {
|
||||
unzip: 1,
|
||||
code: zipCode.value
|
||||
},
|
||||
data: uploadFormData,
|
||||
url: `${parseForwardAddress(cfg.value.addr, "http")}/upload/${cfg.value.password}`,
|
||||
onUploadProgress: (progressEvent: any) => {
|
||||
percentComplete.value = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
}
|
||||
});
|
||||
emit("nextStep", cfg.value.instanceUuid);
|
||||
return message.success(t("创建成功"));
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
return message.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 直接新建实例
|
||||
const {
|
||||
state: newInstanceInfo,
|
||||
execute: executeCreateInstance,
|
||||
isLoading: createInstanceLoading
|
||||
} = createInstanceApi();
|
||||
const createInstance = async () => {
|
||||
try {
|
||||
if (!formData.cwd) formData.cwd = ".";
|
||||
await executeCreateInstance({
|
||||
params: {
|
||||
remote_uuid: props.remoteUuid
|
||||
},
|
||||
data: formData
|
||||
});
|
||||
if (newInstanceInfo.value) emit("nextStep", newInstanceInfo.value.instanceUuid);
|
||||
return message.success(t("创建成功"));
|
||||
} catch (err: any) {
|
||||
return message.error(err.message);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>各种表单实现</div>
|
||||
<div style="text-align: left">
|
||||
<a-form ref="formRef" :rules="rules" :model="formData" layout="vertical" autocomplete="off">
|
||||
<a-form-item name="nickname">
|
||||
<a-typography-title :level="5" class="require-field">
|
||||
{{ t("实例名称") }}
|
||||
</a-typography-title>
|
||||
<a-typography-paragraph>
|
||||
<a-typography-text type="secondary">
|
||||
{{ t("支持中文,尽可能保证唯一性") }}
|
||||
</a-typography-text>
|
||||
</a-typography-paragraph>
|
||||
<a-input v-model:value="formData.nickname" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="startCommand">
|
||||
<a-typography-title :level="5" class="require-field">
|
||||
{{ t("启动命令") }}
|
||||
</a-typography-title>
|
||||
<a-typography-paragraph>
|
||||
<a-typography-text type="secondary">
|
||||
{{
|
||||
createMethod === QUICKSTART_METHOD.IMPORT
|
||||
? t("因为无法识别压缩包中的服务端文件名,请您自行填写启动命令")
|
||||
: t("请您自行填写启动命令")
|
||||
}}
|
||||
</a-typography-text>
|
||||
</a-typography-paragraph>
|
||||
<a-input-group compact style="display: flex">
|
||||
<a-textarea
|
||||
v-model:value="formData.startCommand"
|
||||
:rows="1"
|
||||
:placeholder="t('如 java -jar server.jar,cmd.exe 等等')"
|
||||
/>
|
||||
<a-button
|
||||
type="default"
|
||||
style="height: auto; border-top-left-radius: 0; border-bottom-left-radius: 0"
|
||||
>
|
||||
命令助手
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="cwd">
|
||||
<a-typography-title :level="5">
|
||||
{{ t("服务端文件目录") }}
|
||||
</a-typography-title>
|
||||
<a-typography-paragraph>
|
||||
<a-typography-text type="secondary">
|
||||
{{ t("选填,默认自动创建与管理,如需填写请写完整绝对路径,如: C:/Servers/MyServer") }}
|
||||
</a-typography-text>
|
||||
</a-typography-paragraph>
|
||||
<a-input v-model:value="formData.cwd" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="createMethod === QUICKSTART_METHOD.IMPORT">
|
||||
<a-typography-title :level="5" class="require-field">
|
||||
{{ t("上传服务端压缩包") }}
|
||||
</a-typography-title>
|
||||
<a-typography-paragraph>
|
||||
<a-typography-text type="secondary">
|
||||
{{ t("仅支持 ZIP 格式,上传后压缩包会自动解压到 “文件目录”") }}
|
||||
</a-typography-text>
|
||||
</a-typography-paragraph>
|
||||
<a-upload
|
||||
action="/api/instance/upload"
|
||||
:before-upload="beforeUpload"
|
||||
:max-count="1"
|
||||
:change="selectedFile"
|
||||
:disabled="percentComplete > 0"
|
||||
>
|
||||
<a-button type="primary" :loading="percentComplete > 0">
|
||||
<upload-outlined v-if="percentComplete === 0" />
|
||||
{{ percentComplete > 0 ? t("正在上传:") + percentComplete + "%" : t("上传压缩包") }}
|
||||
</a-button>
|
||||
</a-upload>
|
||||
<a-typography-paragraph class="mt-10">
|
||||
<a-typography-text>
|
||||
{{ t("上传文件后实例将自动创建并解压文件,可能需要一段时间才能完成解压任务") }}
|
||||
</a-typography-text>
|
||||
</a-typography-paragraph>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-else>
|
||||
<a-typography-paragraph class="mt-10">
|
||||
<a-typography-text>
|
||||
{{ t("填写好服务端软件文件名后,再前往文件管理上传服务端软件即可开启实例。") }}
|
||||
</a-typography-text>
|
||||
</a-typography-paragraph>
|
||||
<a-button type="primary" :loading="createInstanceLoading" @click="finalConfirm">
|
||||
{{ t("创建实例") }}
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<SelectUnzipCode ref="selectUnzipCodeDialog" @select-code="setUnzipCode" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -18,8 +18,17 @@ defineProps<{
|
||||
card: LayoutCard;
|
||||
}>();
|
||||
|
||||
const { formData, toStep2, toStep3, toStep4, isLoading, isFormStep, isNormalStep, currentIcon } =
|
||||
useQuickStartFlow();
|
||||
const {
|
||||
formData,
|
||||
toStep2,
|
||||
toStep3,
|
||||
toStep4,
|
||||
toStep5,
|
||||
isLoading,
|
||||
isFormStep,
|
||||
isNormalStep,
|
||||
currentIcon
|
||||
} = useQuickStartFlow();
|
||||
|
||||
const presetAppType = String(route.query.appType);
|
||||
if (presetAppType in QUICKSTART_ACTION_TYPE) {
|
||||
@ -32,12 +41,16 @@ const handleNext = (key: string) => {
|
||||
}
|
||||
|
||||
if (formData.step === 2) {
|
||||
return toStep3();
|
||||
return toStep3(key);
|
||||
}
|
||||
|
||||
if (formData.step === 3) {
|
||||
return toStep4(key as QUICKSTART_METHOD);
|
||||
}
|
||||
|
||||
if (formData.step === 4) {
|
||||
return toStep5(key);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -77,8 +90,43 @@ const handleNext = (key: string) => {
|
||||
</a-col>
|
||||
</a-row>
|
||||
<div v-else-if="isFormStep && formData.appType && formData.createMethod">
|
||||
<CreateInstanceForm :app-type="formData.appType" :create-method="formData.createMethod" />
|
||||
<CreateInstanceForm
|
||||
:app-type="formData.appType"
|
||||
:create-method="formData.createMethod"
|
||||
:remote-uuid="formData.remoteUuid ? formData.remoteUuid : ''"
|
||||
@next-step="handleNext"
|
||||
/>
|
||||
</div>
|
||||
<a-row v-else :gutter="[24, 24]" class="h-100">
|
||||
<a-col v-if="!isPhone" :lg="12">
|
||||
<div class="quickstart-icon flex-center h-100">
|
||||
<Transition name="global-action-float">
|
||||
<component :is="currentIcon"></component>
|
||||
</Transition>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :lg="12">
|
||||
<div class="text-left" style="text-align: left">
|
||||
<a-typography-title :level="5" class="mb-24">
|
||||
{{ formData.title }}
|
||||
</a-typography-title>
|
||||
<div>
|
||||
<a-row :gutter="[12, 12]">
|
||||
<fade-up-animation>
|
||||
<action-button
|
||||
v-for="(action, index) in formData.actions"
|
||||
:key="index"
|
||||
:data-index="index"
|
||||
:title="action.title"
|
||||
:icon="action.icon"
|
||||
:click="() => (action.click ? action.click() : undefined)"
|
||||
/>
|
||||
</fade-up-animation>
|
||||
</a-row>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
<div v-else class="loading flex-center w-100 h-100">
|
||||
<Loading />
|
||||
|
Loading…
Reference in New Issue
Block a user