项目迁移
This commit is contained in:
403
front/src/App.vue
Normal file
403
front/src/App.vue
Normal file
@@ -0,0 +1,403 @@
|
||||
<script lang="ts" setup>
|
||||
import { h, provide, ref, onMounted, computed } from 'vue';
|
||||
import { MenuProps, message } from 'ant-design-vue';
|
||||
import { SettingOutlined, UploadOutlined, UserOutlined, WindowsOutlined, LoadingOutlined, CheckCircleOutlined, CloseCircleOutlined, FileSearchOutlined, FolderOutlined } from '@ant-design/icons-vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { Command } from '@tauri-apps/plugin-shell';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
let killCoreProcess: (() => void) | null = null;
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// 版本号相关
|
||||
const version = ref<string>('V3');
|
||||
|
||||
// 加载版本号
|
||||
async function loadVersion() {
|
||||
try {
|
||||
console.log('开始加载版本号...');
|
||||
const response = await fetch('/version.json');
|
||||
console.log('version.json 响应状态:', response.status);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
console.log('版本号数据:', data);
|
||||
version.value = `V${data.version}`;
|
||||
console.log('设置版本号为:', version.value);
|
||||
} catch (error) {
|
||||
console.error('加载版本号失败:', error);
|
||||
version.value = 'V3';
|
||||
}
|
||||
}
|
||||
|
||||
// 后端连接状态相关
|
||||
const backendStatus = ref<'loading' | 'success' | 'error'>('loading');
|
||||
const backendErrorInfo = ref<string>('');
|
||||
const retryCount = ref<number>(0);
|
||||
const maxRetries = 5;
|
||||
|
||||
// 检测端口是否被正确的后端占用
|
||||
async function checkPortOccupied(): Promise<'correct_backend' | 'wrong_app' | 'free'> {
|
||||
try {
|
||||
const response = await fetch("http://localhost:37019/config/get", {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(1000)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const config = await response.json();
|
||||
// 检查是否包含 DeEarthX 后端的特征字段(mirror、filter 等)
|
||||
if (config.mirror !== undefined || config.filter !== undefined) {
|
||||
// 端口被正确的后端占用
|
||||
return 'correct_backend';
|
||||
} else {
|
||||
// 端口被其他应用占用
|
||||
return 'wrong_app';
|
||||
}
|
||||
} else {
|
||||
return 'free';
|
||||
}
|
||||
} catch (error) {
|
||||
// 连接失败,端口可能是空闲的
|
||||
return 'free';
|
||||
}
|
||||
}
|
||||
|
||||
// 启动后端核心服务
|
||||
async function runCoreProcess() {
|
||||
// 先检测端口状态
|
||||
const portStatus = await checkPortOccupied();
|
||||
|
||||
if (portStatus === 'correct_backend') {
|
||||
// 端口已经被正确的后端占用,直接使用
|
||||
backendStatus.value = 'success';
|
||||
backendErrorInfo.value = '';
|
||||
message.success(t('message.backend_running'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (portStatus === 'wrong_app') {
|
||||
// 端口被其他应用占用
|
||||
backendStatus.value = 'error';
|
||||
backendErrorInfo.value = t('message.backend_port_occupied');
|
||||
message.error(t('message.backend_port_occupied'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 端口空闲,尝试启动后端
|
||||
backendStatus.value = 'loading';
|
||||
|
||||
Command.create("core").spawn()
|
||||
.then((e) => {
|
||||
console.log("DeEarthX V3 Core");
|
||||
killCoreProcess = e.kill;
|
||||
|
||||
// 等待后端启动并检查状态
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch("http://localhost:37019/", { method: "GET" });
|
||||
if (response.ok) {
|
||||
backendStatus.value = 'success';
|
||||
backendErrorInfo.value = '';
|
||||
message.success(t('message.backend_started'));
|
||||
} else {
|
||||
backendStatus.value = 'error';
|
||||
backendErrorInfo.value = t('common.status_error');
|
||||
router.push('/error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("后端连接失败:", error);
|
||||
backendStatus.value = 'error';
|
||||
backendErrorInfo.value = t('common.status_error');
|
||||
router.push('/error');
|
||||
}
|
||||
}, 3000); // 等待3秒让后端启动
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
retryCount.value++;
|
||||
|
||||
if (retryCount.value <= maxRetries) {
|
||||
message.info(t('message.retry_start', { current: retryCount.value, max: maxRetries }));
|
||||
setTimeout(() => {
|
||||
runCoreProcess();
|
||||
}, 2000);
|
||||
} else {
|
||||
backendStatus.value = 'error';
|
||||
backendErrorInfo.value = t('message.backend_start_failed', { count: maxRetries });
|
||||
message.error(t('message.backend_start_failed', { count: maxRetries }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 组件挂载时启动后端
|
||||
onMounted(async () => {
|
||||
loadVersion();
|
||||
runCoreProcess();
|
||||
});
|
||||
|
||||
provide("killCoreProcess", () => {
|
||||
if (killCoreProcess && typeof killCoreProcess === 'function') {
|
||||
killCoreProcess();
|
||||
killCoreProcess = null;
|
||||
message.info(t('message.backend_restart'));
|
||||
runCoreProcess();
|
||||
}
|
||||
});
|
||||
|
||||
// 导航菜单配置
|
||||
const selectedKeys = ref<(string | number)[]>(['main']);
|
||||
|
||||
// 监听路由变化,更新选中菜单
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const routeToKey: Record<string, string> = {
|
||||
'/': 'main',
|
||||
'/setting': 'setting',
|
||||
'/about': 'about',
|
||||
'/error': 'main',
|
||||
'/galaxy': 'galaxy',
|
||||
'/deearth': 'deearth',
|
||||
'/template': 'template'
|
||||
};
|
||||
selectedKeys.value[0] = routeToKey[to.path] || 'main';
|
||||
next();
|
||||
});
|
||||
|
||||
// 菜单项配置(使用计算属性使其响应语言变化)
|
||||
const menuItems = computed<MenuProps['items']>(() => {
|
||||
return [
|
||||
{
|
||||
key: 'main',
|
||||
icon: h(WindowsOutlined),
|
||||
label: t('menu.home'),
|
||||
title: t('menu.home'),
|
||||
},
|
||||
{
|
||||
key: 'deearth',
|
||||
icon: h(FileSearchOutlined),
|
||||
label: t('menu.deearth'),
|
||||
title: t('menu.deearth'),
|
||||
},
|
||||
{
|
||||
key: 'galaxy',
|
||||
icon: h(UploadOutlined),
|
||||
label: t('menu.galaxy'),
|
||||
title: t('menu.galaxy'),
|
||||
},
|
||||
{
|
||||
key: 'template',
|
||||
icon: h(FolderOutlined),
|
||||
label: t('menu.template'),
|
||||
title: t('menu.template'),
|
||||
},
|
||||
{
|
||||
key: 'setting',
|
||||
icon: h(SettingOutlined),
|
||||
label: t('menu.setting'),
|
||||
title: t('menu.setting'),
|
||||
},
|
||||
{
|
||||
key: 'about',
|
||||
icon: h(UserOutlined),
|
||||
label: t('menu.about'),
|
||||
title: t('menu.about'),
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
// 菜单点击事件处理
|
||||
const handleMenuClick: MenuProps['onClick'] = (e) => {
|
||||
selectedKeys.value[0] = e.key;
|
||||
const routeMap: Record<string, string> = {
|
||||
main: '/',
|
||||
deearth: '/deearth',
|
||||
setting: '/setting',
|
||||
about: '/about',
|
||||
galaxy: '/galaxy',
|
||||
template: '/template'
|
||||
};
|
||||
const route = routeMap[e.key] || '/';
|
||||
router.push(route);
|
||||
};
|
||||
|
||||
// 主题配置
|
||||
const theme = ref({
|
||||
token: {
|
||||
colorPrimary: '#67eac3',
|
||||
borderRadius: 8,
|
||||
},
|
||||
components: {
|
||||
Menu: {
|
||||
itemActiveBg: '#e8fff5',
|
||||
itemSelectedBg: '#e8fff5',
|
||||
itemSelectedColor: '#10b981',
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider :theme="theme">
|
||||
<div class="tw:h-screen tw:w-screen tw:flex tw:flex-col tw:overflow-hidden">
|
||||
<!-- 顶部导航栏 -->
|
||||
<a-page-header
|
||||
class="tw:h-14 tw:px-6 tw:flex tw:items-center tw:bg-white tw:shadow-sm tw:z-10 tw:transition-all tw:duration-300"
|
||||
style="border: none;"
|
||||
|
||||
>
|
||||
<!-- <template #extra>
|
||||
<a-button @click="openAuthorBilibili">作者B站</a-button>
|
||||
</template> -->
|
||||
<!-- 后端状态图标 -->
|
||||
<template #title>
|
||||
<div class="tw:flex tw:items-center tw:gap-3">
|
||||
<span>
|
||||
<span style="color: #000000; font-weight: 500;">{{ t('common.app_name') }}</span>
|
||||
<span style="color: #888888; font-size: 12px; margin-left: 5px;">{{ version }}</span>
|
||||
</span>
|
||||
<span
|
||||
class="tw:flex tw:items-center tw:gap-2"
|
||||
:title="backendErrorInfo || t('message.backend_running')"
|
||||
>
|
||||
<LoadingOutlined v-if="backendStatus === 'loading'" style="color: #1890ff; font-size: 18px;" />
|
||||
<CheckCircleOutlined v-else-if="backendStatus === 'success'" style="color: #52c41a; font-size: 18px;" />
|
||||
<CloseCircleOutlined v-else style="color: #ff4d4f; font-size: 18px;" />
|
||||
<span class="tw:text-xs tw:ml-1"
|
||||
:style="{
|
||||
color: backendStatus === 'loading' ? '#1890ff' :
|
||||
backendStatus === 'success' ? '#52c41a' : '#ff4d4f'
|
||||
}">
|
||||
{{ backendStatus === 'loading' ? t('common.status_loading') :
|
||||
backendStatus === 'success' ? t('common.status_success') : t('common.status_error') }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<!-- 主体内容区域 -->
|
||||
<div class="tw:flex tw:flex-1 tw:overflow-hidden">
|
||||
<!-- 侧边菜单 -->
|
||||
<a-menu
|
||||
id="menu"
|
||||
class="tw:shadow-lg tw:z-20"
|
||||
style="width: 180px; flex-shrink: 0;"
|
||||
:selectedKeys="selectedKeys"
|
||||
mode="inline"
|
||||
:items="menuItems"
|
||||
@click="handleMenuClick"
|
||||
/>
|
||||
|
||||
<!-- 内容区域 - 带过渡动画 -->
|
||||
<div class="tw:flex-1 tw:overflow-hidden tw:relative tw:bg-gradient-to-br tw:from-slate-50 tw:via-blue-50 tw:to-indigo-50">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition
|
||||
name="fade-slide"
|
||||
mode="out-in"
|
||||
appear
|
||||
>
|
||||
<component :is="Component" :key="route.path" class="tw:w-full tw:h-full tw:absolute tw:top-0 tw:left-0" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 禁止选择文本的样式 */
|
||||
h1,
|
||||
li,
|
||||
p,
|
||||
span {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 禁止拖拽图片 */
|
||||
img {
|
||||
-webkit-user-drag: none;
|
||||
-moz-user-drag: none;
|
||||
-ms-user-drag: none;
|
||||
}
|
||||
|
||||
/* 页面切换过渡动画 - 淡入淡出 + 滑动 */
|
||||
.fade-slide-enter-active {
|
||||
animation: fadeSlideIn 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.fade-slide-leave-active {
|
||||
animation: fadeSlideOut 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes fadeSlideIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeSlideOut {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 菜单项悬停效果优化 */
|
||||
#menu .ant-menu-item {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
#menu .ant-menu-item:hover {
|
||||
transform: translateX(4px);
|
||||
background: #f0fdf9;
|
||||
}
|
||||
|
||||
#menu .ant-menu-item-selected {
|
||||
background: linear-gradient(135deg, #d1fae5 0%, #e8fff5 100%);
|
||||
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
#menu .ant-menu-item-selected .anticon {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
/* 滚动条美化 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, #94a3b8 0%, #64748b 100%);
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, #64748b 0%, #475569 100%);
|
||||
}
|
||||
</style>
|
||||
1
front/src/assets/vue.svg
Normal file
1
front/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
44
front/src/components/ModeSelector.vue
Normal file
44
front/src/components/ModeSelector.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { SelectProps } from 'ant-design-vue/es/vc-select';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
interface Props {
|
||||
modelValue: string;
|
||||
javaAvailable: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
'select': [value: string];
|
||||
}>();
|
||||
|
||||
const modeOptions = computed<SelectProps['options']>(() => {
|
||||
return [
|
||||
{ label: t('home.mode_server'), value: 'server', disabled: !props.javaAvailable },
|
||||
{ label: t('home.mode_upload'), value: 'upload', disabled: false }
|
||||
];
|
||||
});
|
||||
|
||||
function handleSelect(value: string) {
|
||||
emit('update:modelValue', value);
|
||||
emit('select', value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="tw:text-sm tw:font-semibold tw:text-gray-700 tw:mb-3">
|
||||
{{ t('home.mode_title') }}
|
||||
</h2>
|
||||
<a-select
|
||||
:value="modelValue"
|
||||
:options="modeOptions"
|
||||
@select="handleSelect"
|
||||
class="tw:w-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
147
front/src/components/ProgressCard.vue
Normal file
147
front/src/components/ProgressCard.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
interface ProgressStatus {
|
||||
status: 'active' | 'success' | 'exception' | 'normal';
|
||||
percent: number;
|
||||
display: boolean;
|
||||
uploadedSize?: number;
|
||||
totalSize?: number;
|
||||
speed?: number;
|
||||
remainingTime?: number;
|
||||
}
|
||||
|
||||
const uploadProgress = ref<ProgressStatus>({ status: 'active', percent: 0, display: false });
|
||||
const unzipProgress = ref<ProgressStatus>({ status: 'active', percent: 0, display: true });
|
||||
const downloadProgress = ref<ProgressStatus>({ status: 'active', percent: 0, display: true });
|
||||
const serverInstallProgress = ref<ProgressStatus>({ status: 'active', percent: 0, display: false });
|
||||
const filterModsProgress = ref<ProgressStatus>({ status: 'active', percent: 0, display: false });
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
uploadProgress,
|
||||
unzipProgress,
|
||||
downloadProgress,
|
||||
serverInstallProgress,
|
||||
filterModsProgress,
|
||||
serverInstallInfo,
|
||||
filterModsInfo
|
||||
});
|
||||
|
||||
watch(() => window.progressData, (newData) => {
|
||||
if (newData) {
|
||||
if (newData.uploadProgress) uploadProgress.value = newData.uploadProgress;
|
||||
if (newData.unzipProgress) unzipProgress.value = newData.unzipProgress;
|
||||
if (newData.downloadProgress) downloadProgress.value = newData.downloadProgress;
|
||||
if (newData.serverInstallProgress) serverInstallProgress.value = newData.serverInstallProgress;
|
||||
if (newData.filterModsProgress) filterModsProgress.value = newData.filterModsProgress;
|
||||
if (newData.serverInstallInfo) serverInstallInfo.value = newData.serverInstallInfo;
|
||||
if (newData.filterModsInfo) filterModsInfo.value = newData.filterModsInfo;
|
||||
}
|
||||
}, { deep: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div 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>
|
||||
</template>
|
||||
16
front/src/components/StepIndicator.vue
Normal file
16
front/src/components/StepIndicator.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
import type { StepsProps } from 'ant-design-vue';
|
||||
|
||||
interface Props {
|
||||
current: number;
|
||||
items: Required<StepsProps>['items'];
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div 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="current" :items="items" size="small" />
|
||||
</div>
|
||||
</template>
|
||||
475
front/src/components/WebSocketHandler.vue
Normal file
475
front/src/components/WebSocketHandler.vue
Normal file
@@ -0,0 +1,475 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, inject, watch, onUnmounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { message, notification } from 'ant-design-vue';
|
||||
import { sendNotification } from '@tauri-apps/plugin-notification';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
interface Props {
|
||||
file: File | undefined;
|
||||
mode: string;
|
||||
showSteps: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'step-change': [step: number];
|
||||
'reset': [];
|
||||
}>();
|
||||
|
||||
const currentStep = ref(0);
|
||||
const startTime = ref<number>(0);
|
||||
const javaAvailable = ref(true);
|
||||
const abortController = ref<AbortController | null>(null);
|
||||
const ws = ref<WebSocket | null>(null);
|
||||
|
||||
interface ProgressStatus {
|
||||
status: 'active' | 'success' | 'exception' | 'normal';
|
||||
percent: number;
|
||||
display: boolean;
|
||||
uploadedSize?: number;
|
||||
totalSize?: number;
|
||||
speed?: number;
|
||||
remainingTime?: number;
|
||||
}
|
||||
|
||||
const uploadProgress = ref<ProgressStatus>({ status: 'active', percent: 0, display: false });
|
||||
const unzipProgress = ref<ProgressStatus>({ status: 'active', percent: 0, display: true });
|
||||
const downloadProgress = ref<ProgressStatus>({ status: 'active', percent: 0, display: true });
|
||||
const serverInstallProgress = ref<ProgressStatus>({ status: 'active', percent: 0, display: false });
|
||||
const filterModsProgress = ref<ProgressStatus>({ status: 'active', percent: 0, display: false });
|
||||
|
||||
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 resetState() {
|
||||
// 取消当前请求
|
||||
if (abortController.value) {
|
||||
abortController.value.abort();
|
||||
abortController.value = null;
|
||||
}
|
||||
|
||||
// 关闭WebSocket连接
|
||||
if (ws.value) {
|
||||
ws.value.close();
|
||||
ws.value = null;
|
||||
}
|
||||
|
||||
uploadProgress.value = { status: 'active', percent: 0, display: false };
|
||||
unzipProgress.value = { status: 'active', percent: 0, display: true };
|
||||
downloadProgress.value = { status: 'active', percent: 0, display: true };
|
||||
serverInstallProgress.value = { status: 'active', percent: 0, display: false };
|
||||
filterModsProgress.value = { status: 'active', percent: 0, display: false };
|
||||
currentStep.value = 0;
|
||||
|
||||
const killCoreProcess = inject("killCoreProcess");
|
||||
if (killCoreProcess && typeof killCoreProcess === 'function') {
|
||||
killCoreProcess();
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.showSteps, (newVal) => {
|
||||
if (newVal && props.file) {
|
||||
runDeEarthX(props.file);
|
||||
}
|
||||
});
|
||||
|
||||
async function runDeEarthX(file: File) {
|
||||
message.success(t('home.start_production'));
|
||||
|
||||
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';
|
||||
const url = `http://${apiHost}:${apiPort}/start?mode=${props.mode}`;
|
||||
|
||||
uploadProgress.value = { status: 'active', percent: 0, display: true };
|
||||
startTime.value = Date.now();
|
||||
|
||||
// 创建新的AbortController
|
||||
abortController.value = new AbortController();
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
signal: abortController.value.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
uploadProgress.value.status = 'success';
|
||||
uploadProgress.value.percent = 100;
|
||||
setTimeout(() => {
|
||||
uploadProgress.value.display = false;
|
||||
}, 2000);
|
||||
|
||||
message.success(t('home.task_connecting'));
|
||||
setupWebSocket();
|
||||
} catch (error: any) {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('请求失败:', error);
|
||||
message.error(t('home.request_failed'));
|
||||
uploadProgress.value.status = 'exception';
|
||||
}
|
||||
resetState();
|
||||
}
|
||||
}
|
||||
|
||||
function setupWebSocket() {
|
||||
message.loading(t('home.ws_connecting'));
|
||||
const wsHost = import.meta.env.VITE_WS_HOST || 'localhost';
|
||||
const wsPort = import.meta.env.VITE_WS_PORT || '37019';
|
||||
ws.value = new WebSocket(`ws://${wsHost}:${wsPort}/`);
|
||||
|
||||
ws.value.addEventListener('open', () => {
|
||||
message.success(t('home.ws_connected'));
|
||||
});
|
||||
|
||||
ws.value.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++;
|
||||
emit('step-change', 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.value.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.value.addEventListener('close', () => {
|
||||
console.log('WebSocket连接关闭');
|
||||
ws.value = null;
|
||||
});
|
||||
}
|
||||
|
||||
// 组件卸载时清理资源
|
||||
onUnmounted(() => {
|
||||
resetState();
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
updateWindowProgress();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
updateWindowProgress();
|
||||
}
|
||||
|
||||
function updateWindowProgress() {
|
||||
(window as any).progressData = {
|
||||
uploadProgress: uploadProgress.value,
|
||||
unzipProgress: unzipProgress.value,
|
||||
downloadProgress: downloadProgress.value,
|
||||
serverInstallProgress: serverInstallProgress.value,
|
||||
filterModsProgress: filterModsProgress.value,
|
||||
serverInstallInfo: serverInstallInfo.value,
|
||||
filterModsInfo: filterModsInfo.value
|
||||
};
|
||||
}
|
||||
|
||||
function handleFinish(result: number) {
|
||||
const timeSpent = Math.round(result / 1000);
|
||||
currentStep.value++;
|
||||
emit('step-change', currentStep.value);
|
||||
message.success(t('home.production_complete', { time: timeSpent }));
|
||||
sendNotification({ title: t('common.app_name'), body: t('home.production_complete', { time: timeSpent }) });
|
||||
|
||||
setTimeout(() => {
|
||||
emit('reset');
|
||||
}, 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 };
|
||||
updateWindowProgress();
|
||||
}
|
||||
|
||||
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);
|
||||
updateWindowProgress();
|
||||
}
|
||||
|
||||
function handleServerInstallProgress(result: any) {
|
||||
serverInstallInfo.value.currentStep = result.step;
|
||||
serverInstallInfo.value.message = result.message || result.step;
|
||||
serverInstallProgress.value.percent = result.progress;
|
||||
updateWindowProgress();
|
||||
}
|
||||
|
||||
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++;
|
||||
emit('step-change', 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 }) });
|
||||
|
||||
setTimeout(() => {
|
||||
serverInstallProgress.value.display = false;
|
||||
}, 8000);
|
||||
updateWindowProgress();
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
updateWindowProgress();
|
||||
}
|
||||
|
||||
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 };
|
||||
updateWindowProgress();
|
||||
}
|
||||
|
||||
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;
|
||||
updateWindowProgress();
|
||||
}
|
||||
|
||||
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`);
|
||||
|
||||
setTimeout(() => {
|
||||
filterModsProgress.value.display = false;
|
||||
}, 8000);
|
||||
updateWindowProgress();
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
updateWindowProgress();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
</template>
|
||||
24
front/src/env.d.ts
vendored
Normal file
24
front/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_HOST?: string
|
||||
readonly VITE_API_PORT?: string
|
||||
readonly VITE_WS_HOST?: string
|
||||
readonly VITE_WS_PORT?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
interface Window {
|
||||
progressData?: {
|
||||
uploadProgress?: any;
|
||||
unzipProgress?: any;
|
||||
downloadProgress?: any;
|
||||
serverInstallProgress?: any;
|
||||
filterModsProgress?: any;
|
||||
serverInstallInfo?: any;
|
||||
filterModsInfo?: any;
|
||||
};
|
||||
}
|
||||
14
front/src/main.ts
Normal file
14
front/src/main.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./tailwind.css"
|
||||
import Antd from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
import router from "./utils/router";
|
||||
import i18n from "./utils/i18n";
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(router)
|
||||
app.use(Antd)
|
||||
app.use(i18n)
|
||||
app.mount("#app");
|
||||
1
front/src/tailwind.css
Normal file
1
front/src/tailwind.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss" prefix(tw);
|
||||
30
front/src/utils/axios.ts
Normal file
30
front/src/utils/axios.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import axios, { AxiosInstance, AxiosResponse } from 'axios';
|
||||
|
||||
const axiosInstance: AxiosInstance = axios.create({
|
||||
baseURL: 'http://localhost:37019',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
axiosInstance.interceptors.request.use(
|
||||
(config) => {
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
console.error('Axios error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default axiosInstance;
|
||||
48
front/src/utils/i18n.ts
Normal file
48
front/src/utils/i18n.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import zhCn from '../../lang/zh_cn.json';
|
||||
import zhHk from '../../lang/zh_hk.json';
|
||||
import zhTw from '../../lang/zh_tw.json';
|
||||
import enUs from '../../lang/en_us.json';
|
||||
import jaJp from '../../lang/ja_jp.json';
|
||||
import frFr from '../../lang/fr_fr.json';
|
||||
import deDe from '../../lang/de_de.json';
|
||||
import esEs from '../../lang/es_es.json';
|
||||
|
||||
export type Language = 'zh_cn' | 'zh_hk' | 'zh_tw' | 'en_us' | 'ja_jp' | 'fr_fr' | 'de_de' | 'es_es';
|
||||
|
||||
const messages = {
|
||||
zh_cn: zhCn,
|
||||
zh_hk: zhHk,
|
||||
zh_tw: zhTw,
|
||||
en_us: enUs,
|
||||
ja_jp: jaJp,
|
||||
fr_fr: frFr,
|
||||
de_de: deDe,
|
||||
es_es: esEs
|
||||
};
|
||||
|
||||
const LANGUAGE_STORAGE_KEY = 'deearthx_language';
|
||||
|
||||
const savedLanguage = localStorage.getItem(LANGUAGE_STORAGE_KEY) as Language;
|
||||
const defaultLocale = savedLanguage && messages[savedLanguage] ? savedLanguage : 'zh_cn';
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: defaultLocale,
|
||||
fallbackLocale: 'zh_cn',
|
||||
messages,
|
||||
globalInjection: true
|
||||
});
|
||||
|
||||
export function setLanguage(lang: Language) {
|
||||
if (i18n.global.locale.value !== lang) {
|
||||
i18n.global.locale.value = lang;
|
||||
localStorage.setItem(LANGUAGE_STORAGE_KEY, lang);
|
||||
}
|
||||
}
|
||||
|
||||
export function getLanguage(): Language {
|
||||
return i18n.global.locale.value as Language;
|
||||
}
|
||||
|
||||
export default i18n;
|
||||
40
front/src/utils/router.ts
Normal file
40
front/src/utils/router.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
component: () => import("../views/Main.vue"),
|
||||
},
|
||||
{
|
||||
path: "/setting",
|
||||
component: () => import("../views/SettingView.vue"),
|
||||
meta: {
|
||||
requiresConfigRefresh: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: "/about",
|
||||
component: () => import("../views/AboutView.vue")
|
||||
},
|
||||
{
|
||||
path: "/error",
|
||||
component: () => import("../views/ErrorView.vue")
|
||||
},
|
||||
{
|
||||
path: "/galaxy",
|
||||
component: () => import("../views/GalaxyView.vue")
|
||||
},
|
||||
{
|
||||
path: "/deearth",
|
||||
component: () => import("../views/DeEarthView.vue")
|
||||
},
|
||||
{
|
||||
path: "/template",
|
||||
component: () => import("../views/TemplateView.vue")
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
export default router
|
||||
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