feat(account): 补齐 PC 个人中心与实名认证页
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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: '我的订单',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user