diff --git a/README.md b/README.md index cdda3f2..27a1c13 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 玉宗 - 珠宝设计大师 -AI 驱动的珠宝玉石设计生成系统,支持 12 种玉石品类的智能设计图生成。 +AI 驱动的珠宝玉石设计生成系统,支持 12 种玉石品类的智能设计图生成,双 AI 模型多视角生图,内置后台管理系统。 ## 功能特性 @@ -14,10 +14,31 @@ AI 驱动的珠宝玉石设计生成系统,支持 12 种玉石品类的智能 - **实现方式**:品类通过 `flow_type` 字段区分三种工作流程:`full`(选子类型,如牌子选牌型)、`size_color`(选尺寸+颜色,如珠子)、`simple`(直接设计);前端 `SubTypePanel` 组件根据 flow_type 动态渲染不同的选择界面 - **优点**:灵活的品类工作流适配不同产品特性,用户操作路径清晰 -### 3. 设计图生成 -- **功能说明**:用户选择品类参数后输入设计描述,系统生成 800×800 PNG 设计图 -- **实现方式**:后端 `mock_generator` 使用 Pillow 生成设计图,包含品类信息、颜色映射(中文颜色名→HEX)、自动文字颜色对比计算、系统中文字体检测(PingFang/STHeiti/DroidSansFallback);设计记录先创建(status=generating),生成完成后更新为 completed -- **优点**:即时生成预览图,支持颜色定制;后续可替换为真实 AI 模型 +### 3. AI 多视角设计图生成 +- **功能说明**:用户选择品类参数后,可配置 6 个可选设计参数(雕刻工艺、设计风格、题材纹样、尺寸规格、表面处理、用途场景),输入设计描述后系统自动生成多视角设计图(每个品类 2~4 张不同视角) +- **双模型支持**: + - **SiliconFlow FLUX.1 [dev]**(默认):~0.13元/张,性价比高 + - **火山引擎 Seedream 4.5**(备选):~0.30元/张,高质量 +- **提示词系统**:自动将中文参数(品类/子类型/颜色/6个设计参数+视角)构建为专业英文 prompt,包含玉雕行业专业术语、摄影角度、质量标签 +- **按品类视角配置**: + | 品类 | 视角数 | 视角列表 | + |------|--------|----------| + | 牌子 | 3 | 效果图(45°)、正面图、背面图 | + | 珠子 | 2 | 效果图(45°)、正面图 | + | 手把件/雕刻件/摆件 | 4 | 效果图(45°)、正面图、侧面图、背面图 | + | 手镯 | 3 | 效果图(45°)、正面图、侧面图 | + | 耳钉/耳饰/手链/项链/表带 | 2 | 效果图(45°)、正面图 | + | 戒指 | 3 | 效果图(45°)、正面图、侧面图 | +- **降级机制**:AI 生图失败时自动降级到 mock_generator 生成占位图 +- **可选参数**: + - **雕刻工艺**:浮雕、圆雕、镂空雕、阴刻、线雕、俍色雕、薄意雕、素面,支持自定义输入 + - **设计风格**:古典传统、新中式、写实、抽象意境、极简素面,支持自定义输入 + - **题材纹样**:观音、弥勒、莲花、貔貅、龙凤、麒麟、山水、花鸟、人物、回纹、如意、平安扣,支持自定义输入 + - **尺寸规格**:根据品类动态变化(牌子尺寸/手镯内径/手把件大小等),支持自定义输入 + - **表面处理**:高光抛光、亚光/哑光、磨砂、保留皮色,支持自定义输入 + - **用途场景**:日常佩戴、收藏鉴赏、送礼婚庆、把玩文玩,支持自定义输入 +- **实现方式**:后端 `prompt_builder` 自动构建专业英文 prompt → `ai_generator` 调用 AI API 生图 → 下载保存到本地 → 创建 `DesignImage` 多视角记录;降级时使用 `mock_generator` Pillow 生成占位图 +- **优点**:AI 真实生图 + 多视角展示,专业玉雕提示词系统,双模型热切换 + 降级兜底 ### 4. 设计管理 - **功能说明**:用户中心查看设计历史列表(分页),支持预览、下载、删除设计 @@ -29,6 +50,17 @@ AI 驱动的珠宝玉石设计生成系统,支持 12 种玉石品类的智能 - **实现方式**:手机号唯一性校验,密码修改需验证旧密码;前端表单使用 Element Plus 表单验证 - **优点**:安全的密码修改流程,手机号去重保障数据一致性 +### 6. 后台管理系统 +- **功能说明**:管理员专属后台,支持仪表盘、系统配置、用户管理、品类管理、设计管理五大模块 +- **实现方式**: + - **权限控制**:用户表 `is_admin` 字段 + `get_admin_user` 依赖注入,非管理员访问返回 403 + - **系统配置**:`system_configs` 表存储配置项,数据库配置优先于 .env 文件;敏感信息(API Key)脱敏显示 + - **品类管理**:支持品类、子类型、颜色的增删改查,含颜色拾色器 + - **用户管理**:搜索、分页、设置/取消管理员、删除用户 + - **设计管理**:查看所有用户设计,按状态筛选,管理员删除 + - **前端独立布局**:左侧导航 + 内容区,管理后台与前台完全分离 +- **优点**:AI 配置可热更新(无需重启服务),品类数据可视化管理,用户权限分级控制 + ## 业务流程 ### 整体流程图 @@ -43,9 +75,10 @@ graph TB D --> G[生成页 - 输入设计描述] E --> G F --> G - G --> H[提交生成请求] - H --> I[后端生成设计图] - I --> J[预览设计图] + G --> G2[选择可选参数] + G2 --> H[提交生成请求] + H --> I[后端 AI 多视角生图] + I --> J[预览多视角设计图] J --> K{用户操作} K -->|下载| L[下载 PNG 文件] K -->|重新生成| G @@ -58,18 +91,20 @@ graph TB 1. **用户认证**:注册时检查用户名唯一性 → 密码 bcrypt 加密 → 创建用户记录 → 注册成功后自动登录 → 获取 JWT Token 存储到 localStorage 2. **品类选择**:进入设计页自动加载品类列表 → 左侧导航选择品类 → 根据 flow_type 加载对应的子类型/颜色数据 → 右侧面板显示选择界面 -3. **设计生成**:跳转生成页携带品类参数 → 输入设计描述(最多 500 字) → 提交请求 → 显示水墨风格加载动画 → 生成完成后展示预览 +3. **设计生成**:跳转生成页携带品类参数 → 选择可选参数(工艺/风格/题材/尺寸/表面/用途) → 输入设计描述(最多 2000 字) → 提交请求 → 显示水墨风格加载动画 → 生成完成后展示预览 4. **设计管理**:用户中心加载设计列表 → 卡片网格展示 → 支持分页浏览、下载 PNG、删除(确认弹窗)、点击卡片跳转重新编辑 ### 关键数据流图 ```mermaid graph LR - A[前端 Store] -->|Axios + Token| B[API 路由] - B -->|Depends 注入| C[Service 层] + A[前端 Store] -->|“Axios + Token”| B[API 路由] + B -->|“Depends 注入”| C[Service 层] + C -->|prompt_builder| D1[英文 Prompt 构建] + D1 -->|ai_generator| D2[AI 生图 API] + D2 -->|下载保存| E[uploads/ 目录] C -->|SQLAlchemy ORM| D[MySQL 数据库] - C -->|Pillow 生成| E[uploads/ 目录] - E -->|StaticFiles 服务| F[前端图片展示] + E -->|StaticFiles 服务| F[前端多视角展示] ``` ### API 调用链路 @@ -84,11 +119,22 @@ graph LR | `/api/categories` | GET | 获取品类列表 | - | | `/api/categories/{id}/sub-types` | GET | 获取子类型 | category_id | | `/api/categories/{id}/colors` | GET | 获取颜色选项 | category_id | -| `/api/designs/generate` | POST | 生成设计 | category_id, sub_type_id, color_id, prompt | +| `/api/designs/generate` | POST | 生成设计 | category_id, sub_type_id, color_id, prompt, carving_technique?, design_style?, motif?, size_spec?, surface_finish?, usage_scene? | | `/api/designs` | GET | 设计列表(分页) | page, page_size | | `/api/designs/{id}` | GET | 设计详情 | design_id | | `/api/designs/{id}` | DELETE | 删除设计 | design_id | | `/api/designs/{id}/download` | GET | 下载设计图 | design_id | +| `/api/admin/dashboard` | GET | 管理仪表盘统计 | Bearer Token (管理员) | +| `/api/admin/configs` | GET | 获取系统配置 | group? | +| `/api/admin/configs` | PUT | 更新系统配置 | configs: {key: value} | +| `/api/admin/configs/init` | POST | 初始化默认配置 | - | +| `/api/admin/users` | GET | 用户列表 | page, page_size, keyword? | +| `/api/admin/users/{id}/admin` | PUT | 设置管理员 | is_admin | +| `/api/admin/categories` | GET/POST | 品类列表/创建 | name, flow_type, sort_order | +| `/api/admin/categories/{id}` | PUT/DELETE | 更新/删除品类 | - | +| `/api/admin/sub-types` | POST | 创建子类型 | category_id, name | +| `/api/admin/colors` | POST | 创建颜色 | category_id, name, hex_code | +| `/api/admin/designs` | GET | 所有设计列表 | page, status? | ## 技术栈 @@ -107,7 +153,9 @@ graph LR | **数据库驱动** | PyMySQL | 1.1.0 | MySQL 连接 | | **认证** | python-jose | 3.3.0 | JWT Token | | **密码加密** | passlib + bcrypt | 1.7.4 | bcrypt 哈希 | -| **图片生成** | Pillow | 10.2.0 | PNG 设计图生成 | +| **图片生成** | Pillow | 10.2.0 | PNG 设计图生成(mock 降级) | +| **AI 生图** | SiliconFlow FLUX.1 / Seedream 4.5 | - | 双模型多视角生图 | +| **HTTP 客户端** | httpx | 0.27.0 | 异步调用 AI API + 图片下载 | | **数据库** | MySQL | - | utf8mb4 编码 | ## 目录结构 @@ -124,14 +172,18 @@ YuShiSheJi/ │ │ │ ├── auth.py # 认证路由(注册/登录) │ │ │ ├── categories.py # 品类查询路由 │ │ │ ├── designs.py # 设计生成/管理路由 +│ │ │ ├── admin.py # 管理后台路由 │ │ │ └── users.py # 用户信息路由 │ │ ├── schemas/ # Pydantic 数据验证 │ │ ├── services/ # 业务逻辑层 │ │ │ ├── auth_service.py # 认证业务 -│ │ │ ├── design_service.py # 设计业务 -│ │ │ └── mock_generator.py # 图片生成服务 +│ │ │ ├── design_service.py # 设计业务(AI生图+降级) +│ │ │ ├── ai_generator.py # AI 生图服务(双模型) +│ │ │ ├── prompt_builder.py # 提示词构建器 +│ │ │ ├── config_service.py # 配置服务(数据库优先于.env) +│ │ │ └── mock_generator.py # Mock图片生成(降级兜底) │ │ ├── utils/ # 工具函数 -│ │ │ ├── deps.py # 认证依赖注入 +│ │ │ ├── deps.py # 认证/管理员依赖注入 │ │ │ └── security.py # JWT/密码工具 │ │ ├── config.py # 配置管理 │ │ ├── database.py # 数据库连接 @@ -145,9 +197,11 @@ YuShiSheJi/ │ │ │ ├── request.ts # Axios 实例(拦截器) │ │ │ ├── auth.ts # 认证接口 │ │ │ ├── category.ts # 品类接口 -│ │ │ └── design.ts # 设计接口 +│ │ │ ├── design.ts # 设计接口 +│ │ │ └── admin.ts # 管理后台接口 │ │ ├── components/ # 公共组件 │ │ │ ├── AppHeader.vue # 顶部导航栏 +│ │ │ ├── AdminLayout.vue # 管理后台布局(侧边栏+内容区) │ │ │ ├── CategoryNav.vue # 品类左侧导航 │ │ │ ├── SubTypePanel.vue# 子类型/颜色选择面板 │ │ │ ├── ColorPicker.vue # 颜色选择器 @@ -157,6 +211,12 @@ YuShiSheJi/ │ │ │ ├── category.ts # 品类状态 │ │ │ └── design.ts # 设计状态 │ │ ├── views/ # 页面组件 +│ │ │ ├── admin/ # 管理后台页面 +│ │ │ │ ├── Dashboard.vue # 仪表盘 +│ │ │ │ ├── ConfigManage.vue # 系统配置管理 +│ │ │ │ ├── UserManage.vue # 用户管理 +│ │ │ │ ├── CategoryManage.vue # 品类管理 +│ │ │ │ └── DesignManage.vue # 设计管理 │ │ │ ├── Login.vue # 登录页 │ │ │ ├── Register.vue # 注册页 │ │ │ ├── DesignPage.vue # 设计页(品类选择) @@ -236,11 +296,13 @@ uvicorn app.main:app --host 0.0.0.0 --port 8000 | 分类 | 表名 | 说明 | |-----|------|-----| -| 用户 | `users` | 用户基本信息(用户名、密码、昵称、手机、头像) | +| 用户 | `users` | 用户基本信息(用户名、密码、昵称、手机、头像、管理员标识) | | 品类 | `categories` | 12 种玉石品类(名称、图标、排序、流程类型) | | 品类 | `sub_types` | 品类子类型(牌型/尺寸,关联品类) | | 品类 | `colors` | 品类颜色选项(颜色名、HEX 色值,关联品类) | -| 设计 | `designs` | 设计记录(用户、品类、子类型、颜色、描述、图片URL、状态) | +| 设计 | `designs` | 设计记录(用户、品类、子类型、颜色、描述、6个可选参数、图片URL、状态) | +| 设计 | `design_images` | AI 多视角设计图(视角名称、图片URL、AI模型、prompt、排序) | +| 配置 | `system_configs` | 系统配置(AI API Key、模型、尺寸等,支持后台管理动态修改) | ## 环境变量 @@ -251,6 +313,12 @@ uvicorn app.main:app --host 0.0.0.0 --port 8000 | `ALGORITHM` | `HS256` | 否 | JWT 算法 | | `ACCESS_TOKEN_EXPIRE_MINUTES` | `1440` | 否 | Token 有效期(分钟,默认 24 小时) | | `UPLOAD_DIR` | `uploads` | 否 | 图片上传存储目录 | +| `SILICONFLOW_API_KEY` | 空 | 是(生图) | 硅基流动 API Key(FLUX.1 生图) | +| `SILICONFLOW_BASE_URL` | `https://api.siliconflow.cn/v1` | 否 | 硅基流动 API 地址 | +| `VOLCENGINE_API_KEY` | 空 | 否 | 火山引擎 API Key(Seedream 4.5 备选) | +| `VOLCENGINE_BASE_URL` | `https://ark.cn-beijing.volces.com/api/v3` | 否 | 火山引擎 API 地址 | +| `AI_IMAGE_MODEL` | `flux-dev` | 否 | 默认 AI 模型(flux-dev / seedream-4.5) | +| `AI_IMAGE_SIZE` | `1024` | 否 | 生成图片尺寸 | ## 常用命令 diff --git a/backend/.env b/backend/.env index 665c4cb..7b602a5 100644 --- a/backend/.env +++ b/backend/.env @@ -3,3 +3,11 @@ SECRET_KEY=yuzong-jewelry-design-secret-key-2026 ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=1440 UPLOAD_DIR=uploads + +# AI 生图配置 +SILICONFLOW_API_KEY= +SILICONFLOW_BASE_URL=https://api.siliconflow.cn/v1 +VOLCENGINE_API_KEY= +VOLCENGINE_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 +AI_IMAGE_MODEL=flux-dev +AI_IMAGE_SIZE=1024 diff --git a/backend/.env.example b/backend/.env.example index 7e6b769..68a4136 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,3 +3,11 @@ SECRET_KEY=your-secret-key-change-this ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=1440 UPLOAD_DIR=uploads + +# AI 生图配置 +SILICONFLOW_API_KEY=sk-xxx +SILICONFLOW_BASE_URL=https://api.siliconflow.cn/v1 +VOLCENGINE_API_KEY=xxx +VOLCENGINE_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 +AI_IMAGE_MODEL=flux-dev +AI_IMAGE_SIZE=1024 diff --git a/backend/app/config.py b/backend/app/config.py index 1804846..ae7f4f2 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -13,6 +13,13 @@ class Settings(BaseSettings): ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 UPLOAD_DIR: str = "uploads" + # AI 生图配置 + SILICONFLOW_API_KEY: str = "" + SILICONFLOW_BASE_URL: str = "https://api.siliconflow.cn/v1" + VOLCENGINE_API_KEY: str = "" + VOLCENGINE_BASE_URL: str = "https://ark.cn-beijing.volces.com/api/v3" + AI_IMAGE_MODEL: str = "flux-dev" # flux-dev 或 seedream-4.5 + AI_IMAGE_SIZE: int = 1024 class Config: env_file = ".env" diff --git a/backend/app/main.py b/backend/app/main.py index 6ccfc8f..b60e260 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,6 +10,7 @@ from fastapi.staticfiles import StaticFiles from .config import settings from .routers import categories, designs, users from .routers import auth +from .routers import admin @asynccontextmanager @@ -62,6 +63,7 @@ app.include_router(auth.router) app.include_router(categories.router) app.include_router(designs.router) app.include_router(users.router) +app.include_router(admin.router) # 配置静态文件服务 app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8c9c6a7..8328496 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -6,6 +6,9 @@ from ..database import Base from .user import User from .category import Category, SubType, Color from .design import Design +from .design_image import DesignImage +from .system_config import SystemConfig +from .prompt_template import PromptTemplate, PromptMapping __all__ = [ "Base", @@ -13,5 +16,9 @@ __all__ = [ "Category", "SubType", "Color", - "Design" + "Design", + "DesignImage", + "SystemConfig", + "PromptTemplate", + "PromptMapping", ] diff --git a/backend/app/models/design.py b/backend/app/models/design.py index ff136c9..892dc0d 100644 --- a/backend/app/models/design.py +++ b/backend/app/models/design.py @@ -34,6 +34,7 @@ class Design(Base): category = relationship("Category", back_populates="designs") sub_type = relationship("SubType", back_populates="designs") color = relationship("Color", back_populates="designs") + images = relationship("DesignImage", back_populates="design", cascade="all, delete-orphan", order_by="DesignImage.sort_order") def __repr__(self): return f"" diff --git a/backend/app/models/design_image.py b/backend/app/models/design_image.py new file mode 100644 index 0000000..c9a9192 --- /dev/null +++ b/backend/app/models/design_image.py @@ -0,0 +1,29 @@ +""" +设计图片模型 +存储每个设计的多视角图片 +""" +from sqlalchemy import Column, BigInteger, Integer, String, Text, DateTime, ForeignKey +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship + +from ..database import Base + + +class DesignImage(Base): + """设计图片表 - 存储多视角设计图""" + __tablename__ = "design_images" + + id = Column(BigInteger, primary_key=True, autoincrement=True, comment="图片ID") + design_id = Column(BigInteger, ForeignKey("designs.id", ondelete="CASCADE"), nullable=False, comment="关联设计ID") + view_name = Column(String(20), nullable=False, comment="视角名称: 效果图/正面图/侧面图/背面图") + image_url = Column(String(255), nullable=True, comment="图片URL路径") + model_used = Column(String(50), nullable=True, comment="使用的AI模型: flux-dev/seedream-4.5") + prompt_used = Column(Text, nullable=True, comment="实际使用的英文prompt") + sort_order = Column(Integer, default=0, comment="排序") + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + + # 关联关系 + design = relationship("Design", back_populates="images") + + def __repr__(self): + return f"" diff --git a/backend/app/models/prompt_template.py b/backend/app/models/prompt_template.py new file mode 100644 index 0000000..1047581 --- /dev/null +++ b/backend/app/models/prompt_template.py @@ -0,0 +1,37 @@ +""" +提示词模板模型 +存储可后台配置的提示词模板和映射数据 +""" +from sqlalchemy import Column, BigInteger, Integer, String, Text, DateTime +from sqlalchemy.sql import func + +from ..database import Base + + +class PromptTemplate(Base): + """提示词模板表""" + __tablename__ = "prompt_templates" + + id = Column(BigInteger, primary_key=True, autoincrement=True, comment="模板ID") + template_key = Column(String(100), unique=True, nullable=False, comment="模板键: main_template / quality_suffix") + template_value = Column(Text, nullable=False, comment="模板内容,支持{变量}占位符") + description = Column(String(255), nullable=True, comment="模板说明") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + + def __repr__(self): + return f"" + + +class PromptMapping(Base): + """提示词映射表 - 中文参数到英文描述的映射""" + __tablename__ = "prompt_mappings" + + id = Column(BigInteger, primary_key=True, autoincrement=True, comment="映射ID") + mapping_type = Column(String(50), nullable=False, comment="映射类型: category/color/carving/style/motif/finish/scene/view/sub_type") + cn_key = Column(String(100), nullable=False, comment="中文键") + en_value = Column(Text, nullable=False, comment="英文描述") + sort_order = Column(Integer, default=0, comment="排序") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + + def __repr__(self): + return f"" diff --git a/backend/app/models/system_config.py b/backend/app/models/system_config.py new file mode 100644 index 0000000..a8ea7c7 --- /dev/null +++ b/backend/app/models/system_config.py @@ -0,0 +1,24 @@ +""" +系统配置模型 +存储可通过后台管理的系统配置项 +""" +from sqlalchemy import Column, BigInteger, String, Text, DateTime +from sqlalchemy.sql import func + +from ..database import Base + + +class SystemConfig(Base): + """系统配置表""" + __tablename__ = "system_configs" + + id = Column(BigInteger, primary_key=True, autoincrement=True, comment="配置ID") + config_key = Column(String(100), unique=True, nullable=False, comment="配置键") + config_value = Column(Text, nullable=True, comment="配置值") + description = Column(String(255), nullable=True, comment="配置说明") + config_group = Column(String(50), nullable=False, default="general", comment="配置分组: ai/general") + is_secret = Column(String(1), nullable=False, default="N", comment="是否敏感信息(Y/N)") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + + def __repr__(self): + return f"" diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 4b8627b..b9fa8c0 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,7 +1,7 @@ """ 用户模型 """ -from sqlalchemy import Column, BigInteger, String, DateTime +from sqlalchemy import Column, BigInteger, String, DateTime, Boolean from sqlalchemy.sql import func from sqlalchemy.orm import relationship @@ -18,6 +18,7 @@ class User(Base): hashed_password = Column(String(255), nullable=False, comment="加密密码") nickname = Column(String(50), nullable=True, comment="昵称") avatar = Column(String(255), nullable=True, comment="头像URL") + is_admin = Column(Boolean, default=False, nullable=False, comment="是否管理员") created_at = Column(DateTime, server_default=func.now(), comment="创建时间") updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py new file mode 100644 index 0000000..18effc5 --- /dev/null +++ b/backend/app/routers/admin.py @@ -0,0 +1,627 @@ +""" +管理后台路由 +提供系统配置、用户管理、品类管理、设计管理接口 +所有接口需要管理员权限 +""" +from datetime import datetime, timedelta +import httpx +from fastapi import APIRouter, Depends, HTTPException, status, Query +from pydantic import BaseModel +from sqlalchemy.orm import Session +from sqlalchemy import func, or_ + +from ..database import get_db +from ..models import User, Design, Category, SubType, Color, SystemConfig, PromptTemplate, PromptMapping +from ..schemas.admin import ( + SystemConfigItem, SystemConfigUpdate, SystemConfigResponse, + AdminUserResponse, AdminUserListResponse, AdminSetAdmin, + CategoryCreate, CategoryUpdate, SubTypeCreate, SubTypeUpdate, + ColorCreate, ColorUpdate, + AdminDesignListResponse, DashboardStats, + PromptTemplateItem, PromptTemplateUpdate, + PromptMappingItem, PromptMappingCreate, PromptMappingUpdate +) +from ..utils.deps import get_admin_user + +router = APIRouter(prefix="/api/admin", tags=["管理后台"]) + + +# ==================== 仪表盘 ==================== + +@router.get("/dashboard", response_model=DashboardStats) +def get_dashboard( + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """获取仪表盘统计数据""" + today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + return DashboardStats( + total_users=db.query(func.count(User.id)).scalar() or 0, + total_designs=db.query(func.count(Design.id)).scalar() or 0, + total_categories=db.query(func.count(Category.id)).scalar() or 0, + today_designs=db.query(func.count(Design.id)).filter(Design.created_at >= today).scalar() or 0, + today_users=db.query(func.count(User.id)).filter(User.created_at >= today).scalar() or 0, + ) + + +# ==================== 系统配置管理 ==================== + +@router.get("/configs", response_model=SystemConfigResponse) +def get_configs( + group: str = Query(None, description="按分组筛选"), + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """获取系统配置列表""" + query = db.query(SystemConfig) + if group: + query = query.filter(SystemConfig.config_group == group) + items = query.order_by(SystemConfig.config_group, SystemConfig.config_key).all() + + # 敏感信息脱敏显示 + result = [] + for item in items: + cfg = SystemConfigItem.model_validate(item) + if item.is_secret == "Y" and item.config_value: + # 只显示前4位和后4位 + val = item.config_value + if len(val) > 8: + cfg.config_value = val[:4] + "****" + val[-4:] + else: + cfg.config_value = "****" + result.append(cfg) + return SystemConfigResponse(items=result) + + +@router.put("/configs") +def update_configs( + data: SystemConfigUpdate, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """批量更新系统配置""" + updated = 0 + for key, value in data.configs.items(): + config = db.query(SystemConfig).filter(SystemConfig.config_key == key).first() + if config: + config.config_value = value + updated += 1 + else: + # 自动创建不存在的配置项 + new_config = SystemConfig( + config_key=key, + config_value=value, + config_group="general" + ) + db.add(new_config) + updated += 1 + db.commit() + return {"message": f"已更新 {updated} 项配置"} + + +@router.post("/configs/init") +def init_default_configs( + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """初始化默认配置项(仅当配置表为空时)""" + count = db.query(func.count(SystemConfig.id)).scalar() + if count > 0: + return {"message": "配置已存在,跳过初始化"} + + defaults = [ + ("SILICONFLOW_API_KEY", "", "SiliconFlow API Key", "ai", "Y"), + ("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1", "SiliconFlow 接口地址", "ai", "N"), + ("VOLCENGINE_API_KEY", "", "火山引擎 API Key", "ai", "Y"), + ("VOLCENGINE_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3", "火山引擎接口地址", "ai", "N"), + ("AI_IMAGE_MODEL", "flux-dev", "默认AI生图模型 (flux-dev / seedream-4.5)", "ai", "N"), + ("AI_IMAGE_SIZE", "1024", "AI生图默认尺寸", "ai", "N"), + ] + for key, val, desc, group, secret in defaults: + db.add(SystemConfig( + config_key=key, config_value=val, + description=desc, config_group=group, is_secret=secret + )) + db.commit() + return {"message": f"已初始化 {len(defaults)} 项默认配置"} + + +class TestConnectionRequest(BaseModel): + """API 连接测试请求""" + provider: str # siliconflow / volcengine + api_key: str + base_url: str + + +@router.post("/configs/test") +async def test_api_connection( + data: TestConnectionRequest, + admin: User = Depends(get_admin_user) +): + """测试 AI API 连接是否正常""" + try: + if data.provider == "siliconflow": + url = f"{data.base_url}/models" + headers = {"Authorization": f"Bearer {data.api_key}"} + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(url, headers=headers) + if resp.status_code == 200: + return {"message": "连接成功,API Key 有效"} + elif resp.status_code == 401: + raise HTTPException(status_code=400, detail="API Key 无效,请检查") + else: + raise HTTPException(status_code=400, detail=f"请求失败,状态码: {resp.status_code}") + elif data.provider == "volcengine": + url = f"{data.base_url}/models" + headers = {"Authorization": f"Bearer {data.api_key}"} + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(url, headers=headers) + if resp.status_code == 200: + return {"message": "连接成功,API Key 有效"} + elif resp.status_code == 401: + raise HTTPException(status_code=400, detail="API Key 无效,请检查") + else: + # 火山引擎可能返回其他状态码但连接本身成功 + return {"message": f"连接成功(状态码: {resp.status_code})"} + else: + raise HTTPException(status_code=400, detail=f"未知的服务提供商: {data.provider}") + except httpx.ConnectError: + raise HTTPException(status_code=400, detail="连接失败,请检查接口地址") + except httpx.TimeoutException: + raise HTTPException(status_code=400, detail="连接超时,请检查网络") + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=400, detail=f"测试失败: {str(e)}") + + +# ==================== 用户管理 ==================== + +@router.get("/users", response_model=AdminUserListResponse) +def get_users( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + keyword: str = Query(None, description="搜索用户名/昵称"), + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """获取用户列表""" + query = db.query(User) + if keyword: + query = query.filter( + or_(User.username.like(f"%{keyword}%"), User.nickname.like(f"%{keyword}%")) + ) + + total = query.count() + users = query.order_by(User.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all() + + items = [] + for u in users: + design_count = db.query(func.count(Design.id)).filter(Design.user_id == u.id).scalar() or 0 + items.append(AdminUserResponse( + id=u.id, username=u.username, nickname=u.nickname, + phone=u.phone, is_admin=u.is_admin, + created_at=u.created_at, design_count=design_count + )) + + return AdminUserListResponse(items=items, total=total, page=page, page_size=page_size) + + +@router.put("/users/{user_id}/admin") +def set_user_admin( + user_id: int, + data: AdminSetAdmin, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """设置/取消管理员""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + if user.id == admin.id: + raise HTTPException(status_code=400, detail="不能修改自己的管理员状态") + user.is_admin = data.is_admin + db.commit() + return {"message": f"用户 {user.username} {'已设为管理员' if data.is_admin else '已取消管理员'}"} + + +@router.delete("/users/{user_id}") +def delete_user( + user_id: int, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """删除用户""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + if user.id == admin.id: + raise HTTPException(status_code=400, detail="不能删除自己") + if user.is_admin: + raise HTTPException(status_code=400, detail="不能删除其他管理员") + db.delete(user) + db.commit() + return {"message": "用户已删除"} + + +# ==================== 品类管理 ==================== + +@router.get("/categories") +def get_categories( + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """获取所有品类(含子类型和颜色)""" + categories = db.query(Category).order_by(Category.sort_order).all() + result = [] + for cat in categories: + result.append({ + "id": cat.id, + "name": cat.name, + "icon": cat.icon, + "sort_order": cat.sort_order, + "flow_type": cat.flow_type, + "sub_types": [{"id": st.id, "name": st.name, "description": st.description, + "preview_image": st.preview_image, "sort_order": st.sort_order} + for st in sorted(cat.sub_types, key=lambda x: x.sort_order)], + "colors": [{"id": c.id, "name": c.name, "hex_code": c.hex_code, "sort_order": c.sort_order} + for c in sorted(cat.colors, key=lambda x: x.sort_order)] + }) + return result + + +@router.post("/categories") +def create_category( + data: CategoryCreate, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """创建品类""" + cat = Category(name=data.name, icon=data.icon, sort_order=data.sort_order, flow_type=data.flow_type) + db.add(cat) + db.commit() + db.refresh(cat) + return {"id": cat.id, "message": "品类创建成功"} + + +@router.put("/categories/{cat_id}") +def update_category( + cat_id: int, + data: CategoryUpdate, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """更新品类""" + cat = db.query(Category).filter(Category.id == cat_id).first() + if not cat: + raise HTTPException(status_code=404, detail="品类不存在") + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(cat, field, value) + db.commit() + return {"message": "品类更新成功"} + + +@router.delete("/categories/{cat_id}") +def delete_category( + cat_id: int, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """删除品类(级联删除子类型和颜色)""" + cat = db.query(Category).filter(Category.id == cat_id).first() + if not cat: + raise HTTPException(status_code=404, detail="品类不存在") + # 检查是否有关联设计 + design_count = db.query(func.count(Design.id)).filter(Design.category_id == cat_id).scalar() + if design_count > 0: + raise HTTPException(status_code=400, detail=f"品类下有 {design_count} 个设计,无法删除") + # 删除子类型和颜色 + db.query(SubType).filter(SubType.category_id == cat_id).delete() + db.query(Color).filter(Color.category_id == cat_id).delete() + db.delete(cat) + db.commit() + return {"message": "品类已删除"} + + +# -- 子类型 -- +@router.post("/sub-types") +def create_sub_type( + data: SubTypeCreate, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """创建子类型""" + cat = db.query(Category).filter(Category.id == data.category_id).first() + if not cat: + raise HTTPException(status_code=404, detail="品类不存在") + st = SubType(category_id=data.category_id, name=data.name, + description=data.description, preview_image=data.preview_image, + sort_order=data.sort_order) + db.add(st) + db.commit() + db.refresh(st) + return {"id": st.id, "message": "子类型创建成功"} + + +@router.put("/sub-types/{st_id}") +def update_sub_type( + st_id: int, + data: SubTypeUpdate, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """更新子类型""" + st = db.query(SubType).filter(SubType.id == st_id).first() + if not st: + raise HTTPException(status_code=404, detail="子类型不存在") + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(st, field, value) + db.commit() + return {"message": "子类型更新成功"} + + +@router.delete("/sub-types/{st_id}") +def delete_sub_type( + st_id: int, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """删除子类型""" + st = db.query(SubType).filter(SubType.id == st_id).first() + if not st: + raise HTTPException(status_code=404, detail="子类型不存在") + db.delete(st) + db.commit() + return {"message": "子类型已删除"} + + +# -- 颜色 -- +@router.post("/colors") +def create_color( + data: ColorCreate, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """创建颜色""" + cat = db.query(Category).filter(Category.id == data.category_id).first() + if not cat: + raise HTTPException(status_code=404, detail="品类不存在") + color = Color(category_id=data.category_id, name=data.name, + hex_code=data.hex_code, sort_order=data.sort_order) + db.add(color) + db.commit() + db.refresh(color) + return {"id": color.id, "message": "颜色创建成功"} + + +@router.put("/colors/{color_id}") +def update_color( + color_id: int, + data: ColorUpdate, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """更新颜色""" + color = db.query(Color).filter(Color.id == color_id).first() + if not color: + raise HTTPException(status_code=404, detail="颜色不存在") + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(color, field, value) + db.commit() + return {"message": "颜色更新成功"} + + +@router.delete("/colors/{color_id}") +def delete_color( + color_id: int, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """删除颜色""" + color = db.query(Color).filter(Color.id == color_id).first() + if not color: + raise HTTPException(status_code=404, detail="颜色不存在") + db.delete(color) + db.commit() + return {"message": "颜色已删除"} + + +# ==================== 设计管理 ==================== + +@router.get("/designs", response_model=AdminDesignListResponse) +def get_all_designs( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + user_id: int = Query(None, description="按用户筛选"), + status_filter: str = Query(None, alias="status", description="按状态筛选"), + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """获取所有设计列表""" + query = db.query(Design) + if user_id: + query = query.filter(Design.user_id == user_id) + if status_filter: + query = query.filter(Design.status == status_filter) + + total = query.count() + designs = query.order_by(Design.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all() + + items = [] + for d in designs: + items.append({ + "id": d.id, + "user_id": d.user_id, + "username": d.user.username if d.user else None, + "category_name": d.category.name if d.category else None, + "sub_type_name": d.sub_type.name if d.sub_type else None, + "color_name": d.color.name if d.color else None, + "prompt": d.prompt, + "image_url": d.image_url, + "status": d.status, + "created_at": d.created_at.isoformat() if d.created_at else None, + }) + + return AdminDesignListResponse(items=items, total=total, page=page, page_size=page_size) + + +@router.delete("/designs/{design_id}") +def admin_delete_design( + design_id: int, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """管理员删除任意设计""" + design = db.query(Design).filter(Design.id == design_id).first() + if not design: + raise HTTPException(status_code=404, detail="设计不存在") + db.delete(design) + db.commit() + return {"message": "设计已删除"} + + +# ==================== 提示词管理 ==================== + +@router.get("/prompt-templates") +def get_prompt_templates( + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """获取所有提示词模板""" + templates = db.query(PromptTemplate).order_by(PromptTemplate.template_key).all() + return [PromptTemplateItem.model_validate(t) for t in templates] + + +@router.put("/prompt-templates/{template_id}") +def update_prompt_template( + template_id: int, + data: PromptTemplateUpdate, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """更新提示词模板""" + tpl = db.query(PromptTemplate).filter(PromptTemplate.id == template_id).first() + if not tpl: + raise HTTPException(status_code=404, detail="模板不存在") + tpl.template_value = data.template_value + if data.description is not None: + tpl.description = data.description + db.commit() + return {"message": f"模板 '{tpl.template_key}' 更新成功"} + + +@router.get("/prompt-mappings") +def get_prompt_mappings( + mapping_type: str = Query(None, description="按映射类型筛选"), + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """获取提示词映射列表""" + query = db.query(PromptMapping) + if mapping_type: + query = query.filter(PromptMapping.mapping_type == mapping_type) + mappings = query.order_by(PromptMapping.mapping_type, PromptMapping.sort_order).all() + return [PromptMappingItem.model_validate(m) for m in mappings] + + +@router.get("/prompt-mappings/types") +def get_mapping_types( + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """获取所有映射类型及其数量""" + from sqlalchemy import distinct + types = db.query( + PromptMapping.mapping_type, + func.count(PromptMapping.id) + ).group_by(PromptMapping.mapping_type).all() + return [{"type": t, "count": c, "label": { + "category": "品类", "color": "颜色", "view": "视角", + "carving": "雕刻工艺", "style": "设计风格", "motif": "题材纹样", + "finish": "表面处理", "scene": "用途场景", "sub_type": "子类型" + }.get(t, t)} for t, c in types] + + +@router.post("/prompt-mappings") +def create_prompt_mapping( + data: PromptMappingCreate, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """创建提示词映射""" + # 检查重复 + existing = db.query(PromptMapping).filter( + PromptMapping.mapping_type == data.mapping_type, + PromptMapping.cn_key == data.cn_key + ).first() + if existing: + raise HTTPException(status_code=400, detail=f"映射 '{data.cn_key}' 已存在") + mapping = PromptMapping( + mapping_type=data.mapping_type, + cn_key=data.cn_key, + en_value=data.en_value, + sort_order=data.sort_order + ) + db.add(mapping) + db.commit() + db.refresh(mapping) + return {"id": mapping.id, "message": "映射创建成功"} + + +@router.put("/prompt-mappings/{mapping_id}") +def update_prompt_mapping( + mapping_id: int, + data: PromptMappingUpdate, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """更新提示词映射""" + mapping = db.query(PromptMapping).filter(PromptMapping.id == mapping_id).first() + if not mapping: + raise HTTPException(status_code=404, detail="映射不存在") + for field, value in data.model_dump(exclude_unset=True).items(): + setattr(mapping, field, value) + db.commit() + return {"message": "映射更新成功"} + + +@router.delete("/prompt-mappings/{mapping_id}") +def delete_prompt_mapping( + mapping_id: int, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """删除提示词映射""" + mapping = db.query(PromptMapping).filter(PromptMapping.id == mapping_id).first() + if not mapping: + raise HTTPException(status_code=404, detail="映射不存在") + db.delete(mapping) + db.commit() + return {"message": "映射已删除"} + + +@router.post("/prompt-preview") +def preview_prompt( + params: dict, + db: Session = Depends(get_db), + admin: User = Depends(get_admin_user) +): + """预览提示词生成结果""" + from ..services.prompt_builder import build_prompt + try: + prompt = build_prompt( + category_name=params.get("category_name", "牌子"), + view_name=params.get("view_name", "效果图"), + sub_type_name=params.get("sub_type_name"), + color_name=params.get("color_name"), + user_prompt=params.get("user_prompt"), + carving_technique=params.get("carving_technique"), + design_style=params.get("design_style"), + motif=params.get("motif"), + size_spec=params.get("size_spec"), + surface_finish=params.get("surface_finish"), + usage_scene=params.get("usage_scene"), + ) + return {"prompt": prompt} + except Exception as e: + raise HTTPException(status_code=400, detail=f"提示词生成失败: {str(e)}") diff --git a/backend/app/routers/designs.py b/backend/app/routers/designs.py index 4487cc7..d58fdb4 100644 --- a/backend/app/routers/designs.py +++ b/backend/app/routers/designs.py @@ -9,7 +9,7 @@ from sqlalchemy.orm import Session from ..database import get_db from ..models import User, Design -from ..schemas import DesignCreate, DesignResponse, DesignListResponse +from ..schemas import DesignCreate, DesignResponse, DesignListResponse, DesignImageResponse from ..utils.deps import get_current_user from ..services import design_service @@ -18,6 +18,21 @@ router = APIRouter(prefix="/api/designs", tags=["设计"]) def design_to_response(design: Design) -> DesignResponse: """将 Design 模型转换为响应格式""" + # 构建多视角图片列表 + images = [] + if hasattr(design, 'images') and design.images: + images = [ + DesignImageResponse( + id=img.id, + view_name=img.view_name, + image_url=img.image_url, + model_used=img.model_used, + prompt_used=img.prompt_used, + sort_order=img.sort_order, + ) + for img in design.images + ] + return DesignResponse( id=design.id, user_id=design.user_id, @@ -51,6 +66,7 @@ def design_to_response(design: Design) -> DesignResponse: surface_finish=design.surface_finish, usage_scene=design.usage_scene, image_url=design.image_url, + images=images, status=design.status, created_at=design.created_at, updated_at=design.updated_at @@ -58,17 +74,17 @@ def design_to_response(design: Design) -> DesignResponse: @router.post("/generate", response_model=DesignResponse) -def generate_design( +async def generate_design( design_data: DesignCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): """ - 提交设计生成请求 + 提交设计生成请求(异步,支持 AI 多视角生图) 需要认证 """ try: - design = design_service.create_design( + design = await design_service.create_design_async( db=db, user_id=current_user.id, design_data=design_data diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 4c77ff6..f32e604 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -4,7 +4,7 @@ Pydantic Schemas """ from .user import UserCreate, UserLogin, UserResponse, Token, UserUpdate, PasswordChange from .category import CategoryResponse, SubTypeResponse, ColorResponse -from .design import DesignCreate, DesignResponse, DesignListResponse +from .design import DesignCreate, DesignResponse, DesignListResponse, DesignImageResponse __all__ = [ # User schemas @@ -22,4 +22,5 @@ __all__ = [ "DesignCreate", "DesignResponse", "DesignListResponse", + "DesignImageResponse", ] diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py new file mode 100644 index 0000000..6d4895e --- /dev/null +++ b/backend/app/schemas/admin.py @@ -0,0 +1,173 @@ +""" +管理后台相关 Pydantic Schemas +""" +from pydantic import BaseModel, Field +from datetime import datetime +from typing import Optional, List + + +# ============ 系统配置 ============ +class SystemConfigItem(BaseModel): + """单个配置项""" + config_key: str + config_value: Optional[str] = None + description: Optional[str] = None + config_group: str = "general" + is_secret: str = "N" + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class SystemConfigUpdate(BaseModel): + """批量更新配置""" + configs: dict = Field(..., description="键值对: {config_key: config_value}") + + +class SystemConfigResponse(BaseModel): + """配置列表响应""" + items: List[SystemConfigItem] + + +# ============ 用户管理 ============ +class AdminUserResponse(BaseModel): + """管理端用户信息""" + id: int + username: str + nickname: Optional[str] = None + phone: Optional[str] = None + is_admin: bool = False + created_at: datetime + design_count: int = 0 + + class Config: + from_attributes = True + + +class AdminUserListResponse(BaseModel): + """用户列表响应""" + items: List[AdminUserResponse] + total: int + page: int + page_size: int + + +class AdminSetAdmin(BaseModel): + """设置管理员""" + is_admin: bool + + +# ============ 品类管理 ============ +class CategoryCreate(BaseModel): + """创建品类""" + name: str = Field(..., max_length=50) + icon: Optional[str] = Field(None, max_length=255) + sort_order: int = 0 + flow_type: str = Field("full", max_length=20) + + +class CategoryUpdate(BaseModel): + """更新品类""" + name: Optional[str] = Field(None, max_length=50) + icon: Optional[str] = Field(None, max_length=255) + sort_order: Optional[int] = None + flow_type: Optional[str] = Field(None, max_length=20) + + +class SubTypeCreate(BaseModel): + """创建子类型""" + category_id: int + name: str = Field(..., max_length=50) + description: Optional[str] = Field(None, max_length=255) + preview_image: Optional[str] = Field(None, max_length=255) + sort_order: int = 0 + + +class SubTypeUpdate(BaseModel): + """更新子类型""" + name: Optional[str] = Field(None, max_length=50) + description: Optional[str] = Field(None, max_length=255) + preview_image: Optional[str] = Field(None, max_length=255) + sort_order: Optional[int] = None + + +class ColorCreate(BaseModel): + """创建颜色""" + category_id: int + name: str = Field(..., max_length=50) + hex_code: Optional[str] = Field(None, max_length=10) + sort_order: int = 0 + + +class ColorUpdate(BaseModel): + """更新颜色""" + name: Optional[str] = Field(None, max_length=50) + hex_code: Optional[str] = Field(None, max_length=10) + sort_order: Optional[int] = None + + +# ============ 设计管理 ============ +class AdminDesignListResponse(BaseModel): + """管理端设计列表""" + items: list + total: int + page: int + page_size: int + + +# ============ 提示词管理 ============ +class PromptTemplateItem(BaseModel): + """提示词模板""" + id: Optional[int] = None + template_key: str + template_value: str + description: Optional[str] = None + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class PromptTemplateUpdate(BaseModel): + """更新提示词模板""" + template_value: str + description: Optional[str] = None + + +class PromptMappingItem(BaseModel): + """提示词映射""" + id: Optional[int] = None + mapping_type: str + cn_key: str + en_value: str + sort_order: int = 0 + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class PromptMappingCreate(BaseModel): + """创建提示词映射""" + mapping_type: str + cn_key: str + en_value: str + sort_order: int = 0 + + +class PromptMappingUpdate(BaseModel): + """更新提示词映射""" + cn_key: Optional[str] = None + en_value: Optional[str] = None + sort_order: Optional[int] = None + + +# ============ 仪表盘 ============ +class DashboardStats(BaseModel): + """仪表盘统计""" + total_users: int + total_designs: int + total_categories: int + today_designs: int + today_users: int diff --git a/backend/app/schemas/design.py b/backend/app/schemas/design.py index e8c64f6..f2f93bf 100644 --- a/backend/app/schemas/design.py +++ b/backend/app/schemas/design.py @@ -8,6 +8,20 @@ from typing import Optional, List from .category import CategoryResponse, SubTypeResponse, ColorResponse +class DesignImageResponse(BaseModel): + """设计图片响应(单张视角图)""" + id: int + view_name: str + image_url: Optional[str] = None + model_used: Optional[str] = None + prompt_used: Optional[str] = None + sort_order: int = 0 + + class Config: + from_attributes = True + protected_namespaces = () + + class DesignCreate(BaseModel): """创建设计请求""" category_id: int = Field(..., description="品类ID") @@ -37,6 +51,7 @@ class DesignResponse(BaseModel): surface_finish: Optional[str] = None usage_scene: Optional[str] = None image_url: Optional[str] = None + images: List[DesignImageResponse] = [] status: str created_at: datetime updated_at: datetime diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 68df130..1526587 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -26,6 +26,7 @@ class UserResponse(BaseModel): nickname: Optional[str] = None phone: Optional[str] = None avatar: Optional[str] = None + is_admin: bool = False created_at: datetime class Config: diff --git a/backend/app/services/ai_generator.py b/backend/app/services/ai_generator.py new file mode 100644 index 0000000..491de50 --- /dev/null +++ b/backend/app/services/ai_generator.py @@ -0,0 +1,144 @@ +""" +AI 生图服务 +支持双模型:SiliconFlow FLUX.1 [dev] 和 火山引擎 Seedream 4.5 +""" +import os +import uuid +import logging +import httpx +from typing import Optional + +from ..config import settings +from .config_service import get_ai_config + +logger = logging.getLogger(__name__) + +# 超时设置(秒) +REQUEST_TIMEOUT = 90 +# 最大重试次数 +MAX_RETRIES = 3 + + +async def _call_siliconflow(prompt: str, size: int = 1024, ai_config: dict = None) -> str: + """ + 调用 SiliconFlow FLUX.1 [dev] 生图 API + + Returns: + 远程图片 URL + """ + cfg = ai_config or get_ai_config() + url = f"{cfg['SILICONFLOW_BASE_URL']}/images/generations" + headers = { + "Authorization": f"Bearer {cfg['SILICONFLOW_API_KEY']}", + "Content-Type": "application/json", + } + payload = { + "model": "black-forest-labs/FLUX.1-dev", + "prompt": prompt, + "image_size": f"{size}x{size}", + "num_inference_steps": 20, + } + + async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: + resp = await client.post(url, json=payload, headers=headers) + resp.raise_for_status() + data = resp.json() + + # SiliconFlow 响应格式: {"images": [{"url": "https://..."}]} + images = data.get("images", []) + if not images: + raise ValueError("SiliconFlow 返回空图片列表") + return images[0]["url"] + + +async def _call_seedream(prompt: str, size: int = 1024, ai_config: dict = None) -> str: + """ + 调用火山引擎 Seedream 4.5 生图 API + + Returns: + 远程图片 URL + """ + cfg = ai_config or get_ai_config() + url = f"{cfg['VOLCENGINE_BASE_URL']}/images/generations" + headers = { + "Authorization": f"Bearer {cfg['VOLCENGINE_API_KEY']}", + "Content-Type": "application/json", + } + payload = { + "model": "doubao-seedream-4.5-t2i-250528", + "prompt": prompt, + "size": f"{size}x{size}", + "response_format": "url", + } + + async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client: + resp = await client.post(url, json=payload, headers=headers) + resp.raise_for_status() + data = resp.json() + + # Seedream 响应格式: {"data": [{"url": "https://..."}]} + items = data.get("data", []) + if not items: + raise ValueError("Seedream 返回空图片列表") + return items[0]["url"] + + +async def generate_image(prompt: str, model: Optional[str] = None) -> str: + """ + 统一生图接口,带重试机制 + + Args: + prompt: 英文提示词 + model: 模型名称 (flux-dev / seedream-4.5),为空则使用配置默认值 + + Returns: + 远程图片 URL + + Raises: + Exception: 所有重试失败后抛出 + """ + ai_config = get_ai_config() + model = model or ai_config.get("AI_IMAGE_MODEL", "flux-dev") + size = ai_config.get("AI_IMAGE_SIZE", 1024) + + last_error: Optional[Exception] = None + for attempt in range(1, MAX_RETRIES + 1): + try: + if model == "seedream-4.5": + image_url = await _call_seedream(prompt, size, ai_config) + else: + image_url = await _call_siliconflow(prompt, size, ai_config) + logger.info(f"AI 生图成功 (model={model}, attempt={attempt})") + return image_url + except Exception as e: + last_error = e + logger.warning(f"AI 生图失败 (model={model}, attempt={attempt}/{MAX_RETRIES}): {e}") + if attempt < MAX_RETRIES: + import asyncio + await asyncio.sleep(2 * attempt) # 指数退避 + + raise RuntimeError(f"AI 生图在 {MAX_RETRIES} 次重试后仍然失败: {last_error}") + + +async def download_and_save(image_url: str, save_path: str) -> str: + """ + 下载远程图片并保存到本地 + + Args: + image_url: 远程图片 URL + save_path: 本地保存路径(如 uploads/designs/1001_效果图.png) + + Returns: + 本地文件相对路径(以 / 开头,如 /uploads/designs/1001_效果图.png) + """ + # 确保目录存在 + os.makedirs(os.path.dirname(save_path), exist_ok=True) + + async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client: + resp = await client.get(image_url) + resp.raise_for_status() + with open(save_path, "wb") as f: + f.write(resp.content) + + logger.info(f"图片已下载保存: {save_path}") + return f"/{save_path}" diff --git a/backend/app/services/config_service.py b/backend/app/services/config_service.py new file mode 100644 index 0000000..2cfa8a6 --- /dev/null +++ b/backend/app/services/config_service.py @@ -0,0 +1,58 @@ +""" +配置服务 +优先从数据库 system_configs 表读取配置,数据库无值时回退到 .env +""" +import logging +from typing import Optional +from sqlalchemy.orm import Session + +from ..database import SessionLocal +from ..models.system_config import SystemConfig +from ..config import settings as env_settings + +logger = logging.getLogger(__name__) + + +def get_config_value(key: str, default: Optional[str] = None) -> Optional[str]: + """ + 获取配置值(数据库优先,.env 兜底) + + Args: + key: 配置键名(如 SILICONFLOW_API_KEY) + default: 默认值 + Returns: + 配置值字符串 + """ + # 1. 尝试从数据库读取 + try: + db = SessionLocal() + try: + config = db.query(SystemConfig).filter(SystemConfig.config_key == key).first() + if config and config.config_value: + return config.config_value + finally: + db.close() + except Exception as e: + logger.warning(f"从数据库读取配置 {key} 失败: {e}") + + # 2. 回退到 .env / Settings + env_value = getattr(env_settings, key, None) + if env_value is not None and env_value != "": + return str(env_value) + + return default + + +def get_ai_config() -> dict: + """ + 获取所有 AI 相关配置 + 返回字典,方便 ai_generator 使用 + """ + return { + "SILICONFLOW_API_KEY": get_config_value("SILICONFLOW_API_KEY", ""), + "SILICONFLOW_BASE_URL": get_config_value("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1"), + "VOLCENGINE_API_KEY": get_config_value("VOLCENGINE_API_KEY", ""), + "VOLCENGINE_BASE_URL": get_config_value("VOLCENGINE_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3"), + "AI_IMAGE_MODEL": get_config_value("AI_IMAGE_MODEL", "flux-dev"), + "AI_IMAGE_SIZE": int(get_config_value("AI_IMAGE_SIZE", "1024")), + } diff --git a/backend/app/services/design_service.py b/backend/app/services/design_service.py index e0bda1e..73c8e45 100644 --- a/backend/app/services/design_service.py +++ b/backend/app/services/design_service.py @@ -1,40 +1,56 @@ """ 设计服务 -处理设计相关的业务逻辑 +处理设计相关的业务逻辑,支持 AI 多视角生图 + mock 降级 """ import os +import logging from typing import List, Optional, Tuple from sqlalchemy.orm import Session from sqlalchemy import desc -from ..models import Design, Category, SubType, Color +from ..models import Design, DesignImage, Category, SubType, Color from ..schemas import DesignCreate from ..config import settings from .mock_generator import generate_mock_design +from .prompt_builder import get_views_for_category, build_prompt +from . import ai_generator + +logger = logging.getLogger(__name__) -def create_design(db: Session, user_id: int, design_data: DesignCreate) -> Design: +def _has_ai_key() -> bool: + """检查是否配置了 AI API Key""" + model = settings.AI_IMAGE_MODEL + if model == "seedream-4.5": + return bool(settings.VOLCENGINE_API_KEY) + return bool(settings.SILICONFLOW_API_KEY) + + +async def create_design_async(db: Session, user_id: int, design_data: DesignCreate) -> Design: """ - 创建设计记录 - - 1. 创建设计记录(status=generating) - 2. 调用 mock_generator 生成图片 - 3. 更新设计记录(status=completed, image_url) - 4. 返回设计对象 + 创建设计记录(异步版本,支持 AI 多视角生图) + + 流程: + 1. 创建 Design 记录(status=generating) + 2. 获取品类视角列表 + 3. 循环每个视角:构建 prompt → 调用 AI 生图 → 下载保存 → 创建 DesignImage + 4. 第一张效果图 URL 存入 design.image_url(兼容旧逻辑) + 5. 更新 status=completed + 6. 失败时降级到 mock_generator """ # 获取关联信息 category = db.query(Category).filter(Category.id == design_data.category_id).first() if not category: raise ValueError(f"品类不存在: {design_data.category_id}") - + sub_type = None if design_data.sub_type_id: sub_type = db.query(SubType).filter(SubType.id == design_data.sub_type_id).first() - + color = None if design_data.color_id: color = db.query(Color).filter(Color.id == design_data.color_id).first() - + # 创建设计记录 design = Design( user_id=user_id, @@ -52,8 +68,109 @@ def create_design(db: Session, user_id: int, design_data: DesignCreate) -> Desig ) db.add(design) db.flush() # 获取 ID - - # 生成图片 + + # 尝试 AI 生图 + if _has_ai_key(): + try: + await _generate_ai_images(db, design, category, sub_type, color, design_data) + db.commit() + db.refresh(design) + return design + except Exception as e: + logger.error(f"AI 生图全部失败,降级到 mock: {e}") + db.rollback() + # 重新查询,因为 rollback 后 ORM 对象可能失效 + design = db.query(Design).filter(Design.id == design.id).first() + if not design: + # rollback 导致 design 也没了,重新创建 + design = Design( + user_id=user_id, + category_id=design_data.category_id, + sub_type_id=design_data.sub_type_id, + color_id=design_data.color_id, + prompt=design_data.prompt, + carving_technique=design_data.carving_technique, + design_style=design_data.design_style, + motif=design_data.motif, + size_spec=design_data.size_spec, + surface_finish=design_data.surface_finish, + usage_scene=design_data.usage_scene, + status="generating" + ) + db.add(design) + db.flush() + + # 降级到 mock 生成 + _generate_mock_fallback(db, design, category, sub_type, color, design_data) + db.commit() + db.refresh(design) + return design + + +async def _generate_ai_images( + db: Session, + design: Design, + category, + sub_type, + color, + design_data: DesignCreate, +) -> None: + """使用 AI 模型为每个视角生成图片""" + views = get_views_for_category(category.name) + model = settings.AI_IMAGE_MODEL + + for idx, view_name in enumerate(views): + # 构建 prompt + prompt_text = build_prompt( + category_name=category.name, + view_name=view_name, + sub_type_name=sub_type.name if sub_type else None, + color_name=color.name if color else None, + user_prompt=design_data.prompt, + carving_technique=design_data.carving_technique, + design_style=design_data.design_style, + motif=design_data.motif, + size_spec=design_data.size_spec, + surface_finish=design_data.surface_finish, + usage_scene=design_data.usage_scene, + ) + + # 调用 AI 生图 + remote_url = await ai_generator.generate_image(prompt_text, model) + + # 下载保存到本地 + save_path = os.path.join( + settings.UPLOAD_DIR, "designs", f"{design.id}_{view_name}.png" + ) + local_url = await ai_generator.download_and_save(remote_url, save_path) + + # 创建 DesignImage 记录 + design_image = DesignImage( + design_id=design.id, + view_name=view_name, + image_url=local_url, + model_used=model, + prompt_used=prompt_text, + sort_order=idx, + ) + db.add(design_image) + + # 第一张图(效果图)存入 design.image_url 兼容旧逻辑 + if idx == 0: + design.image_url = local_url + + design.status = "completed" + + +def _generate_mock_fallback( + db: Session, + design: Design, + category, + sub_type, + color, + design_data: DesignCreate, +) -> None: + """降级使用 mock 生成器""" save_path = os.path.join(settings.UPLOAD_DIR, "designs", f"{design.id}.png") image_url = generate_mock_design( category_name=category.name, @@ -68,13 +185,47 @@ def create_design(db: Session, user_id: int, design_data: DesignCreate) -> Desig surface_finish=design_data.surface_finish, usage_scene=design_data.usage_scene, ) - - # 更新设计记录 design.image_url = image_url design.status = "completed" + logger.info(f"Mock 降级生成完成: design_id={design.id}") + + +def create_design(db: Session, user_id: int, design_data: DesignCreate) -> Design: + """ + 同步版本创建设计(兼容旧调用,仅用 mock) + """ + category = db.query(Category).filter(Category.id == design_data.category_id).first() + if not category: + raise ValueError(f"品类不存在: {design_data.category_id}") + + sub_type = None + if design_data.sub_type_id: + sub_type = db.query(SubType).filter(SubType.id == design_data.sub_type_id).first() + + color = None + if design_data.color_id: + color = db.query(Color).filter(Color.id == design_data.color_id).first() + + design = Design( + user_id=user_id, + category_id=design_data.category_id, + sub_type_id=design_data.sub_type_id, + color_id=design_data.color_id, + prompt=design_data.prompt, + carving_technique=design_data.carving_technique, + design_style=design_data.design_style, + motif=design_data.motif, + size_spec=design_data.size_spec, + surface_finish=design_data.surface_finish, + usage_scene=design_data.usage_scene, + status="generating" + ) + db.add(design) + db.flush() + + _generate_mock_fallback(db, design, category, sub_type, color, design_data) db.commit() db.refresh(design) - return design @@ -132,16 +283,24 @@ def delete_design(db: Session, design_id: int, user_id: int) -> bool: if not design: return False - # 删除图片文件 + # 删除主图片文件 if design.image_url: - # image_url 格式: /uploads/designs/1001.png - # 转换为实际文件路径 file_path = design.image_url.lstrip("/") if os.path.exists(file_path): try: os.remove(file_path) except Exception: - pass # 忽略删除失败 + pass + + # 删除多视角图片文件 + for img in design.images: + if img.image_url: + fp = img.image_url.lstrip("/") + if os.path.exists(fp): + try: + os.remove(fp) + except Exception: + pass # 删除数据库记录 db.delete(design) diff --git a/backend/app/services/prompt_builder.py b/backend/app/services/prompt_builder.py new file mode 100644 index 0000000..704a685 --- /dev/null +++ b/backend/app/services/prompt_builder.py @@ -0,0 +1,164 @@ +""" +专业玉雕设计提示词构建器(数据库版) +从数据库 prompt_templates + prompt_mappings 读取配置,支持后台热更新 +""" +import logging +from typing import Optional, Dict, List + +from ..database import SessionLocal +from ..models.prompt_template import PromptTemplate, PromptMapping + +logger = logging.getLogger(__name__) + +# ============================================================ +# 品类视角配置(保留硬编码,因为与业务流程强关联) +# ============================================================ +CATEGORY_VIEWS: Dict[str, List[str]] = { + "牌子": ["效果图", "正面图", "背面图"], + "珠子": ["效果图", "正面图"], + "手把件": ["效果图", "正面图", "侧面图", "背面图"], + "雕刻件": ["效果图", "正面图", "侧面图", "背面图"], + "摆件": ["效果图", "正面图", "侧面图", "背面图"], + "手镯": ["效果图", "正面图", "侧面图"], + "耳钉": ["效果图", "正面图"], + "耳饰": ["效果图", "正面图"], + "手链": ["效果图", "正面图"], + "项链": ["效果图", "正面图"], + "戒指": ["效果图", "正面图", "侧面图"], + "表带": ["效果图", "正面图"], +} + + +def _load_mappings(mapping_type: str) -> Dict[str, str]: + """从数据库加载指定类型的映射字典""" + try: + db = SessionLocal() + try: + rows = db.query(PromptMapping).filter( + PromptMapping.mapping_type == mapping_type + ).order_by(PromptMapping.sort_order).all() + return {r.cn_key: r.en_value for r in rows} + finally: + db.close() + except Exception as e: + logger.warning(f"加载映射 {mapping_type} 失败: {e}") + return {} + + +def _load_template(template_key: str, default: str = "") -> str: + """从数据库加载模板""" + try: + db = SessionLocal() + try: + tpl = db.query(PromptTemplate).filter( + PromptTemplate.template_key == template_key + ).first() + if tpl: + return tpl.template_value + finally: + db.close() + except Exception as e: + logger.warning(f"加载模板 {template_key} 失败: {e}") + return default + + +def get_views_for_category(category_name: str) -> List[str]: + """获取品类对应的视角列表""" + return CATEGORY_VIEWS.get(category_name, ["效果图", "正面图"]) + + +def build_prompt( + category_name: str, + view_name: str, + sub_type_name: Optional[str] = None, + color_name: Optional[str] = None, + user_prompt: Optional[str] = None, + carving_technique: Optional[str] = None, + design_style: Optional[str] = None, + motif: Optional[str] = None, + size_spec: Optional[str] = None, + surface_finish: Optional[str] = None, + usage_scene: Optional[str] = None, +) -> str: + """ + 构建专业英文生图提示词(从数据库读取映射和模板) + + 业务逻辑:用户参数 → 中英映射 → 填入模板 → 最终prompt + """ + # 从数据库加载所有映射 + category_map = _load_mappings("category") + color_map = _load_mappings("color") + view_map = _load_mappings("view") + carving_map = _load_mappings("carving") + style_map = _load_mappings("style") + motif_map = _load_mappings("motif") + finish_map = _load_mappings("finish") + scene_map = _load_mappings("scene") + sub_type_map = _load_mappings("sub_type") + + # 加载模板 + quality_suffix = _load_template("quality_suffix", + "professional jewelry product photography, studio lighting setup, pure white background, ultra-detailed, sharp focus, 8K resolution, photorealistic rendering, high-end commercial quality") + default_color = _load_template("default_color", + "natural Hetian nephrite jade with warm luster") + + # 构建各部分 + parts = [] + + # 1. 品类主体 + subject = category_map.get(category_name, f"Chinese Hetian nephrite jade {category_name}") + parts.append(subject) + + # 2. 子类型 + if sub_type_name: + sub_detail = sub_type_map.get(sub_type_name, sub_type_name) + parts.append(sub_detail) + + # 3. 颜色 + if color_name: + color_desc = color_map.get(color_name, f"{color_name} colored nephrite jade") + parts.append(color_desc) + else: + parts.append(default_color) + + # 4. 题材纹样 + if motif: + motif_desc = motif_map.get(motif, f"{motif} themed design") + parts.append(f"featuring {motif_desc}") + + # 5. 雕刻工艺 + if carving_technique: + tech_desc = carving_map.get(carving_technique, carving_technique) + parts.append(tech_desc) + + # 6. 设计风格 + if design_style: + style_desc = style_map.get(design_style, design_style) + parts.append(style_desc) + + # 7. 表面处理 + if surface_finish: + finish_desc = finish_map.get(surface_finish, surface_finish) + parts.append(finish_desc) + + # 8. 用途场景 + if usage_scene: + scene_desc = scene_map.get(usage_scene, usage_scene) + parts.append(scene_desc) + + # 9. 尺寸 + if size_spec: + parts.append(f"size approximately {size_spec}") + + # 10. 用户描述 + if user_prompt: + parts.append(f"design concept: {user_prompt}") + + # 11. 视角 + view_desc = view_map.get(view_name, "three-quarter view") + parts.append(view_desc) + + # 12. 质量后缀 + parts.append(quality_suffix) + + return ", ".join(parts) diff --git a/backend/app/utils/deps.py b/backend/app/utils/deps.py index 38dddcd..1e0db81 100644 --- a/backend/app/utils/deps.py +++ b/backend/app/utils/deps.py @@ -56,3 +56,19 @@ def get_current_user( raise credentials_exception return user + + +def get_admin_user( + current_user: User = Depends(get_current_user) +) -> User: + """ + 获取当前管理员用户 + + 验证当前用户是否为管理员,非管理员抛出 403 + """ + if not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="权限不足,需要管理员权限" + ) + return current_user diff --git a/backend/init_prompt_data.py b/backend/init_prompt_data.py new file mode 100644 index 0000000..f0fee55 --- /dev/null +++ b/backend/init_prompt_data.py @@ -0,0 +1,203 @@ +""" +初始化默认提示词模板和映射数据 +将 prompt_builder.py 中硬编码的数据迁移到数据库 +""" +import pymysql + +conn = pymysql.connect(host='localhost', port=13306, user='yssjs', password='yssjs', database='yuzong', charset='utf8mb4') +cursor = conn.cursor() + +# ===== 1. 提示词模板 ===== +templates = [ + ("main_template", + "{subject}, {sub_type}, {color}, {motif}, {carving}, {style}, {finish}, {scene}, {size}, {user_prompt}, {view}, {quality}", + "主提示词模板 - 用变量拼接最终prompt。可用变量: {subject}品类主体, {sub_type}子类型, {color}颜色, {motif}题材, {carving}工艺, {style}风格, {finish}表面处理, {scene}用途, {size}尺寸, {user_prompt}用户描述, {view}视角, {quality}质量后缀"), + ("quality_suffix", + "professional jewelry product photography, studio lighting setup, pure white background, ultra-detailed, sharp focus, 8K resolution, photorealistic rendering, high-end commercial quality", + "质量后缀标签 - 附加在prompt末尾的通用质量描述"), + ("default_color", + "natural Hetian nephrite jade with warm luster", + "未选择颜色时的默认颜色描述"), +] +for key, val, desc in templates: + cursor.execute( + "INSERT IGNORE INTO prompt_templates (template_key, template_value, description) VALUES (%s, %s, %s)", + (key, val, desc)) +print(f"✅ 插入 {len(templates)} 个提示词模板") + +# ===== 2. 品类映射 (category) ===== +categories = [ + ("牌子", "Chinese Hetian nephrite jade pendant plaque, rectangular tablet shape"), + ("珠子", "Chinese Hetian nephrite jade bead, perfectly round sphere"), + ("手把件", "Chinese Hetian nephrite jade hand piece (palm stone), ergonomic carved ornament for hand play"), + ("雕刻件", "Chinese Hetian nephrite jade carving, intricate sculptural artwork"), + ("摆件", "Chinese Hetian nephrite jade display sculpture, decorative art piece on wooden stand"), + ("手镯", "Chinese Hetian nephrite jade bangle bracelet, smooth circular form"), + ("耳钉", "Chinese Hetian nephrite jade stud earring, delicate small jewelry piece"), + ("耳饰", "Chinese Hetian nephrite jade drop earring, elegant dangling jewelry"), + ("手链", "Chinese Hetian nephrite jade bead bracelet, string of polished beads"), + ("项链", "Chinese Hetian nephrite jade necklace, elegant pendant on chain"), + ("戒指", "Chinese Hetian nephrite jade ring, polished jade mounted on band"), + ("表带", "Chinese Hetian nephrite jade watch strap, segmented jade links"), +] +for i, (cn, en) in enumerate(categories): + cursor.execute( + "INSERT IGNORE INTO prompt_mappings (mapping_type, cn_key, en_value, sort_order) VALUES (%s, %s, %s, %s)", + ("category", cn, en, i)) +print(f"✅ 插入 {len(categories)} 个品类映射") + +# ===== 3. 颜色映射 (color) ===== +colors = [ + ("白玉", "pure white nephrite jade, milky translucent, warm ivory tone"), + ("青白玉", "celadon-white nephrite jade, pale greenish-white, subtle cool tone"), + ("青玉", "celadon nephrite jade, muted sage green, natural earthy green"), + ("碧玉", "deep green jasper nephrite jade, rich forest green, vivid saturated"), + ("翠青", "emerald-tinted nephrite jade, fresh spring green with blue undertone"), + ("黄玉", "golden yellow nephrite jade, warm honey amber, rich golden hue"), + ("糖玉", "sugar-brown nephrite jade, warm caramel brown with reddish tint"), + ("墨玉", "ink-black nephrite jade, deep charcoal black, mysterious dark"), + ("藕粉", "lotus-pink nephrite jade, soft blush pink, delicate pastel rose"), + ("烟紫", "smoky purple nephrite jade, muted lavender grey, subtle violet"), + ("糖白", "sugar-white nephrite jade, creamy white with light brown edges"), +] +for i, (cn, en) in enumerate(colors): + cursor.execute( + "INSERT IGNORE INTO prompt_mappings (mapping_type, cn_key, en_value, sort_order) VALUES (%s, %s, %s, %s)", + ("color", cn, en, i)) +print(f"✅ 插入 {len(colors)} 个颜色映射") + +# ===== 4. 视角映射 (view) ===== +views = [ + ("效果图", "three-quarter view, 45-degree angle, hero shot, dramatic perspective showing depth and dimension"), + ("正面图", "front view, straight-on, flat lay centered, facing camera directly"), + ("侧面图", "side profile view, 90-degree lateral angle, showing thickness and contour"), + ("背面图", "back view, rear side, showing reverse surface and texture"), +] +for i, (cn, en) in enumerate(views): + cursor.execute( + "INSERT IGNORE INTO prompt_mappings (mapping_type, cn_key, en_value, sort_order) VALUES (%s, %s, %s, %s)", + ("view", cn, en, i)) +print(f"✅ 插入 {len(views)} 个视角映射") + +# ===== 5. 雕刻工艺映射 (carving) ===== +carvings = [ + ("浮雕", "relief carving with raised design emerging from surface"), + ("圆雕", "full three-dimensional round carving, sculptural in the round"), + ("镂空雕", "openwork pierced carving, intricate hollow cutout patterns"), + ("阴刻", "intaglio engraving, incised lines carved into the surface"), + ("线雕", "fine line engraving, delicate linear incised pattern"), + ("俏色雕", "qiaose color-play carving utilizing natural jade skin color contrast"), + ("薄意雕", "shallow thin-relief carving, subtle and understated surface design"), + ("素面", "plain polished surface, smooth minimalist finish without carving"), +] +for i, (cn, en) in enumerate(carvings): + cursor.execute( + "INSERT IGNORE INTO prompt_mappings (mapping_type, cn_key, en_value, sort_order) VALUES (%s, %s, %s, %s)", + ("carving", cn, en, i)) +print(f"✅ 插入 {len(carvings)} 个雕刻工艺映射") + +# ===== 6. 设计风格映射 (style) ===== +styles = [ + ("古典传统", "classical traditional Chinese style, antique aesthetic, heritage craftsmanship"), + ("新中式", "modern neo-Chinese style, contemporary Asian minimalism with traditional elements"), + ("写实", "realistic naturalistic style, lifelike detailed representation"), + ("抽象意境", "abstract artistic impression, fluid organic forms, poetic mood"), + ("极简素面", "ultra-minimalist clean design, sleek smooth surface, zen aesthetic"), +] +for i, (cn, en) in enumerate(styles): + cursor.execute( + "INSERT IGNORE INTO prompt_mappings (mapping_type, cn_key, en_value, sort_order) VALUES (%s, %s, %s, %s)", + ("style", cn, en, i)) +print(f"✅ 插入 {len(styles)} 个设计风格映射") + +# ===== 7. 题材纹样映射 (motif) ===== +motifs = [ + ("观音", "Guanyin Bodhisattva figure, serene Buddhist deity of mercy"), + ("弥勒", "Maitreya laughing Buddha, jovial happy Buddha figure"), + ("莲花", "lotus flower motif, sacred Buddhist lotus blossom petals"), + ("貔貅", "Pixiu mythical beast, Chinese fortune guardian creature"), + ("龙凤", "dragon and phoenix motif, imperial auspicious dual creatures"), + ("麒麟", "Qilin mythical unicorn, auspicious Chinese legendary beast"), + ("山水", "Chinese mountain and water landscape, shanshui scenery"), + ("花鸟", "flower and bird motif, traditional Chinese nature painting theme"), + ("人物", "human figure motif, classical Chinese character portrayal"), + ("回纹", "Greek key fret pattern, Chinese meander geometric border"), + ("如意", "Ruyi scepter motif, auspicious cloud-head wish-granting symbol"), + ("平安扣", "Ping'an buckle motif, smooth circular safety and peace symbol"), +] +for i, (cn, en) in enumerate(motifs): + cursor.execute( + "INSERT IGNORE INTO prompt_mappings (mapping_type, cn_key, en_value, sort_order) VALUES (%s, %s, %s, %s)", + ("motif", cn, en, i)) +print(f"✅ 插入 {len(motifs)} 个题材纹样映射") + +# ===== 8. 表面处理映射 (finish) ===== +finishes = [ + ("高光抛光", "high-gloss mirror polish, reflective glossy surface"), + ("亚光/哑光", "matte satin finish, soft non-reflective surface"), + ("磨砂", "frosted textured finish, fine granular surface"), + ("保留皮色", "natural jade skin preserved, raw russet-brown outer skin layer retained"), +] +for i, (cn, en) in enumerate(finishes): + cursor.execute( + "INSERT IGNORE INTO prompt_mappings (mapping_type, cn_key, en_value, sort_order) VALUES (%s, %s, %s, %s)", + ("finish", cn, en, i)) +print(f"✅ 插入 {len(finishes)} 个表面处理映射") + +# ===== 9. 用途场景映射 (scene) ===== +scenes = [ + ("日常佩戴", "designed for daily wear, comfortable and practical"), + ("收藏鉴赏", "museum-quality collector piece, exquisite showpiece"), + ("送礼婚庆", "premium gift piece, ceremonial and auspicious"), + ("把玩文玩", "tactile palm play piece, smooth hand-feel for meditation"), +] +for i, (cn, en) in enumerate(scenes): + cursor.execute( + "INSERT IGNORE INTO prompt_mappings (mapping_type, cn_key, en_value, sort_order) VALUES (%s, %s, %s, %s)", + ("scene", cn, en, i)) +print(f"✅ 插入 {len(scenes)} 个用途场景映射") + +# ===== 10. 子类型映射 (sub_type) ===== +sub_types = [ + ("二五牌", "2:5 ratio rectangular plaque"), + ("三角牌", "triangular shaped pendant"), + ("三五牌", "3:5 ratio rectangular plaque"), + ("四六牌", "4:6 ratio rectangular plaque"), + ("正方形", "square shaped plaque"), + ("椭圆形", "oval shaped plaque"), + ("平安镯", "flat interior round exterior classic bangle"), + ("福镯", "round interior round exterior full-round bangle"), + ("贵妃镯", "oval elliptical shape bangle fitting wrist contour"), + ("美人镯", "slim delicate thin bangle, elegant refined"), + ("方镯", "square cross-section angular bangle"), + ("雕花镯", "carved decorative pattern bangle"), + ("圆形耳钉", "round circular stud"), + ("水滴形耳钉", "teardrop shaped stud"), + ("方形耳钉", "square geometric stud"), + ("花朵形耳钉", "flower blossom shaped stud"), + ("心形耳钉", "heart shaped stud"), + ("几何形耳钉", "abstract geometric stud"), + ("耳环", "hoop earring"), + ("耳坠", "drop dangle earring"), + ("耳夹", "clip-on earring"), + ("流苏耳饰", "tassel fringe long earring"), + ("素面戒指", "plain smooth surface ring"), + ("镶嵌戒指", "metal-set mounted jade ring"), + ("雕花戒指", "carved decorative ring"), + ("扳指", "traditional archer thumb ring"), + ("指环", "simple band ring"), + ("锁骨链", "short collarbone chain necklace"), + ("吊坠项链", "pendant necklace with jade drop"), + ("串珠项链", "beaded jade strand necklace"), + ("编绳项链", "braided cord necklace with jade"), + ("毛衣链", "long sweater chain necklace"), +] +for i, (cn, en) in enumerate(sub_types): + cursor.execute( + "INSERT IGNORE INTO prompt_mappings (mapping_type, cn_key, en_value, sort_order) VALUES (%s, %s, %s, %s)", + ("sub_type", cn, en, i)) +print(f"✅ 插入 {len(sub_types)} 个子类型映射") + +conn.commit() +conn.close() +print("\n✅ 全部提示词数据初始化完成!") diff --git a/backend/requirements.txt b/backend/requirements.txt index ff62bc9..b600515 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,3 +11,4 @@ python-multipart==0.0.9 Pillow==10.2.0 pydantic[email]==2.6.1 pydantic-settings==2.1.0 +httpx==0.27.0 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e520d87..3d3d622 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,19 +1,23 @@ + + diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index e9a7bab..59b62a6 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -8,6 +8,7 @@