docs(readme): 编写项目README文档,描述功能与架构

- 完整撰写玉宗珠宝设计大师项目README,介绍项目概况及核心功能
- 说明用户认证系统实现及优势,包含JWT鉴权和密码加密细节
- 详细描述品类管理系统,支持多流程类型和多种玉石品类
- 说明设计图生成方案及技术,包含Pillow生成示例及字体支持
- 介绍设计管理功能,支持分页浏览、预览、下载和删除设计
- 个人信息管理模块说明,涵盖昵称、手机号、密码的安全修改
- 绘制业务流程图和关键数据流图,清晰展现系统架构与数据流
- 提供详细API调用链路及参数说明,涵盖用户、品类、设计接口
- 列明技术栈及版本,包含前后端框架、ORM、认证、加密等工具
- 展示目录结构,标明后端与前端项目布局
- 规划本地开发环境与启动步骤,包括数据库初始化及运行命令
- 说明服务器部署流程和Nginx配置方案
- 详细数据库表结构说明及环境变量配置指导
- 汇总常用开发及测试命令,方便开发调试与部署管理
This commit is contained in:
changyoutongxue
2026-03-27 13:10:17 +08:00
commit e3ff55b4db
69 changed files with 8551 additions and 0 deletions

274
README.md Normal file
View File

@@ -0,0 +1,274 @@
# 玉宗 - 珠宝设计大师
AI 驱动的珠宝玉石设计生成系统,支持 12 种玉石品类的智能设计图生成。
## 功能特性
### 1. 用户认证系统
- **功能说明**支持用户注册、登录、退出JWT Token 鉴权
- **实现方式**:后端使用 `python-jose` 生成 JWT Token有效期 1440 分钟),密码通过 `passlib + bcrypt` 加密存储;前端通过 Axios 拦截器自动携带 Token401 响应自动跳转登录页
- **优点**:无状态认证,前后端分离友好;密码 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
后端 APIhttp://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
View 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
View 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
View File

@@ -0,0 +1 @@
# 玉宗 - 珠宝设计大师 后端应用

28
backend/app/config.py Normal file
View 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
View 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
View 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")

View 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"
]

View 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}')>"

View 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}')>"

View 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}')>"

View File

@@ -0,0 +1,8 @@
# API 路由模块
from . import categories, designs, users
__all__ = [
"categories",
"designs",
"users",
]

View 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 tokensub 字段存储用户 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

View 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

View 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"
)

View 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": "密码修改成功"}

View 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",
]

View 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

View 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

View 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="新密码")

View File

@@ -0,0 +1,8 @@
# 业务服务模块
from . import design_service
from .mock_generator import generate_mock_design
__all__ = [
"design_service",
"generate_mock_design",
]

View 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

View 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

View 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

View File

@@ -0,0 +1 @@
# 工具函数模块

58
backend/app/utils/deps.py Normal file
View 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

View 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
View 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
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

24
frontend/.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
frontend/README.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

28
frontend/package.json Normal file
View 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"
}
}

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
View 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
View 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
View 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)
}

View 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`)
}

View 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`
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View 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;
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View 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

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

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

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

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

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

View 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
View 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')

View 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

View 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
}
})

View 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
}
})

View 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
View 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);
}
}

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

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

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

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

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

View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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
View 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
View 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);