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:
110
README.md
110
README.md
@@ -1,6 +1,6 @@
|
|||||||
# 玉宗 - 珠宝设计大师
|
# 玉宗 - 珠宝设计大师
|
||||||
|
|
||||||
AI 驱动的珠宝玉石设计生成系统,支持 12 种玉石品类的智能设计图生成。
|
AI 驱动的珠宝玉石设计生成系统,支持 12 种玉石品类的智能设计图生成,双 AI 模型多视角生图,内置后台管理系统。
|
||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
@@ -14,10 +14,31 @@ AI 驱动的珠宝玉石设计生成系统,支持 12 种玉石品类的智能
|
|||||||
- **实现方式**:品类通过 `flow_type` 字段区分三种工作流程:`full`(选子类型,如牌子选牌型)、`size_color`(选尺寸+颜色,如珠子)、`simple`(直接设计);前端 `SubTypePanel` 组件根据 flow_type 动态渲染不同的选择界面
|
- **实现方式**:品类通过 `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 Key(FLUX.1 生图) |
|
||||||
|
| `SILICONFLOW_BASE_URL` | `https://api.siliconflow.cn/v1` | 否 | 硅基流动 API 地址 |
|
||||||
|
| `VOLCENGINE_API_KEY` | 空 | 否 | 火山引擎 API Key(Seedream 4.5 备选) |
|
||||||
|
| `VOLCENGINE_BASE_URL` | `https://ark.cn-beijing.volces.com/api/v3` | 否 | 火山引擎 API 地址 |
|
||||||
|
| `AI_IMAGE_MODEL` | `flux-dev` | 否 | 默认 AI 模型(flux-dev / seedream-4.5) |
|
||||||
|
| `AI_IMAGE_SIZE` | `1024` | 否 | 生成图片尺寸 |
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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}')>"
|
||||||
|
|||||||
29
backend/app/models/design_image.py
Normal file
29
backend/app/models/design_image.py
Normal 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}')>"
|
||||||
37
backend/app/models/prompt_template.py
Normal file
37
backend/app/models/prompt_template.py
Normal 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}')>"
|
||||||
24
backend/app/models/system_config.py
Normal file
24
backend/app/models/system_config.py
Normal 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}')>"
|
||||||
@@ -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="更新时间")
|
||||||
|
|
||||||
|
|||||||
627
backend/app/routers/admin.py
Normal file
627
backend/app/routers/admin.py
Normal 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)}")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
173
backend/app/schemas/admin.py
Normal file
173
backend/app/schemas/admin.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
144
backend/app/services/ai_generator.py
Normal file
144
backend/app/services/ai_generator.py
Normal 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}"
|
||||||
58
backend/app/services/config_service.py
Normal file
58
backend/app/services/config_service.py
Normal 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")),
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
164
backend/app/services/prompt_builder.py
Normal file
164
backend/app/services/prompt_builder.py
Normal 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)
|
||||||
@@ -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
203
backend/init_prompt_data.py
Normal 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✅ 全部提示词数据初始化完成!")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
94
frontend/src/api/admin.ts
Normal 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)
|
||||||
@@ -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
|
||||||
|
|||||||
177
frontend/src/components/AdminLayout.vue
Normal file
177
frontend/src/components/AdminLayout.vue
Normal 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">⚙</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>
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
7
frontend/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
299
frontend/src/views/admin/CategoryManage.vue
Normal file
299
frontend/src/views/admin/CategoryManage.vue
Normal 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>
|
||||||
381
frontend/src/views/admin/ConfigManage.vue
Normal file
381
frontend/src/views/admin/ConfigManage.vue
Normal 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">字节跳动火山引擎文生图 API,Seedream 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>
|
||||||
75
frontend/src/views/admin/Dashboard.vue
Normal file
75
frontend/src/views/admin/Dashboard.vue
Normal 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>
|
||||||
109
frontend/src/views/admin/DesignManage.vue
Normal file
109
frontend/src/views/admin/DesignManage.vue
Normal 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>
|
||||||
363
frontend/src/views/admin/PromptManage.vue
Normal file
363
frontend/src/views/admin/PromptManage.vue
Normal 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>
|
||||||
129
frontend/src/views/admin/UserManage.vue
Normal file
129
frontend/src/views/admin/UserManage.vue
Normal 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>
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user