项目迁移
Some checks failed
CI/CD / Code Check (push) Has been cancelled
CI/CD / Build Windows (push) Has been cancelled

This commit is contained in:
2026-03-14 21:11:59 +08:00
commit 4654f36202
153 changed files with 55923 additions and 0 deletions

View File

@@ -0,0 +1,258 @@
<script lang="ts" setup>
import { open } from "@tauri-apps/plugin-shell";
import { ref, onMounted, computed } from "vue";
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
interface Sponsor {
id: string;
name: string;
imageUrl: string;
type: string;
url: string;
}
interface VersionInfo {
version: string;
buildTime: string;
author: string;
}
const sponsors = ref<Sponsor[]>([]);
const currentVersion = ref<string>(t('common.loading'));
const buildTime = ref<string>('');
const author = ref<string>('');
async function getVersionFromJson(): Promise<VersionInfo> {
try {
const response = await fetch('/version.json');
if (!response.ok) {
throw new Error(t('about.version_file_read_failed'));
}
return await response.json();
} catch (error) {
console.error('读取 version.json 失败:', error);
return {
version: '1.0.0',
buildTime: 'Unknown',
author: 'Tianpao'
};
}
}
async function getCurrentVersion() {
const versionInfo = await getVersionFromJson();
currentVersion.value = versionInfo.version;
buildTime.value = versionInfo.buildTime;
author.value = versionInfo.author;
}
const SPONSORS_JSON_URL = "https://bk.xcclyc.cn/upzzs.json";
async function fetchSponsors() {
try {
const response = await fetch(SPONSORS_JSON_URL);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
sponsors.value = data;
} catch (error) {
console.error("Failed to fetch sponsors:", error);
sponsors.value = [
{
id: "elfidc",
name: "亿讯云",
imageUrl: "./elfidc.svg",
type: t('about.sponsor_type_gold'),
url: "https://www.elfidc.com"
}
];
}
}
const thanksList = computed(() => {
return [
{
id: "user",
name: "天跑",
avatar: "./tianpao.jpg",
contribution: t('about.contribution_author'),
bilibiliUrl: "https://space.bilibili.com/1728953419"
},
{
id: "dev2",
name: "XCC",
avatar: "./xcc.jpg",
contribution: t('about.contribution_dev2'),
bilibiliUrl: "https://space.bilibili.com/3546586967706135"
},
{
id: "mirror",
name: "bangbang93",
avatar: "./bb93.jpg",
contribution: t('about.contribution_bangbang93')
},{
id: "mirror",
name: "z0z0r4",
avatar: "./z0z0r4.jpg",
contribution: t('about.contribution_z0z0r4')
}
];
});
async function contant(sponsor: Sponsor){
try {
await open(sponsor.url)
} catch (error) {
console.error("Failed to open sponsor URL:", error)
window.open(sponsor.url, '_blank')
}
}
async function openBilibili(url: string) {
try {
await open(url);
} catch (error) {
console.error("Failed to open Bilibili URL:", error)
window.open(url, '_blank')
}
}
onMounted(() => {
fetchSponsors();
getCurrentVersion();
});
</script>
<template>
<div class="tw:h-full tw:w-full tw:p-8 tw:bg-gradient-to-br tw:from-slate-50 tw:via-blue-50 tw:to-indigo-50 tw:overflow-auto">
<div class="tw:w-full tw:max-w-5xl tw:mx-auto tw:flex tw:flex-col tw:gap-8">
<div class="tw:text-center tw:animate-fade-in">
<h1 class="tw:text-3xl tw:font-bold tw:bg-gradient-to-r tw:from-emerald-500 tw:via-cyan-500 tw:to-blue-500 tw:bg-clip-text tw:text-transparent tw:mb-3">
{{ t('about.title') }}
</h1>
<p class="tw:text-gray-500 tw:text-lg">{{ t('about.subtitle') }}</p>
</div>
<div class="tw:bg-white tw:rounded-2xl tw:shadow-lg tw:p-8 tw:animate-fade-in-up">
<h2 class="tw:text-xl tw:font-bold tw:text-gray-800 tw:text-center tw:mb-6 tw:flex tw:items-center tw:justify-center tw:gap-3">
<span class="tw:text-2xl"></span>
<span>{{ t('about.about_software') }}</span>
</h2>
<div class="tw:flex tw:flex-col tw:items-center tw:gap-6">
<div class="tw:flex tw:flex-col tw:items-center tw:gap-3">
<div class="tw:flex tw:items-center tw:gap-3">
<span class="tw:text-gray-600 tw:text-base">{{ t('about.current_version') }}</span>
<span class="tw:text-2xl tw:font-bold tw:bg-gradient-to-r tw:from-emerald-500 tw:to-cyan-500 tw:bg-clip-text tw:text-transparent">
{{ currentVersion }}
</span>
</div>
</div>
<div class="tw:flex tw:flex-col tw:items-center tw:gap-2 tw:text-gray-500 tw:text-sm">
<div class="tw:flex tw:items-center tw:gap-2">
<span>{{ t('about.build_time') }}</span>
<span class="tw:font-medium">{{ buildTime }}</span>
</div>
<div class="tw:flex tw:items-center tw:gap-2">
<span>{{ t('about.author') }}</span>
<span class="tw:font-medium">{{ author }}</span>
</div>
</div>
</div>
</div>
<div class="tw:bg-white tw:rounded-2xl tw:shadow-lg tw:p-8 tw:animate-fade-in-up">
<h2 class="tw:text-xl tw:font-bold tw:text-gray-800 tw:text-center tw:mb-8 tw:flex tw:items-center tw:justify-center tw:gap-3">
<span class="tw:text-2xl"></span>
<span>{{ t('about.development_team') }}</span>
</h2>
<div class="tw:grid tw:grid-cols-2 md:tw:grid-cols-3 lg:tw:grid-cols-5 tw:gap-6 tw:justify-items-center">
<div
v-for="item in thanksList"
:key="item.id"
class="tw:flex tw:flex-col tw:items-center tw:w-36 tw:p-5 tw:bg-gradient-to-br tw:from-white tw:to-gray-50 tw:rounded-2xl tw:shadow-sm tw:transition-all duration-300 hover:shadow-xl hover:-translate-y-2 tw:border tw:border-gray-100 tw:group"
>
<div class="tw:w-20 tw:h-20 tw:bg-gradient-to-br tw:from-emerald-100 tw:to-cyan-100 tw:rounded-full tw:overflow-hidden tw:flex tw:items-center tw:justify-center tw:mb-4 tw:ring-2 tw:ring-emerald-200 tw:ring-offset-2 tw:group-hover:tw:ring-emerald-400 tw:transition-all duration-300">
<img class="tw:w-full tw:h-full tw:object-cover" :src="item.avatar" :alt="item.name">
</div>
<h3 class="tw:text-sm tw:font-bold tw:text-gray-800 tw:group-hover:tw:text-emerald-600 tw:transition-colors">{{ item.name }}</h3>
<p class="tw:text-xs tw:text-gray-500 tw:mt-2">{{ item.contribution }}</p>
<a-button
v-if="item.bilibiliUrl"
type="link"
size="small"
class="tw:text-xs tw:mt-3 tw:px-3 tw:py-1 tw:rounded-full tw:bg-gradient-to-r tw:from-pink-100 tw:to-pink-200 tw:text-pink-600 tw:hover:tw:from-pink-200 tw:hover:tw:to-pink-300 tw:transition-all"
@click="openBilibili(item.bilibiliUrl)"
>
B站
</a-button>
</div>
</div>
</div>
<div class="tw:tw:py-6">
<div class="tw:w-full tw:h-px tw:bg-gradient-to-r tw:from-transparent tw:via-gray-300 tw:to-transparent"></div>
</div>
<div class="tw:bg-white tw:rounded-2xl tw:shadow-lg tw:p-8 tw:animate-fade-in-up tw:delay-100">
<h1 class="tw:text-xl tw:text-center tw:font-bold tw:bg-gradient-to-r tw:from-amber-500 tw:to-orange-500 tw:bg-clip-text tw:text-transparent tw:mb-8 tw:flex tw:items-center tw:justify-center tw:gap-3">
<span class="tw:text-2xl">💎</span>
<span>{{ t('about.sponsor') }}</span>
</h1>
<div class="tw:flex tw:flex-wrap tw:justify-center tw:gap-6">
<div
v-for="sponsor in sponsors"
:key="sponsor.id"
class="tw:flex tw:flex-col tw:items-center tw:w-44 tw:p-5 tw:bg-gradient-to-br tw:from-amber-50 tw:to-orange-50 tw:rounded-2xl tw:shadow-md tw:cursor-pointer tw:hover:shadow-2xl tw:hover:-translate-y-2 tw:transition-all duration-300 tw:group tw:border tw:border-amber-100"
@click="contant(sponsor)"
>
<div class="tw:w-24 tw:h-24 tw:flex tw:items-center tw:justify-center tw:bg-gradient-to-br tw:from-white tw:to-amber-100 tw:rounded-2xl tw:p-3 tw:mb-4 tw:group-hover:tw:scale-110 tw:transition-transform duration-300">
<img class="tw:max-w-full tw:max-h-full tw:object-contain" :src="sponsor.imageUrl" :alt="sponsor.name">
</div>
<h2 class="tw:text-base tw:font-bold tw:text-gray-800 tw:group-hover:tw:text-amber-600 tw:transition-colors">{{ sponsor.name }}</h2>
<span class="tw:text-xs tw:text-amber-600 tw:bg-amber-100 tw:px-3 tw:py-1 tw:rounded-full tw:mt-3 tw:font-medium">
{{ sponsor.type }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.tw-animate-fade-in {
animation: fadeIn 0.6s ease-out;
}
.tw-animate-fade-in-up {
animation: fadeInUp 0.6s ease-out;
}
.delay-100 {
animation-delay: 0.1s;
}
</style>

View File

@@ -0,0 +1,175 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { message } from 'ant-design-vue';
import { FileSearchOutlined, FolderOpenOutlined } from '@ant-design/icons-vue';
import { open } from '@tauri-apps/plugin-dialog';
interface ModCheckResult {
filename: string;
filePath: string;
clientSide: 'required' | 'optional' | 'unsupported' | 'unknown';
serverSide: 'required' | 'optional' | 'unsupported' | 'unknown';
source: string;
checked: boolean;
errors?: string[];
allResults: any[];
}
const selectedFolder = ref<string>('');
const bundleName = ref<string>('');
const checking = ref(false);
const results = ref<ModCheckResult[]>([]);
const showResults = ref(false);
async function selectFolder() {
try {
const selected = await open({
directory: true,
multiple: false,
title: '选择 mods 文件夹'
});
if (selected) {
selectedFolder.value = selected;
message.success(`已选择文件夹: ${selected}`);
}
} catch (error) {
console.error('选择文件夹失败:', error);
message.error('选择文件夹失败');
}
}
async function handleCheck() {
if (!selectedFolder.value) {
message.warning('请先选择 mods 文件夹');
return;
}
if (!bundleName.value.trim()) {
message.warning('请输入整合包名字');
return;
}
checking.value = true;
showResults.value = false;
results.value = [];
try {
const response = await fetch('http://localhost:37019/modcheck/folder', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
folderPath: selectedFolder.value,
bundleName: bundleName.value.trim()
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `请求失败: ${response.status}`);
}
const data = await response.json();
results.value = data;
showResults.value = true;
message.success(`检查完成,共检查 ${data.length} 个模组`);
} catch (error: any) {
console.error('检查失败:', error);
message.error(`检查失败: ${error.message}`);
} finally {
checking.value = false;
}
}
</script>
<template>
<div class="tw:h-full tw:w-full tw:flex tw:flex-col tw:p-4 md:tw:p-6 tw:overflow-auto">
<div class="tw:mb-4 md:tw:mb-6">
<h1 class="tw:text-xl md:tw:text-2xl tw:font-bold tw:mb-2">模组检查</h1>
<p class="tw:text-gray-500 tw:text-sm md:tw:text-base">检查模组是否可以在客户端或服务端使用</p>
</div>
<a-card class="tw:mb-4 md:tw:mb-6">
<div class="tw:mb-4">
<a-button
type="default"
size="large"
block
@click="selectFolder"
class="tw:mb-4"
>
<template #icon>
<FolderOpenOutlined />
</template>
选择 mods 文件夹
</a-button>
<div v-if="selectedFolder" class="tw:mb-4 tw:p-3 tw:bg-gray-50 tw:rounded tw:text-sm tw:text-gray-600">
<span class="tw:font-medium">已选择:</span> {{ selectedFolder }}
</div>
</div>
<div class="tw:mb-4">
<a-input
v-model:value="bundleName"
placeholder="请输入整合包名字"
size="large"
allow-clear
/>
<div class="tw:mt-2 tw:text-xs tw:text-gray-400">
客户端模组将保存到 .rubbish/{{ bundleName || '整合包名字' }} 目录
</div>
</div>
<a-button
type="primary"
size="large"
:loading="checking"
block
@click="handleCheck"
:disabled="checking || !selectedFolder || !bundleName.trim()"
>
<template #icon>
<FileSearchOutlined />
</template>
{{ checking ? '检查中...' : '开始检查' }}
</a-button>
</a-card>
<a-card v-if="showResults" title="检查结果">
<div class="tw:overflow-x-auto">
<a-table
:dataSource="results"
:pagination="false"
:scroll="{ y: 300, x: 'max-content' }"
size="small"
:bordered="true"
>
<a-table-column title="模组信息" key="modInfo" :width="250">
<template #default="{ record }">
<div class="tw:overflow-hidden tw:flex-1 tw:min-w-0">
<div class="tw:font-medium tw:truncate tw:text-sm md:tw:text-base">{{ record.filename }}</div>
</div>
</template>
</a-table-column>
<a-table-column title="类型" key="type" :width="100">
<template #default="{ record }">
<a-tag v-if="record.clientSide === 'required' || record.clientSide === 'optional'" color="purple">
客户端模组
</a-tag>
<a-tag v-else-if="record.serverSide === 'required' || record.serverSide === 'optional'" color="blue">
服务端模组
</a-tag>
<a-tag v-else color="gray">
未知
</a-tag>
</template>
</a-table-column>
</a-table>
</div>
</a-card>
<a-empty v-if="showResults && results.length === 0" description="未找到模组文件" />
</div>
</template>

