feat: build image optimizer page

This commit is contained in:
QiuSW
2026-07-09 01:11:48 +08:00
parent 2c31a41036
commit 357747dd17
9 changed files with 560 additions and 70 deletions
@@ -1,12 +1,255 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import {
EyeOutlined,
PlayCircleOutlined,
ReloadOutlined,
StopOutlined,
UploadOutlined,
} from '@ant-design/icons-vue'
import ModulePageShell from '../../components/template/ModulePageShell.vue'
import {
CancelTask,
CreateTask,
GetTask,
ListTasks,
} from '../../../bindings/cmbone/internal/services/imageoptimizationservice'
const summary = [
{ label: '待处理', value: '0', tone: 'slate' as const },
{ label: '处理中', value: '0', tone: 'blue' as const },
{ label: '成功', value: '0', tone: 'green' as const },
{ label: '失败', value: '0', tone: 'red' as const },
type ImageTask = {
id: number
sourcePath: string
status: string
operation: string
durationMs: number
errorMessage: string
createdAt: string
updatedAt: string
}
type ImageTaskResult = {
id: number
taskId: number
outputPath: string
beforeSize: number
afterSize: number
createdAt: string
}
type ImageTaskDetail = {
task: ImageTask
result: ImageTaskResult
}
const operationOptions = [
{ value: 'enhance', label: '清晰度增强' },
{ value: 'background', label: '背景优化' },
{ value: 'compress', label: '压缩导出' },
]
const statusOptions = [
{ value: '', label: '全部状态' },
{ value: 'pending', label: '待处理' },
{ value: 'processing', label: '处理中' },
{ value: 'succeeded', label: '成功' },
{ value: 'failed', label: '失败' },
{ value: 'canceled', label: '已取消' },
]
const sourcePath = ref('')
const selectedOperation = ref('enhance')
const selectedFileName = ref('')
const statusFilter = ref('')
const operationFilter = ref('')
const tasks = ref<ImageTask[]>([])
const selectedDetail = ref<ImageTaskDetail | null>(null)
const isLoading = ref(false)
const isCreating = ref(false)
const actionError = ref('')
const actionMessage = ref('')
const summary = computed(() => [
{ label: '待处理', value: String(countStatus('pending')), tone: 'slate' as const },
{ label: '处理中', value: String(countStatus('processing')), tone: 'blue' as const },
{ label: '成功', value: String(countStatus('succeeded')), tone: 'green' as const },
{ label: '失败', value: String(countStatus('failed')), tone: 'red' as const },
])
const selectedTask = computed(() => selectedDetail.value?.task)
const selectedResult = computed(() => selectedDetail.value?.result)
onMounted(() => {
void loadTasks()
})
async function loadTasks() {
isLoading.value = true
actionError.value = ''
try {
const rows = await ListTasks({
status: statusFilter.value,
operation: operationFilter.value,
limit: 50,
offset: 0,
}) as ImageTask[]
tasks.value = rows
if (rows.length === 0) {
selectedDetail.value = null
return
}
const selectedID = selectedDetail.value?.task.id
const nextTask = rows.find((task) => task.id === selectedID) || rows[0]
await selectTask(nextTask.id)
} catch (error) {
actionError.value = errorToMessage(error)
} finally {
isLoading.value = false
}
}
async function createTask() {
actionError.value = ''
actionMessage.value = ''
if (!sourcePath.value.trim()) {
actionError.value = '图片路径不能为空'
return
}
isCreating.value = true
try {
const detail = await CreateTask({
sourcePath: sourcePath.value.trim(),
operation: selectedOperation.value,
}) as ImageTaskDetail
selectedDetail.value = detail
actionMessage.value = `任务 #${detail.task.id} 已创建`
await loadTasks()
} catch (error) {
actionError.value = errorToMessage(error)
} finally {
isCreating.value = false
}
}
async function selectTask(id: number) {
try {
selectedDetail.value = await GetTask(id) as ImageTaskDetail
} catch (error) {
actionError.value = errorToMessage(error)
}
}
async function cancelTask(task: ImageTask) {
actionError.value = ''
actionMessage.value = ''
try {
await CancelTask(task.id)
actionMessage.value = `任务 #${task.id} 已取消`
await loadTasks()
} catch (error) {
actionError.value = errorToMessage(error)
}
}
function onFileChange(event: Event) {
actionError.value = ''
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) {
selectedFileName.value = ''
return
}
selectedFileName.value = file.name
const fileWithPath = file as File & { path?: string }
if (fileWithPath.path) {
sourcePath.value = fileWithPath.path
return
}
if (!sourcePath.value.trim()) {
actionError.value = '当前文件选择器未返回完整路径,请在图片路径中粘贴绝对路径'
}
}
function countStatus(status: string) {
return tasks.value.filter((task) => task.status === status).length
}
function operationLabel(value: string) {
return operationOptions.find((option) => option.value === value)?.label || value || '-'
}
function statusLabel(status: string) {
return statusOptions.find((option) => option.value === status)?.label || status || '-'
}
function statusClass(status: string) {
if (status === 'succeeded') {
return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-200'
}
if (status === 'failed') {
return 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-200'
}
if (status === 'processing') {
return 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-200'
}
if (status === 'canceled') {
return 'border-slate-200 bg-slate-100 text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300'
}
return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-200'
}
function canCancel(task: ImageTask) {
return task.status === 'pending' || task.status === 'processing'
}
function formatDuration(value: number) {
if (!value) {
return '-'
}
if (value >= 1000) {
return `${(value / 1000).toFixed(1)} s`
}
return `${value} ms`
}
function formatSize(value: number) {
if (!value) {
return '-'
}
if (value >= 1024 * 1024) {
return `${(value / 1024 / 1024).toFixed(2)} MB`
}
if (value >= 1024) {
return `${(value / 1024).toFixed(1)} KB`
}
return `${value} B`
}
function formatDate(value: string) {
if (!value) {
return '-'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return date.toLocaleString()
}
function baseName(path: string) {
if (!path) {
return '-'
}
const normalized = path.replace(/\\/g, '/')
return normalized.split('/').filter(Boolean).pop() || path
}
function errorToMessage(error: unknown) {
if (error instanceof Error) {
return error.message
}
return String(error)
}
</script>
<template>
@@ -14,63 +257,281 @@ const summary = [
eyebrow="Business"
title="商品图片 AI 优化"
description="第一个主业务样例,用于沉淀任务创建、状态流转、结果预览和失败反馈。"
status="路由可访问"
status="后端已接入"
:items="summary"
>
<div class="grid grid-cols-1 gap-4 xl:grid-cols-[360px_1fr]">
<section class="rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
<h2 class="mb-4 text-base font-semibold">
创建优化任务
</h2>
<div class="space-y-4">
<label class="block">
<span class="mb-1 block text-sm font-medium">图片文件</span>
<input class="block w-full text-sm text-slate-600 file:mr-3 file:rounded-md file:border-0 file:bg-slate-950 file:px-3 file:py-2 file:text-sm file:font-medium file:text-white dark:text-slate-300 dark:file:bg-white dark:file:text-slate-950" type="file" accept="image/*" />
</label>
<label class="block">
<span class="mb-1 block text-sm font-medium">优化类型</span>
<select class="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm dark:border-slate-700 dark:bg-slate-950">
<option>清晰度增强</option>
<option>背景优化</option>
<option>压缩导出</option>
</select>
</label>
<label class="block">
<span class="mb-1 block text-sm font-medium">备注</span>
<textarea class="min-h-[96px] w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-950"></textarea>
</label>
<button class="h-10 w-full rounded-md bg-slate-950 text-sm font-medium text-white dark:bg-white dark:text-slate-950" type="button">
创建任务
</button>
</div>
</section>
<section class="rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div class="border-b border-slate-200 p-4 dark:border-slate-800">
<h2 class="mb-0 text-base font-semibold">
任务列表
<div class="grid grid-cols-1 gap-4 xl:grid-cols-[360px_minmax(0,1fr)]">
<aside class="space-y-4">
<section class="rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
<h2 class="mb-4 text-base font-semibold text-slate-950 dark:text-white">
创建优化任务
</h2>
<div class="space-y-4">
<label class="block">
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-200">选择图片</span>
<input
class="block w-full text-sm text-slate-600 file:mr-3 file:rounded-md file:border-0 file:bg-slate-950 file:px-3 file:py-2 file:text-sm file:font-medium file:text-white dark:text-slate-300 dark:file:bg-white dark:file:text-slate-950"
type="file"
accept="image/*"
@change="onFileChange"
>
<span v-if="selectedFileName" class="mt-2 block truncate text-xs text-slate-500 dark:text-slate-400">
{{ selectedFileName }}
</span>
</label>
<label class="block">
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-200">图片路径</span>
<input
v-model="sourcePath"
class="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm dark:border-slate-700 dark:bg-slate-950"
placeholder="D:\images\sku-main.jpg"
>
</label>
<label class="block">
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-200">优化类型</span>
<select
v-model="selectedOperation"
class="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm dark:border-slate-700 dark:bg-slate-950"
>
<option v-for="option in operationOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<button
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md bg-slate-950 px-4 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-slate-950"
type="button"
:disabled="isCreating"
@click="createTask"
>
<PlayCircleOutlined />
<span>{{ isCreating ? '创建中' : '创建任务' }}</span>
</button>
</div>
</section>
<section class="rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
<h2 class="mb-4 text-base font-semibold text-slate-950 dark:text-white">
任务筛选
</h2>
<div class="space-y-3">
<select
v-model="statusFilter"
class="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm dark:border-slate-700 dark:bg-slate-950"
>
<option v-for="option in statusOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<select
v-model="operationFilter"
class="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm dark:border-slate-700 dark:bg-slate-950"
>
<option value="">全部类型</option>
<option v-for="option in operationOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<button
class="inline-flex h-10 w-full items-center justify-center gap-2 rounded-md border border-slate-300 bg-white px-4 text-sm font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-200 dark:hover:bg-slate-900"
type="button"
@click="loadTasks"
>
<ReloadOutlined />
<span>刷新列表</span>
</button>
</div>
</section>
</aside>
<div class="min-w-0 space-y-4">
<div
v-if="actionError"
class="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-200"
>
{{ actionError }}
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-left text-sm">
<thead class="bg-slate-50 text-xs uppercase text-slate-500 dark:bg-slate-950 dark:text-slate-400">
<tr>
<th class="px-4 py-3 font-medium">任务</th>
<th class="px-4 py-3 font-medium">类型</th>
<th class="px-4 py-3 font-medium">状态</th>
<th class="px-4 py-3 font-medium">耗时</th>
</tr>
</thead>
<tbody>
<tr>
<td class="px-4 py-6 text-slate-500 dark:text-slate-400" colspan="4">
暂无任务
</td>
</tr>
</tbody>
</table>
<div
v-else-if="actionMessage"
class="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-200"
>
{{ actionMessage }}
</div>
</section>
<section class="rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div class="flex flex-col gap-3 border-b border-slate-200 p-4 dark:border-slate-800 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="mb-1 text-base font-semibold text-slate-950 dark:text-white">
任务列表
</h2>
<p class="mb-0 text-sm text-slate-500 dark:text-slate-400">
{{ isLoading ? '加载中' : `共 ${tasks.length} 个任务` }}
</p>
</div>
<button
class="inline-flex h-9 items-center justify-center gap-2 rounded-md border border-slate-300 bg-white px-3 text-sm font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-200 dark:hover:bg-slate-900"
type="button"
@click="loadTasks"
>
<ReloadOutlined />
<span>刷新</span>
</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-left text-sm">
<thead class="bg-slate-50 text-xs uppercase text-slate-500 dark:bg-slate-950 dark:text-slate-400">
<tr>
<th class="w-[90px] px-4 py-3 font-medium">任务</th>
<th class="min-w-[220px] px-4 py-3 font-medium">图片</th>
<th class="w-[140px] px-4 py-3 font-medium">类型</th>
<th class="w-[120px] px-4 py-3 font-medium">状态</th>
<th class="w-[110px] px-4 py-3 font-medium">耗时</th>
<th class="w-[170px] px-4 py-3 font-medium">创建时间</th>
<th class="w-[150px] px-4 py-3 text-right font-medium">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-800">
<tr v-if="!isLoading && tasks.length === 0">
<td class="px-4 py-8 text-center text-slate-500 dark:text-slate-400" colspan="7">
暂无任务
</td>
</tr>
<tr
v-for="task in tasks"
:key="task.id"
class="cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-950"
:class="selectedTask?.id === task.id ? 'bg-slate-50 dark:bg-slate-950' : ''"
@click="selectTask(task.id)"
>
<td class="px-4 py-3 font-medium text-slate-900 dark:text-white">
#{{ task.id }}
</td>
<td class="px-4 py-3">
<div class="max-w-[320px] truncate font-medium text-slate-900 dark:text-white">
{{ baseName(task.sourcePath) }}
</div>
<div class="max-w-[320px] truncate text-xs text-slate-500 dark:text-slate-400">
{{ task.sourcePath }}
</div>
</td>
<td class="px-4 py-3 text-slate-700 dark:text-slate-200">
{{ operationLabel(task.operation) }}
</td>
<td class="px-4 py-3">
<span class="inline-flex rounded-md border px-2 py-1 text-xs font-medium" :class="statusClass(task.status)">
{{ statusLabel(task.status) }}
</span>
</td>
<td class="px-4 py-3 text-slate-600 dark:text-slate-300">
{{ formatDuration(task.durationMs) }}
</td>
<td class="px-4 py-3 text-slate-500 dark:text-slate-400">
{{ formatDate(task.createdAt) }}
</td>
<td class="px-4 py-3">
<div class="flex justify-end gap-2" @click.stop>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-slate-300 text-slate-600 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
type="button"
title="查看详情"
@click="selectTask(task.id)"
>
<EyeOutlined />
</button>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-slate-300 text-slate-600 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
type="button"
title="取消任务"
:disabled="!canCancel(task)"
@click="cancelTask(task)"
>
<StopOutlined />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div class="border-b border-slate-200 p-4 dark:border-slate-800">
<h2 class="mb-0 text-base font-semibold text-slate-950 dark:text-white">
结果预览
</h2>
</div>
<div v-if="!selectedDetail" class="p-4 text-sm text-slate-500 dark:text-slate-400">
请选择一个任务
</div>
<div v-else class="grid grid-cols-1 gap-4 p-4 lg:grid-cols-[minmax(0,1fr)_320px]">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<div class="mb-3 flex items-center gap-2 text-sm font-medium text-slate-900 dark:text-white">
<UploadOutlined />
<span>原图</span>
</div>
<div class="flex aspect-[4/3] items-center justify-center rounded-md bg-slate-100 p-4 text-center text-sm text-slate-500 dark:bg-slate-950 dark:text-slate-400">
{{ baseName(selectedTask?.sourcePath || '') }}
</div>
</div>
<div class="rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<div class="mb-3 flex items-center gap-2 text-sm font-medium text-slate-900 dark:text-white">
<EyeOutlined />
<span>优化结果</span>
</div>
<div class="flex aspect-[4/3] items-center justify-center rounded-md bg-slate-100 p-4 text-center text-sm text-slate-500 dark:bg-slate-950 dark:text-slate-400">
{{ baseName(selectedResult?.outputPath || '') }}
</div>
</div>
</div>
<div class="space-y-3 text-sm">
<div class="flex justify-between gap-3">
<span class="text-slate-500 dark:text-slate-400">任务编号</span>
<span class="font-medium text-slate-900 dark:text-white">#{{ selectedTask?.id }}</span>
</div>
<div class="flex justify-between gap-3">
<span class="text-slate-500 dark:text-slate-400">状态</span>
<span class="font-medium text-slate-900 dark:text-white">{{ statusLabel(selectedTask?.status || '') }}</span>
</div>
<div class="flex justify-between gap-3">
<span class="text-slate-500 dark:text-slate-400">类型</span>
<span class="font-medium text-slate-900 dark:text-white">{{ operationLabel(selectedTask?.operation || '') }}</span>
</div>
<div class="flex justify-between gap-3">
<span class="text-slate-500 dark:text-slate-400">耗时</span>
<span class="font-medium text-slate-900 dark:text-white">{{ formatDuration(selectedTask?.durationMs || 0) }}</span>
</div>
<div class="flex justify-between gap-3">
<span class="text-slate-500 dark:text-slate-400">原始大小</span>
<span class="font-medium text-slate-900 dark:text-white">{{ formatSize(selectedResult?.beforeSize || 0) }}</span>
</div>
<div class="flex justify-between gap-3">
<span class="text-slate-500 dark:text-slate-400">结果大小</span>
<span class="font-medium text-slate-900 dark:text-white">{{ formatSize(selectedResult?.afterSize || 0) }}</span>
</div>
<div>
<p class="mb-1 text-slate-500 dark:text-slate-400">输出路径</p>
<p class="break-all rounded-md bg-slate-50 p-2 text-slate-700 dark:bg-slate-950 dark:text-slate-200">
{{ selectedResult?.outputPath || '-' }}
</p>
</div>
<div v-if="selectedTask?.errorMessage">
<p class="mb-1 text-rose-600 dark:text-rose-300">失败原因</p>
<p class="break-all rounded-md bg-rose-50 p-2 text-rose-700 dark:bg-rose-950/40 dark:text-rose-200">
{{ selectedTask.errorMessage }}
</p>
</div>
</div>
</div>
</section>
</div>
</div>
</ModulePageShell>
</template>