Feat: create instance

This commit is contained in:
Lazy 2023-10-07 20:58:23 +08:00
parent 888ea09759
commit 99702f4146
6 changed files with 464 additions and 17 deletions

View File

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

View File

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

View File

@ -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"
});

View File

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

View File

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

View File

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