即梦 AI 管理后台功能完成

This commit is contained in:
GeekMaster
2025-07-22 15:12:49 +08:00
parent 3156701d4e
commit 454dfc1aa7
10 changed files with 474 additions and 451 deletions

View File

@@ -235,8 +235,6 @@
border-radius: 12px;
box-shadow: var(--card-shadow, 0 2px 8px rgba(0,0,0,0.1));
overflow: hidden;
min-height: 420px;
height: 100%;
transition: box-shadow 0.2s;
&:hover {
box-shadow: 0 4px 24px rgba(88,101,242,0.12);

View File

@@ -5,7 +5,6 @@
// * @Author yangjian102621@163.com
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
import nodata from '@/assets/img/no-data.png'
import { checkSession } from '@/store/cache'
import { showMessageError, showMessageOK } from '@/utils/dialog'
import { httpGet, httpPost } from '@/utils/http'
@@ -26,8 +25,6 @@ export const useJimengStore = defineStore('jimeng', () => {
// 共同状态
const loading = ref(false)
const submitting = ref(false)
const list = ref([])
const noData = ref(true)
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
@@ -186,6 +183,8 @@ export const useJimengStore = defineStore('jimeng', () => {
userPower.value = user.power
// 获取任务列表
await fetchData(1)
// 开始轮询
startPolling()
} catch (error) {
console.error('初始化失败:', error)
}
@@ -257,58 +256,40 @@ export const useJimengStore = defineStore('jimeng', () => {
// 切换任务筛选
const switchTaskFilter = (filter) => {
taskFilter.value = filter
updateCurrentList()
}
// 更新当前列表
const updateCurrentList = () => {
if (taskFilter.value === 'all') {
currentList.value = list.value
} else if (taskFilter.value === 'image') {
currentList.value = list.value.filter((item) =>
['text_to_image', 'image_to_image_portrait', 'image_edit', 'image_effects'].includes(
item.type
)
)
} else if (taskFilter.value === 'video') {
currentList.value = list.value.filter((item) =>
['text_to_video', 'image_to_video'].includes(item.type)
)
}
fetchData(1)
}
// 轮询定时器
let pollHandler = null
// 获取任务列表
const fetchData = async (pageNum = 1) => {
try {
loading.value = true
page.value = pageNum
const response = await httpGet('/api/jimeng/jobs', {
const response = await httpPost('/api/jimeng/jobs', {
page: pageNum,
page_size: pageSize.value,
filter: taskFilter.value,
})
const data = response.data
if (data.total === 0) {
isOver.value = true
currentList.value = []
return
}
if (response.data) {
list.value = response.data.jobs || []
total.value = response.data.total || 0
noData.value = list.value.length === 0
updateCurrentList()
// 判断是否有未完成任务
const hasPending = list.value.some(
(item) => item.status === 'in_queue' || item.status === 'processing'
)
if (hasPending) {
startPolling()
} else {
stopPolling()
}
total.value = data.total || 0
if (data.items.length < pageSize.value) {
isOver.value = true
}
if (pageNum === 1) {
currentList.value = data.items
} else {
currentList.value = currentList.value.concat(data.items)
}
} catch (error) {
console.error('获取任务列表失败:', error)
showMessageError('获取任务列表失败')
showMessageError('获取任务列表失败:' + error.message)
} finally {
loading.value = false
}
@@ -317,8 +298,30 @@ export const useJimengStore = defineStore('jimeng', () => {
// 简单轮询逻辑
const startPolling = () => {
if (pollHandler) return
pollHandler = setInterval(() => {
fetchData(page.value)
pollHandler = setInterval(async () => {
const response = await httpPost('/api/jimeng/jobs', {
page: 1,
page_size: 20,
})
const data = response.data
if (data.items.length === 0) {
stopPolling()
return
}
const todoList = data.items.filter(
(item) => item.status === 'in_queue' || item.status === 'generating'
)
// 更新当前列表
currentList.value.forEach((item) => {
const index = data.items.findIndex((i) => i.id === item.id)
if (index !== -1) {
Object.assign(item, data.items[index])
}
})
if (todoList.length === 0) {
stopPolling()
}
}, 5000)
}
@@ -533,18 +536,16 @@ export const useJimengStore = defineStore('jimeng', () => {
useImageInput,
loading,
submitting,
list,
noData,
page,
pageSize,
total,
taskFilter,
currentList,
isOver,
isLogin,
userPower,
showDialog,
currentVideoUrl,
nodata,
// 配置
categories,
@@ -577,7 +578,6 @@ export const useJimengStore = defineStore('jimeng', () => {
getTaskStatusText,
getStatusType,
switchTaskFilter,
updateCurrentList,
fetchData,
submitTask,
retryTask,

View File

@@ -276,7 +276,7 @@
</div>
<!-- 右侧任务列表 -->
<div class="main-content" v-loading="store.loading">
<div class="main-content">
<div class="works-header">
<h2 class="h-title">你的作品</h2>
<div class="filter-buttons">
@@ -306,119 +306,134 @@
</div>
</div>
<div class="task-list">
<Waterfall
:list="store.currentList"
v-bind="waterfallOptions"
:is-loading="store.loading"
:is-over="store.currentList.length >= store.total"
@afterRender="onWaterfallAfterRender"
>
<template #default="{ item }">
<div class="task-item">
<!-- 保持原有内容 -->
<div class="task-left">
<div class="task-preview">
<el-image
v-if="item.img_url"
:src="item.img_url"
fit="cover"
class="preview-image"
/>
<video
v-else-if="item.video_url"
:src="item.video_url"
class="preview-video"
preload="metadata"
/>
<div v-else class="preview-placeholder">
<i class="iconfont icon-dalle text-2xl" v-if="item.type.includes('image')"></i>
<i
class="iconfont icon-video text-2xl"
v-else-if="item.type.includes('video')"
></i>
<span>{{ store.getTaskStatusText(item.status) }}</span>
<div class="task-list" v-loading="store.loading">
<div v-if="store.currentList.length > 0">
<Waterfall
:list="store.currentList"
v-bind="waterfallOptions"
:is-loading="store.loading"
:is-over="store.isOver"
@afterRender="onWaterfallAfterRender"
>
<template #default="{ item }">
<div class="task-item">
<!-- 保持原有内容 -->
<div class="task-left">
<div class="task-preview">
<el-image
v-if="item.img_url"
:src="item.img_url"
fit="cover"
class="preview-image"
/>
<video
v-else-if="item.video_url"
:src="item.video_url"
class="preview-video"
preload="metadata"
/>
<div v-else class="preview-placeholder">
<i
class="iconfont icon-video text-2xl"
v-if="item.type.includes('video')"
></i>
<i class="iconfont icon-dalle text-2xl" v-else></i>
<span>{{ store.getTaskStatusText(item.status) }}</span>
</div>
</div>
</div>
</div>
<div class="task-center">
<div class="task-info flex justify-between">
<div class="flex gap-2">
<el-tag size="small" :type="store.getStatusType(item.status)">
{{ store.getTaskStatusText(item.status) }}
</el-tag>
<el-tag size="small">{{ store.getFunctionName(item.type) }}</el-tag>
</div>
<div class="flex gap-2">
<span>
<el-tooltip content="复制提示词" placement="top">
<i
class="iconfont icon-copy cursor-pointer"
@click="copyPrompt(item.prompt)"
></i>
</el-tooltip>
</span>
<div class="task-center">
<div class="task-info flex justify-between">
<div class="flex gap-2">
<el-tag size="small" :type="store.getStatusType(item.status)">
{{ store.getTaskStatusText(item.status) }}
</el-tag>
<el-tag size="small">{{ store.getFunctionName(item.type) }}</el-tag>
</div>
<div class="flex gap-2">
<span>
<el-tooltip content="复制提示词" placement="top">
<i
class="iconfont icon-copy cursor-pointer"
@click="copyPrompt(item.prompt)"
></i>
</el-tooltip>
</span>
<span class="ml-1">
<el-tooltip content="画同款" placement="top">
<i
class="iconfont icon-image-list cursor-pointer"
@click="store.drawSame(item)"
></i>
</el-tooltip>
</span>
<span class="ml-1">
<el-tooltip content="画同款" placement="top">
<i
class="iconfont icon-image-list cursor-pointer"
@click="store.drawSame(item)"
></i>
</el-tooltip>
</span>
</div>
</div>
<div
class="task-prompt line-clamp-2 min-h-[40px] text-[14px] text-theme mb-2 leading-snug break-all"
>
{{ store.substr(item.prompt, 200) }}
</div>
<div class="task-meta">
<span>{{ dateFormat(item.created_at) }}</span>
<span v-if="item.power">{{ item.power }}算力</span>
</div>
</div>
<div
class="task-prompt line-clamp-2 min-h-[40px] text-[14px] text-theme mb-2 leading-snug break-all"
>
{{ store.substr(item.prompt, 200) }}
</div>
<div class="task-meta">
<span>{{ dateFormat(item.created_at) }}</span>
<span v-if="item.power">{{ item.power }}算力</span>
<div class="task-right">
<div class="task-actions">
<el-button
v-if="item.status === 'failed'"
type="primary"
size="small"
@click="store.retryTask(item.id)"
>
重试
</el-button>
<el-button
v-if="item.video_url || item.img_url"
type="default"
size="small"
@click="store.downloadFile(item)"
>
下载
</el-button>
<el-button
v-if="item.video_url"
type="default"
size="small"
@click="store.playVideo(item)"
>
播放
</el-button>
<el-button
type="danger"
v-if="item.status === 'failed'"
size="small"
@click="store.removeJob(item)"
>
删除
</el-button>
</div>
</div>
</div>
<div class="task-right">
<div class="task-actions">
<el-button
v-if="item.status === 'failed'"
type="primary"
size="small"
@click="store.retryTask(item.id)"
>
重试
</el-button>
<el-button
v-if="item.video_url || item.img_url"
type="default"
size="small"
@click="store.downloadFile(item)"
>
下载
</el-button>
<el-button
v-if="item.video_url"
type="default"
size="small"
@click="store.playVideo(item)"
>
播放
</el-button>
<el-button
type="danger"
v-if="item.status === 'failed'"
size="small"
@click="store.removeJob(item)"
>
删除
</el-button>
</div>
</template>
</Waterfall>
<div class="flex justify-center py-10">
<img
:src="waterfallOptions.loadProps.loading"
class="max-w-[50px] max-h-[50px]"
v-if="store.loading"
/>
<div v-else>
<div class="no-more-data" v-if="store.isOver">
<span class="text-gray-500 mr-2">没有更多数据了</span>
<i class="iconfont icon-face"></i>
</div>
</div>
</template>
</Waterfall>
<el-empty v-if="store.noData" :image="store.nodata" description="暂无任务,快去创建吧!" />
</div>
</div>
<el-empty v-else :image-size="100" description="暂无记录" />
</div>
</div>
@@ -467,9 +482,8 @@ onUnmounted(() => {
store.cleanup()
})
// 自动加载下一页逻辑
function onWaterfallAfterRender() {
if (!store.loading && store.currentList.length < store.total) {
if (!store.loading && !store.isOver) {
store.fetchData(store.page + 1)
}
}

View File

@@ -1,10 +1,48 @@
<template>
<div class="app-container">
<!-- 页面标题 -->
<div class="page-header">
<h2>即梦AI任务管理</h2>
<p>管理所有用户的即梦AI任务查看任务详情和统计信息</p>
</div>
<!-- 统计信息 -->
<el-row :gutter="20" class="stats-row">
<el-col :span="4">
<el-card class="stat-card">
<div class="stat-item">
<div class="stat-number">{{ stats.totalTasks }}</div>
<div class="stat-label">总任务数</div>
</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card class="stat-card">
<div class="stat-item">
<div class="stat-number !text-blue-500">{{ stats.pendingTasks }}</div>
<div class="stat-label">排队中</div>
</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card class="stat-card">
<div class="stat-item">
<div class="stat-number warning">{{ stats.processingTasks }}</div>
<div class="stat-label">处理中</div>
</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card class="stat-card">
<div class="stat-item">
<div class="stat-number success">{{ stats.completedTasks }}</div>
<div class="stat-label">已完成</div>
</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card class="stat-card">
<div class="stat-item">
<div class="stat-number danger">{{ stats.failedTasks }}</div>
<div class="stat-label">失败</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 搜索筛选 -->
<el-card class="filter-card" shadow="never">
@@ -18,9 +56,15 @@
/>
</el-form-item>
<el-form-item label="任务类型">
<el-select v-model="queryForm.type" placeholder="请选择任务类型" clearable style="width: 150px">
<el-select
v-model="queryForm.type"
placeholder="请选择任务类型"
clearable
style="width: 150px"
@change="handleQuery"
>
<el-option label="文生图" value="text_to_image" />
<el-option label="图生图" value="image_to_image_portrait" />
<el-option label="图生图" value="image_to_image" />
<el-option label="图像编辑" value="image_edit" />
<el-option label="图像特效" value="image_effects" />
<el-option label="文生视频" value="text_to_video" />
@@ -28,66 +72,32 @@
</el-select>
</el-form-item>
<el-form-item label="任务状态">
<el-select v-model="queryForm.status" placeholder="请选择状态" clearable style="width: 120px">
<el-option label="等待中" value="pending" />
<el-option label="处理中" value="processing" />
<el-option label="已完成" value="completed" />
<el-select
v-model="queryForm.status"
placeholder="请选择状态"
clearable
style="width: 120px"
@change="handleQuery"
>
<el-option label="等待中" value="in_queue" />
<el-option label="处理中" value="generating" />
<el-option label="已完成" value="success" />
<el-option label="失败" value="failed" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery" :loading="loading">
<el-icon><Search /></el-icon>
<i class="iconfont icon-search mr-1" />
搜索
</el-button>
<el-button @click="resetQuery">
<el-icon><Refresh /></el-icon>
重置
</el-button>
<el-button type="danger" @click="handleBatchDelete" :disabled="!multipleSelection.length">
<el-icon><Delete /></el-icon>
<i class="iconfont icon-remove mr-1" />
批量删除
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 统计信息 -->
<el-row :gutter="20" class="stats-row">
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-item">
<div class="stat-number">{{ stats.totalTasks }}</div>
<div class="stat-label">总任务数</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-item">
<div class="stat-number success">{{ stats.completedTasks }}</div>
<div class="stat-label">已完成</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-item">
<div class="stat-number warning">{{ stats.processingTasks }}</div>
<div class="stat-label">处理中</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-item">
<div class="stat-number danger">{{ stats.failedTasks }}</div>
<div class="stat-label">失败</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 任务列表 -->
<el-card class="table-card">
<el-table
@@ -126,22 +136,9 @@
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="scope">
<el-button
type="primary"
size="small"
text
@click="handleViewDetail(scope.row)"
>
<el-button type="primary" size="small" text @click="handleViewDetail(scope.row)">
详情
</el-button>
<el-button
type="danger"
size="small"
text
@click="handleDelete(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
@@ -170,21 +167,33 @@
<div class="detail-content" v-if="detailDialog.data">
<el-descriptions :column="2" border>
<el-descriptions-item label="任务ID">{{ detailDialog.data.id }}</el-descriptions-item>
<el-descriptions-item label="用户ID">{{ detailDialog.data.user_id }}</el-descriptions-item>
<el-descriptions-item label="任务类型">{{ getTaskTypeName(detailDialog.data.type) }}</el-descriptions-item>
<el-descriptions-item label="用户ID">{{
detailDialog.data.user_id
}}</el-descriptions-item>
<el-descriptions-item label="任务类型">{{
getTaskTypeName(detailDialog.data.type)
}}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusColor(detailDialog.data.status)">
{{ getStatusName(detailDialog.data.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="进度">{{ detailDialog.data.progress }}%</el-descriptions-item>
<el-descriptions-item label="算力消耗">{{ detailDialog.data.power }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(detailDialog.data.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDateTime(detailDialog.data.updated_at) }}</el-descriptions-item>
<el-descriptions-item label="进度"
>{{ detailDialog.data.progress }}%</el-descriptions-item
>
<el-descriptions-item label="算力消耗">{{
detailDialog.data.power
}}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{
formatDateTime(detailDialog.data.created_at)
}}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{
formatDateTime(detailDialog.data.updated_at)
}}</el-descriptions-item>
</el-descriptions>
<div class="detail-section">
<h4>提示词</h4>
<h4 class="text-base pt-2 font-bold">提示词</h4>
<div class="prompt-content">{{ detailDialog.data.prompt || '无' }}</div>
</div>
@@ -243,24 +252,23 @@
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, Delete } from '@element-plus/icons-vue'
import { formatDateTime } from '@/utils/libs'
import { httpGet, httpPost } from '@/utils/http'
import { formatDateTime } from '@/utils/libs'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, onMounted, reactive, ref } from 'vue'
// 查询表单
const queryForm = reactive({
user_id: '',
type: '',
status: ''
status: '',
})
// 分页信息
const pagination = reactive({
page: 1,
size: 20,
total: 0
total: 0,
})
// 数据
@@ -274,13 +282,13 @@ const stats = reactive({
totalTasks: 0,
completedTasks: 0,
processingTasks: 0,
failedTasks: 0
failedTasks: 0,
})
// 详情对话框
const detailDialog = reactive({
visible: false,
data: {}
data: {},
})
// 格式化原始数据
@@ -296,12 +304,12 @@ const formattedRawData = computed(() => {
// 获取任务类型名称
const getTaskTypeName = (type) => {
const typeMap = {
'text_to_image': '文生图',
'image_to_image_portrait': '图生图',
'image_edit': '图像编辑',
'image_effects': '图像特效',
'text_to_video': '文生视频',
'image_to_video': '图生视频'
text_to_image: '文生图',
image_to_image: '图生图',
image_edit: '图像编辑',
image_effects: '图像特效',
text_to_video: '文生视频',
image_to_video: '图生视频',
}
return typeMap[type] || type
}
@@ -309,10 +317,10 @@ const getTaskTypeName = (type) => {
// 获取状态名称
const getStatusName = (status) => {
const statusMap = {
'pending': '等待中',
'processing': '处理中',
'completed': '已完成',
'failed': '失败'
in_queue: '等待中',
generating: '处理中',
success: '已完成',
failed: '失败',
}
return statusMap[status] || status
}
@@ -320,10 +328,10 @@ const getStatusName = (status) => {
// 获取状态颜色
const getStatusColor = (status) => {
const colorMap = {
'pending': '',
'processing': 'warning',
'completed': 'success',
'failed': 'danger'
in_queue: '',
generating: 'warning',
success: 'success',
failed: 'danger',
}
return colorMap[status] || ''
}
@@ -335,9 +343,9 @@ const getTaskList = async () => {
const params = {
page: pagination.page,
page_size: pagination.size,
...queryForm
...queryForm,
}
const response = await httpGet('/api/admin/jimeng/jobs', params)
taskList.value = response.data.jobs || []
pagination.total = response.data.total || 0
@@ -364,18 +372,6 @@ const handleQuery = () => {
getTaskList()
}
// 重置查询
const resetQuery = () => {
queryFormRef.value?.resetFields()
Object.assign(queryForm, {
user_id: '',
type: '',
status: ''
})
pagination.page = 1
getTaskList()
}
// 选择变化
const handleSelectionChange = (selection) => {
multipleSelection.value = selection
@@ -384,7 +380,7 @@ const handleSelectionChange = (selection) => {
// 查看详情
const handleViewDetail = async (row) => {
try {
const response = await httpGet(`/api/admin/jimeng/job/${row.id}`)
const response = await httpGet(`/api/admin/jimeng/jobs/${row.id}`)
detailDialog.data = response.data
detailDialog.visible = true
} catch (error) {
@@ -392,42 +388,26 @@ const handleViewDetail = async (row) => {
}
}
// 删除任务
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确定要删除这个任务吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await httpPost(`/api/admin/jimeng/job/${row.id}`, {}, { method: 'DELETE' })
ElMessage.success('删除成功')
getTaskList()
getStats()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
// 批量删除
const handleBatchDelete = async () => {
if (!multipleSelection.value.length) {
ElMessage.warning('请选择要删除的任务')
return
}
try {
await ElMessageBox.confirm(`确定要删除选中的 ${multipleSelection.value.length} 个任务吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const jobIds = multipleSelection.value.map(item => item.id)
await httpPost('/api/admin/jimeng/batch-remove', { job_ids: jobIds })
await ElMessageBox.confirm(
`确定要删除选中的 ${multipleSelection.value.length} 个任务吗?`,
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
const jobIds = multipleSelection.value.map((item) => item.id)
await httpPost('/api/admin/jimeng/jobs/remove', { job_ids: jobIds })
ElMessage.success('批量删除成功')
getTaskList()
getStats()
@@ -458,17 +438,20 @@ onMounted(() => {
})
</script>
<style lang="stylus" scoped>
<style lang="stylus">
.app-container
padding 20px
.el-form-item
margin-bottom 0
.page-header
margin-bottom 20px
h2
margin 0 0 8px 0
color #303133
p
margin 0
color #606266
@@ -484,22 +467,22 @@ onMounted(() => {
.stat-item
text-align center
padding 20px
.stat-number
font-size 28px
font-weight bold
color #303133
margin-bottom 8px
&.success
color #67c23a
&.warning
color #e6a23c
&.danger
color #f56c6c
.stat-label
font-size 14px
color #909399
@@ -513,31 +496,31 @@ onMounted(() => {
.detail-content
.detail-section
margin-bottom 20px
h4
margin 0 0 10px 0
color #303133
font-size 16px
.prompt-content
background #f5f7fa
padding 12px
border-radius 4px
color #606266
line-height 1.6
.params-content, .raw-data-content
font-family monospace
.result-content
.result-item
margin-bottom 10px
display flex
align-items center
gap 10px
label
font-weight bold
color #303133
min-width 50px
</style>
</style>