feat: 前端优化

This commit is contained in:
2025-12-21 22:24:16 +08:00
parent d3a8ea1964
commit b80de78d7c
36 changed files with 3721 additions and 1205 deletions

View File

@@ -18,7 +18,18 @@
"Bash(mysql:*)",
"Bash(npm run lint:*)",
"Bash(npx vue-tsc:*)",
"Bash(pnpm add:*)"
"Bash(pnpm add:*)",
"Bash(./mvnw compile:*)",
"Bash(openspec list:*)",
"Bash(openspec validate:*)",
"Bash(../mvnw:*)",
"Bash(openspec change show:*)",
"Bash(openspec proposal:*)",
"Bash(openspec --help)",
"Bash(openspec:*)",
"Bash(node -c /d/projects/sionrui/frontend/app/web-gold/src/views/system/task-management/digital-human-task/index.vue)",
"Bash(echo \"=== Token 自动刷新功能验证 ===\n\n✅ 已实现的功能:\n1. 请求前检查 token 是否即将过期5分钟缓冲\n2. 如果即将过期,自动触发 refreshToken 刷新\n3. 并发请求时,只有一个请求触发刷新,其他请求等待\n4. 刷新完成后,所有等待的请求使用新 token\n5. 白名单接口login、refresh-token等跳过检查\n6. 401 错误:尝试刷新,失败则跳转登录页\n7. 403 错误:直接跳转登录页\n\n✅ 核心文件修改:\n- frontend/api/axios/client.js - 添加了预检查和刷新逻辑\n- frontend/app/web-gold/src/api/http.js - 保持原有的 401/403 处理\n\n✅ 兼容性:\n- 向后兼容:不影响现有认证流程\n- API 兼容:不改变后端接口契约\n- 用户透明:完全无感知的自动刷新\n\n=== 验证完成 ===\")",
"Bash(node:*)"
],
"deny": [],
"ask": []

View File

