Improve music processing robustness and workflow UX
Unify safe file-move behavior and richer progress semantics across backend tasks, while upgrading traditional-to-simplified conversion and refining the frontend multi-step panels for clearer execution feedback.
This commit is contained in:
9
frontend/package-lock.json
generated
9
frontend/package-lock.json
generated
@@ -885,7 +885,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
@@ -1571,15 +1570,13 @@
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
|
||||
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-unified": {
|
||||
"version": "1.0.3",
|
||||
@@ -1909,7 +1906,6 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -1969,7 +1965,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz",
|
||||
"integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.27",
|
||||
"@vue/compiler-sfc": "3.5.27",
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
class="app-menu"
|
||||
@select="handleSelect"
|
||||
>
|
||||
<el-menu-item index="aggregate">01 音频文件汇聚</el-menu-item>
|
||||
<el-menu-item index="convert">02 音频格式智能处理</el-menu-item>
|
||||
<el-menu-item index="dedup">03 音乐去重</el-menu-item>
|
||||
<el-menu-item index="zhconvert">04 元数据繁简转换</el-menu-item>
|
||||
<el-menu-item index="organize">05 音乐整理</el-menu-item>
|
||||
<el-menu-item index="merge">06 整理入库</el-menu-item>
|
||||
<el-menu-item index="settings">全局设置</el-menu-item>
|
||||
<el-menu-item
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:index="tab.key"
|
||||
>
|
||||
{{ tab.menuLabel }}
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
<h1>{{ currentTitle }}</h1>
|
||||
<p>{{ currentSubtitle }}</p>
|
||||
</div>
|
||||
<div class="app-header-meta">
|
||||
<span class="app-header-index">{{ currentTab.badge }}</span>
|
||||
<span class="app-header-helper">执行面板</span>
|
||||
</div>
|
||||
</el-header>
|
||||
<el-main class="app-main">
|
||||
<component :is="currentComponent" />
|
||||
@@ -35,14 +39,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import AggregateTab from './components/AggregateTab.vue';
|
||||
import ConvertTab from './components/ConvertTab.vue';
|
||||
import DedupTab from './components/DedupTab.vue';
|
||||
import TraditionalFilterTab from './components/TraditionalFilterTab.vue';
|
||||
import RenameTab from './components/RenameTab.vue';
|
||||
import MergeTab from './components/MergeTab.vue';
|
||||
import SettingsTab from './components/SettingsTab.vue';
|
||||
import { computed, defineAsyncComponent, ref } from 'vue';
|
||||
import type { Component } from 'vue';
|
||||
|
||||
const AggregateTab = defineAsyncComponent(() => import('./components/AggregateTab.vue'));
|
||||
const ConvertTab = defineAsyncComponent(() => import('./components/ConvertTab.vue'));
|
||||
const DedupTab = defineAsyncComponent(() => import('./components/DedupTab.vue'));
|
||||
const TraditionalFilterTab = defineAsyncComponent(() => import('./components/TraditionalFilterTab.vue'));
|
||||
const RenameTab = defineAsyncComponent(() => import('./components/RenameTab.vue'));
|
||||
const MergeTab = defineAsyncComponent(() => import('./components/MergeTab.vue'));
|
||||
const SettingsTab = defineAsyncComponent(() => import('./components/SettingsTab.vue'));
|
||||
|
||||
type TabKey =
|
||||
| 'aggregate'
|
||||
@@ -53,70 +59,85 @@ type TabKey =
|
||||
| 'merge'
|
||||
| 'settings';
|
||||
|
||||
interface TabDefinition {
|
||||
key: TabKey;
|
||||
menuLabel: string;
|
||||
badge: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
component: Component;
|
||||
}
|
||||
|
||||
const tabs: TabDefinition[] = [
|
||||
{
|
||||
key: 'aggregate',
|
||||
menuLabel: '01 音频文件汇聚',
|
||||
badge: 'STEP 01',
|
||||
title: '01 · 音频文件汇聚',
|
||||
subtitle: '将分散音频扁平化汇聚,为后续处理统一入口。',
|
||||
component: AggregateTab
|
||||
},
|
||||
{
|
||||
key: 'convert',
|
||||
menuLabel: '02 音频格式智能处理',
|
||||
badge: 'STEP 02',
|
||||
title: '02 · 音频格式智能处理',
|
||||
subtitle: '智能识别无损/有损格式并统一转码为 FLAC。',
|
||||
component: ConvertTab
|
||||
},
|
||||
{
|
||||
key: 'dedup',
|
||||
menuLabel: '03 音乐去重',
|
||||
badge: 'STEP 03',
|
||||
title: '03 · 音乐去重',
|
||||
subtitle: '基于 MD5 与元数据的双重策略进行音乐去重。',
|
||||
component: DedupTab
|
||||
},
|
||||
{
|
||||
key: 'zhconvert',
|
||||
menuLabel: '04 元数据繁简转换',
|
||||
badge: 'STEP 04',
|
||||
title: '04 · 音乐元数据繁体转简体',
|
||||
subtitle: '批量检测并转换标签中的繁体中文。',
|
||||
component: TraditionalFilterTab
|
||||
},
|
||||
{
|
||||
key: 'organize',
|
||||
menuLabel: '05 音乐整理',
|
||||
badge: 'STEP 05',
|
||||
title: '05 · 音乐整理',
|
||||
subtitle: '按 Navidrome 规范重命名与整理目录结构。',
|
||||
component: RenameTab
|
||||
},
|
||||
{
|
||||
key: 'merge',
|
||||
menuLabel: '06 整理入库',
|
||||
badge: 'STEP 06',
|
||||
title: '06 · 整理入库',
|
||||
subtitle: '将整理好的 staging 目录智能合并入主库。',
|
||||
component: MergeTab
|
||||
},
|
||||
{
|
||||
key: 'settings',
|
||||
menuLabel: '全局设置',
|
||||
badge: 'CONFIG',
|
||||
title: '全局配置与路径设置',
|
||||
subtitle: '配置全局工作目录与各阶段标准子目录。',
|
||||
component: SettingsTab
|
||||
}
|
||||
];
|
||||
|
||||
const tabMap = tabs.reduce((acc, tab) => {
|
||||
acc[tab.key] = tab;
|
||||
return acc;
|
||||
}, {} as Record<TabKey, TabDefinition>);
|
||||
|
||||
const activeKey = ref<TabKey>('aggregate');
|
||||
|
||||
const currentComponent = computed(() => {
|
||||
switch (activeKey.value) {
|
||||
case 'aggregate':
|
||||
return AggregateTab;
|
||||
case 'convert':
|
||||
return ConvertTab;
|
||||
case 'dedup':
|
||||
return DedupTab;
|
||||
case 'zhconvert':
|
||||
return TraditionalFilterTab;
|
||||
case 'organize':
|
||||
return RenameTab;
|
||||
case 'merge':
|
||||
return MergeTab;
|
||||
case 'settings':
|
||||
return SettingsTab;
|
||||
default:
|
||||
return AggregateTab;
|
||||
}
|
||||
});
|
||||
|
||||
const currentTitle = computed(() => {
|
||||
switch (activeKey.value) {
|
||||
case 'aggregate':
|
||||
return '01 · 音频文件汇聚';
|
||||
case 'convert':
|
||||
return '02 · 音频格式智能处理';
|
||||
case 'dedup':
|
||||
return '03 · 音乐去重';
|
||||
case 'zhconvert':
|
||||
return '04 · 音乐元数据繁体转简体';
|
||||
case 'organize':
|
||||
return '05 · 音乐整理';
|
||||
case 'merge':
|
||||
return '06 · 整理入库';
|
||||
case 'settings':
|
||||
return '全局配置与路径设置';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
const currentSubtitle = computed(() => {
|
||||
switch (activeKey.value) {
|
||||
case 'aggregate':
|
||||
return '将分散音频扁平化汇聚,为后续处理统一入口。';
|
||||
case 'convert':
|
||||
return '智能识别无损/有损格式并统一转码为 FLAC。';
|
||||
case 'dedup':
|
||||
return '基于 MD5 与元数据的双重策略进行音乐去重。';
|
||||
case 'zhconvert':
|
||||
return '批量检测并转换标签中的繁体中文。';
|
||||
case 'organize':
|
||||
return '按 Navidrome 规范重命名与整理目录结构。';
|
||||
case 'merge':
|
||||
return '将整理好的 staging 目录智能合并入主库。';
|
||||
case 'settings':
|
||||
return '配置全局工作目录与各阶段标准子目录。';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
const currentTab = computed(() => tabMap[activeKey.value] || tabs[0]);
|
||||
const currentComponent = computed(() => currentTab.value.component);
|
||||
const currentTitle = computed(() => currentTab.value.title);
|
||||
const currentSubtitle = computed(() => currentTab.value.subtitle);
|
||||
|
||||
function handleSelect(key: string) {
|
||||
activeKey.value = key as TabKey;
|
||||
@@ -125,59 +146,157 @@ function handleSelect(key: string) {
|
||||
|
||||
<style scoped>
|
||||
.app-root {
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
padding: 18px;
|
||||
background:
|
||||
radial-gradient(circle at 12% 12%, rgba(15, 118, 110, 0.13), transparent 45%),
|
||||
radial-gradient(circle at 78% 4%, rgba(14, 116, 144, 0.16), transparent 40%),
|
||||
linear-gradient(145deg, #f8fbff 0%, #f3f7fd 42%, #eef5fa 100%);
|
||||
}
|
||||
|
||||
.app-aside {
|
||||
border-right: 1px solid #e5e7eb;
|
||||
padding: 16px 0;
|
||||
margin-right: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
border-radius: 20px;
|
||||
padding: 18px 12px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow:
|
||||
0 20px 45px -30px rgba(15, 23, 42, 0.45),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
padding: 0 20px 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
margin-bottom: 12px;
|
||||
padding: 2px 14px 16px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.26);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.app-logo-title {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.app-logo-sub {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
letter-spacing: 1.3px;
|
||||
text-transform: uppercase;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.app-menu {
|
||||
flex: 1;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.app-menu :deep(.el-menu-item) {
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
border-radius: 11px;
|
||||
margin: 2px 6px;
|
||||
color: #334155;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.app-menu :deep(.el-menu-item:hover) {
|
||||
color: #0f172a;
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
}
|
||||
|
||||
.app-menu :deep(.el-menu-item.is-active) {
|
||||
color: #0f766e;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(95deg, rgba(15, 118, 110, 0.14), rgba(14, 116, 144, 0.07));
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
justify-content: space-between;
|
||||
min-height: 88px;
|
||||
padding: 0 26px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 16px 35px -30px rgba(15, 23, 42, 0.45);
|
||||
}
|
||||
|
||||
.app-header-title h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.app-header-title p {
|
||||
margin: 4px 0 0;
|
||||
margin: 6px 0 0;
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.app-header-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.app-header-index {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.8px;
|
||||
color: #0b5f59;
|
||||
background: rgba(15, 118, 110, 0.12);
|
||||
}
|
||||
|
||||
.app-header-helper {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding: 16px;
|
||||
background: #f3f4f6;
|
||||
margin-top: 14px;
|
||||
border-radius: 18px;
|
||||
padding: 20px;
|
||||
background: rgba(255, 255, 255, 0.66);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.app-root {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.app-aside {
|
||||
width: 100% !important;
|
||||
margin-right: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
padding: 16px;
|
||||
min-height: auto;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
margin-top: 10px;
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -10,9 +10,18 @@ export interface ProgressMessage {
|
||||
currentFile: string;
|
||||
message: string;
|
||||
completed: boolean;
|
||||
duplicateGroups?: number;
|
||||
movedFiles?: number;
|
||||
organizedFiles?: number;
|
||||
manualFixFiles?: number;
|
||||
traditionalEntries?: number;
|
||||
failedFiles?: number;
|
||||
albumsMerged?: number;
|
||||
tracksMerged?: number;
|
||||
upgradedFiles?: number;
|
||||
skippedFiles?: number;
|
||||
}
|
||||
|
||||
export function getProgress(taskId: string): Promise<ProgressMessage | null> {
|
||||
return request.get(`/api/progress/${taskId}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,7 @@
|
||||
<el-col :xs="24" :sm="24" :md="12" :lg="10">
|
||||
<el-card class="config-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><Setting /></el-icon>
|
||||
<span class="card-title">任务配置</span>
|
||||
</div>
|
||||
<task-card-header :icon="Setting" title="任务配置" />
|
||||
</template>
|
||||
|
||||
<el-form :model="form" label-width="100px" label-position="left">
|
||||
@@ -77,40 +74,7 @@
|
||||
<el-col :xs="24" :sm="24" :md="12" :lg="14">
|
||||
<el-card class="progress-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><DataLine /></el-icon>
|
||||
<span class="card-title">任务进度</span>
|
||||
<el-tag
|
||||
v-if="progress.taskId && progress.completed"
|
||||
type="info"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><CircleCheck /></el-icon>
|
||||
<span class="connection-text">已结束</span>
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="progress.taskId && wsConnected"
|
||||
type="success"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><Connection /></el-icon>
|
||||
<span class="connection-text">已连接</span>
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="progress.taskId && !wsConnected"
|
||||
type="warning"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><Loading /></el-icon>
|
||||
<span class="connection-text">连接中</span>
|
||||
</el-tag>
|
||||
</div>
|
||||
<task-card-header :icon="DataLine" title="任务进度" :status="connectionStatus" />
|
||||
</template>
|
||||
|
||||
<div v-if="!progress.taskId" class="empty-state">
|
||||
@@ -120,24 +84,7 @@
|
||||
|
||||
<div v-else class="progress-content">
|
||||
<!-- 统计信息 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">总数</div>
|
||||
<div class="stat-value">{{ progress.total }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">已处理</div>
|
||||
<div class="stat-value success">{{ progress.processed }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">成功</div>
|
||||
<div class="stat-value success">{{ progress.success }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">失败</div>
|
||||
<div class="stat-value failed">{{ progress.failed }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<task-stats-grid :items="progressStats" />
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="progress-section">
|
||||
@@ -187,16 +134,14 @@ import {
|
||||
VideoPlay,
|
||||
Refresh,
|
||||
DataLine,
|
||||
Connection,
|
||||
CircleCheck,
|
||||
Loading,
|
||||
Warning,
|
||||
Document
|
||||
} from '@element-plus/icons-vue';
|
||||
import { startAggregate } from '../api/aggregate';
|
||||
import { getProgress } from '../api/progress';
|
||||
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
|
||||
import { getConfig } from '../api/config';
|
||||
import TaskCardHeader, { type CardHeaderStatus } from './common/TaskCardHeader.vue';
|
||||
import TaskStatsGrid, { type StatItem } from './common/TaskStatsGrid.vue';
|
||||
|
||||
interface Progress {
|
||||
taskId: string | null;
|
||||
@@ -238,6 +183,23 @@ const percentage = computed(() => {
|
||||
return Math.round((progress.processed / progress.total) * 100);
|
||||
});
|
||||
|
||||
const connectionStatus = computed<CardHeaderStatus | null>(() => {
|
||||
if (!progress.taskId) {
|
||||
return null;
|
||||
}
|
||||
if (progress.completed) {
|
||||
return 'completed';
|
||||
}
|
||||
return wsConnected.value ? 'connected' : 'connecting';
|
||||
});
|
||||
|
||||
const progressStats = computed<StatItem[]>(() => [
|
||||
{ label: '总数', value: progress.total },
|
||||
{ label: '已处理', value: progress.processed, tone: 'success' },
|
||||
{ label: '成功', value: progress.success, tone: 'success' },
|
||||
{ label: '失败', value: progress.failed, tone: 'failed' }
|
||||
]);
|
||||
|
||||
// WebSocket 连接
|
||||
let wsDisconnect: (() => void) | null = null;
|
||||
let pollTimer: number | null = null;
|
||||
@@ -381,163 +343,5 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aggregate-container {
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.config-card,
|
||||
.progress-card {
|
||||
height: 100%;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.connection-tag {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.connection-icon {
|
||||
font-size: 12px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.connection-text {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 18px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.progress-content {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
background: var(--el-bg-color-page);
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.stat-item:hover {
|
||||
background: var(--el-bg-color);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.stat-value.success {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.stat-value.failed {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.current-file-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--el-bg-color-page);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
color: var(--el-color-primary);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.file-text {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@import '../styles/panel-shared.css';
|
||||
</style>
|
||||
|
||||
|
||||
@@ -5,10 +5,7 @@
|
||||
<el-col :xs="24" :sm="24" :md="12" :lg="10">
|
||||
<el-card class="config-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><Setting /></el-icon>
|
||||
<span class="card-title">任务配置</span>
|
||||
</div>
|
||||
<task-card-header :icon="Setting" title="任务配置" />
|
||||
</template>
|
||||
|
||||
<el-form :model="form" label-width="100px" label-position="left">
|
||||
@@ -186,40 +183,7 @@
|
||||
<el-col :xs="24" :sm="24" :md="12" :lg="14">
|
||||
<el-card class="progress-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><DataLine /></el-icon>
|
||||
<span class="card-title">任务进度</span>
|
||||
<el-tag
|
||||
v-if="progress.taskId && progress.completed"
|
||||
type="info"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><CircleCheck /></el-icon>
|
||||
<span class="connection-text">已结束</span>
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="progress.taskId && wsConnected"
|
||||
type="success"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><Connection /></el-icon>
|
||||
<span class="connection-text">已连接</span>
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="progress.taskId && !wsConnected && !progress.completed"
|
||||
type="warning"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><Loading /></el-icon>
|
||||
<span class="connection-text">连接中</span>
|
||||
</el-tag>
|
||||
</div>
|
||||
<task-card-header :icon="DataLine" title="任务进度" :status="connectionStatus" />
|
||||
</template>
|
||||
|
||||
<div v-if="!progress.taskId" class="empty-state">
|
||||
@@ -229,24 +193,7 @@
|
||||
|
||||
<div v-else class="progress-content">
|
||||
<!-- 统计信息 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">待转码</div>
|
||||
<div class="stat-value">{{ progress.total }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">已处理</div>
|
||||
<div class="stat-value success">{{ progress.processed }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">成功</div>
|
||||
<div class="stat-value success">{{ progress.success }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">失败</div>
|
||||
<div class="stat-value failed">{{ progress.failed }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<task-stats-grid :items="progressStats" />
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="progress-section">
|
||||
@@ -296,9 +243,7 @@ import {
|
||||
VideoPlay,
|
||||
Refresh,
|
||||
DataLine,
|
||||
Connection,
|
||||
CircleCheck,
|
||||
Loading,
|
||||
Warning,
|
||||
Document,
|
||||
InfoFilled,
|
||||
@@ -308,6 +253,8 @@ import { startConvert } from '../api/convert';
|
||||
import { getProgress } from '../api/progress';
|
||||
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
|
||||
import { getConfig } from '../api/config';
|
||||
import TaskCardHeader, { type CardHeaderStatus } from './common/TaskCardHeader.vue';
|
||||
import TaskStatsGrid, { type StatItem } from './common/TaskStatsGrid.vue';
|
||||
|
||||
interface Progress {
|
||||
taskId: string | null;
|
||||
@@ -351,6 +298,23 @@ const percentage = computed(() => {
|
||||
return Math.round((progress.processed / progress.total) * 100);
|
||||
});
|
||||
|
||||
const connectionStatus = computed<CardHeaderStatus | null>(() => {
|
||||
if (!progress.taskId) {
|
||||
return null;
|
||||
}
|
||||
if (progress.completed) {
|
||||
return 'completed';
|
||||
}
|
||||
return wsConnected.value ? 'connected' : 'connecting';
|
||||
});
|
||||
|
||||
const progressStats = computed<StatItem[]>(() => [
|
||||
{ label: '待转码', value: progress.total },
|
||||
{ label: '已处理', value: progress.processed, tone: 'success' },
|
||||
{ label: '成功', value: progress.success, tone: 'success' },
|
||||
{ label: '失败', value: progress.failed, tone: 'failed' }
|
||||
]);
|
||||
|
||||
let wsDisconnect: (() => void) | null = null;
|
||||
let stopWatchingConnected: (() => void) | null = null;
|
||||
let stopWatchingError: (() => void) | null = null;
|
||||
@@ -618,150 +582,7 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.convert-container {
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.config-card,
|
||||
.progress-card {
|
||||
height: 100%;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.connection-tag {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.connection-icon {
|
||||
font-size: 12px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.connection-text {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 18px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.progress-content {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
background: var(--el-bg-color-page);
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.stat-item:hover {
|
||||
background: var(--el-bg-color);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.stat-value.success {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.stat-value.failed {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.current-file-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--el-bg-color-page);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
color: var(--el-color-primary);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.file-text {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
@import '../styles/panel-shared.css';
|
||||
|
||||
.mode-help-collapse {
|
||||
border: none;
|
||||
@@ -973,15 +794,4 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,10 +5,7 @@
|
||||
<el-col :xs="24" :sm="24" :md="12" :lg="10">
|
||||
<el-card class="config-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><Setting /></el-icon>
|
||||
<span class="card-title">任务配置</span>
|
||||
</div>
|
||||
<task-card-header :icon="Setting" title="任务配置" />
|
||||
</template>
|
||||
|
||||
<el-form :model="form" label-width="100px" label-position="left">
|
||||
@@ -311,40 +308,7 @@
|
||||
<el-col :xs="24" :sm="24" :md="12" :lg="14">
|
||||
<el-card class="progress-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><DataLine /></el-icon>
|
||||
<span class="card-title">任务进度</span>
|
||||
<el-tag
|
||||
v-if="progress.taskId && progress.completed"
|
||||
type="info"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><CircleCheck /></el-icon>
|
||||
<span class="connection-text">已结束</span>
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="progress.taskId && wsConnected"
|
||||
type="success"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><Connection /></el-icon>
|
||||
<span class="connection-text">已连接</span>
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="progress.taskId && !wsConnected && !progress.completed"
|
||||
type="warning"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><Loading /></el-icon>
|
||||
<span class="connection-text">连接中</span>
|
||||
</el-tag>
|
||||
</div>
|
||||
<task-card-header :icon="DataLine" title="任务进度" :status="connectionStatus" />
|
||||
</template>
|
||||
|
||||
<div v-if="!progress.taskId" class="empty-state">
|
||||
@@ -354,26 +318,7 @@
|
||||
|
||||
<div v-else class="progress-content">
|
||||
<!-- 统计信息 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">扫描文件数</div>
|
||||
<div class="stat-value">{{ progress.scanned }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">重复组数量</div>
|
||||
<div class="stat-value success">{{ progress.duplicateGroups }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">移动/复制文件数</div>
|
||||
<div class="stat-value success">{{ progress.moved }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">完成状态</div>
|
||||
<div class="stat-value" :class="{ success: progress.completed }">
|
||||
{{ progress.completed ? '已完成' : '进行中' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<task-stats-grid :items="progressStats" />
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="progress-section">
|
||||
@@ -416,9 +361,7 @@ import {
|
||||
VideoPlay,
|
||||
Refresh,
|
||||
DataLine,
|
||||
Connection,
|
||||
CircleCheck,
|
||||
Loading,
|
||||
Document,
|
||||
InfoFilled,
|
||||
ArrowDown,
|
||||
@@ -430,6 +373,8 @@ import { startDedup } from '../api/dedup';
|
||||
import { getProgress } from '../api/progress';
|
||||
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
|
||||
import { getConfig } from '../api/config';
|
||||
import TaskCardHeader, { type CardHeaderStatus } from './common/TaskCardHeader.vue';
|
||||
import TaskStatsGrid, { type StatItem } from './common/TaskStatsGrid.vue';
|
||||
|
||||
const form = reactive({
|
||||
libraryDir: '',
|
||||
@@ -470,9 +415,39 @@ const percentage = computed(() => {
|
||||
return Math.round((scannedProcessed.value / scannedTotal.value) * 100);
|
||||
});
|
||||
|
||||
const connectionStatus = computed<CardHeaderStatus | null>(() => {
|
||||
if (!progress.taskId) {
|
||||
return null;
|
||||
}
|
||||
if (progress.completed) {
|
||||
return 'completed';
|
||||
}
|
||||
return wsConnected.value ? 'connected' : 'connecting';
|
||||
});
|
||||
|
||||
const progressStats = computed<StatItem[]>(() => [
|
||||
{ label: '扫描文件数', value: progress.scanned },
|
||||
{ label: '重复组数量', value: progress.duplicateGroups, tone: 'success' },
|
||||
{ label: '移动/复制文件数', value: progress.moved, tone: 'success' },
|
||||
{ label: '完成状态', value: progress.completed ? '已完成' : '进行中', tone: progress.completed ? 'success' : 'default' }
|
||||
]);
|
||||
|
||||
let wsDisconnect: (() => void) | null = null;
|
||||
let connectedWatchStop: (() => void) | null = null;
|
||||
let pollTimer: number | null = null;
|
||||
|
||||
function cleanupRealtime() {
|
||||
if (connectedWatchStop) {
|
||||
connectedWatchStop();
|
||||
connectedWatchStop = null;
|
||||
}
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
wsConnected.value = false;
|
||||
}
|
||||
|
||||
function startPolling(taskId: string) {
|
||||
stopPolling();
|
||||
pollTimer = window.setInterval(async () => {
|
||||
@@ -507,8 +482,8 @@ function handleProgressMessage(msg: ProgressMessage) {
|
||||
scannedProcessed.value = msg.processed;
|
||||
|
||||
progress.scanned = msg.total;
|
||||
progress.duplicateGroups = msg.success;
|
||||
progress.moved = msg.failed;
|
||||
progress.duplicateGroups = msg.duplicateGroups ?? msg.success;
|
||||
progress.moved = msg.movedFiles ?? msg.failed;
|
||||
progress.completed = msg.completed;
|
||||
progress.message = msg.message ?? '';
|
||||
}
|
||||
@@ -516,16 +491,12 @@ function handleProgressMessage(msg: ProgressMessage) {
|
||||
watch(
|
||||
() => progress.taskId,
|
||||
(newTaskId) => {
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
wsConnected.value = false;
|
||||
cleanupRealtime();
|
||||
|
||||
if (newTaskId) {
|
||||
const { connect, disconnect, connected } = useWebSocket(newTaskId, handleProgressMessage);
|
||||
wsDisconnect = disconnect;
|
||||
watch(connected, (val) => (wsConnected.value = val), { immediate: true });
|
||||
connectedWatchStop = watch(connected, (val) => (wsConnected.value = val), { immediate: true });
|
||||
connect();
|
||||
startPolling(newTaskId);
|
||||
}
|
||||
@@ -579,10 +550,7 @@ watch(
|
||||
if (done) {
|
||||
submitting.value = false;
|
||||
stopPolling();
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
cleanupRealtime();
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -593,10 +561,7 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling();
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
cleanupRealtime();
|
||||
});
|
||||
|
||||
async function loadDefaultPaths() {
|
||||
@@ -613,10 +578,7 @@ async function loadDefaultPaths() {
|
||||
|
||||
function reset() {
|
||||
stopPolling();
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
cleanupRealtime();
|
||||
form.useMd5 = false;
|
||||
form.useMetadata = true;
|
||||
form.mode = 'move';
|
||||
@@ -629,130 +591,13 @@ function reset() {
|
||||
scannedTotal.value = 0;
|
||||
scannedProcessed.value = 0;
|
||||
submitting.value = false;
|
||||
wsConnected.value = false;
|
||||
// 重新加载默认路径
|
||||
loadDefaultPaths();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dedup-container {
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.config-card,
|
||||
.progress-card {
|
||||
height: 100%;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.connection-tag {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.connection-icon {
|
||||
font-size: 12px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.connection-text {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 18px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.progress-content {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
background: var(--el-bg-color-page);
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.stat-item:hover {
|
||||
background: var(--el-bg-color);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.stat-value.success {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.message-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
@import '../styles/panel-shared.css';
|
||||
|
||||
/* 去重策略说明样式 */
|
||||
.strategy-help-collapse,
|
||||
@@ -1043,16 +888,4 @@ function reset() {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -5,10 +5,7 @@
|
||||
<el-col :xs="24" :sm="24" :md="12" :lg="10">
|
||||
<el-card class="config-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><Setting /></el-icon>
|
||||
<span class="card-title">任务配置</span>
|
||||
</div>
|
||||
<task-card-header :icon="Setting" title="任务配置" />
|
||||
</template>
|
||||
|
||||
<el-form :model="form" label-width="120px" label-position="left">
|
||||
@@ -220,40 +217,7 @@
|
||||
<el-col :xs="24" :sm="24" :md="12" :lg="14">
|
||||
<el-card class="progress-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><DataLine /></el-icon>
|
||||
<span class="card-title">任务进度</span>
|
||||
<el-tag
|
||||
v-if="progress.taskId && progress.completed"
|
||||
type="info"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><CircleCheck /></el-icon>
|
||||
<span class="connection-text">已结束</span>
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="progress.taskId && wsConnected"
|
||||
type="success"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><Connection /></el-icon>
|
||||
<span class="connection-text">已连接</span>
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="progress.taskId && !wsConnected && !progress.completed"
|
||||
type="warning"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><Loading /></el-icon>
|
||||
<span class="connection-text">连接中</span>
|
||||
</el-tag>
|
||||
</div>
|
||||
<task-card-header :icon="DataLine" title="任务进度" :status="connectionStatus" />
|
||||
</template>
|
||||
|
||||
<div v-if="!progress.taskId" class="empty-state">
|
||||
@@ -263,26 +227,7 @@
|
||||
|
||||
<div v-else class="progress-content">
|
||||
<!-- 统计信息 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">已合并专辑数</div>
|
||||
<div class="stat-value success">{{ progress.albums }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">已合并曲目数</div>
|
||||
<div class="stat-value success">{{ progress.tracks }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">升级替换文件数</div>
|
||||
<div class="stat-value">{{ progress.upgraded }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">完成状态</div>
|
||||
<div class="stat-value" :class="{ success: progress.completed }">
|
||||
{{ progress.completed ? '已完成' : '进行中' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<task-stats-grid :items="progressStats" />
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="progress-section">
|
||||
@@ -323,9 +268,7 @@ import {
|
||||
VideoPlay,
|
||||
Refresh,
|
||||
DataLine,
|
||||
Connection,
|
||||
CircleCheck,
|
||||
Loading,
|
||||
Document,
|
||||
InfoFilled,
|
||||
TrendCharts,
|
||||
@@ -339,6 +282,8 @@ import { startMerge } from '../api/merge';
|
||||
import { getProgress } from '../api/progress';
|
||||
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
|
||||
import { getConfig } from '../api/config';
|
||||
import TaskCardHeader, { type CardHeaderStatus } from './common/TaskCardHeader.vue';
|
||||
import TaskStatsGrid, { type StatItem } from './common/TaskStatsGrid.vue';
|
||||
|
||||
const form = reactive({
|
||||
srcDir: '',
|
||||
@@ -376,9 +321,39 @@ const percentage = computed(() => {
|
||||
return Math.round((processedFiles.value / totalFiles.value) * 100);
|
||||
});
|
||||
|
||||
const connectionStatus = computed<CardHeaderStatus | null>(() => {
|
||||
if (!progress.taskId) {
|
||||
return null;
|
||||
}
|
||||
if (progress.completed) {
|
||||
return 'completed';
|
||||
}
|
||||
return wsConnected.value ? 'connected' : 'connecting';
|
||||
});
|
||||
|
||||
const progressStats = computed<StatItem[]>(() => [
|
||||
{ label: '已合并专辑数', value: progress.albums, tone: 'success' },
|
||||
{ label: '已合并曲目数', value: progress.tracks, tone: 'success' },
|
||||
{ label: '升级替换文件数', value: progress.upgraded },
|
||||
{ label: '完成状态', value: progress.completed ? '已完成' : '进行中', tone: progress.completed ? 'success' : 'default' }
|
||||
]);
|
||||
|
||||
let wsDisconnect: (() => void) | null = null;
|
||||
let connectedWatchStop: (() => void) | null = null;
|
||||
let pollTimer: number | null = null;
|
||||
|
||||
function cleanupRealtime() {
|
||||
if (connectedWatchStop) {
|
||||
connectedWatchStop();
|
||||
connectedWatchStop = null;
|
||||
}
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
wsConnected.value = false;
|
||||
}
|
||||
|
||||
function startPolling(taskId: string) {
|
||||
stopPolling();
|
||||
pollTimer = window.setInterval(async () => {
|
||||
@@ -409,44 +384,25 @@ function handleProgressMessage(msg: ProgressMessage) {
|
||||
}
|
||||
|
||||
// 字段映射:见后端 LibraryMergeService 注释
|
||||
// success 字段存储专辑数,failed 字段存储曲目数
|
||||
totalFiles.value = msg.total;
|
||||
processedFiles.value = msg.processed;
|
||||
|
||||
progress.albums = msg.success;
|
||||
progress.tracks = msg.failed;
|
||||
progress.albums = msg.albumsMerged ?? msg.success;
|
||||
progress.tracks = msg.tracksMerged ?? msg.failed;
|
||||
progress.upgraded = msg.upgradedFiles ?? progress.upgraded;
|
||||
progress.completed = msg.completed;
|
||||
progress.message = msg.message ?? '';
|
||||
|
||||
// 从消息中提取升级数量(如果消息中包含)
|
||||
if (msg.message) {
|
||||
// 匹配 "升级: 5" 或 "升级:5" 或 "(升级: 5)" 等格式
|
||||
const upgradeMatch = msg.message.match(/升级[::]\s*(\d+)/);
|
||||
if (upgradeMatch) {
|
||||
progress.upgraded = parseInt(upgradeMatch[1], 10);
|
||||
} else {
|
||||
// 如果没有找到,尝试从完成消息中提取
|
||||
const finalMatch = msg.message.match(/升级[::]\s*(\d+)/);
|
||||
if (finalMatch) {
|
||||
progress.upgraded = parseInt(finalMatch[1], 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => progress.taskId,
|
||||
(newTaskId) => {
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
wsConnected.value = false;
|
||||
cleanupRealtime();
|
||||
|
||||
if (newTaskId) {
|
||||
const { connect, disconnect, connected } = useWebSocket(newTaskId, handleProgressMessage);
|
||||
wsDisconnect = disconnect;
|
||||
watch(connected, (val) => (wsConnected.value = val), { immediate: true });
|
||||
connectedWatchStop = watch(connected, (val) => (wsConnected.value = val), { immediate: true });
|
||||
connect();
|
||||
startPolling(newTaskId);
|
||||
}
|
||||
@@ -494,10 +450,7 @@ watch(
|
||||
if (done) {
|
||||
submitting.value = false;
|
||||
stopPolling();
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
cleanupRealtime();
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -508,10 +461,7 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling();
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
cleanupRealtime();
|
||||
});
|
||||
|
||||
async function loadDefaultPaths() {
|
||||
@@ -528,10 +478,7 @@ async function loadDefaultPaths() {
|
||||
|
||||
function reset() {
|
||||
stopPolling();
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
cleanupRealtime();
|
||||
form.smartUpgrade = true;
|
||||
form.keepBackup = false;
|
||||
progress.taskId = null;
|
||||
@@ -543,130 +490,13 @@ function reset() {
|
||||
totalFiles.value = 0;
|
||||
processedFiles.value = 0;
|
||||
submitting.value = false;
|
||||
wsConnected.value = false;
|
||||
// 重新加载默认路径
|
||||
loadDefaultPaths();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.merge-container {
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.config-card,
|
||||
.progress-card {
|
||||
height: 100%;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.connection-tag {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.connection-icon {
|
||||
font-size: 12px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.connection-text {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 18px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.progress-content {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
background: var(--el-bg-color-page);
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.stat-item:hover {
|
||||
background: var(--el-bg-color);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.stat-value.success {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.message-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
@import '../styles/panel-shared.css';
|
||||
|
||||
/* 合并策略说明样式 */
|
||||
.strategy-help-collapse {
|
||||
@@ -922,16 +752,4 @@ function reset() {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -5,10 +5,7 @@
|
||||
<el-col :xs="24" :sm="24" :md="12" :lg="10">
|
||||
<el-card class="config-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><Setting /></el-icon>
|
||||
<span class="card-title">任务配置</span>
|
||||
</div>
|
||||
<task-card-header :icon="Setting" title="任务配置" />
|
||||
</template>
|
||||
|
||||
<el-form :model="form" label-width="100px" label-position="left">
|
||||
@@ -208,40 +205,7 @@
|
||||
<el-col :xs="24" :sm="24" :md="12" :lg="14">
|
||||
<el-card class="progress-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><DataLine /></el-icon>
|
||||
<span class="card-title">任务进度</span>
|
||||
<el-tag
|
||||
v-if="progress.taskId && progress.completed"
|
||||
type="info"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><CircleCheck /></el-icon>
|
||||
<span class="connection-text">已结束</span>
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="progress.taskId && wsConnected"
|
||||
type="success"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><Connection /></el-icon>
|
||||
<span class="connection-text">已连接</span>
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="progress.taskId && !wsConnected && !progress.completed"
|
||||
type="warning"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><Loading /></el-icon>
|
||||
<span class="connection-text">连接中</span>
|
||||
</el-tag>
|
||||
</div>
|
||||
<task-card-header :icon="DataLine" title="任务进度" :status="connectionStatus" />
|
||||
</template>
|
||||
|
||||
<div v-if="!progress.taskId" class="empty-state">
|
||||
@@ -250,24 +214,7 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="progress-content">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">扫描文件数</div>
|
||||
<div class="stat-value">{{ progress.total }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">已处理</div>
|
||||
<div class="stat-value">{{ progress.processed }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">整理成功</div>
|
||||
<div class="stat-value success">{{ progress.organized }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">需人工修复</div>
|
||||
<div class="stat-value" :class="{ failed: progress.manualFix > 0 }">{{ progress.manualFix }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<task-stats-grid :items="progressStats" />
|
||||
|
||||
<div class="progress-section">
|
||||
<el-progress
|
||||
@@ -311,9 +258,7 @@ import {
|
||||
VideoPlay,
|
||||
Refresh,
|
||||
DataLine,
|
||||
Connection,
|
||||
CircleCheck,
|
||||
Loading,
|
||||
Document,
|
||||
InfoFilled,
|
||||
Warning
|
||||
@@ -322,6 +267,8 @@ import { startOrganize } from '../api/organize';
|
||||
import { getProgress } from '../api/progress';
|
||||
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
|
||||
import { getConfig } from '../api/config';
|
||||
import TaskCardHeader, { type CardHeaderStatus } from './common/TaskCardHeader.vue';
|
||||
import TaskStatsGrid, { type StatItem } from './common/TaskStatsGrid.vue';
|
||||
|
||||
const form = reactive({
|
||||
srcDir: '',
|
||||
@@ -361,9 +308,39 @@ const percentage = computed(() => {
|
||||
return Math.round((progress.processed / progress.total) * 100);
|
||||
});
|
||||
|
||||
const connectionStatus = computed<CardHeaderStatus | null>(() => {
|
||||
if (!progress.taskId) {
|
||||
return null;
|
||||
}
|
||||
if (progress.completed) {
|
||||
return 'completed';
|
||||
}
|
||||
return wsConnected.value ? 'connected' : 'connecting';
|
||||
});
|
||||
|
||||
const progressStats = computed<StatItem[]>(() => [
|
||||
{ label: '扫描文件数', value: progress.total },
|
||||
{ label: '已处理', value: progress.processed },
|
||||
{ label: '整理成功', value: progress.organized, tone: 'success' },
|
||||
{ label: '需人工修复', value: progress.manualFix, tone: progress.manualFix > 0 ? 'warning' : 'default' }
|
||||
]);
|
||||
|
||||
let wsDisconnect: (() => void) | null = null;
|
||||
let connectedWatchStop: (() => void) | null = null;
|
||||
let pollTimer: number | null = null;
|
||||
|
||||
function cleanupRealtime() {
|
||||
if (connectedWatchStop) {
|
||||
connectedWatchStop();
|
||||
connectedWatchStop = null;
|
||||
}
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
wsConnected.value = false;
|
||||
}
|
||||
|
||||
function startPolling(taskId: string) {
|
||||
stopPolling();
|
||||
pollTimer = window.setInterval(async () => {
|
||||
@@ -393,8 +370,8 @@ function handleProgressMessage(msg: ProgressMessage) {
|
||||
|
||||
progress.total = msg.total;
|
||||
progress.processed = msg.processed;
|
||||
progress.organized = msg.success;
|
||||
progress.manualFix = msg.failed;
|
||||
progress.organized = msg.organizedFiles ?? msg.success;
|
||||
progress.manualFix = msg.manualFixFiles ?? msg.failed;
|
||||
progress.currentFile = msg.currentFile ?? '';
|
||||
progress.message = msg.message ?? '';
|
||||
progress.completed = msg.completed;
|
||||
@@ -412,16 +389,12 @@ function handleProgressMessage(msg: ProgressMessage) {
|
||||
watch(
|
||||
() => progress.taskId,
|
||||
(newTaskId) => {
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
wsConnected.value = false;
|
||||
cleanupRealtime();
|
||||
|
||||
if (newTaskId) {
|
||||
const { connect, disconnect, connected } = useWebSocket(newTaskId, handleProgressMessage);
|
||||
wsDisconnect = disconnect;
|
||||
watch(connected, (val) => (wsConnected.value = val), { immediate: true });
|
||||
connectedWatchStop = watch(connected, (val) => (wsConnected.value = val), { immediate: true });
|
||||
connect();
|
||||
startPolling(newTaskId);
|
||||
}
|
||||
@@ -476,10 +449,7 @@ watch(
|
||||
if (done) {
|
||||
submitting.value = false;
|
||||
stopPolling();
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
cleanupRealtime();
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -490,10 +460,7 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling();
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
cleanupRealtime();
|
||||
});
|
||||
|
||||
async function loadDefaultPaths() {
|
||||
@@ -510,10 +477,7 @@ async function loadDefaultPaths() {
|
||||
|
||||
function reset() {
|
||||
stopPolling();
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
cleanupRealtime();
|
||||
form.mode = 'strict';
|
||||
form.extractCover = true;
|
||||
form.extractLyrics = true;
|
||||
@@ -527,62 +491,13 @@ function reset() {
|
||||
progress.message = '';
|
||||
progress.completed = false;
|
||||
submitting.value = false;
|
||||
wsConnected.value = false;
|
||||
// 重新加载默认路径
|
||||
loadDefaultPaths();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.organize-container {
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.config-card,
|
||||
.progress-card {
|
||||
height: 100%;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.connection-tag {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.connection-icon {
|
||||
font-size: 12px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.connection-text {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 18px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
@import '../styles/panel-shared.css';
|
||||
|
||||
.form-tip {
|
||||
margin-top: -8px;
|
||||
@@ -728,62 +643,6 @@ function reset() {
|
||||
color: var(--el-color-warning-dark-2);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.progress-content {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
background: var(--el-bg-color-page);
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.stat-item:hover {
|
||||
background: var(--el-bg-color);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.stat-value.success {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
@@ -792,19 +651,6 @@ function reset() {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.current-file-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
@@ -816,23 +662,9 @@ function reset() {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<template>
|
||||
<el-card class="settings-root">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><Setting /></el-icon>
|
||||
<span class="card-title">全局路径配置</span>
|
||||
</div>
|
||||
<task-card-header :icon="Setting" title="全局路径配置" />
|
||||
</template>
|
||||
<el-form label-width="140px">
|
||||
<el-form-item label="工作根目录 (BasePath)" required>
|
||||
@@ -78,6 +75,7 @@ import { computed, ref, onMounted } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Setting, Folder, Check, Refresh } from '@element-plus/icons-vue';
|
||||
import { saveBasePath, getConfig } from '../api/config';
|
||||
import TaskCardHeader from './common/TaskCardHeader.vue';
|
||||
|
||||
const basePath = ref('');
|
||||
const saving = ref(false);
|
||||
@@ -206,27 +204,13 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import '../styles/panel-shared.css';
|
||||
|
||||
.settings-root {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 18px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
@@ -245,4 +229,3 @@ onMounted(() => {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -5,10 +5,7 @@
|
||||
<el-col :xs="24" :sm="24" :md="12" :lg="10">
|
||||
<el-card class="config-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><Setting /></el-icon>
|
||||
<span class="card-title">任务配置</span>
|
||||
</div>
|
||||
<task-card-header :icon="Setting" title="任务配置" />
|
||||
</template>
|
||||
|
||||
<el-form :model="form" label-width="110px" label-position="left">
|
||||
@@ -245,7 +242,7 @@
|
||||
<div class="example-scenario">
|
||||
<div class="scenario-title">
|
||||
<el-icon class="scenario-icon"><FolderOpened /></el-icon>
|
||||
<strong>场景二:输出目录不为空(复制到新目录)</strong>
|
||||
<strong>场景二:输出目录不为空(移动到新目录)</strong>
|
||||
</div>
|
||||
<div class="scenario-content">
|
||||
<div class="example-item">
|
||||
@@ -267,7 +264,7 @@
|
||||
<div class="mode-tip">
|
||||
<el-icon class="tip-icon"><Promotion /></el-icon>
|
||||
<div class="tip-content">
|
||||
<strong>使用建议</strong>:建议先用预览模式确认需要转换的文件,然后再使用执行模式。如果输出目录不为空,系统会将包含繁体的文件移动到输出目录,这样可以保留原始文件作为备份。
|
||||
<strong>使用建议</strong>:建议先用预览模式确认需要转换的文件,然后再使用执行模式。如果输出目录不为空,系统会将包含繁体的文件移动到输出目录;如需保留原始文件,请先手动备份源目录。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -307,40 +304,7 @@
|
||||
<el-col :xs="24" :sm="24" :md="12" :lg="14">
|
||||
<el-card class="progress-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><DataLine /></el-icon>
|
||||
<span class="card-title">任务进度</span>
|
||||
<el-tag
|
||||
v-if="progress.taskId && progress.completed"
|
||||
type="info"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><CircleCheck /></el-icon>
|
||||
<span class="connection-text">已结束</span>
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="progress.taskId && wsConnected"
|
||||
type="success"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><Connection /></el-icon>
|
||||
<span class="connection-text">已连接</span>
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="progress.taskId && !wsConnected && !progress.completed"
|
||||
type="warning"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><Loading /></el-icon>
|
||||
<span class="connection-text">连接中</span>
|
||||
</el-tag>
|
||||
</div>
|
||||
<task-card-header :icon="DataLine" title="任务进度" :status="connectionStatus" />
|
||||
</template>
|
||||
|
||||
<div v-if="!progress.taskId" class="empty-state">
|
||||
@@ -350,24 +314,7 @@
|
||||
|
||||
<div v-else class="progress-content">
|
||||
<!-- 统计信息 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">总文件数</div>
|
||||
<div class="stat-value">{{ progress.total }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">已扫描</div>
|
||||
<div class="stat-value success">{{ progress.processed }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">繁体标签条目</div>
|
||||
<div class="stat-value success">{{ progress.entries }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">失败文件数</div>
|
||||
<div class="stat-value failed">{{ progress.failed }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<task-stats-grid :items="progressStats" />
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="progress-section">
|
||||
@@ -414,9 +361,7 @@ import {
|
||||
VideoPlay,
|
||||
Refresh,
|
||||
DataLine,
|
||||
Connection,
|
||||
CircleCheck,
|
||||
Loading,
|
||||
Document,
|
||||
InfoFilled,
|
||||
ArrowDown,
|
||||
@@ -430,6 +375,8 @@ import { startZhConvert } from '../api/zhconvert';
|
||||
import { getProgress } from '../api/progress';
|
||||
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
|
||||
import { getConfig } from '../api/config';
|
||||
import TaskCardHeader, { type CardHeaderStatus } from './common/TaskCardHeader.vue';
|
||||
import TaskStatsGrid, { type StatItem } from './common/TaskStatsGrid.vue';
|
||||
|
||||
interface ProgressState {
|
||||
taskId: string | null;
|
||||
@@ -474,9 +421,39 @@ const percentage = computed(() => {
|
||||
return Math.round((progress.processed / progress.total) * 100);
|
||||
});
|
||||
|
||||
const connectionStatus = computed<CardHeaderStatus | null>(() => {
|
||||
if (!progress.taskId) {
|
||||
return null;
|
||||
}
|
||||
if (progress.completed) {
|
||||
return 'completed';
|
||||
}
|
||||
return wsConnected.value ? 'connected' : 'connecting';
|
||||
});
|
||||
|
||||
const progressStats = computed<StatItem[]>(() => [
|
||||
{ label: '总文件数', value: progress.total },
|
||||
{ label: '已扫描', value: progress.processed, tone: 'success' },
|
||||
{ label: '繁体标签条目', value: progress.entries, tone: 'success' },
|
||||
{ label: '失败文件数', value: progress.failed, tone: 'failed' }
|
||||
]);
|
||||
|
||||
let wsDisconnect: (() => void) | null = null;
|
||||
let connectedWatchStop: (() => void) | null = null;
|
||||
let pollTimer: number | null = null;
|
||||
|
||||
function cleanupRealtime() {
|
||||
if (connectedWatchStop) {
|
||||
connectedWatchStop();
|
||||
connectedWatchStop = null;
|
||||
}
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
wsConnected.value = false;
|
||||
}
|
||||
|
||||
function startPolling(taskId: string) {
|
||||
stopPolling();
|
||||
pollTimer = window.setInterval(async () => {
|
||||
@@ -507,8 +484,8 @@ function handleProgressMessage(msg: ProgressMessage) {
|
||||
}
|
||||
progress.total = msg.total;
|
||||
progress.processed = msg.processed;
|
||||
progress.entries = msg.success;
|
||||
progress.failed = msg.failed;
|
||||
progress.entries = msg.traditionalEntries ?? msg.success;
|
||||
progress.failed = msg.failedFiles ?? msg.failed;
|
||||
progress.currentFile = msg.currentFile || '';
|
||||
progress.message = msg.message || '';
|
||||
progress.completed = msg.completed;
|
||||
@@ -526,16 +503,12 @@ function handleProgressMessage(msg: ProgressMessage) {
|
||||
watch(
|
||||
() => progress.taskId,
|
||||
(newTaskId) => {
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
wsConnected.value = false;
|
||||
cleanupRealtime();
|
||||
|
||||
if (newTaskId) {
|
||||
const { connect, disconnect, connected } = useWebSocket(newTaskId, handleProgressMessage);
|
||||
wsDisconnect = disconnect;
|
||||
watch(connected, (val) => (wsConnected.value = val), { immediate: true });
|
||||
connectedWatchStop = watch(connected, (val) => (wsConnected.value = val), { immediate: true });
|
||||
connect();
|
||||
startPolling(newTaskId);
|
||||
}
|
||||
@@ -592,11 +565,8 @@ async function loadDefaultPaths() {
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
stopPolling();
|
||||
cleanupRealtime();
|
||||
form.threshold = 10;
|
||||
form.mode = 'preview';
|
||||
progress.taskId = null;
|
||||
@@ -608,7 +578,6 @@ function reset() {
|
||||
progress.message = '';
|
||||
progress.completed = false;
|
||||
submitting.value = false;
|
||||
wsConnected.value = false;
|
||||
// 重新加载默认路径
|
||||
loadDefaultPaths();
|
||||
}
|
||||
@@ -618,159 +587,13 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
stopPolling();
|
||||
cleanupRealtime();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.zhconvert-container {
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.config-card,
|
||||
.progress-card {
|
||||
height: 100%;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 18px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.connection-tag {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.connection-icon {
|
||||
font-size: 12px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.connection-text {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.progress-content {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
background: var(--el-bg-color-page);
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.stat-item:hover {
|
||||
background: var(--el-bg-color);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.stat-value.success {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.stat-value.failed {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.current-file-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--el-bg-color-page);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
color: var(--el-color-primary);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.file-text {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
@import '../styles/panel-shared.css';
|
||||
|
||||
/* 繁体占比阈值说明样式 */
|
||||
.threshold-help-collapse,
|
||||
@@ -1101,15 +924,4 @@ onUnmounted(() => {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
71
frontend/src/components/common/TaskCardHeader.vue
Normal file
71
frontend/src/components/common/TaskCardHeader.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><component :is="icon" /></el-icon>
|
||||
<span class="card-title">{{ title }}</span>
|
||||
<el-tag
|
||||
v-if="status"
|
||||
:type="tagType"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><component :is="statusIcon" /></el-icon>
|
||||
<span class="connection-text">{{ statusText }}</span>
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Component } from 'vue';
|
||||
import { CircleCheck, Connection, Loading } from '@element-plus/icons-vue';
|
||||
|
||||
export type CardHeaderStatus = 'completed' | 'connected' | 'connecting';
|
||||
|
||||
interface Props {
|
||||
icon: Component;
|
||||
title: string;
|
||||
status?: CardHeaderStatus | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const statusTextMap: Record<CardHeaderStatus, string> = {
|
||||
completed: '已结束',
|
||||
connected: '已连接',
|
||||
connecting: '连接中'
|
||||
};
|
||||
|
||||
const statusTagTypeMap: Record<CardHeaderStatus, 'info' | 'success' | 'warning'> = {
|
||||
completed: 'info',
|
||||
connected: 'success',
|
||||
connecting: 'warning'
|
||||
};
|
||||
|
||||
const statusIconMap: Record<CardHeaderStatus, Component> = {
|
||||
completed: CircleCheck,
|
||||
connected: Connection,
|
||||
connecting: Loading
|
||||
};
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (!props.status) {
|
||||
return '';
|
||||
}
|
||||
return statusTextMap[props.status];
|
||||
});
|
||||
|
||||
const tagType = computed(() => {
|
||||
if (!props.status) {
|
||||
return 'info';
|
||||
}
|
||||
return statusTagTypeMap[props.status];
|
||||
});
|
||||
|
||||
const statusIcon = computed(() => {
|
||||
if (!props.status) {
|
||||
return CircleCheck;
|
||||
}
|
||||
return statusIconMap[props.status];
|
||||
});
|
||||
</script>
|
||||
24
frontend/src/components/common/TaskStatsGrid.vue
Normal file
24
frontend/src/components/common/TaskStatsGrid.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div class="stats-grid">
|
||||
<div v-for="(item, index) in items" :key="`${item.label}-${index}`" class="stat-item">
|
||||
<div class="stat-label">{{ item.label }}</div>
|
||||
<div class="stat-value" :class="item.tone && item.tone !== 'default' ? item.tone : ''">
|
||||
{{ item.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
export interface StatItem {
|
||||
label: string;
|
||||
value: number | string;
|
||||
tone?: 'default' | 'success' | 'failed' | 'warning';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: StatItem[];
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
@@ -12,6 +12,16 @@ export interface ProgressMessage {
|
||||
currentFile: string;
|
||||
message: string;
|
||||
completed: boolean;
|
||||
duplicateGroups?: number;
|
||||
movedFiles?: number;
|
||||
organizedFiles?: number;
|
||||
manualFixFiles?: number;
|
||||
traditionalEntries?: number;
|
||||
failedFiles?: number;
|
||||
albumsMerged?: number;
|
||||
tracksMerged?: number;
|
||||
upgradedFiles?: number;
|
||||
skippedFiles?: number;
|
||||
}
|
||||
|
||||
export function useWebSocket(taskId: string | null, onMessage: (msg: ProgressMessage) => void) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPinia } from 'pinia';
|
||||
import ElementPlus from 'element-plus';
|
||||
import 'element-plus/dist/index.css';
|
||||
import './styles/reset.css';
|
||||
import './styles/theme.css';
|
||||
import App from './App.vue';
|
||||
|
||||
const app = createApp(App);
|
||||
@@ -11,4 +12,3 @@ app.use(createPinia());
|
||||
app.use(ElementPlus);
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
|
||||
185
frontend/src/styles/panel-shared.css
Normal file
185
frontend/src/styles/panel-shared.css
Normal file
@@ -0,0 +1,185 @@
|
||||
.aggregate-container,
|
||||
.convert-container,
|
||||
.dedup-container,
|
||||
.zhconvert-container,
|
||||
.organize-container,
|
||||
.filter-container,
|
||||
.rename-container,
|
||||
.merge-container {
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
animation: panelFadeIn 0.42s ease-out;
|
||||
}
|
||||
|
||||
.config-card,
|
||||
.progress-card {
|
||||
height: 100%;
|
||||
min-height: 500px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.connection-tag {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.connection-icon {
|
||||
font-size: 12px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.connection-text {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 18px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2px;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.progress-content {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: linear-gradient(155deg, rgba(255, 255, 255, 0.95), rgba(241, 247, 252, 0.85));
|
||||
transition: all 0.22s ease;
|
||||
}
|
||||
|
||||
.stat-item:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(13, 148, 136, 0.32);
|
||||
box-shadow: 0 14px 30px -28px rgba(15, 23, 42, 0.55);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.stat-value.success {
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.stat-value.failed {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.stat-value.warning {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.current-file-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
background: rgba(244, 249, 253, 0.84);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
color: var(--el-color-primary);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.file-text {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes panelFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
76
frontend/src/styles/theme.css
Normal file
76
frontend/src/styles/theme.css
Normal file
@@ -0,0 +1,76 @@
|
||||
:root {
|
||||
--brand-50: #f0fdfa;
|
||||
--brand-100: #ccfbf1;
|
||||
--brand-200: #99f6e4;
|
||||
--brand-500: #14b8a6;
|
||||
--brand-600: #0d9488;
|
||||
--brand-700: #0f766e;
|
||||
--ink-900: #0f172a;
|
||||
--ink-700: #334155;
|
||||
--ink-500: #64748b;
|
||||
|
||||
--el-color-primary: var(--brand-600);
|
||||
--el-color-primary-light-3: #2fb9ae;
|
||||
--el-color-primary-light-5: #5ac7bf;
|
||||
--el-color-primary-light-7: #8ad9d3;
|
||||
--el-color-primary-light-8: #a9e5e1;
|
||||
--el-color-primary-light-9: #daf4f2;
|
||||
--el-color-primary-dark-2: #0c857a;
|
||||
|
||||
--el-bg-color: rgba(255, 255, 255, 0.92);
|
||||
--el-bg-color-page: #f3f7fb;
|
||||
--el-text-color-primary: var(--ink-900);
|
||||
--el-text-color-regular: var(--ink-700);
|
||||
--el-text-color-secondary: var(--ink-500);
|
||||
--el-border-color: #d6dee8;
|
||||
--el-border-color-light: #e5ebf2;
|
||||
--el-border-color-lighter: #edf2f7;
|
||||
|
||||
--el-border-radius-base: 12px;
|
||||
--el-border-radius-small: 10px;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--ink-900);
|
||||
font-family: 'Avenir Next', 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
|
||||
#app {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.el-card {
|
||||
border: 1px solid rgba(148, 163, 184, 0.24) !important;
|
||||
box-shadow: 0 20px 40px -36px rgba(15, 23, 42, 0.5) !important;
|
||||
background: rgba(255, 255, 255, 0.88) !important;
|
||||
}
|
||||
|
||||
.el-card:hover {
|
||||
box-shadow: 0 26px 48px -36px rgba(15, 23, 42, 0.56) !important;
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
background: linear-gradient(120deg, #0d9488, #0e7490) !important;
|
||||
border-color: transparent !important;
|
||||
}
|
||||
|
||||
.el-button--primary:hover {
|
||||
opacity: 0.93;
|
||||
}
|
||||
|
||||
.el-input__wrapper,
|
||||
.el-textarea__inner,
|
||||
.el-select__wrapper {
|
||||
background-color: rgba(255, 255, 255, 0.82) !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user