AI_Painting_V2.0/CLAUDE.md
WangLeo 45a80b83ff docs: 修正 CLAUDE.md 目录结构、依赖描述与架构说明的准确性
- 修正 model-configs/ 路径(项目根目录,非 src/ 下)
- 标注 encrypt.ts 为死代码(依赖未安装,无引用)
- 修正 vue-element-plus-x 描述为"提供 Sender 输入框组件"
- 目录树新增 config/plugins.js、vite.config.js、src/assets/
- 请求拦截器路由表改为说明前缀来自环境变量
- 新增 VirtualScroller 自定义实现 vs npm 包冗余依赖说明
- 修正轮询间隔为"首次 5s + 后续 20s"
2026-06-10 16:51:36 +08:00

23 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

常用命令

pnpm dev          # 启动 Vite 开发服务器
pnpm build        # 生产构建
pnpm preview      # 预览生产构建
npx eslint .      # 代码检查(@antfu/eslint-configVue 支持,无 TypeScript

技术栈

Vue 3 (Composition API) + Vite 7 + Pinia + Vue Router + Element Plus + vue-element-plus-x(提供 Sender 输入框组件) + Less + pnpm

架构概览

AI 绘画/视频生成前端操作平台,通过 HTTP 接口对接算力调度后端suanli提交生成任务并轮询结果。

核心架构Platform Descriptor 模式。 Painting 和 Video 是两个独立的平台包通过统一的注册表动态加载。dialogBox 是通用编排壳,不包含任何平台特定逻辑。

关键目录

├── model-configs/       # 运维参考:模型参数配置 JSON 文件hailuo, ltx, vidu 等,位于项目根目录)
├── config/
│   └── plugins.js       # Vite 插件配置unplugin-auto-import + unplugin-vue-components resolver
├── vite.config.js       # 构建配置alias@→src, ~→根目录、envPrefix: ['VITE','FILE']、optimizeDeps
src/
├── main.js              # 入口:创建 Vue 应用,安装 Pinia/Router/vue-virtual-scroller
├── router/index.js       # 路由定义 + token 验证守卫
├── assets/              # 静态资源
│   ├── dialog/          # 控件区域 SVG 图标model, proportion, quantity, time, videoPattern 等)
│   └── display/         # 结果展示区 SVG 图标download, collection, delete, back, brush 等)
├── platforms/           # 平台包(独立、可插拔)
│   ├── registry.js      # 注册表registerPlatform(id, factory) + createPlatform(type)
│   ├── painting/        # Painting 平台
│   │   ├── index.js     # definePaintingPlatform()controls、state、loadConfig、buildTaskBody 等
│   │   ├── modelSelector.vue  # 模型选择器(按 API tags 分组)
│   │   ├── imageUploader.vue  # 图片上传组件
│   │   └── controls/    # 平台专用控件
│   │       ├── proportion.vue
│   │       ├── dimension.vue
│   │       ├── quality.vue
│   │       └── quantity.vue
│   └── video/           # Video 平台
│       ├── index.js     # defineVideoPlatform()
│       ├── modelSelector.vue
│       ├── imageUploader.vue
│       └── controls/
│           ├── pattern.vue
│           ├── proportion.vue
│           └── time.vue
├── stores/              # Pinia 状态管理
│   ├── user.js          # 用户认证、信息(含 sessionIdpinia persist 持久化 token
│   ├── display.js       # 生成历史列表、UI 状态(滚动、画布等)
│   └── param.js         # 参数 store当前为空
├── apis/                # HTTP API 层:纯请求封装,不含业务逻辑
│   ├── auth/            # 认证相关登录、token 校验、用户信息、验证码)
│   └── display/         # 任务创建/轮询/历史、平台模型、收藏/删除
├── components/
│   ├── dialogBox/       # 通用编排壳(核心交互入口)
│   │   └── index.vue    # <component :is> 动态渲染平台控件,不含平台分支
│   ├── Popover/         # 自定义弹出层Teleport to bodyposition:fixed + fit-content 宽度)
│   ├── Select/          # 自定义下拉选择器(分组选项,与 Popover 协调互斥开关)
│   ├── ParamGroup/      # 动态参数容器:遍历 config.params 中未被专用控件处理的 select/switch 参数
│   ├── SwitchControl/   # 纯 CSS 布尔开关(不用外部组件库)
│   ├── Img/             # 图片包装组件点击全屏查看Teleport 实现)
│   ├── virtual-scroller/   # 虚拟滚动列表自定义实现。reverse 模式用 180deg 旋转实现底部锚定slot 内容须反旋转
│   └── canvas/          # 图片画布编辑(圆/矩形选区局部重绘undo/redo
├── views/               # 页面home、login
└── utils/
    ├── request.js       # Axios 实例 + 拦截器:统一 Auth不带 Bearer+ 按前缀路由 baseURL
    ├── taskPolling.js   # 任务生成入口:组装参数 → POST 创建任务 → 首次 5s + 后续 20s HTTP 轮询直至完成/失败
    ├── modelApi.js      # 模型业务层localStorage 缓存 + 并发去重。平台模型列表(30s TTL) + 模型参数配置(60s TTL)
    ├── modelConfigHelper.js  # 模型配置共享工具syncDefaults / syncParamValues / getDimConfig / checkShowWhen
    ├── downloadImage.js # 图片/视频下载fetch → Blob → 自动文件名下载
    ├── uploadImage.js   # 图片上传工具
    ├── tokenError.js    # 认证失败处理:提示后 5 秒刷新页面
    ├── encrypt.ts       # 加密工具(⚠️ 死代码:依赖 crypto-js/jsencrypt 未安装,无任何 import 引用)
    └── auth.ts          # token 存取工具localStorage

