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} +
+
+ + +
+
+ ))} +
+

添加水印后,可在预览图上按住水印拖动调整位置

+ + )} +
+ + 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({ 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' ? ( + + ) : ( + + )} + +
+
+ + +
+
+
+
+
+ ); +}; + +function Thumb({ + label, + active, + src, + onClick, +}: { + label: string; + active: boolean; + src: string; + onClick: () => void; +}) { + return ( +
+ {label} + {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, + }, + }, + }, +});