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 -1
View File
@@ -8,7 +8,7 @@
- 后端:Go 1.23.0、Wails v3.0.0-alpha.9、SQLite
- 前端:Vue 3、TypeScript、Vite、Tailwind CSS、Ant Design Vue
- 当前功能:多语言、主题切换、统一设置 schema、主窗口状态恢复、本地配置、快捷键、本地登录 provider、本地模拟图片优化任务、审计日志、运行日志、P0 数据统计服务、OCR 示例
- 当前功能:多语言、主题切换、统一设置 schema、主窗口状态恢复、本地配置、快捷键、本地登录 provider、商品图片 AI 优化页面、本地模拟图片优化任务、审计日志、运行日志、P0 数据统计服务、OCR 示例
- 目标模板模块:登录、设置、主业务、审计日志、运行日志、数据统计、常用组件示例
## 环境准备
+2 -2
View File
@@ -50,7 +50,7 @@ frontend/src
- `AuthService`:登录、退出、会话读取;内部通过 `AuthProvider` 接入本地固定账号或真实后端。当前 `LocalAuthProvider` 可用,`RemoteAuthProvider` 保留边界并返回未配置错误。
- `AppConfigService`:通过统一设置 schema 初始化和校验主题、语言、窗口行为、快捷键展示、数据目录、日志策略和登录配置。
- `ImageOptimizationService`:商品图片 AI 优化任务模型和本地模拟任务创建已可用。
- `ImageOptimizationService`:商品图片 AI 优化任务模型和本地模拟任务创建已可用,页面已接入任务创建、列表和结果预览。
- `AuditLogService`:审计日志记录和查询已可用,登录、设置变更和图片任务创建会写审计日志。
- `AppLogService`:运行日志记录和查询已可用,图片任务创建失败和服务错误会写运行日志。
- `MetricsService`:P0 统计指标和最近 N 天趋势已可查询,当前直接聚合 `image_tasks` 和 `audit_logs`。
@@ -63,7 +63,7 @@ frontend/src
- `frontend/src/layouts/`:目标目录,放登录布局和主应用布局。
- `frontend/src/modules/auth/`:目标目录,放登录页面、会话状态和 provider 调用。
- `frontend/src/modules/settings/`:目标目录,放设置页和设置项。
- `frontend/src/modules/image-optimizer/`:目标目录,放商品图片 AI 优化样例。
- `frontend/src/modules/image-optimizer/`:放已接入的商品图片 AI 优化样例页面。
- `frontend/src/modules/logs/`:目标目录,放审计日志和运行日志页面。
- `frontend/src/modules/statistics/`:放统计卡片和趋势图。
- `frontend/src/modules/components-demo/`:目标目录,放常用组件示例。
+1 -1
View File
@@ -40,7 +40,7 @@
| T-020 | 建立商品图片 AI 优化任务模型 | T-013 | `image_tasks`、`image_task_results` 迁移和基础测试通过;可创建模拟任务 | DONE |
| T-021 | 实现审计日志与运行日志边界 | T-013 | 审计日志和运行日志分开;登录、设置变更、任务创建、任务失败可记录 | DONE |
| T-022 | 实现 P0 数据统计 | T-020 | 今日处理图片数、成功率、平均耗时、失败数、今日操作日志数、7 天趋势可查询 | DONE |
| T-023 | 实现商品图片 AI 优化页面 | T-020 | 上传/选择图片、任务列表、状态、结果预览、失败提示路径可用 | TODO |
| T-023 | 实现商品图片 AI 优化页面 | T-020 | 上传/选择图片、任务列表、状态、结果预览、失败提示路径可用 | DONE |
## Phase 3 · 组件、质量与发布
+14 -3
View File
@@ -4,9 +4,9 @@
## 当前阶段
项目处于可复用桌面应用模板的 Phase 1 基础模块建设阶段。目标已经从迁移后的基础工程调整为“Windows-first、可扩展到其他平台的桌面端应用模板”。
项目处于可复用桌面应用模板的 Phase 2 业务样例与日志统计收尾阶段。目标已经从迁移后的基础工程调整为“Windows-first、可扩展到其他平台的桌面端应用模板”。
当前代码已从迁移后的 Wails/Vue/SQLite 基线推进到主应用路由骨架、登录 provider 边界、统一设置 schema、主窗口状态恢复、商品图片 AI 优化任务模型、日志服务边界和 P0 数据统计阶段,已具备设置、多语言、主题、本地配置、热键、托盘、OCR 示例、模板级模块入口、本地登录能力、本地模拟图片优化任务能力、审计日志、运行日志和 P0 统计查询能力。统计页面仍是骨架,尚未展示真实统计数据。
当前代码已从迁移后的 Wails/Vue/SQLite 基线推进到主应用路由骨架、登录 provider 边界、统一设置 schema、主窗口状态恢复、商品图片 AI 优化页面、日志服务边界和 P0 数据统计阶段,已具备设置、多语言、主题、本地配置、热键、托盘、OCR 示例、模板级模块入口、本地登录能力、本地模拟图片优化任务能力、业务样例页面、审计日志、运行日志和 P0 统计查询能力。统计页面仍是骨架,尚未展示真实统计数据。
## 已确认事实
@@ -40,6 +40,7 @@
- 已实现主窗口状态持久化:主窗口普通尺寸和最大化状态会写回 `appconfig`,下次启动恢复;第二窗口不参与该逻辑。
- 已实现 ImageOptimizationService:`CreateTask`、`ListTasks`、`GetTask`、`CancelTask`,当前 provider 为本地模拟实现。
- 已实现 `image_tasks` 和 `image_task_results` 表,模拟任务可写入成功状态、耗时、输出路径和前后文件大小。
- 已实现商品图片 AI 优化页面:支持文件选择、手动图片路径、创建任务、筛选任务、任务列表、状态标签、结果预览和失败提示。
- 已实现 AuditLogService:`Record`、`List`,登录、设置变更和图片任务创建会写入 `audit_logs`。
- 已实现 AppLogService:`Record`、`List`,图片任务创建失败和服务内部错误会写入 `app_logs`。
- 已实现 MetricsService:`GetDashboardMetrics`、`GetProcessingTrend`,可查询今日处理量、成功率、平均耗时、失败数、今日审计日志数和最近 N 天趋势。
@@ -78,6 +79,7 @@ wails3 build
- 模板范围容易膨胀,必须按任务小步实现。
- Wails v3 alpha 版本需要保持 CLI、Go 模块和前端 runtime 匹配。
- 真实后端登录 provider、真实 AI provider 尚未实现;统计页面展示仍待后续扩展。
- WebView 文件选择器不一定返回绝对路径,图片优化页面已保留手动路径输入。
- OCR 和热键依赖平台实现,跨平台修改需要分别验证。
- 当前前端以构建检查为主,暂未引入专门的前端单元测试。
- bindings 生成依赖本机安装的 `wails3`。
@@ -192,9 +194,18 @@ wails3 build
- 已重新生成 Wails `.js` bindings,新增 `metricsservice.js`。
- `go test ./...`、`npm run build`、`go build -o bin\cmbone.exe .` 和 `cmd /c build.bat` 通过。
同日完成商品图片 AI 优化页面:
- `frontend/src/modules/image-optimizer/ImageOptimizerPage.vue` 接入 `ImageOptimizationService` bindings。
- 页面支持选择图片、手动输入图片路径、选择优化类型、创建模拟任务。
- 页面支持按状态和优化类型筛选任务,展示任务列表、状态、耗时和创建时间。
- 页面支持查看任务详情,展示原图/结果占位预览、输出路径、文件大小、失败原因。
- WebView 文件选择器不返回绝对路径时,页面会提示用户粘贴绝对路径。
- `go test ./...`、`npm run build`、`go build -o bin\cmbone.exe .` 和 `cmd /c build.bat` 通过。
## 下一步
从 [`06-tasks.md`](06-tasks.md) 领取第一个 `TODO` 任务。目前建议先做 `T-023`:实现商品图片 AI 优化页面。
从 [`06-tasks.md`](06-tasks.md) 领取第一个 `TODO` 任务。目前建议先做 `T-030`:建立常用组件示例页。
## 当前 blocker
+2 -2
View File
@@ -71,7 +71,7 @@
| 登录 | `AuthService` | 已实现本地固定账号 provider,真实后端 provider 保留边界 |
| 审计日志 | `AuditLogService` | 已实现记录和查询;登录、设置变更、图片任务创建已接入 |
| 运行日志 | `AppLogService` | 已实现记录和查询;图片任务失败和服务错误已接入 |
| 商品图片 AI 优化 | `ImageOptimizationService` | 已实现本地模拟任务模型;页面闭环待 `T-023` |
| 商品图片 AI 优化 | `ImageOptimizationService` | 已实现本地模拟任务模型和页面闭环 |
| 数据统计 | `MetricsService` | 已实现后端聚合;统计页面展示待后续扩展 |
## 热键
@@ -93,7 +93,7 @@
| `frontend/src/components/template/ModulePageShell.vue` | 模块页共享壳 | 模块标题、状态和摘要卡片 |
| `frontend/src/modules/auth/LoginPage.vue` | 登录页 | 调用 AuthService 登录并处理错误 |
| `frontend/src/modules/dashboard/DashboardPage.vue` | 工作台骨架 | P0 指标和近期任务入口 |
| `frontend/src/modules/image-optimizer/ImageOptimizerPage.vue` | 主业务骨架 | 商品图片 AI 优化任务入口 |
| `frontend/src/modules/image-optimizer/ImageOptimizerPage.vue` | 主业务页面 | 商品图片 AI 优化任务创建、列表、状态和结果预览 |
| `frontend/src/modules/logs/LogsPage.vue` | 日志页骨架 | 审计日志和运行日志入口 |
| `frontend/src/modules/statistics/StatisticsPage.vue` | 统计页骨架 | P0 指标和 7 天趋势入口 |
| `frontend/src/modules/settings/SettingsPage.vue` | 设置页 | 复用存量设置组件,并展示后端统一设置 schema |
+2 -2
View File
@@ -6,7 +6,7 @@
| --- | --- | --- |
| 项目结构 | B | 已从模板整理为普通工程,目录职责基本清楚 |
| 产品定位 | B | 已校准为 Windows-first 可复用桌面应用模板,后续需要代码落地 |
| 后端服务 | B+ | 现有服务边界清晰,登录 provider、设置 schema、日志、统计和业务样例服务已落地,真实 provider 和页面闭环仍需推进 |
| 后端服务 | B+ | 现有服务边界清晰,登录 provider、设置 schema、日志、统计和业务样例服务已落地,真实 provider 和统计页面仍需推进 |
| 本地存储 | B | SQLite 初始化和基础测试已存在,目标新增表需要迁移和测试 |
| 前端结构 | B+ | Vue 组件按功能拆分,窗口、布局策略和目标路由骨架已落地,设置页已接入后端 schema |
| 构建验证 | B | Go 测试、前端构建、后端编译可运行 |
@@ -45,5 +45,5 @@
## 改进方向
- 搭建登录、Dashboard、图片优化、日志、统计、设置、组件示例等目标路由骨架。
- 为商品图片 AI 优化样例和统计模块补充真实页面闭环。
- 为统计模块补充真实页面闭环。
- 为 Wails bindings 生成流程补充完整操作说明。
+2 -2
View File
@@ -11,7 +11,7 @@
| `#/` | `frontend/src/layouts/MainLayout.vue` | 主窗口 | 重定向到 `#/dashboard` |
| `#/login` | `frontend/src/modules/auth/LoginPage.vue` | 主窗口 | 登录入口,已接入本地固定账号 provider,真实后端 provider 保留边界 |
| `#/dashboard` | `frontend/src/modules/dashboard/DashboardPage.vue` | 主窗口 | 工作台,展示 P0 指标和近期任务入口 |
| `#/image-optimizer` | `frontend/src/modules/image-optimizer/ImageOptimizerPage.vue` | 主窗口 | 商品图片 AI 优化业务样例骨架 |
| `#/image-optimizer` | `frontend/src/modules/image-optimizer/ImageOptimizerPage.vue` | 主窗口 | 商品图片 AI 优化业务样例,已接入任务创建、列表和结果预览 |
| `#/logs` | `frontend/src/modules/logs/LogsPage.vue` | 主窗口 | 审计日志和运行日志统一入口骨架 |
| `#/statistics` | `frontend/src/modules/statistics/StatisticsPage.vue` | 主窗口 | 第一批统计指标和趋势入口;后端 `MetricsService` 已可查询 |
| `#/settings` | `frontend/src/modules/settings/SettingsPage.vue` | 主窗口 | 设置中心,复用存量设置组件并展示统一设置 schema |
@@ -26,7 +26,7 @@
| 路由 | 页面 | 职责 |
| --- | --- | --- |
| `#/login` | 登录页 | 已接入本地固定账号登录;真实后端登录待后续接入 |
| `#/image-optimizer` | 商品图片 AI 优化 | 后端任务模型已接入;`T-023` 接入页面任务列表、状态、结果和失败原因 |
| `#/image-optimizer` | 商品图片 AI 优化 | 已接入页面任务列表、状态、结果和失败原因 |
| `#/logs` | 操作日志 | 后端审计日志和运行日志服务已接入;页面列表待后续扩展 |
| `#/statistics` | 数据统计 | 后端 `MetricsService` 已接入趋势、成功率、耗时和失败任务;页面展示待后续扩展 |
| `#/settings` | 设置中心 | 已接入统一设置 schema,窗口配置已用于主窗口尺寸恢复 |
@@ -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>
+18
View File
@@ -306,3 +306,21 @@
- 当前 PowerShell 会话 PATH 没有 Go bin,直接运行 `wails3 generate bindings` 会找不到命令;已按项目脚本方式临时补 PATH 后生成绑定。
- `cmd /c build.bat` 仍提示 `wails3 build` 是 `wails3 task` 的 alias。
- 前端构建仍提示 Browserslist 数据过期和 chunk 大小警告。
### 实现商品图片 AI 优化页面
- 任务:完成 `T-023`,上传/选择图片、任务列表、状态、结果预览、失败提示路径可用。
- 结果:
- `frontend/src/modules/image-optimizer/ImageOptimizerPage.vue` 接入 `ImageOptimizationService` bindings。
- 页面支持选择图片、手动输入图片路径、选择优化类型和创建模拟任务。
- 页面支持按状态和优化类型筛选任务,展示任务编号、图片路径、类型、状态、耗时和创建时间。
- 页面支持查看任务详情,展示原图/结果占位预览、输出路径、前后文件大小和失败原因。
- WebView 文件选择器不返回绝对路径时,页面提示用户粘贴图片绝对路径。
- 验证:
- `cd frontend && npm run build` 通过。
- `go test ./...` 通过。
- `go build -o bin\cmbone.exe .` 通过。
- `cmd /c build.bat` 通过。
- 非阻塞警告:
- `cmd /c build.bat` 仍提示 `wails3 build` 是 `wails3 task` 的 alias。
- 前端构建仍提示 Browserslist 数据过期和 chunk 大小警告。