feat: simplify WeChat register UX and gate orders on contact info

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-27 13:07:29 +08:00
parent abfca7a21a
commit 8cf883c90c
36 changed files with 1017 additions and 132 deletions

View File

@ -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. 联调与验收清单勾选

View File

@ -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<CustomerDto>) {
return {
id,
@ -169,6 +178,27 @@ export const CustomerService = {
assertBusinessOk(r.data)
},
async getProfileCompleteness(): Promise<CustomerProfileCompleteness> {
const r = await axiosInstance.get(omsUrl('/customer/profile-completeness'))
const data = unwrapData<Record<string, unknown>>(r.data) || (r.data as Record<string, unknown>)
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<void> {
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

View File

@ -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<WeChatOAuthUserInf
}
/** 微信授权注册客户 */
export async function registerByWechat(input: WeChatCustomerRegisterInput): Promise<void> {
export async function registerByWechat(
input: WeChatCustomerRegisterInput,
): Promise<WeChatCustomerRegisterResult> {
const res = await axiosInstance.post(omsUrl('/wechat/customer/register'), input);
unwrapOrSelf(res.data);
return unwrapOrSelf<WeChatCustomerRegisterResult>(res.data);
}
/** 微信授权绑定已有客户 */

View File

@ -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;

View File

@ -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<typeof import('@vueuse/core')['useWebSocket']>
readonly useWebWorker: UnwrapRef<typeof import('@vueuse/core')['useWebWorker']>
readonly useWebWorkerFn: UnwrapRef<typeof import('@vueuse/core')['useWebWorkerFn']>
readonly useWechatQrLogin: UnwrapRef<typeof import('./composables/useWechatQrLogin')['useWechatQrLogin']>
readonly useWindowFocus: UnwrapRef<typeof import('@vueuse/core')['useWindowFocus']>
readonly useWindowScroll: UnwrapRef<typeof import('@vueuse/core')['useWindowScroll']>
readonly useWindowSize: UnwrapRef<typeof import('@vueuse/core')['useWindowSize']>

1
src/components.d.ts vendored
View File

@ -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']

View File

@ -0,0 +1,117 @@
<template>
<a-modal
:visible="visible"
:title="t('h5.contact.title')"
:ok-text="t('h5.contact.saveContinue')"
:cancel-text="t('h5.contact.cancel')"
:ok-loading="saving"
unmount-on-close
@ok="submit"
@cancel="onCancel"
>
<p class="pc-contact__desc">{{ t('h5.contact.desc') }}</p>
<a-form :model="form" layout="vertical">
<a-form-item
:label="t('wechat.register.phone')"
field="phone"
:validate-status="errors.phone ? 'error' : undefined"
:help="errors.phone || undefined"
>
<a-input v-model="form.phone" allow-clear :placeholder="t('wechat.register.phonePlaceholder')" />
</a-form-item>
<a-form-item
:label="t('wechat.register.email')"
field="email"
:validate-status="errors.email ? 'error' : undefined"
:help="errors.email || undefined"
>
<a-input v-model="form.email" allow-clear :placeholder="t('wechat.register.emailPlaceholder')" />
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue';
import { CustomerService, type CustomerProfileCompleteness } from '@/api/customer';
defineOptions({ name: 'CompleteContactModal' });
const props = defineProps<{
visible: boolean;
profile?: CustomerProfileCompleteness | null;
}>();
const emit = defineEmits<{
'update:visible': [boolean];
success: [];
cancel: [];
}>();
const { t } = useI18n();
const saving = ref(false);
const form = reactive({ phone: '', email: '' });
const errors = reactive({ phone: '', email: '' });
watch(
() => props.visible,
(v) => {
if (!v) return;
form.phone = props.profile?.phone?.trim() || '';
form.email = props.profile?.email?.trim() || '';
errors.phone = '';
errors.email = '';
},
);
function onCancel() {
if (saving.value) return;
emit('update:visible', false);
emit('cancel');
}
function validate(): boolean {
errors.phone = '';
errors.email = '';
const phone = form.phone.trim();
const email = form.email.trim();
if (!phone) errors.phone = t('wechat.register.phonePlaceholder');
else if (!/^1\d{10}$/.test(phone) && !/^\+?\d{6,16}$/.test(phone)) {
errors.phone = t('h5.contact.phoneInvalid');
}
if (!email) errors.email = t('wechat.register.emailPlaceholder');
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.email = t('h5.contact.emailInvalid');
}
return !errors.phone && !errors.email;
}
async function submit() {
if (saving.value || !validate()) return;
saving.value = true;
try {
await CustomerService.completeContact({
phone: form.phone.trim(),
email: form.email.trim(),
});
Message.success(t('h5.contact.saved'));
emit('update:visible', false);
emit('success');
} catch (e: any) {
Message.error(e?.message || t('h5.contact.saveFailed'));
} finally {
saving.value = false;
}
}
</script>
<style scoped>
.pc-contact__desc {
margin: 0 0 16px;
font-size: 13px;
line-height: 1.55;
color: #4e5969;
}
</style>

View File

@ -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<typeof setInterval> | 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,

View File

@ -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 }
}
}

View File

@ -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);
}

View File

@ -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) }"
>

View File

