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

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>