From 395f056d64a7603571bf515f36fce8e4f1502a49 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 30 Oct 2026 14:11:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(mobile):=20=E6=8E=A5=E5=85=A5=E5=AE=9E?= =?UTF-8?q?=E5=90=8D=E8=AE=A4=E8=AF=81=E4=B8=8E=E5=95=86=E5=93=81=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E5=AE=A2=E6=9C=8D=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/Phase2-移动端实名与客服入口验收.md | 51 ++++ docs/项目计划.md | 2 +- frontend/src/app/AppRouter.tsx | 6 + .../mobile/MobileListingDetailPage.tsx | 29 ++- .../src/features/mobile/MobileProfilePage.tsx | 17 +- .../features/mobile/MobileRealnamePage.tsx | 223 ++++++++++++++++++ frontend/src/features/realname/api.ts | 38 +++ frontend/src/styles/mobile-profile-orders.css | 117 +++++++++ 8 files changed, 475 insertions(+), 8 deletions(-) create mode 100644 docs/Phase2-移动端实名与客服入口验收.md create mode 100644 frontend/src/features/mobile/MobileRealnamePage.tsx create mode 100644 frontend/src/features/realname/api.ts 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' ? ( +
+
+ 填写实名信息 + 姓名与身份证号必须与证件一致。 +
+
void submit(values as RealnameFormValues)} + footer={ + + } + > + + + + + + +
+

+ 证件信息由后端加密保存,页面仅展示脱敏结果;如实名服务暂时不可用,可稍后重试或联系客服。 +

+
+ ) : 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; +}