View File

@@ -0,0 +1,22 @@
<template>
<div class="tw:h-full tw:w-full tw:flex tw:flex-col tw:justify-center tw:items-center">
<div class="tw:w-32 tw:h-32 tw:mb-25">
<svg class="w-32 h-32 mb-4" viewBox="0 0 120 120">
<circle cx="60" cy="60" r="50" fill="#ef4444" />
<path d="M40,40 L80,80 M80,40 L40,80" stroke="white" stroke-width="10" stroke-linecap="round" />
</svg>
<p class="tw:text-2xl tw:font-bold tw:text-center tw:mb-20 tw:text-red-500">Error</p>
<p class="tw:text-sm tw:text-center tw:text-gray-500">
{{ errorMessage }}
</p>
</div>
</div>
</template>
<script lang="ts" setup>
import { useRoute } from 'vue-router';
const route = useRoute();
const errorReason = route.query.e as string;
const errorMessage = errorReason ? `错误原因:${errorReason}` : 'DeEarthX.Core 启动失败!';
</script>

View File

@@ -0,0 +1,270 @@
<template>
<div class="tw:h-full tw:w-full tw:p-4 tw:overflow-auto tw:bg-gray-50">
<div class="tw:max-w-2xl tw:mx-auto">
<div class="tw:text-center tw:mb-8">
<h1 class="tw:text-2xl tw:font-bold tw:tracking-tight">
<span
class="tw:bg-gradient-to-r tw:from-cyan-300 tw:to-purple-950 tw:bg-clip-text tw:text-transparent">
{{ t('galaxy.title') }}
</span>
</h1>
<p class="tw:text-gray-500 tw:mt-2">{{ t('galaxy.subtitle') }}</p>
</div>
<div class="tw:bg-white tw:rounded-lg tw:shadow-sm tw:p-6 tw:mb-6">
<h2 class="tw:text-lg tw:font-semibold tw:text-gray-800 tw:mb-4 tw:flex tw:items-center tw:gap-2">
<span class="tw:w-2 tw:h-2 tw:bg-purple-500 tw:rounded-full"></span>
{{ t('galaxy.mod_submit_title') }}
</h2>
<div class="tw:flex tw:flex-col tw:gap-4">
<div>
<label class="tw:block tw:text-sm tw:font-medium tw:text-gray-700 tw:mb-2">
{{ t('galaxy.mod_type_label') }}
</label>
<a-radio-group v-model:value="modType" size="default" button-style="solid">
<a-radio-button value="client">{{ t('galaxy.mod_type_client') }}</a-radio-button>
<a-radio-button value="server">{{ t('galaxy.mod_type_server') }}</a-radio-button>
</a-radio-group>
</div>
<div>
<label class="tw:block tw:text-sm tw:font-medium tw:text-gray-700 tw:mb-2">
{{ t('galaxy.modid_label') }}
</label>
<a-input
v-model:value="modidInput"
:placeholder="t('galaxy.modid_placeholder')"
size="large"
allow-clear
/>
<p class="tw:text-xs tw:text-gray-400 tw:mt-1">
{{ t('galaxy.modid_count', { count: modidList.length }) }}
</p>
</div>
<div>
<label class="tw:block tw:text-sm tw:font-medium tw:text-gray-700 tw:mb-2">
{{ t('galaxy.upload_file_label') }}
</label>
<a-upload-dragger
:fileList="fileList"
:before-upload="beforeUpload"
@remove="handleRemove"
accept=".jar"
multiple
>
<p class="tw-ant-upload-drag-icon">
<InboxOutlined />
</p>
<p class="tw-ant-upload-text">{{ t('galaxy.upload_file_hint') }}</p>
<p class="tw-ant-upload-hint">
{{ t('galaxy.upload_file_support') }}
</p>
</a-upload-dragger>
<div v-if="fileList.length > 0" class="tw:mt-4">
<p class="tw:text-sm tw:font-medium tw:text-gray-700 tw:mb-2">
{{ t('galaxy.file_selected', { count: fileList.length }) }}
</p>
<div v-if="uploading" class="tw:mb-4">
<a-progress :percent="uploadProgress" :status="uploadProgress === 100 ? 'success' : 'active'" />
</div>
<a-button
type="primary"
size="large"
:loading="uploading"
block
@click="handleUpload"
>
<template #icon>
<UploadOutlined />
</template>
{{ uploading ? t('galaxy.uploading') : t('galaxy.start_upload') }}
</a-button>
</div>
</div>
<div v-if="modidList.length > 0" class="tw:mt-2">
<a-button
type="primary"
size="large"
:loading="submitting"
block
@click="handleSubmit"
>
<template #icon>
<SendOutlined />
</template>
{{ submitting ? t('galaxy.submitting') : t('galaxy.submit', { type: modType === 'client' ? t('galaxy.mod_type_client') : t('galaxy.mod_type_server') }) }}
</a-button>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { UploadOutlined, InboxOutlined, SendOutlined } from '@ant-design/icons-vue';
import { message, Modal } from 'ant-design-vue';
import type { UploadFile, UploadProps } from 'ant-design-vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const modType = ref<'client' | 'server'>('client');
const modidList = ref<string[]>([]);
const uploading = ref(false);
const submitting = ref(false);
const fileList = ref<UploadFile[]>([]);
const uploadProgress = ref(0);
const modidInput = computed({
get: () => modidList.value.join(','),
set: (value: string) => {
modidList.value = value
.split(',')
.map(id => id.trim())
.filter(id => id.length > 0);
}
});
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
console.log(file.name);
const uploadFile: UploadFile = {
uid: `${Date.now()}-${Math.random()}`,
name: file.name,
status: 'done',
url: '',
originFileObj: file,
};
fileList.value = [...fileList.value, uploadFile];
return false;
};
const handleRemove: UploadProps['onRemove'] = (file) => {
const index = fileList.value.indexOf(file);
const newFileList = fileList.value.slice();
newFileList.splice(index, 1);
fileList.value = newFileList;
};
const handleUpload = async () => {
if (fileList.value.length === 0) {
message.warning(t('galaxy.please_select_file'));
return;
}
uploading.value = true;
uploadProgress.value = 0;
const formData = new FormData();
fileList.value.forEach((file) => {
if (file.originFileObj) {
const blob = file.originFileObj;
const encodedFileName = encodeURIComponent(file.name);
const fileWithCorrectName = new File([blob], encodedFileName, { type: blob.type });
formData.append('files', fileWithCorrectName);
}
});
try {
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', 'http://localhost:37019/galaxy/upload', true);
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
uploadProgress.value = Math.round((event.loaded / event.total) * 100);
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const data = JSON.parse(xhr.responseText);
console.log(data);
if (data.modids && Array.isArray(data.modids)) {
let addedCount = 0;
data.modids.forEach((modid: string) => {
if (modid && !modidList.value.includes(modid)) {
modidList.value.push(modid);
addedCount++;
}
});
message.success(t('galaxy.upload_success', { count: addedCount }));
} else {
message.error(t('galaxy.data_format_error'));
}
resolve(xhr.responseText);
} catch (e) {
message.error(t('galaxy.data_format_error'));
reject(e);
}
} else {
message.error(t('galaxy.upload_failed'));
reject(new Error(`HTTP ${xhr.status}`));
}
});
xhr.addEventListener('error', () => {
message.error(t('galaxy.upload_error'));
reject(new Error('网络错误'));
});
xhr.addEventListener('abort', () => {
message.error(t('galaxy.upload_error'));
reject(new Error('上传已取消'));
});
xhr.send(formData);
});
} catch (error) {
console.error('上传失败:', error);
} finally {
uploading.value = false;
fileList.value = [];
uploadProgress.value = 0;
}
};
const handleSubmit = () => {
const modTypeText = modType.value === 'client' ? t('galaxy.mod_type_client') : t('galaxy.mod_type_server');
Modal.confirm({
title: t('galaxy.submit_confirm_title'),
content: t('galaxy.submit_confirm_content', { count: modidList.value.length, type: modTypeText }),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
onOk: async () => {
submitting.value = true;
try {
const apiUrl = modType.value === 'client'
? 'http://localhost:37019/galaxy/submit/client'
: 'http://localhost:37019/galaxy/submit/server';
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
modids: modidList.value,
}),
});
if (response.ok) {
message.success(t('galaxy.submit_success', { type: modTypeText }));
modidList.value = [];
} else {
message.error(t('galaxy.submit_failed'));
}
} catch (error) {
message.error(t('galaxy.submit_error'));
} finally {
submitting.value = false;
}
},
});
};
</script>