@ -0,0 +1,252 @@
<template>
<a-drawer
:visible="visible"
placement="bottom"
height="auto"
:footer="false"
:header="false"
unmount-on-close
:mask-closable="!saving"
:drawer-style="drawerStyle"
popup-container="body"
class="h5-contact-drawer"
@cancel="onCancel"
>
<div class="h5-ct">
<div class="h5-ct__handle" aria-hidden="true" />
<header class="h5-ct__head">
<h3>{{ t('h5.contact.title') }}</h3>
<button type="button" class="h5-ct__close" :disabled="saving" @click="onCancel">
<icon-close />
</button>
</header>
<p class="h5-ct__desc">{{ t('h5.contact.desc') }}</p>
<div class="h5-ct__body">
<label class="h5-ct__label">{{ t('wechat.register.phone') }}</label>
<a-input
v-model="form.phone"
size="large"
allow-clear
inputmode="tel"
:placeholder="t('wechat.register.phonePlaceholder')"
:error="Boolean(errors.phone)"
/>
<p v-if="errors.phone" class="h5-ct__err">{{ errors.phone }}</p>
<label class="h5-ct__label">{{ t('wechat.register.email') }}</label>
<a-input
v-model="form.email"
size="large"
allow-clear
inputmode="email"
:placeholder="t('wechat.register.emailPlaceholder')"
:error="Boolean(errors.email)"
/>
<p v-if="errors.email" class="h5-ct__err">{{ errors.email }}</p>
</div>
<footer class="h5-ct__foot">
<button type="button" class="h5-ct__btn is-ghost" :disabled="saving" @click="onCancel">
{{ t('h5.contact.cancel') }}
</button>
<button type="button" class="h5-ct__btn" :disabled="saving" @click="submit">
<span v-if="saving" class="h5-ct__loading" aria-hidden="true" />
{{ t('h5.contact.saveContinue') }}
</button>
</footer>
</div>
</a-drawer>
</template>
<script lang="ts" setup>
import { reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue';
import { IconClose } from '@arco-design/web-vue/es/icon';
import { CustomerService, type CustomerProfileCompleteness } from '@/api/customer';
defineOptions({ name: 'H5CompleteContactSheet' });
const props = defineProps<{
visible: boolean;
profile?: CustomerProfileCompleteness | null;
}>();
const emit = defineEmits<{
'update:visible': [boolean];
success: [];
cancel: [];
}>();
const { t } = useI18n();
const saving = ref(false);
const form = reactive({ phone: '', email: '' });
const errors = reactive({ phone: '', email: '' });
const drawerStyle = {
borderRadius: '16px 16px 0 0',
maxHeight: '88vh',
};
watch(
() => props.visible,
(v) => {
if (!v) return;
form.phone = props.profile?.phone?.trim() || '';
form.email = props.profile?.email?.trim() || '';
errors.phone = '';
errors.email = '';
},
);
function onCancel() {
if (saving.value) return;
emit('update:visible', false);
emit('cancel');
}
function validate(): boolean {
errors.phone = '';
errors.email = '';
const phone = form.phone.trim();
const email = form.email.trim();
if (!phone) {
errors.phone = t('wechat.register.phonePlaceholder');
} else if (!/^1\d{10}$/.test(phone) && !/^\+?\d{6,16}$/.test(phone)) {
errors.phone = t('h5.contact.phoneInvalid');
}
if (!email) {
errors.email = t('wechat.register.emailPlaceholder');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.email = t('h5.contact.emailInvalid');
}
return !errors.phone && !errors.email;
}
async function submit() {
if (saving.value || !validate()) return;
saving.value = true;
try {
await CustomerService.completeContact({
phone: form.phone.trim(),
email: form.email.trim(),
});
Message.success(t('h5.contact.saved'));
emit('update:visible', false);
emit('success');
} catch (e: any) {
Message.error(e?.message || t('h5.contact.saveFailed'));
} finally {
saving.value = false;
}
}
</script>
<style lang="less" scoped>
.h5-ct {
padding: 8px 16px calc(16px + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
}
.h5-ct__handle {
width: 36px;
height: 4px;
margin: 4px auto 12px;
border-radius: 999px;
background: #e5e6eb;
}
.h5-ct__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
h3 {
margin: 0;
font-size: 17px;
font-weight: 700;
color: #1d2129;
}
}
.h5-ct__close {
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: transparent;
color: #86909c;
display: inline-flex;
align-items: center;
justify-content: center;
}
.h5-ct__desc {
margin: 0 0 14px;
font-size: 13px;
line-height: 1.55;
color: #4e5969;
}
.h5-ct__label {
display: block;
margin: 12px 0 6px;
font-size: 13px;
font-weight: 600;
color: #1d2129;
}
.h5-ct__err {
margin: 4px 0 0;
font-size: 12px;
color: #f53f3f;
}
.h5-ct__foot {
display: flex;
gap: 10px;
margin-top: 18px;
}
.h5-ct__btn {
flex: 1;
height: 44px;
border: none;
border-radius: 10px;
background: var(--app-primary-color);
color: #fff;
font-size: 15px;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
&.is-ghost {
background: #f2f3f5;
color: #4e5969;
}
&:disabled {
opacity: 0.65;
}
}
.h5-ct__loading {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
border-radius: 50%;
animation: h5-ct-spin 0.7s linear infinite;
}
@keyframes h5-ct-spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@ -2,13 +2,22 @@
<H5OrderTypeSheet v-model:visible="chooserVisible" @select="onSelectType" />
<H5PlaceOrderSheet v-model:visible="logisticsVisible" @success="onChildSuccess" />
<H5PickupOrderSheet v-model:visible="pickupVisible" @success="onChildSuccess" />
<H5CompleteContactSheet
v-model:visible="contactVisible"
:profile="contactProfile"
@success="onContactSuccess"
@cancel="onContactCancel"
/>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import type { CustomerProfileCompleteness } from '@/api/customer';
import { checkCustomerContactRequired } from '@/utils/customerContactGate';
import H5OrderTypeSheet from './H5OrderTypeSheet.vue';
import H5PlaceOrderSheet from './H5PlaceOrderSheet.vue';
import H5PickupOrderSheet from './H5PickupOrderSheet.vue';
import H5CompleteContactSheet from './H5CompleteContactSheet.vue';
defineOptions({ name: 'H5CreateOrderFlow' });
@ -22,15 +31,36 @@ type Phase = 'idle' | 'choose' | 'logistics' | 'pickup';
const phase = ref<Phase>('idle');
/** 从选择页切到表单时,避免 chooser 关闭误关整个 flow */
let switching = false;
const contactVisible = ref(false);
const contactProfile = ref<CustomerProfileCompleteness | null>(null);
const contactChecking = ref(false);
/** 补全成功后进入的阶段;取消则关闭整个 flow */
let pendingPhase: Phase | null = null;
watch(
() => props.visible,
(v) => {
if (v) {
phase.value = 'choose';
async (v) => {
if (!v) {
phase.value = 'idle';
contactVisible.value = false;
pendingPhase = null;
return;
}
phase.value = 'idle';
// 一进入下单流程即校验联系方式
contactChecking.value = true;
try {
const { needContact, profile } = await checkCustomerContactRequired();
if (needContact) {
contactProfile.value = profile;
pendingPhase = 'choose';
phase.value = 'idle';
contactVisible.value = true;
return;
}
phase.value = 'choose';
} finally {
contactChecking.value = false;
}
},
);
@ -69,6 +99,17 @@ function onSelectType(type: 'logistics' | 'pickup') {
});
}
function onContactSuccess() {
const next = pendingPhase || 'choose';
pendingPhase = null;
phase.value = next;
}
function onContactCancel() {
pendingPhase = null;
emit('update:visible', false);
}
function onChildSuccess() {
emit('success');
}

View File

@ -51,6 +51,7 @@ import {
IconRight,
IconDown,
} from '@arco-design/web-vue/es/icon';
import { h5Navigate } from '@/utils/device';
import H5WarehouseAddressSheet from './H5WarehouseAddressSheet.vue';
import H5ShipGuideSheet from './H5ShipGuideSheet.vue';
import H5PlaceOrderSheet from './H5PlaceOrderSheet.vue';
@ -146,9 +147,9 @@ function onStep(step: Step) {
const [k, v] = pair.split('=');
if (k) query[k] = v || '';
});
router.push({ path, query });
h5Navigate(router, { path, query });
} else {
router.push(path);
h5Navigate(router, path);
}
}
}

