From 8cf883c90c57d9333ae750ddffae1b940134c3fa Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 27 Jul 2026 13:07:29 +0800 Subject: [PATCH] feat: simplify WeChat register UX and gate orders on contact info Co-authored-by: Cursor --- docs/微信注册简化与联系方式补全方案.md | 186 +++++++++++++ src/api/customer/index.ts | 30 +++ src/api/wechat/index.ts | 7 +- src/api/wechat/types.ts | 8 +- src/auto-imports.d.ts | 4 + src/components.d.ts | 1 + src/components/CompleteContactModal/index.vue | 117 ++++++++ src/composables/useWechatQrLogin.ts | 12 +- src/utils/customerContactGate.ts | 36 +++ src/utils/device.ts | 14 + .../customer-center/components/AppTabBar.vue | 2 + .../components/H5CompleteContactSheet.vue | 252 ++++++++++++++++++ .../components/H5CreateOrderFlow.vue | 49 +++- .../components/H5JourneyGuide.vue | 5 +- .../components/H5OrderDetailSheet.vue | 5 +- .../components/H5PickupOrderSheet.vue | 7 +- .../components/H5PlaceOrderSheet.vue | 10 +- .../components/H5ShipGuideSheet.vue | 42 ++- .../components/WechatQrPanel.vue | 79 ++++-- src/views/h5/customer-center/layout.vue | 3 +- src/views/h5/customer-center/locale/en-US.ts | 12 + src/views/h5/customer-center/locale/zh-CN.ts | 12 + src/views/h5/customer-center/login/index.vue | 10 +- src/views/h5/customer-center/me/index.vue | 14 +- src/views/h5/customer-center/orders/index.vue | 7 +- src/views/h5/customer-center/price/index.vue | 5 +- src/views/login/components/wechat-login.vue | 56 ++-- src/views/login/locale/en-US.ts | 3 +- src/views/login/locale/zh-CN.ts | 3 +- src/views/order/pickupOrder/index.vue | 26 ++ src/views/order/placeOrder/index.vue | 36 ++- src/views/wechat/bind/index.vue | 2 + src/views/wechat/locale/en-US.ts | 21 +- src/views/wechat/locale/zh-CN.ts | 20 +- src/views/wechat/register/index.vue | 42 +-- src/views/wechat/styles/wechat-h5.less | 11 + 36 files changed, 1017 insertions(+), 132 deletions(-) create mode 100644 docs/微信注册简化与联系方式补全方案.md create mode 100644 src/components/CompleteContactModal/index.vue create mode 100644 src/utils/customerContactGate.ts create mode 100644 src/views/h5/customer-center/components/H5CompleteContactSheet.vue diff --git a/docs/微信注册简化与联系方式补全方案.md b/docs/微信注册简化与联系方式补全方案.md new file mode 100644 index 0000000..7fd74a2 --- /dev/null +++ b/docs/微信注册简化与联系方式补全方案.md @@ -0,0 +1,186 @@ +# 微信注册简化与联系方式补全方案 + +> 状态:已定稿,按本文实施 +> 范围:微信注册放宽手机/邮箱;「我要下单 / 揽收订单」入口强制补全;RBAC 账号改为客户编号 +> 相关代码:前端 `oms0414`,后端 `oms0414-back` + +--- + +## 1. 目标 + +1. **提高转化**:微信注册不再强制填手机、邮箱,缩短注册路径。 +2. **保证可联系**:进入「我要下单」「揽收订单」时,若缺手机或邮箱,必须补全后才能继续。 +3. **交互明确**:说清「为什么要填」,补全后自动回到原流程,不丢草稿。 +4. **防绕过**:前端入口拦截 + 后端下单/揽收接口兜底。 + +--- + +## 2. 已拍板决策 + +| 项 | 决策 | +|----|------| +| 补全字段 | **手机 + 邮箱都要** | +| 拦截时机 | **一进入**「我要下单 / 揽收」流程就弹;提交时后端再拦 | +| 登录账号 | 微信注册创建的 RBAC `Account = CustomerCode` | +| 注册范围 | **仅微信注册**放宽;PC 注册 / 后台建客 / 子客户仍必填手机+邮箱 | +| 空值存储 | 未填一律存 **`NULL`**,禁止 `''`(避免唯一索引冲突) | + +--- + +## 3. 现状与痛点(摘要) + +- 微信注册:`CustomerName / Phone / Email / Password` 全必填;RBAC `Account = Phone`;注册后用手机号自动登录。 +- `t_customer.Phone`:`NOT NULL` + `uk_cus_phone`;`Email` 可空 + 唯一。 +- 「我要下单 / 揽收」校验的是**收件人/寄件地址电话**,**不看**客户资料手机邮箱。 +- 因此:放开注册后,**不会自动出现**「缺联系方式」拦截,必须新增门禁。 + +--- + +## 4. 方案设计 + +### 4.1 微信注册(简化) + +**表单保留** + +- 客户名称(必填) +- 密码(必填,≥6,建议保持字母+数字规则) +- 微信 OAuth 资料(openid 等) +- 邀请码(可选,来自 `?ref=`) + +**表单去掉必填(可不展示)** + +- 手机、邮箱:注册请求可不传或传空 → 后端存 `NULL` + +**后端** + +- `WeChatCustomerRegisterInput`:`Phone`、`Email` 改为可选;有值时仍做格式与唯一性校验。 +- RBAC:`Account = customerCode`,`Email` 有则写入,无则按 RBAC 允许的空策略处理。 +- `Customer.Phone` / `Email`:可空。 +- 返回或保证前端能拿到 `customerCode`,用于自动登录。 + +**注册成功自动登录** + +- 使用 **`customerCode + password`** 调客户登录(不再用手机号)。 +- 成功页文案提示:可用「客户编号」或微信扫码登录;建议尽快在下单前完善手机与邮箱。 + +### 4.2 资料完整度 + +新增只读接口(命名可微调): + +``` +GET /customer/profile-completeness +→ { + phoneFilled: boolean, + emailFilled: boolean, + needContact: boolean, // !phoneFilled || !emailFilled + customerCode: string + } +``` + +判定:`Phone` / `Email` trim 后非空即算已填。 + +### 4.3 补全接口 + +优先复用 `/customer/update`;若更新契约过重,可增加: + +``` +POST /customer/complete-contact +Body: { phone: string, email: string } +``` + +规则: + +- 手机、邮箱均必填(补全场景) +- 格式校验 + 排除自身的唯一性校验 +- 同步更新 RBAC 用户 Email(若框架支持);**不改** RBAC Account(仍为客户编号) +- 成功后 `needContact = false` + +### 4.4 入口拦截(前端) + +**触发点(一进流程)** + +| 端 | 触发 | +|----|------| +| H5 | 打开「我要下单」总流程(含物流下单 / 揽收创建);发货指引里进入「创建揽收」 | +| PC | 进入「我要下单」「揽收订单」页或点创建同类入口 | + +**不拦截** + +- 仅浏览订单列表、揽收列表(只读) +- 查价、客服、充值(本期不做强制;后续可扩展) + +**交互** + +1. 半屏/弹窗标题:补充联系方式 +2. 说明:下单与揽收前请留下手机号和邮箱。清关补料、派送异常或费用确认时,我们会优先通过这些方式与您确认,避免延误。 +3. 表单:缺啥填啥(两项都缺则两项都填);实时格式校验。 +4. 主按钮:保存并继续;次按钮:取消(关闭流程,不提交订单)。 +5. 保存成功:关弹层,**自动继续**原动作(保留下单/揽收草稿)。 +6. 唯一性冲突:明确提示「手机号/邮箱已被占用」。 + +### 4.5 后端兜底 + +在以下接口进入业务逻辑前校验主客户联系方式: + +- 小包/物流下单创建(如 `parcel/create`) +- 揽收单创建(如 `pickup` create) + +缺手机或邮箱时抛业务异常,建议稳定文案或错误码: + +- 文案:`请先完善手机号和邮箱后再下单` +- 码(可选):`CUSTOMER_CONTACT_REQUIRED` + +前端捕获后同样打开补全弹层。 + +### 4.6 数据库 + +```sql +-- Phone 改为可空(存量数据不变) +ALTER TABLE t_customer + MODIFY COLUMN `Phone` VARCHAR(16) NULL COMMENT '手机号,用于联系/登录辅助'; +``` + +唯一索引 `uk_cus_phone` / `uk_cus_email` 保留;空值必须为 `NULL`。 + +实体 `Customer.Phone` 改为 `string?`。 + +--- + +## 5. 非目标(本期不做) + +- 不改 PC 注册 / 后台建客的必填规则 +- 不改收件人手机、寄件地址电话的面单字段规则(与客户资料联系方式分离) +- 不强制充值、提现、售后入口(可二期) +- 不实现短信通道(有手机即可为后续预留) + +--- + +## 6. 风险与注意 + +1. **老数据**:已有客户均有手机,不受影响。 +2. **密码登录**:无手机邮箱时只能用客户编号或微信扫码;注册成功需展示客户编号。 +3. **RBAC Email**:无邮箱时 CreateUser 需确认 Fake RBAC 是否允许空 Email;不允许则写占位策略需单独评估(优先真实可空)。 +4. **H5 编辑资料**:补全与编辑共用校验,避免「清空手机」绕过。 +5. **并发抢号**:补全时唯一冲突要友好提示。 + +--- + +## 7. 验收清单 + +- [ ] 微信注册可不填手机/邮箱,注册成功并自动登录进入 H5 +- [ ] 新用户 RBAC Account = 客户编号;可用客户编号+密码登录 +- [ ] 缺联系方式时,点「我要下单」立刻弹出补全,取消则不进入下单 +- [ ] 缺联系方式时,进入「创建揽收」同样拦截 +- [ ] 补全成功后自动继续原流程 +- [ ] 绕过前端直接调创建接口,后端返回联系方式必填错误 +- [ ] 手机/邮箱重复时有明确错误提示 +- [ ] 已完善资料的老用户路径无额外弹窗 + +--- + +## 8. 实施顺序 + +1. 本文档定稿 +2. 后端:库表/实体 → 微信注册 → 完整度/补全 API → 下单/揽收门禁 +3. 前端:微信注册页 → 补全弹层组件 → H5/PC 入口接入 +4. 联调与验收清单勾选 diff --git a/src/api/customer/index.ts b/src/api/customer/index.ts index c8d9317..72f162d 100644 --- a/src/api/customer/index.ts +++ b/src/api/customer/index.ts @@ -59,6 +59,15 @@ export interface CustomerPagedResult { total: number } +export interface CustomerProfileCompleteness { + phoneFilled: boolean + emailFilled: boolean + needContact: boolean + customerCode: string + phone?: string + email?: string +} + function mapUpdateBody(id: string | number, data: Partial) { return { id, @@ -169,6 +178,27 @@ export const CustomerService = { assertBusinessOk(r.data) }, + async getProfileCompleteness(): Promise { + const r = await axiosInstance.get(omsUrl('/customer/profile-completeness')) + const data = unwrapData>(r.data) || (r.data as Record) + return { + phoneFilled: Boolean(data.phoneFilled ?? data.PhoneFilled), + emailFilled: Boolean(data.emailFilled ?? data.EmailFilled), + needContact: Boolean(data.needContact ?? data.NeedContact), + customerCode: String(data.customerCode ?? data.CustomerCode ?? ''), + phone: (data.phone ?? data.Phone) as string | undefined, + email: (data.email ?? data.Email) as string | undefined, + } + }, + + async completeContact(input: { phone: string; email: string }): Promise { + const r = await axiosInstance.post(omsUrl('/customer/complete-contact'), { + phone: input.phone.trim(), + email: input.email.trim(), + }) + assertBusinessOk(r.data) + }, + async omsLogin(customerId: string | number): Promise<{ accessToken: string refreshToken?: string | null diff --git a/src/api/wechat/index.ts b/src/api/wechat/index.ts index 81944a8..6d5a592 100644 --- a/src/api/wechat/index.ts +++ b/src/api/wechat/index.ts @@ -4,6 +4,7 @@ import { omsUrl, unwrapData } from '@/api/oms/common'; import type { WeChatCustomerBindInput, WeChatCustomerRegisterInput, + WeChatCustomerRegisterResult, WeChatOAuthUserInfo, WxLoginQrcodeResult, WxLoginResult, @@ -63,9 +64,11 @@ export async function getOAuthUserInfo(code: string): Promise { +export async function registerByWechat( + input: WeChatCustomerRegisterInput, +): Promise { const res = await axiosInstance.post(omsUrl('/wechat/customer/register'), input); - unwrapOrSelf(res.data); + return unwrapOrSelf(res.data); } /** 微信授权绑定已有客户 */ diff --git a/src/api/wechat/types.ts b/src/api/wechat/types.ts index 6af7c11..d09f859 100644 --- a/src/api/wechat/types.ts +++ b/src/api/wechat/types.ts @@ -36,14 +36,18 @@ export interface WeChatProfileInput { export interface WeChatCustomerRegisterInput { customerCode?: string; customerName: string; - phone: string; - email: string; + phone?: string; + email?: string; password: string; /** 邀请码(来自 ?ref=,注册时绑定引荐人) */ inviteCode?: string; wechat: WeChatProfileInput; } +export interface WeChatCustomerRegisterResult { + customerCode: string; +} + export interface WeChatCustomerBindInput { account: string; password: string; diff --git a/src/auto-imports.d.ts b/src/auto-imports.d.ts index 6c3c796..b8a99e9 100644 --- a/src/auto-imports.d.ts +++ b/src/auto-imports.d.ts @@ -331,6 +331,9 @@ declare global { // @ts-ignore export type { WarehouseItem } from './composables/useWarehouseList' import('./composables/useWarehouseList') + // @ts-ignore + export type { WechatQrLoginOptions } from './composables/useWechatQrLogin' + import('./composables/useWechatQrLogin') } // for vue template auto import @@ -629,6 +632,7 @@ declare module 'vue' { readonly useWebSocket: UnwrapRef readonly useWebWorker: UnwrapRef readonly useWebWorkerFn: UnwrapRef + readonly useWechatQrLogin: UnwrapRef readonly useWindowFocus: UnwrapRef readonly useWindowScroll: UnwrapRef readonly useWindowSize: UnwrapRef diff --git a/src/components.d.ts b/src/components.d.ts index 9eddcb9..af40923 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -15,6 +15,7 @@ declare module 'vue' { Cascader: typeof import('./components/CascaderPro/Cascader.vue')['default'] Chart: typeof import('./components/chart/index.vue')['default'] CodeMirror: typeof import('./components/CodeMirror/index.vue')['default'] + CompleteContactModal: typeof import('./components/CompleteContactModal/index.vue')['default'] ContactService: typeof import('./components/ContactService/index.vue')['default'] CustomLeftMenu: typeof import('./components/CustomLeftMenu/index.vue')['default'] CustomList: typeof import('./components/CustomList/index.vue')['default'] diff --git a/src/components/CompleteContactModal/index.vue b/src/components/CompleteContactModal/index.vue new file mode 100644 index 0000000..19fa15f --- /dev/null +++ b/src/components/CompleteContactModal/index.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/src/composables/useWechatQrLogin.ts b/src/composables/useWechatQrLogin.ts index bb0bdf5..a94b841 100644 --- a/src/composables/useWechatQrLogin.ts +++ b/src/composables/useWechatQrLogin.ts @@ -39,9 +39,13 @@ export function useWechatQrLogin(options: WechatQrLoginOptions) { const loadError = ref(false); const qrImageReady = ref(false); const polling = ref(false); + /** 已扫码:遮罩二维码,防止重复扫 */ + const scanned = ref(false); const showOverlay = computed( - () => loadingQr.value || (Boolean(qrCodeUrl.value) && !qrImageReady.value), + () => + !scanned.value && + (loadingQr.value || (Boolean(qrCodeUrl.value) && !qrImageReady.value)), ); let pollTimer: ReturnType | undefined; @@ -79,10 +83,12 @@ export function useWechatQrLogin(options: WechatQrLoginOptions) { const status = await getWxLoginStatus(loginUuid.value); if (!status?.ready || disposed) return; + scanned.value = true; + clearTimers(); + const result = await wxLogin(loginUuid.value); if (!result?.accessToken || disposed) return; - clearTimers(); await options.onSuccess(result); } catch (error) { if (!isWxLoginPendingError(error)) { @@ -121,6 +127,7 @@ export function useWechatQrLogin(options: WechatQrLoginOptions) { const seq = ++loadSeq; clearTimers(); loadError.value = false; + scanned.value = false; loadingQr.value = true; if (!opts?.keepPrevious) { qrCodeUrl.value = ''; @@ -194,6 +201,7 @@ export function useWechatQrLogin(options: WechatQrLoginOptions) { loadError, qrImageReady, polling, + scanned, showOverlay, loadQrCode, refreshQr, diff --git a/src/utils/customerContactGate.ts b/src/utils/customerContactGate.ts new file mode 100644 index 0000000..202dd80 --- /dev/null +++ b/src/utils/customerContactGate.ts @@ -0,0 +1,36 @@ +import { CustomerService, type CustomerProfileCompleteness } from '@/api/customer' + +/** 后端下单/揽收联系方式门禁错误文案 */ +export const CUSTOMER_CONTACT_REQUIRED_HINT = '请先完善手机号和邮箱后再下单' + +export function isCustomerContactRequiredError(error: unknown): boolean { + const msg = String( + (error as any)?.response?.data?.message || + (error as any)?.response?.data?.Message || + (error as any)?.message || + error || + '', + ) + return ( + msg.includes(CUSTOMER_CONTACT_REQUIRED_HINT) || + msg.includes('完善手机号和邮箱') || + msg.includes('CUSTOMER_CONTACT_REQUIRED') + ) +} + +/** + * 检查当前客户是否需补全手机/邮箱。 + * @returns needContact 为 true 时应弹出补全面板 + */ +export async function checkCustomerContactRequired(): Promise<{ + needContact: boolean + profile: CustomerProfileCompleteness | null +}> { + try { + const profile = await CustomerService.getProfileCompleteness() + return { needContact: Boolean(profile.needContact), profile } + } catch { + // 接口失败时不阻断入口,交给提交时后端兜底 + return { needContact: false, profile: null } + } +} diff --git a/src/utils/device.ts b/src/utils/device.ts index c1b4ca6..4473977 100644 --- a/src/utils/device.ts +++ b/src/utils/device.ts @@ -84,3 +84,17 @@ export function preferH5Portal(): boolean { return false; } + +/** + * H5 站内跳转:微信内用 replace,避免历史栈变长触发底部左右箭头栏。 + * 非微信仍可用 push,保留系统返回。 + */ +export function h5Navigate( + router: { push: (to: unknown) => unknown; replace: (to: unknown) => unknown }, + to: unknown, +): unknown { + if (isWeChatBrowser()) { + return router.replace(to); + } + return router.push(to); +} diff --git a/src/views/h5/customer-center/components/AppTabBar.vue b/src/views/h5/customer-center/components/AppTabBar.vue index ac03223..b2cba26 100644 --- a/src/views/h5/customer-center/components/AppTabBar.vue +++ b/src/views/h5/customer-center/components/AppTabBar.vue @@ -4,6 +4,7 @@ v-for="item in leftTabs" :key="item.path" :to="item.path" + replace class="h5-tabbar__item" :class="{ 'is-active': isActive(item.path) }" > @@ -29,6 +30,7 @@ v-for="item in rightTabs" :key="item.path" :to="item.path" + replace class="h5-tabbar__item" :class="{ 'is-active': isActive(item.path) }" > diff --git a/src/views/h5/customer-center/components/H5CompleteContactSheet.vue b/src/views/h5/customer-center/components/H5CompleteContactSheet.vue new file mode 100644 index 0000000..52c7fa4 --- /dev/null +++ b/src/views/h5/customer-center/components/H5CompleteContactSheet.vue @@ -0,0 +1,252 @@ + + + + + diff --git a/src/views/h5/customer-center/components/H5CreateOrderFlow.vue b/src/views/h5/customer-center/components/H5CreateOrderFlow.vue index d66ad35..8654468 100644 --- a/src/views/h5/customer-center/components/H5CreateOrderFlow.vue +++ b/src/views/h5/customer-center/components/H5CreateOrderFlow.vue @@ -2,13 +2,22 @@ +