提交代码

This commit is contained in:
liu
2026-01-29 18:26:02 +08:00
parent 981b4ecf42
commit 7531b6c466
47 changed files with 7257 additions and 16 deletions

19
frontend/index.html Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>MangTool 音乐工具箱</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
// Polyfill for sockjs-client
if (typeof global === 'undefined') {
var global = globalThis;
}
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2085
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "mangtool-frontend",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "echo \"no linter configured yet\""
},
"dependencies": {
"axios": "^1.7.7",
"element-plus": "^2.8.8",
"pinia": "^2.2.6",
"sockjs-client": "^1.6.1",
"stompjs": "^2.3.3",
"vue": "^3.5.12"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.4",
"vite": "^5.4.8"
}
}

183
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,183 @@
<template>
<el-container class="app-root">
<el-aside width="220px" class="app-aside">
<div class="app-logo">
<span class="app-logo-title">MangTool</span>
<span class="app-logo-sub">音乐工具箱</span>
</div>
<el-menu
:default-active="activeKey"
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>
</el-aside>
<el-container>
<el-header class="app-header">
<div class="app-header-title">
<h1>{{ currentTitle }}</h1>
<p>{{ currentSubtitle }}</p>
</div>
</el-header>
<el-main class="app-main">
<component :is="currentComponent" />
</el-main>
</el-container>
</el-container>
</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';
type TabKey =
| 'aggregate'
| 'convert'
| 'dedup'
| 'zhconvert'
| 'organize'
| 'merge'
| 'settings';
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 '';
}
});
function handleSelect(key: string) {
activeKey.value = key as TabKey;
}
</script>
<style scoped>
.app-root {
height: 100vh;
}
.app-aside {
border-right: 1px solid #e5e7eb;
padding: 16px 0;
display: flex;
flex-direction: column;
}
.app-logo {
padding: 0 20px 12px;
border-bottom: 1px solid #e5e7eb;
margin-bottom: 12px;
}
.app-logo-title {
display: block;
font-weight: 600;
font-size: 18px;
}
.app-logo-sub {
display: block;
font-size: 12px;
color: #6b7280;
}
.app-menu {
border-right: none;
}
.app-header {
display: flex;
align-items: center;
border-bottom: 1px solid #e5e7eb;
}
.app-header-title h1 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.app-header-title p {
margin: 4px 0 0;
font-size: 13px;
color: #6b7280;
}
.app-main {
padding: 16px;
background: #f3f4f6;
}
</style>

View File

@@ -0,0 +1,18 @@
import request from './request';
export interface AggregateRequest {
srcDir: string;
dstDir: string;
mode: 'copy' | 'move';
}
export interface AggregateResponse {
taskId: string;
}
/**
* 启动音频文件汇聚任务
*/
export function startAggregate(params: AggregateRequest): Promise<AggregateResponse> {
return request.post('/api/aggregate/start', params);
}

View File

@@ -0,0 +1,18 @@
import request from './request';
export interface ConvertRequest {
srcDir: string;
dstDir: string;
mode: 'copy' | 'move';
}
export interface ConvertResponse {
taskId: string;
}
/**
* 启动音频格式智能处理(转码)任务
*/
export function startConvert(params: ConvertRequest): Promise<ConvertResponse> {
return request.post('/api/convert/start', params);
}

21
frontend/src/api/dedup.ts Normal file
View File

@@ -0,0 +1,21 @@
import request from './request';
export interface DedupRequest {
libraryDir: string;
trashDir: string;
useMd5: boolean;
useMetadata: boolean;
mode: 'copy' | 'move';
}
export interface DedupResponse {
taskId: string;
}
/**
* 启动音乐去重任务
*/
export function startDedup(params: DedupRequest): Promise<DedupResponse> {
return request.post('/api/dedup/start', params);
}

View File

@@ -0,0 +1,18 @@
import request from './request';
export interface ProgressMessage {
taskId: string;
type: string;
total: number;
processed: number;
success: number;
failed: number;
currentFile: string;
message: string;
completed: boolean;
}
export function getProgress(taskId: string): Promise<ProgressMessage | null> {
return request.get(`/api/progress/${taskId}`);
}

View File

@@ -0,0 +1,36 @@
import axios from 'axios';
import { ElMessage } from 'element-plus';
const request = axios.create({
baseURL: 'http://localhost:8080',
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
});
// 响应拦截器:统一处理 Result<T> 格式
request.interceptors.response.use(
(response) => {
const result = response.data;
// 如果后端返回的是 Result<T> 格式
if (result && typeof result === 'object' && 'code' in result) {
if (result.code === 0) {
// 成功,直接返回 data
return result.data;
} else {
// 失败,显示错误信息并 reject
ElMessage.error(result.message || '请求失败');
return Promise.reject(new Error(result.message || '请求失败'));
}
}
// 如果不是 Result 格式,直接返回
return result;
},
(error) => {
ElMessage.error(error.message || '网络错误');
return Promise.reject(error);
}
);
export default request;

View File

@@ -0,0 +1,20 @@
import request from './request';
export interface ZhConvertRequest {
scanDir: string;
outputDir?: string;
threshold: number;
mode: 'preview' | 'execute';
}
export interface ZhConvertResponse {
taskId: string;
}
/**
* 启动音乐元数据繁体转简体任务
*/
export function startZhConvert(params: ZhConvertRequest): Promise<ZhConvertResponse> {
return request.post('/api/zhconvert/start', params);
}

