Files
hfb_sys/frontend/src/views/account/ProfileView.vue
T

715 lines
16 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.
<script setup lang="ts">
import {
Camera,
CircleCheckFilled,
CirclePlus,
Coin,
EditPen,
Finished,
Goods,
Postcard,
RefreshRight,
Shop,
Tickets,
User,
Van,
VideoPlay,
Wallet,
WarningFilled,
} from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { uploadFile } from '@/api/files'
import { useSessionStore } from '@/stores/session'
import { realnameStatusLabel } from '@/utils/statusLabels'
const session = useSessionStore()
const router = useRouter()
const saving = ref(false)
const uploadingAvatar = ref(false)
const avatarFileInput = ref<HTMLInputElement | null>(null)
const form = reactive({
nickname: '',
avatar_url: '',
})
const buyerServices = [
{ label: '待支付', icon: Coin, tone: 'warning', to: { path: '/orders', query: { tab: 'pending_payment' } } },
{ label: '待交接', icon: Van, tone: 'info', to: { path: '/orders', query: { tab: 'pending_handoff' } } },
{ label: '使用中', icon: VideoPlay, tone: 'primary', to: { path: '/orders', query: { tab: 'renting' } } },
{ label: '已完成', icon: Finished, tone: 'success', to: { path: '/orders', query: { tab: 'completed' } } },
]
const sellerServices = [
{ label: '发布商品', icon: CirclePlus, tone: 'orange', to: '/seller/listings/create' },
{ label: '我的商品', icon: Shop, tone: 'purple', to: '/seller/listings' },
{ label: '提现/账单', icon: Wallet, tone: 'teal', to: '/wallet' },
]
const displayName = computed(() => session.displayName)
const maskedPhone = computed(() => {
if (!session.phone) return '未绑定手机号'
return `${session.phone.slice(0, 3)}****${session.phone.slice(-4)}`
})
const avatarText = computed(() => (form.nickname || displayName.value || 'U').slice(0, 1))
const realnameTone = computed(() => {
if (session.realnameStatus === 'verified') return 'verified'
if (session.realnameStatus === 'pending') return 'pending'
if (session.realnameStatus === 'rejected') return 'rejected'
return 'unverified'
})
onMounted(async () => {
if (!session.phone) {
await session.loadMe()
}
resetForm()
})
function resetForm() {
form.nickname = session.nickname || session.displayName
form.avatar_url = session.avatarUrl || ''
}
function resolveAvatarURL(url: string | undefined | null) {
if (!url) return ''
if (url.includes('/api/files/object?key=avatar/')) {
return url.replace('/api/files/object', '/api/public/files/object')
}
return url
}
function triggerAvatarUpload() {
avatarFileInput.value?.click()
}
async function handleAvatarFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
uploadingAvatar.value = true
try {
const uploaded = await uploadFile(file, 'avatar')
form.avatar_url = uploaded.url
ElMessage.success('头像上传成功')
} catch (error) {
ElMessage.error(readError(error, '头像上传失败'))
} finally {
uploadingAvatar.value = false
}
}
async function saveProfile() {
const nickname = form.nickname.trim()
const avatarURL = form.avatar_url.trim()
if (!nickname) {
ElMessage.warning('请输入昵称')
return
}
if (nickname.length > 24) {
ElMessage.warning('昵称不能超过 24 个字符')
return
}
saving.value = true
try {
await session.updateProfile({ nickname, avatar_url: avatarURL })
resetForm()
ElMessage.success('个人资料已保存')
} catch (error) {
ElMessage.error(readError(error, '资料更新失败'))
} finally {
saving.value = false
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
return response?.data?.message || fallback
}
return fallback
}
</script>
<template>
<section class="profile-page">
<div class="profile-hero">
<div class="hero-avatar">
<img v-if="session.avatarUrl" :src="resolveAvatarURL(session.avatarUrl)" alt="" />
<span v-else>{{ session.avatarText }}</span>
</div>
<div class="hero-copy">
<span class="eyebrow">Profile</span>
<h1>个人资料</h1>
<p>{{ displayName }} · {{ maskedPhone }}</p>
</div>
<button class="hero-realname" :class="`is-${realnameTone}`" type="button" @click="router.push('/realname')">
<el-icon><CircleCheckFilled v-if="session.realnameStatus === 'verified'" /><WarningFilled v-else /></el-icon>
<span>{{ realnameStatusLabel(session.realnameStatus) }}</span>
</button>
</div>
<div class="profile-layout">
<section class="edit-panel">
<div class="panel-head">
<div class="panel-icon">
<el-icon><EditPen /></el-icon>
</div>
<div>
<h2>资料编辑</h2>
<p>修改昵称和头像后会同步展示在顶部用户信息中</p>
</div>
</div>
<div class="avatar-editor">
<button class="avatar-preview" type="button" @click="triggerAvatarUpload">
<img v-if="form.avatar_url" :src="resolveAvatarURL(form.avatar_url)" alt="" />
<span v-else>{{ avatarText }}</span>
<i><el-icon><Camera /></el-icon></i>
</button>
<div class="avatar-actions">
<strong>{{ form.nickname || displayName }}</strong>
<p>支持上传本地图片也可以直接填写头像 URL</p>
<el-button :icon="Camera" :loading="uploadingAvatar" @click="triggerAvatarUpload">上传头像</el-button>
<input ref="avatarFileInput" type="file" accept="image/*" hidden @change="handleAvatarFileChange" />
</div>
</div>
<el-form class="profile-form" label-position="top">
<el-form-item label="昵称">
<el-input v-model="form.nickname" :prefix-icon="User" maxlength="24" placeholder="请输入昵称" size="large" />
</el-form-item>
<el-form-item label="头像地址">
<el-input
v-model="form.avatar_url"
:prefix-icon="Postcard"
maxlength="512"
placeholder="可填写图片 URL,留空使用文字头像"
size="large"
/>
</el-form-item>
<div class="form-actions">
<el-button size="large" :icon="RefreshRight" @click="resetForm">重置</el-button>
<el-button type="primary" size="large" :icon="EditPen" :loading="saving" @click="saveProfile">保存资料</el-button>
</div>
</el-form>
</section>
<aside class="account-card">
<span class="card-kicker">账号信息</span>
<dl>
<div>
<dt>用户 ID</dt>
<dd>{{ session.userId || '-' }}</dd>
</div>
<div>
<dt>手机号</dt>
<dd>{{ maskedPhone }}</dd>
</div>
<div>
<dt>实名状态</dt>
<dd :class="`is-${realnameTone}`">{{ realnameStatusLabel(session.realnameStatus) }}</dd>
</div>
</dl>
<button class="realname-shortcut" type="button" @click="router.push('/realname')">
<span>查看实名认证</span>
<el-icon><CircleCheckFilled /></el-icon>
</button>
</aside>
</div>
<div class="service-panels">
<section class="service-panel">
<div class="service-head">
<h2>买家服务</h2>
<RouterLink class="service-all" :to="{ path: '/orders', query: { tab: 'all' } }">
<span>全部订单</span>
<el-icon><Tickets /></el-icon>
</RouterLink>
</div>
<div class="service-grid buyer-grid">
<RouterLink v-for="item in buyerServices" :key="item.label" class="service-item" :to="item.to">
<span class="service-icon" :class="`is-${item.tone}`">
<el-icon><component :is="item.icon" /></el-icon>
</span>
<strong>{{ item.label }}</strong>
</RouterLink>
</div>
</section>
<section class="service-panel">
<div class="service-head">
<h2>卖家服务</h2>
<RouterLink class="service-all" to="/seller/listings">
<span>管理发布</span>
<el-icon><Goods /></el-icon>
</RouterLink>
</div>
<div class="service-grid seller-grid">
<RouterLink v-for="item in sellerServices" :key="item.label" class="service-item" :to="item.to">
<span class="service-icon" :class="`is-${item.tone}`">
<el-icon><component :is="item.icon" /></el-icon>
</span>
<strong>{{ item.label }}</strong>
</RouterLink>
</div>
</section>
</div>
</section>
</template>
<style scoped>
.profile-page {
width: min(1180px, 100%);
margin: 0 auto;
padding: 10px 0 40px;
}
.profile-hero {
display: flex;
align-items: center;
gap: 18px;
padding: 30px 34px;
border: 1px solid #eef1f5;
border-radius: 18px;
background:
linear-gradient(135deg, rgba(255, 106, 0, 0.12), rgba(37, 99, 235, 0.08)),
#ffffff;
box-shadow: 0 16px 36px rgba(23, 35, 61, 0.08);
}
.hero-avatar,
.avatar-preview {
display: grid;
place-items: center;
overflow: hidden;
border-radius: 999px;
background: #ff6a00;
color: #ffffff;
font-weight: 900;
}
.hero-avatar {
width: 74px;
height: 74px;
font-size: 28px;
flex-shrink: 0;
}
.hero-avatar img,
.avatar-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.hero-copy {
min-width: 0;
flex: 1;
}
.eyebrow {
color: #ff6a00;
font-size: 13px;
font-weight: 900;
text-transform: uppercase;
}
.hero-copy h1 {
margin: 8px 0 0;
color: #17233d;
font-size: 34px;
line-height: 1.2;
}
.hero-copy p {
margin: 10px 0 0;
color: #64748b;
font-size: 15px;
font-weight: 700;
}
.hero-realname {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 40px;
padding: 0 14px;
border: none;
border-radius: 999px;
font-size: 14px;
font-weight: 900;
cursor: pointer;
}
.hero-realname.is-verified,
.account-card dd.is-verified {
background: #ecfdf3;
color: #16a34a;
}
.hero-realname.is-pending,
.account-card dd.is-pending {
background: #fff7ed;
color: #f97316;
}
.hero-realname.is-rejected,
.account-card dd.is-rejected {
background: #fff1f2;
color: #e11d48;
}
.hero-realname.is-unverified,
.account-card dd.is-unverified {
background: #eff6ff;
color: #2563eb;
}
.profile-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 22px;
margin-top: 22px;
}
.edit-panel,
.account-card {
border: 1px solid #eef1f5;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 10px 28px rgba(23, 35, 61, 0.06);
}
.edit-panel {
padding: 28px;
}
.panel-head {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 26px;
}
.panel-icon {
display: grid;
width: 48px;
height: 48px;
place-items: center;
border-radius: 12px;
background: #fff3e8;
color: #ff6a00;
font-size: 24px;
}
.panel-head h2 {
margin: 0;
color: #17233d;
font-size: 22px;
}
.panel-head p {
margin: 6px 0 0;
color: #64748b;
font-size: 14px;
line-height: 1.6;
}
.avatar-editor {
display: flex;
align-items: center;
gap: 18px;
max-width: 640px;
padding: 20px;
border-radius: 14px;
background: #f8fafc;
border: 1px solid #e2e8f0;
}
.avatar-preview {
position: relative;
width: 82px;
height: 82px;
border: none;
font-size: 30px;
cursor: pointer;
flex-shrink: 0;
}
.avatar-preview i {
position: absolute;
right: 4px;
bottom: 4px;
display: grid;
width: 26px;
height: 26px;
place-items: center;
border-radius: 999px;
background: #ffffff;
color: #ff6a00;
box-shadow: 0 4px 12px rgba(23, 35, 61, 0.16);
}
.avatar-actions strong {
display: block;
color: #17233d;
font-size: 20px;
}
.avatar-actions p {
margin: 6px 0 12px;
color: #64748b;
font-size: 14px;
}
.profile-form {
display: grid;
gap: 6px;
max-width: 640px;
margin-top: 24px;
}
.profile-form :deep(.el-form-item__label) {
color: #475569;
font-size: 14px;
font-weight: 800;
}
.profile-form :deep(.el-input__wrapper) {
min-height: 48px;
border-radius: 10px;
box-shadow: 0 0 0 1px #dbe3ee inset;
}
.profile-form :deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 1px #ff6a00 inset, 0 0 0 4px rgba(255, 106, 0, 0.08);
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
.form-actions .el-button {
height: 46px;
border-radius: 10px;
font-weight: 900;
}
.form-actions .el-button--primary {
border: none;
background: #ff6a00;
box-shadow: 0 10px 20px rgba(255, 106, 0, 0.18);
}
.account-card {
padding: 24px;
}
.card-kicker {
color: #8b9cb5;
font-size: 13px;
font-weight: 900;
}
.account-card dl {
display: grid;
gap: 12px;
margin: 18px 0 0;
}
.account-card dl div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 48px;
padding: 0 14px;
border-radius: 10px;
background: #f8fafc;
}
.account-card dt {
color: #8b9cb5;
font-size: 13px;
font-weight: 800;
}
.account-card dd {
margin: 0;
color: #334155;
font-size: 14px;
font-weight: 900;
text-align: right;
}
.realname-shortcut {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 44px;
margin-top: 18px;
padding: 0 14px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #ffffff;
color: #475569;
font-size: 14px;
font-weight: 900;
cursor: pointer;
}
.realname-shortcut:hover {
border-color: #ff6a00;
color: #ff6a00;
}
.service-panels {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 22px;
margin-top: 22px;
}
.service-panel {
padding: 24px;
border: 1px solid #eef1f5;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 10px 28px rgba(23, 35, 61, 0.06);
}
.service-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.service-head h2 {
margin: 0;
color: #17233d;
font-size: 22px;
}
.service-all {
display: inline-flex;
align-items: center;
gap: 6px;
color: #64748b;
font-size: 14px;
font-weight: 900;
text-decoration: none;
}
.service-all:hover {
color: #ff6a00;
}
.service-grid {
display: grid;
gap: 14px;
margin-top: 22px;
}
.buyer-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.seller-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.service-item {
display: grid;
min-width: 0;
min-height: 118px;
place-items: center;
gap: 10px;
padding: 16px 10px;
border: 1px solid #edf2f7;
border-radius: 14px;
background: #fbfdff;
color: #1f2937;
text-align: center;
text-decoration: none;
transition:
border-color 0.18s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
}
.service-item:hover {
border-color: rgba(255, 106, 0, 0.35);
box-shadow: 0 12px 24px rgba(23, 35, 61, 0.08);
transform: translateY(-2px);
}
.service-item strong {
color: #334155;
font-size: 15px;
font-weight: 900;
line-height: 1.35;
}
.service-icon {
display: grid;
width: 58px;
height: 58px;
place-items: center;
border-radius: 14px;
font-size: 28px;
}
.service-icon.is-warning {
background: #fff7ed;
color: #f59e0b;
}
.service-icon.is-info {
background: #eff6ff;
color: #2563eb;
}
.service-icon.is-primary {
background: #eef2ff;
color: #4f46e5;
}
.service-icon.is-success {
background: #ecfdf3;
color: #10b981;
}
.service-icon.is-orange {
background: #fff1e8;
color: #ff6a00;
}
.service-icon.is-purple {
background: #f3e8ff;
color: #8b5cf6;
}
.service-icon.is-teal {
background: #e8f7f5;
color: #0f766e;
}
@media (max-width: 1100px) {
.profile-layout {
grid-template-columns: 1fr;
}
.service-panels {
grid-template-columns: 1fr;
}
}
</style>