812
front/src/views/Main.vue Normal file
View File

@@ -0,0 +1,812 @@
<script lang="ts" setup>
import { inject, ref, onMounted, computed } from 'vue';
import { InboxOutlined } from '@ant-design/icons-vue';
import { message, notification, StepsProps } from 'ant-design-vue';
import type { UploadFile, UploadChangeParam } from 'ant-design-vue';
import { sendNotification } from '@tauri-apps/plugin-notification';
import { SelectProps } from 'ant-design-vue/es/vc-select';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
interface Template {
id: string;
metadata: {
name: string;
version: string;
description: string;
author: string;
created: string;
type: string;
};
}
// 进度步骤配置
const showSteps = ref(false);
const currentStep = ref(0);
// 模板选择相关
const showTemplateModal = ref(false);
const templates = ref<Template[]>([]);
const loadingTemplates = ref(false);
const selectedTemplate = ref<string>('0');
// 步骤项使用computed自动响应语言变化
const stepItems = computed<Required<StepsProps>['items']>(() => {
return [
{ title: t('home.step1_title'), description: t('home.step1_desc') },
{ title: t('home.step2_title'), description: t('home.step2_desc') },
{ title: t('home.step3_title'), description: t('home.step3_desc') },
{ title: t('home.step4_title'), description: t('home.step4_desc') }
];
});
// 文件上传相关
const uploadedFiles = ref<UploadFile[]>([]);
const uploadDisabled = ref(false);
const startButtonDisabled = ref(false);
// 阻止默认上传行为
function beforeUpload() {
return false;
}
// 处理文件上传变更
function handleFileChange(info: UploadChangeParam) {
if (info.file.status === 'removed') {
uploadDisabled.value = false;
return;
}
if (info.file.status === 'uploading') {
message.loading(t('home.preparing_file'));
return;
}
if (info.file.status === 'done') {
message.success(t('home.file_prepared'));
}
if (!info.file.name?.endsWith('.zip') && !info.file.name?.endsWith('.mrpack')) {
message.error(t('home.only_zip_mrpack'));
return;
}
uploadDisabled.value = true;
}
// 处理文件拖拽(预留功能)
function handleFileDrop(e: DragEvent) {
console.log(e);
}
// 初始化
onMounted(() => {
// stepItems 和 modeOptions 都是 computed会自动初始化
});
// 重置所有状态
function resetState() {
uploadedFiles.value = [];
uploadDisabled.value = false;
startButtonDisabled.value = false;
showSteps.value = false;
currentStep.value = 0;
unzipProgress.value = { status: 'active', percent: 0, display: true };
downloadProgress.value = { status: 'active', percent: 0, display: true };
const killCoreProcess = inject("killCoreProcess");
if (killCoreProcess && typeof killCoreProcess === 'function') {
killCoreProcess();
}
}
// 模式选择相关
const javaAvailable = ref(true);
const selectedMode = ref(javaAvailable.value ? 'server' : 'upload');
// 模式选项使用computed自动响应语言变化
const modeOptions = computed<SelectProps['options']>(() => {
return [
{ label: t('home.mode_server'), value: 'server', disabled: !javaAvailable.value },
{ label: t('home.mode_upload'), value: 'upload', disabled: false }
];
});
// 处理模式选择
function handleModeSelect(value: string) {
selectedMode.value = value;
}
// 加载模板列表
async function loadTemplates() {
loadingTemplates.value = true;
try {
const apiHost = import.meta.env.VITE_API_HOST || 'localhost';
const apiPort = import.meta.env.VITE_API_PORT || '37019';
const response = await fetch(`http://${apiHost}:${apiPort}/templates`);
const result = await response.json();
if (result.status === 200) {
templates.value = result.data || [];
} else {
message.error(t('home.template_load_failed'));
}
} catch (error) {
console.error('加载模板列表失败:', error);
message.error(t('home.template_load_failed'));
} finally {
loadingTemplates.value = false;
}
}
// 打开模板选择弹窗
function openTemplateModal() {
loadTemplates();
showTemplateModal.value = true;
}
// 选择模板
function selectTemplate(templateId: string) {
selectedTemplate.value = templateId;
showTemplateModal.value = false;
if (templateId === '0') {
message.success(t('home.template_selected') + ': ' + t('home.template_official_loader'));
} else {
const template = templates.value.find(t => t.id === templateId);
if (template) {
message.success(t('home.template_selected') + ': ' + template.metadata.name);
}
}
}
// 获取当前选择的模板名称
const currentTemplateName = computed(() => {
if (selectedTemplate.value === '0' || !selectedTemplate.value) {
return t('home.template_official_loader');
}
const template = templates.value.find(t => t.id === selectedTemplate.value);
return template ? template.metadata.name : t('home.template_official_loader');
});
// 进度显示相关
interface ProgressStatus {
status: 'active' | 'success' | 'exception' | 'normal';
percent: number;
display: boolean;
uploadedSize?: number;
totalSize?: number;
speed?: number;
remainingTime?: number;
}
const unzipProgress = ref<ProgressStatus>({ status: 'active', percent: 0, display: true });
const downloadProgress = ref<ProgressStatus>({ status: 'active', percent: 0, display: true });
const uploadProgress = ref<ProgressStatus>({ status: 'active', percent: 0, display: false });
const serverInstallProgress = ref<ProgressStatus>({ status: 'active', percent: 0, display: false });
const filterModsProgress = ref<ProgressStatus>({ status: 'active', percent: 0, display: false });
const startTime = ref<number>(0);
const serverInstallInfo = ref({
modpackName: '',
minecraftVersion: '',
loaderType: '',
loaderVersion: '',
currentStep: '',
stepIndex: 0,
totalSteps: 0,
message: '',
status: 'idle' as 'idle' | 'installing' | 'completed' | 'error',
error: '',
installPath: '',
duration: 0
});
const filterModsInfo = ref({
totalMods: 0,
currentMod: 0,
modName: '',
filteredCount: 0,
movedCount: 0,
status: 'idle' as 'idle' | 'filtering' | 'completed' | 'error',
error: '',
duration: 0
});
// 格式化文件大小
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
// 格式化时间
function formatTime(seconds: number): string {
if (seconds < 60) return `${Math.round(seconds)}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
}
// 运行DeEarthX核心功能
async function runDeEarthX(file: File, ws: WebSocket) {
message.success(t('home.start_production'));
showSteps.value = true;
const formData = new FormData();
formData.append('file', file);
try {
message.loading(t('home.task_preparing'));
const apiHost = import.meta.env.VITE_API_HOST || 'localhost';
const apiPort = import.meta.env.VITE_API_PORT || '37019';
let url = `http://${apiHost}:${apiPort}/start?mode=${selectedMode.value}`;
if (selectedMode.value === 'server' && selectedTemplate.value) {
url += `&template=${encodeURIComponent(selectedTemplate.value)}`;
}
uploadProgress.value = { status: 'active', percent: 0, display: true };
startTime.value = Date.now();
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100);
uploadProgress.value.percent = percent;
uploadProgress.value.uploadedSize = event.loaded;
uploadProgress.value.totalSize = event.total;
// 计算上传速度
const elapsedTime = (Date.now() - startTime.value) / 1000;
if (elapsedTime > 0) {
uploadProgress.value.speed = event.loaded / elapsedTime;
// 计算剩余时间
const remainingBytes = event.total - event.loaded;
uploadProgress.value.remainingTime = remainingBytes / uploadProgress.value.speed;
}
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
uploadProgress.value.status = 'success';
uploadProgress.value.percent = 100;
setTimeout(() => {
uploadProgress.value.display = false;
}, 2000);
resolve(xhr.response);
} else {
uploadProgress.value.status = 'exception';
reject(new Error(`HTTP ${xhr.status}`));
}
});
xhr.addEventListener('error', () => {
uploadProgress.value.status = 'exception';
reject(new Error('网络错误'));
});
xhr.addEventListener('abort', () => {
uploadProgress.value.status = 'exception';
reject(new Error('上传已取消'));
});
xhr.send(formData);
});
} catch (error) {
console.error('请求失败:', error);
message.error(t('home.request_failed'));
uploadProgress.value.status = 'exception';
resetState();
ws.close();
}
}
// 设置WebSocket连接
// 处理错误消息
function handleError(result: any) {
if (result === 'jini') {
javaAvailable.value = false;
notification.error({
message: t('home.java_error_title'),
description: t('home.java_error_desc'),
duration: 0
});
} else if (typeof result === 'string') {
// 根据错误类型提供不同的解决方案
let errorTitle = t('home.backend_error');
let errorDesc = t('home.backend_error_desc', { error: result });
let suggestions: string[] = [];
// 网络相关错误
if (result.includes('network') || result.includes('connection') || result.includes('timeout')) {
errorTitle = t('home.network_error_title');
errorDesc = t('home.network_error_desc', { error: result });
suggestions = [
t('home.suggestion_check_network'),
t('home.suggestion_check_firewall'),
t('home.suggestion_retry')
];
}
// 文件相关错误
else if (result.includes('file') || result.includes('permission') || result.includes('disk')) {
errorTitle = t('home.file_error_title');
errorDesc = t('home.file_error_desc', { error: result });
suggestions = [
t('home.suggestion_check_disk_space'),
t('home.suggestion_check_permission'),
t('home.suggestion_check_file_format')
];
}
// 内存相关错误
else if (result.includes('memory') || result.includes('out of memory') || result.includes('heap')) {
errorTitle = t('home.memory_error_title');
errorDesc = t('home.memory_error_desc', { error: result });
suggestions = [
t('home.suggestion_increase_memory'),
t('home.suggestion_close_other_apps'),
t('home.suggestion_restart_application')
];
}
// 通用错误
else {
suggestions = [
t('home.suggestion_check_backend'),
t('home.suggestion_check_logs'),
t('home.suggestion_contact_support')
];
}
// 构建完整的错误描述
const fullDescription = `${errorDesc}\n\n${t('home.suggestions')}:\n${suggestions.map((s, i) => `${i + 1}. ${s}`).join('\n')}`;
notification.error({
message: errorTitle,
description: fullDescription,
duration: 0
});
resetState();
} else {
notification.error({
message: t('home.unknown_error_title'),
description: t('home.unknown_error_desc'),
duration: 0
});
resetState();
}
}
// 更新解压进度
function updateUnzipProgress(result: { current: number; total: number }) {
unzipProgress.value.percent = Math.round((result.current / result.total) * 100);
if (result.current === result.total) {
unzipProgress.value.status = 'success';
setTimeout(() => {
unzipProgress.value.display = false;
}, 2000);
}
}
// 更新下载进度
function updateDownloadProgress(result: { index: number; total: number }) {
downloadProgress.value.percent = Math.round((result.index / result.total) * 100);
if (downloadProgress.value.percent === 100) {
downloadProgress.value.status = 'success';
setTimeout(() => {
downloadProgress.value.display = false;
}, 2000);
}
}
// 处理完成状态
function handleFinish(result: number) {
const timeSpent = Math.round(result / 1000);
currentStep.value++;
message.success(t('home.production_complete', { time: timeSpent }));
sendNotification({ title: t('common.app_name'), body: t('home.production_complete', { time: timeSpent }) });
// 8秒后自动重置状态
setTimeout(resetState, 8000);
}
// 处理服务端安装开始
function handleServerInstallStart(result: any) {
serverInstallInfo.value = {
modpackName: result.modpackName,
minecraftVersion: result.minecraftVersion,
loaderType: result.loaderType,
loaderVersion: result.loaderVersion,
currentStep: '',
stepIndex: 0,
totalSteps: 0,
message: 'Starting installation...',
status: 'installing',
error: '',
installPath: '',
duration: 0
};
serverInstallProgress.value = { status: 'active', percent: 0, display: true };
}
// 处理服务端安装步骤
function handleServerInstallStep(result: any) {
serverInstallInfo.value.currentStep = result.step;
serverInstallInfo.value.stepIndex = result.stepIndex;
serverInstallInfo.value.totalSteps = result.totalSteps;
serverInstallInfo.value.message = result.message || result.step;
// 计算总体进度
const overallProgress = (result.stepIndex / result.totalSteps) * 100;
serverInstallProgress.value.percent = Math.round(overallProgress);
}
// 处理服务端安装进度
function handleServerInstallProgress(result: any) {
serverInstallInfo.value.currentStep = result.step;
serverInstallInfo.value.message = result.message || result.step;
serverInstallProgress.value.percent = result.progress;
}
// 处理服务端安装完成
function handleServerInstallComplete(result: any) {
serverInstallInfo.value.status = 'completed';
serverInstallInfo.value.installPath = result.installPath;
serverInstallInfo.value.duration = result.duration;
serverInstallInfo.value.message = t('home.server_install_completed');
serverInstallProgress.value = { status: 'success', percent: 100, display: true };
// 跳转到完成步骤
currentStep.value++;
const timeSpent = Math.round(result.duration / 1000);
message.success(t('home.server_install_completed') + ` ${t('home.server_install_duration')}: ${timeSpent}s`);
sendNotification({ title: t('common.app_name'), body: t('home.production_complete', { time: timeSpent }) });
// 8秒后隐藏进度
setTimeout(() => {
serverInstallProgress.value.display = false;
}, 8000);
}
// 处理服务端安装错误
function handleServerInstallError(result: any) {
serverInstallInfo.value.status = 'error';
serverInstallInfo.value.error = result.error;
serverInstallInfo.value.message = result.error;
serverInstallProgress.value = { status: 'exception', percent: serverInstallProgress.value.percent, display: true };
notification.error({
message: t('home.server_install_error'),
description: result.error,
duration: 0
});
}
// 处理筛选模组开始
function handleFilterModsStart(result: any) {
filterModsInfo.value = {
totalMods: result.totalMods,
currentMod: 0,
modName: '',
filteredCount: 0,
movedCount: 0,
status: 'filtering',
error: '',
duration: 0
};
filterModsProgress.value = { status: 'active', percent: 0, display: true };
}
// 处理筛选模组进度
function handleFilterModsProgress(result: any) {
filterModsInfo.value.currentMod = result.current;
filterModsInfo.value.modName = result.modName;
const percent = Math.round((result.current / result.total) * 100);
filterModsProgress.value.percent = percent;
}
// 处理筛选模组完成
function handleFilterModsComplete(result: any) {
filterModsInfo.value.status = 'completed';
filterModsInfo.value.filteredCount = result.filteredCount;
filterModsInfo.value.movedCount = result.movedCount;
filterModsInfo.value.duration = result.duration;
filterModsProgress.value = { status: 'success', percent: 100, display: true };
const timeSpent = Math.round(result.duration / 1000);
message.success(t('home.filter_mods_completed', { filtered: result.filteredCount, moved: result.movedCount }) + ` ${t('home.server_install_duration')}: ${timeSpent}s`);
// 8秒后隐藏进度
setTimeout(() => {
filterModsProgress.value.display = false;
}, 8000);
}
// 处理筛选模组错误
function handleFilterModsError(result: any) {
filterModsInfo.value.status = 'error';
filterModsInfo.value.error = result.error;
filterModsProgress.value = { status: 'exception', percent: filterModsProgress.value.percent, display: true };
notification.error({
message: t('home.filter_mods_error'),
description: result.error,
duration: 0
});
}
// 开始处理文件
function handleStartProcess() {
if (uploadedFiles.value.length === 0) {
message.warning(t('home.please_select_file'));
return;
}
const file = uploadedFiles.value[0].originFileObj;
if (!file) return;
startButtonDisabled.value = true;
uploadDisabled.value = true;
showSteps.value = true;
message.loading(t('home.ws_connecting'));
const wsHost = import.meta.env.VITE_WS_HOST || 'localhost';
const wsPort = import.meta.env.VITE_WS_PORT || '37019';
const ws = new WebSocket(`ws://${wsHost}:${wsPort}/`);
ws.addEventListener('open', () => {
message.success(t('home.ws_connected'));
runDeEarthX(file, ws);
});
ws.addEventListener('message', (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'error') {
handleError(data.message);
} else if (data.type === 'info') {
message.info(data.message);
} else if (data.status) {
switch (data.status) {
case 'error':
handleError(data.result);
break;
case 'changed':
currentStep.value++;
break;
case 'unzip':
updateUnzipProgress(data.result);
break;
case 'downloading':
updateDownloadProgress(data.result);
break;
case 'finish':
handleFinish(data.result);
break;
case 'server_install_start':
handleServerInstallStart(data.result);
break;
case 'server_install_step':
handleServerInstallStep(data.result);
break;
case 'server_install_progress':
handleServerInstallProgress(data.result);
break;
case 'server_install_complete':
handleServerInstallComplete(data.result);
break;
case 'server_install_error':
handleServerInstallError(data.result);
break;
case 'filter_mods_start':
handleFilterModsStart(data.result);
break;
case 'filter_mods_progress':
handleFilterModsProgress(data.result);
break;
case 'filter_mods_complete':
handleFilterModsComplete(data.result);
break;
case 'filter_mods_error':
handleFilterModsError(data.result);
break;
}
}
} catch (error) {
console.error('解析WebSocket消息失败:', error);
notification.error({ message: t('common.error'), description: t('home.parse_error') });
}
});
ws.addEventListener('error', () => {
notification.error({
message: t('home.ws_error_title'),
description: `${t('home.ws_error_desc')}\n\n${t('home.suggestions')}:\n1. ${t('home.suggestion_check_backend')}\n2. ${t('home.suggestion_check_port')}\n3. ${t('home.suggestion_restart_application')}`,
duration: 0
});
resetState();
});
ws.addEventListener('close', () => {
console.log('WebSocket连接关闭');
});
}
</script>
<template>
<div class="tw:h-full tw:w-full tw:relative tw:flex tw:flex-col">
<div class="tw:flex-1 tw:w-full tw:flex tw:flex-col tw:justify-center tw:items-center tw:p-4">
<div class="tw:w-full tw:max-w-2xl tw:flex tw:flex-col tw:items-center">
<div>
<h1 class="tw:text-4xl tw:text-center tw:animate-pulse">{{ t('common.app_name') }}</h1>
<h1 class="tw:text-sm tw:text-gray-500 tw:text-center">{{ t('home.title') }}</h1>
</div>
<a-upload-dragger :disabled="uploadDisabled" class="tw:w-full tw:max-w-md tw:h-48" name="file"
action="/" :multiple="false" :before-upload="beforeUpload" @change="handleFileChange"
@drop="handleFileDrop" v-model:fileList="uploadedFiles" accept=".zip,.mrpack">
<p class="ant-upload-drag-icon">
<inbox-outlined></inbox-outlined>
</p>
<p class="ant-upload-text">{{ t('home.upload_title') }}</p>
<p class="ant-upload-hint">
{{ t('home.upload_hint') }}
</p>
</a-upload-dragger>
<div class="tw:flex tw:items-center tw:gap-2 tw:mt-8">
<a-select ref="select" :options="modeOptions" :value="selectedMode"
style="width: 120px;" @select="handleModeSelect"></a-select>
<a-button v-if="selectedMode === 'server'" @click="openTemplateModal">
{{ t('home.template_select_button') }}
</a-button>
</div>
<div v-if="selectedMode === 'server'" class="tw:text-xs tw:text-gray-500 tw:mt-2">
{{ t('home.template_selected') }}: {{ currentTemplateName }}
</div>
<a-button :disabled="startButtonDisabled" type="primary" @click="handleStartProcess"
style="margin-top: 6px">
{{ t('common.start') }}
</a-button>
</div>
</div>
<div v-if="showSteps"
class="tw:fixed tw:bottom-2 tw:left-1/2 tw:-translate-x-1/2 tw:w-[65%] tw:h-20 tw:flex tw:justify-center tw:items-center tw:text-sm tw:bg-white tw:rounded-xl tw:shadow-lg tw:px-4 tw:ml-10">
<a-steps :current="currentStep" :items="stepItems" size="small" />
</div>
<div v-if="showSteps" ref="logContainer"
class="tw:absolute tw:right-2 tw:bottom-32 tw:h-80 tw:w-64 tw:rounded-xl tw:overflow-y-auto">
<a-card :title="t('home.progress_title')" :bordered="true" class="tw:h-full">
<div v-if="uploadProgress.display" class="tw:mb-4">
<h1 class="tw:text-sm">{{ t('home.upload_progress') }}</h1>
<a-progress :percent="uploadProgress.percent" :status="uploadProgress.status" size="small" />
<div v-if="uploadProgress.totalSize" class="tw:text-xs tw:text-gray-500 tw:mt-1">
{{ formatFileSize(uploadProgress.uploadedSize || 0) }} / {{ formatFileSize(uploadProgress.totalSize) }}
<span v-if="uploadProgress.speed" class="tw:ml-2">
{{ t('home.speed') }}: {{ formatFileSize(uploadProgress.speed) }}/s
</span>
<span v-if="uploadProgress.remainingTime" class="tw:ml-2">
{{ t('home.remaining') }}: {{ formatTime(uploadProgress.remainingTime) }}
</span>
</div>
</div>
<div v-if="unzipProgress.display" class="tw:mb-4">
<h1 class="tw:text-sm">{{ t('home.unzip_progress') }}</h1>
<a-progress :percent="unzipProgress.percent" :status="unzipProgress.status" size="small" />
</div>
<div v-if="downloadProgress.display" class="tw:mb-4">
<h1 class="tw:text-sm">{{ t('home.download_progress') }}</h1>
<a-progress :percent="downloadProgress.percent" :status="downloadProgress.status" size="small" />
</div>
<div v-if="serverInstallProgress.display" class="tw:mb-4">
<h1 class="tw:text-sm">{{ t('home.server_install_progress') }}</h1>
<a-progress :percent="serverInstallProgress.percent" :status="serverInstallProgress.status" size="small" />
<div v-if="serverInstallInfo.currentStep" class="tw:text-xs tw:text-gray-500 tw:mt-1">
{{ t('home.server_install_step') }}: {{ serverInstallInfo.currentStep }}
<span v-if="serverInstallInfo.totalSteps > 0">
({{ serverInstallInfo.stepIndex }}/{{ serverInstallInfo.totalSteps }})
</span>
</div>
<div v-if="serverInstallInfo.message" class="tw:text-xs tw:text-gray-600 tw:mt-1 tw:break-words">
{{ t('home.server_install_message') }}: {{ serverInstallInfo.message }}
</div>
<div v-if="serverInstallInfo.status === 'completed'" class="tw:text-xs tw:text-green-600 tw:mt-1">
{{ t('home.server_install_completed') }} {{ t('home.server_install_duration') }}: {{ (serverInstallInfo.duration / 1000).toFixed(2) }}s
</div>
<div v-if="serverInstallInfo.status === 'error'" class="tw:text-xs tw:text-red-600 tw:mt-1 tw:break-words">
{{ t('home.server_install_error') }}: {{ serverInstallInfo.error }}
</div>
</div>
<div v-if="filterModsProgress.display" class="tw:mb-4">
<h1 class="tw:text-sm">{{ t('home.filter_mods_progress') }}</h1>
<a-progress :percent="filterModsProgress.percent" :status="filterModsProgress.status" size="small" />
<div v-if="filterModsInfo.totalMods > 0" class="tw:text-xs tw:text-gray-500 tw:mt-1">
{{ t('home.filter_mods_total') }}: {{ filterModsInfo.totalMods }}
</div>
<div v-if="filterModsInfo.modName" class="tw:text-xs tw:text-gray-600 tw:mt-1 tw:break-words">
{{ t('home.filter_mods_current') }}: {{ filterModsInfo.modName }}
</div>
<div v-if="filterModsInfo.status === 'completed'" class="tw:text-xs tw:text-green-600 tw:mt-1">
{{ t('home.filter_mods_completed', { filtered: filterModsInfo.filteredCount, moved: filterModsInfo.movedCount }) }}
</div>
<div v-if="filterModsInfo.status === 'error'" class="tw:text-xs tw:text-red-600 tw:mt-1 tw:break-words">
{{ t('home.filter_mods_error') }}: {{ filterModsInfo.error }}
</div>
</div>
</a-card>
</div>
<a-modal v-model:open="showTemplateModal" :title="t('home.template_select_title')" :footer="null" width="700px">
<a-spin :spinning="loadingTemplates">
<div class="tw:mb-4">
<p class="tw:mb-2 tw:text-gray-600">{{ t('home.template_select_desc') }}</p>
<!-- 导入模板 -->
<!-- <a-upload-dragger name="file" action="/" :multiple="false" :before-upload="beforeUpload" @change="handleImportTemplateChange" accept=".zip">
<p class="ant-upload-drag-icon">
<inbox-outlined></inbox-outlined>
</p>
<p class="ant-upload-text">{{ t('home.template_import_title') }}</p>
<p class="ant-upload-hint">
{{ t('home.template_import_hint') }}
</p>
</a-upload-dragger> -->
</div>
<div class="tw:max-h-96 tw:overflow-y-auto tw:pr-2">
<div class="tw:grid tw:grid-cols-2 tw:gap-3">
<div
@click="selectTemplate('0')"
:class="[
'tw:p-3 tw:rounded-lg tw:cursor-pointer tw:border-2 tw:transition-all tw:tw:h-32 tw:flex tw:flex-col tw:justify-between',
selectedTemplate === '0' ? 'tw:border-blue-500 tw:bg-blue-50' : 'tw:border-gray-200 hover:tw:border-gray-300'
]"
>
<div>
<h3 class="tw:text-base tw:font-semibold tw:mb-1">{{ t('home.template_official_loader') }}</h3>
<p class="tw:text-xs tw:text-gray-600 tw:line-clamp-2">{{ t('home.template_official_loader_desc') }}</p>
</div>
</div>
<div
v-for="template in templates"
:key="template.id"
@click="selectTemplate(template.id)"
:class="[
'tw:p-3 tw:rounded-lg tw:cursor-pointer tw:border-2 tw:transition-all tw:h-32 tw:flex tw:flex-col tw:justify-between',
selectedTemplate === template.id ? 'tw:border-blue-500 tw:bg-blue-50' : 'tw:border-gray-200 hover:tw:border-gray-300'
]"
>
<div class="tw:flex-1 tw:overflow-hidden">
<div class="tw:flex tw:justify-between tw:items-start tw:mb-1">
<h3 class="tw:text-base tw:font-semibold tw:truncate tw:flex-1">{{ template.metadata.name }}</h3>
<!-- <a-button size="small" type="link" @click.stop="exportTemplate(template.id)">
{{ t('home.template_export_button') }}
</a-button> -->
</div>
<p class="tw:text-xs tw:text-gray-600 tw:line-clamp-2 tw:mb-2">{{ template.metadata.description }}</p>
</div>
<div class="tw:flex tw:justify-between tw:text-xs tw:text-gray-500 tw:mt-1">
<span class="tw:truncate tw:max-w-[50%]">{{ template.metadata.author }}</span>
<a-tag color="blue" size="small" class="tw:text-xs tw:px-1 tw:py-0.5 tw:truncate tw:max-w-[45%]">{{ template.metadata.version }}</a-tag>
</div>
</div>
</div>
<div v-if="templates.length === 0 && !loadingTemplates" class="tw:text-center tw:py-8 tw:text-gray-500">
{{ t('template.empty') }}
</div>
</div>
</a-spin>
</a-modal>
</div>
</template>