View File

@@ -0,0 +1,526 @@
<template>
<div class="aggregate-container">
<el-row :gutter="24">
<!-- 任务配置卡片 -->
<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>
</template>
<el-form :model="form" label-width="100px" label-position="left">
<el-form-item label="源目录" required>
<el-input
v-model="form.srcDir"
placeholder="选择需要递归扫描的起始目录"
clearable
>
<template #prefix>
<el-icon><Folder /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="目标目录" required>
<el-input
v-model="form.dstDir"
placeholder="汇聚后的扁平目录"
clearable
>
<template #prefix>
<el-icon><FolderOpened /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="模式">
<el-radio-group v-model="form.mode" size="default">
<el-radio-button value="copy">
<el-icon><DocumentCopy /></el-icon>
<span style="margin-left: 4px">复制模式</span>
</el-radio-button>
<el-radio-button value="move">
<el-icon><Right /></el-icon>
<span style="margin-left: 4px">移动模式</span>
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="submitting"
:disabled="!canStart"
@click="startTask"
size="large"
style="width: 100%"
>
<el-icon v-if="!submitting"><VideoPlay /></el-icon>
<span style="margin-left: 4px">{{ submitting ? '处理中...' : '开始汇聚' }}</span>
</el-button>
</el-form-item>
<el-form-item>
<el-button @click="reset" size="default" style="width: 100%">
<el-icon><Refresh /></el-icon>
<span style="margin-left: 4px">重置</span>
</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
<!-- 任务进度卡片 -->
<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>
</template>
<div v-if="!progress.taskId" class="empty-state">
<el-icon class="empty-icon"><Document /></el-icon>
<p class="empty-text">等待开始任务...</p>
</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 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>
<!-- 进度条 -->
<div class="progress-section">
<el-progress
:percentage="percentage"
:status="progress.completed ? 'success' : progress.failed > 0 ? 'exception' : undefined"
:stroke-width="12"
:show-text="true"
:format="formatProgress"
/>
</div>
<!-- 当前文件 -->
<div v-if="progress.currentFile" class="current-file-section">
<el-icon class="file-icon"><Document /></el-icon>
<span class="file-text">{{ progress.currentFile }}</span>
</div>
<!-- 消息提示 -->
<div v-if="progress.message" class="message-section">
<el-alert
:type="progress.completed ? (progress.failed > 0 ? 'warning' : 'success') : 'info'"
:closable="false"
show-icon
>
<template #title>
<span>{{ progress.message }}</span>
</template>
</el-alert>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch, onUnmounted } from 'vue';
import { ElMessage } from 'element-plus';
import {
Setting,
Folder,
FolderOpened,
DocumentCopy,
Right,
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';
interface Progress {
taskId: string | null;
total: number;
processed: number;
success: number;
failed: number;
currentFile: string;
message: string;
completed: boolean;
}
const form = reactive({
srcDir: '',
dstDir: '',
mode: 'copy' as 'copy' | 'move'
});
const submitting = ref(false);
const wsConnected = ref(false);
const progress = reactive<Progress>({
taskId: null,
total: 0,
processed: 0,
success: 0,
failed: 0,
currentFile: '',
message: '',
completed: false
});
const canStart = computed(() => {
return form.srcDir.trim() && form.dstDir.trim() && !submitting.value;
});
const percentage = computed(() => {
if (!progress.total) return 0;
return Math.round((progress.processed / progress.total) * 100);
});
// WebSocket 连接
let wsDisconnect: (() => void) | null = null;
let pollTimer: number | null = null;
function startPolling(taskId: string) {
stopPolling();
pollTimer = window.setInterval(async () => {
try {
const latest = await getProgress(taskId);
if (latest) {
handleProgressMessage(latest);
if (latest.completed) {
stopPolling();
}
}
} catch {
// 忽略轮询错误
}
}, 800);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
watch(
() => progress.taskId,
(newTaskId) => {
if (newTaskId) {
const { connect, disconnect, connected } = useWebSocket(newTaskId, handleProgressMessage);
wsDisconnect = disconnect;
watch(connected, (val) => {
wsConnected.value = val;
});
connect();
// 兜底轮询:防止任务很快完成导致 WebSocket 消息丢失
startPolling(newTaskId);
}
}
);
function handleProgressMessage(msg: ProgressMessage) {
progress.total = msg.total;
progress.processed = msg.processed;
progress.success = msg.success;
progress.failed = msg.failed;
progress.currentFile = msg.currentFile || '';
progress.message = msg.message || '';
progress.completed = msg.completed;
if (msg.completed) {
submitting.value = false;
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
stopPolling();
}
}
async function startTask() {
if (!canStart.value) {
ElMessage.warning('请填写源目录和目标目录');
return;
}
submitting.value = true;
progress.completed = false;
progress.total = 0;
progress.processed = 0;
progress.success = 0;
progress.failed = 0;
progress.currentFile = '';
progress.message = '';
try {
const response = await startAggregate({
srcDir: form.srcDir.trim(),
dstDir: form.dstDir.trim(),
mode: form.mode
});
progress.taskId = response.taskId;
ElMessage.success('任务已启动,正在处理...');
} catch (error: any) {
submitting.value = false;
ElMessage.error(error.message || '启动任务失败');
}
}
function reset() {
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
stopPolling();
form.srcDir = '';
form.dstDir = '';
form.mode = 'copy';
progress.taskId = null;
progress.total = 0;
progress.processed = 0;
progress.success = 0;
progress.failed = 0;
progress.currentFile = '';
progress.message = '';
progress.completed = false;
submitting.value = false;
wsConnected.value = false;
}
function formatProgress(percentage: number): string {
return `${percentage}%`;
}
onUnmounted(() => {
if (wsDisconnect) {
wsDisconnect();
}
stopPolling();
});
</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;
}
}
</style>

