优化
This commit is contained in:
@@ -104,7 +104,6 @@ function buildRoutesFromNav(config) {
|
||||
|
||||
const hiddenRoutes = [
|
||||
{ path: 'trends/heat', name: '热度分析', component: () => import('../views/trends/Heat.vue'), meta: { hidden: true } },
|
||||
{ path: 'material/mix-task', name: '混剪任务', component: () => import('../views/material/MixTaskList.vue'), meta: { hidden: true } },
|
||||
{ path: 'material/group', name: '素材分组', component: () => import('../views/material/MaterialGroup.vue'), meta: { hidden: true } },
|
||||
{ path: 'user/profile', name: '个人中心', component: () => import('../views/user/Profile.vue'), meta: { hidden: true } }
|
||||
]
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">当前余额</span>
|
||||
<span class="meta-value">2,450 积分</span>
|
||||
<span class="meta-value">{{ userStore.remainingPoints.toLocaleString() }} 积分</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -209,6 +209,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { CloudUploadOutlined, CrownFilled } from '@ant-design/icons-vue'
|
||||
import { useVoiceCopyStore } from '@/stores/voiceCopy'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import VideoSelector from '@/components/VideoSelector.vue'
|
||||
import VoiceSelector from '@/components/VoiceSelector.vue'
|
||||
import ResultPanel from '@/components/ResultPanel.vue'
|
||||
@@ -219,6 +220,7 @@ import PipelineProgress from '@/components/PipelineProgress.vue'
|
||||
import { useIdentifyFaceController } from './hooks/useIdentifyFaceController'
|
||||
|
||||
const voiceStore = useVoiceCopyStore()
|
||||
const userStore = useUserStore()
|
||||
const dragOver = ref(false)
|
||||
|
||||
// ==================== 初始化 Controller ====================
|
||||
|
||||
@@ -1,713 +0,0 @@
|
||||
<template>
|
||||
<FullWidthLayout :show-back="true" @back="router.back()" class="mix-task-list-layout">
|
||||
<!-- 页面标题 -->
|
||||
<template #header>
|
||||
<div class="page-header">
|
||||
<div class="page-header__icon">
|
||||
<VideoCameraOutlined />
|
||||
</div>
|
||||
<div class="page-header__content">
|
||||
<h1 class="page-header__title">混剪任务</h1>
|
||||
<p class="page-header__subtitle">管理和查看混剪任务的进度和结果</p>
|
||||
</div>
|
||||
<div class="page-header__actions">
|
||||
<a-button @click="handleRefresh">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
刷新
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 页面内容 -->
|
||||
<div class="mix-task-list">
|
||||
<!-- 筛选条件 -->
|
||||
<div class="mix-task-list__filters">
|
||||
<a-space>
|
||||
<a-select
|
||||
v-model:value="filters.status"
|
||||
style="width: 120px"
|
||||
placeholder="任务状态"
|
||||
@change="handleFilterChange"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option value="">全部状态</a-select-option>
|
||||
<a-select-option value="pending">待处理</a-select-option>
|
||||
<a-select-option value="running">处理中</a-select-option>
|
||||
<a-select-option value="success">已完成</a-select-option>
|
||||
<a-select-option value="failed">失败</a-select-option>
|
||||
</a-select>
|
||||
|
||||
<a-input
|
||||
v-model="filters.title"
|
||||
placeholder="搜索标题"
|
||||
style="width: 200px"
|
||||
allow-clear
|
||||
@press-enter="handleFilterChange"
|
||||
>
|
||||
<template #prefix>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
|
||||
<a-range-picker
|
||||
v-model:value="filters.createTime"
|
||||
style="width: 300px"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
:placeholder="['开始日期', '结束日期']"
|
||||
@change="handleFilterChange"
|
||||
/>
|
||||
|
||||
<a-button type="primary" @click="handleFilterChange">查询</a-button>
|
||||
<a-button @click="handleResetFilters">重置</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 任务列表 -->
|
||||
<div class="mix-task-list__content">
|
||||
<a-spin :spinning="loading" tip="加载中...">
|
||||
<a-table
|
||||
:data-source="taskList"
|
||||
:columns="columns"
|
||||
:row-key="record => record.id"
|
||||
:pagination="paginationConfig"
|
||||
@change="handleTableChange"
|
||||
:expanded-row-keys="expandedRowKeys"
|
||||
@expandedRowsChange="handleExpandedRowsChange"
|
||||
:scroll="{ x: 1000 }"
|
||||
>
|
||||
<!-- 标题列 -->
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'title'">
|
||||
<div class="title-cell">
|
||||
<strong>{{ record.title }}</strong>
|
||||
<a-tag v-if="record.text" size="small" style="margin-left: var(--space-2)">有文案</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 状态列 -->
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 进度列 -->
|
||||
<template v-else-if="column.key === 'progress'">
|
||||
<div style="min-width: 100px">
|
||||
<div style="font-size: 12px; margin-bottom: 4px">{{ record.progress }}%</div>
|
||||
<a-progress
|
||||
:percent="record.progress"
|
||||
:status="getProgressStatus(record.status)"
|
||||
size="small"
|
||||
:show-info="false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 创建时间列 -->
|
||||
<template v-else-if="column.key === 'createTime'">
|
||||
{{ formatDate(record.createTime) }}
|
||||
</template>
|
||||
|
||||
<!-- 完成时间列 -->
|
||||
<template v-else-if="column.key === 'finishTime'">
|
||||
{{ record.finishTime ? formatDate(record.finishTime) : '-' }}
|
||||
</template>
|
||||
|
||||
<!-- 生成结果列 -->
|
||||
<template v-else-if="column.key === 'outputUrls'">
|
||||
<div v-if="record.outputUrls && record.outputUrls.length > 0">
|
||||
<a-tag color="success">{{ record.outputUrls.length }} 个视频</a-tag>
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button
|
||||
v-if="record.status === 'success' && record.outputUrls && record.outputUrls.length > 0"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleDownloadAll(record.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
<span>下载</span>
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="record.status === 'running'"
|
||||
size="small"
|
||||
@click="handleCancel(record.id)"
|
||||
>
|
||||
取消
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title="确定删除这个任务吗?删除后无法恢复。"
|
||||
@confirm="() => handleDelete(record.id)"
|
||||
>
|
||||
<a-button size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 展开行内容 -->
|
||||
<template #expandedRowRender="{ record }">
|
||||
<div class="expanded-content">
|
||||
<!-- 任务详情 -->
|
||||
<div v-if="record.text" class="task-text">
|
||||
<strong>文案内容:</strong>
|
||||
<p>{{ record.text }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 生成结果 -->
|
||||
<div v-if="record.outputUrls && record.outputUrls.length > 0" class="task-results">
|
||||
<strong>生成结果:</strong>
|
||||
<div class="result-list">
|
||||
<div
|
||||
v-for="(url, index) in record.outputUrls"
|
||||
:key="index"
|
||||
class="result-item"
|
||||
>
|
||||
<a-button
|
||||
v-if="record.status === 'success'"
|
||||
type="link"
|
||||
@click="handlePreviewSignedUrl(record.id, index)"
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
视频 {{ index + 1 }}
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="record.status === 'success'"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleDownloadSignedUrl(record.id, index)"
|
||||
>
|
||||
<DownloadOutlined />
|
||||
</a-button>
|
||||
<span v-else class="processing-tip">
|
||||
视频 {{ index + 1 }} (处理中...)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误信息 -->
|
||||
<div v-if="record.errorMsg" class="task-error">
|
||||
<a-alert
|
||||
type="error"
|
||||
:message="record.errorMsg"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-spin>
|
||||
</div>
|
||||
</div>
|
||||
</FullWidthLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
PlayCircleOutlined,
|
||||
DownloadOutlined,
|
||||
VideoCameraOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { MixTaskService } from '@/api/mixTask'
|
||||
import { formatDate } from '@/utils/file'
|
||||
import FullWidthLayout from '@/layouts/components/FullWidthLayout.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 数据
|
||||
const loading = ref(false)
|
||||
const taskList = ref([])
|
||||
const expandedRowKeys = ref([])
|
||||
const refreshInterval = ref(null) // 定时刷新定时器
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80,
|
||||
fixed: 'left'
|
||||
},
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
width: 250,
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '进度',
|
||||
dataIndex: 'progress',
|
||||
key: 'progress',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '生成结果',
|
||||
dataIndex: 'outputUrls',
|
||||
key: 'outputUrls',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createTime',
|
||||
key: 'createTime',
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
title: '完成时间',
|
||||
dataIndex: 'finishTime',
|
||||
key: 'finishTime',
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 300,
|
||||
fixed: 'right'
|
||||
}
|
||||
]
|
||||
|
||||
// 筛选条件
|
||||
const filters = reactive({
|
||||
status: '',
|
||||
title: '',
|
||||
createTime: undefined
|
||||
})
|
||||
|
||||
// 分页配置
|
||||
const paginationConfig = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
onChange: (page, pageSize) => {
|
||||
paginationConfig.current = page
|
||||
paginationConfig.pageSize = pageSize
|
||||
handlePageChange(page, pageSize)
|
||||
},
|
||||
onShowSizeChange: (current, size) => {
|
||||
paginationConfig.current = 1
|
||||
paginationConfig.pageSize = size
|
||||
handlePageChange(1, size)
|
||||
}
|
||||
})
|
||||
|
||||
// 构建查询参数
|
||||
const buildQueryParams = () => {
|
||||
const params = {
|
||||
pageNo: paginationConfig.current,
|
||||
pageSize: paginationConfig.pageSize,
|
||||
status: filters.status || undefined,
|
||||
title: filters.title || undefined
|
||||
}
|
||||
|
||||
// 处理日期范围
|
||||
if (filters.createTime && Array.isArray(filters.createTime) && filters.createTime.length === 2) {
|
||||
params.createTimeStart = `${filters.createTime[0]} 00:00:00`
|
||||
params.createTimeEnd = `${filters.createTime[1]} 23:59:59`
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// 加载任务列表
|
||||
const loadTaskList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await MixTaskService.getTaskPage(buildQueryParams())
|
||||
if (res.code === 0) {
|
||||
taskList.value = res.data.list || []
|
||||
paginationConfig.total = res.data.total || 0
|
||||
} else {
|
||||
message.error(res.msg || '加载失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载任务列表失败:', error)
|
||||
message.error('加载失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选
|
||||
const handleFilterChange = () => {
|
||||
paginationConfig.current = 1
|
||||
loadTaskList()
|
||||
}
|
||||
|
||||
const handleResetFilters = () => {
|
||||
filters.status = ''
|
||||
filters.title = ''
|
||||
filters.createTime = undefined
|
||||
paginationConfig.current = 1
|
||||
loadTaskList()
|
||||
}
|
||||
|
||||
// 分页
|
||||
const handlePageChange = (page, pageSize) => {
|
||||
loadTaskList()
|
||||
}
|
||||
|
||||
// 表格变化
|
||||
const handleTableChange = (pag, filters, sorter) => {
|
||||
console.log('表格变化:', pag, filters, sorter)
|
||||
}
|
||||
|
||||
// 展开行变化
|
||||
const handleExpandedRowsChange = (expandedRows) => {
|
||||
expandedRowKeys.value = expandedRows
|
||||
}
|
||||
|
||||
// 刷新
|
||||
const handleRefresh = () => {
|
||||
loadTaskList()
|
||||
}
|
||||
|
||||
// 重新生成
|
||||
const handleRetry = (id) => {
|
||||
Modal.confirm({
|
||||
title: '确认重新生成',
|
||||
content: '确定要重新生成这个任务吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await MixTaskService.retryTask(id)
|
||||
message.success('已重新提交任务')
|
||||
loadTaskList()
|
||||
} catch (error) {
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 取消任务
|
||||
const handleCancel = (id) => {
|
||||
Modal.confirm({
|
||||
title: '确认取消',
|
||||
content: '确定要取消这个任务吗?',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await MixTaskService.cancelTask(id)
|
||||
message.success('已取消任务')
|
||||
loadTaskList()
|
||||
} catch (error) {
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 删除任务
|
||||
const handleDelete = (id) => {
|
||||
MixTaskService.deleteTask(id)
|
||||
.then(() => {
|
||||
message.success('删除成功')
|
||||
loadTaskList()
|
||||
})
|
||||
.catch(() => {
|
||||
message.error('删除失败')
|
||||
})
|
||||
}
|
||||
|
||||
// 下载单个视频
|
||||
const handleDownload = (url) => {
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'video'
|
||||
link.target = '_blank'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
// 下载单个视频(使用签名URL)
|
||||
|
||||
const handlePreviewSignedUrl = async (taskId, index) => {
|
||||
try {
|
||||
message.loading('正在获取预览链接...', 0)
|
||||
const res = await MixTaskService.getSignedUrls(taskId)
|
||||
message.destroy()
|
||||
|
||||
if (res.code === 0 && res.data && res.data[index]) {
|
||||
window.open(res.data[index], '_blank')
|
||||
} else {
|
||||
message.warning('获取预览链接失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取预览链接失败:', error)
|
||||
message.destroy()
|
||||
message.error('获取预览链接失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadSignedUrl = async (taskId, index) => {
|
||||
try {
|
||||
message.loading('正在获取下载链接...', 0)
|
||||
const res = await MixTaskService.getSignedUrls(taskId)
|
||||
message.destroy()
|
||||
|
||||
if (res.code === 0 && res.data && res.data[index]) {
|
||||
handleDownload(res.data[index])
|
||||
} else {
|
||||
message.warning('获取下载链接失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取下载链接失败:', error)
|
||||
message.destroy()
|
||||
message.error('获取下载链接失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 批量下载所有视频
|
||||
const handleDownloadAll = async (taskId) => {
|
||||
try {
|
||||
message.loading('正在获取下载链接...', 0)
|
||||
const res = await MixTaskService.getSignedUrls(taskId)
|
||||
message.destroy()
|
||||
|
||||
if (res.code === 0 && res.data && res.data.length > 0) {
|
||||
const urls = res.data
|
||||
message.loading('正在准备下载...', 0)
|
||||
|
||||
// 逐个触发下载,避免浏览器阻止多个弹窗
|
||||
urls.forEach((url, index) => {
|
||||
setTimeout(() => {
|
||||
console.log('下载视频:', url)
|
||||
handleDownload(url)
|
||||
}, index * 500) // 每个下载间隔500ms
|
||||
})
|
||||
|
||||
message.destroy()
|
||||
message.success(`已触发 ${urls.length} 个视频的下载`)
|
||||
} else {
|
||||
message.warning('没有可下载的视频')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取下载链接失败:', error)
|
||||
message.destroy()
|
||||
message.error('获取下载链接失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
const statusMap = {
|
||||
pending: '待处理',
|
||||
running: '处理中',
|
||||
success: '已完成',
|
||||
failed: '失败'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status) => {
|
||||
const colorMap = {
|
||||
pending: 'default',
|
||||
running: 'processing',
|
||||
success: 'success',
|
||||
failed: 'error'
|
||||
}
|
||||
return colorMap[status] || 'default'
|
||||
}
|
||||
|
||||
// 获取进度条状态
|
||||
const getProgressStatus = (status) => {
|
||||
const statusMap = {
|
||||
pending: 'normal',
|
||||
running: 'active',
|
||||
success: 'success',
|
||||
failed: 'exception'
|
||||
}
|
||||
return statusMap[status] || 'normal'
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
loadTaskList()
|
||||
// 开启定时刷新,每30秒检查一次running状态的任务
|
||||
startAutoRefresh()
|
||||
})
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh()
|
||||
})
|
||||
|
||||
// 开启自动刷新
|
||||
const startAutoRefresh = () => {
|
||||
// 清除可能存在的旧定时器
|
||||
stopAutoRefresh()
|
||||
|
||||
refreshInterval.value = setInterval(() => {
|
||||
// 只在页面可见时刷新,避免后台浪费资源
|
||||
if (document.visibilityState === 'visible') {
|
||||
loadTaskList()
|
||||
}
|
||||
}, 30000) // 30秒刷新一次
|
||||
}
|
||||
|
||||
// 停止自动刷新
|
||||
const stopAutoRefresh = () => {
|
||||
if (refreshInterval.value) {
|
||||
clearInterval(refreshInterval.value)
|
||||
refreshInterval.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
// 页面头部样式
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: var(--space-3);
|
||||
|
||||
&__icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: var(--color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
box-shadow: var(--shadow-inset-card);
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.mix-task-list {
|
||||
min-height: calc(100vh - 140px);
|
||||
background: var(--color-bg);
|
||||
padding: var(--space-3) 0;
|
||||
}
|
||||
|
||||
.mix-task-list__filters {
|
||||
margin-bottom: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-surface);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.mix-task-list__content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: var(--color-surface);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.title-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.expanded-content {
|
||||
padding: var(--space-3);
|
||||
background: var(--color-bg);
|
||||
border-radius: 8px;
|
||||
margin: var(--space-2);
|
||||
}
|
||||
|
||||
.task-text {
|
||||
margin-bottom: var(--space-3);
|
||||
|
||||
p {
|
||||
margin: var(--space-2) 0 0 0;
|
||||
padding: var(--space-2);
|
||||
background: var(--color-surface);
|
||||
border-radius: 8px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
.task-results {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.result-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 6px 12px;
|
||||
background: var(--color-surface);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.processing-tip {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.task-error {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
:deep(.ant-btn .anticon) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
@@ -141,6 +141,7 @@
|
||||
<!-- 取消按钮 -->
|
||||
<a-button
|
||||
v-if="isStatus(record.status, 'running')"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleCancel(record.id)"
|
||||
>
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
# AI 服务积分扣减公共服务设计文档
|
||||
|
||||
> 版本: v1.2
|
||||
> 日期: 2025-02-20
|
||||
> 状态: 待确认
|
||||
> 用途: 业务规范文档,供后续开发参考
|
||||
|
||||
---
|
||||
|
||||
# 模块一:公共积分扣减服务
|
||||
|
||||
## 1.1 服务定位
|
||||
|
||||
**积分扣减公共服务** - 位于 `tik` 模块下,供所有 AI 服务复用的基础设施。
|
||||
|
||||
**职责:**
|
||||
- 积分配置查询
|
||||
- 积分预检与扣减
|
||||
- 预扣记录管理
|
||||
- 积分流水记录
|
||||
|
||||
## 1.2 核心能力
|
||||
|
||||
| 能力 | 说明 | 使用场景 |
|
||||
|------|------|---------|
|
||||
| 获取积分配置 | 根据平台+类型获取消耗积分 | 所有业务调用前 |
|
||||
| 预检积分 | 检查余额是否充足,不足抛异常 | 调用前验证 |
|
||||
| 即时扣减 | 直接扣减并记录流水 | 同步调用成功后 |
|
||||
| 创建预扣 | 创建待确认的扣减记录 | 流式/异步任务开始时 |
|
||||
| 确认扣减 | 确认预扣,实际扣减积分 | 流式结束/任务成功时 |
|
||||
| 取消预扣 | 删除预扣记录,不扣费 | 流式出错/任务失败时 |
|
||||
|
||||
## 1.3 数据模型
|
||||
|
||||
### 用户积分(muye_member_user_profile)
|
||||
- `remaining_points` - 剩余积分(扣减来源)
|
||||
- `used_points` - 已用积分
|
||||
|
||||
### 积分记录(muye_point_record)
|
||||
- `point_amount` - 变动数量(负数为扣减)
|
||||
- `biz_type` - 业务类型(dify_chat/voice_tts 等)
|
||||
- `biz_id` - 业务关联ID
|
||||
- `status` - 状态:`pending`(预扣) / `confirmed`(已确认) / `canceled`(已取消)
|
||||
|
||||
### 积分配置(muye_ai_model_config)
|
||||
- `platform` - 平台:dify / tikhub / voice / digital_human
|
||||
- `model_type` - 类型:high/low / tts/clone / latentsync/kling
|
||||
- `consume_points` - 消耗积分
|
||||
- `api_key` - API 密钥(Dify 工作流密钥等)★
|
||||
- `api_url` - API 地址(可选,覆盖默认配置)
|
||||
|
||||
## 1.4 业务规则
|
||||
|
||||
### 积分扣减规则
|
||||
- **原子性**:使用 SQL 条件更新,确保不会超扣
|
||||
- **乐观锁**:`WHERE remaining_points >= points`
|
||||
- **幂等性**:同一预扣记录只能确认/取消一次
|
||||
|
||||
### 预扣过期规则
|
||||
- 预扣记录超过 30 分钟自动清理
|
||||
- 定时任务每 5 分钟执行一次清理
|
||||
|
||||
### 异常处理
|
||||
- 积分不足:抛出 `POINTS_INSUFFICIENT` 异常
|
||||
- 配置不存在:抛出 `POINTS_CONFIG_NOT_FOUND` 异常
|
||||
- 扣减失败:抛出 `POINTS_DEDUCT_FAILED` 异常
|
||||
|
||||
## 1.5 依赖关系
|
||||
|
||||
```
|
||||
业务服务(Dify/TikHub/Voice/DigitalHuman)
|
||||
↓
|
||||
PointsService(公共服务)
|
||||
↓
|
||||
├── AiModelConfigService → 获取积分配置
|
||||
├── MemberUserProfileMapper → 扣减积分
|
||||
└── PointRecordMapper → 记录流水
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 模块二:Dify 工作流集成
|
||||
|
||||
## 2.1 业务概述
|
||||
|
||||
**Dify 工作流** - AI 对话服务,支持智能体配置和流式响应。
|
||||
|
||||
**设计原则:**
|
||||
- 所有智能体共用一个"文案生成"工作流(同一个 api_key)
|
||||
- 智能体之间只通过 `systemPrompt` 区分
|
||||
- 前端只需传 `agentId`
|
||||
|
||||
**核心流程:**
|
||||
1. 前端传入 `agentId` + `content`
|
||||
2. 后端通过 `agentId` 获取智能体的 `systemPrompt`
|
||||
3. 调用 Dify API(固定工作流),传入 `sysPrompt` 参数
|
||||
4. 流式返回内容
|
||||
5. 流结束或用户停止时扣费
|
||||
|
||||
## 2.2 扣费流程
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Dify 流式扣费流程 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 1. 获取智能体配置(systemPrompt) │
|
||||
│ 2. 获取积分配置(platform=dify) │
|
||||
│ 3. 预检积分 → 积分不足则拒绝 │
|
||||
│ 4. 创建预扣记录 │
|
||||
│ 5. 调用 Dify 流式 API(传入 sysPrompt) │
|
||||
│ ├─ 流正常结束 → 确认扣费(全额) │
|
||||
│ ├─ 用户取消 → 确认扣费(按实际消耗或最低消费) │
|
||||
│ └─ 出错 → 取消预扣(不扣费) │
|
||||
│ 6. 记录使用记录 │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 2.3 接口定义
|
||||
|
||||
### 阻塞模式
|
||||
- **URL**: `POST /api/tik/dify/chat`
|
||||
- **入参**: agentId, content, conversationId
|
||||
- **出参**: content, conversationId, consumePoints
|
||||
|
||||
### 流式模式
|
||||
- **URL**: `POST /api/tik/dify/chat/stream`
|
||||
- **入参**: agentId, content, conversationId
|
||||
- **出参**: SSE 流(event: message / done)
|
||||
|
||||
**说明:**
|
||||
- `agentId` 用于获取智能体的 `systemPrompt`
|
||||
- 所有智能体共用同一个 Dify 工作流
|
||||
|
||||
## 2.4 配置方案
|
||||
|
||||
**Dify 配置存储在 `muye_ai_model_config` 表:**
|
||||
|
||||
| 字段 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| model_name | Dify 文案生成 | 配置名称 |
|
||||
| platform | dify | 平台标识 |
|
||||
| model_type | writing | 类型 |
|
||||
| consume_points | 10 | 消耗积分 |
|
||||
| api_key | app-xxx | Dify 工作流密钥 ★ |
|
||||
| status | 1 | 启用 |
|
||||
|
||||
**配置文件只存公共配置:**
|
||||
|
||||
```yaml
|
||||
yudao:
|
||||
dify:
|
||||
api-url: http://8.155.172.147:8088
|
||||
timeout: 60
|
||||
```
|
||||
|
||||
## 2.5 智能体表(无需修改)
|
||||
|
||||
现有 `muye_ai_agent` 表已有字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| agent_id | 智能体ID |
|
||||
| agent_name | 智能体名称 |
|
||||
| system_prompt | 系统提示词(传递给 Dify 的 sysPrompt 参数) |
|
||||
| status | 状态 |
|
||||
|
||||
**调用流程:**
|
||||
1. 通过 `agentId` 获取智能体的 `systemPrompt`
|
||||
2. 从 `muye_ai_model_config` 获取 `api_key` + `consume_points`(platform=dify)
|
||||
3. 调用 Dify API,传入 `sysPrompt` 参数
|
||||
|
||||
---
|
||||
|
||||
# 模块三:各业务积分扣减集成
|
||||
|
||||
## 3.1 扣费模式总览
|
||||
|
||||
| 业务 | 扣费模式 | 扣费时机 | 失败处理 |
|
||||
|------|---------|---------|---------|
|
||||
| Dify 工作流 | 流式结束扣费 | 流结束/用户停止时 | 不扣费 |
|
||||
| TikHub | 成功后扣费 | API 调用成功后 | 不扣费 |
|
||||
| 语音合成 | 成功后扣费 | TTS 生成成功后 | 不扣费 |
|
||||
| 数字人合成 | 任务完成扣费 | 任务成功完成时 | 不扣费 |
|
||||
|
||||
**统一原则:失败不扣费**
|
||||
|
||||
## 3.2 TikHub 集成
|
||||
|
||||
### 业务场景
|
||||
- 抖音/小红书数据抓取
|
||||
- 用户信息、视频、帖子等
|
||||
|
||||
### 扣费流程
|
||||
```
|
||||
1. 获取积分配置(platform=tikhub, modelType=fetch)
|
||||
2. 预检积分
|
||||
3. 调用 TikHub API
|
||||
4. 成功 → 扣减积分
|
||||
5. 失败 → 不扣费
|
||||
```
|
||||
|
||||
### 积分配置
|
||||
| platform | model_type | consume_points |
|
||||
|----------|------------|----------------|
|
||||
| tikhub | fetch | 5 |
|
||||
|
||||
## 3.3 语音合成集成
|
||||
|
||||
### 业务场景
|
||||
- TTS 语音生成
|
||||
- 音色克隆
|
||||
|
||||
### 扣费流程
|
||||
```
|
||||
1. 获取积分配置(platform=voice, modelType=tts/clone)
|
||||
2. 预检积分
|
||||
3. 执行语音合成
|
||||
4. 成功 → 扣减积分
|
||||
5. 失败 → 不扣费
|
||||
```
|
||||
|
||||
### 积分配置
|
||||
| platform | model_type | consume_points |
|
||||
|----------|------------|----------------|
|
||||
| voice | tts | 2 |
|
||||
| voice | clone | 10 |
|
||||
|
||||
## 3.4 数字人合成集成
|
||||
|
||||
### 业务场景
|
||||
- 口型同步视频生成
|
||||
- 支持 Latentsync / 可灵 两种供应商
|
||||
|
||||
### 扣费流程
|
||||
```
|
||||
1. 获取积分配置(platform=digital_human, modelType=latentsync/kling)
|
||||
2. 预检积分
|
||||
3. 创建预扣记录
|
||||
4. 创建任务并异步处理
|
||||
5. 任务成功 → 确认扣费
|
||||
6. 任务失败/取消 → 取消预扣
|
||||
```
|
||||
|
||||
### 积分配置
|
||||
| platform | model_type | consume_points |
|
||||
|----------|------------|----------------|
|
||||
| digital_human | latentsync | 15 |
|
||||
| digital_human | kling | 20 |
|
||||
|
||||
---
|
||||
|
||||
# 附录
|
||||
|
||||
## A. 业务类型枚举
|
||||
|
||||
| 类型码 | 说明 |
|
||||
|--------|------|
|
||||
| dify_chat | Dify对话 |
|
||||
| ai_chat | AI聊天 |
|
||||
| image_gen | 图像生成 |
|
||||
| tikhub_fetch | TikHub数据抓取 |
|
||||
| voice_tts | 语音合成 |
|
||||
| voice_clone | 音色克隆 |
|
||||
| digital_human | 数字人合成 |
|
||||
| kling_video | 可灵视频 |
|
||||
|
||||
## B. 异常码
|
||||
|
||||
| 错误码 | 说明 |
|
||||
|--------|------|
|
||||
| 1001001 | 积分不足 |
|
||||
| 1001002 | 积分配置不存在 |
|
||||
| 1001003 | 积分扣减失败 |
|
||||
|
||||
## C. 开发计划
|
||||
|
||||
| 步骤 | 内容 |
|
||||
|------|------|
|
||||
| 1 | 数据库:muye_point_record 新增 status 字段 |
|
||||
| 2 | 公共服务:PointsService 接口 + 实现 |
|
||||
| 3 | Dify 集成:配置类 + 客户端 + 服务 + Controller |
|
||||
| 4 | TikHub 集成:添加积分扣减逻辑 |
|
||||
| 5 | 语音合成集成:添加积分扣减逻辑 |
|
||||
| 6 | 数字人集成:添加积分扣减逻辑 |
|
||||
| 7 | 测试验证 |
|
||||
|
||||
**无需修改的表:**
|
||||
- `muye_ai_agent` - 无需改动
|
||||
- `muye_ai_model_config` - 已有 api_key 字段,无需改动
|
||||
|
||||
## D. 成熟度检查
|
||||
|
||||
| 检查项 | 说明 |
|
||||
|--------|------|
|
||||
| 原子扣减 | SQL 条件更新,防止超扣 |
|
||||
| 乐观锁 | WHERE remaining_points >= points |
|
||||
| 预扣机制 | 支持流式/异步场景 |
|
||||
| 过期清理 | 30分钟自动清理预扣 |
|
||||
| 事务隔离 | 核心操作加事务 |
|
||||
| 异常处理 | 统一错误码 |
|
||||
| 配置化 | 积分消耗可配置 |
|
||||
| 业务解耦 | 公共服务复用 |
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user