Platform Descriptor 模式

每个平台通过 defineXxxPlatform() 工厂函数返回标准接口:

const platform = {
  id: 'painting',                    // 平台唯一标识
  label: 'AI绘画2026',               // 显示名称
  ModelSelector: markRaw(Component),  // 模型选择器组件
  modelSelectorProps: null,           // 模型选择器的额外 props可为函数
  controls: [{                        // 控件描述符数组
    name: 'proportion',
    component: markRaw(Component),
    beforeModel: false,               // 设为 true 则渲染在 ModelSelector 之前
    show: (config) => boolean,        // 根据 model config 决定是否显示
    props: (config) => ({ ... })      // 根据 model config 生成 v-model props
  }],
  ImageUploader: markRaw(Component),  // 图片上传组件(可为 null
  state: { ... },                     // 平台所有响应式状态
  model, modelType,                   // 当前模型/类型ref
  modelConfig,                        // 当前模型参数配置ref
  promptPlaceholder,                  // 提示词占位文本ref

  async loadModels() { ... },         // 获取模型列表
  async loadConfig(modelName, modelType) { ... },  // 加载模型参数配置modelName 可以是 UUID/name/display_name
  getDefaultModel() { ... },          // 返回默认模型标识(可返回 '' 交由 modelSelector 自动纠错)
  imageUploadLimit() { ... },         // 返回图片上传槽位数(应累加所有 imageUpload 参数的 maxCount
  validateBeforeSubmit() { ... },     // 提交前校验,返回 null 表示通过
  getUploaderBindings() { ... },      // 图片上传组件的绑定参数modelType + imagesCount
  showImageUploader() { ... },        // 是否显示图片上传区域
  isImageRequired() { ... },          // 图片是否必填
  buildTaskBody({ prompt, referenceImages }) { ... },  // 构造扁平 modelParams需将 referenceImages 映射到 imageUpload 参数
  fillFromResult(resultData) { ... },  // 从历史结果回填参数
}

控件通过 ctrl.props(config) 接收 v-model 绑定对:

props: (config) => ({
  modelValue: proportion.value,
  'onUpdate:modelValue': (v) => { proportion.value = v },
  // ...
})

自注册: 每个平台文件底部调用 registerPlatform('Painting', definePaintingPlatform),在 import 时自动注册。dialogBox 通过 createPlatform(props.type) 获取实例。

dialogBox 通用编排壳

src/components/dialogBox/index.vue 是纯编排组件,不含任何平台分支:

  • 平台切换const platform = computed(() => createPlatform(props.type)),切换时重置默认模型并加载模型列表
  • 控件渲染visibleControls = platform.controls.filter(c => c.show(getCurrentConfig())),按 beforeModel 拆分为两组,beforeModel 控件 → ModelSelector → 其余控件,用 <component :is> + v-bind="ctrl.props(...)" 渲染
  • 配置获取getCurrentConfig() 返回 platform.modelConfig?.value
  • 模型切换watch([model, modelType])platform.loadConfig()syncDefaults() 将 schema 写入响应式 state → controls 的 show()/props() 读取 state → visibleControls 自动更新 → UI 重渲染
  • 任务发起handleStart() 调用 platform.validateBeforeSubmit()platform.buildTaskBody()generate()
  • 参数回填fillParamsFromResult() 委托给 platform.fillFromResult()

统一数据流Painting + Video

