feat: simplify WeChat register UX and gate orders on contact info
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
abfca7a21a
commit
8cf883c90c
186
docs/微信注册简化与联系方式补全方案.md
Normal file
186
docs/微信注册简化与联系方式补全方案.md
Normal 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. 联调与验收清单勾选
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/** 微信授权绑定已有客户 */
|
||||
|
||||
@ -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;
|
||||
|
||||
4
src/auto-imports.d.ts
vendored
4
src/auto-imports.d.ts
vendored
@ -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
1
src/components.d.ts
vendored
@ -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']
|
||||
|
||||
117
src/components/CompleteContactModal/index.vue
Normal file
117
src/components/CompleteContactModal/index.vue
Normal 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>
|
||||
@ -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,
|
||||
|
||||
36
src/utils/customerContactGate.ts
Normal file
36
src/utils/customerContactGate.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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) }"
|
||||
>
|
||||
|
||||
@ -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>
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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' },
|
||||
});
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '下单',
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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),
|
||||
});
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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…',
|
||||
|
||||
@ -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': '微信内请长按二维码识别',
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 }}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '请在微信中打开',
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user