View File

@ -118,6 +118,7 @@ import { Modal } from '@arco-design/web-vue';
import { IconClose, IconClockCircle, IconCommon } from '@arco-design/web-vue/es/icon';
import { getOrderDetail } from '@/api/order';
import { postGetDetailTimeline } from '@/api/TrackOrder/trackOrder';
import { h5Navigate } from '@/utils/device';
import {
getOrderStatusMeta,
resolveOrderStatusCode,
@ -302,7 +303,7 @@ function onPayFail(error: unknown) {
okText: t('h5.pay.goRecharge'),
cancelText: t('h5.pay.later'),
onOk: () => {
router.push('/h5/recharge');
h5Navigate(router, '/h5/recharge');
},
});
}
@ -320,7 +321,7 @@ function askAdvisor() {
'h5Orders',
);
close();
router.push({
h5Navigate(router, {
path: '/h5/assistant',
query: buildAskAdvisorQuery(ctx),
});

View File

@ -83,6 +83,7 @@ import { getWarehouseList } from '@/api/system/warehouse';
import { getSalesProductList } from '@/api/channel/salesProduct';
import { createPickupOrder, getSenderAddressList } from '@/api/order/senderAddress';
import { pickField } from '@/utils/warehouseListMapper';
import { isCustomerContactRequiredError } from '@/utils/customerContactGate';
defineOptions({ name: 'H5PickupOrderSheet' });
@ -222,7 +223,11 @@ async function submit() {
emit('success');
emit('update:visible', false);
} catch (e: any) {
Message.error(e?.message || t('h5.journey.ship.pickupFailed'));
if (isCustomerContactRequiredError(e)) {
Message.warning(t('h5.contact.requiredToast'));
} else {
Message.error(e?.message || t('h5.journey.ship.pickupFailed'));
}
} finally {
submitting.value = false;
}

View File

@ -231,6 +231,8 @@ import { getCountryList } from '@/api/system/country';
import { getWarehouseList } from '@/api/system/warehouse';
import { normalizeCargoAttributeCode } from '@/utils/cargoAttribute';
import { pickField } from '@/utils/warehouseListMapper';
import { h5Navigate } from '@/utils/device';
import { isCustomerContactRequiredError } from '@/utils/customerContactGate';
defineOptions({ name: 'H5PlaceOrderSheet' });
@ -558,7 +560,7 @@ async function submit() {
cancelText: t('h5.placeOrder.stay'),
onOk: () => {
emit('update:visible', false);
router.push('/h5/orders');
h5Navigate(router, '/h5/orders');
},
onCancel: () => {
// 继续下单:重置表单留在抽屉
@ -569,7 +571,11 @@ async function submit() {
},
});
} catch (e: any) {
Message.error(e?.message || t('h5.placeOrder.failed'));
if (isCustomerContactRequiredError(e)) {
Message.warning(t('h5.contact.requiredToast'));
} else {
Message.error(e?.message || t('h5.placeOrder.failed'));
}
} finally {
submitting.value = false;
}

View File

@ -31,7 +31,7 @@
<button
type="button"
class="h5-ship__path is-primary"
@click="mode = mode === 'pickup' ? 'guide' : 'pickup'"
@click="togglePickupMode"
>
<strong>{{ t('h5.journey.ship.pickupTitle') }}</strong>
<span>{{ t('h5.journey.ship.pickupDesc') }}</span>
@ -113,6 +113,11 @@
</div>
<H5WarehouseAddressSheet v-model:visible="warehouseVisible" />
<H5CompleteContactSheet
v-model:visible="contactVisible"
:profile="contactProfile"
@success="onContactSuccess"
/>
</a-drawer>
</template>
@ -121,6 +126,7 @@ import { reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue';
import { IconClose, IconLocation } from '@arco-design/web-vue/es/icon';
import type { CustomerProfileCompleteness } from '@/api/customer';
import { getWarehouseList } from '@/api/system/warehouse';
import { getSalesProductList } from '@/api/channel/salesProduct';
import {
@ -129,7 +135,12 @@ import {
getSenderAddressList,
} from '@/api/order/senderAddress';
import { pickField } from '@/utils/warehouseListMapper';
import {
checkCustomerContactRequired,
isCustomerContactRequiredError,
} from '@/utils/customerContactGate';
import H5WarehouseAddressSheet from './H5WarehouseAddressSheet.vue';
import H5CompleteContactSheet from './H5CompleteContactSheet.vue';
import H5EmptyState from './H5EmptyState.vue';
defineOptions({ name: 'H5ShipGuideSheet' });
@ -140,6 +151,8 @@ const emit = defineEmits<{ 'update:visible': [boolean]; done: [] }>();
const { t } = useI18n();
const mode = ref<'guide' | 'pickup'>('guide');
const warehouseVisible = ref(false);
const contactVisible = ref(false);
const contactProfile = ref<CustomerProfileCompleteness | null>(null);
const loading = ref(false);
const showSenderForm = ref(false);
const savingSender = ref(false);
@ -279,6 +292,24 @@ async function saveSender() {
}
}
async function togglePickupMode() {
if (mode.value === 'pickup') {
mode.value = 'guide';
return;
}
const { needContact, profile } = await checkCustomerContactRequired();
if (needContact) {
contactProfile.value = profile;
contactVisible.value = true;
return;
}
mode.value = 'pickup';
}
function onContactSuccess() {
mode.value = 'pickup';
}
async function submitPickup() {
if (!pickupForm.senderAddressId) {
Message.warning(t('h5.journey.ship.pickSender'));
@ -315,7 +346,14 @@ async function submitPickup() {
emit('done');
close();
} catch (e: any) {
Message.error(e?.message || t('h5.journey.ship.pickupFailed'));
if (isCustomerContactRequiredError(e)) {
const { profile } = await checkCustomerContactRequired();
contactProfile.value = profile;
contactVisible.value = true;
Message.warning(t('h5.contact.requiredToast'));
} else {
Message.error(e?.message || t('h5.journey.ship.pickupFailed'));
}
} finally {
creatingPickup.value = false;
}

View File

@ -2,7 +2,11 @@
<div class="h5-wx-qr" :class="{ 'is-compact': compact }">
<div
class="h5-wx-qr__box"
:class="{ 'is-loading': showOverlay, 'is-error': loadError && !qrCodeUrl }"
:class="{
'is-loading': showOverlay,
'is-error': loadError && !qrCodeUrl,
'is-scanned': scanned,
}"
>
<!--
必须用原生 <img>:微信对 canvas 长按常无「识别图中二维码」菜单。
@ -11,17 +15,23 @@
<img
v-if="qrCodeUrl"
class="h5-wx-qr__img"
:class="{ 'is-ready': qrImageReady }"
:class="{ 'is-ready': qrImageReady, 'is-masked': scanned }"
:src="qrCodeUrl"
alt="wechat-login-qrcode"
:width="compact ? 256 : 200"
:height="compact ? 256 : 200"
referrerpolicy="no-referrer"
decoding="async"
draggable="false"
@contextmenu.prevent
@load="onQrImageLoad"
@error="onQrImageError"
/>
<div v-if="showOverlay" class="h5-wx-qr__overlay">
<div v-if="scanned" class="h5-wx-qr__overlay is-scanned">
<icon-check-circle-fill :size="32" class="h5-wx-qr__ok" />
<span>{{ t('h5.wxQr.scanned') }}</span>
</div>
<div v-else-if="showOverlay" class="h5-wx-qr__overlay">
<a-spin :size="28" />
<span>{{ t('h5.wxQr.loading') }}</span>
</div>
@ -33,28 +43,31 @@
</div>
</div>
<p class="h5-wx-qr__tip h5-wx-qr__tip--primary">{{ t('h5.wxQr.longPress') }}</p>
<template v-if="!compact">
<p class="h5-wx-qr__tip">{{ t('h5.wxQr.valid') }}</p>
<a-button long class="h5-wx-qr__refresh" :loading="loadingQr" @click="refreshQr">
{{ t('h5.wxQr.refresh') }}
</a-button>
<template v-if="!scanned">
<p class="h5-wx-qr__tip h5-wx-qr__tip--primary">{{ t('h5.wxQr.longPress') }}</p>
<template v-if="!compact">
<p class="h5-wx-qr__tip">{{ t('h5.wxQr.valid') }}</p>
<a-button long class="h5-wx-qr__refresh" :loading="loadingQr" @click="refreshQr">
{{ t('h5.wxQr.refresh') }}
</a-button>
</template>
<button
v-else
type="button"
class="h5-wx-qr__refresh-link"
:disabled="loadingQr"
@click="refreshQr"
>
{{ loadingQr ? t('h5.wxQr.loading') : t('h5.wxQr.refresh') }}
</button>
</template>
<button
v-else
type="button"
class="h5-wx-qr__refresh-link"
:disabled="loadingQr"
@click="refreshQr"
>
{{ loadingQr ? t('h5.wxQr.loading') : t('h5.wxQr.refresh') }}
</button>
</div>
</template>
<script lang="ts" setup>
import { watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { IconCheckCircleFill } from '@arco-design/web-vue/es/icon';
import type { LoginResultDto } from '@/api/rbac';
import { useWechatQrLogin } from '@/composables/useWechatQrLogin';
@ -86,6 +99,7 @@ const {
loadingQr,
loadError,
qrImageReady,
scanned,
showOverlay,
refreshQr,
pauseQr,
@ -135,9 +149,10 @@ defineExpose({ refreshQr, pauseQr });
align-items: center;
justify-content: center;
box-sizing: border-box;
/* 允许微信长按菜单(识别二维码) */
-webkit-touch-callout: default;
user-select: auto;
/* 禁 iOS 系统「保存图片/共享」;微信内「识别图中二维码」由客户端接管,仍可用 */
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
.h5-wx-qr.is-compact .h5-wx-qr__box {
@ -163,14 +178,22 @@ defineExpose({ refreshQr, pauseQr });
height: 200px;
object-fit: contain;
opacity: 0;
transition: opacity 0.35s cubic-bezier(0.22, 1, 0.36, 1);
transition: opacity 0.35s cubic-bezier(0.22, 1, 0.36, 1), filter 0.2s ease;
/* 勿加 pointer-events:none,否则无法长按识别 */
-webkit-touch-callout: default;
user-select: auto;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
-webkit-user-drag: none;
&.is-ready {
opacity: 1;
}
&.is-masked {
filter: blur(6px);
opacity: 0.35;
pointer-events: none;
}
}
.h5-wx-qr__overlay {
@ -193,6 +216,14 @@ defineExpose({ refreshQr, pauseQr });
&.is-error {
pointer-events: auto;
}
&.is-scanned {
pointer-events: none;
}
}
.h5-wx-qr__ok {
color: var(--app-primary-color);
}
.h5-wx-qr.is-compact .h5-wx-qr__overlay {

View File

@ -29,6 +29,7 @@ import { useI18n } from 'vue-i18n';
import { isLogin } from '@/utils/auth';
import { getOrderStatusCounts } from '@/api/order';
import { ensureFullLocaleMessages } from '@/locale';
import { h5Navigate } from '@/utils/device';
import { H5_ORDER_GROUPS, sumStatusCounts } from '@/constants/h5OrderStatus';
import LoginLangToggle from '@/views/login/components/LoginLangToggle.vue';
import AppTabBar from './components/AppTabBar.vue';
@ -44,7 +45,7 @@ const placeOrderVisible = ref(false);
function openPlaceOrder() {
if (!isLogin()) {
router.push({
h5Navigate(router, {
path: '/h5/me',
query: { redirect: '/h5/place-order' },
});

View File

@ -129,6 +129,7 @@ export default {
'h5.wxQr.refresh': 'Refresh QR code',
'h5.wxQr.longPress': 'Long-press → “Recognize QR Code in Image”',
'h5.wxQr.valid': 'QR code is valid for 10 minutes',
'h5.wxQr.scanned': 'Scanned. Signing you in…',
'h5.wxQr.success': 'Signed in',
'h5.login.title': 'Sign in',
@ -177,6 +178,17 @@ export default {
'h5.orderType.logisticsDesc': 'Ship overseas — recipient & declaration',
'h5.orderType.pickup': 'Pickup order',
'h5.orderType.pickupDesc': 'Door pickup to warehouse',
'h5.contact.title': 'Add your contact details',
'h5.contact.desc':
'Please leave a phone number and email before ordering or scheduling pickup. We use them for customs documents, delivery updates, and fee confirmations — so shipments are not delayed waiting for a reply.',
'h5.contact.cancel': 'Cancel',
'h5.contact.saveContinue': 'Save & continue',
'h5.contact.saved': 'Saved — you can continue',
'h5.contact.saveFailed': 'Save failed. Please retry.',
'h5.contact.phoneInvalid': 'Enter a valid phone number',
'h5.contact.emailInvalid': 'Enter a valid email',
'h5.contact.requiredToast': 'Add phone and email to continue ordering',
'h5.pickupCreate.title': 'Create pickup order',
'h5.placeOrder.title': 'Place order',

View File

@ -128,6 +128,7 @@ export default {
'h5.wxQr.refresh': '刷新二维码',
'h5.wxQr.longPress': '请长按二维码,选择「识别图中二维码」',
'h5.wxQr.valid': '二维码十分钟内有效',
'h5.wxQr.scanned': '扫码成功,正在登录…',
'h5.wxQr.success': '登录成功',
'h5.login.title': '登录',
@ -176,6 +177,17 @@ export default {
'h5.orderType.logisticsDesc': '寄往海外,填写收件人与申报信息',
'h5.orderType.pickup': '揽收订单',
'h5.orderType.pickupDesc': '上门取件入库,填写发件与揽收信息',
'h5.contact.title': '补充联系方式',
'h5.contact.desc':
'下单与揽收前请留下手机号和邮箱。清关补料、派送异常或费用确认时,我们会优先通过这些方式与您确认,避免延误。',
'h5.contact.cancel': '取消',
'h5.contact.saveContinue': '保存并继续',
'h5.contact.saved': '已保存,可以继续',
'h5.contact.saveFailed': '保存失败,请重试',
'h5.contact.phoneInvalid': '请输入有效手机号',
'h5.contact.emailInvalid': '请输入有效邮箱',
'h5.contact.requiredToast': '请先填写手机号和邮箱,再继续下单',
'h5.pickupCreate.title': '创建揽收订单',
'h5.placeOrder.title': '下单',

View File

@ -141,6 +141,7 @@ import { OmsLoginType, type LoginResultDto } from '@/api/rbac';
import { isLogin, setToken } from '@/utils/auth';
import { applyRbacUserToUserStore } from '@/utils/enter-system';
import { ensureFullLocaleMessages } from '@/locale';
import { h5Navigate } from '@/utils/device';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import logoImg from '@/assets/logo2.png';
import LoginLangToggle from '@/views/login/components/LoginLangToggle.vue';
@ -195,11 +196,11 @@ function goBack() {
}
function goRegister() {
router.push('/wechat/register');
h5Navigate(router, '/wechat/register');
}
function goBind() {
router.push('/wechat/bind');
h5Navigate(router, '/wechat/bind');
}
function openAgreement(type: 'privacy' | 'terms') {
@ -394,6 +395,11 @@ onMounted(() => {
border-radius: 16px;
background: #fff;
box-shadow: 0 8px 22px color-mix(in srgb, var(--app-primary-color) 18%, transparent);
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
-webkit-user-drag: none;
pointer-events: none;
}
.h5-login-hero__title {

View File

@ -221,6 +221,7 @@ import { resolveAvatarUrl } from '@/utils/avatar';
import { getMapIdFromAccessToken } from '@/utils/jwt';
import { applyRbacUserToUserStore } from '@/utils/enter-system';
import { ensureFullLocaleMessages } from '@/locale';
import { h5Navigate } from '@/utils/device';
import { useUserStore } from '@/store';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import useLocale from '@/hooks/locale';
@ -359,7 +360,7 @@ function onServiceClick(item: { path: string }) {
commissionVisible.value = true;
return;
}
router.push(item.path);
h5Navigate(router, item.path);
}
function onCouponUse() {
@ -416,16 +417,16 @@ async function onWxLoginSuccess(result: LoginResultDto) {
}
function goWechatRegister() {
router.push('/wechat/register');
h5Navigate(router, '/wechat/register');
}
function goWechatBind() {
router.push('/wechat/bind');
h5Navigate(router, '/wechat/bind');
}
function goPasswordLogin() {
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/h5/orders';
router.push({ path: '/h5/login', query: { redirect, tab: 'password' } });
h5Navigate(router, { path: '/h5/login', query: { redirect, tab: 'password' } });
}
function goRecharge() {
@ -1077,6 +1078,11 @@ onMounted(() => {
border-radius: 16px;
background: #fff;
box-shadow: 0 8px 22px color-mix(in srgb, var(--app-primary-color) 18%, transparent);
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
-webkit-user-drag: none;
pointer-events: none;
}
.h5-me-guest__title {

View File

@ -67,7 +67,7 @@
:icon="IconList"
:text="t('h5.orders.empty')"
>
<a-button type="outline" size="small" @click="router.push('/h5/price')">
<a-button type="outline" size="small" @click="h5Navigate(router, '/h5/price')">
{{ t('h5.orders.emptyGoPrice') }}
</a-button>
</H5EmptyState>
@ -197,6 +197,7 @@ import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Message, Modal } from '@arco-design/web-vue';
import { IconCopy, IconList, IconSearch } from '@arco-design/web-vue/es/icon';
import { h5Navigate } from '@/utils/device';
import H5EmptyState from '../components/H5EmptyState.vue';
import {
getOrderStatusCounts,
@ -481,14 +482,14 @@ function onPayFail(error: unknown) {
okText: t('h5.pay.goRecharge'),
cancelText: t('h5.pay.later'),
onOk: () => {
router.push('/h5/recharge');
h5Navigate(router, '/h5/recharge');
},
});
}
function askAdvisor(row: any) {
const ctx = buildWaybillContextFromRow(row, 'h5Orders');
router.push({
h5Navigate(router, {
path: '/h5/assistant',
query: buildAskAdvisorQuery(ctx),
});

View File

@ -116,7 +116,7 @@
</svg>
</div>
<p>{{ queryFailureText || t('h5.price.noResult') }}</p>
<a-button type="outline" size="small" @click="router.push('/h5/assistant')">
<a-button type="outline" size="small" @click="h5Navigate(router, '/h5/assistant')">
{{ t('h5.price.askAdvisor') }}
</a-button>
</div>
@ -364,6 +364,7 @@ import { getCountryList } from '@/api/system/country';
import { getWarehouseList } from '@/api/system/warehouse';
import { getDictListAll } from '@/api/system/dict';
import { getSalesProductDetail } from '@/api/channel/salesProduct';
import { h5Navigate } from '@/utils/device';
import H5PlaceOrderSheet from '../components/H5PlaceOrderSheet.vue';
defineOptions({ name: 'H5Price' });
@ -734,7 +735,7 @@ function askAdvisor(item: any) {
.filter(Boolean)
.join(' / ');
detailVisible.value = false;
router.push({
h5Navigate(router, {
path: '/h5/assistant',
query: { prefill: text },
});

View File

@ -1,11 +1,11 @@
<template>
<div class="wechat-login">
<div class="qr-card">
<div class="qr-box" :class="{ 'qr-box--loading': showOverlay, 'qr-box--error': loadError }">
<div class="qr-box" :class="{ 'qr-box--loading': showOverlay, 'qr-box--error': loadError, 'qr-box--scanned': scanned }">
<img
v-if="qrCodeUrl"
class="login-wx-qrcode"
:class="{ 'login-wx-qrcode--ready': qrImageReady }"
:class="{ 'login-wx-qrcode--ready': qrImageReady, 'login-wx-qrcode--masked': scanned }"
:src="qrCodeUrl"
referrerpolicy="no-referrer"
width="190"
@ -15,7 +15,11 @@
@load="qrImageReady = true"
@error="onQrImageError"
/>
<div v-if="showOverlay" class="qr-overlay">
<div v-if="scanned" class="qr-overlay qr-overlay--scanned">
<icon-check-circle-fill :size="36" class="qr-overlay__ok" />
<span class="qr-overlay__text">{{ t('login.wechat.scanned') }}</span>
</div>
<div v-else-if="showOverlay" class="qr-overlay">
<a-spin :size="28" />
<span class="qr-overlay__text">{{ t('login.wechat.loading') }}</span>
</div>
@ -26,13 +30,9 @@
</button>
</div>
</div>
<div class="tips tips--secondary">{{ t('login.wechat.longPress') }}</div>
<div class="tips">{{ t('login.wechat.qrValid') }}</div>
<div class="tips">{{ t('login.wechat.tips') }}</div>
<div v-if="polling && qrImageReady" class="tips tips--pending">
{{ t('login.wechat.scanPending') }}
</div>
<div v-if="!scanned" class="tips">{{ t('login.wechat.tips') }}</div>
<a-button
v-if="!scanned"
class="refresh"
:loading="loadingQr"
:disabled="loadingQr"
@ -61,7 +61,7 @@ import { ref, computed, onMounted, onBeforeUnmount, inject, nextTick } from 'vue
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue';
import { IconUser } from '@arco-design/web-vue/es/icon';
import { IconUser, IconCheckCircleFill } from '@arco-design/web-vue/es/icon';
import { OmsLoginType } from '@/api/rbac';
import { getLoginQrcode, getWxLoginStatus, isWxLoginPendingError, wxLogin } from '@/api/wechat';
import { useRbacUserStore } from '@/store/modules/rbac-user';
@ -89,6 +89,8 @@ const loadingQr = ref(false);
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),
@ -177,6 +179,9 @@ async function pollWxLogin() {
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;
@ -209,6 +214,7 @@ async function loadQrCode(options?: { keepPrevious?: boolean }) {
const seq = ++loadSeq;
clearTimers();
loadError.value = false;
scanned.value = false;
loadingQr.value = true;
// 刷新时保留旧图,避免白屏闪一下
if (!options?.keepPrevious) {
@ -326,6 +332,14 @@ onBeforeUnmount(() => {
background: #fafafa;
}
&--scanned {
background: rgba(255, 255, 255, 0.94);
}
&__ok {
color: var(--app-primary-color, #007979);
}
&__text {
font-size: 12px;
color: #86909c;
@ -356,7 +370,7 @@ onBeforeUnmount(() => {
max-width: 100%;
max-height: 100%;
opacity: 0;
transition: opacity 0.2s ease;
transition: opacity 0.2s ease, filter 0.2s ease;
/* 微信内长按识别:勿禁用触摸呼出 */
-webkit-touch-callout: default;
user-select: auto;
@ -364,6 +378,12 @@ onBeforeUnmount(() => {
&--ready {
opacity: 1;
}
&--masked {
filter: blur(6px);
opacity: 0.35;
pointer-events: none;
}
}
.tips {
@ -371,18 +391,8 @@ onBeforeUnmount(() => {
color: #666;
font-size: 12px;
text-align: center;
}
.tips--secondary {
margin-top: 4px;
color: var(--app-primary-color, #007979);
font-weight: 500;
}
.tips--pending {
margin-top: 4px;
color: #94a3b8;
font-size: 11px;
line-height: 1.5;
max-width: 240px;
}
.refresh {

View File

@ -18,7 +18,8 @@
'login.form.brandHint.admin': 'Welcome to IDE Operations Console',
'login.switchToWechat': 'Sign in with WeChat',
'login.switchToAccount': 'Account and password',
'login.wechat.tips': 'Scan with WeChat to sign in; in WeChat, long-press to recognize',
'login.wechat.tips': 'Scan with WeChat to sign in. QR code valid for 10 minutes.',
'login.wechat.scanned': 'Scanned. Signing you in…',
'login.wechat.qrValid': 'QR code valid for 10 minutes',
'login.wechat.scanPending': 'Waiting for scan…',
'login.wechat.loading': 'Loading QR code…',

View File

@ -18,7 +18,8 @@
'login.form.brandHint.admin': '欢迎登录 IDE 运营管理后台',
'login.switchToWechat': '微信扫码登录',
'login.switchToAccount': '账号密码登录',
'login.wechat.tips': '请使用微信扫一扫扫描二维码登录;手机微信内可长按识别',
'login.wechat.tips': '请使用微信扫一扫登录,二维码 10 分钟内有效',
'login.wechat.scanned': '扫码成功,正在登录…',
'login.wechat.qrValid': '二维码十分钟有效',
'login.wechat.scanPending': '等待扫码中…',
'login.wechat.longPress': '微信内请长按二维码识别',

View File

@ -14,6 +14,12 @@ import { getSalesProductList } from '@/api/channel/salesProduct'
import { getWarehouseList } from '@/api/system/warehouse'
import { $baseMessage } from '@/utils'
import { hasPermission } from '@/utils/rbac-permission'
import CompleteContactModal from '@/components/CompleteContactModal/index.vue'
import type { CustomerProfileCompleteness } from '@/api/customer'
import {
checkCustomerContactRequired,
isCustomerContactRequiredError,
} from '@/utils/customerContactGate'
import { formatDateTime, normalizeListRow, pickField } from '@/utils/warehouseListMapper'
defineOptions({ name: 'OrderPickupOrder' })
@ -32,6 +38,8 @@ const keyword = ref('')
const listLoading = ref(false)
const creating = ref(false)
const refLoading = ref(false)
const contactVisible = ref(false)
const contactProfile = ref<CustomerProfileCompleteness | null>(null)
const tableRows = ref<any[]>([])
const currentPage = ref(1)
const pageSize = ref(30)
@ -208,6 +216,15 @@ function resetForm() {
async function handleOpenCreate() {
if (!hasPermission('Order.Pickup.Create')) return
if (isAdmin.value && ensureMutationCustomerId() == null) return
if (!isAdmin.value) {
const { needContact, profile } = await checkCustomerContactRequired()
if (needContact) {
contactProfile.value = profile
contactVisible.value = true
$baseMessage(t('h5.contact.requiredToast'), 'warning')
return
}
}
await loadReferenceOptions()
if (!senderOptions.value.length) {
$baseMessage(t('order.pickupOrder.message.noSenderAddress'), 'warning')
@ -217,6 +234,10 @@ async function handleOpenCreate() {
openModal(true)
}
function onContactSuccess() {
void handleOpenCreate()
}
function validateForm() {
if (!formModel.senderAddressId) {
$baseMessage(t('order.pickupOrder.message.senderRequired'), 'warning')
@ -288,6 +309,11 @@ onMounted(async () => {
<template>
<div class="pickup-order-container warehouse-list-page">
<CompleteContactModal
v-model:visible="contactVisible"
:profile="contactProfile"
@success="onContactSuccess"
/>
<div class="warehouse-list-page-card">
<div class="table-toolbar">
<div class="toolbar-left">

View File

@ -25,6 +25,12 @@
import { normalizeListRow, pickField } from '@/utils/warehouseListMapper';
import { normalizeCargoAttributeCode } from '@/utils/cargoAttribute';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import {
checkCustomerContactRequired,
isCustomerContactRequiredError,
} from '@/utils/customerContactGate';
import CompleteContactModal from '@/components/CompleteContactModal/index.vue';
import type { CustomerProfileCompleteness } from '@/api/customer';
defineOptions({
name: 'PlaceOrder',
@ -36,6 +42,9 @@
const router = useRouter();
const route = useRoute();
const rbacUserStore = useRbacUserStore();
const contactVisible = ref(false);
const contactProfile = ref<CustomerProfileCompleteness | null>(null);
const contactBlocked = ref(false);
const activeOrderMode = ref<'batch' | 'single'>('single');
const uploadDragActive = ref(false);
const priceCheckHandoff = ref<{
@ -1625,8 +1634,27 @@
onMounted(() => {
loadSingleFormReferenceOptions();
void (async () => {
const { needContact, profile } = await checkCustomerContactRequired();
if (needContact) {
contactBlocked.value = true;
contactProfile.value = profile;
contactVisible.value = true;
}
})();
});
function onContactCompleted() {
contactBlocked.value = false;
}
function onContactCancelled() {
if (contactBlocked.value) {
Message.warning(t('h5.contact.requiredToast'));
router.replace('/home/workplace').catch(() => undefined);
}
}
function clearUploadedFile() {
pendingUploadFile.value = null;
if (nativeFileInputRef.value) {
@ -2121,7 +2149,13 @@
</script>
<template>
<div class="place-order-container">
<div class="place-order-container" :class="{ 'is-contact-blocked': contactBlocked }">
<CompleteContactModal
v-model:visible="contactVisible"
:profile="contactProfile"
@success="onContactCompleted"
@cancel="onContactCancelled"
/>
<a-tabs v-model:active-key="activeOrderMode" type="line" class="place-order-tabs">
<a-tab-pane key="single" :title="t('order.placeOrder.singleOrder')">
<div class="single-tab">

View File

@ -47,6 +47,8 @@
:src="wxUserInfo.headimgurl"
alt=""
class="wechat-h5-hero__avatar"
draggable="false"
@contextmenu.prevent
/>
<div v-else class="wechat-h5-hero__avatar wechat-h5-hero__fallback">
{{ avatarLetter }}

View File

@ -2,11 +2,12 @@ export default {
'wechat.common.loading': 'Getting WeChat authorization…',
'wechat.common.retry': 'Retry authorization',
'wechat.register.title': 'Complete your profile',
'wechat.register.subtitle': 'A few details, then you can sign in with WeChat',
'wechat.register.title': 'Confirm registration',
'wechat.register.subtitle': 'WeChat name and a default password are ready — confirm to finish',
'wechat.register.success': 'Registered',
'wechat.register.successDesc': 'Your account is ready and linked to this WeChat',
'wechat.register.submit': 'Create account',
'wechat.register.successDesc':
'Account linked to this WeChat. Sign in with customer code or WeChat QR.',
'wechat.register.submit': 'Confirm',
'wechat.register.submitting': 'Submitting…',
'wechat.register.alreadyBound': 'This WeChat is already linked. Please sign in.',
'wechat.register.alreadyBoundTip': 'Sign in with WeChat QR or your account password',
@ -20,20 +21,20 @@ export default {
'wechat.register.password': 'Password',
'wechat.register.customerCodePlaceholder': 'Enter customer code',
'wechat.register.customerNamePlaceholder': 'What should we call you',
'wechat.register.phonePlaceholder': 'For notices and recovery',
'wechat.register.emailPlaceholder': 'For invoices and notices',
'wechat.register.phonePlaceholder': 'Enter phone number',
'wechat.register.emailPlaceholder': 'Enter email',
'wechat.register.passwordPlaceholder': 'Set a password',
'wechat.register.passwordMinLength': 'At least 6 characters',
'wechat.register.passwordLetterAndDigit': 'Must include letters and numbers',
'wechat.register.passwordTipPrefix': 'Default password prefilled: ',
'wechat.register.passwordTipSuffix': '. Keep it or change it.',
'wechat.register.hint': 'After registering, sign in with WeChat QR or password',
'wechat.register.passwordTipPrefix': 'Default password ',
'wechat.register.passwordTipSuffix': ' — keep it or change it',
'wechat.register.hint': 'After registering, sign in with WeChat QR or customer code + password',
'wechat.register.goCenter': 'Open customer center',
'wechat.register.goLogin': 'Sign in',
'wechat.register.hasAccount': 'Already have an account?',
'wechat.register.goBind': 'Bind WeChat',
'wechat.register.wxUser': 'WeChat user',
'wechat.register.wxReady': 'WeChat authorized — finish the form below',
'wechat.register.wxReady': 'Authorized — review and confirm',
'wechat.register.referrerHint': 'Invited by {name} ({code})',
'wechat.register.invalidRef': 'Invite code is invalid or disabled. Ask your inviter for a new link.',
'wechat.register.openInWechat': 'Open in WeChat',

View File

@ -2,11 +2,11 @@ export default {
'wechat.common.loading': '正在获取微信授权…',
'wechat.common.retry': '重新授权',
'wechat.register.title': '完善资料,完成注册',
'wechat.register.subtitle': '只需填几项信息,之后可用微信扫码登录',
'wechat.register.title': '确认注册',
'wechat.register.subtitle': '已带入微信昵称与初始密码,确认后即可完成',
'wechat.register.success': '注册成功',
'wechat.register.successDesc': '账号已创建并绑定当前微信,可直接进入客户中心',
'wechat.register.submit': '完成注册',
'wechat.register.successDesc': '账号已创建并绑定当前微信;登录可用客户编号或微信扫码',
'wechat.register.submit': '确认注册',
'wechat.register.submitting': '提交中…',
'wechat.register.alreadyBound': '该微信已绑定账号,请直接登录',
'wechat.register.alreadyBoundTip': '请使用微信扫码或账号密码登录',
@ -20,20 +20,20 @@ export default {
'wechat.register.password': '登录密码',
'wechat.register.customerCodePlaceholder': '请输入客户编号',
'wechat.register.customerNamePlaceholder': '怎么称呼您',
'wechat.register.phonePlaceholder': '用于接收通知与找回密码',
'wechat.register.emailPlaceholder': '用于接收账单与通知',
'wechat.register.phonePlaceholder': '请输入手机号',
'wechat.register.emailPlaceholder': '请输入邮箱',
'wechat.register.passwordPlaceholder': '请设置登录密码',
'wechat.register.passwordMinLength': '密码至少 6 位',
'wechat.register.passwordLetterAndDigit': '密码须同时包含字母和数字',
'wechat.register.passwordTipPrefix': '已为您预填初始密码 ',
'wechat.register.passwordTipSuffix': ',可直接使用或自行修改',
'wechat.register.hint': '注册后可用微信扫码或账号密码登录',
'wechat.register.passwordTipPrefix': '初始密码 ',
'wechat.register.passwordTipSuffix': ',可直接用,也可改成自己的',
'wechat.register.hint': '注册后可用微信扫码或客户编号+密码登录',
'wechat.register.goCenter': '进入客户中心',
'wechat.register.goLogin': '去登录',
'wechat.register.hasAccount': '已有系统账号?',
'wechat.register.goBind': '去绑定微信',
'wechat.register.wxUser': '微信用户',
'wechat.register.wxReady': '微信已授权,请完善以下信息',
'wechat.register.wxReady': '微信已授权,核对后点确认即可',
'wechat.register.referrerHint': '您由 {name}({code})邀请注册',
'wechat.register.invalidRef': '邀请码无效或已停用,请联系邀请人或重新扫码',
'wechat.register.openInWechat': '请在微信中打开',

View File

@ -67,6 +67,8 @@
:src="wxUserInfo.headimgurl"
alt=""
class="wechat-h5-hero__avatar"
draggable="false"
@contextmenu.prevent
/>
<div v-else class="wechat-h5-hero__avatar wechat-h5-hero__fallback">
{{ avatarLetter }}
@ -124,24 +126,6 @@
allow-clear
/>
</a-form-item>
<a-form-item field="phone" :label="t('wechat.register.phone')" required>
<a-input
v-model="formState.phone"
size="large"
:placeholder="t('wechat.register.phonePlaceholder')"
allow-clear
inputmode="tel"
/>
</a-form-item>
<a-form-item field="email" :label="t('wechat.register.email')" required>
<a-input
v-model="formState.email"
size="large"
:placeholder="t('wechat.register.emailPlaceholder')"
allow-clear
inputmode="email"
/>
</a-form-item>
<a-form-item field="password" :label="t('wechat.register.password')" required>
<a-input-password
v-model="formState.password"
@ -151,7 +135,6 @@
allow-clear
/>
</a-form-item>
<p class="wechat-h5-pwd-tip">
{{ t('wechat.register.passwordTipPrefix') }}
<strong>{{ DEFAULT_CUSTOMER_PASSWORD }}</strong>
@ -261,8 +244,6 @@ const referrerInfo = ref<{ name: string; code: string } | null>(null);
const formState = reactive({
customerName: '',
phone: '',
email: '',
password: DEFAULT_CUSTOMER_PASSWORD,
});
@ -273,8 +254,6 @@ const avatarLetter = computed(() => {
const formRules: Record<string, FieldRule | FieldRule[]> = {
customerName: [{ required: true, message: t('wechat.register.customerNamePlaceholder') }],
phone: [{ required: true, message: t('wechat.register.phonePlaceholder') }],
email: [{ required: true, message: t('wechat.register.emailPlaceholder') }],
password: [
{ required: true, message: t('wechat.register.passwordPlaceholder') },
{ minLength: 6, message: t('wechat.register.passwordMinLength') },
@ -349,10 +328,8 @@ async function handleSubmit() {
submitting.value = true;
try {
await registerByWechat({
const result = await registerByWechat({
customerName: formState.customerName.trim(),
phone: formState.phone.trim(),
email: formState.email.trim(),
password: formState.password,
...(canBindInvite ? { inviteCode: inviteCode.value } : {}),
wechat: {
@ -364,11 +341,14 @@ async function handleSubmit() {
});
clearStashedWeChatInviteRef();
Message.success(t('wechat.register.success'));
const loggedIn = await loginCustomerAfterWechatAuth({
account: formState.phone.trim(),
password: formState.password,
redirectPath: '/h5/me',
});
const account = String(result?.customerCode || '').trim();
const loggedIn = account
? await loginCustomerAfterWechatAuth({
account,
password: formState.password,
redirectPath: '/h5/me',
})
: false;
if (!loggedIn) {
success.value = true;
}

View File

@ -59,6 +59,11 @@
object-fit: cover;
background: #fff;
box-shadow: 0 8px 22px color-mix(in srgb, var(--app-primary-color) 18%, transparent);
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
-webkit-user-drag: none;
pointer-events: none;
}
.wechat-h5-hero__fallback {
@ -181,6 +186,12 @@
color: #86909c;
text-align: center;
line-height: 1.6;
&--inline {
margin-top: 8px;
text-align: left;
color: #4e5969;
}
}
.wechat-h5-switch {