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

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