Compare commits
1 Commits
main
...
feature/p6
| Author | SHA1 | Date | |
|---|---|---|---|
| cd716ed2af |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -5,3 +5,7 @@ target/
|
|||||||
.settings/
|
.settings/
|
||||||
*.log
|
*.log
|
||||||
data/
|
data/
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
src/main/resources/static/app/*
|
||||||
|
!src/main/resources/static/app/.gitkeep
|
||||||
|
|||||||
220
docs/P6-前端界面集成-开发落地说明.md
Normal file
220
docs/P6-前端界面集成-开发落地说明.md
Normal 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 与后续优化项,不影响本阶段交付范围内的实现与构建验证结果。
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
| P0 | 已完成 | `docs/P0-基础架构-开发落地说明.md` | 已完成基础工程、数据库底座、基础持久化层与统一异常处理 |
|
| P0 | 已完成 | `docs/P0-基础架构-开发落地说明.md` | 已完成基础工程、数据库底座、基础持久化层与统一异常处理 |
|
||||||
| P1 | 已完成 | `docs/P1-元数据解析与校验-开发落地说明.md` | 已完成元数据读取、校验、快照存储、真实文件验证与覆盖率达标 |
|
| P1 | 已完成 | `docs/P1-元数据解析与校验-开发落地说明.md` | 已完成元数据读取、校验、快照存储、真实文件验证与覆盖率达标 |
|
||||||
| P2 | 待开始 | 暂无 | 后续建议按同样模板新增阶段文档 |
|
| P2 | 待开始 | 暂无 | 后续建议按同样模板新增阶段文档 |
|
||||||
|
| P6 | 已完成 | `docs/P6-前端界面集成-开发落地说明.md` | 已完成 Vue 3 前端工程、6 个核心页面、Spring Boot `/app/` 托管与前端构建集成 |
|
||||||
|
|
||||||
### 1. 总方案文档
|
### 1. 总方案文档
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
|
|
||||||
- `docs/P0-基础架构-开发落地说明.md`
|
- `docs/P0-基础架构-开发落地说明.md`
|
||||||
- `docs/P1-元数据解析与校验-开发落地说明.md`
|
- `docs/P1-元数据解析与校验-开发落地说明.md`
|
||||||
|
- `docs/P6-前端界面集成-开发落地说明.md`
|
||||||
|
|
||||||
## 后续约定
|
## 后续约定
|
||||||
|
|
||||||
|
|||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal 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
4962
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
frontend/package.json
Normal file
32
frontend/package.json
Normal 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
0
frontend/public/.gitkeep
Normal file
3
frontend/src/App.vue
Normal file
3
frontend/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
18
frontend/src/api/modules/config.ts
Normal file
18
frontend/src/api/modules/config.ts
Normal 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 })
|
||||||
|
}
|
||||||
18
frontend/src/api/modules/failFile.ts
Normal file
18
frontend/src/api/modules/failFile.ts
Normal 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)
|
||||||
|
}
|
||||||
16
frontend/src/api/modules/log.ts
Normal file
16
frontend/src/api/modules/log.ts
Normal 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)
|
||||||
|
}
|
||||||
42
frontend/src/api/modules/task.ts
Normal file
42
frontend/src/api/modules/task.ts
Normal 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'
|
||||||
|
})
|
||||||
|
}
|
||||||
22
frontend/src/api/modules/upload.ts
Normal file
22
frontend/src/api/modules/upload.ts
Normal 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)
|
||||||
|
}
|
||||||
97
frontend/src/api/request.ts
Normal file
97
frontend/src/api/request.ts
Normal 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
|
||||||
153
frontend/src/components/common/AppHeader.vue
Normal file
153
frontend/src/components/common/AppHeader.vue
Normal 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>
|
||||||
68
frontend/src/components/common/AppSidebar.vue
Normal file
68
frontend/src/components/common/AppSidebar.vue
Normal 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>
|
||||||
49
frontend/src/components/common/PageSectionHeader.vue
Normal file
49
frontend/src/components/common/PageSectionHeader.vue
Normal 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>
|
||||||
36
frontend/src/components/common/StatCard.vue
Normal file
36
frontend/src/components/common/StatCard.vue
Normal 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>
|
||||||
41
frontend/src/components/common/StatusBadge.vue
Normal file
41
frontend/src/components/common/StatusBadge.vue
Normal 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>
|
||||||
46
frontend/src/components/config/ConfigGroupPanel.vue
Normal file
46
frontend/src/components/config/ConfigGroupPanel.vue
Normal 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>
|
||||||
87
frontend/src/components/dashboard/DashboardOverview.vue
Normal file
87
frontend/src/components/dashboard/DashboardOverview.vue
Normal 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>
|
||||||
71
frontend/src/components/fail-file/FailFileManager.vue
Normal file
71
frontend/src/components/fail-file/FailFileManager.vue
Normal 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>
|
||||||
80
frontend/src/components/metadata/MetadataEditorPanel.vue
Normal file
80
frontend/src/components/metadata/MetadataEditorPanel.vue
Normal 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>
|
||||||
112
frontend/src/components/task/TaskManagerPanel.vue
Normal file
112
frontend/src/components/task/TaskManagerPanel.vue
Normal 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>
|
||||||
74
frontend/src/components/upload/UploadQueuePanel.vue
Normal file
74
frontend/src/components/upload/UploadQueuePanel.vue
Normal 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>
|
||||||
5
frontend/src/config/upload.ts
Normal file
5
frontend/src/config/upload.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const uploadConfig = {
|
||||||
|
chunkSize: 2 * 1024 * 1024,
|
||||||
|
concurrency: 2,
|
||||||
|
retry: 2
|
||||||
|
} as const
|
||||||
16
frontend/src/layouts/AppLayout.vue
Normal file
16
frontend/src/layouts/AppLayout.vue
Normal 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
16
frontend/src/main.ts
Normal 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')
|
||||||
9
frontend/src/router/index.ts
Normal file
9
frontend/src/router/index.ts
Normal 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
|
||||||
75
frontend/src/router/routes.ts
Normal file
75
frontend/src/router/routes.ts
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
41
frontend/src/stores/app.ts
Normal file
41
frontend/src/stores/app.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
41
frontend/src/stores/config.ts
Normal file
41
frontend/src/stores/config.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
37
frontend/src/stores/user.ts
Normal file
37
frontend/src/stores/user.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
11
frontend/src/styles/element-dark.css
Normal file
11
frontend/src/styles/element-dark.css
Normal 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;
|
||||||
|
}
|
||||||
303
frontend/src/styles/index.css
Normal file
303
frontend/src/styles/index.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
129
frontend/src/tests/api-modules.spec.ts
Normal file
129
frontend/src/tests/api-modules.spec.ts
Normal 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'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
28
frontend/src/tests/config-store.spec.ts
Normal file
28
frontend/src/tests/config-store.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
387
frontend/src/tests/feature-views.spec.ts
Normal file
387
frontend/src/tests/feature-views.spec.ts
Normal 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))
|
||||||
|
})
|
||||||
|
})
|
||||||
80
frontend/src/tests/layout-shell.spec.ts
Normal file
80
frontend/src/tests/layout-shell.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
135
frontend/src/tests/request.spec.ts
Normal file
135
frontend/src/tests/request.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
39
frontend/src/tests/router.spec.ts
Normal file
39
frontend/src/tests/router.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
8
frontend/src/tests/scaffold.spec.ts
Normal file
8
frontend/src/tests/scaffold.spec.ts
Normal 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/')
|
||||||
|
})
|
||||||
|
})
|
||||||
35
frontend/src/tests/setup.ts
Normal file
35
frontend/src/tests/setup.ts
Normal 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 = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
21
frontend/src/tests/task-utils.spec.ts
Normal file
21
frontend/src/tests/task-utils.spec.ts
Normal 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
13
frontend/src/types/api.ts
Normal 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
|
||||||
|
}
|
||||||
19
frontend/src/types/config.ts
Normal file
19
frontend/src/types/config.ts
Normal 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)
|
||||||
|
}
|
||||||
13
frontend/src/types/dashboard.ts
Normal file
13
frontend/src/types/dashboard.ts
Normal 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
|
||||||
|
}
|
||||||
36
frontend/src/types/fail-file.ts
Normal file
36
frontend/src/types/fail-file.ts
Normal 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
|
||||||
|
}
|
||||||
41
frontend/src/types/task.ts
Normal file
41
frontend/src/types/task.ts
Normal 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
|
||||||
|
}
|
||||||
33
frontend/src/types/upload.ts
Normal file
33
frontend/src/types/upload.ts
Normal 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
|
||||||
|
}
|
||||||
13
frontend/src/utils/format.ts
Normal file
13
frontend/src/utils/format.ts
Normal 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))
|
||||||
|
}
|
||||||
19
frontend/src/utils/request-state.ts
Normal file
19
frontend/src/utils/request-state.ts
Normal 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
|
||||||
|
}
|
||||||
20
frontend/src/utils/task.ts
Normal file
20
frontend/src/utils/task.ts
Normal 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'
|
||||||
|
}
|
||||||
75
frontend/src/views/DashboardView.vue
Normal file
75
frontend/src/views/DashboardView.vue
Normal 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>
|
||||||
115
frontend/src/views/FailFileView.vue
Normal file
115
frontend/src/views/FailFileView.vue
Normal 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>
|
||||||
188
frontend/src/views/FileUploadView.vue
Normal file
188
frontend/src/views/FileUploadView.vue
Normal 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>
|
||||||
92
frontend/src/views/MetadataEditorView.vue
Normal file
92
frontend/src/views/MetadataEditorView.vue
Normal 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>
|
||||||
80
frontend/src/views/SystemConfigView.vue
Normal file
80
frontend/src/views/SystemConfigView.vue
Normal 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>
|
||||||
135
frontend/src/views/TaskManagementView.vue
Normal file
135
frontend/src/views/TaskManagementView.vue
Normal 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
23
frontend/tsconfig.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
9
frontend/tsconfig.node.json
Normal file
9
frontend/tsconfig.node.json
Normal 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
16
frontend/vite.config.ts
Normal 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'
|
||||||
|
}
|
||||||
|
})
|
||||||
132
pom.xml
132
pom.xml
@@ -20,7 +20,6 @@
|
|||||||
<properties>
|
<properties>
|
||||||
<java.version>21</java.version>
|
<java.version>21</java.version>
|
||||||
<mybatis-plus.version>3.5.7</mybatis-plus.version>
|
<mybatis-plus.version>3.5.7</mybatis-plus.version>
|
||||||
<graalvm.native.buildtools.version>0.10.2</graalvm.native.buildtools.version>
|
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
@@ -93,20 +92,123 @@
|
|||||||
</executions>
|
</executions>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.graalvm.buildtools</groupId>
|
|
||||||
<artifactId>native-maven-plugin</artifactId>
|
|
||||||
<version>${graalvm.native.buildtools.version}</version>
|
|
||||||
<extensions>true</extensions>
|
|
||||||
<executions>
|
|
||||||
<execution>
|
|
||||||
<id>build-native</id>
|
|
||||||
<goals>
|
|
||||||
<goal>compile-no-fork</goal>
|
|
||||||
</goals>
|
|
||||||
</execution>
|
|
||||||
</executions>
|
|
||||||
</plugin>
|
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|
||||||
|
<profiles>
|
||||||
|
<profile>
|
||||||
|
<id>frontend-build</id>
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.codehaus.mojo</groupId>
|
||||||
|
<artifactId>exec-maven-plugin</artifactId>
|
||||||
|
<version>3.3.0</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>frontend-npm-ci</id>
|
||||||
|
<phase>generate-resources</phase>
|
||||||
|
<goals>
|
||||||
|
<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>
|
</project>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
0
src/main/resources/static/app/.gitkeep
Normal file
0
src/main/resources/static/app/.gitkeep
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
0
src/test/resources/test-empty/app/.gitkeep
Normal file
0
src/test/resources/test-empty/app/.gitkeep
Normal file
1
src/test/resources/test-static/app/assets/main.js
Normal file
1
src/test/resources/test-static/app/assets/main.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
console.log('packaged frontend fixture')
|
||||||
1
src/test/resources/test-static/app/favicon.ico
Normal file
1
src/test/resources/test-static/app/favicon.ico
Normal file
@@ -0,0 +1 @@
|
|||||||
|
test-icon
|
||||||
12
src/test/resources/test-static/app/index.html
Normal file
12
src/test/resources/test-static/app/index.html
Normal 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>
|
||||||
Reference in New Issue
Block a user