AI-News/frontend/app/pages/profile.vue
2025-12-04 10:04:21 +08:00

530 lines
13 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="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>