Files
YuShiSheJiShi/frontend/src/views/UserCenter.vue
4382feedb3 style(frontend): 优化前端样式和界面细节
- 统一并丰富主题色变量,新增多级浅色和圆角、阴影变量
- 调整应用头部布局及风格,增加logo子标题和用户头像显示
- 细化分类导航样式,添加品类图标和渐变背景
- 优化颜色选择器的交互动效和样式细节
- 美化设计预览组件,提升边框圆角和阴影效果
- 调整子类型面板布局、尺寸及交互动画效果
- 修正全局样式中字体和滚动条的表现,增强用户体验
- 统一按钮、标签等控件的圆角和颜色渐变样式
- 增强Element Plus UI组件的主题覆盖和交互状态样式
2026-03-29 15:55:27 +08:00

685 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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'
import request from '@/api/request'
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 = async (design: Design) => {
if (!design.image_url) {
ElMessage.warning('暂无可下载的图片')
return
}
// 远程 URL 直接新窗口打开
const imgUrl = design.image_url
if (imgUrl.startsWith('http')) {
window.open(imgUrl, '_blank')
return
}
// 本地文件通过 axios 携带 Token 下载
try {
const response = await request.get(getDesignDownloadUrl(design.id), {
responseType: 'blob'
}) as any
const blob = new Blob([response], { type: 'image/png' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `design_${design.id}.png`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
} catch {
ElMessage.error('下载失败,请重试')
}
}
// 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;
$primary-lighter: #D4E5DB;
$bg-color: #FAF8F5;
$border-color: #E8E4DF;
$border-light: #F0EDE8;
$text-primary: #2C2C2C;
$text-secondary: #6B6B6B;
$text-light: #999999;
.user-center {
min-height: calc(100vh - 64px);
background-color: $bg-color;
padding: 32px 24px;
}
.user-center-container {
max-width: 1200px;
margin: 0 auto;
}
.page-title {
font-size: 26px;
font-weight: 700;
color: $text-primary;
margin: 0 0 24px 0;
letter-spacing: 2px;
}
.user-tabs {
background: #fff;
border-radius: 16px;
padding: 24px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.03);
:deep(.el-tabs__nav-wrap::after) {
height: 1px;
background-color: $border-light;
}
:deep(.el-tabs__item) {
font-size: 15px;
color: $text-secondary;
&.is-active {
color: $primary-color;
font-weight: 600;
}
&:hover {
color: $primary-color;
}
}
:deep(.el-tabs__active-bar) {
background-color: $primary-color;
border-radius: 2px;
}
}
// 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: 15px;
color: $text-secondary;
margin: 0 0 24px 0;
}
.create-btn {
background: linear-gradient(135deg, $primary-color, $primary-dark);
border-color: $primary-color;
padding: 12px 32px;
font-size: 15px;
border-radius: 12px;
&:hover {
background: linear-gradient(135deg, $primary-dark, #2d4638);
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: 14px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
cursor: pointer;
transition: all 0.25s ease;
border: 1px solid $border-light;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.08);
}
}
.card-image {
position: relative;
width: 100%;
padding-top: 75%;
background-color: $bg-color;
overflow: hidden;
img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.design-card:hover & img {
transform: scale(1.03);
}
.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: $bg-color;
}
}
.card-content {
padding: 16px;
}
.card-category {
font-size: 12px;
color: $primary-color;
margin-bottom: 8px;
font-weight: 600;
}
.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: 600;
}
}
// Profile section
.profile-section,
.password-section {
max-width: 480px;
padding: 24px 0;
}
.password-section {
border-top: 1px solid $border-light;
margin-top: 24px;
padding-top: 32px;
}
.section-title {
font-size: 18px;
font-weight: 600;
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: 10px;
}
:deep(.el-input.is-disabled .el-input__wrapper) {
background-color: $bg-color;
}
:deep(.el-button--primary) {
background: linear-gradient(135deg, $primary-color, $primary-dark);
border-color: $primary-color;
border-radius: 10px;
&:hover {
background: linear-gradient(135deg, $primary-dark, #2d4638);
border-color: $primary-dark;
}
}
}
</style>