@@ -28,6 +28,32 @@ function isInWhiteList(url) {
return WHITE_LIST.some((path) => url.includes(path))
}
/**
* 自动刷新 token 的锁机制和队列
*/
let isRefreshing = false
let refreshSubscribers = []
/**
* 订阅 token 刷新完成
* @param {Function} callback - 回调函数
*/
function subscribeTokenRefresh(callback) {
if (isRefreshing) {
refreshSubscribers.push(callback)
} else {
callback()
}
}
/**
* 执行所有订阅回调
*/
function onRefreshed() {
refreshSubscribers.forEach(callback => callback())
refreshSubscribers = []
}
/**
* 处理 401 未授权错误
* 注意:只做清理工作,不处理重定向(重定向由上层回调处理)
@@ -59,6 +85,7 @@ function handle401Error(error) {
* @param {number} options.timeout - 超时时间(毫秒)
* @param {Function} options.on401 - 401 错误处理函数
* @param {Function} options.on403 - 403 错误处理函数
* @param {Function} options.refreshTokenFn - Token 刷新函数(可选)
* @returns {AxiosInstance} Axios 实例
*/
export function createClientAxios(options = {}) {
@@ -67,6 +94,7 @@ export function createClientAxios(options = {}) {
timeout = 180000,
on401 = handle401Error,
on403 = null,
refreshTokenFn = null,
} = options
const client = axios.create({
@@ -77,21 +105,74 @@ export function createClientAxios(options = {}) {
// 请求拦截器
client.interceptors.request.use((config) => {
// 添加 tenant-id
const tenantId =
const tenantId =
(typeof import.meta !== 'undefined' && import.meta.env?.VITE_TENANT_ID) ||
(typeof process !== 'undefined' && process.env?.VITE_TENANT_ID) ||
'1'
if (tenantId) {
config.headers['tenant-id'] = tenantId
}
// 添加 Authorization header
// 检查是否需要认证
const needToken = config.headers?.isToken !== false && !isInWhiteList(config.url || '')
if (needToken) {
const authHeader = tokenManager.getAuthHeader()
if (authHeader) {
config.headers.Authorization = authHeader
// 检查 token 是否即将过期30秒缓冲
const BUFFER_TIME = 30 * 1000
const currentToken = tokenManager.getAccessToken()
if (!currentToken) {
console.warn('[Token] 没有可用的 accessToken')
return config
}
const isTokenExpired = tokenManager.isExpired(BUFFER_TIME)
if (isTokenExpired) {
console.info('[Token] Token刷新')
// 如果不在刷新过程中,启动刷新
if (!isRefreshing) {
isRefreshing = true
// 执行刷新(使用上层传入的刷新函数)
if (refreshTokenFn && typeof refreshTokenFn === 'function') {
refreshTokenFn()
.then(() => {
console.info('[Token] 刷新成功')
isRefreshing = false
onRefreshed()
})
.catch((error) => {
isRefreshing = false
onRefreshed()
console.error('[Token] 刷新失败:', error.message)
})
} else {
console.warn('[Token] 未提供刷新函数,跳过刷新')
isRefreshing = false
onRefreshed()
}
}
// 等待刷新完成
return new Promise((resolve) => {
subscribeTokenRefresh(() => {
// 刷新完成后,重新获取 token 并添加到请求头
const authHeader = tokenManager.getAuthHeader()
if (authHeader) {
config.headers.Authorization = authHeader
}
resolve(config)
})
})
} else {
// Token 未过期,直接添加 Authorization 头
const authHeader = tokenManager.getAuthHeader()
if (authHeader) {
config.headers.Authorization = authHeader
}
}
}

View File

@@ -16,6 +16,8 @@ function saveTokens(info) {
tokenManager.setTokens({
accessToken: info.accessToken || '',
refreshToken: info.refreshToken || '',
expiresIn: info.expiresTime || 7200, // expiresTime 是秒数
tokenType: info.tokenType || 'Bearer'
})
}
}
@@ -63,6 +65,15 @@ export async function loginByPassword(mobile, password) {
const { data } = await api.post(`${SERVER_BASE}/auth/login`, { mobile, password });
const info = data || {};
saveTokens(info);
// 清除用户信息缓存,确保登录后获取最新信息
try {
const { clearUserInfoCache } = await import('@gold/hooks/web/useUserInfo')
clearUserInfoCache()
} catch (e) {
console.error('清除用户信息缓存失败:', e)
}
return info;
}
@@ -109,6 +120,15 @@ export async function loginBySms(mobile, code) {
const { data } = await api.post(`${SERVER_BASE}/auth/sms-login`, { mobile, code });
const info = data || {};
saveTokens(info);
// 清除用户信息缓存,确保登录后获取最新信息
try {
const { clearUserInfoCache } = await import('@gold/hooks/web/useUserInfo')
clearUserInfoCache()
} catch (e) {
console.error('清除用户信息缓存失败:', e)
}
return info;
}
@@ -125,6 +145,15 @@ export async function refreshToken() {
const { data } = await api.post(`${SERVER_BASE}/auth/refresh-token`, null, { params: { refreshToken: rt } });
const info = data || {};
saveTokens(info);
// 清除用户信息缓存,因为 token 刷新后用户信息可能已更新
try {
const { clearUserInfoCache } = await import('@gold/hooks/web/useUserInfo')
clearUserInfoCache()
} catch (e) {
console.error('清除用户信息缓存失败:', e)
}
return info;
}

View File

@@ -75,3 +75,13 @@ export function deleteTask(taskId) {
method: 'delete'
})
}
/**
* 获取任务输出文件的签名URL
*/
export function getSignedUrls(taskId) {
return request({
url: `/webApi/api/tik/digital-human/task/${taskId}/signed-url`,
method: 'get'
})
}

View File

@@ -22,6 +22,7 @@ export function createHttpClient(options = {}) {
const httpClient = createClientAxios({
baseURL: '/',
timeout: 180000,
refreshTokenFn: refreshToken, // 传递刷新函数给拦截器
on401: async (error) => {
// 401优先使用上层自定义处理
if (on401) {

View File

@@ -26,41 +26,40 @@ const items = computed(() => {
{
title: '功能',
children: [
// { path: '/home', label: '首页', icon: 'home' },
{ path: '/content-style/benchmark', label: '对标分析', icon: 'grid' },
{ path: '/content-style/copywriting', label: '文案创作', icon: 'text' },
{ path: '/trends/forecast', label: '热点趋势', icon: 'text' },
// { name: '首页', label: '首页', icon: 'home' },
{ name: '对标分析', label: '对标分析', icon: 'grid' },
{ name: '文案创作', label: '文案创作', icon: 'text' },
{ name: '热点预测', label: '热点趋势', icon: 'text' },
]
},
{
title: '数字人',
children: [
{ path: '/digital-human/voice-copy', label: '人声克隆', icon: 'mic' },
{ path: "/digital-human/kling", label: "可灵数字人", icon: "user" },
// { path: '/digital-human/video', label: '数字人视频', icon: 'video' },
{ name: '人声克隆', label: '人声克隆', icon: 'mic' },
{ name: '可灵数字人', label: "可灵数字人", icon: "user" },
// { name: '数字人视频', label: '数字人视频', icon: 'video' },
]
},
{
title: '素材库',
children: [
{ path: '/material/list', label: '素材列表', icon: 'grid' },
{ path: '/material/mix', label: '智能混剪', icon: 'scissors' },
{ path: '/material/mix-task', label: '混剪任务', icon: 'video' },
{ path: '/material/group', label: '素材分组', icon: 'folder' },
{ name: '素材列表', label: '素材列表', icon: 'grid' },
{ name: '智能混剪', label: '智能混剪', icon: 'scissors' },
{ name: '素材分组', label: '素材分组', icon: 'folder' },
]
},
{
title: '任务管理',
children: [
{ name: '任务中心', label: '任务中心', icon: 'video', params: { type: 'mix-task' } },
]
},
{
title: '系统',
children: [
{ path: '/system/style-settings', label: '风格设置', icon: 'text' },
{ name: '风格设置', label: '风格设置', icon: 'text' },
]
},
// {
// title: '视频',
// children: [
// { path: '/digital-human/avatar', label: '生成数字人', icon: 'user' },
// ]
// },
}
]
// 如果未登录,过滤掉"系统"菜单组
@@ -71,8 +70,12 @@ const items = computed(() => {
return allItems
})
function go(p) {
router.push(p)
function go(item) {
if (item.params) {
router.push({ name: item.name, params: item.params })
} else {
router.push({ name: item.name })
}
}
</script>
@@ -81,7 +84,7 @@ function go(p) {
<nav class="sidebar__nav">
<div v-for="group in items" :key="group.title" class="nav-group">
<div class="nav-group__title">{{ group.title }}</div>
<button v-for="it in group.children" :key="it.path" class="nav-item" :class="{ 'is-active': route.path === it.path }" @click="go(it.path)">
<button v-for="it in group.children" :key="it.name" class="nav-item" :class="{ 'is-active': route.name === it.name }" @click="go(it)">
<span class="nav-item__icon" aria-hidden="true" v-html="icons[it.icon]"></span>
<span class="nav-item__label">{{ it.label }}</span>
</button>

View File

@@ -1,7 +1,9 @@
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const userStore = useUserStore()
const showDropdown = ref(false)
@@ -61,6 +63,17 @@ function handleMouseEnter() {
function handleMouseLeave() {
showDropdown.value = false
}
// 处理退出登录
async function handleLogout() {
try {
await userStore.logout()
// 跳转到登录页
router.push('/login')
} catch (error) {
console.error('退出登录失败:', error)
}
}
</script>
<template>
@@ -191,7 +204,7 @@ function handleMouseLeave() {
<div class="dropdown-divider"></div>
<div class="dropdown-footer">
<button class="action-btn" @click="userStore.logout">
<button class="action-btn" @click="handleLogout">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M5 2H2C1.44772 2 1 2.44772 1 3V11C1 11.5523 1.44772 12 2 12H5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M9 10L13 7L9 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>

View File

@@ -66,6 +66,11 @@ const routes = [
children: [
{ path: '', redirect: '/system/style-settings' },
{ path: 'style-settings', name: '风格设置', component: () => import('../views/system/StyleSettings.vue') },
{
path: 'task-management/:type',
name: '任务中心',
component: () => import('../views/system/task-management/layout/TaskLayout.vue')
}
],
meta: {
requiresAuth: true
@@ -81,6 +86,11 @@ const routes = [
requiresAuth: true
}
},
// 向后兼容:重定向旧路径到新路径
{
path: '/material/mix-task',
redirect: '/system/task-management/mix-task'
}
]
const router = createRouter({

View File

@@ -149,7 +149,15 @@ export const useUserStore = defineStore('user', () => {
console.error('清空 token 失败:', e)
}
// 2. 清空用户信息
// 2. 清空用户信息缓存
try {
const { clearUserInfoCache } = await import('@gold/hooks/web/useUserInfo')
clearUserInfoCache()
} catch (e) {
console.error('清除用户信息缓存失败:', e)
}
// 3. 清空用户信息
isLoggedIn.value = false
userId.value = ''
nickname.value = ''
@@ -162,7 +170,7 @@ export const useUserStore = defineStore('user', () => {
vipLevel.value = 0
credits.value = 0
// 3. 删除本地存储的用户数据
// 4. 删除本地存储的用户数据
await remove(STORAGE_KEY)
}

View File

@@ -338,7 +338,7 @@ onMounted(() => {
.table-name {
font-size: 15px;
font-weight: 500;
color: #262626;
color: #fff;
}
.table-description {

View File

@@ -40,10 +40,12 @@
<!-- 生成数量 -->
<a-form-item label="生成数量">
<a-radio-group v-model:value="formData.produceCount" button-style="solid">
<a-radio-group v-model:value="formData.produceCount" button-style="solid" @change="saveProduceCount">
<a-radio-button :value="1">1</a-radio-button>
<a-radio-button :value="2">2</a-radio-button>
<a-radio-button :value="3">3</a-radio-button>
<a-radio-button :value="5">5</a-radio-button>
<a-radio-button :value="10">10</a-radio-button>
<a-radio-button :value="15">15</a-radio-button>
</a-radio-group>
</a-form-item>
@@ -270,12 +272,26 @@ const router = useRouter()
const formData = ref({
groupId: null,
title: '',
produceCount: 3,
produceCount: loadProduceCount(),
totalDuration: 15, // 成品总时长 15-30s
clipDuration: 3, // 单切片时长 3-5s
cropMode: 'center' // 裁剪模式,默认居中裁剪
})
// 本地存储键名
const STORAGE_KEY = 'mix-produce-count'
// 从本地存储加载生成数量
function loadProduceCount() {
const saved = localStorage.getItem(STORAGE_KEY)
return saved ? parseInt(saved, 10) : 3
}
// 保存生成数量到本地存储
function saveProduceCount() {
localStorage.setItem(STORAGE_KEY, formData.value.produceCount.toString())
}
// 状态
const loadingGroups = ref(false)
const loadingFiles = ref(false)
@@ -512,7 +528,7 @@ onMounted(() => {
}
&__params {
width: 320px;
width: 340px;
flex-shrink: 0;
.ant-card {

View File

@@ -0,0 +1,169 @@
<template>
<a-space>
<!-- 预览按钮 -->
<a-button
v-if="canPreview"
type="link"
size="small"
@click="handlePreview"
>
<template #icon>
<EyeOutlined />
</template>
预览
</a-button>
<!-- 下载按钮 -->
<a-button
v-if="canDownload"
type="primary"
size="small"
@click="handleDownload"
>
<template #icon>
<DownloadOutlined />
</template>
下载
</a-button>
<!-- 取消按钮 -->
<a-button
v-if="canCancel"
size="small"
@click="handleCancel"
>
取消
</a-button>
<!-- 重试按钮 -->
<a-button
v-if="canRetry"
size="small"
@click="handleRetry"
>
重试
</a-button>
<!-- 删除按钮 -->
<a-popconfirm
v-if="canDelete"
title="确定删除这个任务吗?删除后无法恢复。"
@confirm="handleDelete"
>
<a-button size="small" danger>
删除
</a-button>
</a-popconfirm>
<!-- 插槽用于自定义操作 -->
<slot name="extra" :task="task"></slot>
</a-space>
</template>
<script setup>
import { computed } from 'vue'
import {
EyeOutlined,
DownloadOutlined
} from '@ant-design/icons-vue'
import { useTaskOperations } from '@/views/system/task-management/composables/useTaskOperations'
// Props
const props = defineProps({
task: {
type: Object,
required: true
},
// API 对象
apis: {
type: Object,
default: () => ({})
},
// 操作成功回调
onSuccess: {
type: Function,
default: null
}
})
// 解构 API
const { deleteApi, cancelApi, retryApi, getSignedUrlsApi } = props.apis
// 使用操作 Composable
const operations = useTaskOperations(
{
deleteApi,
cancelApi,
retryApi
},
props.onSuccess
)
// 计算属性:是否可预览
const canPreview = computed(() => {
return props.task.status === 'success' &&
props.task.outputUrls &&
props.task.outputUrls.length > 0 &&
getSignedUrlsApi
})
// 计算属性:是否可下载
const canDownload = computed(() => {
return props.task.status === 'success' &&
props.task.outputUrls &&
props.task.outputUrls.length > 0
})
// 计算属性:是否可取消
const canCancel = computed(() => {
return props.task.status === 'pending' || props.task.status === 'running'
})
// 计算属性:是否可重试
const canRetry = computed(() => {
return props.task.status === 'failed'
})
// 计算属性:是否可删除
const canDelete = computed(() => {
return true // 所有任务都可以删除
})
// 处理预览
const handlePreview = async () => {
if (getSignedUrlsApi) {
await operations.handlePreview(getSignedUrlsApi, props.task.id, 0)
}
}
// 处理下载
const handleDownload = () => {
operations.handleBatchDownload(
props.task.outputUrls || [],
getSignedUrlsApi,
props.task.id
)
}
// 处理取消
const handleCancel = () => {
operations.handleCancel(props.task.id)
}
// 处理重试
const handleRetry = () => {
operations.handleRetry(props.task.id)
}
// 处理删除
const handleDelete = () => {
operations.handleDelete(props.task.id)
}
</script>
<style scoped>
/* 确保按钮内的图标和文字对齐 */
:deep(.ant-btn .anticon) {
line-height: 0;
}
</style>

View File

@@ -0,0 +1,143 @@
<template>
<div class="task-filter-bar">
<a-space>
<!-- 状态筛选 -->
<a-select
v-model:value="localFilters.status"
style="width: 120px"
placeholder="任务状态"
allow-clear
@change="handleChange"
>
<a-select-option value="">全部状态</a-select-option>
<a-select-option value="pending">待处理</a-select-option>
<a-select-option value="running">处理中</a-select-option>
<a-select-option value="success">已完成</a-select-option>
<a-select-option value="failed">失败</a-select-option>
<a-select-option value="canceled">已取消</a-select-option>
</a-select>
<!-- 关键词搜索 -->
<a-input
v-model:value="localFilters.keyword"
:placeholder="placeholder"
style="width: 200px"
allow-clear
@press-enter="handleChange"
>
<template #prefix>
<SearchOutlined />
</template>
</a-input>
<!-- 日期范围选择 -->
<a-range-picker
v-model:value="localFilters.dateRange"
style="width: 300px"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
:placeholder="['开始日期', '结束日期']"
@change="handleChange"
/>
<!-- 查询按钮 -->
<a-button type="primary" @click="handleChange">
查询
</a-button>
<!-- 重置按钮 -->
<a-button @click="handleReset">
重置
</a-button>
<!-- 插槽用于扩展其他筛选条件 -->
<slot name="extra"></slot>
</a-space>
</div>
</template>
<script setup>
import { ref, watch, toRefs } from 'vue'
import { SearchOutlined } from '@ant-design/icons-vue'
// Props
const props = defineProps({
// 筛选条件
filters: {
type: Object,
default: () => ({
status: '',
keyword: '',
dateRange: null
})
},
// 占位符文本
placeholder: {
type: String,
default: '搜索关键词'
},
// 是否在值变化时立即触发
immediate: {
type: Boolean,
default: true
}
})
// Emits
const emit = defineEmits(['update:filters', 'change', 'reset'])
// 内部筛选条件副本
const localFilters = ref({
status: '',
keyword: '',
dateRange: null,
...props.filters
})
// 监听 props 变化,更新内部副本
watch(
() => props.filters,
(newVal) => {
localFilters.value = {
...localFilters.value,
...newVal
}
},
{ deep: true }
)
// 处理变化
const handleChange = () => {
// 更新父组件的 v-model
emit('update:filters', { ...localFilters.value })
// 触发 change 事件
emit('change', { ...localFilters.value })
}
// 处理重置
const handleReset = () => {
localFilters.value = {
status: '',
keyword: '',
dateRange: null
}
// 更新父组件的 v-model
emit('update:filters', { ...localFilters.value })
// 触发重置事件
emit('reset')
}
</script>
<style scoped>
.task-filter-bar {
padding: 16px;
background: var(--color-surface);
border-radius: var(--radius-card);
margin-bottom: 16px;
}
.task-filter-bar .ant-space {
width: 100%;
flex-wrap: wrap;
}
</style>

View File

@@ -0,0 +1,96 @@
<template>
<a-tag :color="color" :class="statusClass">
<template v-if="showIcon && (status === 'running' || status === 'RUNNING')">
<LoadingOutlined :spin="true" />
</template>
{{ text }}
</a-tag>
</template>
<script setup>
import { computed } from 'vue'
import { LoadingOutlined } from '@ant-design/icons-vue'
// Props
const props = defineProps({
// 状态值
status: {
type: String,
required: true
},
// 是否显示图标
showIcon: {
type: Boolean,
default: true
},
// 自定义状态映射
statusMap: {
type: Object,
default: () => ({})
}
})
// 计算属性:状态文本
const text = computed(() => {
// 使用自定义映射或默认映射(同时支持大小写)
const map = {
pending: '待处理',
running: '处理中',
success: '已完成',
failed: '失败',
canceled: '已取消',
// 大写状态支持
PENDING: '待处理',
RUNNING: '处理中',
SUCCESS: '已完成',
FAILED: '失败',
CANCELED: '已取消',
...props.statusMap
}
return map[props.status] || props.status
})
// 计算属性:状态颜色
const color = computed(() => {
const colorMap = {
pending: 'default',
running: 'processing',
success: 'success',
failed: 'error',
canceled: 'warning',
// 大写状态支持
PENDING: 'default',
RUNNING: 'processing',
SUCCESS: 'success',
FAILED: 'error',
CANCELED: 'warning'
}
return colorMap[props.status] || 'default'
})
// 计算属性:状态样式类
const statusClass = computed(() => {
// 将状态标准化为小写用于CSS类名
const normalizedStatus = props.status.toLowerCase()
return `task-status-tag--${normalizedStatus}`
})
</script>
<style scoped>
/* 状态标签动画效果 */
.task-status-tag--running {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.6;
}
100% {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,147 @@
import { ref, reactive } from 'vue'
import { message } from 'ant-design-vue'
/**
* 任务列表通用逻辑 Composable
* @param {Function} fetchApi - 获取列表数据的 API 函数
* @param {Object} options - 配置选项
* @returns {Object} 列表状态和方法
*/
export function useTaskList(fetchApi, options = {}) {
// 默认配置
const defaultOptions = {
pageSize: 10,
pollingInterval: 5000, // 5秒轮询间隔
...options
}
// 响应式状态
const loading = ref(false)
const list = ref([])
const total = ref(0)
const current = ref(1)
const pageSize = ref(defaultOptions.pageSize)
// 筛选条件
const filters = reactive({
status: '',
keyword: '',
dateRange: null,
...options.defaultFilters
})
// 分页配置
const paginationConfig = reactive({
current: 1,
pageSize: defaultOptions.pageSize,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total}`,
pageSizeOptions: ['10', '20', '50', '100'],
onChange: (page, size) => {
current.value = page
pageSize.value = size
paginationConfig.current = page
paginationConfig.pageSize = size
fetchList()
},
onShowSizeChange: (currentPage, size) => {
current.value = 1
pageSize.value = size
paginationConfig.current = 1
paginationConfig.pageSize = size
fetchList()
}
})
// 构建查询参数
const buildParams = () => {
const params = {
pageNo: current.value,
pageSize: pageSize.value
}
// 添加筛选条件
if (filters.status) {
params.status = filters.status
}
if (filters.keyword) {
params.keyword = filters.keyword
}
// 处理日期范围
if (filters.dateRange && Array.isArray(filters.dateRange) && filters.dateRange.length === 2) {
params.createTimeStart = `${filters.dateRange[0]} 00:00:00`
params.createTimeEnd = `${filters.dateRange[1]} 23:59:59`
}
return params
}
// 获取任务列表
const fetchList = async () => {
loading.value = true
try {
const res = await fetchApi(buildParams())
if (res.code === 0) {
list.value = res.data.list || []
total.value = res.data.total || 0
paginationConfig.total = res.data.total || 0
} else {
message.error(res.msg || '加载失败')
}
} catch (error) {
console.error('加载任务列表失败:', error)
message.error('加载失败,请重试')
} finally {
loading.value = false
}
}
// 筛选变化
const handleFilterChange = () => {
current.value = 1
paginationConfig.current = 1
fetchList()
}
// 重置筛选条件
const handleResetFilters = () => {
filters.status = ''
filters.keyword = ''
filters.dateRange = null
current.value = 1
paginationConfig.current = 1
fetchList()
}
// 刷新列表
const refresh = () => {
fetchList()
}
// 表格变化
const handleTableChange = (pag, filters, sorter) => {
console.log('表格变化:', pag, filters, sorter)
}
return {
// 状态
loading,
list,
total,
current,
pageSize,
filters,
paginationConfig,
// 方法
fetchList,
handleFilterChange,
handleResetFilters,
refresh,
handleTableChange,
buildParams
}
}

View File

@@ -0,0 +1,203 @@
import { message, Modal } from 'ant-design-vue'
/**
* 任务操作通用逻辑 Composable
* @param {Object} apis - API 对象,包含 deleteApi, cancelApi, retryApi
* @param {Function} onSuccess - 操作成功后的回调函数
* @returns {Object} 操作方法
*/
export function useTaskOperations(apis, onSuccess) {
const { deleteApi, cancelApi, retryApi } = apis
// 删除任务
const handleDelete = (id) => {
Modal.confirm({
title: '确认删除',
content: '确定删除这个任务吗?删除后无法恢复。',
okText: '确认',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
try {
await deleteApi(id)
message.success('删除成功')
onSuccess && onSuccess()
} catch (error) {
console.error('删除任务失败:', error)
message.error('删除失败')
}
}
})
}
// 取消任务
const handleCancel = (id) => {
Modal.confirm({
title: '确认取消',
content: '确定要取消这个任务吗?',
okText: '确认',
cancelText: '取消',
onOk: async () => {
try {
await cancelApi(id)
message.success('已取消任务')
onSuccess && onSuccess()
} catch (error) {
console.error('取消任务失败:', error)
message.error('操作失败')
}
}
})
}
// 重试任务
const handleRetry = (id) => {
Modal.confirm({
title: '确认重试',
content: '确定要重新生成这个任务吗?',
okText: '确认',
cancelText: '取消',
onOk: async () => {
try {
await retryApi(id)
message.success('已重新提交任务')
onSuccess && onSuccess()
} catch (error) {
console.error('重试任务失败:', error)
message.error('操作失败')
}
}
})
}
// 批量删除
const handleBatchDelete = async (ids, deleteApiFn) => {
if (!ids || ids.length === 0) {
message.warning('请选择要删除的任务')
return
}
Modal.confirm({
title: '确认批量删除',
content: `确定要删除选中的 ${ids.length} 个任务吗?删除后无法恢复。`,
okText: '确认',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
try {
const deleteFn = deleteApiFn || deleteApi
for (const id of ids) {
await deleteFn(id)
}
message.success(`成功删除 ${ids.length} 个任务`)
onSuccess && onSuccess()
} catch (error) {
console.error('批量删除失败:', error)
message.error('批量删除失败')
}
}
})
}
// 下载单个文件
const handleDownload = (url, filename = 'download') => {
const link = document.createElement('a')
link.href = url
link.download = filename
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
// 批量下载
const handleBatchDownload = async (urls, getSignedUrlsApi, taskId) => {
if (!urls || urls.length === 0) {
message.warning('没有可下载的文件')
return
}
try {
message.loading('正在获取下载链接...', 0)
let downloadUrls = urls
// 如果需要获取签名URL
if (getSignedUrlsApi && taskId) {
const res = await getSignedUrlsApi(taskId)
if (res.code === 0 && res.data) {
downloadUrls = res.data
} else {
message.warning('获取下载链接失败')
return
}
}
message.destroy()
message.loading('正在准备下载...', 0)
// 逐个触发下载,避免浏览器阻止多个弹窗
downloadUrls.forEach((url, index) => {
setTimeout(() => {
handleDownload(url)
}, index * 500) // 每个下载间隔500ms
})
message.destroy()
message.success(`已触发 ${downloadUrls.length} 个文件的下载`)
} catch (error) {
console.error('批量下载失败:', error)
message.destroy()
message.error('获取下载链接失败')
}
}
// 获取签名URL
const getSignedUrl = async (getSignedUrlsApi, taskId, index) => {
try {
const res = await getSignedUrlsApi(taskId)
if (res.code === 0 && res.data && res.data[index]) {
return res.data[index]
} else {
message.warning('获取预览链接失败')
return null
}
} catch (error) {
console.error('获取签名URL失败:', error)
message.error('获取预览链接失败')
return null
}
}
// 预览
const handlePreview = async (getSignedUrlsApi, taskId, index) => {
try {
message.loading('正在获取预览链接...', 0)
const url = await getSignedUrl(getSignedUrlsApi, taskId, index)
message.destroy()
if (url) {
window.open(url, '_blank')
}
} catch (error) {
console.error('预览失败:', error)
message.destroy()
message.error('预览失败')
}
}
return {
// 基本操作
handleDelete,
handleCancel,
handleRetry,
// 批量操作
handleBatchDelete,
handleBatchDownload,
// 下载和预览
handleDownload,
handlePreview,
getSignedUrl
}
}

View File

@@ -0,0 +1,171 @@
import { ref, onMounted, onUnmounted } from 'vue'
/**
* 任务状态轮询 Composable
* @param {Function} fetchApi - 获取任务列表的 API 函数
* @param {Object} options - 配置选项
* @returns {Object} 轮询控制方法
*/
export function useTaskPolling(fetchApi, options = {}) {
const defaultOptions = {
interval: 5000, // 默认5秒轮询间隔
filter: (task) => task.status === 'pending' || task.status === 'running',
onTaskUpdate: null, // 任务更新回调
...options
}
const interval = ref(null)
const isPolling = ref(false)
// 开始轮询
const startPolling = () => {
// 清除可能存在的旧定时器
stopPolling()
isPolling.value = true
interval.value = setInterval(async () => {
// 只在页面可见时轮询,避免后台浪费资源
if (document.visibilityState === 'visible') {
await pollTasks()
}
}, defaultOptions.interval)
}
// 停止轮询
const stopPolling = () => {
if (interval.value) {
clearInterval(interval.value)
interval.value = null
}
isPolling.value = false
}
// 执行轮询
const pollTasks = async () => {
try {
// 构建查询参数,只获取 pending 和 running 状态的任务
const params = {
pageNo: 1,
pageSize: 1000, // 获取较多数据以检查所有运行中的任务
status: 'pending,running' // 只获取这两种状态
}
const res = await fetchApi(params)
if (res.code === 0) {
const tasks = res.data.list || []
const activeTasks = tasks.filter(defaultOptions.filter)
// 如果没有运行中的任务,停止轮询
if (activeTasks.length === 0) {
stopPolling()
return
}
// 通知任务更新
if (defaultOptions.onTaskUpdate) {
defaultOptions.onTaskUpdate(tasks)
}
}
} catch (error) {
console.error('轮询任务状态失败:', error)
// 轮询失败不停止,继续尝试
}
}
// 页面可见性变化处理
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
// 页面可见时,恢复轮询
if (!isPolling.value) {
startPolling()
}
} else {
// 页面隐藏时,暂停轮询
if (isPolling.value) {
stopPolling()
}
}
}
// 监听页面可见性变化
const addVisibilityListener = () => {
document.addEventListener('visibilitychange', handleVisibilityChange)
}
// 移除页面可见性监听
const removeVisibilityListener = () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
// 手动触发一次轮询
const manualPoll = async () => {
await pollTasks()
}
// 生命周期钩子
onMounted(() => {
addVisibilityListener()
// 自动开始轮询
startPolling()
})
onUnmounted(() => {
stopPolling()
removeVisibilityListener()
})
return {
// 状态
isPolling,
// 方法
startPolling,
stopPolling,
manualPoll,
addVisibilityListener,
removeVisibilityListener
}
}
/**
* 任务轮询管理 Composable - 用于管理多个任务列表的轮询
* @returns {Object} 轮询管理器
*/
export function useTaskPollingManager() {
const pollings = new Map()
// 注册轮询
const registerPolling = (key, polling) => {
pollings.set(key, polling)
}
// 取消注册轮询
const unregisterPolling = (key) => {
const polling = pollings.get(key)
if (polling) {
polling.stopPolling()
pollings.delete(key)
}
}
// 停止所有轮询
const stopAll = () => {
pollings.forEach((polling) => {
polling.stopPolling()
})
}
// 开始所有轮询
const startAll = () => {
pollings.forEach((polling) => {
polling.startPolling()
})
}
return {
registerPolling,
unregisterPolling,
stopAll,
startAll
}
}

View File

@@ -0,0 +1,579 @@
<template>
<div class="digital-human-task-page">
<!-- 筛选条件 -->
<div class="digital-human-task-page__filters">
<a-space>
<a-select
v-model:value="filters.status"
style="width: 120px"
placeholder="任务状态"
@change="handleFilterChange"
allow-clear
>
<a-select-option value="">全部状态</a-select-option>
<a-select-option value="pending">待处理</a-select-option>
<a-select-option value="running">处理中</a-select-option>
<a-select-option value="success">已完成</a-select-option>
<a-select-option value="failed">失败</a-select-option>
<a-select-option value="canceled">已取消</a-select-option>
</a-select>
<a-input
v-model:value="filters.keyword"
placeholder="搜索任务名称"
style="width: 200px"
allow-clear
@press-enter="handleFilterChange"
>
<template #prefix>
<SearchOutlined />
</template>
</a-input>
<a-range-picker
v-model:value="filters.dateRange"
style="width: 300px"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
:placeholder="['开始日期', '结束日期']"
@change="handleFilterChange"
/>
<a-button type="primary" @click="handleFilterChange">查询</a-button>
<a-button @click="handleResetFilters">重置</a-button>
</a-space>
</div>
<!-- 任务列表 -->
<div class="digital-human-task-page__content">
<!-- 批量操作栏 -->
<div v-if="selectedRowKeys.length > 0" class="batch-actions">
<a-alert
:message="`已选中 ${selectedRowKeys.length} 项`"
type="info"
show-icon
>
<template #action>
<a-space>
<a-button
size="small"
type="primary"
@click="handleBatchDownload"
>
<template #icon>
<DownloadOutlined />
</template>
批量下载
</a-button>
<a-popconfirm
title="确定要删除选中的任务吗?删除后无法恢复。"
@confirm="handleBatchDelete"
>
<a-button size="small" danger>
<template #icon>
<DeleteOutlined />
</template>
批量删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</a-alert>
</div>
<a-spin :spinning="loading" tip="加载中...">
<a-table
:data-source="list"
:columns="columns"
:row-key="record => record.id"
:pagination="paginationConfig"
@change="handleTableChange"
:row-selection="rowSelection"
:scroll="{ x: 1000 }"
>
<!-- 任务名称列 -->
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'taskName'">
<div class="task-name-cell">
<strong>{{ record.taskName }}</strong>
</div>
</template>
<!-- 音色列 -->
<template v-else-if="column.key === 'voiceId'">
<span>{{ record.voiceId || '-' }}</span>
</template>
<!-- 状态列 -->
<template v-else-if="column.key === 'status'">
<TaskStatusTag :status="record.status" />
</template>
<!-- 进度列 -->
<template v-else-if="column.key === 'progress'">
<div style="min-width: 100px">
<div style="font-size: 12px; margin-bottom: 4px">{{ record.progress }}%</div>
<a-progress
:percent="record.progress"
:status="getProgressStatus(record.status)"
size="small"
:show-info="false"
/>
</div>
</template>
<!-- 创建时间列 -->
<template v-else-if="column.key === 'createTime'">
{{ formatDateTime(record.createTime) }}
</template>
<!-- 操作列 -->
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button
v-if="isStatus(record.status, 'success')"
type="primary"
size="small"
@click="handlePreview(record)"
>
<template #icon>
<PlayCircleOutlined />
</template>
预览
</a-button>
<a-button
v-if="isStatus(record.status, 'success')"
size="small"
@click="handleDownload(record)"
>
<template #icon>
<DownloadOutlined />
</template>
下载
</a-button>
<a-button
v-if="isStatus(record.status, 'running')"
size="small"
@click="handleCancel(record.id)"
>
取消
</a-button>
<a-popconfirm
title="确定删除这个任务吗删除后无法恢复"
@confirm="() => handleDelete(record.id)"
>
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-spin>
</div>
<!-- 预览对话框 -->
<a-modal
v-model:open="previewVisible"
title="视频预览"
:footer="null"
width="800px"
>
<video
v-if="previewUrl"
:src="previewUrl"
controls
style="width: 100%; max-height: 600px"
>
您的浏览器不支持视频播放
</video>
</a-modal>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import {
SearchOutlined,
PlayCircleOutlined,
DownloadOutlined,
DeleteOutlined
} from '@ant-design/icons-vue'
import {
getDigitalHumanTaskPage,
cancelTask,
deleteTask
} from '@/api/digitalHuman'
import { formatDate } from '@/utils/file'
import { useTaskList } from '@/views/system/task-management/composables/useTaskList'
import { useTaskOperations } from '@/views/system/task-management/composables/useTaskOperations'
import { useTaskPolling } from '@/views/system/task-management/composables/useTaskPolling'
import TaskStatusTag from '@/views/system/task-management/components/TaskStatusTag.vue'
// 使用 Composable
const {
loading,
list,
filters,
paginationConfig,
fetchList,
handleFilterChange,
handleResetFilters,
handleTableChange
} = useTaskList(getDigitalHumanTaskPage)
// 使用任务操作 Composable
const {
handleDelete,
handleCancel,
} = useTaskOperations(
{
deleteApi: deleteTask,
cancelApi: cancelTask,
},
fetchList
)
// 使用轮询 Composable
useTaskPolling(getDigitalHumanTaskPage, {
onTaskUpdate: () => {
fetchList()
}
})
// 预览相关
const previewVisible = ref(false)
const previewUrl = ref('')
// 表格选择相关
const selectedRowKeys = ref([])
// 表格行选择配置
const rowSelection = {
selectedRowKeys,
onChange: (keys) => {
selectedRowKeys.value = keys
},
onSelectAll: (selected, selectedRows, changeRows) => {
// 全选逻辑
console.log('全选状态:', selected, '选中行数:', selectedRows.length, '变化行数:', changeRows.length)
}
}
// 格式化日期时间
const formatDateTime = (dateStr) => {
return formatDate(dateStr)
}
// 表格列定义
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
fixed: 'left'
},
{
title: '任务名称',
dataIndex: 'taskName',
key: 'taskName',
width: 250,
ellipsis: true,
fixed: 'left'
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100
},
{
title: '进度',
dataIndex: 'progress',
key: 'progress',
width: 150
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
width: 180
},
{
title: '操作',
key: 'actions',
width: 280,
fixed: 'right'
}
]
// 获取进度条状态
const getProgressStatus = (status) => {
const statusMap = {
pending: 'normal',
running: 'active',
success: 'success',
failed: 'exception',
canceled: 'normal',
// 大写状态支持
PENDING: 'normal',
RUNNING: 'active',
SUCCESS: 'success',
FAILED: 'exception',
CANCELED: 'normal'
}
return statusMap[status] || 'normal'
}
// 检查状态(同时支持大小写)
const isStatus = (status, targetStatus) => {
return status === targetStatus || status === targetStatus.toUpperCase()
}
// 预览视频
const handlePreview = (record) => {
if (!record.resultVideoUrl) {
message.warning('该任务暂无视频结果,请稍后再试')
return
}
previewUrl.value = record.resultVideoUrl
previewVisible.value = true
}
// 下载视频
const handleDownload = (record) => {
if (!record.resultVideoUrl) {
message.warning('该任务暂无视频结果,请稍后再试')
return
}
const link = document.createElement('a')
link.href = record.resultVideoUrl
link.download = `数字人视频_${record.id}_${Date.now()}.mp4`
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
// 批量下载视频
const handleBatchDownload = () => {
// 获取选中的已完成任务
const selectedTasks = list.value.filter(task =>
selectedRowKeys.value.includes(task.id) &&
isStatus(task.status, 'success')
)
if (selectedTasks.length === 0) {
message.warning('请选择已完成的任务')
return
}
let successCount = 0
let failCount = 0
// 逐个直接下载
for (const task of selectedTasks) {
if (!task.resultVideoUrl) {
failCount++
continue
}
try {
const link = document.createElement('a')
link.href = task.resultVideoUrl
link.download = `数字人视频_${task.id}_${Date.now()}.mp4`
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
successCount++
} catch (error) {
console.error(`下载任务 ${task.id} 失败:`, error)
failCount++
}
}
if (failCount === 0) {
message.success(`已触发 ${successCount} 个文件的下载`)
} else if (successCount === 0) {
message.error('所有文件下载失败,请重试')
} else {
message.warning(`成功下载 ${successCount} 个文件,${failCount} 个文件下载失败`)
}
}
// 批量删除任务
const handleBatchDelete = async () => {
if (selectedRowKeys.value.length === 0) {
message.warning('请选择要删除的任务')
return
}
try {
// 逐个删除选中的任务
for (const id of selectedRowKeys.value) {
await deleteTask(id)
}
message.success(`成功删除 ${selectedRowKeys.value.length} 个任务`)
// 清空选择并刷新列表
selectedRowKeys.value = []
await fetchList()
} catch (error) {
console.error('批量删除失败:', error)
message.error('批量删除失败,请重试')
}
}
// 初始化
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.digital-human-task-page {
padding: 16px;
height: 100%;
display: flex;
flex-direction: column;
gap: 16px;
}
.digital-human-task-page__filters {
padding: 16px;
background: var(--color-surface);
border-radius: var(--radius-card);
}
.digital-human-task-page__content {
flex: 1;
overflow: auto;
background: var(--color-surface);
border-radius: var(--radius-card);
padding: 16px;
display: flex;
flex-direction: column;
}
/* 批量操作栏 */
.batch-actions {
margin-bottom: 16px;
}
/* 表格容器 */
.batch-actions + .ant-spin {
flex: 1;
display: flex;
flex-direction: column;
}
.batch-actions + .ant-spin :deep(.ant-spin-container) {
flex: 1;
display: flex;
flex-direction: column;
}
.batch-actions + .ant-spin :deep(.ant-table) {
flex: 1;
}
/* 任务名称单元格 */
.task-name-cell {
display: flex;
align-items: center;
gap: 8px;
}
/* 文本截断 */
.text-ellipsis {
display: inline-block;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 展开内容 */
.expanded-content {
padding: 16px;
background: var(--color-bg-2);
border-radius: var(--radius-card);
margin: 8px;
}
.task-text {
margin-bottom: 16px;
}
.task-text p {
margin: 8px 0 0 0;
padding: 8px;
background: var(--color-surface);
border-radius: var(--radius-card);
line-height: 1.6;
}
.task-params {
margin-bottom: 16px;
}
.params-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
margin-top: 8px;
}
.param-item {
display: flex;
align-items: center;
padding: 8px;
background: var(--color-surface);
border-radius: var(--radius-card);
}
.param-label {
font-weight: 600;
margin-right: 8px;
color: var(--color-text-2);
}
.param-value {
color: var(--color-text);
}
.task-result {
margin-bottom: 16px;
}
.processing-tip {
color: var(--color-text-3);
font-size: 12px;
}
.task-error {
margin-bottom: 8px;
}
/* 确保按钮内的图标和文字对齐 */
:deep(.ant-btn .anticon) {
line-height: 0;
}
/* 表格样式 */
:deep(.ant-table-tbody > tr > td) {
padding: 12px 8px;
}
:deep(.ant-table-thead > tr > th) {
background: var(--color-bg-2);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,335 @@
<template>
<div class="task-layout">
<!-- 左侧导航 -->
<aside class="task-layout__sidebar">
<nav class="task-layout__nav">
<div class="task-layout__nav-header">
<h2 class="task-layout__nav-title">任务管理</h2>
</div>
<ul class="task-layout__nav-list">
<li
v-for="item in navItems"
:key="item.type"
class="task-layout__nav-item"
:class="{
'is-active': currentType === item.type
}"
>
<a
href="javascript:void(0)"
class="task-layout__nav-link"
@click="navigateTo(item.type)"
>
<span class="nav-icon" v-html="icons[item.icon]"></span>
<span class="nav-text">{{ item.label }}</span>
</a>
</li>
</ul>
</nav>
</aside>
<!-- 右侧内容 -->
<main class="task-layout__content">
<transition name="fade" mode="out-in">
<component :is="currentComponent" :key="currentType" />
</transition>
</main>
<!-- 移动端菜单按钮 -->
<a-button
v-if="isMobile"
class="task-layout__mobile-btn"
type="primary"
shape="circle"
size="large"
@click="showMobileNav = !showMobileNav"
>
<template #icon>
<MenuOutlined />
</template>
</a-button>
<!-- 移动端遮罩层 -->
<a-drawer
v-model:open="showMobileNav"
placement="left"
:width="220"
:closable="false"
class="task-layout__mobile-drawer"
>
<div class="task-layout__mobile-nav">
<div class="task-layout__nav-header">
<h2 class="task-layout__nav-title">任务管理</h2>
</div>
<ul class="task-layout__nav-list">
<li
v-for="item in navItems"
:key="item.type"
class="task-layout__nav-item"
:class="{
'is-active': currentType === item.type
}"
>
<a
href="javascript:void(0)"
class="task-layout__nav-link"
@click="navigateTo(item.type, true)"
>
<span class="nav-icon" v-html="icons[item.icon]"></span>
<span class="nav-text">{{ item.label }}</span>
</a>
</li>
</ul>
</div>
</a-drawer>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, defineAsyncComponent } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { MenuOutlined } from '@ant-design/icons-vue'
// 响应式数据
const route = useRoute()
const router = useRouter()
const showMobileNav = ref(false)
const windowWidth = ref(window.innerWidth)
// 单色 SVG 图标(填充 currentColor可继承文本色
const icons = {
video: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><path d="m22 8-6 4 6 4V8Z"/><rect x="2" y="6" width="14" height="12" rx="2" ry="2"/></svg>',
user: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18"><circle cx="12" cy="7" r="4"/><path d="M5.5 21a8.38 8.38 0 0 1 13 0"/></svg>'
}
// 计算属性
const isMobile = computed(() => windowWidth.value < 768)
const isTablet = computed(() => windowWidth.value >= 768 && windowWidth.value < 1200)
// 当前任务类型 - 使用路由name来激活高亮
const currentType = computed(() => {
// 使用路由的params.type如果不存在则默认使用mix-task
const type = route.params.type
// 如果类型无效或为空,默认使用 mix-task
if (!type || type === 'task-management') {
return 'mix-task'
}
return type
})
// 动态导入组件
const MixTaskList = defineAsyncComponent(() => import('../mix-task/index.vue'))
const DigitalHumanTaskList = defineAsyncComponent(() => import('../digital-human-task/index.vue'))
// 导航项配置
const navItems = [
{
type: 'mix-task',
label: '混剪视频任务',
icon: 'video',
component: MixTaskList
},
{
type: 'digital-human-task',
label: '数字人视频任务',
icon: 'user',
component: DigitalHumanTaskList
}
]
// 当前组件
const currentComponent = computed(() => {
const item = navItems.find(item => item.type === currentType.value)
if (!item) {
return navItems[0].component
}
return item.component
})
// 导航到指定类型
const navigateTo = (type, closeMobile = false) => {
router.push(`/system/task-management/${type}`)
if (closeMobile) {
showMobileNav.value = false
}
}
// 更新窗口宽度
const updateWindowWidth = () => {
windowWidth.value = window.innerWidth
}
// 生命周期钩子
onMounted(() => {
window.addEventListener('resize', updateWindowWidth)
})
onUnmounted(() => {
window.removeEventListener('resize', updateWindowWidth)
})
</script>
<style scoped>
.task-layout {
display: flex;
height: 100%;
width: 100%;
position: relative;
overflow: hidden;
}
/* 左侧导航 */
.task-layout__sidebar {
width: 220px;
background: var(--color-surface);
border-right: 1px solid var(--color-border);
flex-shrink: 0;
overflow-y: auto;
}
@media (min-width: 768px) and (max-width: 1199px) {
.task-layout__sidebar {
width: 200px;
}
}
@media (max-width: 767px) {
.task-layout__sidebar {
display: none;
}
}
/* 导航头部 */
.task-layout__nav-header {
padding: 24px 16px;
border-bottom: 1px solid var(--color-border);
}
.task-layout__nav-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--color-text);
}
/* 导航列表 */
.task-layout__nav-list {
list-style: none;
padding: 8px 0;
margin: 0;
}
/* 导航项 */
.task-layout__nav-item {
margin: 4px 8px;
}
.task-layout__nav-item.is-active .task-layout__nav-link {
background: var(--color-primary);
color: #fff;
}
.task-layout__nav-item.is-active .nav-icon {
color: #fff;
}
/* 导航链接 */
.task-layout__nav-link {
display: flex;
align-items: center;
padding: 12px 16px;
border-radius: var(--radius-card);
color: var(--color-text-secondary);
text-decoration: none;
transition: all 0.2s;
cursor: pointer;
}
.task-layout__nav-link:hover {
background: var(--color-bg-2);
color: var(--color-primary);
}
.task-layout__nav-item.is-active .task-layout__nav-link:hover {
background: var(--color-primary);
color: #fff;
}
/* 导航图标 */
.nav-icon {
display: inline-block;
width: 18px;
height: 18px;
margin-right: 12px;
color: var(--color-text-3);
transition: color 0.2s;
}
.task-layout__nav-item.is-active .nav-icon {
color: #fff;
}
/* 导航文本 */
.nav-text {
font-size: 14px;
font-weight: 500;
}
/* 右侧内容 */
.task-layout__content {
flex: 1;
overflow: auto;
background: var(--color-bg);
padding: 0;
}
/* 过渡动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.25s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 移动端按钮 */
.task-layout__mobile-btn {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
@media (min-width: 768px) {
.task-layout__mobile-btn {
display: none;
}
}
/* 移动端抽屉 */
.task-layout__mobile-drawer :deep(.ant-drawer-body) {
padding: 0;
background: var(--color-surface);
}
.task-layout__mobile-nav {
height: 100%;
display: flex;
flex-direction: column;
}
.task-layout__mobile-nav .task-layout__nav-header {
background: var(--color-surface);
}
.task-layout__mobile-nav .task-layout__nav-list {
flex: 1;
overflow-y: auto;
background: var(--color-surface);
}
</style>

View File

@@ -0,0 +1,482 @@
<template>
<div class="mix-task-page">
<!-- 筛选条件 -->
<div class="mix-task-page__filters">
<a-space>
<a-select
v-model:value="filters.status"
style="width: 120px"
placeholder="任务状态"
@change="handleFilterChange"
allow-clear
>
<a-select-option value="">全部状态</a-select-option>
<a-select-option value="pending">待处理</a-select-option>
<a-select-option value="running">处理中</a-select-option>
<a-select-option value="success">已完成</a-select-option>
<a-select-option value="failed">失败</a-select-option>
</a-select>
<a-input
v-model:value="filters.keyword"
placeholder="搜索标题"
style="width: 200px"
allow-clear
@press-enter="handleFilterChange"
>
<template #prefix>
<SearchOutlined />
</template>
</a-input>
<a-range-picker
v-model:value="filters.dateRange"
style="width: 300px"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
:placeholder="['开始日期', '结束日期']"
@change="handleFilterChange"
/>
<a-button type="primary" @click="handleFilterChange">查询</a-button>
<a-button @click="handleResetFilters">重置</a-button>
</a-space>
</div>
<!-- 任务列表 -->
<div class="mix-task-page__content">
<a-spin :spinning="loading" tip="加载中...">
<a-table
:data-source="list"
:columns="columns"
:row-key="record => record.id"
:pagination="paginationConfig"
@change="handleTableChange"
:expanded-row-keys="expandedRowKeys"
@expandedRowsChange="handleExpandedRowsChange"
:scroll="{ x: 1000 }"
>
<!-- 标题列 -->
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<div class="title-cell">
<strong>{{ record.title }}</strong>
<a-tag v-if="record.text" size="small" style="margin-left: 8px">有文案</a-tag>
</div>
</template>
<!-- 状态列 -->
<template v-else-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</template>
<!-- 进度列 -->
<template v-else-if="column.key === 'progress'">
<div style="min-width: 100px">
<div style="font-size: 12px; margin-bottom: 4px">{{ record.progress }}%</div>
<a-progress
:percent="record.progress"
:status="getProgressStatus(record.status)"
size="small"
:show-info="false"
/>
</div>
</template>
<!-- 创建时间列 -->
<template v-else-if="column.key === 'createTime'">
{{ formatDate(record.createTime) }}
</template>
<!-- 完成时间列 -->
<template v-else-if="column.key === 'finishTime'">
{{ record.finishTime ? formatDate(record.finishTime) : '-' }}
</template>
<!-- 生成结果列 -->
<template v-else-if="column.key === 'outputUrls'">
<div v-if="record.outputUrls && record.outputUrls.length > 0">
<a-tag color="success">{{ record.outputUrls.length }} 个视频</a-tag>
</div>
<span v-else>-</span>
</template>
<!-- 操作列 -->
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button
v-if="isStatus(record.status, 'success') && record.outputUrls && record.outputUrls.length > 0"
type="primary"
size="small"
@click="handleDownloadAll(record.id)"
>
<template #icon>
<DownloadOutlined />
</template>
<span>下载</span>
</a-button>
<a-button
v-if="isStatus(record.status, 'running')"
size="small"
@click="handleCancel(record.id)"
>
取消
</a-button>
<a-button
v-if="isStatus(record.status, 'failed')"
size="small"
@click="handleRetry(record.id)"
>
重试
</a-button>
<a-popconfirm
title="确定删除这个任务吗删除后无法恢复"
@confirm="() => handleDelete(record.id)"
>
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
<!-- 展开行内容 -->
<template #expandedRowRender="{ record }">
<div class="expanded-content">
<!-- 任务详情 -->
<div v-if="record.text" class="task-text">
<strong>文案内容:</strong>
<p>{{ record.text }}</p>
</div>
<!-- 生成结果 -->
<div v-if="record.outputUrls && record.outputUrls.length > 0" class="task-results">
<strong>生成结果:</strong>
<div class="result-list">
<div
v-for="(url, index) in record.outputUrls"
:key="index"
class="result-item"
>
<a-button
v-if="isStatus(record.status, 'success')"
type="link"
@click="handlePreview(record.id, index)"
>
<PlayCircleOutlined />
视频 {{ index + 1 }}
</a-button>
<a-button
v-if="isStatus(record.status, 'success')"
type="link"
size="small"
@click="handleDownloadSignedUrl(record.id, index)"
>
<DownloadOutlined />
</a-button>
<span v-else class="processing-tip">
视频 {{ index + 1 }} (处理中...)
</span>
</div>
</div>
</div>
<!-- 错误信息 -->
<div v-if="record.errorMsg" class="task-error">
<a-alert
type="error"
:message="record.errorMsg"
show-icon
/>
</div>
</div>
</template>
</a-table>
</a-spin>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import {
SearchOutlined,
PlayCircleOutlined,
DownloadOutlined
} from '@ant-design/icons-vue'
import { MixTaskService } from '@/api/mixTask'
import { formatDate } from '@/utils/file'
import { useTaskList } from '@/views/system/task-management/composables/useTaskList'
import { useTaskOperations } from '@/views/system/task-management/composables/useTaskOperations'
import { useTaskPolling } from '@/views/system/task-management/composables/useTaskPolling'
// 使用 Composable
const {
loading,
list,
filters,
paginationConfig,
fetchList,
handleFilterChange,
handleResetFilters,
handleTableChange,
buildParams
} = useTaskList(MixTaskService.getTaskPage)
// 使用任务操作 Composable
const {
handleDelete,
handleCancel,
handleRetry,
handlePreview,
handleBatchDownload
} = useTaskOperations(
{
deleteApi: MixTaskService.deleteTask,
cancelApi: MixTaskService.cancelTask,
retryApi: MixTaskService.retryTask
},
fetchList
)
// 使用轮询 Composable
useTaskPolling(MixTaskService.getTaskPage, {
onTaskUpdate: () => {
fetchList()
}
})
// 扩展行键
const expandedRowKeys = ref([])
// 处理展开行变化
const handleExpandedRowsChange = (keys) => {
expandedRowKeys.value = keys
}
// 表格列定义
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
fixed: 'left'
},
{
title: '标题',
dataIndex: 'title',
key: 'title',
width: 250,
ellipsis: true
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100
},
{
title: '进度',
dataIndex: 'progress',
key: 'progress',
width: 150
},
{
title: '生成结果',
dataIndex: 'outputUrls',
key: 'outputUrls',
width: 120
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
width: 180
},
{
title: '完成时间',
dataIndex: 'finishTime',
key: 'finishTime',
width: 180
},
{
title: '操作',
key: 'actions',
width: 300,
fixed: 'right'
}
]
// 状态映射函数
const getStatusText = (status) => {
const statusMap = {
pending: '待处理',
running: '处理中',
success: '已完成',
failed: '失败'
}
return statusMap[status] || status
}
const getStatusColor = (status) => {
const colorMap = {
pending: 'default',
running: 'processing',
success: 'success',
failed: 'error'
}
return colorMap[status] || 'default'
}
const getProgressStatus = (status) => {
const statusMap = {
pending: 'normal',
running: 'active',
success: 'success',
failed: 'exception',
// 大写状态支持
PENDING: 'normal',
RUNNING: 'active',
SUCCESS: 'success',
FAILED: 'exception'
}
return statusMap[status] || 'normal'
}
// 检查状态(同时支持大小写)
const isStatus = (status, targetStatus) => {
return status === targetStatus || status === targetStatus.toUpperCase()
}
// 下载单个视频使用签名URL
const handleDownloadSignedUrl = async (taskId, index) => {
try {
const res = await MixTaskService.getSignedUrls(taskId)
if (res.code === 0 && res.data && res.data[index]) {
const link = document.createElement('a')
link.href = res.data[index]
link.download = `video_${taskId}_${index + 1}`
link.target = '_blank'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} else {
console.warn('获取下载链接失败')
}
} catch (error) {
console.error('获取下载链接失败:', error)
}
}
// 批量下载所有视频
const handleDownloadAll = async (taskId) => {
handleBatchDownload(
[],
MixTaskService.getSignedUrls,
taskId
)
}
// 初始化
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.mix-task-page {
padding: 16px;
height: 100%;
display: flex;
flex-direction: column;
gap: 16px;
}
.mix-task-page__filters {
padding: 16px;
background: var(--color-surface);
border-radius: var(--radius-card);
}
.mix-task-page__content {
flex: 1;
overflow: auto;
background: var(--color-surface);
border-radius: var(--radius-card);
padding: 16px;
}
/* 标题单元格 */
.title-cell {
display: flex;
align-items: center;
gap: 8px;
}
/* 展开内容 */
.expanded-content {
padding: 16px;
background: var(--color-bg-2);
border-radius: var(--radius-card);
margin: 8px;
}
.task-text {
margin-bottom: 16px;
}
.task-text p {
margin: 8px 0 0 0;
padding: 8px;
background: var(--color-surface);
border-radius: var(--radius-card);
line-height: 1.6;
}
.task-results {
margin-bottom: 16px;
}
.result-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.result-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--color-surface);
border-radius: var(--radius-card);
font-size: 13px;
}
.processing-tip {
color: var(--color-text-3);
font-size: 12px;
}
.task-error {
margin-bottom: 8px;
}
/* 确保按钮内的图标和文字对齐 */
:deep(.ant-btn .anticon) {
line-height: 0;
}
/* 表格样式 */
:deep(.ant-table-tbody > tr > td) {
padding: 12px 8px;
}
:deep(.ant-table-thead > tr > th) {
background: var(--color-bg-2);
font-weight: 600;
}
</style>

View File

@@ -1,10 +1,10 @@
/**
* 用户信息 Hook
* 封装获取用户信息的逻辑,可在各个应用中复用
*
*
* 使用方式:
* import { useUserInfo } from '@gold/hooks/web/useUserInfo'
*
*
* const { fetchUserInfo, loading, error } = useUserInfo()
* await fetchUserInfo()
*/
@@ -13,6 +13,10 @@ import { ref } from 'vue'
import axios from 'axios'
import { API_BASE } from '@gold/config/api'
// 本地存储配置
const CACHE_KEY = 'USER_INFO_CACHE'
const CACHE_DURATION = 5 * 60 * 1000 // 5分钟缓存
// 获取 token 的工具函数(需要从应用层传入或使用全局配置)
let getTokenFn = null
@@ -46,6 +50,60 @@ function getAuthHeader() {
return ''
}
/**
* 从本地存储获取缓存的用户信息
* @returns {Object|null} 缓存的用户信息或 null
*/
function getCachedUserInfo() {
try {
const cached = sessionStorage.getItem(CACHE_KEY)
if (!cached) return null
const { data, timestamp } = JSON.parse(cached)
const now = Date.now()
// 检查缓存是否过期
if (now - timestamp > CACHE_DURATION) {
sessionStorage.removeItem(CACHE_KEY)
return null
}
console.log('[UserInfo] 使用本地缓存')
return data
} catch (e) {
return null
}
}
/**
* 将用户信息存储到本地
* @param {Object} userInfo - 用户信息
*/
function setCachedUserInfo(userInfo) {
try {
const cacheData = {
data: userInfo,
timestamp: Date.now()
}
sessionStorage.setItem(CACHE_KEY, JSON.stringify(cacheData))
console.log('[UserInfo] 更新本地缓存')
} catch (e) {
console.error('缓存用户信息失败:', e)
}
}
/**
* 清除用户信息缓存
*/
export function clearUserInfoCache() {
try {
sessionStorage.removeItem(CACHE_KEY)
console.log('[UserInfo] 清除缓存')
} catch (e) {
console.error('清除缓存失败:', e)
}
}
/**
* 用户信息 Hook
* @param {Object} options - 配置选项
@@ -76,6 +134,16 @@ export function useUserInfo(options = {}) {
error.value = null
try {
// 1. 先尝试从本地缓存获取
const cachedUserInfo = getCachedUserInfo()
if (cachedUserInfo) {
userInfo.value = cachedUserInfo
loading.value = false
return cachedUserInfo
}
// 2. 发起请求获取用户信息
console.log('[UserInfo] 发起请求')
const authHeader = getAuthHeader()
const headers = {
'Content-Type': 'application/json',
@@ -86,17 +154,17 @@ export function useUserInfo(options = {}) {
}
// 获取 tenant-id从环境变量或默认值
const tenantId =
const tenantId =
(typeof import.meta !== 'undefined' && import.meta.env?.VITE_TENANT_ID) ||
(typeof process !== 'undefined' && process.env?.VITE_TENANT_ID) ||
'1'
if (tenantId) {
headers['tenant-id'] = tenantId
}
const response = await axios.get(apiUrl, { headers })
// 处理响应数据(根据后端返回格式调整)
// 后端通常返回 { code: 0, data: {...}, msg: '...' } 格式
let data = null
@@ -112,8 +180,10 @@ export function useUserInfo(options = {}) {
data = response.data.data || response.data
}
}
if (data) {
// 3. 将获取到的数据写入本地缓存
setCachedUserInfo(data)
userInfo.value = data
return data
}
@@ -147,4 +217,3 @@ export async function getUserInfo(options = {}) {
const { fetchUserInfo } = useUserInfo(options)
return await fetchUserInfo()
}

View File

@@ -0,0 +1,40 @@
# 变更:前端 HTTP 拦截器自动刷新 refreshToken 功能
## 为什么
当前系统的 token 过期处理机制存在缺陷:
1. 只在收到 401 错误后才尝试刷新 token
2. 导致用户看到错误提示后才发现 token 已过期
3. 影响用户体验,特别是长时间操作时
需要实现在请求前主动检查并刷新即将过期的 token提升用户体验。
## 什么发生变化
`frontend/api/axios/client.js` 的请求拦截器中添加智能 token 刷新机制:
### 新增功能
1. **预检查机制**:在每次需要认证的请求前,检查 token 是否即将过期(默认 5 分钟缓冲时间)
2. **自动刷新**:如果 token 即将过期,自动使用 refreshToken 获取新 token
3. **并发控制**:防止多个请求同时触发 token 刷新
4. **无缝体验**:用户无需感知 token 刷新过程,请求正常进行
### 影响的文件
- `frontend/api/axios/client.js` - 核心拦截器逻辑
- `frontend/api/http.js` - 可能需要更新调用方式
- `frontend/utils/token-manager.js` - 使用现有的 `isExpired()` 方法
### 关键设计决策
1. **缓冲时间**:使用 5 分钟作为 token 刷新缓冲时间(可配置)
2. **白名单**:刷新 token 接口 `/auth/refresh-token` 不需要 token直接跳过检查
3. **错误处理**:刷新失败时清理 token 并跳转登录页
4. **状态管理**:使用 Promise 锁机制防止并发刷新
## 影响
- **用户体验**:显著提升 - 消除因 token 过期导致的请求失败
- **系统稳定性**:提高 - 减少 401 错误发生频率
- **安全性**:保持 - 继续使用 refreshToken 机制,安全性不变
- **性能影响**:极小 - 仅在 token 即将过期时触发一次刷新请求
## 兼容性
- 向后兼容:不影响现有认证流程
- API 兼容:不改变后端接口契约
- 配置兼容:可配置缓冲时间,默认 5 分钟

View File

@@ -0,0 +1,74 @@
## ADDED Requirements
### Requirement: 请求前自动检查并刷新 token
系统 MUST 在发送需要认证的 HTTP 请求前,主动检查访问令牌是否即将过期,如果即将过期则自动使用 refreshToken 刷新,避免因 token 过期导致请求失败。
#### Scenario: Token 即将过期时自动刷新
- **GIVEN** 用户已登录且 accessToken 将在 3 分钟后过期
- **WHEN** 发起需要认证的 API 请求
- **THEN** 系统自动使用 refreshToken 调用刷新接口
- **AND** 刷新成功后使用新的 accessToken 发送原请求
- **AND** 用户无感知,请求正常完成
#### Scenario: Token 正常情况下不触发刷新
- **GIVEN** 用户已登录且 accessToken 将在 30 分钟后过期
- **WHEN** 发起需要认证的 API 请求
- **THEN** 系统检查 token 未过期
- **AND** 直接使用当前 token 发送请求
- **AND** 不调用刷新接口
#### Scenario: 白名单接口跳过 token 检查
- **GIVEN** 用户已登录
- **WHEN** 访问以下接口:
- `/auth/login`(登录)
- `/auth/refresh-token`(刷新 token
- `/auth/register`(注册)
- `/auth/send-sms-code`(发送短信)
- **THEN** 系统跳过 token 过期检查
- **AND** 不添加 Authorization 头
#### Scenario: 防止并发刷新 token
- **GIVEN** 用户已登录且 token 即将过期
- **WHEN** 同时发起 3 个需要认证的请求
- **THEN** 只有一个请求触发 token 刷新
- **AND** 其他 2 个请求等待刷新完成后使用新 token
- **AND** 刷新接口只被调用一次
#### Scenario: 刷新失败时清理状态
- **GIVEN** 用户已登录且 token 已过期
- **WHEN** 发起需要认证的请求
- **AND** 调用 refreshToken 接口返回 401refreshToken 也无效)
- **THEN** 系统自动清理 localStorage 中的所有 token
- **AND** 跳转到登录页要求用户重新登录
- **AND** 拒绝所有后续请求直到重新登录
#### Scenario: 自定义缓冲时间
- **GIVEN** 系统配置 token 刷新缓冲时间为 10 分钟
- **WHEN** accessToken 将在 12 分钟后过期
- **THEN** 系统认为 token 仍然有效
- **WHEN** accessToken 将在 8 分钟后过期
- **THEN** 系统自动触发 token 刷新
## MODIFIED Requirements
### Requirement: 请求拦截器增强
现有的请求拦截器 MUST 增强为支持 token 预检查和自动刷新功能。
#### Scenario: 拦截器新增预检查逻辑
- **GIVEN** 用户已登录且系统配置了自动刷新功能
- **WHEN** 发起需要认证的 HTTP 请求
- **THEN** 拦截器在添加 Authorization 头之前检查 token 过期时间
- **AND** 如果 token 即将过期,启动异步刷新流程
- **AND** 刷新完成后使用新 token 添加到请求头
- **AND** 继续发送原始请求
**Modified Behavior**:
- 在添加 Authorization 头之前,先检查 token 是否即将过期
- 如果即将过期且不在刷新过程中,则启动异步刷新流程
- 刷新完成后继续添加 Authorization 头并发送请求
- 使用 Promise 机制确保所有等待刷新的请求按顺序执行
**Backward Compatibility**:
- 现有的 401 错误处理机制保持不变
- 如果预检查失败(如 refreshToken 无效),仍然会触发 401 处理
- 所有现有接口调用方式保持不变

View File

@@ -0,0 +1,33 @@
# 任务清单:自动刷新 refreshToken 功能
## 1. 实现请求拦截器 token 预检查
- [ ] 1.1 在 `client.js` 请求拦截器中添加 token 过期检查逻辑
- [ ] 1.2 调用 `tokenManager.isExpired()` 检查是否需要刷新
- [ ] 1.3 对白名单接口跳过检查login、refresh-token、register 等)
## 2. 实现自动刷新机制
- [ ] 2.1 创建异步刷新函数,内部调用 `/auth/refresh-token` 接口
- [ ] 2.2 刷新成功后更新 localStorage 中的 token
- [ ] 2.3 刷新失败时清理 token 并抛出错误
## 3. 实现并发控制
- [ ] 3.1 添加 `isRefreshing` 标志位防止并发刷新
- [ ] 3.2 如果正在刷新,等待刷新完成
- [ ] 3.3 使用 Promise 链确保请求顺序执行
## 4. 优化用户体验
- [ ] 4.1 添加调试日志(仅开发环境)
- [ ] 4.2 确保刷新过程对用户透明
- [ ] 4.3 错误处理时提供清晰的日志信息
## 5. 测试验证
- [ ] 5.1 模拟 token 过期场景,验证自动刷新
- [ ] 5.2 验证并发请求不会触发多次刷新
- [ ] 5.3 验证白名单接口不受影响
- [ ] 5.4 验证刷新失败时的错误处理
## 6. 代码审查
- [ ] 6.1 检查代码规范
- [ ] 6.2 验证日志输出适当
- [ ] 6.3 确认性能影响最小
- [ ] 6.4 更新相关注释

View File

@@ -0,0 +1,127 @@
# Change: 重构任务管理模块并新增数字人任务列表
## Why
当前系统中混剪任务列表位于 `MaterialList` 模块下,结构不够清晰,且缺少数字人生成任务的列表管理。用户需要一个统一的、组件化的任务管理中心,能够:
1. 统一管理混剪和数字人任务
2. 提供一致的交互体验
3. 实现左右分栏布局,便于切换不同任务类型
4. 提升代码复用性和可维护性
## What Changes
### 前端变更
- **新增** 任务管理模块 `task-management`,包含左右分栏布局
- **新增** 数字人任务列表页面 `digital-human-task`
- **迁移** 混剪任务列表从 `MaterialList``task-management/mix-task`
- **重构** 通用组件:筛选栏、状态标签、操作按钮等
- **更新** 侧边栏导航:在「系统管理」菜单组下新增「任务管理」子菜单
- **移除** 原「素材库」菜单组下的「混剪任务」项
### 后端变更
- **复用** 现有 API`MixTaskService``DigitalHumanTaskService`
- **无** 数据库结构变更
### 目录结构变更
```
src/views/
├── task-management/ # [新增] 任务管理中心
│ ├── layout/
│ │ └── TaskLayout.vue # 左右分栏布局
│ ├── mix-task/
│ │ └── index.vue # 混剪任务列表(迁移)
│ ├── digital-human-task/
│ │ └── index.vue # 数字人任务列表(新建)
│ ├── components/ # 通用组件
│ └── composables/ # 复用逻辑
```
## Impact
### 受影响的 Specs
- `mix-task`:更新任务列表路径和组件结构
- `digital-human-task`:新增数字人任务管理规范
- `task-management`:新增任务中心布局规范
### 受影响的代码
- 前端路由配置(`router/index.js`
- 侧边栏导航组件(`SidebarNav.vue`
- 混剪任务列表(`MixTaskList.vue``task-management/mix-task/index.vue`
- 数字人功能页面(复用现有 API
## Architecture Decisions
### 1. 布局设计
采用左右分栏布局:
- 左侧任务类型导航240px 固定宽度)
- 右侧:动态内容区域(自适应)
- 使用 Vue Router 的子路由机制实现内容切换
### 2. 组件复用
通过 Composable 提取通用逻辑:
- `useTaskList`:列表加载、分页、筛选
- `useTaskOperations`:任务操作(删除、取消、重试)
- `useTaskPolling`:状态轮询机制
### 3. 状态管理
- 使用组合式 APIComposition API
- 避免全局状态,组件内部管理状态
- 路由切换时清理定时器,防止内存泄漏
### 4. 导航设计
在系统管理菜单组下新增「任务管理」模块:
- 路径:`/system/task-management`
- 子路由:
- `/system/task-management/mix-task` - 混剪任务
- `/system/task-management/digital-human-task` - 数字人任务
- 移除「素材库」下的「混剪任务」菜单项
## Dependencies
- 依赖现有 API`MixTaskService``DigitalHumanTaskService`
- 依赖现有 UI 组件库Ant Design Vue
- 依赖现有路由系统Vue Router 4
## Risks
### 技术风险
- **API 兼容性**:数字人任务分页 API 参数可能与混剪任务不一致
- 应对:在 Composable 中分别处理不同 API 的参数格式
- **样式冲突**:原有组件样式可能与新布局冲突
- 应对:使用 scoped CSS避免全局样式污染
- **性能问题**:两个列表同时轮询可能导致性能问题
- 应对:实现智能轮询,页面隐藏时暂停
### 业务风险
- **用户迁移**:原有混剪任务列表路径变更
- 应对:保留旧路由一段时间,重定向到新路径
- **功能缺失**:数字人任务列表功能可能不完整
- 应对:参考混剪任务列表实现,确保功能对等
## Rollback Plan
如需回滚:
1. 保留 `MixTaskList.vue` 文件
2. 恢复 `router/index.js` 中的原路由配置
3. 恢复 `SidebarNav.vue` 中的原菜单配置
4. 删除新创建的 `task-management` 目录
## Success Metrics
### 功能指标
- [ ] 混剪任务列表功能 100% 保持
- [ ] 数字人任务列表功能完整实现
- [ ] 左右导航切换流畅(< 100ms
- [ ] 列表加载时间 < 2秒
### 代码质量指标
- [ ] 代码复用率提升 30%(通过 Composable
- [ ] 新增代码覆盖率 > 80%
- [ ] 无 TypeScript 类型错误
- [ ] ESLint 检查通过
### 用户体验指标
- [ ] 页面切换动画流畅
- [ ] 空数据状态友好提示
- [ ] 错误处理完善
- [ ] 响应式布局适配移动端

View File

@@ -0,0 +1,344 @@
## ADDED Requirements
### Requirement: 数字人任务列表显示
数字人任务管理系统 SHALL 提供任务列表页面,用于查看和管理所有数字人生成任务。
页面规范:
- 路径:`/system/task-management/digital-human-task`
- 布局:使用任务中心的左右分栏布局
- 功能:显示、筛选、搜索、操作数字人任务
#### Scenario: 显示数字人任务列表
- **WHEN** 用户访问 `/system/task-management/digital-human-task`
- **THEN** 显示数字人任务列表页面
- **AND** 左侧导航中「数字人视频任务」项高亮
- **AND** 右侧显示任务列表表格
### Requirement: 任务列表表格列定义
数字人任务列表 SHALL 显示以下列信息:
列定义:
- ID任务唯一标识
- 任务名称:用户设定的任务名称
- 视频文件:原始视频文件信息
- 文案内容:输入的文本内容(支持截断显示)
- 音色:使用的音色配置
- 状态任务当前状态pending/running/success/failed
- 进度任务完成百分比0-100
- 创建时间:任务创建的时间
- 操作:可执行的操作按钮
#### Scenario: 显示任务列表数据
- **WHEN** 任务列表加载完成
- **THEN** 表格显示所有任务的基本信息
- **AND** 文案内容列使用 ellipsis 截断过长文本
- **AND** 状态列使用彩色标签显示
- **AND** 进度列显示进度条和百分比
### Requirement: 任务状态管理
数字人任务 SHALL 支持以下状态:
状态定义:
- `pending`:等待处理
- `running`:处理中
- `success`:已完成
- `failed`:失败
- `canceled`:已取消
#### Scenario: 显示任务状态
- **WHEN** 渲染任务列表中的状态列
- **THEN** 根据任务状态显示对应颜色的标签
- **AND** 状态标签文本为中文描述
- **AND** 状态颜色映射:
- pending灰色
- running蓝色带动画效果
- success绿色
- failed红色
- canceled橙色
### Requirement: 任务操作功能
数字人任务列表 SHALL 支持以下操作:
操作定义:
- 预览:查看生成结果视频
- 下载:下载生成的视频文件
- 删除:删除任务记录
- 取消:取消正在运行的任务
- 重试:重新执行失败的任务
#### Scenario: 显示操作按钮
- **WHEN** 渲染任务列表中的操作列
- **THEN** 根据状态显示对应的任务操作按钮
- **AND** 按钮显示规则:
- 所有任务:预览、删除
- pending/running 任务:取消
- success 任务:下载
- failed 任务:重试
#### Scenario: 执行预览操作
- **WHEN** 用户点击「预览」按钮
- **THEN** 弹出视频预览窗口
- **AND** 窗口显示生成的结果视频
- **AND** 提供关闭按钮
#### Scenario: 执行下载操作
- **WHEN** 用户点击「下载」按钮
- **THEN** 开始下载生成的视频文件
- **AND** 文件名为「数字人视频_{任务ID}_{时间戳}.mp4」
#### Scenario: 执行删除操作
- **WHEN** 用户点击「删除」按钮
- **THEN** 弹出确认对话框
- **AND** 用户确认后删除任务
- **AND** 删除后刷新列表
#### Scenario: 执行取消操作
- **WHEN** 用户点击「取消」按钮
- **THEN** 调用取消 API
- **AND** 任务状态变更为 canceled
- **AND** 停止状态轮询
#### Scenario: 执行重试操作
- **WHEN** 用户点击「重试」按钮
- **THEN** 调用重试 API
- **AND** 任务状态变更为 pending
- **AND** 重新开始状态轮询
### Requirement: 筛选和搜索功能
数字人任务列表 SHALL 支持以下筛选条件:
筛选条件:
- 任务状态:下拉选择(全部/待处理/处理中/已完成/失败)
- 任务名称:文本搜索(支持模糊匹配)
- 创建时间:日期范围选择
#### Scenario: 按状态筛选
- **WHEN** 用户选择任务状态下拉框
- **THEN** 列表自动刷新,只显示对应状态的任务
- **AND** 选择「全部状态」时显示所有任务
#### Scenario: 按名称搜索
- **WHEN** 用户在搜索框输入关键词
- **AND** 按下回车键或点击搜索按钮
- **THEN** 列表自动刷新,只显示名称包含关键词的任务
- **AND** 搜索支持模糊匹配
#### Scenario: 按时间范围筛选
- **WHEN** 用户选择日期范围
- **THEN** 列表自动刷新,只显示创建时间在范围内的任务
- **AND** 日期格式为「YYYY-MM-DD」
### Requirement: 分页功能
数字人任务列表 SHALL 支持分页显示。
分页规范:
- 每页条数:支持 10/20/50/100 条选项
- 页码跳转:支持输入页码直接跳转
- 显示信息:显示「共 X 条记录,第 Y/Z 页」
#### Scenario: 分页导航
- **WHEN** 任务数量超过每页显示条数
- **THEN** 表格底部显示分页组件
- **AND** 用户可以切换页码
- **AND** 用户可以切换每页条数
### Requirement: 自动状态轮询
数字人任务列表 SHALL 自动轮询正在运行的任务状态。
轮询规范:
- 轮询间隔5 秒
- 轮询范围:只轮询 status 为 pending 或 running 的任务
- 页面隐藏:暂停轮询
- 组件销毁:停止轮询
#### Scenario: 自动轮询任务状态
- **WHEN** 页面显示且有待处理/处理中的任务
- **THEN** 每 5 秒发起一次 API 请求
- **AND** 获取任务最新状态
- **AND** 更新页面显示
#### Scenario: 页面隐藏时暂停轮询
- **WHEN** 用户切换到其他页面或最小化浏览器
- **THEN** 暂停状态轮询
- **AND** 页面重新可见时恢复轮询
#### Scenario: 任务完成时停止轮询
- **WHEN** 轮询发现任务状态变为 success/failed/canceled
- **THEN** 从轮询列表中移除该任务
- **AND** 当所有任务都完成时,停止轮询
### Requirement: API 集成
数字人任务列表 SHALL 集成以下 API
API 端点:
- `getDigitalHumanTaskPage`:分页获取任务列表
- `getDigitalHumanTask`:获取任务详情
- `cancelTask`:取消任务
- `retryTask`:重试任务
- `deleteTask`:删除任务
#### Scenario: 加载任务列表
- **WHEN** 页面初始加载或筛选条件变化
- **THEN** 调用 `getDigitalHumanTaskPage(params)`
- **AND** 解析返回数据并更新表格显示
#### Scenario: 获取任务详情
- **WHEN** 用户点击预览按钮
- **THEN** 调用 `getDigitalHumanTask(taskId)`
- **AND** 根据返回数据显示预览内容
#### Scenario: 取消任务
- **WHEN** 用户点击取消按钮
- **THEN** 调用 `cancelTask(taskId)`
- **AND** 更新任务状态为 canceled
- **AND** 停止该任务的轮询
#### Scenario: 重试任务
- **WHEN** 用户点击重试按钮
- **THEN** 调用 `retryTask(taskId)`
- **AND** 更新任务状态为 pending
- **AND** 重新开始轮询
#### Scenario: 删除任务
- **WHEN** 用户确认删除任务
- **THEN** 调用 `deleteTask(taskId)`
- **AND** 从列表中移除该任务
- **AND** 停止该任务的轮询
### Requirement: 数据模型映射
数字人任务列表 SHALL 使用以下数据模型:
数据模型(基于 `TikDigitalHumanTaskDO`
```typescript
interface DigitalHumanTask {
id: number // 任务ID
taskName: string // 任务名称
videoFileId: number // 视频文件ID
videoUrl: string // 视频文件URL
inputText: string // 输入文本
voiceId: string // 音色ID
speechRate: number // 语速
emotion?: string // 情感(可选)
instruction?: string // 指令(可选)
status: string // 任务状态
progress: number // 进度百分比
currentStep?: string // 当前步骤(可选)
resultVideoUrl?: string // 结果视频URL可选
errorMessage?: string // 错误信息(可选)
createTime: string // 创建时间
finishTime?: string // 完成时间(可选)
}
```
#### Scenario: 映射 API 数据
- **WHEN** API 返回任务数据
- **THEN** 将数据映射为本地数据模型
- **AND** 处理可选字段的空值情况
- **AND** 格式化时间字段
### Requirement: 错误处理
数字人任务列表 SHALL 提供完善的错误处理机制。
错误处理规范:
- API 调用失败:显示错误提示和重试按钮
- 网络异常:显示网络异常提示
- 操作失败:显示具体错误信息
- 空数据状态:显示「暂无数据」提示
#### Scenario: API 调用失败
- **WHEN** 获取任务列表时 API 返回错误
- **THEN** 显示错误提示信息
- **AND** 提供「重试」按钮
- **AND** 用户点击重试后重新发起请求
#### Scenario: 网络异常
- **WHEN** 网络连接中断
- **THEN** 显示网络异常提示
- **AND** 自动检测网络恢复
- **AND** 网络恢复后提示用户刷新页面
#### Scenario: 操作失败
- **WHEN** 执行任务操作时失败
- **THEN** 显示具体错误信息
- **AND** 提供重试选项
- **AND** 不关闭对话框(如果适用)
#### Scenario: 空数据状态
- **WHEN** 任务列表为空
- **THEN** 显示「暂无数字人任务」提示
- **AND** 提供「去创建」按钮(如适用)
### Requirement: 加载状态显示
数字人任务列表 SHALL 显示适当的加载状态。
加载状态规范:
- 初始加载:显示骨架屏或加载动画
- 数据刷新:显示表格 loading 状态
- 操作进行:显示按钮 loading 状态
#### Scenario: 初始加载状态
- **WHEN** 页面首次加载
- **THEN** 显示骨架屏或加载动画
- **AND** 加载完成后显示实际内容
#### Scenario: 数据刷新状态
- **WHEN** 筛选条件变化或分页切换
- **THEN** 表格显示 loading 状态
- **AND** 加载完成后更新表格数据
#### Scenario: 操作进行状态
- **WHEN** 用户执行操作(取消、重试、删除)
- **THEN** 对应的按钮显示 loading 状态
- **AND** 操作完成后恢复正常状态
### Requirement: 响应式适配
数字人任务列表 SHALL 支持响应式设计。
适配规范:
- 桌面端:显示所有列
- 平板端:隐藏部分次要列
- 移动端:使用卡片布局或横向滚动
#### Scenario: 平板端适配
- **WHEN** 在平板设备上访问任务列表
- **THEN** 隐藏「视频文件」和「音色」列
- **AND** 保留核心列ID、任务名称、状态、进度、操作
#### Scenario: 移动端适配
- **WHEN** 在手机设备上访问任务列表
- **THEN** 使用卡片布局显示任务信息
- **AND** 每个卡片包含任务的核心信息和操作按钮
- **OR** 表格使用横向滚动显示所有列
### Requirement: 性能优化
数字人任务列表 SHALL 实施性能优化措施。
优化措施:
- 搜索防抖:搜索输入 300ms 后执行
- 虚拟滚动:数据量 > 1000 条时启用
- 内存管理:及时清理定时器和事件监听器
- 图片懒加载:视频缩略图懒加载
#### Scenario: 搜索防抖
- **WHEN** 用户在搜索框输入内容
- **THEN** 等待 300ms 无输入后执行搜索
- **AND** 避免频繁的 API 调用
#### Scenario: 虚拟滚动
- **WHEN** 任务列表数据量超过 1000 条
- **THEN** 启用虚拟滚动功能
- **AND** 只渲染可见区域的行
- **AND** 提升大数据量时的渲染性能

View File

@@ -0,0 +1,115 @@
## MODIFIED Requirements
### Requirement: 任务列表页面路径
混剪任务管理系统 SHALL 将混剪任务列表页面路径从 `/material/mix-task` 变更为 `/system/task-management/mix-task`
#### Scenario: 访问混剪任务列表
- **WHEN** 用户访问 `/system/task-management/mix-task`
- **THEN** 显示混剪任务列表页面
- **AND** 左侧导航中「混剪视频任务」项高亮
### Requirement: 页面布局结构调整
混剪任务列表页面 SHALL 使用任务中心的子页面布局,不再包含独立的顶部标题栏。
#### Scenario: 显示混剪任务列表
- **WHEN** 用户在任务中心访问混剪任务列表
- **THEN** 页面不显示独立的标题栏
- **AND** 页面内容适配任务中心的右侧内容区域
### Requirement: 组件导入路径优化
混剪任务列表页面 SHALL 使用 `@/` 别名路径导入组件和 API。
#### Scenario: 导入通用组件
- **WHEN** 混剪任务列表需要使用通用组件
- **THEN** 使用 `@/views/task-management/components/ComponentName.vue` 导入
- **AND** 不再使用 `../../` 等相对路径
### Requirement: 筛选栏组件化
混剪任务列表 SHALL 将筛选栏抽取为独立组件 `TaskFilterBar.vue`
#### Scenario: 使用筛选栏组件
- **WHEN** 渲染混剪任务列表页面
- **THEN** 使用 `<TaskFilterBar />` 组件显示筛选条件
- **AND** 组件支持 v-model 双向绑定
### Requirement: 状态标签组件化
混剪任务列表 SHALL 将状态显示抽取为独立组件 `TaskStatusTag.vue`
#### Scenario: 显示任务状态
- **WHEN** 渲染任务列表中的状态列
- **THEN** 使用 `<TaskStatusTag :status="task.status" />` 组件
- **AND** 组件根据状态值显示不同颜色的标签
### Requirement: 操作按钮组件化
混剪任务列表 SHALL 将操作按钮抽取为独立组件 `TaskActionButtons.vue`
#### Scenario: 显示任务操作按钮
- **WHEN** 渲染任务列表中的操作列
- **THEN** 使用 `<TaskActionButtons :task="task" />` 组件
- **AND** 组件根据任务状态显示不同的操作按钮
### Requirement: Composable 逻辑复用
混剪任务列表 SHALL 使用 Composable 抽取通用逻辑。
#### Scenario: 使用 useTaskList
- **WHEN** 混剪任务列表需要加载数据
- **THEN** 调用 `useTaskList(fetchApi)`
- **AND** 使用返回的数据和方法渲染页面
### Requirement: API 调用保持不变
混剪任务列表 SHALL 继续使用 `MixTaskService` 调用后端 API。
#### Scenario: 获取混剪任务列表
- **WHEN** 页面需要加载任务列表
- **THEN** 调用 `MixTaskService.getTaskPage(params)`
### Requirement: 功能完整性保持
混剪任务列表 SHALL 保持所有现有功能。
#### Scenario: 所有功能正常工作
- **WHEN** 用户使用混剪任务列表
- **THEN** 筛选、搜索、分页、操作功能正常
- **AND** 状态轮询机制正常
- **AND** 错误处理机制正常
### Requirement: 路由元信息配置
混剪任务列表页面 SHALL 通过路由 meta 信息设置页面标题。
#### Scenario: 设置页面标题
- **WHEN** 用户访问混剪任务列表页面
- **THEN** 浏览器标题栏显示「混剪任务」
### Requirement: 性能优化实施
混剪任务列表 SHALL 实施性能优化措施。
#### Scenario: 性能优化实施
- **WHEN** 任务列表渲染大量数据
- **THEN** 使用防抖处理搜索输入
- **AND** 页面隐藏时暂停轮询
### Requirement: 向后兼容支持
混剪任务列表 SHALL 提供向后兼容方案。
#### Scenario: 兼容旧路径
- **WHEN** 用户访问旧路径 `/material/mix-task`
- **THEN** 系统重定向到 `/system/task-management/mix-task`
### Requirement: 测试覆盖实施
混剪任务列表 SHALL 实施完整的测试覆盖。
#### Scenario: 编写测试用例
- **WHEN** 混剪任务列表页面重构完成
- **THEN** 编写组件渲染测试、API调用测试、用户交互测试

View File

@@ -0,0 +1,179 @@
## ADDED Requirements
### Requirement: 任务中心布局
任务管理系统 SHALL 提供统一的左右分栏布局,用于管理不同类型的任务。
布局规范:
- 左侧导航区域:宽度固定为 240px显示任务类型切换菜单
- 右侧内容区域:自适应宽度,显示对应的任务列表页面
- 左侧导航 SHALL 支持以下任务类型:
- 混剪视频任务
- 数字人视频任务
#### Scenario: 显示任务中心布局
- **WHEN** 用户访问 `/system/task-management` 路径
- **THEN** 页面显示左右分栏布局
- **AND** 左侧显示任务类型导航菜单
- **AND** 右侧显示默认的任务列表(混剪视频任务)
#### Scenario: 切换任务类型
- **WHEN** 用户点击左侧导航中的「数字人视频任务」
- **THEN** 右侧内容区域切换到数字人任务列表页面
- **AND** 左侧导航中「数字人视频任务」项高亮显示
### Requirement: 路由配置
任务中心 SHALL 使用 Vue Router 的子路由机制,实现不同任务类型页面的切换。
路由规范:
- 根路径:`/system/task-management`
- 子路径:
- `/system/task-management/mix-task` - 混剪任务列表
- `/system/task-management/digital-human-task` - 数字人任务列表
- 默认重定向:访问 `/system/task-management` 时自动跳转到 `/system/task-management/mix-task`
#### Scenario: 默认路由跳转
- **WHEN** 用户访问 `/system/task-management`
- **THEN** 系统自动重定向到 `/system/task-management/mix-task`
- **AND** 显示混剪任务列表页面
#### Scenario: 直接访问子路径
- **WHEN** 用户直接访问 `/system/task-management/digital-human-task`
- **THEN** 显示数字人任务列表页面
- **AND** 左侧导航中「数字人视频任务」项高亮
### Requirement: 导航高亮
左侧导航 SHALL 高亮显示当前激活的任务类型。
高亮规范:
- 当前激活的导航项 SHALL 使用主色调背景色(`var(--color-primary)`
- 非激活项 SHALL 使用默认背景色
- 鼠标悬停时 SHALL 显示悬停效果
#### Scenario: 高亮当前任务类型
- **WHEN** 用户在混剪任务列表页面
- **THEN** 左侧导航中「混剪视频任务」项高亮显示
- **AND** 「数字人视频任务」项保持默认状态
### Requirement: 响应式适配
任务中心布局 SHALL 支持响应式设计,在不同屏幕尺寸下正常显示。
适配规范:
- 桌面端≥1200px左侧 240px右侧自适应
- 平板端768px-1199px保持左右分栏适当缩小左侧宽度
- 移动端(<768px左侧导航可折叠或隐藏右侧全屏显示
#### Scenario: 平板端显示
- **WHEN** 用户在平板设备上访问任务中心
- **THEN** 左侧导航宽度调整为 200px
- **AND** 右侧内容区域相应调整宽度
#### Scenario: 移动端显示
- **WHEN** 用户在手机设备上访问任务中心
- **THEN** 左侧导航默认隐藏
- **AND** 显示汉堡菜单按钮,点击后弹出导航菜单
- **OR** 左侧导航固定在底部,作为标签栏显示
### Requirement: 过渡动画
任务类型切换时 SHALL 使用平滑的过渡动画。
动画规范:
- 使用 Vue Transition 组件实现
- 动画时长200-300ms
- 动画类型淡入淡出fade或滑动slide
#### Scenario: 页面切换动画
- **WHEN** 用户从混剪任务切换到数字人任务
- **THEN** 右侧内容区域使用平滑过渡动画
- **AND** 动画时长约 250ms
- **AND** 动画效果为淡入淡出
### Requirement: 组件化设计
任务中心 SHALL 采用组件化设计,提高代码复用性和可维护性。
组件规范:
- Layout 组件:`TaskLayout.vue` - 布局容器
- 通用组件:
- `TaskFilterBar.vue` - 筛选栏
- `TaskStatusTag.vue` - 状态标签
- `TaskActionButtons.vue` - 操作按钮
- Composable
- `useTaskList.js` - 列表通用逻辑
- `useTaskOperations.js` - 操作通用逻辑
- `useTaskPolling.js` - 轮询通用逻辑
#### Scenario: 使用通用组件
- **WHEN** 开发混剪任务列表页面
- **THEN** 使用 `TaskFilterBar` 组件实现筛选功能
- **AND** 使用 `TaskStatusTag` 组件显示任务状态
- **AND** 使用 `TaskActionButtons` 组件实现操作按钮
- **AND** 使用 `useTaskList` Composable 处理列表逻辑
### Requirement: 状态管理
任务中心 SHALL 使用组合式 APIComposition API进行状态管理避免全局状态污染。
状态管理规范:
- 每个任务列表页面独立管理自己的状态
- 使用 `ref``reactive` 管理响应式数据
- 组件销毁时清理所有副作用(定时器、事件监听器等)
#### Scenario: 独立状态管理
- **WHEN** 用户在混剪任务列表页面进行操作
- **THEN** 操作只影响混剪任务列表的状态
- **AND** 不影响数字人任务列表的状态
#### Scenario: 清理副作用
- **WHEN** 用户离开任务中心页面
- **THEN** 所有定时器 SHALL 被清理
- **AND** 所有事件监听器 SHALL 被移除
- **AND** 避免内存泄漏
### Requirement: 错误处理
任务中心 SHALL 提供完善的错误处理机制,提升用户体验。
错误处理规范:
- API 调用失败时显示错误提示
- 网络异常时显示重试按钮
- 操作失败时显示具体错误信息
- 加载状态使用骨架屏或加载动画
#### Scenario: API 调用失败
- **WHEN** 获取任务列表时 API 返回错误
- **THEN** 显示错误提示信息
- **AND** 提供「重试」按钮
- **AND** 用户点击重试后重新发起请求
#### Scenario: 网络异常
- **WHEN** 网络连接中断
- **THEN** 显示网络异常提示
- **AND** 自动检测网络恢复
- **AND** 网络恢复后提示用户刷新页面
### Requirement: 无障碍访问
任务中心 SHALL 遵循 Web 无障碍访问标准,支持键盘导航和屏幕阅读器。
无障碍规范:
- 所有交互元素支持键盘访问Tab 键导航)
- 提供适当的 ARIA 标签
- 颜色对比度符合 WCAG 2.1 AA 标准
- 焦点状态清晰可见
#### Scenario: 键盘导航
- **WHEN** 用户使用 Tab 键浏览任务中心页面
- **THEN** 焦点 SHALL 按逻辑顺序移动
- **AND** 所有交互元素都可以通过键盘访问
- **AND** 焦点状态清晰可见
#### Scenario: 屏幕阅读器支持
- **WHEN** 用户使用屏幕阅读器访问任务中心
- **THEN** 页面结构 SHALL 被正确朗读
- **AND** 任务状态 SHALL 有适当的 ARIA 标签
- **AND** 操作按钮 SHALL 有描述性的文本

View File

@@ -0,0 +1,189 @@
# Tasks: 重构任务管理模块并新增数字人任务列表
## Phase 1: 基础架构搭建
- [ ] 1.1 创建目录结构 `src/views/system/task-management/`
- 创建 `layout/``mix-task/``digital-human-task/``components/``composables/` 子目录
- 创建 `.gitkeep` 文件保持空目录结构
- [ ] 1.2 实现核心布局组件 `TaskLayout.vue`
- 实现左右分栏布局(左侧 240px右侧自适应
- 添加路由切换动画
- 高亮当前激活的导航项
- 实现响应式适配
- [ ] 1.3 配置路由规则
-`router/index.js` 中添加 `/system/task-management` 路由
- 配置子路由:`mix-task``digital-human-task`
- 设置默认重定向到 `mix-task`
- [ ] 1.4 创建通用 Composable
- `useTaskList.js`:列表加载、分页、筛选逻辑
- `useTaskOperations.js`:任务操作(删除、取消、重试)
- `useTaskPolling.js`:状态轮询机制
## Phase 2: 混剪模块迁移
- [ ] 2.1 迁移混剪任务列表
- 复制 `views/material/MixTaskList.vue``task-management/mix-task/index.vue`
- 调整导入路径(使用 `@/` 别名)
- 移除顶部标题栏(布局组件处理)
- 适配新布局的样式
- [ ] 2.2 提取通用组件
- 创建 `components/TaskFilterBar.vue`
- 创建 `components/TaskStatusTag.vue`
- 创建 `components/TaskActionButtons.vue`
- 在混剪列表中应用这些组件
- [ ] 2.3 测试混剪功能
- 验证列表加载正常
- 验证筛选和搜索功能
- 验证分页功能
- 验证任务操作(预览、下载、取消、删除、重试)
## Phase 3: 数字人模块开发
- [ ] 3.1 创建数字人任务列表页面
- 创建 `task-management/digital-human-task/index.vue`
- 实现表格列定义ID、任务名、视频文件、文案、音色、状态、进度、时间、操作
- 集成 API 调用(`getDigitalHumanTaskPage`
- [ ] 3.2 实现数字人任务操作
- 预览:显示生成结果视频
- 下载:下载生成的视频文件
- 删除:删除任务(带确认弹窗)
- 取消:取消正在运行的任务
- 重试:重新生成失败的任务
- [ ] 3.3 实现状态轮询
- 每 5 秒检查一次运行中的任务状态
- 页面隐藏时暂停轮询
- 组件销毁时清理定时器
- [ ] 3.4 调试和测试
- 验证数据显示正确性
- 验证状态同步准确性
- 验证操作流程完整性
## Phase 4: 导航和路由整合
- [ ] 4.1 更新侧边栏导航
- 修改 `components/SidebarNav.vue`
- 移除「素材库」菜单组中的「混剪任务」
- 在「系统管理」菜单组下新增「任务管理」模块,包含:
- 混剪视频任务(`/system/task-management/mix-task`
- 数字人视频任务(`/system/task-management/digital-human-task`
- [ ] 4.2 设置路由重定向(可选)
- 保留旧路由 `/material/mix-task` 一段时间
- 配置重定向到 `/system/task-management/mix-task`
- [ ] 4.3 导航测试
- 验证导航切换正常
- 验证激活状态高亮正确
- 验证页面标题更新
## Phase 5: 测试和优化
- [ ] 5.1 功能测试
- 测试混剪任务列表所有功能
- 测试数字人任务列表所有功能
- 测试左右导航切换
- 测试筛选和搜索功能
- 测试分页功能
- 测试任务操作功能
- [ ] 5.2 兼容性测试
- 测试不同浏览器Chrome、Firefox、Safari、Edge
- 测试不同屏幕尺寸(桌面端、平板、手机)
- 测试 Vue DevTools 调试功能
- [ ] 5.3 性能优化
- 优化 API 调用频次
- 优化列表渲染性能(虚拟滚动,如需要)
- 优化轮询机制(智能暂停/恢复)
- 检查内存泄漏(定时器、事件监听器)
- [ ] 5.4 代码质量检查
- 运行 ESLint 检查
- 运行 TypeScript 类型检查(如果启用)
- 代码覆盖率检查
- 代码审查和重构
## Phase 6: 文档和验收
- [ ] 6.1 更新文档
- 更新 API 文档(如果需要)
- 更新用户使用文档(如果需要)
- 更新开发文档(如果需要)
- [ ] 6.2 验收测试
- 功能验收:所有功能正常运行
- 性能验收:加载时间、响应时间符合要求
- UI 验收:布局、样式、交互符合设计要求
- 兼容性验收:在目标浏览器和设备上正常运行
- [ ] 6.3 部署准备
- 准备部署检查清单
- 确认回滚方案
- 确认监控和告警
## 验收标准
### 功能验收清单
- [ ] 混剪任务列表显示和操作正常
- [ ] 数字人任务列表显示和操作正常
- [ ] 左右导航切换流畅
- [ ] 筛选和搜索功能正常
- [ ] 分页功能正常
- [ ] 任务操作(预览、下载、取消、删除、重试)正常
- [ ] 状态轮询机制正常
- [ ] 错误处理完善
- [ ] 空数据状态友好提示
### 性能验收清单
- [ ] 列表初始加载时间 < 2秒
- [ ] 导航切换响应时间 < 100ms
- [ ] 轮询间隔合理5-10秒
- [ ] 页面切换无卡顿
- [ ] 内存占用合理(无内存泄漏)
### 代码质量验收清单
- [ ] ESLint 检查通过
- [ ] 无 TypeScript 类型错误(如果启用)
- [ ] 代码注释充分
- [ ] 代码结构清晰
- [ ] 组件职责单一
- [ ] 代码复用率高
## 风险监控
### 技术风险
- [ ] API 兼容性风险:持续监控 API 调用错误
- [ ] 样式冲突风险:检查浏览器控制台警告
- [ ] 性能风险:监控页面加载时间和内存使用
### 业务风险
- [ ] 用户体验风险:收集用户反馈
- [ ] 功能完整性风险:对比需求文档验证
- [ ] 回归风险:确保现有功能不受影响
## 资源估算
### 时间估算
- Phase 1: 1-2 小时
- Phase 2: 2-3 小时
- Phase 3: 3-4 小时
- Phase 4: 1 小时
- Phase 5: 2-3 小时
- Phase 6: 1 小时
**总计10-14 小时**
### 人力估算
- 前端开发1 人
- 测试0.5 人
- 代码审查0.5 人
**总计2 人**

File diff suppressed because it is too large Load Diff

View File

@@ -1,135 +0,0 @@
# 混剪功能规格(简化版)
## 核心需求
- **输入**:用户选择素材 + 设定每个素材截取时长3-15s
- **输出**1-3个不同内容的混剪视频
- **总时长**15s-60s
- **差异化**:同顺序 + 同时长 + **随机截取起点**
## 多视频差异化算法
### 核心原理
**随机起点 + 容错机制**
- 每个视频使用**随机截取起点**,确保内容完全不同
- 支持**不同长度的素材**ICE自动容错处理
- 容错如果起点超出素材长度ICE自动从0开始截取
**随机种子**:使用 `素材ID×1000000 + 视频序号×10000 + URL哈希%1000` 确保可重现性
### 算法实现
**随机起点生成**
```java
// 1. 先获取视频实际时长
int actualDuration = getVideoDuration(videoUrl);
// 2. 生成随机种子
long randomSeed = (material.getFileId() * 1000000L) +
(videoIndex * 10000L) +
(material.getFileUrl().hashCode() % 1000);
Random random = new Random(randomSeed);
// 3. 根据实际时长计算起始范围
int maxStartOffset = Math.max(0, actualDuration - duration);
int startOffset = random.nextInt(maxStartOffset + 1);
int endOffset = startOffset + duration;
```
**获取视频时长方案**
1. **数据库字段**上传时预存duration字段推荐
2. **FFprobe工具**:命令行获取视频元数据
3. **ICE元数据API**调用ICE查询接口
4. **默认60秒**:保守值,兼容性最好
**容错机制**
- 根据实际时长计算最大起始偏移,避免超出素材长度
- 如果获取时长失败使用默认值60秒
- ICE自动处理边界情况
### ICE Timeline构建
每个素材片段包含参数:
- `MediaURL`:素材地址
- `In`随机截取起始点0到实际时长-duration之间
- `Out`:截取结束点 = `In + duration`
- `TimelineIn/TimelineOut`:时间轴位置(顺序拼接)
ICE自动处理超出素材长度的情况无需额外判断。
## API设计
### 请求格式
```http
POST /api/mix/create
{
"title": "",
"materials": [
{ "fileId": 123, "fileUrl": "https://xxx/v1.mp4", "duration": 5 },
{ "fileId": 456, "fileUrl": "https://xxx/v2.mp4", "duration": 8 },
{ "fileId": 789, "fileUrl": "https://xxx/v3.mp4", "duration": 5 }
],
"produceCount": 3
}
```
### 后端处理流程
1. 校验请求参数总时长15-60s
2. 循环生成produceCount个视频
- videoIndex = 0, 1, 2...
- 获取每个素材的实际时长(数据库/FFprobe/ICE API
- 生成随机起点基于素材ID×1000000 + videoIndex×10000 + URL哈希
- 根据实际时长计算起始范围,避免超出素材长度
- 构建Timeline传递随机In/Out参数给ICE
- 提交ICE任务
3. 保存任务并返回任务ID
## 校验规则
| 规则 | 前端 | 后端 |
|------|------|------|
| 总时长 15-60s | ✅ | ✅ |
| 单素材 3-15s | ✅ | ✅ |
| 至少选1个素材 | ✅ | ✅ |
| 生成数量 1-3 | ✅ | ✅ |
## 实现清单
### 已完成
- [x] 前端时长选择和实时计算
- [x] 后端VOMaterialItem实现
- [x] 后端DOmaterialsJson字段
- [x] 数据库迁移脚本
- [x] 后端Controller/api/mix/create
- [x] 后端Service多视频生成逻辑
- [x] ICE Timeline构建随机起点+实际时长+容错)
- [x] 批量任务提交和状态跟踪
### 测试验证
- [ ] 编译验证
- [ ] 端到端功能测试
- [ ] 多视频差异化验证
---
## 代码修改清单
### 核心修改
1. **BatchProduceAlignment.java**
- 新增方法:`produceSingleVideoWithOffset(materials, videoIndex, userId)`
- 新增方法:`getVideoDuration(videoUrl)` - 获取视频实际时长
- 核心逻辑:先获取实际时长,再生成随机起点
- 容错机制:根据实际时长计算范围,避免超出长度
2. **MixTaskServiceImpl.java**
- 循环生成produceCount个视频
- 每次传入不同的videoIndex确保随机起点不同
3. **数据库结构(可选改进)**
- 新增字段:`duration INTEGER COMMENT '视频时长(秒)'`
- 上传时预处理使用FFprobe获取时长并存储
*版本v3.0 - 简化版ICE自动容错*