diff --git a/.env.example b/.env.example
index 5a1e2be..df583d6 100644
--- a/.env.example
+++ b/.env.example
@@ -2,7 +2,12 @@ DEEPSEEK_API_KEY=sk-xxxx
# 以后若接入其他厂商,按 models.yaml 中的 api_key_env 增加对应变量,例如:
# OPENAI_API_KEY=sk-xxxx
+# 阿里云百炼(万相图生图 wanx2.1-imageedit),华北2(北京)地域的 API Key
+DASHSCOPE_API_KEY=sk-xxxx
+# 仅业务空间调用时填写:https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1
+# DASHSCOPE_BASE_HTTP_API_URL=
+
HOST=127.0.0.1
-PORT=8000
+PORT=8800
# Comma-separated origins when frontend runs on another port. Same-origin mount can leave empty.
CORS_ORIGINS=
diff --git a/.gitignore b/.gitignore
index 9ba40ec..9edea78 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,4 @@ node_modules/
dist/
.output/
.wxt/
+.pnpm-store/
diff --git a/README.md b/README.md
index 5ab07ec..91e0551 100644
--- a/README.md
+++ b/README.md
@@ -8,8 +8,8 @@ Ozon 跨境上品工具链:**采集 → 编辑 → 发布**。
|---|---|---|
| `web/` | ① 工具台 v1:计价、登记、水印、俄文文案 | ✅ 在用(冻结) |
| `extension/` | ② Chrome 采集插件:Ozon / 1688 商品页采集 | 🔨 待开发 |
-| `studio/` | ③ 发布工作台:导入文件夹 → 编辑 → 发布 | 📋 待设计 |
-| `server/` | ④ FastAPI:AI 文案、图片处理、Ozon API | 🔨 部分就绪 |
+| `studio/` | ③ 发布工作台:AI 图生图(上传/水印/万相图生图) | 🔨 开发中 |
+| `server/` | ④ FastAPI:AI 文案、图生图(wanx2.1-imageedit)、Ozon API | 🔨 部分就绪 |
四者通过磁盘上的「[商品文件夹](docs/contracts/product-json.md)」契约衔接,不直接耦合代码。
@@ -20,7 +20,7 @@ ozon-seller-kit/
├── server/ # ④ FastAPI(main.py / api / services / schemas / config)
├── web/ # ① 工具台 v1,冻结
├── extension/ # ② 采集插件(待建)
-├── studio/ # ③ 发布工作台(待建)
+├── studio/ # ③ 发布工作台(React + Vite + antd,AI 图生图已上线)
├── packages/schema/ # 跨端共享契约(待建)
├── docs/ # 全部文档
├── start.command
@@ -35,11 +35,28 @@ ozon-seller-kit/
./start.command
```
-打开 http://127.0.0.1:8000/ozonSeller.html
+打开 http://127.0.0.1:8800/ozonSeller.html
- 密钥写在根目录 `.env`(参考 `.env.example`)
- 可选模型写在 `server/config/models.yaml`
+### 发布工作台(studio/,AI 图生图)
+
+先启动后端(同上 `./start.command`,或确保 `uvicorn` 跑在 8800),再启动前端:
+
+```bash
+cd studio
+pnpm install
+pnpm dev # http://localhost:8900(启动后自动打开浏览器)
+```
+
+前端通过 Vite 代理把 `/api` 转发到 `http://127.0.0.1:8800`。图生图依赖阿里云百炼的
+**wanx2.1-imageedit**(华北2/北京),需在 `.env` 配置:
+
+```bash
+DASHSCOPE_API_KEY=sk-xxxx
+```
+
## 文档
| 文档 | 内容 |
@@ -50,3 +67,4 @@ ozon-seller-kit/
| [`docs/extension/plan-revision.md`](docs/extension/plan-revision.md) | 插件方案修正(针对 Ozon 优先)|
| [`docs/deployment.md`](docs/deployment.md) | 部署与启动 |
| [`docs/ai-copy-backend-plan.md`](docs/ai-copy-backend-plan.md) | 俄文文案后端方案 |
+| [`docs/studio/image-edit.md`](docs/studio/image-edit.md) | AI 图生图(studio 页面 + /api/image/edit) |
diff --git a/docs/architecture.md b/docs/architecture.md
index 7c58d8d..73628f7 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -12,17 +12,21 @@
四个组成部分,各自独立可用、通过明确契约衔接:
-| # | 部分 | 形态 | 状态 | 职责 |
-|---|---|---|---|---|
-| ① | **工具台 v1** | 静态页(原生 JS + Tailwind CDN) | ✅ 在用 | 计价、上品登记、水印、俄文文案。**冻结维护,不重构** |
-| ② | **采集插件** | Chrome MV3 扩展 | 🔨 待开发 | Ozon/1688 商品页采集 → 落本地文件夹 |
-| ③ | **发布工作台** | Web 应用 | 📋 待设计 | 导入本地文件夹 → 编辑/图片处理 → 提交发布 |
-| ④ | **服务端** | FastAPI | 🔨 部分就绪 | AI 文案、图片处理、Ozon API 代理、凭证保管 |
+
+| # | 部分 | 形态 | 状态 | 职责 |
+| --- | ---------- | ------------------------- | ------- | ---------------------------- |
+| ① | **工具台 v1** | 静态页(原生 JS + Tailwind CDN) | ✅ 在用 | 计价、上品登记、水印、俄文文案。**冻结维护,不重构** |
+| ② | **采集插件** | Chrome MV3 扩展 | 🔨 待开发 | Ozon/1688 商品页采集 → 落本地文件夹 |
+| ③ | **发布工作台** | Web 应用 | 📋 待设计 | 导入本地文件夹 → 编辑/图片处理 → 提交发布 |
+| ④ | **服务端** | FastAPI | 🔨 部分就绪 | AI 文案、图片处理、Ozon API 代理、凭证保管 |
+
**关键边界**:① 与 ③ 并存不互相替代。① 是已验证好用的轻量工具,③ 是面向"整商品发布"的新流程;③ 成熟后 ① 的计价器可能被吸收,但那是以后的事。
---
+
+
## 2. 目录结构
```
@@ -74,7 +78,7 @@ ozon-seller-kit/
代价:`start.command` 与 `docs/deployment.md` 里的启动路径要改,`.env` 加载路径 `parents[1]` 要跟着调。Python 内部 import 全是 `from api import ...` 这类顶层相对形式,只要工作目录切到 `server/` 就不受影响。
-**这是唯一有破坏性的改动,建议在动 `studio/` 之前一次做完,不要拖到中途。** 如果你想零风险,也可以让后端留在根目录——架构其余部分不依赖这个决定。
+**这是唯一有破坏性的改动,建议在动** `studio/` **之前一次做完,不要拖到中途。** 如果你想零风险,也可以让后端留在根目录——架构其余部分不依赖这个决定。
### 2.2 为什么 `web/` 不改名
@@ -84,6 +88,8 @@ ozon-seller-kit/
---
+
+
## 3. 核心数据契约:商品文件夹
**这是整个架构最重要的一个决定。** 四个部分之间不直接调用彼此的代码,只认一个约定:磁盘上的「商品文件夹」。
@@ -113,23 +119,27 @@ ozon-seller-kit/
2. **中间产物可见可改**。采集结果就是普通文件夹,能用 Finder 看、能手动补图、能备份、能在两台机器间拷。
3. **跨平台合并天然成立**。Ozon 采完,切到 1688 采同类商品,选同一个文件夹继续写入——就是往同一目录追加文件,不需要任何服务端参与。
+
+
### 3.1 product.json
结构对齐 Ozon `ProductAPI_ImportProductsV3`,但**不等于**它的请求体。区别在于采集阶段拿不到的字段留空,由工作台补齐:
-| 字段 | 采集阶段 | 工作台补齐 |
-|---|---|---|
-| `name` / `description` | ✅ 原文(可能是俄文) | 改写/翻译 |
-| `images` | 本地相对路径 | 上传图床后换成公网 URL |
-| `offer_id` | ❌ 空 | **必须填自己的货号**(跟卖场景下不能沿用竞品的) |
-| `description_category_id` | ❌ 空 | 类目选择/推荐 |
-| `attributes[].id` | ❌ 空 | 查类目属性字典映射 |
-| 尺寸重量 | 参数表里有就带上 | 校验补全 |
-| `price` / `old_price` | 采到竞品价,仅供参考 | 计价器算出 |
+
+| 字段 | 采集阶段 | 工作台补齐 |
+| ------------------------- | ----------- | -------------------------- |
+| `name` / `description` | ✅ 原文(可能是俄文) | 改写/翻译 |
+| `images` | 本地相对路径 | 上传图床后换成公网 URL |
+| `offer_id` | ❌ 空 | **必须填自己的货号**(跟卖场景下不能沿用竞品的) |
+| `description_category_id` | ❌ 空 | 类目选择/推荐 |
+| `attributes[].id` | ❌ 空 | 查类目属性字典映射 |
+| 尺寸重量 | 参数表里有就带上 | 校验补全 |
+| `price` / `old_price` | 采到竞品价,仅供参考 | 计价器算出 |
+
所以 product.json 有个 `_meta.stage` 字段标明它处在哪个阶段:`collected` → `edited` → `published`。
-契约细节见 [`docs/contracts/product-json.md`](./contracts/product-json.md)。
+契约细节见 `[docs/contracts/product-json.md](./contracts/product-json.md)`。
### 3.2 契约真源与双语言实现
@@ -148,8 +158,12 @@ server/schemas/product.py (Pydantic)
---
+
+
## 4. 各部分职责与边界
+
+
### ① 工具台 v1(`web/`)
**冻结。** 只修 bug,不加功能、不重构、不迁技术栈。它当前的价值是「已经好用」,任何改动都是风险。
@@ -162,7 +176,7 @@ server/schemas/product.py (Pydantic)
不做:LLM 调用、图片处理、Ozon API、持有任何密钥。
-一期只支持 Ozon(跟卖是第一优先级),1688 二期。详见 [`docs/extension/plan.md`](./extension/plan.md)。
+一期只支持 Ozon(跟卖是第一优先级),1688 二期。详见 `[docs/extension/plan.md](./extension/plan.md)`。
### ③ 发布工作台(`studio/`)
@@ -172,6 +186,8 @@ server/schemas/product.py (Pydantic)
- **图片处理**:水印、白底、图内翻译、图生图 —— 重活走服务端
- **发布**:提交 Ozon 草稿,回填 `product_id`
+
+
### ④ 服务端(`server/`)
唯一持有密钥的地方。当前有 `/api/ai/*`(文案);待补 `/api/image/*`、`/api/ozon/*`。
@@ -180,6 +196,8 @@ server/schemas/product.py (Pydantic)
---
+
+
## 5. 端到端数据流
```
@@ -204,38 +222,50 @@ server/schemas/product.py (Pydantic)
---
+
+
## 6. 技术栈
-| 部分 | 技术栈 | 说明 |
-|---|---|---|
-| ① web | 原生 JS + Tailwind CDN | 不动 |
-| ② extension | WXT + React + TS strict | WXT 比 Plasmo 活跃 |
-| ③ studio | 待定(见 §8) | |
-| ④ server | FastAPI + Pydantic | 已有 |
-| 共享 | pnpm workspace | 只为 `packages/schema` 共享,不上 turborepo |
+
+| 部分 | 技术栈 | 说明 |
+| ----------- | ----------------------- | ------------------------------------ |
+| ① web | 原生 JS + Tailwind CDN | 不动 |
+| ② extension | WXT + React + TS strict | WXT 比 Plasmo 活跃 |
+| ③ studio | 待定(见 §8) | |
+| ④ server | FastAPI + Pydantic | 已有 |
+| 共享 | pnpm workspace | 只为 `packages/schema` 共享,不上 turborepo |
+
pnpm workspace 的唯一目的是让插件和 studio 共用契约类型。`pnpm-workspace.yaml` 三行搞定,不引入构建编排复杂度。
---
+
+
## 7. 服务端演进
当前是无状态的:只有 AI 文案代理,没有数据库。按需要逐步加,不要一次上齐:
-| 阶段 | 触发条件 | 要加什么 |
-|---|---|---|
-| 现在 | — | 无状态,`/api/ai/*` |
-| S1 | studio 要处理图片 | `/api/image/*`(水印/白底/翻译),仍无状态:收图返图 |
-| S2 | 要发布到 Ozon | `/api/ozon/*` + 图床 + 类目字典缓存(SQLite 够用) |
-| S3 | 图片处理变慢(图生图、视频) | 任务队列 + `/api/job/:id` 轮询 |
-| S4 | 想要跨设备同步 | 商品库落库,本地文件夹降级为导入导出格式 |
+
+| 阶段 | 触发条件 | 要加什么 |
+| --- | -------------- | -------------------------------------- |
+| 现在 | — | 无状态,`/api/ai/*` |
+| S1 | studio 要处理图片 | `/api/image/*`(水印/白底/翻译),仍无状态:收图返图 |
+| S2 | 要发布到 Ozon | `/api/ozon/*` + 图床 + 类目字典缓存(SQLite 够用) |
+| S3 | 图片处理变慢(图生图、视频) | 任务队列 + `/api/job/:id` 轮询 |
+| S4 | 想要跨设备同步 | 商品库落库,本地文件夹降级为导入导出格式 |
+
**S1、S2 都不需要数据库**,类目字典用文件缓存即可。S4 是个大改动,只在真的有多设备需求时才做——本地文件夹方案的一个优点就是单机场景下完全不需要它。
---
+
+
## 8. 已定决策
+
+
### D1 · 后端迁入 `server/` ✅
代价是改 `start.command`、`settings.py` 的 `.env` 路径、`deployment.md`。Python 内部 import 不受影响(工作目录切到 `server/` 即可)。**这是唯一有破坏性的改动,在开工 studio 前一次做完。**
@@ -250,10 +280,12 @@ studio 的核心是"几十个字段的结构化表单 + 图片批处理",正
File System Access 在 side panel(扩展页面上下文)可用,`showDirectoryPicker()` 拿到的 handle 存进 IndexedDB 后**跨会话免重复授权**,正好支撑"Ozon 采完切 1688 追加到同一文件夹"。`chrome.downloads` 保留为降级路径(用户拒绝授权时)。
-细节见 [`docs/extension/plan-revision.md`](./extension/plan-revision.md) R1。
+细节见 `[docs/extension/plan-revision.md](./extension/plan-revision.md)` R1。
---
+
+
## 9. 迁移计划
一次性做完,中途不要停在半路:
@@ -285,26 +317,29 @@ File System Access 在 side panel(扩展页面上下文)可用,`showDirect
⑥ web/ 加 README 标注 v1 冻结
```
-`reference/1688-extension/` 要不要进 git:它是反编译产物,约 40 个 bundle。建议**加进 `.gitignore`**,保留在本地即可——设计结论已经写进 `docs/extension/plan.md` §2,原始 bundle 只在需要再次查证时才用。
+`reference/1688-extension/` 要不要进 git:它是反编译产物,约 40 个 bundle。建议**加进** `.gitignore`,保留在本地即可——设计结论已经写进 `docs/extension/plan.md` §2,原始 bundle 只在需要再次查证时才用。
---
+
+
## 10. 里程碑(已调整优先级)
-**插件先行:1688/淘宝 → Ozon**。理由见 [`docs/extension/1688-taobao-implementation.md`](./extension/1688-taobao-implementation.md)。
+**插件先行:1688/淘宝 → Ozon**。理由见 `[docs/extension/1688-taobao-implementation.md](./extension/1688-taobao-implementation.md)`。
-| # | 内容 | 依赖 | 产出 | 工作量 |
-|---|---|---|---|---|
-| **M0** | 目录迁移 + 文档集中 | — | ✅ 已完成,服务正常起 | — |
-| **M1** | 插件:1688 采集引擎 | M0 | Console 里能跑 `scanCurrentPage()` | 4h |
-| **M2** | 插件:淘宝 profile | M1 | 淘宝页面同样可用 | 1h |
-| **M3** | 插件:Side Panel + File System Access | M2 | 生成完整商品文件夹到本地 | 4h |
-| **M4** | 插件:sources.json 去重 | M3 | 二次采集追加不重复 | 1h |
-| **M5** | 契约真源:product.json Pydantic | M0 | `server/schemas/product.py` | 2h |
-| **M6** | 插件:Ozon profile 实测 | M4 | Ozon 选择器验证(需真实页面链接) | 2h |
-| **M7** | studio:导入文件夹 + 表单编辑 | M5 | 能改能存 | 8h |
-| **M8** | studio + server:图片处理 | M7 | 水印/白底可用 | 6h |
-| **M9** | server:Ozon 发布 | M8 | 草稿进 Ozon 后台 | 4h |
-**M4 结束时插件功能完整**,可以采集 1688/淘宝商品到本地文件夹,跨平台追加不重复。M9 结束时全链路跑通。
+| # | 内容 | 依赖 | 产出 | 工作量 |
+| ------ | ---------------------------------- | --- | ------------------------------- | --- |
+| **M0** | 目录迁移 + 文档集中 | — | ✅ 已完成,服务正常起 | — |
+| **M1** | 插件:1688 采集引擎 | M0 | Console 里能跑 `scanCurrentPage()` | 4h |
+| **M2** | 插件:淘宝 profile | M1 | 淘宝页面同样可用 | 1h |
+| **M3** | 插件:Side Panel + File System Access | M2 | 生成完整商品文件夹到本地 | 4h |
+| **M4** | 插件:sources.json 去重 | M3 | 二次采集追加不重复 | 1h |
+| **M5** | 契约真源:product.json Pydantic | M0 | `server/schemas/product.py` | 2h |
+| **M6** | 插件:Ozon profile 实测 | M4 | Ozon 选择器验证(需真实页面链接) | 2h |
+| **M7** | studio:导入文件夹 + 表单编辑 | M5 | 能改能存 | 8h |
+| **M8** | studio + server:图片处理 | M7 | 水印/白底可用 | 6h |
+| **M9** | server:Ozon 发布 | M8 | 草稿进 Ozon 后台 | 4h |
+
+**M4 结束时插件功能完整**,可以采集 1688/淘宝商品到本地文件夹,跨平台追加不重复。M9 结束时全链路跑通。
\ No newline at end of file
diff --git a/docs/studio/image-edit.md b/docs/studio/image-edit.md
new file mode 100644
index 0000000..bb8ca6a
--- /dev/null
+++ b/docs/studio/image-edit.md
@@ -0,0 +1,71 @@
+# AI 图生图(wanx2.1-imageedit)
+
+「发布工作台」(`studio/`)当前唯一的页面:上传多张图片 → 加水印(文字/图片)→ 对单张图片调用
+万相 2.1 通用图像编辑模型做图生图。
+
+## 目录与职责
+
+| 位置 | 职责 |
+|---|---|
+| `studio/src/pages/ai-image/` | 页面:上传、水印设置、预览网格、AI 弹窗 |
+| `studio/src/pages/ai-image/components/WatermarkCanvas.tsx` | 单图可拖拽水印画布 |
+| `studio/src/pages/ai-image/components/ImageEditModal.tsx` | 图生图弹窗(上:原图/生成对比;下:对话框) |
+| `studio/src/utils/watermark.ts` | 水印绘制(移植自 `web/js/app.js`,坐标相对原图) |
+| `studio/src/services/image.ts` | 前端调用后端 `/api/image/edit` |
+| `server/api/image.py` | 路由 `POST /api/image/edit` |
+| `server/services/image_edit.py` | 万相模型调用(同步 SDK 在线程池执行) |
+| `server/schemas/image_edit.py` | 请求/响应 Pydantic 模型 |
+
+## 接口契约
+
+`POST /api/image/edit`
+
+请求(JSON):
+
+```json
+{
+ "base_image": "data:image/png;base64,... | https://...",
+ "prompt": "把背景换成纯白色摄影棚",
+ "function": "description_edit",
+ "n": 1,
+ "size": "",
+ "seed": null,
+ "style": "",
+ "prompt_extend": true,
+ "strength": 0.8
+}
+```
+
+- `base_image`:前端把「已加水印」的当前图片以 data URL 传入(data URL 与公网 URL 二选一)。
+- `function` 白名单:`description_edit`(文本指令编辑)、`description_edit_with_mask`(局部重绘,需 `mask_image`)、
+ `stylization_local`、`stylization_all`。默认 `description_edit`。
+- `prompt_extend`:是否自动扩写提示词,官方示例默认开启(默认 `true`)。
+- `strength`:修改幅度 0.0~1.0,官方默认 0.5(值越小越接近原图)。
+ **新增文字、大幅修改等场景建议调高到 0.8 左右**,否则模型可能因"贴近原图"而忽略新内容。
+
+响应:
+
+```json
+{
+ "task_id": "6e319d88-...",
+ "results": [{ "url": "https://...oss-cn-wulanchabu...png?...", "image_base64": "data:image/png;base64,..." }],
+ "image_count": 1,
+ "request_id": "dc41682c-..."
+}
+```
+
+> `url` 有效期 24 小时。`image_base64` 为服务端代下载的 data URL,前端可据此直接合成/导出,
+> 规避阿里云结果 URL 未开放 CORS 导致的画布污染(无法 toDataURL)。
+
+## 密钥与地域
+
+- 模型:`wanx2.1-imageedit`,仅支持**华北 2(北京)**地域的 API Key。
+- 密钥:`.env` 中的 `DASHSCOPE_API_KEY`。
+- 业务空间调用时,另配 `DASHSCOPE_BASE_HTTP_API_URL=https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1`;
+ 普通 API Key 调用留空。
+
+## 关于图片存储
+
+当前服务端**不做图片存储**:前端直接把 base64 图片传给模型,结果 URL 原样返回给前端展示。
+后续接入七牛云时,可在 `services/image_edit.py` 拿到 `results[].url` 后增加「下载 → 转存七牛 → 返回新 URL」一步,
+api 层与前端契约无需改动。
diff --git a/server/api/image.py b/server/api/image.py
index 35fb140..adc5278 100644
--- a/server/api/image.py
+++ b/server/api/image.py
@@ -1,5 +1,13 @@
from fastapi import APIRouter
+from schemas.image_edit import ImageEditRequest, ImageEditResponse
+from services.image_edit import edit_image
+
router = APIRouter(prefix="/api/image", tags=["image"])
-# Phase 2: watermark / white background / img2img proxy
+
+@router.post("/edit", response_model=ImageEditResponse, summary="AI 图生图(wanx2.1-imageedit)")
+async def create_image_edit(body: ImageEditRequest) -> ImageEditResponse:
+ return await edit_image(body)
+
+# 后续扩展点:白底 / 水印等服务端处理可继续在此文件下新增路由。
diff --git a/server/config/settings.py b/server/config/settings.py
index ef9e7e1..34569cb 100644
--- a/server/config/settings.py
+++ b/server/config/settings.py
@@ -20,8 +20,12 @@ class Settings(BaseSettings):
deepseek_api_key: str = ""
openai_api_key: str = ""
+ dashscope_api_key: str = ""
+ # 华北2(北京)业务空间时需填:https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1
+ # 普通 API Key 调用留空即可。
+ dashscope_base_http_api_url: str = ""
host: str = "127.0.0.1"
- port: int = 8000
+ port: int = 8800
cors_origins: str = ""
@property
diff --git a/server/requirements.txt b/server/requirements.txt
index 33c378b..10361c4 100644
--- a/server/requirements.txt
+++ b/server/requirements.txt
@@ -4,3 +4,4 @@ httpx>=0.27.0
pydantic-settings>=2.6.0
python-dotenv>=1.0.0
PyYAML>=6.0.0
+dashscope>=1.23.8
diff --git a/server/schemas/image_edit.py b/server/schemas/image_edit.py
new file mode 100644
index 0000000..c69750d
--- /dev/null
+++ b/server/schemas/image_edit.py
@@ -0,0 +1,125 @@
+"""图生图 / 图像编辑请求响应模型(多模型)。"""
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import BaseModel, Field, field_validator
+
+# 支持的模型白名单
+SUPPORTED_MODELS: frozenset[str] = frozenset(
+ {
+ "wanx2.1-imageedit",
+ "wan2.6-image",
+ "qwen-image-edit",
+ "qwen-image-edit-plus",
+ "qwen-image-edit-plus-2025-10-30",
+ }
+)
+
+# 万相 2.1 通用图像编辑支持的 function 白名单
+# description_edit :文本指令编辑(无掩码,即“图生图”)
+# description_edit_with_mask :局部重绘(需 mask)
+# stylization_local :局部风格化
+# stylization_all :整体风格化
+ImageEditFunction = Literal[
+ "description_edit",
+ "description_edit_with_mask",
+ "stylization_local",
+ "stylization_all",
+]
+
+# 单张 base64 图片(编码后)上限,超出直接拒绝,避免无谓占用内存。
+_MAX_BASE64_LENGTH = 15 * 1024 * 1024
+
+
+class ImageEditRequest(BaseModel):
+ """图生图请求。图片以 data URL(data:image/...;base64,...)或公网 URL 传入。"""
+
+ base_image: str = Field(..., description="待编辑原图:data URL 或公网 http(s) URL")
+ prompt: str = Field(..., description="编辑指令,例如“把背景换成纯白色摄影棚”")
+ model: str = Field(
+ default="wanx2.1-imageedit",
+ description="模型 code,见 SUPPORTED_MODELS",
+ )
+ mask_image: str | None = Field(
+ default=None, description="掩码图(仅 function=description_edit_with_mask 时使用)"
+ )
+ function: ImageEditFunction = Field(
+ default="description_edit", description="编辑能力类型,见 ImageEditFunction"
+ )
+ n: int = Field(default=1, ge=1, le=4, description="生成数量")
+ size: str = Field(default="", description="输出尺寸,如 1024*1024;留空由模型决定")
+ seed: int | None = Field(default=None, ge=0, description="随机种子,可选")
+ style: str = Field(default="", description="输出风格,如 auto/photography/anime 等,可选")
+ prompt_extend: bool = Field(default=True, description="是否自动扩写提示词(官方示例默认开启)")
+ strength: float | None = Field(
+ default=None,
+ ge=0.0,
+ le=1.0,
+ description=(
+ "修改幅度 0.0~1.0,官方默认 0.5;值越大改动越明显、越偏离原图。"
+ "新增文字、大幅修改等场景建议 0.8 左右"
+ ),
+ )
+
+ @field_validator("prompt")
+ @classmethod
+ def prompt_not_empty(cls, value: str) -> str:
+ text = (value or "").strip()
+ if not text:
+ raise ValueError("prompt 不能为空")
+ return text
+
+ @field_validator("model")
+ @classmethod
+ def model_whitelist(cls, value: str) -> str:
+ model = (value or "").strip()
+ if model not in SUPPORTED_MODELS:
+ raise ValueError(f"不支持的模型「{model}」,可选:{sorted(SUPPORTED_MODELS)}")
+ return model
+
+ @field_validator("base_image")
+ @classmethod
+ def base_image_valid(cls, value: str) -> str:
+ image = (value or "").strip()
+ if not image:
+ raise ValueError("base_image 不能为空")
+ if image.startswith("data:image/"):
+ if len(image) > _MAX_BASE64_LENGTH:
+ raise ValueError("图片过大,请压缩后重试")
+ elif not image.startswith(("http://", "https://")):
+ raise ValueError("base_image 仅支持 data URL 或公网 http(s) URL")
+ return image
+
+ @field_validator("mask_image")
+ @classmethod
+ def mask_image_valid(cls, value: str | None) -> str | None:
+ if value is None or not value.strip():
+ return None
+ image = value.strip()
+ if image.startswith("data:image/"):
+ if len(image) > _MAX_BASE64_LENGTH:
+ raise ValueError("mask_image 图片过大")
+ elif not image.startswith(("http://", "https://")):
+ raise ValueError("mask_image 仅支持 data URL 或公网 http(s) URL")
+ return image
+
+ @field_validator("style")
+ @classmethod
+ def normalize_style(cls, value: str) -> str:
+ return (value or "").strip()
+
+
+class ImageEditResult(BaseModel):
+ url: str = Field(..., description="生成图片 URL(有效期 24 小时)")
+ image_base64: str = Field(
+ default="",
+ description="生成图片的 data URL(服务端代理下载,便于前端直接合成/导出,规避跨域画布污染)",
+ )
+
+
+class ImageEditResponse(BaseModel):
+ task_id: str = ""
+ results: list[ImageEditResult] = Field(default_factory=list)
+ image_count: int = 0
+ request_id: str = ""
diff --git a/server/services/image_edit.py b/server/services/image_edit.py
new file mode 100644
index 0000000..3ec21fe
--- /dev/null
+++ b/server/services/image_edit.py
@@ -0,0 +1,185 @@
+"""多模型图生图 / 图像编辑服务。
+
+按模型路由到不同的 DashScope 调用形态:
+- wanx2.1-imageedit → ImageSynthesis(function=description_edit)
+- wan2.6-image / qwen-image-edit(-plus) → MultiModalConversation(多模态生成)
+
+职责单一:把请求翻译成 DashScope 调用并返回结果。当前不落盘、不存储图片;
+后续接入七牛云时,可在返回结果 URL 之后增加「下载并转存」步骤,而不改 api 层与前端契约。
+"""
+from __future__ import annotations
+
+import asyncio
+import base64
+from http import HTTPStatus
+
+import httpx
+from fastapi import HTTPException
+
+from config import get_settings
+from schemas.image_edit import ImageEditRequest, ImageEditResponse, ImageEditResult
+
+# 服务端代理下载结果图时的体积上限,超出则跳过 base64(前端回退用 url)
+_MAX_PROXY_BYTES = 20 * 1024 * 1024
+
+# 走 ImageSynthesis 的模型(其余统一走 MultiModalConversation)
+_WANX_IMAGEEDIT = "wanx2.1-imageedit"
+
+
+def _download_to_data_url(url: str) -> str:
+ """把生成结果图下载并转成 data URL。
+
+ 目的:阿里云结果 URL 未开放 CORS,前端直接绘制到 canvas 会导致画布被污染、
+ 无法 toDataURL 导出;由服务端代下载可彻底规避。失败时返回空串,前端回退用 url。
+ """
+ try:
+ with httpx.Client(timeout=60.0, follow_redirects=True) as client:
+ resp = client.get(url)
+ resp.raise_for_status()
+ content_type = (resp.headers.get("content-type") or "image/png").split(";")[0].strip()
+ if not content_type.startswith("image/"):
+ content_type = "image/png"
+ payload = resp.content
+ if len(payload) > _MAX_PROXY_BYTES:
+ return ""
+ b64 = base64.b64encode(payload).decode("ascii")
+ return f"data:{content_type};base64,{b64}"
+ except Exception: # noqa: BLE001 - 下载失败不影响主流程
+ return ""
+
+
+def _apply_base_url(settings) -> None:
+ """仅华北2(北京)业务空间需要配置 base_http_api_url。"""
+ import dashscope
+
+ url = (settings.dashscope_base_http_api_url or "").strip()
+ if url:
+ dashscope.base_http_api_url = url
+
+
+def _fail(rsp, label: str) -> None:
+ code = getattr(rsp, "code", None)
+ message = getattr(rsp, "message", "")
+ status = getattr(rsp, "status_code", "?")
+ raise HTTPException(status_code=502, detail=f"{label}失败(HTTP {status},code={code}):{message}")
+
+
+def _to_results(urls: list[str]) -> list[ImageEditResult]:
+ return [ImageEditResult(url=u, image_base64=_download_to_data_url(u)) for u in urls]
+
+
+def _call_wanx(req: ImageEditRequest, api_key: str) -> ImageEditResponse:
+ """wanx2.1-imageedit:ImageSynthesis 同步调用。"""
+ from dashscope import ImageSynthesis
+
+ kwargs: dict = {
+ "api_key": api_key,
+ "model": "wanx2.1-imageedit",
+ "function": req.function,
+ "prompt": req.prompt,
+ "base_image_url": req.base_image,
+ "n": req.n,
+ }
+ if req.mask_image:
+ kwargs["mask_image_url"] = req.mask_image
+ if req.size:
+ kwargs["size"] = req.size
+ if req.seed is not None:
+ kwargs["seed"] = req.seed
+ if req.style:
+ kwargs["style"] = req.style
+ if req.prompt_extend:
+ kwargs["prompt_extend"] = True
+ if req.strength is not None:
+ # strength 走 SDK 的 **kwargs,最终落入请求体的 parameters
+ kwargs["strength"] = req.strength
+
+ try:
+ rsp = ImageSynthesis.call(**kwargs)
+ except Exception as exc: # noqa: BLE001 - SDK 抛错类型不统一,统一转为 502
+ raise HTTPException(status_code=502, detail=f"DashScope 调用异常:{exc}") from exc
+
+ if rsp.status_code != HTTPStatus.OK:
+ _fail(rsp, "图生图")
+
+ urls: list[str] = []
+ for item in getattr(rsp.output, "results", None) or []:
+ url = item.get("url") if isinstance(item, dict) else getattr(item, "url", None)
+ if url:
+ urls.append(url)
+
+ if not urls:
+ raise HTTPException(status_code=502, detail="模型未返回结果图片 URL")
+
+ usage = getattr(rsp, "usage", None)
+ image_count = int(getattr(usage, "image_count", 0) or 0) if usage else len(urls)
+ return ImageEditResponse(
+ task_id=getattr(rsp.output, "task_id", ""),
+ results=_to_results(urls),
+ image_count=image_count or len(urls),
+ request_id=getattr(rsp, "request_id", ""),
+ )
+
+
+def _call_multimodal(req: ImageEditRequest, api_key: str) -> ImageEditResponse:
+ """wan2.6-image / qwen-image-edit(-plus[-快照]):MultiModalConversation。"""
+ from dashscope import MultiModalConversation
+
+ messages = [{"role": "user", "content": [{"image": req.base_image}, {"text": req.prompt}]}]
+ kwargs: dict = {
+ "api_key": api_key,
+ "model": req.model,
+ "messages": messages,
+ "prompt_extend": req.prompt_extend,
+ }
+ if req.n:
+ kwargs["n"] = req.n
+ if req.size:
+ kwargs["size"] = req.size
+
+ try:
+ rsp = MultiModalConversation.call(**kwargs)
+ except Exception as exc: # noqa: BLE001
+ raise HTTPException(status_code=502, detail=f"DashScope 调用异常:{exc}") from exc
+
+ if rsp.status_code != HTTPStatus.OK:
+ _fail(rsp, "图生图")
+
+ urls: list[str] = []
+ try:
+ content = rsp.output.choices[0].message.content
+ except (AttributeError, IndexError, KeyError, TypeError):
+ content = []
+ if isinstance(content, list):
+ for item in content:
+ img = item.get("image") if isinstance(item, dict) else None
+ if img:
+ urls.append(img)
+
+ if not urls:
+ raise HTTPException(status_code=502, detail="模型未返回结果图片")
+
+ return ImageEditResponse(
+ task_id="",
+ results=_to_results(urls),
+ image_count=len(urls),
+ request_id=getattr(rsp, "request_id", ""),
+ )
+
+
+def _call_dashscope(req: ImageEditRequest, api_key: str) -> ImageEditResponse:
+ """同步调用 DashScope(在线程池中执行),按模型路由。"""
+ if req.model == _WANX_IMAGEEDIT:
+ return _call_wanx(req, api_key)
+ return _call_multimodal(req, api_key)
+
+
+async def edit_image(req: ImageEditRequest) -> ImageEditResponse:
+ """入口:校验密钥后在独立线程中执行同步 SDK 调用。"""
+ settings = get_settings()
+ api_key = (settings.dashscope_api_key or "").strip()
+ if not api_key:
+ raise HTTPException(status_code=500, detail="未配置 DASHSCOPE_API_KEY,请写入 .env")
+
+ _apply_base_url(settings)
+ return await asyncio.to_thread(_call_dashscope, req, api_key)
diff --git a/start.command b/start.command
index e8f86cb..65d47d6 100755
--- a/start.command
+++ b/start.command
@@ -19,12 +19,12 @@ if [[ ! -f .env ]]; then
exit 1
fi
-echo "启动中:http://127.0.0.1:8000/ozonSeller.html"
+echo "启动中:http://127.0.0.1:8800/ozonSeller.html"
echo "按 Ctrl+C 可停止服务"
echo
# --app-dir server 让 uvicorn 在 server/ 下找 main:app,工作目录仍是仓库根(.env 在此)
-uvicorn main:app --app-dir server --reload --reload-include '*.yaml' --host 127.0.0.1 --port 8000
+uvicorn main:app --app-dir server --reload --reload-include '*.yaml' --host 127.0.0.1 --port 8800
echo
echo "服务已停止。按回车关闭窗口…"
diff --git a/studio/.env.example b/studio/.env.example
new file mode 100644
index 0000000..d0f04cc
--- /dev/null
+++ b/studio/.env.example
@@ -0,0 +1,4 @@
+# 前端环境变量(Vite 仅暴露 VITE_ 前缀变量)
+# 开发环境默认走 vite 代理 /api -> http://127.0.0.1:8000,可留空
+VITE_API_BASE_URL=/api
+VITE_APP_NAME=Ozon 发布工作台
diff --git a/studio/.gitignore b/studio/.gitignore
new file mode 100644
index 0000000..7e4c82c
--- /dev/null
+++ b/studio/.gitignore
@@ -0,0 +1,5 @@
+node_modules
+dist
+*.local
+.env
+.DS_Store
diff --git a/studio/index.html b/studio/index.html
new file mode 100644
index 0000000..675212d
--- /dev/null
+++ b/studio/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Ozon 发布工作台
+
+
+
+
+
+
diff --git a/studio/package.json b/studio/package.json
new file mode 100644
index 0000000..d38ad93
--- /dev/null
+++ b/studio/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "studio",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@ant-design/icons": "^6.1.0",
+ "antd": "^6.1.3",
+ "axios": "^1.13.2",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0",
+ "react-router": "^7.11.0"
+ },
+ "devDependencies": {
+ "@types/node": "^24.10.1",
+ "@types/react": "^19.2.5",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react-swc": "^4.2.2",
+ "typescript": "~5.9.3",
+ "vite": "^7.2.4"
+ },
+ "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be"
+}
diff --git a/studio/pnpm-lock.yaml b/studio/pnpm-lock.yaml
new file mode 100644
index 0000000..2a8011c
--- /dev/null
+++ b/studio/pnpm-lock.yaml
@@ -0,0 +1,2043 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ '@ant-design/icons':
+ specifier: ^6.1.0
+ version: 6.3.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ antd:
+ specifier: ^6.1.3
+ version: 6.6.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ axios:
+ specifier: ^1.13.2
+ version: 1.19.0
+ react:
+ specifier: ^19.2.0
+ version: 19.2.8
+ react-dom:
+ specifier: ^19.2.0
+ version: 19.2.8(react@19.2.8)
+ react-router:
+ specifier: ^7.11.0
+ version: 7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ devDependencies:
+ '@types/node':
+ specifier: ^24.10.1
+ version: 24.13.3
+ '@types/react':
+ specifier: ^19.2.5
+ version: 19.2.18
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.4(@types/react@19.2.18)
+ '@vitejs/plugin-react-swc':
+ specifier: ^4.2.2
+ version: 4.3.3(vite@7.3.6(@types/node@24.13.3))
+ typescript:
+ specifier: ~5.9.3
+ version: 5.9.3
+ vite:
+ specifier: ^7.2.4
+ version: 7.3.6(@types/node@24.13.3)
+
+packages:
+
+ '@ant-design/colors@8.0.1':
+ resolution: {integrity: sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==}
+
+ '@ant-design/cssinjs-utils@2.1.2':
+ resolution: {integrity: sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA==}
+ peerDependencies:
+ react: '>=18'
+ react-dom: '>=18'
+
+ '@ant-design/cssinjs@2.1.2':
+ resolution: {integrity: sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==}
+ peerDependencies:
+ react: '>=16.0.0'
+ react-dom: '>=16.0.0'
+
+ '@ant-design/fast-color@3.0.1':
+ resolution: {integrity: sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==}
+ engines: {node: '>=8.x'}
+
+ '@ant-design/icons-svg@4.5.0':
+ resolution: {integrity: sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==}
+
+ '@ant-design/icons@6.3.2':
+ resolution: {integrity: sha512-B6O5a5XJ4wjtNOfZejXYwHW5zvKV5gYkjGf11dHGLEbKn0ABDGndo41+gfIiXyTFhvESj4XTotuud33mUFid0g==}
+ engines: {node: '>=8'}
+ peerDependencies:
+ react: '>=16.0.0'
+ react-dom: '>=16.0.0'
+
+ '@ant-design/react-slick@2.0.0':
+ resolution: {integrity: sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg==}
+ peerDependencies:
+ react: ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ '@babel/runtime@7.29.7':
+ resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/runtime@8.0.0':
+ resolution: {integrity: sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==}
+
+ '@emotion/hash@0.8.0':
+ resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==}
+
+ '@emotion/unitless@0.7.5':
+ resolution: {integrity: sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==}
+
+ '@esbuild/aix-ppc64@0.28.2':
+ resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.28.2':
+ resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.28.2':
+ resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.28.2':
+ resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.28.2':
+ resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.28.2':
+ resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.28.2':
+ resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.28.2':
+ resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.28.2':
+ resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.28.2':
+ resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.28.2':
+ resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.28.2':
+ resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.28.2':
+ resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.28.2':
+ resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.28.2':
+ resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.28.2':
+ resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.28.2':
+ resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.28.2':
+ resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.28.2':
+ resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.28.2':
+ resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.28.2':
+ resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.28.2':
+ resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.28.2':
+ resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.28.2':
+ resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.28.2':
+ resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.28.2':
+ resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@napi-rs/lzma-linux-x64-gnu@1.5.1':
+ resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
+ engines: {node: ^22.20 || ^24.12 || >=25}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rc-component/async-validator@6.0.0':
+ resolution: {integrity: sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA==}
+ engines: {node: '>=14.x'}
+
+ '@rc-component/cascader@1.22.0':
+ resolution: {integrity: sha512-SffrA57aS9oub3VuI7ajPhJTPtaNxngSvtRhD40Rd8dwJ5vfWPSrVanWgeepdWFGBt7EHftIK5RUU0u3rCTwWw==}
+ peerDependencies:
+ react: '>=18.0.0'
+ react-dom: '>=18.0.0'
+
+ '@rc-component/checkbox@2.0.0':
+ resolution: {integrity: sha512-3CXGPpAR9gsPKeO2N78HAPOzU30UdemD6HGJoWVJOpa6WleaGB5kzZj3v6bdTZab31YuWgY/RxV3VKPctn0DwQ==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/collapse@1.2.0':
+ resolution: {integrity: sha512-ZRYSKSS39qsFx93p26bde7JUZJshsUBEQRlRXPuJYlAiNX0vyYlF5TsAm8JZN3LcF8XvKikdzPbgAtXSbkLUkw==}
+ peerDependencies:
+ react: '>=18.0.0'
+ react-dom: '>=18.0.0'
+
+ '@rc-component/color-picker@3.1.1':
+ resolution: {integrity: sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/context@2.0.2':
+ resolution: {integrity: sha512-uiGpAlblCNlziHPwj4S4Iy/oemeuz/hR03mbiEjTCXwsqOIN3BOzsRMyDwpyO5Fm0vIEEJRUf9ZtbRLbhksuTA==}
+ peerDependencies:
+ react: '>=18.0.0'
+ react-dom: '>=18.0.0'
+
+ '@rc-component/dialog@1.10.0':
+ resolution: {integrity: sha512-eDukNlz9vNszAGv7i3zKXdxEd3wgVmNxuJijYt8zvTh17QwTu8KK/bdURRd/lU4qaMzhO1HKKmMrwOnkaw0BvQ==}
+ peerDependencies:
+ react: '>=18.0.0'
+ react-dom: '>=18.0.0'
+
+ '@rc-component/drawer@1.4.2':
+ resolution: {integrity: sha512-1ib+fZEp6FBu+YvcIktm+nCQ+Q+qIpwpoaJH6opGr4ofh2QMq+qdr5DLC4oCf5qf3pcWX9lUWPYX652k4ini8Q==}
+ peerDependencies:
+ react: '>=18.0.0'
+ react-dom: '>=18.0.0'
+
+ '@rc-component/dropdown@1.0.3':
+ resolution: {integrity: sha512-YTST/N6kpqpDz3IMuM/PSSZnrDpSOA6dgHv12gPA90ZTSLv2CoqkZ0+9NtwTY6BeO7dstPblSic2QJg7dSFy/g==}
+ peerDependencies:
+ react: '>=16.11.0'
+ react-dom: '>=16.11.0'
+
+ '@rc-component/form@1.8.6':
+ resolution: {integrity: sha512-1EXVsSKPZC6kGrIqxVck7kAWM6T65b6et/n5wIcyiaIa41VrDshD0nVLHoBDEIZr2ATQsOP6icb4XQkNFMLBAw==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/image@1.10.0':
+ resolution: {integrity: sha512-BjeZCRQ+hw+4WAhvrw8rJvy5fckA2xpf/X2XQEOABUHvLTNB9inB98X3Mp54jYQ7g10DfWERQWHXeC4ylxp1Uw==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/input-number@1.6.2':
+ resolution: {integrity: sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/input@1.3.1':
+ resolution: {integrity: sha512-iFvTUT9W+JC/MSin2aGAk8NqsVlTzcExNC9DZariON1IWirju9NoNeEk47an4Q8iHazkoVI/y1LnDi88+CPcig==}
+ peerDependencies:
+ react: '>=16.0.0'
+ react-dom: '>=16.0.0'
+
+ '@rc-component/listy@1.2.3':
+ resolution: {integrity: sha512-IXiMjV5s0rczLBlfh7G5nB4M3365mrEeedjwKtf5I+Ns3PqRUsebR2h5u8CeFarsVfLUPC2I5p0h09TNoOWyvQ==}
+ peerDependencies:
+ react: '>=18.0.0'
+ react-dom: '>=18.0.0'
+
+ '@rc-component/mentions@1.11.0':
+ resolution: {integrity: sha512-IC2qXuEBMFHxPIXEFfYWj6Sr7UiDZnOqJHCYQBbwPzopBJOPZIR6mV9U4QH1bYQRlKYlYnIsajWDMgVGgWQyWQ==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/menu@1.4.1':
+ resolution: {integrity: sha512-3GsVRoQ4cnF/AoIQ4P+Z1haBfgfBPQfLT1RJY3Nu4DzOnheTslfCiGSPj7bv/cLj5sW5pHqN25dDXGP3JELAlQ==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/mini-decimal@1.1.4':
+ resolution: {integrity: sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w==}
+ engines: {node: '>=8.x'}
+
+ '@rc-component/motion@1.3.3':
+ resolution: {integrity: sha512-Xh3IszxvlSv3/PLYFyC2UZi9LNB83yOnkB/LNmRzaypZLvkhqUIPS7MQpGZcCMWrNsXV2p6YTSWbSGvFpEle9A==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/mutate-observer@2.0.1':
+ resolution: {integrity: sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/notification@2.0.7':
+ resolution: {integrity: sha512-nqZzpf6BPdaj+3ILx7si79LLmqPKyUmQoXa+/9gg0SkH0v1DbD66oJgRMSBEVnd/zUT3D4gwxWIHUKebYf2ZXQ==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '>=18.0.0'
+ react-dom: '>=18.0.0'
+
+ '@rc-component/overflow@1.0.1':
+ resolution: {integrity: sha512-syfmgAABaHCnCDzPwHZ/2tuvIcpOO3jefYZMmfkN+pmo8HKTzsfhS57vxo4ksPdN0By+uWVJhJWNFozNBxi2eA==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/pagination@1.4.0':
+ resolution: {integrity: sha512-CW1g7P9V8u+e8JQdUsl2RWg+GCsoee0mtJjZUCCxn/vb3jzOwDKm6hAdwddHCVBfWJ58eGUBZz3IvnU8rRktjw==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/picker@1.12.0':
+ resolution: {integrity: sha512-0FZGgDiDZFMm2hcfGv4jyjm7yWIj2MiwfeTzLL0vWkO+8cCSKdiM9XhWpkn1aAsoI2EwUy48Oz2cGfvL8ZKrfw==}
+ engines: {node: '>=12.x'}
+ peerDependencies:
+ date-fns: '>= 2.x'
+ dayjs: '>= 1.x'
+ luxon: '>= 3.x'
+ moment: '>= 2.x'
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+ peerDependenciesMeta:
+ date-fns:
+ optional: true
+ dayjs:
+ optional: true
+ luxon:
+ optional: true
+ moment:
+ optional: true
+
+ '@rc-component/portal@2.2.1':
+ resolution: {integrity: sha512-ck+r1kW/JSv0wxPji3KN2ss9K6Z0qqwusw/mf/0JobXhZ8hC2ejZwCJObW/SvDi0uhA0VzmCnx0CaCci95tcmA==}
+ engines: {node: '>=12.x'}
+ peerDependencies:
+ react: '>=18.0.0'
+ react-dom: '>=18.0.0'
+
+ '@rc-component/progress@1.0.2':
+ resolution: {integrity: sha512-WZUnH9eGxH1+xodZKqdrHke59uyGZSWgj5HBM5Kwk5BrTMuAORO7VJ2IP5Qbm9aH3n9x3IcesqHHR0NWPBC7fQ==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/qrcode@2.0.0':
+ resolution: {integrity: sha512-aAv3QhPP1xyafuTZOxub6a54pCeBnN3IwQkpETrBtthq4BL5IgxnCbuoBWPDpdLw1y1j6BgBUCAKV92+yX06Dw==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/rate@1.0.1':
+ resolution: {integrity: sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/resize-observer@1.1.2':
+ resolution: {integrity: sha512-t/Bb0W8uvL4PYKAB3YcChC+DlHh0Wt5kM7q/J+0qpVEUMLe7Hk5zuvc9km0hMnTFPSx5Z7Wu/fzCLN6erVLE8Q==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/segmented@1.3.0':
+ resolution: {integrity: sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg==}
+ peerDependencies:
+ react: '>=16.0.0'
+ react-dom: '>=16.0.0'
+
+ '@rc-component/select@1.10.1':
+ resolution: {integrity: sha512-H+yQsl+qED9NilQ3g6zdpsMwUgwVjrcMTkNHAWRVU/MoNCYgTbDgU+MIMgZDK+rVdd2JUfI/MkysMcZZ0cyQKw==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '*'
+ react-dom: '*'
+
+ '@rc-component/slider@1.1.1':
+ resolution: {integrity: sha512-LSzgWGYDgeCDgR4r1XlU29gbYws6HpLnvJd/uMhLeW/vQgxldeR+Wb4uzHDCHiYEbr1bnEHWdjkPxjJRHxuiig==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/steps@1.2.2':
+ resolution: {integrity: sha512-/yVIZ00gDYYPHSY0JP+M+s3ZvuXLu2f9rEjQqiUDs7EcYsUYrpJ/1bLj9aI9R7MBR3fu/NGh6RM9u2qGfqp+Nw==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/switch@1.0.3':
+ resolution: {integrity: sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/table@1.11.1':
+ resolution: {integrity: sha512-OWdS6DMmeWb7bJBGqPxYZpQbzBlBiXZUu2sqo6Ii7Sjs9GeK1IsrXrWk26SL2c6KEseabswdxrRj7WUm9LdECw==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '>=18.0.0'
+ react-dom: '>=18.0.0'
+
+ '@rc-component/tabs@1.12.0':
+ resolution: {integrity: sha512-XL7Kqy5fnUE2WTlO1/fCGrrfNlGFebdr7JseGkEIjzcVMAtIFQJ8sqCSOmxcXstjU6fonD/4rnhZHxj7sDTajQ==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/tooltip@1.5.0':
+ resolution: {integrity: sha512-agQ/+mBqrEQfTX4D3KhQ7j+ZbX4/VHjoJ7Noa2wIdZ1/FbQTOd7Sn92rp+jtCoqAVTLUgSOydePIgZ204gi2EQ==}
+ peerDependencies:
+ react: '>=18.0.0'
+ react-dom: '>=18.0.0'
+
+ '@rc-component/tour@2.4.0':
+ resolution: {integrity: sha512-aui4r4TqmTzwaBgcQxHYep8kM8PTjZFufjokObpy35KfFeZ0k9ArquWFZqegQlH24P14t+F0qO0mGTgzlav1yg==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/tree-select@1.16.1':
+ resolution: {integrity: sha512-a1Oi6EJhqAhdOxxupdJi6fP0RPHMKn5TcfkX2+llaQ4lF4nwfH7b6SCHcnsybaa2s+pk1yZYwVyeOYkDnEBRdg==}
+ peerDependencies:
+ react: '*'
+ react-dom: '*'
+
+ '@rc-component/tree@1.4.0':
+ resolution: {integrity: sha512-dGsJGDJQedA0BqqVgj3F8BvHXTSZijyhTXdbAdkcx8lynzZkty/CV3Z3LOm/fxz+BCfl3dfGiAQpb7Q5XNvl0Q==}
+ engines: {node: '>=10.x'}
+ peerDependencies:
+ react: '*'
+ react-dom: '*'
+
+ '@rc-component/trigger@3.10.1':
+ resolution: {integrity: sha512-mXlDN0IXdtV8Yqqm8195ECCyrbmfvvfKvwVvSlH0+qvKD6BUF8gRhEjSy0FOcD1+CcDRHgTiX99LoxfQrmh3Cw==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '>=18.0.0'
+ react-dom: '>=18.0.0'
+
+ '@rc-component/upload@1.1.1':
+ resolution: {integrity: sha512-GvYWSKeaJTOxxC5p6+nOSadzfvXA1h8C/iHFPFZX+szH3JUXrvs+DLiW8YUTBgvMh8m63mJeHrlYlJzAlg+pDA==}
+ peerDependencies:
+ react: '>=16.9.0'
+ react-dom: '>=16.9.0'
+
+ '@rc-component/util@1.12.0':
+ resolution: {integrity: sha512-AEjPL8JVdohIITaiXokyjL9WQ6tKWWjAYK9QU16tGNE9JaQABBQy+hA4H2Lup5MgXy9yY3iLrbZJheuU13hTdQ==}
+ peerDependencies:
+ react: '>=18.0.0'
+ react-dom: '>=18.0.0'
+
+ '@rc-component/virtual-list@1.5.1':
+ resolution: {integrity: sha512-boqHxdtyWC88u8quYgEO49bcBy5fzRiOcnBge+N4nLzs2k8hUQ/yw7JE9dM6yCBE4jSm5YSHVCVMS+suBuJGKA==}
+ engines: {node: '>=8.x'}
+ peerDependencies:
+ react: '>=18.0.0'
+ react-dom: '>=18.0.0'
+
+ '@rolldown/pluginutils@1.0.1':
+ resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
+
+ '@rollup/rollup-android-arm-eabi@4.62.4':
+ resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.62.4':
+ resolution: {integrity: sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.62.4':
+ resolution: {integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.62.4':
+ resolution: {integrity: sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.62.4':
+ resolution: {integrity: sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.62.4':
+ resolution: {integrity: sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.62.4':
+ resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.62.4':
+ resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==}
+ cpu: [arm]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-arm64-gnu@4.62.4':
+ resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm64-musl@4.62.4':
+ resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-loong64-gnu@4.62.4':
+ resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-loong64-musl@4.62.4':
+ resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.62.4':
+ resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-ppc64-musl@4.62.4':
+ resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.62.4':
+ resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-riscv64-musl@4.62.4':
+ resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-s390x-gnu@4.62.4':
+ resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-gnu@4.62.4':
+ resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-musl@4.62.4':
+ resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-openbsd-x64@4.62.4':
+ resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@rollup/rollup-openharmony-arm64@4.62.4':
+ resolution: {integrity: sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.62.4':
+ resolution: {integrity: sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.62.4':
+ resolution: {integrity: sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.62.4':
+ resolution: {integrity: sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.62.4':
+ resolution: {integrity: sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==}
+ cpu: [x64]
+ os: [win32]
+
+ '@swc/core-darwin-arm64@1.15.47':
+ resolution: {integrity: sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@swc/core-darwin-x64@1.15.47':
+ resolution: {integrity: sha512-leTi7Rx3KF4zcC637iqWgk9SoV8VXAD8ppQYXsep63px5A/UftOcxLN1pmr8Z1si/YvX90ompP/rHgpYkgwXWg==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@swc/core-linux-arm-gnueabihf@1.15.47':
+ resolution: {integrity: sha512-hBqHuoWKKIsKmDBn9qVeWqj5GWZhtlcczVaqQmNRXsDfq+voR5CxKRfamA367QjJXtceYuliLFfEL8QsskRM2g==}
+ engines: {node: '>=10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@swc/core-linux-arm64-gnu@1.15.47':
+ resolution: {integrity: sha512-TBxvRz+B4K205TWHHZxWVxkC2RFNP/Mz3PNcECBos5PsKwxjg3QSJzdoebr0VCf0Bfh8HOPldKxAP/8XkFe9gA==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@swc/core-linux-arm64-musl@1.15.47':
+ resolution: {integrity: sha512-3Yu3Uq/VgytqsPjTMbkPU1ExADytbdWbruJYhA584E9jrpE2Ki+R6VVPoZCeAVk1Cb7QxcRTgblw6bSa6a/R+w==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@swc/core-linux-ppc64-gnu@1.15.47':
+ resolution: {integrity: sha512-wfdMi5IaOaNtmh2/6geRoxIdNfqylUZFdtzTKS655y1axWfIWyx7As74vv0wVdjeCIZ3WmCI9odDd4rUttXOSQ==}
+ engines: {node: '>=10'}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@swc/core-linux-s390x-gnu@1.15.47':
+ resolution: {integrity: sha512-3hHYBY0yx8Ez7GMRrkhXHQzMdR5IZA6Wq5Ee4svlgwvSECLpnAJ9+0AimEGUFDvuLwE7nV/2+PYe8+Nm4rvNcQ==}
+ engines: {node: '>=10'}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@swc/core-linux-x64-gnu@1.15.47':
+ resolution: {integrity: sha512-TjfhjgP/jGCfFHYC3JQPhJA1HwErbIJ9JfREDc1KNkvY6P0LodCgKVIlQ5deeTbkG7ih3bF5PHJLuLpaZjdRyQ==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@swc/core-linux-x64-musl@1.15.47':
+ resolution: {integrity: sha512-CQpS8Ge/avfjZd0UEwG/sds83Uu32deQXcV1Jo3jD0mmvQQqtYAjpsDZXugmheeAwmt+YIuoVtVHro8LMYHqsQ==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@swc/core-win32-arm64-msvc@1.15.47':
+ resolution: {integrity: sha512-0W8IKHsUTYiT7G2RqtOoVWk+89yzZikIiDUb/sCK6BmQDBhN91hQSfyUtW12jhEWLzYgcfmisfsZrmZE+84U1A==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@swc/core-win32-ia32-msvc@1.15.47':
+ resolution: {integrity: sha512-ZIp49d2Z4/ka2jO9otOg4hDvTdPmp86kVOgS2M5FCPI7eKKZ1W0boxWn+8XeZrfERtFGW0AlMRm4JhlJa7l3NA==}
+ engines: {node: '>=10'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@swc/core-win32-x64-msvc@1.15.47':
+ resolution: {integrity: sha512-2h8Iek95vnixkBRCo+H8p09+Q5ll2NgSMFrWTy0iKt7+/t+8/T5mBpiT6c0ZxSS7wcWjwZ9sGZkK70tTSYHdDw==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@swc/core@1.15.47':
+ resolution: {integrity: sha512-FbsO5JcfOjfH38W/rohBRBweJeERsAuIP4f377lmkmxTcq9exjtx4SkRuZY5CdfhR2CBVwDIJegBpJDffwNsOg==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@swc/helpers': '>=0.5.17'
+ peerDependenciesMeta:
+ '@swc/helpers':
+ optional: true
+
+ '@swc/counter@0.1.3':
+ resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
+
+ '@swc/types@0.1.28':
+ resolution: {integrity: sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==}
+
+ '@types/estree@1.0.9':
+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
+ '@types/node@24.13.3':
+ resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
+
+ '@types/react-dom@19.2.4':
+ resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==}
+ peerDependencies:
+ '@types/react': ^19.2.0
+
+ '@types/react@19.2.18':
+ resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
+
+ '@vitejs/plugin-react-swc@4.3.3':
+ resolution: {integrity: sha512-bti8ZAcvz4Lh6/e4Uk2k3aa1TiUXbbMsahuqOHvd3MveFTkKDZOA6wQVkpj7J/+tepX/wGfe+lsGh/t24HTXMQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ peerDependencies:
+ vite: ^4 || ^5 || ^6 || ^7 || ^8
+
+ agent-base@6.0.2:
+ resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
+ engines: {node: '>= 6.0.0'}
+
+ antd@6.6.0:
+ resolution: {integrity: sha512-UDwWIbpmrCHB9ZQ+bPh4vQfB6DTI2ulIyoQ0Tc9xxalFblttiNGHl3ySBD9SyV/8+gUjFzfSx1+iU1Fog2i46w==}
+ peerDependencies:
+ react: '>=18.0.0'
+ react-dom: '>=18.0.0'
+
+ asynckit@0.4.0:
+ resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
+
+ axios@1.19.0:
+ resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==}
+
+ call-bind-apply-helpers@1.0.2:
+ resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
+ engines: {node: '>= 0.4'}
+
+ clsx@2.1.1:
+ resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
+ engines: {node: '>=6'}
+
+ combined-stream@1.0.8:
+ resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
+ engines: {node: '>= 0.8'}
+
+ compute-scroll-into-view@3.1.1:
+ resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==}
+
+ cookie@1.1.1:
+ resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
+ engines: {node: '>=18'}
+
+ csstype@3.2.3:
+ resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+
+ dayjs@1.11.21:
+ resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ delayed-stream@1.0.0:
+ resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
+ engines: {node: '>=0.4.0'}
+
+ dunder-proto@1.0.1:
+ resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
+ engines: {node: '>= 0.4'}
+
+ es-define-property@1.0.1:
+ resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
+ engines: {node: '>= 0.4'}
+
+ es-errors@1.3.0:
+ resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+ engines: {node: '>= 0.4'}
+
+ es-object-atoms@1.1.2:
+ resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
+ engines: {node: '>= 0.4'}
+
+ es-set-tostringtag@2.1.0:
+ resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
+ engines: {node: '>= 0.4'}
+
+ esbuild@0.28.2:
+ resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ follow-redirects@1.16.0:
+ resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
+ engines: {node: '>=4.0'}
+ peerDependencies:
+ debug: '*'
+ peerDependenciesMeta:
+ debug:
+ optional: true
+
+ form-data@4.0.6:
+ resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
+ engines: {node: '>= 6'}
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ function-bind@1.1.2:
+ resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+ get-intrinsic@1.3.0:
+ resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
+ engines: {node: '>= 0.4'}
+
+ get-proto@1.0.1:
+ resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
+ engines: {node: '>= 0.4'}
+
+ gopd@1.2.0:
+ resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
+ engines: {node: '>= 0.4'}
+
+ has-symbols@1.1.0:
+ resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
+ engines: {node: '>= 0.4'}
+
+ has-tostringtag@1.0.2:
+ resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
+ engines: {node: '>= 0.4'}
+
+ hasown@2.0.4:
+ resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
+ engines: {node: '>= 0.4'}
+
+ https-proxy-agent@5.0.1:
+ resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
+ engines: {node: '>= 6'}
+
+ is-mobile@5.0.0:
+ resolution: {integrity: sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==}
+
+ json2mq@0.2.0:
+ resolution: {integrity: sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==}
+
+ math-intrinsics@1.1.0:
+ resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+ engines: {node: '>= 0.4'}
+
+ mime-db@1.52.0:
+ resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
+ engines: {node: '>= 0.6'}
+
+ mime-types@2.1.35:
+ resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
+ engines: {node: '>= 0.6'}
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ nanoid@3.3.18:
+ resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.5:
+ resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
+ engines: {node: '>=12'}
+
+ postcss@8.5.26:
+ resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ proxy-from-env@2.1.0:
+ resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
+ engines: {node: '>=10'}
+
+ react-dom@19.2.8:
+ resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==}
+ peerDependencies:
+ react: ^19.2.8
+
+ react-is@19.2.8:
+ resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==}
+
+ react-router@7.18.2:
+ resolution: {integrity: sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==}
+ engines: {node: '>=20.0.0'}
+ peerDependencies:
+ react: '>=18'
+ react-dom: '>=18'
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+
+ react@19.2.8:
+ resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
+ engines: {node: '>=0.10.0'}
+
+ rollup@4.62.4:
+ resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ scheduler@0.27.0:
+ resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+
+ scroll-into-view-if-needed@3.1.0:
+ resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==}
+
+ set-cookie-parser@2.7.2:
+ resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+
+ source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+
+ string-convert@0.2.1:
+ resolution: {integrity: sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==}
+
+ stylis@4.4.0:
+ resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==}
+
+ throttle-debounce@5.0.2:
+ resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==}
+ engines: {node: '>=12.22'}
+
+ tinyglobby@0.2.17:
+ resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
+ engines: {node: '>=12.0.0'}
+
+ typescript@5.9.3:
+ resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+
+ undici-types@7.18.2:
+ resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
+
+ vite@7.3.6:
+ resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ lightningcss: ^1.21.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: '>=0.54.8'
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+snapshots:
+
+ '@ant-design/colors@8.0.1':
+ dependencies:
+ '@ant-design/fast-color': 3.0.1
+
+ '@ant-design/cssinjs-utils@2.1.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@ant-design/cssinjs': 2.1.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@babel/runtime': 7.29.7
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@ant-design/cssinjs@2.1.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@emotion/hash': 0.8.0
+ '@emotion/unitless': 0.7.5
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ csstype: 3.2.3
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+ stylis: 4.4.0
+
+ '@ant-design/fast-color@3.0.1': {}
+
+ '@ant-design/icons-svg@4.5.0': {}
+
+ '@ant-design/icons@6.3.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@ant-design/colors': 8.0.1
+ '@ant-design/icons-svg': 4.5.0
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@ant-design/react-slick@2.0.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ clsx: 2.1.1
+ json2mq: 0.2.0
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+ throttle-debounce: 5.0.2
+
+ '@babel/runtime@7.29.7': {}
+
+ '@babel/runtime@8.0.0': {}
+
+ '@emotion/hash@0.8.0': {}
+
+ '@emotion/unitless@0.7.5': {}
+
+ '@esbuild/aix-ppc64@0.28.2':
+ optional: true
+
+ '@esbuild/android-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/android-arm@0.28.2':
+ optional: true
+
+ '@esbuild/android-x64@0.28.2':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/darwin-x64@0.28.2':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-arm@0.28.2':
+ optional: true
+
+ '@esbuild/linux-ia32@0.28.2':
+ optional: true
+
+ '@esbuild/linux-loong64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.28.2':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.28.2':
+ optional: true
+
+ '@esbuild/linux-s390x@0.28.2':
+ optional: true
+
+ '@esbuild/linux-x64@0.28.2':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.28.2':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/sunos-x64@0.28.2':
+ optional: true
+
+ '@esbuild/win32-arm64@0.28.2':
+ optional: true
+
+ '@esbuild/win32-ia32@0.28.2':
+ optional: true
+
+ '@esbuild/win32-x64@0.28.2':
+ optional: true
+
+ '@napi-rs/lzma-linux-x64-gnu@1.5.1':
+ optional: true
+
+ '@rc-component/async-validator@6.0.0':
+ dependencies:
+ '@babel/runtime': 7.29.7
+
+ '@rc-component/cascader@1.22.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/select': 1.10.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/tree': 1.4.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/checkbox@2.0.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/collapse@1.2.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@rc-component/motion': 1.3.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/color-picker@3.1.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@ant-design/fast-color': 3.0.1
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/context@2.0.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/dialog@1.10.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/motion': 1.3.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/portal': 2.2.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/drawer@1.4.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/motion': 1.3.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/portal': 2.2.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/dropdown@1.0.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/trigger': 3.10.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/form@1.8.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/async-validator': 6.0.0
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/image@1.10.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/motion': 1.3.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/portal': 2.2.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/input-number@1.6.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/mini-decimal': 1.1.4
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/input@1.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/resize-observer': 1.1.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/listy@1.2.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/motion': 1.3.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/portal': 2.2.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/resize-observer': 1.1.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/virtual-list': 1.5.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/mentions@1.11.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/input': 1.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/menu': 1.4.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/trigger': 3.10.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/menu@1.4.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/motion': 1.3.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/overflow': 1.0.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/trigger': 3.10.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/mini-decimal@1.1.4':
+ dependencies:
+ '@babel/runtime': 7.29.7
+
+ '@rc-component/motion@1.3.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/mutate-observer@2.0.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/notification@2.0.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/motion': 1.3.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/overflow@1.0.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@rc-component/resize-observer': 1.1.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/pagination@1.4.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/picker@1.12.0(dayjs@1.11.21)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/overflow': 1.0.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/resize-observer': 1.1.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/trigger': 3.10.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+ optionalDependencies:
+ dayjs: 1.11.21
+
+ '@rc-component/portal@2.2.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/progress@1.0.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/qrcode@2.0.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/rate@1.0.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/resize-observer@1.1.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/segmented@1.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@rc-component/motion': 1.3.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/select@1.10.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/overflow': 1.0.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/trigger': 3.10.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/virtual-list': 1.5.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/slider@1.1.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/steps@1.2.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/switch@1.0.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/table@1.11.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/context': 2.0.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/resize-observer': 1.1.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/virtual-list': 1.5.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/tabs@1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/dropdown': 1.0.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/menu': 1.4.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/motion': 1.3.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/resize-observer': 1.1.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/tooltip@1.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/trigger': 3.10.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/tour@2.4.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/portal': 2.2.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/trigger': 3.10.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/tree-select@1.16.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/select': 1.10.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/tree': 1.4.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/tree@1.4.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/motion': 1.3.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/virtual-list': 1.5.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/trigger@3.10.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/motion': 1.3.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/portal': 2.2.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/resize-observer': 1.1.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/upload@1.1.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rc-component/util@1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ is-mobile: 5.0.0
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+ react-is: 19.2.8
+
+ '@rc-component/virtual-list@1.5.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ dependencies:
+ '@babel/runtime': 8.0.0
+ '@rc-component/resize-observer': 1.1.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+
+ '@rolldown/pluginutils@1.0.1': {}
+
+ '@rollup/rollup-android-arm-eabi@4.62.4':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.62.4':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.62.4':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.62.4':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.62.4':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-musl@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-musl@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.62.4':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.62.4':
+ optional: true
+
+ '@rollup/rollup-openbsd-x64@4.62.4':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.62.4':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.62.4':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.62.4':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.62.4':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.62.4':
+ optional: true
+
+ '@swc/core-darwin-arm64@1.15.47':
+ optional: true
+
+ '@swc/core-darwin-x64@1.15.47':
+ optional: true
+
+ '@swc/core-linux-arm-gnueabihf@1.15.47':
+ optional: true
+
+ '@swc/core-linux-arm64-gnu@1.15.47':
+ optional: true
+
+ '@swc/core-linux-arm64-musl@1.15.47':
+ optional: true
+
+ '@swc/core-linux-ppc64-gnu@1.15.47':
+ optional: true
+
+ '@swc/core-linux-s390x-gnu@1.15.47':
+ optional: true
+
+ '@swc/core-linux-x64-gnu@1.15.47':
+ optional: true
+
+ '@swc/core-linux-x64-musl@1.15.47':
+ optional: true
+
+ '@swc/core-win32-arm64-msvc@1.15.47':
+ optional: true
+
+ '@swc/core-win32-ia32-msvc@1.15.47':
+ optional: true
+
+ '@swc/core-win32-x64-msvc@1.15.47':
+ optional: true
+
+ '@swc/core@1.15.47':
+ dependencies:
+ '@swc/counter': 0.1.3
+ '@swc/types': 0.1.28
+ optionalDependencies:
+ '@swc/core-darwin-arm64': 1.15.47
+ '@swc/core-darwin-x64': 1.15.47
+ '@swc/core-linux-arm-gnueabihf': 1.15.47
+ '@swc/core-linux-arm64-gnu': 1.15.47
+ '@swc/core-linux-arm64-musl': 1.15.47
+ '@swc/core-linux-ppc64-gnu': 1.15.47
+ '@swc/core-linux-s390x-gnu': 1.15.47
+ '@swc/core-linux-x64-gnu': 1.15.47
+ '@swc/core-linux-x64-musl': 1.15.47
+ '@swc/core-win32-arm64-msvc': 1.15.47
+ '@swc/core-win32-ia32-msvc': 1.15.47
+ '@swc/core-win32-x64-msvc': 1.15.47
+
+ '@swc/counter@0.1.3': {}
+
+ '@swc/types@0.1.28':
+ dependencies:
+ '@swc/counter': 0.1.3
+
+ '@types/estree@1.0.9': {}
+
+ '@types/node@24.13.3':
+ dependencies:
+ undici-types: 7.18.2
+
+ '@types/react-dom@19.2.4(@types/react@19.2.18)':
+ dependencies:
+ '@types/react': 19.2.18
+
+ '@types/react@19.2.18':
+ dependencies:
+ csstype: 3.2.3
+
+ '@vitejs/plugin-react-swc@4.3.3(vite@7.3.6(@types/node@24.13.3))':
+ dependencies:
+ '@rolldown/pluginutils': 1.0.1
+ '@swc/core': 1.15.47
+ vite: 7.3.6(@types/node@24.13.3)
+ transitivePeerDependencies:
+ - '@swc/helpers'
+
+ agent-base@6.0.2:
+ dependencies:
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ antd@6.6.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
+ dependencies:
+ '@ant-design/colors': 8.0.1
+ '@ant-design/cssinjs': 2.1.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@ant-design/cssinjs-utils': 2.1.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@ant-design/fast-color': 3.0.1
+ '@ant-design/icons': 6.3.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@ant-design/react-slick': 2.0.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@babel/runtime': 7.29.7
+ '@rc-component/cascader': 1.22.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/checkbox': 2.0.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/collapse': 1.2.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/color-picker': 3.1.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/dialog': 1.10.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/drawer': 1.4.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/dropdown': 1.0.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/form': 1.8.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/image': 1.10.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/input': 1.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/input-number': 1.6.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/listy': 1.2.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/mentions': 1.11.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/menu': 1.4.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/motion': 1.3.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/mutate-observer': 2.0.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/notification': 2.0.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/pagination': 1.4.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/picker': 1.12.0(dayjs@1.11.21)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/progress': 1.0.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/qrcode': 2.0.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/rate': 1.0.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/resize-observer': 1.1.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/segmented': 1.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/select': 1.10.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/slider': 1.1.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/steps': 1.2.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/switch': 1.0.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/table': 1.11.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/tabs': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/tooltip': 1.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/tour': 2.4.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/tree': 1.4.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/tree-select': 1.16.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/trigger': 3.10.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/upload': 1.1.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ '@rc-component/util': 1.12.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ clsx: 2.1.1
+ dayjs: 1.11.21
+ react: 19.2.8
+ react-dom: 19.2.8(react@19.2.8)
+ scroll-into-view-if-needed: 3.1.0
+ throttle-debounce: 5.0.2
+ transitivePeerDependencies:
+ - date-fns
+ - luxon
+ - moment
+
+ asynckit@0.4.0: {}
+
+ axios@1.19.0:
+ dependencies:
+ follow-redirects: 1.16.0
+ form-data: 4.0.6
+ https-proxy-agent: 5.0.1
+ proxy-from-env: 2.1.0
+ transitivePeerDependencies:
+ - debug
+ - supports-color
+
+ call-bind-apply-helpers@1.0.2:
+ dependencies:
+ es-errors: 1.3.0
+ function-bind: 1.1.2
+
+ clsx@2.1.1: {}
+
+ combined-stream@1.0.8:
+ dependencies:
+ delayed-stream: 1.0.0
+
+ compute-scroll-into-view@3.1.1: {}
+
+ cookie@1.1.1: {}
+
+ csstype@3.2.3: {}
+
+ dayjs@1.11.21: {}
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ delayed-stream@1.0.0: {}
+
+ dunder-proto@1.0.1:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-errors: 1.3.0
+ gopd: 1.2.0
+
+ es-define-property@1.0.1: {}
+
+ es-errors@1.3.0: {}
+
+ es-object-atoms@1.1.2:
+ dependencies:
+ es-errors: 1.3.0
+
+ es-set-tostringtag@2.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ get-intrinsic: 1.3.0
+ has-tostringtag: 1.0.2
+ hasown: 2.0.4
+
+ esbuild@0.28.2:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.28.2
+ '@esbuild/android-arm': 0.28.2
+ '@esbuild/android-arm64': 0.28.2
+ '@esbuild/android-x64': 0.28.2
+ '@esbuild/darwin-arm64': 0.28.2
+ '@esbuild/darwin-x64': 0.28.2
+ '@esbuild/freebsd-arm64': 0.28.2
+ '@esbuild/freebsd-x64': 0.28.2
+ '@esbuild/linux-arm': 0.28.2
+ '@esbuild/linux-arm64': 0.28.2
+ '@esbuild/linux-ia32': 0.28.2
+ '@esbuild/linux-loong64': 0.28.2
+ '@esbuild/linux-mips64el': 0.28.2
+ '@esbuild/linux-ppc64': 0.28.2
+ '@esbuild/linux-riscv64': 0.28.2
+ '@esbuild/linux-s390x': 0.28.2
+ '@esbuild/linux-x64': 0.28.2
+ '@esbuild/netbsd-arm64': 0.28.2
+ '@esbuild/netbsd-x64': 0.28.2
+ '@esbuild/openbsd-arm64': 0.28.2
+ '@esbuild/openbsd-x64': 0.28.2
+ '@esbuild/openharmony-arm64': 0.28.2
+ '@esbuild/sunos-x64': 0.28.2
+ '@esbuild/win32-arm64': 0.28.2
+ '@esbuild/win32-ia32': 0.28.2
+ '@esbuild/win32-x64': 0.28.2
+
+ fdir@6.5.0(picomatch@4.0.5):
+ optionalDependencies:
+ picomatch: 4.0.5
+
+ follow-redirects@1.16.0: {}
+
+ form-data@4.0.6:
+ dependencies:
+ asynckit: 0.4.0
+ combined-stream: 1.0.8
+ es-set-tostringtag: 2.1.0
+ hasown: 2.0.4
+ mime-types: 2.1.35
+
+ fsevents@2.3.3:
+ optional: true
+
+ function-bind@1.1.2: {}
+
+ get-intrinsic@1.3.0:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ es-define-property: 1.0.1
+ es-errors: 1.3.0
+ es-object-atoms: 1.1.2
+ function-bind: 1.1.2
+ get-proto: 1.0.1
+ gopd: 1.2.0
+ has-symbols: 1.1.0
+ hasown: 2.0.4
+ math-intrinsics: 1.1.0
+
+ get-proto@1.0.1:
+ dependencies:
+ dunder-proto: 1.0.1
+ es-object-atoms: 1.1.2
+
+ gopd@1.2.0: {}
+
+ has-symbols@1.1.0: {}
+
+ has-tostringtag@1.0.2:
+ dependencies:
+ has-symbols: 1.1.0
+
+ hasown@2.0.4:
+ dependencies:
+ function-bind: 1.1.2
+
+ https-proxy-agent@5.0.1:
+ dependencies:
+ agent-base: 6.0.2
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ is-mobile@5.0.0: {}
+
+ json2mq@0.2.0:
+ dependencies:
+ string-convert: 0.2.1
+
+ math-intrinsics@1.1.0: {}
+
+ mime-db@1.52.0: {}
+
+ mime-types@2.1.35:
+ dependencies:
+ mime-db: 1.52.0
+
+ ms@2.1.3: {}
+
+ nanoid@3.3.18: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.5: {}
+
+ postcss@8.5.26:
+ dependencies:
+ nanoid: 3.3.18
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ proxy-from-env@2.1.0: {}
+
+ react-dom@19.2.8(react@19.2.8):
+ dependencies:
+ react: 19.2.8
+ scheduler: 0.27.0
+
+ react-is@19.2.8: {}
+
+ react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
+ dependencies:
+ cookie: 1.1.1
+ react: 19.2.8
+ set-cookie-parser: 2.7.2
+ optionalDependencies:
+ react-dom: 19.2.8(react@19.2.8)
+
+ react@19.2.8: {}
+
+ rollup@4.62.4:
+ dependencies:
+ '@types/estree': 1.0.9
+ optionalDependencies:
+ '@napi-rs/lzma-linux-x64-gnu': 1.5.1
+ '@rollup/rollup-android-arm-eabi': 4.62.4
+ '@rollup/rollup-android-arm64': 4.62.4
+ '@rollup/rollup-darwin-arm64': 4.62.4
+ '@rollup/rollup-darwin-x64': 4.62.4
+ '@rollup/rollup-freebsd-arm64': 4.62.4
+ '@rollup/rollup-freebsd-x64': 4.62.4
+ '@rollup/rollup-linux-arm-gnueabihf': 4.62.4
+ '@rollup/rollup-linux-arm-musleabihf': 4.62.4
+ '@rollup/rollup-linux-arm64-gnu': 4.62.4
+ '@rollup/rollup-linux-arm64-musl': 4.62.4
+ '@rollup/rollup-linux-loong64-gnu': 4.62.4
+ '@rollup/rollup-linux-loong64-musl': 4.62.4
+ '@rollup/rollup-linux-ppc64-gnu': 4.62.4
+ '@rollup/rollup-linux-ppc64-musl': 4.62.4
+ '@rollup/rollup-linux-riscv64-gnu': 4.62.4
+ '@rollup/rollup-linux-riscv64-musl': 4.62.4
+ '@rollup/rollup-linux-s390x-gnu': 4.62.4
+ '@rollup/rollup-linux-x64-gnu': 4.62.4
+ '@rollup/rollup-linux-x64-musl': 4.62.4
+ '@rollup/rollup-openbsd-x64': 4.62.4
+ '@rollup/rollup-openharmony-arm64': 4.62.4
+ '@rollup/rollup-win32-arm64-msvc': 4.62.4
+ '@rollup/rollup-win32-ia32-msvc': 4.62.4
+ '@rollup/rollup-win32-x64-gnu': 4.62.4
+ '@rollup/rollup-win32-x64-msvc': 4.62.4
+ fsevents: 2.3.3
+
+ scheduler@0.27.0: {}
+
+ scroll-into-view-if-needed@3.1.0:
+ dependencies:
+ compute-scroll-into-view: 3.1.1
+
+ set-cookie-parser@2.7.2: {}
+
+ source-map-js@1.2.1: {}
+
+ string-convert@0.2.1: {}
+
+ stylis@4.4.0: {}
+
+ throttle-debounce@5.0.2: {}
+
+ tinyglobby@0.2.17:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.5)
+ picomatch: 4.0.5
+
+ typescript@5.9.3: {}
+
+ undici-types@7.18.2: {}
+
+ vite@7.3.6(@types/node@24.13.3):
+ dependencies:
+ esbuild: 0.28.2
+ fdir: 6.5.0(picomatch@4.0.5)
+ picomatch: 4.0.5
+ postcss: 8.5.26
+ rollup: 4.62.4
+ tinyglobby: 0.2.17
+ optionalDependencies:
+ '@types/node': 24.13.3
+ fsevents: 2.3.3
diff --git a/studio/public/imgs/panda_shop_200x200_opacity30.png b/studio/public/imgs/panda_shop_200x200_opacity30.png
new file mode 100644
index 0000000..ab0063c
Binary files /dev/null and b/studio/public/imgs/panda_shop_200x200_opacity30.png differ
diff --git a/studio/public/imgs/watermark.jpg b/studio/public/imgs/watermark.jpg
new file mode 100644
index 0000000..af3f3bd
Binary files /dev/null and b/studio/public/imgs/watermark.jpg differ
diff --git a/studio/src/App.css b/studio/src/App.css
new file mode 100644
index 0000000..74c6000
--- /dev/null
+++ b/studio/src/App.css
@@ -0,0 +1 @@
+/* 全局应用级样式补充(主题变量见 index.css) */
diff --git a/studio/src/App.tsx b/studio/src/App.tsx
new file mode 100644
index 0000000..6ad2ceb
--- /dev/null
+++ b/studio/src/App.tsx
@@ -0,0 +1,25 @@
+import { RouterProvider } from 'react-router';
+import { ConfigProvider, App as AntdApp } from 'antd';
+import zhCN from 'antd/locale/zh_CN';
+import { router } from './router';
+import './App.css';
+
+function App() {
+ return (
+
+
+
+
+
+ );
+}
+
+export default App;
diff --git a/studio/src/config/env.ts b/studio/src/config/env.ts
new file mode 100644
index 0000000..ac6573e
--- /dev/null
+++ b/studio/src/config/env.ts
@@ -0,0 +1,20 @@
+/**
+ * 环境变量配置(Vite 仅暴露 VITE_ 前缀变量)
+ */
+
+interface EnvConfig {
+ apiBaseUrl: string;
+ appName: string;
+ debug: boolean;
+}
+
+const getEnv = (key: string, defaultValue: string = ''): string => {
+ return import.meta.env[key] || defaultValue;
+};
+
+export const envConfig: EnvConfig = {
+ // 开发环境默认走 vite 代理 /api -> http://127.0.0.1:8800
+ apiBaseUrl: getEnv('VITE_API_BASE_URL', '/api'),
+ appName: getEnv('VITE_APP_NAME', 'Ozon 发布工作台'),
+ debug: import.meta.env.DEV,
+};
diff --git a/studio/src/index.css b/studio/src/index.css
new file mode 100644
index 0000000..ffd8409
--- /dev/null
+++ b/studio/src/index.css
@@ -0,0 +1,35 @@
+:root {
+ --primary-color: #8b5cf6;
+ --primary-light: #a78bfa;
+ --primary-dark: #7c3aed;
+ /* 侧边菜单背景(纯色,中间调;想再调直接改这里) */
+ --sider-bg: #6d28d9;
+ --text-primary: #1f2937;
+ --text-secondary: #6b7280;
+ --bg-color: #f9fafb;
+ --card-bg: #ffffff;
+ --border-color: #e5e7eb;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ font-family:
+ -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
+ 'Microsoft YaHei', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
+ 'Helvetica Neue', sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ background-color: var(--bg-color);
+ color: var(--text-primary);
+ line-height: 1.6;
+}
+
+#root {
+ min-height: 100vh;
+}
diff --git a/studio/src/layouts/MainLayout.css b/studio/src/layouts/MainLayout.css
new file mode 100644
index 0000000..c07c96a
--- /dev/null
+++ b/studio/src/layouts/MainLayout.css
@@ -0,0 +1,115 @@
+.main-layout {
+ min-height: 100vh;
+}
+
+.main-sider {
+ background: var(--sider-bg) !important;
+ display: flex;
+ flex-direction: column;
+}
+
+.sidebar-header {
+ padding: 24px 20px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ text-align: center;
+}
+
+.logo {
+ font-size: 22px;
+ font-weight: 700;
+ color: white;
+ margin-bottom: 4px;
+}
+
+.logo-subtitle {
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.8);
+}
+
+.main-header {
+ background: white !important;
+ padding: 0 24px !important;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ box-shadow:
+ 0 1px 3px 0 rgba(0, 0, 0, 0.1),
+ 0 1px 2px 0 rgba(0, 0, 0, 0.06);
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ height: 64px;
+ line-height: 1;
+}
+
+.header-left {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.collapse-trigger,
+.mobile-menu-trigger {
+ font-size: 18px;
+ cursor: pointer;
+ color: #1f2937;
+ border-radius: 4px;
+ transition: background 0.2s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ flex-shrink: 0;
+}
+
+.collapse-trigger:hover,
+.mobile-menu-trigger:hover {
+ background: #f9fafb;
+}
+
+.page-title-group {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ line-height: 1.4;
+}
+
+.page-title {
+ font-size: 20px;
+ font-weight: 600;
+ color: #1f2937;
+ line-height: 1.2;
+ margin-bottom: 4px;
+}
+
+.page-subtitle {
+ font-size: 14px;
+ color: #6b7280;
+ font-style: italic;
+ line-height: 1.2;
+}
+
+.main-content {
+ padding: 24px;
+ background: #f3f4f6;
+ min-height: calc(100vh - 64px);
+}
+
+@media (max-width: 768px) {
+ .main-content {
+ padding: 16px;
+ }
+
+ .main-header {
+ padding: 0 16px !important;
+ }
+
+ .page-title {
+ font-size: 18px;
+ }
+
+ .page-subtitle {
+ font-size: 12px;
+ }
+}
diff --git a/studio/src/layouts/MainLayout.tsx b/studio/src/layouts/MainLayout.tsx
new file mode 100644
index 0000000..e3e9cbb
--- /dev/null
+++ b/studio/src/layouts/MainLayout.tsx
@@ -0,0 +1,102 @@
+import { useEffect, useMemo, useState } from 'react';
+import { Outlet, useLocation } from 'react-router';
+import { Drawer, Layout } from 'antd';
+import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
+import SidebarMenu from './SidebarMenu';
+import { getPageInfo } from './menuConfig';
+import './MainLayout.css';
+
+const { Header, Sider, Content } = Layout;
+
+const MainLayout = () => {
+ const [collapsed, setCollapsed] = useState(false);
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
+ const [isMobile, setIsMobile] = useState(false);
+ const location = useLocation();
+
+ const pageInfo = useMemo(() => getPageInfo(location.pathname), [location.pathname]);
+
+ useEffect(() => {
+ const checkMobile = () => {
+ setIsMobile(window.innerWidth < 768);
+ if (window.innerWidth >= 768) setMobileMenuOpen(false);
+ };
+ checkMobile();
+ window.addEventListener('resize', checkMobile);
+ return () => window.removeEventListener('resize', checkMobile);
+ }, []);
+
+ return (
+
+ {!isMobile && (
+
+
+
{collapsed ? 'O' : 'Ozon Kit'}
+ {!collapsed &&
发布工作台
}
+
+
+
+ )}
+
+ {isMobile && (
+ setMobileMenuOpen(false)}
+ open={mobileMenuOpen}
+ styles={{ body: { padding: 0, background: 'var(--sider-bg)' } }}
+ width={240}
+ >
+ setMobileMenuOpen(false)} />
+
+ )}
+
+
+
+
+ {isMobile && (
+
setMobileMenuOpen(true)}
+ />
+ )}
+ {!isMobile && (
+ setCollapsed(!collapsed)}>
+ {collapsed ? : }
+
+ )}
+
+
{pageInfo.title}
+
{pageInfo.subtitle}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default MainLayout;
diff --git a/studio/src/layouts/SidebarMenu.css b/studio/src/layouts/SidebarMenu.css
new file mode 100644
index 0000000..dee6f15
--- /dev/null
+++ b/studio/src/layouts/SidebarMenu.css
@@ -0,0 +1,82 @@
+.sidebar-menu-container {
+ flex: 1;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.sidebar-menu {
+ background: transparent !important;
+ border: none;
+ height: 100%;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+.sidebar-menu .ant-menu-item {
+ margin: 0 !important;
+ padding: 12px 20px !important;
+ height: auto !important;
+ line-height: 1.5 !important;
+ transition: all 0.3s !important;
+ font-size: 15px !important;
+ font-weight: 600 !important;
+}
+
+.sidebar-menu .ant-menu-item .ant-menu-title-content {
+ font-size: 15px !important;
+ font-weight: 600 !important;
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ display: inline-block;
+}
+
+.sidebar-menu .ant-menu-item:hover {
+ background: rgba(255, 255, 255, 0.1) !important;
+}
+
+.sidebar-menu .ant-menu-item-selected {
+ background: rgba(255, 255, 255, 0.2) !important;
+ border-left: 3px solid white;
+}
+
+.sidebar-menu .ant-menu-item-selected .ant-menu-title-content {
+ transform: translateX(8px);
+}
+
+.sidebar-menu .ant-menu-item-selected:hover {
+ background: rgba(255, 255, 255, 0.25) !important;
+}
+
+.sidebar-menu .ant-menu-item-selected:hover .ant-menu-title-content {
+ transform: translateX(8px);
+}
+
+.sidebar-menu .ant-menu-item-group-title {
+ padding: 12px 20px 8px !important;
+ font-size: 12px !important;
+ color: rgba(255, 255, 255, 0.7) !important;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ font-weight: 500;
+}
+
+.sidebar-menu .ant-menu-item-icon {
+ transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ font-size: 18px !important;
+ display: inline-flex;
+ align-items: center;
+}
+
+.sidebar-menu .ant-menu-item:hover .ant-menu-item-icon {
+ transform: scale(1.05);
+}
+
+.sidebar-menu .ant-menu-item-selected .ant-menu-item-icon {
+ transform: translateX(8px);
+}
+
+@media (max-width: 768px) {
+ .sidebar-menu .ant-menu-item {
+ padding: 14px 20px !important;
+ }
+}
diff --git a/studio/src/layouts/SidebarMenu.tsx b/studio/src/layouts/SidebarMenu.tsx
new file mode 100644
index 0000000..74953c1
--- /dev/null
+++ b/studio/src/layouts/SidebarMenu.tsx
@@ -0,0 +1,61 @@
+import { useMemo } from 'react';
+import { useLocation, useNavigate } from 'react-router';
+import { Menu } from 'antd';
+import type { MenuProps } from 'antd';
+import { routeMenuConfig } from './menuConfig';
+import './SidebarMenu.css';
+
+interface SidebarMenuProps {
+ collapsed: boolean;
+ onMenuClick?: () => void;
+}
+
+const SidebarMenu = ({ collapsed, onMenuClick }: SidebarMenuProps) => {
+ const location = useLocation();
+ const navigate = useNavigate();
+
+ const menuItems: MenuProps['items'] = useMemo(() => {
+ const children = routeMenuConfig.map((config) => ({
+ key: config.key,
+ icon: config.icon,
+ label: config.label,
+ }));
+ return [
+ {
+ type: 'group',
+ label: collapsed ? '' : '主要功能',
+ className: 'menu-section-title',
+ children,
+ },
+ ];
+ }, [collapsed]);
+
+ const selectedKeys = useMemo(() => {
+ const path = location.pathname;
+ if (path === '/' || path === '') {
+ return [routeMenuConfig[0].key];
+ }
+ return [path];
+ }, [location.pathname]);
+
+ const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
+ navigate(key);
+ if (onMenuClick) onMenuClick();
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default SidebarMenu;
diff --git a/studio/src/layouts/menuConfig.tsx b/studio/src/layouts/menuConfig.tsx
new file mode 100644
index 0000000..ffaf2f6
--- /dev/null
+++ b/studio/src/layouts/menuConfig.tsx
@@ -0,0 +1,39 @@
+import { PictureOutlined } from '@ant-design/icons';
+import type { ReactNode } from 'react';
+
+export interface RouteMenuConfig {
+ /** 路由路径 */
+ path: string;
+ /** 菜单键值(与 path 相同) */
+ key: string;
+ /** 菜单图标 */
+ icon: ReactNode;
+ /** 菜单标签 */
+ label: string;
+ /** 页面标题 */
+ title: string;
+ /** 页面副标题 */
+ subtitle: string;
+}
+
+/** 菜单配置列表(当前仅「AI 图生图」一页) */
+export const routeMenuConfig: RouteMenuConfig[] = [
+ {
+ path: '/ai-image',
+ key: '/ai-image',
+ icon: ,
+ label: 'AI 图生图',
+ title: 'AI 图生图',
+ subtitle: '上传图片、加水印、用万相模型进行图生图编辑',
+ },
+];
+
+/** 根据路径获取页面标题信息 */
+export const getPageInfo = (path: string): { title: string; subtitle: string } => {
+ if (path === '/' || path === '') {
+ const first = routeMenuConfig[0];
+ return { title: first.title, subtitle: first.subtitle };
+ }
+ const config = routeMenuConfig.find((item) => item.path === path);
+ return config ? { title: config.title, subtitle: config.subtitle } : getPageInfo('/');
+};
diff --git a/studio/src/main.tsx b/studio/src/main.tsx
new file mode 100644
index 0000000..00f5203
--- /dev/null
+++ b/studio/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import './index.css';
+import App from './App';
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/studio/src/pages/ai-image/AiImagePage.css b/studio/src/pages/ai-image/AiImagePage.css
new file mode 100644
index 0000000..2ab103d
--- /dev/null
+++ b/studio/src/pages/ai-image/AiImagePage.css
@@ -0,0 +1,538 @@
+.ai-image-page {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ max-width: 1200px;
+}
+
+.panel {
+ background: var(--card-bg);
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+ padding: 20px;
+ box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.04);
+}
+
+.panel-title {
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 16px;
+}
+
+.panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 12px;
+}
+
+.panel-header .panel-title {
+ margin-bottom: 0;
+}
+
+/* 水印设置 */
+.settings-row {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 32px;
+}
+
+.settings-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.settings-label {
+ font-size: 14px;
+ color: var(--text-secondary);
+ white-space: nowrap;
+}
+
+.settings-hint {
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.wm-preview-img {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ border: 1px solid var(--border-color);
+ object-fit: cover;
+}
+
+.settings-opacity {
+ flex: 1;
+ min-width: 240px;
+ max-width: 420px;
+}
+
+/* 上传区 */
+.panel .ant-upload-wrapper .ant-upload-drag {
+ background: #fafafa;
+}
+
+/* 预览网格 */
+.image-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ gap: 16px;
+}
+
+.image-card {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ border: 1px solid var(--border-color);
+ border-radius: 10px;
+ padding: 10px;
+ background: #fff;
+}
+
+.watermark-canvas {
+ display: block;
+ width: 100%;
+ height: auto;
+ border-radius: 6px;
+ background:
+ repeating-conic-gradient(#f3f4f6 0% 25%, #fff 0% 50%) 50% / 16px 16px;
+}
+
+.watermark-canvas-draggable {
+ cursor: grab;
+}
+
+.watermark-canvas-draggable:active {
+ cursor: grabbing;
+}
+
+.image-card-name {
+ font-size: 12px;
+ color: var(--text-secondary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.image-card-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.drag-hint {
+ margin-top: 12px;
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+/* ===== 图片编辑工作台(AI + 精确标注) ===== */
+.workbench-root {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+}
+
+.workbench {
+ display: flex;
+ gap: 12px;
+ flex: 1;
+ min-height: 0;
+}
+
+.wb-left {
+ width: 112px;
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ overflow-y: auto;
+}
+
+.wb-thumb {
+ cursor: pointer;
+ border: 2px solid transparent;
+ border-radius: 8px;
+ padding: 4px;
+ background: #fff;
+ transition: border-color 0.2s;
+}
+
+.wb-thumb img {
+ width: 100%;
+ height: 78px;
+ object-fit: contain;
+ border-radius: 4px;
+ background:
+ repeating-conic-gradient(#f3f4f6 0% 25%, #fff 0% 50%) 50% / 16px 16px;
+}
+
+.wb-thumb span {
+ display: block;
+ font-size: 12px;
+ text-align: center;
+ color: var(--text-secondary);
+}
+
+.wb-thumb.active {
+ border-color: var(--primary-color);
+}
+
+.wb-thumb.active span {
+ color: var(--primary-color);
+}
+
+.wb-thumb-empty {
+ font-size: 12px;
+ color: var(--text-secondary);
+ text-align: center;
+ padding: 8px;
+}
+
+.wb-center {
+ /* 中间图片区域:宽度更窄(接近电商商品图竖版比例),水平居中、顶部与标注编辑区对齐 */
+ width: min(680px, 72%);
+ height: 100%;
+ margin: 0 auto;
+ align-self: flex-start;
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: #f3f4f6;
+ border: 1px solid var(--border-color);
+ border-radius: 10px;
+}
+
+.wb-loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+}
+
+.annotation-canvas-wrap {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 12px;
+}
+
+.annotation-canvas {
+ max-width: 100%;
+ max-height: 100%;
+ cursor: default;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ border-radius: 4px;
+}
+
+/* 标注编辑区(右侧面板) */
+.wb-right {
+ width: 340px;
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ min-width: 0;
+ overflow-x: hidden;
+ border: 1px solid var(--border-color);
+ border-radius: 10px;
+ padding: 8px 12px 12px;
+ background: #fff;
+}
+
+.wb-tabs {
+ flex-shrink: 0;
+}
+
+.wb-add-row {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 10px;
+ flex-shrink: 0;
+}
+
+.wb-add-row .ant-btn {
+ flex: 1;
+}
+
+.wb-preset-row {
+ margin-bottom: 10px;
+ flex-shrink: 0;
+}
+
+.wb-preset-select {
+ width: 100%;
+}
+
+.wb-save-row {
+ margin-top: 10px;
+ flex-shrink: 0;
+}
+
+.wb-props {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+/* 属性行内控件允许收缩,避免横向滚动条 */
+.prop-row > * {
+ min-width: 0;
+}
+
+.wb-edit-actions {
+ display: flex;
+ gap: 8px;
+ padding-top: 10px;
+ margin-top: 10px;
+ border-top: 1px solid var(--border-color);
+ flex-shrink: 0;
+}
+
+.wb-edit-actions .ant-btn {
+ flex: 1;
+}
+
+.props-empty {
+ padding-top: 24px;
+}
+
+.props-hint {
+ font-size: 12px;
+ color: var(--text-secondary);
+ text-align: center;
+}
+
+.props-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.prop-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.prop-row .ant-input,
+.prop-row .ant-input-textarea,
+.prop-row textarea {
+ flex: 1;
+}
+
+.prop-label {
+ width: 62px;
+ font-size: 13px;
+ color: var(--text-secondary);
+ flex-shrink: 0;
+}
+
+.wb-bottom {
+ margin-top: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ flex-shrink: 0;
+}
+
+.wb-ai {
+ display: flex;
+ gap: 12px;
+ height: 96px;
+ align-items: stretch;
+}
+
+.wb-ai-input {
+ flex: 1;
+ min-width: 0;
+ height: 100%;
+}
+
+/* 右侧:模型下拉 + AI 生成,宽度与标注编辑区(340px)一致 */
+.wb-ai-side {
+ width: 340px;
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.wb-model-select {
+ width: 100%;
+}
+
+.wb-ai-side .ant-btn {
+ flex: 1;
+ min-height: 44px;
+}
+
+/* ===== 旧的 AI 弹窗样式(保留兼容) ===== */
+.edit-modal {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.edit-modal-compare {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+}
+
+.compare-pane {
+ border: 1px solid var(--border-color);
+ border-radius: 10px;
+ overflow: hidden;
+}
+
+.pane-title {
+ padding: 8px 12px;
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-primary);
+ background: #f9fafb;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.pane-body {
+ min-height: 320px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background:
+ repeating-conic-gradient(#f3f4f6 0% 25%, #fff 0% 50%) 50% / 16px 16px;
+ padding: 12px;
+}
+
+.pane-img {
+ max-width: 100%;
+ max-height: 420px;
+ object-fit: contain;
+}
+
+.pane-loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 320px;
+}
+
+/* 对话框 */
+.edit-modal-chat {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.chat-messages {
+ height: 220px;
+ overflow-y: auto;
+ border: 1px solid var(--border-color);
+ border-radius: 10px;
+ padding: 12px;
+ background: #fafafa;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.chat-placeholder {
+ margin: auto;
+ color: var(--text-secondary);
+ font-size: 13px;
+ text-align: center;
+}
+
+.chat-message {
+ display: flex;
+}
+
+.chat-user {
+ justify-content: flex-end;
+}
+
+.chat-assistant {
+ justify-content: flex-start;
+}
+
+.chat-bubble {
+ max-width: 80%;
+ padding: 8px 12px;
+ border-radius: 10px;
+ font-size: 14px;
+ line-height: 1.5;
+ word-break: break-word;
+}
+
+.chat-user .chat-bubble {
+ background: var(--primary-color);
+ color: #fff;
+ border-bottom-right-radius: 2px;
+}
+
+.chat-assistant .chat-bubble {
+ background: #fff;
+ border: 1px solid var(--border-color);
+ color: var(--text-primary);
+ border-bottom-left-radius: 2px;
+}
+
+.chat-img {
+ display: block;
+ margin-top: 8px;
+ max-width: 100%;
+ max-height: 160px;
+ border-radius: 6px;
+}
+
+.chat-options {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.chat-strength {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.chat-strength-label {
+ font-size: 13px;
+ color: var(--text-secondary);
+ white-space: nowrap;
+}
+
+.chat-strength-value {
+ font-size: 13px;
+ color: var(--text-primary);
+ min-width: 2.5em;
+}
+
+.chat-strength-hint {
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.chat-input-row {
+ display: flex;
+ gap: 12px;
+ align-items: flex-end;
+}
+
+.chat-input-row .ant-input {
+ flex: 1;
+}
diff --git a/studio/src/pages/ai-image/AiImagePage.tsx b/studio/src/pages/ai-image/AiImagePage.tsx
new file mode 100644
index 0000000..08dcc82
--- /dev/null
+++ b/studio/src/pages/ai-image/AiImagePage.tsx
@@ -0,0 +1,266 @@
+import { useEffect, useRef, useState } from 'react';
+import { App, Button, Empty, Input, Radio, Slider, Space, Upload } from 'antd';
+import {
+ DeleteOutlined,
+ DownloadOutlined,
+ ExperimentOutlined,
+ InboxOutlined,
+} from '@ant-design/icons';
+import type { ImageItem, WatermarkState, WatermarkType } from '@/types/image';
+import { canvasToDataUrl, downloadCanvas, fileToImage } from '@/utils/image';
+import { defaultPos, renderFullRes } from '@/utils/watermark';
+import WatermarkCanvas from './components/WatermarkCanvas';
+import ImageEditModal from './components/ImageEditModal';
+import './AiImagePage.css';
+
+const DEFAULT_WM_TEXT = 'Panda Store';
+const DEFAULT_OPACITY = 30;
+const WATERMARK_IMG_SRC = '/imgs/watermark.jpg';
+
+type WatermarkKind = Exclude;
+
+const AiImagePage = () => {
+ const { message } = App.useApp();
+
+ const [items, setItems] = useState([]);
+ const seqRef = useRef(0);
+
+ // 全局水印设置
+ const [wmType, setWmType] = useState('image');
+ const [wmText, setWmText] = useState(DEFAULT_WM_TEXT);
+ const [wmOpacity, setWmOpacity] = useState(DEFAULT_OPACITY);
+ const [watermarkImg, setWatermarkImg] = useState(null);
+
+ // 弹窗
+ const [modalOpen, setModalOpen] = useState(false);
+ const [editing, setEditing] = useState<{ dataUrl: string; fileName: string } | null>(null);
+
+ useEffect(() => {
+ const img = new Image();
+ img.onload = () => setWatermarkImg(img);
+ img.onerror = () => console.error('水印图片加载失败:', WATERMARK_IMG_SRC);
+ img.src = WATERMARK_IMG_SRC;
+ }, []);
+
+ function buildState(imgWidth: number, imgHeight: number): WatermarkState {
+ const state: WatermarkState = {
+ type: wmType,
+ text: wmText,
+ opacity: wmOpacity,
+ pos: { x: 0, y: 0 },
+ };
+ state.pos = defaultPos(imgWidth, imgHeight, state);
+ return state;
+ }
+
+ function validateWatermark(): boolean {
+ if (wmType === 'text' && !wmText.trim()) {
+ message.warning('请先填写水印文字');
+ return false;
+ }
+ if (wmType === 'image' && !watermarkImg) {
+ message.warning('水印图片加载中,请稍后');
+ return false;
+ }
+ return true;
+ }
+
+ const addFile = async (file: File) => {
+ try {
+ const { image, dataUrl } = await fileToImage(file);
+ const item: ImageItem = {
+ id: seqRef.current++,
+ fileName: file.name,
+ image,
+ dataUrl,
+ state: buildState(image.width, image.height),
+ };
+ setItems((prev) => [...prev, item]);
+ } catch {
+ message.error(`图片读取失败:${file.name}`);
+ }
+ };
+
+ const handleBeforeUpload = (file: File) => {
+ if (file.type.startsWith('image/')) {
+ void addFile(file);
+ }
+ return false;
+ };
+
+ const updateItemState = (id: number, patch: Partial) => {
+ setItems((prev) =>
+ prev.map((it) => (it.id === id ? { ...it, state: { ...it.state, ...patch } } : it)),
+ );
+ };
+
+ const applyToAll = () => {
+ if (items.length === 0) {
+ message.warning('请先上传图片');
+ return;
+ }
+ if (!validateWatermark()) return;
+ setItems((prev) =>
+ prev.map((it) => ({ ...it, state: buildState(it.image.width, it.image.height) })),
+ );
+ };
+
+ const toggleWatermark = (item: ImageItem) => {
+ if (item.state.type) {
+ updateItemState(item.id, { type: null });
+ return;
+ }
+ if (!validateWatermark()) return;
+ const next = buildState(item.image.width, item.image.height);
+ setItems((prev) => prev.map((it) => (it.id === item.id ? { ...it, state: next } : it)));
+ };
+
+ const deleteItem = (id: number) => setItems((prev) => prev.filter((it) => it.id !== id));
+ const clearAll = () => setItems([]);
+
+ const openEdit = (item: ImageItem) => {
+ const canvas = renderFullRes(item.image, item.state, watermarkImg);
+ setEditing({ dataUrl: canvasToDataUrl(canvas), fileName: item.fileName });
+ setModalOpen(true);
+ };
+
+ const exportItem = (item: ImageItem) => {
+ const canvas = renderFullRes(item.image, item.state, watermarkImg);
+ const base = item.fileName.replace(/\.\w+$/, '');
+ downloadCanvas(canvas, `${base}${item.state.type ? '_watermark' : ''}.png`);
+ };
+
+ return (
+
+ {/* 水印设置 */}
+
+
水印设置
+
+
+ 水印类型
+ setWmType(e.target.value as WatermarkKind)}
+ optionType="button"
+ >
+ 图片水印
+ 文字水印
+
+
+
+ {wmType === 'text' ? (
+
+ 水印文字
+ setWmText(e.target.value)}
+ style={{ width: 200 }}
+ />
+
+ ) : (
+
+
水印图片
+

+
默认水印图,贴图后为圆形
+
+ )}
+
+
+ 透明度 {wmOpacity}%
+
+
+
+
+
+ {/* 上传 */}
+
+
+
+
+
+ 点击或拖拽图片到此处上传(支持多张)
+ 支持 JPG、PNG、WEBP 格式
+
+
+
+ {/* 预览 */}
+
+
+
预览(处理后)
+
+
+
+
+
+
+ {items.length === 0 ? (
+
+ ) : (
+ <>
+
+ {items.map((item) => (
+
+
updateItemState(item.id, { pos })}
+ />
+
+ {item.fileName}
+
+
+
+ }
+ onClick={() => openEdit(item)}
+ >
+ AI 生图
+
+ }
+ onClick={() => exportItem(item)}
+ />
+ }
+ onClick={() => deleteItem(item.id)}
+ />
+
+
+ ))}
+
+
添加水印后,可在预览图上按住水印拖动调整位置
+ >
+ )}
+
+
+
setModalOpen(false)}
+ />
+
+ );
+};
+
+export default AiImagePage;
diff --git a/studio/src/pages/ai-image/components/AnnotationCanvas.tsx b/studio/src/pages/ai-image/components/AnnotationCanvas.tsx
new file mode 100644
index 0000000..6c38e21
--- /dev/null
+++ b/studio/src/pages/ai-image/components/AnnotationCanvas.tsx
@@ -0,0 +1,191 @@
+import { useEffect, useRef } from 'react';
+import type { PointerEvent as ReactPointerEvent } from 'react';
+import type { AnnotationElement } from '@/types/annotation';
+import {
+ drawElement,
+ drawSelectionHandles,
+ hitTest,
+ type HandleType,
+} from '@/utils/annotation';
+
+const PREVIEW_MAX_WIDTH = 900;
+
+interface Interaction {
+ action: 'move' | 'rotate' | 'resize';
+ elemId: number;
+ moved: boolean;
+ grabDX?: number;
+ grabDY?: number;
+ startAngleRad?: number;
+ startRotation?: number;
+ startElem?: AnnotationElement;
+ startDist?: number;
+}
+
+interface AnnotationCanvasProps {
+ baseImage: HTMLImageElement | null;
+ elements: AnnotationElement[];
+ selectedId: number | null;
+ onSelect: (id: number | null) => void;
+ onChange: (next: AnnotationElement[]) => void;
+ /** 一次交互(拖拽/旋转/拉伸)开始时触发一次,父组件用于存撤销快照 */
+ onBeginInteraction: () => void;
+}
+
+const AnnotationCanvas = ({
+ baseImage,
+ elements,
+ selectedId,
+ onSelect,
+ onChange,
+ onBeginInteraction,
+}: AnnotationCanvasProps) => {
+ const canvasRef = useRef(null);
+ const interactionRef = useRef(null);
+
+ const scale = () => {
+ const canvas = canvasRef.current;
+ if (!canvas || !baseImage) return 1;
+ return canvas.width / baseImage.width;
+ };
+
+ const draw = () => {
+ const canvas = canvasRef.current;
+ if (!canvas || !baseImage) return;
+ const previewW = Math.min(baseImage.width, PREVIEW_MAX_WIDTH);
+ canvas.width = previewW;
+ canvas.height = Math.round((baseImage.height / baseImage.width) * previewW);
+ const ctx = canvas.getContext('2d')!;
+ const s = canvas.width / baseImage.width;
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(baseImage, 0, 0, canvas.width, canvas.height);
+ elements.forEach((el) => drawElement(ctx, el, s));
+ if (selectedId != null) {
+ const sel = elements.find((el) => el.id === selectedId);
+ if (sel) drawSelectionHandles(ctx, sel, s);
+ }
+ };
+
+ useEffect(() => {
+ draw();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [baseImage, elements, selectedId]);
+
+ const toImageCoords = (e: ReactPointerEvent) => {
+ const canvas = canvasRef.current!;
+ const rect = canvas.getBoundingClientRect();
+ const k = baseImage!.width / rect.width;
+ return { x: (e.clientX - rect.left) * k, y: (e.clientY - rect.top) * k };
+ };
+
+ const findElementAt = (
+ px: number,
+ py: number,
+ ): { el: AnnotationElement; handle: HandleType } | null => {
+ for (let i = elements.length - 1; i >= 0; i--) {
+ const el = elements[i];
+ const handle = hitTest(el, px, py, scale());
+ if (handle) return { el, handle };
+ }
+ return null;
+ };
+
+ const handlePointerDown = (e: ReactPointerEvent) => {
+ if (!baseImage) return;
+ const p = toImageCoords(e);
+ const hit = findElementAt(p.x, p.y);
+ if (!hit) {
+ onSelect(null);
+ return;
+ }
+ onSelect(hit.el.id);
+ e.currentTarget.setPointerCapture(e.pointerId);
+ e.preventDefault();
+
+ if (hit.handle === 'body') {
+ interactionRef.current = {
+ action: 'move',
+ elemId: hit.el.id,
+ moved: false,
+ grabDX: p.x - hit.el.x,
+ grabDY: p.y - hit.el.y,
+ };
+ } else if (hit.handle === 'rotate') {
+ interactionRef.current = {
+ action: 'rotate',
+ elemId: hit.el.id,
+ moved: false,
+ startAngleRad: Math.atan2(p.y - hit.el.y, p.x - hit.el.x),
+ startRotation: hit.el.rotation,
+ };
+ } else {
+ // 四角圆点缩放
+ interactionRef.current = {
+ action: 'resize',
+ elemId: hit.el.id,
+ moved: false,
+ startElem: hit.el,
+ startDist: Math.hypot(p.x - hit.el.x, p.y - hit.el.y) || 1,
+ };
+ }
+ };
+
+ const handlePointerMove = (e: ReactPointerEvent) => {
+ const it = interactionRef.current;
+ if (!it) return;
+ const p = toImageCoords(e);
+ if (!it.moved) {
+ onBeginInteraction();
+ it.moved = true;
+ }
+ const next = elements.map((el) => {
+ if (el.id !== it.elemId) return el;
+ if (it.action === 'move') {
+ return { ...el, x: p.x - (it.grabDX ?? 0), y: p.y - (it.grabDY ?? 0) };
+ }
+ if (it.action === 'rotate') {
+ const angle = Math.atan2(p.y - el.y, p.x - el.x);
+ const delta = (angle - (it.startAngleRad ?? 0)) * (180 / Math.PI);
+ return { ...el, rotation: (it.startRotation ?? 0) + delta };
+ }
+ const se = it.startElem!;
+ const raw = Math.hypot(p.x - se.x, p.y - se.y) / (it.startDist || 1);
+ const k = Math.min(10, Math.max(0.1, raw));
+ if (se.type === 'text') {
+ return { ...se, fontSize: Math.max(6, Math.round(se.fontSize * k)) };
+ }
+ return {
+ ...se,
+ length: se.length * k,
+ tSize: se.tSize * k,
+ lineWidth: Math.max(1, se.lineWidth * k),
+ labelFontSize: Math.max(6, se.labelFontSize * k),
+ };
+ });
+ onChange(next);
+ e.preventDefault();
+ };
+
+ const stopDrag = (e: ReactPointerEvent) => {
+ if (!interactionRef.current) return;
+ interactionRef.current = null;
+ if (canvasRef.current?.hasPointerCapture(e.pointerId)) {
+ canvasRef.current.releasePointerCapture(e.pointerId);
+ }
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default AnnotationCanvas;
diff --git a/studio/src/pages/ai-image/components/ElementPropsPanel.tsx b/studio/src/pages/ai-image/components/ElementPropsPanel.tsx
new file mode 100644
index 0000000..5a3d374
--- /dev/null
+++ b/studio/src/pages/ai-image/components/ElementPropsPanel.tsx
@@ -0,0 +1,166 @@
+import { ColorPicker, Empty, Input, InputNumber, Select, Slider, Switch } from 'antd';
+import type { AnnotationElement } from '@/types/annotation';
+import { FONT_FAMILIES } from '@/utils/annotation';
+
+interface ElementPropsPanelProps {
+ element: AnnotationElement | null;
+ onUpdate: (id: number, patch: Partial) => void;
+}
+
+const FONT_OPTIONS = FONT_FAMILIES.map((f) => ({ value: f, label: f }));
+
+const ElementPropsPanel = ({ element, onUpdate }: ElementPropsPanelProps) => {
+ if (!element) {
+ return (
+
+ );
+ }
+
+ const update = (patch: Partial) => onUpdate(element.id, patch);
+
+ return (
+
+ {element.type === 'text' ? (
+ <>
+
+ 内容
+ update({ content: e.target.value })}
+ />
+
+
+ 字体
+
+
+ 字号
+ update({ fontSize: v ?? 18 })}
+ style={{ flex: 1 }}
+ />
+
+
+ 加粗
+ update({ bold: v })} />
+
+
+ 文字颜色
+ update({ color: c.toHexString() })}
+ />
+
+
+ 描边颜色
+ update({ strokeColor: c.toHexString() })}
+ />
+
+
+ 描边宽度
+ update({ strokeWidth: v ?? 0 })}
+ style={{ flex: 1 }}
+ />
+
+ >
+ ) : (
+ <>
+
+ 长度(px)
+ update({ length: v ?? 50 })}
+ style={{ flex: 1 }}
+ />
+
+
+ 线色
+ update({ lineColor: c.toHexString() })}
+ />
+
+
+ 线宽
+ update({ lineWidth: v ?? 2 })}
+ style={{ flex: 1 }}
+ />
+
+
+ T字大小
+ update({ tSize: v ?? 14 })}
+ style={{ flex: 1 }}
+ />
+
+
+ 标签文字
+ update({ label: e.target.value })} />
+
+
+ 标签颜色
+ update({ labelColor: c.toHexString() })}
+ />
+
+
+ 标签字号
+ update({ labelFontSize: v ?? 14 })}
+ style={{ flex: 1 }}
+ />
+
+ >
+ )}
+
+
+ 透明度
+ update({ opacity: v })}
+ style={{ flex: 1 }}
+ />
+
+
+ 旋转(°)
+ update({ rotation: (v ?? 0) % 360 })}
+ style={{ flex: 1 }}
+ />
+
+
+ );
+};
+
+export default ElementPropsPanel;
diff --git a/studio/src/pages/ai-image/components/ImageEditModal.tsx b/studio/src/pages/ai-image/components/ImageEditModal.tsx
new file mode 100644
index 0000000..b09b1a3
--- /dev/null
+++ b/studio/src/pages/ai-image/components/ImageEditModal.tsx
@@ -0,0 +1,479 @@
+import { useEffect, useRef, useState } from 'react';
+import { App, Button, Drawer, Empty, Input, Select, Spin, Tabs } from 'antd';
+import {
+ ClearOutlined,
+ ColumnHeightOutlined,
+ DeleteOutlined,
+ DownloadOutlined,
+ ExperimentOutlined,
+ FontSizeOutlined,
+ UndoOutlined,
+} from '@ant-design/icons';
+import type { AnnotationElement } from '@/types/annotation';
+import { createRulerElement, createTextElement, drawElement } from '@/utils/annotation';
+import { downloadCanvas } from '@/utils/image';
+import { editImage } from '@/services/image';
+import { apiErrorMessage } from '@/services/api';
+import AnnotationCanvas from './AnnotationCanvas';
+import ElementPropsPanel from './ElementPropsPanel';
+
+const MODEL_OPTIONS = [
+ { value: 'wanx2.1-imageedit', label: 'wanx2.1-imageedit|通用编辑,加文字弱' },
+ { value: 'wan2.6-image', label: 'wan2.6-image|全能图文,效果均衡' },
+ { value: 'qwen-image-edit', label: 'qwen-image-edit|精准中英文字' },
+ { value: 'qwen-image-edit-plus', label: 'qwen-image-edit-plus|精准文字·更快' },
+ {
+ value: 'qwen-image-edit-plus-2025-10-30',
+ label: 'qwen-image-edit-plus-2025-10-30|精准文字·稳定',
+ },
+];
+
+type EditTab = 'text' | 'ruler';
+
+const PRESETS_KEY = 'ozon_annotation_presets_v1';
+
+interface PresetItem {
+ id: string;
+ type: EditTab;
+ name: string;
+ config: Record;
+}
+
+/** 从元素中提取可复用的「样式配置」(不含内容文字与位置/旋转) */
+function elementToConfig(el: AnnotationElement): Record {
+ if (el.type === 'text') {
+ return {
+ fontFamily: el.fontFamily,
+ fontSize: el.fontSize,
+ color: el.color,
+ strokeColor: el.strokeColor,
+ strokeWidth: el.strokeWidth,
+ bold: el.bold,
+ opacity: el.opacity,
+ };
+ }
+ return {
+ length: el.length,
+ lineColor: el.lineColor,
+ lineWidth: el.lineWidth,
+ tSize: el.tSize,
+ labelColor: el.labelColor,
+ labelFontSize: el.labelFontSize,
+ opacity: el.opacity,
+ };
+}
+
+interface AiResult {
+ id: string;
+ image: HTMLImageElement;
+ dataUrl: string;
+ url: string;
+}
+
+interface ImageEditModalProps {
+ open: boolean;
+ imageDataUrl: string;
+ fileName: string;
+ onClose: () => void;
+}
+
+const ImageEditModal = ({ open, imageDataUrl, fileName, onClose }: ImageEditModalProps) => {
+ const { message } = App.useApp();
+ const [originalImage, setOriginalImage] = useState(null);
+ const [aiResults, setAiResults] = useState([]);
+ const [currentKey, setCurrentKey] = useState('original');
+ const [elements, setElements] = useState([]);
+ const [selectedId, setSelectedId] = useState(null);
+ const [editTab, setEditTab] = useState('text');
+ const [undoStack, setUndoStack] = useState([]);
+ const [aiPrompt, setAiPrompt] = useState('');
+ const [aiLoading, setAiLoading] = useState(false);
+ const [model, setModel] = useState('wanx2.1-imageedit');
+ const [presets, setPresets] = useState([]);
+ const [selectedPreset, setSelectedPreset] = useState>({
+ text: null,
+ ruler: null,
+ });
+ const seqRef = useRef(0);
+
+ // 每次打开重置
+ useEffect(() => {
+ if (!open) return;
+ setAiResults([]);
+ setCurrentKey('original');
+ setElements([]);
+ setSelectedId(null);
+ setEditTab('text');
+ setUndoStack([]);
+ setAiPrompt('');
+ setAiLoading(false);
+ const img = new Image();
+ img.onload = () => setOriginalImage(img);
+ img.onerror = () => message.error('原图加载失败');
+ img.src = imageDataUrl;
+ }, [open, imageDataUrl, message]);
+
+ // 加载已保存的预设
+ useEffect(() => {
+ try {
+ const raw = localStorage.getItem(PRESETS_KEY);
+ if (raw) setPresets(JSON.parse(raw) || []);
+ } catch {
+ /* ignore */
+ }
+ }, []);
+
+ const baseImage =
+ currentKey === 'original'
+ ? originalImage
+ : aiResults.find((r) => r.id === currentKey)?.image ?? originalImage;
+
+ const currentDataUrl =
+ currentKey === 'original'
+ ? imageDataUrl
+ : aiResults.find((r) => r.id === currentKey)?.dataUrl ?? imageDataUrl;
+
+ const pushUndo = () => setUndoStack((prev) => [...prev, elements]);
+
+ const undo = () => {
+ if (undoStack.length === 0) return;
+ const last = undoStack[undoStack.length - 1];
+ setElements(last);
+ setUndoStack(undoStack.slice(0, -1));
+ setSelectedId(null);
+ };
+
+ const addText = () => {
+ if (!baseImage) return;
+ pushUndo();
+ const base = createTextElement(seqRef.current++, baseImage.width, baseImage.height);
+ const preset = presets.find((p) => p.id === selectedPreset.text);
+ const el = preset ? ({ ...base, ...preset.config } as AnnotationElement) : base;
+ setElements((prev) => [...prev, el]);
+ setSelectedId(el.id);
+ setEditTab('text');
+ };
+
+ const addRuler = () => {
+ if (!baseImage) return;
+ pushUndo();
+ const base = createRulerElement(seqRef.current++, baseImage.width, baseImage.height);
+ const preset = presets.find((p) => p.id === selectedPreset.ruler);
+ const el = preset ? ({ ...base, ...preset.config } as AnnotationElement) : base;
+ setElements((prev) => [...prev, el]);
+ setSelectedId(el.id);
+ setEditTab('ruler');
+ };
+
+ const clearAnnotations = () => {
+ if (elements.length === 0) return;
+ pushUndo();
+ setElements([]);
+ setSelectedId(null);
+ };
+
+ const onUpdate = (id: number, patch: Partial) => {
+ setElements((prev) =>
+ prev.map((el) => (el.id === id ? ({ ...el, ...patch } as AnnotationElement) : el)),
+ );
+ };
+
+ const onDelete = (id: number) => {
+ pushUndo();
+ setElements((prev) => prev.filter((el) => el.id !== id));
+ setSelectedId((cur) => (cur === id ? null : cur));
+ };
+
+ const handleSelect = (id: number | null) => {
+ setSelectedId(id);
+ if (id != null) {
+ const el = elements.find((e) => e.id === id);
+ if (el) setEditTab(el.type);
+ }
+ };
+
+ const runAi = async () => {
+ const prompt = aiPrompt.trim();
+ if (!prompt || aiLoading) return;
+ setAiLoading(true);
+ try {
+ const res = await editImage({
+ base_image: currentDataUrl,
+ prompt,
+ model,
+ n: 1,
+ strength: 0.8,
+ prompt_extend: true,
+ });
+ const r = res.results[0];
+ const dataUrl = r?.image_base64 || r?.url;
+ if (!r || !dataUrl) throw new Error('模型未返回图片');
+ const img = new Image();
+ img.onload = () => {
+ const id = `ai-${Date.now()}`;
+ setAiResults((prev) => [...prev, { id, image: img, dataUrl, url: r.url }]);
+ setCurrentKey(id);
+ setAiLoading(false);
+ setAiPrompt('');
+ };
+ img.onerror = () => {
+ setAiLoading(false);
+ message.error('AI 结果图加载失败');
+ };
+ img.src = dataUrl;
+ } catch (error) {
+ setAiLoading(false);
+ message.error(apiErrorMessage(error));
+ }
+ };
+
+ const exportImage = () => {
+ if (!baseImage) return;
+ const canvas = document.createElement('canvas');
+ canvas.width = baseImage.width;
+ canvas.height = baseImage.height;
+ const ctx = canvas.getContext('2d')!;
+ ctx.drawImage(baseImage, 0, 0);
+ elements.forEach((el) => drawElement(ctx, el, 1));
+ const base = fileName.replace(/\.\w+$/, '');
+ downloadCanvas(canvas, `${base}_edited.png`);
+ };
+
+ const selectedElement = elements.find((el) => el.id === selectedId) ?? null;
+ const hasSelection = selectedElement != null && selectedElement.type === editTab;
+ const deleteSelected = () => {
+ if (selectedElement) onDelete(selectedElement.id);
+ };
+
+ const persistPresets = (next: PresetItem[]) => {
+ setPresets(next);
+ try {
+ localStorage.setItem(PRESETS_KEY, JSON.stringify(next));
+ } catch {
+ /* ignore */
+ }
+ };
+
+ const savePreset = () => {
+ if (!selectedElement || selectedElement.type !== editTab) return;
+ const type = selectedElement.type;
+ const count = presets.filter((p) => p.type === type).length;
+ const item: PresetItem = {
+ id: `${type}-${Date.now()}`,
+ type,
+ name: `${type === 'text' ? '文字' : '标尺'}预设 ${count + 1}`,
+ config: elementToConfig(selectedElement),
+ };
+ persistPresets([...presets, item]);
+ setSelectedPreset((prev) => ({ ...prev, [type]: item.id }));
+ message.success(`已保存「${item.name}」`);
+ };
+
+ const applyPreset = (presetId?: string) => {
+ setSelectedPreset((prev) => ({ ...prev, [editTab]: presetId ?? null }));
+ if (!presetId) return;
+ const preset = presets.find((p) => p.id === presetId);
+ if (!preset) return;
+ if (selectedElement && selectedElement.type === editTab) {
+ onUpdate(selectedElement.id, preset.config as Partial);
+ }
+ };
+
+ return (
+
+
+
+ {/* 左:底图切换 */}
+
+
setCurrentKey('original')}
+ />
+ {aiResults.map((r) => (
+ setCurrentKey(r.id)}
+ />
+ ))}
+ {aiResults.length === 0 && (
+ AI 生成结果会出现在这里,点击可切换为底图
+ )}
+
+
+ {/* 中:画布 */}
+
+ {baseImage ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* 右:标注编辑区 */}
+
+
setEditTab(k as EditTab)}
+ size="small"
+ className="wb-tabs"
+ items={[
+ { key: 'text', label: '文字' },
+ { key: 'ruler', label: '标尺' },
+ ]}
+ />
+
+ {editTab === 'text' ? (
+ } onClick={addText} disabled={!baseImage}>
+ 添加文字
+
+ ) : (
+ }
+ onClick={addRuler}
+ disabled={!baseImage}
+ >
+ 添加标尺
+
+ )}
+ } onClick={deleteSelected} disabled={!hasSelection}>
+ 删除
+
+
+
+
+
+ {selectedElement && selectedElement.type === editTab ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ } onClick={undo} disabled={undoStack.length === 0}>
+ 撤销
+
+ }
+ onClick={clearAnnotations}
+ disabled={elements.length === 0}
+ >
+ 清空
+
+ }
+ onClick={exportImage}
+ disabled={!baseImage}
+ >
+ 导出 PNG
+
+
+
+
+
+ {/* 底部:AI 指令 */}
+
+
+
setAiPrompt(e.target.value)}
+ placeholder="AI 修改:换背景 / 去杂物 / 补光影 / 改风格…(生成后自动作为新底图,可继续叠加标注)"
+ rows={3}
+ className="wb-ai-input"
+ disabled={!baseImage}
+ onPressEnter={(e) => {
+ if (!e.shiftKey) {
+ e.preventDefault();
+ void runAi();
+ }
+ }}
+ />
+
+
+ }
+ loading={aiLoading}
+ onClick={() => void runAi()}
+ disabled={!baseImage}
+ block
+ >
+ AI 生成
+
+
+
+
+
+
+ );
+};
+
+function Thumb({
+ label,
+ active,
+ src,
+ onClick,
+}: {
+ label: string;
+ active: boolean;
+ src: string;
+ onClick: () => void;
+}) {
+ return (
+
+

+
{label}
+
+ );
+}
+
+export default ImageEditModal;
diff --git a/studio/src/pages/ai-image/components/WatermarkCanvas.tsx b/studio/src/pages/ai-image/components/WatermarkCanvas.tsx
new file mode 100644
index 0000000..479673d
--- /dev/null
+++ b/studio/src/pages/ai-image/components/WatermarkCanvas.tsx
@@ -0,0 +1,99 @@
+import { useEffect, useRef } from 'react';
+import type { PointerEvent as ReactPointerEvent } from 'react';
+import type { ImageItem } from '@/types/image';
+import { clampPos, drawWatermarked, getWatermarkSize } from '@/utils/watermark';
+
+const PREVIEW_MAX_WIDTH = 360;
+
+interface WatermarkCanvasProps {
+ item: ImageItem;
+ watermarkImg: HTMLImageElement | null;
+ onPosChange: (pos: { x: number; y: number }) => void;
+}
+
+/**
+ * 单张图片的可拖拽水印预览画布。
+ * 水印坐标始终相对原图;预览按 PREVIEW_MAX_WIDTH 等比绘制。
+ */
+const WatermarkCanvas = ({ item, watermarkImg, onPosChange }: WatermarkCanvasProps) => {
+ const canvasRef = useRef(null);
+ const posRef = useRef(item.state.pos);
+ const dragRef = useRef<{ grabX: number; grabY: number } | null>(null);
+
+ const draw = () => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ drawWatermarked(canvas, item.image, {
+ state: { ...item.state, pos: posRef.current },
+ watermarkImg,
+ targetWidth: PREVIEW_MAX_WIDTH,
+ });
+ };
+
+ useEffect(() => {
+ posRef.current = item.state.pos;
+ draw();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [item.state, watermarkImg]);
+
+ const toImageCoords = (e: ReactPointerEvent) => {
+ const canvas = canvasRef.current!;
+ const rect = canvas.getBoundingClientRect();
+ const scale = item.image.width / rect.width;
+ return { x: (e.clientX - rect.left) * scale, y: (e.clientY - rect.top) * scale };
+ };
+
+ const handlePointerDown = (e: ReactPointerEvent) => {
+ if (!item.state.type) return;
+ if (item.state.type === 'image' && !watermarkImg) return;
+
+ const point = toImageCoords(e);
+ const size = getWatermarkSize(item.image.width, { ...item.state, pos: posRef.current });
+ const inside =
+ point.x >= posRef.current.x &&
+ point.x <= posRef.current.x + size.width &&
+ point.y >= posRef.current.y &&
+ point.y <= posRef.current.y + size.height;
+ if (!inside) return;
+
+ dragRef.current = { grabX: point.x - posRef.current.x, grabY: point.y - posRef.current.y };
+ e.currentTarget.setPointerCapture(e.pointerId);
+ e.preventDefault();
+ };
+
+ const handlePointerMove = (e: ReactPointerEvent) => {
+ const drag = dragRef.current;
+ if (!drag) return;
+ const point = toImageCoords(e);
+ const next = clampPos(
+ item.image.width,
+ item.image.height,
+ point.x - drag.grabX,
+ point.y - drag.grabY,
+ item.state,
+ );
+ posRef.current = next;
+ draw();
+ onPosChange(next);
+ e.preventDefault();
+ };
+
+ const stopDrag = (e: ReactPointerEvent) => {
+ if (!dragRef.current) return;
+ dragRef.current = null;
+ e.currentTarget.releasePointerCapture(e.pointerId);
+ };
+
+ return (
+
+ );
+};
+
+export default WatermarkCanvas;
diff --git a/studio/src/pages/ai-image/index.ts b/studio/src/pages/ai-image/index.ts
new file mode 100644
index 0000000..8c479b4
--- /dev/null
+++ b/studio/src/pages/ai-image/index.ts
@@ -0,0 +1 @@
+export { default } from './AiImagePage';
diff --git a/studio/src/router/index.tsx b/studio/src/router/index.tsx
new file mode 100644
index 0000000..2d48512
--- /dev/null
+++ b/studio/src/router/index.tsx
@@ -0,0 +1,15 @@
+import { createBrowserRouter, Navigate } from 'react-router';
+import MainLayout from '@/layouts/MainLayout';
+import AiImagePage from '@/pages/ai-image';
+
+export const router = createBrowserRouter([
+ {
+ path: '/',
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: 'ai-image', element: },
+ ],
+ },
+ { path: '*', element: },
+]);
diff --git a/studio/src/services/api.ts b/studio/src/services/api.ts
new file mode 100644
index 0000000..feb4427
--- /dev/null
+++ b/studio/src/services/api.ts
@@ -0,0 +1,48 @@
+import axios from 'axios';
+import type { AxiosInstance, AxiosRequestConfig } from 'axios';
+import { envConfig } from '@/config/env';
+
+const apiClient: AxiosInstance = axios.create({
+ baseURL: envConfig.apiBaseUrl,
+ timeout: 120000, // 图生图较慢,放宽到 120s
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+});
+
+apiClient.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ if (envConfig.debug) {
+ console.error('请求失败:', error.response?.data || error.message);
+ }
+ return Promise.reject(error);
+ },
+);
+
+export const api = {
+ get: (url: string, config?: AxiosRequestConfig) =>
+ apiClient.get(url, config).then((res) => res.data),
+
+ post: (url: string, data?: unknown, config?: AxiosRequestConfig) =>
+ apiClient.post(url, data, config).then((res) => res.data),
+};
+
+/** 从异常中提取后端返回的错误信息(FastAPI 的 HTTPException detail) */
+export function apiErrorMessage(error: unknown): string {
+ if (axios.isAxiosError(error)) {
+ const data = error.response?.data as { detail?: unknown } | undefined;
+ if (data?.detail) {
+ if (typeof data.detail === 'string') return data.detail;
+ try {
+ return JSON.stringify(data.detail);
+ } catch {
+ return String(data.detail);
+ }
+ }
+ return error.message;
+ }
+ return error instanceof Error ? error.message : String(error);
+}
+
+export default apiClient;
diff --git a/studio/src/services/image.ts b/studio/src/services/image.ts
new file mode 100644
index 0000000..c17e396
--- /dev/null
+++ b/studio/src/services/image.ts
@@ -0,0 +1,7 @@
+import { api } from './api';
+import type { ImageEditRequest, ImageEditResponse } from '@/types/image';
+
+/** 调用后端图生图接口(wanx2.1-imageedit) */
+export function editImage(payload: ImageEditRequest): Promise {
+ return api.post('/image/edit', payload);
+}
diff --git a/studio/src/types/annotation.ts b/studio/src/types/annotation.ts
new file mode 100644
index 0000000..2e643d2
--- /dev/null
+++ b/studio/src/types/annotation.ts
@@ -0,0 +1,43 @@
+/** 图片标注元素模型(精确文字叠加 + 标尺) */
+
+export type AnnotationElementType = 'text' | 'ruler';
+
+export interface BaseAnnotationElement {
+ id: number;
+ type: AnnotationElementType;
+ /** 中心点,相对原图坐标 */
+ x: number;
+ y: number;
+ /** 旋转角度(度) */
+ rotation: number;
+ /** 透明度 0~1 */
+ opacity: number;
+}
+
+export interface TextElement extends BaseAnnotationElement {
+ type: 'text';
+ content: string;
+ fontFamily: string;
+ /** 字号(原图像素) */
+ fontSize: number;
+ color: string;
+ strokeColor: string;
+ /** 描边宽度(原图像素),0 = 无描边 */
+ strokeWidth: number;
+ bold: boolean;
+}
+
+export interface RulerElement extends BaseAnnotationElement {
+ type: 'ruler';
+ /** 标尺长度(原图像素) */
+ length: number;
+ lineColor: string;
+ lineWidth: number;
+ /** 两端 T 字短横总长(原图像素) */
+ tSize: number;
+ label: string;
+ labelColor: string;
+ labelFontSize: number;
+}
+
+export type AnnotationElement = TextElement | RulerElement;
diff --git a/studio/src/types/image.ts b/studio/src/types/image.ts
new file mode 100644
index 0000000..e8977ee
--- /dev/null
+++ b/studio/src/types/image.ts
@@ -0,0 +1,57 @@
+/** 水印类型:'image' 图片水印,'text' 文字水印,null 无水印 */
+export type WatermarkType = 'image' | 'text' | null;
+
+export interface WatermarkPos {
+ x: number;
+ y: number;
+}
+
+/** 单张图片的水印状态(坐标均相对原图) */
+export interface WatermarkState {
+ type: WatermarkType;
+ text: string;
+ opacity: number; // 0 ~ 100
+ pos: WatermarkPos;
+}
+
+/** 图片列表项 */
+export interface ImageItem {
+ id: number;
+ fileName: string;
+ /** 原始图片 */
+ image: HTMLImageElement;
+ /** 原始图片 data URL */
+ dataUrl: string;
+ state: WatermarkState;
+}
+
+/** 图生图请求体 */
+export interface ImageEditRequest {
+ base_image: string;
+ prompt: string;
+ /** 模型 code,见后端 SUPPORTED_MODELS */
+ model?: string;
+ mask_image?: string;
+ function?: 'description_edit' | 'description_edit_with_mask' | 'stylization_local' | 'stylization_all';
+ n?: number;
+ size?: string;
+ seed?: number;
+ style?: string;
+ prompt_extend?: boolean;
+ /** 修改幅度 0.0~1.0,越大改动越明显;新增文字等建议 0.8 左右 */
+ strength?: number;
+}
+
+/** 图生图结果 */
+export interface ImageEditResult {
+ url: string;
+ /** 服务端代理下载的 data URL,便于前端合成/导出(规避跨域画布污染) */
+ image_base64?: string;
+}
+
+export interface ImageEditResponse {
+ task_id: string;
+ results: ImageEditResult[];
+ image_count: number;
+ request_id: string;
+}
diff --git a/studio/src/utils/annotation.ts b/studio/src/utils/annotation.ts
new file mode 100644
index 0000000..4a800e4
--- /dev/null
+++ b/studio/src/utils/annotation.ts
@@ -0,0 +1,233 @@
+/** 标注元素的绘制、测量、命中检测与工厂函数 */
+import type { AnnotationElement, RulerElement, TextElement } from '@/types/annotation';
+
+export const FONT_FAMILIES = [
+ 'PingFang SC',
+ 'Microsoft YaHei',
+ 'SimSun',
+ 'Songti SC',
+ 'Arial',
+ 'Helvetica',
+ 'Georgia',
+ 'Times New Roman',
+ 'Courier New',
+];
+
+// 屏幕像素下的手柄常量(绘制与命中检测时按 scale 换算到原图坐标)
+export const HANDLE_RADIUS = 7; // 手柄半径
+export const ROTATE_GAP = 26; // 旋转手柄距元素包围盒顶部的间距
+
+const measureCtx = document.createElement('canvas').getContext('2d')!;
+
+export function textFont(elem: TextElement, scale = 1): string {
+ return `${elem.bold ? 'bold ' : ''}${Math.round(elem.fontSize * scale)}px ${elem.fontFamily}`;
+}
+
+export function measureText(elem: TextElement): { width: number; height: number } {
+ measureCtx.font = textFont(elem, 1);
+ const lines = (elem.content || '').split('\n');
+ let width = 0;
+ for (const line of lines) width = Math.max(width, measureCtx.measureText(line).width);
+ return { width, height: elem.fontSize * 1.2 * lines.length };
+}
+
+/** 元素包围盒半宽半高(原图坐标),用于画选中框与旋转手柄定位 */
+export function elementBox(elem: AnnotationElement): { halfW: number; halfH: number } {
+ if (elem.type === 'text') {
+ const { width, height } = measureText(elem);
+ return { halfW: width / 2 + 4, halfH: height / 2 + 4 };
+ }
+ const half = elem.length / 2;
+ return {
+ halfW: half + elem.tSize / 2 + 4,
+ halfH: Math.max(elem.tSize / 2, elem.lineWidth / 2 + 4) + 4,
+ };
+}
+
+/** 世界坐标 → 元素局部坐标(逆旋转 + 平移) */
+export function toLocal(elem: AnnotationElement, px: number, py: number): { lx: number; ly: number } {
+ const rad = (-elem.rotation * Math.PI) / 180;
+ const dx = px - elem.x;
+ const dy = py - elem.y;
+ return {
+ lx: dx * Math.cos(rad) - dy * Math.sin(rad),
+ ly: dx * Math.sin(rad) + dy * Math.cos(rad),
+ };
+}
+
+export type HandleType = 'body' | 'rotate' | 'tl' | 'tr' | 'bl' | 'br';
+
+export function hitTest(elem: AnnotationElement, px: number, py: number, scale: number): HandleType | null {
+ const { lx, ly } = toLocal(elem, px, py);
+ const r = HANDLE_RADIUS / scale; // 原图坐标下的手柄半径
+ const { halfW, halfH } = elementBox(elem);
+ const rotateY = -(halfH + ROTATE_GAP / scale); // 旋转手柄在局部坐标的 y
+ if (Math.hypot(lx, ly - rotateY) <= r * 1.6) return 'rotate';
+
+ // 四角缩放圆点
+ const corners: [HandleType, number, number][] = [
+ ['tl', -halfW, -halfH],
+ ['tr', halfW, -halfH],
+ ['bl', -halfW, halfH],
+ ['br', halfW, halfH],
+ ];
+ for (const [name, cx, cy] of corners) {
+ if (Math.hypot(lx - cx, ly - cy) <= r * 1.6) return name;
+ }
+
+ if (elem.type === 'ruler') {
+ const half = elem.length / 2;
+ const hitHalfW = half + elem.tSize / 2;
+ const hitHalfH = Math.max(elem.lineWidth / 2, 4) + 4;
+ if (Math.abs(lx) <= hitHalfW && Math.abs(ly) <= hitHalfH) return 'body';
+ return null;
+ }
+
+ const { width, height } = measureText(elem);
+ if (Math.abs(lx) <= width / 2 + 4 && Math.abs(ly) <= height / 2 + 4) return 'body';
+ return null;
+}
+
+export function drawElement(ctx: CanvasRenderingContext2D, elem: AnnotationElement, scale: number): void {
+ ctx.save();
+ ctx.globalAlpha = elem.opacity;
+ ctx.translate(elem.x * scale, elem.y * scale);
+ ctx.rotate((elem.rotation * Math.PI) / 180);
+ if (elem.type === 'text') drawText(ctx, elem, scale);
+ else drawRuler(ctx, elem, scale);
+ ctx.restore();
+}
+
+function drawText(ctx: CanvasRenderingContext2D, elem: TextElement, scale: number): void {
+ const fontSize = elem.fontSize * scale;
+ ctx.font = textFont(elem, scale);
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.lineJoin = 'round';
+ const lines = (elem.content || '').split('\n');
+ const lineHeight = fontSize * 1.2;
+ const startY = -((lines.length - 1) * lineHeight) / 2;
+ lines.forEach((line, i) => {
+ const y = startY + i * lineHeight;
+ if (elem.strokeWidth > 0) {
+ ctx.lineWidth = elem.strokeWidth * scale;
+ ctx.strokeStyle = elem.strokeColor;
+ ctx.strokeText(line, 0, y);
+ }
+ ctx.fillStyle = elem.color;
+ ctx.fillText(line, 0, y);
+ });
+}
+
+function drawRuler(ctx: CanvasRenderingContext2D, elem: RulerElement, scale: number): void {
+ const half = (elem.length / 2) * scale;
+ const lw = elem.lineWidth * scale;
+ const t = elem.tSize * scale;
+ ctx.strokeStyle = elem.lineColor;
+ ctx.lineWidth = lw;
+ ctx.lineCap = 'round';
+ ctx.beginPath();
+ ctx.moveTo(-half, 0);
+ ctx.lineTo(half, 0);
+ ctx.moveTo(-half, -t / 2);
+ ctx.lineTo(-half, t / 2);
+ ctx.moveTo(half, -t / 2);
+ ctx.lineTo(half, t / 2);
+ ctx.stroke();
+ if (elem.label) {
+ const fs = elem.labelFontSize * scale;
+ ctx.font = `${Math.round(fs)}px "PingFang SC", "Microsoft YaHei", sans-serif`;
+ ctx.fillStyle = elem.labelColor;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'alphabetic';
+ ctx.fillText(elem.label, 0, -t / 2 - fs * 0.25);
+ }
+}
+
+/** 绘制选中态:虚线包围盒 + 旋转手柄 +(标尺)端点手柄 */
+export function drawSelectionHandles(
+ ctx: CanvasRenderingContext2D,
+ elem: AnnotationElement,
+ scale: number,
+): void {
+ const { halfW, halfH } = elementBox(elem);
+ ctx.save();
+ ctx.translate(elem.x * scale, elem.y * scale);
+ ctx.rotate((elem.rotation * Math.PI) / 180);
+ const w = halfW * 2 * scale;
+ const h = halfH * 2 * scale;
+ const r = HANDLE_RADIUS;
+
+ ctx.strokeStyle = '#8b5cf6';
+ ctx.lineWidth = 1.5;
+ ctx.setLineDash([5, 4]);
+ ctx.strokeRect(-w / 2, -h / 2, w, h);
+ ctx.setLineDash([]);
+
+ const rotY = -(h / 2 + ROTATE_GAP);
+ ctx.beginPath();
+ ctx.moveTo(0, -h / 2);
+ ctx.lineTo(0, rotY);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.arc(0, rotY, r, 0, Math.PI * 2);
+ ctx.fillStyle = '#8b5cf6';
+ ctx.fill();
+ ctx.strokeStyle = '#fff';
+ ctx.lineWidth = 2;
+ ctx.stroke();
+
+ // 四角缩放圆点
+ const corners = [
+ [-w / 2, -h / 2],
+ [w / 2, -h / 2],
+ [-w / 2, h / 2],
+ [w / 2, h / 2],
+ ];
+ for (const [cx, cy] of corners) {
+ ctx.beginPath();
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
+ ctx.fillStyle = '#fff';
+ ctx.fill();
+ ctx.strokeStyle = '#8b5cf6';
+ ctx.lineWidth = 2;
+ ctx.stroke();
+ }
+ ctx.restore();
+}
+
+export function createTextElement(id: number, imgW: number, imgH: number): TextElement {
+ return {
+ id,
+ type: 'text',
+ x: imgW / 2,
+ y: imgH / 2,
+ rotation: 0,
+ opacity: 1,
+ content: '双击或右侧编辑文字',
+ fontFamily: 'PingFang SC',
+ fontSize: Math.max(18, Math.round(imgW * 0.06)),
+ color: '#ffffff',
+ strokeColor: '#000000',
+ strokeWidth: 0,
+ bold: false,
+ };
+}
+
+export function createRulerElement(id: number, imgW: number, imgH: number): RulerElement {
+ return {
+ id,
+ type: 'ruler',
+ x: imgW / 2,
+ y: imgH / 2,
+ rotation: 0,
+ opacity: 1,
+ length: Math.round(imgW * 0.5),
+ lineColor: '#ff4d4f',
+ lineWidth: Math.max(2, Math.round(imgW / 300)),
+ tSize: Math.max(14, Math.round(imgW * 0.04)),
+ label: '',
+ labelColor: '#ff4d4f',
+ labelFontSize: Math.max(14, Math.round(imgW * 0.035)),
+ };
+}
diff --git a/studio/src/utils/image.ts b/studio/src/utils/image.ts
new file mode 100644
index 0000000..a008132
--- /dev/null
+++ b/studio/src/utils/image.ts
@@ -0,0 +1,37 @@
+/** 图片文件读取与导出辅助 */
+
+export interface LoadedImage {
+ image: HTMLImageElement;
+ dataUrl: string;
+}
+
+/** 读取 File 为 HTMLImageElement 与 data URL */
+export function fileToImage(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ const dataUrl = reader.result as string;
+ const image = new Image();
+ image.onload = () => resolve({ image, dataUrl });
+ image.onerror = () => reject(new Error('图片加载失败'));
+ image.src = dataUrl;
+ };
+ reader.onerror = () => reject(new Error('文件读取失败'));
+ reader.readAsDataURL(file);
+ });
+}
+
+/** canvas 转 data URL(PNG) */
+export function canvasToDataUrl(canvas: HTMLCanvasElement): string {
+ return canvas.toDataURL('image/png');
+}
+
+/** 下载单个 canvas 为 PNG 文件 */
+export function downloadCanvas(canvas: HTMLCanvasElement, fileName: string): void {
+ const link = document.createElement('a');
+ link.href = canvas.toDataURL('image/png');
+ link.download = fileName;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+}
diff --git a/studio/src/utils/watermark.ts b/studio/src/utils/watermark.ts
new file mode 100644
index 0000000..70aa2f3
--- /dev/null
+++ b/studio/src/utils/watermark.ts
@@ -0,0 +1,145 @@
+/**
+ * 水印绘制能力(移植自 web/js/app.js,去掉了 web v1 的“图片水印缩图”行为)。
+ * 所有坐标均以原图为基准;预览/导出时按 targetWidth 等比缩放绘制。
+ */
+import type { WatermarkState } from '@/types/image';
+
+export const WATERMARK_SCALE = 0.15; // 图片水印直径占原图宽度的比例
+export const WATERMARK_MARGIN = 10; // 默认位置距右下角的间距(原图像素)
+export const WATERMARK_TEXT_SCALE = 0.051; // 文字水印字号占原图宽度的比例(原 0.06 的 85%)
+
+interface Size {
+ width: number;
+ height: number;
+}
+
+const measureCtx = document.createElement('canvas').getContext('2d')!;
+
+function watermarkFont(fontSize: number): string {
+ return `bold ${fontSize}px "PingFang SC", "Microsoft YaHei", Arial, sans-serif`;
+}
+
+function fontSizeFor(imgWidth: number): number {
+ return Math.max(12, Math.round(imgWidth * WATERMARK_TEXT_SCALE));
+}
+
+/** 水印在原图坐标系中的尺寸 */
+export function getWatermarkSize(imgWidth: number, state: WatermarkState): Size {
+ if (state.type === 'text') {
+ const fontSize = fontSizeFor(imgWidth);
+ measureCtx.font = watermarkFont(fontSize);
+ return { width: measureCtx.measureText(state.text).width, height: fontSize * 1.25 };
+ }
+ const diameter = imgWidth * WATERMARK_SCALE;
+ return { width: diameter, height: diameter };
+}
+
+/** 限制水印不被拖出图片范围 */
+export function clampPos(
+ imgWidth: number,
+ imgHeight: number,
+ x: number,
+ y: number,
+ state: WatermarkState,
+): { x: number; y: number } {
+ const size = getWatermarkSize(imgWidth, state);
+ const maxX = Math.max(0, imgWidth - size.width);
+ const maxY = Math.max(0, imgHeight - size.height);
+ return {
+ x: Math.min(Math.max(0, x), maxX),
+ y: Math.min(Math.max(0, y), maxY),
+ };
+}
+
+/** 默认位置:右下角 */
+export function defaultPos(imgWidth: number, imgHeight: number, state: WatermarkState): { x: number; y: number } {
+ const size = getWatermarkSize(imgWidth, state);
+ return clampPos(
+ imgWidth,
+ imgHeight,
+ imgWidth - size.width - WATERMARK_MARGIN,
+ imgHeight - size.height - WATERMARK_MARGIN,
+ state,
+ );
+}
+
+interface DrawOptions {
+ state: WatermarkState;
+ watermarkImg: HTMLImageElement | null;
+ targetWidth: number;
+}
+
+/** 把原图 + 水印合成到指定 canvas(targetWidth 为输出宽度,等比缩放) */
+export function drawWatermarked(
+ canvas: HTMLCanvasElement,
+ source: HTMLImageElement | HTMLCanvasElement,
+ { state, watermarkImg, targetWidth }: DrawOptions,
+): HTMLCanvasElement {
+ const scale = targetWidth / source.width;
+ canvas.width = Math.max(1, Math.round(targetWidth));
+ canvas.height = Math.max(1, Math.round(source.height * scale));
+ const ctx = canvas.getContext('2d')!;
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.drawImage(source, 0, 0, canvas.width, canvas.height);
+
+ if (state.type === 'image' && watermarkImg) {
+ const size = getWatermarkSize(source.width, state);
+ const drawX = state.pos.x * scale;
+ const drawY = state.pos.y * scale;
+ const drawSize = size.width * scale;
+
+ const edge = Math.min(watermarkImg.width, watermarkImg.height);
+ const sx = (watermarkImg.width - edge) / 2;
+ const sy = (watermarkImg.height - edge) / 2;
+
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(drawX + drawSize / 2, drawY + drawSize / 2, drawSize / 2, 0, Math.PI * 2);
+ ctx.closePath();
+ ctx.clip();
+ ctx.globalAlpha = state.opacity / 100;
+ ctx.drawImage(watermarkImg, sx, sy, edge, edge, drawX, drawY, drawSize, drawSize);
+ ctx.restore();
+ } else if (state.type === 'text' && state.text) {
+ const fontSize = fontSizeFor(source.width) * scale;
+ const font = watermarkFont(fontSize);
+ measureCtx.font = font;
+ const padding = Math.ceil(fontSize / 4);
+
+ // 先画到离屏画布再整体合成,避免透明度让描边透出字面显脏
+ const layer = document.createElement('canvas');
+ layer.width = Math.ceil(measureCtx.measureText(state.text).width) + padding * 2;
+ layer.height = Math.ceil(fontSize * 1.5) + padding * 2;
+ const layerCtx = layer.getContext('2d')!;
+ layerCtx.font = font;
+ layerCtx.textBaseline = 'top';
+ layerCtx.lineWidth = Math.max(1, fontSize / 8);
+ layerCtx.lineJoin = 'round';
+ layerCtx.strokeStyle = 'rgba(0, 0, 0, 0.55)';
+ layerCtx.strokeText(state.text, padding, padding);
+ layerCtx.fillStyle = '#ffffff';
+ layerCtx.fillText(state.text, padding, padding);
+
+ const drawX = state.pos.x * scale;
+ const drawY = state.pos.y * scale;
+ ctx.save();
+ ctx.globalAlpha = state.opacity / 100;
+ ctx.drawImage(layer, drawX - padding, drawY + fontSize * 0.1 - padding);
+ ctx.restore();
+ }
+
+ return canvas;
+}
+
+/** 生成全分辨率合成图(用于导出 / 发送给 AI) */
+export function renderFullRes(
+ source: HTMLImageElement | HTMLCanvasElement,
+ state: WatermarkState,
+ watermarkImg: HTMLImageElement | null,
+): HTMLCanvasElement {
+ return drawWatermarked(document.createElement('canvas'), source, {
+ state,
+ watermarkImg,
+ targetWidth: source.width,
+ });
+}
diff --git a/studio/src/vite-env.d.ts b/studio/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/studio/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/studio/tsconfig.app.json b/studio/tsconfig.app.json
new file mode 100644
index 0000000..26143f8
--- /dev/null
+++ b/studio/tsconfig.app.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ "moduleResolution": "bundler",
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/studio/tsconfig.app.tsbuildinfo b/studio/tsconfig.app.tsbuildinfo
new file mode 100644
index 0000000..6413cea
--- /dev/null
+++ b/studio/tsconfig.app.tsbuildinfo
@@ -0,0 +1 @@
+{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/config/env.ts","./src/layouts/mainlayout.tsx","./src/layouts/sidebarmenu.tsx","./src/layouts/menuconfig.tsx","./src/pages/ai-image/aiimagepage.tsx","./src/pages/ai-image/index.ts","./src/pages/ai-image/components/annotationcanvas.tsx","./src/pages/ai-image/components/elementpropspanel.tsx","./src/pages/ai-image/components/imageeditmodal.tsx","./src/pages/ai-image/components/watermarkcanvas.tsx","./src/router/index.tsx","./src/services/api.ts","./src/services/image.ts","./src/types/annotation.ts","./src/types/image.ts","./src/utils/annotation.ts","./src/utils/image.ts","./src/utils/watermark.ts"],"version":"5.9.3"}
\ No newline at end of file
diff --git a/studio/tsconfig.json b/studio/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/studio/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/studio/tsconfig.node.json b/studio/tsconfig.node.json
new file mode 100644
index 0000000..c5395d4
--- /dev/null
+++ b/studio/tsconfig.node.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ "moduleResolution": "bundler",
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/studio/tsconfig.node.tsbuildinfo b/studio/tsconfig.node.tsbuildinfo
new file mode 100644
index 0000000..62c7bf9
--- /dev/null
+++ b/studio/tsconfig.node.tsbuildinfo
@@ -0,0 +1 @@
+{"root":["./vite.config.ts"],"version":"5.9.3"}
\ No newline at end of file
diff --git a/studio/vite.config.ts b/studio/vite.config.ts
new file mode 100644
index 0000000..51ded0f
--- /dev/null
+++ b/studio/vite.config.ts
@@ -0,0 +1,24 @@
+import { fileURLToPath, URL } from 'node:url';
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react-swc';
+
+// 前端开发服务器,/api 代理到本地 FastAPI(server/)
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ },
+ },
+ server: {
+ port: 8900,
+ strictPort: true, // 端口被占用时直接报错,而不是自动改到别的端口
+ open: true, // 启动后自动打开浏览器
+ proxy: {
+ '/api': {
+ target: 'http://127.0.0.1:8800',
+ changeOrigin: true,
+ },
+ },
+ },
+});