refactor(TaskStatusTag): replace a-tag with span element and improve status configuration
- Replace a-tag component with semantic span element for better accessibility - Introduce centralized STATUS_CONFIG object for consistent status mapping - Add isRunning computed property for cleaner conditional logic - Remove redundant statusMap handling and normalize status values - Add proper CSS class bindings for styling consistency - Update component structure to use
This commit is contained in:
@@ -1,277 +0,0 @@
|
|||||||
# 设计系统迁移指南
|
|
||||||
|
|
||||||
> 将现有前端代码迁移到统一设计系统
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、迁移步骤
|
|
||||||
|
|
||||||
### Step 1: 引入设计令牌
|
|
||||||
|
|
||||||
在 `src/main.ts` 中添加设计令牌导入:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// main.ts
|
|
||||||
import './styles/design-tokens.css' // 添加在最先
|
|
||||||
import './style.less'
|
|
||||||
// ... 其他导入
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: 配置 TailwindCSS
|
|
||||||
|
|
||||||
更新 `tailwind.config.js` 为 `tailwind.config.ts`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 删除旧配置
|
|
||||||
rm tailwind.config.js
|
|
||||||
|
|
||||||
# 使用新配置
|
|
||||||
# 已创建: tailwind.config.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: 配置 Ant Design Vue 主题
|
|
||||||
|
|
||||||
更新 `src/App.vue`:
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ConfigProvider, theme } from 'ant-design-vue'
|
|
||||||
import { antdThemeConfig, themeManager } from '@gold/styles/antd-theme'
|
|
||||||
|
|
||||||
const isDark = ref(themeManager.initTheme())
|
|
||||||
const currentTheme = computed(() => themeManager.getAntdTheme(isDark.value))
|
|
||||||
|
|
||||||
// 监听系统主题变化
|
|
||||||
watchEffect(() => {
|
|
||||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
|
||||||
const handler = (e: MediaQueryListEvent) => {
|
|
||||||
if (!localStorage.getItem('theme')) {
|
|
||||||
isDark.value = e.matches
|
|
||||||
themeManager.setTheme(e.matches)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
media.addEventListener('change', handler)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<ConfigProvider :theme="currentTheme">
|
|
||||||
<router-view />
|
|
||||||
</ConfigProvider>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、组件迁移示例
|
|
||||||
|
|
||||||
### 2.1 颜色迁移
|
|
||||||
|
|
||||||
**Before (问题代码)**:
|
|
||||||
```less
|
|
||||||
// ❌ 组件内定义变量
|
|
||||||
@primary: #6366f1;
|
|
||||||
@primary-gold: #D4A853;
|
|
||||||
|
|
||||||
.button {
|
|
||||||
background: #1890FF;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**After (规范代码)**:
|
|
||||||
```less
|
|
||||||
// ✅ 使用全局设计令牌
|
|
||||||
.button {
|
|
||||||
background: var(--color-primary-500);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--color-primary-400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 字体迁移
|
|
||||||
|
|
||||||
**Before**:
|
|
||||||
```css
|
|
||||||
/* ❌ 硬编码字体大小 */
|
|
||||||
.title { font-size: 24px; }
|
|
||||||
.desc { font-size: 12px; }
|
|
||||||
```
|
|
||||||
|
|
||||||
**After**:
|
|
||||||
```css
|
|
||||||
/* ✅ 使用设计令牌 */
|
|
||||||
.title { font-size: var(--font-size-2xl); }
|
|
||||||
.desc { font-size: var(--font-size-xs); }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.3 间距迁移
|
|
||||||
|
|
||||||
**Before**:
|
|
||||||
```css
|
|
||||||
/* ❌ 魔法数字 */
|
|
||||||
.card {
|
|
||||||
padding: 20px;
|
|
||||||
margin-top: 24px;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**After**:
|
|
||||||
```css
|
|
||||||
/* ✅ 语义化间距 */
|
|
||||||
.card {
|
|
||||||
padding: var(--space-5);
|
|
||||||
margin-top: var(--space-6);
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.4 使用 TailwindCSS
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<template>
|
|
||||||
<!-- 使用 TailwindCSS 工具类 -->
|
|
||||||
<div class="p-5 rounded-lg shadow-base bg-white">
|
|
||||||
<h2 class="text-lg font-medium text-gray-900">标题</h2>
|
|
||||||
<p class="mt-2 text-sm text-gray-500">描述文字</p>
|
|
||||||
<button class="mt-4 px-4 py-2 bg-primary-500 text-white rounded-base
|
|
||||||
hover:bg-primary-400 transition-duration-fast">
|
|
||||||
操作按钮
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、需要修改的文件清单
|
|
||||||
|
|
||||||
### 优先级 P0 - 立即修改
|
|
||||||
|
|
||||||
| 文件 | 问题 | 修改内容 |
|
|
||||||
|------|------|----------|
|
|
||||||
| `src/components/agents/ChatDrawer.vue` | 局部定义 `@primary: #6366f1` | 改用 `var(--color-primary-500)` |
|
|
||||||
| `src/components/agents/HistoryPanel.vue` | 局部定义 `@primary: #6366f1` | 改用 `var(--color-primary-500)` |
|
|
||||||
| `src/views/auth/Login.vue` | 金色主题 `@primary-gold: #D4A853` | 改用品牌蓝 |
|
|
||||||
| `src/views/home/Home.vue` | 硬编码颜色 `#1890FF` | 改用 `var(--color-primary-500)` |
|
|
||||||
|
|
||||||
### 优先级 P1 - 短期修改
|
|
||||||
|
|
||||||
| 文件 | 问题 |
|
|
||||||
|------|------|
|
|
||||||
| `src/views/content-style/Copywriting.vue` | 混用 `var(--color-primary)` 和 `#1677ff` |
|
|
||||||
| `src/components/GradientButton.vue` | 需要验证与设计系统一致 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、批量替换脚本
|
|
||||||
|
|
||||||
### 4.1 颜色替换
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 在 frontend/app/web-gold 目录执行
|
|
||||||
|
|
||||||
# 替换硬编码的主色
|
|
||||||
find src -name "*.vue" -o -name "*.less" | xargs sed -i 's/#1890FF/var(--color-primary-500)/g'
|
|
||||||
find src -name "*.vue" -o -name "*.less" | xargs sed -i 's/#40A9FF/var(--color-primary-400)/g'
|
|
||||||
find src -name "*.vue" -o -name "*.less" | xargs sed -i 's/#6366f1/var(--color-primary-500)/g'
|
|
||||||
find src -name "*.vue" -o -name "*.less" | xargs sed -i 's/#1677ff/var(--color-primary-500)/g'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 间距替换
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# padding: 16px -> padding: var(--space-4)
|
|
||||||
find src -name "*.vue" -o -name "*.less" | xargs sed -i 's/padding: 16px/padding: var(--space-4)/g'
|
|
||||||
find src -name "*.vue" -o -name "*.less" | xargs sed -i 's/padding: 20px/padding: var(--space-5)/g'
|
|
||||||
find src -name "*.vue" -o -name "*.less" | xargs sed -i 's/padding: 24px/padding: var(--space-6)/g'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、验证清单
|
|
||||||
|
|
||||||
完成迁移后,检查以下项目:
|
|
||||||
|
|
||||||
- [ ] 所有颜色使用 CSS 变量或 TailwindCSS 类
|
|
||||||
- [ ] 没有硬编码的 `#xxxxxx` 颜色值(除特殊情况)
|
|
||||||
- [ ] 字体大小使用 `var(--font-size-*)` 或 TailwindCSS
|
|
||||||
- [ ] 间距使用 `var(--space-*)` 或 TailwindCSS
|
|
||||||
- [ ] 组件内没有重复定义 `@primary` 等 Less 变量
|
|
||||||
- [ ] 页面视觉效果一致
|
|
||||||
- [ ] 深色模式切换正常工作
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、设计令牌速查表
|
|
||||||
|
|
||||||
### 颜色
|
|
||||||
|
|
||||||
| 用途 | 变量 | 值 |
|
|
||||||
|------|------|-----|
|
|
||||||
| 主色 | `--color-primary-500` | #3B82F6 |
|
|
||||||
| 主色悬浮 | `--color-primary-400` | #60A5FA |
|
|
||||||
| 成功 | `--color-success-500` | #22C55E |
|
|
||||||
| 警告 | `--color-warning-500` | #F59E0B |
|
|
||||||
| 错误 | `--color-error-500` | #EF4444 |
|
|
||||||
| 正文 | `--color-text-primary` | #111827 |
|
|
||||||
| 次要文字 | `--color-text-secondary` | #4B5563 |
|
|
||||||
| 边框 | `--color-border` | #E5E7EB |
|
|
||||||
|
|
||||||
### 间距
|
|
||||||
|
|
||||||
| 变量 | 值 | 用途 |
|
|
||||||
|------|-----|------|
|
|
||||||
| `--space-2` | 8px | 图标与文字 |
|
|
||||||
| `--space-3` | 12px | 小间距 |
|
|
||||||
| `--space-4` | 16px | 标准间距 |
|
|
||||||
| `--space-5` | 20px | 卡片内边距 |
|
|
||||||
| `--space-6` | 24px | 区块间距 |
|
|
||||||
|
|
||||||
### 圆角
|
|
||||||
|
|
||||||
| 变量 | 值 | 用途 |
|
|
||||||
|------|-----|------|
|
|
||||||
| `--radius-sm` | 4px | 标签 |
|
|
||||||
| `--radius-base` | 6px | 按钮、输入框 |
|
|
||||||
| `--radius-md` | 8px | 下拉框 |
|
|
||||||
| `--radius-lg` | 12px | 卡片、弹窗 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、常见问题
|
|
||||||
|
|
||||||
### Q: 旧组件样式不生效?
|
|
||||||
|
|
||||||
确保设计令牌在 `main.ts` 中最先导入。
|
|
||||||
|
|
||||||
### Q: Ant Design 组件样式不一致?
|
|
||||||
|
|
||||||
检查 `App.vue` 中是否正确配置了 `ConfigProvider` 的 `theme` 属性。
|
|
||||||
|
|
||||||
### Q: TailwindCSS 类不生效?
|
|
||||||
|
|
||||||
1. 确保 `tailwind.config.ts` 文件存在
|
|
||||||
2. 确保 `postcss.config.js` 配置正确
|
|
||||||
3. 重启开发服务器
|
|
||||||
|
|
||||||
### Q: 深色模式切换有闪烁?
|
|
||||||
|
|
||||||
在 `index.html` 的 `<head>` 中添加:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<script>
|
|
||||||
(function() {
|
|
||||||
const theme = localStorage.getItem('theme');
|
|
||||||
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
||||||
document.documentElement.setAttribute('data-theme', 'dark');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> **迁移完成后**,项目将拥有统一的设计语言,后续开发效率将显著提升。
|
|
||||||
@@ -1,97 +1,92 @@
|
|||||||
<template>
|
<template>
|
||||||
<a-tag :color="color" :class="statusClass">
|
<span class="task-status-tag" :class="statusClass">
|
||||||
<template v-if="showIcon && (status === 'running' || status === 'RUNNING')">
|
<template v-if="showIcon && isRunning">
|
||||||
<LoadingOutlined :spin="true" />
|
<LoadingOutlined class="task-status-tag__icon" :spin="true" />
|
||||||
</template>
|
</template>
|
||||||
{{ text }}
|
{{ text }}
|
||||||
</a-tag>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { LoadingOutlined } from '@ant-design/icons-vue'
|
import { LoadingOutlined } from '@ant-design/icons-vue'
|
||||||
|
|
||||||
// Props
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
// 状态值
|
status: { type: String, required: true },
|
||||||
status: {
|
showIcon: { type: Boolean, default: true },
|
||||||
type: String,
|
statusMap: { type: Object, default: () => ({}) }
|
||||||
required: true
|
|
||||||
},
|
|
||||||
// 是否显示图标
|
|
||||||
showIcon: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true
|
|
||||||
},
|
|
||||||
// 自定义状态映射
|
|
||||||
statusMap: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算属性:状态文本
|
const STATUS_CONFIG = {
|
||||||
const text = computed(() => {
|
pending: { text: '待处理', class: 'task-status-tag--pending' },
|
||||||
// 使用自定义映射或默认映射(同时支持大小写)
|
running: { text: '处理中', class: 'task-status-tag--running' },
|
||||||
const map = {
|
success: { text: '已完成', class: 'task-status-tag--success' },
|
||||||
pending: '待处理',
|
failed: { text: '失败', class: 'task-status-tag--failed' },
|
||||||
running: '处理中',
|
canceled: { text: '已取消', class: 'task-status-tag--canceled' }
|
||||||
success: '已完成',
|
}
|
||||||
failed: '失败',
|
|
||||||
canceled: '已取消',
|
const normalizedStatus = computed(() => props.status?.toLowerCase() || '')
|
||||||
// 大写状态支持
|
|
||||||
PENDING: '待处理',
|
const config = computed(() => {
|
||||||
RUNNING: '处理中',
|
const custom = props.statusMap[props.status]
|
||||||
SUCCESS: '已完成',
|
if (custom) return { text: custom, class: `task-status-tag--${normalizedStatus.value}` }
|
||||||
PROCESSING: '处理中',
|
return STATUS_CONFIG[normalizedStatus.value] || { text: props.status, class: 'task-status-tag--default' }
|
||||||
FAILED: '失败',
|
|
||||||
CANCELED: '已取消',
|
|
||||||
...props.statusMap
|
|
||||||
}
|
|
||||||
return map[props.status] || props.status
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算属性:状态颜色
|
const text = computed(() => config.value.text)
|
||||||
const color = computed(() => {
|
const statusClass = computed(() => config.value.class)
|
||||||
const colorMap = {
|
const isRunning = computed(() => normalizedStatus.value === 'running')
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped lang="less">
|
||||||
/* 状态标签动画效果 */
|
.task-status-tag {
|
||||||
.task-status-tag--running {
|
display: inline-flex;
|
||||||
animation: pulse 1.5s ease-in-out infinite;
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--pending {
|
||||||
|
background: var(--color-gray-100);
|
||||||
|
color: var(--color-gray-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--running {
|
||||||
|
background: var(--color-primary-50);
|
||||||
|
color: var(--color-primary-600);
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--success {
|
||||||
|
background: var(--color-success-50);
|
||||||
|
color: var(--color-success-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--failed {
|
||||||
|
background: var(--color-error-50);
|
||||||
|
color: var(--color-error-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--canceled {
|
||||||
|
background: var(--color-warning-50);
|
||||||
|
color: var(--color-warning-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--default {
|
||||||
|
background: var(--color-gray-100);
|
||||||
|
color: var(--color-gray-600);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0% {
|
0%, 100% { opacity: 1; }
|
||||||
opacity: 1;
|
50% { opacity: 0.6; }
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="digital-human-task-page">
|
<div class="task-page">
|
||||||
<!-- 筛选条件 -->
|
<!-- 筛选条件 -->
|
||||||
<div class="digital-human-task-page__filters">
|
<div class="task-page__filters">
|
||||||
<a-space :size="16">
|
<a-space :size="16">
|
||||||
<a-select
|
<a-select
|
||||||
v-model:value="filters.status"
|
v-model:value="filters.status"
|
||||||
class="filter-select"
|
class="filter-select"
|
||||||
placeholder="任务状态"
|
placeholder="任务状态"
|
||||||
@change="handleFilterChange"
|
|
||||||
allow-clear
|
allow-clear
|
||||||
|
@change="handleFilterChange"
|
||||||
>
|
>
|
||||||
<a-select-option value="">全部状态</a-select-option>
|
<a-select-option value="">全部状态</a-select-option>
|
||||||
<a-select-option value="pending">待处理</a-select-option>
|
<a-select-option value="pending">待处理</a-select-option>
|
||||||
@@ -25,9 +25,7 @@
|
|||||||
allow-clear
|
allow-clear
|
||||||
@press-enter="handleFilterChange"
|
@press-enter="handleFilterChange"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix><SearchOutlined /></template>
|
||||||
<SearchOutlined />
|
|
||||||
</template>
|
|
||||||
</a-input>
|
</a-input>
|
||||||
|
|
||||||
<a-range-picker
|
<a-range-picker
|
||||||
@@ -39,39 +37,22 @@
|
|||||||
@change="handleFilterChange"
|
@change="handleFilterChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<a-button type="primary" class="filter-button" @click="handleFilterChange">
|
<a-button type="primary" @click="handleFilterChange">查询</a-button>
|
||||||
查询
|
<a-button @click="handleResetFilters">重置</a-button>
|
||||||
</a-button>
|
|
||||||
<a-button class="filter-button" @click="handleResetFilters">
|
|
||||||
重置
|
|
||||||
</a-button>
|
|
||||||
</a-space>
|
</a-space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 任务列表 -->
|
<!-- 任务列表 -->
|
||||||
<div class="digital-human-task-page__content">
|
<div class="task-page__content">
|
||||||
<!-- 批量操作栏 -->
|
<!-- 批量操作栏 -->
|
||||||
<div v-if="selectedRowKeys.length > 0" class="batch-actions">
|
<div v-if="selectedRowKeys.length > 0" class="batch-actions">
|
||||||
<a-alert
|
<a-alert :message="`已选中 ${selectedRowKeys.length} 项`" type="info" show-icon>
|
||||||
:message="`已选中 ${selectedRowKeys.length} 项`"
|
|
||||||
type="info"
|
|
||||||
show-icon
|
|
||||||
>
|
|
||||||
<template #action>
|
<template #action>
|
||||||
<a-space>
|
<a-popconfirm title="确定要删除选中的任务吗?" @confirm="handleBatchDelete">
|
||||||
|
<a-button size="small" danger>
|
||||||
<a-popconfirm
|
<DeleteOutlined /> 批量删除
|
||||||
title="确定要删除选中的任务吗?删除后无法恢复。"
|
</a-button>
|
||||||
@confirm="handleBatchDelete"
|
</a-popconfirm>
|
||||||
>
|
|
||||||
<a-button size="small" danger>
|
|
||||||
<template #icon>
|
|
||||||
<DeleteOutlined />
|
|
||||||
</template>
|
|
||||||
批量删除
|
|
||||||
</a-button>
|
|
||||||
</a-popconfirm>
|
|
||||||
</a-space>
|
|
||||||
</template>
|
</template>
|
||||||
</a-alert>
|
</a-alert>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,21 +63,14 @@
|
|||||||
:columns="columns"
|
:columns="columns"
|
||||||
:row-key="record => record.id"
|
:row-key="record => record.id"
|
||||||
:pagination="paginationConfig"
|
:pagination="paginationConfig"
|
||||||
@change="handleTableChange"
|
|
||||||
:row-selection="rowSelection"
|
:row-selection="rowSelection"
|
||||||
:scroll="{ x: 1000 }"
|
:scroll="{ x: 1000 }"
|
||||||
|
@change="handleTableChange"
|
||||||
>
|
>
|
||||||
<!-- 任务名称列 -->
|
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
|
<!-- 任务名称列 -->
|
||||||
<template v-if="column.key === 'taskName'">
|
<template v-if="column.key === 'taskName'">
|
||||||
<div class="task-name-cell">
|
<strong>{{ record.taskName }}</strong>
|
||||||
<strong>{{ record.taskName }}</strong>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 音色列 -->
|
|
||||||
<template v-else-if="column.key === 'voiceId'">
|
|
||||||
<span>{{ record.voiceId || '-' }}</span>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 状态列 -->
|
<!-- 状态列 -->
|
||||||
@@ -106,10 +80,10 @@
|
|||||||
|
|
||||||
<!-- 进度列 -->
|
<!-- 进度列 -->
|
||||||
<template v-else-if="column.key === 'progress'">
|
<template v-else-if="column.key === 'progress'">
|
||||||
<div style="min-width: 100px">
|
<div class="progress-cell">
|
||||||
<a-progress
|
<a-progress
|
||||||
:percent="record.progress"
|
:percent="record.progress"
|
||||||
:status="getProgressStatus(record.status)"
|
:status="PROGRESS_STATUS[record.status]"
|
||||||
size="small"
|
size="small"
|
||||||
:show-info="false"
|
:show-info="false"
|
||||||
/>
|
/>
|
||||||
@@ -118,27 +92,22 @@
|
|||||||
|
|
||||||
<!-- 创建时间列 -->
|
<!-- 创建时间列 -->
|
||||||
<template v-else-if="column.key === 'createTime'">
|
<template v-else-if="column.key === 'createTime'">
|
||||||
{{ formatDateTime(record.createTime) }}
|
{{ formatDate(record.createTime) }}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 操作列 -->
|
<!-- 操作列 -->
|
||||||
<template v-else-if="column.key === 'actions'">
|
<template v-else-if="column.key === 'actions'">
|
||||||
<a-space>
|
<a-space>
|
||||||
|
|
||||||
<!-- 下载按钮 -->
|
|
||||||
<a-button
|
<a-button
|
||||||
v-if="isStatus(record.status, 'success')"
|
v-if="isStatus(record.status, 'success')"
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
|
class="action-btn action-btn--success"
|
||||||
@click="handleDownload(record)"
|
@click="handleDownload(record)"
|
||||||
class="action-btn-download"
|
|
||||||
>
|
>
|
||||||
<template #icon>
|
<DownloadOutlined /> 下载
|
||||||
<DownloadOutlined />
|
|
||||||
</template>
|
|
||||||
下载
|
|
||||||
</a-button>
|
</a-button>
|
||||||
<!-- 取消按钮 -->
|
|
||||||
<a-button
|
<a-button
|
||||||
v-if="isStatus(record.status, 'running')"
|
v-if="isStatus(record.status, 'running')"
|
||||||
type="link"
|
type="link"
|
||||||
@@ -148,11 +117,8 @@
|
|||||||
取消
|
取消
|
||||||
</a-button>
|
</a-button>
|
||||||
|
|
||||||
<a-popconfirm
|
<a-popconfirm title="确定删除?删除后无法恢复。" @confirm="handleDelete(record.id)">
|
||||||
title="确定删除这个任务吗?删除后无法恢复。"
|
<a-button type="link" size="small" class="action-btn action-btn--danger">删除</a-button>
|
||||||
@confirm="() => handleDelete(record.id)"
|
|
||||||
>
|
|
||||||
<a-button size="small" type="link" class="action-btn-delete">删除</a-button>
|
|
||||||
</a-popconfirm>
|
</a-popconfirm>
|
||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
@@ -166,364 +132,145 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import {
|
import { SearchOutlined, DownloadOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||||
SearchOutlined,
|
import { getDigitalHumanTaskPage, cancelTask, deleteTask } from '@/api/digitalHuman'
|
||||||
PlayCircleOutlined,
|
|
||||||
DownloadOutlined,
|
|
||||||
DeleteOutlined
|
|
||||||
} from '@ant-design/icons-vue'
|
|
||||||
import {
|
|
||||||
getDigitalHumanTaskPage,
|
|
||||||
cancelTask,
|
|
||||||
deleteTask
|
|
||||||
} from '@/api/digitalHuman'
|
|
||||||
import { formatDate } from '@/utils/file'
|
import { formatDate } from '@/utils/file'
|
||||||
import { useTaskList } from '@/views/system/task-management/composables/useTaskList'
|
import { useTaskList } from '@/views/system/task-management/composables/useTaskList'
|
||||||
import { useTaskOperations } from '@/views/system/task-management/composables/useTaskOperations'
|
import { useTaskOperations } from '@/views/system/task-management/composables/useTaskOperations'
|
||||||
import { useTaskPolling } from '@/views/system/task-management/composables/useTaskPolling'
|
import { useTaskPolling } from '@/views/system/task-management/composables/useTaskPolling'
|
||||||
import TaskStatusTag from '@/views/system/task-management/components/TaskStatusTag.vue'
|
import TaskStatusTag from '@/views/system/task-management/components/TaskStatusTag.vue'
|
||||||
|
|
||||||
// 使用 Composable
|
// 进度状态映射
|
||||||
const {
|
const PROGRESS_STATUS = {
|
||||||
loading,
|
pending: 'normal', running: 'active', success: 'success', failed: 'exception', canceled: 'normal',
|
||||||
list,
|
PENDING: 'normal', RUNNING: 'active', SUCCESS: 'success', FAILED: 'exception', CANCELED: 'normal'
|
||||||
filters,
|
}
|
||||||
paginationConfig,
|
|
||||||
fetchList,
|
|
||||||
handleFilterChange,
|
|
||||||
handleResetFilters,
|
|
||||||
handleTableChange
|
|
||||||
} = useTaskList(getDigitalHumanTaskPage)
|
|
||||||
|
|
||||||
// 使用任务操作 Composable
|
// Composables
|
||||||
const {
|
const { loading, list, filters, paginationConfig, fetchList, handleFilterChange, handleResetFilters, handleTableChange } = useTaskList(getDigitalHumanTaskPage)
|
||||||
handleDelete,
|
const { handleDelete, handleCancel } = useTaskOperations({ deleteApi: deleteTask, cancelApi: cancelTask }, fetchList)
|
||||||
handleCancel,
|
useTaskPolling(getDigitalHumanTaskPage, { onTaskUpdate: fetchList })
|
||||||
} = useTaskOperations(
|
|
||||||
{
|
|
||||||
deleteApi: deleteTask,
|
|
||||||
cancelApi: cancelTask,
|
|
||||||
},
|
|
||||||
fetchList
|
|
||||||
)
|
|
||||||
|
|
||||||
// 使用轮询 Composable
|
// 表格选择
|
||||||
useTaskPolling(getDigitalHumanTaskPage, {
|
|
||||||
onTaskUpdate: () => {
|
|
||||||
fetchList()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 表格选择相关
|
|
||||||
const selectedRowKeys = ref([])
|
const selectedRowKeys = ref([])
|
||||||
|
|
||||||
// 表格行选择配置
|
|
||||||
const rowSelection = {
|
const rowSelection = {
|
||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
onChange: (keys) => {
|
onChange: (keys) => { selectedRowKeys.value = keys }
|
||||||
selectedRowKeys.value = keys
|
|
||||||
},
|
|
||||||
onSelectAll: (selected, selectedRows, changeRows) => {
|
|
||||||
// 全选逻辑
|
|
||||||
console.log('全选状态:', selected, '选中行数:', selectedRows.length, '变化行数:', changeRows.length)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化日期时间
|
// 状态判断
|
||||||
const formatDateTime = (dateStr) => {
|
const isStatus = (status, target) => status === target || status === target.toUpperCase()
|
||||||
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: 240,
|
|
||||||
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 handleDownload = (record) => {
|
const handleDownload = (record) => {
|
||||||
console.log(record)
|
|
||||||
if (!record.resultVideoUrl) {
|
if (!record.resultVideoUrl) {
|
||||||
message.warning('该任务暂无视频结果,请稍后再试')
|
message.warning('该任务暂无视频结果,请稍后再试')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
window.open(record.resultVideoUrl, '_blank')
|
window.open(record.resultVideoUrl, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 批量删除任务
|
// 批量删除
|
||||||
const handleBatchDelete = async () => {
|
const handleBatchDelete = async () => {
|
||||||
if (selectedRowKeys.value.length === 0) {
|
if (!selectedRowKeys.value.length) return
|
||||||
message.warning('请选择要删除的任务')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 逐个删除选中的任务
|
for (const id of selectedRowKeys.value) await deleteTask(id)
|
||||||
for (const id of selectedRowKeys.value) {
|
|
||||||
await deleteTask(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
message.success(`成功删除 ${selectedRowKeys.value.length} 个任务`)
|
message.success(`成功删除 ${selectedRowKeys.value.length} 个任务`)
|
||||||
|
|
||||||
// 清空选择并刷新列表
|
|
||||||
selectedRowKeys.value = []
|
selectedRowKeys.value = []
|
||||||
await fetchList()
|
await fetchList()
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
console.error('批量删除失败:', error)
|
console.error('批量删除失败:', e)
|
||||||
message.error('批量删除失败,请重试')
|
message.error('批量删除失败,请重试')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化
|
// 表格列定义
|
||||||
onMounted(() => {
|
const columns = [
|
||||||
fetchList()
|
{ 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: 200, fixed: 'right' }
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(fetchList)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
.digital-human-task-page {
|
.task-page {
|
||||||
padding: 0 var(--space-3);
|
padding: var(--space-4);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-3);
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
&__filters {
|
.task-page__filters {
|
||||||
padding: var(--space-3);
|
padding: var(--space-4);
|
||||||
background: var(--color-surface);
|
background: var(--color-bg-card);
|
||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
box-shadow: var(--shadow-sm);
|
||||||
|
|
||||||
.filter-select,
|
|
||||||
.filter-input {
|
|
||||||
width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-date-picker {
|
|
||||||
width: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
.filter-select,
|
||||||
|
.filter-input {
|
||||||
|
width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__content {
|
.filter-date-picker {
|
||||||
flex: 1;
|
width: 280px;
|
||||||
overflow: auto;
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-radius: var(--radius-card);
|
|
||||||
padding: var(--space-3);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 批量操作栏 */
|
.task-page__content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
.batch-actions {
|
.batch-actions {
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-4);
|
||||||
|
|
||||||
+ .ant-spin {
|
+ :deep(.ant-spin) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
:deep(.ant-spin-container) {
|
.ant-spin-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.ant-table) {
|
.ant-table {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 任务名称单元格 */
|
.progress-cell {
|
||||||
.task-name-cell {
|
min-width: 100px;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 操作按钮样式 */
|
.action-btn {
|
||||||
.action-btn-preview {
|
&--success { color: var(--color-success-500); &:hover { color: var(--color-success-600); } }
|
||||||
color: var(--color-primary);
|
&--danger { color: var(--color-error-500); &:hover { color: var(--color-error-600); } }
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--color-primary-hover, var(--color-blue-600));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn-download {
|
|
||||||
color: var(--color-success);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #059669;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn-delete {
|
|
||||||
color: var(--color-error);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 文本截断 */
|
|
||||||
.text-ellipsis {
|
|
||||||
display: inline-block;
|
|
||||||
max-width: 180px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 展开内容 */
|
|
||||||
.expanded-content {
|
|
||||||
padding: var(--space-3);
|
|
||||||
background: var(--color-bg-2);
|
|
||||||
border-radius: var(--radius-card);
|
|
||||||
margin: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-text {
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: var(--space-2) 0 0 0;
|
|
||||||
padding: var(--space-2);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-radius: var(--radius-card);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-params {
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
|
|
||||||
.params-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
||||||
gap: var(--space-2);
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
|
|
||||||
.param-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: var(--space-2);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-radius: var(--radius-card);
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.param-label {
|
|
||||||
font-weight: 600;
|
|
||||||
margin-right: var(--space-2);
|
|
||||||
color: var(--color-text-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.param-value {
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-result {
|
|
||||||
margin-bottom: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.processing-tip {
|
|
||||||
color: var(--color-text-3);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-error {
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 确保按钮内的图标和文字对齐 */
|
|
||||||
:deep(.ant-btn .anticon) {
|
|
||||||
line-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 表格样式 */
|
|
||||||
:deep(.ant-table-tbody > tr > td) {
|
:deep(.ant-table-tbody > tr > td) {
|
||||||
padding: 12px 8px;
|
padding: var(--space-3) var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.ant-table-thead > tr > th) {
|
:deep(.ant-table-thead > tr > th) {
|
||||||
background: var(--color-bg-2);
|
background: var(--color-gray-50);
|
||||||
font-weight: 600;
|
font-weight: var(--font-weight-semibold);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 桌面端样式优化 */
|
:deep(.ant-btn .anticon) {
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,22 +8,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<ul class="task-layout__nav-list">
|
<ul class="task-layout__nav-list">
|
||||||
<li
|
<li
|
||||||
v-for="item in navItems"
|
v-for="item in NAV_ITEMS"
|
||||||
:key="item.type"
|
:key="item.type"
|
||||||
class="task-layout__nav-item"
|
class="task-layout__nav-item"
|
||||||
:class="{
|
:class="{ 'is-active': currentType === item.type }"
|
||||||
'is-active': currentType === item.type
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<a
|
<a class="task-layout__nav-link" @click="navigateTo(item.type)">
|
||||||
href="javascript:void(0)"
|
<component :is="item.icon" class="nav-icon" />
|
||||||
class="task-layout__nav-link"
|
|
||||||
@click="navigateTo(item.type)"
|
|
||||||
>
|
|
||||||
<span class="nav-icon">
|
|
||||||
<VideoCameraOutlined v-if="item.icon === 'video'" />
|
|
||||||
<UserOutlined v-else-if="item.icon === 'user'" />
|
|
||||||
</span>
|
|
||||||
<span class="nav-text">{{ item.label }}</span>
|
<span class="nav-text">{{ item.label }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -41,55 +32,37 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, defineAsyncComponent } from 'vue'
|
import { computed, defineAsyncComponent, markRaw } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { VideoCameraOutlined, UserOutlined } from '@ant-design/icons-vue'
|
import { VideoCameraOutlined, UserOutlined } from '@ant-design/icons-vue'
|
||||||
|
|
||||||
// 响应式数据
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
// 当前任务类型
|
|
||||||
const currentType = computed(() => {
|
const currentType = computed(() => {
|
||||||
const type = route.params.type
|
const { type } = route.params
|
||||||
|
return !type || type === 'task-management' ? 'mix-task' : type
|
||||||
if (!type || type === 'task-management') {
|
|
||||||
return 'mix-task'
|
|
||||||
}
|
|
||||||
|
|
||||||
return type
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 动态导入组件
|
const NAV_ITEMS = [
|
||||||
const MixTaskList = defineAsyncComponent(() => import('../mix-task/index.vue'))
|
|
||||||
const DigitalHumanTaskList = defineAsyncComponent(() => import('../digital-human-task/index.vue'))
|
|
||||||
|
|
||||||
// 导航项配置
|
|
||||||
const navItems = [
|
|
||||||
{
|
{
|
||||||
type: 'mix-task',
|
type: 'mix-task',
|
||||||
label: '混剪视频任务',
|
label: '混剪视频任务',
|
||||||
icon: 'video',
|
icon: VideoCameraOutlined,
|
||||||
component: MixTaskList
|
component: markRaw(defineAsyncComponent(() => import('../mix-task/index.vue')))
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'digital-human-task',
|
type: 'digital-human-task',
|
||||||
label: '数字人视频任务',
|
label: '数字人视频任务',
|
||||||
icon: 'user',
|
icon: UserOutlined,
|
||||||
component: DigitalHumanTaskList
|
component: markRaw(defineAsyncComponent(() => import('../digital-human-task/index.vue')))
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
// 当前组件
|
|
||||||
const currentComponent = computed(() => {
|
const currentComponent = computed(() => {
|
||||||
const item = navItems.find(item => item.type === currentType.value)
|
return NAV_ITEMS.find(item => item.type === currentType.value)?.component ?? NAV_ITEMS[0].component
|
||||||
if (!item) {
|
|
||||||
return navItems[0].component
|
|
||||||
}
|
|
||||||
return item.component
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 导航到指定类型
|
|
||||||
const navigateTo = (type) => {
|
const navigateTo = (type) => {
|
||||||
router.push(`/system/task-management/${type}`)
|
router.push(`/system/task-management/${type}`)
|
||||||
}
|
}
|
||||||
@@ -99,141 +72,96 @@ const navigateTo = (type) => {
|
|||||||
.task-layout {
|
.task-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 左侧导航 */
|
|
||||||
.task-layout__sidebar {
|
.task-layout__sidebar {
|
||||||
width: 220px;
|
width: 220px;
|
||||||
background: var(--color-surface);
|
background: transparent;
|
||||||
border-right: 1px solid var(--color-border);
|
border-right: 1px solid var(--color-gray-200);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
@media (max-width: 1199px) {
|
|
||||||
width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
width: 100%;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 10;
|
|
||||||
transform: translateX(-100%);
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
|
|
||||||
&.is-mobile-open {
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 导航头部 */
|
|
||||||
.task-layout__nav-header {
|
.task-layout__nav-header {
|
||||||
height: 64px;
|
height: 56px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 24px;
|
padding: 0 var(--space-6);
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-gray-200);
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-layout__nav-title {
|
.task-layout__nav-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 18px;
|
font-size: var(--font-size-lg);
|
||||||
font-weight: 600;
|
font-weight: var(--font-weight-semibold);
|
||||||
color: var(--color-text);
|
color: var(--color-gray-800);
|
||||||
line-height: 1.4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 导航列表 */
|
|
||||||
.task-layout__nav-list {
|
.task-layout__nav-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: var(--space-1) 0;
|
padding: var(--space-2) 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 导航项 */
|
|
||||||
.task-layout__nav-item {
|
.task-layout__nav-item {
|
||||||
margin: 4px var(--space-2);
|
margin: var(--space-1) var(--space-3);
|
||||||
|
|
||||||
&.is-active {
|
&.is-active .task-layout__nav-link {
|
||||||
.task-layout__nav-link {
|
background: var(--color-primary-500);
|
||||||
background: var(--color-primary);
|
color: #fff;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
|
||||||
.nav-icon {
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 导航链接 */
|
|
||||||
.task-layout__nav-link {
|
.task-layout__nav-link {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: var(--space-2) var(--space-2);
|
padding: var(--space-3);
|
||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-md);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-gray-600);
|
||||||
text-decoration: none;
|
|
||||||
transition: all 0.2s;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: all var(--duration-fast) var(--ease-out);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-bg-2);
|
background: var(--color-gray-100);
|
||||||
color: var(--color-primary);
|
color: var(--color-primary-500);
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-active & {
|
.is-active &:hover {
|
||||||
&:hover {
|
background: var(--color-primary-600);
|
||||||
background: var(--color-primary);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 导航图标 */
|
|
||||||
.nav-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
margin-right: var(--space-2);
|
|
||||||
color: var(--color-text-3);
|
|
||||||
transition: color 0.2s;
|
|
||||||
|
|
||||||
.task-layout__nav-item.is-active & {
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 导航文本 */
|
.nav-icon {
|
||||||
.nav-text {
|
width: 18px;
|
||||||
font-size: 14px;
|
height: 18px;
|
||||||
font-weight: 500;
|
margin-right: var(--space-2);
|
||||||
|
color: var(--color-gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-text {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 右侧内容 */
|
|
||||||
.task-layout__content {
|
.task-layout__content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg-page);
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 过渡动画 */
|
|
||||||
.fade-enter-active,
|
.fade-enter-active,
|
||||||
.fade-leave-active {
|
.fade-leave-active {
|
||||||
transition: opacity 0.25s ease;
|
transition: opacity var(--duration-base) var(--ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-enter-from,
|
.fade-enter-from,
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mix-task-page">
|
<div class="task-page">
|
||||||
<!-- 筛选条件 -->
|
<!-- 筛选条件 -->
|
||||||
<div class="mix-task-page__filters">
|
<div class="task-page__filters">
|
||||||
<a-space :size="16">
|
<a-space :size="16">
|
||||||
<a-select
|
<a-select
|
||||||
v-model:value="filters.status"
|
v-model:value="filters.status"
|
||||||
class="filter-select"
|
class="filter-select"
|
||||||
placeholder="任务状态"
|
placeholder="任务状态"
|
||||||
@change="handleFilterChange"
|
|
||||||
allow-clear
|
allow-clear
|
||||||
|
@change="handleFilterChange"
|
||||||
>
|
>
|
||||||
<a-select-option value="">全部状态</a-select-option>
|
<a-select-option value="">全部状态</a-select-option>
|
||||||
<a-select-option value="pending">待处理</a-select-option>
|
<a-select-option value="pending">待处理</a-select-option>
|
||||||
@@ -24,9 +24,7 @@
|
|||||||
allow-clear
|
allow-clear
|
||||||
@press-enter="handleFilterChange"
|
@press-enter="handleFilterChange"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix><SearchOutlined /></template>
|
||||||
<SearchOutlined />
|
|
||||||
</template>
|
|
||||||
</a-input>
|
</a-input>
|
||||||
|
|
||||||
<a-range-picker
|
<a-range-picker
|
||||||
@@ -38,177 +36,138 @@
|
|||||||
@change="handleFilterChange"
|
@change="handleFilterChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<a-button type="primary" class="filter-button" @click="handleFilterChange">
|
<a-button type="primary" @click="handleFilterChange">查询</a-button>
|
||||||
查询
|
<a-button @click="handleResetFilters">重置</a-button>
|
||||||
</a-button>
|
|
||||||
<a-button class="filter-button" @click="handleResetFilters">
|
|
||||||
重置
|
|
||||||
</a-button>
|
|
||||||
</a-space>
|
</a-space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 任务列表 -->
|
<!-- 任务列表 -->
|
||||||
<div class="mix-task-page__content">
|
<div class="task-page__content">
|
||||||
<a-spin :spinning="loading" tip="加载中...">
|
<a-spin :spinning="loading" tip="加载中...">
|
||||||
<a-table
|
<a-table
|
||||||
:data-source="list"
|
:data-source="list"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:row-key="record => record.id"
|
:row-key="record => record.id"
|
||||||
:pagination="paginationConfig"
|
:pagination="paginationConfig"
|
||||||
@change="handleTableChange"
|
|
||||||
:expanded-row-keys="expandedRowKeys"
|
:expanded-row-keys="expandedRowKeys"
|
||||||
@expandedRowsChange="handleExpandedRowsChange"
|
|
||||||
:scroll="{ x: 'max-content' }"
|
:scroll="{ x: 'max-content' }"
|
||||||
|
@change="handleTableChange"
|
||||||
|
@expandedRowsChange="handleExpandedRowsChange"
|
||||||
>
|
>
|
||||||
<!-- 标题列 -->
|
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
|
<!-- 标题列 -->
|
||||||
<template v-if="column.key === 'title'">
|
<template v-if="column.key === 'title'">
|
||||||
<div class="title-cell">
|
<div class="title-cell">
|
||||||
<strong>{{ record.title }}</strong>
|
<strong>{{ record.title }}</strong>
|
||||||
<a-tag v-if="record.text" size="small" style="margin-left: 8px">有文案</a-tag>
|
<a-tag v-if="record.text" size="small">有文案</a-tag>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 状态列 -->
|
<!-- 状态列 -->
|
||||||
<template v-else-if="column.key === 'status'">
|
<template v-else-if="column.key === 'status'">
|
||||||
<a-tag :color="getStatusColor(record.status)" class="status-tag">
|
<TaskStatusTag :status="record.status" />
|
||||||
{{ getStatusText(record.status) }}
|
|
||||||
</a-tag>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 创建时间列 -->
|
<!-- 时间列 -->
|
||||||
<template v-else-if="column.key === 'createTime'">
|
<template v-else-if="column.key === 'createTime'">
|
||||||
{{ formatDate(record.createTime) }}
|
{{ formatDate(record.createTime) }}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 完成时间列 -->
|
|
||||||
<template v-else-if="column.key === 'finishTime'">
|
<template v-else-if="column.key === 'finishTime'">
|
||||||
{{ record.finishTime ? formatDate(record.finishTime) : '-' }}
|
{{ record.finishTime ? formatDate(record.finishTime) : '-' }}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 生成结果列 -->
|
<!-- 生成结果列 -->
|
||||||
<template v-else-if="column.key === 'outputUrls'">
|
<template v-else-if="column.key === 'outputUrls'">
|
||||||
<div v-if="record.outputUrls && record.outputUrls.length > 0">
|
<a-tag v-if="record.outputUrls?.length" color="success">
|
||||||
<a-tag color="success">{{ record.outputUrls.length }} 个视频</a-tag>
|
{{ record.outputUrls.length }} 个视频
|
||||||
</div>
|
</a-tag>
|
||||||
<span v-else>-</span>
|
<span v-else class="text-muted">-</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 操作列 (增强版:预览+下载+其他操作) -->
|
<!-- 操作列 -->
|
||||||
<template v-else-if="column.key === 'actions'">
|
<template v-else-if="column.key === 'actions'">
|
||||||
<a-space>
|
<a-space>
|
||||||
<!-- 预览按钮 -->
|
|
||||||
<a-button
|
<a-button
|
||||||
v-if="isStatus(record.status, 'success') && record.outputUrls && record.outputUrls.length > 0"
|
v-if="canOperate(record, 'preview')"
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
@click="openPreviewModal(record)"
|
class="action-btn action-btn--primary"
|
||||||
class="action-btn-preview"
|
@click="openPreview(record)"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<PlayCircleOutlined /> 预览
|
||||||
<PlayCircleOutlined />
|
|
||||||
</template>
|
|
||||||
预览
|
|
||||||
</a-button>
|
</a-button>
|
||||||
|
|
||||||
<!-- 下载按钮 -->
|
|
||||||
<a-button
|
<a-button
|
||||||
v-if="isStatus(record.status, 'success') && record.outputUrls && record.outputUrls.length > 0"
|
v-if="canOperate(record, 'download')"
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
|
class="action-btn action-btn--success"
|
||||||
@click="handleDownload(record)"
|
@click="handleDownload(record)"
|
||||||
class="action-btn-download"
|
|
||||||
>
|
>
|
||||||
<template #icon>
|
<DownloadOutlined /> 下载
|
||||||
<DownloadOutlined />
|
|
||||||
</template>
|
|
||||||
下载
|
|
||||||
</a-button>
|
</a-button>
|
||||||
|
|
||||||
<!-- 取消按钮 -->
|
|
||||||
<a-button
|
<a-button
|
||||||
v-if="isStatus(record.status, 'running')"
|
v-if="canOperate(record, 'cancel')"
|
||||||
size="small"
|
size="small"
|
||||||
@click="handleCancel(record.id)"
|
@click="handleCancel(record.id)"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</a-button>
|
</a-button>
|
||||||
|
|
||||||
<!-- 重试按钮 -->
|
|
||||||
<a-button
|
<a-button
|
||||||
v-if="isStatus(record.status, 'failed')"
|
v-if="canOperate(record, 'retry')"
|
||||||
size="small"
|
size="small"
|
||||||
@click="handleRetry(record.id)"
|
@click="handleRetry(record.id)"
|
||||||
>
|
>
|
||||||
重试
|
重试
|
||||||
</a-button>
|
</a-button>
|
||||||
|
|
||||||
<!-- 删除按钮 -->
|
<a-popconfirm title="确定删除?删除后无法恢复。" @confirm="handleDelete(record.id)">
|
||||||
<a-popconfirm
|
<a-button type="link" size="small" class="action-btn action-btn--danger">删除</a-button>
|
||||||
title="确定删除这个任务吗?删除后无法恢复。"
|
|
||||||
@confirm="() => handleDelete(record.id)"
|
|
||||||
>
|
|
||||||
<a-button size="small" type="link" class="action-btn-delete">删除</a-button>
|
|
||||||
</a-popconfirm>
|
</a-popconfirm>
|
||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 展开行内容 (优化版) -->
|
<!-- 展开行内容 -->
|
||||||
<template #expandedRowRender="{ record }">
|
<template #expandedRowRender="{ record }">
|
||||||
<div class="expanded-content">
|
<div class="expanded-content">
|
||||||
<!-- 任务详情 -->
|
|
||||||
<div v-if="record.text" class="task-text">
|
<div v-if="record.text" class="task-text">
|
||||||
<strong>文案内容:</strong>
|
<strong>文案内容:</strong>
|
||||||
<p>{{ record.text }}</p>
|
<p>{{ record.text }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 生成结果 -->
|
<div v-if="record.outputUrls?.length" class="task-results">
|
||||||
<div v-if="record.outputUrls && record.outputUrls.length > 0" class="task-results">
|
|
||||||
<div class="result-header">
|
<div class="result-header">
|
||||||
<strong>生成结果:</strong>
|
<strong>生成结果:</strong>
|
||||||
<span class="result-count">{{ record.outputUrls.length }} 个视频</span>
|
<span class="result-count">{{ record.outputUrls.length }} 个视频</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="result-list">
|
<div class="result-list">
|
||||||
<div
|
<div v-for="(_, index) in record.outputUrls" :key="index" class="result-item">
|
||||||
v-for="(url, index) in record.outputUrls"
|
|
||||||
:key="index"
|
|
||||||
class="result-item"
|
|
||||||
>
|
|
||||||
<a-button
|
<a-button
|
||||||
v-if="isStatus(record.status, 'success')"
|
v-if="isStatus(record.status, 'success')"
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
@click="handlePreviewSingle(record, index)"
|
@click="previewVideo(record, index)"
|
||||||
class="result-preview-btn"
|
|
||||||
>
|
>
|
||||||
<PlayCircleOutlined />
|
<PlayCircleOutlined /> 视频 {{ index + 1 }}
|
||||||
视频 {{ index + 1 }}
|
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button
|
<a-button
|
||||||
v-if="isStatus(record.status, 'success')"
|
v-if="isStatus(record.status, 'success')"
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
@click="handleDownloadSingle(record.id, index)"
|
@click="downloadVideo(record.id, index)"
|
||||||
class="result-download-btn"
|
|
||||||
>
|
>
|
||||||
<DownloadOutlined />
|
<DownloadOutlined />
|
||||||
</a-button>
|
</a-button>
|
||||||
<span v-else class="processing-tip">
|
<span v-else class="text-muted">视频 {{ index + 1 }} (处理中...)</span>
|
||||||
视频 {{ index + 1 }} (处理中...)
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 错误信息 -->
|
<a-alert v-if="record.errorMsg" type="error" :message="record.errorMsg" show-icon />
|
||||||
<div v-if="record.errorMsg" class="task-error">
|
|
||||||
<a-alert
|
|
||||||
type="error"
|
|
||||||
:message="record.errorMsg"
|
|
||||||
show-icon
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
@@ -216,21 +175,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 预览模态框 -->
|
<!-- 预览模态框 -->
|
||||||
<a-modal
|
<a-modal v-model:open="preview.visible" :title="preview.title" width="800px" :footer="null" centered>
|
||||||
v-model:open="previewVisible"
|
<div v-if="preview.url" class="preview-container">
|
||||||
:title="previewTitle"
|
<video :src="preview.url" controls autoplay class="preview-video">
|
||||||
width="800px"
|
|
||||||
:footer="null"
|
|
||||||
:centered="true"
|
|
||||||
class="preview-modal"
|
|
||||||
>
|
|
||||||
<div v-if="previewUrl" class="preview-container">
|
|
||||||
<video
|
|
||||||
:src="previewUrl"
|
|
||||||
controls
|
|
||||||
autoplay
|
|
||||||
style="width: 100%; max-height: 600px; border-radius: 8px;"
|
|
||||||
>
|
|
||||||
您的浏览器不支持视频播放
|
您的浏览器不支持视频播放
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,76 +189,64 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import {
|
import { SearchOutlined, PlayCircleOutlined, DownloadOutlined } from '@ant-design/icons-vue'
|
||||||
SearchOutlined,
|
|
||||||
PlayCircleOutlined,
|
|
||||||
DownloadOutlined
|
|
||||||
} from '@ant-design/icons-vue'
|
|
||||||
import { MixTaskService } from '@/api/mixTask'
|
import { MixTaskService } from '@/api/mixTask'
|
||||||
import { formatDate } from '@/utils/file'
|
import { formatDate } from '@/utils/file'
|
||||||
import { useTaskList } from '@/views/system/task-management/composables/useTaskList'
|
import { useTaskList } from '@/views/system/task-management/composables/useTaskList'
|
||||||
import { useTaskOperations } from '@/views/system/task-management/composables/useTaskOperations'
|
import { useTaskOperations } from '@/views/system/task-management/composables/useTaskOperations'
|
||||||
import { useTaskPolling } from '@/views/system/task-management/composables/useTaskPolling'
|
import { useTaskPolling } from '@/views/system/task-management/composables/useTaskPolling'
|
||||||
|
import TaskStatusTag from '@/views/system/task-management/components/TaskStatusTag.vue'
|
||||||
|
|
||||||
// 使用 Composable
|
// Composables
|
||||||
const {
|
const { loading, list, filters, paginationConfig, fetchList, handleFilterChange, handleResetFilters, handleTableChange } = useTaskList(MixTaskService.getTaskPage)
|
||||||
loading,
|
const { handleDelete, handleCancel, handleRetry, handleBatchDownload } = useTaskOperations(
|
||||||
list,
|
{ deleteApi: MixTaskService.deleteTask, cancelApi: MixTaskService.cancelTask, retryApi: MixTaskService.retryTask, getSignedUrlsApi: MixTaskService.getSignedUrls },
|
||||||
filters,
|
|
||||||
paginationConfig,
|
|
||||||
fetchList,
|
|
||||||
handleFilterChange,
|
|
||||||
handleResetFilters,
|
|
||||||
handleTableChange,
|
|
||||||
buildParams
|
|
||||||
} = useTaskList(MixTaskService.getTaskPage)
|
|
||||||
|
|
||||||
// 使用任务操作 Composable
|
|
||||||
const {
|
|
||||||
handleDelete,
|
|
||||||
handleCancel,
|
|
||||||
handleRetry,
|
|
||||||
handleBatchDownload
|
|
||||||
} = useTaskOperations(
|
|
||||||
{
|
|
||||||
deleteApi: MixTaskService.deleteTask,
|
|
||||||
cancelApi: MixTaskService.cancelTask,
|
|
||||||
retryApi: MixTaskService.retryTask,
|
|
||||||
getSignedUrlsApi: MixTaskService.getSignedUrls
|
|
||||||
},
|
|
||||||
fetchList
|
fetchList
|
||||||
)
|
)
|
||||||
|
useTaskPolling(MixTaskService.getTaskPage, { onTaskUpdate: fetchList })
|
||||||
|
|
||||||
// 预览相关状态
|
// 展开行
|
||||||
const previewVisible = ref(false)
|
const expandedRowKeys = ref([])
|
||||||
const previewUrl = ref('')
|
const handleExpandedRowsChange = (keys) => { expandedRowKeys.value = keys }
|
||||||
const previewTitle = ref('')
|
|
||||||
|
|
||||||
// 预览单个视频
|
// 预览状态
|
||||||
const handlePreviewSingle = async (record, index) => {
|
const preview = reactive({ visible: false, title: '', url: '' })
|
||||||
|
|
||||||
|
// 状态判断
|
||||||
|
const isStatus = (status, target) => status === target || status === target.toUpperCase()
|
||||||
|
const canOperate = (record, action) => {
|
||||||
|
const isSuccess = isStatus(record.status, 'success')
|
||||||
|
const hasUrls = record.outputUrls?.length > 0
|
||||||
|
const actions = {
|
||||||
|
preview: isSuccess && hasUrls,
|
||||||
|
download: isSuccess && hasUrls,
|
||||||
|
cancel: isStatus(record.status, 'running'),
|
||||||
|
retry: isStatus(record.status, 'failed')
|
||||||
|
}
|
||||||
|
return actions[action]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预览视频
|
||||||
|
const previewVideo = async (record, index) => {
|
||||||
|
preview.title = `${record.title} - 视频 ${index + 1}`
|
||||||
|
preview.visible = true
|
||||||
|
preview.url = ''
|
||||||
try {
|
try {
|
||||||
previewTitle.value = `${record.title} - 视频 ${index + 1}`
|
|
||||||
previewVisible.value = true
|
|
||||||
previewUrl.value = ''
|
|
||||||
|
|
||||||
// 获取签名URL
|
|
||||||
const res = await MixTaskService.getSignedUrls(record.id)
|
const res = await MixTaskService.getSignedUrls(record.id)
|
||||||
if (res.code === 0 && res.data && res.data[index]) {
|
if (res.code === 0 && res.data?.[index]) preview.url = res.data[index]
|
||||||
previewUrl.value = res.data[index]
|
} catch (e) {
|
||||||
} else {
|
console.error('获取预览链接失败:', e)
|
||||||
console.warn('获取预览链接失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取预览链接失败:', error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下载单个视频
|
const openPreview = (record) => previewVideo(record, 0)
|
||||||
const handleDownloadSingle = async (taskId, index) => {
|
|
||||||
|
// 下载视频
|
||||||
|
const downloadVideo = async (taskId, index) => {
|
||||||
try {
|
try {
|
||||||
const res = await MixTaskService.getSignedUrls(taskId)
|
const res = await MixTaskService.getSignedUrls(taskId)
|
||||||
if (res.code === 0 && res.data && res.data[index]) {
|
if (res.code === 0 && res.data?.[index]) {
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = res.data[index]
|
link.href = res.data[index]
|
||||||
link.download = `video_${taskId}_${index + 1}.mp4`
|
link.download = `video_${taskId}_${index + 1}.mp4`
|
||||||
@@ -319,242 +254,112 @@ const handleDownloadSingle = async (taskId, index) => {
|
|||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
document.body.removeChild(link)
|
document.body.removeChild(link)
|
||||||
} else {
|
|
||||||
console.warn('获取下载链接失败')
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
console.error('获取下载链接失败:', error)
|
console.error('获取下载链接失败:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 预览任务(主列表)
|
const handleDownload = (record) => {
|
||||||
const openPreviewModal = async (record) => {
|
if (record.outputUrls?.length) handleBatchDownload([], MixTaskService.getSignedUrls, record.id)
|
||||||
await handlePreviewSingle(record, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 下载任务
|
|
||||||
const handleDownload = async (record) => {
|
|
||||||
if (record.outputUrls && record.outputUrls.length > 0) {
|
|
||||||
await handleBatchDownload(
|
|
||||||
[],
|
|
||||||
MixTaskService.getSignedUrls,
|
|
||||||
record.id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用轮询 Composable
|
|
||||||
useTaskPolling(MixTaskService.getTaskPage, {
|
|
||||||
onTaskUpdate: () => {
|
|
||||||
fetchList()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 扩展行键
|
|
||||||
const expandedRowKeys = ref([])
|
|
||||||
|
|
||||||
// 处理展开行变化
|
|
||||||
const handleExpandedRowsChange = (keys) => {
|
|
||||||
expandedRowKeys.value = keys
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 表格列定义
|
// 表格列定义
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{ title: 'ID', dataIndex: 'id', key: 'id', width: 70, fixed: 'left' },
|
||||||
title: 'ID',
|
{ title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
|
||||||
dataIndex: 'id',
|
{ title: '状态', dataIndex: 'status', key: 'status', width: 90 },
|
||||||
key: 'id',
|
{ title: '生成结果', dataIndex: 'outputUrls', key: 'outputUrls', width: 100 },
|
||||||
width: 70,
|
{ title: '创建时间', dataIndex: 'createTime', key: 'createTime', width: 160 },
|
||||||
fixed: 'left'
|
{ title: '完成时间', dataIndex: 'finishTime', key: 'finishTime', width: 160 },
|
||||||
},
|
{ title: '操作', key: 'actions', width: 240, fixed: 'right' }
|
||||||
{
|
|
||||||
title: '标题',
|
|
||||||
dataIndex: 'title',
|
|
||||||
key: 'title',
|
|
||||||
ellipsis: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
dataIndex: 'status',
|
|
||||||
key: 'status',
|
|
||||||
width: 90
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '生成结果',
|
|
||||||
dataIndex: 'outputUrls',
|
|
||||||
key: 'outputUrls',
|
|
||||||
width: 100
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '创建时间',
|
|
||||||
dataIndex: 'createTime',
|
|
||||||
key: 'createTime',
|
|
||||||
width: 160
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '完成时间',
|
|
||||||
dataIndex: 'finishTime',
|
|
||||||
key: 'finishTime',
|
|
||||||
width: 160
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'actions',
|
|
||||||
width: 240,
|
|
||||||
fixed: 'right'
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
|
|
||||||
// 状态映射函数
|
onMounted(fetchList)
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除未使用的方法
|
|
||||||
// handleDownloadSignedUrl 和 handleDownloadAll 已被移除
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
onMounted(() => {
|
|
||||||
fetchList()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
.mix-task-page {
|
.task-page {
|
||||||
padding: 0 var(--space-3);
|
padding: var(--space-4);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-3);
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
&__filters {
|
.task-page__filters {
|
||||||
padding: var(--space-3);
|
padding: var(--space-4);
|
||||||
background: var(--color-surface);
|
background: var(--color-bg-card);
|
||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
box-shadow: var(--shadow-sm);
|
||||||
|
|
||||||
.filter-select,
|
|
||||||
.filter-input {
|
|
||||||
width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-date-picker {
|
|
||||||
width: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
.filter-select,
|
||||||
|
.filter-input {
|
||||||
|
width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__content {
|
.filter-date-picker {
|
||||||
flex: 1;
|
width: 280px;
|
||||||
overflow: auto;
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-radius: var(--radius-card);
|
|
||||||
padding: var(--space-3);
|
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 标题单元格 */
|
.task-page__content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-4);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
.title-cell {
|
.title-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-1);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 操作按钮样式 */
|
.text-muted {
|
||||||
.action-btn-preview {
|
color: var(--color-gray-400);
|
||||||
color: var(--color-primary);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--color-primary-hover, var(--color-blue-600));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn-download {
|
.action-btn {
|
||||||
color: var(--color-success);
|
&--primary { color: var(--color-primary-500); &:hover { color: var(--color-primary-600); } }
|
||||||
|
&--success { color: var(--color-success-500); &:hover { color: var(--color-success-600); } }
|
||||||
&:hover {
|
&--danger { color: var(--color-error-500); &:hover { color: var(--color-error-600); } }
|
||||||
color: #059669;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn-delete {
|
|
||||||
color: var(--color-error);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #dc2626;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 展开内容 */
|
|
||||||
.expanded-content {
|
.expanded-content {
|
||||||
padding: var(--space-3);
|
padding: var(--space-4);
|
||||||
background: var(--color-bg-2);
|
background: var(--color-gray-50);
|
||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-md);
|
||||||
margin: var(--space-2);
|
margin: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-text {
|
.task-text {
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-4);
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin: var(--space-2) 0 0 0;
|
margin: var(--space-2) 0 0;
|
||||||
padding: var(--space-2);
|
padding: var(--space-3);
|
||||||
background: var(--color-surface);
|
background: var(--color-gray-100);
|
||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-md);
|
||||||
line-height: 1.6;
|
line-height: var(--line-height-base);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-results {
|
.task-results {
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-4);
|
||||||
|
|
||||||
.result-header {
|
.result-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.result-count {
|
.result-count {
|
||||||
font-size: 12px;
|
font-size: var(--font-size-xs);
|
||||||
color: var(--color-text-3, #8c8c8c);
|
color: var(--color-gray-500);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-list {
|
.result-list {
|
||||||
@@ -562,72 +367,20 @@ onMounted(() => {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
|
|
||||||
.result-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: var(--space-1) var(--space-2);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border-radius: var(--radius-card);
|
|
||||||
font-size: 13px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-preview-btn {
|
|
||||||
color: var(--color-primary);
|
|
||||||
padding: 0;
|
|
||||||
height: auto;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--color-primary-hover, var(--color-blue-600));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-download-btn {
|
|
||||||
color: var(--color-success);
|
|
||||||
padding: 0;
|
|
||||||
height: auto;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #059669;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.processing-tip {
|
.result-item {
|
||||||
color: var(--color-text-3);
|
display: flex;
|
||||||
font-size: 12px;
|
align-items: center;
|
||||||
}
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background: var(--color-gray-100);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: box-shadow var(--duration-fast) var(--ease-out);
|
||||||
|
|
||||||
.task-error {
|
&:hover {
|
||||||
margin-bottom: var(--space-2);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 确保按钮内的图标和文字对齐 */
|
|
||||||
: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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 预览模态框样式 */
|
|
||||||
.preview-modal {
|
|
||||||
:deep(.ant-modal-body) {
|
|
||||||
padding: var(--space-3);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,6 +391,12 @@ onMounted(() => {
|
|||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-video {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 600px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
.preview-loading {
|
.preview-loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -645,5 +404,16 @@ onMounted(() => {
|
|||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 桌面端样式优化 */
|
:deep(.ant-table-tbody > tr > td) {
|
||||||
|
padding: var(--space-3) var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-table-thead > tr > th) {
|
||||||
|
background: var(--color-gray-50);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-btn .anticon) {
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ onMounted(async () => {
|
|||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-label">剩余积分</div>
|
<div class="stat-label">剩余积分</div>
|
||||||
<div class="stat-value">{{ formatCredits(userStore.remainingPoints) }}</div>
|
<div class="stat-value">{{ formatCredits(userStore.remainingPoints) }}</div>
|
||||||
<div class="stat-desc">用于AI生成消耗</div>
|
<div class="stat-desc">用于生成消耗</div>
|
||||||
</div>
|
</div>
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
|
|||||||
Reference in New Issue
Block a user