feat(ai): 支持双模型多视角AI设计生图与后台管理系统

- 实现AI多视角设计图生成功能,支持6个可选设计参数配置
- 集成SiliconFlow FLUX.1与火山引擎Seedream 4.5双模型切换
- 构建专业中文转英文prompt系统,提升AI生成质量
- 前端设计预览支持多视角切换与视角指示器展示
- 增加多视角设计图片DesignImage模型关联及存储
- 后端设计服务异步调用AI接口,失败时降级生成mock图
- 新增管理员后台管理路由及完整的权限校验机制
- 实现后台模块:仪表盘、系统配置、用户/品类/设计管理
- 配置数据库系统配置表,支持动态AI配置及热更新
- 增加用户管理员标识字段,管理后台登录鉴权支持
- 更新API接口支持多视角设计参数及后台管理接口
- 优化设计删除逻辑,删除多视角相关图片文件
- 前端新增管理后台页面与路由,布局样式独立分离
- 更新环境变量增加AI模型相关Key与参数配置说明
- 引入httpx异步HTTP客户端用于AI接口调用及图片下载
- README文档完善AI多视角生图与后台管理详细功能与流程说明
This commit is contained in:
2026-03-27 15:29:50 +08:00
parent e3ff55b4db
commit 032c43525a
41 changed files with 3756 additions and 81 deletions

110
README.md
View File

