feat: 优化

This commit is contained in:
2026-03-16 01:13:32 +08:00
parent cd1382533c
commit 8fdce94c47
14 changed files with 1366 additions and 1088 deletions

View File

@@ -208,6 +208,53 @@ background: var(--primary) /* 主色 */
border-color: var(--border) /* 边框 */
```
## 迁移后检查
### Step 5: 现代化与一致性审查
迁移完成后,必须进行设计审查:
**现代化检查**
- [ ] Tabs 是否使用胶囊式设计(非底部粗线)?
- [ ] 表单间距是否宽松space-y-6
- [ ] 卡片阴影是否克制shadow-sm
- [ ] 边框是否避免重叠?
- [ ] 颜色是否有活力(非灰暗)?
- [ ] 按钮是否有圆角rounded-lg
- [ ] 表格是否简洁无斑马纹?
- [ ] 整体是否有呼吸感?
**一致性检查**
- [ ] 相似页面布局是否一致?
- [ ] 同类组件样式是否统一?
- [ ] 间距规范是否遵循4px 递进)?
- [ ] 颜色使用是否语义化?
- [ ] 字体大小是否有层级?
### Step 6: 业务功能验证
确保迁移没有破坏现有功能:
**交互检查**
- [ ] 表单提交是否正常?
- [ ] 按钮点击事件是否触发?
- [ ] 弹窗/对话框是否正常打开关闭?
- [ ] 下拉选择是否正常工作?
- [ ] 分页是否正常?
- [ ] 搜索筛选是否正常?
**数据检查**
- [ ] 数据绑定是否正常v-model
- [ ] 列表渲染是否正常?
- [ ] 条件渲染是否正常?
- [ ] 数据加载状态是否显示?
**边界情况**
- [ ] 空状态是否显示正常?
- [ ] 错误状态是否处理?
- [ ] 加载状态是否显示?
- [ ] 禁用状态是否正常?
## 检查清单
迁移完成后验证:
@@ -216,4 +263,6 @@ border-color: var(--border) /* 边框 */
- [ ] 图标已迁移到 Iconify
- [ ] message/notification 已迁移到 Sonner
- [ ] 样式使用 Tailwind 类或 CSS 变量
- [ ] 业务功能测试通过
- [ ] **现代化审查通过**
- [ ] **一致性审查通过**
- [ ] **业务功能验证通过**

View File

@@ -4,21 +4,21 @@ import { cva } from "class-variance-authority"
export { default as Button } from "./Button.vue"
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground hover:bg-primary/90",
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md active:scale-[0.98]",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
"bg-destructive text-white shadow-sm hover:bg-destructive/90 hover:shadow-md active:scale-[0.98]",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-transparent dark:text-foreground dark:hover:bg-accent dark:hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80 dark:bg-secondary/50 dark:text-foreground",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
"hover:bg-accent hover:text-accent-foreground dark:text-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline dark:text-primary",
},
size: {
"default": "h-9 px-4 py-2 has-[>svg]:px-3",

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Check } from "lucide-vue-next"
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<CheckboxRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<CheckboxRoot
v-slot="slotProps"
data-slot="checkbox"
v-bind="forwarded"
:class="
cn('peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
props.class)"
>
<CheckboxIndicator
data-slot="checkbox-indicator"
class="grid place-content-center text-current transition-none"
>
<slot v-bind="slotProps">
<Check class="size-3.5" />
</slot>
</CheckboxIndicator>
</CheckboxRoot>
</template>

View File

@@ -0,0 +1 @@
export { default as Checkbox } from "./Checkbox.vue"

View File

@@ -17,7 +17,7 @@
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.45 0.16 254.604);
--primary: oklch(0.55 0.18 254.604);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0.001 264.695);
--secondary-foreground: oklch(0.205 0.015 264.695);

View File

@@ -1,7 +1,51 @@
<script setup>
import { ref, onMounted, reactive, h } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { EditOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue'
import { ref, onMounted, reactive } from 'vue'
import { Icon } from '@iconify/vue'
import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Spinner } from '@/components/ui/spinner'
import { Checkbox } from '@/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Label } from '@/components/ui/label'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationFirst,
PaginationItem,
PaginationLast,
PaginationNext,
PaginationPrevious
} from '@/components/ui/pagination'
import { UserPromptApi } from '@/api/userPrompt'
import { usePromptStore } from '@/stores/prompt'
import dayjs from 'dayjs'
@@ -13,8 +57,6 @@ const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showTotal: (total) => `${total}`,
})
// 搜索表单
@@ -31,52 +73,14 @@ const editForm = reactive({
content: '',
status: 1,
})
const editFormRef = ref(null)
// 删除确认弹窗
const deleteDialogOpen = ref(false)
const deleteTarget = ref(null)
// Store
const promptStore = usePromptStore()
// 表格列定义
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80,
},
{
title: '名称',
dataIndex: 'name',
key: 'name',
width: 200,
ellipsis: true,
},
{
title: '内容',
dataIndex: 'content',
key: 'content',
ellipsis: true,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
width: 180,
},
{
title: '操作',
key: 'action',
width: 150,
fixed: 'right',
},
]
// 加载数据
async function loadData() {
loading.value = true
@@ -89,7 +93,7 @@ async function loadData() {
}
const response = await UserPromptApi.getUserPromptPage(params)
if (response && (response.code === 0 || response.code === 200)) {
dataSource.value = response.data?.list || []
pagination.total = response.data?.total || 0
@@ -98,7 +102,7 @@ async function loadData() {
}
} catch (error) {
console.error('加载提示词列表失败:', error)
message.error(error?.message || '加载失败,请稍后重试')
toast.error(error?.message || '加载失败,请稍后重试')
} finally {
loading.value = false
}
@@ -141,93 +145,91 @@ function resetEditForm() {
editForm.status = 1
}
// 通用API调用
async function apiCall(apiFunc, param, successMessage, isDelete = false) {
loading.value = true
try {
const response = await apiFunc(param)
if (response && (response.code === 0 || response.code === 200)) {
message.success(successMessage)
if (!isDelete) {
editModalVisible.value = false
}
loadData()
return true
} else {
throw new Error(response?.msg || response?.message || '操作失败')
}
} catch (error) {
console.error('API调用失败:', error)
message.error(error?.message || '操作失败,请稍后重试')
return false
} finally {
loading.value = false
}
}
// 保存(新增/编辑)
async function handleSave() {
try {
await editFormRef.value.validate()
} catch (error) {
console.error('表单验证失败:', error)
if (!editForm.name.trim()) {
toast.error('请输入提示词名称')
return
}
if (!editForm.content.trim()) {
toast.error('请输入提示词内容')
return
}
loading.value = true
const payload = {
name: editForm.name.trim(),
content: editForm.content.trim(),
status: editForm.status,
}
if (editForm.id) {
payload.id = editForm.id
const result = await apiCall(
(data) => UserPromptApi.updateUserPrompt(data),
payload,
'更新成功'
)
// 同步更新 store
if (result && result.data) {
promptStore.updatePromptInList(result.data)
}
} else {
const result = await apiCall(
(data) => UserPromptApi.createUserPrompt(data),
payload,
'创建成功'
)
// 同步更新 store
if (result && result.data) {
promptStore.addPromptToList(result.data)
try {
if (editForm.id) {
payload.id = editForm.id
const response = await UserPromptApi.updateUserPrompt(payload)
if (response && (response.code === 0 || response.code === 200)) {
toast.success('更新成功')
editModalVisible.value = false
loadData()
if (response.data) {
promptStore.updatePromptInList(response.data)
}
} else {
throw new Error(response?.msg || response?.message || '更新失败')
}
} else {
const response = await UserPromptApi.createUserPrompt(payload)
if (response && (response.code === 0 || response.code === 200)) {
toast.success('创建成功')
editModalVisible.value = false
loadData()
if (response.data) {
promptStore.addPromptToList(response.data)
}
} else {
throw new Error(response?.msg || response?.message || '创建失败')
}
}
} catch (error) {
console.error('保存失败:', error)
toast.error(error?.message || '操作失败,请稍后重试')
} finally {
loading.value = false
}
}
// 删除
function handleDelete(record) {
Modal.confirm({
title: '确认删除',
content: `确定要删除提示词"${record.name}"吗?`,
onOk: async () => {
const result = await apiCall(
(id) => UserPromptApi.deleteUserPrompt(id),
record.id,
'删除成功',
true
)
// 同步更新 store
if (result) {
promptStore.removePromptFromList(record.id)
}
},
})
deleteTarget.value = record
deleteDialogOpen.value = true
}
async function confirmDelete() {
if (!deleteTarget.value) return
loading.value = true
try {
const response = await UserPromptApi.deleteUserPrompt(deleteTarget.value.id)
if (response && (response.code === 0 || response.code === 200)) {
toast.success('删除成功')
promptStore.removePromptFromList(deleteTarget.value.id)
deleteDialogOpen.value = false
deleteTarget.value = null
loadData()
} else {
throw new Error(response?.msg || response?.message || '删除失败')
}
} catch (error) {
console.error('删除失败:', error)
toast.error(error?.message || '删除失败,请稍后重试')
} finally {
loading.value = false
}
}
// 分页变化
function handleTableChange(pag) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
function handlePageChange(page) {
pagination.current = page
loadData()
}
@@ -241,172 +243,225 @@ onMounted(() => {
<div class="style-settings-page">
<!-- 筛选条件 -->
<div class="style-settings-page__filters">
<a-space :size="16">
<a-input
v-model:value="searchForm.name"
class="filter-input"
placeholder="搜索提示词名称"
allow-clear
@press-enter="handleSearch"
>
<template #prefix>
<EditOutlined />
</template>
</a-input>
<div class="flex flex-wrap items-center gap-3">
<!-- 搜索框 -->
<div class="relative w-[200px]">
<Icon icon="lucide:search" class="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
v-model="searchForm.name"
placeholder="搜索提示词名称"
class="pl-9"
@keyup.enter="handleSearch"
/>
</div>
<a-select
v-model:value="searchForm.status"
class="filter-select"
placeholder="状态筛选"
allow-clear
@change="handleSearch"
>
<a-select-option :value="1"></a-select-option>
<a-select-option :value="0">禁用</a-select-option>
</a-select>
<!-- 状态筛选 -->
<Select v-model="searchForm.status" @update:model-value="handleSearch">
<SelectTrigger class="w-[140px]">
<SelectValue placeholder="状态筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="1">启用</SelectItem>
<SelectItem :value="0"></SelectItem>
</SelectContent>
</Select>
<a-button type="primary" class="filter-button" @click="handleSearch">
<Button @click="handleSearch">
<Icon icon="lucide:search" class="mr-1 size-4" />
搜索
</a-button>
<a-button class="filter-button" @click="handleReset">
</Button>
<Button variant="outline" @click="handleReset">
<Icon icon="lucide:rotate-ccw" class="mr-1 size-4" />
重置
</a-button>
</Button>
<a-button type="primary" class="filter-button add-btn" @click="handleAdd">
<template #icon>
<PlusOutlined />
</template>
<div class="flex-1" />
<Button @click="handleAdd">
<Icon icon="lucide:plus" class="mr-1 size-4" />
新增提示词
</a-button>
</a-space>
</Button>
</div>
</div>
<!-- 任务列表 -->
<div class="style-settings-page__content">
<a-spin :spinning="loading" tip="加载中...">
<a-table
:data-source="dataSource"
:columns="columns"
:row-key="record => record.id"
:pagination="pagination"
@change="handleTableChange"
:scroll="{ x: 1200 }"
>
<!-- 名称列 -->
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<div class="name-cell">
<strong>{{ record.name }}</strong>
</div>
</template>
<div class="relative min-h-[200px]">
<Spinner v-if="loading" class="absolute inset-0 z-10 m-auto size-8" />
<div class="overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-[80px]">ID</TableHead>
<TableHead class="w-[200px]">名称</TableHead>
<TableHead>内容</TableHead>
<TableHead class="w-[100px]">状态</TableHead>
<TableHead class="w-[180px]">创建时间</TableHead>
<TableHead class="w-[150px] sticky right-0 bg-background">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="record in dataSource" :key="record.id">
<TableCell>{{ record.id }}</TableCell>
<TableCell>
<span class="font-medium">{{ record.name }}</span>
</TableCell>
<TableCell>
<div class="max-w-[400px] truncate text-muted-foreground" :title="record.content">
{{ record.content ? (record.content.length > 100 ? record.content.substring(0, 100) + '...' : record.content) : '-' }}
</div>
</TableCell>
<TableCell>
<Badge :variant="record.status === 1 ? 'success' : 'secondary'">
{{ record.status === 1 ? '启用' : '禁用' }}
</Badge>
</TableCell>
<TableCell>
{{ record.createTime ? dayjs(record.createTime).format('YYYY-MM-DD HH:mm:ss') : '-' }}
</TableCell>
<TableCell>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-primary hover:text-primary/80"
@click="handleEdit(record)"
>
<Icon icon="lucide:pencil" class="mr-1 size-4" />
编辑
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-destructive hover:text-destructive/80 hover:bg-destructive/10"
@click="handleDelete(record)"
>
<Icon icon="lucide:trash-2" class="mr-1 size-4" />
删除
</Button>
</div>
</TableCell>
</TableRow>
<TableRow v-if="dataSource.length === 0 && !loading">
<TableCell colspan="6" class="h-32 text-center text-muted-foreground">
暂无数据
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- 内容列 -->
<template v-else-if="column.key === 'content'">
<div class="content-cell" :title="record.content">
{{ record.content ? (record.content.length > 100 ? record.content.substring(0, 100) + '...' : record.content) : '-' }}
</div>
</template>
<!-- 状态列 -->
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'success' : 'error'">
{{ record.status === 1 ? '启用' : '禁用' }}
</a-tag>
</template>
<!-- 创建时间列 -->
<template v-else-if="column.key === 'createTime'">
{{ record.createTime ? dayjs(record.createTime).format('YYYY-MM-DD HH:mm:ss') : '-' }}
</template>
<!-- 操作列 -->
<template v-else-if="column.key === 'action'">
<a-space>
<a-button
type="link"
size="small"
@click="handleEdit(record)"
class="action-btn-edit"
<!-- 分页 -->
<div v-if="pagination.total > 0" class="flex items-center justify-between py-4 border-t">
<span class="text-sm text-muted-foreground">
{{ pagination.total }} 条记录
</span>
<Pagination
v-slot="{ page }"
:items-per-page="pagination.pageSize"
:total="pagination.total"
:sibling-count="1"
show-edges
:page="pagination.current"
@update:page="handlePageChange"
>
<PaginationContent v-slot="{ items }">
<PaginationFirst @click="handlePageChange(1)" />
<PaginationPrevious @click="handlePageChange(pagination.current - 1)" />
<template v-for="(item, index) in items" :key="index">
<PaginationItem
v-if="item.type === 'page'"
:value="item.value"
as-child
>
<template #icon>
<EditOutlined />
</template>
编辑
</a-button>
<a-button
type="link"
size="small"
@click="handleDelete(record)"
class="action-btn-delete"
>
<template #icon>
<DeleteOutlined />
</template>
删除
</a-button>
</a-space>
</template>
</template>
</a-table>
</a-spin>
<Button
:variant="page === item.value ? 'default' : 'outline'"
size="icon-sm"
class="h-8 w-8"
>
{{ item.value }}
</Button>
</PaginationItem>
<PaginationEllipsis v-else :index="index" />
</template>
<PaginationNext @click="handlePageChange(pagination.current + 1)" />
<PaginationLast @click="handlePageChange(Math.ceil(pagination.total / pagination.pageSize))" />
</PaginationContent>
</Pagination>
</div>
</div>
</div>
<!-- 编辑弹窗 -->
<a-modal
v-model:visible="editModalVisible"
:title="editForm.id ? '编辑提示词' : '新增提示词'"
width="800px"
:footer="null"
class="edit-modal"
>
<a-form
ref="editFormRef"
:model="editForm"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 20 }"
>
<a-form-item
label="名称"
name="name"
:rules="[{ required: true, message: '请输入提示词名称' }]"
>
<a-input v-model:value="editForm.name" placeholder="请输入提示词名称" />
</a-form-item>
<a-form-item
label="内容"
name="content"
:rules="[{ required: true, message: '请输入提示词内容' }]"
>
<a-textarea
v-model:value="editForm.content"
placeholder="请输入提示词内容"
:rows="8"
/>
</a-form-item>
<a-form-item
label="状态"
name="status"
:rules="[{ required: true, message: '请选择状态' }]"
>
<a-radio-group v-model:value="editForm.status">
<a-radio :value="1">启用</a-radio>
<a-radio :value="0">禁用</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
<!-- 弹窗底部按钮 -->
<div class="modal-footer">
<a-space :size="12" class="button-container">
<a-button @click="editModalVisible = false" class="footer-btn">
<Dialog v-model:open="editModalVisible">
<DialogContent class="max-w-[800px]">
<DialogHeader>
<DialogTitle>{{ editForm.id ? '编辑提示词' : '新增提示词' }}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-4">
<div class="space-y-2">
<Label for="name">名称 <span class="text-destructive">*</span></Label>
<Input
id="name"
v-model="editForm.name"
placeholder="请输入提示词名称"
/>
</div>
<div class="space-y-2">
<Label for="content">内容 <span class="text-destructive">*</span></Label>
<Textarea
id="content"
v-model="editForm.content"
placeholder="请输入提示词内容"
:rows="8"
/>
</div>
<div class="space-y-2">
<Label>状态</Label>
<RadioGroup v-model="editForm.status" class="flex gap-4">
<div class="flex items-center space-x-2">
<RadioGroupItem :value="1" id="status-enabled" />
<Label for="status-enabled" class="cursor-pointer">启用</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroupItem :value="0" id="status-disabled" />
<Label for="status-disabled" class="cursor-pointer">禁用</Label>
</div>
</RadioGroup>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="editModalVisible = false">
取消
</a-button>
<a-button type="primary" @click="handleSave" :loading="loading" class="footer-btn">
</Button>
<Button :loading="loading" @click="handleSave">
确定
</a-button>
</a-space>
</div>
</a-modal>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- 删除确认弹窗 -->
<AlertDialog v-model:open="deleteDialogOpen">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除</AlertDialogTitle>
<AlertDialogDescription>
确定要删除提示词"{{ deleteTarget?.name }}"删除后无法恢复
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
:disabled="loading"
class="bg-destructive hover:bg-destructive/90"
@click="confirmDelete"
>
确定删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
@@ -422,178 +477,16 @@ onMounted(() => {
padding: var(--space-3);
background: var(--color-surface);
border-radius: var(--radius-card);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
.filter-input {
width: 200px;
@media (max-width: 1199px) {
width: 160px;
}
@media (max-width: 767px) {
width: 100%;
}
}
.filter-select {
width: 140px;
@media (max-width: 1199px) {
width: 120px;
}
@media (max-width: 767px) {
width: 100%;
}
}
.filter-button {
@media (max-width: 767px) {
min-width: auto;
}
&.add-btn {
margin-left: auto;
@media (max-width: 767px) {
margin-left: 0;
width: 100%;
}
}
}
box-shadow: var(--shadow-sm);
}
&__content {
flex: 1;
overflow: auto;
overflow: hidden;
background: var(--color-surface);
border-radius: var(--radius-card);
padding: var(--space-3);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
}
}
/* 表格单元格样式 */
.name-cell {
font-weight: 500;
color: var(--color-text);
}
.content-cell {
color: var(--color-text-secondary);
font-size: 13px;
line-height: 1.5;
}
/* 操作按钮样式 */
.action-btn-edit {
color: var(--color-primary);
&:hover {
color: var(--color-primary-hover, var(--color-primary-600));
}
}
.action-btn-delete {
color: var(--color-error);
&:hover {
color: #dc2626;
}
}
/* 表格样式 */
:deep(.ant-table-tbody > tr > td) {
padding: 12px 8px;
}
:deep(.ant-table-thead > tr > th) {
background: var(--color-bg-2);
font-weight: 600;
}
/* 编辑弹窗样式 */
.edit-modal {
:deep(.ant-modal-body) {
padding: var(--space-3);
}
:deep(.ant-form-item-label > label) {
color: var(--color-text);
font-weight: 500;
}
:deep(.ant-input),
:deep(.ant-input-number),
:deep(.ant-select),
:deep(.ant-radio-group) {
border-radius: var(--radius-tag);
}
:deep(.ant-input:focus),
:deep(.ant-input-focused) {
border-color: var(--color-border-focus);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
.modal-footer {
display: flex;
justify-content: flex-end;
padding-top: var(--space-3);
border-top: 1px solid var(--color-border);
margin-top: var(--space-3);
.button-container {
display: flex;
align-items: center;
}
.footer-btn {
min-width: 88px;
text-align: center;
display: flex;
justify-content: center;
align-items: center;
}
}
}
/* 响应式优化 */
@media (max-width: 767px) {
.style-settings-page {
padding: var(--space-2);
&__filters {
padding: var(--space-2);
.ant-space {
width: 100%;
flex-wrap: wrap;
.ant-input,
.ant-select,
.ant-btn {
width: calc(50% - var(--space-1)) !important;
}
}
}
&__content {
padding: var(--space-2);
}
}
:deep(.ant-table-tbody > tr > td) {
padding: 8px 4px;
}
.action-btn-edit,
.action-btn-delete {
font-size: 12px;
padding: 0 4px;
box-shadow: var(--shadow-sm);
}
}
</style>

View File

@@ -1,63 +1,69 @@
<template>
<a-space>
<div class="flex items-center gap-2">
<!-- 预览按钮 -->
<a-button
<Button
v-if="canPreview"
type="link"
size="small"
variant="ghost"
size="sm"
class="h-7 px-2 text-primary hover:text-primary/80"
@click="handlePreview"
>
<template #icon>
<EyeOutlined />
</template>
<Icon icon="lucide:eye" class="size-4" />
预览
</a-button>
</Button>
<!-- 下载按钮 -->
<a-button
<Button
v-if="canDownload"
type="primary"
size="small"
size="sm"
class="h-7 px-2"
@click="handleDownload"
>
<template #icon>
<DownloadOutlined />
</template>
<Icon icon="lucide:download" class="size-4" />
下载
</a-button>
</Button>
<!-- 取消按钮 -->
<a-button
<Button
v-if="canCancel"
size="small"
variant="outline"
size="sm"
class="h-7 px-2"
@click="handleCancel"
>
取消
</a-button>
</Button>
<!-- 重试按钮 -->
<a-button
<Button
v-if="canRetry"
size="small"
variant="outline"
size="sm"
class="h-7 px-2"
@click="handleRetry"
>
重试
</a-button>
</Button>
<!-- 删除按钮 -->
<a-button size="small" danger @click="handleDelete">删除</a-button>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-destructive hover:text-destructive/80 hover:bg-destructive/10"
@click="handleDelete"
>
删除
</Button>
<!-- 插槽用于自定义操作 -->
<slot name="extra" :task="task"></slot>
</a-space>
</div>
</template>
<script setup>
import { computed } from 'vue'
import {
EyeOutlined,
DownloadOutlined
} from '@ant-design/icons-vue'
import { Icon } from '@iconify/vue'
import { Button } from '@/components/ui/button'
import { useTaskOperations } from '@/views/system/task-management/composables/useTaskOperations'
// Props
@@ -148,9 +154,3 @@ const handleDelete = () => {
operations.handleDelete(props.task.id)
}
</script>
<style scoped lang="less">
:deep(.ant-btn .anticon) {
line-height: 0;
}
</style>

View File

@@ -1,68 +1,83 @@
<template>
<div class="task-filter-bar">
<a-space>
<div class="flex flex-wrap items-center gap-3">
<!-- 状态筛选 -->
<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>
<Select v-model="localFilters.status" @update:model-value="handleChange">
<SelectTrigger class="w-[120px]">
<SelectValue placeholder="任务状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="pending">待处理</SelectItem>
<SelectItem value="running">处理中</SelectItem>
<SelectItem value="success">已完成</SelectItem>
<SelectItem value="failed">失败</SelectItem>
<SelectItem value="canceled">取消</SelectItem>
</SelectContent>
</Select>
<!-- 关键词搜索 -->
<a-input
v-model:value="localFilters.keyword"
:placeholder="placeholder"
style="width: 200px"
allow-clear
@press-enter="handleChange"
>
<template #prefix>
<SearchOutlined />
</template>
</a-input>
<div class="relative w-[200px]">
<Icon icon="lucide:search" class="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
v-model="localFilters.keyword"
:placeholder="placeholder"
class="pl-9"
@keyup.enter="handleChange"
/>
</div>
<!-- 日期范围选择 -->
<a-range-picker
v-model:value="localFilters.dateRange"
style="width: 300px"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
:placeholder="['开始日期', '结束日期']"
@change="handleChange"
/>
<Popover v-model:open="datePickerOpen">
<PopoverTrigger as-child>
<Button variant="outline" class="w-[280px] justify-start font-normal">
<Icon icon="lucide:calendar" class="mr-2 size-4" />
<template v-if="localFilters.dateRange?.length === 2">
{{ localFilters.dateRange[0] }} {{ localFilters.dateRange[1] }}
</template>
<template v-else>
<span class="text-muted-foreground">选择日期范围</span>
</template>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0" align="start">
<RangeCalendar
v-model="selectedDateRange"
:number-of-months="2"
@update:model-value="handleDateSelect"
/>
</PopoverContent>
</Popover>
<!-- 查询按钮 -->
<a-button type="primary" @click="handleChange">
<Button @click="handleChange">
<Icon icon="lucide:search" class="mr-1 size-4" />
查询
</a-button>
</Button>
<!-- 重置按钮 -->
<a-button @click="handleReset">
<Button variant="outline" @click="handleReset">
<Icon icon="lucide:rotate-ccw" class="mr-1 size-4" />
重置
</a-button>
</Button>
<!-- 插槽用于扩展其他筛选条件 -->
<slot name="extra"></slot>
</a-space>
</div>
</div>
</template>
<script setup>
import { ref, watch, toRefs } from 'vue'
import { SearchOutlined } from '@ant-design/icons-vue'
import { ref, watch } from 'vue'
import { Icon } from '@iconify/vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { RangeCalendar } from '@/components/ui/range-calendar'
// Props
const props = defineProps({
// 筛选条件
filters: {
type: Object,
default: () => ({
@@ -71,12 +86,10 @@ const props = defineProps({
dateRange: null
})
},
// 占位符文本
placeholder: {
type: String,
default: '搜索关键词'
},
// 是否在值变化时立即触发
immediate: {
type: Boolean,
default: true
@@ -86,14 +99,20 @@ const props = defineProps({
// Emits
const emit = defineEmits(['update:filters', 'change', 'reset'])
// 日期选择器开关
const datePickerOpen = ref(false)
// 内部筛选条件副本
const localFilters = ref({
status: '',
status: 'all',
keyword: '',
dateRange: null,
...props.filters
})
// RangeCalendar 使用的日期对象
const selectedDateRange = ref(null)
// 监听 props 变化,更新内部副本
watch(
() => props.filters,
@@ -106,38 +125,55 @@ watch(
{ deep: true }
)
// 处理日期选择
const handleDateSelect = (value) => {
if (value?.start && value?.end) {
const start = formatDateToLocal(value.start)
const end = formatDateToLocal(value.end)
localFilters.value.dateRange = [start, end]
datePickerOpen.value = false
handleChange()
}
}
// 格式化日期为本地字符串 YYYY-MM-DD
const formatDateToLocal = (date) => {
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
// 处理变化
const handleChange = () => {
// 更新父组件的 v-model
emit('update:filters', { ...localFilters.value })
// 触发 change 事件
emit('change', { ...localFilters.value })
const outputFilters = { ...localFilters.value }
// 将 'all' 转换为空字符串供外部使用
if (outputFilters.status === 'all') {
outputFilters.status = ''
}
emit('update:filters', outputFilters)
emit('change', outputFilters)
}
// 处理重置
const handleReset = () => {
localFilters.value = {
status: '',
status: 'all',
keyword: '',
dateRange: null
}
// 更新父组件的 v-model
emit('update:filters', { ...localFilters.value })
// 触发重置事件
selectedDateRange.value = null
emit('update:filters', { status: '', keyword: '', dateRange: null })
emit('reset')
}
</script>
<style scoped>
<style scoped lang="less">
.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;
padding: var(--space-4);
background: var(--color-bg-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<span class="task-status-tag" :class="statusClass">
<template v-if="showIcon && isRunning">
<LoadingOutlined class="task-status-tag__icon" :spin="true" />
<Icon icon="lucide:loader-2" class="task-status-tag__icon animate-spin" />
</template>
{{ text }}
</span>
@@ -9,7 +9,7 @@
<script setup>
import { computed } from 'vue'
import { LoadingOutlined } from '@ant-design/icons-vue'
import { Icon } from '@iconify/vue'
const props = defineProps({
status: { type: String, required: true },

View File

@@ -24,7 +24,7 @@ export function useTaskList(fetchApi, options = {}) {
// 筛选条件
const filters = reactive({
status: '',
status: 'all',
keyword: '',
dateRange: null,
...options.defaultFilters
@@ -62,8 +62,8 @@ export function useTaskList(fetchApi, options = {}) {
pageSize: pageSize.value
}
// 添加筛选条件
if (filters.status) {
// 添加筛选条件'all' 表示全部,不传给后端)
if (filters.status && filters.status !== 'all') {
params.status = filters.status
}
if (filters.keyword) {
@@ -108,7 +108,7 @@ export function useTaskList(fetchApi, options = {}) {
// 重置筛选条件
const handleResetFilters = () => {
filters.status = ''
filters.status = 'all'
filters.keyword = ''
filters.dateRange = null
current.value = 1

View File

@@ -2,143 +2,273 @@
<div class="task-page">
<!-- 筛选条件 -->
<div class="task-page__filters">
<a-space :size="16">
<a-select
v-model:value="filters.status"
class="filter-select"
placeholder="任务状态"
allow-clear
@change="handleFilterChange"
>
<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>
<div class="flex flex-wrap items-center gap-3">
<!-- 状态筛选 -->
<Select v-model="filters.status" @update:model-value="handleFilterChange">
<SelectTrigger class="w-[120px]">
<SelectValue placeholder="任务状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="pending">待处理</SelectItem>
<SelectItem value="running">处理</SelectItem>
<SelectItem value="success">已完成</SelectItem>
<SelectItem value="failed">失败</SelectItem>
<SelectItem value="canceled">已取消</SelectItem>
</SelectContent>
</Select>
<a-input
v-model:value="filters.keyword"
class="filter-input"
placeholder="搜索任务名称"
allow-clear
@press-enter="handleFilterChange"
>
<template #prefix><SearchOutlined /></template>
</a-input>
<!-- 关键词搜索 -->
<div class="relative w-[200px]">
<Icon icon="lucide:search" class="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
v-model="filters.keyword"
placeholder="搜索任务名称"
class="pl-9"
@keyup.enter="handleFilterChange"
/>
</div>
<a-range-picker
v-model:value="filters.dateRange"
class="filter-date-picker"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
:placeholder="['开始日期', '结束日期']"
@change="handleFilterChange"
/>
<!-- 日期范围选择 -->
<Popover v-model:open="datePickerOpen">
<PopoverTrigger as-child>
<Button variant="outline" class="w-[280px] justify-start font-normal">
<Icon icon="lucide:calendar" class="mr-2 size-4" />
<template v-if="filters.dateRange?.length === 2">
{{ filters.dateRange[0] }} {{ filters.dateRange[1] }}
</template>
<template v-else>
<span class="text-muted-foreground">选择日期范围</span>
</template>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0" align="start">
<RangeCalendar
v-model="selectedDateRange"
:number-of-months="2"
@update:model-value="handleDateSelect"
/>
</PopoverContent>
</Popover>
<a-button type="primary" @click="handleFilterChange">查询</a-button>
<a-button @click="handleResetFilters">重置</a-button>
</a-space>
<Button @click="handleFilterChange">
<Icon icon="lucide:search" class="mr-1 size-4" />
查询
</Button>
<Button variant="outline" @click="handleResetFilters">
<Icon icon="lucide:rotate-ccw" class="mr-1 size-4" />
重置
</Button>
</div>
</div>
<!-- 任务列表 -->
<div class="task-page__content">
<!-- 批量操作栏 -->
<div v-if="selectedRowKeys.length > 0" class="batch-actions">
<a-alert :message="`已选中 ${selectedRowKeys.length} 项`" type="info" show-icon>
<template #action>
<a-button size="small" danger @click="confirmBatchDelete">
<DeleteOutlined /> 批量删除
</a-button>
</template>
</a-alert>
<Alert class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Icon icon="lucide:info" class="size-4" />
<span>已选中 {{ selectedRowKeys.length }} </span>
</div>
<Button variant="destructive" size="sm" @click="confirmBatchDelete">
<Icon icon="lucide:trash-2" class="mr-1 size-4" />
批量删除
</Button>
</Alert>
</div>
<a-spin :spinning="loading" tip="加载中...">
<a-table
:data-source="list"
:columns="columns"
:row-key="record => record.id"
:pagination="paginationConfig"
:row-selection="rowSelection"
:scroll="{ x: 1000 }"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<!-- 任务名称列 -->
<template v-if="column.key === 'taskName'">
<strong>{{ record.taskName }}</strong>
</template>
<div class="relative">
<div v-if="loading" class="absolute inset-0 flex items-center justify-center bg-background/50 z-10">
<Spinner class="size-8" />
</div>
<!-- 状态列 -->
<template v-else-if="column.key === 'status'">
<TaskStatusTag :status="record.status" />
</template>
<div class="overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-[50px]">
<Checkbox
:checked="isAllSelected"
@update:checked="handleSelectAll"
/>
</TableHead>
<TableHead class="w-[80px]">ID</TableHead>
<TableHead class="min-w-[200px]">任务名称</TableHead>
<TableHead class="w-[100px]">状态</TableHead>
<TableHead class="w-[150px]">进度</TableHead>
<TableHead class="w-[180px]">创建时间</TableHead>
<TableHead class="w-[180px] sticky right-0 bg-background">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="record in list" :key="record.id">
<TableCell>
<Checkbox
:checked="selectedRowKeys.includes(record.id)"
@update:checked="handleSelectRow(record.id)"
/>
</TableCell>
<TableCell>{{ record.id }}</TableCell>
<TableCell>
<span class="font-medium">{{ record.taskName }}</span>
</TableCell>
<TableCell>
<TaskStatusTag :status="record.status" />
</TableCell>
<TableCell>
<div class="w-[120px]">
<Progress :model-value="record.progress" class="h-2" />
</div>
</TableCell>
<TableCell>{{ formatDate(record.createTime) }}</TableCell>
<TableCell class="sticky right-0 bg-background">
<div class="flex items-center gap-2">
<Button
v-if="isStatus(record.status, 'success')"
variant="ghost"
size="sm"
class="h-7 px-2 text-primary hover:text-primary/80"
@click="openVideoUrl(record)"
>
<Icon icon="lucide:play-circle" class="mr-1 size-4" />
预览
</Button>
<Button
v-if="isStatus(record.status, 'success')"
variant="ghost"
size="sm"
class="h-7 px-2 text-success hover:text-success/80"
@click="openVideoUrl(record)"
>
<Icon icon="lucide:download" class="mr-1 size-4" />
下载
</Button>
<Button
v-if="isStatus(record.status, 'running')"
variant="ghost"
size="sm"
class="h-7 px-2"
@click="handleCancel(record.id)"
>
取消
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-destructive hover:text-destructive/80 hover:bg-destructive/10"
@click="handleDelete(record.id)"
>
删除
</Button>
</div>
</TableCell>
</TableRow>
<TableRow v-if="list.length === 0 && !loading">
<TableCell colspan="7" class="h-32 text-center text-muted-foreground">
暂无数据
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- 进度列 -->
<template v-else-if="column.key === 'progress'">
<div class="progress-cell">
<a-progress
:percent="record.progress"
:status="PROGRESS_STATUS[record.status?.toLowerCase()] || 'normal'"
size="small"
:show-info="false"
/>
</div>
</template>
<!-- 创建时间列 -->
<template v-else-if="column.key === 'createTime'">
{{ formatDate(record.createTime) }}
</template>
<!-- 操作列 -->
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button
v-if="isStatus(record.status, 'success')"
type="link"
size="small"
class="action-btn action-btn--primary"
@click="openVideoUrl(record)"
<!-- 分页 -->
<div v-if="paginationConfig.total > 0" class="flex items-center justify-between py-4 border-t">
<span class="text-sm text-muted-foreground">
{{ paginationConfig.total }} 条记录
</span>
<Pagination
v-slot="{ page }"
:items-per-page="paginationConfig.pageSize"
:total="paginationConfig.total"
:sibling-count="1"
show-edges
:page="paginationConfig.current"
@update:page="handlePageChange"
>
<PaginationContent v-slot="{ items }">
<PaginationFirst @click="handlePageChange(1)" />
<PaginationPrevious @click="handlePageChange(paginationConfig.current - 1)" />
<template v-for="(item, index) in items" :key="index">
<PaginationItem
v-if="item.type === 'page'"
:value="item.value"
as-child
>
<PlayCircleOutlined /> 预览
</a-button>
<a-button
v-if="isStatus(record.status, 'success')"
type="link"
size="small"
class="action-btn action-btn--success"
@click="openVideoUrl(record)"
>
<DownloadOutlined /> 下载
</a-button>
<a-button
v-if="isStatus(record.status, 'running')"
type="link"
size="small"
@click="handleCancel(record.id)"
>
取消
</a-button>
<a-button type="link" size="small" class="action-btn action-btn--danger" @click="handleDelete(record.id)">删除</a-button>
</a-space>
</template>
</template>
</a-table>
</a-spin>
<Button
:variant="page === item.value ? 'default' : 'outline'"
size="icon-sm"
class="h-8 w-8"
>
{{ item.value }}
</Button>
</PaginationItem>
<PaginationEllipsis v-else :index="index" />
</template>
<PaginationNext @click="handlePageChange(paginationConfig.current + 1)" />
<PaginationLast @click="handlePageChange(Math.ceil(paginationConfig.total / paginationConfig.pageSize))" />
</PaginationContent>
</Pagination>
</div>
</div>
</div>
<!-- 确认删除对话框 -->
<AlertDialog :open="deleteDialogOpen" @update:open="deleteDialogOpen = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除</AlertDialogTitle>
<AlertDialogDescription>
确定要删除选中的任务吗此操作无法撤销
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel @click="deleteDialogOpen = false">取消</AlertDialogCancel>
<AlertDialogAction @click="handleBatchDelete" :disabled="deleteLoading">
<Spinner v-if="deleteLoading" class="mr-2 size-4" />
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { SearchOutlined, DownloadOutlined, DeleteOutlined, PlayCircleOutlined } from '@ant-design/icons-vue'
import { ref, computed, onMounted } from 'vue'
import { Icon } from '@iconify/vue'
import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { RangeCalendar } from '@/components/ui/range-calendar'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Progress } from '@/components/ui/progress'
import { Alert } from '@/components/ui/alert'
import { Spinner } from '@/components/ui/spinner'
import { Checkbox } from '@/components/ui/checkbox'
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationFirst,
PaginationItem,
PaginationLast,
PaginationNext,
PaginationPrevious
} from '@/components/ui/pagination'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { getDigitalHumanTaskPage, cancelTask, deleteTask } from '@/api/digitalHuman'
import { formatDate } from '@/utils/file'
import { useTaskList } from '@/views/system/task-management/composables/useTaskList'
@@ -146,25 +276,57 @@ import { useTaskOperations } from '@/views/system/task-management/composables/us
import { useTaskPolling } from '@/views/system/task-management/composables/useTaskPolling'
import TaskStatusTag from '@/views/system/task-management/components/TaskStatusTag.vue'
// 进度状态映射
const PROGRESS_STATUS = {
pending: 'normal',
running: 'active',
success: 'success',
failed: 'exception',
canceled: 'normal'
}
// 日期选择器开关
const datePickerOpen = ref(false)
const selectedDateRange = ref(null)
// Composables
const { loading, list, filters, paginationConfig, fetchList, handleFilterChange, handleResetFilters, handleTableChange } = useTaskList(getDigitalHumanTaskPage)
const { handleDelete, handleCancel } = useTaskOperations({ deleteApi: deleteTask, cancelApi: cancelTask }, fetchList)
// 初始化 filters.status 为 'all'
if (!filters.status) {
filters.status = 'all'
}
// 包装 handleFilterChange 处理 'all' 值
const wrappedHandleFilterChange = () => {
const params = { ...filters }
if (params.status === 'all') {
params.status = undefined
}
handleFilterChange()
}
const { handleDelete: deleteTaskById, handleCancel } = useTaskOperations({ deleteApi: deleteTask, cancelApi: cancelTask }, fetchList)
useTaskPolling(getDigitalHumanTaskPage, { onTaskUpdate: fetchList })
// 表格选择
const selectedRowKeys = ref([])
const rowSelection = {
selectedRowKeys,
onChange: (keys) => { selectedRowKeys.value = keys }
const isAllSelected = computed(() => {
return list.value.length > 0 && list.value.every(item => selectedRowKeys.value.includes(item.id))
})
const handleSelectAll = (checked) => {
if (checked) {
selectedRowKeys.value = list.value.map(item => item.id)
} else {
selectedRowKeys.value = []
}
}
const handleSelectRow = (id) => {
const index = selectedRowKeys.value.indexOf(id)
if (index > -1) {
selectedRowKeys.value.splice(index, 1)
} else {
selectedRowKeys.value.push(id)
}
}
// 分页处理
const handlePageChange = (page) => {
paginationConfig.current = page
fetchList()
}
// 状态判断
@@ -173,48 +335,61 @@ const isStatus = (status, target) => status === target || status === target.toUp
// 打开视频链接(预览/下载共用)
const openVideoUrl = (record) => {
if (!record.resultVideoUrl) {
message.warning('该任务暂无视频结果,请稍后再试')
toast.warning('该任务暂无视频结果,请稍后再试')
return
}
window.open(record.resultVideoUrl, '_blank')
}
// 删除对话框
const deleteDialogOpen = ref(false)
const deleteLoading = ref(false)
// 确认批量删除
const confirmBatchDelete = () => {
deleteDialogOpen.value = true
}
// 批量删除
const handleBatchDelete = async () => {
if (!selectedRowKeys.value.length) return
deleteLoading.value = true
try {
for (const id of selectedRowKeys.value) await deleteTask(id)
message.success(`成功删除 ${selectedRowKeys.value.length} 个任务`)
toast.success(`成功删除 ${selectedRowKeys.value.length} 个任务`)
selectedRowKeys.value = []
deleteDialogOpen.value = false
await fetchList()
} catch (e) {
console.error('批量删除失败:', e)
message.error('批量删除失败,请重试')
toast.error('批量删除失败,请重试')
} finally {
deleteLoading.value = false
}
}
// 确认批量删除
const confirmBatchDelete = () => {
Modal.confirm({
title: '确认删除',
content: '确定要删除选中的任务吗?',
okText: '确定',
cancelText: '取消',
okType: 'danger',
onOk: handleBatchDelete
})
// 处理单个删除
const handleDelete = (id) => {
selectedRowKeys.value = [id]
confirmBatchDelete()
}
// 表格列定义
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: 200, fixed: 'right' }
]
// 处理日期选择
const handleDateSelect = (value) => {
if (value?.start && value?.end) {
const formatDate = (date) => {
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
filters.dateRange = [formatDate(value.start), formatDate(value.end)]
datePickerOpen.value = false
handleFilterChange()
}
}
onMounted(fetchList)
</script>
@@ -233,15 +408,6 @@ onMounted(fetchList)
background: var(--color-bg-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
.filter-select,
.filter-input {
width: 200px;
}
.filter-date-picker {
width: 280px;
}
}
.task-page__content {
@@ -257,42 +423,5 @@ onMounted(fetchList)
.batch-actions {
margin-bottom: var(--space-4);
+ :deep(.ant-spin) {
flex: 1;
.ant-spin-container {
flex: 1;
display: flex;
flex-direction: column;
}
.ant-table {
flex: 1;
}
}
}
.progress-cell {
min-width: 100px;
}
.action-btn {
&--primary { color: var(--color-primary-500); &:hover { color: var(--color-primary-600); } }
&--success { color: var(--color-success-500); &:hover { color: var(--color-success-600); } }
&--danger { color: var(--color-error-500); &:hover { color: var(--color-error-600); } }
}
: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>

View File

@@ -2,18 +2,21 @@
<div class="task-layout">
<!-- 顶部Tab栏 -->
<div class="task-layout__header">
<div class="task-tabs">
<div
v-for="item in NAV_ITEMS"
:key="item.type"
class="task-tab"
:class="{ 'is-active': currentType === item.type }"
@click="currentType = item.type"
>
<component :is="item.icon" class="tab-icon" />
<span>{{ item.label }}</span>
</div>
</div>
<Tabs v-model:model-value="currentType" class="w-full">
<TabsList class="h-12 bg-transparent p-0 gap-1">
<TabsTrigger
v-for="item in NAV_ITEMS"
:key="item.type"
:value="item.type"
class="h-10 px-4 gap-2 rounded-lg transition-all
data-[state=active]:bg-primary data-[state=active]:text-white data-[state=active]:shadow-sm
data-[state=inactive]:text-muted-foreground data-[state=inactive]:hover:text-foreground data-[state=inactive]:hover:bg-muted"
>
<Icon :icon="item.icon" class="size-4" />
<span>{{ item.label }}</span>
</TabsTrigger>
</TabsList>
</Tabs>
</div>
<!-- 内容区 -->
@@ -26,8 +29,9 @@
</template>
<script setup>
import { ref, computed, defineAsyncComponent, markRaw, onMounted, watch } from 'vue'
import { VideoCameraOutlined, UserOutlined, FormOutlined } from '@ant-design/icons-vue'
import { ref, computed, defineAsyncComponent, markRaw, watch } from 'vue'
import { Icon } from '@iconify/vue'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
const STORAGE_KEY = 'task-management-active-tab'
@@ -42,19 +46,19 @@ const NAV_ITEMS = [
{
type: 'mix-task',
label: '混剪视频任务',
icon: VideoCameraOutlined,
icon: 'lucide:video',
component: markRaw(defineAsyncComponent(() => import('../mix-task/index.vue')))
},
{
type: 'digital-human-task',
label: '数字人视频任务',
icon: UserOutlined,
icon: 'lucide:user',
component: markRaw(defineAsyncComponent(() => import('../digital-human-task/index.vue')))
},
{
type: 'style-task',
label: '风格任务',
icon: FormOutlined,
icon: 'lucide:palette',
component: markRaw(defineAsyncComponent(() => import('../../../task-center/BenchmarkTaskList.vue')))
}
]
@@ -78,50 +82,10 @@ const currentComponent = computed(() => {
.task-layout__header {
flex-shrink: 0;
padding: 0 var(--space-4);
border-bottom: 1px solid var(--color-gray-200);
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-card);
}
.task-tabs {
display: flex;
gap: var(--space-1);
height: 48px;
}
.task-tab {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-4);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
color: var(--color-gray-500);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all var(--duration-fast) var(--ease-out);
user-select: none;
&:hover {
color: var(--color-primary-500);
}
&.is-active {
color: var(--color-primary-500);
border-bottom-color: var(--color-primary-500);
.tab-icon {
color: var(--color-primary-500);
}
}
.tab-icon {
width: 16px;
height: 16px;
color: var(--color-gray-400);
transition: color var(--duration-fast) var(--ease-out);
}
}
.task-layout__content {
flex: 1;
overflow: auto;

View File

@@ -2,221 +2,376 @@
<div class="task-page">
<!-- 筛选条件 -->
<div class="task-page__filters">
<a-space :size="16">
<a-select
v-model:value="filters.status"
class="filter-select"
placeholder="任务状态"
allow-clear
@change="handleFilterChange"
>
<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>
<div class="flex flex-wrap items-center gap-3">
<!-- 状态筛选 -->
<Select v-model="filters.status" @update:model-value="handleFilterChange">
<SelectTrigger class="w-[120px]">
<SelectValue placeholder="任务状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="pending">待处理</SelectItem>
<SelectItem value="running">处理</SelectItem>
<SelectItem value="success">已完成</SelectItem>
<SelectItem value="failed">失败</SelectItem>
</SelectContent>
</Select>
<a-input
v-model:value="filters.keyword"
class="filter-input"
placeholder="搜索标题"
allow-clear
@press-enter="handleFilterChange"
>
<template #prefix><SearchOutlined /></template>
</a-input>
<!-- 关键词搜索 -->
<div class="relative w-[200px]">
<Icon icon="lucide:search" class="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
v-model="filters.keyword"
placeholder="搜索标题"
class="pl-9"
@keyup.enter="handleFilterChange"
/>
</div>
<a-range-picker
v-model:value="filters.dateRange"
class="filter-date-picker"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
:placeholder="['开始日期', '结束日期']"
@change="handleFilterChange"
/>
<!-- 日期范围选择 -->
<Popover v-model:open="datePickerOpen">
<PopoverTrigger as-child>
<Button variant="outline" class="w-[280px] justify-start font-normal">
<Icon icon="lucide:calendar" class="mr-2 size-4" />
<template v-if="filters.dateRange?.length === 2">
{{ filters.dateRange[0] }} {{ filters.dateRange[1] }}
</template>
<template v-else>
<span class="text-muted-foreground">选择日期范围</span>
</template>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0" align="start">
<RangeCalendar
v-model="selectedDateRange"
:number-of-months="2"
@update:model-value="handleDateSelect"
/>
</PopoverContent>
</Popover>
<a-button type="primary" @click="handleFilterChange">查询</a-button>
<a-button @click="handleResetFilters">重置</a-button>
</a-space>
<Button @click="handleFilterChange">
<Icon icon="lucide:search" class="mr-1 size-4" />
查询
</Button>
<Button variant="outline" @click="handleResetFilters">
<Icon icon="lucide:rotate-ccw" class="mr-1 size-4" />
重置
</Button>
</div>
</div>
<!-- 任务列表 -->
<div class="task-page__content">
<a-spin :spinning="loading" tip="加载中...">
<a-table
:data-source="list"
:columns="columns"
:row-key="record => record.id"
:pagination="paginationConfig"
:expanded-row-keys="expandedRowKeys"
:row-selection="rowSelection"
:scroll="{ x: 'max-content' }"
@change="handleTableChange"
@expandedRowsChange="handleExpandedRowsChange"
<!-- 批量操作工具栏 -->
<div class="batch-toolbar flex items-center gap-3 pb-3 border-b">
<span v-if="selectedRowKeys.length > 0" class="text-sm">
已选择 <strong class="text-primary">{{ selectedRowKeys.length }}</strong>
</span>
<Button
:disabled="!hasDownloadableSelected"
:loading="batchDownloading"
size="sm"
@click="handleBatchDownloadSelected"
>
<!-- 批量操作工具栏 -->
<template #title>
<div class="batch-toolbar">
<a-space>
<span v-if="selectedRowKeys.length > 0">
已选择 <strong>{{ selectedRowKeys.length }}</strong> 项
</span>
<a-button
type="primary"
:disabled="!hasDownloadableSelected"
:loading="batchDownloading"
@click="handleBatchDownloadSelected"
<Icon icon="lucide:download" class="mr-1 size-4" />
批量下载 ({{ downloadableCount }})
</Button>
<Button
variant="destructive"
size="sm"
:disabled="selectedRowKeys.length === 0"
@click="handleBatchDeleteSelected"
>
<Icon icon="lucide:trash-2" class="mr-1 size-4" />
批量删除
</Button>
</div>
<div class="relative min-h-[200px]">
<Spinner v-if="loading" class="absolute inset-0 z-10 m-auto size-8" />
<div class="overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-[50px]">
<Checkbox
:checked="isAllSelected"
@update:checked="handleSelectAll"
/>
</TableHead>
<TableHead class="w-[70px]">ID</TableHead>
<TableHead>标题</TableHead>
<TableHead class="w-[90px]">状态</TableHead>
<TableHead class="w-[100px]">生成结果</TableHead>
<TableHead class="w-[160px]">创建时间</TableHead>
<TableHead class="w-[160px]">完成时间</TableHead>
<TableHead class="w-[240px] sticky right-0 bg-background">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<template v-for="record in list" :key="record.id">
<TableRow
class="cursor-pointer hover:bg-muted/50"
@click="toggleExpand(record.id)"
>
<DownloadOutlined /> 批量下载 ({{ downloadableCount }})
</a-button>
<a-button
danger
:disabled="selectedRowKeys.length === 0"
@click="handleBatchDeleteSelected"
<TableCell>
<Checkbox
:checked="selectedRowKeys.includes(record.id)"
@update:checked.stop="handleSelectRow(record.id)"
/>
</TableCell>
<TableCell>{{ record.id }}</TableCell>
<TableCell>
<div class="flex items-center gap-2">
<Icon
:icon="expandedRowKeys.includes(record.id) ? 'lucide:chevron-down' : 'lucide:chevron-right'"
class="size-4 text-muted-foreground"
/>
<span class="font-medium">{{ record.title }}</span>
<Badge v-if="record.text" variant="secondary" class="text-xs">有文案</Badge>
</div>
</TableCell>
<TableCell>
<TaskStatusTag :status="record.status" />
</TableCell>
<TableCell>
<Badge v-if="record.outputUrls?.length" variant="success">
{{ record.outputUrls.length }} 个视频
</Badge>
<span v-else class="text-muted-foreground">-</span>
</TableCell>
<TableCell>{{ formatDate(record.createTime) }}</TableCell>
<TableCell>{{ record.finishTime ? formatDate(record.finishTime) : '-' }}</TableCell>
<TableCell @click.stop>
<div class="flex items-center gap-1">
<Button
v-if="canOperate(record, 'preview')"
variant="ghost"
size="sm"
class="h-7 px-2 text-primary hover:text-primary/80"
@click="openPreview(record)"
>
<Icon icon="lucide:play-circle" class="mr-1 size-4" />
预览
</Button>
<Button
v-if="canOperate(record, 'download')"
variant="ghost"
size="sm"
class="h-7 px-2 text-success hover:text-success/80"
@click="handleDownload(record)"
>
<Icon icon="lucide:download" class="mr-1 size-4" />
下载
</Button>
<Button
v-if="canOperate(record, 'cancel')"
variant="outline"
size="sm"
class="h-7 px-2"
@click="handleCancel(record.id)"
>
取消
</Button>
<Button
v-if="canOperate(record, 'retry')"
variant="outline"
size="sm"
class="h-7 px-2"
@click="handleRetry(record.id)"
>
重试
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-destructive hover:text-destructive/80 hover:bg-destructive/10"
@click="handleDeleteClick(record.id)"
>
删除
</Button>
</div>
</TableCell>
</TableRow>
<!-- 展开行 -->
<TableRow v-if="expandedRowKeys.includes(record.id)" class="bg-muted/30">
<TableCell colspan="8" class="p-0">
<div class="expanded-content">
<div v-if="record.text" class="task-text">
<strong>文案内容</strong>
<p>{{ record.text }}</p>
</div>
<div v-if="record.outputUrls?.length" class="task-results">
<div class="result-header">
<strong>生成结果</strong>
<span class="result-count">{{ record.outputUrls.length }} 个视频</span>
</div>
<div class="result-list">
<div v-for="(_, index) in record.outputUrls" :key="index" class="result-item">
<Button
v-if="isStatus(record.status, 'success')"
variant="ghost"
size="sm"
class="h-7 px-2"
@click="previewVideo(record, index)"
>
<Icon icon="lucide:play-circle" class="mr-1 size-4" />
视频 {{ index + 1 }}
</Button>
<Button
v-if="isStatus(record.status, 'success')"
variant="ghost"
size="sm"
class="h-7 px-2"
@click="downloadVideo(record.id, index)"
>
<Icon icon="lucide:download" class="size-4" />
</Button>
<span v-else class="text-muted-foreground text-sm">视频 {{ index + 1 }} (处理中...)</span>
</div>
</div>
</div>
<Alert v-if="record.errorMsg" variant="destructive" class="mt-3">
<Icon icon="lucide:alert-circle" class="size-4" />
<AlertDescription>{{ record.errorMsg }}</AlertDescription>
</Alert>
</div>
</TableCell>
</TableRow>
</template>
<TableRow v-if="list.length === 0 && !loading">
<TableCell colspan="8" class="h-32 text-center text-muted-foreground">
暂无数据
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- 分页 -->
<div v-if="paginationConfig.total > 0" class="flex items-center justify-between py-4 border-t">
<span class="text-sm text-muted-foreground">
{{ paginationConfig.total }} 条记录
</span>
<Pagination
v-slot="{ page }"
:items-per-page="paginationConfig.pageSize"
:total="paginationConfig.total"
:sibling-count="1"
show-edges
:page="paginationConfig.current"
@update:page="handlePageChange"
>
<PaginationContent v-slot="{ items }">
<PaginationFirst @click="handlePageChange(1)" />
<PaginationPrevious @click="handlePageChange(paginationConfig.current - 1)" />
<template v-for="(item, index) in items" :key="index">
<PaginationItem
v-if="item.type === 'page'"
:value="item.value"
as-child
>
<DeleteOutlined /> 批量删除
</a-button>
</a-space>
</div>
</template>
<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">有文案</a-tag>
</div>
</template>
<!-- 状态列 -->
<template v-else-if="column.key === 'status'">
<TaskStatusTag :status="record.status" />
</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'">
<a-tag v-if="record.outputUrls?.length" color="success">
{{ record.outputUrls.length }} 个视频
</a-tag>
<span v-else class="text-muted">-</span>
</template>
<!-- 操作列 -->
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button
v-if="canOperate(record, 'preview')"
type="link"
size="small"
class="action-btn action-btn--primary"
@click="openPreview(record)"
>
<PlayCircleOutlined /> 预览
</a-button>
<a-button
v-if="canOperate(record, 'download')"
type="link"
size="small"
class="action-btn action-btn--success"
@click="handleDownload(record)"
>
<DownloadOutlined /> 下载
</a-button>
<a-button
v-if="canOperate(record, 'cancel')"
size="small"
@click="handleCancel(record.id)"
>
取消
</a-button>
<a-button
v-if="canOperate(record, 'retry')"
size="small"
@click="handleRetry(record.id)"
>
重试
</a-button>
<a-button type="link" size="small" class="action-btn action-btn--danger" @click="handleDelete(record.id)">删除</a-button>
</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?.length" class="task-results">
<div class="result-header">
<strong>生成结果:</strong>
<span class="result-count">{{ record.outputUrls.length }} 个视频</span>
</div>
<div class="result-list">
<div v-for="(_, index) in record.outputUrls" :key="index" class="result-item">
<a-button
v-if="isStatus(record.status, 'success')"
type="link"
size="small"
@click="previewVideo(record, index)"
>
<PlayCircleOutlined /> 视频 {{ index + 1 }}
</a-button>
<a-button
v-if="isStatus(record.status, 'success')"
type="link"
size="small"
@click="downloadVideo(record.id, index)"
>
<DownloadOutlined />
</a-button>
<span v-else class="text-muted">视频 {{ index + 1 }} (处理中...)</span>
</div>
</div>
</div>
<a-alert v-if="record.errorMsg" type="error" :message="record.errorMsg" show-icon />
</div>
</template>
</a-table>
</a-spin>
<Button
:variant="page === item.value ? 'default' : 'outline'"
size="icon-sm"
class="h-8 w-8"
>
{{ item.value }}
</Button>
</PaginationItem>
<PaginationEllipsis v-else :index="index" />
</template>
<PaginationNext @click="handlePageChange(paginationConfig.current + 1)" />
<PaginationLast @click="handlePageChange(Math.ceil(paginationConfig.total / paginationConfig.pageSize))" />
</PaginationContent>
</Pagination>
</div>
</div>
</div>
<!-- 预览模态框 -->
<a-modal v-model:open="preview.visible" :title="preview.title" width="800px" :footer="null" centered>
<div v-if="preview.url" class="preview-container">
<video :src="preview.url" controls autoplay class="preview-video">
您的浏览器不支持视频播放
</video>
</div>
<div v-else class="preview-loading">
<a-spin size="large" tip="正在加载预览..." />
</div>
</a-modal>
<Dialog v-model:open="preview.visible">
<DialogContent class="max-w-[800px]">
<DialogHeader>
<DialogTitle>{{ preview.title }}</DialogTitle>
</DialogHeader>
<div v-if="preview.url" class="preview-container">
<video :src="preview.url" controls autoplay class="preview-video">
您的浏览器不支持视频播放
</video>
</div>
<div v-else class="preview-loading">
<Spinner class="size-6" />
<span class="text-muted-foreground mt-2">正在加载预览...</span>
</div>
</DialogContent>
</Dialog>
<!-- 删除确认对话框 -->
<AlertDialog v-model:open="deleteDialogOpen">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认批量删除</AlertDialogTitle>
<AlertDialogDescription>
确定要删除选中的 {{ selectedRowKeys.length }} 个任务吗删除后无法恢复
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
:disabled="deleteLoading"
class="bg-destructive hover:bg-destructive/90"
@click="confirmBatchDelete"
>
确定删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { SearchOutlined, PlayCircleOutlined, DownloadOutlined, DeleteOutlined } from '@ant-design/icons-vue'
import { Modal, message } from 'ant-design-vue'
import { ref, computed, onMounted } from 'vue'
import { Icon } from '@iconify/vue'
import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { RangeCalendar } from '@/components/ui/range-calendar'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Spinner } from '@/components/ui/spinner'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationFirst,
PaginationItem,
PaginationLast,
PaginationNext,
PaginationPrevious
} from '@/components/ui/pagination'
import { MixTaskService } from '@/api/mixTask'
import { formatDate } from '@/utils/file'
import { useTaskList } from '@/views/system/task-management/composables/useTaskList'
@@ -224,9 +379,18 @@ import { useTaskOperations } from '@/views/system/task-management/composables/us
import { useTaskPolling } from '@/views/system/task-management/composables/useTaskPolling'
import TaskStatusTag from '@/views/system/task-management/components/TaskStatusTag.vue'
// 日期选择器开关
const datePickerOpen = ref(false)
const selectedDateRange = ref(null)
// Composables
const { loading, list, filters, paginationConfig, fetchList, handleFilterChange, handleResetFilters, handleTableChange } = useTaskList(MixTaskService.getTaskPage)
const { handleDelete, handleCancel, handleRetry, handleBatchDownload, handleBatchDelete } = useTaskOperations(
// 初始化 filters.status 为 'all'
if (!filters.status) {
filters.status = 'all'
}
const { handleDelete, handleCancel, handleRetry, handleBatchDownload } = useTaskOperations(
{ deleteApi: MixTaskService.deleteTask, cancelApi: MixTaskService.cancelTask, retryApi: MixTaskService.retryTask, getSignedUrlsApi: MixTaskService.getSignedUrls },
fetchList
)
@@ -234,18 +398,39 @@ useTaskPolling(MixTaskService.getTaskPage, { onTaskUpdate: fetchList })
// 展开行
const expandedRowKeys = ref([])
const handleExpandedRowsChange = (keys) => { expandedRowKeys.value = keys }
const toggleExpand = (id) => {
const index = expandedRowKeys.value.indexOf(id)
if (index > -1) {
expandedRowKeys.value.splice(index, 1)
} else {
expandedRowKeys.value.push(id)
}
}
// 批量选择
const selectedRowKeys = ref([])
const batchDownloading = ref(false)
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys
const isAllSelected = computed(() => {
return list.value.length > 0 && list.value.every(item => selectedRowKeys.value.includes(item.id))
})
const handleSelectAll = (checked) => {
if (checked) {
selectedRowKeys.value = list.value.map(item => item.id)
} else {
selectedRowKeys.value = []
}
}))
}
const handleSelectRow = (id) => {
const index = selectedRowKeys.value.indexOf(id)
if (index > -1) {
selectedRowKeys.value.splice(index, 1)
} else {
selectedRowKeys.value.push(id)
}
}
// 可下载的选中项
const downloadableCount = computed(() => {
@@ -258,7 +443,7 @@ const downloadableCount = computed(() => {
const hasDownloadableSelected = computed(() => downloadableCount.value > 0)
// 批量下载选中的任务(控制并发)
// 批量下载选中的任务
const handleBatchDownloadSelected = async () => {
const downloadableTasks = list.value.filter(item =>
selectedRowKeys.value.includes(item.id) &&
@@ -267,15 +452,14 @@ const handleBatchDownloadSelected = async () => {
)
if (downloadableTasks.length === 0) {
message.warning('没有可下载的任务')
toast.warning('没有可下载的任务')
return
}
batchDownloading.value = true
message.loading(`正在准备下载 ${downloadableTasks.length} 个任务的视频...`, 0)
const loadingToast = toast.loading(`正在准备下载 ${downloadableTasks.length} 个任务的视频...`)
try {
// 并发控制:同时最多下载 3 个任务
const CONCURRENCY = 3
let completed = 0
const total = downloadableTasks.length
@@ -286,7 +470,6 @@ const handleBatchDownloadSelected = async () => {
try {
const res = await MixTaskService.getSignedUrls(task.id)
if (res.code === 0 && res.data?.length > 0) {
// 下载该任务的所有视频
for (let j = 0; j < res.data.length; j++) {
await downloadFile(res.data[j], `video_${task.id}_${j + 1}.mp4`)
}
@@ -295,41 +478,51 @@ const handleBatchDownloadSelected = async () => {
console.error(`任务 ${task.id} 下载失败:`, e)
}
completed++
message.destroy()
message.loading(`下载进度: ${completed}/${total}`, 0)
toast.loading(`下载进度: ${completed}/${total}`, { id: loadingToast })
}))
}
message.destroy()
message.success(`成功下载 ${total} 个任务的视频`)
toast.success(`成功下载 ${total} 个任务的视频`, { id: loadingToast })
} catch (e) {
message.destroy()
message.error('批量下载失败')
toast.error('批量下载失败', { id: loadingToast })
console.error('批量下载失败:', e)
} finally {
batchDownloading.value = false
}
}
// 批量删除选中的任务
// 批量删除
const deleteDialogOpen = ref(false)
const deleteLoading = ref(false)
const handleBatchDeleteSelected = () => {
const count = selectedRowKeys.value.length
Modal.confirm({
title: '确认批量删除',
content: `确定要删除选中的 ${count} 个任务吗?删除后无法恢复。`,
okType: 'danger',
onOk: async () => {
for (const id of selectedRowKeys.value) {
await MixTaskService.deleteTask(id)
}
message.success(`成功删除 ${count} 个任务`)
selectedRowKeys.value = []
fetchList()
}
})
deleteDialogOpen.value = true
}
// 下载单个文件(使用 fetch + blob 强制下载)
const confirmBatchDelete = async () => {
const count = selectedRowKeys.value.length
deleteLoading.value = true
try {
for (const id of selectedRowKeys.value) {
await MixTaskService.deleteTask(id)
}
toast.success(`成功删除 ${count} 个任务`)
selectedRowKeys.value = []
deleteDialogOpen.value = false
fetchList()
} catch (e) {
toast.error('批量删除失败')
} finally {
deleteLoading.value = false
}
}
const handleDeleteClick = (id) => {
selectedRowKeys.value = [id]
deleteDialogOpen.value = true
}
// 下载单个文件
const downloadFile = async (url, filename) => {
const response = await fetch(url)
if (!response.ok) throw new Error('下载失败')
@@ -345,7 +538,7 @@ const downloadFile = async (url, filename) => {
}
// 预览状态
const preview = reactive({ visible: false, title: '', url: '' })
const preview = ref({ visible: false, title: '', url: '' })
// 状态判断
const isStatus = (status, target) => status?.toLowerCase() === target.toLowerCase()
@@ -365,12 +558,12 @@ const canOperate = (record, action) => {
// 预览视频
const previewVideo = async (record, index) => {
preview.title = `${record.title} - 视频 ${index + 1}`
preview.visible = true
preview.url = ''
preview.value.title = `${record.title} - 视频 ${index + 1}`
preview.value.visible = true
preview.value.url = ''
try {
const res = await MixTaskService.getSignedUrls(record.id)
if (res.code === 0 && res.data?.[index]) preview.url = res.data[index]
if (res.code === 0 && res.data?.[index]) preview.value.url = res.data[index]
} catch (e) {
console.error('获取预览链接失败:', e)
}
@@ -378,7 +571,7 @@ const previewVideo = async (record, index) => {
const openPreview = (record) => previewVideo(record, 0)
// 下载视频(使用 fetch + blob 强制下载)
// 下载视频
const downloadVideo = async (taskId, index) => {
try {
const res = await MixTaskService.getSignedUrls(taskId)
@@ -389,7 +582,7 @@ const downloadVideo = async (taskId, index) => {
}
} catch (e) {
console.error('下载失败:', e)
message.error('下载失败')
toast.error('下载失败')
}
}
@@ -399,16 +592,27 @@ const handleDownload = (record) => {
}
}
// 表格列定义
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 70, fixed: 'left' },
{ 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' }
]
// 处理日期选择
const handleDateSelect = (value) => {
if (value?.start && value?.end) {
const formatDate = (date) => {
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
filters.dateRange = [formatDate(value.start), formatDate(value.end)]
datePickerOpen.value = false
handleFilterChange()
}
}
// 分页处理
const handlePageChange = (page) => {
paginationConfig.current = page
fetchList()
}
onMounted(fetchList)
</script>
@@ -427,44 +631,21 @@ onMounted(fetchList)
background: var(--color-bg-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
.filter-select,
.filter-input {
width: 200px;
}
.filter-date-picker {
width: 280px;
}
}
.task-page__content {
flex: 1;
overflow: auto;
overflow: hidden;
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-toolbar {
padding: var(--space-2) 0;
}
.title-cell {
display: flex;
align-items: center;
gap: var(--space-2);
}
.text-muted {
color: var(--color-gray-400);
}
.action-btn {
&--primary { color: var(--color-primary-500); &:hover { color: var(--color-primary-600); } }
&--success { color: var(--color-success-500); &:hover { color: var(--color-success-600); } }
&--danger { color: var(--color-error-500); &:hover { color: var(--color-error-600); } }
flex-shrink: 0;
}
.expanded-content {
@@ -482,7 +663,7 @@ onMounted(fetchList)
padding: var(--space-3);
background: var(--color-gray-100);
border-radius: var(--radius-md);
line-height: var(--line-height-base);
line-height: 1.5;
}
}
@@ -515,7 +696,7 @@ onMounted(fetchList)
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);
transition: box-shadow var(--duration-fast) ease;
&:hover {
box-shadow: var(--shadow-sm);
@@ -538,21 +719,9 @@ onMounted(fetchList)
.preview-loading {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
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>

View File

@@ -18,8 +18,10 @@
"axios": "^1.12.2",
"github-markdown-css": "^5.8.1",
"localforage": "^1.10.0",
"lucide-vue-next": "^0.575.0",
"unocss": "^66.5.4",
"vite-plugin-compression2": "^2.4.0",
"vue-sonner": "^2.0.9",
"web-storage-cache": "^1.1.1"
}
}