docs(readme): 编写项目README文档,描述功能与架构
- 完整撰写玉宗珠宝设计大师项目README,介绍项目概况及核心功能 - 说明用户认证系统实现及优势,包含JWT鉴权和密码加密细节 - 详细描述品类管理系统,支持多流程类型和多种玉石品类 - 说明设计图生成方案及技术,包含Pillow生成示例及字体支持 - 介绍设计管理功能,支持分页浏览、预览、下载和删除设计 - 个人信息管理模块说明,涵盖昵称、手机号、密码的安全修改 - 绘制业务流程图和关键数据流图,清晰展现系统架构与数据流 - 提供详细API调用链路及参数说明,涵盖用户、品类、设计接口 - 列明技术栈及版本,包含前后端框架、ORM、认证、加密等工具 - 展示目录结构,标明后端与前端项目布局 - 规划本地开发环境与启动步骤,包括数据库初始化及运行命令 - 说明服务器部署流程和Nginx配置方案 - 详细数据库表结构说明及环境变量配置指导 - 汇总常用开发及测试命令,方便开发调试与部署管理
This commit is contained in:
274
README.md
Normal file
274
README.md
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
# 玉宗 - 珠宝设计大师
|
||||||
|
|
||||||
|
AI 驱动的珠宝玉石设计生成系统,支持 12 种玉石品类的智能设计图生成。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 1. 用户认证系统
|
||||||
|
- **功能说明**:支持用户注册、登录、退出,JWT Token 鉴权
|
||||||
|
- **实现方式**:后端使用 `python-jose` 生成 JWT Token(有效期 1440 分钟),密码通过 `passlib + bcrypt` 加密存储;前端通过 Axios 拦截器自动携带 Token,401 响应自动跳转登录页
|
||||||
|
- **优点**:无状态认证,前后端分离友好;密码 bcrypt 加密,安全可靠
|
||||||
|
|
||||||
|
### 2. 品类管理系统
|
||||||
|
- **功能说明**: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 模型
|
||||||
|
|
||||||
|
### 4. 设计管理
|
||||||
|
- **功能说明**:用户中心查看设计历史列表(分页),支持预览、下载、删除设计
|
||||||
|
- **实现方式**:设计列表通过 `DesignListResponse` 分页返回(默认每页 20 条),图片通过 `FileResponse` 下载;删除时同步清理数据库记录和磁盘文件
|
||||||
|
- **优点**:完整的设计生命周期管理,支持历史设计的查看和复用
|
||||||
|
|
||||||
|
### 5. 个人信息管理
|
||||||
|
- **功能说明**:修改昵称、手机号、密码
|
||||||
|
- **实现方式**:手机号唯一性校验,密码修改需验证旧密码;前端表单使用 Element Plus 表单验证
|
||||||
|
- **优点**:安全的密码修改流程,手机号去重保障数据一致性
|
||||||
|
|
||||||
|
## 业务流程
|
||||||
|
|
||||||
|
### 整体流程图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
A[用户注册/登录] --> B[设计页 - 选择品类]
|
||||||
|
B --> C{品类类型}
|
||||||
|
C -->|full| D[选择子类型/牌型]
|
||||||
|
C -->|size_color| E[选择尺寸 + 颜色]
|
||||||
|
C -->|simple| F[直接进入]
|
||||||
|
D --> G[生成页 - 输入设计描述]
|
||||||
|
E --> G
|
||||||
|
F --> G
|
||||||
|
G --> H[提交生成请求]
|
||||||
|
H --> I[后端生成设计图]
|
||||||
|
I --> J[预览设计图]
|
||||||
|
J --> K{用户操作}
|
||||||
|
K -->|下载| L[下载 PNG 文件]
|
||||||
|
K -->|重新生成| G
|
||||||
|
K -->|查看历史| M[用户中心]
|
||||||
|
M --> N[设计历史列表]
|
||||||
|
N -->|点击卡片| G
|
||||||
|
```
|
||||||
|
|
||||||
|
### 各阶段详细流程
|
||||||
|
|
||||||
|
1. **用户认证**:注册时检查用户名唯一性 → 密码 bcrypt 加密 → 创建用户记录 → 注册成功后自动登录 → 获取 JWT Token 存储到 localStorage
|
||||||
|
2. **品类选择**:进入设计页自动加载品类列表 → 左侧导航选择品类 → 根据 flow_type 加载对应的子类型/颜色数据 → 右侧面板显示选择界面
|
||||||
|
3. **设计生成**:跳转生成页携带品类参数 → 输入设计描述(最多 500 字) → 提交请求 → 显示水墨风格加载动画 → 生成完成后展示预览
|
||||||
|
4. **设计管理**:用户中心加载设计列表 → 卡片网格展示 → 支持分页浏览、下载 PNG、删除(确认弹窗)、点击卡片跳转重新编辑
|
||||||
|
|
||||||
|
### 关键数据流图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
A[前端 Store] -->|Axios + Token| B[API 路由]
|
||||||
|
B -->|Depends 注入| C[Service 层]
|
||||||
|
C -->|SQLAlchemy ORM| D[MySQL 数据库]
|
||||||
|
C -->|Pillow 生成| E[uploads/ 目录]
|
||||||
|
E -->|StaticFiles 服务| F[前端图片展示]
|
||||||
|
```
|
||||||
|
|
||||||
|
### API 调用链路
|
||||||
|
|
||||||
|
| 接口路径 | 方法 | 功能说明 | 关键参数 |
|
||||||
|
|---------|------|---------|---------|
|
||||||
|
| `/api/auth/register` | POST | 用户注册 | username, password, nickname |
|
||||||
|
| `/api/auth/login` | POST | 用户登录 | username, password |
|
||||||
|
| `/api/auth/me` | GET | 获取当前用户 | Bearer Token |
|
||||||
|
| `/api/users/profile` | PUT | 更新个人信息 | nickname, phone, avatar |
|
||||||
|
| `/api/users/password` | PUT | 修改密码 | old_password, new_password |
|
||||||
|
| `/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` | GET | 设计列表(分页) | page, page_size |
|
||||||
|
| `/api/designs/{id}` | GET | 设计详情 | design_id |
|
||||||
|
| `/api/designs/{id}` | DELETE | 删除设计 | design_id |
|
||||||
|
| `/api/designs/{id}/download` | GET | 下载设计图 | design_id |
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 分类 | 技术 | 版本 | 说明 |
|
||||||
|
|-----|------|------|-----|
|
||||||
|
| **前端框架** | Vue 3 | 3.5.30 | Composition API + `<script setup>` |
|
||||||
|
| **构建工具** | Vite | 8.0.1 | 开发服务器 + 构建打包 |
|
||||||
|
| **类型系统** | TypeScript | 5.9.3 | 全项目类型化 |
|
||||||
|
| **UI 组件库** | Element Plus | 2.13.6 | 表单、弹窗、分页等 |
|
||||||
|
| **状态管理** | Pinia | 3.0.4 | Composition API 风格 Store |
|
||||||
|
| **路由** | Vue Router | 4.6.4 | History 模式 + 路由守卫 |
|
||||||
|
| **HTTP 客户端** | Axios | 1.13.6 | 请求/响应拦截器 |
|
||||||
|
| **CSS 预处理** | Sass | 1.98.0 | SCSS 语法 |
|
||||||
|
| **后端框架** | FastAPI | 0.109.2 | 异步 Python Web 框架 |
|
||||||
|
| **ORM** | SQLAlchemy | 2.0.27 | 同步模式 |
|
||||||
|
| **数据库驱动** | PyMySQL | 1.1.0 | MySQL 连接 |
|
||||||
|
| **认证** | python-jose | 3.3.0 | JWT Token |
|
||||||
|
| **密码加密** | passlib + bcrypt | 1.7.4 | bcrypt 哈希 |
|
||||||
|
| **图片生成** | Pillow | 10.2.0 | PNG 设计图生成 |
|
||||||
|
| **数据库** | MySQL | - | utf8mb4 编码 |
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
YuShiSheJi/
|
||||||
|
├── backend/ # 后端服务
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── models/ # SQLAlchemy 数据模型
|
||||||
|
│ │ │ ├── user.py # 用户模型
|
||||||
|
│ │ │ ├── category.py # 品类/子类型/颜色模型
|
||||||
|
│ │ │ └── design.py # 设计作品模型
|
||||||
|
│ │ ├── routers/ # API 路由
|
||||||
|
│ │ │ ├── auth.py # 认证路由(注册/登录)
|
||||||
|
│ │ │ ├── categories.py # 品类查询路由
|
||||||
|
│ │ │ ├── designs.py # 设计生成/管理路由
|
||||||
|
│ │ │ └── users.py # 用户信息路由
|
||||||
|
│ │ ├── schemas/ # Pydantic 数据验证
|
||||||
|
│ │ ├── services/ # 业务逻辑层
|
||||||
|
│ │ │ ├── auth_service.py # 认证业务
|
||||||
|
│ │ │ ├── design_service.py # 设计业务
|
||||||
|
│ │ │ └── mock_generator.py # 图片生成服务
|
||||||
|
│ │ ├── utils/ # 工具函数
|
||||||
|
│ │ │ ├── deps.py # 认证依赖注入
|
||||||
|
│ │ │ └── security.py # JWT/密码工具
|
||||||
|
│ │ ├── config.py # 配置管理
|
||||||
|
│ │ ├── database.py # 数据库连接
|
||||||
|
│ │ └── main.py # 应用入口
|
||||||
|
│ ├── uploads/ # 生成图片存储目录
|
||||||
|
│ ├── .env # 环境变量
|
||||||
|
│ └── requirements.txt # Python 依赖
|
||||||
|
├── frontend/ # 前端应用
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── api/ # API 接口定义
|
||||||
|
│ │ │ ├── request.ts # Axios 实例(拦截器)
|
||||||
|
│ │ │ ├── auth.ts # 认证接口
|
||||||
|
│ │ │ ├── category.ts # 品类接口
|
||||||
|
│ │ │ └── design.ts # 设计接口
|
||||||
|
│ │ ├── components/ # 公共组件
|
||||||
|
│ │ │ ├── AppHeader.vue # 顶部导航栏
|
||||||
|
│ │ │ ├── CategoryNav.vue # 品类左侧导航
|
||||||
|
│ │ │ ├── SubTypePanel.vue# 子类型/颜色选择面板
|
||||||
|
│ │ │ ├── ColorPicker.vue # 颜色选择器
|
||||||
|
│ │ │ └── DesignPreview.vue # 设计预览(缩放/下载)
|
||||||
|
│ │ ├── stores/ # Pinia 状态管理
|
||||||
|
│ │ │ ├── user.ts # 用户状态
|
||||||
|
│ │ │ ├── category.ts # 品类状态
|
||||||
|
│ │ │ └── design.ts # 设计状态
|
||||||
|
│ │ ├── views/ # 页面组件
|
||||||
|
│ │ │ ├── Login.vue # 登录页
|
||||||
|
│ │ │ ├── Register.vue # 注册页
|
||||||
|
│ │ │ ├── DesignPage.vue # 设计页(品类选择)
|
||||||
|
│ │ │ ├── GeneratePage.vue# 生成页(输入描述+预览)
|
||||||
|
│ │ │ └── UserCenter.vue # 用户中心(历史+信息)
|
||||||
|
│ │ ├── router/index.ts # 路由配置 + 认证守卫
|
||||||
|
│ │ ├── assets/styles/theme.scss # 品牌主题色
|
||||||
|
│ │ └── main.ts # 应用入口
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── vite.config.ts # Vite 配置(代理)
|
||||||
|
└── init_data.sql # 数据库初始化脚本
|
||||||
|
```
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Python 3.10+
|
||||||
|
- Node.js 18+
|
||||||
|
- MySQL 5.7+ / 8.0
|
||||||
|
- npm 或 pnpm
|
||||||
|
|
||||||
|
### 启动步骤
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 创建数据库
|
||||||
|
mysql -u root -p -e "CREATE DATABASE yuzong CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||||
|
|
||||||
|
# 2. 初始化表结构(FastAPI 启动时 SQLAlchemy 自动创建表)并导入初始数据
|
||||||
|
mysql -u root -p yuzong < init_data.sql
|
||||||
|
|
||||||
|
# 3. 后端启动
|
||||||
|
cd backend
|
||||||
|
cp .env.example .env # 修改数据库连接信息
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uvicorn app.main:app --reload --port 8000
|
||||||
|
|
||||||
|
# 4. 前端启动(新终端)
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
前端访问:http://localhost:3000
|
||||||
|
后端 API:http://localhost:8000
|
||||||
|
API 文档:http://localhost:8000/docs
|
||||||
|
|
||||||
|
## 服务器部署
|
||||||
|
|
||||||
|
### 部署步骤
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 安装依赖
|
||||||
|
cd backend && pip install -r requirements.txt
|
||||||
|
cd frontend && npm install
|
||||||
|
|
||||||
|
# 2. 前端构建
|
||||||
|
cd frontend
|
||||||
|
npm run build # 输出到 dist/ 目录
|
||||||
|
|
||||||
|
# 3. 配置后端环境变量
|
||||||
|
cd backend
|
||||||
|
vim .env # 配置生产数据库地址和密钥
|
||||||
|
|
||||||
|
# 4. 启动后端服务
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
|
# 5. Nginx 配置(前端静态文件 + API 反向代理)
|
||||||
|
# location / { root /path/to/frontend/dist; try_files $uri $uri/ /index.html; }
|
||||||
|
# location /api { proxy_pass http://127.0.0.1:8000; }
|
||||||
|
# location /uploads { proxy_pass http://127.0.0.1:8000; }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据库
|
||||||
|
|
||||||
|
### 表结构说明
|
||||||
|
|
||||||
|
| 分类 | 表名 | 说明 |
|
||||||
|
|-----|------|-----|
|
||||||
|
| 用户 | `users` | 用户基本信息(用户名、密码、昵称、手机、头像) |
|
||||||
|
| 品类 | `categories` | 12 种玉石品类(名称、图标、排序、流程类型) |
|
||||||
|
| 品类 | `sub_types` | 品类子类型(牌型/尺寸,关联品类) |
|
||||||
|
| 品类 | `colors` | 品类颜色选项(颜色名、HEX 色值,关联品类) |
|
||||||
|
| 设计 | `designs` | 设计记录(用户、品类、子类型、颜色、描述、图片URL、状态) |
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
| 配置项 | 默认值 | 必填 | 说明 |
|
||||||
|
|-------|--------|-----|------|
|
||||||
|
| `DATABASE_URL` | `mysql+pymysql://root:password@localhost:3306/yuzong` | 是 | MySQL 连接字符串 |
|
||||||
|
| `SECRET_KEY` | `your-secret-key-change-this` | 是 | JWT 签名密钥(生产环境务必修改) |
|
||||||
|
| `ALGORITHM` | `HS256` | 否 | JWT 算法 |
|
||||||
|
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `1440` | 否 | Token 有效期(分钟,默认 24 小时) |
|
||||||
|
| `UPLOAD_DIR` | `uploads` | 否 | 图片上传存储目录 |
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ========== 开发 ==========
|
||||||
|
cd backend && uvicorn app.main:app --reload --port 8000 # 启动后端(热重载)
|
||||||
|
cd frontend && npm run dev # 启动前端开发服务器
|
||||||
|
cd frontend && npm run build # 前端生产构建
|
||||||
|
|
||||||
|
# ========== 数据库 ==========
|
||||||
|
mysql -u root -p yuzong < init_data.sql # 导入初始品类数据
|
||||||
|
mysql -u root -p -e "SELECT * FROM yuzong.categories;" # 查看品类数据
|
||||||
|
|
||||||
|
# ========== 依赖管理 ==========
|
||||||
|
cd backend && pip install -r requirements.txt # 安装后端依赖
|
||||||
|
cd frontend && npm install # 安装前端依赖
|
||||||
|
|
||||||
|
# ========== 调试 ==========
|
||||||
|
curl http://localhost:8000/health # 后端健康检查
|
||||||
|
curl http://localhost:8000/docs # 查看 API 文档
|
||||||
|
```
|
||||||
5
backend/.env
Normal file
5
backend/.env
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
DATABASE_URL=mysql+pymysql://yssjs:yssjs@localhost:13306/yuzong?charset=utf8mb4
|
||||||
|
SECRET_KEY=yuzong-jewelry-design-secret-key-2026
|
||||||
|
ALGORITHM=HS256
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||||||
|
UPLOAD_DIR=uploads
|
||||||
5
backend/.env.example
Normal file
5
backend/.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
DATABASE_URL=mysql+pymysql://root:password@localhost:3306/yuzong
|
||||||
|
SECRET_KEY=your-secret-key-change-this
|
||||||
|
ALGORITHM=HS256
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=1440
|
||||||
|
UPLOAD_DIR=uploads
|
||||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# 玉宗 - 珠宝设计大师 后端应用
|
||||||
28
backend/app/config.py
Normal file
28
backend/app/config.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""
|
||||||
|
应用配置管理
|
||||||
|
使用 pydantic-settings 从环境变量读取配置
|
||||||
|
"""
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""应用配置"""
|
||||||
|
DATABASE_URL: str = "mysql+pymysql://root:password@localhost:3306/yuzong"
|
||||||
|
SECRET_KEY: str = "your-secret-key-change-this"
|
||||||
|
ALGORITHM: str = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
|
||||||
|
UPLOAD_DIR: str = "uploads"
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
env_file_encoding = "utf-8"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache()
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
"""获取配置单例"""
|
||||||
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
37
backend/app/database.py
Normal file
37
backend/app/database.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
数据库连接配置
|
||||||
|
使用 SQLAlchemy 2.0 同步方式
|
||||||
|
"""
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
|
# 创建数据库引擎
|
||||||
|
engine = create_engine(
|
||||||
|
settings.DATABASE_URL,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_recycle=3600,
|
||||||
|
echo=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建会话工厂
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
# 创建基类
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Generator:
|
||||||
|
"""
|
||||||
|
数据库会话依赖注入
|
||||||
|
用于 FastAPI 的依赖注入系统
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
67
backend/app/main.py
Normal file
67
backend/app/main.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
玉宗 - 珠宝设计大师 后端服务入口
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .routers import categories, designs, users
|
||||||
|
from .routers import auth
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""应用生命周期管理"""
|
||||||
|
# 启动时:创建 uploads 目录
|
||||||
|
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
|
||||||
|
print(f"✅ 上传目录已准备: {settings.UPLOAD_DIR}")
|
||||||
|
yield
|
||||||
|
# 关闭时:清理资源(如需要)
|
||||||
|
print("👋 应用已关闭")
|
||||||
|
|
||||||
|
|
||||||
|
# 创建 FastAPI 应用实例
|
||||||
|
app = FastAPI(
|
||||||
|
title="玉宗 - 珠宝设计大师",
|
||||||
|
description="AI驱动的珠宝设计微信小程序后端服务",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan
|
||||||
|
)
|
||||||
|
|
||||||
|
# 配置 CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["http://localhost:3000"], # 生产环境应限制具体域名
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""根路径健康检查"""
|
||||||
|
return {
|
||||||
|
"message": "玉宗 - 珠宝设计大师 API",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"status": "running"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""健康检查接口"""
|
||||||
|
return {"status": "healthy"}
|
||||||
|
|
||||||
|
|
||||||
|
# 注册路由
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(categories.router)
|
||||||
|
app.include_router(designs.router)
|
||||||
|
app.include_router(users.router)
|
||||||
|
|
||||||
|
# 配置静态文件服务
|
||||||
|
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||||
17
backend/app/models/__init__.py
Normal file
17
backend/app/models/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""
|
||||||
|
数据库模型
|
||||||
|
导出所有模型,确保 Base.metadata 包含所有表
|
||||||
|
"""
|
||||||
|
from ..database import Base
|
||||||
|
from .user import User
|
||||||
|
from .category import Category, SubType, Color
|
||||||
|
from .design import Design
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Base",
|
||||||
|
"User",
|
||||||
|
"Category",
|
||||||
|
"SubType",
|
||||||
|
"Color",
|
||||||
|
"Design"
|
||||||
|
]
|
||||||
64
backend/app/models/category.py
Normal file
64
backend/app/models/category.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""
|
||||||
|
品类相关模型
|
||||||
|
包含:品类、子类型、颜色
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, Integer, String, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from ..database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Category(Base):
|
||||||
|
"""品类表"""
|
||||||
|
__tablename__ = "categories"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True, comment="品类ID")
|
||||||
|
name = Column(String(50), nullable=False, comment="品类名称")
|
||||||
|
icon = Column(String(255), nullable=True, comment="品类图标")
|
||||||
|
sort_order = Column(Integer, default=0, comment="排序")
|
||||||
|
flow_type = Column(String(20), nullable=False, comment="流程类型:full/size_color/simple")
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
sub_types = relationship("SubType", back_populates="category")
|
||||||
|
colors = relationship("Color", back_populates="category")
|
||||||
|
designs = relationship("Design", back_populates="category")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Category(id={self.id}, name='{self.name}')>"
|
||||||
|
|
||||||
|
|
||||||
|
class SubType(Base):
|
||||||
|
"""子类型表"""
|
||||||
|
__tablename__ = "sub_types"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True, comment="子类型ID")
|
||||||
|
category_id = Column(Integer, ForeignKey("categories.id"), nullable=False, comment="所属品类")
|
||||||
|
name = Column(String(50), nullable=False, comment="名称")
|
||||||
|
description = Column(String(255), nullable=True, comment="描述")
|
||||||
|
preview_image = Column(String(255), nullable=True, comment="预览图")
|
||||||
|
sort_order = Column(Integer, default=0, comment="排序")
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
category = relationship("Category", back_populates="sub_types")
|
||||||
|
designs = relationship("Design", back_populates="sub_type")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<SubType(id={self.id}, name='{self.name}')>"
|
||||||
|
|
||||||
|
|
||||||
|
class Color(Base):
|
||||||
|
"""颜色表"""
|
||||||
|
__tablename__ = "colors"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True, comment="颜色ID")
|
||||||
|
category_id = Column(Integer, ForeignKey("categories.id"), nullable=False, comment="适用品类")
|
||||||
|
name = Column(String(50), nullable=False, comment="颜色名称")
|
||||||
|
hex_code = Column(String(7), nullable=False, comment="色值")
|
||||||
|
sort_order = Column(Integer, default=0, comment="排序")
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
category = relationship("Category", back_populates="colors")
|
||||||
|
designs = relationship("Design", back_populates="color")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Color(id={self.id}, name='{self.name}', hex_code='{self.hex_code}')>"
|
||||||
39
backend/app/models/design.py
Normal file
39
backend/app/models/design.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""
|
||||||
|
设计作品模型
|
||||||
|
"""
|
||||||
|
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 Design(Base):
|
||||||
|
"""设计作品表"""
|
||||||
|
__tablename__ = "designs"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="设计ID")
|
||||||
|
user_id = Column(BigInteger, ForeignKey("users.id"), nullable=False, comment="用户ID")
|
||||||
|
category_id = Column(Integer, ForeignKey("categories.id"), nullable=False, comment="品类ID")
|
||||||
|
sub_type_id = Column(Integer, ForeignKey("sub_types.id"), nullable=True, comment="子类型ID")
|
||||||
|
color_id = Column(Integer, ForeignKey("colors.id"), nullable=True, comment="颜色ID")
|
||||||
|
prompt = Column(Text, nullable=False, comment="设计需求")
|
||||||
|
carving_technique = Column(String(50), nullable=True, comment="雕刻工艺")
|
||||||
|
design_style = Column(String(50), nullable=True, comment="设计风格")
|
||||||
|
motif = Column(String(100), nullable=True, comment="题材纹样")
|
||||||
|
size_spec = Column(String(100), nullable=True, comment="尺寸规格")
|
||||||
|
surface_finish = Column(String(50), nullable=True, comment="表面处理")
|
||||||
|
usage_scene = Column(String(50), nullable=True, comment="用途场景")
|
||||||
|
image_url = Column(String(255), nullable=True, comment="设计图URL")
|
||||||
|
status = Column(String(20), default="generating", comment="状态")
|
||||||
|
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
user = relationship("User", back_populates="designs")
|
||||||
|
category = relationship("Category", back_populates="designs")
|
||||||
|
sub_type = relationship("SubType", back_populates="designs")
|
||||||
|
color = relationship("Color", back_populates="designs")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Design(id={self.id}, status='{self.status}')>"
|
||||||
28
backend/app/models/user.py
Normal file
28
backend/app/models/user.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""
|
||||||
|
用户模型
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, BigInteger, String, DateTime
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from ..database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""用户表"""
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(BigInteger, primary_key=True, autoincrement=True, comment="用户ID")
|
||||||
|
username = Column(String(50), unique=True, nullable=False, comment="用户名")
|
||||||
|
phone = Column(String(20), unique=True, nullable=True, comment="手机号")
|
||||||
|
hashed_password = Column(String(255), nullable=False, comment="加密密码")
|
||||||
|
nickname = Column(String(50), nullable=True, comment="昵称")
|
||||||
|
avatar = Column(String(255), nullable=True, comment="头像URL")
|
||||||
|
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||||
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||||
|
|
||||||
|
# 关联关系
|
||||||
|
designs = relationship("Design", back_populates="user")
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<User(id={self.id}, username='{self.username}')>"
|
||||||
8
backend/app/routers/__init__.py
Normal file
8
backend/app/routers/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# API 路由模块
|
||||||
|
from . import categories, designs, users
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"categories",
|
||||||
|
"designs",
|
||||||
|
"users",
|
||||||
|
]
|
||||||
63
backend/app/routers/auth.py
Normal file
63
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
认证路由
|
||||||
|
提供用户注册、登录和获取当前用户信息的 API
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..schemas.user import UserCreate, UserLogin, UserResponse, Token
|
||||||
|
from ..services.auth_service import register_user, authenticate_user
|
||||||
|
from ..utils.deps import get_current_user
|
||||||
|
from ..utils.security import create_access_token
|
||||||
|
from ..models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=UserResponse)
|
||||||
|
def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
用户注册
|
||||||
|
|
||||||
|
创建新用户账号,用户名必须唯一
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = register_user(db, user_data)
|
||||||
|
return user
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=Token)
|
||||||
|
def login(user_data: UserLogin, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
用户登录
|
||||||
|
|
||||||
|
验证用户名和密码,返回 JWT access token
|
||||||
|
"""
|
||||||
|
user = authenticate_user(db, user_data.username, user_data.password)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="用户名或密码错误",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 生成 JWT token,sub 字段存储用户 ID
|
||||||
|
access_token = create_access_token(data={"sub": str(user.id)})
|
||||||
|
return Token(access_token=access_token, token_type="bearer")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserResponse)
|
||||||
|
def get_me(current_user: User = Depends(get_current_user)):
|
||||||
|
"""
|
||||||
|
获取当前登录用户信息
|
||||||
|
|
||||||
|
需要认证,从 token 中解析用户身份
|
||||||
|
"""
|
||||||
|
return current_user
|
||||||
71
backend/app/routers/categories.py
Normal file
71
backend/app/routers/categories.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"""
|
||||||
|
品类相关路由
|
||||||
|
提供品类、子类型、颜色的查询接口
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..models import Category, SubType, Color
|
||||||
|
from ..schemas import CategoryResponse, SubTypeResponse, ColorResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/categories", tags=["品类"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=List[CategoryResponse])
|
||||||
|
def get_categories(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
获取所有品类列表
|
||||||
|
按 sort_order 排序,无需认证
|
||||||
|
"""
|
||||||
|
categories = db.query(Category).order_by(Category.sort_order).all()
|
||||||
|
return categories
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{category_id}/sub-types", response_model=List[SubTypeResponse])
|
||||||
|
def get_category_sub_types(
|
||||||
|
category_id: int,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取品类下的子类型
|
||||||
|
无需认证
|
||||||
|
"""
|
||||||
|
# 检查品类是否存在
|
||||||
|
category = db.query(Category).filter(Category.id == category_id).first()
|
||||||
|
if not category:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="品类不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
sub_types = db.query(SubType).filter(
|
||||||
|
SubType.category_id == category_id
|
||||||
|
).order_by(SubType.sort_order).all()
|
||||||
|
|
||||||
|
return sub_types
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{category_id}/colors", response_model=List[ColorResponse])
|
||||||
|
def get_category_colors(
|
||||||
|
category_id: int,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取品类下的颜色选项
|
||||||
|
无需认证
|
||||||
|
"""
|
||||||
|
# 检查品类是否存在
|
||||||
|
category = db.query(Category).filter(Category.id == category_id).first()
|
||||||
|
if not category:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="品类不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
colors = db.query(Color).filter(
|
||||||
|
Color.category_id == category_id
|
||||||
|
).order_by(Color.sort_order).all()
|
||||||
|
|
||||||
|
return colors
|
||||||
201
backend/app/routers/designs.py
Normal file
201
backend/app/routers/designs.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"""
|
||||||
|
设计相关路由
|
||||||
|
提供设计生成、查询、删除、下载接口
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..models import User, Design
|
||||||
|
from ..schemas import DesignCreate, DesignResponse, DesignListResponse
|
||||||
|
from ..utils.deps import get_current_user
|
||||||
|
from ..services import design_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/designs", tags=["设计"])
|
||||||
|
|
||||||
|
|
||||||
|
def design_to_response(design: Design) -> DesignResponse:
|
||||||
|
"""将 Design 模型转换为响应格式"""
|
||||||
|
return DesignResponse(
|
||||||
|
id=design.id,
|
||||||
|
user_id=design.user_id,
|
||||||
|
category={
|
||||||
|
"id": design.category.id,
|
||||||
|
"name": design.category.name,
|
||||||
|
"icon": design.category.icon,
|
||||||
|
"sort_order": design.category.sort_order,
|
||||||
|
"flow_type": design.category.flow_type
|
||||||
|
},
|
||||||
|
sub_type={
|
||||||
|
"id": design.sub_type.id,
|
||||||
|
"category_id": design.sub_type.category_id,
|
||||||
|
"name": design.sub_type.name,
|
||||||
|
"description": design.sub_type.description,
|
||||||
|
"preview_image": design.sub_type.preview_image,
|
||||||
|
"sort_order": design.sub_type.sort_order
|
||||||
|
} if design.sub_type else None,
|
||||||
|
color={
|
||||||
|
"id": design.color.id,
|
||||||
|
"category_id": design.color.category_id,
|
||||||
|
"name": design.color.name,
|
||||||
|
"hex_code": design.color.hex_code,
|
||||||
|
"sort_order": design.color.sort_order
|
||||||
|
} if design.color else None,
|
||||||
|
prompt=design.prompt,
|
||||||
|
carving_technique=design.carving_technique,
|
||||||
|
design_style=design.design_style,
|
||||||
|
motif=design.motif,
|
||||||
|
size_spec=design.size_spec,
|
||||||
|
surface_finish=design.surface_finish,
|
||||||
|
usage_scene=design.usage_scene,
|
||||||
|
image_url=design.image_url,
|
||||||
|
status=design.status,
|
||||||
|
created_at=design.created_at,
|
||||||
|
updated_at=design.updated_at
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/generate", response_model=DesignResponse)
|
||||||
|
def generate_design(
|
||||||
|
design_data: DesignCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
提交设计生成请求
|
||||||
|
需要认证
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
design = design_service.create_design(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
design_data=design_data
|
||||||
|
)
|
||||||
|
return design_to_response(design)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=DesignListResponse)
|
||||||
|
def get_designs(
|
||||||
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
|
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取当前用户的设计历史列表(分页)
|
||||||
|
需要认证
|
||||||
|
"""
|
||||||
|
designs, total = design_service.get_user_designs(
|
||||||
|
db=db,
|
||||||
|
user_id=current_user.id,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
return DesignListResponse(
|
||||||
|
items=[design_to_response(d) for d in designs],
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{design_id}", response_model=DesignResponse)
|
||||||
|
def get_design(
|
||||||
|
design_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取设计详情
|
||||||
|
只能查看自己的设计,非本人设计返回 404
|
||||||
|
"""
|
||||||
|
design = design_service.get_design_by_id(
|
||||||
|
db=db,
|
||||||
|
design_id=design_id,
|
||||||
|
user_id=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not design:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="设计不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
return design_to_response(design)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{design_id}")
|
||||||
|
def delete_design(
|
||||||
|
design_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
删除设计
|
||||||
|
只能删除自己的设计,非本人设计返回 404
|
||||||
|
"""
|
||||||
|
success = design_service.delete_design(
|
||||||
|
db=db,
|
||||||
|
design_id=design_id,
|
||||||
|
user_id=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="设计不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"message": "删除成功"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{design_id}/download")
|
||||||
|
def download_design(
|
||||||
|
design_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
下载设计图
|
||||||
|
只能下载自己的设计,非本人设计返回 404
|
||||||
|
"""
|
||||||
|
design = design_service.get_design_by_id(
|
||||||
|
db=db,
|
||||||
|
design_id=design_id,
|
||||||
|
user_id=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not design:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="设计不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not design.image_url:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="设计图片不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 转换 URL 为文件路径
|
||||||
|
file_path = design.image_url.lstrip("/")
|
||||||
|
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="设计图片文件不存在"
|
||||||
|
)
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=file_path,
|
||||||
|
filename=f"design_{design_id}.png",
|
||||||
|
media_type="image/png"
|
||||||
|
)
|
||||||
75
backend/app/routers/users.py
Normal file
75
backend/app/routers/users.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""
|
||||||
|
用户相关路由
|
||||||
|
提供用户信息更新、密码修改接口
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..models import User
|
||||||
|
from ..schemas import UserResponse, UserUpdate, PasswordChange
|
||||||
|
from ..utils.deps import get_current_user
|
||||||
|
from ..utils.security import verify_password, get_password_hash
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/users", tags=["用户"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/profile", response_model=UserResponse)
|
||||||
|
def update_profile(
|
||||||
|
user_data: UserUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
更新个人信息
|
||||||
|
需要认证
|
||||||
|
"""
|
||||||
|
# 更新非空字段
|
||||||
|
if user_data.nickname is not None:
|
||||||
|
current_user.nickname = user_data.nickname
|
||||||
|
|
||||||
|
if user_data.phone is not None:
|
||||||
|
# 检查手机号是否已被其他用户使用
|
||||||
|
if user_data.phone:
|
||||||
|
existing_user = db.query(User).filter(
|
||||||
|
User.phone == user_data.phone,
|
||||||
|
User.id != current_user.id
|
||||||
|
).first()
|
||||||
|
if existing_user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="手机号已被使用"
|
||||||
|
)
|
||||||
|
current_user.phone = user_data.phone
|
||||||
|
|
||||||
|
if user_data.avatar is not None:
|
||||||
|
current_user.avatar = user_data.avatar
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(current_user)
|
||||||
|
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/password")
|
||||||
|
def change_password(
|
||||||
|
password_data: PasswordChange,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
修改密码
|
||||||
|
需要认证,旧密码错误返回 400
|
||||||
|
"""
|
||||||
|
# 验证旧密码
|
||||||
|
if not verify_password(password_data.old_password, current_user.hashed_password):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="旧密码错误"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新密码
|
||||||
|
current_user.hashed_password = get_password_hash(password_data.new_password)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"message": "密码修改成功"}
|
||||||
25
backend/app/schemas/__init__.py
Normal file
25
backend/app/schemas/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""
|
||||||
|
Pydantic Schemas
|
||||||
|
导出所有 Schema 类型
|
||||||
|
"""
|
||||||
|
from .user import UserCreate, UserLogin, UserResponse, Token, UserUpdate, PasswordChange
|
||||||
|
from .category import CategoryResponse, SubTypeResponse, ColorResponse
|
||||||
|
from .design import DesignCreate, DesignResponse, DesignListResponse
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# User schemas
|
||||||
|
"UserCreate",
|
||||||
|
"UserLogin",
|
||||||
|
"UserResponse",
|
||||||
|
"Token",
|
||||||
|
"UserUpdate",
|
||||||
|
"PasswordChange",
|
||||||
|
# Category schemas
|
||||||
|
"CategoryResponse",
|
||||||
|
"SubTypeResponse",
|
||||||
|
"ColorResponse",
|
||||||
|
# Design schemas
|
||||||
|
"DesignCreate",
|
||||||
|
"DesignResponse",
|
||||||
|
"DesignListResponse",
|
||||||
|
]
|
||||||
42
backend/app/schemas/category.py
Normal file
42
backend/app/schemas/category.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
品类相关 Pydantic Schemas
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryResponse(BaseModel):
|
||||||
|
"""品类响应"""
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
icon: Optional[str] = None
|
||||||
|
sort_order: int
|
||||||
|
flow_type: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class SubTypeResponse(BaseModel):
|
||||||
|
"""子类型响应"""
|
||||||
|
id: int
|
||||||
|
category_id: int
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
preview_image: Optional[str] = None
|
||||||
|
sort_order: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ColorResponse(BaseModel):
|
||||||
|
"""颜色响应"""
|
||||||
|
id: int
|
||||||
|
category_id: int
|
||||||
|
name: str
|
||||||
|
hex_code: str
|
||||||
|
sort_order: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
53
backend/app/schemas/design.py
Normal file
53
backend/app/schemas/design.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""
|
||||||
|
设计作品相关 Pydantic Schemas
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from .category import CategoryResponse, SubTypeResponse, ColorResponse
|
||||||
|
|
||||||
|
|
||||||
|
class DesignCreate(BaseModel):
|
||||||
|
"""创建设计请求"""
|
||||||
|
category_id: int = Field(..., description="品类ID")
|
||||||
|
sub_type_id: Optional[int] = Field(None, description="子类型ID")
|
||||||
|
color_id: Optional[int] = Field(None, description="颜色ID")
|
||||||
|
prompt: str = Field(..., min_length=1, max_length=2000, description="设计需求")
|
||||||
|
carving_technique: Optional[str] = Field(None, max_length=50, description="雕刻工艺")
|
||||||
|
design_style: Optional[str] = Field(None, max_length=50, description="设计风格")
|
||||||
|
motif: Optional[str] = Field(None, max_length=100, description="题材纹样")
|
||||||
|
size_spec: Optional[str] = Field(None, max_length=100, description="尺寸规格")
|
||||||
|
surface_finish: Optional[str] = Field(None, max_length=50, description="表面处理")
|
||||||
|
usage_scene: Optional[str] = Field(None, max_length=50, description="用途场景")
|
||||||
|
|
||||||
|
|
||||||
|
class DesignResponse(BaseModel):
|
||||||
|
"""设计作品响应"""
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
category: CategoryResponse
|
||||||
|
sub_type: Optional[SubTypeResponse] = None
|
||||||
|
color: Optional[ColorResponse] = None
|
||||||
|
prompt: str
|
||||||
|
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
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class DesignListResponse(BaseModel):
|
||||||
|
"""设计作品列表响应"""
|
||||||
|
items: List[DesignResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
51
backend/app/schemas/user.py
Normal file
51
backend/app/schemas/user.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""
|
||||||
|
用户相关 Pydantic Schemas
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
"""用户注册请求"""
|
||||||
|
username: str = Field(..., min_length=2, max_length=50, description="用户名")
|
||||||
|
password: str = Field(..., min_length=6, max_length=100, description="密码")
|
||||||
|
nickname: Optional[str] = Field(None, max_length=50, description="昵称")
|
||||||
|
|
||||||
|
|
||||||
|
class UserLogin(BaseModel):
|
||||||
|
"""用户登录请求"""
|
||||||
|
username: str = Field(..., description="用户名")
|
||||||
|
password: str = Field(..., description="密码")
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
"""用户响应"""
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
nickname: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
avatar: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
"""认证令牌响应"""
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
"""用户信息更新请求"""
|
||||||
|
nickname: Optional[str] = Field(None, max_length=50, description="昵称")
|
||||||
|
phone: Optional[str] = Field(None, max_length=20, description="手机号")
|
||||||
|
avatar: Optional[str] = Field(None, max_length=255, description="头像URL")
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordChange(BaseModel):
|
||||||
|
"""修改密码请求"""
|
||||||
|
old_password: str = Field(..., description="旧密码")
|
||||||
|
new_password: str = Field(..., min_length=6, max_length=100, description="新密码")
|
||||||
8
backend/app/services/__init__.py
Normal file
8
backend/app/services/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# 业务服务模块
|
||||||
|
from . import design_service
|
||||||
|
from .mock_generator import generate_mock_design
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"design_service",
|
||||||
|
"generate_mock_design",
|
||||||
|
]
|
||||||
67
backend/app/services/auth_service.py
Normal file
67
backend/app/services/auth_service.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
认证服务
|
||||||
|
提供用户注册和登录业务逻辑
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..models.user import User
|
||||||
|
from ..schemas.user import UserCreate
|
||||||
|
from ..utils.security import get_password_hash, verify_password
|
||||||
|
|
||||||
|
|
||||||
|
def register_user(db: Session, user_data: UserCreate) -> User:
|
||||||
|
"""
|
||||||
|
注册新用户
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
user_data: 用户注册数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
创建的用户对象
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 用户名已存在时抛出
|
||||||
|
"""
|
||||||
|
# 检查用户名是否已存在
|
||||||
|
existing_user = db.query(User).filter(User.username == user_data.username).first()
|
||||||
|
if existing_user:
|
||||||
|
raise ValueError("用户名已存在")
|
||||||
|
|
||||||
|
# 创建新用户,密码加密存储
|
||||||
|
db_user = User(
|
||||||
|
username=user_data.username,
|
||||||
|
hashed_password=get_password_hash(user_data.password),
|
||||||
|
nickname=user_data.nickname or user_data.username
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(db_user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_user)
|
||||||
|
|
||||||
|
return db_user
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_user(db: Session, username: str, password: str) -> Optional[User]:
|
||||||
|
"""
|
||||||
|
验证用户登录
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
username: 用户名
|
||||||
|
password: 明文密码
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
验证成功返回用户对象,失败返回 None
|
||||||
|
"""
|
||||||
|
# 查询用户
|
||||||
|
user = db.query(User).filter(User.username == username).first()
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 验证密码
|
||||||
|
if not verify_password(password, user.hashed_password):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return user
|
||||||
150
backend/app/services/design_service.py
Normal file
150
backend/app/services/design_service.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
设计服务
|
||||||
|
处理设计相关的业务逻辑
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import desc
|
||||||
|
|
||||||
|
from ..models import Design, Category, SubType, Color
|
||||||
|
from ..schemas import DesignCreate
|
||||||
|
from ..config import settings
|
||||||
|
from .mock_generator import generate_mock_design
|
||||||
|
|
||||||
|
|
||||||
|
def create_design(db: Session, user_id: int, design_data: DesignCreate) -> Design:
|
||||||
|
"""
|
||||||
|
创建设计记录
|
||||||
|
|
||||||
|
1. 创建设计记录(status=generating)
|
||||||
|
2. 调用 mock_generator 生成图片
|
||||||
|
3. 更新设计记录(status=completed, image_url)
|
||||||
|
4. 返回设计对象
|
||||||
|
"""
|
||||||
|
# 获取关联信息
|
||||||
|
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() # 获取 ID
|
||||||
|
|
||||||
|
# 生成图片
|
||||||
|
save_path = os.path.join(settings.UPLOAD_DIR, "designs", f"{design.id}.png")
|
||||||
|
image_url = generate_mock_design(
|
||||||
|
category_name=category.name,
|
||||||
|
sub_type_name=sub_type.name if sub_type else None,
|
||||||
|
color_name=color.name if color else None,
|
||||||
|
prompt=design_data.prompt,
|
||||||
|
save_path=save_path,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新设计记录
|
||||||
|
design.image_url = image_url
|
||||||
|
design.status = "completed"
|
||||||
|
db.commit()
|
||||||
|
db.refresh(design)
|
||||||
|
|
||||||
|
return design
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_designs(
|
||||||
|
db: Session,
|
||||||
|
user_id: int,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20
|
||||||
|
) -> Tuple[List[Design], int]:
|
||||||
|
"""
|
||||||
|
分页查询用户设计历史
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(设计列表, 总数)
|
||||||
|
"""
|
||||||
|
query = db.query(Design).filter(Design.user_id == user_id)
|
||||||
|
|
||||||
|
# 获取总数
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
# 分页查询,按创建时间倒序
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
designs = query.order_by(desc(Design.created_at)).offset(offset).limit(page_size).all()
|
||||||
|
|
||||||
|
return designs, total
|
||||||
|
|
||||||
|
|
||||||
|
def get_design_by_id(db: Session, design_id: int, user_id: int) -> Optional[Design]:
|
||||||
|
"""
|
||||||
|
获取单个设计
|
||||||
|
只返回属于该用户的设计
|
||||||
|
"""
|
||||||
|
return db.query(Design).filter(
|
||||||
|
Design.id == design_id,
|
||||||
|
Design.user_id == user_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_design(db: Session, design_id: int, user_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
删除设计
|
||||||
|
|
||||||
|
1. 查找设计(必须属于该用户)
|
||||||
|
2. 删除图片文件
|
||||||
|
3. 删除数据库记录
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否删除成功
|
||||||
|
"""
|
||||||
|
design = db.query(Design).filter(
|
||||||
|
Design.id == design_id,
|
||||||
|
Design.user_id == user_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
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 # 忽略删除失败
|
||||||
|
|
||||||
|
# 删除数据库记录
|
||||||
|
db.delete(design)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return True
|
||||||
222
backend/app/services/mock_generator.py
Normal file
222
backend/app/services/mock_generator.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"""
|
||||||
|
Mock 图片生成服务
|
||||||
|
使用 Pillow 生成带文字的占位设计图
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from typing import Optional, Tuple, Union
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
|
||||||
|
# 颜色映射表(中文颜色名 -> 十六进制)
|
||||||
|
COLOR_MAP = {
|
||||||
|
# 和田玉国标色种
|
||||||
|
"白玉": "#FEFEF2",
|
||||||
|
"青白玉": "#E8EDE4",
|
||||||
|
"青玉": "#7A8B6E",
|
||||||
|
"碧玉": "#2D5F2D",
|
||||||
|
"翠青": "#6BAF8D",
|
||||||
|
"黄玉": "#D4A843",
|
||||||
|
"糖玉": "#C4856C",
|
||||||
|
"墨玉": "#2C2C2C",
|
||||||
|
"藕粉": "#E8B4B8",
|
||||||
|
"烟紫": "#8B7D9B",
|
||||||
|
# 原有颜色
|
||||||
|
"糖白": "#F5F0E8",
|
||||||
|
# 通用颜色
|
||||||
|
"白色": "#FFFFFF",
|
||||||
|
"黑色": "#333333",
|
||||||
|
"红色": "#C41E3A",
|
||||||
|
"绿色": "#228B22",
|
||||||
|
"蓝色": "#4169E1",
|
||||||
|
"黄色": "#FFD700",
|
||||||
|
"紫色": "#9370DB",
|
||||||
|
"粉色": "#FFB6C1",
|
||||||
|
"橙色": "#FF8C00",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 默认背景色(浅灰)
|
||||||
|
DEFAULT_BG_COLOR = "#E8E4DF"
|
||||||
|
|
||||||
|
|
||||||
|
def hex_to_rgb(hex_color: str) -> Tuple[int, int, int]:
|
||||||
|
"""将十六进制颜色转换为 RGB 元组"""
|
||||||
|
hex_color = hex_color.lstrip('#')
|
||||||
|
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
|
||||||
|
|
||||||
|
|
||||||
|
def get_contrast_text_color(bg_color: str) -> str:
|
||||||
|
"""根据背景色计算合适的文字颜色(黑或白)"""
|
||||||
|
r, g, b = hex_to_rgb(bg_color)
|
||||||
|
# 使用亮度公式
|
||||||
|
brightness = (r * 299 + g * 587 + b * 114) / 1000
|
||||||
|
return "#333333" if brightness > 128 else "#FFFFFF"
|
||||||
|
|
||||||
|
|
||||||
|
def get_font(size: int = 24) -> Union[ImageFont.FreeTypeFont, ImageFont.ImageFont]:
|
||||||
|
"""
|
||||||
|
获取字体,优先使用系统中文字体
|
||||||
|
"""
|
||||||
|
# 常见中文字体路径
|
||||||
|
font_paths = [
|
||||||
|
# macOS
|
||||||
|
"/System/Library/Fonts/PingFang.ttc",
|
||||||
|
"/System/Library/Fonts/STHeiti Light.ttc",
|
||||||
|
"/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
|
||||||
|
"/Library/Fonts/Arial Unicode.ttf",
|
||||||
|
# Linux
|
||||||
|
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
|
||||||
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
|
||||||
|
# Windows
|
||||||
|
"C:\\Windows\\Fonts\\msyh.ttc",
|
||||||
|
"C:\\Windows\\Fonts\\simsun.ttc",
|
||||||
|
]
|
||||||
|
|
||||||
|
for font_path in font_paths:
|
||||||
|
if os.path.exists(font_path):
|
||||||
|
try:
|
||||||
|
return ImageFont.truetype(font_path, size)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 回退到默认字体
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_mock_design(
|
||||||
|
category_name: str,
|
||||||
|
sub_type_name: Optional[str],
|
||||||
|
color_name: Optional[str],
|
||||||
|
prompt: str,
|
||||||
|
save_path: str,
|
||||||
|
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:
|
||||||
|
"""
|
||||||
|
生成 Mock 设计图
|
||||||
|
|
||||||
|
Args:
|
||||||
|
category_name: 品类名称
|
||||||
|
sub_type_name: 子类型名称(可选)
|
||||||
|
color_name: 颜色名称(可选)
|
||||||
|
prompt: 用户设计需求
|
||||||
|
save_path: 保存路径
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
相对 URL 路径,如 /uploads/designs/1001.png
|
||||||
|
"""
|
||||||
|
# 确定背景色
|
||||||
|
if color_name and color_name in COLOR_MAP:
|
||||||
|
bg_color = COLOR_MAP[color_name]
|
||||||
|
elif color_name:
|
||||||
|
# 尝试直接使用颜色名(可能是十六进制)
|
||||||
|
bg_color = color_name if color_name.startswith("#") else DEFAULT_BG_COLOR
|
||||||
|
else:
|
||||||
|
bg_color = DEFAULT_BG_COLOR
|
||||||
|
|
||||||
|
# 创建图片
|
||||||
|
width, height = 800, 800
|
||||||
|
bg_rgb = hex_to_rgb(bg_color)
|
||||||
|
image = Image.new("RGB", (width, height), bg_rgb)
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
|
||||||
|
# 获取文字颜色(与背景对比)
|
||||||
|
text_color = get_contrast_text_color(bg_color)
|
||||||
|
text_rgb = hex_to_rgb(text_color)
|
||||||
|
|
||||||
|
# 获取字体
|
||||||
|
title_font = get_font(48)
|
||||||
|
info_font = get_font(32)
|
||||||
|
prompt_font = get_font(28)
|
||||||
|
|
||||||
|
# 绘制标题
|
||||||
|
title = "玉宗设计"
|
||||||
|
draw.text((width // 2, 100), title, font=title_font, fill=text_rgb, anchor="mm")
|
||||||
|
|
||||||
|
# 绘制分隔线
|
||||||
|
line_y = 160
|
||||||
|
draw.line([(100, line_y), (700, line_y)], fill=text_rgb, width=2)
|
||||||
|
|
||||||
|
# 绘制品类信息
|
||||||
|
y_position = 220
|
||||||
|
info_lines = [f"品类: {category_name}"]
|
||||||
|
|
||||||
|
if sub_type_name:
|
||||||
|
info_lines.append(f"类型: {sub_type_name}")
|
||||||
|
|
||||||
|
if color_name:
|
||||||
|
info_lines.append(f"颜色: {color_name}")
|
||||||
|
|
||||||
|
if carving_technique:
|
||||||
|
info_lines.append(f"工艺: {carving_technique}")
|
||||||
|
|
||||||
|
if design_style:
|
||||||
|
info_lines.append(f"风格: {design_style}")
|
||||||
|
|
||||||
|
if motif:
|
||||||
|
info_lines.append(f"题材: {motif}")
|
||||||
|
|
||||||
|
if size_spec:
|
||||||
|
info_lines.append(f"尺寸: {size_spec}")
|
||||||
|
|
||||||
|
if surface_finish:
|
||||||
|
info_lines.append(f"表面: {surface_finish}")
|
||||||
|
|
||||||
|
if usage_scene:
|
||||||
|
info_lines.append(f"用途: {usage_scene}")
|
||||||
|
|
||||||
|
for line in info_lines:
|
||||||
|
draw.text((width // 2, y_position), line, font=info_font, fill=text_rgb, anchor="mm")
|
||||||
|
y_position += 50
|
||||||
|
|
||||||
|
# 绘制分隔线
|
||||||
|
y_position += 20
|
||||||
|
draw.line([(100, y_position), (700, y_position)], fill=text_rgb, width=1)
|
||||||
|
y_position += 40
|
||||||
|
|
||||||
|
# 绘制用户需求标题
|
||||||
|
draw.text((width // 2, y_position), "设计需求:", font=info_font, fill=text_rgb, anchor="mm")
|
||||||
|
y_position += 50
|
||||||
|
|
||||||
|
# 绘制用户需求文本(自动换行)
|
||||||
|
max_chars_per_line = 20
|
||||||
|
prompt_lines = []
|
||||||
|
current_line = ""
|
||||||
|
for char in prompt:
|
||||||
|
current_line += char
|
||||||
|
if len(current_line) >= max_chars_per_line:
|
||||||
|
prompt_lines.append(current_line)
|
||||||
|
current_line = ""
|
||||||
|
if current_line:
|
||||||
|
prompt_lines.append(current_line)
|
||||||
|
|
||||||
|
# 限制最多显示 5 行
|
||||||
|
for line in prompt_lines[:5]:
|
||||||
|
draw.text((width // 2, y_position), line, font=prompt_font, fill=text_rgb, anchor="mm")
|
||||||
|
y_position += 40
|
||||||
|
|
||||||
|
if len(prompt_lines) > 5:
|
||||||
|
draw.text((width // 2, y_position), "...", font=prompt_font, fill=text_rgb, anchor="mm")
|
||||||
|
|
||||||
|
# 绘制底部装饰
|
||||||
|
draw.rectangle([(50, 720), (750, 750)], outline=text_rgb, width=2)
|
||||||
|
draw.text((width // 2, 735), "AI Generated Mock Design", font=get_font(20), fill=text_rgb, anchor="mm")
|
||||||
|
|
||||||
|
# 确保目录存在
|
||||||
|
os.makedirs(os.path.dirname(save_path), exist_ok=True)
|
||||||
|
|
||||||
|
# 保存图片
|
||||||
|
image.save(save_path, "PNG")
|
||||||
|
|
||||||
|
# 返回相对 URL 路径
|
||||||
|
# save_path 格式类似 uploads/designs/1001.png
|
||||||
|
# 需要转换为 /uploads/designs/1001.png
|
||||||
|
relative_path = save_path.replace("\\", "/")
|
||||||
|
if not relative_path.startswith("/"):
|
||||||
|
relative_path = "/" + relative_path
|
||||||
|
|
||||||
|
return relative_path
|
||||||
1
backend/app/utils/__init__.py
Normal file
1
backend/app/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# 工具函数模块
|
||||||
58
backend/app/utils/deps.py
Normal file
58
backend/app/utils/deps.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""
|
||||||
|
认证依赖注入
|
||||||
|
提供用户认证相关的 FastAPI 依赖
|
||||||
|
"""
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from ..database import get_db
|
||||||
|
from ..models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
# OAuth2 密码认证方案,tokenUrl 指向登录接口
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(
|
||||||
|
token: str = Depends(oauth2_scheme),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
) -> User:
|
||||||
|
"""
|
||||||
|
获取当前登录用户
|
||||||
|
|
||||||
|
从 JWT token 中解析用户 ID,查询数据库返回用户对象
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: JWT access token
|
||||||
|
db: 数据库会话
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
当前登录的用户对象
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: token 无效或用户不存在时抛出 401
|
||||||
|
"""
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="无法验证凭据",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 解码 JWT token
|
||||||
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||||
|
user_id: str = payload.get("sub")
|
||||||
|
if user_id is None:
|
||||||
|
raise credentials_exception
|
||||||
|
except JWTError:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
# 从数据库查询用户
|
||||||
|
user = db.query(User).filter(User.id == int(user_id)).first()
|
||||||
|
if user is None:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
return user
|
||||||
63
backend/app/utils/security.py
Normal file
63
backend/app/utils/security.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
安全工具函数
|
||||||
|
包含 JWT 令牌创建和密码加密验证
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, Any
|
||||||
|
|
||||||
|
from jose import jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
# 密码加密上下文
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
|
"""
|
||||||
|
创建 JWT access token
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: 要编码到 token 中的数据
|
||||||
|
expires_delta: token 过期时间,默认使用配置中的时间
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
编码后的 JWT token 字符串
|
||||||
|
"""
|
||||||
|
to_encode = data.copy()
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
"""
|
||||||
|
验证密码
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plain_password: 明文密码
|
||||||
|
hashed_password: 哈希后的密码
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
密码是否匹配
|
||||||
|
"""
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
"""
|
||||||
|
对密码进行哈希
|
||||||
|
|
||||||
|
Args:
|
||||||
|
password: 明文密码
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
哈希后的密码字符串
|
||||||
|
"""
|
||||||
|
return pwd_context.hash(password)
|
||||||
13
backend/requirements.txt
Normal file
13
backend/requirements.txt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
fastapi==0.109.2
|
||||||
|
uvicorn[standard]==0.27.1
|
||||||
|
sqlalchemy==2.0.27
|
||||||
|
pymysql==1.1.0
|
||||||
|
cryptography==42.0.2
|
||||||
|
alembic==1.13.1
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
python-multipart==0.0.9
|
||||||
|
Pillow==10.2.0
|
||||||
|
pydantic[email]==2.6.1
|
||||||
|
pydantic-settings==2.1.0
|
||||||
0
backend/uploads/.gitkeep
Normal file
0
backend/uploads/.gitkeep
Normal file
BIN
backend/uploads/designs/1.png
Normal file
BIN
backend/uploads/designs/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
3
frontend/.vscode/extensions.json
vendored
Normal file
3
frontend/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
5
frontend/README.md
Normal file
5
frontend/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + TypeScript + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2274
frontend/package-lock.json
generated
Normal file
2274
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
frontend/package.json
Normal file
28
frontend/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
|
"axios": "^1.13.6",
|
||||||
|
"element-plus": "^2.13.6",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"vue": "^3.5.30",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.12.0",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
|
"@vue/tsconfig": "^0.9.0",
|
||||||
|
"sass": "^1.98.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^8.0.1",
|
||||||
|
"vue-tsc": "^3.2.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend/public/icons.svg
Normal file
24
frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
34
frontend/src/App.vue
Normal file
34
frontend/src/App.vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<AppHeader />
|
||||||
|
<main class="main-content">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import AppHeader from '@/components/AppHeader.vue'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
// 应用初始化时恢复登录状态
|
||||||
|
onMounted(() => {
|
||||||
|
userStore.init()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.app-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
51
frontend/src/api/auth.ts
Normal file
51
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// Auth API - 用户认证相关接口
|
||||||
|
import request from './request'
|
||||||
|
|
||||||
|
export interface LoginParams {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterParams {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
nickname?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
access_token: string
|
||||||
|
token_type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
nickname: string
|
||||||
|
phone?: string | null
|
||||||
|
avatar_url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录
|
||||||
|
export const login = (data: LoginParams) => {
|
||||||
|
return request.post<any, LoginResponse>('/auth/login', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册
|
||||||
|
export const register = (data: RegisterParams) => {
|
||||||
|
return request.post('/auth/register', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前用户信息
|
||||||
|
export const getCurrentUser = () => {
|
||||||
|
return request.get<any, UserInfo>('/auth/me')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新个人资料
|
||||||
|
export function updateProfileApi(data: { nickname?: string; phone?: string }) {
|
||||||
|
return request.put<any, UserInfo>('/users/profile', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改密码
|
||||||
|
export function changePasswordApi(data: { old_password: string; new_password: string }) {
|
||||||
|
return request.put('/users/password', data)
|
||||||
|
}
|
||||||
42
frontend/src/api/category.ts
Normal file
42
frontend/src/api/category.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// Category API - 玉石分类相关接口
|
||||||
|
import request from './request'
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
icon: string | null
|
||||||
|
sort_order: number
|
||||||
|
flow_type: 'full' | 'size_color' | 'simple'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubType {
|
||||||
|
id: number
|
||||||
|
category_id: number
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
preview_image: string | null
|
||||||
|
sort_order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColorOption {
|
||||||
|
id: number
|
||||||
|
category_id: number
|
||||||
|
name: string
|
||||||
|
hex_code: string
|
||||||
|
sort_order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取品类列表
|
||||||
|
export function getCategoriesApi() {
|
||||||
|
return request.get<any, Category[]>('/categories')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取品类下的子类型列表
|
||||||
|
export function getSubTypesApi(categoryId: number) {
|
||||||
|
return request.get<any, SubType[]>(`/categories/${categoryId}/sub-types`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取品类下的颜色列表
|
||||||
|
export function getColorsApi(categoryId: number) {
|
||||||
|
return request.get<any, ColorOption[]>(`/categories/${categoryId}/colors`)
|
||||||
|
}
|
||||||
76
frontend/src/api/design.ts
Normal file
76
frontend/src/api/design.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// Design API - 设计相关接口
|
||||||
|
import request from './request'
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubType {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Design {
|
||||||
|
id: number
|
||||||
|
user_id: number
|
||||||
|
category: Category
|
||||||
|
sub_type: SubType
|
||||||
|
color: { id: number; name: string } | null
|
||||||
|
prompt: string
|
||||||
|
carving_technique: string | null
|
||||||
|
design_style: string | null
|
||||||
|
motif: string | null
|
||||||
|
size_spec: string | null
|
||||||
|
surface_finish: string | null
|
||||||
|
usage_scene: string | null
|
||||||
|
image_url: string | null
|
||||||
|
status: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DesignListResponse {
|
||||||
|
items: Design[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerateDesignParams {
|
||||||
|
category_id: number
|
||||||
|
sub_type_id?: number
|
||||||
|
color_id?: number
|
||||||
|
prompt: string
|
||||||
|
carving_technique?: string
|
||||||
|
design_style?: string
|
||||||
|
motif?: string
|
||||||
|
size_spec?: string
|
||||||
|
surface_finish?: string
|
||||||
|
usage_scene?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取设计列表
|
||||||
|
export function getDesignsApi(page: number = 1, pageSize: number = 20) {
|
||||||
|
return request.get<any, DesignListResponse>('/designs', { params: { page, page_size: pageSize } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取设计详情
|
||||||
|
export function getDesignApi(id: number) {
|
||||||
|
return request.get<any, Design>(`/designs/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成设计
|
||||||
|
export function generateDesignApi(data: GenerateDesignParams) {
|
||||||
|
return request.post<any, Design>('/designs/generate', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除设计
|
||||||
|
export function deleteDesignApi(id: number) {
|
||||||
|
return request.delete(`/designs/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取设计下载 URL
|
||||||
|
export function getDesignDownloadUrl(id: number) {
|
||||||
|
return `/api/designs/${id}/download`
|
||||||
|
}
|
||||||
35
frontend/src/api/request.ts
Normal file
35
frontend/src/api/request.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const request = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
timeout: 30000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 请求拦截器 - 自动携带 JWT Token
|
||||||
|
request.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 响应拦截器 - 统一错误处理
|
||||||
|
request.interceptors.response.use(
|
||||||
|
(response) => response.data,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
const message = error.response?.data?.detail || '请求失败'
|
||||||
|
ElMessage.error(message)
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default request
|
||||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
49
frontend/src/assets/styles/theme.scss
Normal file
49
frontend/src/assets/styles/theme.scss
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// 玉宗品牌色 - 中式雅致风格
|
||||||
|
$primary-color: #5B7E6B; // 青玉色 - 主色调
|
||||||
|
$primary-light: #8BAF9C; // 浅青
|
||||||
|
$primary-dark: #3D5A4A; // 深青
|
||||||
|
$secondary-color: #C4A86C; // 金缕色 - 辅助色
|
||||||
|
$bg-color: #FAF8F5; // 暖白底色
|
||||||
|
$bg-dark: #F0EDE8; // 深一级底色
|
||||||
|
$text-primary: #2C2C2C; // 主文字
|
||||||
|
$text-secondary: #6B6B6B; // 次要文字
|
||||||
|
$text-light: #999999; // 辅助文字
|
||||||
|
$border-color: #E8E4DF; // 边框色
|
||||||
|
$ink-color: #1A1A2E; // 墨色
|
||||||
|
|
||||||
|
// Element Plus 主题覆盖
|
||||||
|
:root {
|
||||||
|
--el-color-primary: #{$primary-color};
|
||||||
|
--el-color-primary-light-3: #{$primary-light};
|
||||||
|
--el-color-primary-dark-2: #{$primary-dark};
|
||||||
|
--el-bg-color: #{$bg-color};
|
||||||
|
--el-border-color: #{$border-color};
|
||||||
|
--el-text-color-primary: #{$text-primary};
|
||||||
|
--el-text-color-regular: #{$text-secondary};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局基础样式
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||||
|
background-color: $bg-color;
|
||||||
|
color: $text-primary;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动条美化
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: $border-color;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: $text-light;
|
||||||
|
}
|
||||||
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
147
frontend/src/components/AppHeader.vue
Normal file
147
frontend/src/components/AppHeader.vue
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<template>
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<router-link to="/" class="logo">
|
||||||
|
<span class="logo-text">玉宗</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<nav class="header-nav">
|
||||||
|
<router-link to="/" class="nav-link">设计</router-link>
|
||||||
|
<router-link to="/generate" class="nav-link">生成</router-link>
|
||||||
|
</nav>
|
||||||
|
<div class="header-right">
|
||||||
|
<template v-if="isLoggedIn">
|
||||||
|
<el-dropdown trigger="click" @command="handleCommand">
|
||||||
|
<span class="user-dropdown">
|
||||||
|
<span class="user-nickname">{{ userNickname }}</span>
|
||||||
|
<el-icon><ArrowDown /></el-icon>
|
||||||
|
</span>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="user">个人中心</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<router-link to="/login" class="auth-link">登录</router-link>
|
||||||
|
<router-link to="/register" class="auth-link auth-register">注册</router-link>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ArrowDown } from '@element-plus/icons-vue'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => !!userStore.token)
|
||||||
|
const userNickname = computed(() => userStore.userInfo?.nickname || '用户')
|
||||||
|
|
||||||
|
const handleCommand = (command: string) => {
|
||||||
|
if (command === 'user') {
|
||||||
|
router.push('/user')
|
||||||
|
} else if (command === 'logout') {
|
||||||
|
userStore.logout()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 60px;
|
||||||
|
padding: 0 32px;
|
||||||
|
background-color: #fff;
|
||||||
|
border-bottom: 1px solid #E8E4DF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
.logo {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.logo-text {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #5B7E6B;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 32px;
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: #6B6B6B;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.router-link-active {
|
||||||
|
color: #5B7E6B;
|
||||||
|
border-bottom-color: #5B7E6B;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
.auth-link {
|
||||||
|
color: #6B6B6B;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
transition: color 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #5B7E6B;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.auth-register {
|
||||||
|
background-color: #5B7E6B;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #3D5A4A;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-dropdown {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #2C2C2C;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #5B7E6B;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-nickname {
|
||||||
|
max-width: 120px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
100
frontend/src/components/CategoryNav.vue
Normal file
100
frontend/src/components/CategoryNav.vue
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<nav class="category-nav">
|
||||||
|
<div class="nav-header">
|
||||||
|
<h3>玉石品类</h3>
|
||||||
|
</div>
|
||||||
|
<ul class="category-list">
|
||||||
|
<li
|
||||||
|
v-for="category in categories"
|
||||||
|
:key="category.id"
|
||||||
|
class="category-item"
|
||||||
|
:class="{ active: currentCategory?.id === category.id }"
|
||||||
|
@click="handleSelect(category)"
|
||||||
|
>
|
||||||
|
<span class="category-name">{{ category.name }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useCategoryStore } from '@/stores/category'
|
||||||
|
import type { Category } from '@/stores/category'
|
||||||
|
|
||||||
|
const categoryStore = useCategoryStore()
|
||||||
|
|
||||||
|
const categories = computed(() => categoryStore.categories)
|
||||||
|
const currentCategory = computed(() => categoryStore.currentCategory)
|
||||||
|
|
||||||
|
const handleSelect = (category: Category) => {
|
||||||
|
categoryStore.selectCategory(category)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
$primary-color: #5B7E6B;
|
||||||
|
$primary-light: #8BAF9C;
|
||||||
|
$bg-color: #FAF8F5;
|
||||||
|
$border-color: #E8E4DF;
|
||||||
|
$text-primary: #2C2C2C;
|
||||||
|
$text-secondary: #6B6B6B;
|
||||||
|
|
||||||
|
.category-nav {
|
||||||
|
width: 200px;
|
||||||
|
min-width: 200px;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #fff;
|
||||||
|
border-right: 1px solid $border-color;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-header {
|
||||||
|
padding: 20px 16px;
|
||||||
|
border-bottom: 1px solid $border-color;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text-primary;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-item {
|
||||||
|
padding: 14px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
border-bottom: 1px solid $border-color;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba($primary-color, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: $primary-color;
|
||||||
|
|
||||||
|
.category-name {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-name {
|
||||||
|
font-size: 14px;
|
||||||
|
color: $text-secondary;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
transition: color 0.25s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
103
frontend/src/components/ColorPicker.vue
Normal file
103
frontend/src/components/ColorPicker.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<template>
|
||||||
|
<div class="color-picker">
|
||||||
|
<h4 class="picker-title">选择颜色</h4>
|
||||||
|
<div class="color-grid">
|
||||||
|
<div
|
||||||
|
v-for="color in colors"
|
||||||
|
:key="color.id"
|
||||||
|
class="color-item"
|
||||||
|
:class="{ active: modelValue?.id === color.id }"
|
||||||
|
@click="handleSelect(color)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="color-swatch"
|
||||||
|
:style="{ backgroundColor: color.hex_code }"
|
||||||
|
></div>
|
||||||
|
<span class="color-name">{{ color.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ColorOption } from '@/stores/category'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
colors: ColorOption[]
|
||||||
|
modelValue: ColorOption | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', color: ColorOption): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const handleSelect = (color: ColorOption) => {
|
||||||
|
emit('update:modelValue', color)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
$primary-color: #5B7E6B;
|
||||||
|
$secondary-color: #C4A86C;
|
||||||
|
$border-color: #E8E4DF;
|
||||||
|
$text-primary: #2C2C2C;
|
||||||
|
$text-secondary: #6B6B6B;
|
||||||
|
|
||||||
|
.color-picker {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text-primary;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba($primary-color, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
.color-swatch {
|
||||||
|
box-shadow: 0 0 0 3px $secondary-color;
|
||||||
|
}
|
||||||
|
.color-name {
|
||||||
|
color: $primary-color;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-swatch {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid $border-color;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-name {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-secondary;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
366
frontend/src/components/DesignPreview.vue
Normal file
366
frontend/src/components/DesignPreview.vue
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
<template>
|
||||||
|
<div class="design-preview">
|
||||||
|
<!-- 图片预览区 -->
|
||||||
|
<div class="preview-container">
|
||||||
|
<div class="image-wrapper" :style="{ transform: `scale(${scale})` }">
|
||||||
|
<el-image
|
||||||
|
:src="imageUrl"
|
||||||
|
:alt="design.prompt"
|
||||||
|
fit="contain"
|
||||||
|
:preview-src-list="[imageUrl]"
|
||||||
|
:initial-index="0"
|
||||||
|
preview-teleported
|
||||||
|
class="design-image"
|
||||||
|
>
|
||||||
|
<template #placeholder>
|
||||||
|
<div class="image-placeholder">
|
||||||
|
<el-icon class="loading-icon"><Loading /></el-icon>
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #error>
|
||||||
|
<div class="image-error">
|
||||||
|
<el-icon><PictureFilled /></el-icon>
|
||||||
|
<span>图片加载失败</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-image>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 缩放控制 -->
|
||||||
|
<div class="zoom-controls">
|
||||||
|
<button class="zoom-btn" @click="zoomOut" :disabled="scale <= 0.5">
|
||||||
|
<el-icon><ZoomOut /></el-icon>
|
||||||
|
</button>
|
||||||
|
<span class="zoom-level">{{ Math.round(scale * 100) }}%</span>
|
||||||
|
<button class="zoom-btn" @click="zoomIn" :disabled="scale >= 2">
|
||||||
|
<el-icon><ZoomIn /></el-icon>
|
||||||
|
</button>
|
||||||
|
<button class="zoom-btn reset-btn" @click="resetZoom" v-if="scale !== 1">
|
||||||
|
<el-icon><RefreshRight /></el-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 设计信息 -->
|
||||||
|
<div class="design-info">
|
||||||
|
<h4 class="info-title">设计详情</h4>
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="info-label">品类</span>
|
||||||
|
<span class="info-value">{{ design.category?.name || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" v-if="design.sub_type">
|
||||||
|
<span class="info-label">类型</span>
|
||||||
|
<span class="info-value">{{ design.sub_type.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" v-if="design.color">
|
||||||
|
<span class="info-label">颜色</span>
|
||||||
|
<span class="info-value">{{ design.color.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item full-width">
|
||||||
|
<span class="info-label">设计需求</span>
|
||||||
|
<span class="info-value prompt">{{ design.prompt }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="action-buttons">
|
||||||
|
<a
|
||||||
|
:href="downloadUrl"
|
||||||
|
:download="downloadFilename"
|
||||||
|
class="action-btn download-btn"
|
||||||
|
>
|
||||||
|
<el-icon><Download /></el-icon>
|
||||||
|
<span>下载设计图</span>
|
||||||
|
</a>
|
||||||
|
<button class="action-btn secondary-btn" @click="goToUserCenter">
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
|
<span>查看我的设计</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } 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'
|
||||||
|
import type { Design } from '@/stores/design'
|
||||||
|
import { getDesignDownloadUrl } from '@/api/design'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
design: Design
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 缩放比例
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return `/api${props.design.image_url}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 下载URL
|
||||||
|
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 zoomIn = () => {
|
||||||
|
if (scale.value < 2) {
|
||||||
|
scale.value = Math.min(2, scale.value + 0.25)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缩小
|
||||||
|
const zoomOut = () => {
|
||||||
|
if (scale.value > 0.5) {
|
||||||
|
scale.value = Math.max(0.5, scale.value - 0.25)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置缩放
|
||||||
|
const resetZoom = () => {
|
||||||
|
scale.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳转到用户中心
|
||||||
|
const goToUserCenter = () => {
|
||||||
|
ElMessage.success('设计已自动保存到您的设计历史中')
|
||||||
|
router.push('/user')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
$primary-color: #5B7E6B;
|
||||||
|
$primary-light: #8BAF9C;
|
||||||
|
$secondary-color: #C4A86C;
|
||||||
|
$bg-color: #FAF8F5;
|
||||||
|
$bg-dark: #F0EDE8;
|
||||||
|
$border-color: #E8E4DF;
|
||||||
|
$text-primary: #2C2C2C;
|
||||||
|
$text-secondary: #6B6B6B;
|
||||||
|
$text-light: #999999;
|
||||||
|
|
||||||
|
.design-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-container {
|
||||||
|
position: relative;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 300px;
|
||||||
|
max-height: 500px;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 450px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||||
|
cursor: zoom-in;
|
||||||
|
|
||||||
|
:deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 450px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-placeholder,
|
||||||
|
.image-error {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
background: $bg-color;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: $text-light;
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-icon {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-controls {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: $text-secondary;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: $primary-color;
|
||||||
|
border-color: $primary-color;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-level {
|
||||||
|
min-width: 48px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-info {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text-primary;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid $border-color;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
&.full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-light;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 14px;
|
||||||
|
color: $text-primary;
|
||||||
|
|
||||||
|
&.prompt {
|
||||||
|
line-height: 1.6;
|
||||||
|
color: $text-secondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn {
|
||||||
|
background: $primary-color;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #4a6a5a;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba($primary-color, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-btn {
|
||||||
|
background: #fff;
|
||||||
|
color: $primary-color;
|
||||||
|
border: 1px solid $primary-color;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $primary-color;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
93
frontend/src/components/HelloWorld.vue
Normal file
93
frontend/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import viteLogo from '../assets/vite.svg'
|
||||||
|
import heroImg from '../assets/hero.png'
|
||||||
|
import vueLogo from '../assets/vue.svg'
|
||||||
|
|
||||||
|
const count = ref(0)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section id="center">
|
||||||
|
<div class="hero">
|
||||||
|
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
||||||
|
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
||||||
|
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1>Get started</h1>
|
||||||
|
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
||||||
|
</div>
|
||||||
|
<button class="counter" @click="count++">Count is {{ count }}</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="ticks"></div>
|
||||||
|
|
||||||
|
<section id="next-steps">
|
||||||
|
<div id="docs">
|
||||||
|
<svg class="icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#documentation-icon"></use>
|
||||||
|
</svg>
|
||||||
|
<h2>Documentation</h2>
|
||||||
|
<p>Your questions, answered</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="https://vite.dev/" target="_blank">
|
||||||
|
<img class="logo" :src="viteLogo" alt="" />
|
||||||
|
Explore Vite
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://vuejs.org/" target="_blank">
|
||||||
|
<img class="button-icon" :src="vueLogo" alt="" />
|
||||||
|
Learn more
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div id="social">
|
||||||
|
<svg class="icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#social-icon"></use>
|
||||||
|
</svg>
|
||||||
|
<h2>Connect with us</h2>
|
||||||
|
<p>Join the Vite community</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||||
|
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#github-icon"></use>
|
||||||
|
</svg>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://chat.vite.dev/" target="_blank">
|
||||||
|
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#discord-icon"></use>
|
||||||
|
</svg>
|
||||||
|
Discord
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://x.com/vite_js" target="_blank">
|
||||||
|
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#x-icon"></use>
|
||||||
|
</svg>
|
||||||
|
X.com
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||||
|
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||||
|
<use href="/icons.svg#bluesky-icon"></use>
|
||||||
|
</svg>
|
||||||
|
Bluesky
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="ticks"></div>
|
||||||
|
<section id="spacer"></section>
|
||||||
|
</template>
|
||||||
414
frontend/src/components/SubTypePanel.vue
Normal file
414
frontend/src/components/SubTypePanel.vue
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
<template>
|
||||||
|
<div class="subtype-panel">
|
||||||
|
<!-- 未选中品类时的引导 -->
|
||||||
|
<div v-if="!currentCategory" class="empty-state">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="empty-text">请从左侧选择您感兴趣的品类开始设计</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<div v-else-if="loading" class="loading-state">
|
||||||
|
<el-icon class="loading-icon"><Loading /></el-icon>
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- flow_type = "full"(牌子):选择牌型 -->
|
||||||
|
<div v-else-if="currentCategory.flow_type === 'full'" class="panel-content">
|
||||||
|
<h3 class="panel-title">选择牌型</h3>
|
||||||
|
<p class="panel-desc">为「{{ currentCategory.name }}」选择一个款式</p>
|
||||||
|
<div class="card-grid">
|
||||||
|
<div
|
||||||
|
v-for="subType in subTypes"
|
||||||
|
:key="subType.id"
|
||||||
|
class="subtype-card"
|
||||||
|
:class="{ active: currentSubType?.id === subType.id }"
|
||||||
|
@click="handleSelectSubType(subType)"
|
||||||
|
>
|
||||||
|
<div class="card-preview">
|
||||||
|
<img
|
||||||
|
v-if="subType.preview_image"
|
||||||
|
:src="subType.preview_image"
|
||||||
|
:alt="subType.name"
|
||||||
|
/>
|
||||||
|
<div v-else class="card-placeholder">
|
||||||
|
<span>{{ subType.name.charAt(0) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-info">
|
||||||
|
<span class="card-name">{{ subType.name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 选择子类型后显示颜色选择(如有颜色数据) -->
|
||||||
|
<template v-if="currentSubType && colors.length > 0">
|
||||||
|
<ColorPicker
|
||||||
|
v-model="selectedColor"
|
||||||
|
:colors="colors"
|
||||||
|
/>
|
||||||
|
<div v-if="selectedColor" class="action-bar">
|
||||||
|
<button class="btn-primary" @click="goToGenerate">
|
||||||
|
开始设计
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else-if="currentSubType" class="action-bar">
|
||||||
|
<button class="btn-primary" @click="goToGenerate">
|
||||||
|
开始设计
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- flow_type = "size_color"(珠子/手链/表带):选择规格 + 颜色 -->
|
||||||
|
<div v-else-if="currentCategory.flow_type === 'size_color'" class="panel-content">
|
||||||
|
<h3 class="panel-title">{{ sizeColorTitle }}</h3>
|
||||||
|
<p class="panel-desc">为「{{ currentCategory.name }}」{{ sizeColorDesc }}</p>
|
||||||
|
<div class="size-grid">
|
||||||
|
<div
|
||||||
|
v-for="subType in subTypes"
|
||||||
|
:key="subType.id"
|
||||||
|
class="size-tag"
|
||||||
|
:class="{ active: currentSubType?.id === subType.id }"
|
||||||
|
@click="handleSelectSubType(subType)"
|
||||||
|
>
|
||||||
|
{{ subType.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 选择尺寸后显示颜色选择 -->
|
||||||
|
<template v-if="currentSubType && colors.length > 0">
|
||||||
|
<ColorPicker
|
||||||
|
v-model="selectedColor"
|
||||||
|
:colors="colors"
|
||||||
|
/>
|
||||||
|
<div v-if="selectedColor" class="action-bar">
|
||||||
|
<button class="btn-primary" @click="goToGenerate">
|
||||||
|
开始设计
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- flow_type = "simple"(其他品类):直接开始设计 -->
|
||||||
|
<div v-else class="panel-content">
|
||||||
|
<h3 class="panel-title">{{ currentCategory.name }}</h3>
|
||||||
|
<p class="panel-desc">您已选择「{{ currentCategory.name }}」品类</p>
|
||||||
|
<div class="simple-intro">
|
||||||
|
<div class="intro-icon">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
<path d="M8 12L11 15L16 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p>点击下方按钮,进入设计生成页面</p>
|
||||||
|
</div>
|
||||||
|
<div class="action-bar">
|
||||||
|
<button class="btn-primary" @click="goToGenerate">
|
||||||
|
开始设计
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, watch, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Loading } from '@element-plus/icons-vue'
|
||||||
|
import { useCategoryStore } from '@/stores/category'
|
||||||
|
import type { SubType, ColorOption } from '@/stores/category'
|
||||||
|
import ColorPicker from './ColorPicker.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const categoryStore = useCategoryStore()
|
||||||
|
|
||||||
|
const currentCategory = computed(() => categoryStore.currentCategory)
|
||||||
|
const subTypes = computed(() => categoryStore.subTypes)
|
||||||
|
const currentSubType = computed(() => categoryStore.currentSubType)
|
||||||
|
const colors = computed(() => categoryStore.colors)
|
||||||
|
const loading = computed(() => categoryStore.loading)
|
||||||
|
|
||||||
|
// 本地颜色选择状态,用于 v-model 双向绑定
|
||||||
|
const selectedColor = ref<ColorOption | null>(null)
|
||||||
|
|
||||||
|
// size_color 流程的动态标题
|
||||||
|
const sizeColorTitle = computed(() => {
|
||||||
|
const name = currentCategory.value?.name || ''
|
||||||
|
if (name === '表带') return '选择宽度'
|
||||||
|
return '选择珠径'
|
||||||
|
})
|
||||||
|
const sizeColorDesc = computed(() => {
|
||||||
|
const name = currentCategory.value?.name || ''
|
||||||
|
if (name === '表带') return '选择宽度规格'
|
||||||
|
return '选择珠径规格'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 store 中的颜色变化
|
||||||
|
watch(() => categoryStore.currentColor, (newVal) => {
|
||||||
|
selectedColor.value = newVal
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// 监听本地颜色选择变化,同步到 store
|
||||||
|
watch(selectedColor, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
categoryStore.selectColor(newVal)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 选择子类型时重置颜色
|
||||||
|
watch(currentSubType, () => {
|
||||||
|
selectedColor.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSelectSubType = (subType: SubType) => {
|
||||||
|
categoryStore.selectSubType(subType)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToGenerate = () => {
|
||||||
|
if (!currentCategory.value) return
|
||||||
|
|
||||||
|
const query: Record<string, string> = {
|
||||||
|
categoryId: String(currentCategory.value.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSubType.value) {
|
||||||
|
query.subTypeId = String(currentSubType.value.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedColor.value) {
|
||||||
|
query.colorId = String(selectedColor.value.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push({ path: '/generate', query })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
$primary-color: #5B7E6B;
|
||||||
|
$primary-light: #8BAF9C;
|
||||||
|
$secondary-color: #C4A86C;
|
||||||
|
$bg-color: #FAF8F5;
|
||||||
|
$border-color: #E8E4DF;
|
||||||
|
$text-primary: #2C2C2C;
|
||||||
|
$text-secondary: #6B6B6B;
|
||||||
|
$text-light: #999999;
|
||||||
|
|
||||||
|
.subtype-panel {
|
||||||
|
flex: 1;
|
||||||
|
padding: 32px 40px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: $bg-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
color: $border-color;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 15px;
|
||||||
|
color: $text-light;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 400px;
|
||||||
|
gap: 16px;
|
||||||
|
color: $text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text-primary;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: $text-secondary;
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 牌型卡片网格
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtype-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: $primary-color;
|
||||||
|
box-shadow: 0 4px 16px rgba($primary-color, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-preview {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
background-color: $bg-color;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-placeholder {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, $primary-light, $primary-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-info {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid $border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
font-size: 14px;
|
||||||
|
color: $text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尺寸标签网格
|
||||||
|
.size-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.size-tag {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: $text-secondary;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $primary-light;
|
||||||
|
color: $primary-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: $primary-color;
|
||||||
|
border-color: $primary-color;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple 类型介绍
|
||||||
|
.simple-intro {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 40px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-icon {
|
||||||
|
color: $primary-color;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-intro p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: $text-secondary;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 操作栏
|
||||||
|
.action-bar {
|
||||||
|
margin-top: 32px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid $border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 12px 32px;
|
||||||
|
background-color: $primary-color;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #4a6a5a;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
15
frontend/src/main.ts
Normal file
15
frontend/src/main.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './assets/styles/theme.scss'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.use(ElementPlus)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
47
frontend/src/router/index.ts
Normal file
47
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Design',
|
||||||
|
component: () => import('@/views/DesignPage.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/generate',
|
||||||
|
name: 'Generate',
|
||||||
|
component: () => import('@/views/GeneratePage.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/user',
|
||||||
|
name: 'UserCenter',
|
||||||
|
component: () => import('@/views/UserCenter.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: () => import('@/views/Login.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/register',
|
||||||
|
name: 'Register',
|
||||||
|
component: () => import('@/views/Register.vue')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 路由守卫
|
||||||
|
router.beforeEach((to, _from, next) => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (to.meta.requiresAuth && !token) {
|
||||||
|
next('/login')
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
105
frontend/src/stores/category.ts
Normal file
105
frontend/src/stores/category.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { getCategoriesApi, getSubTypesApi, getColorsApi } from '@/api/category'
|
||||||
|
import type { Category, SubType, ColorOption } from '@/api/category'
|
||||||
|
|
||||||
|
export type { Category, SubType, ColorOption }
|
||||||
|
|
||||||
|
export const useCategoryStore = defineStore('category', () => {
|
||||||
|
// 品类列表
|
||||||
|
const categories = ref<Category[]>([])
|
||||||
|
// 当前选中的品类
|
||||||
|
const currentCategory = ref<Category | null>(null)
|
||||||
|
// 当前品类的子类型列表
|
||||||
|
const subTypes = ref<SubType[]>([])
|
||||||
|
// 当前选中的子类型
|
||||||
|
const currentSubType = ref<SubType | null>(null)
|
||||||
|
// 当前品类的颜色列表
|
||||||
|
const colors = ref<ColorOption[]>([])
|
||||||
|
// 当前选中的颜色
|
||||||
|
const currentColor = ref<ColorOption | null>(null)
|
||||||
|
// 加载状态
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 获取品类列表
|
||||||
|
async function fetchCategories() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await getCategoriesApi()
|
||||||
|
categories.value = data
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选中品类,根据 flow_type 加载子类型/颜色
|
||||||
|
async function selectCategory(category: Category) {
|
||||||
|
currentCategory.value = category
|
||||||
|
// 重置子选择
|
||||||
|
currentSubType.value = null
|
||||||
|
currentColor.value = null
|
||||||
|
subTypes.value = []
|
||||||
|
colors.value = []
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
// 根据 flow_type 加载不同数据
|
||||||
|
if (category.flow_type === 'full') {
|
||||||
|
// full 流程:加载子类型 + 颜色(如有)
|
||||||
|
const [subTypesData, colorsData] = await Promise.all([
|
||||||
|
getSubTypesApi(category.id),
|
||||||
|
getColorsApi(category.id)
|
||||||
|
])
|
||||||
|
subTypes.value = subTypesData
|
||||||
|
colors.value = colorsData
|
||||||
|
} else if (category.flow_type === 'size_color') {
|
||||||
|
// 珠子:加载子类型(尺寸)和颜色
|
||||||
|
const [subTypesData, colorsData] = await Promise.all([
|
||||||
|
getSubTypesApi(category.id),
|
||||||
|
getColorsApi(category.id)
|
||||||
|
])
|
||||||
|
subTypes.value = subTypesData
|
||||||
|
colors.value = colorsData
|
||||||
|
}
|
||||||
|
// simple 类型不需要加载额外数据
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选中子类型
|
||||||
|
function selectSubType(subType: SubType) {
|
||||||
|
currentSubType.value = subType
|
||||||
|
// 重置颜色选择
|
||||||
|
currentColor.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选中颜色
|
||||||
|
function selectColor(color: ColorOption) {
|
||||||
|
currentColor.value = color
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置所有选择状态
|
||||||
|
function resetSelection() {
|
||||||
|
currentCategory.value = null
|
||||||
|
currentSubType.value = null
|
||||||
|
currentColor.value = null
|
||||||
|
subTypes.value = []
|
||||||
|
colors.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
categories,
|
||||||
|
currentCategory,
|
||||||
|
subTypes,
|
||||||
|
currentSubType,
|
||||||
|
colors,
|
||||||
|
currentColor,
|
||||||
|
loading,
|
||||||
|
fetchCategories,
|
||||||
|
selectCategory,
|
||||||
|
selectSubType,
|
||||||
|
selectColor,
|
||||||
|
resetSelection
|
||||||
|
}
|
||||||
|
})
|
||||||
94
frontend/src/stores/design.ts
Normal file
94
frontend/src/stores/design.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { getDesignsApi, getDesignApi, generateDesignApi, deleteDesignApi, type Design, type DesignListResponse, type GenerateDesignParams } from '@/api/design'
|
||||||
|
|
||||||
|
export type { Design } from '@/api/design'
|
||||||
|
|
||||||
|
export const useDesignStore = defineStore('design', () => {
|
||||||
|
const designs = ref<Design[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 当前设计(用于生成页面)
|
||||||
|
const currentDesign = ref<Design | null>(null)
|
||||||
|
// 生成中状态
|
||||||
|
const generating = ref(false)
|
||||||
|
|
||||||
|
// 获取设计历史列表
|
||||||
|
const fetchDesigns = async (page: number = 1) => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data: DesignListResponse = await getDesignsApi(page, pageSize.value)
|
||||||
|
designs.value = data.items
|
||||||
|
total.value = data.total
|
||||||
|
currentPage.value = data.page
|
||||||
|
return data
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取单个设计详情
|
||||||
|
const fetchDesign = async (id: number) => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await getDesignApi(id)
|
||||||
|
currentDesign.value = data
|
||||||
|
return data
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成设计
|
||||||
|
const generateDesign = async (params: GenerateDesignParams) => {
|
||||||
|
generating.value = true
|
||||||
|
try {
|
||||||
|
const data = await generateDesignApi(params)
|
||||||
|
currentDesign.value = data
|
||||||
|
return data
|
||||||
|
} finally {
|
||||||
|
generating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除设计
|
||||||
|
const deleteDesign = async (id: number) => {
|
||||||
|
await deleteDesignApi(id)
|
||||||
|
// 删除成功后刷新列表
|
||||||
|
await fetchDesigns(currentPage.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除当前设计
|
||||||
|
const clearCurrentDesign = () => {
|
||||||
|
currentDesign.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
|
const reset = () => {
|
||||||
|
designs.value = []
|
||||||
|
total.value = 0
|
||||||
|
currentPage.value = 1
|
||||||
|
loading.value = false
|
||||||
|
currentDesign.value = null
|
||||||
|
generating.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
designs,
|
||||||
|
total,
|
||||||
|
currentPage,
|
||||||
|
pageSize,
|
||||||
|
loading,
|
||||||
|
currentDesign,
|
||||||
|
generating,
|
||||||
|
fetchDesigns,
|
||||||
|
fetchDesign,
|
||||||
|
generateDesign,
|
||||||
|
deleteDesign,
|
||||||
|
clearCurrentDesign,
|
||||||
|
reset
|
||||||
|
}
|
||||||
|
})
|
||||||
92
frontend/src/stores/user.ts
Normal file
92
frontend/src/stores/user.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { login as loginApi, register as registerApi, getCurrentUser } from '@/api/auth'
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
nickname: string
|
||||||
|
phone?: string | null
|
||||||
|
avatar?: string | null
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUserStore = defineStore('user', () => {
|
||||||
|
const token = ref<string | null>(localStorage.getItem('token'))
|
||||||
|
const userInfo = ref<UserInfo | null>(null)
|
||||||
|
|
||||||
|
// 是否已登录
|
||||||
|
const isLoggedIn = computed(() => !!token.value)
|
||||||
|
|
||||||
|
// 设置 token
|
||||||
|
const setToken = (newToken: string) => {
|
||||||
|
token.value = newToken
|
||||||
|
localStorage.setItem('token', newToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除 token
|
||||||
|
const clearToken = () => {
|
||||||
|
token.value = null
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
const fetchUserInfo = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getCurrentUser()
|
||||||
|
userInfo.value = data
|
||||||
|
return data
|
||||||
|
} catch (error) {
|
||||||
|
clearToken()
|
||||||
|
userInfo.value = null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录
|
||||||
|
const login = async (username: string, password: string) => {
|
||||||
|
const data = await loginApi({ username, password })
|
||||||
|
setToken(data.access_token)
|
||||||
|
await fetchUserInfo()
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册
|
||||||
|
const register = async (username: string, password: string, nickname: string) => {
|
||||||
|
await registerApi({ username, password, nickname })
|
||||||
|
// 注册成功后自动登录
|
||||||
|
await login(username, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退出登录
|
||||||
|
const logout = () => {
|
||||||
|
clearToken()
|
||||||
|
userInfo.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化 - 从 localStorage 恢复状态
|
||||||
|
const init = async () => {
|
||||||
|
const savedToken = localStorage.getItem('token')
|
||||||
|
if (savedToken) {
|
||||||
|
token.value = savedToken
|
||||||
|
try {
|
||||||
|
await fetchUserInfo()
|
||||||
|
} catch {
|
||||||
|
// 如果获取用户信息失败,清除 token
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
userInfo,
|
||||||
|
isLoggedIn,
|
||||||
|
setToken,
|
||||||
|
fetchUserInfo,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
init
|
||||||
|
}
|
||||||
|
})
|
||||||
296
frontend/src/style.css
Normal file
296
frontend/src/style.css
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
:root {
|
||||||
|
--text: #6b6375;
|
||||||
|
--text-h: #08060d;
|
||||||
|
--bg: #fff;
|
||||||
|
--border: #e5e4e7;
|
||||||
|
--code-bg: #f4f3ec;
|
||||||
|
--accent: #aa3bff;
|
||||||
|
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||||
|
--accent-border: rgba(170, 59, 255, 0.5);
|
||||||
|
--social-bg: rgba(244, 243, 236, 0.5);
|
||||||
|
--shadow:
|
||||||
|
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||||
|
|
||||||
|
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--mono: ui-monospace, Consolas, monospace;
|
||||||
|
|
||||||
|
font: 18px/145% var(--sans);
|
||||||
|
letter-spacing: 0.18px;
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--text: #9ca3af;
|
||||||
|
--text-h: #f3f4f6;
|
||||||
|
--bg: #16171d;
|
||||||
|
--border: #2e303a;
|
||||||
|
--code-bg: #1f2028;
|
||||||
|
--accent: #c084fc;
|
||||||
|
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||||
|
--accent-border: rgba(192, 132, 252, 0.5);
|
||||||
|
--social-bg: rgba(47, 48, 58, 0.5);
|
||||||
|
--shadow:
|
||||||
|
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#social .button-icon {
|
||||||
|
filter: invert(1) brightness(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2 {
|
||||||
|
font-family: var(--heading);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 56px;
|
||||||
|
letter-spacing: -1.68px;
|
||||||
|
margin: 32px 0;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 36px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 118%;
|
||||||
|
letter-spacing: -0.24px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
.counter {
|
||||||
|
font-family: var(--mono);
|
||||||
|
display: inline-flex;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 135%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--code-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.base,
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
inset-inline: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base {
|
||||||
|
width: 170px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework {
|
||||||
|
z-index: 1;
|
||||||
|
top: 34px;
|
||||||
|
height: 28px;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||||
|
scale(1.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vite {
|
||||||
|
z-index: 0;
|
||||||
|
top: 107px;
|
||||||
|
height: 26px;
|
||||||
|
width: auto;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||||
|
scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 1126px;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
border-inline: 1px solid var(--border);
|
||||||
|
min-height: 100svh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 25px;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 32px 20px 24px;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps {
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
flex: 1 1 0;
|
||||||
|
padding: 32px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#docs {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 32px 0 0;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--text-h);
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--social-bg);
|
||||||
|
display: flex;
|
||||||
|
padding: 6px 12px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.button-icon {
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
li {
|
||||||
|
flex: 1 1 calc(50% - 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#spacer {
|
||||||
|
height: 88px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticks {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -4.5px;
|
||||||
|
border: 5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
left: 0;
|
||||||
|
border-left-color: var(--border);
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
right: 0;
|
||||||
|
border-right-color: var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
frontend/src/views/DesignPage.vue
Normal file
28
frontend/src/views/DesignPage.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<div class="design-page">
|
||||||
|
<CategoryNav />
|
||||||
|
<SubTypePanel />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useCategoryStore } from '@/stores/category'
|
||||||
|
import CategoryNav from '@/components/CategoryNav.vue'
|
||||||
|
import SubTypePanel from '@/components/SubTypePanel.vue'
|
||||||
|
|
||||||
|
const categoryStore = useCategoryStore()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 进入页面时自动加载品类列表
|
||||||
|
categoryStore.fetchCategories()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.design-page {
|
||||||
|
display: flex;
|
||||||
|
height: calc(100vh - 60px);
|
||||||
|
background-color: #FAF8F5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
768
frontend/src/views/GeneratePage.vue
Normal file
768
frontend/src/views/GeneratePage.vue
Normal file
@@ -0,0 +1,768 @@
|
|||||||
|
<template>
|
||||||
|
<div class="generate-page">
|
||||||
|
<!-- 顶部信息栏 -->
|
||||||
|
<header class="page-header">
|
||||||
|
<button class="back-btn" @click="goBack">
|
||||||
|
<el-icon><ArrowLeft /></el-icon>
|
||||||
|
<span>返回</span>
|
||||||
|
</button>
|
||||||
|
<nav class="breadcrumb" v-if="categoryName">
|
||||||
|
<span class="crumb">{{ categoryName }}</span>
|
||||||
|
<template v-if="subTypeName">
|
||||||
|
<el-icon class="separator"><ArrowRight /></el-icon>
|
||||||
|
<span class="crumb">{{ subTypeName }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-if="colorName">
|
||||||
|
<el-icon class="separator"><ArrowRight /></el-icon>
|
||||||
|
<span class="crumb color">{{ colorName }}</span>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 缺少参数错误提示 -->
|
||||||
|
<div v-if="!categoryId" class="error-state">
|
||||||
|
<div class="error-icon">
|
||||||
|
<el-icon><WarningFilled /></el-icon>
|
||||||
|
</div>
|
||||||
|
<h3>缺少必要参数</h3>
|
||||||
|
<p>请先从设计页选择品类和类型</p>
|
||||||
|
<button class="btn-primary" @click="goBack">返回设计页</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 主内容区 -->
|
||||||
|
<main v-else class="main-content">
|
||||||
|
<!-- 生成中遮罩 -->
|
||||||
|
<div v-if="generating" class="loading-overlay">
|
||||||
|
<div class="loading-content">
|
||||||
|
<div class="ink-loader">
|
||||||
|
<div class="ink-drop"></div>
|
||||||
|
<div class="ink-drop"></div>
|
||||||
|
<div class="ink-drop"></div>
|
||||||
|
</div>
|
||||||
|
<p class="loading-text">设计生成中,请稍候...</p>
|
||||||
|
<p class="loading-hint">正在将您的创意转化为玉雕设计</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 设计输入区 -->
|
||||||
|
<section v-if="!currentDesign" class="input-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">设计参数</h2>
|
||||||
|
<p class="section-desc">选择参数辅助生成(全部选填),也可以只写描述</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 参数面板 -->
|
||||||
|
<div class="params-panel">
|
||||||
|
<!-- 雕刻工艺 -->
|
||||||
|
<div class="param-group">
|
||||||
|
<label class="param-label">雕刻工艺</label>
|
||||||
|
<div class="tag-list">
|
||||||
|
<span
|
||||||
|
v-for="opt in carvingOptions"
|
||||||
|
:key="opt"
|
||||||
|
class="tag-item"
|
||||||
|
:class="{ active: carvingTechnique === opt }"
|
||||||
|
@click="carvingTechnique = carvingTechnique === opt ? '' : opt"
|
||||||
|
>{{ opt }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 设计风格 -->
|
||||||
|
<div class="param-group">
|
||||||
|
<label class="param-label">设计风格</label>
|
||||||
|
<div class="tag-list">
|
||||||
|
<span
|
||||||
|
v-for="opt in styleOptions"
|
||||||
|
:key="opt"
|
||||||
|
class="tag-item"
|
||||||
|
:class="{ active: designStyle === opt }"
|
||||||
|
@click="designStyle = designStyle === opt ? '' : opt"
|
||||||
|
>{{ opt }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 题材纹样 -->
|
||||||
|
<div class="param-group">
|
||||||
|
<label class="param-label">题材纹样</label>
|
||||||
|
<div class="tag-list">
|
||||||
|
<span
|
||||||
|
v-for="opt in motifOptions"
|
||||||
|
:key="opt"
|
||||||
|
class="tag-item"
|
||||||
|
:class="{ active: motif === opt }"
|
||||||
|
@click="motif = motif === opt ? '' : opt"
|
||||||
|
>{{ opt }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 尺寸规格 -->
|
||||||
|
<div class="param-group">
|
||||||
|
<label class="param-label">尺寸规格</label>
|
||||||
|
<div class="tag-list">
|
||||||
|
<span
|
||||||
|
v-for="opt in sizeOptions"
|
||||||
|
:key="opt"
|
||||||
|
class="tag-item"
|
||||||
|
:class="{ active: sizeSpec === opt }"
|
||||||
|
@click="sizeSpec = sizeSpec === opt ? '' : opt"
|
||||||
|
>{{ opt }}</span>
|
||||||
|
<el-input
|
||||||
|
v-model="customSize"
|
||||||
|
placeholder="自定义尺寸"
|
||||||
|
size="small"
|
||||||
|
class="custom-size-input"
|
||||||
|
@focus="sizeSpec = ''"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 表面处理 -->
|
||||||
|
<div class="param-group">
|
||||||
|
<label class="param-label">表面处理</label>
|
||||||
|
<div class="tag-list">
|
||||||
|
<span
|
||||||
|
v-for="opt in finishOptions"
|
||||||
|
:key="opt"
|
||||||
|
class="tag-item"
|
||||||
|
:class="{ active: surfaceFinish === opt }"
|
||||||
|
@click="surfaceFinish = surfaceFinish === opt ? '' : opt"
|
||||||
|
>{{ opt }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 用途场景 -->
|
||||||
|
<div class="param-group">
|
||||||
|
<label class="param-label">用途场景</label>
|
||||||
|
<div class="tag-list">
|
||||||
|
<span
|
||||||
|
v-for="opt in sceneOptions"
|
||||||
|
:key="opt"
|
||||||
|
class="tag-item"
|
||||||
|
:class="{ active: usageScene === opt }"
|
||||||
|
@click="usageScene = usageScene === opt ? '' : opt"
|
||||||
|
>{{ opt }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 设计描述 -->
|
||||||
|
<div class="prompt-header">
|
||||||
|
<h3 class="prompt-title">设计描述</h3>
|
||||||
|
</div>
|
||||||
|
<div class="input-area">
|
||||||
|
<el-input
|
||||||
|
v-model="prompt"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
:placeholder="promptPlaceholder"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
resize="none"
|
||||||
|
class="prompt-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="generate-action">
|
||||||
|
<button
|
||||||
|
class="btn-generate"
|
||||||
|
:disabled="!prompt.trim() || generating"
|
||||||
|
@click="handleGenerate"
|
||||||
|
>
|
||||||
|
<el-icon v-if="!generating"><MagicStick /></el-icon>
|
||||||
|
<span>{{ generating ? '生成中...' : '生成设计' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 设计预览区 -->
|
||||||
|
<section v-else class="preview-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">设计预览</h2>
|
||||||
|
<p class="section-desc">您的设计已生成完成</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DesignPreview :design="currentDesign" />
|
||||||
|
|
||||||
|
<div class="regenerate-action">
|
||||||
|
<button class="btn-regenerate" @click="handleRegenerate">
|
||||||
|
<el-icon><RefreshRight /></el-icon>
|
||||||
|
<span>重新生成</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ArrowLeft, ArrowRight, WarningFilled, MagicStick, RefreshRight } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useDesignStore } from '@/stores/design'
|
||||||
|
import { useCategoryStore } from '@/stores/category'
|
||||||
|
import DesignPreview from '@/components/DesignPreview.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const designStore = useDesignStore()
|
||||||
|
const categoryStore = useCategoryStore()
|
||||||
|
|
||||||
|
// URL参数
|
||||||
|
const categoryId = computed(() => {
|
||||||
|
const id = route.query.categoryId
|
||||||
|
return id ? Number(id) : null
|
||||||
|
})
|
||||||
|
const subTypeId = computed(() => {
|
||||||
|
const id = route.query.subTypeId
|
||||||
|
return id ? Number(id) : null
|
||||||
|
})
|
||||||
|
const colorId = computed(() => {
|
||||||
|
const id = route.query.colorId
|
||||||
|
return id ? Number(id) : null
|
||||||
|
})
|
||||||
|
|
||||||
|
// 名称(从store缓存获取)
|
||||||
|
const categoryName = computed(() => {
|
||||||
|
if (!categoryId.value) return ''
|
||||||
|
// 优先从 currentCategory 获取
|
||||||
|
if (categoryStore.currentCategory?.id === categoryId.value) {
|
||||||
|
return categoryStore.currentCategory.name
|
||||||
|
}
|
||||||
|
// 否则从列表中查找
|
||||||
|
const cat = categoryStore.categories.find(c => c.id === categoryId.value)
|
||||||
|
return cat?.name || '设计'
|
||||||
|
})
|
||||||
|
|
||||||
|
const subTypeName = computed(() => {
|
||||||
|
if (!subTypeId.value) return ''
|
||||||
|
// 从 store 的 subTypes 中查找
|
||||||
|
const st = categoryStore.subTypes.find(s => s.id === subTypeId.value)
|
||||||
|
return st?.name || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const colorName = computed(() => {
|
||||||
|
if (!colorId.value) return ''
|
||||||
|
// 从 store 的 colors 中查找
|
||||||
|
const c = categoryStore.colors.find(col => col.id === colorId.value)
|
||||||
|
return c?.name || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 设计相关状态
|
||||||
|
const prompt = ref('')
|
||||||
|
const generating = computed(() => designStore.generating)
|
||||||
|
const currentDesign = computed(() => designStore.currentDesign)
|
||||||
|
|
||||||
|
// 新增参数状态
|
||||||
|
const carvingTechnique = ref('')
|
||||||
|
const designStyle = ref('')
|
||||||
|
const motif = ref('')
|
||||||
|
const sizeSpec = ref('')
|
||||||
|
const customSize = ref('')
|
||||||
|
const surfaceFinish = ref('')
|
||||||
|
const usageScene = ref('')
|
||||||
|
|
||||||
|
// 静态选项
|
||||||
|
const carvingOptions = ['浮雕', '圆雕', '镂空雕', '阴刻', '线雕', '俏色雕', '薄意雕', '素面']
|
||||||
|
const styleOptions = ['古典传统', '新中式', '写实', '抽象意境', '极简素面']
|
||||||
|
const motifOptions = ['观音', '弥勒', '莲花', '貔貅', '龙凤', '麒麟', '山水', '花鸟', '人物', '回纹', '如意', '平安扣']
|
||||||
|
const finishOptions = ['高光抛光', '亚光/哑光', '磨砂', '保留皮色']
|
||||||
|
const sceneOptions = ['日常佩戴', '收藏鉴赏', '送礼婚庆', '把玩文玩']
|
||||||
|
|
||||||
|
// 尺寸规格按品类动态变化
|
||||||
|
const sizeOptions = computed(() => {
|
||||||
|
const name = categoryName.value
|
||||||
|
if (name.includes('牌')) return ['60x40x12mm', '70x45x14mm', '50x35x10mm']
|
||||||
|
if (name.includes('手镯')) return ['内径54mm', '内径56mm', '内径58mm', '内径60mm', '内径62mm']
|
||||||
|
if (name.includes('手把件')) return ['小(约60mm)', '中(约80mm)', '大(约100mm)']
|
||||||
|
if (name.includes('摆件')) return ['小(约8cm)', '中(约15cm)', '大(约25cm)']
|
||||||
|
if (name.includes('戒')) return ['戒面7号', '戒鞍12号', '戒鞍15号', '戒鞍18号']
|
||||||
|
if (name.includes('表带')) return ['宽18mm', '宽20mm', '宽22mm']
|
||||||
|
return ['小', '中', '大']
|
||||||
|
})
|
||||||
|
|
||||||
|
// placeholder 根据品类动态变化
|
||||||
|
const promptPlaceholder = computed(() => {
|
||||||
|
const name = categoryName.value.toLowerCase()
|
||||||
|
if (name.includes('牌') || name.includes('牌子')) {
|
||||||
|
return '请描述您想要的设计,如:山水意境、貔貅纹、雕刻荷花...'
|
||||||
|
}
|
||||||
|
if (name.includes('珠') || name.includes('珠子')) {
|
||||||
|
return '请描述您想要的图案,如:回纹、云纹、简单素面...'
|
||||||
|
}
|
||||||
|
return '请描述您的设计需求...'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 返回设计页
|
||||||
|
const goBack = () => {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成设计
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
if (!categoryId.value) {
|
||||||
|
ElMessage.error('缺少品类参数')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!prompt.value.trim()) {
|
||||||
|
ElMessage.warning('请输入设计描述')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await designStore.generateDesign({
|
||||||
|
category_id: categoryId.value,
|
||||||
|
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,
|
||||||
|
size_spec: sizeSpec.value || customSize.value || undefined,
|
||||||
|
surface_finish: surfaceFinish.value || undefined,
|
||||||
|
usage_scene: usageScene.value || undefined,
|
||||||
|
})
|
||||||
|
ElMessage.success('设计生成成功!')
|
||||||
|
} catch (error) {
|
||||||
|
// 错误已在 request 拦截器中处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新生成
|
||||||
|
const handleRegenerate = () => {
|
||||||
|
designStore.clearCurrentDesign()
|
||||||
|
// 保留之前的 prompt,用户可以修改后重新生成
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面挂载时,确保有品类数据
|
||||||
|
onMounted(async () => {
|
||||||
|
// 清除之前的设计状态
|
||||||
|
designStore.clearCurrentDesign()
|
||||||
|
|
||||||
|
// 如果 store 中没有品类数据,尝试加载
|
||||||
|
if (categoryStore.categories.length === 0) {
|
||||||
|
await categoryStore.fetchCategories()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 离开页面时清理状态
|
||||||
|
onUnmounted(() => {
|
||||||
|
designStore.clearCurrentDesign()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
$primary-color: #5B7E6B;
|
||||||
|
$primary-light: #8BAF9C;
|
||||||
|
$primary-dark: #3D5A4A;
|
||||||
|
$secondary-color: #C4A86C;
|
||||||
|
$bg-color: #FAF8F5;
|
||||||
|
$bg-dark: #F0EDE8;
|
||||||
|
$border-color: #E8E4DF;
|
||||||
|
$text-primary: #2C2C2C;
|
||||||
|
$text-secondary: #6B6B6B;
|
||||||
|
$text-light: #999999;
|
||||||
|
|
||||||
|
.generate-page {
|
||||||
|
min-height: calc(100vh - 60px);
|
||||||
|
background-color: $bg-color;
|
||||||
|
padding-bottom: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 顶部信息栏
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 20px 40px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid $border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: $text-secondary;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $primary-color;
|
||||||
|
color: $primary-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb {
|
||||||
|
color: $text-primary;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&.color {
|
||||||
|
color: $secondary-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 错误状态
|
||||||
|
.error-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 400px;
|
||||||
|
gap: 16px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
color: $secondary-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state h3 {
|
||||||
|
font-size: 20px;
|
||||||
|
color: $text-primary;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: $text-secondary;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主内容区
|
||||||
|
.main-content {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 24px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输入区
|
||||||
|
.input-section,
|
||||||
|
.preview-section {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text-primary;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: $text-secondary;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-area {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 参数面板
|
||||||
|
.params-panel {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px 28px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-group {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text-secondary;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-item {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6px 14px;
|
||||||
|
background: $bg-color;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: $text-secondary;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $primary-light;
|
||||||
|
color: $primary-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: $primary-color;
|
||||||
|
border-color: $primary-color;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-size-input {
|
||||||
|
width: 130px;
|
||||||
|
|
||||||
|
:deep(.el-input__inner) {
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-header {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text-primary;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-input {
|
||||||
|
:deep(.el-textarea__inner) {
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid $border-color;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: $text-primary;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $text-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: $primary-color;
|
||||||
|
box-shadow: 0 0 0 4px rgba($primary-color, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__count) {
|
||||||
|
background: transparent;
|
||||||
|
color: $text-light;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-action {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-generate {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 48px;
|
||||||
|
background: linear-gradient(135deg, $primary-color, $primary-dark);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 24px rgba($primary-color, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主按钮
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 32px;
|
||||||
|
background: $primary-color;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $primary-dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新生成
|
||||||
|
.regenerate-action {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 32px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid $border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-regenerate {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 28px;
|
||||||
|
background: #fff;
|
||||||
|
color: $text-secondary;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $primary-color;
|
||||||
|
color: $primary-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载遮罩
|
||||||
|
.loading-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 水墨风格加载动画
|
||||||
|
.ink-loader {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ink-drop {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: $primary-color;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: ink-spread 1.4s ease-in-out infinite;
|
||||||
|
|
||||||
|
&:nth-child(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ink-spread {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.5);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
font-size: 18px;
|
||||||
|
color: $text-primary;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-hint {
|
||||||
|
font-size: 14px;
|
||||||
|
color: $text-light;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page-header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-generate {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 14px 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
199
frontend/src/views/Login.vue
Normal file
199
frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-card">
|
||||||
|
<!-- 品牌区域 -->
|
||||||
|
<div class="brand-section">
|
||||||
|
<h1 class="brand-name">玉宗</h1>
|
||||||
|
<p class="brand-subtitle">珠宝设计大师</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 登录表单 -->
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
class="login-form"
|
||||||
|
@submit.prevent="handleLogin"
|
||||||
|
>
|
||||||
|
<el-form-item prop="username">
|
||||||
|
<el-input
|
||||||
|
v-model="form.username"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
size="large"
|
||||||
|
:prefix-icon="User"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item prop="password">
|
||||||
|
<el-input
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
size="large"
|
||||||
|
:prefix-icon="Lock"
|
||||||
|
show-password
|
||||||
|
@keyup.enter="handleLogin"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
class="login-button"
|
||||||
|
:loading="loading"
|
||||||
|
@click="handleLogin"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<!-- 底部链接 -->
|
||||||
|
<div class="footer-links">
|
||||||
|
<span class="text">没有账号?</span>
|
||||||
|
<router-link to="/register" class="link">立即注册</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { User, Lock } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
username: '',
|
||||||
|
password: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules: FormRules = {
|
||||||
|
username: [
|
||||||
|
{ required: true, message: '请输入用户名', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
password: [
|
||||||
|
{ required: true, message: '请输入密码', trigger: 'blur' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!formRef.value) return
|
||||||
|
|
||||||
|
const valid = await formRef.value.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await userStore.login(form.username, form.password)
|
||||||
|
ElMessage.success('登录成功')
|
||||||
|
router.push('/')
|
||||||
|
} catch (error: any) {
|
||||||
|
// 错误已在拦截器中处理
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.login-page {
|
||||||
|
min-height: calc(100vh - 108px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: #FAF8F5;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 48px 40px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-section {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #5B7E6B;
|
||||||
|
letter-spacing: 8px;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
:deep(.el-input__wrapper) {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__prefix) {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
background-color: #5B7E6B;
|
||||||
|
border-color: #5B7E6B;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
background-color: #3D5A4A;
|
||||||
|
border-color: #3D5A4A;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
.text {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
color: #5B7E6B;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-left: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #3D5A4A;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
238
frontend/src/views/Register.vue
Normal file
238
frontend/src/views/Register.vue
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
<template>
|
||||||
|
<div class="register-page">
|
||||||
|
<div class="register-card">
|
||||||
|
<!-- 品牌区域 -->
|
||||||
|
<div class="brand-section">
|
||||||
|
<h1 class="brand-name">玉宗</h1>
|
||||||
|
<p class="brand-subtitle">创建您的账户</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 注册表单 -->
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
class="register-form"
|
||||||
|
@submit.prevent="handleRegister"
|
||||||
|
>
|
||||||
|
<el-form-item prop="username">
|
||||||
|
<el-input
|
||||||
|
v-model="form.username"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
size="large"
|
||||||
|
:prefix-icon="User"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item prop="nickname">
|
||||||
|
<el-input
|
||||||
|
v-model="form.nickname"
|
||||||
|
placeholder="请输入昵称"
|
||||||
|
size="large"
|
||||||
|
:prefix-icon="UserFilled"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item prop="password">
|
||||||
|
<el-input
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入密码(至少6位)"
|
||||||
|
size="large"
|
||||||
|
:prefix-icon="Lock"
|
||||||
|
show-password
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item prop="confirmPassword">
|
||||||
|
<el-input
|
||||||
|
v-model="form.confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="请再次输入密码"
|
||||||
|
size="large"
|
||||||
|
:prefix-icon="Lock"
|
||||||
|
show-password
|
||||||
|
@keyup.enter="handleRegister"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
class="register-button"
|
||||||
|
:loading="loading"
|
||||||
|
@click="handleRegister"
|
||||||
|
>
|
||||||
|
注册
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<!-- 底部链接 -->
|
||||||
|
<div class="footer-links">
|
||||||
|
<span class="text">已有账号?</span>
|
||||||
|
<router-link to="/login" class="link">返回登录</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { User, UserFilled, Lock } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>()
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
username: '',
|
||||||
|
nickname: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 确认密码验证器
|
||||||
|
const validateConfirmPassword = (_rule: any, value: string, callback: any) => {
|
||||||
|
if (value !== form.password) {
|
||||||
|
callback(new Error('两次输入的密码不一致'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules: FormRules = {
|
||||||
|
username: [
|
||||||
|
{ required: true, message: '请输入用户名', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
nickname: [
|
||||||
|
{ required: true, message: '请输入昵称', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
password: [
|
||||||
|
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||||
|
{ min: 6, message: '密码长度至少为6位', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
confirmPassword: [
|
||||||
|
{ required: true, message: '请再次输入密码', trigger: 'blur' },
|
||||||
|
{ validator: validateConfirmPassword, trigger: 'blur' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
if (!formRef.value) return
|
||||||
|
|
||||||
|
const valid = await formRef.value.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await userStore.register(form.username, form.password, form.nickname)
|
||||||
|
ElMessage.success('注册成功')
|
||||||
|
router.push('/')
|
||||||
|
} catch (error: any) {
|
||||||
|
// 错误已在拦截器中处理
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.register-page {
|
||||||
|
min-height: calc(100vh - 108px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: #FAF8F5;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 48px 40px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-section {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #5B7E6B;
|
||||||
|
letter-spacing: 8px;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-form {
|
||||||
|
:deep(.el-input__wrapper) {
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__prefix) {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-button {
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
background-color: #5B7E6B;
|
||||||
|
border-color: #5B7E6B;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
background-color: #3D5A4A;
|
||||||
|
border-color: #3D5A4A;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
.text {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
color: #5B7E6B;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-left: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #3D5A4A;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
657
frontend/src/views/UserCenter.vue
Normal file
657
frontend/src/views/UserCenter.vue
Normal file
@@ -0,0 +1,657 @@
|
|||||||
|
<template>
|
||||||
|
<div class="user-center">
|
||||||
|
<div class="user-center-container">
|
||||||
|
<h1 class="page-title">个人中心</h1>
|
||||||
|
|
||||||
|
<el-tabs v-model="activeTab" class="user-tabs">
|
||||||
|
<!-- Tab 1: 设计历史 -->
|
||||||
|
<el-tab-pane label="设计历史" name="designs">
|
||||||
|
<div v-if="designStore.loading" class="loading-state">
|
||||||
|
<el-icon class="loading-icon"><Loading /></el-icon>
|
||||||
|
<p>加载中...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-if="designStore.designs.length === 0" class="empty-state">
|
||||||
|
<div class="empty-icon">📐</div>
|
||||||
|
<p class="empty-text">暂无设计作品,去创作第一个设计吧</p>
|
||||||
|
<el-button type="primary" class="create-btn" @click="goToGenerate">
|
||||||
|
开始设计
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 设计卡片网格 -->
|
||||||
|
<div v-else class="design-grid">
|
||||||
|
<div
|
||||||
|
v-for="design in designStore.designs"
|
||||||
|
:key="design.id"
|
||||||
|
class="design-card"
|
||||||
|
@click="handleCardClick(design)"
|
||||||
|
>
|
||||||
|
<div class="card-image">
|
||||||
|
<img
|
||||||
|
v-if="design.image_url"
|
||||||
|
:src="design.image_url"
|
||||||
|
:alt="design.prompt"
|
||||||
|
/>
|
||||||
|
<div v-else class="no-image">
|
||||||
|
<span>暂无图片</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="card-category">
|
||||||
|
{{ design.category?.name || '未分类' }}
|
||||||
|
<span v-if="design.sub_type">· {{ design.sub_type.name }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="card-prompt" :title="design.prompt">{{ design.prompt }}</p>
|
||||||
|
<div class="card-footer">
|
||||||
|
<span class="card-time">{{ formatTime(design.created_at) }}</span>
|
||||||
|
<div class="card-actions" @click.stop>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
text
|
||||||
|
@click="handleDownload(design)"
|
||||||
|
>
|
||||||
|
下载
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="danger"
|
||||||
|
text
|
||||||
|
@click="handleDelete(design)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div v-if="designStore.total > designStore.pageSize" class="pagination-wrapper">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
:page-size="designStore.pageSize"
|
||||||
|
:total="designStore.total"
|
||||||
|
layout="prev, pager, next"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- Tab 2: 个人信息 -->
|
||||||
|
<el-tab-pane label="个人信息" name="profile">
|
||||||
|
<div class="profile-section">
|
||||||
|
<h3 class="section-title">基本信息</h3>
|
||||||
|
<el-form
|
||||||
|
ref="profileFormRef"
|
||||||
|
:model="profileForm"
|
||||||
|
:rules="profileRules"
|
||||||
|
label-width="80px"
|
||||||
|
class="profile-form"
|
||||||
|
>
|
||||||
|
<el-form-item label="用户名">
|
||||||
|
<el-input v-model="profileForm.username" disabled />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="昵称" prop="nickname">
|
||||||
|
<el-input v-model="profileForm.nickname" placeholder="请输入昵称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="手机号" prop="phone">
|
||||||
|
<el-input v-model="profileForm.phone" placeholder="请输入手机号" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="profileLoading"
|
||||||
|
@click="handleSaveProfile"
|
||||||
|
>
|
||||||
|
保存修改
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="password-section">
|
||||||
|
<h3 class="section-title">修改密码</h3>
|
||||||
|
<el-form
|
||||||
|
ref="passwordFormRef"
|
||||||
|
:model="passwordForm"
|
||||||
|
:rules="passwordRules"
|
||||||
|
label-width="100px"
|
||||||
|
class="password-form"
|
||||||
|
>
|
||||||
|
<el-form-item label="旧密码" prop="old_password">
|
||||||
|
<el-input
|
||||||
|
v-model="passwordForm.old_password"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入旧密码"
|
||||||
|
show-password
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="新密码" prop="new_password">
|
||||||
|
<el-input
|
||||||
|
v-model="passwordForm.new_password"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入新密码(至少6位)"
|
||||||
|
show-password
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="确认密码" prop="confirm_password">
|
||||||
|
<el-input
|
||||||
|
v-model="passwordForm.confirm_password"
|
||||||
|
type="password"
|
||||||
|
placeholder="请再次输入新密码"
|
||||||
|
show-password
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="passwordLoading"
|
||||||
|
@click="handleChangePassword"
|
||||||
|
>
|
||||||
|
修改密码
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { Loading } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
|
import { useDesignStore, type Design } from '@/stores/design'
|
||||||
|
import { updateProfileApi, changePasswordApi } from '@/api/auth'
|
||||||
|
import { getDesignDownloadUrl } from '@/api/design'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const designStore = useDesignStore()
|
||||||
|
|
||||||
|
const activeTab = ref('designs')
|
||||||
|
const currentPage = ref(1)
|
||||||
|
|
||||||
|
// Profile form
|
||||||
|
const profileFormRef = ref<FormInstance>()
|
||||||
|
const profileLoading = ref(false)
|
||||||
|
const profileForm = reactive({
|
||||||
|
username: '',
|
||||||
|
nickname: '',
|
||||||
|
phone: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const profileRules: FormRules = {
|
||||||
|
nickname: [
|
||||||
|
{ max: 20, message: '昵称最多20个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
phone: [
|
||||||
|
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password form
|
||||||
|
const passwordFormRef = ref<FormInstance>()
|
||||||
|
const passwordLoading = ref(false)
|
||||||
|
const passwordForm = reactive({
|
||||||
|
old_password: '',
|
||||||
|
new_password: '',
|
||||||
|
confirm_password: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const validateConfirmPassword = (_rule: any, value: string, callback: any) => {
|
||||||
|
if (value !== passwordForm.new_password) {
|
||||||
|
callback(new Error('两次输入的密码不一致'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordRules: FormRules = {
|
||||||
|
old_password: [
|
||||||
|
{ required: true, message: '请输入旧密码', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
new_password: [
|
||||||
|
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||||
|
{ min: 6, message: '密码至少6位', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
confirm_password: [
|
||||||
|
{ required: true, message: '请确认新密码', trigger: 'blur' },
|
||||||
|
{ validator: validateConfirmPassword, trigger: 'blur' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
onMounted(async () => {
|
||||||
|
// Load user info to form
|
||||||
|
if (userStore.userInfo) {
|
||||||
|
profileForm.username = userStore.userInfo.username || ''
|
||||||
|
profileForm.nickname = userStore.userInfo.nickname || ''
|
||||||
|
profileForm.phone = userStore.userInfo.phone || ''
|
||||||
|
}
|
||||||
|
// Load designs
|
||||||
|
await designStore.fetchDesigns()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch user info changes
|
||||||
|
watch(() => userStore.userInfo, (info) => {
|
||||||
|
if (info) {
|
||||||
|
profileForm.username = info.username || ''
|
||||||
|
profileForm.nickname = info.nickname || ''
|
||||||
|
profileForm.phone = info.phone || ''
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Format time
|
||||||
|
const formatTime = (time: string) => {
|
||||||
|
const date = new Date(time)
|
||||||
|
return date.toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go to generate page
|
||||||
|
const goToGenerate = () => {
|
||||||
|
router.push('/generate')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle card click - go to generate page with design info
|
||||||
|
const handleCardClick = (design: Design) => {
|
||||||
|
router.push({
|
||||||
|
path: '/generate',
|
||||||
|
query: {
|
||||||
|
design_id: design.id,
|
||||||
|
category_id: design.category?.id,
|
||||||
|
sub_type_id: design.sub_type?.id,
|
||||||
|
color_id: design.color?.id,
|
||||||
|
prompt: design.prompt
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle download
|
||||||
|
const handleDownload = (design: Design) => {
|
||||||
|
if (!design.image_url) {
|
||||||
|
ElMessage.warning('暂无可下载的图片')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = getDesignDownloadUrl(design.id)
|
||||||
|
link.download = `design_${design.id}.png`
|
||||||
|
link.target = '_blank'
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle delete
|
||||||
|
const handleDelete = async (design: Design) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'确定要删除这个设计吗?删除后无法恢复。',
|
||||||
|
'删除确认',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确定删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await designStore.deleteDesign(design.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
// Error already handled by interceptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle page change
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
currentPage.value = page
|
||||||
|
designStore.fetchDesigns(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save profile
|
||||||
|
const handleSaveProfile = async () => {
|
||||||
|
if (!profileFormRef.value) return
|
||||||
|
|
||||||
|
const valid = await profileFormRef.value.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
profileLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = await updateProfileApi({
|
||||||
|
nickname: profileForm.nickname,
|
||||||
|
phone: profileForm.phone || undefined
|
||||||
|
})
|
||||||
|
// Update user store
|
||||||
|
if (userStore.userInfo) {
|
||||||
|
userStore.userInfo.nickname = data.nickname
|
||||||
|
userStore.userInfo.phone = data.phone
|
||||||
|
}
|
||||||
|
ElMessage.success('保存成功')
|
||||||
|
} catch (error) {
|
||||||
|
// Error handled by interceptor
|
||||||
|
} finally {
|
||||||
|
profileLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change password
|
||||||
|
const handleChangePassword = async () => {
|
||||||
|
if (!passwordFormRef.value) return
|
||||||
|
|
||||||
|
const valid = await passwordFormRef.value.validate().catch(() => false)
|
||||||
|
if (!valid) return
|
||||||
|
|
||||||
|
passwordLoading.value = true
|
||||||
|
try {
|
||||||
|
await changePasswordApi({
|
||||||
|
old_password: passwordForm.old_password,
|
||||||
|
new_password: passwordForm.new_password
|
||||||
|
})
|
||||||
|
ElMessage.success('密码修改成功')
|
||||||
|
// Reset form
|
||||||
|
passwordForm.old_password = ''
|
||||||
|
passwordForm.new_password = ''
|
||||||
|
passwordForm.confirm_password = ''
|
||||||
|
passwordFormRef.value.resetFields()
|
||||||
|
} catch (error) {
|
||||||
|
// Error handled by interceptor
|
||||||
|
} finally {
|
||||||
|
passwordLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
$primary-color: #5B7E6B;
|
||||||
|
$primary-dark: #3D5A4A;
|
||||||
|
$bg-color: #FAF8F5;
|
||||||
|
$border-color: #E8E4DF;
|
||||||
|
$text-primary: #2C2C2C;
|
||||||
|
$text-secondary: #6B6B6B;
|
||||||
|
$text-light: #999999;
|
||||||
|
|
||||||
|
.user-center {
|
||||||
|
min-height: calc(100vh - 108px);
|
||||||
|
background-color: $bg-color;
|
||||||
|
padding: 32px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-center-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-primary;
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-tabs {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
|
:deep(.el-tabs__nav-wrap::after) {
|
||||||
|
height: 1px;
|
||||||
|
background-color: $border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__item) {
|
||||||
|
font-size: 16px;
|
||||||
|
color: $text-secondary;
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
color: $primary-color;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $primary-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tabs__active-bar) {
|
||||||
|
background-color: $primary-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 80px 0;
|
||||||
|
color: $text-light;
|
||||||
|
|
||||||
|
.loading-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 80px 0;
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 16px;
|
||||||
|
color: $text-secondary;
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-btn {
|
||||||
|
background-color: $primary-color;
|
||||||
|
border-color: $primary-color;
|
||||||
|
padding: 12px 32px;
|
||||||
|
font-size: 15px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $primary-dark;
|
||||||
|
border-color: $primary-dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Design grid
|
||||||
|
.design-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.design-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-image {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 75%; // 4:3 aspect ratio
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
img {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-image {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: $text-light;
|
||||||
|
font-size: 14px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-category {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $primary-color;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-prompt {
|
||||||
|
font-size: 14px;
|
||||||
|
color: $text-primary;
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
:deep(.el-button) {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-button--primary) {
|
||||||
|
color: $primary-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
.pagination-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 32px;
|
||||||
|
|
||||||
|
:deep(.el-pagination) {
|
||||||
|
--el-pagination-button-bg-color: transparent;
|
||||||
|
--el-pagination-hover-color: #{$primary-color};
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-pager li.is-active) {
|
||||||
|
color: $primary-color;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile section
|
||||||
|
.profile-section,
|
||||||
|
.password-section {
|
||||||
|
max-width: 480px;
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-section {
|
||||||
|
border-top: 1px solid $border-color;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text-primary;
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-form,
|
||||||
|
.password-form {
|
||||||
|
:deep(.el-form-item__label) {
|
||||||
|
color: $text-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__wrapper) {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input.is-disabled .el-input__wrapper) {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-button--primary) {
|
||||||
|
background-color: $primary-color;
|
||||||
|
border-color: $primary-color;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $primary-dark;
|
||||||
|
border-color: $primary-dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
20
frontend/tsconfig.app.json
Normal file
20
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
24
frontend/vite.config.ts
Normal file
24
frontend/vite.config.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/uploads': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': '/src'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
315
init_data.sql
Normal file
315
init_data.sql
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
-- 玉宗 - 珠宝设计大师 数据库初始化脚本
|
||||||
|
-- 使用前请先创建数据库: CREATE DATABASE yuzong CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
-- 导入命令: mysql --default-character-set=utf8mb4 -u root -p yuzong < init_data.sql
|
||||||
|
-- 注意: designs 表包含 6 个可选设计参数字段:
|
||||||
|
-- carving_technique(雕刻工艺), design_style(设计风格), motif(题材纹样),
|
||||||
|
-- size_spec(尺寸规格), surface_finish(表面处理), usage_scene(用途场景)
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- 品类数据 (12个品类)
|
||||||
|
-- ========================================
|
||||||
|
INSERT INTO categories (id, name, icon, sort_order, flow_type) VALUES
|
||||||
|
(1, '牌子', NULL, 1, 'full'),
|
||||||
|
(2, '珠子', NULL, 2, 'size_color'),
|
||||||
|
(3, '手把件', NULL, 3, 'full'),
|
||||||
|
(4, '雕刻件', NULL, 4, 'full'),
|
||||||
|
(5, '摆件', NULL, 5, 'full'),
|
||||||
|
(6, '手镯', NULL, 6, 'full'),
|
||||||
|
(7, '耳钉', NULL, 7, 'full'),
|
||||||
|
(8, '耳饰', NULL, 8, 'full'),
|
||||||
|
(9, '手链', NULL, 9, 'size_color'),
|
||||||
|
(10, '项链', NULL, 10, 'full'),
|
||||||
|
(11, '戒指', NULL, 11, 'full'),
|
||||||
|
(12, '表带', NULL, 12, 'size_color');
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- 牌子的子类型 (category_id=1) - 牌型
|
||||||
|
-- ========================================
|
||||||
|
INSERT INTO sub_types (category_id, name, description, preview_image, sort_order) VALUES
|
||||||
|
(1, '二五牌', NULL, NULL, 1),
|
||||||
|
(1, '三角牌', NULL, NULL, 2),
|
||||||
|
(1, '三五牌', NULL, NULL, 3),
|
||||||
|
(1, '四六牌', NULL, NULL, 4),
|
||||||
|
(1, '正方形', NULL, NULL, 5),
|
||||||
|
(1, '椭圆形', NULL, NULL, 6);
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- 珠子的子类型 (category_id=2) - 尺寸
|
||||||
|
-- ========================================
|
||||||
|
INSERT INTO sub_types (category_id, name, description, preview_image, sort_order) VALUES
|
||||||
|
(2, '4mm', NULL, NULL, 1),
|
||||||
|
(2, '6mm', NULL, NULL, 2),
|
||||||
|
(2, '8mm', NULL, NULL, 3),
|
||||||
|
(2, '10mm', NULL, NULL, 4),
|
||||||
|
(2, '12mm', NULL, NULL, 5),
|
||||||
|
(2, '14mm', NULL, NULL, 6),
|
||||||
|
(2, '16mm', NULL, NULL, 7),
|
||||||
|
(2, '18mm', NULL, NULL, 8),
|
||||||
|
(2, '20mm', NULL, NULL, 9);
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- 手把件的子类型 (category_id=3) - 题材
|
||||||
|
-- ========================================
|
||||||
|
INSERT INTO sub_types (category_id, name, description, preview_image, sort_order) VALUES
|
||||||
|
(3, '山水手把件', '山水意境题材', NULL, 1),
|
||||||
|
(3, '动物手把件', '动物造型题材', NULL, 2),
|
||||||
|
(3, '瑞兽手把件', '貔貅、麒麟等瑞兽', NULL, 3),
|
||||||
|
(3, '人物手把件', '人物造型题材', NULL, 4),
|
||||||
|
(3, '花鸟手把件', '花鸟自然题材', NULL, 5),
|
||||||
|
(3, '佛像手把件', '佛教题材', NULL, 6);
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- 雕刻件的子类型 (category_id=4) - 题材
|
||||||
|
-- ========================================
|
||||||
|
INSERT INTO sub_types (category_id, name, description, preview_image, sort_order) VALUES
|
||||||
|
(4, '山水雕刻', '山水意境雕刻', NULL, 1),
|
||||||
|
(4, '花鸟雕刻', '花鸟自然雕刻', NULL, 2),
|
||||||
|
(4, '人物雕刻', '人物造型雕刻', NULL, 3),
|
||||||
|
(4, '佛像雕刻', '佛教题材雕刻', NULL, 4),
|
||||||
|
(4, '瑞兽雕刻', '瑞兽神兽雕刻', NULL, 5),
|
||||||
|
(4, '仿古雕刻', '仿古纹饰雕刻', NULL, 6);
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- 摆件的子类型 (category_id=5) - 题材
|
||||||
|
-- ========================================
|
||||||
|
INSERT INTO sub_types (category_id, name, description, preview_image, sort_order) VALUES
|
||||||
|
(5, '山水摆件', '山水意境摆件', NULL, 1),
|
||||||
|
(5, '人物摆件', '人物造型摆件', NULL, 2),
|
||||||
|
(5, '动物摆件', '动物造型摆件', NULL, 3),
|
||||||
|
(5, '佛像摆件', '佛教题材摆件', NULL, 4),
|
||||||
|
(5, '花鸟摆件', '花鸟自然摆件', NULL, 5),
|
||||||
|
(5, '器皿摆件', '香炉、花瓶等器皿', NULL, 6);
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- 手镯的子类型 (category_id=6) - 镯型
|
||||||
|
-- ========================================
|
||||||
|
INSERT INTO sub_types (category_id, name, description, preview_image, sort_order) VALUES
|
||||||
|
(6, '平安镯', '内平外圆,最经典的镯型', NULL, 1),
|
||||||
|
(6, '福镯', '内圆外圆,圆条造型', NULL, 2),
|
||||||
|
(6, '贵妃镯', '椭圆形,贴合手腕', NULL, 3),
|
||||||
|
(6, '美人镯', '条杆纤细,秀气典雅', NULL, 4),
|
||||||
|
(6, '方镯', '方形截面,棱角分明', NULL, 5),
|
||||||
|
(6, '雕花镯', '表面雕刻纹饰', NULL, 6);
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- 耳钉的子类型 (category_id=7) - 形状
|
||||||
|
-- ========================================
|
||||||
|
INSERT INTO sub_types (category_id, name, description, preview_image, sort_order) VALUES
|
||||||
|
(7, '圆形耳钉', '经典圆形造型', NULL, 1),
|
||||||
|
(7, '水滴形耳钉', '水滴形优雅造型', NULL, 2),
|
||||||
|
(7, '方形耳钉', '方形简约造型', NULL, 3),
|
||||||
|
(7, '花朵形耳钉', '花朵造型', NULL, 4),
|
||||||
|
(7, '心形耳钉', '心形浪漫造型', NULL, 5),
|
||||||
|
(7, '几何形耳钉', '几何抽象造型', NULL, 6);
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- 耳饰的子类型 (category_id=8) - 款式
|
||||||
|
-- ========================================
|
||||||
|
INSERT INTO sub_types (category_id, name, description, preview_image, sort_order) VALUES
|
||||||
|
(8, '耳环', '圆环形耳饰', NULL, 1),
|
||||||
|
(8, '耳坠', '垂坠型耳饰', NULL, 2),
|
||||||
|
(8, '耳夹', '无需耳洞的耳饰', NULL, 3),
|
||||||
|
(8, '流苏耳饰', '长款流苏造型', NULL, 4);
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- 手链的子类型 (category_id=9) - 珠径
|
||||||
|
-- ========================================
|
||||||
|
INSERT INTO sub_types (category_id, name, description, preview_image, sort_order) VALUES
|
||||||
|
(9, '6mm', NULL, NULL, 1),
|
||||||
|
(9, '8mm', NULL, NULL, 2),
|
||||||
|
(9, '10mm', NULL, NULL, 3),
|
||||||
|
(9, '12mm', NULL, NULL, 4),
|
||||||
|
(9, '14mm', NULL, NULL, 5);
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- 项链的子类型 (category_id=10) - 款式
|
||||||
|
-- ========================================
|
||||||
|
INSERT INTO sub_types (category_id, name, description, preview_image, sort_order) VALUES
|
||||||
|
(10, '锁骨链', '短款锁骨链', NULL, 1),
|
||||||
|
(10, '吊坠项链', '搭配玉石吊坠', NULL, 2),
|
||||||
|
(10, '串珠项链', '玉珠串联而成', NULL, 3),
|
||||||
|
(10, '编绳项链', '编织绳搭配玉石', NULL, 4),
|
||||||
|
(10, '毛衣链', '长款毛衣链', NULL, 5);
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- 戒指的子类型 (category_id=11) - 款式
|
||||||
|
-- ========================================
|
||||||
|
INSERT INTO sub_types (category_id, name, description, preview_image, sort_order) VALUES
|
||||||
|
(11, '素面戒指', '光面简约戒指', NULL, 1),
|
||||||
|
(11, '镶嵌戒指', '金属镶嵌玉石', NULL, 2),
|
||||||
|
(11, '雕花戒指', '表面雕刻纹饰', NULL, 3),
|
||||||
|
(11, '扳指', '传统扳指造型', NULL, 4),
|
||||||
|
(11, '指环', '环形简约指环', NULL, 5);
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- 表带的子类型 (category_id=12) - 宽度
|
||||||
|
-- ========================================
|
||||||
|
INSERT INTO sub_types (category_id, name, description, preview_image, sort_order) VALUES
|
||||||
|
(12, '18mm', NULL, NULL, 1),
|
||||||
|
(12, '20mm', NULL, NULL, 2),
|
||||||
|
(12, '22mm', NULL, NULL, 3),
|
||||||
|
(12, '24mm', NULL, NULL, 4);
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- 颜色数据
|
||||||
|
-- 基于和田玉国标颜色分类及市场主流色种
|
||||||
|
-- ========================================
|
||||||
|
|
||||||
|
-- 牌子颜色 (category_id=1) - 全部10种
|
||||||
|
INSERT INTO colors (category_id, name, hex_code, sort_order) VALUES
|
||||||
|
(1, '白玉', '#FEFEF2', 1),
|
||||||
|
(1, '青白玉', '#E8EDE4', 2),
|
||||||
|
(1, '青玉', '#7A8B6E', 3),
|
||||||
|
(1, '碧玉', '#2D5F2D', 4),
|
||||||
|
(1, '翠青', '#6BAF8D', 5),
|
||||||
|
(1, '黄玉', '#D4A843', 6),
|
||||||
|
(1, '糖玉', '#C4856C', 7),
|
||||||
|
(1, '墨玉', '#2C2C2C', 8),
|
||||||
|
(1, '藕粉', '#E8B4B8', 9),
|
||||||
|
(1, '烟紫', '#8B7D9B', 10);
|
||||||
|
|
||||||
|
-- 珠子颜色 (category_id=2) - 11种(含糖白)
|
||||||
|
INSERT INTO colors (category_id, name, hex_code, sort_order) VALUES
|
||||||
|
(2, '糖白', '#F5F0E8', 1),
|
||||||
|
(2, '白玉', '#FEFEF2', 2),
|
||||||
|
(2, '碧玉', '#2D5F2D', 3),
|
||||||
|
(2, '青白玉', '#E8EDE4', 4),
|
||||||
|
(2, '青玉', '#7A8B6E', 5),
|
||||||
|
(2, '翠青', '#6BAF8D', 6),
|
||||||
|
(2, '黄玉', '#D4A843', 7),
|
||||||
|
(2, '糖玉', '#C4856C', 8),
|
||||||
|
(2, '墨玉', '#2C2C2C', 9),
|
||||||
|
(2, '藕粉', '#E8B4B8', 10),
|
||||||
|
(2, '烟紫', '#8B7D9B', 11);
|
||||||
|
|
||||||
|
-- 手把件颜色 (category_id=3) - 全部10种
|
||||||
|
INSERT INTO colors (category_id, name, hex_code, sort_order) VALUES
|
||||||
|
(3, '白玉', '#FEFEF2', 1),
|
||||||
|
(3, '青白玉', '#E8EDE4', 2),
|
||||||
|
(3, '青玉', '#7A8B6E', 3),
|
||||||
|
(3, '碧玉', '#2D5F2D', 4),
|
||||||
|
(3, '翠青', '#6BAF8D', 5),
|
||||||
|
(3, '黄玉', '#D4A843', 6),
|
||||||
|
(3, '糖玉', '#C4856C', 7),
|
||||||
|
(3, '墨玉', '#2C2C2C', 8),
|
||||||
|
(3, '藕粉', '#E8B4B8', 9),
|
||||||
|
(3, '烟紫', '#8B7D9B', 10);
|
||||||
|
|
||||||
|
-- 雕刻件颜色 (category_id=4) - 全部10种
|
||||||
|
INSERT INTO colors (category_id, name, hex_code, sort_order) VALUES
|
||||||
|
(4, '白玉', '#FEFEF2', 1),
|
||||||
|
(4, '青白玉', '#E8EDE4', 2),
|
||||||
|
(4, '青玉', '#7A8B6E', 3),
|
||||||
|
(4, '碧玉', '#2D5F2D', 4),
|
||||||
|
(4, '翠青', '#6BAF8D', 5),
|
||||||
|
(4, '黄玉', '#D4A843', 6),
|
||||||
|
(4, '糖玉', '#C4856C', 7),
|
||||||
|
(4, '墨玉', '#2C2C2C', 8),
|
||||||
|
(4, '藕粉', '#E8B4B8', 9),
|
||||||
|
(4, '烟紫', '#8B7D9B', 10);
|
||||||
|
|
||||||
|
-- 摆件颜色 (category_id=5) - 全部10种
|
||||||
|
INSERT INTO colors (category_id, name, hex_code, sort_order) VALUES
|
||||||
|
(5, '白玉', '#FEFEF2', 1),
|
||||||
|
(5, '青白玉', '#E8EDE4', 2),
|
||||||
|
(5, '青玉', '#7A8B6E', 3),
|
||||||
|
(5, '碧玉', '#2D5F2D', 4),
|
||||||
|
(5, '翠青', '#6BAF8D', 5),
|
||||||
|
(5, '黄玉', '#D4A843', 6),
|
||||||
|
(5, '糖玉', '#C4856C', 7),
|
||||||
|
(5, '墨玉', '#2C2C2C', 8),
|
||||||
|
(5, '藕粉', '#E8B4B8', 9),
|
||||||
|
(5, '烟紫', '#8B7D9B', 10);
|
||||||
|
|
||||||
|
-- 手镯颜色 (category_id=6) - 全部10种
|
||||||
|
INSERT INTO colors (category_id, name, hex_code, sort_order) VALUES
|
||||||
|
(6, '白玉', '#FEFEF2', 1),
|
||||||
|
(6, '青白玉', '#E8EDE4', 2),
|
||||||
|
(6, '青玉', '#7A8B6E', 3),
|
||||||
|
(6, '碧玉', '#2D5F2D', 4),
|
||||||
|
(6, '翠青', '#6BAF8D', 5),
|
||||||
|
(6, '黄玉', '#D4A843', 6),
|
||||||
|
(6, '糖玉', '#C4856C', 7),
|
||||||
|
(6, '墨玉', '#2C2C2C', 8),
|
||||||
|
(6, '藕粉', '#E8B4B8', 9),
|
||||||
|
(6, '烟紫', '#8B7D9B', 10);
|
||||||
|
|
||||||
|
-- 耳钉颜色 (category_id=7) - 全部10种
|
||||||
|
INSERT INTO colors (category_id, name, hex_code, sort_order) VALUES
|
||||||
|
(7, '白玉', '#FEFEF2', 1),
|
||||||
|
(7, '青白玉', '#E8EDE4', 2),
|
||||||
|
(7, '青玉', '#7A8B6E', 3),
|
||||||
|
(7, '碧玉', '#2D5F2D', 4),
|
||||||
|
(7, '翠青', '#6BAF8D', 5),
|
||||||
|
(7, '黄玉', '#D4A843', 6),
|
||||||
|
(7, '糖玉', '#C4856C', 7),
|
||||||
|
(7, '墨玉', '#2C2C2C', 8),
|
||||||
|
(7, '藕粉', '#E8B4B8', 9),
|
||||||
|
(7, '烟紫', '#8B7D9B', 10);
|
||||||
|
|
||||||
|
-- 耳饰颜色 (category_id=8) - 全部10种
|
||||||
|
INSERT INTO colors (category_id, name, hex_code, sort_order) VALUES
|
||||||
|
(8, '白玉', '#FEFEF2', 1),
|
||||||
|
(8, '青白玉', '#E8EDE4', 2),
|
||||||
|
(8, '青玉', '#7A8B6E', 3),
|
||||||
|
(8, '碧玉', '#2D5F2D', 4),
|
||||||
|
(8, '翠青', '#6BAF8D', 5),
|
||||||
|
(8, '黄玉', '#D4A843', 6),
|
||||||
|
(8, '糖玉', '#C4856C', 7),
|
||||||
|
(8, '墨玉', '#2C2C2C', 8),
|
||||||
|
(8, '藕粉', '#E8B4B8', 9),
|
||||||
|
(8, '烟紫', '#8B7D9B', 10);
|
||||||
|
|
||||||
|
-- 手链颜色 (category_id=9) - 全部10种
|
||||||
|
INSERT INTO colors (category_id, name, hex_code, sort_order) VALUES
|
||||||
|
(9, '白玉', '#FEFEF2', 1),
|
||||||
|
(9, '青白玉', '#E8EDE4', 2),
|
||||||
|
(9, '青玉', '#7A8B6E', 3),
|
||||||
|
(9, '碧玉', '#2D5F2D', 4),
|
||||||
|
(9, '翠青', '#6BAF8D', 5),
|
||||||
|
(9, '黄玉', '#D4A843', 6),
|
||||||
|
(9, '糖玉', '#C4856C', 7),
|
||||||
|
(9, '墨玉', '#2C2C2C', 8),
|
||||||
|
(9, '藕粉', '#E8B4B8', 9),
|
||||||
|
(9, '烟紫', '#8B7D9B', 10);
|
||||||
|
|
||||||
|
-- 项链颜色 (category_id=10) - 全部10种
|
||||||
|
INSERT INTO colors (category_id, name, hex_code, sort_order) VALUES
|
||||||
|
(10, '白玉', '#FEFEF2', 1),
|
||||||
|
(10, '青白玉', '#E8EDE4', 2),
|
||||||
|
(10, '青玉', '#7A8B6E', 3),
|
||||||
|
(10, '碧玉', '#2D5F2D', 4),
|
||||||
|
(10, '翠青', '#6BAF8D', 5),
|
||||||
|
(10, '黄玉', '#D4A843', 6),
|
||||||
|
(10, '糖玉', '#C4856C', 7),
|
||||||
|
(10, '墨玉', '#2C2C2C', 8),
|
||||||
|
(10, '藕粉', '#E8B4B8', 9),
|
||||||
|
(10, '烟紫', '#8B7D9B', 10);
|
||||||
|
|
||||||
|
-- 戒指颜色 (category_id=11) - 全部10种
|
||||||
|
INSERT INTO colors (category_id, name, hex_code, sort_order) VALUES
|
||||||
|
(11, '白玉', '#FEFEF2', 1),
|
||||||
|
(11, '青白玉', '#E8EDE4', 2),
|
||||||
|
(11, '青玉', '#7A8B6E', 3),
|
||||||
|
(11, '碧玉', '#2D5F2D', 4),
|
||||||
|
(11, '翠青', '#6BAF8D', 5),
|
||||||
|
(11, '黄玉', '#D4A843', 6),
|
||||||
|
(11, '糖玉', '#C4856C', 7),
|
||||||
|
(11, '墨玉', '#2C2C2C', 8),
|
||||||
|
(11, '藕粉', '#E8B4B8', 9),
|
||||||
|
(11, '烟紫', '#8B7D9B', 10);
|
||||||
|
|
||||||
|
-- 表带颜色 (category_id=12) - 全部10种
|
||||||
|
INSERT INTO colors (category_id, name, hex_code, sort_order) VALUES
|
||||||
|
(12, '白玉', '#FEFEF2', 1),
|
||||||
|
(12, '青白玉', '#E8EDE4', 2),
|
||||||
|
(12, '青玉', '#7A8B6E', 3),
|
||||||
|
(12, '碧玉', '#2D5F2D', 4),
|
||||||
|
(12, '翠青', '#6BAF8D', 5),
|
||||||
|
(12, '黄玉', '#D4A843', 6),
|
||||||
|
(12, '糖玉', '#C4856C', 7),
|
||||||
|
(12, '墨玉', '#2C2C2C', 8),
|
||||||
|
(12, '藕粉', '#E8B4B8', 9),
|
||||||
|
(12, '烟紫', '#8B7D9B', 10);
|
||||||
Reference in New Issue
Block a user