feat: build image optimizer page
This commit is contained in:
@@ -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 示例
|
||||
- 目标模板模块:登录、设置、主业务、审计日志、运行日志、数据统计、常用组件示例
|
||||
|
||||
## 环境准备
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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 大小警告。
|
||||
|
||||
Reference in New Issue
Block a user