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