feat: 开发编辑工作台
This commit is contained in:
+6
-1
@@ -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=
|
||||
|
||||
@@ -13,3 +13,4 @@ node_modules/
|
||||
dist/
|
||||
.output/
|
||||
.wxt/
|
||||
.pnpm-store/
|
||||
|
||||
@@ -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) |
|
||||
|
||||
+47
-12
@@ -12,17 +12,21 @@
|
||||
|
||||
四个组成部分,各自独立可用、通过明确契约衔接:
|
||||
|
||||
|
||||
| # | 部分 | 形态 | 状态 | 职责 |
|
||||
|---|---|---|---|---|
|
||||
| --- | ---------- | ------------------------- | ------- | ---------------------------- |
|
||||
| ① | **工具台 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,12 +119,15 @@ ozon-seller-kit/
|
||||
2. **中间产物可见可改**。采集结果就是普通文件夹,能用 Finder 看、能手动补图、能备份、能在两台机器间拷。
|
||||
3. **跨平台合并天然成立**。Ozon 采完,切到 1688 采同类商品,选同一个文件夹继续写入——就是往同一目录追加文件,不需要任何服务端参与。
|
||||
|
||||
|
||||
|
||||
### 3.1 product.json
|
||||
|
||||
结构对齐 Ozon `ProductAPI_ImportProductsV3`,但**不等于**它的请求体。区别在于采集阶段拿不到的字段留空,由工作台补齐:
|
||||
|
||||
|
||||
| 字段 | 采集阶段 | 工作台补齐 |
|
||||
|---|---|---|
|
||||
| ------------------------- | ----------- | -------------------------- |
|
||||
| `name` / `description` | ✅ 原文(可能是俄文) | 改写/翻译 |
|
||||
| `images` | 本地相对路径 | 上传图床后换成公网 URL |
|
||||
| `offer_id` | ❌ 空 | **必须填自己的货号**(跟卖场景下不能沿用竞品的) |
|
||||
@@ -127,9 +136,10 @@ ozon-seller-kit/
|
||||
| 尺寸重量 | 参数表里有就带上 | 校验补全 |
|
||||
| `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 |
|
||||
|
||||
|
||||
pnpm workspace 的唯一目的是让插件和 studio 共用契约类型。`pnpm-workspace.yaml` 三行搞定,不引入构建编排复杂度。
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## 7. 服务端演进
|
||||
|
||||
当前是无状态的:只有 AI 文案代理,没有数据库。按需要逐步加,不要一次上齐:
|
||||
|
||||
|
||||
| 阶段 | 触发条件 | 要加什么 |
|
||||
|---|---|---|
|
||||
| --- | -------------- | -------------------------------------- |
|
||||
| 现在 | — | 无状态,`/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,16 +317,19 @@ 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 |
|
||||
@@ -306,5 +341,5 @@ File System Access 在 side panel(扩展页面上下文)可用,`showDirect
|
||||
| **M8** | studio + server:图片处理 | M7 | 水印/白底可用 | 6h |
|
||||
| **M9** | server:Ozon 发布 | M8 | 草稿进 Ozon 后台 | 4h |
|
||||
|
||||
**M4 结束时插件功能完整**,可以采集 1688/淘宝商品到本地文件夹,跨平台追加不重复。M9 结束时全链路跑通。
|
||||
|
||||
**M4 结束时插件功能完整**,可以采集 1688/淘宝商品到本地文件夹,跨平台追加不重复。M9 结束时全链路跑通。
|
||||
@@ -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 层与前端契约无需改动。
|
||||
+9
-1
@@ -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)
|
||||
|
||||
# 后续扩展点:白底 / 水印等服务端处理可继续在此文件下新增路由。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ""
|
||||
@@ -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)
|
||||
+2
-2
@@ -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 "服务已停止。按回车关闭窗口…"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# 前端环境变量(Vite 仅暴露 VITE_ 前缀变量)
|
||||
# 开发环境默认走 vite 代理 /api -> http://127.0.0.1:8000,可留空
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_APP_NAME=Ozon 发布工作台
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
.env
|
||||
.DS_Store
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Ozon 发布工作台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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"
|
||||
}
|
||||
Generated
+2043
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1 @@
|
||||
/* 全局应用级样式补充(主题变量见 index.css) */
|
||||
@@ -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 (
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: '#8b5cf6',
|
||||
borderRadius: 8,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AntdApp>
|
||||
<RouterProvider router={router} />
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<Layout className="main-layout">
|
||||
{!isMobile && (
|
||||
<Sider
|
||||
className="main-sider"
|
||||
width={240}
|
||||
collapsed={collapsed}
|
||||
theme="dark"
|
||||
style={{
|
||||
overflow: 'auto',
|
||||
height: '100vh',
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<div className="sidebar-header">
|
||||
<div className="logo">{collapsed ? 'O' : 'Ozon Kit'}</div>
|
||||
{!collapsed && <div className="logo-subtitle">发布工作台</div>}
|
||||
</div>
|
||||
<SidebarMenu collapsed={collapsed} />
|
||||
</Sider>
|
||||
)}
|
||||
|
||||
{isMobile && (
|
||||
<Drawer
|
||||
title="Ozon Kit"
|
||||
placement="left"
|
||||
onClose={() => setMobileMenuOpen(false)}
|
||||
open={mobileMenuOpen}
|
||||
styles={{ body: { padding: 0, background: 'var(--sider-bg)' } }}
|
||||
width={240}
|
||||
>
|
||||
<SidebarMenu collapsed={false} onMenuClick={() => setMobileMenuOpen(false)} />
|
||||
</Drawer>
|
||||
)}
|
||||
|
||||
<Layout
|
||||
className="main-content-layout"
|
||||
style={{
|
||||
marginLeft: isMobile ? 0 : collapsed ? 80 : 240,
|
||||
transition: 'margin-left 0.2s',
|
||||
}}
|
||||
>
|
||||
<Header className="main-header">
|
||||
<div className="header-left">
|
||||
{isMobile && (
|
||||
<MenuFoldOutlined
|
||||
className="mobile-menu-trigger"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{!isMobile && (
|
||||
<div className="collapse-trigger" onClick={() => setCollapsed(!collapsed)}>
|
||||
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
</div>
|
||||
)}
|
||||
<div className="page-title-group">
|
||||
<div className="page-title">{pageInfo.title}</div>
|
||||
<div className="page-subtitle">{pageInfo.subtitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
<Content className="main-content">
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainLayout;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="sidebar-menu-container">
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
inlineCollapsed={collapsed}
|
||||
selectedKeys={selectedKeys}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
className="sidebar-menu"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidebarMenu;
|
||||
@@ -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: <PictureOutlined />,
|
||||
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('/');
|
||||
};
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<WatermarkType, null>;
|
||||
|
||||
const AiImagePage = () => {
|
||||
const { message } = App.useApp();
|
||||
|
||||
const [items, setItems] = useState<ImageItem[]>([]);
|
||||
const seqRef = useRef(0);
|
||||
|
||||
// 全局水印设置
|
||||
const [wmType, setWmType] = useState<WatermarkKind>('image');
|
||||
const [wmText, setWmText] = useState(DEFAULT_WM_TEXT);
|
||||
const [wmOpacity, setWmOpacity] = useState(DEFAULT_OPACITY);
|
||||
const [watermarkImg, setWatermarkImg] = useState<HTMLImageElement | null>(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<WatermarkState>) => {
|
||||
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 (
|
||||
<div className="ai-image-page">
|
||||
{/* 水印设置 */}
|
||||
<div className="panel">
|
||||
<div className="panel-title">水印设置</div>
|
||||
<div className="settings-row">
|
||||
<div className="settings-item">
|
||||
<span className="settings-label">水印类型</span>
|
||||
<Radio.Group
|
||||
value={wmType}
|
||||
onChange={(e) => setWmType(e.target.value as WatermarkKind)}
|
||||
optionType="button"
|
||||
>
|
||||
<Radio.Button value="image">图片水印</Radio.Button>
|
||||
<Radio.Button value="text">文字水印</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
{wmType === 'text' ? (
|
||||
<div className="settings-item">
|
||||
<span className="settings-label">水印文字</span>
|
||||
<Input
|
||||
value={wmText}
|
||||
onChange={(e) => setWmText(e.target.value)}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="settings-item">
|
||||
<span className="settings-label">水印图片</span>
|
||||
<img src={WATERMARK_IMG_SRC} alt="水印" className="wm-preview-img" />
|
||||
<span className="settings-hint">默认水印图,贴图后为圆形</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="settings-item settings-opacity">
|
||||
<span className="settings-label">透明度 {wmOpacity}%</span>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
value={wmOpacity}
|
||||
onChange={setWmOpacity}
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 上传 */}
|
||||
<div className="panel">
|
||||
<Upload.Dragger
|
||||
multiple
|
||||
accept="image/*"
|
||||
showUploadList={false}
|
||||
beforeUpload={handleBeforeUpload}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">点击或拖拽图片到此处上传(支持多张)</p>
|
||||
<p className="ant-upload-hint">支持 JPG、PNG、WEBP 格式</p>
|
||||
</Upload.Dragger>
|
||||
</div>
|
||||
|
||||
{/* 预览 */}
|
||||
<div className="panel">
|
||||
<div className="panel-header">
|
||||
<div className="panel-title">预览(处理后)</div>
|
||||
<Space>
|
||||
<Button onClick={applyToAll}>全部加水印</Button>
|
||||
<Button danger disabled={items.length === 0} onClick={clearAll}>
|
||||
清空所有
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<Empty description="暂无图片" />
|
||||
) : (
|
||||
<>
|
||||
<div className="image-grid">
|
||||
{items.map((item) => (
|
||||
<div className="image-card" key={item.id}>
|
||||
<WatermarkCanvas
|
||||
item={item}
|
||||
watermarkImg={watermarkImg}
|
||||
onPosChange={(pos) => updateItemState(item.id, { pos })}
|
||||
/>
|
||||
<div className="image-card-name" title={item.fileName}>
|
||||
{item.fileName}
|
||||
</div>
|
||||
<div className="image-card-actions">
|
||||
<Button size="small" onClick={() => toggleWatermark(item)}>
|
||||
{item.state.type ? '清除水印' : '加水印'}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<ExperimentOutlined />}
|
||||
onClick={() => openEdit(item)}
|
||||
>
|
||||
AI 生图
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => exportItem(item)}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => deleteItem(item.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="drag-hint">添加水印后,可在预览图上按住水印拖动调整位置</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ImageEditModal
|
||||
open={modalOpen}
|
||||
imageDataUrl={editing?.dataUrl ?? ''}
|
||||
fileName={editing?.fileName ?? ''}
|
||||
onClose={() => setModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiImagePage;
|
||||
@@ -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<HTMLCanvasElement>(null);
|
||||
const interactionRef = useRef<Interaction | null>(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<HTMLCanvasElement>) => {
|
||||
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<HTMLCanvasElement>) => {
|
||||
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<HTMLCanvasElement>) => {
|
||||
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<HTMLCanvasElement>) => {
|
||||
if (!interactionRef.current) return;
|
||||
interactionRef.current = null;
|
||||
if (canvasRef.current?.hasPointerCapture(e.pointerId)) {
|
||||
canvasRef.current.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="annotation-canvas-wrap">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="annotation-canvas"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={stopDrag}
|
||||
onPointerCancel={stopDrag}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnnotationCanvas;
|
||||
@@ -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<AnnotationElement>) => void;
|
||||
}
|
||||
|
||||
const FONT_OPTIONS = FONT_FAMILIES.map((f) => ({ value: f, label: f }));
|
||||
|
||||
const ElementPropsPanel = ({ element, onUpdate }: ElementPropsPanelProps) => {
|
||||
if (!element) {
|
||||
return (
|
||||
<div className="props-empty">
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未选中元素" />
|
||||
<p className="props-hint">点击画布上的文字或标尺进行编辑</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const update = (patch: Partial<AnnotationElement>) => onUpdate(element.id, patch);
|
||||
|
||||
return (
|
||||
<div className="props-panel">
|
||||
{element.type === 'text' ? (
|
||||
<>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">内容</span>
|
||||
<Input.TextArea
|
||||
value={element.content}
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
onChange={(e) => update({ content: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">字体</span>
|
||||
<Select
|
||||
value={element.fontFamily}
|
||||
options={FONT_OPTIONS}
|
||||
onChange={(v) => update({ fontFamily: v })}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">字号</span>
|
||||
<InputNumber
|
||||
value={element.fontSize}
|
||||
min={8}
|
||||
onChange={(v) => update({ fontSize: v ?? 18 })}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">加粗</span>
|
||||
<Switch checked={element.bold} onChange={(v) => update({ bold: v })} />
|
||||
</div>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">文字颜色</span>
|
||||
<ColorPicker
|
||||
value={element.color}
|
||||
onChange={(c) => update({ color: c.toHexString() })}
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">描边颜色</span>
|
||||
<ColorPicker
|
||||
value={element.strokeColor}
|
||||
onChange={(c) => update({ strokeColor: c.toHexString() })}
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">描边宽度</span>
|
||||
<InputNumber
|
||||
value={element.strokeWidth}
|
||||
min={0}
|
||||
onChange={(v) => update({ strokeWidth: v ?? 0 })}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">长度(px)</span>
|
||||
<InputNumber
|
||||
value={Math.round(element.length)}
|
||||
min={4}
|
||||
onChange={(v) => update({ length: v ?? 50 })}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">线色</span>
|
||||
<ColorPicker
|
||||
value={element.lineColor}
|
||||
onChange={(c) => update({ lineColor: c.toHexString() })}
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">线宽</span>
|
||||
<InputNumber
|
||||
value={element.lineWidth}
|
||||
min={1}
|
||||
onChange={(v) => update({ lineWidth: v ?? 2 })}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">T字大小</span>
|
||||
<InputNumber
|
||||
value={Math.round(element.tSize)}
|
||||
min={4}
|
||||
onChange={(v) => update({ tSize: v ?? 14 })}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">标签文字</span>
|
||||
<Input value={element.label} onChange={(e) => update({ label: e.target.value })} />
|
||||
</div>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">标签颜色</span>
|
||||
<ColorPicker
|
||||
value={element.labelColor}
|
||||
onChange={(c) => update({ labelColor: c.toHexString() })}
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">标签字号</span>
|
||||
<InputNumber
|
||||
value={Math.round(element.labelFontSize)}
|
||||
min={8}
|
||||
onChange={(v) => update({ labelFontSize: v ?? 14 })}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">透明度</span>
|
||||
<Slider
|
||||
min={0.05}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={element.opacity}
|
||||
onChange={(v) => update({ opacity: v })}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="prop-row">
|
||||
<span className="prop-label">旋转(°)</span>
|
||||
<InputNumber
|
||||
value={Math.round(element.rotation)}
|
||||
min={0}
|
||||
max={360}
|
||||
onChange={(v) => update({ rotation: (v ?? 0) % 360 })}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ElementPropsPanel;
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/** 从元素中提取可复用的「样式配置」(不含内容文字与位置/旋转) */
|
||||
function elementToConfig(el: AnnotationElement): Record<string, unknown> {
|
||||
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<HTMLImageElement | null>(null);
|
||||
const [aiResults, setAiResults] = useState<AiResult[]>([]);
|
||||
const [currentKey, setCurrentKey] = useState('original');
|
||||
const [elements, setElements] = useState<AnnotationElement[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [editTab, setEditTab] = useState<EditTab>('text');
|
||||
const [undoStack, setUndoStack] = useState<AnnotationElement[][]>([]);
|
||||
const [aiPrompt, setAiPrompt] = useState('');
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const [model, setModel] = useState('wanx2.1-imageedit');
|
||||
const [presets, setPresets] = useState<PresetItem[]>([]);
|
||||
const [selectedPreset, setSelectedPreset] = useState<Record<EditTab, string | null>>({
|
||||
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<AnnotationElement>) => {
|
||||
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<AnnotationElement>);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
placement="right"
|
||||
width="min(1240px, 96vw)"
|
||||
title={`图片编辑 - ${fileName}`}
|
||||
styles={{ body: { padding: 16 } }}
|
||||
>
|
||||
<div className="workbench-root">
|
||||
<div className="workbench">
|
||||
{/* 左:底图切换 */}
|
||||
<div className="wb-left">
|
||||
<Thumb
|
||||
label="原图"
|
||||
active={currentKey === 'original'}
|
||||
src={imageDataUrl}
|
||||
onClick={() => setCurrentKey('original')}
|
||||
/>
|
||||
{aiResults.map((r) => (
|
||||
<Thumb
|
||||
key={r.id}
|
||||
label="AI 结果"
|
||||
active={currentKey === r.id}
|
||||
src={r.dataUrl}
|
||||
onClick={() => setCurrentKey(r.id)}
|
||||
/>
|
||||
))}
|
||||
{aiResults.length === 0 && (
|
||||
<div className="wb-thumb-empty">AI 生成结果会出现在这里,点击可切换为底图</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 中:画布 */}
|
||||
<div className="wb-center">
|
||||
{baseImage ? (
|
||||
<AnnotationCanvas
|
||||
baseImage={baseImage}
|
||||
elements={elements}
|
||||
selectedId={selectedId}
|
||||
onSelect={handleSelect}
|
||||
onChange={setElements}
|
||||
onBeginInteraction={pushUndo}
|
||||
/>
|
||||
) : (
|
||||
<div className="wb-loading">
|
||||
<Spin />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右:标注编辑区 */}
|
||||
<div className="wb-right">
|
||||
<Tabs
|
||||
activeKey={editTab}
|
||||
onChange={(k) => setEditTab(k as EditTab)}
|
||||
size="small"
|
||||
className="wb-tabs"
|
||||
items={[
|
||||
{ key: 'text', label: '文字' },
|
||||
{ key: 'ruler', label: '标尺' },
|
||||
]}
|
||||
/>
|
||||
<div className="wb-add-row">
|
||||
{editTab === 'text' ? (
|
||||
<Button icon={<FontSizeOutlined />} onClick={addText} disabled={!baseImage}>
|
||||
添加文字
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
icon={<ColumnHeightOutlined />}
|
||||
onClick={addRuler}
|
||||
disabled={!baseImage}
|
||||
>
|
||||
添加标尺
|
||||
</Button>
|
||||
)}
|
||||
<Button danger icon={<DeleteOutlined />} onClick={deleteSelected} disabled={!hasSelection}>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
<div className="wb-preset-row">
|
||||
<Select
|
||||
value={selectedPreset[editTab] ?? undefined}
|
||||
onChange={applyPreset}
|
||||
options={presets
|
||||
.filter((p) => p.type === editTab)
|
||||
.map((p) => ({ value: p.id, label: p.name }))}
|
||||
placeholder="选择预设"
|
||||
allowClear
|
||||
disabled={presets.filter((p) => p.type === editTab).length === 0}
|
||||
className="wb-preset-select"
|
||||
/>
|
||||
</div>
|
||||
<div className="wb-props">
|
||||
{selectedElement && selectedElement.type === editTab ? (
|
||||
<ElementPropsPanel element={selectedElement} onUpdate={onUpdate} />
|
||||
) : (
|
||||
<div className="props-empty">
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={`未选中「${editTab === 'text' ? '文字' : '标尺'}」元素`}
|
||||
/>
|
||||
<p className="props-hint">点击画布上的元素,或点上方按钮添加</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="wb-save-row">
|
||||
<Button onClick={savePreset} disabled={!hasSelection} block>
|
||||
保存预设
|
||||
</Button>
|
||||
</div>
|
||||
<div className="wb-edit-actions">
|
||||
<Button icon={<UndoOutlined />} onClick={undo} disabled={undoStack.length === 0}>
|
||||
撤销
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ClearOutlined />}
|
||||
onClick={clearAnnotations}
|
||||
disabled={elements.length === 0}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={exportImage}
|
||||
disabled={!baseImage}
|
||||
>
|
||||
导出 PNG
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部:AI 指令 */}
|
||||
<div className="wb-bottom">
|
||||
<div className="wb-ai">
|
||||
<Input.TextArea
|
||||
value={aiPrompt}
|
||||
onChange={(e) => setAiPrompt(e.target.value)}
|
||||
placeholder="AI 修改:换背景 / 去杂物 / 补光影 / 改风格…(生成后自动作为新底图,可继续叠加标注)"
|
||||
rows={3}
|
||||
className="wb-ai-input"
|
||||
disabled={!baseImage}
|
||||
onPressEnter={(e) => {
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void runAi();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="wb-ai-side">
|
||||
<Select
|
||||
value={model}
|
||||
options={MODEL_OPTIONS}
|
||||
onChange={setModel}
|
||||
disabled={!baseImage}
|
||||
className="wb-model-select"
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ExperimentOutlined />}
|
||||
loading={aiLoading}
|
||||
onClick={() => void runAi()}
|
||||
disabled={!baseImage}
|
||||
block
|
||||
>
|
||||
AI 生成
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
function Thumb({
|
||||
label,
|
||||
active,
|
||||
src,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
src: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={`wb-thumb${active ? ' active' : ''}`} onClick={onClick}>
|
||||
<img src={src} alt={label} />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageEditModal;
|
||||
@@ -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<HTMLCanvasElement>(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<HTMLCanvasElement>) => {
|
||||
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<HTMLCanvasElement>) => {
|
||||
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<HTMLCanvasElement>) => {
|
||||
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<HTMLCanvasElement>) => {
|
||||
if (!dragRef.current) return;
|
||||
dragRef.current = null;
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={`watermark-canvas${item.state.type ? ' watermark-canvas-draggable' : ''}`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={stopDrag}
|
||||
onPointerCancel={stopDrag}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default WatermarkCanvas;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AiImagePage';
|
||||
@@ -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: <MainLayout />,
|
||||
children: [
|
||||
{ index: true, element: <AiImagePage /> },
|
||||
{ path: 'ai-image', element: <AiImagePage /> },
|
||||
],
|
||||
},
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
]);
|
||||
@@ -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: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
|
||||
apiClient.get<T>(url, config).then((res) => res.data),
|
||||
|
||||
post: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||
apiClient.post<T>(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;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { api } from './api';
|
||||
import type { ImageEditRequest, ImageEditResponse } from '@/types/image';
|
||||
|
||||
/** 调用后端图生图接口(wanx2.1-imageedit) */
|
||||
export function editImage(payload: ImageEditRequest): Promise<ImageEditResponse> {
|
||||
return api.post<ImageEditResponse>('/image/edit', payload);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/** 图片文件读取与导出辅助 */
|
||||
|
||||
export interface LoadedImage {
|
||||
image: HTMLImageElement;
|
||||
dataUrl: string;
|
||||
}
|
||||
|
||||
/** 读取 File 为 HTMLImageElement 与 data URL */
|
||||
export function fileToImage(file: File): Promise<LoadedImage> {
|
||||
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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./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"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./vite.config.ts"],"version":"5.9.3"}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user