feat(account): 补齐 PC 个人中心与实名认证页

This commit is contained in:
yml2213
2026-10-30 17:58:47 +08:00
parent 0a85634a58
commit 20ba072494
6 changed files with 559 additions and 0 deletions
+8
View File
@@ -463,6 +463,12 @@ const AdminDistributionPaymentChannelsPage = lazy(() =>
const AdminLayout = lazy(() =>
import('@/layouts/AdminLayout').then(module => ({ default: module.AdminLayout }))
)
const ProfilePage = lazy(() =>
import('@/features/profile/ProfilePage').then(module => ({ default: module.ProfilePage }))
)
const RealnamePage = lazy(() =>
import('@/features/realname/RealnamePage').then(module => ({ default: module.RealnamePage }))
)
const AdminSectionPlaceholder = lazy(() =>
import('@/layouts/AdminLayout').then(module => ({ default: module.AdminSectionPlaceholder }))
)
@@ -490,6 +496,8 @@ export function AppRouter() {
<Route path="crash/:id" element={<CrashDetailPage />} />
<Route path="messages" element={<MessagesPage />} />
<Route path="messages/:id" element={<ChatPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="realname" element={<RealnamePage />} />
<Route path="seller/listings" element={<SellerListingsPage />} />
<Route path="seller/listings/new" element={<SellerListingFormPage />} />
<Route path="seller/listings/:id" element={<SellerListingDetailPage />} />
@@ -0,0 +1,192 @@
import { useQuery } from '@tanstack/react-query'
import { App, Avatar, Button, Card, Col, Descriptions, Row, Space, Tag, Typography } from 'antd'
import {
ArrowRightOutlined,
BellOutlined,
IdcardOutlined,
MessageOutlined,
ProfileOutlined,
ShopOutlined,
TeamOutlined,
WalletOutlined,
} from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { BrandLogo } from '@/components/BrandLogo'
import { fetchWalletBalance } from '@/features/wallet/api'
import { useSessionStore } from '@/stores/session'
import { formatMoney } from '@/shared/utils/format'
const realnameMeta: Record<string, { label: string; color: string }> = {
verified: { label: '已实名', color: 'green' },
pending: { label: '审核中', color: 'gold' },
rejected: { label: '未通过', color: 'volcano' },
unverified: { label: '未实名', color: 'default' },
}
const quickEntries = [
{ key: 'orders', label: '我的订单', hint: '查看租号进度与交接记录', icon: <ProfileOutlined />, to: '/orders' },
{ key: 'messages', label: '消息中心', hint: '账号群聊与客服会话', icon: <MessageOutlined />, to: '/messages' },
{ key: 'wallet', label: '我的钱包', hint: '余额、流水与提现', icon: <WalletOutlined />, to: '/wallet' },
{ key: 'seller', label: '卖家中心', hint: '发布账号与号主待办', icon: <ShopOutlined />, to: '/seller/listings' },
{ key: 'invitations', label: '邀请好友', hint: '邀请码与返利记录', icon: <TeamOutlined />, to: '/invitations' },
{ key: 'notifications', label: '我的通知', hint: '系统提醒与公告推送', icon: <BellOutlined />, to: '/notifications' },
]
export function ProfilePage() {
const navigate = useNavigate()
const { modal, message } = App.useApp()
const user = useSessionStore(state => state.user)
const token = useSessionStore(state => state.token)
const hydrated = useSessionStore(state => state.hydrated)
const logout = useSessionStore(state => state.logout)
const wallet = useQuery({
queryKey: ['wallet-balance', 'profile'],
queryFn: fetchWalletBalance,
enabled: Boolean(token),
})
if (!hydrated) {
return (
<div className="pc-account-page">
<Card loading />
</div>
)
}
if (!token || !user) {
return (
<div className="pc-account-page">
<Card className="pc-account-login">
<BrandLogo height={48} variant="seal" />
<Typography.Title level={3}>登录后查看个人中心</Typography.Title>
<Typography.Paragraph type="secondary">
账号资料、实名状态、订单与钱包只对当前账号开放。
</Typography.Paragraph>
<Button type="primary" onClick={() => navigate('/login?redirect=/profile')}>
去登录
</Button>
</Card>
</div>
)
}
const status = user.realname_status || 'unverified'
const meta = realnameMeta[status] ?? realnameMeta.unverified
const initial = (user.nickname || user.phone || '客').slice(0, 1)
const confirmLogout = () => {
modal.confirm({
title: '确认退出登录',
content: '退出后需要重新登录才能查看订单与钱包。',
okText: '退出登录',
cancelText: '再想想',
okButtonProps: { danger: true },
onOk: async () => {
await logout()
message.success('已退出登录')
navigate('/')
},
})
}
return (
<div className="pc-account-page">
<header className="pc-account-hero">
<div>
<span className="pc-account-hero__eyebrow">MY ACCOUNT</span>
<Typography.Title level={2} className="brand-title">
个人中心
</Typography.Title>
<Typography.Paragraph type="secondary">
账号资料、实名认证、订单与资金入口都集中在这里。
</Typography.Paragraph>
</div>
<Button icon={<IdcardOutlined />} onClick={() => navigate('/realname')}>
实名认证
</Button>
</header>
<Row gutter={[16, 16]}>
<Col xs={24} lg={8}>
<Card className="pc-account-card">
<Space size={16} align="center">
<Avatar size={64} src={user.avatar_url || undefined} className="pc-account-avatar">
{initial}
</Avatar>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
{user.nickname || '我的商行'}
</Typography.Title>
<Typography.Text type="secondary">{user.phone}</Typography.Text>
</div>
</Space>
<Space size={8} wrap style={{ marginTop: 16 }}>
<Tag color={meta.color}>{meta.label}</Tag>
{user.credit_score != null ? <Tag color="blue">信用分 {user.credit_score}</Tag> : null}
{user.risk_status && user.risk_status !== 'normal' ? (
<Tag color="volcano">风险提示</Tag>
) : null}
</Space>
<div className="pc-account-balance">
<span>可用余额</span>
<strong>{wallet.data ? formatMoney(wallet.data.available_balance_cent) : '—'}</strong>
<span className="pc-account-balance__frozen">
冻结 {wallet.data ? formatMoney(wallet.data.frozen_balance_cent) : '—'}
</span>
</div>
<Button block onClick={() => navigate('/wallet')}>
查看钱包流水
</Button>
</Card>
</Col>
<Col xs={24} lg={16}>
<Card title="常用入口" className="pc-account-card">
<div className="pc-account-quick">
{quickEntries.map(item => (
<button
key={item.key}
type="button"
className="pc-account-quick__item"
onClick={() => navigate(item.to)}
>
<span className="pc-account-quick__icon">{item.icon}</span>
<strong>{item.label}</strong>
<span className="pc-account-quick__hint">{item.hint}</span>
<ArrowRightOutlined className="pc-account-quick__arrow" />
</button>
))}
</div>
</Card>
<Card title="账号资料" className="pc-account-card" style={{ marginTop: 16 }}>
<Descriptions column={{ xs: 1, sm: 2 }} size="middle">
<Descriptions.Item label="用户 ID">{user.id}</Descriptions.Item>
<Descriptions.Item label="手机号">{user.phone}</Descriptions.Item>
<Descriptions.Item label="昵称">{user.nickname || '未设置'}</Descriptions.Item>
<Descriptions.Item label="实名状态">
<Space size={8}>
<Tag color={meta.color}>{meta.label}</Tag>
{status !== 'verified' ? (
<Button type="link" size="small" onClick={() => navigate('/realname')}>
{status === 'pending' ? '查看进度' : '去认证'}
</Button>
) : null}
</Space>
</Descriptions.Item>
</Descriptions>
<Space size={12} style={{ marginTop: 8 }} wrap>
<Button danger onClick={confirmLogout}>
退出登录
</Button>
</Space>
</Card>
</Col>
</Row>
</div>
)
}
@@ -0,0 +1,209 @@
import { useState } from 'react'
import {
Alert,
App,
Button,
Card,
Descriptions,
Form,
Input,
Space,
Tag,
Typography,
} from 'antd'
import { CheckCircleOutlined, IdcardOutlined, ReloadOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { BrandLogo } from '@/components/BrandLogo'
import { startRealname, useRealnameStatus } from '@/features/realname/api'
import { useSessionStore } from '@/stores/session'
import { formatDateTime } from '@/shared/utils/format'
interface RealnameFormValues {
name: string
idNo: string
}
const statusMeta: Record<string, { label: string; color: string; hint: string }> = {
unverified: { label: '未实名', color: 'default', hint: '完成实名后才能创建租赁订单。' },
pending: { label: '审核中', color: 'gold', hint: '资料已提交,正在等待实名校验结果。' },
verified: { label: '已实名', color: 'green', hint: '实名信息已通过校验,可以正常下单。' },
rejected: { label: '未通过', color: 'volcano', hint: '实名信息未通过校验,请核对后重新提交。' },
}
export function RealnamePage() {
const navigate = useNavigate()
const { message, modal } = App.useApp()
const token = useSessionStore(state => state.token)
const hydrated = useSessionStore(state => state.hydrated)
const loadMe = useSessionStore(state => state.loadMe)
const query = useRealnameStatus(Boolean(token))
const [saving, setSaving] = useState(false)
const [form] = Form.useForm<RealnameFormValues>()
if (!hydrated) {
return (
<div className="pc-account-page">
<Card loading />
</div>
)
}
if (!token) {
return (
<div className="pc-account-page">
<Card className="pc-account-login">
<BrandLogo height={48} variant="seal" />
<Typography.Title level={3}>登录后完成实名</Typography.Title>
<Typography.Paragraph type="secondary">
实名信息只对当前账号开放,请先登录。
</Typography.Paragraph>
<Button type="primary" onClick={() => navigate('/login?redirect=/realname')}>
去登录
</Button>
</Card>
</div>
)
}
const status = query.data?.status ?? 'unverified'
const meta = statusMeta[status] ?? statusMeta.unverified
const submit = async () => {
const values = await form.validateFields()
modal.confirm({
title: '确认提交实名认证',
content: '姓名与身份证号仅用于实名校验,请确认填写无误。',
okText: '确认提交',
cancelText: '再检查一下',
onOk: async () => {
setSaving(true)
try {
const result = await startRealname(values.name.trim(), values.idNo.trim())
message.success(result.status === 'verified' ? '实名认证已通过' : '实名资料已提交')
form.resetFields()
await query.refetch()
await loadMe().catch(() => undefined)
} finally {
setSaving(false)
}
},
})
}
return (
<div className="pc-account-page">
<header className="pc-account-hero">
<div>
<span className="pc-account-hero__eyebrow">IDENTITY</span>
<Typography.Title level={2} className="brand-title">
实名认证
</Typography.Title>
<Typography.Paragraph type="secondary">
用于确认租赁双方身份,降低账号交易风险。
</Typography.Paragraph>
</div>
<Tag color={meta.color} icon={<IdcardOutlined />}>
{meta.label}
</Tag>
</header>
{query.isError ? (
<Card className="pc-account-card">
<Alert
type="error"
showIcon
message="实名状态没有加载出来"
action={
<Button size="small" onClick={() => void query.refetch()}>
重新加载
</Button>
}
/>
</Card>
) : (
<Card
title="认证信息"
className="pc-account-card"
loading={query.isPending}
extra={
<Button
size="small"
icon={<ReloadOutlined />}
loading={query.isFetching}
onClick={() => void query.refetch()}
>
刷新
</Button>
}
>
<Alert type="info" showIcon message={meta.hint} style={{ marginBottom: 16 }} />
<Descriptions column={{ xs: 1, sm: 2 }} size="middle">
<Descriptions.Item label="当前状态">
<Tag color={meta.color}>{meta.label}</Tag>
</Descriptions.Item>
<Descriptions.Item label="姓名">{query.data?.masked_name || '—'}</Descriptions.Item>
<Descriptions.Item label="身份证号">
{query.data?.masked_id_no || '—'}
</Descriptions.Item>
<Descriptions.Item label="认证时间">
{formatDateTime(query.data?.verified_at)}
</Descriptions.Item>
{query.data?.fail_reason ? (
<Descriptions.Item label="失败原因" span={2}>
{query.data.fail_reason}
</Descriptions.Item>
) : null}
</Descriptions>
{status === 'verified' ? (
<Alert
style={{ marginTop: 16 }}
type="success"
showIcon
icon={<CheckCircleOutlined />}
message="已完成实名认证,无需重复提交。"
/>
) : null}
{status === 'unverified' || status === 'rejected' ? (
<div className="pc-account-form">
<Typography.Title level={5}>填写实名信息</Typography.Title>
<Typography.Paragraph type="secondary">
姓名与身份证号必须与证件一致。
</Typography.Paragraph>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item
name="name"
label="姓名"
rules={[{ required: true, message: '请填写真实姓名' }]}
>
<Input placeholder="请输入证件上的姓名" maxLength={30} />
</Form.Item>
<Form.Item
name="idNo"
label="身份证号"
rules={[
{ required: true, message: '请填写身份证号' },
{ pattern: /^\d{17}[\dXx]$/, message: '请填写 18 位居民身份证号' },
]}
>
<Input placeholder="请输入 18 位身份证号" maxLength={18} />
</Form.Item>
<Space size={12}>
<Button type="primary" loading={saving} onClick={() => void submit()}>
提交实名认证
</Button>
<Button onClick={() => navigate('/profile')}>返回个人中心</Button>
</Space>
</Form>
<Typography.Paragraph type="secondary" style={{ marginTop: 12 }}>
证件信息由后端加密保存,页面仅展示脱敏结果;如实名服务暂时不可用,可稍后重试或联系客服。
</Typography.Paragraph>
</div>
) : null}
</Card>
)}
</div>
)
}
+10
View File
@@ -44,6 +44,16 @@ export function StorefrontLayout() {
menu={{
items: [
{ key: 'phone', label: `已登录:${user.phone}`, disabled: true },
{
key: 'profile',
label: '个人中心',
onClick: () => navigate('/profile'),
},
{
key: 'realname',
label: '实名认证',
onClick: () => navigate('/realname'),
},
{
key: 'orders',
label: '我的订单',
+1
View File
@@ -18,6 +18,7 @@
@import './mobile-messages.css';
@import './mobile-chat.css';
@import './pc-messages.css';
@import './pc-account.css';
@import './seller.css';
@import './mobile-seller.css';
@import './components.css';
+139
View File
@@ -0,0 +1,139 @@
/* PC 个人中心与实名认证 */
.pc-account-page {
max-width: 1180px;
margin: 0 auto;
padding: 32px 24px 56px;
}
.pc-account-hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
padding: 28px 32px;
margin-bottom: 20px;
border: 1px solid var(--line);
border-radius: 18px;
background: linear-gradient(135deg, rgba(30, 77, 66, 0.06), rgba(176, 138, 74, 0.08));
}
.pc-account-hero__eyebrow {
display: inline-block;
font-size: 12px;
letter-spacing: 0.24em;
color: var(--ink-muted);
}
.pc-account-hero h2 {
margin: 6px 0 4px;
}
.pc-account-card {
height: 100%;
border-radius: 16px;
border-color: var(--line);
background: var(--paper-warm);
}
.pc-account-avatar {
background: var(--brand-ink);
color: #fff;
font-size: 26px;
}
.pc-account-balance {
display: flex;
flex-direction: column;
gap: 2px;
margin: 20px 0 14px;
padding: 14px 16px;
border-radius: 12px;
background: rgba(30, 77, 66, 0.05);
}
.pc-account-balance span {
font-size: 12px;
color: var(--ink-muted);
}
.pc-account-balance strong {
font-size: 26px;
color: var(--brand-ink-deep);
letter-spacing: 0.02em;
}
.pc-account-balance__frozen {
color: var(--ink-secondary) !important;
}
.pc-account-quick {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.pc-account-quick__item {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 18px;
text-align: left;
border: 1px solid var(--line);
border-radius: 14px;
background: #fff;
cursor: pointer;
transition:
border-color 0.18s ease,
transform 0.18s ease,
box-shadow 0.18s ease;
}
.pc-account-quick__item:hover {
border-color: var(--brand-brass);
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(30, 77, 66, 0.08);
}
.pc-account-quick__icon {
font-size: 20px;
color: var(--brand-ink);
}
.pc-account-quick__hint {
font-size: 12px;
color: var(--ink-muted);
}
.pc-account-quick__arrow {
position: absolute;
top: 18px;
right: 16px;
color: var(--ink-muted);
}
.pc-account-login {
max-width: 460px;
margin: 64px auto;
text-align: center;
border-radius: 18px;
border-color: var(--line);
background: var(--paper-warm);
}
.pc-account-form {
margin-top: 20px;
padding-top: 16px;
border-top: 1px dashed var(--line);
}
@media (max-width: 640px) {
.pc-account-page {
padding: 20px 16px 40px;
}
.pc-account-hero {
flex-direction: column;
padding: 22px;
}
}