View File

@@ -0,0 +1,324 @@
<script lang="ts" setup>
import { ref, watch, onMounted, computed } from 'vue';
import { message } from 'ant-design-vue';
import { useI18n } from 'vue-i18n';
import { setLanguage, type Language } from '../utils/i18n';
import axios from '../utils/axios';
interface AppConfig {
mirror: {
bmclapi: boolean;
mcimirror: boolean;
};
filter: {
hashes: boolean;
dexpub: boolean;
mixins: boolean;
modrinth: boolean;
};
oaf: boolean;
autoZip: boolean;
javaPath?: string;
}
interface SettingItem {
key: string;
name: string;
description: string;
path: string;
defaultValue: boolean;
}
interface SettingCategory {
id: string;
title: string;
icon: string;
bgColor: string;
textColor: string;
items: SettingItem[];
hasJava?: boolean;
hasLanguage?: boolean;
}
const config = ref<AppConfig>({
mirror: { bmclapi: false, mcimirror: false },
filter: { hashes: false, dexpub: false, mixins: false, modrinth: false },
oaf: false,
autoZip: false,
javaPath: undefined
});
const settings = computed<SettingCategory[]>(() => {
return [
{
id: 'filter',
title: t('setting.category_filter'),
icon: '🧩',
bgColor: 'bg-emerald-100',
textColor: 'text-emerald-800',
items: [
{
key: 'hashes',
name: t('setting.filter_hashes_name'),
description: t('setting.filter_hashes_desc'),
path: 'filter.hashes',
defaultValue: false
},
{
key: 'dexpub',
name: t('setting.filter_dexpub_name'),
description: t('setting.filter_dexpub_desc'),
path: 'filter.dexpub',
defaultValue: false
},
{
key: 'modrinth',
name: t('setting.filter_modrinth_name'),
description: t('setting.filter_modrinth_desc'),
path: 'filter.modrinth',
defaultValue: false
},
{
key: 'mixins',
name: t('setting.filter_mixins_name'),
description: t('setting.filter_mixins_desc'),
path: 'filter.mixins',
defaultValue: false
}
]
},
{
id: 'mirror',
title: t('setting.category_mirror'),
icon: '⬇️',
bgColor: 'bg-cyan-100',
textColor: 'text-cyan-800',
items: [
{
key: 'mcimirror',
name: t('setting.mirror_mcimirror_name'),
description: t('setting.mirror_mcimirror_desc'),
path: 'mirror.mcimirror',
defaultValue: false
},
{
key: 'bmclapi',
name: t('setting.mirror_bmclapi_name'),
description: t('setting.mirror_bmclapi_desc'),
path: 'mirror.bmclapi',
defaultValue: false
}
]
},
{
id: 'system',
title: t('setting.category_system'),
icon: '🛠️',
bgColor: 'bg-purple-100',
textColor: 'text-purple-800',
hasLanguage: true,
items: [
{
key: 'oaf',
name: t('setting.system_oaf_name'),
description: t('setting.system_oaf_desc'),
path: 'oaf',
defaultValue: false
},
{
key: 'autoZip',
name: t('setting.system_autozip_name'),
description: t('setting.system_autozip_desc'),
path: 'autoZip',
defaultValue: false
}
]
}
];
});
const languageOptions = computed(() => {
return [
{ label: '简体中文', value: 'zh_cn' },
{ label: '繁體中文(香港)', value: 'zh_hk' },
{ label: '繁體中文(台灣)', value: 'zh_tw' },
{ label: 'English', value: 'en_us' },
{ label: '日本語', value: 'ja_jp' },
{ label: 'Français', value: 'fr_fr' },
{ label: 'Deutsch', value: 'de_de' },
{ label: 'Español', value: 'es_es' }
];
});
function handleLanguageChange(value: Language) {
setLanguage(value);
}
const { t, locale } = useI18n();
function getConfigValue(path: string): boolean {
const keys = path.split('.');
let value: any = config.value;
for (const key of keys) {
value = value[key];
}
if (typeof value === 'boolean') {
return value;
}
console.warn(`Config value at path "${path}" is not a boolean:`, value);
return false;
}
function setConfigValue(path: string, newValue: boolean): void {
const keys = path.split('.');
let obj: any = config.value;
for (let i = 0; i < keys.length - 1; i++) {
obj = obj[keys[i]];
}
obj[keys[keys.length - 1]] = newValue;
}
async function loadConfig() {
try {
const response = await axios.get('/config/get');
config.value = response.data;
console.log('[Setting] 配置已从后端刷新');
} catch (error) {
console.error('加载配置失败:', error);
message.error(t('setting.config_load_failed'));
}
}
defineExpose({
refreshConfig: loadConfig
});
async function saveConfig(newConfig: AppConfig) {
try {
await axios.post('/config/post', newConfig, {
headers: { 'Content-Type': 'application/json' }
});
message.success(t('setting.config_saved'));
} catch (error) {
console.error('保存配置失败:', error);
message.error(t('setting.config_save_failed'));
}
}
onMounted(() => {
loadConfig();
});
let isInitialLoad = true;
watch(config, (newValue) => {
if (isInitialLoad) {
isInitialLoad = false;
return;
}
saveConfig(newValue);
}, { deep: true });
</script>
<template>
<div class="tw:h-full tw:w-full tw:p-8 tw:overflow-auto tw:bg-gradient-to-br tw:from-slate-50 tw:via-blue-50 tw:to-indigo-50">
<div class="tw:max-w-3xl tw:mx-auto">
<div class="tw:text-center tw:mb-10 tw:animate-fade-in">
<h1 class="tw:text-4xl tw:font-bold tw:tracking-tight tw:mb-3">
<span class="tw:bg-gradient-to-r tw:from-emerald-500 tw:to-cyan-500 tw:bg-clip-text tw:text-transparent">
{{ t('common.app_name') }}
</span>
<span class="tw:text-gray-800">{{ t('menu.setting') }}</span>
</h1>
<p class="tw:text-gray-500 tw:text-lg">{{ t('setting.subtitle') }}</p>
</div>
<div
v-for="(category, index) in settings"
:key="category.id"
class="tw-bg-white tw:rounded-2xl tw:shadow-lg tw:p-7 tw:mb-6 tw:animate-fade-in-up tw:group tw:border tw:border-gray-100 tw:hover:tw:border-emerald-200 tw:transition-all duration-300"
:style="{ animationDelay: `${index * 0.1}s` }"
>
<h2 class="tw:text-xl tw:font-bold tw:text-gray-800 tw-mb-6 tw:flex tw:items-center tw:group-hover:tw:translate-x-2 tw:transition-transform duration-300">
<span :class="[category.bgColor, category.textColor, 'tw-w-10 tw:h-10 tw:rounded-xl tw:flex tw:items-center tw:justify-center tw-mr-3 tw:shadow-md']">
{{ category.icon }}
</span>
{{ category.title }}
</h2>
<div class="tw:grid tw:grid-cols-1 md:tw:grid-cols-2 lg:tw:grid-cols-3 tw:gap-4">
<div
v-if="category.hasLanguage"
class="tw:flex tw:flex-col tw-justify-between tw-p-4 tw:border tw:border-gray-100 tw:rounded-xl tw-hover:bg-gradient-to-r tw:hover:from-emerald-50 tw:hover:to-cyan-50 tw:hover:border-emerald-200 tw:transition-all duration-300 tw:group/item"
>
<div class="tw:flex-1">
<p class="tw:text-gray-700 tw:font-semibold tw:text-sm tw:group-hover/item:tw:text-emerald-700 tw:transition-colors tw:flex tw:items-center tw:gap-2">
<span>🌐</span> {{ t('setting.language_title') }}
</p>
<p class="tw:text-xs tw:text-gray-500 tw:mt-2">{{ t('setting.language_desc') }}</p>
<div class="tw-mt-3">
<a-select
:value="locale"
:options="languageOptions"
@change="handleLanguageChange"
class="tw:w-full"
/>
</div>
</div>
</div>
<div
v-for="item in category.items"
:key="item.key"
class="tw:flex tw:items-center tw:justify-between tw-p-4 tw:border tw:border-gray-100 tw:rounded-xl tw-hover:bg-gradient-to-r tw:hover:from-emerald-50 tw:hover:to-cyan-50 tw:hover:border-emerald-200 tw:transition-all duration-300 tw:group/item"
>
<div class="tw:flex-1">
<p class="tw:text-gray-700 tw:font-semibold tw:text-sm tw:group-hover/item:tw:text-emerald-700 tw:transition-colors">{{ item.name }}</p>
<p class="tw:text-xs tw:text-gray-500 tw:mt-1">{{ item.description }}</p>
</div>
<a-switch
:checked="getConfigValue(item.path)"
@change="setConfigValue(item.path, $event)"
:checked-children="t('setting.switch_on')"
:un-checked-children="t('setting.switch_off')"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.tw-animate-fade-in {
animation: fadeIn 0.6s ease-out;
}
.tw-animate-fade-in-up {
animation: fadeInUp 0.6s ease-out;
animation-fill-mode: both;
}
</style>

