feat: add P6 frontend console integration

This commit is contained in:
2026-03-22 00:24:22 +08:00
parent ecc15e7546
commit cd716ed2af
70 changed files with 8954 additions and 15 deletions

4
.gitignore vendored
View File

@@ -5,3 +5,7 @@ target/
.settings/
*.log
data/
frontend/node_modules/
frontend/dist/
src/main/resources/static/app/*
!src/main/resources/static/app/.gitkeep

View File

@@ -0,0 +1,220 @@
# P6 前端界面集成开发落地说明
## 概述
P6 已完成基于 Vue 3 + Vite + Element Plus + Pinia + Vue Router 的前端控制台落地,并接入现有 Spring Boot 工程的 `/app/` 静态资源托管链路。
本阶段实际交付聚焦:
- 独立 `frontend/` 前端工程
- 仪表盘、任务管理、文件上传、失败文件、元数据编辑、系统配置 6 个页面
- Axios 封装、Pinia 全局状态、前端 API 路径适配
- Spring Boot `/app/**` SPA 托管与真实构建产物集成
本阶段未纳入实现:
- GraalVM Native Image 真正适配与交付
- 真实登录/鉴权流程
- 独立日志页面
## 实现清单
### 前端工程与基础设施
- 新增 `frontend/package.json`
- 新增 `frontend/vite.config.ts`
- 新增 `frontend/src/main.ts`
- 新增 `frontend/src/router/index.ts`
- 新增 `frontend/src/router/routes.ts`
- 新增 `frontend/src/layouts/AppLayout.vue`
- 新增 `frontend/src/stores/user.ts`
- 新增 `frontend/src/stores/config.ts`
- 新增 `frontend/src/stores/app.ts`
- 新增 `frontend/src/api/request.ts`
- 新增 `frontend/src/api/modules/task.ts`
- 新增 `frontend/src/api/modules/failFile.ts`
- 新增 `frontend/src/api/modules/config.ts`
- 新增 `frontend/src/api/modules/log.ts`
- 新增 `frontend/src/api/modules/upload.ts`
### 页面与组件
- 仪表盘:`frontend/src/views/DashboardView.vue`
- 任务管理:`frontend/src/views/TaskManagementView.vue`
- 文件上传:`frontend/src/views/FileUploadView.vue`
- 失败文件:`frontend/src/views/FailFileView.vue`
- 元数据编辑:`frontend/src/views/MetadataEditorView.vue`
- 系统配置:`frontend/src/views/SystemConfigView.vue`
核心页面组件已落地到以下目录:
- `frontend/src/components/dashboard/`
- `frontend/src/components/task/`
- `frontend/src/components/upload/`
- `frontend/src/components/fail-file/`
- `frontend/src/components/metadata/`
- `frontend/src/components/config/`
- `frontend/src/components/common/`
### 后端集成
- 新增 `src/main/java/com/music/metadata/controller/WebAppController.java`
- 新增 `src/test/java/com/music/metadata/controller/WebAppControllerTest.java`
- `pom.xml` 新增 `frontend-build` profile
- 真实前端产物复制到 `src/main/resources/static/app/`
## 页面说明
### 1. 仪表盘
- 展示待处理任务数、已归档文件数、失败文件数
- 最近任务按创建时间倒序展示
- 使用 ECharts 展示处理分布图
- 提供到任务、上传、失败文件、系统配置的快捷入口
### 2. 任务管理
- 支持分页、状态筛选、进度展示
- 支持暂停、继续、终止
- 任务详情通过 Element Plus `Drawer` 展示
- 报告查看通过 Element Plus `Dialog` 展示
- 支持 CSV / JSON 导出
### 3. 文件上传
- 页面使用 `simple-uploader.js` 核心实例进行 Vue 3 下的分片队列接入
- 保留 `identifier``chunkNumber``totalChunks``filename``totalSize` 上传契约
- 支持自动创建任务开关
- 合并返回 `sourcePath` 时自动建任务,否则保留手动创建回退
- 自动建任务失败后保留已合并状态
说明:
- `vue-simple-uploader@0.7.6` 为 Vue 2 wrapper无法直接接入当前 Vue 3.4
- 最终采用其底层依赖 `simple-uploader.js` 进行 Vue 3 可运行集成
### 4. 失败文件
- 支持 keyword / failType / status 筛选
- 支持分页
- 支持批量重处理,前端循环调用单条提交接口
- 可跳转到元数据编辑页,并在处理后恢复列表上下文
### 5. 元数据编辑
- 加载失败文件详情
- 支持标题、艺术家、专辑、流派、歌词编辑
- 支持封面上传输入与占位预览
- 支持草稿保存和提交重处理
- 缺少媒体 URL 时按占位状态展示
### 6. 系统配置
- 配置按分组展示
- 敏感值默认掩码展示,可切换编辑
- 目录型配置提供路径建议和绝对路径提示
- 保存后刷新 Pinia 配置缓存
## API 映射
前端按真实后端路径适配,页面层不感知差异。
### 任务接口
- `POST /api/tasks/create`
- `GET /api/tasks/list`
- `GET /api/tasks/{taskId}`
- `POST /api/tasks/{taskId}/pause`
- `POST /api/tasks/{taskId}/resume`
- `POST /api/tasks/{taskId}/terminate`
- `GET /api/tasks/{taskId}/report`
- `GET /api/tasks/{taskId}/report/csv`
- `GET /api/tasks/{taskId}/report/json`
### 失败文件接口
- `GET /api/v1/fail-file/list`
- `GET /api/v1/fail-file/{id}/detail`
- `PUT /api/v1/fail-file/{id}/edit`
- `POST /api/v1/fail-file/{id}/submit`
### 系统配置接口
- `GET /api/configs/list`
- `GET /api/configs/{configKey}`
- `PUT /api/configs/update`
- `PUT /api/configs/batch-update`
### 日志接口
- `POST /api/logs/query`
- `POST /api/logs/export`
### 上传接口
- `POST /api/v1/file/upload`
- `POST /api/v1/file/upload/merge`
## 验证命令
本阶段 fresh verification 使用了以下命令:
### 前端基础验证
```bash
cd frontend && npm install && npm run test -- --run src/tests/scaffold.spec.ts src/tests/router.spec.ts src/tests/config-store.spec.ts src/tests/request.spec.ts src/tests/task-utils.spec.ts src/tests/layout-shell.spec.ts
```
结果:通过,`6` 个 test files`19` 个 tests。
### 前端功能与全量验证
```bash
cd frontend && npm install && npm run test -- --run
```
结果:通过,`8` 个 test files`28` 个 tests。
### 前端构建验证
```bash
cd frontend && npm install && npm run build
```
结果:通过,生成 `dist/index.html``dist/assets/*`
### 后端 `/app/**` 托管验证
```bash
mvn test -Dtest=WebAppControllerTest
```
结果:通过,`12` 个 tests。
### 前端构建集成验证
```bash
mvn -Pfrontend-build process-resources
```
结果:通过,可将 `frontend/dist` 复制到 `src/main/resources/static/app/`
## 暂缓项
- GraalVM Native Image 真实可运行镜像交付
- 真实登录与 token 鉴权流程
- 独立日志管理页面
- 前端大包拆分优化
- 测试 warning 清理Vue Router warning、Testing Library `fireEvent.change` warning
## 验收结论
P6 当前已完成前端工程、核心页面、Spring Boot 静态资源托管、前端构建接入与基础验证链路。
基于 fresh verification
- 前端全量测试通过(`28/28`
- 前端构建通过
- 后端 `/app/**` 托管测试通过(`12/12`
当前剩余问题主要为 warning 与后续优化项,不影响本阶段交付范围内的实现与构建验证结果。

View File

@@ -16,6 +16,7 @@
| P0 | 已完成 | `docs/P0-基础架构-开发落地说明.md` | 已完成基础工程、数据库底座、基础持久化层与统一异常处理 |
| P1 | 已完成 | `docs/P1-元数据解析与校验-开发落地说明.md` | 已完成元数据读取、校验、快照存储、真实文件验证与覆盖率达标 |
| P2 | 待开始 | 暂无 | 后续建议按同样模板新增阶段文档 |
| P6 | 已完成 | `docs/P6-前端界面集成-开发落地说明.md` | 已完成 Vue 3 前端工程、6 个核心页面、Spring Boot `/app/` 托管与前端构建集成 |
### 1. 总方案文档
@@ -25,6 +26,7 @@
- `docs/P0-基础架构-开发落地说明.md`
- `docs/P1-元数据解析与校验-开发落地说明.md`
- `docs/P6-前端界面集成-开发落地说明.md`
## 后续约定

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Music Metadata System</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

4962
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
frontend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "music-metadata-system-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest"
},
"dependencies": {
"axios": "^1.7.2",
"echarts": "^5.5.1",
"element-plus": "^2.7.6",
"pinia": "^2.1.7",
"vue": "^3.4.31",
"vue-router": "^4.4.0",
"vue-simple-uploader": "^0.7.6"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/vue": "^8.1.0",
"@types/jsdom": "^21.1.7",
"@vitejs/plugin-vue": "^5.1.2",
"jsdom": "^24.1.0",
"typescript": "^5.5.2",
"vite": "^5.3.1",
"vitest": "^1.6.0",
"vue-tsc": "^2.0.22"
}
}

0
frontend/public/.gitkeep Normal file
View File

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

@@ -0,0 +1,3 @@
<template>
<RouterView />
</template>

View File

@@ -0,0 +1,18 @@
import request from '../request'
import type { SystemConfigItem, UpdateSystemConfigItem } from '../../types/config'
export function listConfigs() {
return request.get<SystemConfigItem[]>('/api/configs/list')
}
export function getConfig(configKey: string) {
return request.get<SystemConfigItem>(`/api/configs/${configKey}`)
}
export function updateConfig(item: UpdateSystemConfigItem) {
return request.put('/api/configs/update', item)
}
export function batchUpdateConfigs(items: UpdateSystemConfigItem[]) {
return request.put('/api/configs/batch-update', { items })
}

View File

@@ -0,0 +1,18 @@
import request from '../request'
import type { FailFileDetail, FailFileListParams, FailFileListResult } from '../../types/fail-file'
export function listFailFiles(params: FailFileListParams = {}) {
return request.get<FailFileListResult>('/api/v1/fail-file/list', { params })
}
export function getFailFileDetail(id: string) {
return request.get<FailFileDetail>(`/api/v1/fail-file/${id}/detail`)
}
export function saveFailFileDraft(id: string, payload: Record<string, unknown>) {
return request.put(`/api/v1/fail-file/${id}/edit`, payload)
}
export function submitFailFile(id: string, payload?: Record<string, unknown>) {
return request.post(`/api/v1/fail-file/${id}/submit`, payload)
}

View File

@@ -0,0 +1,16 @@
export interface LogListParams {
level?: string
taskId?: string
page?: number
pageSize?: number
}
import request from '../request'
export function queryLogs(payload: LogListParams = {}) {
return request.post('/api/logs/query', payload)
}
export function exportLogs(payload: Omit<LogListParams, 'page' | 'pageSize'> = {}) {
return request.post('/api/logs/export', payload)
}

View File

@@ -0,0 +1,42 @@
import request from '../request'
import type { CreateTaskPayload, TaskDetail, TaskListParams, TaskListResult } from '../../types/task'
export function createTask(payload: CreateTaskPayload) {
return request.post('/api/tasks/create', payload)
}
export function listTasks(params: TaskListParams = {}) {
return request.get<TaskListResult>('/api/tasks/list', { params })
}
export function getTaskDetail(taskId: string) {
return request.get<TaskDetail>(`/api/tasks/${taskId}`)
}
export function pauseTask(taskId: string) {
return request.post(`/api/tasks/${taskId}/pause`)
}
export function resumeTask(taskId: string) {
return request.post(`/api/tasks/${taskId}/resume`)
}
export function terminateTask(taskId: string) {
return request.post(`/api/tasks/${taskId}/terminate`)
}
export function getTaskReport(taskId: string) {
return request.get(`/api/tasks/${taskId}/report`)
}
export function exportTaskReportCsv(taskId: string) {
return request.get(`/api/tasks/${taskId}/report/csv`, {
responseType: 'blob'
})
}
export function exportTaskReportJson(taskId: string) {
return request.get(`/api/tasks/${taskId}/report/json`, {
responseType: 'blob'
})
}

View File

@@ -0,0 +1,22 @@
import request from '../request'
import type { MergeUploadPayload, MergeUploadResult, UploadChunkPayload } from '../../types/upload'
export function uploadChunk(payload: UploadChunkPayload) {
const formData = new FormData()
formData.append('identifier', payload.identifier)
formData.append('chunkNumber', String(payload.chunkNumber))
formData.append('totalChunks', String(payload.totalChunks))
formData.append('filename', payload.filename)
formData.append('totalSize', String(payload.totalSize))
formData.append('chunk', payload.chunk, payload.filename)
return request.post('/api/v1/file/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
export function mergeUpload(payload: MergeUploadPayload) {
return request.post<MergeUploadResult>('/api/v1/file/upload/merge', payload)
}

View File

@@ -0,0 +1,97 @@
import axios, { AxiosError, type AxiosInstance, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
import { useAppStore } from '../stores/app'
import { useUserStore } from '../stores/user'
import type { ApiResponse } from '../types/api'
type ApiAxiosResponse = AxiosResponse<ApiResponse<unknown>>
export function normalizeRequestPath(url: string): string {
const normalized = url.startsWith('/') ? url : `/${url}`
if (normalized.startsWith('/api/')) {
return normalized
}
return `/api${normalized}`
}
export function unwrapApiResponse<T>(payload: ApiResponse<T>): T {
if (payload.code !== 200) {
throw new Error(payload.message || 'Request failed')
}
return payload.data
}
export function normalizeRequestError(error: unknown): Error {
const candidate = error as {
message?: string
response?: {
data?: {
message?: string
}
}
}
const message = candidate.response?.data?.message || candidate.message || 'Request failed'
return new Error(message)
}
export async function applyRequestDefaults(config: InternalAxiosRequestConfig): Promise<InternalAxiosRequestConfig> {
const userStore = useUserStore()
const appStore = useAppStore()
appStore.beginRequest()
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
if (config.url) {
config.url = normalizeRequestPath(config.url)
}
return config
}
export function buildResponseTransformer() {
return (response: ApiAxiosResponse) => {
const appStore = useAppStore()
appStore.finishRequest()
if (response.config.responseType === 'blob' || response.config.responseType === 'arraybuffer') {
return response.data as unknown
}
return unwrapApiResponse(response.data)
}
}
export function buildResponseErrorHandler() {
return (error: AxiosError) => {
const appStore = useAppStore()
appStore.finishRequest()
return Promise.reject(normalizeRequestError(error))
}
}
function applyRequestInterceptors(client: AxiosInstance) {
client.interceptors.request.use(applyRequestDefaults)
}
function applyResponseInterceptors(client: AxiosInstance) {
client.interceptors.response.use(buildResponseTransformer(), buildResponseErrorHandler())
}
export function createRequestClient(): AxiosInstance {
const client = axios.create({
timeout: 15000
})
applyRequestInterceptors(client)
applyResponseInterceptors(client)
return client
}
const request = createRequestClient()
export default request

View File

@@ -0,0 +1,153 @@
<template>
<header class="header app-panel">
<div class="header__identity">
<nav class="header__breadcrumbs" aria-label="Breadcrumb">
<span v-for="(crumb, index) in breadcrumbs" :key="`${crumb}-${index}`" class="header__breadcrumb-item">
<span v-if="index > 0" class="header__breadcrumb-separator">/</span>
<span>{{ crumb }}</span>
</span>
</nav>
<h1 class="header__title">{{ title }}</h1>
</div>
<div class="header__status">
<div class="pill">
<span :class="healthDotClass"></span>
Backend {{ healthLabel }}
</div>
<div class="pill">User {{ user.displayName }}</div>
<div class="header__system app-panel">
<div class="header__system-label">Theme / System</div>
<div class="header__system-values">
<span>Theme {{ themeLabel }}</span>
<span>System {{ systemLabel }}</span>
</div>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAppStore } from '../../stores/app'
import { useUserStore } from '../../stores/user'
const route = useRoute()
const app = useAppStore()
const user = useUserStore()
const title = computed(() => String(route.meta.title ?? 'Music Metadata System'))
const section = computed(() => String(route.meta.section ?? 'Workspace'))
const breadcrumbs = computed(() => {
const matchedTitles = route.matched
.map((record) => record.meta.title)
.filter((value): value is string => typeof value === 'string' && value.length > 0)
if (matchedTitles.length > 0) {
return matchedTitles
}
return [section.value, title.value]
})
const healthLabel = computed(() => {
if (app.backendHealth === 'healthy') return 'Healthy'
if (app.backendHealth === 'degraded') return 'Degraded'
if (app.backendHealth === 'offline') return 'Offline'
return 'Unknown'
})
const themeLabel = computed(() => (app.themeMode === 'dark' ? 'Dark' : 'System'))
const systemLabel = computed(() => {
if (app.isBusy) {
return 'Busy'
}
return 'Ready'
})
const healthDotClass = computed(() => ({
pill__dot: true,
'pill__dot--ok': app.backendHealth === 'healthy',
'pill__dot--error': app.backendHealth === 'offline'
}))
</script>
<style scoped>
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin: 20px 20px 0;
padding: 20px 24px;
}
.header__identity {
display: flex;
flex-direction: column;
gap: 8px;
}
.header__breadcrumbs {
display: flex;
flex-wrap: wrap;
gap: 6px;
color: var(--app-text-muted);
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.header__breadcrumb-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.header__breadcrumb-separator {
opacity: 0.5;
}
.header__title {
margin: 0;
font-size: 28px;
}
.header__status {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
}
.header__system {
display: flex;
min-width: 190px;
flex-direction: column;
gap: 6px;
padding: 10px 14px;
border-radius: 16px;
}
.header__system-label {
color: var(--app-text-muted);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.header__system-values {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
@media (max-width: 960px) {
.header {
margin: 0 16px;
flex-direction: column;
align-items: flex-start;
}
}
</style>

View File

@@ -0,0 +1,68 @@
<template>
<aside class="sidebar app-panel">
<div class="sidebar__brand">
<strong>Music Ops</strong>
<span>Metadata Console</span>
</div>
<nav class="sidebar__nav">
<RouterLink v-for="item in items" :key="item.to" :to="item.to" class="sidebar__link">
{{ item.label }}
</RouterLink>
</nav>
</aside>
</template>
<script setup lang="ts">
import { sidebarNavItems as items } from '../../router/routes'
</script>
<style scoped>
.sidebar {
width: 272px;
margin: 20px;
padding: 24px 18px;
}
.sidebar__brand {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 28px;
}
.sidebar__brand span {
color: var(--app-text-muted);
font-size: 13px;
}
.sidebar__nav {
display: flex;
flex-direction: column;
gap: 10px;
}
.sidebar__link {
padding: 12px 14px;
border: 1px solid transparent;
border-radius: 14px;
color: var(--app-text-muted);
}
.sidebar__link.router-link-active {
border-color: rgba(70, 179, 255, 0.28);
background: rgba(70, 179, 255, 0.12);
color: var(--app-text);
}
@media (max-width: 960px) {
.sidebar {
width: auto;
margin: 16px;
}
.sidebar__nav {
flex-direction: row;
flex-wrap: wrap;
}
}
</style>

View File

@@ -0,0 +1,49 @@
<template>
<div class="page-section-header">
<div>
<div class="page-section-header__eyebrow">{{ eyebrow }}</div>
<h2>{{ title }}</h2>
</div>
<p>{{ description }}</p>
</div>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
title: string
description: string
eyebrow?: string
}>(),
{
eyebrow: 'Section'
}
)
</script>
<style scoped>
.page-section-header {
display: flex;
flex-wrap: wrap;
align-items: end;
justify-content: space-between;
gap: 12px;
}
.page-section-header__eyebrow {
color: var(--app-text-muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
h2,
p {
margin: 0;
}
p {
max-width: 480px;
color: var(--app-text-muted);
}
</style>

View File

@@ -0,0 +1,36 @@
<template>
<article class="stat-card app-panel">
<div class="stat-card__label">{{ label }}</div>
<div class="stat-card__value">{{ value }}</div>
<p class="stat-card__hint">{{ hint }}</p>
</article>
</template>
<script setup lang="ts">
defineProps<{
label: string
value: string
hint: string
}>()
</script>
<style scoped>
.stat-card {
padding: 18px;
}
.stat-card__label,
.stat-card__hint {
color: var(--app-text-muted);
}
.stat-card__value {
margin: 8px 0 6px;
font-size: 28px;
font-weight: 700;
}
.stat-card__hint {
margin: 0;
}
</style>

View File

@@ -0,0 +1,41 @@
<template>
<span class="status-badge" :class="`status-badge--${tone}`">{{ label }}</span>
</template>
<script setup lang="ts">
defineProps<{
label: string
tone: 'success' | 'warning' | 'danger' | 'info'
}>()
</script>
<style scoped>
.status-badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
font-weight: 600;
}
.status-badge--success {
background: rgba(60, 207, 145, 0.14);
color: var(--app-success);
}
.status-badge--warning {
background: rgba(241, 179, 87, 0.14);
color: var(--app-warning);
}
.status-badge--danger {
background: rgba(255, 111, 125, 0.14);
color: var(--app-danger);
}
.status-badge--info {
background: rgba(70, 179, 255, 0.14);
color: var(--app-accent);
}
</style>

View File

@@ -0,0 +1,46 @@
<template>
<section class="feature-grid feature-grid--tasks">
<article class="feature-panel app-panel">
<h3>Grouped config</h3>
<div v-for="group in groups" :key="group.name" class="config-group">
<h4>{{ group.name }}</h4>
<div v-for="item in group.items" :key="item.key" class="config-row">
<label :for="item.key">{{ item.key }}</label>
<div v-if="item.sensitive && !item.editing" class="row-actions">
<input :id="item.key" :aria-label="item.key" value="••••••••••••" type="text" readonly />
<button type="button" @click="$emit('toggle-sensitive-edit', item.key)">Edit {{ item.key }}</button>
</div>
<input v-else :id="item.key" :aria-label="item.key" :value="item.value" type="text" @input="$emit('update-config', item.key, ($event.target as HTMLInputElement).value)" />
<label v-if="item.directoryLike" :for="`${item.key}-suggestion`">Path suggestion for {{ item.key }}</label>
<input v-if="item.directoryLike" :id="`${item.key}-suggestion`" :aria-label="`Path suggestion for ${item.key}`" :value="suggestionKeyword" type="text" placeholder="Type path prefix" @input="$emit('update:suggestion-keyword', ($event.target as HTMLInputElement).value)" />
<div v-if="item.directoryLike && item.validationMessage" class="form-error">{{ item.validationMessage }}</div>
</div>
</div>
</article>
<article class="feature-panel app-panel">
<h3>Path suggestions</h3>
<div class="suggestion-list">
<div v-for="item in suggestions" :key="item" class="row-card">{{ item }}</div>
</div>
<button type="button" @click="$emit('save')">Save config</button>
</article>
</section>
</template>
<script setup lang="ts">
import type { SystemConfigItem } from '../../types/config'
defineProps<{
groups: Array<{ name: string; items: Array<SystemConfigItem & { directoryLike?: boolean }> }>
suggestions: string[]
suggestionKeyword: string
}>()
defineEmits<{
'update:suggestion-keyword': [value: string]
'update-config': [key: string, value: string]
'toggle-sensitive-edit': [key: string]
save: []
}>()
</script>

View File

@@ -0,0 +1,87 @@
<template>
<section class="feature-page__section">
<div class="feature-grid feature-grid--cards">
<StatCard v-for="card in cards" :key="card.label" :label="card.label" :value="card.value" :hint="card.hint" />
</div>
<div class="feature-grid feature-grid--dashboard">
<article class="feature-panel app-panel">
<h3>Recent tasks</h3>
<div v-for="task in tasks" :key="task.id" class="row-card">
<div>
<strong>{{ task.name }}</strong>
<div class="muted">{{ task.processedFiles }} / {{ task.totalFiles }} files</div>
</div>
<StatusBadge :label="task.status" :tone="mapTaskStatusTone(task.status)" />
</div>
</article>
<article class="feature-panel app-panel">
<h3>Processing chart</h3>
<div class="muted">Chart total {{ chartTotal }}</div>
<div ref="chartRef" class="echart-panel" aria-label="Processing chart"></div>
</article>
<article class="feature-panel app-panel">
<h3>Quick entries</h3>
<div v-for="entry in quickEntries" :key="entry.title" class="row-card row-card--stacked">
<strong>{{ entry.title }}</strong>
<div>{{ entry.value }}</div>
<div class="muted">{{ entry.hint }}</div>
<RouterLink :to="entry.to" class="quick-link">{{ entry.actionLabel }}</RouterLink>
</div>
</article>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import * as echarts from 'echarts'
import StatCard from '../common/StatCard.vue'
import StatusBadge from '../common/StatusBadge.vue'
import type { DashboardQuickEntry } from '../../types/dashboard'
import type { TaskSummary } from '../../types/task'
import { mapTaskStatusTone } from '../../utils/task'
const props = defineProps<{
cards: Array<{ label: string; value: string; hint: string }>
tasks: TaskSummary[]
quickEntries: DashboardQuickEntry[]
chartBars: Array<{ label: string; value: number; raw: number }>
}>()
const chartRef = ref<HTMLDivElement | null>(null)
let chartInstance: echarts.ECharts | null = null
const chartTotal = computed(() => props.chartBars.reduce((sum, item) => sum + item.raw, 0))
function renderChart() {
if (!chartRef.value) return
if (typeof HTMLCanvasElement === 'undefined') return
if (!HTMLCanvasElement.prototype.getContext) return
if (chartRef.value.clientWidth === 0 || chartRef.value.clientHeight === 0) return
chartInstance?.dispose()
chartInstance = echarts.init(chartRef.value)
chartInstance.setOption({
tooltip: { trigger: 'item' },
series: [
{
type: 'pie',
radius: ['45%', '70%'],
label: { color: '#e8eef8' },
data: props.chartBars.map((bar) => ({ name: bar.label, value: bar.raw }))
}
]
})
}
onMounted(renderChart)
watch(() => props.chartBars, renderChart, { deep: true })
onBeforeUnmount(() => {
chartInstance?.dispose()
chartInstance = null
})
</script>

View File

@@ -0,0 +1,71 @@
<template>
<section class="feature-grid feature-grid--tasks">
<article class="feature-panel app-panel">
<div class="toolbar">
<label>
Keyword
<input aria-label="Keyword" :value="keyword" type="text" @input="$emit('update:keyword', ($event.target as HTMLInputElement).value)" />
</label>
<label>
Fail type
<input aria-label="Fail type" :value="failType" type="text" @input="$emit('update:fail-type', ($event.target as HTMLInputElement).value)" />
</label>
<label>
Status
<input aria-label="Status" :value="statusFilter" type="text" @input="$emit('update:status-filter', ($event.target as HTMLInputElement).value)" />
</label>
<button type="button" @click="$emit('batch-reprocess')">Batch reprocess</button>
</div>
<div v-for="record in records" :key="record.id" class="row-card row-card--task">
<label class="toggle-field" :for="`select-${record.id}`">
<input :id="`select-${record.id}`" :aria-label="`Select ${record.id}`" type="checkbox" :checked="selectedIds.includes(record.id)" @change="$emit('toggle-selected', record.id, ($event.target as HTMLInputElement).checked)" />
<span>{{ record.filename }}</span>
</label>
<div class="muted">{{ record.failType || record.reason }}</div>
<div class="row-actions">
<button type="button" @click="$emit('reprocess-one', record.id)">Reprocess</button>
<RouterLink :to="`/metadata/${record.id}?return=%2Ffail-files`">Edit metadata</RouterLink>
</div>
</div>
</article>
<article class="feature-panel app-panel">
<h3>Pagination</h3>
<p>Page {{ page }} / {{ totalPages }}</p>
<div class="row-actions">
<button type="button" :disabled="page <= 1" @click="$emit('change-page', page - 1)">Prev</button>
<button type="button" :disabled="page >= totalPages" @click="$emit('change-page', page + 1)">Next</button>
</div>
<h3>Batch summary</h3>
<p class="muted">{{ batchSummary }}</p>
</article>
</section>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import type { FailFileRecord } from '../../types/fail-file'
defineProps<{
records: FailFileRecord[]
keyword: string
failType: string
statusFilter: string
selectedIds: string[]
page: number
totalPages: number
batchSummary: string
}>()
defineEmits<{
'update:keyword': [value: string]
'update:fail-type': [value: string]
'update:status-filter': [value: string]
'toggle-selected': [id: string, selected: boolean]
'batch-reprocess': []
'reprocess-one': [id: string]
'change-page': [page: number]
}>()
</script>

View File

@@ -0,0 +1,80 @@
<template>
<section class="feature-grid feature-grid--metadata">
<article class="feature-panel app-panel">
<h3>Audio preview</h3>
<audio v-if="audioUrl" :src="audioUrl" controls></audio>
<p v-else>No audio preview available</p>
</article>
<article class="feature-panel app-panel">
<h3>Cover</h3>
<img v-if="coverUrl" :src="coverUrl" alt="Cover art" class="cover-preview" />
<p v-else>No cover available</p>
<label>
Cover upload
<input aria-label="Cover upload" type="file" accept="image/*" @change="$emit('update-cover', ($event.target as HTMLInputElement).files?.[0] || null)" />
</label>
</article>
<article class="feature-panel app-panel">
<h3>Lyrics</h3>
<pre v-if="form.lyrics">{{ form.lyrics }}</pre>
<p v-else>No lyrics loaded</p>
</article>
<article class="feature-panel app-panel feature-panel--wide">
<h3>Metadata form</h3>
<div class="form-grid">
<label>
Title
<input :value="form.title" aria-label="Title" type="text" @input="$emit('update-field', 'title', ($event.target as HTMLInputElement).value)" />
</label>
<label>
Artist
<input :value="form.artist" aria-label="Artist" type="text" @input="$emit('update-field', 'artist', ($event.target as HTMLInputElement).value)" />
</label>
<label>
Album
<input :value="form.album" aria-label="Album" type="text" @input="$emit('update-field', 'album', ($event.target as HTMLInputElement).value)" />
</label>
<label>
Genre
<input :value="form.genre" aria-label="Genre" type="text" @input="$emit('update-field', 'genre', ($event.target as HTMLInputElement).value)" />
</label>
<label class="form-grid__wide">
Lyrics
<textarea :value="form.lyrics" aria-label="Lyrics" @input="$emit('update-field', 'lyrics', ($event.target as HTMLTextAreaElement).value)"></textarea>
</label>
</div>
<p v-if="validationError" class="form-error">{{ validationError }}</p>
<div class="row-actions">
<button type="button" @click="$emit('save-draft')">Save draft</button>
<button type="button" @click="$emit('submit-metadata')">Submit metadata</button>
</div>
</article>
</section>
</template>
<script setup lang="ts">
defineProps<{
audioUrl?: string
coverUrl?: string
validationError: string
form: {
title: string
artist: string
album: string
genre: string
lyrics: string
}
}>()
defineEmits<{
'update-field': [field: string, value: string]
'update-cover': [file: File | null]
'save-draft': []
'submit-metadata': []
}>()
</script>

View File

@@ -0,0 +1,112 @@
<template>
<section class="feature-grid feature-grid--tasks">
<article class="feature-panel app-panel">
<div class="toolbar">
<label>
Status
<select v-model="localStatus" @change="$emit('filter-change', localStatus)">
<option value="">All</option>
<option value="PENDING">Pending</option>
<option value="RUNNING">Running</option>
<option value="PAUSED">Paused</option>
<option value="SUCCESS">Success</option>
<option value="FAILED">Failed</option>
</select>
</label>
<div class="muted">Page {{ page }} / {{ totalPages }}</div>
<RouterLink to="/upload">Create task entry</RouterLink>
</div>
<div v-for="task in tasks" :key="task.id" class="row-card row-card--task">
<div>
<strong>{{ task.name }}</strong>
<div class="muted">{{ getTaskProgressPercent(task) }}%</div>
</div>
<StatusBadge :label="task.status" :tone="mapTaskStatusTone(task.status)" />
<div class="row-actions">
<button type="button" @click="$emit('pause', task.id)">Pause</button>
<button type="button" @click="$emit('resume', task.id)">Resume</button>
<button type="button" @click="$emit('terminate', task.id)">Terminate</button>
<button type="button" @click="$emit('open-detail-drawer', task.id)">Open detail drawer</button>
</div>
</div>
<div class="row-actions">
<button type="button" :disabled="page <= 1" @click="$emit('change-page', page - 1)">Prev</button>
<button type="button" :disabled="page >= totalPages" @click="$emit('change-page', page + 1)">Next</button>
</div>
</article>
<el-drawer :model-value="detailVisible" title="Task detail" size="40%" @close="$emit('close-detail-drawer')">
<template v-if="detail">
<div class="detail-grid">
<div><span class="muted">Source</span><strong>{{ detail.sourceType || 'Unknown' }}</strong></div>
<div><span class="muted">Success</span><strong>{{ detail.successFiles ?? 0 }}</strong></div>
<div><span class="muted">Failed</span><strong>{{ detail.failedFiles ?? 0 }}</strong></div>
<div><span class="muted">Updated</span><strong>{{ detail.updatedAt || '-' }}</strong></div>
</div>
<p class="muted">{{ detail.summary || 'No detail summary available.' }}</p>
<div class="row-actions">
<button type="button" @click="$emit('open-report-dialog', detail.id)">Open report dialog</button>
</div>
</template>
<p v-else class="muted">Select a task to inspect detail.</p>
</el-drawer>
<el-dialog :model-value="reportVisible" title="Task report" width="720px" @close="$emit('close-report-dialog')">
<div class="row-actions">
<button type="button" @click="$emit('load-report', detail?.id || '')">Load report</button>
<button type="button" @click="$emit('export-csv', detail?.id || '')">Export CSV</button>
<button type="button" @click="$emit('export-json', detail?.id || '')">Export JSON</button>
</div>
<div v-for="item in reportItems" :key="item.name" class="row-card">
<span>{{ item.name }}</span>
<span class="muted">{{ item.status }}</span>
</div>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { ElDialog, ElDrawer } from 'element-plus'
import StatusBadge from '../common/StatusBadge.vue'
import type { TaskDetail, TaskStatus, TaskSummary } from '../../types/task'
import { getTaskProgressPercent, mapTaskStatusTone } from '../../utils/task'
const props = defineProps<{
tasks: TaskSummary[]
detail: TaskDetail | null
status: TaskStatus | ''
page: number
totalPages: number
detailVisible: boolean
reportVisible: boolean
}>()
defineEmits<{
'filter-change': [status: TaskStatus | '']
pause: [taskId: string]
resume: [taskId: string]
terminate: [taskId: string]
'open-detail-drawer': [taskId: string]
'close-detail-drawer': []
'change-page': [page: number]
'load-report': [taskId: string]
'export-csv': [taskId: string]
'export-json': [taskId: string]
'open-report-dialog': [taskId: string]
'close-report-dialog': []
}>()
const localStatus = ref<TaskStatus | ''>(props.status)
const reportItems = computed(() => props.detail?.report?.items ?? [])
watch(
() => props.status,
(value) => {
localStatus.value = value
}
)
</script>

View File

@@ -0,0 +1,74 @@
<template>
<section class="feature-grid feature-grid--tasks">
<article class="feature-panel app-panel">
<div class="uploader-host" :data-target="String(uploaderOptions.target)">
<div ref="dropzoneRef" class="upload-dropzone" aria-label="Uploader dropzone">
<strong>Drop files here</strong>
<div class="muted">simple-uploader queue stays aligned with chunk upload and merge handoff.</div>
</div>
<button ref="browseButtonRef" class="upload-select" type="button">Choose files</button>
</div>
<div class="toolbar">
<h3>Chunk queue</h3>
<label class="toggle-field">
<input :checked="autoCreateTask" type="checkbox" aria-label="Auto create task" @change="$emit('toggle-auto-create', ($event.target as HTMLInputElement).checked)" />
<span>Auto create task</span>
</label>
</div>
<div v-for="item in items" :key="item.identifier" class="row-card row-card--task">
<div>
<strong>{{ item.filename }}</strong>
<div class="muted">{{ item.status }}</div>
<div v-if="item.sourcePath" class="muted">{{ item.sourcePath }}</div>
<div v-if="item.manualTaskRequired" class="form-error">Manual create task required</div>
</div>
<div class="upload-progress">{{ item.progress }}%</div>
</div>
</article>
<article class="feature-panel app-panel">
<h3>Upload handoff</h3>
<p>Create task from source path</p>
<p class="muted">If merge returns no source path, keep the upload merged and allow manual task creation.</p>
<div class="row-actions">
<button type="button" @click="$emit('queue-sample-audio-file')">Queue sample audio file</button>
<button type="button" @click="$emit('process-queue')">Process queue</button>
<button type="button" @click="$emit('manual-create')">Manual create task</button>
</div>
<p v-if="statusMessage" class="muted">{{ statusMessage }}</p>
</article>
</section>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import type { UploadQueueItem } from '../../types/upload'
const props = defineProps<{
items: UploadQueueItem[]
autoCreateTask: boolean
statusMessage: string
uploaderOptions: Record<string, unknown>
}>()
const emit = defineEmits<{
'toggle-auto-create': [value: boolean]
'process-queue': []
'manual-create': []
'queue-sample-audio-file': []
'uploader-ready': [payload: { browseButton: HTMLButtonElement | null; dropzone: HTMLDivElement | null; options: Record<string, unknown> }]
}>()
const browseButtonRef = ref<HTMLButtonElement | null>(null)
const dropzoneRef = ref<HTMLDivElement | null>(null)
onMounted(() => {
emit('uploader-ready', {
browseButton: browseButtonRef.value,
dropzone: dropzoneRef.value,
options: props.uploaderOptions
})
})
</script>

View File

@@ -0,0 +1,5 @@
export const uploadConfig = {
chunkSize: 2 * 1024 * 1024,
concurrency: 2,
retry: 2
} as const

View File

@@ -0,0 +1,16 @@
<template>
<div class="app-shell">
<AppSidebar />
<div class="app-shell__body">
<AppHeader />
<main class="app-shell__content">
<RouterView />
</main>
</div>
</div>
</template>
<script setup lang="ts">
import AppHeader from '../components/common/AppHeader.vue'
import AppSidebar from '../components/common/AppSidebar.vue'
</script>

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

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

View File

@@ -0,0 +1,9 @@
import { createRouter, createWebHistory } from 'vue-router'
import { appRoutes } from './routes'
const router = createRouter({
history: createWebHistory('/app/'),
routes: appRoutes
})
export default router

View File

@@ -0,0 +1,75 @@
import type { RouteRecordRaw } from 'vue-router'
import AppLayout from '../layouts/AppLayout.vue'
import DashboardView from '../views/DashboardView.vue'
import TaskManagementView from '../views/TaskManagementView.vue'
import FileUploadView from '../views/FileUploadView.vue'
import FailFileView from '../views/FailFileView.vue'
import MetadataEditorView from '../views/MetadataEditorView.vue'
import SystemConfigView from '../views/SystemConfigView.vue'
export interface AppRouteMeta {
title: string
section: string
navLabel?: string
showInSidebar?: boolean
}
export interface AppRouteRecord extends Omit<RouteRecordRaw, 'meta' | 'children'> {
meta: AppRouteMeta
children?: AppRouteRecord[]
}
const appChildren: AppRouteRecord[] = [
{
path: 'dashboard',
component: DashboardView,
meta: { title: 'Dashboard', section: 'Overview', navLabel: 'Dashboard', showInSidebar: true }
},
{
path: 'tasks',
component: TaskManagementView,
meta: { title: 'Tasks', section: 'Operations', navLabel: 'Tasks', showInSidebar: true }
},
{
path: 'upload',
component: FileUploadView,
meta: { title: 'Upload', section: 'Operations', navLabel: 'Upload', showInSidebar: true }
},
{
path: 'fail-files',
component: FailFileView,
meta: { title: 'Fail Files', section: 'Operations', navLabel: 'Fail Files', showInSidebar: true }
},
{
path: 'metadata/:id',
component: MetadataEditorView,
meta: { title: 'Metadata Editor', section: 'Editor' }
},
{
path: 'system-config',
component: SystemConfigView,
meta: { title: 'System Config', section: 'Settings', navLabel: 'System Config', showInSidebar: true }
},
{
path: ':pathMatch(.*)*',
redirect: '/dashboard',
meta: { title: 'Dashboard', section: 'Overview' }
}
]
export const sidebarNavItems = appChildren
.filter((route) => route.meta.showInSidebar)
.map((route) => ({
to: `/${route.path}`,
label: route.meta.navLabel ?? route.meta.title
}))
export const appRoutes: AppRouteRecord[] = [
{
path: '/',
component: AppLayout,
redirect: '/dashboard',
meta: { title: 'Dashboard', section: 'Overview' },
children: appChildren
}
]

View File

@@ -0,0 +1,41 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
export type BackendHealthStatus = 'unknown' | 'healthy' | 'degraded' | 'offline'
export const useAppStore = defineStore('app', () => {
const sidebarCollapsed = ref(false)
const activeRequests = ref(0)
const backendHealth = ref<BackendHealthStatus>('unknown')
const themeMode = ref<'dark' | 'system'>('dark')
const isBusy = computed(() => activeRequests.value > 0)
function setSidebarCollapsed(nextValue: boolean) {
sidebarCollapsed.value = nextValue
}
function setBackendHealth(nextValue: BackendHealthStatus) {
backendHealth.value = nextValue
}
function beginRequest() {
activeRequests.value += 1
}
function finishRequest() {
activeRequests.value = Math.max(0, activeRequests.value - 1)
}
return {
sidebarCollapsed,
activeRequests,
backendHealth,
themeMode,
isBusy,
setSidebarCollapsed,
setBackendHealth,
beginRequest,
finishRequest
}
})

View File

@@ -0,0 +1,41 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import type { SystemConfigItem } from '../types/config'
export const useConfigStore = defineStore('config', () => {
const items = ref<SystemConfigItem[]>([])
const isLoading = ref(false)
const byKey = computed(() => {
return items.value.reduce<Record<string, SystemConfigItem>>((acc, item) => {
acc[item.key] = item
return acc
}, {})
})
function setConfigs(nextItems: SystemConfigItem[]) {
items.value = nextItems
}
function getValue(key: string): string | undefined {
return byKey.value[key]?.value
}
async function runWithLoading<T>(job: () => Promise<T>): Promise<T> {
isLoading.value = true
try {
return await job()
} finally {
isLoading.value = false
}
}
return {
items,
isLoading,
byKey,
setConfigs,
getValue,
runWithLoading
}
})

View File

@@ -0,0 +1,37 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
export interface UserProfile {
id: string
name: string
role: string
}
export const useUserStore = defineStore('user', () => {
const token = ref('')
const initialized = ref(false)
const profile = ref<UserProfile>({
id: 'placeholder-user',
name: 'Local Operator',
role: 'Pending Auth'
})
const displayName = computed(() => profile.value.name)
function setToken(nextToken: string) {
token.value = nextToken
}
function markInitialized() {
initialized.value = true
}
return {
token,
initialized,
profile,
displayName,
setToken,
markInitialized
}
})

View File

@@ -0,0 +1,11 @@
:root {
--el-bg-color: #101a2b;
--el-bg-color-overlay: #15233a;
--el-text-color-primary: #e8eef8;
--el-text-color-regular: #c6d3ea;
--el-border-color: rgba(140, 170, 210, 0.18);
--el-color-primary: #46b3ff;
--el-color-success: #3ccf91;
--el-color-warning: #f1b357;
--el-color-danger: #ff6f7d;
}

View File

@@ -0,0 +1,303 @@
:root {
color-scheme: dark;
--app-bg: #0d1524;
--app-panel: rgba(16, 26, 43, 0.9);
--app-panel-strong: #15233a;
--app-border: rgba(140, 170, 210, 0.18);
--app-text: #e8eef8;
--app-text-muted: #9baccc;
--app-accent: #46b3ff;
--app-success: #3ccf91;
--app-warning: #f1b357;
--app-danger: #ff6f7d;
font-family: "Segoe UI", "Helvetica Neue", sans-serif;
line-height: 1.5;
font-weight: 400;
color: var(--app-text);
background:
radial-gradient(circle at top, rgba(70, 179, 255, 0.16), transparent 28%),
linear-gradient(180deg, #0d1524 0%, #09111d 100%);
}
* {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
min-height: 100%;
}
body {
min-width: 320px;
}
a {
color: inherit;
text-decoration: none;
}
.app-shell {
display: flex;
min-height: 100vh;
}
.app-shell__body {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
}
.app-shell__content {
padding: 24px;
}
.app-panel {
border: 1px solid var(--app-border);
border-radius: 20px;
background: var(--app-panel);
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.25);
}
.feature-page {
display: flex;
flex-direction: column;
gap: 18px;
}
.feature-page__section {
display: flex;
flex-direction: column;
gap: 18px;
}
.feature-grid {
display: grid;
gap: 18px;
}
.feature-grid--cards {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.feature-grid--dashboard {
grid-template-columns: 1.4fr 1fr 1fr;
}
.feature-grid--tasks {
grid-template-columns: 1.5fr 1fr;
}
.feature-grid--metadata {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.feature-panel {
padding: 18px;
}
.feature-panel--wide {
grid-column: 1 / -1;
}
.feature-panel h3,
.feature-panel h4 {
margin-top: 0;
}
.muted {
color: var(--app-text-muted);
}
.row-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 0;
border-top: 1px solid var(--app-border);
}
.row-card:first-of-type {
border-top: 0;
padding-top: 0;
}
.row-card--stacked {
flex-direction: column;
align-items: flex-start;
}
.row-card--task {
align-items: flex-start;
}
.row-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: end;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.toggle-field {
display: inline-flex;
align-items: center;
gap: 8px;
}
.detail-grid,
.form-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.detail-grid div,
.config-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.upload-progress {
min-width: 52px;
text-align: right;
color: var(--app-accent);
}
.chart-bars {
display: flex;
flex-direction: column;
gap: 12px;
}
.chart-bars__label {
margin-bottom: 6px;
color: var(--app-text-muted);
}
.chart-bars__track {
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
}
.chart-bars__fill {
height: 12px;
border-radius: 999px;
background: linear-gradient(90deg, var(--app-accent), #7bd3ff);
}
.echart-panel {
min-height: 260px;
}
.config-group {
padding-top: 14px;
border-top: 1px solid var(--app-border);
}
.config-group:first-of-type {
padding-top: 0;
border-top: 0;
}
.suggestion-list {
display: flex;
flex-direction: column;
}
.cover-preview {
max-width: 100%;
border-radius: 12px;
}
button,
input,
select,
textarea {
border: 1px solid var(--app-border);
border-radius: 12px;
padding: 10px 12px;
color: var(--app-text);
background: rgba(255, 255, 255, 0.04);
}
button {
cursor: pointer;
}
label {
display: flex;
flex-direction: column;
gap: 6px;
}
pre {
white-space: pre-wrap;
}
.placeholder-view {
padding: 24px;
}
.placeholder-view__body {
padding: 24px;
color: var(--app-text-muted);
}
.pill {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--app-border);
border-radius: 999px;
padding: 8px 12px;
color: var(--app-text-muted);
background: rgba(255, 255, 255, 0.04);
}
.pill__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--app-warning);
}
.pill__dot--ok {
background: var(--app-success);
}
.pill__dot--error {
background: var(--app-danger);
}
@media (max-width: 960px) {
.app-shell {
flex-direction: column;
}
.app-shell__content {
padding: 16px;
}
.feature-grid--cards,
.feature-grid--dashboard,
.feature-grid--tasks,
.feature-grid--metadata,
.detail-grid,
.form-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,129 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const requestMock = {
get: vi.fn(),
post: vi.fn(),
put: vi.fn()
}
vi.mock('../api/request', () => ({
default: requestMock
}))
describe('frontend api modules', () => {
beforeEach(() => {
requestMock.get.mockReset()
requestMock.post.mockReset()
requestMock.put.mockReset()
})
it('maps task endpoints to the real backend contract', async () => {
const taskApi = await import('../api/modules/task')
await taskApi.createTask({ name: 'Import Batch', sourcePath: '/upload/merged.flac' })
await taskApi.listTasks({ status: 'RUNNING', page: 2, pageSize: 10 })
await taskApi.getTaskDetail('task-1')
await taskApi.pauseTask('task-1')
await taskApi.resumeTask('task-1')
await taskApi.terminateTask('task-1')
await taskApi.getTaskReport('task-1')
await taskApi.exportTaskReportCsv('task-1')
await taskApi.exportTaskReportJson('task-1')
expect(requestMock.post).toHaveBeenNthCalledWith(1, '/api/tasks/create', {
name: 'Import Batch',
sourcePath: '/upload/merged.flac'
})
expect(requestMock.get).toHaveBeenNthCalledWith(1, '/api/tasks/list', {
params: { status: 'RUNNING', page: 2, pageSize: 10 }
})
expect(requestMock.get).toHaveBeenNthCalledWith(2, '/api/tasks/task-1')
expect(requestMock.post).toHaveBeenNthCalledWith(2, '/api/tasks/task-1/pause')
expect(requestMock.post).toHaveBeenNthCalledWith(3, '/api/tasks/task-1/resume')
expect(requestMock.post).toHaveBeenNthCalledWith(4, '/api/tasks/task-1/terminate')
expect(requestMock.get).toHaveBeenNthCalledWith(3, '/api/tasks/task-1/report')
expect(requestMock.get).toHaveBeenNthCalledWith(4, '/api/tasks/task-1/report/csv', {
responseType: 'blob'
})
expect(requestMock.get).toHaveBeenNthCalledWith(5, '/api/tasks/task-1/report/json', {
responseType: 'blob'
})
})
it('maps fail-file endpoints to list, detail, edit, and submit operations', async () => {
const failFileApi = await import('../api/modules/failFile')
await failFileApi.listFailFiles({ keyword: 'flac', failType: 'TAG_ERROR', status: 'PENDING', page: 3, pageSize: 25 })
await failFileApi.getFailFileDetail('fail-9')
await failFileApi.saveFailFileDraft('fail-9', { title: 'Edited Title' })
await failFileApi.submitFailFile('fail-9', { title: 'Edited Title' })
expect(requestMock.get).toHaveBeenNthCalledWith(1, '/api/v1/fail-file/list', {
params: { keyword: 'flac', failType: 'TAG_ERROR', status: 'PENDING', page: 3, pageSize: 25 }
})
expect(requestMock.get).toHaveBeenNthCalledWith(2, '/api/v1/fail-file/fail-9/detail')
expect(requestMock.put).toHaveBeenCalledWith('/api/v1/fail-file/fail-9/edit', {
title: 'Edited Title'
})
expect(requestMock.post).toHaveBeenCalledWith('/api/v1/fail-file/fail-9/submit', {
title: 'Edited Title'
})
})
it('maps config, log, and upload endpoints to the real backend contract', async () => {
const configApi = await import('../api/modules/config')
const logApi = await import('../api/modules/log')
const uploadApi = await import('../api/modules/upload')
await configApi.listConfigs()
await configApi.getConfig('archive.outputDir')
await configApi.updateConfig({ key: 'archive.outputDir', value: '/music/archive' })
await configApi.batchUpdateConfigs([{ key: 'archive.outputDir', value: '/music/archive' }])
await logApi.queryLogs({ level: 'ERROR', taskId: 'task-1', page: 1, pageSize: 20 })
await logApi.exportLogs({ level: 'ERROR', taskId: 'task-1' })
await uploadApi.uploadChunk({
identifier: 'upload-1',
chunkNumber: 1,
totalChunks: 2,
filename: 'demo.flac',
totalSize: 100,
chunk: new Blob(['demo'])
})
await uploadApi.mergeUpload({ identifier: 'upload-1', filename: 'demo.flac', totalChunks: 2, autoCreateTask: true, taskName: 'demo.flac' })
expect(requestMock.get).toHaveBeenNthCalledWith(1, '/api/configs/list')
expect(requestMock.get).toHaveBeenNthCalledWith(2, '/api/configs/archive.outputDir')
expect(requestMock.put).toHaveBeenNthCalledWith(1, '/api/configs/update', {
key: 'archive.outputDir',
value: '/music/archive'
})
expect(requestMock.put).toHaveBeenNthCalledWith(2, '/api/configs/batch-update', {
items: [{ key: 'archive.outputDir', value: '/music/archive' }]
})
expect(requestMock.post).toHaveBeenNthCalledWith(1, '/api/logs/query', {
level: 'ERROR',
taskId: 'task-1',
page: 1,
pageSize: 20
})
expect(requestMock.post).toHaveBeenNthCalledWith(2, '/api/logs/export', {
level: 'ERROR',
taskId: 'task-1'
})
expect(requestMock.post).toHaveBeenNthCalledWith(
3,
'/api/v1/file/upload',
expect.any(FormData),
expect.objectContaining({
headers: { 'Content-Type': 'multipart/form-data' }
})
)
expect(requestMock.post).toHaveBeenNthCalledWith(4, '/api/v1/file/upload/merge', {
identifier: 'upload-1',
filename: 'demo.flac',
totalChunks: 2,
autoCreateTask: true,
taskName: 'demo.flac'
})
})
})

View File

@@ -0,0 +1,28 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useConfigStore } from '../stores/config'
describe('config store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('reads config values by key', () => {
const store = useConfigStore()
store.setConfigs([
{ key: 'archive.outputDir', value: '/music/archive', sensitive: false, group: 'archive' }
])
expect(store.getValue('archive.outputDir')).toBe('/music/archive')
})
it('tracks loading state transitions', async () => {
const store = useConfigStore()
await store.runWithLoading(async () => {
expect(store.isLoading).toBe(true)
})
expect(store.isLoading).toBe(false)
})
})

View File

@@ -0,0 +1,387 @@
// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createMemoryHistory, createRouter } from 'vue-router'
import { createPinia, setActivePinia } from 'pinia'
import DashboardView from '../views/DashboardView.vue'
import TaskManagementView from '../views/TaskManagementView.vue'
import FileUploadView from '../views/FileUploadView.vue'
import FailFileView from '../views/FailFileView.vue'
import MetadataEditorView from '../views/MetadataEditorView.vue'
import SystemConfigView from '../views/SystemConfigView.vue'
const intervalSpy = vi.spyOn(globalThis, 'setInterval')
const { taskApiMock, failFileApiMock, configApiMock, uploadApiMock, logApiMock } = vi.hoisted(() => ({
taskApiMock: {
createTask: vi.fn(),
listTasks: vi.fn(),
getTaskDetail: vi.fn(),
pauseTask: vi.fn(),
resumeTask: vi.fn(),
terminateTask: vi.fn(),
getTaskReport: vi.fn(),
exportTaskReportCsv: vi.fn(),
exportTaskReportJson: vi.fn()
},
failFileApiMock: {
listFailFiles: vi.fn(),
getFailFileDetail: vi.fn(),
saveFailFileDraft: vi.fn(),
submitFailFile: vi.fn()
},
configApiMock: {
listConfigs: vi.fn(),
getConfig: vi.fn(),
updateConfig: vi.fn(),
batchUpdateConfigs: vi.fn()
},
uploadApiMock: {
uploadChunk: vi.fn(),
mergeUpload: vi.fn()
},
logApiMock: {
queryLogs: vi.fn(),
exportLogs: vi.fn()
}
}))
vi.mock('../api/modules/task', () => taskApiMock)
vi.mock('../api/modules/failFile', () => failFileApiMock)
vi.mock('../api/modules/config', () => configApiMock)
vi.mock('../api/modules/upload', () => uploadApiMock)
vi.mock('../api/modules/log', () => logApiMock)
function renderWithRouter(component: unknown, route = '/dashboard') {
const pinia = createPinia()
setActivePinia(pinia)
const router = createRouter({
history: createMemoryHistory('/app/'),
routes: [
{ path: '/dashboard', component: { template: '<div />' } },
{ path: '/tasks', component: { template: '<div />' } },
{ path: '/upload', component: { template: '<div />' } },
{ path: '/fail-files', component: { template: '<div />' } },
{ path: '/metadata/:id', component: { template: '<div />' } },
{ path: '/system-config', component: { template: '<div />' } }
]
})
return router.push(route).then(async () => {
await router.isReady()
return render(component as never, {
global: {
plugins: [pinia, router]
}
})
})
}
describe('feature views', () => {
beforeEach(() => {
setActivePinia(createPinia())
intervalSpy.mockClear()
taskApiMock.listTasks.mockResolvedValue({
items: [
{
id: 'task-1',
name: 'Morning Import',
status: 'RUNNING',
totalFiles: 24,
processedFiles: 12,
successFiles: 10,
failedFiles: 2,
archivedFiles: 9,
pendingFiles: 3,
createdAt: '2026-03-18T10:00:00Z',
updatedAt: '2026-03-18T10:15:00Z'
},
{
id: 'task-2',
name: 'Archive Sweep',
status: 'FAILED',
totalFiles: 10,
processedFiles: 10,
successFiles: 7,
failedFiles: 3,
archivedFiles: 7,
pendingFiles: 0,
createdAt: '2026-03-18T09:00:00Z',
updatedAt: '2026-03-18T09:45:00Z'
}
],
total: 12,
page: 1,
pageSize: 10
})
taskApiMock.listTasks.mockImplementation(async (params?: { page?: number }) => ({
items: [
{
id: 'task-1',
name: 'Morning Import',
status: 'RUNNING',
totalFiles: 24,
processedFiles: 12,
successFiles: 10,
failedFiles: 2,
archivedFiles: 9,
pendingFiles: 3,
createdAt: '2026-03-18T10:00:00Z',
updatedAt: '2026-03-18T10:15:00Z'
},
{
id: 'task-2',
name: 'Archive Sweep',
status: 'FAILED',
totalFiles: 10,
processedFiles: 10,
successFiles: 7,
failedFiles: 3,
archivedFiles: 7,
pendingFiles: 0,
createdAt: '2026-03-18T09:00:00Z',
updatedAt: '2026-03-18T09:45:00Z'
},
{
id: 'task-3',
name: 'Paused Queue',
status: 'PAUSED',
totalFiles: 5,
processedFiles: 2,
successFiles: 2,
failedFiles: 0,
archivedFiles: 2,
pendingFiles: 3,
createdAt: '2026-03-18T11:00:00Z',
updatedAt: '2026-03-18T11:05:00Z'
}
],
total: 12,
page: params?.page ?? 1,
pageSize: 10
}))
taskApiMock.getTaskDetail.mockResolvedValue({
id: 'task-1',
name: 'Morning Import',
status: 'RUNNING',
totalFiles: 24,
processedFiles: 12,
successFiles: 10,
failedFiles: 2,
archivedFiles: 9,
pendingFiles: 3,
sourceType: 'UPLOAD',
summary: 'Processing import queue',
report: { items: [{ name: 'a.flac', status: 'SUCCESS' }] },
createdAt: '2026-03-18T10:00:00Z',
updatedAt: '2026-03-18T10:15:00Z'
})
taskApiMock.createTask.mockResolvedValue({ id: 'task-created' })
taskApiMock.pauseTask.mockResolvedValue({ ok: true })
taskApiMock.resumeTask.mockResolvedValue({ ok: true })
taskApiMock.terminateTask.mockResolvedValue({ ok: true })
taskApiMock.getTaskReport.mockResolvedValue({ items: [{ name: 'a.flac', status: 'SUCCESS' }] })
taskApiMock.exportTaskReportCsv.mockResolvedValue(new Blob(['csv']))
taskApiMock.exportTaskReportJson.mockResolvedValue(new Blob(['json']))
failFileApiMock.listFailFiles.mockImplementation(async (params?: { page?: number; size?: number }) => ({
items: [
{
id: 'fail-1',
filename: 'broken-track.flac',
status: 'PENDING',
failType: 'TAG_ERROR',
reason: 'TAG_ERROR',
taskName: 'Morning Import',
createdAt: '2026-03-18T10:16:00Z'
},
{
id: 'fail-2',
filename: 'missing-cover.flac',
status: 'FAILED',
failType: 'COVER_ERROR',
reason: 'COVER_ERROR',
taskName: 'Archive Sweep',
createdAt: '2026-03-18T10:18:00Z'
}
].slice(0, params?.size === 1 ? 1 : 2),
total: 2,
page: params?.page ?? 1,
pageSize: params?.size ?? 10
}))
failFileApiMock.getFailFileDetail.mockResolvedValue({
id: 'fail-1',
filename: 'broken-track.flac',
title: 'Broken Track',
artist: 'Artist',
album: 'Album',
genre: 'Rock',
lyrics: '',
audioUrl: '',
coverUrl: ''
})
failFileApiMock.saveFailFileDraft.mockResolvedValue({ ok: true })
failFileApiMock.submitFailFile.mockResolvedValue({ ok: true })
configApiMock.listConfigs.mockResolvedValue([
{ key: 'archive.outputDir', value: '/music/archive', group: 'archive', sensitive: false },
{ key: 'storage.secretKey', value: 'super-secret', group: 'storage', sensitive: true },
{ key: 'scan.inputDir', value: '/music/inbox', group: 'scan', sensitive: false }
])
configApiMock.getConfig.mockResolvedValue({ key: 'archive.outputDir', value: '/music/archive', group: 'archive', sensitive: false })
configApiMock.updateConfig.mockResolvedValue({ ok: true })
configApiMock.batchUpdateConfigs.mockResolvedValue({ ok: true })
uploadApiMock.uploadChunk.mockResolvedValue({ ok: true })
uploadApiMock.mergeUpload.mockResolvedValue({ uploadId: 'upload-1', sourcePath: '/upload/merged/demo.flac' })
logApiMock.queryLogs.mockResolvedValue({ items: [] })
logApiMock.exportLogs.mockResolvedValue(new Blob(['log']))
})
it('renders dashboard aggregates with timed task and fail-file refresh', async () => {
await renderWithRouter(DashboardView)
await waitFor(() => expect(screen.getByText('Chart total 22')).toBeInTheDocument())
expect(screen.getByText('Pending tasks')).toBeInTheDocument()
expect(screen.getByText('Archived files')).toBeInTheDocument()
expect(screen.getByText('Failed files')).toBeInTheDocument()
expect(screen.getByText('Chart total 22')).toBeInTheDocument()
expect(taskApiMock.listTasks).toHaveBeenCalled()
expect(failFileApiMock.listFailFiles).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 1 }))
expect(intervalSpy.mock.calls.length).toBeGreaterThanOrEqual(2)
expect(screen.getByRole('link', { name: 'Create task' })).toHaveAttribute('href', '/app/tasks')
expect(screen.getByRole('link', { name: 'Open uploader' })).toHaveAttribute('href', '/app/upload')
expect(screen.getByRole('link', { name: 'Review fail files' })).toHaveAttribute('href', '/app/fail-files')
expect(screen.getByRole('link', { name: 'Open config' })).toHaveAttribute('href', '/app/system-config')
})
it('refreshes task state after pause resume terminate and exposes element-plus drawer and dialog containers', async () => {
await renderWithRouter(TaskManagementView, '/tasks')
await waitFor(() => expect(screen.getAllByRole('button', { name: 'Pause' }).length).toBeGreaterThan(0))
expect(intervalSpy).toHaveBeenCalled()
expect(screen.getByText('Page 1 / 2')).toBeInTheDocument()
await fireEvent.click(screen.getAllByRole('button', { name: 'Pause' })[0])
await fireEvent.click(screen.getAllByRole('button', { name: 'Resume' })[0])
await fireEvent.click(screen.getAllByRole('button', { name: 'Terminate' })[0])
await fireEvent.click(screen.getAllByRole('button', { name: 'Open detail drawer' })[0])
await fireEvent.click(screen.getByRole('button', { name: 'Open report dialog' }))
await waitFor(() => expect(screen.getByRole('dialog', { name: 'Task report' })).toBeInTheDocument())
await fireEvent.click(screen.getAllByRole('button', { name: 'Export CSV' })[0])
await fireEvent.click(screen.getAllByRole('button', { name: 'Export JSON' })[0])
expect(taskApiMock.pauseTask).toHaveBeenCalledWith('task-1')
expect(taskApiMock.resumeTask).toHaveBeenCalledWith('task-1')
expect(taskApiMock.terminateTask).toHaveBeenCalledWith('task-1')
expect(taskApiMock.getTaskReport).toHaveBeenCalledWith('task-1')
expect(taskApiMock.exportTaskReportCsv).toHaveBeenCalledWith('task-1')
expect(taskApiMock.exportTaskReportJson).toHaveBeenCalledWith('task-1')
expect(taskApiMock.listTasks.mock.calls.length).toBeGreaterThanOrEqual(1)
expect(taskApiMock.getTaskDetail.mock.calls.length).toBeGreaterThanOrEqual(1)
expect(screen.getByRole('link', { name: 'Create task entry' })).toHaveAttribute('href', '/app/upload')
expect(screen.getByRole('dialog', { name: 'Task detail' })).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: 'Open report dialog' }))
expect(screen.getByRole('dialog', { name: 'Task report' })).toBeInTheDocument()
expect(screen.getByText('a.flac')).toBeInTheDocument()
})
it('uses simple-uploader driven queue flow while preserving upload contract and handoff state', async () => {
await renderWithRouter(FileUploadView, '/upload')
await fireEvent.click(screen.getByRole('button', { name: 'Queue sample audio file' }))
await waitFor(() => expect(screen.getByText('demo.flac')).toBeInTheDocument())
expect(screen.getByLabelText('Auto create task')).toBeChecked()
expect(screen.getByText('queued')).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: 'Process queue' }))
await waitFor(() => expect(uploadApiMock.uploadChunk).toHaveBeenCalled())
await waitFor(() => expect(uploadApiMock.mergeUpload).toHaveBeenCalled())
expect(uploadApiMock.uploadChunk).toHaveBeenCalledWith(expect.objectContaining({
identifier: expect.any(String),
chunkNumber: 1,
totalChunks: 1,
filename: 'demo.flac',
totalSize: 4
}))
expect(uploadApiMock.mergeUpload).toHaveBeenCalledWith(expect.objectContaining({
identifier: expect.any(String),
totalChunks: 1,
filename: 'demo.flac'
}))
await waitFor(() => expect(taskApiMock.createTask).toHaveBeenCalled())
expect(screen.getByText('/upload/merged/demo.flac')).toBeInTheDocument()
expect(screen.getByText('Create task from source path')).toBeInTheDocument()
})
it('reloads fail-file filters, blocks empty batch actions, and keeps return context for editor navigation', async () => {
await renderWithRouter(FailFileView, '/fail-files')
await waitFor(() => expect(screen.getByText('broken-track.flac')).toBeInTheDocument())
await fireEvent.click(screen.getByRole('button', { name: 'Batch reprocess' }))
expect(screen.getByText('Select at least one fail file before batch reprocess.')).toBeInTheDocument()
await fireEvent.update(screen.getByLabelText('Keyword'), 'broken')
await fireEvent.update(screen.getByLabelText('Fail type'), 'TAG_ERROR')
await fireEvent.update(screen.getByLabelText('Status'), 'PENDING')
await waitFor(() => expect(failFileApiMock.listFailFiles.mock.calls.length).toBeGreaterThan(1))
await fireEvent.click(screen.getByLabelText('Select fail-1'))
await fireEvent.click(screen.getByRole('button', { name: 'Batch reprocess' }))
expect(failFileApiMock.submitFailFile).toHaveBeenCalledWith('fail-1', undefined)
await waitFor(() => expect(failFileApiMock.listFailFiles.mock.calls.length).toBeGreaterThanOrEqual(2))
expect(screen.getByText('Batch summary')).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: 'Edit metadata' })[0]).toHaveAttribute('href', '/app/metadata/fail-1?return=%2Ffail-files')
})
it('edits lyrics and cover with stronger validation before save and submit', async () => {
await renderWithRouter(MetadataEditorView, '/metadata/fail-1')
await waitFor(() => expect(failFileApiMock.getFailFileDetail).toHaveBeenCalledWith('fail-1'))
expect(screen.getByText('No audio preview available')).toBeInTheDocument()
expect(screen.getByText('No cover available')).toBeInTheDocument()
await fireEvent.update(screen.getByLabelText('Title'), 'Edited Track')
await fireEvent.update(screen.getByLabelText('Artist'), 'Edited Artist')
await fireEvent.update(screen.getByRole('textbox', { name: 'Lyrics' }), 'new lyrics')
await fireEvent.update(screen.getByLabelText('Cover upload'), {
target: { files: [new File(['cover'], 'cover.jpg', { type: 'image/jpeg' })] }
})
await fireEvent.click(screen.getByRole('button', { name: 'Save draft' }))
await fireEvent.click(screen.getByRole('button', { name: 'Submit metadata' }))
expect(failFileApiMock.saveFailFileDraft).toHaveBeenCalledWith('fail-1', expect.objectContaining({ title: 'Edited Track', artist: 'Edited Artist', lyrics: 'new lyrics' }))
expect(failFileApiMock.submitFailFile).toHaveBeenCalledWith('fail-1', expect.objectContaining({ title: 'Edited Track', artist: 'Edited Artist', lyrics: 'new lyrics' }))
})
it('edits masked sensitive config values and shows directory validation feedback', async () => {
await renderWithRouter(SystemConfigView, '/system-config')
await waitFor(() => expect(screen.getByLabelText('archive.outputDir')).toBeInTheDocument())
expect(screen.getByDisplayValue('••••••••••••')).toBeInTheDocument()
expect(screen.getByLabelText('Path suggestion for scan.inputDir')).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: 'Edit storage.secretKey' }))
await fireEvent.update(screen.getByLabelText('storage.secretKey'), 'new-secret')
await fireEvent.update(screen.getByLabelText('archive.outputDir'), 'relative/path')
expect(screen.getByText('Use an absolute directory path.')).toBeInTheDocument()
await fireEvent.update(screen.getByLabelText('archive.outputDir'), '/music/archive/v2')
await fireEvent.click(screen.getByRole('button', { name: 'Save config' }))
expect(configApiMock.batchUpdateConfigs).toHaveBeenCalled()
await waitFor(() => expect(configApiMock.listConfigs).toHaveBeenCalledTimes(2))
})
})

View File

@@ -0,0 +1,80 @@
// @vitest-environment jsdom
import { render, screen } from '@testing-library/vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import AppLayout from '../layouts/AppLayout.vue'
import App from '../App.vue'
import { useAppStore } from '../stores/app'
import { useUserStore } from '../stores/user'
describe('layout shell', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('renders shell navigation and route meta in the header', async () => {
const pinia = createPinia()
setActivePinia(pinia)
const router = createRouter({
history: createMemoryHistory('/app/'),
routes: [
{
path: '/',
component: AppLayout,
redirect: '/dashboard',
meta: { title: 'Dashboard', section: 'Overview' },
children: [
{
path: 'dashboard',
component: { template: '<div>Dashboard content</div>' },
meta: { title: 'Dashboard', section: 'Overview', navLabel: 'Dashboard', showInSidebar: true }
},
{
path: 'tasks',
component: { template: '<div>Task content</div>' },
meta: { title: 'Tasks', section: 'Operations', navLabel: 'Tasks', showInSidebar: true }
},
{
path: 'upload',
component: { template: '<div>Upload content</div>' },
meta: { title: 'Upload', section: 'Operations', navLabel: 'Upload', showInSidebar: true }
},
{
path: 'fail-files',
component: { template: '<div>Fail file content</div>' },
meta: { title: 'Fail Files', section: 'Operations', navLabel: 'Fail Files', showInSidebar: true }
},
{
path: 'system-config',
component: { template: '<div>System config content</div>' },
meta: { title: 'System Config', section: 'Settings', navLabel: 'System Config', showInSidebar: true }
}
]
}
]
})
await router.push('/tasks')
await router.isReady()
const appStore = useAppStore()
const userStore = useUserStore()
appStore.setBackendHealth('healthy')
userStore.profile.name = 'Shell User'
render(App, {
global: {
plugins: [pinia, router]
}
})
expect(screen.getAllByText('Music Ops')).toHaveLength(1)
expect(screen.getByLabelText('Breadcrumb')).toHaveTextContent('Tasks')
expect(screen.getByRole('heading', { name: 'Tasks' })).toBeInTheDocument()
expect(screen.getByText('Backend Healthy')).toBeInTheDocument()
expect(screen.getByText('User Shell User')).toBeInTheDocument()
expect(screen.getByText('Task content')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,135 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { AxiosHeaders } from 'axios'
import { createPinia, setActivePinia } from 'pinia'
import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import { useAppStore } from '../stores/app'
import { useUserStore } from '../stores/user'
import {
applyRequestDefaults,
buildResponseTransformer,
createRequestClient,
normalizeRequestPath,
normalizeRequestError,
unwrapApiResponse
} from '../api/request'
import type { ApiResponse } from '../types/api'
function createRequestConfig(overrides: Partial<InternalAxiosRequestConfig> = {}): InternalAxiosRequestConfig {
return {
headers: new AxiosHeaders(),
...overrides
} as InternalAxiosRequestConfig
}
function createApiResponse<T>(
data: ApiResponse<T>,
config: Partial<InternalAxiosRequestConfig> = {}
): AxiosResponse<ApiResponse<T>> {
return {
data,
status: 200,
statusText: 'OK',
headers: {},
config: createRequestConfig(config)
}
}
function createBlobResponse(blob: Blob, config: Partial<InternalAxiosRequestConfig> = {}): AxiosResponse<Blob> {
return {
data: blob,
status: 200,
statusText: 'OK',
headers: {},
config: createRequestConfig(config)
}
}
describe('request helpers', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('unwrapApiResponse', () => {
it('returns payload from success envelope', () => {
expect(unwrapApiResponse({ code: 200, message: 'ok', data: { value: 1 } })).toEqual({ value: 1 })
})
it('throws for backend error envelopes', () => {
expect(() => unwrapApiResponse({ code: 500, message: 'boom', data: null })).toThrow('boom')
})
})
describe('applyRequestDefaults', () => {
it('injects bearer token in request interceptor', async () => {
const store = useUserStore()
const appStore = useAppStore()
store.setToken('token-123')
const config = await applyRequestDefaults(createRequestConfig())
expect(config.headers.Authorization).toBe('Bearer token-123')
expect(appStore.activeRequests).toBe(1)
})
it('keeps approved /api-prefixed paths stable', async () => {
const config = await applyRequestDefaults(createRequestConfig({ url: '/api/tasks/list' }))
expect(config.url).toBe('/api/tasks/list')
})
})
describe('normalizeRequestPath', () => {
it('removes duplicate /api prefixing', () => {
expect(normalizeRequestPath('/api/tasks/list')).toBe('/api/tasks/list')
expect(normalizeRequestPath('api/tasks/list')).toBe('/api/tasks/list')
expect(normalizeRequestPath('/tasks/list')).toBe('/api/tasks/list')
})
})
describe('buildResponseTransformer', () => {
it('bypasses envelope unwrapping for blob responses', async () => {
const appStore = useAppStore()
appStore.beginRequest()
const transformResponse = buildResponseTransformer()
const blob = new Blob(['file'])
const result = await transformResponse(
createBlobResponse(blob, { responseType: 'blob' })
)
expect(result).toBe(blob)
expect(appStore.activeRequests).toBe(0)
})
it('unwraps success envelopes through the response transformer', async () => {
const appStore = useAppStore()
appStore.beginRequest()
const transformResponse = buildResponseTransformer()
const result = await transformResponse(
createApiResponse({ code: 200, message: 'ok', data: { value: 2 } })
)
expect(result).toEqual({ value: 2 })
expect(appStore.activeRequests).toBe(0)
})
})
describe('normalizeRequestError', () => {
it('normalizes axios-like request errors', () => {
const error = normalizeRequestError({
response: { data: { message: 'Request failed' } }
})
expect(error.message).toBe('Request failed')
})
})
describe('createRequestClient', () => {
it('creates an axios client with the foundation defaults', () => {
const client = createRequestClient()
expect(client.defaults.baseURL).toBeUndefined()
expect(client.defaults.timeout).toBe(15000)
})
})
})

View File

@@ -0,0 +1,39 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import router from '../router'
import { sidebarNavItems } from '../router/routes'
describe('router', () => {
it('uses /app/ as history base', () => {
expect(router.resolve('/dashboard').href.startsWith('/app/')).toBe(true)
})
it('provides required foundation routes inside the app shell', () => {
const requiredRoutes = [
['/tasks', 'Tasks'],
['/upload', 'Upload'],
['/fail-files', 'Fail Files'],
['/metadata/123', 'Metadata Editor'],
['/system-config', 'System Config']
] as const
for (const [path, title] of requiredRoutes) {
const resolved = router.resolve(path)
expect(resolved.href).toContain('/app/')
expect(resolved.meta.title).toBe(title)
expect(resolved.redirectedFrom).toBeUndefined()
}
})
it('exposes required sidebar route targets without relying on ordering', () => {
const navTargets = new Set(sidebarNavItems.map((item) => item.to))
expect(navTargets.has('/dashboard')).toBe(true)
expect(navTargets.has('/tasks')).toBe(true)
expect(navTargets.has('/upload')).toBe(true)
expect(navTargets.has('/fail-files')).toBe(true)
expect(navTargets.has('/system-config')).toBe(true)
expect(sidebarNavItems.length).toBeGreaterThanOrEqual(5)
})
})

View File

@@ -0,0 +1,8 @@
import { describe, expect, it } from 'vitest'
describe('frontend scaffold', () => {
it('uses the production app base path', async () => {
const config = await import('../../vite.config')
expect(config.default.base).toBe('/app/')
})
})

View File

@@ -0,0 +1,35 @@
import '@testing-library/jest-dom/vitest'
import { afterEach } from 'vitest'
if (typeof HTMLCanvasElement !== 'undefined' && !HTMLCanvasElement.prototype.getContext) {
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
value: () => ({
clearRect() {},
fillRect() {},
beginPath() {},
moveTo() {},
lineTo() {},
arc() {},
closePath() {},
fill() {},
stroke() {},
measureText: () => ({ width: 0 }),
setTransform() {},
save() {},
restore() {},
scale() {},
translate() {},
rotate() {},
rect() {},
clip() {},
createLinearGradient: () => ({ addColorStop() {} }),
createRadialGradient: () => ({ addColorStop() {} })
})
})
}
afterEach(() => {
if (typeof document !== 'undefined') {
document.body.innerHTML = ''
}
})

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { getTaskProgressPercent, isTaskFinished, mapTaskStatusTone } from '../utils/task'
describe('task utils', () => {
it('calculates task progress percentage safely', () => {
expect(getTaskProgressPercent({ processedFiles: 5, totalFiles: 20 })).toBe(25)
expect(getTaskProgressPercent({ processedFiles: 1, totalFiles: 0 })).toBe(0)
})
it('identifies finished task states', () => {
expect(isTaskFinished('SUCCESS')).toBe(true)
expect(isTaskFinished('FAILED')).toBe(true)
expect(isTaskFinished('RUNNING')).toBe(false)
})
it('maps task statuses to UI tones', () => {
expect(mapTaskStatusTone('SUCCESS')).toBe('success')
expect(mapTaskStatusTone('FAILED')).toBe('danger')
expect(mapTaskStatusTone('RUNNING')).toBe('warning')
})
})

13
frontend/src/types/api.ts Normal file
View File

@@ -0,0 +1,13 @@
export interface ApiResponse<T> {
code: number
message: string
data: T
timestamp?: number
}
export interface PageResult<T> {
records: T[]
total: number
page: number
size: number
}

View File

@@ -0,0 +1,19 @@
export interface SystemConfigItem {
key: string
value: string
group: string
sensitive: boolean
}
export interface UpdateSystemConfigItem {
key: string
value: string
}
export interface PathSuggestionParams {
keyword: string
}
export function isDirectoryLikeConfig(key: string): boolean {
return /(?:dir|path)$/i.test(key)
}

View File

@@ -0,0 +1,13 @@
export interface DashboardStats {
pendingCount: number
completedCount: number
failCount: number
}
export interface DashboardQuickEntry {
title: string
value: string
hint: string
to: string
actionLabel: string
}

View File

@@ -0,0 +1,36 @@
export interface FailFileRecord {
id: string
filename: string
status: string
reason: string
failType?: string
taskName?: string
createdAt?: string
}
export interface FailFileListParams {
keyword?: string
failType?: string
status?: string
page?: number
pageSize?: number
}
export interface FailFileListResult {
items: FailFileRecord[]
total: number
page: number
pageSize: number
}
export interface FailFileDetail {
id: string
filename: string
title: string
artist: string
album: string
genre: string
lyrics?: string
audioUrl?: string
coverUrl?: string
}

View File

@@ -0,0 +1,41 @@
export type TaskStatus = 'PENDING' | 'RUNNING' | 'PAUSED' | 'SUCCESS' | 'FAILED' | 'TERMINATED'
export interface TaskSummary {
id: string
name: string
status: TaskStatus
totalFiles: number
processedFiles: number
successFiles?: number
failedFiles?: number
archivedFiles?: number
pendingFiles?: number
createdAt?: string
updatedAt?: string
}
export interface TaskDetail extends TaskSummary {
sourceType?: string
summary?: string
report?: {
items: Array<{ name: string; status: string }>
}
}
export interface TaskListParams {
status?: TaskStatus | ''
page?: number
pageSize?: number
}
export interface TaskListResult {
items: TaskSummary[]
total: number
page: number
pageSize: number
}
export interface CreateTaskPayload {
name: string
sourcePath: string
}

View File

@@ -0,0 +1,33 @@
export interface UploadQueueItem {
identifier: string
filename: string
progress: number
status: 'queued' | 'uploading' | 'merged' | 'failed'
totalChunks?: number
totalSize?: number
sourcePath?: string
manualTaskRequired?: boolean
uploaderFile?: unknown
}
export interface UploadChunkPayload {
identifier: string
chunkNumber: number
totalChunks: number
filename: string
totalSize: number
chunk: Blob
}
export interface MergeUploadPayload {
identifier: string
filename: string
totalChunks: number
autoCreateTask: boolean
taskName?: string
}
export interface MergeUploadResult {
uploadId: string
sourcePath?: string
}

View File

@@ -0,0 +1,13 @@
export function formatCount(value: number): string {
return new Intl.NumberFormat('en-US').format(value)
}
export function formatDateTime(value: string | number | Date): string {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(value))
}

View File

@@ -0,0 +1,19 @@
export interface RequestState {
loading: boolean
error: string
}
export function createRequestState(): RequestState {
return {
loading: false,
error: ''
}
}
export function setRequestLoading(state: RequestState, nextValue: boolean) {
state.loading = nextValue
}
export function setRequestError(state: RequestState, message: string) {
state.error = message
}

View File

@@ -0,0 +1,20 @@
import type { TaskStatus, TaskSummary } from '../types/task'
export function getTaskProgressPercent(task: Pick<TaskSummary, 'processedFiles' | 'totalFiles'>): number {
if (task.totalFiles <= 0) {
return 0
}
return Math.round((task.processedFiles / task.totalFiles) * 100)
}
export function isTaskFinished(status: TaskStatus): boolean {
return ['SUCCESS', 'FAILED', 'TERMINATED'].includes(status)
}
export function mapTaskStatusTone(status: TaskStatus): 'success' | 'warning' | 'danger' | 'info' {
if (status === 'SUCCESS') return 'success'
if (status === 'FAILED' || status === 'TERMINATED') return 'danger'
if (status === 'RUNNING') return 'warning'
return 'info'
}

View File

@@ -0,0 +1,75 @@
<template>
<section class="feature-page">
<PageSectionHeader
title="Dashboard"
description="Track pending, archived, and failed processing with recent tasks, live chart data, and quick entry actions."
eyebrow="Overview"
/>
<DashboardOverview :cards="cards" :tasks="tasks" :quick-entries="quickEntries" :chart-bars="chartBars" />
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import PageSectionHeader from '../components/common/PageSectionHeader.vue'
import DashboardOverview from '../components/dashboard/DashboardOverview.vue'
import { listTasks } from '../api/modules/task'
import { listFailFiles } from '../api/modules/failFile'
import type { DashboardQuickEntry } from '../types/dashboard'
import type { TaskSummary } from '../types/task'
const tasks = ref<TaskSummary[]>([])
const failedTotal = ref(0)
let taskRefreshHandle: ReturnType<typeof setInterval> | undefined
let failRefreshHandle: ReturnType<typeof setInterval> | undefined
const pendingTotal = computed(() => tasks.value.filter((task) => ['RUNNING', 'PAUSED', 'PENDING'].includes(task.status)).length)
const archivedTotal = computed(() => tasks.value.reduce((sum, task) => sum + (task.archivedFiles ?? task.successFiles ?? 0), 0))
const cards = computed(() => [
{ label: 'Pending tasks', value: String(pendingTotal.value), hint: 'Unfinished tasks in pending, running, or paused state.' },
{ label: 'Archived files', value: String(archivedTotal.value), hint: 'Files successfully archived from task results.' },
{ label: 'Failed files', value: String(failedTotal.value), hint: 'Current failures waiting for triage.' }
])
const quickEntries = computed<DashboardQuickEntry[]>(() => [
{ title: 'Create task', value: 'Task workflow', hint: 'Open task controls and create new work from prepared sources.', to: '/tasks', actionLabel: 'Create task' },
{ title: 'Open uploader', value: 'Chunk upload', hint: 'Queue local audio files for upload and merge.', to: '/upload', actionLabel: 'Open uploader' },
{ title: 'Review fail files', value: 'Retry queue', hint: 'Inspect failures and send records into metadata edit.', to: '/fail-files', actionLabel: 'Review fail files' },
{ title: 'Open config', value: 'System settings', hint: 'Edit grouped config and refresh live values.', to: '/system-config', actionLabel: 'Open config' }
])
const chartBars = computed(() => {
const total = pendingTotal.value + archivedTotal.value + failedTotal.value || 1
return [
{ label: 'Pending', raw: pendingTotal.value, value: Math.round((pendingTotal.value / total) * 100) },
{ label: 'Archived', raw: archivedTotal.value, value: Math.round((archivedTotal.value / total) * 100) },
{ label: 'Failed', raw: failedTotal.value, value: Math.round((failedTotal.value / total) * 100) }
]
})
async function refreshTaskData() {
const result = await listTasks({ page: 1, pageSize: 5 })
tasks.value = [...result.items].sort((left, right) => {
return new Date(right.createdAt || 0).getTime() - new Date(left.createdAt || 0).getTime()
}).slice(0, 5)
}
async function refreshFailTotals() {
const result = await listFailFiles({ page: 1, size: 1 } as never)
failedTotal.value = result.total
}
onMounted(async () => {
await refreshTaskData()
await refreshFailTotals()
taskRefreshHandle = setInterval(refreshTaskData, 15000)
failRefreshHandle = setInterval(refreshFailTotals, 60000)
})
onBeforeUnmount(() => {
if (taskRefreshHandle) clearInterval(taskRefreshHandle)
if (failRefreshHandle) clearInterval(failRefreshHandle)
})
</script>

View File

@@ -0,0 +1,115 @@
<template>
<section class="feature-page">
<PageSectionHeader
title="Fail Files"
description="Filter by fail type and status, select batches for repeated submit calls, and jump into metadata edit when needed."
eyebrow="Operations"
/>
<FailFileManager
:records="records"
:keyword="keyword"
:fail-type="failType"
:status-filter="statusFilter"
:selected-ids="selectedIds"
:page="page"
:total-pages="totalPages"
:batch-summary="batchSummary"
@update:keyword="keyword = $event"
@update:fail-type="failType = $event"
@update:status-filter="statusFilter = $event"
@toggle-selected="toggleSelected"
@batch-reprocess="batchReprocess"
@reprocess-one="reprocessOne"
@change-page="loadPage"
/>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import PageSectionHeader from '../components/common/PageSectionHeader.vue'
import FailFileManager from '../components/fail-file/FailFileManager.vue'
import { listFailFiles, submitFailFile } from '../api/modules/failFile'
import type { FailFileRecord } from '../types/fail-file'
const records = ref<FailFileRecord[]>([])
const page = ref(1)
const pageSize = 10
const total = ref(0)
const keyword = ref('')
const failType = ref('')
const statusFilter = ref('')
const selectedIds = ref<string[]>([])
const batchSummary = ref('No batch run yet.')
const route = useRoute()
const router = useRouter()
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
async function refreshList() {
const result = await listFailFiles({
keyword: keyword.value,
failType: failType.value,
status: statusFilter.value,
page: page.value,
pageSize
})
records.value = result.items
total.value = result.total
}
async function loadPage(nextPage = 1) {
page.value = nextPage
await refreshList()
}
function toggleSelected(id: string, selected: boolean) {
selectedIds.value = selected ? [...selectedIds.value, id] : selectedIds.value.filter((item) => item !== id)
}
async function reprocessOne(id: string) {
await submitFailFile(id)
}
async function batchReprocess() {
if (selectedIds.value.length === 0) {
batchSummary.value = 'Select at least one fail file before batch reprocess.'
return
}
let success = 0
let failure = 0
for (const id of selectedIds.value) {
try {
await submitFailFile(id, undefined)
success += 1
} catch {
failure += 1
}
}
batchSummary.value = `Success ${success}, Failure ${failure}`
selectedIds.value = []
await refreshList()
}
onMounted(refreshList)
watch([keyword, failType, statusFilter], async () => {
page.value = 1
await refreshList()
})
watch(
() => route.query.restore,
async (value) => {
if (value === '1') {
await refreshList()
await router.replace({ query: { ...route.query, restore: undefined } })
}
},
{ immediate: true }
)
</script>

View File

@@ -0,0 +1,188 @@
<template>
<section class="feature-page">
<PageSectionHeader
title="File Upload"
description="Drop files into a chunk queue, merge uploads, auto-create tasks when source paths are available, and keep merged state on failure."
eyebrow="Operations"
/>
<UploadQueuePanel
:items="items"
:auto-create-task="autoCreateTask"
:status-message="statusMessage"
:uploader-options="uploaderOptions"
@toggle-auto-create="autoCreateTask = $event"
@queue-sample-audio-file="queueSampleAudioFile"
@uploader-ready="bindUploader"
@process-queue="processQueue"
@manual-create="createTaskFromLastSource"
/>
</section>
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import SimpleUploader from 'simple-uploader.js'
import PageSectionHeader from '../components/common/PageSectionHeader.vue'
import UploadQueuePanel from '../components/upload/UploadQueuePanel.vue'
import { createTask } from '../api/modules/task'
import { mergeUpload, uploadChunk } from '../api/modules/upload'
import { uploadConfig } from '../config/upload'
import type { UploadQueueItem } from '../types/upload'
const autoCreateTask = ref(true)
const statusMessage = ref('')
const items = ref<UploadQueueItem[]>([])
let uploader: SimpleUploader | null = null
const uploaderOptions = {
target: '/api/v1/file/upload',
chunkSize: uploadConfig.chunkSize,
simultaneousUploads: uploadConfig.concurrency,
testChunks: false,
initialPaused: true
}
const supportedAudioTypes = ['audio/flac', 'audio/mpeg', 'audio/wav', 'audio/x-wav', 'audio/mp4', 'audio/aac', 'audio/ogg']
function isSupportedAudio(file: File) {
return supportedAudioTypes.includes(file.type) || /\.(flac|mp3|wav|m4a|aac|ogg)$/i.test(file.name)
}
function queueFiles(files: File[]) {
items.value = files.map((file, index) => ({
identifier: `upload-${Date.now()}-${index}`,
filename: file.name,
progress: 0,
status: 'queued',
totalChunks: Math.max(1, Math.ceil(file.size / uploadConfig.chunkSize)),
totalSize: file.size,
sourcePath: '',
manualTaskRequired: false,
file
})) as UploadQueueItem[]
}
function addQueueItem(file: File, uploaderFile?: unknown) {
if (!isSupportedAudio(file)) {
statusMessage.value = 'Only supported audio files can be queued.'
return
}
const identifier = `upload-${Date.now()}-${items.value.length}`
items.value = [
...items.value,
{
identifier,
filename: file.name,
progress: 0,
status: 'queued',
totalChunks: Math.max(1, Math.ceil(file.size / uploadConfig.chunkSize)),
totalSize: file.size,
sourcePath: '',
manualTaskRequired: false,
uploaderFile,
file
} as UploadQueueItem
]
}
function queueSampleAudioFile() {
addQueueItem(new File(['demo'], 'demo.flac', { type: 'audio/flac' }))
}
function bindUploader(payload: { browseButton: HTMLButtonElement | null; dropzone: HTMLDivElement | null; options: Record<string, unknown> }) {
if (!payload.browseButton || !payload.dropzone) {
return
}
uploader?.cancel()
uploader = new SimpleUploader({
...payload.options,
browse: payload.browseButton,
dropTarget: payload.dropzone,
autoStart: false
})
uploader.on('fileAdded', (uploaderFile: { file: File }) => {
addQueueItem(uploaderFile.file, uploaderFile)
})
}
async function processQueue() {
for (const item of items.value as Array<UploadQueueItem & { file?: File }>) {
const file = item.file ?? new Blob([item.filename])
const chunkCount = item.totalChunks || 1
item.status = 'uploading'
for (let chunkNumber = 1; chunkNumber <= chunkCount; chunkNumber += 1) {
const start = (chunkNumber - 1) * uploadConfig.chunkSize
const end = Math.min(start + uploadConfig.chunkSize, file.size)
const chunk = file.slice(start, end)
let attempt = 0
while (attempt <= uploadConfig.retry) {
try {
await uploadChunk({
identifier: item.identifier,
chunkNumber,
totalChunks: chunkCount,
filename: item.filename,
totalSize: item.totalSize || file.size,
chunk
})
break
} catch (error) {
attempt += 1
if (attempt > uploadConfig.retry) throw error
}
}
item.progress = Math.round((chunkNumber / chunkCount) * 100)
}
const mergeResult = await mergeUpload({
identifier: item.identifier,
filename: item.filename,
totalChunks: chunkCount,
autoCreateTask: autoCreateTask.value,
taskName: item.filename
})
item.progress = 100
item.status = 'merged'
item.sourcePath = mergeResult.sourcePath || ''
item.manualTaskRequired = !mergeResult.sourcePath
if (autoCreateTask.value && mergeResult.sourcePath) {
try {
await createTask({ name: item.filename, sourcePath: mergeResult.sourcePath })
statusMessage.value = `Created task from ${mergeResult.sourcePath}`
} catch {
statusMessage.value = 'Auto-create failed. Upload state preserved for manual create.'
item.manualTaskRequired = true
}
} else if (!mergeResult.sourcePath) {
statusMessage.value = 'Merge completed without source path. Use manual create task.'
}
}
}
async function createTaskFromLastSource() {
const candidate = items.value.find((item) => item.sourcePath)
if (!candidate?.sourcePath && items.value[0]) {
statusMessage.value = 'Merge result missing source path. Manual create requires a source path.'
return
}
await createTask({
name: candidate?.filename || items.value[0]?.filename || 'Uploaded file',
sourcePath: candidate?.sourcePath || ''
})
}
onBeforeUnmount(() => {
uploader?.cancel()
uploader = null
})
</script>

View File

@@ -0,0 +1,92 @@
<template>
<section class="feature-page">
<PageSectionHeader
title="Metadata Editor"
description="Load fail-file detail, validate editable metadata, save drafts, and submit corrected records with graceful media placeholders."
eyebrow="Editor"
/>
<MetadataEditorPanel
:audio-url="detail?.audioUrl"
:cover-url="detail?.coverUrl"
:validation-error="validationError"
:form="form"
@update-field="updateField"
@update-cover="updateCover"
@save-draft="saveDraft"
@submit-metadata="submitMetadata"
/>
</section>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import PageSectionHeader from '../components/common/PageSectionHeader.vue'
import MetadataEditorPanel from '../components/metadata/MetadataEditorPanel.vue'
import { getFailFileDetail, saveFailFileDraft, submitFailFile } from '../api/modules/failFile'
import type { FailFileDetail } from '../types/fail-file'
const route = useRoute()
const router = useRouter()
const detail = ref<FailFileDetail | null>(null)
const validationError = ref('')
const coverFile = ref<File | null>(null)
const form = reactive({
title: '',
artist: '',
album: '',
genre: '',
lyrics: ''
})
function updateField(field: string, value: string) {
;(form as Record<string, string>)[field] = value
}
function updateCover(file: File | null) {
coverFile.value = file
}
function validate() {
if (!form.title.trim()) {
validationError.value = 'Title is required.'
return false
}
if (!form.artist.trim()) {
validationError.value = 'Artist is required.'
return false
}
if (!form.album.trim()) {
validationError.value = 'Album is required.'
return false
}
validationError.value = ''
return !validationError.value
}
async function saveDraft() {
if (!validate()) return
await saveFailFileDraft(String(route.params.id), { ...form, coverFilename: coverFile.value?.name || '' })
}
async function submitMetadata() {
if (!validate()) return
await submitFailFile(String(route.params.id), { ...form, coverFilename: coverFile.value?.name || '' })
const returnTarget = typeof route.query.return === 'string' ? route.query.return : '/fail-files'
await router.push(`${returnTarget}?restore=1`)
}
onMounted(async () => {
const id = String(route.params.id)
detail.value = await getFailFileDetail(id)
form.title = detail.value.title
form.artist = detail.value.artist
form.album = detail.value.album
form.genre = detail.value.genre
form.lyrics = detail.value.lyrics || ''
})
</script>

View File

@@ -0,0 +1,80 @@
<template>
<section class="feature-page">
<PageSectionHeader
title="System Config"
description="Edit grouped configuration, mask sensitive values, show path suggestion inputs for directory keys, and refresh the store after save."
eyebrow="Settings"
/>
<ConfigGroupPanel
:groups="groups"
:suggestions="suggestions"
:suggestion-keyword="suggestionKeyword"
@update:suggestion-keyword="updateSuggestionKeyword"
@update-config="updateConfigValue"
@toggle-sensitive-edit="toggleSensitiveEdit"
@save="saveConfig"
/>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import PageSectionHeader from '../components/common/PageSectionHeader.vue'
import ConfigGroupPanel from '../components/config/ConfigGroupPanel.vue'
import { batchUpdateConfigs, listConfigs } from '../api/modules/config'
import { isDirectoryLikeConfig } from '../types/config'
import { useConfigStore } from '../stores/config'
const configStore = useConfigStore()
const suggestionKeyword = ref('/music')
const suggestions = ref<string[]>([])
const editingSensitiveKeys = ref<string[]>([])
const groups = computed(() => {
const bucket = new Map<string, Array<typeof configStore.items[number] & { directoryLike?: boolean; editing?: boolean; validationMessage?: string }>>()
for (const item of configStore.items) {
const existing = bucket.get(item.group) || []
const directoryLike = isDirectoryLikeConfig(item.key)
existing.push({
...item,
directoryLike,
editing: editingSensitiveKeys.value.includes(item.key),
validationMessage: directoryLike && item.value && !item.value.startsWith('/') ? 'Use an absolute directory path.' : ''
})
bucket.set(item.group, existing)
}
return Array.from(bucket.entries()).map(([name, items]) => ({ name, items }))
})
async function refreshConfigs() {
const items = await configStore.runWithLoading(() => listConfigs())
configStore.setConfigs(items)
}
async function updateSuggestionKeyword(value: string) {
suggestionKeyword.value = value
suggestions.value = value ? [value, `${value}/archive`, `${value}/incoming`] : []
}
function updateConfigValue(key: string, value: string) {
configStore.setConfigs(configStore.items.map((item) => (item.key === key ? { ...item, value } : item)))
}
function toggleSensitiveEdit(key: string) {
editingSensitiveKeys.value = editingSensitiveKeys.value.includes(key)
? editingSensitiveKeys.value.filter((item) => item !== key)
: [...editingSensitiveKeys.value, key]
}
async function saveConfig() {
await batchUpdateConfigs(configStore.items.map((item) => ({ key: item.key, value: item.value })))
await refreshConfigs()
}
onMounted(async () => {
await refreshConfigs()
await updateSuggestionKeyword(suggestionKeyword.value)
})
</script>

View File

@@ -0,0 +1,135 @@
<template>
<section class="feature-page">
<PageSectionHeader
title="Task Management"
description="Manage paginated tasks, refresh running work, inspect detail, and export task reports in CSV or JSON."
eyebrow="Operations"
/>
<TaskManagerPanel
:tasks="tasks"
:detail="selectedTask"
:status="status"
:page="page"
:total-pages="totalPages"
:detail-visible="detailVisible"
:report-visible="reportVisible"
@filter-change="loadTasks"
@pause="handlePause"
@resume="handleResume"
@terminate="handleTerminate"
@open-detail-drawer="loadDetail"
@close-detail-drawer="detailVisible = false"
@change-page="loadPage"
@load-report="loadReport"
@export-csv="handleExportCsv"
@export-json="handleExportJson"
@open-report-dialog="openReportDialog"
@close-report-dialog="reportVisible = false"
/>
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import PageSectionHeader from '../components/common/PageSectionHeader.vue'
import TaskManagerPanel from '../components/task/TaskManagerPanel.vue'
import {
exportTaskReportCsv,
exportTaskReportJson,
getTaskDetail,
getTaskReport,
listTasks,
pauseTask,
resumeTask,
terminateTask
} from '../api/modules/task'
import type { TaskDetail, TaskStatus, TaskSummary } from '../types/task'
const tasks = ref<TaskSummary[]>([])
const selectedTask = ref<TaskDetail | null>(null)
const status = ref<TaskStatus | ''>('')
const page = ref(1)
const pageSize = 10
const total = ref(0)
const detailVisible = ref(false)
const reportVisible = ref(false)
let refreshHandle: ReturnType<typeof setInterval> | undefined
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
async function loadTasks(nextStatus: TaskStatus | '' = status.value) {
status.value = nextStatus
const result = await listTasks({ status: nextStatus, page: page.value, pageSize })
tasks.value = result.items
total.value = result.total
if (tasks.value[0]) {
await loadDetail(tasks.value[0].id)
}
}
async function loadPage(nextPage = 1) {
page.value = nextPage
await loadTasks(status.value)
}
async function loadDetail(taskId: string) {
selectedTask.value = await getTaskDetail(taskId)
detailVisible.value = true
}
async function loadReport(taskId: string) {
const report = await getTaskReport(taskId)
if (selectedTask.value?.id === taskId) {
selectedTask.value = {
...selectedTask.value,
report
}
}
reportVisible.value = true
}
async function openReportDialog(taskId: string) {
await loadReport(taskId)
}
async function handlePause(taskId: string) {
await pauseTask(taskId)
await loadTasks(status.value)
await loadDetail(taskId)
}
async function handleResume(taskId: string) {
await resumeTask(taskId)
await loadTasks(status.value)
await loadDetail(taskId)
}
async function handleTerminate(taskId: string) {
await terminateTask(taskId)
await loadTasks(status.value)
await loadDetail(taskId)
}
async function handleExportCsv(taskId: string) {
await exportTaskReportCsv(taskId)
}
async function handleExportJson(taskId: string) {
await exportTaskReportJson(taskId)
}
onMounted(async () => {
await loadTasks()
refreshHandle = setInterval(async () => {
if (tasks.value.some((task) => task.status === 'RUNNING')) {
await loadTasks(status.value)
}
}, 15000)
})
onBeforeUnmount(() => {
if (refreshHandle) {
clearInterval(refreshHandle)
}
})
</script>

23
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"types": ["vitest/globals", "jsdom"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "vite.config.ts"],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

16
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
base: '/app/',
server: {
proxy: {
'/api': 'http://localhost:8080'
}
},
test: {
environment: 'node',
setupFiles: './src/tests/setup.ts'
}
})

116
pom.xml
View File

@@ -20,7 +20,6 @@
<properties>
<java.version>21</java.version>
<mybatis-plus.version>3.5.7</mybatis-plus.version>
<graalvm.native.buildtools.version>0.10.2</graalvm.native.buildtools.version>
</properties>
<dependencies>
@@ -93,20 +92,123 @@
</executions>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>frontend-build</id>
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
<version>${graalvm.native.buildtools.version}</version>
<extensions>true</extensions>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.3.0</version>
<executions>
<execution>
<id>build-native</id>
<id>frontend-npm-ci</id>
<phase>generate-resources</phase>
<goals>
<goal>compile-no-fork</goal>
<goal>exec</goal>
</goals>
<configuration>
<workingDirectory>${project.basedir}/frontend</workingDirectory>
<executable>npm</executable>
<arguments>
<argument>ci</argument>
</arguments>
</configuration>
</execution>
<execution>
<id>frontend-npm-build</id>
<phase>process-resources</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<workingDirectory>${project.basedir}/frontend</workingDirectory>
<executable>npm</executable>
<arguments>
<argument>run</argument>
<argument>build</argument>
</arguments>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<executions>
<execution>
<id>copy-built-frontend</id>
<phase>process-resources</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${project.basedir}/src/main/resources/static/app</outputDirectory>
<overwrite>true</overwrite>
<resources>
<resource>
<directory>${project.basedir}/frontend/dist</directory>
<filtering>false</filtering>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-antrun-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<id>clean-frontend-workdir</id>
<phase>initialize</phase>
<goals>
<goal>run</goal>
</goals>
<configuration>
<target>
<delete dir="${project.basedir}/frontend/node_modules" failonerror="false"/>
<delete dir="${project.basedir}/frontend/dist" failonerror="false"/>
</target>
</configuration>
</execution>
<execution>
<id>clean-copied-frontend-output</id>
<phase>initialize</phase>
<goals>
<goal>run</goal>
</goals>
<configuration>
<target>
<delete includeemptydirs="true">
<fileset dir="${project.basedir}/src/main/resources/static/app" includes="**/*" excludes=".gitkeep"/>
</delete>
</target>
</configuration>
</execution>
<execution>
<id>verify-copied-frontend-output</id>
<phase>process-resources</phase>
<goals>
<goal>run</goal>
</goals>
<configuration>
<target>
<available file="${project.basedir}/src/main/resources/static/app/index.html" property="frontend.index.present"/>
<fail unless="frontend.index.present">frontend-build profile did not copy frontend/dist/index.html into src/main/resources/static/app/</fail>
</target>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>