View File

@@ -0,0 +1,638 @@
<template>
<div class="convert-container">
<el-row :gutter="24">
<!-- 任务配置卡片 -->
<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>
</template>
<el-form :model="form" label-width="100px" label-position="left">
<el-form-item label="输入目录" required>
<el-input
v-model="form.srcDir"
placeholder="待转换音频目录(递归扫描)"
clearable
>
<template #prefix>
<el-icon><Folder /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="输出目录" required>
<el-input
v-model="form.dstDir"
placeholder="FLAC 目标目录(可与输入目录相同)"
clearable
>
<template #prefix>
<el-icon><FolderOpened /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="模式">
<el-radio-group v-model="form.mode" size="default">
<el-radio-button value="copy">
<el-icon><DocumentCopy /></el-icon>
<span style="margin-left: 4px">复制模式</span>
</el-radio-button>
<el-radio-button value="move">
<el-icon><Right /></el-icon>
<span style="margin-left: 4px">移动模式</span>
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="submitting"
:disabled="!canStart"
@click="startTask"
size="large"
style="width: 100%"
>
<el-icon v-if="!submitting"><VideoPlay /></el-icon>
<span style="margin-left: 4px">{{ submitting ? '转码中...' : '开始转码' }}</span>
</el-button>
</el-form-item>
<el-form-item>
<el-button @click="reset" size="default" style="width: 100%">
<el-icon><Refresh /></el-icon>
<span style="margin-left: 4px">重置</span>
</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
<!-- 任务进度卡片 -->
<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>
</template>
<div v-if="!progress.taskId" class="empty-state">
<el-icon class="empty-icon"><Document /></el-icon>
<p class="empty-text">等待开始任务...</p>
</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 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>
<!-- 进度条 -->
<div class="progress-section">
<el-progress
:percentage="percentage"
:status="progress.completed ? 'success' : progress.failed > 0 ? 'exception' : undefined"
:stroke-width="12"
:show-text="true"
:format="formatProgress"
/>
</div>
<!-- 当前文件 -->
<div v-if="progress.currentFile" class="current-file-section">
<el-icon class="file-icon"><Document /></el-icon>
<span class="file-text">{{ progress.currentFile }}</span>
</div>
<!-- 消息提示 -->
<div v-if="progress.message" class="message-section">
<el-alert
:type="progress.completed ? (progress.failed > 0 ? 'warning' : 'success') : 'info'"
:closable="false"
show-icon
>
<template #title>
<span>{{ progress.message }}</span>
</template>
</el-alert>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch, onUnmounted } from 'vue';
import { ElMessage } from 'element-plus';
import {
Setting,
Folder,
FolderOpened,
DocumentCopy,
Right,
VideoPlay,
Refresh,
DataLine,
Connection,
CircleCheck,
Loading,
Warning,
Document
} from '@element-plus/icons-vue';
import { startConvert } from '../api/convert';
import { getProgress } from '../api/progress';
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
interface Progress {
taskId: string | null;
total: number;
processed: number;
success: number;
failed: number;
currentFile: string;
message: string;
completed: boolean;
}
const form = reactive({
srcDir: '',
dstDir: '',
mode: 'copy' as 'copy' | 'move'
});
const submitting = ref(false);
const wsConnected = ref(false);
const wsErrorShown = ref(false);
const progress = reactive<Progress>({
taskId: null,
total: 0,
processed: 0,
success: 0,
failed: 0,
currentFile: '',
message: '',
completed: false
});
const canStart = computed(() => {
return form.srcDir.trim() !== '' && form.dstDir.trim() !== '' && !submitting.value;
});
const percentage = computed(() => {
if (!progress.total) return 0;
return Math.round((progress.processed / progress.total) * 100);
});
let wsDisconnect: (() => void) | null = null;
let stopWatchingConnected: (() => void) | null = null;
let stopWatchingError: (() => void) | null = null;
let progressTimeout: number | null = null;
let pollTimer: number | null = null;
watch(
() => progress.taskId,
(newTaskId) => {
// 清理之前的连接和 watch
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
if (stopWatchingConnected) {
stopWatchingConnected();
stopWatchingConnected = null;
}
if (stopWatchingError) {
stopWatchingError();
stopWatchingError = null;
}
wsConnected.value = false;
if (newTaskId) {
const { connect, disconnect, connected, error } = useWebSocket(newTaskId, handleProgressMessage);
wsDisconnect = disconnect;
// 立即设置初始连接状态
wsConnected.value = connected.value;
// 监听连接状态变化
stopWatchingConnected = watch(connected, (val) => {
wsConnected.value = val;
if (!val && progress.taskId && !progress.completed) {
// 连接失败时的处理(不在这里显示错误,由错误监听器处理)
console.warn('WebSocket 连接状态变为未连接');
}
}, { immediate: true });
// 监听错误
stopWatchingError = watch(error, (err) => {
if (err && progress.taskId && !progress.completed) {
console.error('WebSocket 错误:', err);
// 只在首次错误时提示,避免重复提示
if (!wsErrorShown.value) {
wsErrorShown.value = true;
ElMessage.warning('WebSocket 连接失败,请检查后端服务是否运行。进度可能无法实时更新。');
}
} else if (!err) {
wsErrorShown.value = false;
}
}, { immediate: true });
// 立即连接
connect();
// 启动兜底轮询:防止任务很快完成导致 WebSocket 消息丢失
startPolling(newTaskId);
}
}
);
function startPolling(taskId: string) {
stopPolling();
pollTimer = window.setInterval(async () => {
try {
const latest = await getProgress(taskId);
if (latest) {
handleProgressMessage(latest);
if (latest.completed) {
stopPolling();
}
}
} catch {
// 忽略轮询错误,等待下一次
}
}, 800);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function handleProgressMessage(msg: ProgressMessage) {
// 清除超时定时器
if (progressTimeout) {
clearTimeout(progressTimeout);
progressTimeout = null;
}
progress.total = msg.total;
progress.processed = msg.processed;
progress.success = msg.success;
progress.failed = msg.failed;
progress.currentFile = msg.currentFile ?? '';
progress.message = msg.message ?? '';
progress.completed = msg.completed;
if (msg.completed) {
submitting.value = false;
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
if (stopWatchingConnected) {
stopWatchingConnected();
stopWatchingConnected = null;
}
stopPolling();
} else {
// 如果任务未完成设置超时保护30秒无更新则重置状态
progressTimeout = window.setTimeout(() => {
if (!progress.completed && submitting.value) {
console.warn('任务进度超时,可能后端任务已失败');
ElMessage.warning('任务进度更新超时,请检查后端日志');
submitting.value = false;
}
}, 30000);
}
}
async function startTask() {
// 验证输入目录和输出目录路径是否填写(不验证目录是否存在或是否有文件)
const srcDir = form.srcDir.trim();
const dstDir = form.dstDir.trim();
if (!srcDir || !dstDir) {
ElMessage.warning('请填写输入目录和输出目录路径');
return;
}
if (srcDir === dstDir && form.mode === 'move') {
ElMessage.warning('移动模式下,输入目录和输出目录不能相同');
return;
}
// 清理之前的超时定时器
if (progressTimeout) {
clearTimeout(progressTimeout);
progressTimeout = null;
}
submitting.value = true;
wsConnected.value = false;
progress.completed = false;
progress.taskId = null;
progress.total = 0;
progress.processed = 0;
progress.success = 0;
progress.failed = 0;
progress.currentFile = '';
progress.message = '';
try {
const response = await startConvert({
srcDir: srcDir,
dstDir: dstDir,
mode: form.mode
});
progress.taskId = response.taskId;
ElMessage.success('转码任务已启动,正在处理...');
// 设置初始超时保护如果5秒内没有收到任何进度消息提示用户
progressTimeout = window.setTimeout(() => {
if (progress.total === 0 && !progress.completed && submitting.value) {
ElMessage.warning('任务已启动,但尚未收到进度更新,请稍候...');
}
}, 5000);
} catch (err: unknown) {
submitting.value = false;
progress.taskId = null;
wsConnected.value = false;
if (progressTimeout) {
clearTimeout(progressTimeout);
progressTimeout = null;
}
ElMessage.error(err instanceof Error ? err.message : '启动任务失败');
}
}
function reset() {
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
if (stopWatchingConnected) {
stopWatchingConnected();
stopWatchingConnected = null;
}
if (stopWatchingError) {
stopWatchingError();
stopWatchingError = null;
}
if (progressTimeout) {
clearTimeout(progressTimeout);
progressTimeout = null;
}
stopPolling();
form.srcDir = '';
form.dstDir = '';
form.mode = 'copy';
progress.taskId = null;
progress.total = 0;
progress.processed = 0;
progress.success = 0;
progress.failed = 0;
progress.currentFile = '';
progress.message = '';
progress.completed = false;
submitting.value = false;
wsConnected.value = false;
wsErrorShown.value = false;
}
function formatProgress(percentage: number): string {
return `${percentage}%`;
}
onUnmounted(() => {
if (wsDisconnect) {
wsDisconnect();
}
if (stopWatchingConnected) {
stopWatchingConnected();
}
if (stopWatchingError) {
stopWatchingError();
}
if (progressTimeout) {
clearTimeout(progressTimeout);
}
stopPolling();
});
</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;
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,516 @@
<template>
<div class="dedup-container">
<el-row :gutter="24">
<!-- 任务配置卡片 -->
<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>
</template>
<el-form :model="form" label-width="100px" label-position="left">
<el-form-item label="音乐库目录" required>
<el-input
v-model="form.libraryDir"
placeholder="音乐库根目录(递归扫描)"
clearable
>
<template #prefix>
<el-icon><Folder /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="回收站目录" required>
<el-input
v-model="form.trashDir"
placeholder="重复文件移动/复制目录"
clearable
>
<template #prefix>
<el-icon><FolderOpened /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="去重策略">
<el-checkbox v-model="form.useMd5">启用 MD5 去重</el-checkbox>
<el-checkbox v-model="form.useMetadata">启用元数据匹配</el-checkbox>
</el-form-item>
<el-form-item label="执行模式">
<el-radio-group v-model="form.mode" size="default">
<el-radio-button value="copy">
<el-icon><DocumentCopy /></el-icon>
<span style="margin-left: 4px">复制模式</span>
</el-radio-button>
<el-radio-button value="move">
<el-icon><Right /></el-icon>
<span style="margin-left: 4px">移动模式</span>
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="submitting"
:disabled="!canStart"
@click="startTask"
size="large"
style="width: 100%"
>
<el-icon v-if="!submitting"><VideoPlay /></el-icon>
<span style="margin-left: 4px">{{ submitting ? '去重中...' : '开始去重' }}</span>
</el-button>
</el-form-item>
<el-form-item>
<el-button @click="reset" size="default" style="width: 100%">
<el-icon><Refresh /></el-icon>
<span style="margin-left: 4px">重置</span>
</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
<!-- 任务进度卡片 -->
<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>
</template>
<div v-if="!progress.taskId" class="empty-state">
<el-icon class="empty-icon"><Document /></el-icon>
<p class="empty-text">等待开始任务...</p>
</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.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>
<!-- 进度条 -->
<div class="progress-section">
<el-progress
:percentage="percentage"
:status="progress.completed ? 'success' : undefined"
:stroke-width="12"
:show-text="true"
/>
</div>
<!-- 消息提示 -->
<div v-if="progress.message" class="message-section">
<el-alert
:type="progress.completed ? 'success' : 'info'"
:closable="false"
show-icon
>
<template #title>
<span>{{ progress.message }}</span>
</template>
</el-alert>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch, onUnmounted } from 'vue';
import { ElMessage } from 'element-plus';
import {
Setting,
Folder,
FolderOpened,
DocumentCopy,
Right,
VideoPlay,
Refresh,
DataLine,
Connection,
CircleCheck,
Loading,
Document
} from '@element-plus/icons-vue';
import { startDedup } from '../api/dedup';
import { getProgress } from '../api/progress';
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
const form = reactive({
libraryDir: '',
trashDir: '',
useMd5: true,
useMetadata: true,
mode: 'copy' as 'copy' | 'move'
});
const submitting = ref(false);
const wsConnected = ref(false);
const progress = reactive({
taskId: '' as string | null,
scanned: 0,
duplicateGroups: 0,
moved: 0,
completed: false,
message: ''
});
const canStart = computed(() => {
return (
form.libraryDir.trim() !== '' &&
form.trashDir.trim() !== '' &&
(form.useMd5 || form.useMetadata) &&
!submitting.value
);
});
const scannedTotal = ref(0);
const scannedProcessed = ref(0);
const percentage = computed(() => {
if (!scannedTotal.value) return 0;
return Math.round((scannedProcessed.value / scannedTotal.value) * 100);
});
let wsDisconnect: (() => void) | null = null;
let pollTimer: number | null = null;
function startPolling(taskId: string) {
stopPolling();
pollTimer = window.setInterval(async () => {
try {
const latest = await getProgress(taskId);
if (latest) {
handleProgressMessage(latest);
if (latest.completed) {
stopPolling();
}
}
} catch {
// 忽略轮询错误
}
}, 1000);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function handleProgressMessage(msg: ProgressMessage) {
if (msg.type !== 'dedup') {
return;
}
// 字段映射:见后端 DedupService 注释
scannedTotal.value = msg.total;
scannedProcessed.value = msg.processed;
progress.scanned = msg.total;
progress.duplicateGroups = msg.success;
progress.moved = msg.failed;
progress.completed = msg.completed;
progress.message = msg.message ?? '';
}
watch(
() => progress.taskId,
(newTaskId) => {
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
wsConnected.value = false;
if (newTaskId) {
const { connect, disconnect, connected } = useWebSocket(newTaskId, handleProgressMessage);
wsDisconnect = disconnect;
watch(connected, (val) => (wsConnected.value = val), { immediate: true });
connect();
startPolling(newTaskId);
}
}
);
async function startTask() {
const libraryDir = form.libraryDir.trim();
const trashDir = form.trashDir.trim();
if (!libraryDir || !trashDir) {
ElMessage.warning('请填写音乐库目录和回收站目录');
return;
}
if (!form.useMd5 && !form.useMetadata) {
ElMessage.warning('请至少选择一种去重策略');
return;
}
submitting.value = true;
progress.taskId = null;
progress.scanned = 0;
progress.duplicateGroups = 0;
progress.moved = 0;
progress.completed = false;
progress.message = '';
scannedTotal.value = 0;
scannedProcessed.value = 0;
try {
const res = await startDedup({
libraryDir,
trashDir,
useMd5: form.useMd5,
useMetadata: form.useMetadata,
mode: form.mode
});
progress.taskId = res.taskId;
ElMessage.success('去重任务已启动');
} catch (e: unknown) {
submitting.value = false;
ElMessage.error(e instanceof Error ? e.message : '启动去重任务失败');
}
}
watch(
() => progress.completed,
(done) => {
if (done) {
submitting.value = false;
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
}
}
);
onUnmounted(() => {
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
});
function reset() {
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
form.libraryDir = '';
form.trashDir = '';
form.useMd5 = true;
form.useMetadata = true;
form.mode = 'copy';
progress.taskId = null;
progress.scanned = 0;
progress.duplicateGroups = 0;
progress.moved = 0;
progress.completed = false;
progress.message = '';
scannedTotal.value = 0;
scannedProcessed.value = 0;
submitting.value = false;
wsConnected.value = false;
}
</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;
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,96 @@
<template>
<el-space direction="vertical" :size="16" class="tab-root">
<el-card>
<template #header>
<span>任务配置</span>
</template>
<el-form label-width="120px">
<el-form-item label="源目录 (staging)">
<el-input v-model="form.srcDir" placeholder="整理完成后的 staging 目录" />
</el-form-item>
<el-form-item label="目标目录 (主库)">
<el-input v-model="form.dstDir" placeholder="Navidrome 主库根目录" />
</el-form-item>
<el-form-item label="合并策略">
<el-checkbox v-model="form.smartUpgrade">启用智能升级</el-checkbox>
<el-checkbox v-model="form.keepBackup">保留旧版本备份</el-checkbox>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="startTask">
开始合并
</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card>
<template #header>
<span>任务进度</span>
</template>
<div class="progress-row">
<span>已合并专辑数</span>
<span>{{ progress.albums }}</span>
</div>
<div class="progress-row">
<span>已合并曲目数</span>
<span>{{ progress.tracks }}</span>
</div>
<div class="progress-row">
<span>升级替换文件数</span>
<span>{{ progress.upgraded }}</span>
</div>
<el-progress
:percentage="percentage"
:status="progress.completed ? 'success' : 'active'"
style="margin-top: 8px"
/>
</el-card>
</el-space>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
const form = reactive({
srcDir: '',
dstDir: '',
smartUpgrade: true,
keepBackup: false
});
const submitting = ref(false);
const progress = reactive({
albums: 0,
tracks: 0,
upgraded: 0,
completed: false
});
const percentage = computed(() => {
if (!progress.albums && !progress.tracks) return 0;
return 0;
});
function startTask() {
submitting.value = true;
// TODO: 调用 /merge 接口并订阅进度
setTimeout(() => {
submitting.value = false;
}, 500);
}
</script>
<style scoped>
.tab-root {
width: 100%;
}
.progress-row {
display: flex;
justify-content: space-between;
font-size: 13px;
color: #4b5563;
}
</style>

View File

@@ -0,0 +1,105 @@
<template>
<el-space direction="vertical" :size="16" class="tab-root">
<el-card>
<template #header>
<span>任务配置</span>
</template>
<el-form label-width="120px">
<el-form-item label="源目录">
<el-input v-model="form.srcDir" placeholder="staging 源目录" />
</el-form-item>
<el-form-item label="目标目录">
<el-input v-model="form.dstDir" placeholder="规范化输出目录" />
</el-form-item>
<el-form-item label="标签完整度">
<el-radio-group v-model="form.mode">
<el-radio value="strict">严格模式</el-radio>
<el-radio value="lenient">宽松模式</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="附加选项">
<el-checkbox v-model="form.extractCover">提取封面</el-checkbox>
<el-checkbox v-model="form.extractLyrics">提取歌词</el-checkbox>
<el-checkbox v-model="form.generateReport">生成整理报告</el-checkbox>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="startTask">
开始整理
</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card>
<template #header>
<span>任务进度</span>
</template>
<div class="progress-row">
<span>扫描文件数</span>
<span>{{ progress.scanned }}</span>
</div>
<div class="progress-row">
<span>整理成功文件数</span>
<span>{{ progress.organized }}</span>
</div>
<div class="progress-row">
<span>需要人工修复文件数</span>
<span>{{ progress.manualFix }}</span>
</div>
<el-progress
:percentage="percentage"
:status="progress.completed ? 'success' : 'active'"
style="margin-top: 8px"
/>
</el-card>
</el-space>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
const form = reactive({
srcDir: '',
dstDir: '',
mode: 'strict',
extractCover: true,
extractLyrics: true,
generateReport: true
});
const submitting = ref(false);
const progress = reactive({
scanned: 0,
organized: 0,
manualFix: 0,
completed: false
});
const percentage = computed(() => {
if (!progress.scanned) return 0;
return 0;
});
function startTask() {
submitting.value = true;
// TODO: 调用 /organize 接口并订阅进度
setTimeout(() => {
submitting.value = false;
}, 500);
}
</script>
<style scoped>
.tab-root {
width: 100%;
}
.progress-row {
display: flex;
justify-content: space-between;
font-size: 13px;
color: #4b5563;
}
</style>

View File

@@ -0,0 +1,90 @@
<template>
<el-card class="settings-root">
<template #header>
<span>全局路径配置</span>
</template>
<el-form label-width="140px">
<el-form-item label="工作根目录 (BasePath)">
<el-input v-model="basePath" placeholder="例如D:/MusicWork" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="save">
保存配置
</el-button>
</el-form-item>
</el-form>
<el-divider />
<h4>派生目录预览</h4>
<el-descriptions :column="1" size="small" border>
<el-descriptions-item label="Input (SRC_ACC_DIR)">
{{ preview.input }}
</el-descriptions-item>
<el-descriptions-item label="Staging_Aggregated (DST_ACC_DIR)">
{{ preview.aggregated }}
</el-descriptions-item>
<el-descriptions-item label="Staging_Format_Issues (DST_CONV_ISSUE)">
{{ preview.formatIssues }}
</el-descriptions-item>
<el-descriptions-item label="Staging_Duplicates (DST_DEDUP_TRASH)">
{{ preview.duplicates }}
</el-descriptions-item>
<el-descriptions-item label="Staging_T2S_Output (DST_ZH_CONV)">
{{ preview.zhOutput }}
</el-descriptions-item>
<el-descriptions-item label="Staging_Organized (DST_ORG_DIR)">
{{ preview.organized }}
</el-descriptions-item>
<el-descriptions-item label="Library_Final (DST_LIB_FINAL)">
{{ preview.libraryFinal }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
const basePath = ref('');
const saving = ref(false);
const preview = computed(() => {
const root = basePath.value.replace(/\\/g, '/').replace(/\/+$/, '');
if (!root) {
return {
input: '',
aggregated: '',
formatIssues: '',
duplicates: '',
zhOutput: '',
organized: '',
libraryFinal: ''
};
}
return {
input: `${root}/Input`,
aggregated: `${root}/Staging_Aggregated`,
formatIssues: `${root}/Staging_Format_Issues`,
duplicates: `${root}/Staging_Duplicates`,
zhOutput: `${root}/Staging_T2S_Output`,
organized: `${root}/Staging_Organized`,
libraryFinal: `${root}/Library_Final`
};
});
function save() {
saving.value = true;
// TODO: 调用 /config/base-path 接口保存配置
setTimeout(() => {
saving.value = false;
}, 500);
}
</script>
<style scoped>
.settings-root {
max-width: 800px;
}
</style>

View File

@@ -0,0 +1,554 @@
<template>
<div class="zhconvert-container">
<el-row :gutter="24">
<!-- 任务配置卡片 -->
<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>
</template>
<el-form :model="form" label-width="110px" label-position="left">
<el-form-item label="扫描目录" required>
<el-input
v-model="form.scanDir"
placeholder="待检测标签的根目录"
clearable
>
<template #prefix>
<el-icon><Folder /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="输出目录">
<el-input
v-model="form.outputDir"
placeholder="可选:执行模式下输出到新目录(留空则原地修改)"
clearable
>
<template #prefix>
<el-icon><FolderOpened /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="繁体占比阈值">
<div class="inline-field">
<el-input-number
v-model="form.threshold"
:min="1"
:max="100"
:step="1"
controls-position="right"
/>
<span class="suffix">%</span>
</div>
</el-form-item>
<el-form-item label="处理模式">
<el-radio-group v-model="form.mode" size="default">
<el-radio-button value="preview">
预览仅检测
</el-radio-button>
<el-radio-button value="execute">
执行转换
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="submitting"
:disabled="!canStart"
@click="startTask"
size="large"
style="width: 100%"
>
<el-icon v-if="!submitting"><VideoPlay /></el-icon>
<span style="margin-left: 4px">
{{ submitting ? '处理中...' : form.mode === 'preview' ? '开始检测' : '开始转换' }}
</span>
</el-button>
</el-form-item>
<el-form-item>
<el-button @click="reset" size="default" style="width: 100%">
<el-icon><Refresh /></el-icon>
<span style="margin-left: 4px">重置</span>
</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
<!-- 任务进度卡片 -->
<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>
</template>
<div v-if="!progress.taskId" class="empty-state">
<el-icon class="empty-icon"><Document /></el-icon>
<p class="empty-text">等待开始任务...</p>
</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 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>
<!-- 进度条 -->
<div class="progress-section">
<el-progress
:percentage="percentage"
:status="progress.completed ? 'success' : progress.failed > 0 ? 'exception' : undefined"
:stroke-width="12"
:show-text="true"
/>
</div>
<!-- 当前文件 -->
<div v-if="progress.currentFile" class="current-file-section">
<el-icon class="file-icon"><Document /></el-icon>
<span class="file-text">{{ progress.currentFile }}</span>
</div>
<!-- 消息提示 -->
<div v-if="progress.message" class="message-section">
<el-alert
:type="progress.completed ? (progress.failed > 0 ? 'warning' : 'success') : 'info'"
:closable="false"
show-icon
>
<template #title>
<span>{{ progress.message }}</span>
</template>
</el-alert>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch, onUnmounted } from 'vue';
import { ElMessage } from 'element-plus';
import {
Setting,
Folder,
FolderOpened,
VideoPlay,
Refresh,
DataLine,
Connection,
CircleCheck,
Loading,
Document
} from '@element-plus/icons-vue';
import { startZhConvert } from '../api/zhconvert';
import { getProgress } from '../api/progress';
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
interface ProgressState {
taskId: string | null;
total: number;
processed: number;
entries: number;
failed: number;
currentFile: string;
message: string;
completed: boolean;
}
const form = reactive({
scanDir: '',
outputDir: '',
threshold: 10,
mode: 'preview' as 'preview' | 'execute'
});
const submitting = ref(false);
const wsConnected = ref(false);
const progress = reactive<ProgressState>({
taskId: null,
total: 0,
processed: 0,
entries: 0,
failed: 0,
currentFile: '',
message: '',
completed: false
});
const canStart = computed(() => {
return form.scanDir.trim() !== '' && form.threshold >= 1 && form.threshold <= 100 && !submitting.value;
});
const percentage = computed(() => {
if (!progress.total) return 0;
return Math.round((progress.processed / progress.total) * 100);
});
let wsDisconnect: (() => void) | null = null;
let pollTimer: number | null = null;
function startPolling(taskId: string) {
stopPolling();
pollTimer = window.setInterval(async () => {
try {
const latest = await getProgress(taskId);
if (latest && latest.type === 'zhconvert') {
handleProgressMessage(latest);
if (latest.completed) {
stopPolling();
}
}
} catch {
// 忽略轮询错误
}
}, 800);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function handleProgressMessage(msg: ProgressMessage) {
if (msg.type !== 'zhconvert') {
return;
}
progress.total = msg.total;
progress.processed = msg.processed;
progress.entries = msg.success;
progress.failed = msg.failed;
progress.currentFile = msg.currentFile || '';
progress.message = msg.message || '';
progress.completed = msg.completed;
if (msg.completed) {
submitting.value = false;
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
stopPolling();
}
}
watch(
() => progress.taskId,
(newTaskId) => {
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
wsConnected.value = false;
if (newTaskId) {
const { connect, disconnect, connected } = useWebSocket(newTaskId, handleProgressMessage);
wsDisconnect = disconnect;
watch(connected, (val) => (wsConnected.value = val), { immediate: true });
connect();
startPolling(newTaskId);
}
}
);
async function startTask() {
const scanDir = form.scanDir.trim();
if (!scanDir) {
ElMessage.warning('请填写扫描目录');
return;
}
if (form.threshold < 1 || form.threshold > 100) {
ElMessage.warning('繁体占比阈值必须在 1-100 之间');
return;
}
submitting.value = true;
progress.taskId = null;
progress.total = 0;
progress.processed = 0;
progress.entries = 0;
progress.failed = 0;
progress.currentFile = '';
progress.message = '';
progress.completed = false;
try {
const res = await startZhConvert({
scanDir,
outputDir: form.outputDir.trim() || undefined,
threshold: form.threshold,
mode: form.mode
});
progress.taskId = res.taskId;
ElMessage.success('任务已启动,正在处理...');
} catch (e: unknown) {
submitting.value = false;
ElMessage.error(e instanceof Error ? e.message : '启动任务失败');
}
}
function reset() {
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
stopPolling();
form.scanDir = '';
form.outputDir = '';
form.threshold = 10;
form.mode = 'preview';
progress.taskId = null;
progress.total = 0;
progress.processed = 0;
progress.entries = 0;
progress.failed = 0;
progress.currentFile = '';
progress.message = '';
progress.completed = false;
submitting.value = false;
wsConnected.value = false;
}
onUnmounted(() => {
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
stopPolling();
});
</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;
}
.inline-field {
display: inline-flex;
align-items: center;
gap: 8px;
}
.suffix {
font-size: 13px;
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>

View File

@@ -0,0 +1,100 @@
import { ref } from 'vue';
import SockJS from 'sockjs-client';
import Stomp from 'stompjs';
export interface ProgressMessage {
taskId: string;
type: string;
total: number;
processed: number;
success: number;
failed: number;
currentFile: string;
message: string;
completed: boolean;
}
export function useWebSocket(taskId: string | null, onMessage: (msg: ProgressMessage) => void) {
const connected = ref(false);
const error = ref<string | null>(null);
let stompClient: Stomp.Client | null = null;
const connect = () => {
if (!taskId) {
return;
}
// 如果已有连接,先断开
if (stompClient) {
disconnect();
}
try {
const socket = new SockJS('http://localhost:8080/ws');
stompClient = Stomp.over(socket);
// 禁用调试日志
stompClient.debug = () => {};
stompClient.connect(
{},
() => {
// 连接成功
connected.value = true;
error.value = null;
// 订阅任务进度
if (stompClient) {
stompClient.subscribe(`/topic/progress/${taskId}`, (message) => {
try {
const progress: ProgressMessage = JSON.parse(message.body);
onMessage(progress);
} catch (e) {
console.error('Failed to parse progress message:', e);
}
});
}
},
(errorFrame) => {
// 连接失败
const errorMsg = errorFrame.headers?.['message'] || errorFrame.toString() || 'WebSocket 连接错误';
error.value = errorMsg;
connected.value = false;
console.error('WebSocket 连接失败:', errorMsg, errorFrame);
}
);
} catch (e) {
const errorMsg = e instanceof Error ? e.message : 'WebSocket 初始化失败';
error.value = errorMsg;
connected.value = false;
console.error('WebSocket 初始化失败:', e);
}
};
const disconnect = () => {
if (stompClient) {
try {
if (connected.value) {
stompClient.disconnect(() => {
connected.value = false;
});
} else {
// 如果连接未成功,直接清理
connected.value = false;
}
} catch (e) {
console.error('断开 WebSocket 连接时出错:', e);
connected.value = false;
}
stompClient = null;
}
error.value = null;
};
return {
connected,
error,
connect,
disconnect
};
}

14
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,14 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import './styles/reset.css';
import App from './App.vue';
const app = createApp(App);
app.use(createPinia());
app.use(ElementPlus);
app.mount('#app');

View File

@@ -0,0 +1,26 @@
/* 重置 body 默认样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif, 'Noto Sans SC', 'HarmonyOS Sans SC';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
width: 100%;
height: 100%;
}

24
frontend/vite.config.mts Normal file
View File

@@ -0,0 +1,24 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
define: {
global: 'globalThis',
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
},
'/ws': {
target: 'http://localhost:8080',
changeOrigin: true,
ws: true
}
}
}
});