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 动态渲染不同的选择界面
|
||||
- **优点**:灵活的品类工作流适配不同产品特性,用户操作路径清晰
|
||||
|
||||
### 3. 设计图生成
|
||||
- **功能说明**:用户选择品类参数后输入设计描述,系统生成 800×800 PNG 设计图
|
||||
- **实现方式**:后端 `mock_generator` 使用 Pillow 生成设计图,包含品类信息、颜色映射(中文颜色名→HEX)、自动文字颜色对比计算、系统中文字体检测(PingFang/STHeiti/DroidSansFallback);设计记录先创建(status=generating),生成完成后更新为 completed
|
||||
- **优点**:即时生成预览图,支持颜色定制;后续可替换为真实 AI 模型
|
||||
### 3. AI 多视角设计图生成
|
||||
- **功能说明**:用户选择品类参数后,可配置 6 个可选设计参数(雕刻工艺、设计风格、题材纹样、尺寸规格、表面处理、用途场景),输入设计描述后系统自动生成多视角设计图(每个品类 2~4 张不同视角)
|
||||
- **双模型支持**:
|
||||
- **SiliconFlow FLUX.1 [dev]**(默认):~0.13元/张,性价比高
|
||||
- **火山引擎 Seedream 4.5**(备选):~0.30元/张,高质量
|
||||
- **提示词系统**:自动将中文参数(品类/子类型/颜色/6个设计参数+视角)构建为专业英文 prompt,包含玉雕行业专业术语、摄影角度、质量标签
|
||||
- **按品类视角配置**:
|
||||
| 品类 | 视角数 | 视角列表 |
|
||||
|------|--------|----------|
|
||||
| 牌子 | 3 | 效果图(45°)、正面图、背面图 |
|
||||
| 珠子 | 2 | 效果图(45°)、正面图 |
|
||||
| 手把件/雕刻件/摆件 | 4 | 效果图(45°)、正面图、侧面图、背面图 |
|
||||
| 手镯 | 3 | 效果图(45°)、正面图、侧面图 |
|
||||
| 耳钉/耳饰/手链/项链/表带 | 2 | 效果图(45°)、正面图 |
|
||||
| 戒指 | 3 | 效果图(45°)、正面图、侧面图 |
|
||||
- **降级机制**:AI 生图失败时自动降级到 mock_generator 生成占位图
|
||||
- **可选参数**:
|
||||
- **雕刻工艺**:浮雕、圆雕、镂空雕、阴刻、线雕、俍色雕、薄意雕、素面,支持自定义输入
|
||||
- **设计风格**:古典传统、新中式、写实、抽象意境、极简素面,支持自定义输入
|
||||
- **题材纹样**:观音、弥勒、莲花、貔貅、龙凤、麒麟、山水、花鸟、人物、回纹、如意、平安扣,支持自定义输入
|
||||
- **尺寸规格**:根据品类动态变化(牌子尺寸/手镯内径/手把件大小等),支持自定义输入
|
||||
- **表面处理**:高光抛光、亚光/哑光、磨砂、保留皮色,支持自定义输入
|
||||
- **用途场景**:日常佩戴、收藏鉴赏、送礼婚庆、把玩文玩,支持自定义输入
|
||||
- **实现方式**:后端 `prompt_builder` 自动构建专业英文 prompt → `ai_generator` 调用 AI API 生图 → 下载保存到本地 → 创建 `DesignImage` 多视角记录;降级时使用 `mock_generator` Pillow 生成占位图
|
||||
- **优点**:AI 真实生图 + 多视角展示,专业玉雕提示词系统,双模型热切换 + 降级兜底
|
||||
|
||||
### 4. 设计管理
|
||||
- **功能说明**:用户中心查看设计历史列表(分页),支持预览、下载、删除设计
|
||||
@@ -29,6 +50,17 @@ AI 驱动的珠宝玉石设计生成系统,支持 12 种玉石品类的智能
|
||||
- **实现方式**:手机号唯一性校验,密码修改需验证旧密码;前端表单使用 Element Plus 表单验证
|
||||
- **优点**:安全的密码修改流程,手机号去重保障数据一致性
|
||||
|
||||
### 6. 后台管理系统
|
||||
- **功能说明**:管理员专属后台,支持仪表盘、系统配置、用户管理、品类管理、设计管理五大模块
|
||||
- **实现方式**:
|
||||
- **权限控制**:用户表 `is_admin` 字段 + `get_admin_user` 依赖注入,非管理员访问返回 403
|
||||
- **系统配置**:`system_configs` 表存储配置项,数据库配置优先于 .env 文件;敏感信息(API Key)脱敏显示
|
||||
- **品类管理**:支持品类、子类型、颜色的增删改查,含颜色拾色器
|
||||
- **用户管理**:搜索、分页、设置/取消管理员、删除用户
|
||||
- **设计管理**:查看所有用户设计,按状态筛选,管理员删除
|
||||
- **前端独立布局**:左侧导航 + 内容区,管理后台与前台完全分离
|
||||
- **优点**:AI 配置可热更新(无需重启服务),品类数据可视化管理,用户权限分级控制
|
||||
|
||||
## 业务流程
|
||||
|
||||
### 整体流程图
|
||||
@@ -43,9 +75,10 @@ graph TB
|
||||
D --> G[生成页 - 输入设计描述]
|
||||
E --> G
|
||||
F --> G
|
||||
G --> H[提交生成请求]
|
||||
H --> I[后端生成设计图]
|
||||
I --> J[预览设计图]
|
||||
G --> G2[选择可选参数]
|
||||
G2 --> H[提交生成请求]
|
||||
H --> I[后端 AI 多视角生图]
|
||||
I --> J[预览多视角设计图]
|
||||
J --> K{用户操作}
|
||||
K -->|下载| L[下载 PNG 文件]
|
||||
K -->|重新生成| G
|
||||
@@ -58,18 +91,20 @@ graph TB
|
||||
|
||||
1. **用户认证**:注册时检查用户名唯一性 → 密码 bcrypt 加密 → 创建用户记录 → 注册成功后自动登录 → 获取 JWT Token 存储到 localStorage
|
||||
2. **品类选择**:进入设计页自动加载品类列表 → 左侧导航选择品类 → 根据 flow_type 加载对应的子类型/颜色数据 → 右侧面板显示选择界面
|
||||
3. **设计生成**:跳转生成页携带品类参数 → 输入设计描述(最多 500 字) → 提交请求 → 显示水墨风格加载动画 → 生成完成后展示预览
|
||||
3. **设计生成**:跳转生成页携带品类参数 → 选择可选参数(工艺/风格/题材/尺寸/表面/用途) → 输入设计描述(最多 2000 字) → 提交请求 → 显示水墨风格加载动画 → 生成完成后展示预览
|
||||
4. **设计管理**:用户中心加载设计列表 → 卡片网格展示 → 支持分页浏览、下载 PNG、删除(确认弹窗)、点击卡片跳转重新编辑
|
||||
|
||||
### 关键数据流图
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[前端 Store] -->|Axios + Token| B[API 路由]
|
||||
B -->|Depends 注入| C[Service 层]
|
||||
A[前端 Store] -->|“Axios + Token”| B[API 路由]
|
||||
B -->|“Depends 注入”| C[Service 层]
|
||||
C -->|prompt_builder| D1[英文 Prompt 构建]
|
||||
D1 -->|ai_generator| D2[AI 生图 API]
|
||||
D2 -->|下载保存| E[uploads/ 目录]
|
||||
C -->|SQLAlchemy ORM| D[MySQL 数据库]
|
||||
C -->|Pillow 生成| E[uploads/ 目录]
|
||||
E -->|StaticFiles 服务| F[前端图片展示]
|
||||
E -->|StaticFiles 服务| F[前端多视角展示]
|
||||
```
|
||||
|
||||
### API 调用链路
|
||||
@@ -84,11 +119,22 @@ graph LR
|
||||
| `/api/categories` | GET | 获取品类列表 | - |
|
||||
| `/api/categories/{id}/sub-types` | GET | 获取子类型 | category_id |
|
||||
| `/api/categories/{id}/colors` | GET | 获取颜色选项 | category_id |
|
||||
| `/api/designs/generate` | POST | 生成设计 | category_id, sub_type_id, color_id, prompt |
|
||||
| `/api/designs/generate` | POST | 生成设计 | category_id, sub_type_id, color_id, prompt, carving_technique?, design_style?, motif?, size_spec?, surface_finish?, usage_scene? |
|
||||
| `/api/designs` | GET | 设计列表(分页) | page, page_size |
|
||||
| `/api/designs/{id}` | GET | 设计详情 | design_id |
|
||||
| `/api/designs/{id}` | DELETE | 删除设计 | design_id |
|
||||
| `/api/designs/{id}/download` | GET | 下载设计图 | design_id |
|
||||
| `/api/admin/dashboard` | GET | 管理仪表盘统计 | Bearer Token (管理员) |
|
||||
| `/api/admin/configs` | GET | 获取系统配置 | group? |
|
||||
| `/api/admin/configs` | PUT | 更新系统配置 | configs: {key: value} |
|
||||
| `/api/admin/configs/init` | POST | 初始化默认配置 | - |
|
||||
| `/api/admin/users` | GET | 用户列表 | page, page_size, keyword? |
|
||||
| `/api/admin/users/{id}/admin` | PUT | 设置管理员 | is_admin |
|
||||
| `/api/admin/categories` | GET/POST | 品类列表/创建 | name, flow_type, sort_order |
|
||||
| `/api/admin/categories/{id}` | PUT/DELETE | 更新/删除品类 | - |
|
||||
| `/api/admin/sub-types` | POST | 创建子类型 | category_id, name |
|
||||
| `/api/admin/colors` | POST | 创建颜色 | category_id, name, hex_code |
|
||||
| `/api/admin/designs` | GET | 所有设计列表 | page, status? |
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -107,7 +153,9 @@ graph LR
|
||||
| **数据库驱动** | PyMySQL | 1.1.0 | MySQL 连接 |
|
||||
| **认证** | python-jose | 3.3.0 | JWT Token |
|
||||
| **密码加密** | passlib + bcrypt | 1.7.4 | bcrypt 哈希 |
|
||||
| **图片生成** | Pillow | 10.2.0 | PNG 设计图生成 |
|
||||
| **图片生成** | Pillow | 10.2.0 | PNG 设计图生成(mock 降级) |
|
||||
| **AI 生图** | SiliconFlow FLUX.1 / Seedream 4.5 | - | 双模型多视角生图 |
|
||||
| **HTTP 客户端** | httpx | 0.27.0 | 异步调用 AI API + 图片下载 |
|
||||
| **数据库** | MySQL | - | utf8mb4 编码 |
|
||||
|
||||
## 目录结构
|
||||
@@ -124,14 +172,18 @@ YuShiSheJi/
|
||||
│ │ │ ├── auth.py # 认证路由(注册/登录)
|
||||
│ │ │ ├── categories.py # 品类查询路由
|
||||
│ │ │ ├── designs.py # 设计生成/管理路由
|
||||
│ │ │ ├── admin.py # 管理后台路由
|
||||
│ │ │ └── users.py # 用户信息路由
|
||||
│ │ ├── schemas/ # Pydantic 数据验证
|
||||
│ │ ├── services/ # 业务逻辑层
|
||||
│ │ │ ├── auth_service.py # 认证业务
|
||||
│ │ │ ├── design_service.py # 设计业务
|
||||
│ │ │ └── mock_generator.py # 图片生成服务
|
||||
│ │ │ ├── design_service.py # 设计业务(AI生图+降级)
|
||||
│ │ │ ├── ai_generator.py # AI 生图服务(双模型)
|
||||
│ │ │ ├── prompt_builder.py # 提示词构建器
|
||||
│ │ │ ├── config_service.py # 配置服务(数据库优先于.env)
|
||||
│ │ │ └── mock_generator.py # Mock图片生成(降级兜底)
|
||||
│ │ ├── utils/ # 工具函数
|
||||
│ │ │ ├── deps.py # 认证依赖注入
|
||||
│ │ │ ├── deps.py # 认证/管理员依赖注入
|
||||
│ │ │ └── security.py # JWT/密码工具
|
||||
│ │ ├── config.py # 配置管理
|
||||
│ │ ├── database.py # 数据库连接
|
||||
@@ -145,9 +197,11 @@ YuShiSheJi/
|
||||
│ │ │ ├── request.ts # Axios 实例(拦截器)
|
||||
│ │ │ ├── auth.ts # 认证接口
|
||||
│ │ │ ├── category.ts # 品类接口
|
||||
│ │ │ └── design.ts # 设计接口
|
||||
│ │ │ ├── design.ts # 设计接口
|
||||
│ │ │ └── admin.ts # 管理后台接口
|
||||
│ │ ├── components/ # 公共组件
|
||||
│ │ │ ├── AppHeader.vue # 顶部导航栏
|
||||
│ │ │ ├── AdminLayout.vue # 管理后台布局(侧边栏+内容区)
|
||||
│ │ │ ├── CategoryNav.vue # 品类左侧导航
|
||||
│ │ │ ├── SubTypePanel.vue# 子类型/颜色选择面板
|
||||
│ │ │ ├── ColorPicker.vue # 颜色选择器
|
||||
@@ -157,6 +211,12 @@ YuShiSheJi/
|
||||
│ │ │ ├── category.ts # 品类状态
|
||||
│ │ │ └── design.ts # 设计状态
|
||||
│ │ ├── views/ # 页面组件
|
||||
│ │ │ ├── admin/ # 管理后台页面
|
||||
│ │ │ │ ├── Dashboard.vue # 仪表盘
|
||||
│ │ │ │ ├── ConfigManage.vue # 系统配置管理
|
||||
│ │ │ │ ├── UserManage.vue # 用户管理
|
||||
│ │ │ │ ├── CategoryManage.vue # 品类管理
|
||||
│ │ │ │ └── DesignManage.vue # 设计管理
|
||||
│ │ │ ├── Login.vue # 登录页
|
||||
│ │ │ ├── Register.vue # 注册页
|
||||
│ │ │ ├── DesignPage.vue # 设计页(品类选择)
|
||||
@@ -236,11 +296,13 @@ uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
| 分类 | 表名 | 说明 |
|
||||
|-----|------|-----|
|
||||
| 用户 | `users` | 用户基本信息(用户名、密码、昵称、手机、头像) |
|
||||
| 用户 | `users` | 用户基本信息(用户名、密码、昵称、手机、头像、管理员标识) |
|
||||
| 品类 | `categories` | 12 种玉石品类(名称、图标、排序、流程类型) |
|
||||
| 品类 | `sub_types` | 品类子类型(牌型/尺寸,关联品类) |
|
||||
| 品类 | `colors` | 品类颜色选项(颜色名、HEX 色值,关联品类) |
|
||||
| 设计 | `designs` | 设计记录(用户、品类、子类型、颜色、描述、图片URL、状态) |
|
||||
| 设计 | `designs` | 设计记录(用户、品类、子类型、颜色、描述、6个可选参数、图片URL、状态) |
|
||||
| 设计 | `design_images` | AI 多视角设计图(视角名称、图片URL、AI模型、prompt、排序) |
|
||||
| 配置 | `system_configs` | 系统配置(AI API Key、模型、尺寸等,支持后台管理动态修改) |
|
||||
|
||||
## 环境变量
|
||||
|
||||
@@ -251,6 +313,12 @@ uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
| `ALGORITHM` | `HS256` | 否 | JWT 算法 |
|
||||
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `1440` | 否 | Token 有效期(分钟,默认 24 小时) |
|
||||
| `UPLOAD_DIR` | `uploads` | 否 | 图片上传存储目录 |
|
||||
| `SILICONFLOW_API_KEY` | 空 | 是(生图) | 硅基流动 API Key(FLUX.1 生图) |
|
||||
| `SILICONFLOW_BASE_URL` | `https://api.siliconflow.cn/v1` | 否 | 硅基流动 API 地址 |
|
||||
| `VOLCENGINE_API_KEY` | 空 | 否 | 火山引擎 API Key(Seedream 4.5 备选) |
|
||||
| `VOLCENGINE_BASE_URL` | `https://ark.cn-beijing.volces.com/api/v3` | 否 | 火山引擎 API 地址 |
|
||||
| `AI_IMAGE_MODEL` | `flux-dev` | 否 | 默认 AI 模型(flux-dev / seedream-4.5) |
|
||||
| `AI_IMAGE_SIZE` | `1024` | 否 | 生成图片尺寸 |
|
||||
|
||||
## 常用命令
|
||||
|
||||
|
||||
@@ -3,3 +3,11 @@ SECRET_KEY=yuzong-jewelry-design-secret-key-2026
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||||
UPLOAD_DIR=uploads
|
||||
|
||||
# AI 生图配置
|
||||
SILICONFLOW_API_KEY=
|
||||
SILICONFLOW_BASE_URL=https://api.siliconflow.cn/v1
|
||||
VOLCENGINE_API_KEY=
|
||||
VOLCENGINE_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
AI_IMAGE_MODEL=flux-dev
|
||||
AI_IMAGE_SIZE=1024
|
||||
|
||||
@@ -3,3 +3,11 @@ SECRET_KEY=your-secret-key-change-this
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||||
UPLOAD_DIR=uploads
|
||||
|
||||
# AI 生图配置
|
||||
SILICONFLOW_API_KEY=sk-xxx
|
||||
SILICONFLOW_BASE_URL=https://api.siliconflow.cn/v1
|
||||
VOLCENGINE_API_KEY=xxx
|
||||
VOLCENGINE_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
AI_IMAGE_MODEL=flux-dev
|
||||
AI_IMAGE_SIZE=1024
|
||||
|
||||
@@ -13,6 +13,13 @@ class Settings(BaseSettings):
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
|
||||
UPLOAD_DIR: str = "uploads"
|
||||
# AI 生图配置
|
||||
SILICONFLOW_API_KEY: str = ""
|
||||
SILICONFLOW_BASE_URL: str = "https://api.siliconflow.cn/v1"
|
||||
VOLCENGINE_API_KEY: str = ""
|
||||
VOLCENGINE_BASE_URL: str = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
AI_IMAGE_MODEL: str = "flux-dev" # flux-dev 或 seedream-4.5
|
||||
AI_IMAGE_SIZE: int = 1024
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
@@ -10,6 +10,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from .config import settings
|
||||
from .routers import categories, designs, users
|
||||
from .routers import auth
|
||||
from .routers import admin
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -62,6 +63,7 @@ app.include_router(auth.router)
|
||||
app.include_router(categories.router)
|
||||
app.include_router(designs.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(admin.router)
|
||||
|
||||
# 配置静态文件服务
|
||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||
|
||||
@@ -6,6 +6,9 @@ from ..database import Base
|
||||
from .user import User
|
||||
from .category import Category, SubType, Color
|
||||
from .design import Design
|
||||
from .design_image import DesignImage
|
||||
from .system_config import SystemConfig
|
||||
from .prompt_template import PromptTemplate, PromptMapping
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
@@ -13,5 +16,9 @@ __all__ = [
|
||||
"Category",
|
||||
"SubType",
|
||||
"Color",
|
||||
"Design"
|
||||
"Design",
|
||||
"DesignImage",
|
||||
"SystemConfig",
|
||||
"PromptTemplate",
|
||||
"PromptMapping",
|
||||
]
|
||||
|
||||
@@ -34,6 +34,7 @@ class Design(Base):
|
||||
category = relationship("Category", back_populates="designs")
|
||||
sub_type = relationship("SubType", back_populates="designs")
|
||||
color = relationship("Color", back_populates="designs")
|
||||
images = relationship("DesignImage", back_populates="design", cascade="all, delete-orphan", order_by="DesignImage.sort_order")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<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.orm import relationship
|
||||
|
||||
@@ -18,6 +18,7 @@ class User(Base):
|
||||
hashed_password = Column(String(255), nullable=False, comment="加密密码")
|
||||
nickname = Column(String(50), nullable=True, comment="昵称")
|
||||
avatar = Column(String(255), nullable=True, comment="头像URL")
|
||||
is_admin = Column(Boolean, default=False, nullable=False, comment="是否管理员")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
|
||||
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 ..models import User, Design
|
||||
from ..schemas import DesignCreate, DesignResponse, DesignListResponse
|
||||
from ..schemas import DesignCreate, DesignResponse, DesignListResponse, DesignImageResponse
|
||||
from ..utils.deps import get_current_user
|
||||
from ..services import design_service
|
||||
|
||||
@@ -18,6 +18,21 @@ router = APIRouter(prefix="/api/designs", tags=["设计"])
|
||||
|
||||
def design_to_response(design: Design) -> DesignResponse:
|
||||
"""将 Design 模型转换为响应格式"""
|
||||
# 构建多视角图片列表
|
||||
images = []
|
||||
if hasattr(design, 'images') and design.images:
|
||||
images = [
|
||||
DesignImageResponse(
|
||||
id=img.id,
|
||||
view_name=img.view_name,
|
||||
image_url=img.image_url,
|
||||
model_used=img.model_used,
|
||||
prompt_used=img.prompt_used,
|
||||
sort_order=img.sort_order,
|
||||
)
|
||||
for img in design.images
|
||||
]
|
||||
|
||||
return DesignResponse(
|
||||
id=design.id,
|
||||
user_id=design.user_id,
|
||||
@@ -51,6 +66,7 @@ def design_to_response(design: Design) -> DesignResponse:
|
||||
surface_finish=design.surface_finish,
|
||||
usage_scene=design.usage_scene,
|
||||
image_url=design.image_url,
|
||||
images=images,
|
||||
status=design.status,
|
||||
created_at=design.created_at,
|
||||
updated_at=design.updated_at
|
||||
@@ -58,17 +74,17 @@ def design_to_response(design: Design) -> DesignResponse:
|
||||
|
||||
|
||||
@router.post("/generate", response_model=DesignResponse)
|
||||
def generate_design(
|
||||
async def generate_design(
|
||||
design_data: DesignCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
提交设计生成请求
|
||||
提交设计生成请求(异步,支持 AI 多视角生图)
|
||||
需要认证
|
||||
"""
|
||||
try:
|
||||
design = design_service.create_design(
|
||||
design = await design_service.create_design_async(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
design_data=design_data
|
||||
|
||||
@@ -4,7 +4,7 @@ Pydantic Schemas
|
||||
"""
|
||||
from .user import UserCreate, UserLogin, UserResponse, Token, UserUpdate, PasswordChange
|
||||
from .category import CategoryResponse, SubTypeResponse, ColorResponse
|
||||
from .design import DesignCreate, DesignResponse, DesignListResponse
|
||||
from .design import DesignCreate, DesignResponse, DesignListResponse, DesignImageResponse
|
||||
|
||||
__all__ = [
|
||||
# User schemas
|
||||
@@ -22,4 +22,5 @@ __all__ = [
|
||||
"DesignCreate",
|
||||
"DesignResponse",
|
||||
"DesignListResponse",
|
||||
"DesignImageResponse",
|
||||
]
|
||||
|
||||
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
|
||||
|
||||
|
||||
class DesignImageResponse(BaseModel):
|
||||
"""设计图片响应(单张视角图)"""
|
||||
id: int
|
||||
view_name: str
|
||||
image_url: Optional[str] = None
|
||||
model_used: Optional[str] = None
|
||||
prompt_used: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
protected_namespaces = ()
|
||||
|
||||
|
||||
class DesignCreate(BaseModel):
|
||||
"""创建设计请求"""
|
||||
category_id: int = Field(..., description="品类ID")
|
||||
@@ -37,6 +51,7 @@ class DesignResponse(BaseModel):
|
||||
surface_finish: Optional[str] = None
|
||||
usage_scene: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
images: List[DesignImageResponse] = []
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -26,6 +26,7 @@ class UserResponse(BaseModel):
|
||||
nickname: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
avatar: Optional[str] = None
|
||||
is_admin: bool = False
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
|
||||
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 logging
|
||||
from typing import List, Optional, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
|
||||
from ..models import Design, Category, SubType, Color
|
||||
from ..models import Design, DesignImage, Category, SubType, Color
|
||||
from ..schemas import DesignCreate
|
||||
from ..config import settings
|
||||
from .mock_generator import generate_mock_design
|
||||
from .prompt_builder import get_views_for_category, build_prompt
|
||||
from . import ai_generator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_design(db: Session, user_id: int, design_data: DesignCreate) -> Design:
|
||||
def _has_ai_key() -> bool:
|
||||
"""检查是否配置了 AI API Key"""
|
||||
model = settings.AI_IMAGE_MODEL
|
||||
if model == "seedream-4.5":
|
||||
return bool(settings.VOLCENGINE_API_KEY)
|
||||
return bool(settings.SILICONFLOW_API_KEY)
|
||||
|
||||
|
||||
async def create_design_async(db: Session, user_id: int, design_data: DesignCreate) -> Design:
|
||||
"""
|
||||
创建设计记录
|
||||
|
||||
1. 创建设计记录(status=generating)
|
||||
2. 调用 mock_generator 生成图片
|
||||
3. 更新设计记录(status=completed, image_url)
|
||||
4. 返回设计对象
|
||||
创建设计记录(异步版本,支持 AI 多视角生图)
|
||||
|
||||
流程:
|
||||
1. 创建 Design 记录(status=generating)
|
||||
2. 获取品类视角列表
|
||||
3. 循环每个视角:构建 prompt → 调用 AI 生图 → 下载保存 → 创建 DesignImage
|
||||
4. 第一张效果图 URL 存入 design.image_url(兼容旧逻辑)
|
||||
5. 更新 status=completed
|
||||
6. 失败时降级到 mock_generator
|
||||
"""
|
||||
# 获取关联信息
|
||||
category = db.query(Category).filter(Category.id == design_data.category_id).first()
|
||||
if not category:
|
||||
raise ValueError(f"品类不存在: {design_data.category_id}")
|
||||
|
||||
|
||||
sub_type = None
|
||||
if design_data.sub_type_id:
|
||||
sub_type = db.query(SubType).filter(SubType.id == design_data.sub_type_id).first()
|
||||
|
||||
|
||||
color = None
|
||||
if design_data.color_id:
|
||||
color = db.query(Color).filter(Color.id == design_data.color_id).first()
|
||||
|
||||
|
||||
# 创建设计记录
|
||||
design = Design(
|
||||
user_id=user_id,
|
||||
@@ -52,8 +68,109 @@ def create_design(db: Session, user_id: int, design_data: DesignCreate) -> Desig
|
||||
)
|
||||
db.add(design)
|
||||
db.flush() # 获取 ID
|
||||
|
||||
# 生成图片
|
||||
|
||||
# 尝试 AI 生图
|
||||
if _has_ai_key():
|
||||
try:
|
||||
await _generate_ai_images(db, design, category, sub_type, color, design_data)
|
||||
db.commit()
|
||||
db.refresh(design)
|
||||
return design
|
||||
except Exception as e:
|
||||
logger.error(f"AI 生图全部失败,降级到 mock: {e}")
|
||||
db.rollback()
|
||||
# 重新查询,因为 rollback 后 ORM 对象可能失效
|
||||
design = db.query(Design).filter(Design.id == design.id).first()
|
||||
if not design:
|
||||
# rollback 导致 design 也没了,重新创建
|
||||
design = Design(
|
||||
user_id=user_id,
|
||||
category_id=design_data.category_id,
|
||||
sub_type_id=design_data.sub_type_id,
|
||||
color_id=design_data.color_id,
|
||||
prompt=design_data.prompt,
|
||||
carving_technique=design_data.carving_technique,
|
||||
design_style=design_data.design_style,
|
||||
motif=design_data.motif,
|
||||
size_spec=design_data.size_spec,
|
||||
surface_finish=design_data.surface_finish,
|
||||
usage_scene=design_data.usage_scene,
|
||||
status="generating"
|
||||
)
|
||||
db.add(design)
|
||||
db.flush()
|
||||
|
||||
# 降级到 mock 生成
|
||||
_generate_mock_fallback(db, design, category, sub_type, color, design_data)
|
||||
db.commit()
|
||||
db.refresh(design)
|
||||
return design
|
||||
|
||||
|
||||
async def _generate_ai_images(
|
||||
db: Session,
|
||||
design: Design,
|
||||
category,
|
||||
sub_type,
|
||||
color,
|
||||
design_data: DesignCreate,
|
||||
) -> None:
|
||||
"""使用 AI 模型为每个视角生成图片"""
|
||||
views = get_views_for_category(category.name)
|
||||
model = settings.AI_IMAGE_MODEL
|
||||
|
||||
for idx, view_name in enumerate(views):
|
||||
# 构建 prompt
|
||||
prompt_text = build_prompt(
|
||||
category_name=category.name,
|
||||
view_name=view_name,
|
||||
sub_type_name=sub_type.name if sub_type else None,
|
||||
color_name=color.name if color else None,
|
||||
user_prompt=design_data.prompt,
|
||||
carving_technique=design_data.carving_technique,
|
||||
design_style=design_data.design_style,
|
||||
motif=design_data.motif,
|
||||
size_spec=design_data.size_spec,
|
||||
surface_finish=design_data.surface_finish,
|
||||
usage_scene=design_data.usage_scene,
|
||||
)
|
||||
|
||||
# 调用 AI 生图
|
||||
remote_url = await ai_generator.generate_image(prompt_text, model)
|
||||
|
||||
# 下载保存到本地
|
||||
save_path = os.path.join(
|
||||
settings.UPLOAD_DIR, "designs", f"{design.id}_{view_name}.png"
|
||||
)
|
||||
local_url = await ai_generator.download_and_save(remote_url, save_path)
|
||||
|
||||
# 创建 DesignImage 记录
|
||||
design_image = DesignImage(
|
||||
design_id=design.id,
|
||||
view_name=view_name,
|
||||
image_url=local_url,
|
||||
model_used=model,
|
||||
prompt_used=prompt_text,
|
||||
sort_order=idx,
|
||||
)
|
||||
db.add(design_image)
|
||||
|
||||
# 第一张图(效果图)存入 design.image_url 兼容旧逻辑
|
||||
if idx == 0:
|
||||
design.image_url = local_url
|
||||
|
||||
design.status = "completed"
|
||||
|
||||
|
||||
def _generate_mock_fallback(
|
||||
db: Session,
|
||||
design: Design,
|
||||
category,
|
||||
sub_type,
|
||||
color,
|
||||
design_data: DesignCreate,
|
||||
) -> None:
|
||||
"""降级使用 mock 生成器"""
|
||||
save_path = os.path.join(settings.UPLOAD_DIR, "designs", f"{design.id}.png")
|
||||
image_url = generate_mock_design(
|
||||
category_name=category.name,
|
||||
@@ -68,13 +185,47 @@ def create_design(db: Session, user_id: int, design_data: DesignCreate) -> Desig
|
||||
surface_finish=design_data.surface_finish,
|
||||
usage_scene=design_data.usage_scene,
|
||||
)
|
||||
|
||||
# 更新设计记录
|
||||
design.image_url = image_url
|
||||
design.status = "completed"
|
||||
logger.info(f"Mock 降级生成完成: design_id={design.id}")
|
||||
|
||||
|
||||
def create_design(db: Session, user_id: int, design_data: DesignCreate) -> Design:
|
||||
"""
|
||||
同步版本创建设计(兼容旧调用,仅用 mock)
|
||||
"""
|
||||
category = db.query(Category).filter(Category.id == design_data.category_id).first()
|
||||
if not category:
|
||||
raise ValueError(f"品类不存在: {design_data.category_id}")
|
||||
|
||||
sub_type = None
|
||||
if design_data.sub_type_id:
|
||||
sub_type = db.query(SubType).filter(SubType.id == design_data.sub_type_id).first()
|
||||
|
||||
color = None
|
||||
if design_data.color_id:
|
||||
color = db.query(Color).filter(Color.id == design_data.color_id).first()
|
||||
|
||||
design = Design(
|
||||
user_id=user_id,
|
||||
category_id=design_data.category_id,
|
||||
sub_type_id=design_data.sub_type_id,
|
||||
color_id=design_data.color_id,
|
||||
prompt=design_data.prompt,
|
||||
carving_technique=design_data.carving_technique,
|
||||
design_style=design_data.design_style,
|
||||
motif=design_data.motif,
|
||||
size_spec=design_data.size_spec,
|
||||
surface_finish=design_data.surface_finish,
|
||||
usage_scene=design_data.usage_scene,
|
||||
status="generating"
|
||||
)
|
||||
db.add(design)
|
||||
db.flush()
|
||||
|
||||
_generate_mock_fallback(db, design, category, sub_type, color, design_data)
|
||||
db.commit()
|
||||
db.refresh(design)
|
||||
|
||||
return design
|
||||
|
||||
|
||||
@@ -132,16 +283,24 @@ def delete_design(db: Session, design_id: int, user_id: int) -> bool:
|
||||
if not design:
|
||||
return False
|
||||
|
||||
# 删除图片文件
|
||||
# 删除主图片文件
|
||||
if design.image_url:
|
||||
# image_url 格式: /uploads/designs/1001.png
|
||||
# 转换为实际文件路径
|
||||
file_path = design.image_url.lstrip("/")
|
||||
if os.path.exists(file_path):
|
||||
try:
|
||||
os.remove(file_path)
|
||||
except Exception:
|
||||
pass # 忽略删除失败
|
||||
pass
|
||||
|
||||
# 删除多视角图片文件
|
||||
for img in design.images:
|
||||
if img.image_url:
|
||||
fp = img.image_url.lstrip("/")
|
||||
if os.path.exists(fp):
|
||||
try:
|
||||
os.remove(fp)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 删除数据库记录
|
||||
db.delete(design)
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
pydantic[email]==2.6.1
|
||||
pydantic-settings==2.1.0
|
||||
httpx==0.27.0
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<AppHeader />
|
||||
<main class="main-content">
|
||||
<AppHeader v-if="!isAdminRoute" />
|
||||
<main :class="isAdminRoute ? 'admin-main-content' : 'main-content'">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 { useUserStore } from '@/stores/user'
|
||||
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const isAdminRoute = computed(() => route.path.startsWith('/admin'))
|
||||
|
||||
// 应用初始化时恢复登录状态
|
||||
onMounted(() => {
|
||||
userStore.init()
|
||||
@@ -31,4 +35,8 @@ onMounted(() => {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.admin-main-content {
|
||||
flex: 1;
|
||||
}
|
||||
</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
|
||||
}
|
||||
|
||||
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 {
|
||||
id: number
|
||||
user_id: number
|
||||
@@ -25,6 +34,7 @@ export interface Design {
|
||||
surface_finish: string | null
|
||||
usage_scene: string | null
|
||||
image_url: string | null
|
||||
images: DesignImage[]
|
||||
status: string
|
||||
created_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">
|
||||
<router-link to="/" 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>
|
||||
<div class="header-right">
|
||||
<template v-if="isLoggedIn">
|
||||
@@ -43,6 +44,7 @@ const userStore = useUserStore()
|
||||
|
||||
const isLoggedIn = computed(() => !!userStore.token)
|
||||
const userNickname = computed(() => userStore.userInfo?.nickname || '用户')
|
||||
const isAdmin = computed(() => !!userStore.userInfo?.is_admin)
|
||||
|
||||
const handleCommand = (command: string) => {
|
||||
if (command === 'user') {
|
||||
@@ -94,6 +96,11 @@ const handleCommand = (command: string) => {
|
||||
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>
|
||||
<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="image-wrapper" :style="{ transform: `scale(${scale})` }">
|
||||
<el-image
|
||||
:src="imageUrl"
|
||||
:src="currentImageUrl"
|
||||
:alt="design.prompt"
|
||||
fit="contain"
|
||||
:preview-src-list="[imageUrl]"
|
||||
:initial-index="0"
|
||||
:preview-src-list="allImageUrls"
|
||||
:initial-index="activeViewIndex"
|
||||
preview-teleported
|
||||
class="design-image"
|
||||
>
|
||||
@@ -27,6 +40,11 @@
|
||||
</el-image>
|
||||
</div>
|
||||
|
||||
<!-- 视角指示器(多图时显示) -->
|
||||
<div class="view-indicator" v-if="hasMultipleViews">
|
||||
<span class="indicator-text">{{ activeViewName }} ({{ activeViewIndex + 1 }}/{{ design.images.length }})</span>
|
||||
</div>
|
||||
|
||||
<!-- 缩放控制 -->
|
||||
<div class="zoom-controls">
|
||||
<button class="zoom-btn" @click="zoomOut" :disabled="scale <= 0.5">
|
||||
@@ -84,7 +102,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Loading, PictureFilled, ZoomIn, ZoomOut, RefreshRight, Download, User } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
@@ -97,17 +115,48 @@ const props = defineProps<{
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 当前视角索引
|
||||
const activeViewIndex = ref(0)
|
||||
|
||||
// 缩放比例
|
||||
const scale = ref(1)
|
||||
|
||||
// 图片URL(添加API前缀)
|
||||
const imageUrl = computed(() => {
|
||||
if (!props.design.image_url) return ''
|
||||
// 如果已经是完整URL则直接使用,否则添加 /api 前缀
|
||||
if (props.design.image_url.startsWith('http')) {
|
||||
return props.design.image_url
|
||||
// 是否有多视角图片
|
||||
const hasMultipleViews = computed(() => {
|
||||
return props.design.images && props.design.images.length > 1
|
||||
})
|
||||
|
||||
// 当前视角名称
|
||||
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
|
||||
@@ -117,7 +166,13 @@ const downloadUrl = computed(() => getDesignDownloadUrl(props.design.id))
|
||||
const downloadFilename = computed(() => {
|
||||
const category = props.design.category?.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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
position: relative;
|
||||
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',
|
||||
name: 'Register',
|
||||
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
|
||||
phone?: string | null
|
||||
avatar?: string | null
|
||||
is_admin?: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
<div class="ink-drop"></div>
|
||||
<div class="ink-drop"></div>
|
||||
</div>
|
||||
<p class="loading-text">设计生成中,请稍候...</p>
|
||||
<p class="loading-hint">正在将您的创意转化为玉雕设计</p>
|
||||
<p class="loading-text">正在用 AI 生成多视角设计图...</p>
|
||||
<p class="loading-hint">根据品类自动生成 2~4 张不同视角的设计效果图,请耐心等候</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,8 +62,15 @@
|
||||
:key="opt"
|
||||
class="tag-item"
|
||||
:class="{ active: carvingTechnique === opt }"
|
||||
@click="carvingTechnique = carvingTechnique === opt ? '' : opt"
|
||||
@click="selectTag('carvingTechnique', opt)"
|
||||
>{{ opt }}</span>
|
||||
<el-input
|
||||
v-model="customCarving"
|
||||
placeholder="自定义工艺"
|
||||
size="small"
|
||||
class="custom-input"
|
||||
@focus="carvingTechnique = ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -76,8 +83,15 @@
|
||||
:key="opt"
|
||||
class="tag-item"
|
||||
:class="{ active: designStyle === opt }"
|
||||
@click="designStyle = designStyle === opt ? '' : opt"
|
||||
@click="selectTag('designStyle', opt)"
|
||||
>{{ opt }}</span>
|
||||
<el-input
|
||||
v-model="customStyle"
|
||||
placeholder="自定义风格"
|
||||
size="small"
|
||||
class="custom-input"
|
||||
@focus="designStyle = ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -90,8 +104,15 @@
|
||||
:key="opt"
|
||||
class="tag-item"
|
||||
:class="{ active: motif === opt }"
|
||||
@click="motif = motif === opt ? '' : opt"
|
||||
@click="selectTag('motif', opt)"
|
||||
>{{ opt }}</span>
|
||||
<el-input
|
||||
v-model="customMotif"
|
||||
placeholder="自定义题材"
|
||||
size="small"
|
||||
class="custom-input"
|
||||
@focus="motif = ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -104,13 +125,13 @@
|
||||
:key="opt"
|
||||
class="tag-item"
|
||||
:class="{ active: sizeSpec === opt }"
|
||||
@click="sizeSpec = sizeSpec === opt ? '' : opt"
|
||||
@click="selectTag('sizeSpec', opt)"
|
||||
>{{ opt }}</span>
|
||||
<el-input
|
||||
v-model="customSize"
|
||||
placeholder="自定义尺寸"
|
||||
size="small"
|
||||
class="custom-size-input"
|
||||
class="custom-input"
|
||||
@focus="sizeSpec = ''"
|
||||
/>
|
||||
</div>
|
||||
@@ -125,8 +146,15 @@
|
||||
:key="opt"
|
||||
class="tag-item"
|
||||
:class="{ active: surfaceFinish === opt }"
|
||||
@click="surfaceFinish = surfaceFinish === opt ? '' : opt"
|
||||
@click="selectTag('surfaceFinish', opt)"
|
||||
>{{ opt }}</span>
|
||||
<el-input
|
||||
v-model="customFinish"
|
||||
placeholder="自定义处理"
|
||||
size="small"
|
||||
class="custom-input"
|
||||
@focus="surfaceFinish = ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -139,8 +167,15 @@
|
||||
:key="opt"
|
||||
class="tag-item"
|
||||
:class="{ active: usageScene === opt }"
|
||||
@click="usageScene = usageScene === opt ? '' : opt"
|
||||
@click="selectTag('usageScene', opt)"
|
||||
>{{ opt }}</span>
|
||||
<el-input
|
||||
v-model="customScene"
|
||||
placeholder="自定义场景"
|
||||
size="small"
|
||||
class="custom-input"
|
||||
@focus="usageScene = ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,7 +213,7 @@
|
||||
<section v-else class="preview-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">设计预览</h2>
|
||||
<p class="section-desc">您的设计已生成完成</p>
|
||||
<p class="section-desc">您的多视角设计已生成完成</p>
|
||||
</div>
|
||||
|
||||
<DesignPreview :design="currentDesign" />
|
||||
@@ -258,10 +293,38 @@ const carvingTechnique = ref('')
|
||||
const designStyle = ref('')
|
||||
const motif = ref('')
|
||||
const sizeSpec = ref('')
|
||||
const customSize = ref('')
|
||||
const surfaceFinish = 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 styleOptions = ['古典传统', '新中式', '写实', '抽象意境', '极简素面']
|
||||
@@ -315,12 +378,12 @@ const handleGenerate = async () => {
|
||||
sub_type_id: subTypeId.value || undefined,
|
||||
color_id: colorId.value || undefined,
|
||||
prompt: prompt.value.trim(),
|
||||
carving_technique: carvingTechnique.value || undefined,
|
||||
design_style: designStyle.value || undefined,
|
||||
motif: motif.value || undefined,
|
||||
carving_technique: carvingTechnique.value || customCarving.value || undefined,
|
||||
design_style: designStyle.value || customStyle.value || undefined,
|
||||
motif: motif.value || customMotif.value || undefined,
|
||||
size_spec: sizeSpec.value || customSize.value || undefined,
|
||||
surface_finish: surfaceFinish.value || undefined,
|
||||
usage_scene: usageScene.value || undefined,
|
||||
surface_finish: surfaceFinish.value || customFinish.value || undefined,
|
||||
usage_scene: usageScene.value || customScene.value || undefined,
|
||||
})
|
||||
ElMessage.success('设计生成成功!')
|
||||
} catch (error) {
|
||||
@@ -542,7 +605,7 @@ $text-light: #999999;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-size-input {
|
||||
.custom-input {
|
||||
width: 130px;
|
||||
|
||||
: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, '藕粉', '#E8B4B8', 9),
|
||||
(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