@@ -1,6 +1,6 @@
# 玉宗 - 珠宝设计大师 # 玉宗 - 珠宝设计大师
AI 驱动的珠宝玉石设计生成系统,支持 12 种玉石品类的智能设计图生成。 AI 驱动的珠宝玉石设计生成系统,支持 12 种玉石品类的智能设计图生成,双 AI 模型多视角生图,内置后台管理系统
## 功能特性 ## 功能特性
@@ -14,10 +14,31 @@ AI 驱动的珠宝玉石设计生成系统,支持 12 种玉石品类的智能
- **实现方式**:品类通过 `flow_type` 字段区分三种工作流程:`full`(选子类型,如牌子选牌型)、`size_color`(选尺寸+颜色,如珠子)、`simple`(直接设计);前端 `SubTypePanel` 组件根据 flow_type 动态渲染不同的选择界面 - **实现方式**:品类通过 `flow_type` 字段区分三种工作流程:`full`(选子类型,如牌子选牌型)、`size_color`(选尺寸+颜色,如珠子)、`simple`(直接设计);前端 `SubTypePanel` 组件根据 flow_type 动态渲染不同的选择界面
- **优点**:灵活的品类工作流适配不同产品特性,用户操作路径清晰 - **优点**:灵活的品类工作流适配不同产品特性,用户操作路径清晰
### 3. 设计图生成 ### 3. AI 多视角设计图生成
- **功能说明**:用户选择品类参数后输入设计描述系统生成 800×800 PNG 设计图 - **功能说明**:用户选择品类参数后,可配置 6 个可选设计参数(雕刻工艺、设计风格、题材纹样、尺寸规格、表面处理、用途场景),输入设计描述系统自动生成多视角设计图(每个品类 2~4 张不同视角)
- **实现方式**:后端 `mock_generator` 使用 Pillow 生成设计图包含品类信息、颜色映射中文颜色名→HEX、自动文字颜色对比计算、系统中文字体检测PingFang/STHeiti/DroidSansFallback设计记录先创建status=generating生成完成后更新为 completed - **双模型支持**
- **优点**:即时生成预览图,支持颜色定制;后续可替换为真实 AI 模型 - **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. 设计管理 ### 4. 设计管理
- **功能说明**:用户中心查看设计历史列表(分页),支持预览、下载、删除设计 - **功能说明**:用户中心查看设计历史列表(分页),支持预览、下载、删除设计
@@ -29,6 +50,17 @@ AI 驱动的珠宝玉石设计生成系统,支持 12 种玉石品类的智能
- **实现方式**:手机号唯一性校验,密码修改需验证旧密码;前端表单使用 Element Plus 表单验证 - **实现方式**:手机号唯一性校验,密码修改需验证旧密码;前端表单使用 Element Plus 表单验证
- **优点**:安全的密码修改流程,手机号去重保障数据一致性 - **优点**:安全的密码修改流程,手机号去重保障数据一致性
### 6. 后台管理系统
- **功能说明**:管理员专属后台,支持仪表盘、系统配置、用户管理、品类管理、设计管理五大模块
- **实现方式**
- **权限控制**:用户表 `is_admin` 字段 + `get_admin_user` 依赖注入,非管理员访问返回 403
- **系统配置**`system_configs` 表存储配置项,数据库配置优先于 .env 文件敏感信息API Key脱敏显示
- **品类管理**:支持品类、子类型、颜色的增删改查,含颜色拾色器
- **用户管理**:搜索、分页、设置/取消管理员、删除用户
- **设计管理**:查看所有用户设计,按状态筛选,管理员删除
- **前端独立布局**:左侧导航 + 内容区,管理后台与前台完全分离
- **优点**AI 配置可热更新(无需重启服务),品类数据可视化管理,用户权限分级控制
## 业务流程 ## 业务流程
### 整体流程图 ### 整体流程图
@@ -43,9 +75,10 @@ graph TB
D --> G[生成页 - 输入设计描述] D --> G[生成页 - 输入设计描述]
E --> G E --> G
F --> G F --> G
G --> H[提交生成请求] G --> G2[选择可选参数]
H --> I[后端生成设计图] G2 --> H[提交生成请求]
I --> J[预览设计图] H --> I[后端 AI 多视角生图]
I --> J[预览多视角设计图]
J --> K{用户操作} J --> K{用户操作}
K -->|下载| L[下载 PNG 文件] K -->|下载| L[下载 PNG 文件]
K -->|重新生成| G K -->|重新生成| G
@@ -58,18 +91,20 @@ graph TB
1. **用户认证**:注册时检查用户名唯一性 → 密码 bcrypt 加密 → 创建用户记录 → 注册成功后自动登录 → 获取 JWT Token 存储到 localStorage 1. **用户认证**:注册时检查用户名唯一性 → 密码 bcrypt 加密 → 创建用户记录 → 注册成功后自动登录 → 获取 JWT Token 存储到 localStorage
2. **品类选择**:进入设计页自动加载品类列表 → 左侧导航选择品类 → 根据 flow_type 加载对应的子类型/颜色数据 → 右侧面板显示选择界面 2. **品类选择**:进入设计页自动加载品类列表 → 左侧导航选择品类 → 根据 flow_type 加载对应的子类型/颜色数据 → 右侧面板显示选择界面
3. **设计生成**:跳转生成页携带品类参数 → 输入设计描述(最多 500 字) → 提交请求 → 显示水墨风格加载动画 → 生成完成后展示预览 3. **设计生成**:跳转生成页携带品类参数 → 选择可选参数(工艺/风格/题材/尺寸/表面/用途) → 输入设计描述(最多 2000 字) → 提交请求 → 显示水墨风格加载动画 → 生成完成后展示预览
4. **设计管理**:用户中心加载设计列表 → 卡片网格展示 → 支持分页浏览、下载 PNG、删除确认弹窗、点击卡片跳转重新编辑 4. **设计管理**:用户中心加载设计列表 → 卡片网格展示 → 支持分页浏览、下载 PNG、删除确认弹窗、点击卡片跳转重新编辑
### 关键数据流图 ### 关键数据流图
```mermaid ```mermaid
graph LR graph LR
A[前端 Store] -->|Axios + Token| B[API 路由] A[前端 Store] -->|Axios + Token| B[API 路由]
B -->|Depends 注入| C[Service 层] B -->|Depends 注入| C[Service 层]
C -->|prompt_builder| D1[英文 Prompt 构建]
D1 -->|ai_generator| D2[AI 生图 API]
D2 -->|下载保存| E[uploads/ 目录]
C -->|SQLAlchemy ORM| D[MySQL 数据库] C -->|SQLAlchemy ORM| D[MySQL 数据库]
C -->|Pillow 生成| E[uploads/ 目录] E -->|StaticFiles 服务| F[前端多视角展示]
E -->|StaticFiles 服务| F[前端图片展示]
``` ```
### API 调用链路 ### API 调用链路
@@ -84,11 +119,22 @@ graph LR
| `/api/categories` | GET | 获取品类列表 | - | | `/api/categories` | GET | 获取品类列表 | - |
| `/api/categories/{id}/sub-types` | GET | 获取子类型 | category_id | | `/api/categories/{id}/sub-types` | GET | 获取子类型 | category_id |
| `/api/categories/{id}/colors` | 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` | GET | 设计列表(分页) | page, page_size |
| `/api/designs/{id}` | GET | 设计详情 | design_id | | `/api/designs/{id}` | GET | 设计详情 | design_id |
| `/api/designs/{id}` | DELETE | 删除设计 | design_id | | `/api/designs/{id}` | DELETE | 删除设计 | design_id |
| `/api/designs/{id}/download` | GET | 下载设计图 | 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 连接 | | **数据库驱动** | PyMySQL | 1.1.0 | MySQL 连接 |
| **认证** | python-jose | 3.3.0 | JWT Token | | **认证** | python-jose | 3.3.0 | JWT Token |
| **密码加密** | passlib + bcrypt | 1.7.4 | bcrypt 哈希 | | **密码加密** | 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 编码 | | **数据库** | MySQL | - | utf8mb4 编码 |
## 目录结构 ## 目录结构
@@ -124,14 +172,18 @@ YuShiSheJi/
│ │ │ ├── auth.py # 认证路由(注册/登录) │ │ │ ├── auth.py # 认证路由(注册/登录)
│ │ │ ├── categories.py # 品类查询路由 │ │ │ ├── categories.py # 品类查询路由
│ │ │ ├── designs.py # 设计生成/管理路由 │ │ │ ├── designs.py # 设计生成/管理路由
│ │ │ ├── admin.py # 管理后台路由
│ │ │ └── users.py # 用户信息路由 │ │ │ └── users.py # 用户信息路由
│ │ ├── schemas/ # Pydantic 数据验证 │ │ ├── schemas/ # Pydantic 数据验证
│ │ ├── services/ # 业务逻辑层 │ │ ├── services/ # 业务逻辑层
│ │ │ ├── auth_service.py # 认证业务 │ │ │ ├── auth_service.py # 认证业务
│ │ │ ├── design_service.py # 设计业务 │ │ │ ├── design_service.py # 设计业务AI生图+降级)
│ │ │ ── mock_generator.py # 图片生成服务 │ │ │ ── ai_generator.py # AI 生图服务(双模型)
│ │ │ ├── prompt_builder.py # 提示词构建器
│ │ │ ├── config_service.py # 配置服务(数据库优先于.env
│ │ │ └── mock_generator.py # Mock图片生成降级兜底
│ │ ├── utils/ # 工具函数 │ │ ├── utils/ # 工具函数
│ │ │ ├── deps.py # 认证依赖注入 │ │ │ ├── deps.py # 认证/管理员依赖注入
│ │ │ └── security.py # JWT/密码工具 │ │ │ └── security.py # JWT/密码工具
│ │ ├── config.py # 配置管理 │ │ ├── config.py # 配置管理
│ │ ├── database.py # 数据库连接 │ │ ├── database.py # 数据库连接
@@ -145,9 +197,11 @@ YuShiSheJi/
│ │ │ ├── request.ts # Axios 实例(拦截器) │ │ │ ├── request.ts # Axios 实例(拦截器)
│ │ │ ├── auth.ts # 认证接口 │ │ │ ├── auth.ts # 认证接口
│ │ │ ├── category.ts # 品类接口 │ │ │ ├── category.ts # 品类接口
│ │ │ ── design.ts # 设计接口 │ │ │ ── design.ts # 设计接口
│ │ │ └── admin.ts # 管理后台接口
│ │ ├── components/ # 公共组件 │ │ ├── components/ # 公共组件
│ │ │ ├── AppHeader.vue # 顶部导航栏 │ │ │ ├── AppHeader.vue # 顶部导航栏
│ │ │ ├── AdminLayout.vue # 管理后台布局(侧边栏+内容区)
│ │ │ ├── CategoryNav.vue # 品类左侧导航 │ │ │ ├── CategoryNav.vue # 品类左侧导航
│ │ │ ├── SubTypePanel.vue# 子类型/颜色选择面板 │ │ │ ├── SubTypePanel.vue# 子类型/颜色选择面板
│ │ │ ├── ColorPicker.vue # 颜色选择器 │ │ │ ├── ColorPicker.vue # 颜色选择器
@@ -157,6 +211,12 @@ YuShiSheJi/
│ │ │ ├── category.ts # 品类状态 │ │ │ ├── category.ts # 品类状态
│ │ │ └── design.ts # 设计状态 │ │ │ └── design.ts # 设计状态
│ │ ├── views/ # 页面组件 │ │ ├── views/ # 页面组件
│ │ │ ├── admin/ # 管理后台页面
│ │ │ │ ├── Dashboard.vue # 仪表盘
│ │ │ │ ├── ConfigManage.vue # 系统配置管理
│ │ │ │ ├── UserManage.vue # 用户管理
│ │ │ │ ├── CategoryManage.vue # 品类管理
│ │ │ │ └── DesignManage.vue # 设计管理
│ │ │ ├── Login.vue # 登录页 │ │ │ ├── Login.vue # 登录页
│ │ │ ├── Register.vue # 注册页 │ │ │ ├── Register.vue # 注册页
│ │ │ ├── DesignPage.vue # 设计页(品类选择) │ │ │ ├── DesignPage.vue # 设计页(品类选择)
@@ -236,11 +296,13 @@ uvicorn app.main:app --host 0.0.0.0 --port 8000
| 分类 | 表名 | 说明 | | 分类 | 表名 | 说明 |
|-----|------|-----| |-----|------|-----|
| 用户 | `users` | 用户基本信息(用户名、密码、昵称、手机、头像) | | 用户 | `users` | 用户基本信息(用户名、密码、昵称、手机、头像、管理员标识 |
| 品类 | `categories` | 12 种玉石品类(名称、图标、排序、流程类型) | | 品类 | `categories` | 12 种玉石品类(名称、图标、排序、流程类型) |
| 品类 | `sub_types` | 品类子类型(牌型/尺寸,关联品类) | | 品类 | `sub_types` | 品类子类型(牌型/尺寸,关联品类) |
| 品类 | `colors` | 品类颜色选项颜色名、HEX 色值,关联品类) | | 品类 | `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 算法 | | `ALGORITHM` | `HS256` | 否 | JWT 算法 |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `1440` | 否 | Token 有效期(分钟,默认 24 小时) | | `ACCESS_TOKEN_EXPIRE_MINUTES` | `1440` | 否 | Token 有效期(分钟,默认 24 小时) |
| `UPLOAD_DIR` | `uploads` | 否 | 图片上传存储目录 | | `UPLOAD_DIR` | `uploads` | 否 | 图片上传存储目录 |
| `SILICONFLOW_API_KEY` | 空 | 是(生图) | 硅基流动 API KeyFLUX.1 生图) |
| `SILICONFLOW_BASE_URL` | `https://api.siliconflow.cn/v1` | 否 | 硅基流动 API 地址 |
| `VOLCENGINE_API_KEY` | 空 | 否 | 火山引擎 API KeySeedream 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` | 否 | 生成图片尺寸 |
## 常用命令 ## 常用命令

View File

@@ -3,3 +3,11 @@ SECRET_KEY=yuzong-jewelry-design-secret-key-2026
ALGORITHM=HS256 ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=1440 ACCESS_TOKEN_EXPIRE_MINUTES=1440
UPLOAD_DIR=uploads 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

View File

@@ -3,3 +3,11 @@ SECRET_KEY=your-secret-key-change-this
ALGORITHM=HS256 ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=1440 ACCESS_TOKEN_EXPIRE_MINUTES=1440
UPLOAD_DIR=uploads 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

View File

@@ -13,6 +13,13 @@ class Settings(BaseSettings):
ALGORITHM: str = "HS256" ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
UPLOAD_DIR: str = "uploads" 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: class Config:
env_file = ".env" env_file = ".env"

View File

@@ -10,6 +10,7 @@ from fastapi.staticfiles import StaticFiles
from .config import settings from .config import settings
from .routers import categories, designs, users from .routers import categories, designs, users
from .routers import auth from .routers import auth
from .routers import admin
@asynccontextmanager @asynccontextmanager
@@ -62,6 +63,7 @@ app.include_router(auth.router)
app.include_router(categories.router) app.include_router(categories.router)
app.include_router(designs.router) app.include_router(designs.router)
app.include_router(users.router) app.include_router(users.router)
app.include_router(admin.router)
# 配置静态文件服务 # 配置静态文件服务
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")

View File

@@ -6,6 +6,9 @@ from ..database import Base
from .user import User from .user import User
from .category import Category, SubType, Color from .category import Category, SubType, Color
from .design import Design from .design import Design
from .design_image import DesignImage
from .system_config import SystemConfig
from .prompt_template import PromptTemplate, PromptMapping
__all__ = [ __all__ = [
"Base", "Base",
@@ -13,5 +16,9 @@ __all__ = [
"Category", "Category",
"SubType", "SubType",
"Color", "Color",
"Design" "Design",
"DesignImage",
"SystemConfig",
"PromptTemplate",
"PromptMapping",
] ]

View File

@@ -34,6 +34,7 @@ class Design(Base):
category = relationship("Category", back_populates="designs") category = relationship("Category", back_populates="designs")
sub_type = relationship("SubType", back_populates="designs") sub_type = relationship("SubType", back_populates="designs")
color = relationship("Color", 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): def __repr__(self):
return f"<Design(id={self.id}, status='{self.status}')>" return f"<Design(id={self.id}, status='{self.status}')>"

View File

@@ -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"<DesignImage(id={self.id}, view='{self.view_name}')>"

View File

@@ -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"<PromptTemplate(key='{self.template_key}')>"
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"<PromptMapping(type='{self.mapping_type}', key='{self.cn_key}')>"

View File

@@ -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"<SystemConfig(key='{self.config_key}')>"

View File

@@ -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.sql import func
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@@ -18,6 +18,7 @@ class User(Base):
hashed_password = Column(String(255), nullable=False, comment="加密密码") hashed_password = Column(String(255), nullable=False, comment="加密密码")
nickname = Column(String(50), nullable=True, comment="昵称") nickname = Column(String(50), nullable=True, comment="昵称")
avatar = Column(String(255), nullable=True, comment="头像URL") 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="创建时间") created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")

View File

@@ -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)}")

View File

@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..models import User, Design 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 ..utils.deps import get_current_user
from ..services import design_service from ..services import design_service
@@ -18,6 +18,21 @@ router = APIRouter(prefix="/api/designs", tags=["设计"])
def design_to_response(design: Design) -> DesignResponse: def design_to_response(design: Design) -> DesignResponse:
"""将 Design 模型转换为响应格式""" """将 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( return DesignResponse(
id=design.id, id=design.id,
user_id=design.user_id, user_id=design.user_id,
@@ -51,6 +66,7 @@ def design_to_response(design: Design) -> DesignResponse:
surface_finish=design.surface_finish, surface_finish=design.surface_finish,
usage_scene=design.usage_scene, usage_scene=design.usage_scene,
image_url=design.image_url, image_url=design.image_url,
images=images,
status=design.status, status=design.status,
created_at=design.created_at, created_at=design.created_at,
updated_at=design.updated_at updated_at=design.updated_at
@@ -58,17 +74,17 @@ def design_to_response(design: Design) -> DesignResponse:
@router.post("/generate", response_model=DesignResponse) @router.post("/generate", response_model=DesignResponse)
def generate_design( async def generate_design(
design_data: DesignCreate, design_data: DesignCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
""" """
提交设计生成请求 提交设计生成请求(异步,支持 AI 多视角生图)
需要认证 需要认证
""" """
try: try:
design = design_service.create_design( design = await design_service.create_design_async(
db=db, db=db,
user_id=current_user.id, user_id=current_user.id,
design_data=design_data design_data=design_data

View File

@@ -4,7 +4,7 @@ Pydantic Schemas
""" """
from .user import UserCreate, UserLogin, UserResponse, Token, UserUpdate, PasswordChange from .user import UserCreate, UserLogin, UserResponse, Token, UserUpdate, PasswordChange
from .category import CategoryResponse, SubTypeResponse, ColorResponse from .category import CategoryResponse, SubTypeResponse, ColorResponse
from .design import DesignCreate, DesignResponse, DesignListResponse from .design import DesignCreate, DesignResponse, DesignListResponse, DesignImageResponse
__all__ = [ __all__ = [
# User schemas # User schemas
@@ -22,4 +22,5 @@ __all__ = [
"DesignCreate", "DesignCreate",
"DesignResponse", "DesignResponse",
"DesignListResponse", "DesignListResponse",
"DesignImageResponse",
] ]

View File

@@ -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

View File

@@ -8,6 +8,20 @@ from typing import Optional, List
from .category import CategoryResponse, SubTypeResponse, ColorResponse 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): class DesignCreate(BaseModel):
"""创建设计请求""" """创建设计请求"""
category_id: int = Field(..., description="品类ID") category_id: int = Field(..., description="品类ID")
@@ -37,6 +51,7 @@ class DesignResponse(BaseModel):
surface_finish: Optional[str] = None surface_finish: Optional[str] = None
usage_scene: Optional[str] = None usage_scene: Optional[str] = None
image_url: Optional[str] = None image_url: Optional[str] = None
images: List[DesignImageResponse] = []
status: str status: str
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

@@ -26,6 +26,7 @@ class UserResponse(BaseModel):
nickname: Optional[str] = None nickname: Optional[str] = None
phone: Optional[str] = None phone: Optional[str] = None
avatar: Optional[str] = None avatar: Optional[str] = None
is_admin: bool = False
created_at: datetime created_at: datetime
class Config: class Config:

View File

@@ -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}"

View File

@@ -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")),
}

View File

@@ -1,40 +1,56 @@
""" """
设计服务 设计服务
处理设计相关的业务逻辑 处理设计相关的业务逻辑,支持 AI 多视角生图 + mock 降级
""" """
import os import os
import logging
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import desc from sqlalchemy import desc
from ..models import Design, Category, SubType, Color from ..models import Design, DesignImage, Category, SubType, Color
from ..schemas import DesignCreate from ..schemas import DesignCreate
from ..config import settings from ..config import settings
from .mock_generator import generate_mock_design 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:
""" """
创建设计记录 创建设计记录(异步版本,支持 AI 多视角生图)
1. 创建设计记录status=generating 流程:
2. 调用 mock_generator 生成图片 1. 创建 Design 记录status=generating
3. 更新设计记录status=completed, image_url 2. 获取品类视角列表
4. 返回设计对象 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() category = db.query(Category).filter(Category.id == design_data.category_id).first()
if not category: if not category:
raise ValueError(f"品类不存在: {design_data.category_id}") raise ValueError(f"品类不存在: {design_data.category_id}")
sub_type = None sub_type = None
if design_data.sub_type_id: if design_data.sub_type_id:
sub_type = db.query(SubType).filter(SubType.id == design_data.sub_type_id).first() sub_type = db.query(SubType).filter(SubType.id == design_data.sub_type_id).first()
color = None color = None
if design_data.color_id: if design_data.color_id:
color = db.query(Color).filter(Color.id == design_data.color_id).first() color = db.query(Color).filter(Color.id == design_data.color_id).first()
# 创建设计记录 # 创建设计记录
design = Design( design = Design(
user_id=user_id, user_id=user_id,
@@ -52,8 +68,109 @@ def create_design(db: Session, user_id: int, design_data: DesignCreate) -> Desig
) )
db.add(design) db.add(design)
db.flush() # 获取 ID 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") save_path = os.path.join(settings.UPLOAD_DIR, "designs", f"{design.id}.png")
image_url = generate_mock_design( image_url = generate_mock_design(
category_name=category.name, 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, surface_finish=design_data.surface_finish,
usage_scene=design_data.usage_scene, usage_scene=design_data.usage_scene,
) )
# 更新设计记录
design.image_url = image_url design.image_url = image_url
design.status = "completed" 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.commit()
db.refresh(design) db.refresh(design)
return design return design
@@ -132,16 +283,24 @@ def delete_design(db: Session, design_id: int, user_id: int) -> bool:
if not design: if not design:
return False return False
# 删除图片文件 # 删除图片文件
if design.image_url: if design.image_url:
# image_url 格式: /uploads/designs/1001.png
# 转换为实际文件路径
file_path = design.image_url.lstrip("/") file_path = design.image_url.lstrip("/")
if os.path.exists(file_path): if os.path.exists(file_path):
try: try:
os.remove(file_path) os.remove(file_path)
except Exception: 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) db.delete(design)

View File

@@ -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)

View File

@@ -56,3 +56,19 @@ def get_current_user(
raise credentials_exception raise credentials_exception
return user 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

203
backend/init_prompt_data.py Normal file
View File

@@ -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✅ 全部提示词数据初始化完成!")

View File

@@ -11,3 +11,4 @@ python-multipart==0.0.9
Pillow==10.2.0 Pillow==10.2.0
pydantic[email]==2.6.1 pydantic[email]==2.6.1
pydantic-settings==2.1.0 pydantic-settings==2.1.0
httpx==0.27.0

View File

@@ -1,19 +1,23 @@
<template> <template>
<div class="app-container"> <div class="app-container">
<AppHeader /> <AppHeader v-if="!isAdminRoute" />
<main class="main-content"> <main :class="isAdminRoute ? 'admin-main-content' : 'main-content'">
<router-view /> <router-view />
</main> </main>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' import { computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import AppHeader from '@/components/AppHeader.vue' import AppHeader from '@/components/AppHeader.vue'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
const route = useRoute()
const userStore = useUserStore() const userStore = useUserStore()
const isAdminRoute = computed(() => route.path.startsWith('/admin'))
// 应用初始化时恢复登录状态 // 应用初始化时恢复登录状态
onMounted(() => { onMounted(() => {
userStore.init() userStore.init()
@@ -31,4 +35,8 @@ onMounted(() => {
flex: 1; flex: 1;
padding: 24px; padding: 24px;
} }
.admin-main-content {
flex: 1;
}
</style> </style>

94
frontend/src/api/admin.ts Normal file
View File

@@ -0,0 +1,94 @@
import request from './request'
// ============ 仪表盘 ============
export const getDashboard = () => request.get('/admin/dashboard')
// ============ 系统配置 ============
export const getConfigs = (group?: string) =>
request.get('/admin/configs', { params: group ? { group } : {} })
export const updateConfigs = (configs: Record<string, string>) =>
request.post('/admin/configs', null, {
// PUT 方法
})
export const updateConfigsBatch = (configs: Record<string, string>) =>
request.put('/admin/configs', { configs })
export const initDefaultConfigs = () =>
request.post('/admin/configs/init')
// ============ 用户管理 ============
export const getUsers = (params: { page?: number; page_size?: number; keyword?: string }) =>
request.get('/admin/users', { params })
export const setUserAdmin = (userId: number, isAdmin: boolean) =>
request.put(`/admin/users/${userId}/admin`, { is_admin: isAdmin })
export const deleteUser = (userId: number) =>
request.delete(`/admin/users/${userId}`)
// ============ 品类管理 ============
export const getAdminCategories = () =>
request.get('/admin/categories')
export const createCategory = (data: { name: string; icon?: string; sort_order?: number; flow_type?: string }) =>
request.post('/admin/categories', data)
export const updateCategory = (catId: number, data: { name?: string; icon?: string; sort_order?: number; flow_type?: string }) =>
request.put(`/admin/categories/${catId}`, data)
export const deleteCategory = (catId: number) =>
request.delete(`/admin/categories/${catId}`)
// -- 子类型 --
export const createSubType = (data: { category_id: number; name: string; description?: string; preview_image?: string; sort_order?: number }) =>
request.post('/admin/sub-types', data)
export const updateSubType = (stId: number, data: { name?: string; description?: string; preview_image?: string; sort_order?: number }) =>
request.put(`/admin/sub-types/${stId}`, data)
export const deleteSubType = (stId: number) =>
request.delete(`/admin/sub-types/${stId}`)
// -- 颜色 --
export const createColor = (data: { category_id: number; name: string; hex_code?: string; sort_order?: number }) =>
request.post('/admin/colors', data)
export const updateColor = (colorId: number, data: { name?: string; hex_code?: string; sort_order?: number }) =>
request.put(`/admin/colors/${colorId}`, data)
export const deleteColor = (colorId: number) =>
request.delete(`/admin/colors/${colorId}`)
// ============ 设计管理 ============
export const getAdminDesigns = (params: { page?: number; page_size?: number; user_id?: number; status?: string }) =>
request.get('/admin/designs', { params })
export const adminDeleteDesign = (designId: number) =>
request.delete(`/admin/designs/${designId}`)
// ============ 提示词管理 ============
export const getPromptTemplates = () =>
request.get('/admin/prompt-templates')
export const updatePromptTemplate = (templateId: number, data: { template_value: string; description?: string }) =>
request.put(`/admin/prompt-templates/${templateId}`, data)
export const getPromptMappings = (mappingType?: string) =>
request.get('/admin/prompt-mappings', { params: mappingType ? { mapping_type: mappingType } : {} })
export const getMappingTypes = () =>
request.get('/admin/prompt-mappings/types')
export const createPromptMapping = (data: { mapping_type: string; cn_key: string; en_value: string; sort_order?: number }) =>
request.post('/admin/prompt-mappings', data)
export const updatePromptMapping = (mappingId: number, data: { cn_key?: string; en_value?: string; sort_order?: number }) =>
request.put(`/admin/prompt-mappings/${mappingId}`, data)
export const deletePromptMapping = (mappingId: number) =>
request.delete(`/admin/prompt-mappings/${mappingId}`)
export const previewPrompt = (params: Record<string, string>) =>
request.post('/admin/prompt-preview', params)

View File

@@ -11,6 +11,15 @@ export interface SubType {
name: string name: string
} }
export interface DesignImage {
id: number
view_name: string
image_url: string | null
model_used: string | null
prompt_used: string | null
sort_order: number
}
export interface Design { export interface Design {
id: number id: number
user_id: number user_id: number
@@ -25,6 +34,7 @@ export interface Design {
surface_finish: string | null surface_finish: string | null
usage_scene: string | null usage_scene: string | null
image_url: string | null image_url: string | null
images: DesignImage[]
status: string status: string
created_at: string created_at: string
updated_at: string updated_at: string

View File

@@ -0,0 +1,177 @@
<template>
<div class="admin-layout">
<aside class="admin-sidebar">
<div class="sidebar-header">
<router-link to="/admin" class="admin-logo">
<span class="logo-icon">&#9881;</span>
<span class="logo-text">管理后台</span>
</router-link>
</div>
<nav class="sidebar-nav">
<router-link to="/admin" class="nav-item" exact-active-class="active">
<el-icon><DataBoard /></el-icon>
<span>仪表盘</span>
</router-link>
<router-link to="/admin/configs" class="nav-item" active-class="active">
<el-icon><Setting /></el-icon>
<span>系统配置</span>
</router-link>
<router-link to="/admin/users" class="nav-item" active-class="active">
<el-icon><User /></el-icon>
<span>用户管理</span>
</router-link>
<router-link to="/admin/categories" class="nav-item" active-class="active">
<el-icon><Grid /></el-icon>
<span>品类管理</span>
</router-link>
<router-link to="/admin/designs" class="nav-item" active-class="active">
<el-icon><PictureFilled /></el-icon>
<span>设计管理</span>
</router-link>
<router-link to="/admin/prompts" class="nav-item" active-class="active">
<el-icon><ChatLineSquare /></el-icon>
<span>提示词管理</span>
</router-link>
</nav>
<div class="sidebar-footer">
<router-link to="/" class="back-link">
<el-icon><Back /></el-icon>
<span>返回前台</span>
</router-link>
</div>
</aside>
<div class="admin-main">
<header class="admin-header">
<div class="header-title">{{ pageTitle }}</div>
<div class="header-user">
<span>{{ userStore.userInfo?.nickname || '管理员' }}</span>
</div>
</header>
<div class="admin-content">
<router-view />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { DataBoard, Setting, User, Grid, PictureFilled, Back, ChatLineSquare } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
const route = useRoute()
const userStore = useUserStore()
const pageTitle = computed(() => {
const titles: Record<string, string> = {
'AdminDashboard': '仪表盘',
'AdminConfigs': '系统配置',
'AdminUsers': '用户管理',
'AdminCategories': '品类管理',
'AdminDesigns': '设计管理',
'AdminPrompts': '提示词管理',
}
return titles[route.name as string] || '管理后台'
})
</script>
<style scoped lang="scss">
.admin-layout {
display: flex;
min-height: 100vh;
background: #f0f2f5;
}
.admin-sidebar {
width: 220px;
background: #1d1e2c;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-header {
padding: 20px;
border-bottom: 1px solid rgba(255,255,255,0.08);
.admin-logo {
display: flex;
align-items: center;
gap: 8px;
text-decoration: none;
color: #fff;
.logo-icon { font-size: 22px; }
.logo-text { font-size: 18px; font-weight: 600; letter-spacing: 2px; }
}
}
.sidebar-nav {
flex: 1;
padding: 12px 0;
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 24px;
color: rgba(255,255,255,0.65);
text-decoration: none;
font-size: 14px;
transition: all 0.2s;
&:hover { color: #fff; background: rgba(255,255,255,0.06); }
&.active {
color: #fff;
background: #5B7E6B;
border-right: 3px solid #A8D5BA;
}
}
}
.sidebar-footer {
padding: 16px;
border-top: 1px solid rgba(255,255,255,0.08);
.back-link {
display: flex;
align-items: center;
gap: 8px;
color: rgba(255,255,255,0.5);
text-decoration: none;
font-size: 13px;
padding: 8px;
border-radius: 6px;
transition: all 0.2s;
&:hover { color: #fff; background: rgba(255,255,255,0.06); }
}
}
.admin-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
height: 56px;
padding: 0 24px;
background: #fff;
border-bottom: 1px solid #e8e8e8;
.header-title { font-size: 16px; font-weight: 600; color: #1d1e2c; }
.header-user { font-size: 14px; color: #666; }
}
.admin-content {
flex: 1;
padding: 24px;
overflow-y: auto;
}
</style>

View File

@@ -8,6 +8,7 @@
<nav class="header-nav"> <nav class="header-nav">
<router-link to="/" class="nav-link">设计</router-link> <router-link to="/" class="nav-link">设计</router-link>
<router-link to="/generate" class="nav-link">生成</router-link> <router-link to="/generate" class="nav-link">生成</router-link>
<router-link to="/admin" class="nav-link admin-link" v-if="isAdmin">管理后台</router-link>
</nav> </nav>
<div class="header-right"> <div class="header-right">
<template v-if="isLoggedIn"> <template v-if="isLoggedIn">
@@ -43,6 +44,7 @@ const userStore = useUserStore()
const isLoggedIn = computed(() => !!userStore.token) const isLoggedIn = computed(() => !!userStore.token)
const userNickname = computed(() => userStore.userInfo?.nickname || '用户') const userNickname = computed(() => userStore.userInfo?.nickname || '用户')
const isAdmin = computed(() => !!userStore.userInfo?.is_admin)
const handleCommand = (command: string) => { const handleCommand = (command: string) => {
if (command === 'user') { if (command === 'user') {
@@ -94,6 +96,11 @@ const handleCommand = (command: string) => {
color: #5B7E6B; color: #5B7E6B;
border-bottom-color: #5B7E6B; border-bottom-color: #5B7E6B;
} }
&.admin-link {
color: #E6A23C;
&:hover, &.router-link-active { color: #E6A23C; border-bottom-color: #E6A23C; }
}
} }
} }

View File

@@ -1,14 +1,27 @@
<template> <template>
<div class="design-preview"> <div class="design-preview">
<!-- 视角 Tab 多图时显示 -->
<div class="view-tabs" v-if="hasMultipleViews">
<button
v-for="(img, idx) in design.images"
:key="img.id"
class="view-tab"
:class="{ active: activeViewIndex === idx }"
@click="activeViewIndex = idx"
>
{{ img.view_name }}
</button>
</div>
<!-- 图片预览区 --> <!-- 图片预览区 -->
<div class="preview-container"> <div class="preview-container">
<div class="image-wrapper" :style="{ transform: `scale(${scale})` }"> <div class="image-wrapper" :style="{ transform: `scale(${scale})` }">
<el-image <el-image
:src="imageUrl" :src="currentImageUrl"
:alt="design.prompt" :alt="design.prompt"
fit="contain" fit="contain"
:preview-src-list="[imageUrl]" :preview-src-list="allImageUrls"
:initial-index="0" :initial-index="activeViewIndex"
preview-teleported preview-teleported
class="design-image" class="design-image"
> >
@@ -27,6 +40,11 @@
</el-image> </el-image>
</div> </div>
<!-- 视角指示器多图时显示 -->
<div class="view-indicator" v-if="hasMultipleViews">
<span class="indicator-text">{{ activeViewName }} ({{ activeViewIndex + 1 }}/{{ design.images.length }})</span>
</div>
<!-- 缩放控制 --> <!-- 缩放控制 -->
<div class="zoom-controls"> <div class="zoom-controls">
<button class="zoom-btn" @click="zoomOut" :disabled="scale <= 0.5"> <button class="zoom-btn" @click="zoomOut" :disabled="scale <= 0.5">
@@ -84,7 +102,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Loading, PictureFilled, ZoomIn, ZoomOut, RefreshRight, Download, User } from '@element-plus/icons-vue' import { Loading, PictureFilled, ZoomIn, ZoomOut, RefreshRight, Download, User } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
@@ -97,17 +115,48 @@ const props = defineProps<{
const router = useRouter() const router = useRouter()
// 当前视角索引
const activeViewIndex = ref(0)
// 缩放比例 // 缩放比例
const scale = ref(1) const scale = ref(1)
// 图片URL添加API前缀 // 是否有多视角图片
const imageUrl = computed(() => { const hasMultipleViews = computed(() => {
if (!props.design.image_url) return '' return props.design.images && props.design.images.length > 1
// 如果已经是完整URL则直接使用否则添加 /api 前缀 })
if (props.design.image_url.startsWith('http')) {
return props.design.image_url // 当前视角名称
const activeViewName = computed(() => {
if (props.design.images && props.design.images.length > 0) {
return props.design.images[activeViewIndex.value]?.view_name || ''
} }
return `/api${props.design.image_url}` return ''
})
// 获取图片URL添加API前缀
const toImageUrl = (url: string | null): string => {
if (!url) return ''
if (url.startsWith('http')) return url
return `/api${url}`
}
// 当前显示的图片URL
const currentImageUrl = computed(() => {
// 优先用多视角图片
if (props.design.images && props.design.images.length > 0) {
return toImageUrl(props.design.images[activeViewIndex.value]?.image_url)
}
// 兼容旧数据,使用单图
return toImageUrl(props.design.image_url)
})
// 所有图片URL用于大图预览
const allImageUrls = computed(() => {
if (props.design.images && props.design.images.length > 0) {
return props.design.images.map(img => toImageUrl(img.image_url))
}
return [toImageUrl(props.design.image_url)]
}) })
// 下载URL // 下载URL
@@ -117,7 +166,13 @@ const downloadUrl = computed(() => getDesignDownloadUrl(props.design.id))
const downloadFilename = computed(() => { const downloadFilename = computed(() => {
const category = props.design.category?.name || '设计' const category = props.design.category?.name || '设计'
const subType = props.design.sub_type?.name || '' const subType = props.design.sub_type?.name || ''
return `${category}${subType ? '-' + subType : ''}-${props.design.id}.png` const viewSuffix = hasMultipleViews.value ? `-${activeViewName.value}` : ''
return `${category}${subType ? '-' + subType : ''}${viewSuffix}-${props.design.id}.png`
})
// 切换视角时重置缩放
watch(activeViewIndex, () => {
scale.value = 1
}) })
// 放大 // 放大
@@ -163,6 +218,54 @@ $text-light: #999999;
gap: 24px; gap: 24px;
} }
.view-tabs {
display: flex;
gap: 8px;
padding: 4px;
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.view-tab {
flex: 1;
padding: 10px 16px;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
font-size: 14px;
color: $text-secondary;
cursor: pointer;
transition: all 0.25s ease;
letter-spacing: 1px;
&:hover {
color: $primary-color;
background: rgba($primary-color, 0.05);
}
&.active {
background: $primary-color;
color: #fff;
border-color: $primary-color;
font-weight: 500;
}
}
.view-indicator {
position: absolute;
top: 16px;
left: 16px;
background: rgba(0, 0, 0, 0.5);
padding: 4px 12px;
border-radius: 12px;
}
.indicator-text {
font-size: 12px;
color: #fff;
}
.preview-container { .preview-container {
position: relative; position: relative;
background: #fff; background: #fff;

7
frontend/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@@ -30,6 +30,44 @@ const router = createRouter({
path: '/register', path: '/register',
name: 'Register', name: 'Register',
component: () => import('@/views/Register.vue') component: () => import('@/views/Register.vue')
},
// 管理后台路由
{
path: '/admin',
component: () => import('@/components/AdminLayout.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
children: [
{
path: '',
name: 'AdminDashboard',
component: () => import('@/views/admin/Dashboard.vue')
},
{
path: 'configs',
name: 'AdminConfigs',
component: () => import('@/views/admin/ConfigManage.vue')
},
{
path: 'users',
name: 'AdminUsers',
component: () => import('@/views/admin/UserManage.vue')
},
{
path: 'categories',
name: 'AdminCategories',
component: () => import('@/views/admin/CategoryManage.vue')
},
{
path: 'designs',
name: 'AdminDesigns',
component: () => import('@/views/admin/DesignManage.vue')
},
{
path: 'prompts',
name: 'AdminPrompts',
component: () => import('@/views/admin/PromptManage.vue')
}
]
} }
] ]
}) })

View File

@@ -8,6 +8,7 @@ export interface UserInfo {
nickname: string nickname: string
phone?: string | null phone?: string | null
avatar?: string | null avatar?: string | null
is_admin?: boolean
created_at?: string created_at?: string
} }

View File

@@ -39,8 +39,8 @@
<div class="ink-drop"></div> <div class="ink-drop"></div>
<div class="ink-drop"></div> <div class="ink-drop"></div>
</div> </div>
<p class="loading-text">设计生成中请稍候...</p> <p class="loading-text">正在用 AI 生成多视角设计图...</p>
<p class="loading-hint">正在将您的创意转化为玉雕设计</p> <p class="loading-hint">根据品类自动生成 2~4 张不同视角的设计效果图请耐心等候</p>
</div> </div>
</div> </div>
@@ -62,8 +62,15 @@
:key="opt" :key="opt"
class="tag-item" class="tag-item"
:class="{ active: carvingTechnique === opt }" :class="{ active: carvingTechnique === opt }"
@click="carvingTechnique = carvingTechnique === opt ? '' : opt" @click="selectTag('carvingTechnique', opt)"
>{{ opt }}</span> >{{ opt }}</span>
<el-input
v-model="customCarving"
placeholder="自定义工艺"
size="small"
class="custom-input"
@focus="carvingTechnique = ''"
/>
</div> </div>
</div> </div>
@@ -76,8 +83,15 @@
:key="opt" :key="opt"
class="tag-item" class="tag-item"
:class="{ active: designStyle === opt }" :class="{ active: designStyle === opt }"
@click="designStyle = designStyle === opt ? '' : opt" @click="selectTag('designStyle', opt)"
>{{ opt }}</span> >{{ opt }}</span>
<el-input
v-model="customStyle"
placeholder="自定义风格"
size="small"
class="custom-input"
@focus="designStyle = ''"
/>
</div> </div>
</div> </div>
@@ -90,8 +104,15 @@
:key="opt" :key="opt"
class="tag-item" class="tag-item"
:class="{ active: motif === opt }" :class="{ active: motif === opt }"
@click="motif = motif === opt ? '' : opt" @click="selectTag('motif', opt)"
>{{ opt }}</span> >{{ opt }}</span>
<el-input
v-model="customMotif"
placeholder="自定义题材"
size="small"
class="custom-input"
@focus="motif = ''"
/>
</div> </div>
</div> </div>
@@ -104,13 +125,13 @@
:key="opt" :key="opt"
class="tag-item" class="tag-item"
:class="{ active: sizeSpec === opt }" :class="{ active: sizeSpec === opt }"
@click="sizeSpec = sizeSpec === opt ? '' : opt" @click="selectTag('sizeSpec', opt)"
>{{ opt }}</span> >{{ opt }}</span>
<el-input <el-input
v-model="customSize" v-model="customSize"
placeholder="自定义尺寸" placeholder="自定义尺寸"
size="small" size="small"
class="custom-size-input" class="custom-input"
@focus="sizeSpec = ''" @focus="sizeSpec = ''"
/> />
</div> </div>
@@ -125,8 +146,15 @@
:key="opt" :key="opt"
class="tag-item" class="tag-item"
:class="{ active: surfaceFinish === opt }" :class="{ active: surfaceFinish === opt }"
@click="surfaceFinish = surfaceFinish === opt ? '' : opt" @click="selectTag('surfaceFinish', opt)"
>{{ opt }}</span> >{{ opt }}</span>
<el-input
v-model="customFinish"
placeholder="自定义处理"
size="small"
class="custom-input"
@focus="surfaceFinish = ''"
/>
</div> </div>
</div> </div>
@@ -139,8 +167,15 @@
:key="opt" :key="opt"
class="tag-item" class="tag-item"
:class="{ active: usageScene === opt }" :class="{ active: usageScene === opt }"
@click="usageScene = usageScene === opt ? '' : opt" @click="selectTag('usageScene', opt)"
>{{ opt }}</span> >{{ opt }}</span>
<el-input
v-model="customScene"
placeholder="自定义场景"
size="small"
class="custom-input"
@focus="usageScene = ''"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -178,7 +213,7 @@
<section v-else class="preview-section"> <section v-else class="preview-section">
<div class="section-header"> <div class="section-header">
<h2 class="section-title">设计预览</h2> <h2 class="section-title">设计预览</h2>
<p class="section-desc">您的设计已生成完成</p> <p class="section-desc">您的多视角设计已生成完成</p>
</div> </div>
<DesignPreview :design="currentDesign" /> <DesignPreview :design="currentDesign" />
@@ -258,10 +293,38 @@ const carvingTechnique = ref('')
const designStyle = ref('') const designStyle = ref('')
const motif = ref('') const motif = ref('')
const sizeSpec = ref('') const sizeSpec = ref('')
const customSize = ref('')
const surfaceFinish = ref('') const surfaceFinish = ref('')
const usageScene = ref('') const usageScene = ref('')
// 自定义输入状态
const customCarving = ref('')
const customStyle = ref('')
const customMotif = ref('')
const customSize = ref('')
const customFinish = ref('')
const customScene = ref('')
// 自定义输入与标签的关联 Map
const tagRefs: Record<string, any> = {
carvingTechnique, designStyle, motif, sizeSpec, surfaceFinish, usageScene
}
const customRefs: Record<string, any> = {
carvingTechnique: customCarving, designStyle: customStyle, motif: customMotif,
sizeSpec: customSize, surfaceFinish: customFinish, usageScene: customScene
}
// 选择标签时清除对应自定义输入
const selectTag = (field: string, opt: string) => {
const tagRef = tagRefs[field]
const customRef = customRefs[field]
if (tagRef.value === opt) {
tagRef.value = ''
} else {
tagRef.value = opt
if (customRef) customRef.value = ''
}
}
// 静态选项 // 静态选项
const carvingOptions = ['浮雕', '圆雕', '镂空雕', '阴刻', '线雕', '俏色雕', '薄意雕', '素面'] const carvingOptions = ['浮雕', '圆雕', '镂空雕', '阴刻', '线雕', '俏色雕', '薄意雕', '素面']
const styleOptions = ['古典传统', '新中式', '写实', '抽象意境', '极简素面'] const styleOptions = ['古典传统', '新中式', '写实', '抽象意境', '极简素面']
@@ -315,12 +378,12 @@ const handleGenerate = async () => {
sub_type_id: subTypeId.value || undefined, sub_type_id: subTypeId.value || undefined,
color_id: colorId.value || undefined, color_id: colorId.value || undefined,
prompt: prompt.value.trim(), prompt: prompt.value.trim(),
carving_technique: carvingTechnique.value || undefined, carving_technique: carvingTechnique.value || customCarving.value || undefined,
design_style: designStyle.value || undefined, design_style: designStyle.value || customStyle.value || undefined,
motif: motif.value || undefined, motif: motif.value || customMotif.value || undefined,
size_spec: sizeSpec.value || customSize.value || undefined, size_spec: sizeSpec.value || customSize.value || undefined,
surface_finish: surfaceFinish.value || undefined, surface_finish: surfaceFinish.value || customFinish.value || undefined,
usage_scene: usageScene.value || undefined, usage_scene: usageScene.value || customScene.value || undefined,
}) })
ElMessage.success('设计生成成功!') ElMessage.success('设计生成成功!')
} catch (error) { } catch (error) {
@@ -542,7 +605,7 @@ $text-light: #999999;
} }
} }
.custom-size-input { .custom-input {
width: 130px; width: 130px;
:deep(.el-input__inner) { :deep(.el-input__inner) {

View File

@@ -0,0 +1,299 @@
<template>
<div class="category-manage">
<div class="page-actions">
<el-button type="primary" @click="showAddCategory">新增品类</el-button>
</div>
<div class="category-card" v-for="cat in categories" :key="cat.id" v-loading="loading">
<div class="cat-header">
<div class="cat-info">
<span class="cat-icon">{{ cat.icon || '📦' }}</span>
<span class="cat-name">{{ cat.name }}</span>
<el-tag size="small" type="info">{{ cat.flow_type }}</el-tag>
<el-tag size="small">排序: {{ cat.sort_order }}</el-tag>
</div>
<div class="cat-actions">
<el-button size="small" @click="editCategory(cat)">编辑</el-button>
<el-button size="small" type="danger" @click="removeCategory(cat)">删除</el-button>
</div>
</div>
<!-- 子类型 -->
<div class="sub-section">
<div class="sub-title">
<span>子类型 ({{ cat.sub_types.length }})</span>
<el-button size="small" @click="showAddSubType(cat.id)">添加</el-button>
</div>
<el-table :data="cat.sub_types" size="small" v-if="cat.sub_types.length > 0">
<el-table-column prop="name" label="名称" width="120" />
<el-table-column prop="description" label="描述" />
<el-table-column prop="sort_order" label="排序" width="80" />
<el-table-column label="操作" width="140">
<template #default="{ row }">
<el-button size="small" @click="editSubType(row)">编辑</el-button>
<el-button size="small" type="danger" @click="removeSubType(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 颜色 -->
<div class="sub-section">
<div class="sub-title">
<span>颜色 ({{ cat.colors.length }})</span>
<el-button size="small" @click="showAddColor(cat.id)">添加</el-button>
</div>
<div class="color-list" v-if="cat.colors.length > 0">
<div class="color-chip" v-for="c in cat.colors" :key="c.id">
<span class="color-dot" :style="{ backgroundColor: c.hex_code }"></span>
<span class="color-name">{{ c.name }}</span>
<span class="color-hex">{{ c.hex_code }}</span>
<el-button size="small" text @click="editColor(c, cat.id)">编辑</el-button>
<el-button size="small" text type="danger" @click="removeColor(c)">删除</el-button>
</div>
</div>
</div>
</div>
<!-- 品类弹窗 -->
<el-dialog v-model="catDialogVisible" :title="catForm.id ? '编辑品类' : '新增品类'" width="460px">
<el-form :model="catForm" label-width="80px">
<el-form-item label="名称"><el-input v-model="catForm.name" /></el-form-item>
<el-form-item label="图标"><el-input v-model="catForm.icon" placeholder="emoji 或图标名" /></el-form-item>
<el-form-item label="流程类型">
<el-select v-model="catForm.flow_type">
<el-option label="full" value="full" />
<el-option label="size_color" value="size_color" />
<el-option label="simple" value="simple" />
</el-select>
</el-form-item>
<el-form-item label="排序"><el-input-number v-model="catForm.sort_order" :min="0" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="catDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveCat">确定</el-button>
</template>
</el-dialog>
<!-- 子类型弹窗 -->
<el-dialog v-model="stDialogVisible" :title="stForm.id ? '编辑子类型' : '新增子类型'" width="460px">
<el-form :model="stForm" label-width="80px">
<el-form-item label="名称"><el-input v-model="stForm.name" /></el-form-item>
<el-form-item label="描述"><el-input v-model="stForm.description" /></el-form-item>
<el-form-item label="排序"><el-input-number v-model="stForm.sort_order" :min="0" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="stDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveSt">确定</el-button>
</template>
</el-dialog>
<!-- 颜色弹窗 -->
<el-dialog v-model="colorDialogVisible" :title="colorForm.id ? '编辑颜色' : '新增颜色'" width="460px">
<el-form :model="colorForm" label-width="80px">
<el-form-item label="名称"><el-input v-model="colorForm.name" /></el-form-item>
<el-form-item label="色值">
<el-color-picker v-model="colorForm.hex_code" />
<el-input v-model="colorForm.hex_code" style="width: 120px; margin-left: 12px" />
</el-form-item>
<el-form-item label="排序"><el-input-number v-model="colorForm.sort_order" :min="0" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="colorDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveColor">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getAdminCategories, createCategory, updateCategory, deleteCategory,
createSubType, updateSubType, deleteSubType,
createColor, updateColor, deleteColor
} from '@/api/admin'
interface ColorItem { id: number; name: string; hex_code: string; sort_order: number }
interface SubTypeItem { id: number; name: string; description: string; preview_image: string; sort_order: number }
interface CategoryItem { id: number; name: string; icon: string; sort_order: number; flow_type: string; sub_types: SubTypeItem[]; colors: ColorItem[] }
const categories = ref<CategoryItem[]>([])
const loading = ref(false)
// 品类表单
const catDialogVisible = ref(false)
const catForm = ref<any>({ id: 0, name: '', icon: '', sort_order: 0, flow_type: 'full' })
// 子类型表单
const stDialogVisible = ref(false)
const stForm = ref<any>({ id: 0, category_id: 0, name: '', description: '', sort_order: 0 })
// 颜色表单
const colorDialogVisible = ref(false)
const colorForm = ref<any>({ id: 0, category_id: 0, name: '', hex_code: '#8B7355', sort_order: 0 })
const loadData = async () => {
loading.value = true
try {
categories.value = (await getAdminCategories()) as any
} catch (e) { console.error(e) }
finally { loading.value = false }
}
// ---- 品类 ----
const showAddCategory = () => {
catForm.value = { id: 0, name: '', icon: '', sort_order: 0, flow_type: 'full' }
catDialogVisible.value = true
}
const editCategory = (cat: CategoryItem) => {
catForm.value = { ...cat }
catDialogVisible.value = true
}
const saveCat = async () => {
try {
if (catForm.value.id) {
await updateCategory(catForm.value.id, { name: catForm.value.name, icon: catForm.value.icon, sort_order: catForm.value.sort_order, flow_type: catForm.value.flow_type })
} else {
await createCategory({ name: catForm.value.name, icon: catForm.value.icon, sort_order: catForm.value.sort_order, flow_type: catForm.value.flow_type })
}
ElMessage.success('保存成功')
catDialogVisible.value = false
await loadData()
} catch (e) { console.error(e) }
}
const removeCategory = async (cat: CategoryItem) => {
try {
await ElMessageBox.confirm(`确定删除品类「${cat.name}」?子类型和颜色也将被删除`, '警告', { type: 'warning' })
await deleteCategory(cat.id)
ElMessage.success('品类已删除')
await loadData()
} catch (e: any) { if (e !== 'cancel') console.error(e) }
}
// ---- 子类型 ----
const showAddSubType = (catId: number) => {
stForm.value = { id: 0, category_id: catId, name: '', description: '', sort_order: 0 }
stDialogVisible.value = true
}
const editSubType = (st: SubTypeItem) => {
stForm.value = { ...st }
stDialogVisible.value = true
}
const saveSt = async () => {
try {
if (stForm.value.id) {
await updateSubType(stForm.value.id, { name: stForm.value.name, description: stForm.value.description, sort_order: stForm.value.sort_order })
} else {
await createSubType({ category_id: stForm.value.category_id, name: stForm.value.name, description: stForm.value.description, sort_order: stForm.value.sort_order })
}
ElMessage.success('保存成功')
stDialogVisible.value = false
await loadData()
} catch (e) { console.error(e) }
}
const removeSubType = async (st: SubTypeItem) => {
try {
await ElMessageBox.confirm(`确定删除子类型「${st.name}」?`, '确认')
await deleteSubType(st.id)
ElMessage.success('子类型已删除')
await loadData()
} catch (e: any) { if (e !== 'cancel') console.error(e) }
}
// ---- 颜色 ----
const showAddColor = (catId: number) => {
colorForm.value = { id: 0, category_id: catId, name: '', hex_code: '#8B7355', sort_order: 0 }
colorDialogVisible.value = true
}
const editColor = (c: ColorItem, catId: number) => {
colorForm.value = { ...c, category_id: catId }
colorDialogVisible.value = true
}
const saveColor = async () => {
try {
if (colorForm.value.id) {
await updateColor(colorForm.value.id, { name: colorForm.value.name, hex_code: colorForm.value.hex_code, sort_order: colorForm.value.sort_order })
} else {
await createColor({ category_id: colorForm.value.category_id, name: colorForm.value.name, hex_code: colorForm.value.hex_code, sort_order: colorForm.value.sort_order })
}
ElMessage.success('保存成功')
colorDialogVisible.value = false
await loadData()
} catch (e) { console.error(e) }
}
const removeColor = async (c: ColorItem) => {
try {
await ElMessageBox.confirm(`确定删除颜色「${c.name}」?`, '确认')
await deleteColor(c.id)
ElMessage.success('颜色已删除')
await loadData()
} catch (e: any) { if (e !== 'cancel') console.error(e) }
}
onMounted(loadData)
</script>
<style scoped lang="scss">
.page-actions { margin-bottom: 20px; }
.category-card {
background: #fff;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
.cat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.cat-info {
display: flex;
align-items: center;
gap: 10px;
.cat-icon { font-size: 22px; }
.cat-name { font-size: 16px; font-weight: 600; }
}
}
.sub-section {
margin-top: 16px;
.sub-title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
font-size: 14px;
font-weight: 500;
color: #666;
}
}
.color-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.color-chip {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #f9f9f9;
border-radius: 6px;
font-size: 13px;
.color-dot {
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid #ddd;
}
.color-hex { color: #999; font-family: monospace; font-size: 12px; }
}
</style>

View File

@@ -0,0 +1,381 @@
<template>
<div class="config-page">
<!-- 默认模型选择 -->
<div class="section-card">
<h3 class="section-title">默认生图模型</h3>
<div class="model-switch">
<div
class="model-option"
:class="{ active: defaultModel === 'flux-dev' }"
@click="setDefaultModel('flux-dev')"
>
<div class="model-badge">默认</div>
<div class="model-name">SiliconFlow FLUX.1 [dev]</div>
<div class="model-price">~0.13 /</div>
<div class="model-tag">性价比高</div>
</div>
<div
class="model-option"
:class="{ active: defaultModel === 'seedream-4.5' }"
@click="setDefaultModel('seedream-4.5')"
>
<div class="model-badge">备选</div>
<div class="model-name">火山引擎 Seedream 4.5</div>
<div class="model-price">~0.30 /</div>
<div class="model-tag">高质量</div>
</div>
</div>
</div>
<!-- SiliconFlow 配置卡片 -->
<div class="section-card">
<div class="card-header">
<div class="card-title-row">
<h3 class="section-title">SiliconFlow FLUX.1 [dev]</h3>
<el-tag :type="siliconflowStatus" size="small">
{{ siliconflowStatusText }}
</el-tag>
</div>
<p class="card-desc">硅基流动文生图 API基于 FLUX.1 开源模型性价比高</p>
</div>
<el-form label-width="120px" class="config-form">
<el-form-item label="API Key">
<el-input
v-model="siliconflowKey"
type="password"
show-password
placeholder="请输入 SiliconFlow API Key"
clearable
/>
</el-form-item>
<el-form-item label="接口地址">
<el-input v-model="siliconflowUrl" placeholder="https://api.siliconflow.cn/v1" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="testSiliconflow" :loading="testingSiliconflow">
测试连接
</el-button>
<span v-if="siliconflowTestResult" :class="['test-result', siliconflowTestResult.ok ? 'success' : 'fail']">
{{ siliconflowTestResult.msg }}
</span>
</el-form-item>
</el-form>
</div>
<!-- 火山引擎配置卡片 -->
<div class="section-card">
<div class="card-header">
<div class="card-title-row">
<h3 class="section-title">火山引擎 Seedream 4.5</h3>
<el-tag :type="volcengineStatus" size="small">
{{ volcengineStatusText }}
</el-tag>
</div>
<p class="card-desc">字节跳动火山引擎文生图 APISeedream 4.5 模型高质量输出</p>
</div>
<el-form label-width="120px" class="config-form">
<el-form-item label="API Key">
<el-input
v-model="volcengineKey"
type="password"
show-password
placeholder="请输入火山引擎 API Key"
clearable
/>
</el-form-item>
<el-form-item label="接口地址">
<el-input v-model="volcengineUrl" placeholder="https://ark.cn-beijing.volces.com/api/v3" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="testVolcengine" :loading="testingVolcengine">
测试连接
</el-button>
<span v-if="volcengineTestResult" :class="['test-result', volcengineTestResult.ok ? 'success' : 'fail']">
{{ volcengineTestResult.msg }}
</span>
</el-form-item>
</el-form>
</div>
<!-- 通用设置 -->
<div class="section-card">
<h3 class="section-title">通用设置</h3>
<el-form label-width="120px" class="config-form">
<el-form-item label="图片尺寸">
<el-select v-model="imageSize" style="width: 200px">
<el-option label="512 x 512" value="512" />
<el-option label="768 x 768" value="768" />
<el-option label="1024 x 1024" value="1024" />
</el-select>
<span class="form-tip">生成图片的宽高像素</span>
</el-form-item>
</el-form>
</div>
<!-- 保存按钮 -->
<div class="save-bar">
<el-button type="primary" size="large" @click="handleSave" :loading="saving">
保存所有配置
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getConfigs, updateConfigsBatch } from '@/api/admin'
import request from '@/api/request'
// 配置值
const defaultModel = ref('flux-dev')
const siliconflowKey = ref('')
const siliconflowUrl = ref('https://api.siliconflow.cn/v1')
const volcengineKey = ref('')
const volcengineUrl = ref('https://ark.cn-beijing.volces.com/api/v3')
const imageSize = ref('1024')
const saving = ref(false)
// 测试状态
const testingSiliconflow = ref(false)
const testingVolcengine = ref(false)
const siliconflowTestResult = ref<{ ok: boolean; msg: string } | null>(null)
const volcengineTestResult = ref<{ ok: boolean; msg: string } | null>(null)
// 状态计算
const siliconflowStatus = computed(() => siliconflowKey.value ? 'success' : 'info')
const siliconflowStatusText = computed(() => siliconflowKey.value ? '已配置' : '未配置')
const volcengineStatus = computed(() => volcengineKey.value ? 'success' : 'info')
const volcengineStatusText = computed(() => volcengineKey.value ? '已配置' : '未配置')
// 加载配置
const loadConfigs = async () => {
try {
const data = await getConfigs() as any
const items: any[] = data.items || []
const map: Record<string, string> = {}
for (const item of items) {
map[item.config_key] = item.config_value || ''
}
defaultModel.value = map['AI_IMAGE_MODEL'] || 'flux-dev'
// 注意API Key 是脱敏的(****),不回填到输入框
// 只有完整值才回填
if (map['SILICONFLOW_API_KEY'] && !map['SILICONFLOW_API_KEY'].includes('****')) {
siliconflowKey.value = map['SILICONFLOW_API_KEY']
}
siliconflowUrl.value = map['SILICONFLOW_BASE_URL'] || 'https://api.siliconflow.cn/v1'
if (map['VOLCENGINE_API_KEY'] && !map['VOLCENGINE_API_KEY'].includes('****')) {
volcengineKey.value = map['VOLCENGINE_API_KEY']
}
volcengineUrl.value = map['VOLCENGINE_BASE_URL'] || 'https://ark.cn-beijing.volces.com/api/v3'
imageSize.value = map['AI_IMAGE_SIZE'] || '1024'
} catch (e) {
console.error('加载配置失败', e)
}
}
// 切换默认模型
const setDefaultModel = (model: string) => {
defaultModel.value = model
}
// 保存配置
const handleSave = async () => {
saving.value = true
try {
const configs: Record<string, string> = {
AI_IMAGE_MODEL: defaultModel.value,
SILICONFLOW_BASE_URL: siliconflowUrl.value,
VOLCENGINE_BASE_URL: volcengineUrl.value,
AI_IMAGE_SIZE: imageSize.value,
}
// API Key 只有用户填写了才提交(留空不覆盖)
if (siliconflowKey.value) {
configs['SILICONFLOW_API_KEY'] = siliconflowKey.value
}
if (volcengineKey.value) {
configs['VOLCENGINE_API_KEY'] = volcengineKey.value
}
await updateConfigsBatch(configs)
ElMessage.success('配置已保存')
await loadConfigs()
} catch (e) {
console.error('保存失败', e)
} finally {
saving.value = false
}
}
// 测试 SiliconFlow 连接
const testSiliconflow = async () => {
if (!siliconflowKey.value) {
siliconflowTestResult.value = { ok: false, msg: '请先填写 API Key' }
return
}
testingSiliconflow.value = true
siliconflowTestResult.value = null
try {
const resp = await request.post('/admin/configs/test', {
provider: 'siliconflow',
api_key: siliconflowKey.value,
base_url: siliconflowUrl.value,
}) as any
siliconflowTestResult.value = { ok: true, msg: resp.message || '连接成功' }
} catch (e: any) {
const msg = e.response?.data?.detail || '连接失败'
siliconflowTestResult.value = { ok: false, msg }
} finally {
testingSiliconflow.value = false
}
}
// 测试火山引擎连接
const testVolcengine = async () => {
if (!volcengineKey.value) {
volcengineTestResult.value = { ok: false, msg: '请先填写 API Key' }
return
}
testingVolcengine.value = true
volcengineTestResult.value = null
try {
const resp = await request.post('/admin/configs/test', {
provider: 'volcengine',
api_key: volcengineKey.value,
base_url: volcengineUrl.value,
}) as any
volcengineTestResult.value = { ok: true, msg: resp.message || '连接成功' }
} catch (e: any) {
const msg = e.response?.data?.detail || '连接失败'
volcengineTestResult.value = { ok: false, msg }
} finally {
testingVolcengine.value = false
}
}
onMounted(loadConfigs)
</script>
<style scoped lang="scss">
.section-card {
background: #fff;
border-radius: 10px;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #1d1e2c;
margin: 0 0 4px;
}
.card-header {
margin-bottom: 20px;
.card-title-row {
display: flex;
align-items: center;
gap: 12px;
}
.card-desc {
font-size: 13px;
color: #999;
margin: 6px 0 0;
}
}
// 模型切换
.model-switch {
display: flex;
gap: 20px;
margin-top: 16px;
}
.model-option {
flex: 1;
position: relative;
border: 2px solid #e8e8e8;
border-radius: 12px;
padding: 24px;
cursor: pointer;
transition: all 0.25s;
text-align: center;
&:hover {
border-color: #a8d5ba;
box-shadow: 0 2px 12px rgba(91, 126, 107, 0.1);
}
&.active {
border-color: #5B7E6B;
background: linear-gradient(135deg, #f0f7f3 0%, #fff 100%);
box-shadow: 0 4px 16px rgba(91, 126, 107, 0.15);
.model-badge {
background: #5B7E6B;
color: #fff;
}
}
.model-badge {
position: absolute;
top: -10px;
left: 16px;
background: #ddd;
color: #666;
font-size: 11px;
padding: 2px 10px;
border-radius: 10px;
font-weight: 500;
}
.model-name {
font-size: 15px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.model-price {
font-size: 22px;
font-weight: 700;
color: #5B7E6B;
margin-bottom: 6px;
}
.model-tag {
font-size: 12px;
color: #999;
}
}
// 配置表单
.config-form {
margin-top: 16px;
max-width: 600px;
}
.form-tip {
font-size: 12px;
color: #999;
margin-left: 12px;
}
// 测试结果
.test-result {
margin-left: 12px;
font-size: 13px;
&.success { color: #67c23a; }
&.fail { color: #f56c6c; }
}
// 保存栏
.save-bar {
text-align: center;
padding: 20px 0;
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<div class="dashboard">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ stats.total_users }}</div>
<div class="stat-label">用户总数</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.total_designs }}</div>
<div class="stat-label">设计总数</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.total_categories }}</div>
<div class="stat-label">品类数量</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.today_designs }}</div>
<div class="stat-label">今日新增设计</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.today_users }}</div>
<div class="stat-label">今日新增用户</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getDashboard } from '@/api/admin'
const stats = ref({
total_users: 0,
total_designs: 0,
total_categories: 0,
today_designs: 0,
today_users: 0,
})
onMounted(async () => {
try {
const data = await getDashboard()
stats.value = data as any
} catch (e) {
console.error('获取仪表盘数据失败', e)
}
})
</script>
<style scoped lang="scss">
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
}
.stat-card {
background: #fff;
border-radius: 8px;
padding: 24px;
text-align: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
.stat-value {
font-size: 36px;
font-weight: 700;
color: #5B7E6B;
margin-bottom: 8px;
}
.stat-label {
font-size: 14px;
color: #999;
}
}
</style>

View File

@@ -0,0 +1,109 @@
<template>
<div class="design-manage">
<div class="page-actions">
<el-select v-model="statusFilter" placeholder="状态筛选" clearable style="width: 140px" @change="loadDesigns">
<el-option label="生成中" value="generating" />
<el-option label="已完成" value="completed" />
<el-option label="失败" value="failed" />
</el-select>
<el-button type="primary" @click="loadDesigns">刷新</el-button>
</div>
<el-table :data="designs" stripe v-loading="loading" style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户" width="120">
<template #default="{ row }">{{ row.username || '-' }}</template>
</el-table-column>
<el-table-column prop="category_name" label="品类" width="100" />
<el-table-column prop="sub_type_name" label="子类型" width="100">
<template #default="{ row }">{{ row.sub_type_name || '-' }}</template>
</el-table-column>
<el-table-column prop="color_name" label="颜色" width="100">
<template #default="{ row }">{{ row.color_name || '-' }}</template>
</el-table-column>
<el-table-column prop="prompt" label="描述" show-overflow-tooltip />
<el-table-column prop="image_url" label="图片" width="80">
<template #default="{ row }">
<el-image v-if="row.image_url" :src="row.image_url" :preview-src-list="[row.image_url]"
style="width: 40px; height: 40px; border-radius: 4px" fit="cover" />
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="100">
<template #default="{ row }">
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="total > pageSize"
:current-page="page"
:page-size="pageSize"
:total="total"
layout="prev, pager, next"
@current-change="handlePageChange"
style="margin-top: 20px; justify-content: center"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getAdminDesigns, adminDeleteDesign } from '@/api/admin'
const designs = ref<any[]>([])
const loading = ref(false)
const statusFilter = ref('')
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const formatTime = (t: string) => t ? new Date(t).toLocaleString('zh-CN') : '-'
const statusLabel = (s: string) => ({ generating: '生成中', completed: '已完成', failed: '失败' }[s] || s)
const statusType = (s: string) => ({ generating: 'warning', completed: 'success', failed: 'danger' }[s] || 'info') as any
const loadDesigns = async () => {
loading.value = true
try {
const data = await getAdminDesigns({
page: page.value,
page_size: pageSize.value,
status: statusFilter.value || undefined
}) as any
designs.value = data.items || []
total.value = data.total || 0
} catch (e) { console.error(e) }
finally { loading.value = false }
}
const handlePageChange = (p: number) => { page.value = p; loadDesigns() }
const handleDelete = async (design: any) => {
try {
await ElMessageBox.confirm(`确定要删除设计 #${design.id}`, '确认', { type: 'warning' })
await adminDeleteDesign(design.id)
ElMessage.success('设计已删除')
await loadDesigns()
} catch (e: any) { if (e !== 'cancel') console.error(e) }
}
onMounted(loadDesigns)
</script>
<style scoped lang="scss">
.page-actions {
margin-bottom: 20px;
display: flex;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,363 @@
<template>
<div class="prompt-manage">
<!-- 提示词模板区 -->
<el-card class="section-card" shadow="never">
<template #header>
<div class="card-header">
<span>提示词模板</span>
<el-tag type="info" size="small">修改后实时生效</el-tag>
</div>
</template>
<div v-loading="templateLoading">
<div v-for="tpl in templates" :key="tpl.id" class="template-item">
<div class="template-header">
<el-tag>{{ tpl.template_key }}</el-tag>
<span class="template-desc">{{ tpl.description }}</span>
</div>
<el-input
v-model="tpl.template_value"
type="textarea"
:autosize="{ minRows: 2, maxRows: 6 }"
@blur="saveTemplate(tpl)"
/>
</div>
<el-empty v-if="!templateLoading && templates.length === 0" description="暂无模板" />
</div>
</el-card>
<!-- 映射管理区 -->
<el-card class="section-card" shadow="never" style="margin-top: 16px">
<template #header>
<div class="card-header">
<span>中英映射配置</span>
<div class="header-actions">
<el-button type="primary" size="small" @click="openAddMapping">新增映射</el-button>
<el-button size="small" @click="openPreview">预览提示词</el-button>
</div>
</div>
</template>
<!-- 类型筛选标签 -->
<div class="type-tabs">
<el-tag
v-for="t in mappingTypes"
:key="t.type"
:type="currentType === t.type ? '' : 'info'"
:effect="currentType === t.type ? 'dark' : 'plain'"
class="type-tag"
@click="switchType(t.type)"
>
{{ t.label }} ({{ t.count }})
</el-tag>
<el-tag
:type="!currentType ? '' : 'info'"
:effect="!currentType ? 'dark' : 'plain'"
class="type-tag"
@click="switchType('')"
>
全部
</el-tag>
</div>
<!-- 映射表格 -->
<el-table :data="mappings" v-loading="mappingLoading" stripe style="margin-top: 12px" max-height="500">
<el-table-column prop="mapping_type" label="类型" width="100">
<template #default="{ row }">
<el-tag size="small" type="info">{{ typeLabels[row.mapping_type] || row.mapping_type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="cn_key" label="中文" width="120" />
<el-table-column prop="en_value" label="英文描述" show-overflow-tooltip />
<el-table-column prop="sort_order" label="排序" width="70" />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="openEditMapping(row)">编辑</el-button>
<el-popconfirm title="确认删除?" @confirm="handleDeleteMapping(row.id)">
<template #reference>
<el-button size="small" link type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 新增/编辑映射弹窗 -->
<el-dialog v-model="mappingDialogVisible" :title="editingMapping ? '编辑映射' : '新增映射'" width="500px">
<el-form :model="mappingForm" label-width="80px">
<el-form-item label="映射类型">
<el-select v-model="mappingForm.mapping_type" :disabled="!!editingMapping" style="width: 100%">
<el-option v-for="t in allTypes" :key="t.value" :label="t.label" :value="t.value" />
</el-select>
</el-form-item>
<el-form-item label="中文">
<el-input v-model="mappingForm.cn_key" placeholder="如:白玉" />
</el-form-item>
<el-form-item label="英文描述">
<el-input v-model="mappingForm.en_value" type="textarea" :rows="3" placeholder="如pure white nephrite jade..." />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="mappingForm.sort_order" :min="0" :max="999" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="mappingDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSaveMapping" :loading="saving">保存</el-button>
</template>
</el-dialog>
<!-- 预览弹窗 -->
<el-dialog v-model="previewVisible" title="提示词预览" width="650px">
<el-form :model="previewParams" label-width="80px" size="small">
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="品类">
<el-input v-model="previewParams.category_name" placeholder="牌子" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="视角">
<el-input v-model="previewParams.view_name" placeholder="效果图" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="颜色">
<el-input v-model="previewParams.color_name" placeholder="白玉" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="子类型">
<el-input v-model="previewParams.sub_type_name" placeholder="平安扣" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="工艺">
<el-input v-model="previewParams.carving_technique" placeholder="浮雕" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="风格">
<el-input v-model="previewParams.design_style" placeholder="古典" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<el-button type="primary" size="small" @click="handlePreview" :loading="previewing" style="margin-bottom: 12px">
生成预览
</el-button>
<div v-if="previewResult" class="preview-result">
<div class="preview-label">生成的英文提示词</div>
<div class="preview-text">{{ previewResult }}</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import {
getPromptTemplates, updatePromptTemplate,
getPromptMappings, getMappingTypes,
createPromptMapping, updatePromptMapping, deletePromptMapping,
previewPrompt
} from '@/api/admin'
interface Template {
id: number
template_key: string
template_value: string
description: string
}
interface MappingType {
type: string
count: number
label: string
}
interface Mapping {
id: number
mapping_type: string
cn_key: string
en_value: string
sort_order: number
}
const typeLabels: Record<string, string> = {
category: '品类', color: '颜色', view: '视角',
carving: '雕刻工艺', style: '设计风格', motif: '题材纹样',
finish: '表面处理', scene: '用途场景', sub_type: '子类型'
}
const allTypes = [
{ value: 'category', label: '品类' },
{ value: 'color', label: '颜色' },
{ value: 'sub_type', label: '子类型' },
{ value: 'view', label: '视角' },
{ value: 'carving', label: '雕刻工艺' },
{ value: 'style', label: '设计风格' },
{ value: 'motif', label: '题材纹样' },
{ value: 'finish', label: '表面处理' },
{ value: 'scene', label: '用途场景' },
]
// 模板
const templates = ref<Template[]>([])
const templateLoading = ref(false)
// 映射
const mappingTypes = ref<MappingType[]>([])
const mappings = ref<Mapping[]>([])
const mappingLoading = ref(false)
const currentType = ref('')
// 弹窗
const mappingDialogVisible = ref(false)
const editingMapping = ref<Mapping | null>(null)
const mappingForm = ref({ mapping_type: 'category', cn_key: '', en_value: '', sort_order: 0 })
const saving = ref(false)
// 预览
const previewVisible = ref(false)
const previewParams = ref<Record<string, string>>({ category_name: '牌子', view_name: '效果图' })
const previewResult = ref('')
const previewing = ref(false)
async function loadTemplates() {
templateLoading.value = true
try {
const res = await getPromptTemplates()
templates.value = res.data
} catch { /* ignore */ } finally { templateLoading.value = false }
}
async function saveTemplate(tpl: Template) {
try {
await updatePromptTemplate(tpl.id, { template_value: tpl.template_value, description: tpl.description })
ElMessage.success('模板已保存')
} catch { ElMessage.error('保存失败') }
}
async function loadMappingTypes() {
try {
const res = await getMappingTypes()
mappingTypes.value = res.data
} catch { /* ignore */ }
}
async function loadMappings() {
mappingLoading.value = true
try {
const res = await getPromptMappings(currentType.value || undefined)
mappings.value = res.data
} catch { /* ignore */ } finally { mappingLoading.value = false }
}
function switchType(type: string) {
currentType.value = type
loadMappings()
}
function openAddMapping() {
editingMapping.value = null
mappingForm.value = { mapping_type: currentType.value || 'category', cn_key: '', en_value: '', sort_order: 0 }
mappingDialogVisible.value = true
}
function openEditMapping(row: Mapping) {
editingMapping.value = row
mappingForm.value = { mapping_type: row.mapping_type, cn_key: row.cn_key, en_value: row.en_value, sort_order: row.sort_order }
mappingDialogVisible.value = true
}
async function handleSaveMapping() {
if (!mappingForm.value.cn_key || !mappingForm.value.en_value) {
ElMessage.warning('请填写完整')
return
}
saving.value = true
try {
if (editingMapping.value) {
await updatePromptMapping(editingMapping.value.id, {
cn_key: mappingForm.value.cn_key,
en_value: mappingForm.value.en_value,
sort_order: mappingForm.value.sort_order
})
} else {
await createPromptMapping(mappingForm.value)
}
ElMessage.success('保存成功')
mappingDialogVisible.value = false
loadMappings()
loadMappingTypes()
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '保存失败')
} finally { saving.value = false }
}
async function handleDeleteMapping(id: number) {
try {
await deletePromptMapping(id)
ElMessage.success('已删除')
loadMappings()
loadMappingTypes()
} catch { ElMessage.error('删除失败') }
}
function openPreview() {
previewResult.value = ''
previewVisible.value = true
}
async function handlePreview() {
previewing.value = true
try {
const res = await previewPrompt(previewParams.value)
previewResult.value = res.data.prompt
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '预览失败')
} finally { previewing.value = false }
}
onMounted(() => {
loadTemplates()
loadMappingTypes()
loadMappings()
})
</script>
<style scoped lang="scss">
.section-card {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-actions {
display: flex;
gap: 8px;
}
}
.template-item {
margin-bottom: 16px;
.template-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
.template-desc { color: #999; font-size: 13px; }
}
}
.type-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
.type-tag { cursor: pointer; }
}
.preview-result {
background: #f5f7fa;
border-radius: 8px;
padding: 16px;
.preview-label { color: #666; font-size: 13px; margin-bottom: 8px; }
.preview-text { font-family: monospace; font-size: 13px; line-height: 1.6; word-break: break-all; color: #333; }
}
</style>

View File

@@ -0,0 +1,129 @@
<template>
<div class="user-manage">
<div class="page-actions">
<el-input v-model="keyword" placeholder="搜索用户名/昵称" clearable style="width: 260px" @keyup.enter="loadUsers" />
<el-button type="primary" @click="loadUsers">搜索</el-button>
</div>
<el-table :data="users" stripe v-loading="loading" style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" width="150" />
<el-table-column prop="nickname" label="昵称" width="150">
<template #default="{ row }">{{ row.nickname || '-' }}</template>
</el-table-column>
<el-table-column prop="phone" label="手机号" width="140">
<template #default="{ row }">{{ row.phone || '-' }}</template>
</el-table-column>
<el-table-column prop="design_count" label="设计数" width="100" />
<el-table-column prop="is_admin" label="管理员" width="100">
<template #default="{ row }">
<el-tag :type="row.is_admin ? 'success' : 'info'" size="small">
{{ row.is_admin ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="注册时间" width="180">
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="200">
<template #default="{ row }">
<el-button size="small" :type="row.is_admin ? 'warning' : 'primary'" @click="toggleAdmin(row)">
{{ row.is_admin ? '取消管理员' : '设为管理员' }}
</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="total > pageSize"
:current-page="page"
:page-size="pageSize"
:total="total"
layout="prev, pager, next"
@current-change="handlePageChange"
style="margin-top: 20px; justify-content: center"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getUsers, setUserAdmin, deleteUser } from '@/api/admin'
interface AdminUser {
id: number
username: string
nickname: string | null
phone: string | null
is_admin: boolean
created_at: string
design_count: number
}
const users = ref<AdminUser[]>([])
const loading = ref(false)
const keyword = ref('')
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const formatTime = (t: string) => {
if (!t) return '-'
return new Date(t).toLocaleString('zh-CN')
}
const loadUsers = async () => {
loading.value = true
try {
const data = await getUsers({ page: page.value, page_size: pageSize.value, keyword: keyword.value || undefined }) as any
users.value = data.items || []
total.value = data.total || 0
} catch (e) {
console.error('加载用户列表失败', e)
} finally {
loading.value = false
}
}
const handlePageChange = (p: number) => {
page.value = p
loadUsers()
}
const toggleAdmin = async (user: AdminUser) => {
try {
const newStatus = !user.is_admin
await ElMessageBox.confirm(
`确定要${newStatus ? '设为管理员' : '取消管理员'}${user.username}`, '确认'
)
await setUserAdmin(user.id, newStatus)
ElMessage.success('操作成功')
await loadUsers()
} catch (e: any) {
if (e !== 'cancel') console.error(e)
}
}
const handleDelete = async (user: AdminUser) => {
try {
await ElMessageBox.confirm(`确定要删除用户 ${user.username}?此操作不可恢复!`, '警告', { type: 'warning' })
await deleteUser(user.id)
ElMessage.success('用户已删除')
await loadUsers()
} catch (e: any) {
if (e !== 'cancel') console.error(e)
}
}
onMounted(loadUsers)
</script>
<style scoped lang="scss">
.page-actions {
margin-bottom: 20px;
display: flex;
gap: 12px;
}
</style>

View File

@@ -313,3 +313,44 @@ INSERT INTO colors (category_id, name, hex_code, sort_order) VALUES
(12, '墨玉', '#2C2C2C', 8), (12, '墨玉', '#2C2C2C', 8),
(12, '藕粉', '#E8B4B8', 9), (12, '藕粉', '#E8B4B8', 9),
(12, '烟紫', '#8B7D9B', 10); (12, '烟紫', '#8B7D9B', 10);
-- ========================================
-- design_images 表AI 多视角设计图)
-- ========================================
CREATE TABLE IF NOT EXISTS design_images (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '图片ID',
design_id BIGINT NOT NULL COMMENT '关联设计ID',
view_name VARCHAR(20) NOT NULL COMMENT '视角名称: 效果图/正面图/侧面图/背面图',
image_url VARCHAR(255) DEFAULT NULL COMMENT '图片URL路径',
model_used VARCHAR(50) DEFAULT NULL COMMENT '使用的AI模型: flux-dev/seedream-4.5',
prompt_used TEXT DEFAULT NULL COMMENT '实际使用的英文prompt',
sort_order INT DEFAULT 0 COMMENT '排序',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
CONSTRAINT fk_design_images_design FOREIGN KEY (design_id) REFERENCES designs(id) ON DELETE CASCADE,
INDEX idx_design_images_design_id (design_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI多视角设计图';
-- ========================================
-- system_configs 表(系统配置)
-- ========================================
CREATE TABLE IF NOT EXISTS system_configs (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '配置ID',
config_key VARCHAR(100) NOT NULL UNIQUE COMMENT '配置键',
config_value TEXT COMMENT '配置值',
description VARCHAR(255) COMMENT '配置说明',
config_group VARCHAR(50) NOT NULL DEFAULT 'general' COMMENT '配置分组: ai/general',
is_secret CHAR(1) NOT NULL DEFAULT 'N' COMMENT '是否敏感信息(Y/N)',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置表';
-- 插入默认 AI 配置项
INSERT IGNORE INTO system_configs (config_key, config_value, description, config_group, is_secret) VALUES
('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');
-- users 表添加 is_admin 字段(如果不存在)
-- ALTER TABLE users ADD COLUMN is_admin TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否管理员' AFTER avatar;