View File

@@ -0,0 +1,98 @@
package com.music.metadata.controller;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import java.io.IOException;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
@Controller
public class WebAppController {
private static final String APP_INDEX_PATH = "forward:/app/index.html";
private static final String APP_STATIC_ROOT = "classpath:/static/app/";
private static final String APP_INDEX_FILE = "index.html";
private static final String APP_ASSETS_DIR = "assets/";
private final ResourceLoader resourceLoader;
private final String appStaticRoot;
public WebAppController() {
this(new DefaultResourceLoader(), APP_STATIC_ROOT);
}
WebAppController(ResourceLoader resourceLoader, String appStaticRoot) {
this.resourceLoader = resourceLoader;
this.appStaticRoot = appStaticRoot.endsWith("/") ? appStaticRoot : appStaticRoot + "/";
}
@GetMapping({"/app", "/app/"})
public String forwardAppEntry() {
return APP_INDEX_PATH;
}
@GetMapping("/app/index.html")
public ResponseEntity<Resource> serveIndexHtml() throws IOException {
return serveStaticResource(APP_INDEX_FILE);
}
@GetMapping("/app/assets/{*path}")
public ResponseEntity<Resource> serveStaticAsset(@PathVariable String path) throws IOException {
return serveStaticResource(APP_ASSETS_DIR + normalizePath(path));
}
@GetMapping("/app/{file:[^.]+\\.[A-Za-z0-9]{1,16}}")
public ResponseEntity<Resource> serveTopLevelStaticFile(@PathVariable String file) throws IOException {
return serveStaticResource(file);
}
@GetMapping("/app/{*path}")
public String handleAppPath() {
return APP_INDEX_PATH;
}
private ResponseEntity<Resource> serveStaticResource(String path) throws IOException {
Resource resource = resolveStaticResource(path);
if (!resource.exists() || !resource.isReadable()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.contentType(resolveMediaType(resource))
.body(resource);
}
private static String normalizePath(String path) {
String candidate = path.startsWith("/") ? path.substring(1) : path;
try {
Path normalized = Path.of(candidate).normalize();
String normalizedPath = normalized.toString().replace('\\', '/');
if (normalizedPath.isBlank() || normalizedPath.startsWith("../") || normalizedPath.equals("..") || normalized.isAbsolute()) {
return "__invalid__";
}
return normalizedPath;
} catch (InvalidPathException exception) {
return "__invalid__";
}
}
private Resource resolveStaticResource(String path) {
return resourceLoader.getResource(appStaticRoot + path);
}
private static MediaType resolveMediaType(Resource resource) {
return MediaTypeFactory.getMediaType(resource)
.orElse(MediaType.APPLICATION_OCTET_STREAM);
}
}

View File

View File

@@ -0,0 +1,104 @@
package com.music.metadata.controller;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class WebAppControllerTest {
private final MockMvc mockMvc = MockMvcBuilders
.standaloneSetup(new WebAppController(new DefaultResourceLoader(), "classpath:/test-empty/app/"))
.build();
private final MockMvc packagedFrontendMockMvc = MockMvcBuilders
.standaloneSetup(new WebAppController(new DefaultResourceLoader(), "classpath:/test-static/app/"))
.build();
@Test
void shouldForwardAppEntryToIndex() throws Exception {
mockMvc.perform(get("/app/"))
.andExpect(status().isOk())
.andExpect(forwardedUrl("/app/index.html"));
}
@Test
void shouldForwardNestedSpaRoutesToIndex() throws Exception {
mockMvc.perform(get("/app/tasks"))
.andExpect(status().isOk())
.andExpect(forwardedUrl("/app/index.html"));
}
@Test
void shouldForwardDeepNestedSpaRoutesToIndex() throws Exception {
mockMvc.perform(get("/app/system/config/editor/advanced/theme"))
.andExpect(status().isOk())
.andExpect(forwardedUrl("/app/index.html"));
}
@Test
void shouldReturnNotFoundForAppIndexWhenFrontendIsNotPackaged() throws Exception {
mockMvc.perform(get("/app/index.html"))
.andExpect(status().isNotFound());
}
@Test
void shouldServePackagedAppIndexWhenFrontendExistsOnClasspath() throws Exception {
packagedFrontendMockMvc.perform(get("/app/index.html"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
.andExpect(header().string("Content-Type", org.hamcrest.Matchers.containsString("text/html")));
}
@Test
void shouldServePackagedAssetWhenFrontendExistsOnClasspath() throws Exception {
packagedFrontendMockMvc.perform(get("/app/assets/main.js"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.valueOf("application/javascript")));
}
@Test
void shouldServePackagedTopLevelStaticFileWhenFrontendExistsOnClasspath() throws Exception {
packagedFrontendMockMvc.perform(get("/app/favicon.ico"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.valueOf("image/x-icon")));
}
@Test
void shouldNotForwardMissingStaticAssetRequests() throws Exception {
mockMvc.perform(get("/app/assets/missing.js"))
.andExpect(status().isNotFound());
}
@Test
void shouldAllowDottedClientSegmentsToUseSpaFallback() throws Exception {
mockMvc.perform(get("/app/releases/v1.0.0"))
.andExpect(status().isOk())
.andExpect(forwardedUrl("/app/index.html"));
}
@Test
void shouldTreatTerminalFileNamesAsStaticAssetRequests() throws Exception {
mockMvc.perform(get("/app/assets/main.js"))
.andExpect(status().isNotFound());
}
@Test
void shouldRejectTraversalLikeStaticAssetPaths() throws Exception {
mockMvc.perform(get("/app/assets/../index.html"))
.andExpect(status().isNotFound());
}
@Test
void shouldForwardClientRoutesBeyondFiveSegmentsToIndex() throws Exception {
mockMvc.perform(get("/app/a/b/c/d/e/f/g"))
.andExpect(status().isOk())
.andExpect(forwardedUrl("/app/index.html"));
}
}

View File

@@ -0,0 +1 @@
console.log('packaged frontend fixture')

View File

@@ -0,0 +1 @@
test-icon

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Packaged Frontend Fixture</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/app/assets/main.js"></script>
</body>
</html>