530 lines
13 KiB
Vue
530 lines
13 KiB
Vue
<template>
|
||
<div class="profile-page">
|
||
<div class="profile-shell">
|
||
<header class="profile-hero">
|
||
<div class="hero-text">
|
||
<p class="eyebrow">Profile · 自定义你的身份</p>
|
||
<h1 class="title">个人主页</h1>
|
||
<p class="desc">管理头像、用户名、密码与个人资料</p>
|
||
</div>
|
||
<button class="btn ghost" type="button" @click="goHome">返回首页</button>
|
||
</header>
|
||
|
||
<div class="cards">
|
||
<section class="card info-card">
|
||
<div class="card-head">
|
||
<div>
|
||
<h2>基本信息</h2>
|
||
<span class="muted">头像、用户名、简介</span>
|
||
</div>
|
||
<button class="pill-btn" type="button" @click="saveProfile" :disabled="savingProfile">
|
||
{{ savingProfile ? '保存中...' : '保存信息' }}
|
||
</button>
|
||
</div>
|
||
|
||
<div class="avatar-block">
|
||
<button class="avatar-preview" type="button" @click="triggerAvatar">
|
||
<img v-if="profileForm.image" :src="profileForm.image" alt="avatar" />
|
||
<div v-else class="avatar-placeholder">上传头像</div>
|
||
</button>
|
||
<div class="avatar-meta">
|
||
<div class="muted">点击头像上传,支持常见图片格式</div>
|
||
<button class="btn ghost" type="button" @click="triggerAvatar">选择文件</button>
|
||
<input ref="avatarInput" type="file" accept="image/*" hidden @change="onAvatarChange" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-grid">
|
||
<label class="field">
|
||
<span>用户名</span>
|
||
<input v-model.trim="profileForm.username" type="text" placeholder="输入用户名" />
|
||
</label>
|
||
<label class="field">
|
||
<span>邮箱</span>
|
||
<input v-model.trim="profileForm.email" type="email" placeholder="邮箱(可选)" />
|
||
</label>
|
||
<label class="field">
|
||
<span>手机号</span>
|
||
<input v-model.trim="profileForm.phone" type="tel" placeholder="输入手机号" />
|
||
</label>
|
||
<label class="field">
|
||
<span>身份类型</span>
|
||
<div class="chip-row">
|
||
<button
|
||
type="button"
|
||
class="chip-option"
|
||
:class="{ active: profileForm.userType === 'personal' }"
|
||
@click="profileForm.userType = 'personal'"
|
||
>
|
||
个人
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="chip-option"
|
||
:class="{ active: profileForm.userType === 'company' }"
|
||
@click="profileForm.userType = 'company'"
|
||
>
|
||
公司
|
||
</button>
|
||
</div>
|
||
</label>
|
||
<label v-if="profileForm.userType === 'company'" class="field">
|
||
<span>公司名称</span>
|
||
<input v-model.trim="profileForm.companyName" type="text" placeholder="请输入公司全称" />
|
||
</label>
|
||
</div>
|
||
<label class="field">
|
||
<span>个人简介</span>
|
||
<textarea v-model.trim="profileForm.bio" rows="3" placeholder="一句话介绍你自己" />
|
||
</label>
|
||
</section>
|
||
|
||
<section class="card password-card">
|
||
<header class="card-head collapse-head" @click="showPassword = !showPassword">
|
||
<div>
|
||
<h2>密码与安全</h2>
|
||
<span class="muted">设置新密码,至少 6 位</span>
|
||
</div>
|
||
<button class="pill-btn ghost" type="button">
|
||
{{ showPassword ? '收起' : '展开' }}
|
||
</button>
|
||
</header>
|
||
<transition name="fade">
|
||
<div v-if="showPassword" class="collapse-body">
|
||
<label class="field">
|
||
<span>新密码</span>
|
||
<input v-model="passwordForm.newPassword" type="password" placeholder="输入新密码" />
|
||
</label>
|
||
<label class="field">
|
||
<span>确认新密码</span>
|
||
<input v-model="passwordForm.confirmPassword" type="password" placeholder="再次输入新密码" />
|
||
</label>
|
||
<div class="card-actions">
|
||
<button class="btn primary" type="button" :disabled="savingPassword" @click="savePassword">
|
||
{{ savingPassword ? '提交中...' : '更新密码' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</transition>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { onMounted, reactive, ref } from 'vue'
|
||
import type { Ref } from 'vue'
|
||
import { useRouter, navigateTo } from '#app'
|
||
import { useApi, useAuthToken } from '@/composables/useApi'
|
||
import { useAuth } from '@/composables/useAuth'
|
||
import { useUpload } from '@/composables/useUpload'
|
||
|
||
const router = useRouter()
|
||
const api = useApi()
|
||
const { token } = useAuthToken()
|
||
|
||
type AuthUserProfile = {
|
||
username?: string | null
|
||
email?: string | null
|
||
bio?: string | null
|
||
image?: string | null
|
||
roles?: string[]
|
||
phone?: string | null
|
||
userType?: 'personal' | 'company' | null
|
||
companyName?: string | null
|
||
}
|
||
|
||
const { user: authUser, fetchMe } = useAuth() as unknown as {
|
||
user: Ref<AuthUserProfile | null>
|
||
fetchMe: () => Promise<any>
|
||
}
|
||
const { uploadImage } = useUpload()
|
||
|
||
interface ProfileForm {
|
||
username: string
|
||
email: string
|
||
bio: string
|
||
image: string
|
||
phone: string
|
||
userType: 'personal' | 'company'
|
||
companyName: string
|
||
}
|
||
|
||
const profileForm = reactive<ProfileForm>({
|
||
username: '',
|
||
email: '',
|
||
bio: '',
|
||
image: '',
|
||
phone: '',
|
||
userType: 'personal',
|
||
companyName: '',
|
||
})
|
||
|
||
const passwordForm = reactive({
|
||
newPassword: '',
|
||
confirmPassword: '',
|
||
})
|
||
|
||
const savingProfile = ref(false)
|
||
const savingPassword = ref(false)
|
||
const avatarInput = ref<HTMLInputElement | null>(null)
|
||
const showPassword = ref(false)
|
||
|
||
function goHome(): void {
|
||
router.push('/')
|
||
}
|
||
|
||
function ensureLoggedIn(): boolean {
|
||
if (!token.value) {
|
||
navigateTo('/login')
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
function fillFormFromUser(): void {
|
||
const u = authUser.value || null
|
||
profileForm.username = u?.username || ''
|
||
profileForm.email = u?.email || ''
|
||
profileForm.bio = u?.bio || ''
|
||
profileForm.image = u?.image || ''
|
||
profileForm.phone = u?.phone || ''
|
||
profileForm.userType = (u?.userType as 'personal' | 'company') || 'personal'
|
||
profileForm.companyName = u?.companyName || ''
|
||
}
|
||
|
||
async function init(): Promise<void> {
|
||
if (!ensureLoggedIn()) return
|
||
if (!authUser.value) {
|
||
try {
|
||
await fetchMe()
|
||
} catch (err) {
|
||
console.warn('[Profile] fetchMe failed', err)
|
||
}
|
||
}
|
||
fillFormFromUser()
|
||
}
|
||
|
||
function triggerAvatar(): void {
|
||
avatarInput.value?.click()
|
||
}
|
||
|
||
async function onAvatarChange(event: Event): Promise<void> {
|
||
const target = event.target as HTMLInputElement
|
||
const file = target.files?.[0]
|
||
target.value = ''
|
||
if (!file) return
|
||
try {
|
||
const url = await uploadImage(file)
|
||
profileForm.image = url
|
||
} catch (err: any) {
|
||
alert(err?.message || '上传失败')
|
||
}
|
||
}
|
||
|
||
async function saveProfile(): Promise<void> {
|
||
if (!ensureLoggedIn()) return
|
||
if (!profileForm.username.trim()) {
|
||
alert('用户名不能为空')
|
||
return
|
||
}
|
||
savingProfile.value = true
|
||
try {
|
||
await api.put('/user', {
|
||
user: {
|
||
username: profileForm.username.trim(),
|
||
email: profileForm.email.trim() || undefined,
|
||
bio: profileForm.bio.trim(),
|
||
image: profileForm.image || undefined,
|
||
phone: profileForm.phone.trim() || undefined,
|
||
userType: profileForm.userType,
|
||
companyName: profileForm.userType === 'company' ? profileForm.companyName.trim() || undefined : undefined,
|
||
},
|
||
})
|
||
await fetchMe()
|
||
alert('资料已保存')
|
||
} catch (err: any) {
|
||
console.error('[Profile] save profile failed', err)
|
||
alert(err?.statusMessage || err?.message || '保存失败')
|
||
} finally {
|
||
savingProfile.value = false
|
||
}
|
||
}
|
||
|
||
async function savePassword(): Promise<void> {
|
||
if (!ensureLoggedIn()) return
|
||
if (!passwordForm.newPassword || passwordForm.newPassword.length < 6) {
|
||
alert('新密码至少 6 位')
|
||
return
|
||
}
|
||
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
||
alert('两次输入的密码不一致')
|
||
return
|
||
}
|
||
savingPassword.value = true
|
||
try {
|
||
await api.put('/user', {
|
||
user: {
|
||
password: passwordForm.newPassword,
|
||
},
|
||
})
|
||
passwordForm.newPassword = ''
|
||
passwordForm.confirmPassword = ''
|
||
alert('密码已更新')
|
||
} catch (err: any) {
|
||
console.error('[Profile] update password failed', err)
|
||
alert(err?.statusMessage || err?.message || '修改密码失败')
|
||
} finally {
|
||
savingPassword.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(init)
|
||
</script>
|
||
|
||
<style scoped>
|
||
.profile-page {
|
||
margin-top: 5%;
|
||
min-height: 100vh;
|
||
background: var(--soft-page-bg);
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: 32px 16px 48px;
|
||
}
|
||
.profile-shell {
|
||
width: min(1100px, 96vw);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
.profile-hero {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
background: linear-gradient(120deg, #f5f7ff, #ecfdf3);
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 20px;
|
||
padding: 18px 20px;
|
||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
|
||
}
|
||
.hero-text .eyebrow {
|
||
margin: 0;
|
||
color: #6b7280;
|
||
font-size: 12px;
|
||
letter-spacing: 0.4px;
|
||
text-transform: uppercase;
|
||
}
|
||
.title {
|
||
margin: 0;
|
||
font-size: 26px;
|
||
font-weight: 800;
|
||
}
|
||
.desc {
|
||
margin: 4px 0 0;
|
||
color: #6b7280;
|
||
}
|
||
.cards {
|
||
display: grid;
|
||
grid-template-columns: 1.6fr 1fr;
|
||
gap: 18px;
|
||
}
|
||
.card {
|
||
background: #fff;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 16px;
|
||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
|
||
padding: 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
.info-card {
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
.info-card::after {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
background: radial-gradient(circle at 20% 0%, rgba(99, 102, 241, 0.08), transparent 35%),
|
||
radial-gradient(circle at 90% 20%, rgba(14, 165, 233, 0.08), transparent 30%);
|
||
pointer-events: none;
|
||
}
|
||
.info-card > * {
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
.card-head {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 10px;
|
||
justify-content: space-between;
|
||
}
|
||
.card-head h2 {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
font-weight: 800;
|
||
}
|
||
.muted {
|
||
color: #9ca3af;
|
||
font-size: 13px;
|
||
}
|
||
.avatar-block {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
}
|
||
.avatar-preview {
|
||
width: 104px;
|
||
height: 104px;
|
||
border-radius: 16px;
|
||
border: 1px solid #e5e7eb;
|
||
overflow: hidden;
|
||
display: grid;
|
||
place-items: center;
|
||
background: #f8fafc;
|
||
cursor: pointer;
|
||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||
}
|
||
.avatar-preview:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.15);
|
||
}
|
||
.avatar-preview img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
.avatar-placeholder {
|
||
color: #9ca3af;
|
||
font-size: 14px;
|
||
}
|
||
.avatar-meta {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.form-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||
gap: 12px;
|
||
}
|
||
.field {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
font-size: 14px;
|
||
color: #4b5563;
|
||
}
|
||
.field input,
|
||
.field textarea {
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 12px;
|
||
padding: 10px 12px;
|
||
font-size: 14px;
|
||
outline: none;
|
||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||
}
|
||
.field input:focus,
|
||
.field textarea:focus {
|
||
border-color: #6366f1;
|
||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
|
||
}
|
||
.chip-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
.chip-option {
|
||
flex: 1;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 12px;
|
||
padding: 10px 12px;
|
||
background: #f9fafb;
|
||
cursor: pointer;
|
||
transition: all 0.15s ease;
|
||
}
|
||
.chip-option.active {
|
||
background: #111827;
|
||
color: #fff;
|
||
border-color: #111827;
|
||
box-shadow: 0 6px 16px rgba(17, 24, 39, 0.14);
|
||
}
|
||
.card-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
.btn {
|
||
padding: 10px 14px;
|
||
border-radius: 12px;
|
||
border: 1px solid #d1d5db;
|
||
background: #fff;
|
||
cursor: pointer;
|
||
transition: all 0.18s ease;
|
||
font-weight: 600;
|
||
}
|
||
.btn:hover {
|
||
background: #f3f4f6;
|
||
}
|
||
.btn.primary {
|
||
background: #111827;
|
||
color: #fff;
|
||
border-color: #111827;
|
||
}
|
||
.btn.primary:hover {
|
||
background: #0b1324;
|
||
}
|
||
.btn.ghost {
|
||
background: #fff;
|
||
color: #111827;
|
||
}
|
||
.pill-btn {
|
||
border: 1px solid #e5e7eb;
|
||
background: #f9fafb;
|
||
border-radius: 999px;
|
||
padding: 8px 14px;
|
||
cursor: pointer;
|
||
font-weight: 700;
|
||
color: #111827;
|
||
transition: all 0.18s ease;
|
||
}
|
||
.pill-btn:hover {
|
||
background: #111827;
|
||
color: #fff;
|
||
border-color: #111827;
|
||
}
|
||
.pill-btn.ghost {
|
||
background: #fff;
|
||
}
|
||
.collapse-head {
|
||
cursor: pointer;
|
||
align-items: center;
|
||
}
|
||
.collapse-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
.password-card {
|
||
align-self: start;
|
||
}
|
||
.fade-enter-active,
|
||
.fade-leave-active {
|
||
transition: opacity 0.18s ease;
|
||
}
|
||
.fade-enter-from,
|
||
.fade-leave-to {
|
||
opacity: 0;
|
||
}
|
||
@media (max-width: 960px) {
|
||
.cards {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.profile-hero {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
}
|
||
}
|
||
</style>
|