feat: 开发编辑工作台

This commit is contained in:
R524809
2026-08-14 18:27:45 +08:00
parent b27e42dc75
commit c18d70017e
50 changed files with 5496 additions and 57 deletions
+6 -1
View File
@@ -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=
+1
View File
@@ -13,3 +13,4 @@ node_modules/
dist/
.output/
.wxt/
.pnpm-store/
+22 -4
View File
@@ -8,8 +8,8 @@ Ozon 跨境上品工具链:**采集 → 编辑 → 发布**。
|---|---|---|
| `web/` | ① 工具台 v1:计价、登记、水印、俄文文案 | ✅ 在用(冻结) |
| `extension/` | ② Chrome 采集插件:Ozon / 1688 商品页采集 | 🔨 待开发 |
| `studio/` | ③ 发布工作台:导入文件夹 → 编辑 → 发布 | 📋 待设计 |
| `server/` | ④ FastAPIAI 文案、图片处理、Ozon API | 🔨 部分就绪 |
| `studio/` | ③ 发布工作台:AI 图生图(上传/水印/万相图生图) | 🔨 开发中 |
| `server/` | ④ FastAPIAI 文案、图生图(wanx2.1-imageedit、Ozon API | 🔨 部分就绪 |
四者通过磁盘上的「[商品文件夹](docs/contracts/product-json.md)」契约衔接,不直接耦合代码。
@@ -20,7 +20,7 @@ ozon-seller-kit/
├── server/ # ④ FastAPImain.py / api / services / schemas / config
├── web/ # ① 工具台 v1,冻结
├── extension/ # ② 采集插件(待建)
├── studio/ # ③ 发布工作台(待建
├── studio/ # ③ 发布工作台(React + Vite + antdAI 图生图已上线
├── 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
View File
@@ -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** | serverOzon 发布 | M8 | 草稿进 Ozon 后台 | 4h |
**M4 结束时插件功能完整**,可以采集 1688/淘宝商品到本地文件夹,跨平台追加不重复。M9 结束时全链路跑通。
**M4 结束时插件功能完整**,可以采集 1688/淘宝商品到本地文件夹,跨平台追加不重复。M9 结束时全链路跑通。
+71
View File
@@ -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
View File
@@ -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)
# 后续扩展点:白底 / 水印等服务端处理可继续在此文件下新增路由。
+5 -1
View File
@@ -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
+1
View File
@@ -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
+125
View File
@@ -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 URLdata: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 = ""
+185
View File
@@ -0,0 +1,185 @@
"""多模型图生图 / 图像编辑服务。
按模型路由到不同的 DashScope 调用形态:
- wanx2.1-imageedit → ImageSynthesisfunction=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-imageeditImageSynthesis 同步调用。"""
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
View File
@@ -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 "服务已停止。按回车关闭窗口…"
+4
View File
@@ -0,0 +1,4 @@
# 前端环境变量(Vite 仅暴露 VITE_ 前缀变量)
# 开发环境默认走 vite 代理 /api -> http://127.0.0.1:8000,可留空
VITE_API_BASE_URL=/api
VITE_APP_NAME=Ozon 发布工作台
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
*.local
.env
.DS_Store
+12
View File
@@ -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>
+28
View File
@@ -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"
}
+2043
View File
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

+1
View File
@@ -0,0 +1 @@
/* 全局应用级样式补充(主题变量见 index.css) */
+25
View File
@@ -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;
+20
View File
@@ -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,
};
+35
View File
@@ -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;
}
+115
View File
@@ -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;
}
}
+102
View File
@@ -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;
+82
View File
@@ -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;
}
}
+61
View File
@@ -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;
+39
View File
@@ -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('/');
};
+10
View File
@@ -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>,
);
+538
View File
@@ -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;
}
+266
View File
@@ -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"> JPGPNGWEBP </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;
+1
View File
@@ -0,0 +1 @@
export { default } from './AiImagePage';
+15
View File
@@ -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 /> },
]);
+48
View File
@@ -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;
+7
View File
@@ -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);
}
+43
View File
@@ -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;
+57
View File
@@ -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;
}
+233
View File
@@ -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)),
};
}
+37
View File
@@ -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 URLPNG */
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);
}
+145
View File
@@ -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,
});
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+26
View File
@@ -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"]
}
+1
View File
@@ -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"}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+19
View File
@@ -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"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./vite.config.ts"],"version":"5.9.3"}
+24
View File
@@ -0,0 +1,24 @@
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
// 前端开发服务器,/api 代理到本地 FastAPIserver/
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,
},
},
},
});