feat: 优化
This commit is contained in:
@@ -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 变量
|
||||
- [ ] 业务功能测试通过
|
||||
- [ ] **现代化审查通过**
|
||||
- [ ] **一致性审查通过**
|
||||
- [ ] **业务功能验证通过**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Checkbox } from "./Checkbox.vue"
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user