两个平台现已统一,通过后端 API 获取模型参数配置:

  1. 用户选择模型 → platform.loadConfig(modelName, modelType) → 调用 GET /suanli/v1/models/:id/config(优先 60s 缓存)加载参数 schema
  2. 参数 schema 驱动 controls 渲染 UI用户填写参数
  3. 用户点击发送 → handleStart()platform.buildTaskBody({ prompt, referenceImages }) 返回扁平 modelParams
  4. taskPolling.js 直接读取 data.bodygetModelId(type, modelName) 查找 UUID → POST /suanli/v1/tasksX-Session-Id header
  5. 20s 间隔轮询直至完成/失败

模型标识与查找

API 返回的模型对象包含三个标识字段:

字段 示例 用途
id e7e1e743-7621-403d-b8fb-2b7f4fa1b4fc UUID 主键,唯一且稳定,用于 API 调用和内部追踪
name vidu-text-to-video-q3-turbo 内部标识名,通常也唯一
display_name Vidu q3-turbo 用户可见的显示名,可能重复(不同 pattern 下的同名模型)

Video 平台使用 idUUID作为 model.value,避免 display_name 冲突导致的模型查找错误。modelSelector.vuemodelGroupsvalue 设为 m.idlabel 仍用 display_name 显示。

getModelId(type, modelName) 查找优先级:m.id === modelNamem.name === modelNamem.display_name === modelName,向下兼容旧的 name/display_name 调用。

Video 的 getDefaultModel() 返回 '',不硬编码 UUID模型列表加载后由 modelSelector 的 watcher 自动选择第一个可用模型。

模型参数配置

模型参数配置通过后端 API GET /suanli/v1/models/:id/config 获取60s localStorage 缓存),替代了旧的本地硬编码。参数通过 ui 字段映射到前端控件:

