项目迁移
This commit is contained in:
258
front/src/views/AboutView.vue
Normal file
258
front/src/views/AboutView.vue
Normal 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>
|
||||
175
front/src/views/DeEarthView.vue
Normal file
175
front/src/views/DeEarthView.vue
Normal 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>
|
||||
22
front/src/views/ErrorView.vue
Normal file
22
front/src/views/ErrorView.vue
Normal 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>
|
||||
270
front/src/views/GalaxyView.vue
Normal file
270
front/src/views/GalaxyView.vue
Normal 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
812
front/src/views/Main.vue
Normal 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>
|
||||
324
front/src/views/SettingView.vue
Normal file
324
front/src/views/SettingView.vue
Normal 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>
|
||||
823
front/src/views/TemplateView.vue
Normal file
823
front/src/views/TemplateView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user