docs(readme): 编写项目README文档,描述功能与架构
- 完整撰写玉宗珠宝设计大师项目README,介绍项目概况及核心功能 - 说明用户认证系统实现及优势,包含JWT鉴权和密码加密细节 - 详细描述品类管理系统,支持多流程类型和多种玉石品类 - 说明设计图生成方案及技术,包含Pillow生成示例及字体支持 - 介绍设计管理功能,支持分页浏览、预览、下载和删除设计 - 个人信息管理模块说明,涵盖昵称、手机号、密码的安全修改 - 绘制业务流程图和关键数据流图,清晰展现系统架构与数据流 - 提供详细API调用链路及参数说明,涵盖用户、品类、设计接口 - 列明技术栈及版本,包含前后端框架、ORM、认证、加密等工具 - 展示目录结构,标明后端与前端项目布局 - 规划本地开发环境与启动步骤,包括数据库初始化及运行命令 - 说明服务器部署流程和Nginx配置方案 - 详细数据库表结构说明及环境变量配置指导 - 汇总常用开发及测试命令,方便开发调试与部署管理
This commit is contained in:
34
frontend/src/App.vue
Normal file
34
frontend/src/App.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<AppHeader />
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 应用初始化时恢复登录状态
|
||||
onMounted(() => {
|
||||
userStore.init()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
51
frontend/src/api/auth.ts
Normal file
51
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// Auth API - 用户认证相关接口
|
||||
import request from './request'
|
||||
|
||||
export interface LoginParams {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface RegisterParams {
|
||||
username: string
|
||||
password: string
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string
|
||||
token_type: string
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
phone?: string | null
|
||||
avatar_url?: string
|
||||
}
|
||||
|
||||
// 登录
|
||||
export const login = (data: LoginParams) => {
|
||||
return request.post<any, LoginResponse>('/auth/login', data)
|
||||
}
|
||||
|
||||
// 注册
|
||||
export const register = (data: RegisterParams) => {
|
||||
return request.post('/auth/register', data)
|
||||
}
|
||||
|
||||
// 获取当前用户信息
|
||||
export const getCurrentUser = () => {
|
||||
return request.get<any, UserInfo>('/auth/me')
|
||||
}
|
||||
|
||||
// 更新个人资料
|
||||
export function updateProfileApi(data: { nickname?: string; phone?: string }) {
|
||||
return request.put<any, UserInfo>('/users/profile', data)
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
export function changePasswordApi(data: { old_password: string; new_password: string }) {
|
||||
return request.put('/users/password', data)
|
||||
}
|
||||
42
frontend/src/api/category.ts
Normal file
42
frontend/src/api/category.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// Category API - 玉石分类相关接口
|
||||
import request from './request'
|
||||
|
||||
export interface Category {
|
||||
id: number
|
||||
name: string
|
||||
icon: string | null
|
||||
sort_order: number
|
||||
flow_type: 'full' | 'size_color' | 'simple'
|
||||
}
|
||||
|
||||
export interface SubType {
|
||||
id: number
|
||||
category_id: number
|
||||
name: string
|
||||
description: string | null
|
||||
preview_image: string | null
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface ColorOption {
|
||||
id: number
|
||||
category_id: number
|
||||
name: string
|
||||
hex_code: string
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
// 获取品类列表
|
||||
export function getCategoriesApi() {
|
||||
return request.get<any, Category[]>('/categories')
|
||||
}
|
||||
|
||||
// 获取品类下的子类型列表
|
||||
export function getSubTypesApi(categoryId: number) {
|
||||
return request.get<any, SubType[]>(`/categories/${categoryId}/sub-types`)
|
||||
}
|
||||
|
||||
// 获取品类下的颜色列表
|
||||
export function getColorsApi(categoryId: number) {
|
||||
return request.get<any, ColorOption[]>(`/categories/${categoryId}/colors`)
|
||||
}
|
||||
76
frontend/src/api/design.ts
Normal file
76
frontend/src/api/design.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// Design API - 设计相关接口
|
||||
import request from './request'
|
||||
|
||||
export interface Category {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface SubType {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface Design {
|
||||
id: number
|
||||
user_id: number
|
||||
category: Category
|
||||
sub_type: SubType
|
||||
color: { id: number; name: string } | null
|
||||
prompt: string
|
||||
carving_technique: string | null
|
||||
design_style: string | null
|
||||
motif: string | null
|
||||
size_spec: string | null
|
||||
surface_finish: string | null
|
||||
usage_scene: string | null
|
||||
image_url: string | null
|
||||
status: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface DesignListResponse {
|
||||
items: Design[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface GenerateDesignParams {
|
||||
category_id: number
|
||||
sub_type_id?: number
|
||||
color_id?: number
|
||||
prompt: string
|
||||
carving_technique?: string
|
||||
design_style?: string
|
||||
motif?: string
|
||||
size_spec?: string
|
||||
surface_finish?: string
|
||||
usage_scene?: string
|
||||
}
|
||||
|
||||
// 获取设计列表
|
||||
export function getDesignsApi(page: number = 1, pageSize: number = 20) {
|
||||
return request.get<any, DesignListResponse>('/designs', { params: { page, page_size: pageSize } })
|
||||
}
|
||||
|
||||
// 获取设计详情
|
||||
export function getDesignApi(id: number) {
|
||||
return request.get<any, Design>(`/designs/${id}`)
|
||||
}
|
||||
|
||||
// 生成设计
|
||||
export function generateDesignApi(data: GenerateDesignParams) {
|
||||
return request.post<any, Design>('/designs/generate', data)
|
||||
}
|
||||
|
||||
// 删除设计
|
||||
export function deleteDesignApi(id: number) {
|
||||
return request.delete(`/designs/${id}`)
|
||||
}
|
||||
|
||||
// 获取设计下载 URL
|
||||
export function getDesignDownloadUrl(id: number) {
|
||||
return `/api/designs/${id}/download`
|
||||
}
|
||||
35
frontend/src/api/request.ts
Normal file
35
frontend/src/api/request.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// 请求拦截器 - 自动携带 JWT Token
|
||||
request.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
// 响应拦截器 - 统一错误处理
|
||||
request.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
const message = error.response?.data?.detail || '请求失败'
|
||||
ElMessage.error(message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
49
frontend/src/assets/styles/theme.scss
Normal file
49
frontend/src/assets/styles/theme.scss
Normal file
@@ -0,0 +1,49 @@
|
||||
// 玉宗品牌色 - 中式雅致风格
|
||||
$primary-color: #5B7E6B; // 青玉色 - 主色调
|
||||
$primary-light: #8BAF9C; // 浅青
|
||||
$primary-dark: #3D5A4A; // 深青
|
||||
$secondary-color: #C4A86C; // 金缕色 - 辅助色
|
||||
$bg-color: #FAF8F5; // 暖白底色
|
||||
$bg-dark: #F0EDE8; // 深一级底色
|
||||
$text-primary: #2C2C2C; // 主文字
|
||||
$text-secondary: #6B6B6B; // 次要文字
|
||||
$text-light: #999999; // 辅助文字
|
||||
$border-color: #E8E4DF; // 边框色
|
||||
$ink-color: #1A1A2E; // 墨色
|
||||
|
||||
// Element Plus 主题覆盖
|
||||
:root {
|
||||
--el-color-primary: #{$primary-color};
|
||||
--el-color-primary-light-3: #{$primary-light};
|
||||
--el-color-primary-dark-2: #{$primary-dark};
|
||||
--el-bg-color: #{$bg-color};
|
||||
--el-border-color: #{$border-color};
|
||||
--el-text-color-primary: #{$text-primary};
|
||||
--el-text-color-regular: #{$text-secondary};
|
||||
}
|
||||
|
||||
// 全局基础样式
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
background-color: $bg-color;
|
||||
color: $text-primary;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
// 滚动条美化
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: $border-color;
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: $text-light;
|
||||
}
|
||||
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
147
frontend/src/components/AppHeader.vue
Normal file
147
frontend/src/components/AppHeader.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<router-link to="/" class="logo">
|
||||
<span class="logo-text">玉宗</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<nav class="header-nav">
|
||||
<router-link to="/" class="nav-link">设计</router-link>
|
||||
<router-link to="/generate" class="nav-link">生成</router-link>
|
||||
</nav>
|
||||
<div class="header-right">
|
||||
<template v-if="isLoggedIn">
|
||||
<el-dropdown trigger="click" @command="handleCommand">
|
||||
<span class="user-dropdown">
|
||||
<span class="user-nickname">{{ userNickname }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="user">个人中心</el-dropdown-item>
|
||||
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-link to="/login" class="auth-link">登录</router-link>
|
||||
<router-link to="/register" class="auth-link auth-register">注册</router-link>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ArrowDown } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const isLoggedIn = computed(() => !!userStore.token)
|
||||
const userNickname = computed(() => userStore.userInfo?.nickname || '用户')
|
||||
|
||||
const handleCommand = (command: string) => {
|
||||
if (command === 'user') {
|
||||
router.push('/user')
|
||||
} else if (command === 'logout') {
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 60px;
|
||||
padding: 0 32px;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #E8E4DF;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
.logo {
|
||||
text-decoration: none;
|
||||
}
|
||||
.logo-text {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #5B7E6B;
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
|
||||
.nav-link {
|
||||
color: #6B6B6B;
|
||||
text-decoration: none;
|
||||
font-size: 15px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover,
|
||||
&.router-link-active {
|
||||
color: #5B7E6B;
|
||||
border-bottom-color: #5B7E6B;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.auth-link {
|
||||
color: #6B6B6B;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
padding: 6px 16px;
|
||||
transition: color 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #5B7E6B;
|
||||
}
|
||||
|
||||
&.auth-register {
|
||||
background-color: #5B7E6B;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background-color: #3D5A4A;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
color: #2C2C2C;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
color: #5B7E6B;
|
||||
}
|
||||
}
|
||||
|
||||
.user-nickname {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
100
frontend/src/components/CategoryNav.vue
Normal file
100
frontend/src/components/CategoryNav.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<nav class="category-nav">
|
||||
<div class="nav-header">
|
||||
<h3>玉石品类</h3>
|
||||
</div>
|
||||
<ul class="category-list">
|
||||
<li
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
class="category-item"
|
||||
:class="{ active: currentCategory?.id === category.id }"
|
||||
@click="handleSelect(category)"
|
||||
>
|
||||
<span class="category-name">{{ category.name }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useCategoryStore } from '@/stores/category'
|
||||
import type { Category } from '@/stores/category'
|
||||
|
||||
const categoryStore = useCategoryStore()
|
||||
|
||||
const categories = computed(() => categoryStore.categories)
|
||||
const currentCategory = computed(() => categoryStore.currentCategory)
|
||||
|
||||
const handleSelect = (category: Category) => {
|
||||
categoryStore.selectCategory(category)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$primary-color: #5B7E6B;
|
||||
$primary-light: #8BAF9C;
|
||||
$bg-color: #FAF8F5;
|
||||
$border-color: #E8E4DF;
|
||||
$text-primary: #2C2C2C;
|
||||
$text-secondary: #6B6B6B;
|
||||
|
||||
.category-nav {
|
||||
width: 200px;
|
||||
min-width: 200px;
|
||||
height: 100%;
|
||||
background-color: #fff;
|
||||
border-right: 1px solid $border-color;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nav-header {
|
||||
padding: 20px 16px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: $text-primary;
|
||||
margin: 0;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.category-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
padding: 14px 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
border-bottom: 1px solid $border-color;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba($primary-color, 0.06);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: $primary-color;
|
||||
|
||||
.category-name {
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.category-name {
|
||||
font-size: 14px;
|
||||
color: $text-secondary;
|
||||
letter-spacing: 1px;
|
||||
transition: color 0.25s ease;
|
||||
}
|
||||
</style>
|
||||
103
frontend/src/components/ColorPicker.vue
Normal file
103
frontend/src/components/ColorPicker.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="color-picker">
|
||||
<h4 class="picker-title">选择颜色</h4>
|
||||
<div class="color-grid">
|
||||
<div
|
||||
v-for="color in colors"
|
||||
:key="color.id"
|
||||
class="color-item"
|
||||
:class="{ active: modelValue?.id === color.id }"
|
||||
@click="handleSelect(color)"
|
||||
>
|
||||
<div
|
||||
class="color-swatch"
|
||||
:style="{ backgroundColor: color.hex_code }"
|
||||
></div>
|
||||
<span class="color-name">{{ color.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ColorOption } from '@/stores/category'
|
||||
|
||||
defineProps<{
|
||||
colors: ColorOption[]
|
||||
modelValue: ColorOption | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', color: ColorOption): void
|
||||
}>()
|
||||
|
||||
const handleSelect = (color: ColorOption) => {
|
||||
emit('update:modelValue', color)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$primary-color: #5B7E6B;
|
||||
$secondary-color: #C4A86C;
|
||||
$border-color: #E8E4DF;
|
||||
$text-primary: #2C2C2C;
|
||||
$text-secondary: #6B6B6B;
|
||||
|
||||
.color-picker {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.picker-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: $text-primary;
|
||||
margin: 0 0 16px 0;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.color-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.color-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba($primary-color, 0.06);
|
||||
}
|
||||
|
||||
&.active {
|
||||
.color-swatch {
|
||||
box-shadow: 0 0 0 3px $secondary-color;
|
||||
}
|
||||
.color-name {
|
||||
color: $primary-color;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid $border-color;
|
||||
transition: all 0.25s ease;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.color-name {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: $text-secondary;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
</style>
|
||||
366
frontend/src/components/DesignPreview.vue
Normal file
366
frontend/src/components/DesignPreview.vue
Normal file
@@ -0,0 +1,366 @@
|
||||
<template>
|
||||
<div class="design-preview">
|
||||
<!-- 图片预览区 -->
|
||||
<div class="preview-container">
|
||||
<div class="image-wrapper" :style="{ transform: `scale(${scale})` }">
|
||||
<el-image
|
||||
:src="imageUrl"
|
||||
:alt="design.prompt"
|
||||
fit="contain"
|
||||
:preview-src-list="[imageUrl]"
|
||||
:initial-index="0"
|
||||
preview-teleported
|
||||
class="design-image"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="image-placeholder">
|
||||
<el-icon class="loading-icon"><Loading /></el-icon>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #error>
|
||||
<div class="image-error">
|
||||
<el-icon><PictureFilled /></el-icon>
|
||||
<span>图片加载失败</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
</div>
|
||||
|
||||
<!-- 缩放控制 -->
|
||||
<div class="zoom-controls">
|
||||
<button class="zoom-btn" @click="zoomOut" :disabled="scale <= 0.5">
|
||||
<el-icon><ZoomOut /></el-icon>
|
||||
</button>
|
||||
<span class="zoom-level">{{ Math.round(scale * 100) }}%</span>
|
||||
<button class="zoom-btn" @click="zoomIn" :disabled="scale >= 2">
|
||||
<el-icon><ZoomIn /></el-icon>
|
||||
</button>
|
||||
<button class="zoom-btn reset-btn" @click="resetZoom" v-if="scale !== 1">
|
||||
<el-icon><RefreshRight /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设计信息 -->
|
||||
<div class="design-info">
|
||||
<h4 class="info-title">设计详情</h4>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">品类</span>
|
||||
<span class="info-value">{{ design.category?.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="design.sub_type">
|
||||
<span class="info-label">类型</span>
|
||||
<span class="info-value">{{ design.sub_type.name }}</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="design.color">
|
||||
<span class="info-label">颜色</span>
|
||||
<span class="info-value">{{ design.color.name }}</span>
|
||||
</div>
|
||||
<div class="info-item full-width">
|
||||
<span class="info-label">设计需求</span>
|
||||
<span class="info-value prompt">{{ design.prompt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-buttons">
|
||||
<a
|
||||
:href="downloadUrl"
|
||||
:download="downloadFilename"
|
||||
class="action-btn download-btn"
|
||||
>
|
||||
<el-icon><Download /></el-icon>
|
||||
<span>下载设计图</span>
|
||||
</a>
|
||||
<button class="action-btn secondary-btn" @click="goToUserCenter">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>查看我的设计</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Loading, PictureFilled, ZoomIn, ZoomOut, RefreshRight, Download, User } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { Design } from '@/stores/design'
|
||||
import { getDesignDownloadUrl } from '@/api/design'
|
||||
|
||||
const props = defineProps<{
|
||||
design: Design
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 缩放比例
|
||||
const scale = ref(1)
|
||||
|
||||
// 图片URL(添加API前缀)
|
||||
const imageUrl = computed(() => {
|
||||
if (!props.design.image_url) return ''
|
||||
// 如果已经是完整URL则直接使用,否则添加 /api 前缀
|
||||
if (props.design.image_url.startsWith('http')) {
|
||||
return props.design.image_url
|
||||
}
|
||||
return `/api${props.design.image_url}`
|
||||
})
|
||||
|
||||
// 下载URL
|
||||
const downloadUrl = computed(() => getDesignDownloadUrl(props.design.id))
|
||||
|
||||
// 下载文件名
|
||||
const downloadFilename = computed(() => {
|
||||
const category = props.design.category?.name || '设计'
|
||||
const subType = props.design.sub_type?.name || ''
|
||||
return `${category}${subType ? '-' + subType : ''}-${props.design.id}.png`
|
||||
})
|
||||
|
||||
// 放大
|
||||
const zoomIn = () => {
|
||||
if (scale.value < 2) {
|
||||
scale.value = Math.min(2, scale.value + 0.25)
|
||||
}
|
||||
}
|
||||
|
||||
// 缩小
|
||||
const zoomOut = () => {
|
||||
if (scale.value > 0.5) {
|
||||
scale.value = Math.max(0.5, scale.value - 0.25)
|
||||
}
|
||||
}
|
||||
|
||||
// 重置缩放
|
||||
const resetZoom = () => {
|
||||
scale.value = 1
|
||||
}
|
||||
|
||||
// 跳转到用户中心
|
||||
const goToUserCenter = () => {
|
||||
ElMessage.success('设计已自动保存到您的设计历史中')
|
||||
router.push('/user')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$primary-color: #5B7E6B;
|
||||
$primary-light: #8BAF9C;
|
||||
$secondary-color: #C4A86C;
|
||||
$bg-color: #FAF8F5;
|
||||
$bg-dark: #F0EDE8;
|
||||
$border-color: #E8E4DF;
|
||||
$text-primary: #2C2C2C;
|
||||
$text-secondary: #6B6B6B;
|
||||
$text-light: #999999;
|
||||
|
||||
.design-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 300px;
|
||||
max-height: 500px;
|
||||
transition: transform 0.3s ease;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.design-image {
|
||||
max-width: 100%;
|
||||
max-height: 450px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
cursor: zoom-in;
|
||||
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
max-height: 450px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.image-placeholder,
|
||||
.image-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: $bg-color;
|
||||
border-radius: 8px;
|
||||
color: $text-light;
|
||||
|
||||
.el-icon {
|
||||
font-size: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.zoom-controls {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.zoom-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: transparent;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: $text-secondary;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: $primary-color;
|
||||
border-color: $primary-color;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.zoom-level {
|
||||
min-width: 48px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.design-info {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px 24px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.info-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: $text-primary;
|
||||
margin: 0 0 16px 0;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid $border-color;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
|
||||
&.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 12px;
|
||||
color: $text-light;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 14px;
|
||||
color: $text-primary;
|
||||
|
||||
&.prompt {
|
||||
line-height: 1.6;
|
||||
color: $text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 14px 28px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
text-decoration: none;
|
||||
letter-spacing: 1px;
|
||||
|
||||
.el-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
background: $primary-color;
|
||||
color: #fff;
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
background: #4a6a5a;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba($primary-color, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
background: #fff;
|
||||
color: $primary-color;
|
||||
border: 1px solid $primary-color;
|
||||
|
||||
&:hover {
|
||||
background: $primary-color;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
93
frontend/src/components/HelloWorld.vue
Normal file
93
frontend/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import viteLogo from '../assets/vite.svg'
|
||||
import heroImg from '../assets/hero.png'
|
||||
import vueLogo from '../assets/vue.svg'
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="center">
|
||||
<div class="hero">
|
||||
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
||||
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
||||
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
||||
</div>
|
||||
<button class="counter" @click="count++">Count is {{ count }}</button>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img class="logo" :src="viteLogo" alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vuejs.org/" target="_blank">
|
||||
<img class="button-icon" :src="vueLogo" alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</template>
|
||||
414
frontend/src/components/SubTypePanel.vue
Normal file
414
frontend/src/components/SubTypePanel.vue
Normal file
@@ -0,0 +1,414 @@
|
||||
<template>
|
||||
<div class="subtype-panel">
|
||||
<!-- 未选中品类时的引导 -->
|
||||
<div v-if="!currentCategory" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 17L12 22L22 17" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 12L12 17L22 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="empty-text">请从左侧选择您感兴趣的品类开始设计</p>
|
||||
</div>
|
||||
|
||||
<!-- 加载中 -->
|
||||
<div v-else-if="loading" class="loading-state">
|
||||
<el-icon class="loading-icon"><Loading /></el-icon>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<!-- flow_type = "full"(牌子):选择牌型 -->
|
||||
<div v-else-if="currentCategory.flow_type === 'full'" class="panel-content">
|
||||
<h3 class="panel-title">选择牌型</h3>
|
||||
<p class="panel-desc">为「{{ currentCategory.name }}」选择一个款式</p>
|
||||
<div class="card-grid">
|
||||
<div
|
||||
v-for="subType in subTypes"
|
||||
:key="subType.id"
|
||||
class="subtype-card"
|
||||
:class="{ active: currentSubType?.id === subType.id }"
|
||||
@click="handleSelectSubType(subType)"
|
||||
>
|
||||
<div class="card-preview">
|
||||
<img
|
||||
v-if="subType.preview_image"
|
||||
:src="subType.preview_image"
|
||||
:alt="subType.name"
|
||||
/>
|
||||
<div v-else class="card-placeholder">
|
||||
<span>{{ subType.name.charAt(0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<span class="card-name">{{ subType.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 选择子类型后显示颜色选择(如有颜色数据) -->
|
||||
<template v-if="currentSubType && colors.length > 0">
|
||||
<ColorPicker
|
||||
v-model="selectedColor"
|
||||
:colors="colors"
|
||||
/>
|
||||
<div v-if="selectedColor" class="action-bar">
|
||||
<button class="btn-primary" @click="goToGenerate">
|
||||
开始设计
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="currentSubType" class="action-bar">
|
||||
<button class="btn-primary" @click="goToGenerate">
|
||||
开始设计
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- flow_type = "size_color"(珠子/手链/表带):选择规格 + 颜色 -->
|
||||
<div v-else-if="currentCategory.flow_type === 'size_color'" class="panel-content">
|
||||
<h3 class="panel-title">{{ sizeColorTitle }}</h3>
|
||||
<p class="panel-desc">为「{{ currentCategory.name }}」{{ sizeColorDesc }}</p>
|
||||
<div class="size-grid">
|
||||
<div
|
||||
v-for="subType in subTypes"
|
||||
:key="subType.id"
|
||||
class="size-tag"
|
||||
:class="{ active: currentSubType?.id === subType.id }"
|
||||
@click="handleSelectSubType(subType)"
|
||||
>
|
||||
{{ subType.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 选择尺寸后显示颜色选择 -->
|
||||
<template v-if="currentSubType && colors.length > 0">
|
||||
<ColorPicker
|
||||
v-model="selectedColor"
|
||||
:colors="colors"
|
||||
/>
|
||||
<div v-if="selectedColor" class="action-bar">
|
||||
<button class="btn-primary" @click="goToGenerate">
|
||||
开始设计
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- flow_type = "simple"(其他品类):直接开始设计 -->
|
||||
<div v-else class="panel-content">
|
||||
<h3 class="panel-title">{{ currentCategory.name }}</h3>
|
||||
<p class="panel-desc">您已选择「{{ currentCategory.name }}」品类</p>
|
||||
<div class="simple-intro">
|
||||
<div class="intro-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M8 12L11 15L16 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p>点击下方按钮,进入设计生成页面</p>
|
||||
</div>
|
||||
<div class="action-bar">
|
||||
<button class="btn-primary" @click="goToGenerate">
|
||||
开始设计
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import { useCategoryStore } from '@/stores/category'
|
||||
import type { SubType, ColorOption } from '@/stores/category'
|
||||
import ColorPicker from './ColorPicker.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const categoryStore = useCategoryStore()
|
||||
|
||||
const currentCategory = computed(() => categoryStore.currentCategory)
|
||||
const subTypes = computed(() => categoryStore.subTypes)
|
||||
const currentSubType = computed(() => categoryStore.currentSubType)
|
||||
const colors = computed(() => categoryStore.colors)
|
||||
const loading = computed(() => categoryStore.loading)
|
||||
|
||||
// 本地颜色选择状态,用于 v-model 双向绑定
|
||||
const selectedColor = ref<ColorOption | null>(null)
|
||||
|
||||
// size_color 流程的动态标题
|
||||
const sizeColorTitle = computed(() => {
|
||||
const name = currentCategory.value?.name || ''
|
||||
if (name === '表带') return '选择宽度'
|
||||
return '选择珠径'
|
||||
})
|
||||
const sizeColorDesc = computed(() => {
|
||||
const name = currentCategory.value?.name || ''
|
||||
if (name === '表带') return '选择宽度规格'
|
||||
return '选择珠径规格'
|
||||
})
|
||||
|
||||
// 监听 store 中的颜色变化
|
||||
watch(() => categoryStore.currentColor, (newVal) => {
|
||||
selectedColor.value = newVal
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听本地颜色选择变化,同步到 store
|
||||
watch(selectedColor, (newVal) => {
|
||||
if (newVal) {
|
||||
categoryStore.selectColor(newVal)
|
||||
}
|
||||
})
|
||||
|
||||
// 选择子类型时重置颜色
|
||||
watch(currentSubType, () => {
|
||||
selectedColor.value = null
|
||||
})
|
||||
|
||||
const handleSelectSubType = (subType: SubType) => {
|
||||
categoryStore.selectSubType(subType)
|
||||
}
|
||||
|
||||
const goToGenerate = () => {
|
||||
if (!currentCategory.value) return
|
||||
|
||||
const query: Record<string, string> = {
|
||||
categoryId: String(currentCategory.value.id)
|
||||
}
|
||||
|
||||
if (currentSubType.value) {
|
||||
query.subTypeId = String(currentSubType.value.id)
|
||||
}
|
||||
|
||||
if (selectedColor.value) {
|
||||
query.colorId = String(selectedColor.value.id)
|
||||
}
|
||||
|
||||
router.push({ path: '/generate', query })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$primary-color: #5B7E6B;
|
||||
$primary-light: #8BAF9C;
|
||||
$secondary-color: #C4A86C;
|
||||
$bg-color: #FAF8F5;
|
||||
$border-color: #E8E4DF;
|
||||
$text-primary: #2C2C2C;
|
||||
$text-secondary: #6B6B6B;
|
||||
$text-light: #999999;
|
||||
|
||||
.subtype-panel {
|
||||
flex: 1;
|
||||
padding: 32px 40px;
|
||||
overflow-y: auto;
|
||||
background-color: $bg-color;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
color: $border-color;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 15px;
|
||||
color: $text-light;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
gap: 16px;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
font-size: 32px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: $text-primary;
|
||||
margin: 0 0 8px 0;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.panel-desc {
|
||||
font-size: 14px;
|
||||
color: $text-secondary;
|
||||
margin: 0 0 24px 0;
|
||||
}
|
||||
|
||||
// 牌型卡片网格
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.subtype-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
border: 2px solid transparent;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: $primary-color;
|
||||
box-shadow: 0 4px 16px rgba($primary-color, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.card-preview {
|
||||
aspect-ratio: 1;
|
||||
background-color: $bg-color;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.card-placeholder {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, $primary-light, $primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
span {
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.card-info {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
border-top: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: 14px;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
// 尺寸标签网格
|
||||
.size-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.size-tag {
|
||||
padding: 10px 20px;
|
||||
background: #fff;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: $text-secondary;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: $primary-light;
|
||||
color: $primary-color;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: $primary-color;
|
||||
border-color: $primary-color;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
// Simple 类型介绍
|
||||
.simple-intro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 40px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.intro-icon {
|
||||
color: $primary-color;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.simple-intro p {
|
||||
font-size: 14px;
|
||||
color: $text-secondary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// 操作栏
|
||||
.action-bar {
|
||||
margin-top: 32px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 12px 32px;
|
||||
background-color: $primary-color;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
letter-spacing: 2px;
|
||||
|
||||
&:hover {
|
||||
background-color: #4a6a5a;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
15
frontend/src/main.ts
Normal file
15
frontend/src/main.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/styles/theme.scss'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
|
||||
app.mount('#app')
|
||||
47
frontend/src/router/index.ts
Normal file
47
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Design',
|
||||
component: () => import('@/views/DesignPage.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/generate',
|
||||
name: 'Generate',
|
||||
component: () => import('@/views/GeneratePage.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
name: 'UserCenter',
|
||||
component: () => import('@/views/UserCenter.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue')
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('@/views/Register.vue')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (to.meta.requiresAuth && !token) {
|
||||
next('/login')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
105
frontend/src/stores/category.ts
Normal file
105
frontend/src/stores/category.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { getCategoriesApi, getSubTypesApi, getColorsApi } from '@/api/category'
|
||||
import type { Category, SubType, ColorOption } from '@/api/category'
|
||||
|
||||
export type { Category, SubType, ColorOption }
|
||||
|
||||
export const useCategoryStore = defineStore('category', () => {
|
||||
// 品类列表
|
||||
const categories = ref<Category[]>([])
|
||||
// 当前选中的品类
|
||||
const currentCategory = ref<Category | null>(null)
|
||||
// 当前品类的子类型列表
|
||||
const subTypes = ref<SubType[]>([])
|
||||
// 当前选中的子类型
|
||||
const currentSubType = ref<SubType | null>(null)
|
||||
// 当前品类的颜色列表
|
||||
const colors = ref<ColorOption[]>([])
|
||||
// 当前选中的颜色
|
||||
const currentColor = ref<ColorOption | null>(null)
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 获取品类列表
|
||||
async function fetchCategories() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getCategoriesApi()
|
||||
categories.value = data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 选中品类,根据 flow_type 加载子类型/颜色
|
||||
async function selectCategory(category: Category) {
|
||||
currentCategory.value = category
|
||||
// 重置子选择
|
||||
currentSubType.value = null
|
||||
currentColor.value = null
|
||||
subTypes.value = []
|
||||
colors.value = []
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
// 根据 flow_type 加载不同数据
|
||||
if (category.flow_type === 'full') {
|
||||
// full 流程:加载子类型 + 颜色(如有)
|
||||
const [subTypesData, colorsData] = await Promise.all([
|
||||
getSubTypesApi(category.id),
|
||||
getColorsApi(category.id)
|
||||
])
|
||||
subTypes.value = subTypesData
|
||||
colors.value = colorsData
|
||||
} else if (category.flow_type === 'size_color') {
|
||||
// 珠子:加载子类型(尺寸)和颜色
|
||||
const [subTypesData, colorsData] = await Promise.all([
|
||||
getSubTypesApi(category.id),
|
||||
getColorsApi(category.id)
|
||||
])
|
||||
subTypes.value = subTypesData
|
||||
colors.value = colorsData
|
||||
}
|
||||
// simple 类型不需要加载额外数据
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 选中子类型
|
||||
function selectSubType(subType: SubType) {
|
||||
currentSubType.value = subType
|
||||
// 重置颜色选择
|
||||
currentColor.value = null
|
||||
}
|
||||
|
||||
// 选中颜色
|
||||
function selectColor(color: ColorOption) {
|
||||
currentColor.value = color
|
||||
}
|
||||
|
||||
// 重置所有选择状态
|
||||
function resetSelection() {
|
||||
currentCategory.value = null
|
||||
currentSubType.value = null
|
||||
currentColor.value = null
|
||||
subTypes.value = []
|
||||
colors.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
categories,
|
||||
currentCategory,
|
||||
subTypes,
|
||||
currentSubType,
|
||||
colors,
|
||||
currentColor,
|
||||
loading,
|
||||
fetchCategories,
|
||||
selectCategory,
|
||||
selectSubType,
|
||||
selectColor,
|
||||
resetSelection
|
||||
}
|
||||
})
|
||||
94
frontend/src/stores/design.ts
Normal file
94
frontend/src/stores/design.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { getDesignsApi, getDesignApi, generateDesignApi, deleteDesignApi, type Design, type DesignListResponse, type GenerateDesignParams } from '@/api/design'
|
||||
|
||||
export type { Design } from '@/api/design'
|
||||
|
||||
export const useDesignStore = defineStore('design', () => {
|
||||
const designs = ref<Design[]>([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const loading = ref(false)
|
||||
|
||||
// 当前设计(用于生成页面)
|
||||
const currentDesign = ref<Design | null>(null)
|
||||
// 生成中状态
|
||||
const generating = ref(false)
|
||||
|
||||
// 获取设计历史列表
|
||||
const fetchDesigns = async (page: number = 1) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data: DesignListResponse = await getDesignsApi(page, pageSize.value)
|
||||
designs.value = data.items
|
||||
total.value = data.total
|
||||
currentPage.value = data.page
|
||||
return data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取单个设计详情
|
||||
const fetchDesign = async (id: number) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getDesignApi(id)
|
||||
currentDesign.value = data
|
||||
return data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 生成设计
|
||||
const generateDesign = async (params: GenerateDesignParams) => {
|
||||
generating.value = true
|
||||
try {
|
||||
const data = await generateDesignApi(params)
|
||||
currentDesign.value = data
|
||||
return data
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除设计
|
||||
const deleteDesign = async (id: number) => {
|
||||
await deleteDesignApi(id)
|
||||
// 删除成功后刷新列表
|
||||
await fetchDesigns(currentPage.value)
|
||||
}
|
||||
|
||||
// 清除当前设计
|
||||
const clearCurrentDesign = () => {
|
||||
currentDesign.value = null
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
const reset = () => {
|
||||
designs.value = []
|
||||
total.value = 0
|
||||
currentPage.value = 1
|
||||
loading.value = false
|
||||
currentDesign.value = null
|
||||
generating.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
designs,
|
||||
total,
|
||||
currentPage,
|
||||
pageSize,
|
||||
loading,
|
||||
currentDesign,
|
||||
generating,
|
||||
fetchDesigns,
|
||||
fetchDesign,
|
||||
generateDesign,
|
||||
deleteDesign,
|
||||
clearCurrentDesign,
|
||||
reset
|
||||
}
|
||||
})
|
||||
92
frontend/src/stores/user.ts
Normal file
92
frontend/src/stores/user.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { login as loginApi, register as registerApi, getCurrentUser } from '@/api/auth'
|
||||
|
||||
export interface UserInfo {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
phone?: string | null
|
||||
avatar?: string | null
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref<string | null>(localStorage.getItem('token'))
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
|
||||
// 是否已登录
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
|
||||
// 设置 token
|
||||
const setToken = (newToken: string) => {
|
||||
token.value = newToken
|
||||
localStorage.setItem('token', newToken)
|
||||
}
|
||||
|
||||
// 清除 token
|
||||
const clearToken = () => {
|
||||
token.value = null
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
const fetchUserInfo = async () => {
|
||||
try {
|
||||
const data = await getCurrentUser()
|
||||
userInfo.value = data
|
||||
return data
|
||||
} catch (error) {
|
||||
clearToken()
|
||||
userInfo.value = null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 登录
|
||||
const login = async (username: string, password: string) => {
|
||||
const data = await loginApi({ username, password })
|
||||
setToken(data.access_token)
|
||||
await fetchUserInfo()
|
||||
return data
|
||||
}
|
||||
|
||||
// 注册
|
||||
const register = async (username: string, password: string, nickname: string) => {
|
||||
await registerApi({ username, password, nickname })
|
||||
// 注册成功后自动登录
|
||||
await login(username, password)
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
const logout = () => {
|
||||
clearToken()
|
||||
userInfo.value = null
|
||||
}
|
||||
|
||||
// 初始化 - 从 localStorage 恢复状态
|
||||
const init = async () => {
|
||||
const savedToken = localStorage.getItem('token')
|
||||
if (savedToken) {
|
||||
token.value = savedToken
|
||||
try {
|
||||
await fetchUserInfo()
|
||||
} catch {
|
||||
// 如果获取用户信息失败,清除 token
|
||||
logout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
userInfo,
|
||||
isLoggedIn,
|
||||
setToken,
|
||||
fetchUserInfo,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
init
|
||||
}
|
||||
})
|
||||
296
frontend/src/style.css
Normal file
296
frontend/src/style.css
Normal file
@@ -0,0 +1,296 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
28
frontend/src/views/DesignPage.vue
Normal file
28
frontend/src/views/DesignPage.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="design-page">
|
||||
<CategoryNav />
|
||||
<SubTypePanel />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useCategoryStore } from '@/stores/category'
|
||||
import CategoryNav from '@/components/CategoryNav.vue'
|
||||
import SubTypePanel from '@/components/SubTypePanel.vue'
|
||||
|
||||
const categoryStore = useCategoryStore()
|
||||
|
||||
onMounted(() => {
|
||||
// 进入页面时自动加载品类列表
|
||||
categoryStore.fetchCategories()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.design-page {
|
||||
display: flex;
|
||||
height: calc(100vh - 60px);
|
||||
background-color: #FAF8F5;
|
||||
}
|
||||
</style>
|
||||
768
frontend/src/views/GeneratePage.vue
Normal file
768
frontend/src/views/GeneratePage.vue
Normal file
@@ -0,0 +1,768 @@
|
||||
<template>
|
||||
<div class="generate-page">
|
||||
<!-- 顶部信息栏 -->
|
||||
<header class="page-header">
|
||||
<button class="back-btn" @click="goBack">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
<span>返回</span>
|
||||
</button>
|
||||
<nav class="breadcrumb" v-if="categoryName">
|
||||
<span class="crumb">{{ categoryName }}</span>
|
||||
<template v-if="subTypeName">
|
||||
<el-icon class="separator"><ArrowRight /></el-icon>
|
||||
<span class="crumb">{{ subTypeName }}</span>
|
||||
</template>
|
||||
<template v-if="colorName">
|
||||
<el-icon class="separator"><ArrowRight /></el-icon>
|
||||
<span class="crumb color">{{ colorName }}</span>
|
||||
</template>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- 缺少参数错误提示 -->
|
||||
<div v-if="!categoryId" class="error-state">
|
||||
<div class="error-icon">
|
||||
<el-icon><WarningFilled /></el-icon>
|
||||
</div>
|
||||
<h3>缺少必要参数</h3>
|
||||
<p>请先从设计页选择品类和类型</p>
|
||||
<button class="btn-primary" @click="goBack">返回设计页</button>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main v-else class="main-content">
|
||||
<!-- 生成中遮罩 -->
|
||||
<div v-if="generating" class="loading-overlay">
|
||||
<div class="loading-content">
|
||||
<div class="ink-loader">
|
||||
<div class="ink-drop"></div>
|
||||
<div class="ink-drop"></div>
|
||||
<div class="ink-drop"></div>
|
||||
</div>
|
||||
<p class="loading-text">设计生成中,请稍候...</p>
|
||||
<p class="loading-hint">正在将您的创意转化为玉雕设计</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设计输入区 -->
|
||||
<section v-if="!currentDesign" class="input-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">设计参数</h2>
|
||||
<p class="section-desc">选择参数辅助生成(全部选填),也可以只写描述</p>
|
||||
</div>
|
||||
|
||||
<!-- 参数面板 -->
|
||||
<div class="params-panel">
|
||||
<!-- 雕刻工艺 -->
|
||||
<div class="param-group">
|
||||
<label class="param-label">雕刻工艺</label>
|
||||
<div class="tag-list">
|
||||
<span
|
||||
v-for="opt in carvingOptions"
|
||||
:key="opt"
|
||||
class="tag-item"
|
||||
:class="{ active: carvingTechnique === opt }"
|
||||
@click="carvingTechnique = carvingTechnique === opt ? '' : opt"
|
||||
>{{ opt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设计风格 -->
|
||||
<div class="param-group">
|
||||
<label class="param-label">设计风格</label>
|
||||
<div class="tag-list">
|
||||
<span
|
||||
v-for="opt in styleOptions"
|
||||
:key="opt"
|
||||
class="tag-item"
|
||||
:class="{ active: designStyle === opt }"
|
||||
@click="designStyle = designStyle === opt ? '' : opt"
|
||||
>{{ opt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 题材纹样 -->
|
||||
<div class="param-group">
|
||||
<label class="param-label">题材纹样</label>
|
||||
<div class="tag-list">
|
||||
<span
|
||||
v-for="opt in motifOptions"
|
||||
:key="opt"
|
||||
class="tag-item"
|
||||
:class="{ active: motif === opt }"
|
||||
@click="motif = motif === opt ? '' : opt"
|
||||
>{{ opt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 尺寸规格 -->
|
||||
<div class="param-group">
|
||||
<label class="param-label">尺寸规格</label>
|
||||
<div class="tag-list">
|
||||
<span
|
||||
v-for="opt in sizeOptions"
|
||||
:key="opt"
|
||||
class="tag-item"
|
||||
:class="{ active: sizeSpec === opt }"
|
||||
@click="sizeSpec = sizeSpec === opt ? '' : opt"
|
||||
>{{ opt }}</span>
|
||||
<el-input
|
||||
v-model="customSize"
|
||||
placeholder="自定义尺寸"
|
||||
size="small"
|
||||
class="custom-size-input"
|
||||
@focus="sizeSpec = ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表面处理 -->
|
||||
<div class="param-group">
|
||||
<label class="param-label">表面处理</label>
|
||||
<div class="tag-list">
|
||||
<span
|
||||
v-for="opt in finishOptions"
|
||||
:key="opt"
|
||||
class="tag-item"
|
||||
:class="{ active: surfaceFinish === opt }"
|
||||
@click="surfaceFinish = surfaceFinish === opt ? '' : opt"
|
||||
>{{ opt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用途场景 -->
|
||||
<div class="param-group">
|
||||
<label class="param-label">用途场景</label>
|
||||
<div class="tag-list">
|
||||
<span
|
||||
v-for="opt in sceneOptions"
|
||||
:key="opt"
|
||||
class="tag-item"
|
||||
:class="{ active: usageScene === opt }"
|
||||
@click="usageScene = usageScene === opt ? '' : opt"
|
||||
>{{ opt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设计描述 -->
|
||||
<div class="prompt-header">
|
||||
<h3 class="prompt-title">设计描述</h3>
|
||||
</div>
|
||||
<div class="input-area">
|
||||
<el-input
|
||||
v-model="prompt"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="promptPlaceholder"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
resize="none"
|
||||
class="prompt-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="generate-action">
|
||||
<button
|
||||
class="btn-generate"
|
||||
:disabled="!prompt.trim() || generating"
|
||||
@click="handleGenerate"
|
||||
>
|
||||
<el-icon v-if="!generating"><MagicStick /></el-icon>
|
||||
<span>{{ generating ? '生成中...' : '生成设计' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 设计预览区 -->
|
||||
<section v-else class="preview-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">设计预览</h2>
|
||||
<p class="section-desc">您的设计已生成完成</p>
|
||||
</div>
|
||||
|
||||
<DesignPreview :design="currentDesign" />
|
||||
|
||||
<div class="regenerate-action">
|
||||
<button class="btn-regenerate" @click="handleRegenerate">
|
||||
<el-icon><RefreshRight /></el-icon>
|
||||
<span>重新生成</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, ArrowRight, WarningFilled, MagicStick, RefreshRight } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useDesignStore } from '@/stores/design'
|
||||
import { useCategoryStore } from '@/stores/category'
|
||||
import DesignPreview from '@/components/DesignPreview.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const designStore = useDesignStore()
|
||||
const categoryStore = useCategoryStore()
|
||||
|
||||
// URL参数
|
||||
const categoryId = computed(() => {
|
||||
const id = route.query.categoryId
|
||||
return id ? Number(id) : null
|
||||
})
|
||||
const subTypeId = computed(() => {
|
||||
const id = route.query.subTypeId
|
||||
return id ? Number(id) : null
|
||||
})
|
||||
const colorId = computed(() => {
|
||||
const id = route.query.colorId
|
||||
return id ? Number(id) : null
|
||||
})
|
||||
|
||||
// 名称(从store缓存获取)
|
||||
const categoryName = computed(() => {
|
||||
if (!categoryId.value) return ''
|
||||
// 优先从 currentCategory 获取
|
||||
if (categoryStore.currentCategory?.id === categoryId.value) {
|
||||
return categoryStore.currentCategory.name
|
||||
}
|
||||
// 否则从列表中查找
|
||||
const cat = categoryStore.categories.find(c => c.id === categoryId.value)
|
||||
return cat?.name || '设计'
|
||||
})
|
||||
|
||||
const subTypeName = computed(() => {
|
||||
if (!subTypeId.value) return ''
|
||||
// 从 store 的 subTypes 中查找
|
||||
const st = categoryStore.subTypes.find(s => s.id === subTypeId.value)
|
||||
return st?.name || ''
|
||||
})
|
||||
|
||||
const colorName = computed(() => {
|
||||
if (!colorId.value) return ''
|
||||
// 从 store 的 colors 中查找
|
||||
const c = categoryStore.colors.find(col => col.id === colorId.value)
|
||||
return c?.name || ''
|
||||
})
|
||||
|
||||
// 设计相关状态
|
||||
const prompt = ref('')
|
||||
const generating = computed(() => designStore.generating)
|
||||
const currentDesign = computed(() => designStore.currentDesign)
|
||||
|
||||
// 新增参数状态
|
||||
const carvingTechnique = ref('')
|
||||
const designStyle = ref('')
|
||||
const motif = ref('')
|
||||
const sizeSpec = ref('')
|
||||
const customSize = ref('')
|
||||
const surfaceFinish = ref('')
|
||||
const usageScene = ref('')
|
||||
|
||||
// 静态选项
|
||||
const carvingOptions = ['浮雕', '圆雕', '镂空雕', '阴刻', '线雕', '俏色雕', '薄意雕', '素面']
|
||||
const styleOptions = ['古典传统', '新中式', '写实', '抽象意境', '极简素面']
|
||||
const motifOptions = ['观音', '弥勒', '莲花', '貔貅', '龙凤', '麒麟', '山水', '花鸟', '人物', '回纹', '如意', '平安扣']
|
||||
const finishOptions = ['高光抛光', '亚光/哑光', '磨砂', '保留皮色']
|
||||
const sceneOptions = ['日常佩戴', '收藏鉴赏', '送礼婚庆', '把玩文玩']
|
||||
|
||||
// 尺寸规格按品类动态变化
|
||||
const sizeOptions = computed(() => {
|
||||
const name = categoryName.value
|
||||
if (name.includes('牌')) return ['60x40x12mm', '70x45x14mm', '50x35x10mm']
|
||||
if (name.includes('手镯')) return ['内径54mm', '内径56mm', '内径58mm', '内径60mm', '内径62mm']
|
||||
if (name.includes('手把件')) return ['小(约60mm)', '中(约80mm)', '大(约100mm)']
|
||||
if (name.includes('摆件')) return ['小(约8cm)', '中(约15cm)', '大(约25cm)']
|
||||
if (name.includes('戒')) return ['戒面7号', '戒鞍12号', '戒鞍15号', '戒鞍18号']
|
||||
if (name.includes('表带')) return ['宽18mm', '宽20mm', '宽22mm']
|
||||
return ['小', '中', '大']
|
||||
})
|
||||
|
||||
// placeholder 根据品类动态变化
|
||||
const promptPlaceholder = computed(() => {
|
||||
const name = categoryName.value.toLowerCase()
|
||||
if (name.includes('牌') || name.includes('牌子')) {
|
||||
return '请描述您想要的设计,如:山水意境、貔貅纹、雕刻荷花...'
|
||||
}
|
||||
if (name.includes('珠') || name.includes('珠子')) {
|
||||
return '请描述您想要的图案,如:回纹、云纹、简单素面...'
|
||||
}
|
||||
return '请描述您的设计需求...'
|
||||
})
|
||||
|
||||
// 返回设计页
|
||||
const goBack = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
// 生成设计
|
||||
const handleGenerate = async () => {
|
||||
if (!categoryId.value) {
|
||||
ElMessage.error('缺少品类参数')
|
||||
return
|
||||
}
|
||||
if (!prompt.value.trim()) {
|
||||
ElMessage.warning('请输入设计描述')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await designStore.generateDesign({
|
||||
category_id: categoryId.value,
|
||||
sub_type_id: subTypeId.value || undefined,
|
||||
color_id: colorId.value || undefined,
|
||||
prompt: prompt.value.trim(),
|
||||
carving_technique: carvingTechnique.value || undefined,
|
||||
design_style: designStyle.value || undefined,
|
||||
motif: motif.value || undefined,
|
||||
size_spec: sizeSpec.value || customSize.value || undefined,
|
||||
surface_finish: surfaceFinish.value || undefined,
|
||||
usage_scene: usageScene.value || undefined,
|
||||
})
|
||||
ElMessage.success('设计生成成功!')
|
||||
} catch (error) {
|
||||
// 错误已在 request 拦截器中处理
|
||||
}
|
||||
}
|
||||
|
||||
// 重新生成
|
||||
const handleRegenerate = () => {
|
||||
designStore.clearCurrentDesign()
|
||||
// 保留之前的 prompt,用户可以修改后重新生成
|
||||
}
|
||||
|
||||
// 页面挂载时,确保有品类数据
|
||||
onMounted(async () => {
|
||||
// 清除之前的设计状态
|
||||
designStore.clearCurrentDesign()
|
||||
|
||||
// 如果 store 中没有品类数据,尝试加载
|
||||
if (categoryStore.categories.length === 0) {
|
||||
await categoryStore.fetchCategories()
|
||||
}
|
||||
})
|
||||
|
||||
// 离开页面时清理状态
|
||||
onUnmounted(() => {
|
||||
designStore.clearCurrentDesign()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$primary-color: #5B7E6B;
|
||||
$primary-light: #8BAF9C;
|
||||
$primary-dark: #3D5A4A;
|
||||
$secondary-color: #C4A86C;
|
||||
$bg-color: #FAF8F5;
|
||||
$bg-dark: #F0EDE8;
|
||||
$border-color: #E8E4DF;
|
||||
$text-primary: #2C2C2C;
|
||||
$text-secondary: #6B6B6B;
|
||||
$text-light: #999999;
|
||||
|
||||
.generate-page {
|
||||
min-height: calc(100vh - 60px);
|
||||
background-color: $bg-color;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
// 顶部信息栏
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 20px 40px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 6px;
|
||||
color: $text-secondary;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: $primary-color;
|
||||
color: $primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.crumb {
|
||||
color: $text-primary;
|
||||
font-weight: 500;
|
||||
|
||||
&.color {
|
||||
color: $secondary-color;
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
font-size: 12px;
|
||||
color: $text-light;
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 64px;
|
||||
color: $secondary-color;
|
||||
}
|
||||
|
||||
.error-state h3 {
|
||||
font-size: 20px;
|
||||
color: $text-primary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error-state p {
|
||||
font-size: 14px;
|
||||
color: $text-secondary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// 主内容区
|
||||
.main-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// 输入区
|
||||
.input-section,
|
||||
.preview-section {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: $text-primary;
|
||||
margin: 0 0 8px 0;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: 14px;
|
||||
color: $text-secondary;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
// 参数面板
|
||||
.params-panel {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 24px 28px;
|
||||
margin-bottom: 28px;
|
||||
border: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.param-group {
|
||||
margin-bottom: 18px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.param-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: $text-secondary;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tag-item {
|
||||
display: inline-block;
|
||||
padding: 6px 14px;
|
||||
background: $bg-color;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 16px;
|
||||
font-size: 13px;
|
||||
color: $text-secondary;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
border-color: $primary-light;
|
||||
color: $primary-color;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: $primary-color;
|
||||
border-color: $primary-color;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-size-input {
|
||||
width: 130px;
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
border-radius: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.prompt-header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.prompt-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: $text-primary;
|
||||
margin: 0;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.prompt-input {
|
||||
:deep(.el-textarea__inner) {
|
||||
background: #fff;
|
||||
border: 2px solid $border-color;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
font-size: 16px;
|
||||
line-height: 1.8;
|
||||
color: $text-primary;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&::placeholder {
|
||||
color: $text-light;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: $primary-color;
|
||||
box-shadow: 0 0 0 4px rgba($primary-color, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-input__count) {
|
||||
background: transparent;
|
||||
color: $text-light;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.generate-action {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-generate {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 48px;
|
||||
background: linear-gradient(135deg, $primary-color, $primary-dark);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
letter-spacing: 2px;
|
||||
|
||||
.el-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba($primary-color, 0.4);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
// 主按钮
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 32px;
|
||||
background: $primary-color;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
background: $primary-dark;
|
||||
}
|
||||
}
|
||||
|
||||
// 重新生成
|
||||
.regenerate-action {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 32px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid $border-color;
|
||||
}
|
||||
|
||||
.btn-regenerate {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 28px;
|
||||
background: #fff;
|
||||
color: $text-secondary;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: $primary-color;
|
||||
color: $primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载遮罩
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.loading-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
// 水墨风格加载动画
|
||||
.ink-loader {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ink-drop {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: $primary-color;
|
||||
border-radius: 50%;
|
||||
animation: ink-spread 1.4s ease-in-out infinite;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ink-spread {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.5);
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 18px;
|
||||
color: $text-primary;
|
||||
letter-spacing: 2px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.loading-hint {
|
||||
font-size: 14px;
|
||||
color: $text-light;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// 响应式
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
padding: 16px 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.btn-generate {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 14px 32px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
199
frontend/src/views/Login.vue
Normal file
199
frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<!-- 品牌区域 -->
|
||||
<div class="brand-section">
|
||||
<h1 class="brand-name">玉宗</h1>
|
||||
<p class="brand-subtitle">珠宝设计大师</p>
|
||||
</div>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
class="login-form"
|
||||
@submit.prevent="handleLogin"
|
||||
>
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="请输入用户名"
|
||||
size="large"
|
||||
:prefix-icon="User"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="login-button"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 底部链接 -->
|
||||
<div class="footer-links">
|
||||
<span class="text">没有账号?</span>
|
||||
<router-link to="/register" class="link">立即注册</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { User, Lock } from '@element-plus/icons-vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.login(form.username, form.password)
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/')
|
||||
} catch (error: any) {
|
||||
// 错误已在拦截器中处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.login-page {
|
||||
min-height: calc(100vh - 108px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #FAF8F5;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 48px 40px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.brand-section {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
|
||||
.brand-name {
|
||||
font-size: 36px;
|
||||
font-weight: 600;
|
||||
color: #5B7E6B;
|
||||
letter-spacing: 8px;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
letter-spacing: 4px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.login-form {
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 8px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
:deep(.el-input__prefix) {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
background-color: #5B7E6B;
|
||||
border-color: #5B7E6B;
|
||||
letter-spacing: 2px;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: #3D5A4A;
|
||||
border-color: #3D5A4A;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
font-size: 14px;
|
||||
|
||||
.text {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #5B7E6B;
|
||||
text-decoration: none;
|
||||
margin-left: 4px;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
color: #3D5A4A;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
238
frontend/src/views/Register.vue
Normal file
238
frontend/src/views/Register.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div class="register-page">
|
||||
<div class="register-card">
|
||||
<!-- 品牌区域 -->
|
||||
<div class="brand-section">
|
||||
<h1 class="brand-name">玉宗</h1>
|
||||
<p class="brand-subtitle">创建您的账户</p>
|
||||
</div>
|
||||
|
||||
<!-- 注册表单 -->
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
class="register-form"
|
||||
@submit.prevent="handleRegister"
|
||||
>
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="请输入用户名"
|
||||
size="large"
|
||||
:prefix-icon="User"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="nickname">
|
||||
<el-input
|
||||
v-model="form.nickname"
|
||||
placeholder="请输入昵称"
|
||||
size="large"
|
||||
:prefix-icon="UserFilled"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码(至少6位)"
|
||||
size="large"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
placeholder="请再次输入密码"
|
||||
size="large"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
@keyup.enter="handleRegister"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="register-button"
|
||||
:loading="loading"
|
||||
@click="handleRegister"
|
||||
>
|
||||
注册
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 底部链接 -->
|
||||
<div class="footer-links">
|
||||
<span class="text">已有账号?</span>
|
||||
<router-link to="/login" class="link">返回登录</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { User, UserFilled, Lock } from '@element-plus/icons-vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
nickname: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
// 确认密码验证器
|
||||
const validateConfirmPassword = (_rule: any, value: string, callback: any) => {
|
||||
if (value !== form.password) {
|
||||
callback(new Error('两次输入的密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' }
|
||||
],
|
||||
nickname: [
|
||||
{ required: true, message: '请输入昵称', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度至少为6位', trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请再次输入密码', trigger: 'blur' },
|
||||
{ validator: validateConfirmPassword, trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.register(form.username, form.password, form.nickname)
|
||||
ElMessage.success('注册成功')
|
||||
router.push('/')
|
||||
} catch (error: any) {
|
||||
// 错误已在拦截器中处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.register-page {
|
||||
min-height: calc(100vh - 108px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #FAF8F5;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.register-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 48px 40px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.brand-section {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
|
||||
.brand-name {
|
||||
font-size: 36px;
|
||||
font-weight: 600;
|
||||
color: #5B7E6B;
|
||||
letter-spacing: 8px;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
letter-spacing: 2px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.register-form {
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 8px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
:deep(.el-input__prefix) {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.register-button {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
background-color: #5B7E6B;
|
||||
border-color: #5B7E6B;
|
||||
letter-spacing: 2px;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: #3D5A4A;
|
||||
border-color: #3D5A4A;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
font-size: 14px;
|
||||
|
||||
.text {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #5B7E6B;
|
||||
text-decoration: none;
|
||||
margin-left: 4px;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
color: #3D5A4A;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
657
frontend/src/views/UserCenter.vue
Normal file
657
frontend/src/views/UserCenter.vue
Normal file
@@ -0,0 +1,657 @@
|
||||
<template>
|
||||
<div class="user-center">
|
||||
<div class="user-center-container">
|
||||
<h1 class="page-title">个人中心</h1>
|
||||
|
||||
<el-tabs v-model="activeTab" class="user-tabs">
|
||||
<!-- Tab 1: 设计历史 -->
|
||||
<el-tab-pane label="设计历史" name="designs">
|
||||
<div v-if="designStore.loading" class="loading-state">
|
||||
<el-icon class="loading-icon"><Loading /></el-icon>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 空状态 -->
|
||||
<div v-if="designStore.designs.length === 0" class="empty-state">
|
||||
<div class="empty-icon">📐</div>
|
||||
<p class="empty-text">暂无设计作品,去创作第一个设计吧</p>
|
||||
<el-button type="primary" class="create-btn" @click="goToGenerate">
|
||||
开始设计
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 设计卡片网格 -->
|
||||
<div v-else class="design-grid">
|
||||
<div
|
||||
v-for="design in designStore.designs"
|
||||
:key="design.id"
|
||||
class="design-card"
|
||||
@click="handleCardClick(design)"
|
||||
>
|
||||
<div class="card-image">
|
||||
<img
|
||||
v-if="design.image_url"
|
||||
:src="design.image_url"
|
||||
:alt="design.prompt"
|
||||
/>
|
||||
<div v-else class="no-image">
|
||||
<span>暂无图片</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="card-category">
|
||||
{{ design.category?.name || '未分类' }}
|
||||
<span v-if="design.sub_type">· {{ design.sub_type.name }}</span>
|
||||
</div>
|
||||
<p class="card-prompt" :title="design.prompt">{{ design.prompt }}</p>
|
||||
<div class="card-footer">
|
||||
<span class="card-time">{{ formatTime(design.created_at) }}</span>
|
||||
<div class="card-actions" @click.stop>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
text
|
||||
@click="handleDownload(design)"
|
||||
>
|
||||
下载
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
text
|
||||
@click="handleDelete(design)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="designStore.total > designStore.pageSize" class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="designStore.pageSize"
|
||||
:total="designStore.total"
|
||||
layout="prev, pager, next"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 2: 个人信息 -->
|
||||
<el-tab-pane label="个人信息" name="profile">
|
||||
<div class="profile-section">
|
||||
<h3 class="section-title">基本信息</h3>
|
||||
<el-form
|
||||
ref="profileFormRef"
|
||||
:model="profileForm"
|
||||
:rules="profileRules"
|
||||
label-width="80px"
|
||||
class="profile-form"
|
||||
>
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="profileForm.username" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="昵称" prop="nickname">
|
||||
<el-input v-model="profileForm.nickname" placeholder="请输入昵称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input v-model="profileForm.phone" placeholder="请输入手机号" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="profileLoading"
|
||||
@click="handleSaveProfile"
|
||||
>
|
||||
保存修改
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="password-section">
|
||||
<h3 class="section-title">修改密码</h3>
|
||||
<el-form
|
||||
ref="passwordFormRef"
|
||||
:model="passwordForm"
|
||||
:rules="passwordRules"
|
||||
label-width="100px"
|
||||
class="password-form"
|
||||
>
|
||||
<el-form-item label="旧密码" prop="old_password">
|
||||
<el-input
|
||||
v-model="passwordForm.old_password"
|
||||
type="password"
|
||||
placeholder="请输入旧密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" prop="new_password">
|
||||
<el-input
|
||||
v-model="passwordForm.new_password"
|
||||
type="password"
|
||||
placeholder="请输入新密码(至少6位)"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirm_password">
|
||||
<el-input
|
||||
v-model="passwordForm.confirm_password"
|
||||
type="password"
|
||||
placeholder="请再次输入新密码"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="passwordLoading"
|
||||
@click="handleChangePassword"
|
||||
>
|
||||
修改密码
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useDesignStore, type Design } from '@/stores/design'
|
||||
import { updateProfileApi, changePasswordApi } from '@/api/auth'
|
||||
import { getDesignDownloadUrl } from '@/api/design'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const designStore = useDesignStore()
|
||||
|
||||
const activeTab = ref('designs')
|
||||
const currentPage = ref(1)
|
||||
|
||||
// Profile form
|
||||
const profileFormRef = ref<FormInstance>()
|
||||
const profileLoading = ref(false)
|
||||
const profileForm = reactive({
|
||||
username: '',
|
||||
nickname: '',
|
||||
phone: ''
|
||||
})
|
||||
|
||||
const profileRules: FormRules = {
|
||||
nickname: [
|
||||
{ max: 20, message: '昵称最多20个字符', trigger: 'blur' }
|
||||
],
|
||||
phone: [
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// Password form
|
||||
const passwordFormRef = ref<FormInstance>()
|
||||
const passwordLoading = ref(false)
|
||||
const passwordForm = reactive({
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
})
|
||||
|
||||
const validateConfirmPassword = (_rule: any, value: string, callback: any) => {
|
||||
if (value !== passwordForm.new_password) {
|
||||
callback(new Error('两次输入的密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const passwordRules: FormRules = {
|
||||
old_password: [
|
||||
{ required: true, message: '请输入旧密码', trigger: 'blur' }
|
||||
],
|
||||
new_password: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码至少6位', trigger: 'blur' }
|
||||
],
|
||||
confirm_password: [
|
||||
{ required: true, message: '请确认新密码', trigger: 'blur' },
|
||||
{ validator: validateConfirmPassword, trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// Initialize
|
||||
onMounted(async () => {
|
||||
// Load user info to form
|
||||
if (userStore.userInfo) {
|
||||
profileForm.username = userStore.userInfo.username || ''
|
||||
profileForm.nickname = userStore.userInfo.nickname || ''
|
||||
profileForm.phone = userStore.userInfo.phone || ''
|
||||
}
|
||||
// Load designs
|
||||
await designStore.fetchDesigns()
|
||||
})
|
||||
|
||||
// Watch user info changes
|
||||
watch(() => userStore.userInfo, (info) => {
|
||||
if (info) {
|
||||
profileForm.username = info.username || ''
|
||||
profileForm.nickname = info.nickname || ''
|
||||
profileForm.phone = info.phone || ''
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Format time
|
||||
const formatTime = (time: string) => {
|
||||
const date = new Date(time)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// Go to generate page
|
||||
const goToGenerate = () => {
|
||||
router.push('/generate')
|
||||
}
|
||||
|
||||
// Handle card click - go to generate page with design info
|
||||
const handleCardClick = (design: Design) => {
|
||||
router.push({
|
||||
path: '/generate',
|
||||
query: {
|
||||
design_id: design.id,
|
||||
category_id: design.category?.id,
|
||||
sub_type_id: design.sub_type?.id,
|
||||
color_id: design.color?.id,
|
||||
prompt: design.prompt
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Handle download
|
||||
const handleDownload = (design: Design) => {
|
||||
if (!design.image_url) {
|
||||
ElMessage.warning('暂无可下载的图片')
|
||||
return
|
||||
}
|
||||
const link = document.createElement('a')
|
||||
link.href = getDesignDownloadUrl(design.id)
|
||||
link.download = `design_${design.id}.png`
|
||||
link.target = '_blank'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = async (design: Design) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确定要删除这个设计吗?删除后无法恢复。',
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
await designStore.deleteDesign(design.id)
|
||||
ElMessage.success('删除成功')
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
// Error already handled by interceptor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle page change
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
designStore.fetchDesigns(page)
|
||||
}
|
||||
|
||||
// Save profile
|
||||
const handleSaveProfile = async () => {
|
||||
if (!profileFormRef.value) return
|
||||
|
||||
const valid = await profileFormRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
profileLoading.value = true
|
||||
try {
|
||||
const data = await updateProfileApi({
|
||||
nickname: profileForm.nickname,
|
||||
phone: profileForm.phone || undefined
|
||||
})
|
||||
// Update user store
|
||||
if (userStore.userInfo) {
|
||||
userStore.userInfo.nickname = data.nickname
|
||||
userStore.userInfo.phone = data.phone
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
} catch (error) {
|
||||
// Error handled by interceptor
|
||||
} finally {
|
||||
profileLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Change password
|
||||
const handleChangePassword = async () => {
|
||||
if (!passwordFormRef.value) return
|
||||
|
||||
const valid = await passwordFormRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
passwordLoading.value = true
|
||||
try {
|
||||
await changePasswordApi({
|
||||
old_password: passwordForm.old_password,
|
||||
new_password: passwordForm.new_password
|
||||
})
|
||||
ElMessage.success('密码修改成功')
|
||||
// Reset form
|
||||
passwordForm.old_password = ''
|
||||
passwordForm.new_password = ''
|
||||
passwordForm.confirm_password = ''
|
||||
passwordFormRef.value.resetFields()
|
||||
} catch (error) {
|
||||
// Error handled by interceptor
|
||||
} finally {
|
||||
passwordLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$primary-color: #5B7E6B;
|
||||
$primary-dark: #3D5A4A;
|
||||
$bg-color: #FAF8F5;
|
||||
$border-color: #E8E4DF;
|
||||
$text-primary: #2C2C2C;
|
||||
$text-secondary: #6B6B6B;
|
||||
$text-light: #999999;
|
||||
|
||||
.user-center {
|
||||
min-height: calc(100vh - 108px);
|
||||
background-color: $bg-color;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.user-center-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
margin: 0 0 24px 0;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.user-tabs {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||
|
||||
:deep(.el-tabs__nav-wrap::after) {
|
||||
height: 1px;
|
||||
background-color: $border-color;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__item) {
|
||||
font-size: 16px;
|
||||
color: $text-secondary;
|
||||
|
||||
&.is-active {
|
||||
color: $primary-color;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-tabs__active-bar) {
|
||||
background-color: $primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
// Loading state
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 0;
|
||||
color: $text-light;
|
||||
|
||||
.loading-icon {
|
||||
font-size: 32px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
// Empty state
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 0;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
color: $text-secondary;
|
||||
margin: 0 0 24px 0;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
background-color: $primary-color;
|
||||
border-color: $primary-color;
|
||||
padding: 12px 32px;
|
||||
font-size: 15px;
|
||||
|
||||
&:hover {
|
||||
background-color: $primary-dark;
|
||||
border-color: $primary-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Design grid
|
||||
.design-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.design-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
border: 1px solid $border-color;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.card-image {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-top: 75%; // 4:3 aspect ratio
|
||||
background-color: #f5f5f5;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.no-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $text-light;
|
||||
font-size: 14px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.card-category {
|
||||
font-size: 12px;
|
||||
color: $primary-color;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-prompt {
|
||||
font-size: 14px;
|
||||
color: $text-primary;
|
||||
margin: 0 0 12px 0;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-time {
|
||||
font-size: 12px;
|
||||
color: $text-light;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
:deep(.el-button) {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
:deep(.el-button--primary) {
|
||||
color: $primary-color;
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 32px;
|
||||
|
||||
:deep(.el-pagination) {
|
||||
--el-pagination-button-bg-color: transparent;
|
||||
--el-pagination-hover-color: #{$primary-color};
|
||||
}
|
||||
|
||||
:deep(.el-pager li.is-active) {
|
||||
color: $primary-color;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// Profile section
|
||||
.profile-section,
|
||||
.password-section {
|
||||
max-width: 480px;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.password-section {
|
||||
border-top: 1px solid $border-color;
|
||||
margin-top: 24px;
|
||||
padding-top: 32px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: $text-primary;
|
||||
margin: 0 0 24px 0;
|
||||
}
|
||||
|
||||
.profile-form,
|
||||
.password-form {
|
||||
:deep(.el-form-item__label) {
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
:deep(.el-input.is-disabled .el-input__wrapper) {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
:deep(.el-button--primary) {
|
||||
background-color: $primary-color;
|
||||
border-color: $primary-color;
|
||||
|
||||
&:hover {
|
||||
background-color: $primary-dark;
|
||||
border-color: $primary-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user