diff --git a/docs/Phase2-移动端实名与客服入口验收.md b/docs/Phase2-移动端实名与客服入口验收.md
new file mode 100644
index 0000000..7534eb5
--- /dev/null
+++ b/docs/Phase2-移动端实名与客服入口验收.md
@@ -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 位格式校验,最终合法性由后端与实名服务判断。
diff --git a/docs/项目计划.md b/docs/项目计划.md
index 87db7a3..44cb31a 100644
--- a/docs/项目计划.md
+++ b/docs/项目计划.md
@@ -381,7 +381,7 @@ hfb_jysh/
### Phase 3:C 端 PC + 移动端(3 周)
-> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价、申诉、证据图片上传与订单群聊;PC 商品详情、下单、订单中心、操作页、消息中心与聊天页已接通,移动端实名入口、卖家发布链路待迁移。
+> 状态:**进行中(2026-10-30)**。移动端已完成首页、商品列表/详情、下单确认、登录、个人中心、实名认证、订单列表/详情、消息中心与聊天,并接入支付、交接、确认收号、结账、还价、申诉、证据图片上传、订单群聊与客服会话;PC 商品详情、下单、订单中心、操作页、消息中心与聊天页已接通,卖家发布/编辑/审核链路待迁移。
- 首页、商品列表、商品详情、撞车商品。
- 登录、注册、找回密码、实名认证。
diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx
index 05bd76f..c39cad0 100644
--- a/frontend/src/app/AppRouter.tsx
+++ b/frontend/src/app/AppRouter.tsx
@@ -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() {
} />
} />
} />
+ } />
} />
diff --git a/frontend/src/features/mobile/MobileListingDetailPage.tsx b/frontend/src/features/mobile/MobileListingDetailPage.tsx
index 9650f9a..5a7e326 100644
--- a/frontend/src/features/mobile/MobileListingDetailPage.tsx
+++ b/frontend/src/features/mobile/MobileListingDetailPage.tsx
@@ -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() {
diff --git a/frontend/src/features/mobile/MobileProfilePage.tsx b/frontend/src/features/mobile/MobileProfilePage.tsx
index 2f10278..b318d88 100644
--- a/frontend/src/features/mobile/MobileProfilePage.tsx
+++ b/frontend/src/features/mobile/MobileProfilePage.tsx
@@ -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
= {
+ 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 (
@@ -21,7 +30,11 @@ export function MobileProfilePage() {
账号状态
-
+ navigate(user ? '/m/realname' : '/m/login?redirect=/m/realname')}
+ extra={{realnameLabel[realnameStatus] ?? '未实名'}}
+ >
实名认证
navigate('/m/orders')}>我的订单
diff --git a/frontend/src/features/mobile/MobileRealnamePage.tsx b/frontend/src/features/mobile/MobileRealnamePage.tsx
new file mode 100644
index 0000000..62579d1
--- /dev/null
+++ b/frontend/src/features/mobile/MobileRealnamePage.tsx
@@ -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 (
+
+
+
+
+ )
+ }
+
+ if (!token) {
+ return (
+
+
+
+
+
+
+
登录后完成实名
+
实名信息只对当前账号开放,请先登录。
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
IDENTITY
+
实名认证
+
用于确认租赁双方身份,降低账号交易风险。
+
+ {meta.label}
+
+
+ {query.isPending ? (
+
+ ) : query.isError ? (
+
+
+
+ ) : (
+ <>
+
+ {meta.label}}>当前状态
+ {meta.hint}
+ {query.data?.masked_name ? (
+ 姓名
+ ) : null}
+ {query.data?.masked_id_no ? (
+ 身份证号
+ ) : null}
+ {query.data?.verified_at ? (
+ 认证时间
+ ) : null}
+ {query.data?.fail_reason ? (
+ 失败原因
+ ) : null}
+
+
+ {status === 'pending' ? (
+
+ ) : null}
+
+ {status === 'unverified' || status === 'rejected' ? (
+
+ ) : null}
+ >
+ )}
+
+ )
+}
diff --git a/frontend/src/features/realname/api.ts b/frontend/src/features/realname/api.ts
new file mode 100644
index 0000000..e134e0c
--- /dev/null
+++ b/frontend/src/features/realname/api.ts
@@ -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(
+ apiClient.get>('/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(
+ apiClient.post>('/realname/start', {
+ name,
+ id_no: idNo,
+ })
+ )
+}
diff --git a/frontend/src/styles/mobile-profile-orders.css b/frontend/src/styles/mobile-profile-orders.css
index 136b8f6..425641c 100644
--- a/frontend/src/styles/mobile-profile-orders.css
+++ b/frontend/src/styles/mobile-profile-orders.css
@@ -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;
+}