ui 控件 说明
textarea Sender 内置 textarea prompt 输入框
proportion PaintingProportion / VideoProportion 比例选择 Popoveroptionscustom 时可自定义宽高)
resolution proportion 控件内部 分辨率子选项,与 proportion 共用 Popover
dimension DimensionInput 组合模式:单字段 "W*H" 格式,后端传 dimension.separator,前端生成 parse/format
dimensionWidth + dimensionHeight DimensionInput 拆分模式:两个独立字段(含 min/max),共享比例锁
number proportion 控件内部 自定义宽高(如 Flux 的 customWidth/customHight配合 showWhen 条件显示
select Select(通过 ParamGroup 或专用控件) 通用下拉。专用控件直接使用 Select其余由 ParamGroup 动态渲染
switch SwitchControl(通过 ParamGroup 布尔开关,纯 CSS 实现,不用外部组件库
quantity Quantity 生成数量,options 必须为数字数组,上限由 Math.max() 派生
imageUpload ImageUploader 参考图上传,maxCount 控制上限
hidden 静默写入默认值

dimension 模式区分getDimConfig()(来自 modelConfigHelper.js)自动检测 ui: 'dimension'(组合)或 ui: 'dimensionWidth'(拆分),两种模式共用 DimensionInput 组件。

条件显示showWhen: { aspectRatio: 'custom' } 使参数仅在 proportion 选 custom 时显示。checkShowWhen() 在 controls 的 show() 回调中调用,因直接读取 reactive 的 paramValues[key]computed 自动追踪依赖。

modelConfigHelper 共享工具

src/utils/modelConfigHelper.js 提供 Painting/Video 双平台共用的 4 个纯函数:

函数 说明
syncDefaults(config, state) 将 API 返回的 config 同步到响应式 state。处理 paramValues 初始化、专用 ref 同步proportion/resolution/quantity/quality/duration/dimensionpromptPlaceholder 同步。resolution 同时匹配 ui:"resolution"ui:"select"
syncParamValues(config, state) buildTaskBody 前将专用 refproportion/resolution/quantity/quality/duration/dimension回写到 paramValues
getDimConfig(config) 检测 combined/split 模式
checkShowWhen(param, paramValues) 检查 showWhen 条件

state 参数对象需包含 modelConfig, paramValues, proportion, resolution, quantity, quality, customWidth, customHight, dimWidth, dimHeight, promptPlaceholder。Video 平台额外包含 duration(用于 syncDefaults 同步时长默认值)。各平台通过包装函数适配。

ParamGroup 动态参数渲染

当 API 返回的 params 中包含无专用控件的类型(如 selectswitch),由 ParamGroup 统一处理:

  • 容器定位:作为平台 controls 数组的最后一项,show() 检查是否存在未被 handledUis 覆盖的参数
  • handledUis['textarea', 'proportion', 'resolution', 'dimension', 'dimensionWidth', 'dimensionHeight', 'quantity', 'imageUpload', 'hidden', 'number'] — 这些类型的参数由专用控件处理ParamGroup 跳过
  • excludeNames prop:额外按参数名排除(如 resolutionduration),因为 VideoProportion/Time 已处理这些参数(即使其 ui 类型不在 handledUis 中)
  • 双层过滤:平台 show() 做第一层(判断是否渲染 ParamGroupParamGroup 内部 dynamicParams 做第二层(判断具体渲染哪些参数)。两层必须保持一致,否则会出现重复控件
// Video 平台的 ParamGroup 配置
{
  name: 'paramGroup',
  component: markRaw(ParamGroup),
  show: (config) => {
    // 第一层:有未被处理的参数才显示
    return config.params.some((p) => {
      if (handledUis.includes(p.ui)) return false
      if (p.name === 'resolution') return false
      if (p.name === 'duration') return false
      return true
    })
  },
  props: (config) => ({
    config,
    paramValues,
    excludeNames: ['resolution', 'duration']  // 第二层过滤
  })
}

组件规范

禁止在项目组件中使用外部 UI 库Element Plus 等),图标除外。自定义组件使用项目自研的 SelectPopover 或纯 CSS 实现。SwitchControl 即为一例——纯 CSS 滑动开关,不依赖 el-switch

$attrs 穿透注意

向子组件传递的 prop 如果子组件未声明,会通过 $attrs 穿透到根元素。所有通过 v-model 传递的值,子组件必须声明对应的 prop。

API 层设计原则

  • src/apis/ 只做纯 HTTP 调用(service.get/post/delete不含缓存、localStorage、业务逻辑
  • 缓存、数据转换等业务逻辑放在 src/utils/

关键注意事项

  • sessionId 来自登录接口返回的 userInfo.sessionId,存储在 useUserStore().userInfo 中。taskPolling.js 必须使用该值,禁止随机生成。
  • X-Session-Id 自定义 header 需要 nginx 在 /suanli/ location 的 Access-Control-Allow-Headers 中加入,否则 POST 请求会触发 CORS 预检失败。
  • 模型列表缓存modelApi.jsfetchPlatformModels 使用 localStorage 30 秒 TTL + pendingRequests Map 并发去重。
  • 模型配置缓存modelApi.jsgetModelConfig 使用 localStorage 60 秒 TTL + pendingConfigRequests Map 并发去重。loadModels() 会在获取模型列表后调用 preloadModelConfigs 批量预加载。
  • 平台包预加载dialogBox 顶层 import Painting 和 Video 平台包,触发自注册,确保首次使用时 registry 已就绪。
  • VirtualScroller 实现说明:虽然 package.json 安装了 vue-virtual-scrollernpm 包)并在 main.js 中全局注册,但 display/index.vue<VirtualScroller> 实际使用的是 src/components/virtual-scroller/VirtualScroller.vue 自定义实现(通过 unplugin-vue-components 自动注册的同名组件遮蔽 npm 包)。vue-virtual-scroller npm 包当前为冗余依赖。
  • VirtualScroller 反旋转:组件用外层 transform: rotate(180deg) 实现 reverse 底部锚定。所有作为 slot 插入的内容必须在根元素加 transform: rotate(180deg) 反旋转,否则文字/图片会颠倒显示。参考 src/views/home/display/components/set.vue:2
  • VirtualScroller 存在独立测试页src/components/virtual-scroller/test.html + test-data.js,可用于验证虚拟滚动行为。
  • VirtualScroller 残留备份文件src/components/virtual-scroller/VirtualScroller copy.vue 是备份副本,不应被引用或修改。
  • Element Plus <el-input type="textarea"> 不响应 autosize 动态变化ElInput 只在 mount 时读取 autosizeprop 更新时不会重新计算高度。因此 dialogBoxSender 必须绑定 :key="useDisplay.Sender_variant",通过强制重挂载来使新的 autosizeminRows/maxRows)生效。
  • Video modelSelector pattern→modelType 映射getModelType()modelSelector.vue 中将 pattern tag 映射为 modelType——"文生视频"→text"首尾帧"→image"数字人"→digitalHuman。该值用于 imageUploader 的 showEndFrame 判断和上传槽位数量。
  • Video imageUploadLimit() 累加逻辑:对于有多个 imageUpload 参数的模型(如首尾帧模型的 firstImageUrl + lastImageUrl),应累加所有 imageUpload 参数的 maxCount,而非只取第一个。否则首尾帧模型只显示一个上传槽位,尾帧上传无法触发。
  • buildTaskBody 参考图映射Video 平台在 buildTaskBody 中需将 referenceImages 按索引顺序写入 imageUpload 参数(referenceImages[0]firstImageUrlreferenceImages[1]lastImageUrl),否则图片数据不会包含在任务请求中。

VirtualScroller 坐标系统

VirtualScroller 使用 180deg 旋转实现 reverse 模式。handleScroll 内部将容器物理坐标映射为"页面逻辑坐标"后通过 emit('scroll', {...}) 发出:

字段 计算 含义
distanceToPageTop scrollHeight - scrollTop - clientHeight 距离页面顶部(旧内容端)的距离
distanceToPageBottom scrollTop 距离页面底部(新内容端)的距离
isAtPageTop distanceToPageTop <= 0 滚动到页面最顶部
isAtPageBottom distanceToPageBottom <= 0 滚动到页面最底部

注意distanceToPageBottom = scrollTop(不是 distanceToPageTop),表示从底部向上滚动的距离。判断"用户向上滚动了多少"应使用此字段。

输入框滚动收缩机制

滚动列表时提示词输入框自动收缩为一行,这是跨 4 个文件的响应式链路:

  1. VirtualScroller 发出 scroll 事件(含 isAtPageBottomdistanceToPageBottom 等)
  2. display/index.vuehandleScroll 根据滚动位置切换 useDisplay.Sender_variant
    • isAtPageBottom(底部)→ 'updown'展开5-9 行)
    • distanceToPageBottom >= 350(已滚离底部 350px'default'收缩1 行)
    • 中间状态 → 'updown'(展开)
  3. display.js store 中 Sender_variant 是响应式 ref
  4. dialogBox/index.vueautoSizeConfig 计算属性读取 Sender_variant,返回 { minRows, maxRows }
  5. Sender 组件通过 :key="useDisplay.Sender_variant" 强制重挂载(因为 Element Plus ElInput 不支持 autosize 动态更新),新实例以正确的行数初始化