View File

@@ -0,0 +1,823 @@
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import { message } from 'ant-design-vue';
import { PlusOutlined, DeleteOutlined, FolderOutlined, ExclamationCircleOutlined, EditOutlined, UploadOutlined, DownloadOutlined } from '@ant-design/icons-vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
interface Template {
id: string;
metadata: {
name: string;
version: string;
description: string;
author: string;
created: string;
type: string;
};
}
// 模板商店模板接口
interface StoreTemplate {
id: string;
name: string;
description: string;
size: string;
downloadUrls: string[];
}
const templates = ref<Template[]>([]);
const loading = ref(false);
const showCreateModal = ref(false);
const showDeleteModal = ref(false);
const showEditModal = ref(false);
const deletingTemplate = ref<Template | null>(null);
const editingTemplate = ref<Template | null>(null);
// 导出进度相关状态
const exportLoading = ref(false);
const exportProgress = ref(0);
const showExportProgress = ref(false);
// 导入进度相关状态
const importLoading = ref(false);
const importProgress = ref(0);
const showImportProgress = ref(false);
// 下载进度相关状态
const downloadLoading = ref(false);
const downloadProgress = ref(0);
const showDownloadProgress = ref(false);
// 下载状态管理
const downloadStates = ref<Map<string, { url: string, downloadedSize: number, totalSize: number }>>(new Map());
// 测试下载链接速度
async function testDownloadSpeed(urls: string[]): Promise<string> {
const speedTests = urls.map(async (url) => {
try {
const startTime = performance.now();
const response = await fetch(url, {
method: 'HEAD'
});
if (response.ok) {
const endTime = performance.now();
return { url, time: endTime - startTime };
}
return { url, time: Infinity };
} catch (error) {
console.error(`测试链接 ${url} 失败:`, error);
return { url, time: Infinity };
}
});
const results = await Promise.all(speedTests);
const fastest = results.sort((a, b) => a.time - b.time)[0];
return fastest.url;
}
// 模板商店相关状态
const storeTemplates = ref<StoreTemplate[]>([]);
const storeLoading = ref(false);
const activeTab = ref('local'); // 'local' 或 'store'
const newTemplate = ref({
name: '',
version: '1.0.0',
description: '',
author: ''
});
async function loadTemplates() {
loading.value = true;
try {
const apiHost = import.meta.env.VITE_API_HOST || 'localhost';
const apiPort = import.meta.env.VITE_API_PORT || '37019';
const response = await fetch(`http://${apiHost}:${apiPort}/templates`);
const result = await response.json();
if (result.status === 200) {
templates.value = result.data || [];
} else {
message.error(t('home.template_load_failed'));
}
} catch (error) {
console.error('加载模板列表失败:', error);
message.error(t('home.template_load_failed'));
} finally {
loading.value = false;
}
}
function openCreateModal() {
newTemplate.value = {
name: '',
version: '1.0.0',
description: '',
author: ''
};
showCreateModal.value = true;
}
async function createTemplate() {
if (!newTemplate.value.name) {
message.error(t('template.name_required'));
return;
}
try {
const apiHost = import.meta.env.VITE_API_HOST || 'localhost';
const apiPort = import.meta.env.VITE_API_PORT || '37019';
const response = await fetch(`http://${apiHost}:${apiPort}/templates`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(newTemplate.value)
});
const result = await response.json();
if (result.status === 200) {
message.success(t('template.create_success'));
showCreateModal.value = false;
await loadTemplates();
} else {
message.error(result.message || t('template.create_failed'));
}
} catch (error) {
console.error('创建模板失败:', error);
message.error(t('template.create_failed'));
}
}
function openDeleteModal(template: Template) {
deletingTemplate.value = template;
showDeleteModal.value = true;
}
async function confirmDelete() {
if (!deletingTemplate.value) return;
try {
const apiHost = import.meta.env.VITE_API_HOST || 'localhost';
const apiPort = import.meta.env.VITE_API_PORT || '37019';
const response = await fetch(`http://${apiHost}:${apiPort}/templates/${deletingTemplate.value.id}`, {
method: 'DELETE'
});
const result = await response.json();
if (result.status === 200) {
message.success(t('template.delete_success'));
showDeleteModal.value = false;
await loadTemplates();
} else {
message.error(result.message || t('template.delete_failed'));
}
} catch (error) {
console.error('删除模板失败:', error);
message.error(t('template.delete_failed'));
}
}
async function openTemplateFolder(template: Template) {
try {
const apiHost = import.meta.env.VITE_API_HOST || 'localhost';
const apiPort = import.meta.env.VITE_API_PORT || '37019';
const response = await fetch(`http://${apiHost}:${apiPort}/templates/${template.id}/path`);
const result = await response.json();
if (result.status !== 200) {
message.error(result.message || t('template.open_folder_failed'));
}
} catch (error) {
console.error('打开文件夹失败:', error);
message.error(t('template.open_folder_failed'));
}
}
function openEditModal(template: Template) {
editingTemplate.value = template;
newTemplate.value = {
name: template.metadata.name,
version: template.metadata.version,
description: template.metadata.description,
author: template.metadata.author
};
showEditModal.value = true;
}
async function updateTemplate() {
if (!editingTemplate.value || !newTemplate.value.name) {
message.error(t('template.name_required'));
return;
}
try {
const apiHost = import.meta.env.VITE_API_HOST || 'localhost';
const apiPort = import.meta.env.VITE_API_PORT || '37019';
const response = await fetch(`http://${apiHost}:${apiPort}/templates/${editingTemplate.value.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(newTemplate.value)
});
const result = await response.json();
if (result.status === 200) {
message.success(t('template.update_success'));
showEditModal.value = false;
await loadTemplates();
} else {
message.error(result.message || t('template.update_failed'));
}
} catch (error) {
console.error('更新模板失败:', error);
message.error(t('template.update_failed'));
}
}
// 导出模板
async function exportTemplate(templateId: string) {
try {
const apiHost = import.meta.env.VITE_API_HOST || 'localhost';
const apiPort = import.meta.env.VITE_API_PORT || '37019';
// 重置进度状态
exportProgress.value = 0;
exportLoading.value = true;
showExportProgress.value = true;
// 发送导出请求
const response = await fetch(`http://${apiHost}:${apiPort}/templates/${templateId}/export`);
if (response.ok) {
// 获取文件名
const contentDisposition = response.headers.get('content-disposition');
let fileName = 'template.zip';
if (contentDisposition) {
const matches = /filename="([^"]+)"/.exec(contentDisposition);
if (matches && matches[1]) {
fileName = matches[1];
}
}
// 获取文件大小
const contentLength = response.headers.get('content-length');
const totalSize = contentLength ? parseInt(contentLength) : 0;
// 创建读取器
const reader = response.body?.getReader();
if (!reader) {
throw new Error('无法读取响应体');
}
// 存储数据
const chunks: Uint8Array[] = [];
let loadedSize = 0;
// 读取数据并更新进度
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
chunks.push(value);
loadedSize += value.length;
if (totalSize > 0) {
exportProgress.value = Math.round((loadedSize / totalSize) * 100);
}
}
}
// 合并数据
const blob = new Blob(chunks);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
message.success(t('home.template_export_success'));
} else {
message.error(t('home.template_export_failed'));
}
} catch (error) {
console.error('导出模板失败:', error);
message.error(t('home.template_export_failed'));
} finally {
// 重置状态
exportLoading.value = false;
showExportProgress.value = false;
exportProgress.value = 0;
}
}
// 导入模板
function importTemplate(options: any) {
const { file, onSuccess, onError } = options;
// 重置进度状态
importProgress.value = 0;
importLoading.value = true;
showImportProgress.value = true;
const apiHost = import.meta.env.VITE_API_HOST || 'localhost';
const apiPort = import.meta.env.VITE_API_PORT || '37019';
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest();
// 监听上传进度
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const percentComplete = Math.round((event.loaded / event.total) * 100);
importProgress.value = percentComplete;
}
});
// 监听完成
xhr.addEventListener('load', async () => {
try {
const result = JSON.parse(xhr.responseText);
if (result.status === 200) {
message.success(t('home.template_import_success'));
// 重新加载模板列表
await loadTemplates();
if (onSuccess) onSuccess(result);
} else {
message.error(t('home.template_import_failed'));
if (onError) onError(result);
}
} catch (error) {
console.error('导入模板失败:', error);
message.error(t('home.template_import_failed'));
if (onError) onError(error);
} finally {
// 重置状态
importLoading.value = false;
showImportProgress.value = false;
importProgress.value = 0;
}
});
// 监听错误
xhr.addEventListener('error', (error) => {
console.error('导入模板失败:', error);
message.error(t('home.template_import_failed'));
if (onError) onError(error);
// 重置状态
importLoading.value = false;
showImportProgress.value = false;
importProgress.value = 0;
});
// 发送请求
xhr.open('POST', `http://${apiHost}:${apiPort}/templates/import`);
xhr.send(formData);
}
// 加载模板商店数据
async function loadStoreTemplates() {
storeLoading.value = true;
try {
const apiHost = import.meta.env.VITE_API_HOST || 'localhost';
const apiPort = import.meta.env.VITE_API_PORT || '37019';
const response = await fetch(`http://${apiHost}:${apiPort}/templates/store`);
const result = await response.json();
if (result.status === 200 && result.data && Array.isArray(result.data.templates)) {
storeTemplates.value = result.data.templates;
} else {
message.error(t('template.store_load_failed'));
}
} catch (error) {
console.error('加载模板商店失败:', error);
message.error(t('template.store_load_failed'));
} finally {
storeLoading.value = false;
}
}
// 下载并安装模板
async function downloadAndInstallTemplate(template: StoreTemplate) {
// 重置进度状态
downloadProgress.value = 0;
downloadLoading.value = true;
showDownloadProgress.value = true;
const apiHost = import.meta.env.VITE_API_HOST || 'localhost';
const apiPort = import.meta.env.VITE_API_PORT || '37019';
try {
// 测试所有下载链接的速度
console.log('正在测试下载链接速度...');
const fastestUrl = await testDownloadSpeed(template.downloadUrls);
console.log('选择最快的下载链接:', fastestUrl);
// 创建一个唯一的ID用于SSE连接
const requestId = Math.random().toString(36).substring(2, 10);
const notificationKey = `download-progress-${requestId}`;
// 检查是否有未完成的下载
const existingState = downloadStates.value.get(template.id);
const resumeFrom = existingState ? existingState.downloadedSize : 0;
// 发送POST请求启动下载
const xhr = new XMLHttpRequest();
xhr.open('POST', `http://${apiHost}:${apiPort}/templates/install-from-url`);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = () => {
if (xhr.status === 200) {
// 处理响应
console.log('POST请求成功');
}
};
xhr.onerror = () => {
console.error('POST请求失败');
message.error(t('template.install_failed'));
// 重置状态
downloadLoading.value = false;
showDownloadProgress.value = false;
downloadProgress.value = 0;
};
xhr.send(JSON.stringify({ url: fastestUrl, requestId, resumeFrom }));
// 使用EventSource接收进度更新
const eventSource = new EventSource(`http://${apiHost}:${apiPort}/templates/install-from-url?requestId=${requestId}`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('收到SSE消息:', data);
switch (data.type) {
case 'init':
// 初始化信息,包含文件大小
console.log('初始化信息:', data);
// 存储下载状态
downloadStates.value.set(template.id, {
url: fastestUrl,
downloadedSize: data.resumeFrom || 0,
totalSize: data.totalSize || 0
});
break;
case 'progress':
// 进度更新
downloadProgress.value = data.progress;
console.log('进度更新:', data.progress);
// 更新下载状态
const currentState = downloadStates.value.get(template.id);
if (currentState) {
downloadStates.value.set(template.id, {
...currentState,
downloadedSize: data.downloadedSize || 0,
totalSize: data.totalSize || currentState.totalSize
});
}
// 如果用户关闭了进度对话框,在右上角显示进度
if (!showDownloadProgress.value) {
// 使用相同的key更新通知
message.loading({
content: `${t('home.template_download_progress')} ${data.progress}%`,
duration: 0,
key: notificationKey
});
}
break;
case 'complete':
// 下载完成
downloadProgress.value = 100;
console.log('下载完成:', data);
// 关闭通知
message.destroy(notificationKey);
// 清理下载状态
downloadStates.value.delete(template.id);
// 短暂延迟让用户看到100%的进度
setTimeout(async () => {
message.success(t('template.install_success'));
// 重新加载本地模板列表
await loadTemplates();
// 重置状态
downloadLoading.value = false;
showDownloadProgress.value = false;
downloadProgress.value = 0;
// 关闭EventSource
eventSource.close();
console.log('重置状态');
}, 500);
break;
case 'error':
// 错误信息
console.error('下载错误:', data);
// 关闭通知
message.destroy(notificationKey);
message.error(data.message || t('template.install_failed'));
// 重置状态
downloadLoading.value = false;
showDownloadProgress.value = false;
downloadProgress.value = 0;
// 关闭EventSource
eventSource.close();
break;
}
} catch (error) {
console.error('解析SSE消息失败:', error);
}
};
eventSource.onerror = (error) => {
console.error('SSE连接错误:', error);
// 关闭通知
message.destroy(notificationKey);
message.error(t('template.install_failed'));
// 重置状态
downloadLoading.value = false;
showDownloadProgress.value = false;
downloadProgress.value = 0;
// 关闭EventSource
eventSource.close();
};
} catch (error) {
console.error('下载模板失败:', error);
message.error(t('template.install_failed'));
// 重置状态
downloadLoading.value = false;
showDownloadProgress.value = false;
downloadProgress.value = 0;
}
}
onMounted(() => {
loadTemplates();
});
</script>
<template>
<div class="tw:h-full tw:w-full tw:overflow-hidden">
<div class="tw:h-full tw:w-full tw:p-6 tw:overflow-y-auto">
<div class="tw:max-w-7xl tw:mx-auto">
<div class="tw:flex tw:justify-between tw:items-center tw:mb-6">
<div>
<h1 class="tw:text-2xl tw:font-bold tw:text-gray-800">{{ t('template.title') }}</h1>
<p class="tw:text-gray-600 tw:mt-1">{{ t('template.description') }}</p>
</div>
<div class="tw:flex tw:gap-2">
<a-upload
name="file"
:show-upload-list="false"
:custom-request="importTemplate"
>
<a-button type="default" class="tw:flex tw:items-center tw:gap-2">
<UploadOutlined />
{{ t('home.template_import_title') }}
</a-button>
</a-upload>
<a-button type="primary" @click="openCreateModal" class="tw:flex tw:items-center tw:gap-2">
<PlusOutlined />
{{ t('template.create_button') }}
</a-button>
</div>
</div>
<!-- 标签页切换 -->
<a-tabs v-model:activeKey="activeTab" class="tw:mb-6" @change="(key: string) => {
if (key === 'store') {
loadStoreTemplates();
}
}">
<a-tab-pane key="local" :tab="t('template.local_templates')"></a-tab-pane>
<a-tab-pane key="store" :tab="t('template.template_store')"></a-tab-pane>
</a-tabs>
<!-- 本地模板 -->
<a-spin v-if="activeTab === 'local'" :spinning="loading">
<div v-if="templates.length === 0 && !loading" class="tw:text-center tw:py-16 tw:text-gray-500">
<FolderOutlined style="font-size: 64px; margin-bottom: 16px;" />
<p class="tw:text-lg">{{ t('template.empty') }}</p>
<p class="tw:text-sm tw:mt-2">{{ t('template.empty_hint') }}</p>
</div>
<div v-else class="tw:grid tw:grid-cols-1 md:tw:grid-cols-2 lg:tw:grid-cols-3 tw:gap-4">
<div
v-for="template in templates"
:key="template.id"
class="tw:bg-white tw:rounded-lg tw:shadow-md tw:p-5 tw:h-48 tw:flex tw:flex-col tw:border tw:border-gray-200 tw:transition-all tw:duration-300 hover:tw:shadow-lg hover:tw:border-blue-300"
>
<div class="tw:flex-1 tw:overflow-hidden">
<div class="tw:flex tw:justify-between tw:items-start tw:mb-2">
<h3 class="tw:text-lg tw:font-semibold tw:truncate tw:flex-1 tw:mr-2">{{ template.metadata.name }}</h3>
<a-tag color="blue" size="small">{{ template.metadata.version }}</a-tag>
</div>
<p class="tw:text-sm tw:text-gray-600 tw:line-clamp-2 tw:mb-3">{{ template.metadata.description }}</p>
<div class="tw:flex tw:justify-between tw:text-xs tw:text-gray-500">
<span>{{ t('template.author') }}: {{ template.metadata.author }}</span>
<span>{{ template.metadata.created }}</span>
</div>
</div>
<div class="tw:flex tw:justify-between tw:items-center tw:mt-4 tw:pt-4 tw:border-t tw:border-gray-100">
<a-button size="small" @click="openTemplateFolder(template)">
<div class="tw:flex tw:items-center tw:gap-1">
<FolderOutlined />
<span>{{ t('template.open_folder') }}</span>
</div>
</a-button>
<div class="tw:flex tw:gap-2">
<a-button size="small" @click="exportTemplate(template.id)">
<div class="tw:flex tw:items-center tw:gap-1">
<DownloadOutlined />
<span>{{ t('home.template_export_button') }}</span>
</div>
</a-button>
<a-button size="small" @click="openEditModal(template)">
<div class="tw:flex tw:items-center tw:gap-1">
<EditOutlined />
<span>{{ t('template.edit_button') }}</span>
</div>
</a-button>
<a-button size="small" danger @click="openDeleteModal(template)">
<div class="tw:flex tw:items-center tw:gap-1">
<DeleteOutlined />
<span>{{ t('template.delete_button') }}</span>
</div>
</a-button>
</div>
</div>
</div>
</div>
</a-spin>
<!-- 模板商店 -->
<a-spin v-if="activeTab === 'store'" :spinning="storeLoading">
<div v-if="storeTemplates.length === 0 && !storeLoading" class="tw:text-center tw:py-16 tw:text-gray-500">
<DownloadOutlined style="font-size: 64px; margin-bottom: 16px;" />
<p class="tw:text-lg">{{ t('template.store_empty') }}</p>
<p class="tw:text-sm tw:mt-2">{{ t('template.store_empty_hint') }}</p>
</div>
<div v-else class="tw:grid tw:grid-cols-1 md:tw:grid-cols-2 lg:tw:grid-cols-3 tw:gap-4">
<div
v-for="template in storeTemplates"
:key="template.id"
class="tw:bg-white tw:rounded-lg tw:shadow-md tw:p-5 tw:h-48 tw:flex tw:flex-col tw:border tw:border-gray-200 tw:transition-all tw:duration-300 hover:tw:shadow-lg hover:tw:border-blue-300"
>
<div class="tw:flex-1 tw:overflow-hidden">
<div class="tw:flex tw:justify-between tw:items-start tw:mb-2">
<h3 class="tw:text-lg tw:font-semibold tw:truncate tw:flex-1 tw:mr-2">{{ template.name }}</h3>
<a-tag color="green" size="small">{{ template.size }}</a-tag>
</div>
<p class="tw:text-sm tw:text-gray-600 tw:line-clamp-2 tw:mb-3">{{ template.description }}</p>
</div>
<div class="tw:flex tw:justify-end tw:mt-4 tw:pt-4 tw:border-t tw:border-gray-100">
<a-button type="primary" size="small" @click="downloadAndInstallTemplate(template)">
<div class="tw:flex tw:items-center tw:gap-1">
<DownloadOutlined />
<span>{{ t('template.install_button') }}</span>
</div>
</a-button>
</div>
</div>
</div>
</a-spin>
</div>
</div>
<a-modal
v-model:open="showCreateModal"
:title="t('template.create_title')"
@ok="createTemplate"
:ok-text="t('common.confirm')"
:cancel-text="t('common.cancel')"
>
<a-form layout="vertical">
<a-form-item :label="t('template.name')" required>
<a-input v-model:value="newTemplate.name" :placeholder="t('template.name_placeholder')" />
</a-form-item>
<a-form-item :label="t('template.version')">
<a-input v-model:value="newTemplate.version" :placeholder="t('template.version_placeholder')" />
</a-form-item>
<a-form-item :label="t('template.description')">
<a-textarea v-model:value="newTemplate.description" :placeholder="t('template.description_placeholder')" :rows="4" />
</a-form-item>
<a-form-item :label="t('template.author')">
<a-input v-model:value="newTemplate.author" :placeholder="t('template.author_placeholder')" />
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:open="showDeleteModal"
:title="t('template.delete_title')"
@ok="confirmDelete"
:ok-text="t('common.confirm')"
:cancel-text="t('common.cancel')"
ok-type="danger"
>
<div class="tw:flex tw:items-start tw:gap-3">
<ExclamationCircleOutlined style="font-size: 24px; color: #ff4d4f;" />
<div>
<p class="tw:mb-2">{{ t('template.delete_confirm', { name: deletingTemplate?.metadata.name }) }}</p>
<p class="tw:text-sm tw:text-gray-500">{{ t('template.delete_warning') }}</p>
</div>
</div>
</a-modal>
<a-modal
v-model:open="showEditModal"
:title="t('template.edit_title')"
@ok="updateTemplate"
:ok-text="t('common.confirm')"
:cancel-text="t('common.cancel')"
>
<a-form layout="vertical">
<a-form-item :label="t('template.name')" required>
<a-input v-model:value="newTemplate.name" :placeholder="t('template.name_placeholder')" />
</a-form-item>
<a-form-item :label="t('template.version')">
<a-input v-model:value="newTemplate.version" :placeholder="t('template.version_placeholder')" />
</a-form-item>
<a-form-item :label="t('template.description')">
<a-textarea v-model:value="newTemplate.description" :placeholder="t('template.description_placeholder')" :rows="4" />
</a-form-item>
<a-form-item :label="t('template.author')">
<a-input v-model:value="newTemplate.author" :placeholder="t('template.author_placeholder')" />
</a-form-item>
</a-form>
</a-modal>
<!-- 导出进度对话框 -->
<a-modal
v-model:open="showExportProgress"
:title="t('home.template_export_button')"
:footer="null"
:closable="false"
>
<div class="tw:py-4">
<p class="tw:text-center tw:mb-4">{{ t('home.template_export_progress') }}</p>
<a-progress
:percent="exportProgress"
:status="exportLoading ? 'active' : 'success'"
:stroke-width="10"
/>
</div>
</a-modal>
<!-- 导入进度对话框 -->
<a-modal
v-model:open="showImportProgress"
:title="t('home.template_import_title')"
:footer="null"
:closable="false"
>
<div class="tw:py-4">
<p class="tw:text-center tw:mb-4">{{ t('home.template_import_progress') }}</p>
<a-progress
:percent="importProgress"
:status="importLoading ? 'active' : 'success'"
:stroke-width="10"
/>
<p class="tw:text-center tw:mt-4 tw:text-gray-500">{{ importProgress }}%</p>
</div>
</a-modal>
<!-- 下载进度对话框 -->
<a-modal
v-model:open="showDownloadProgress"
:title="t('template.install_button')"
:footer="null"
:closable="false"
>
<div class="tw:py-4">
<p class="tw:text-center tw:mb-4">{{ t('home.template_download_progress') }}</p>
<a-progress
:percent="downloadProgress"
:status="downloadLoading ? 'active' : 'success'"
:stroke-width="10"
/>
</div>
</a-modal>
</div>
</template>