feat(mobile): 接入实名认证与商品详情客服入口
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
# Phase 2 移动端实名与客服入口验收
|
||||
|
||||
> 日期:2026-10-30
|
||||
> 范围:移动端实名认证入口、移动商品详情客服入口。
|
||||
|
||||
## 1. 交付内容
|
||||
|
||||
### 1.1 实名认证
|
||||
|
||||
- 新增 `frontend/src/features/realname/api.ts`
|
||||
- `GET /api/realname/status`
|
||||
- `POST /api/realname/start`
|
||||
- 新增 `frontend/src/features/mobile/MobileRealnamePage.tsx`
|
||||
- 状态区分:未实名 / 审核中 / 已实名 / 未通过。
|
||||
- 已实名展示脱敏姓名、脱敏身份证号与认证时间。
|
||||
- 审核中提供刷新按钮。
|
||||
- 未实名或未通过展示姓名、18 位身份证号表单,提交前二次确认。
|
||||
- 提交成功后刷新实名状态并重新拉取用户信息。
|
||||
- 新增路由 `/m/realname`。
|
||||
- 移动个人中心「实名认证」可点击进入;已实名、审核中、未通过使用不同标签。
|
||||
- 商品详情未实名弹窗新增「去实名」按钮,直接进入实名页。
|
||||
|
||||
### 1.2 客服入口
|
||||
|
||||
- 移动商品详情「联系客服」从占位 Toast 改为真实链路:
|
||||
- 未登录跳转 `/m/login?redirect=/m/listings/:id`。
|
||||
- 已登录调用 `POST /api/chats/support` 获取或创建客服会话。
|
||||
- 成功后跳转 `/m/messages/:id`。
|
||||
- 按钮增加加载态与重复点击保护。
|
||||
|
||||
### 1.3 样式
|
||||
|
||||
- `frontend/src/styles/mobile-profile-orders.css` 新增 `.mobile-realname*`:
|
||||
- 深绿实名头图、状态标签、信息列表、表单卡片、隐私说明。
|
||||
- 复用现有移动端间距与字体变量。
|
||||
|
||||
## 2. 验证记录
|
||||
|
||||
| 检查 | 结果 |
|
||||
|---|---|
|
||||
| `cd frontend && npx tsc --noEmit` | 通过 |
|
||||
| `cd frontend && npm run build` | 通过,主入口 516.40 kB / gzip 166.44 kB |
|
||||
| `GET /m/realname` | 200 |
|
||||
| `GET /api/realname/status`(联调用户) | 200,返回 `verified`、`mock`、脱敏姓名与证件号 |
|
||||
| `POST /api/chats/support`(scene=general) | 200,返回客服会话 `id=2` |
|
||||
|
||||
## 3. 说明
|
||||
|
||||
- 联调用户 `13800138000` 已是 `verified` 状态,因此页面默认展示已实名信息,不会重复提交。
|
||||
- 开发环境实名 provider 为 `mock`;生产未配置实名服务时,后端会快速失败并返回「实名认证服务未配置」。
|
||||
- 身份证号前端仅做 18 位格式校验,最终合法性由后端与实名服务判断。
|
||||
+1
-1
@@ -381,7 +381,7 @@ hfb_jysh/
|
||||
|
||||
### Phase 3:C 端 PC + 移动端(3 周)
|
||||
|
||||
> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价、申诉、证据图片上传与订单群聊;PC 商品详情、下单、订单中心、操作页、消息中心与聊天页已接通,移动端实名入口、卖家发布链路待迁移。
|
||||
> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、实名认证、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价、申诉、证据图片上传、订单群聊与客服会话;PC 商品详情、下单、订单中心、操作页、消息中心与聊天页已接通,卖家发布/编辑/审核链路待迁移。
|
||||
|
||||
- 首页、商品列表、商品详情、撞车商品。
|
||||
- 登录、注册、找回密码、实名认证。
|
||||
|
||||
@@ -64,6 +64,11 @@ const MobileChatPage = lazy(() =>
|
||||
default: module.MobileChatPage,
|
||||
}))
|
||||
)
|
||||
const MobileRealnamePage = lazy(() =>
|
||||
import('@/features/mobile/MobileRealnamePage').then(module => ({
|
||||
default: module.MobileRealnamePage,
|
||||
}))
|
||||
)
|
||||
const AdminLoginPage = lazy(() =>
|
||||
import('@/features/admin/AdminLoginPage').then(module => ({ default: module.AdminLoginPage }))
|
||||
)
|
||||
@@ -178,6 +183,7 @@ export function AppRouter() {
|
||||
<Route path="orders/:id" element={<MobileOrderDetailPage />} />
|
||||
<Route path="messages" element={<MobileMessagesPage />} />
|
||||
<Route path="messages/:id" element={<MobileChatPage />} />
|
||||
<Route path="realname" element={<MobileRealnamePage />} />
|
||||
<Route path="profile" element={<MobileProfilePage />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useNavigate, useParams } from 'react-router-dom'
|
||||
|
||||
import { BrandLogo } from '@/components/BrandLogo'
|
||||
import { useListingDetail } from '@/features/listings/api'
|
||||
import { ensureSupportConversation } from '@/features/messages/api'
|
||||
import {
|
||||
createOrder,
|
||||
useOrderAgreements,
|
||||
@@ -41,6 +42,24 @@ export function MobileListingDetailPage() {
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const [agreed, setAgreed] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [openingSupport, setOpeningSupport] = useState(false)
|
||||
|
||||
const openSupport = async () => {
|
||||
if (!token) {
|
||||
navigate(`/m/login?redirect=/m/listings/${id}`)
|
||||
return
|
||||
}
|
||||
if (openingSupport) return
|
||||
setOpeningSupport(true)
|
||||
try {
|
||||
const conversation = await ensureSupportConversation()
|
||||
navigate(`/m/messages/${conversation.id}`)
|
||||
} catch {
|
||||
// 全局响应拦截已提示,保留按钮可重试。
|
||||
} finally {
|
||||
setOpeningSupport(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showAgreement = (title: string, content?: string) => {
|
||||
Dialog.alert({
|
||||
@@ -66,8 +85,9 @@ export function MobileListingDetailPage() {
|
||||
if (user?.realname_status !== 'verified') {
|
||||
Dialog.alert({
|
||||
title: '需要先完成实名认证',
|
||||
content: '当前账号未完成实名认证,无法创建租赁订单。请先联系客服或在实名入口完成认证后再下单。',
|
||||
confirmText: '知道了',
|
||||
content: '当前账号未完成实名认证,无法创建租赁订单。完成实名后即可继续下单。',
|
||||
confirmText: '去实名',
|
||||
onConfirm: () => navigate('/m/realname'),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -204,9 +224,8 @@ export function MobileListingDetailPage() {
|
||||
<div className="mobile-detail__actions">
|
||||
<Button
|
||||
block
|
||||
onClick={() => {
|
||||
Toast.show({ content: '客服接入将在下一批完成' })
|
||||
}}
|
||||
loading={openingSupport}
|
||||
onClick={() => void openSupport()}
|
||||
>
|
||||
<PhoneFill /> 联系客服
|
||||
</Button>
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { Button, List } from 'antd-mobile'
|
||||
import { Button, List, Tag } from 'antd-mobile'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { BrandLogo } from '@/components/BrandLogo'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const realnameLabel: Record<string, string> = {
|
||||
verified: '已实名',
|
||||
pending: '审核中',
|
||||
rejected: '未通过',
|
||||
}
|
||||
|
||||
export function MobileProfilePage() {
|
||||
const navigate = useNavigate()
|
||||
const user = useSessionStore(state => state.user)
|
||||
const logout = useSessionStore(state => state.logout)
|
||||
const realnameStatus = user?.realname_status || 'unverified'
|
||||
const realnameTone =
|
||||
realnameStatus === 'verified' ? 'success' : realnameStatus === 'rejected' ? 'danger' : 'default'
|
||||
|
||||
return (
|
||||
<div className="mobile-profile">
|
||||
@@ -21,7 +30,11 @@ export function MobileProfilePage() {
|
||||
|
||||
<List className="mobile-profile__list">
|
||||
<List.Item extra={user ? '已登录' : '未登录'}>账号状态</List.Item>
|
||||
<List.Item extra={user?.realname_status === 'verified' ? '已实名' : '未实名'}>
|
||||
<List.Item
|
||||
clickable
|
||||
onClick={() => navigate(user ? '/m/realname' : '/m/login?redirect=/m/realname')}
|
||||
extra={<Tag color={realnameTone}>{realnameLabel[realnameStatus] ?? '未实名'}</Tag>}
|
||||
>
|
||||
实名认证
|
||||
</List.Item>
|
||||
<List.Item onClick={() => navigate('/m/orders')}>我的订单</List.Item>
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
ErrorBlock,
|
||||
Form,
|
||||
Input,
|
||||
List,
|
||||
Skeleton,
|
||||
Tag,
|
||||
Toast,
|
||||
} from 'antd-mobile'
|
||||
import { CheckShieldOutline, LeftOutline } from 'antd-mobile-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: 'default' | 'primary' | 'success' | 'warning' | 'danger'
|
||||
hint: string
|
||||
}
|
||||
> = {
|
||||
unverified: {
|
||||
label: '未实名',
|
||||
color: 'default',
|
||||
hint: '完成实名后才能创建租赁订单。',
|
||||
},
|
||||
pending: {
|
||||
label: '审核中',
|
||||
color: 'warning',
|
||||
hint: '资料已提交,正在等待实名校验结果。',
|
||||
},
|
||||
verified: {
|
||||
label: '已实名',
|
||||
color: 'success',
|
||||
hint: '实名信息已通过校验,可以正常下单。',
|
||||
},
|
||||
rejected: {
|
||||
label: '未通过',
|
||||
color: 'danger',
|
||||
hint: '实名信息未通过校验,请核对后重新提交。',
|
||||
},
|
||||
}
|
||||
|
||||
export function MobileRealnamePage() {
|
||||
const navigate = useNavigate()
|
||||
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 status = query.data?.status ?? 'unverified'
|
||||
const meta = statusMeta[status] ?? statusMeta.unverified
|
||||
|
||||
const submit = async (values: RealnameFormValues) => {
|
||||
if (saving) return
|
||||
const confirmed = await Dialog.confirm({
|
||||
title: '确认提交实名认证',
|
||||
content: '姓名与身份证号仅用于实名校验,请确认填写无误。',
|
||||
confirmText: '确认提交',
|
||||
cancelText: '再检查一下',
|
||||
})
|
||||
if (!confirmed) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = await startRealname(values.name.trim(), values.idNo.trim())
|
||||
Toast.show({
|
||||
icon: 'success',
|
||||
content: result.status === 'verified' ? '实名认证已通过' : '实名资料已提交',
|
||||
})
|
||||
await query.refetch()
|
||||
await loadMe().catch(() => undefined)
|
||||
} catch {
|
||||
// 全局响应拦截已提示具体原因。
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hydrated) {
|
||||
return (
|
||||
<div className="mobile-realname">
|
||||
<Skeleton.Title animated />
|
||||
<Skeleton.Paragraph lineCount={6} animated />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="mobile-realname">
|
||||
<div className="mobile-realname__back">
|
||||
<button type="button" onClick={() => navigate('/m/profile')}>
|
||||
<LeftOutline /> 返回个人中心
|
||||
</button>
|
||||
</div>
|
||||
<div className="mobile-orders__login">
|
||||
<BrandLogo height={40} variant="seal" />
|
||||
<h2>登录后完成实名</h2>
|
||||
<p>实名信息只对当前账号开放,请先登录。</p>
|
||||
<Button block color="primary" onClick={() => navigate('/m/login?redirect=/m/realname')}>
|
||||
去登录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mobile-realname">
|
||||
<div className="mobile-realname__back">
|
||||
<button type="button" onClick={() => navigate('/m/profile')}>
|
||||
<LeftOutline /> 返回个人中心
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="mobile-realname__hero">
|
||||
<span className="mobile-realname__icon">
|
||||
<CheckShieldOutline />
|
||||
</span>
|
||||
<div>
|
||||
<span className="mobile-realname__eyebrow">IDENTITY</span>
|
||||
<h1>实名认证</h1>
|
||||
<p>用于确认租赁双方身份,降低账号交易风险。</p>
|
||||
</div>
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
</section>
|
||||
|
||||
{query.isPending ? (
|
||||
<Skeleton.Paragraph lineCount={5} animated />
|
||||
) : query.isError ? (
|
||||
<ErrorBlock status="disconnected" title="实名状态没有加载出来">
|
||||
<Button size="small" onClick={() => void query.refetch()}>
|
||||
重新加载
|
||||
</Button>
|
||||
</ErrorBlock>
|
||||
) : (
|
||||
<>
|
||||
<List className="mobile-realname__info">
|
||||
<List.Item extra={<Tag color={meta.color}>{meta.label}</Tag>}>当前状态</List.Item>
|
||||
<List.Item>{meta.hint}</List.Item>
|
||||
{query.data?.masked_name ? (
|
||||
<List.Item extra={query.data.masked_name}>姓名</List.Item>
|
||||
) : null}
|
||||
{query.data?.masked_id_no ? (
|
||||
<List.Item extra={query.data.masked_id_no}>身份证号</List.Item>
|
||||
) : null}
|
||||
{query.data?.verified_at ? (
|
||||
<List.Item extra={formatDateTime(query.data.verified_at)}>认证时间</List.Item>
|
||||
) : null}
|
||||
{query.data?.fail_reason ? (
|
||||
<List.Item extra={query.data.fail_reason}>失败原因</List.Item>
|
||||
) : null}
|
||||
</List>
|
||||
|
||||
{status === 'pending' ? (
|
||||
<Button
|
||||
block
|
||||
fill="outline"
|
||||
loading={query.isFetching}
|
||||
onClick={() => void query.refetch()}
|
||||
>
|
||||
刷新审核状态
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{status === 'unverified' || status === 'rejected' ? (
|
||||
<section className="mobile-realname__form">
|
||||
<div className="mobile-realname__form-head">
|
||||
<strong>填写实名信息</strong>
|
||||
<span>姓名与身份证号必须与证件一致。</span>
|
||||
</div>
|
||||
<Form
|
||||
layout="horizontal"
|
||||
onFinish={values => void submit(values as RealnameFormValues)}
|
||||
footer={
|
||||
<Button block color="primary" type="submit" loading={saving}>
|
||||
提交实名认证
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</Form>
|
||||
<p className="mobile-realname__privacy">
|
||||
证件信息由后端加密保存,页面仅展示脱敏结果;如实名服务暂时不可用,可稍后重试或联系客服。
|
||||
</p>
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { apiClient, unwrapData } from '@/shared/api/client'
|
||||
import type { ApiResponse } from '@/shared/api/types'
|
||||
|
||||
export interface RealnameStatus {
|
||||
status: string
|
||||
provider?: string
|
||||
provider_order_no?: string
|
||||
masked_name?: string
|
||||
masked_id_no?: string
|
||||
verified_at?: string
|
||||
fail_reason?: string
|
||||
}
|
||||
|
||||
export function fetchRealnameStatus() {
|
||||
return unwrapData<RealnameStatus>(
|
||||
apiClient.get<ApiResponse<RealnameStatus>>('/realname/status')
|
||||
)
|
||||
}
|
||||
|
||||
export function useRealnameStatus(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['realname-status'],
|
||||
queryFn: fetchRealnameStatus,
|
||||
enabled,
|
||||
staleTime: 10_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function startRealname(name: string, idNo: string) {
|
||||
return unwrapData<RealnameStatus>(
|
||||
apiClient.post<ApiResponse<RealnameStatus>>('/realname/start', {
|
||||
name,
|
||||
id_no: idNo,
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -205,3 +205,120 @@
|
||||
color: var(--ink-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* 移动端实名认证 */
|
||||
.mobile-realname {
|
||||
padding: 16px 16px 40px;
|
||||
}
|
||||
|
||||
.mobile-realname__back {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mobile-realname__back button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--ink-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-realname__hero {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 20px 18px;
|
||||
border-radius: 12px;
|
||||
background: var(--brand-ink);
|
||||
color: var(--paper-warm);
|
||||
}
|
||||
|
||||
.mobile-realname__icon {
|
||||
display: grid;
|
||||
flex: 0 0 42px;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(246, 242, 232, 0.28);
|
||||
border-radius: 50%;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.mobile-realname__hero > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-realname__eyebrow {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.22em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.mobile-realname__hero h1 {
|
||||
margin: 4px 0 6px;
|
||||
font-family: 'Noto Serif SC', 'Songti SC', serif;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.mobile-realname__hero p {
|
||||
margin: 0;
|
||||
color: rgba(246, 242, 232, 0.72);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.mobile-realname__hero .adm-tag {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.mobile-realname__info {
|
||||
margin-bottom: 14px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mobile-realname__form {
|
||||
margin-top: 18px;
|
||||
padding: 18px 14px 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--paper-warm);
|
||||
}
|
||||
|
||||
.mobile-realname__form-head {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mobile-realname__form-head strong {
|
||||
display: block;
|
||||
color: var(--brand-ink);
|
||||
font-family: 'Noto Serif SC', 'Songti SC', serif;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.mobile-realname__form-head span {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mobile-realname__form .adm-form {
|
||||
--border-inner: transparent;
|
||||
--border-top: transparent;
|
||||
--border-bottom: transparent;
|
||||
}
|
||||
|
||||
.mobile-realname__privacy {
|
||||
margin: 14px 0 0;
|
||||
color: var(--ink-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user