# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## 常用命令 ```bash pnpm dev # 启动 Vite 开发服务器 pnpm build # 生产构建 pnpm preview # 预览生产构建 npx eslint . # 代码检查(@antfu/eslint-config,Vue 支持,无 TypeScript) ``` ## 技术栈 Vue 3 (Composition API) + Vite 7 + Pinia + Vue Router + Element Plus + `vue-element-plus-x`(多媒体编辑) + Less + pnpm ## 架构概览 AI 绘画/视频生成前端操作平台,通过 HTTP 接口对接算力调度后端(suanli),提交生成任务并轮询结果。 **核心架构:Platform Descriptor 模式。** Painting 和 Video 是两个独立的平台包,通过统一的注册表动态加载。dialogBox 是通用编排壳,不包含任何平台特定逻辑。 ### 关键目录 ``` src/ ├── main.js # 入口:创建 Vue 应用,安装 Pinia/Router/VueVirtualScroller ├── router/index.js # 路由定义 + token 验证守卫 ├── 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 # 用户认证、信息(含 sessionId),pinia persist 持久化 token │ ├── display.js # 生成历史列表、UI 状态(滚动、画布等) │ └── param.js # 参数 store(当前为空) ├── apis/ # HTTP API 层:纯请求封装,不含业务逻辑 │ ├── auth/ # 认证相关(登录、token 校验、用户信息、验证码) │ └── display/ # 任务创建/轮询/历史、平台模型、收藏/删除 ├── components/ │ ├── dialogBox/ # 通用编排壳(核心交互入口) │ │ └── index.vue # 动态渲染平台控件,不含平台分支 │ ├── Popover/ # 自定义弹出层(Teleport to body,position:fixed + fit-content 宽度) │ ├── Select/ # 自定义下拉选择器(分组选项,与 Popover 协调互斥开关) │ ├── Img/ # 图片包装组件(点击全屏查看,Teleport 实现) │ ├── virtual-scroller/ # 虚拟滚动列表(自定义实现)。reverse 模式用 180deg 旋转实现底部锚定,slot 内容须反旋转 │ └── canvas/ # 图片画布编辑(圆/矩形选区,局部重绘,undo/redo) ├── views/ # 页面(home、login) └── utils/ ├── request.js # Axios 实例 + 拦截器:统一 Auth(不带 Bearer)+ 按前缀路由 baseURL ├── taskPolling.js # 任务生成入口:组装参数 → POST 创建任务 → 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 # 加密工具(Base64/MD5/RSA/AES,依赖 crypto-js、jsencrypt) └── auth.ts # token 存取工具(localStorage) ``` ### Platform Descriptor 模式 每个平台通过 `defineXxxPlatform()` 工厂函数返回标准接口: ```js const platform = { id: 'painting', // 平台唯一标识 label: 'AI绘画2026', // 显示名称 ModelSelector: markRaw(Component), // 模型选择器组件 modelSelectorProps: null, // 模型选择器的额外 props(可为函数) controls: [{ // 控件描述符数组 name: 'proportion', component: markRaw(Component), 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) { ... }, // 加载模型参数配置 getDefaultModel() { ... }, // 返回默认模型名 validateBeforeSubmit() { ... }, // 提交前校验,返回 null 表示通过 getUploaderBindings() { ... }, // 图片上传组件的绑定参数 showImageUploader() { ... }, // 是否显示图片上传区域 isImageRequired() { ... }, // 图片是否必填 buildTaskBody({ prompt, referenceImages }) { ... }, // 构造扁平 modelParams fillFromResult(resultData) { ... }, // 从历史结果回填参数 } ``` 控件通过 `ctrl.props(config)` 接收 v-model 绑定对: ```js 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()))`,用 `` + `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.body` → `getModelId(type, modelName)` 查找 UUID → POST `/suanli/v1/tasks`(`X-Session-Id` header) 5. 20s 间隔轮询直至完成/失败 ### 模型参数配置 模型参数配置通过后端 API `GET /suanli/v1/models/:id/config` 获取(60s localStorage 缓存),替代了旧的本地硬编码。参数通过 `ui` 字段映射到前端控件: | `ui` 值 | 控件 | 说明 | |---------|------|------| | `textarea` | Sender 内置 textarea | prompt 输入框 | | `proportion` | `PaintingProportion` / `VideoProportion` | 比例选择 Popover(`options` 含 `custom` 时可自定义宽高) | | `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` | 通用下拉(如 quality) | | `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。增强项:`dimension.separator` → parse/format 生成、`promptPlaceholder` 同步 | | `syncParamValues(config, state)` | 在 `buildTaskBody` 前将专用 ref 回写到 `paramValues` | | `getDimConfig(config)` | 检测 combined/split 模式 | | `checkShowWhen(param, paramValues)` | 检查 `showWhen` 条件 | `state` 参数对象需包含 `modelConfig, paramValues, proportion, resolution, quantity, quality, customWidth, customHight, dimWidth, dimHeight, promptPlaceholder`。各平台通过包装函数(如 `syncDefaults(config) { _syncDefaults(config, paintingState) }`)适配。 ### `$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.js` 中 `fetchPlatformModels` 使用 localStorage 30 秒 TTL + `pendingRequests` Map 并发去重。 - **模型配置缓存**:`modelApi.js` 中 `getModelConfig` 使用 localStorage 60 秒 TTL + `pendingConfigRequests` Map 并发去重。`loadModels()` 会在获取模型列表后调用 `preloadModelConfigs` 批量预加载。 - **平台包预加载**:dialogBox 顶层 `import` Painting 和 Video 平台包,触发自注册,确保首次使用时 registry 已就绪。 - **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`,可用于验证虚拟滚动行为。 - **Element Plus `` 不响应 `autosize` 动态变化**:`ElInput` 只在 mount 时读取 `autosize`,prop 更新时不会重新计算高度。因此 `dialogBox` 中 `Sender` 必须绑定 `:key="useDisplay.Sender_variant"`,通过强制重挂载来使新的 `autosize`(`minRows`/`maxRows`)生效。 ### 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` 事件(含 `isAtPageBottom`、`distanceToPageBottom` 等) 2. `display/index.vue` 的 `handleScroll` 根据滚动位置切换 `useDisplay.Sender_variant`: - `isAtPageBottom`(底部)→ `'updown'`(展开,5-9 行) - `distanceToPageBottom >= 350`(已滚离底部 350px)→ `'default'`(收缩,1 行) - 中间状态 → `'updown'`(展开) 3. `display.js` store 中 `Sender_variant` 是响应式 ref 4. `dialogBox/index.vue` 的 `autoSizeConfig` 计算属性读取 `Sender_variant`,返回 `{ minRows, maxRows }` 5. `Sender` 组件通过 `:key="useDisplay.Sender_variant"` **强制重挂载**(因为 Element Plus `ElInput` 不支持 `autosize` 动态更新),新实例以正确的行数初始化 滚动阈值 350px 对应 `VirtualScroller` 的 `bottomPlaceholderHeight`。 ### 接口速查 | 函数 | 端点 | 用途 | |------|------|------| | `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, display_name, tags, disabled?}`) | | `requestModelConfigsBatch` | POST `/suanli/v1/models/configs` | 批量获取模型配置(body: `{ modelIds: [...] }`) | | `requestModelConfig` | GET `/suanli/v1/models/:id/config` | 单条模型配置(60s 缓存优先) | | `cancelOrCollect` | POST `/collect/toggle` | 收藏/取消收藏 | | `deleteGenerateHistory` | DELETE `/taskRecordHistory/delete` | 删除历史记录 | ### 任务响应格式 ```json // 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: `(不带 Bearer 前缀),根据 URL 前缀切换后端: | URL 前缀 | 环境变量 | |----------|----------| | `/suanli` | `VITE_API_TASK_TARGET` | | `/pay` | `VITE_API_PAY_TARGET` | | `/aigc` | `VITE_API_AIGC_TARGET` | | 其他 | `VITE_API_BASE_URL`(默认) | ### 平台编码映射 | 类型 | 平台编码 | |------|----------| | 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 组件 - Element Plus 图标通过 `unplugin-icons` 按需加载