滚动阈值 350px 对应 VirtualScrollerbottomPlaceholderHeight

接口速查

函数 端点 用途
requestCreateTask POST /suanli/v1/tasks 创建任务(带 X-Session-Id header
requestTaskStatus GET /suanli/v1/tasks/:id 查询单个任务状态
requestTaskHistory GET /suanli/v1/tasks/history 历史任务列表(支持 user_id/platform_code/page/pageSize
fetchPlatformModels GET /suanli/v1/platforms/:code/models 获取平台模型列表(返回 {id(UUID), name, display_name, tags, disabled?})。id=UUID 主键,name=内部标识,display_name=用户可见名
requestModelConfigsBatch POST /suanli/v1/models/configs 批量获取模型配置body: { modelIds: [...] }
requestModelConfig GET /suanli/v1/models/:id/config 单条模型配置60s 缓存优先)
cancelOrCollect POST /collect/toggle 收藏/取消收藏
deleteGenerateHistory DELETE /taskRecordHistory/delete 删除历史记录

任务响应格式

// GET /suanli/v1/tasks/:id 返回结构
{
  "code": 0,
  "data": {
    "task_id": "uuid",
    "status": "completed",       // queued | processing | completed | failed
    "outputs": [                  // ⚠️ 扁平数组,不是 { images: [...] }
      { "url": "https://...", "type": "png" }
    ],
    "vendor_error": "..."        // 仅 failed 时有值
  }
}

请求拦截器路由

拦截器统一设置 Authorization: <token>(不带 Bearer 前缀),根据请求 URL 前缀(由环境变量 VITE_API_TASK_PREFIX / VITE_API_PAY_PREFIX / VITE_API_AIGC_PREFIX 定义)切换后端:

URL 前缀(由环境变量定义) 目标环境变量
VITE_API_TASK_PREFIX 对应前缀 VITE_API_TASK_TARGET
VITE_API_PAY_PREFIX 对应前缀 VITE_API_PAY_TARGET
VITE_API_AIGC_PREFIX 对应前缀 VITE_API_AIGC_TARGET
其他 VITE_API_BASE_URL(默认)

注意:前缀字符串本身来自环境变量(如 VITE_API_TASK_PREFIX=/suanli),不是硬编码。request.js 在初始化时读取这些变量,构建 prefix→target 映射表。

平台编码映射

类型 平台编码
Painting ai_painting_talk
Video ai_video_talk

映射函数 getPlatformCode() 位于 utils/modelApi.js

自动导入

  • unplugin-auto-import:自动导入 Vue/Router/Pinia API
  • unplugin-vue-components:自动注册 src/components/ 下的组件和 Element Plus 组件,生成 components.d.ts(勿手动编辑)
  • Element Plus 图标通过 unplugin-icons 按需加载