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
|
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>) {
|
function mapUpdateBody(id: string | number, data: Partial<CustomerDto>) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@ -169,6 +178,27 @@ export const CustomerService = {
|
|||||||
assertBusinessOk(r.data)
|
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<{
|
async omsLogin(customerId: string | number): Promise<{
|
||||||
accessToken: string
|
accessToken: string
|
||||||
refreshToken?: string | null
|
refreshToken?: string | null
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { omsUrl, unwrapData } from '@/api/oms/common';
|
|||||||
import type {
|
import type {
|
||||||
WeChatCustomerBindInput,
|
WeChatCustomerBindInput,
|
||||||
WeChatCustomerRegisterInput,
|
WeChatCustomerRegisterInput,
|
||||||
|
WeChatCustomerRegisterResult,
|
||||||
WeChatOAuthUserInfo,
|
WeChatOAuthUserInfo,
|
||||||
WxLoginQrcodeResult,
|
WxLoginQrcodeResult,
|
||||||
WxLoginResult,
|
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);
|
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 {
|
export interface WeChatCustomerRegisterInput {
|
||||||
customerCode?: string;
|
customerCode?: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
phone: string;
|
phone?: string;
|
||||||
email: string;
|
email?: string;
|
||||||
password: string;
|
password: string;
|
||||||
/** 邀请码(来自 ?ref=,注册时绑定引荐人) */
|
/** 邀请码(来自 ?ref=,注册时绑定引荐人) */
|
||||||
inviteCode?: string;
|
inviteCode?: string;
|
||||||
wechat: WeChatProfileInput;
|
wechat: WeChatProfileInput;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WeChatCustomerRegisterResult {
|
||||||
|
customerCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WeChatCustomerBindInput {
|
export interface WeChatCustomerBindInput {
|
||||||
account: string;
|
account: string;
|
||||||
password: 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
|
// @ts-ignore
|
||||||
export type { WarehouseItem } from './composables/useWarehouseList'
|
export type { WarehouseItem } from './composables/useWarehouseList'
|
||||||
import('./composables/useWarehouseList')
|
import('./composables/useWarehouseList')
|
||||||
|
// @ts-ignore
|
||||||
|
export type { WechatQrLoginOptions } from './composables/useWechatQrLogin'
|
||||||
|
import('./composables/useWechatQrLogin')
|
||||||
}
|
}
|
||||||
|
|
||||||
// for vue template auto import
|
// for vue template auto import
|
||||||
@ -629,6 +632,7 @@ declare module 'vue' {
|
|||||||
readonly useWebSocket: UnwrapRef<typeof import('@vueuse/core')['useWebSocket']>
|
readonly useWebSocket: UnwrapRef<typeof import('@vueuse/core')['useWebSocket']>
|
||||||
readonly useWebWorker: UnwrapRef<typeof import('@vueuse/core')['useWebWorker']>
|
readonly useWebWorker: UnwrapRef<typeof import('@vueuse/core')['useWebWorker']>
|
||||||
readonly useWebWorkerFn: UnwrapRef<typeof import('@vueuse/core')['useWebWorkerFn']>
|
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 useWindowFocus: UnwrapRef<typeof import('@vueuse/core')['useWindowFocus']>
|
||||||
readonly useWindowScroll: UnwrapRef<typeof import('@vueuse/core')['useWindowScroll']>
|
readonly useWindowScroll: UnwrapRef<typeof import('@vueuse/core')['useWindowScroll']>
|
||||||
readonly useWindowSize: UnwrapRef<typeof import('@vueuse/core')['useWindowSize']>
|
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']
|
Cascader: typeof import('./components/CascaderPro/Cascader.vue')['default']
|
||||||
Chart: typeof import('./components/chart/index.vue')['default']
|
Chart: typeof import('./components/chart/index.vue')['default']
|
||||||
CodeMirror: typeof import('./components/CodeMirror/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']
|
ContactService: typeof import('./components/ContactService/index.vue')['default']
|
||||||
CustomLeftMenu: typeof import('./components/CustomLeftMenu/index.vue')['default']
|
CustomLeftMenu: typeof import('./components/CustomLeftMenu/index.vue')['default']
|
||||||
CustomList: typeof import('./components/CustomList/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 loadError = ref(false);
|
||||||
const qrImageReady = ref(false);
|
const qrImageReady = ref(false);
|
||||||
const polling = ref(false);
|
const polling = ref(false);
|
||||||
|
/** 已扫码:遮罩二维码,防止重复扫 */
|
||||||
|
const scanned = ref(false);
|
||||||
|
|
||||||
const showOverlay = computed(
|
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;
|
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
@ -79,10 +83,12 @@ export function useWechatQrLogin(options: WechatQrLoginOptions) {
|
|||||||
const status = await getWxLoginStatus(loginUuid.value);
|
const status = await getWxLoginStatus(loginUuid.value);
|
||||||
if (!status?.ready || disposed) return;
|
if (!status?.ready || disposed) return;
|
||||||
|
|
||||||
|
scanned.value = true;
|
||||||
|
clearTimers();
|
||||||
|
|
||||||
const result = await wxLogin(loginUuid.value);
|
const result = await wxLogin(loginUuid.value);
|
||||||
if (!result?.accessToken || disposed) return;
|
if (!result?.accessToken || disposed) return;
|
||||||
|
|
||||||
clearTimers();
|
|
||||||
await options.onSuccess(result);
|
await options.onSuccess(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isWxLoginPendingError(error)) {
|
if (!isWxLoginPendingError(error)) {
|
||||||
@ -121,6 +127,7 @@ export function useWechatQrLogin(options: WechatQrLoginOptions) {
|
|||||||
const seq = ++loadSeq;
|
const seq = ++loadSeq;
|
||||||
clearTimers();
|
clearTimers();
|
||||||
loadError.value = false;
|
loadError.value = false;
|
||||||
|
scanned.value = false;
|
||||||
loadingQr.value = true;
|
loadingQr.value = true;
|
||||||
if (!opts?.keepPrevious) {
|
if (!opts?.keepPrevious) {
|
||||||
qrCodeUrl.value = '';
|
qrCodeUrl.value = '';
|
||||||
@ -194,6 +201,7 @@ export function useWechatQrLogin(options: WechatQrLoginOptions) {
|
|||||||
loadError,
|
loadError,
|
||||||
qrImageReady,
|
qrImageReady,
|
||||||
polling,
|
polling,
|
||||||
|
scanned,
|
||||||
showOverlay,
|
showOverlay,
|
||||||
loadQrCode,
|
loadQrCode,
|
||||||
refreshQr,
|
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;
|
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"
|
v-for="item in leftTabs"
|
||||||
:key="item.path"
|
:key="item.path"
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
|
replace
|
||||||
class="h5-tabbar__item"
|
class="h5-tabbar__item"
|
||||||
:class="{ 'is-active': isActive(item.path) }"
|
:class="{ 'is-active': isActive(item.path) }"
|
||||||
>
|
>
|
||||||
@ -29,6 +30,7 @@
|
|||||||
v-for="item in rightTabs"
|
v-for="item in rightTabs"
|
||||||
:key="item.path"
|
:key="item.path"
|
||||||
:to="item.path"
|
:to="item.path"
|
||||||
|
replace
|
||||||
class="h5-tabbar__item"
|
class="h5-tabbar__item"
|
||||||
:class="{ 'is-active': isActive(item.path) }"
|
: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" />
|
<H5OrderTypeSheet v-model:visible="chooserVisible" @select="onSelectType" />
|
||||||
<H5PlaceOrderSheet v-model:visible="logisticsVisible" @success="onChildSuccess" />
|
<H5PlaceOrderSheet v-model:visible="logisticsVisible" @success="onChildSuccess" />
|
||||||
<H5PickupOrderSheet v-model:visible="pickupVisible" @success="onChildSuccess" />
|
<H5PickupOrderSheet v-model:visible="pickupVisible" @success="onChildSuccess" />
|
||||||
|
<H5CompleteContactSheet
|
||||||
|
v-model:visible="contactVisible"
|
||||||
|
:profile="contactProfile"
|
||||||
|
@success="onContactSuccess"
|
||||||
|
@cancel="onContactCancel"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import type { CustomerProfileCompleteness } from '@/api/customer';
|
||||||
|
import { checkCustomerContactRequired } from '@/utils/customerContactGate';
|
||||||
import H5OrderTypeSheet from './H5OrderTypeSheet.vue';
|
import H5OrderTypeSheet from './H5OrderTypeSheet.vue';
|
||||||
import H5PlaceOrderSheet from './H5PlaceOrderSheet.vue';
|
import H5PlaceOrderSheet from './H5PlaceOrderSheet.vue';
|
||||||
import H5PickupOrderSheet from './H5PickupOrderSheet.vue';
|
import H5PickupOrderSheet from './H5PickupOrderSheet.vue';
|
||||||
|
import H5CompleteContactSheet from './H5CompleteContactSheet.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'H5CreateOrderFlow' });
|
defineOptions({ name: 'H5CreateOrderFlow' });
|
||||||
|
|
||||||
@ -22,15 +31,36 @@ type Phase = 'idle' | 'choose' | 'logistics' | 'pickup';
|
|||||||
const phase = ref<Phase>('idle');
|
const phase = ref<Phase>('idle');
|
||||||
/** 从选择页切到表单时,避免 chooser 关闭误关整个 flow */
|
/** 从选择页切到表单时,避免 chooser 关闭误关整个 flow */
|
||||||
let switching = false;
|
let switching = false;
|
||||||
|
const contactVisible = ref(false);
|
||||||
|
const contactProfile = ref<CustomerProfileCompleteness | null>(null);
|
||||||
|
const contactChecking = ref(false);
|
||||||
|
/** 补全成功后进入的阶段;取消则关闭整个 flow */
|
||||||
|
let pendingPhase: Phase | null = null;
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.visible,
|
() => props.visible,
|
||||||
(v) => {
|
async (v) => {
|
||||||
if (v) {
|
if (!v) {
|
||||||
phase.value = 'choose';
|
phase.value = 'idle';
|
||||||
|
contactVisible.value = false;
|
||||||
|
pendingPhase = null;
|
||||||
return;
|
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() {
|
function onChildSuccess() {
|
||||||
emit('success');
|
emit('success');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,6 +51,7 @@ import {
|
|||||||
IconRight,
|
IconRight,
|
||||||
IconDown,
|
IconDown,
|
||||||
} from '@arco-design/web-vue/es/icon';
|
} from '@arco-design/web-vue/es/icon';
|
||||||
|
import { h5Navigate } from '@/utils/device';
|
||||||
import H5WarehouseAddressSheet from './H5WarehouseAddressSheet.vue';
|
import H5WarehouseAddressSheet from './H5WarehouseAddressSheet.vue';
|
||||||
import H5ShipGuideSheet from './H5ShipGuideSheet.vue';
|
import H5ShipGuideSheet from './H5ShipGuideSheet.vue';
|
||||||
import H5PlaceOrderSheet from './H5PlaceOrderSheet.vue';
|
import H5PlaceOrderSheet from './H5PlaceOrderSheet.vue';
|
||||||
@ -146,9 +147,9 @@ function onStep(step: Step) {
|
|||||||
const [k, v] = pair.split('=');
|
const [k, v] = pair.split('=');
|
||||||
if (k) query[k] = v || '';
|
if (k) query[k] = v || '';
|
||||||
});
|
});
|
||||||
router.push({ path, query });
|
h5Navigate(router, { path, query });
|
||||||
} else {
|
} 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 { IconClose, IconClockCircle, IconCommon } from '@arco-design/web-vue/es/icon';
|
||||||
import { getOrderDetail } from '@/api/order';
|
import { getOrderDetail } from '@/api/order';
|
||||||
import { postGetDetailTimeline } from '@/api/TrackOrder/trackOrder';
|
import { postGetDetailTimeline } from '@/api/TrackOrder/trackOrder';
|
||||||
|
import { h5Navigate } from '@/utils/device';
|
||||||
import {
|
import {
|
||||||
getOrderStatusMeta,
|
getOrderStatusMeta,
|
||||||
resolveOrderStatusCode,
|
resolveOrderStatusCode,
|
||||||
@ -302,7 +303,7 @@ function onPayFail(error: unknown) {
|
|||||||
okText: t('h5.pay.goRecharge'),
|
okText: t('h5.pay.goRecharge'),
|
||||||
cancelText: t('h5.pay.later'),
|
cancelText: t('h5.pay.later'),
|
||||||
onOk: () => {
|
onOk: () => {
|
||||||
router.push('/h5/recharge');
|
h5Navigate(router, '/h5/recharge');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -320,7 +321,7 @@ function askAdvisor() {
|
|||||||
'h5Orders',
|
'h5Orders',
|
||||||
);
|
);
|
||||||
close();
|
close();
|
||||||
router.push({
|
h5Navigate(router, {
|
||||||
path: '/h5/assistant',
|
path: '/h5/assistant',
|
||||||
query: buildAskAdvisorQuery(ctx),
|
query: buildAskAdvisorQuery(ctx),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -83,6 +83,7 @@ import { getWarehouseList } from '@/api/system/warehouse';
|
|||||||
import { getSalesProductList } from '@/api/channel/salesProduct';
|
import { getSalesProductList } from '@/api/channel/salesProduct';
|
||||||
import { createPickupOrder, getSenderAddressList } from '@/api/order/senderAddress';
|
import { createPickupOrder, getSenderAddressList } from '@/api/order/senderAddress';
|
||||||
import { pickField } from '@/utils/warehouseListMapper';
|
import { pickField } from '@/utils/warehouseListMapper';
|
||||||
|
import { isCustomerContactRequiredError } from '@/utils/customerContactGate';
|
||||||
|
|
||||||
defineOptions({ name: 'H5PickupOrderSheet' });
|
defineOptions({ name: 'H5PickupOrderSheet' });
|
||||||
|
|
||||||
@ -222,7 +223,11 @@ async function submit() {
|
|||||||
emit('success');
|
emit('success');
|
||||||
emit('update:visible', false);
|
emit('update:visible', false);
|
||||||
} catch (e: any) {
|
} 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 {
|
} finally {
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -231,6 +231,8 @@ import { getCountryList } from '@/api/system/country';
|
|||||||
import { getWarehouseList } from '@/api/system/warehouse';
|
import { getWarehouseList } from '@/api/system/warehouse';
|
||||||
import { normalizeCargoAttributeCode } from '@/utils/cargoAttribute';
|
import { normalizeCargoAttributeCode } from '@/utils/cargoAttribute';
|
||||||
import { pickField } from '@/utils/warehouseListMapper';
|
import { pickField } from '@/utils/warehouseListMapper';
|
||||||
|
import { h5Navigate } from '@/utils/device';
|
||||||
|
import { isCustomerContactRequiredError } from '@/utils/customerContactGate';
|
||||||
|
|
||||||
defineOptions({ name: 'H5PlaceOrderSheet' });
|
defineOptions({ name: 'H5PlaceOrderSheet' });
|
||||||
|
|
||||||
@ -558,7 +560,7 @@ async function submit() {
|
|||||||
cancelText: t('h5.placeOrder.stay'),
|
cancelText: t('h5.placeOrder.stay'),
|
||||||
onOk: () => {
|
onOk: () => {
|
||||||
emit('update:visible', false);
|
emit('update:visible', false);
|
||||||
router.push('/h5/orders');
|
h5Navigate(router, '/h5/orders');
|
||||||
},
|
},
|
||||||
onCancel: () => {
|
onCancel: () => {
|
||||||
// 继续下单:重置表单留在抽屉
|
// 继续下单:重置表单留在抽屉
|
||||||
@ -569,7 +571,11 @@ async function submit() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} 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 {
|
} finally {
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,7 +31,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="h5-ship__path is-primary"
|
class="h5-ship__path is-primary"
|
||||||
@click="mode = mode === 'pickup' ? 'guide' : 'pickup'"
|
@click="togglePickupMode"
|
||||||
>
|
>
|
||||||
<strong>{{ t('h5.journey.ship.pickupTitle') }}</strong>
|
<strong>{{ t('h5.journey.ship.pickupTitle') }}</strong>
|
||||||
<span>{{ t('h5.journey.ship.pickupDesc') }}</span>
|
<span>{{ t('h5.journey.ship.pickupDesc') }}</span>
|
||||||
@ -113,6 +113,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<H5WarehouseAddressSheet v-model:visible="warehouseVisible" />
|
<H5WarehouseAddressSheet v-model:visible="warehouseVisible" />
|
||||||
|
<H5CompleteContactSheet
|
||||||
|
v-model:visible="contactVisible"
|
||||||
|
:profile="contactProfile"
|
||||||
|
@success="onContactSuccess"
|
||||||
|
/>
|
||||||
</a-drawer>
|
</a-drawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -121,6 +126,7 @@ import { reactive, ref, watch } from 'vue';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { Message } from '@arco-design/web-vue';
|
import { Message } from '@arco-design/web-vue';
|
||||||
import { IconClose, IconLocation } from '@arco-design/web-vue/es/icon';
|
import { IconClose, IconLocation } from '@arco-design/web-vue/es/icon';
|
||||||
|
import type { CustomerProfileCompleteness } from '@/api/customer';
|
||||||
import { getWarehouseList } from '@/api/system/warehouse';
|
import { getWarehouseList } from '@/api/system/warehouse';
|
||||||
import { getSalesProductList } from '@/api/channel/salesProduct';
|
import { getSalesProductList } from '@/api/channel/salesProduct';
|
||||||
import {
|
import {
|
||||||
@ -129,7 +135,12 @@ import {
|
|||||||
getSenderAddressList,
|
getSenderAddressList,
|
||||||
} from '@/api/order/senderAddress';
|
} from '@/api/order/senderAddress';
|
||||||
import { pickField } from '@/utils/warehouseListMapper';
|
import { pickField } from '@/utils/warehouseListMapper';
|
||||||
|
import {
|
||||||
|
checkCustomerContactRequired,
|
||||||
|
isCustomerContactRequiredError,
|
||||||
|
} from '@/utils/customerContactGate';
|
||||||
import H5WarehouseAddressSheet from './H5WarehouseAddressSheet.vue';
|
import H5WarehouseAddressSheet from './H5WarehouseAddressSheet.vue';
|
||||||
|
import H5CompleteContactSheet from './H5CompleteContactSheet.vue';
|
||||||
import H5EmptyState from './H5EmptyState.vue';
|
import H5EmptyState from './H5EmptyState.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'H5ShipGuideSheet' });
|
defineOptions({ name: 'H5ShipGuideSheet' });
|
||||||
@ -140,6 +151,8 @@ const emit = defineEmits<{ 'update:visible': [boolean]; done: [] }>();
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const mode = ref<'guide' | 'pickup'>('guide');
|
const mode = ref<'guide' | 'pickup'>('guide');
|
||||||
const warehouseVisible = ref(false);
|
const warehouseVisible = ref(false);
|
||||||
|
const contactVisible = ref(false);
|
||||||
|
const contactProfile = ref<CustomerProfileCompleteness | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const showSenderForm = ref(false);
|
const showSenderForm = ref(false);
|
||||||
const savingSender = 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() {
|
async function submitPickup() {
|
||||||
if (!pickupForm.senderAddressId) {
|
if (!pickupForm.senderAddressId) {
|
||||||
Message.warning(t('h5.journey.ship.pickSender'));
|
Message.warning(t('h5.journey.ship.pickSender'));
|
||||||
@ -315,7 +346,14 @@ async function submitPickup() {
|
|||||||
emit('done');
|
emit('done');
|
||||||
close();
|
close();
|
||||||
} catch (e: any) {
|
} 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 {
|
} finally {
|
||||||
creatingPickup.value = false;
|
creatingPickup.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,11 @@
|
|||||||
<div class="h5-wx-qr" :class="{ 'is-compact': compact }">
|
<div class="h5-wx-qr" :class="{ 'is-compact': compact }">
|
||||||
<div
|
<div
|
||||||
class="h5-wx-qr__box"
|
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 长按常无「识别图中二维码」菜单。
|
必须用原生 <img>:微信对 canvas 长按常无「识别图中二维码」菜单。
|
||||||
@ -11,17 +15,23 @@
|
|||||||
<img
|
<img
|
||||||
v-if="qrCodeUrl"
|
v-if="qrCodeUrl"
|
||||||
class="h5-wx-qr__img"
|
class="h5-wx-qr__img"
|
||||||
:class="{ 'is-ready': qrImageReady }"
|
:class="{ 'is-ready': qrImageReady, 'is-masked': scanned }"
|
||||||
:src="qrCodeUrl"
|
:src="qrCodeUrl"
|
||||||
alt="wechat-login-qrcode"
|
alt="wechat-login-qrcode"
|
||||||
:width="compact ? 256 : 200"
|
:width="compact ? 256 : 200"
|
||||||
:height="compact ? 256 : 200"
|
:height="compact ? 256 : 200"
|
||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
draggable="false"
|
||||||
|
@contextmenu.prevent
|
||||||
@load="onQrImageLoad"
|
@load="onQrImageLoad"
|
||||||
@error="onQrImageError"
|
@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" />
|
<a-spin :size="28" />
|
||||||
<span>{{ t('h5.wxQr.loading') }}</span>
|
<span>{{ t('h5.wxQr.loading') }}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -33,28 +43,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="h5-wx-qr__tip h5-wx-qr__tip--primary">{{ t('h5.wxQr.longPress') }}</p>
|
<template v-if="!scanned">
|
||||||
<template v-if="!compact">
|
<p class="h5-wx-qr__tip h5-wx-qr__tip--primary">{{ t('h5.wxQr.longPress') }}</p>
|
||||||
<p class="h5-wx-qr__tip">{{ t('h5.wxQr.valid') }}</p>
|
<template v-if="!compact">
|
||||||
<a-button long class="h5-wx-qr__refresh" :loading="loadingQr" @click="refreshQr">
|
<p class="h5-wx-qr__tip">{{ t('h5.wxQr.valid') }}</p>
|
||||||
{{ t('h5.wxQr.refresh') }}
|
<a-button long class="h5-wx-qr__refresh" :loading="loadingQr" @click="refreshQr">
|
||||||
</a-button>
|
{{ 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>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { watch } from 'vue';
|
import { watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { IconCheckCircleFill } from '@arco-design/web-vue/es/icon';
|
||||||
import type { LoginResultDto } from '@/api/rbac';
|
import type { LoginResultDto } from '@/api/rbac';
|
||||||
import { useWechatQrLogin } from '@/composables/useWechatQrLogin';
|
import { useWechatQrLogin } from '@/composables/useWechatQrLogin';
|
||||||
|
|
||||||
@ -86,6 +99,7 @@ const {
|
|||||||
loadingQr,
|
loadingQr,
|
||||||
loadError,
|
loadError,
|
||||||
qrImageReady,
|
qrImageReady,
|
||||||
|
scanned,
|
||||||
showOverlay,
|
showOverlay,
|
||||||
refreshQr,
|
refreshQr,
|
||||||
pauseQr,
|
pauseQr,
|
||||||
@ -135,9 +149,10 @@ defineExpose({ refreshQr, pauseQr });
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
/* 允许微信长按菜单(识别二维码) */
|
/* 禁 iOS 系统「保存图片/共享」;微信内「识别图中二维码」由客户端接管,仍可用 */
|
||||||
-webkit-touch-callout: default;
|
-webkit-touch-callout: none;
|
||||||
user-select: auto;
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.h5-wx-qr.is-compact .h5-wx-qr__box {
|
.h5-wx-qr.is-compact .h5-wx-qr__box {
|
||||||
@ -163,14 +178,22 @@ defineExpose({ refreshQr, pauseQr });
|
|||||||
height: 200px;
|
height: 200px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
opacity: 0;
|
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,否则无法长按识别 */
|
/* 勿加 pointer-events:none,否则无法长按识别 */
|
||||||
-webkit-touch-callout: default;
|
-webkit-touch-callout: none;
|
||||||
user-select: auto;
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
|
||||||
&.is-ready {
|
&.is-ready {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.is-masked {
|
||||||
|
filter: blur(6px);
|
||||||
|
opacity: 0.35;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.h5-wx-qr__overlay {
|
.h5-wx-qr__overlay {
|
||||||
@ -193,6 +216,14 @@ defineExpose({ refreshQr, pauseQr });
|
|||||||
&.is-error {
|
&.is-error {
|
||||||
pointer-events: auto;
|
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 {
|
.h5-wx-qr.is-compact .h5-wx-qr__overlay {
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { isLogin } from '@/utils/auth';
|
import { isLogin } from '@/utils/auth';
|
||||||
import { getOrderStatusCounts } from '@/api/order';
|
import { getOrderStatusCounts } from '@/api/order';
|
||||||
import { ensureFullLocaleMessages } from '@/locale';
|
import { ensureFullLocaleMessages } from '@/locale';
|
||||||
|
import { h5Navigate } from '@/utils/device';
|
||||||
import { H5_ORDER_GROUPS, sumStatusCounts } from '@/constants/h5OrderStatus';
|
import { H5_ORDER_GROUPS, sumStatusCounts } from '@/constants/h5OrderStatus';
|
||||||
import LoginLangToggle from '@/views/login/components/LoginLangToggle.vue';
|
import LoginLangToggle from '@/views/login/components/LoginLangToggle.vue';
|
||||||
import AppTabBar from './components/AppTabBar.vue';
|
import AppTabBar from './components/AppTabBar.vue';
|
||||||
@ -44,7 +45,7 @@ const placeOrderVisible = ref(false);
|
|||||||
|
|
||||||
function openPlaceOrder() {
|
function openPlaceOrder() {
|
||||||
if (!isLogin()) {
|
if (!isLogin()) {
|
||||||
router.push({
|
h5Navigate(router, {
|
||||||
path: '/h5/me',
|
path: '/h5/me',
|
||||||
query: { redirect: '/h5/place-order' },
|
query: { redirect: '/h5/place-order' },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -129,6 +129,7 @@ export default {
|
|||||||
'h5.wxQr.refresh': 'Refresh QR code',
|
'h5.wxQr.refresh': 'Refresh QR code',
|
||||||
'h5.wxQr.longPress': 'Long-press → “Recognize QR Code in Image”',
|
'h5.wxQr.longPress': 'Long-press → “Recognize QR Code in Image”',
|
||||||
'h5.wxQr.valid': 'QR code is valid for 10 minutes',
|
'h5.wxQr.valid': 'QR code is valid for 10 minutes',
|
||||||
|
'h5.wxQr.scanned': 'Scanned. Signing you in…',
|
||||||
'h5.wxQr.success': 'Signed in',
|
'h5.wxQr.success': 'Signed in',
|
||||||
|
|
||||||
'h5.login.title': 'Sign in',
|
'h5.login.title': 'Sign in',
|
||||||
@ -177,6 +178,17 @@ export default {
|
|||||||
'h5.orderType.logisticsDesc': 'Ship overseas — recipient & declaration',
|
'h5.orderType.logisticsDesc': 'Ship overseas — recipient & declaration',
|
||||||
'h5.orderType.pickup': 'Pickup order',
|
'h5.orderType.pickup': 'Pickup order',
|
||||||
'h5.orderType.pickupDesc': 'Door pickup to warehouse',
|
'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.pickupCreate.title': 'Create pickup order',
|
||||||
|
|
||||||
'h5.placeOrder.title': 'Place order',
|
'h5.placeOrder.title': 'Place order',
|
||||||
|
|||||||
@ -128,6 +128,7 @@ export default {
|
|||||||
'h5.wxQr.refresh': '刷新二维码',
|
'h5.wxQr.refresh': '刷新二维码',
|
||||||
'h5.wxQr.longPress': '请长按二维码,选择「识别图中二维码」',
|
'h5.wxQr.longPress': '请长按二维码,选择「识别图中二维码」',
|
||||||
'h5.wxQr.valid': '二维码十分钟内有效',
|
'h5.wxQr.valid': '二维码十分钟内有效',
|
||||||
|
'h5.wxQr.scanned': '扫码成功,正在登录…',
|
||||||
'h5.wxQr.success': '登录成功',
|
'h5.wxQr.success': '登录成功',
|
||||||
|
|
||||||
'h5.login.title': '登录',
|
'h5.login.title': '登录',
|
||||||
@ -176,6 +177,17 @@ export default {
|
|||||||
'h5.orderType.logisticsDesc': '寄往海外,填写收件人与申报信息',
|
'h5.orderType.logisticsDesc': '寄往海外,填写收件人与申报信息',
|
||||||
'h5.orderType.pickup': '揽收订单',
|
'h5.orderType.pickup': '揽收订单',
|
||||||
'h5.orderType.pickupDesc': '上门取件入库,填写发件与揽收信息',
|
'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.pickupCreate.title': '创建揽收订单',
|
||||||
|
|
||||||
'h5.placeOrder.title': '下单',
|
'h5.placeOrder.title': '下单',
|
||||||
|
|||||||
@ -141,6 +141,7 @@ import { OmsLoginType, type LoginResultDto } from '@/api/rbac';
|
|||||||
import { isLogin, setToken } from '@/utils/auth';
|
import { isLogin, setToken } from '@/utils/auth';
|
||||||
import { applyRbacUserToUserStore } from '@/utils/enter-system';
|
import { applyRbacUserToUserStore } from '@/utils/enter-system';
|
||||||
import { ensureFullLocaleMessages } from '@/locale';
|
import { ensureFullLocaleMessages } from '@/locale';
|
||||||
|
import { h5Navigate } from '@/utils/device';
|
||||||
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
||||||
import logoImg from '@/assets/logo2.png';
|
import logoImg from '@/assets/logo2.png';
|
||||||
import LoginLangToggle from '@/views/login/components/LoginLangToggle.vue';
|
import LoginLangToggle from '@/views/login/components/LoginLangToggle.vue';
|
||||||
@ -195,11 +196,11 @@ function goBack() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goRegister() {
|
function goRegister() {
|
||||||
router.push('/wechat/register');
|
h5Navigate(router, '/wechat/register');
|
||||||
}
|
}
|
||||||
|
|
||||||
function goBind() {
|
function goBind() {
|
||||||
router.push('/wechat/bind');
|
h5Navigate(router, '/wechat/bind');
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAgreement(type: 'privacy' | 'terms') {
|
function openAgreement(type: 'privacy' | 'terms') {
|
||||||
@ -394,6 +395,11 @@ onMounted(() => {
|
|||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
box-shadow: 0 8px 22px color-mix(in srgb, var(--app-primary-color) 18%, transparent);
|
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 {
|
.h5-login-hero__title {
|
||||||
|
|||||||
@ -221,6 +221,7 @@ import { resolveAvatarUrl } from '@/utils/avatar';
|
|||||||
import { getMapIdFromAccessToken } from '@/utils/jwt';
|
import { getMapIdFromAccessToken } from '@/utils/jwt';
|
||||||
import { applyRbacUserToUserStore } from '@/utils/enter-system';
|
import { applyRbacUserToUserStore } from '@/utils/enter-system';
|
||||||
import { ensureFullLocaleMessages } from '@/locale';
|
import { ensureFullLocaleMessages } from '@/locale';
|
||||||
|
import { h5Navigate } from '@/utils/device';
|
||||||
import { useUserStore } from '@/store';
|
import { useUserStore } from '@/store';
|
||||||
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
||||||
import useLocale from '@/hooks/locale';
|
import useLocale from '@/hooks/locale';
|
||||||
@ -359,7 +360,7 @@ function onServiceClick(item: { path: string }) {
|
|||||||
commissionVisible.value = true;
|
commissionVisible.value = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(item.path);
|
h5Navigate(router, item.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCouponUse() {
|
function onCouponUse() {
|
||||||
@ -416,16 +417,16 @@ async function onWxLoginSuccess(result: LoginResultDto) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goWechatRegister() {
|
function goWechatRegister() {
|
||||||
router.push('/wechat/register');
|
h5Navigate(router, '/wechat/register');
|
||||||
}
|
}
|
||||||
|
|
||||||
function goWechatBind() {
|
function goWechatBind() {
|
||||||
router.push('/wechat/bind');
|
h5Navigate(router, '/wechat/bind');
|
||||||
}
|
}
|
||||||
|
|
||||||
function goPasswordLogin() {
|
function goPasswordLogin() {
|
||||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/h5/orders';
|
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() {
|
function goRecharge() {
|
||||||
@ -1077,6 +1078,11 @@ onMounted(() => {
|
|||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
box-shadow: 0 8px 22px color-mix(in srgb, var(--app-primary-color) 18%, transparent);
|
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 {
|
.h5-me-guest__title {
|
||||||
|
|||||||
@ -67,7 +67,7 @@
|
|||||||
:icon="IconList"
|
:icon="IconList"
|
||||||
:text="t('h5.orders.empty')"
|
: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') }}
|
{{ t('h5.orders.emptyGoPrice') }}
|
||||||
</a-button>
|
</a-button>
|
||||||
</H5EmptyState>
|
</H5EmptyState>
|
||||||
@ -197,6 +197,7 @@ import { useRoute, useRouter } from 'vue-router';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { Message, Modal } from '@arco-design/web-vue';
|
import { Message, Modal } from '@arco-design/web-vue';
|
||||||
import { IconCopy, IconList, IconSearch } from '@arco-design/web-vue/es/icon';
|
import { IconCopy, IconList, IconSearch } from '@arco-design/web-vue/es/icon';
|
||||||
|
import { h5Navigate } from '@/utils/device';
|
||||||
import H5EmptyState from '../components/H5EmptyState.vue';
|
import H5EmptyState from '../components/H5EmptyState.vue';
|
||||||
import {
|
import {
|
||||||
getOrderStatusCounts,
|
getOrderStatusCounts,
|
||||||
@ -481,14 +482,14 @@ function onPayFail(error: unknown) {
|
|||||||
okText: t('h5.pay.goRecharge'),
|
okText: t('h5.pay.goRecharge'),
|
||||||
cancelText: t('h5.pay.later'),
|
cancelText: t('h5.pay.later'),
|
||||||
onOk: () => {
|
onOk: () => {
|
||||||
router.push('/h5/recharge');
|
h5Navigate(router, '/h5/recharge');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function askAdvisor(row: any) {
|
function askAdvisor(row: any) {
|
||||||
const ctx = buildWaybillContextFromRow(row, 'h5Orders');
|
const ctx = buildWaybillContextFromRow(row, 'h5Orders');
|
||||||
router.push({
|
h5Navigate(router, {
|
||||||
path: '/h5/assistant',
|
path: '/h5/assistant',
|
||||||
query: buildAskAdvisorQuery(ctx),
|
query: buildAskAdvisorQuery(ctx),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -116,7 +116,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p>{{ queryFailureText || t('h5.price.noResult') }}</p>
|
<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') }}
|
{{ t('h5.price.askAdvisor') }}
|
||||||
</a-button>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
@ -364,6 +364,7 @@ import { getCountryList } from '@/api/system/country';
|
|||||||
import { getWarehouseList } from '@/api/system/warehouse';
|
import { getWarehouseList } from '@/api/system/warehouse';
|
||||||
import { getDictListAll } from '@/api/system/dict';
|
import { getDictListAll } from '@/api/system/dict';
|
||||||
import { getSalesProductDetail } from '@/api/channel/salesProduct';
|
import { getSalesProductDetail } from '@/api/channel/salesProduct';
|
||||||
|
import { h5Navigate } from '@/utils/device';
|
||||||
import H5PlaceOrderSheet from '../components/H5PlaceOrderSheet.vue';
|
import H5PlaceOrderSheet from '../components/H5PlaceOrderSheet.vue';
|
||||||
|
|
||||||
defineOptions({ name: 'H5Price' });
|
defineOptions({ name: 'H5Price' });
|
||||||
@ -734,7 +735,7 @@ function askAdvisor(item: any) {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' / ');
|
.join(' / ');
|
||||||
detailVisible.value = false;
|
detailVisible.value = false;
|
||||||
router.push({
|
h5Navigate(router, {
|
||||||
path: '/h5/assistant',
|
path: '/h5/assistant',
|
||||||
query: { prefill: text },
|
query: { prefill: text },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="wechat-login">
|
<div class="wechat-login">
|
||||||
<div class="qr-card">
|
<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
|
<img
|
||||||
v-if="qrCodeUrl"
|
v-if="qrCodeUrl"
|
||||||
class="login-wx-qrcode"
|
class="login-wx-qrcode"
|
||||||
:class="{ 'login-wx-qrcode--ready': qrImageReady }"
|
:class="{ 'login-wx-qrcode--ready': qrImageReady, 'login-wx-qrcode--masked': scanned }"
|
||||||
:src="qrCodeUrl"
|
:src="qrCodeUrl"
|
||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
width="190"
|
width="190"
|
||||||
@ -15,7 +15,11 @@
|
|||||||
@load="qrImageReady = true"
|
@load="qrImageReady = true"
|
||||||
@error="onQrImageError"
|
@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" />
|
<a-spin :size="28" />
|
||||||
<span class="qr-overlay__text">{{ t('login.wechat.loading') }}</span>
|
<span class="qr-overlay__text">{{ t('login.wechat.loading') }}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -26,13 +30,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tips tips--secondary">{{ t('login.wechat.longPress') }}</div>
|
<div v-if="!scanned" class="tips">{{ t('login.wechat.tips') }}</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>
|
|
||||||
<a-button
|
<a-button
|
||||||
|
v-if="!scanned"
|
||||||
class="refresh"
|
class="refresh"
|
||||||
:loading="loadingQr"
|
:loading="loadingQr"
|
||||||
:disabled="loadingQr"
|
:disabled="loadingQr"
|
||||||
@ -61,7 +61,7 @@ import { ref, computed, onMounted, onBeforeUnmount, inject, nextTick } from 'vue
|
|||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { Message } from '@arco-design/web-vue';
|
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 { OmsLoginType } from '@/api/rbac';
|
||||||
import { getLoginQrcode, getWxLoginStatus, isWxLoginPendingError, wxLogin } from '@/api/wechat';
|
import { getLoginQrcode, getWxLoginStatus, isWxLoginPendingError, wxLogin } from '@/api/wechat';
|
||||||
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
||||||
@ -89,6 +89,8 @@ const loadingQr = ref(false);
|
|||||||
const loadError = ref(false);
|
const loadError = ref(false);
|
||||||
const qrImageReady = ref(false);
|
const qrImageReady = ref(false);
|
||||||
const polling = ref(false);
|
const polling = ref(false);
|
||||||
|
/** 已扫码:遮罩二维码,防止重复扫 */
|
||||||
|
const scanned = ref(false);
|
||||||
|
|
||||||
const showOverlay = computed(
|
const showOverlay = computed(
|
||||||
() => loadingQr.value || (Boolean(qrCodeUrl.value) && !qrImageReady.value),
|
() => loadingQr.value || (Boolean(qrCodeUrl.value) && !qrImageReady.value),
|
||||||
@ -177,6 +179,9 @@ async function pollWxLogin() {
|
|||||||
const status = await getWxLoginStatus(loginUuid.value);
|
const status = await getWxLoginStatus(loginUuid.value);
|
||||||
if (!status?.ready || disposed) return;
|
if (!status?.ready || disposed) return;
|
||||||
|
|
||||||
|
scanned.value = true;
|
||||||
|
clearTimers();
|
||||||
|
|
||||||
const result = await wxLogin(loginUuid.value);
|
const result = await wxLogin(loginUuid.value);
|
||||||
if (!result?.accessToken || disposed) return;
|
if (!result?.accessToken || disposed) return;
|
||||||
|
|
||||||
@ -209,6 +214,7 @@ async function loadQrCode(options?: { keepPrevious?: boolean }) {
|
|||||||
const seq = ++loadSeq;
|
const seq = ++loadSeq;
|
||||||
clearTimers();
|
clearTimers();
|
||||||
loadError.value = false;
|
loadError.value = false;
|
||||||
|
scanned.value = false;
|
||||||
loadingQr.value = true;
|
loadingQr.value = true;
|
||||||
// 刷新时保留旧图,避免白屏闪一下
|
// 刷新时保留旧图,避免白屏闪一下
|
||||||
if (!options?.keepPrevious) {
|
if (!options?.keepPrevious) {
|
||||||
@ -326,6 +332,14 @@ onBeforeUnmount(() => {
|
|||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--scanned {
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ok {
|
||||||
|
color: var(--app-primary-color, #007979);
|
||||||
|
}
|
||||||
|
|
||||||
&__text {
|
&__text {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #86909c;
|
color: #86909c;
|
||||||
@ -356,7 +370,7 @@ onBeforeUnmount(() => {
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease, filter 0.2s ease;
|
||||||
/* 微信内长按识别:勿禁用触摸呼出 */
|
/* 微信内长按识别:勿禁用触摸呼出 */
|
||||||
-webkit-touch-callout: default;
|
-webkit-touch-callout: default;
|
||||||
user-select: auto;
|
user-select: auto;
|
||||||
@ -364,6 +378,12 @@ onBeforeUnmount(() => {
|
|||||||
&--ready {
|
&--ready {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--masked {
|
||||||
|
filter: blur(6px);
|
||||||
|
opacity: 0.35;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tips {
|
.tips {
|
||||||
@ -371,18 +391,8 @@ onBeforeUnmount(() => {
|
|||||||
color: #666;
|
color: #666;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
line-height: 1.5;
|
||||||
|
max-width: 240px;
|
||||||
.tips--secondary {
|
|
||||||
margin-top: 4px;
|
|
||||||
color: var(--app-primary-color, #007979);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tips--pending {
|
|
||||||
margin-top: 4px;
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh {
|
.refresh {
|
||||||
|
|||||||
@ -18,7 +18,8 @@
|
|||||||
'login.form.brandHint.admin': 'Welcome to IDE Operations Console',
|
'login.form.brandHint.admin': 'Welcome to IDE Operations Console',
|
||||||
'login.switchToWechat': 'Sign in with WeChat',
|
'login.switchToWechat': 'Sign in with WeChat',
|
||||||
'login.switchToAccount': 'Account and password',
|
'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.qrValid': 'QR code valid for 10 minutes',
|
||||||
'login.wechat.scanPending': 'Waiting for scan…',
|
'login.wechat.scanPending': 'Waiting for scan…',
|
||||||
'login.wechat.loading': 'Loading QR code…',
|
'login.wechat.loading': 'Loading QR code…',
|
||||||
|
|||||||
@ -18,7 +18,8 @@
|
|||||||
'login.form.brandHint.admin': '欢迎登录 IDE 运营管理后台',
|
'login.form.brandHint.admin': '欢迎登录 IDE 运营管理后台',
|
||||||
'login.switchToWechat': '微信扫码登录',
|
'login.switchToWechat': '微信扫码登录',
|
||||||
'login.switchToAccount': '账号密码登录',
|
'login.switchToAccount': '账号密码登录',
|
||||||
'login.wechat.tips': '请使用微信扫一扫扫描二维码登录;手机微信内可长按识别',
|
'login.wechat.tips': '请使用微信扫一扫登录,二维码 10 分钟内有效',
|
||||||
|
'login.wechat.scanned': '扫码成功,正在登录…',
|
||||||
'login.wechat.qrValid': '二维码十分钟有效',
|
'login.wechat.qrValid': '二维码十分钟有效',
|
||||||
'login.wechat.scanPending': '等待扫码中…',
|
'login.wechat.scanPending': '等待扫码中…',
|
||||||
'login.wechat.longPress': '微信内请长按二维码识别',
|
'login.wechat.longPress': '微信内请长按二维码识别',
|
||||||
|
|||||||
@ -14,6 +14,12 @@ import { getSalesProductList } from '@/api/channel/salesProduct'
|
|||||||
import { getWarehouseList } from '@/api/system/warehouse'
|
import { getWarehouseList } from '@/api/system/warehouse'
|
||||||
import { $baseMessage } from '@/utils'
|
import { $baseMessage } from '@/utils'
|
||||||
import { hasPermission } from '@/utils/rbac-permission'
|
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'
|
import { formatDateTime, normalizeListRow, pickField } from '@/utils/warehouseListMapper'
|
||||||
|
|
||||||
defineOptions({ name: 'OrderPickupOrder' })
|
defineOptions({ name: 'OrderPickupOrder' })
|
||||||
@ -32,6 +38,8 @@ const keyword = ref('')
|
|||||||
const listLoading = ref(false)
|
const listLoading = ref(false)
|
||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
const refLoading = ref(false)
|
const refLoading = ref(false)
|
||||||
|
const contactVisible = ref(false)
|
||||||
|
const contactProfile = ref<CustomerProfileCompleteness | null>(null)
|
||||||
const tableRows = ref<any[]>([])
|
const tableRows = ref<any[]>([])
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageSize = ref(30)
|
const pageSize = ref(30)
|
||||||
@ -208,6 +216,15 @@ function resetForm() {
|
|||||||
async function handleOpenCreate() {
|
async function handleOpenCreate() {
|
||||||
if (!hasPermission('Order.Pickup.Create')) return
|
if (!hasPermission('Order.Pickup.Create')) return
|
||||||
if (isAdmin.value && ensureMutationCustomerId() == null) 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()
|
await loadReferenceOptions()
|
||||||
if (!senderOptions.value.length) {
|
if (!senderOptions.value.length) {
|
||||||
$baseMessage(t('order.pickupOrder.message.noSenderAddress'), 'warning')
|
$baseMessage(t('order.pickupOrder.message.noSenderAddress'), 'warning')
|
||||||
@ -217,6 +234,10 @@ async function handleOpenCreate() {
|
|||||||
openModal(true)
|
openModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onContactSuccess() {
|
||||||
|
void handleOpenCreate()
|
||||||
|
}
|
||||||
|
|
||||||
function validateForm() {
|
function validateForm() {
|
||||||
if (!formModel.senderAddressId) {
|
if (!formModel.senderAddressId) {
|
||||||
$baseMessage(t('order.pickupOrder.message.senderRequired'), 'warning')
|
$baseMessage(t('order.pickupOrder.message.senderRequired'), 'warning')
|
||||||
@ -288,6 +309,11 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="pickup-order-container warehouse-list-page">
|
<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="warehouse-list-page-card">
|
||||||
<div class="table-toolbar">
|
<div class="table-toolbar">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
|
|||||||
@ -25,6 +25,12 @@
|
|||||||
import { normalizeListRow, pickField } from '@/utils/warehouseListMapper';
|
import { normalizeListRow, pickField } from '@/utils/warehouseListMapper';
|
||||||
import { normalizeCargoAttributeCode } from '@/utils/cargoAttribute';
|
import { normalizeCargoAttributeCode } from '@/utils/cargoAttribute';
|
||||||
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
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({
|
defineOptions({
|
||||||
name: 'PlaceOrder',
|
name: 'PlaceOrder',
|
||||||
@ -36,6 +42,9 @@
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const rbacUserStore = useRbacUserStore();
|
const rbacUserStore = useRbacUserStore();
|
||||||
|
const contactVisible = ref(false);
|
||||||
|
const contactProfile = ref<CustomerProfileCompleteness | null>(null);
|
||||||
|
const contactBlocked = ref(false);
|
||||||
const activeOrderMode = ref<'batch' | 'single'>('single');
|
const activeOrderMode = ref<'batch' | 'single'>('single');
|
||||||
const uploadDragActive = ref(false);
|
const uploadDragActive = ref(false);
|
||||||
const priceCheckHandoff = ref<{
|
const priceCheckHandoff = ref<{
|
||||||
@ -1625,8 +1634,27 @@
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadSingleFormReferenceOptions();
|
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() {
|
function clearUploadedFile() {
|
||||||
pendingUploadFile.value = null;
|
pendingUploadFile.value = null;
|
||||||
if (nativeFileInputRef.value) {
|
if (nativeFileInputRef.value) {
|
||||||
@ -2121,7 +2149,13 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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-tabs v-model:active-key="activeOrderMode" type="line" class="place-order-tabs">
|
||||||
<a-tab-pane key="single" :title="t('order.placeOrder.singleOrder')">
|
<a-tab-pane key="single" :title="t('order.placeOrder.singleOrder')">
|
||||||
<div class="single-tab">
|
<div class="single-tab">
|
||||||
|
|||||||
@ -47,6 +47,8 @@
|
|||||||
:src="wxUserInfo.headimgurl"
|
:src="wxUserInfo.headimgurl"
|
||||||
alt=""
|
alt=""
|
||||||
class="wechat-h5-hero__avatar"
|
class="wechat-h5-hero__avatar"
|
||||||
|
draggable="false"
|
||||||
|
@contextmenu.prevent
|
||||||
/>
|
/>
|
||||||
<div v-else class="wechat-h5-hero__avatar wechat-h5-hero__fallback">
|
<div v-else class="wechat-h5-hero__avatar wechat-h5-hero__fallback">
|
||||||
{{ avatarLetter }}
|
{{ avatarLetter }}
|
||||||
|
|||||||
@ -2,11 +2,12 @@ export default {
|
|||||||
'wechat.common.loading': 'Getting WeChat authorization…',
|
'wechat.common.loading': 'Getting WeChat authorization…',
|
||||||
'wechat.common.retry': 'Retry authorization',
|
'wechat.common.retry': 'Retry authorization',
|
||||||
|
|
||||||
'wechat.register.title': 'Complete your profile',
|
'wechat.register.title': 'Confirm registration',
|
||||||
'wechat.register.subtitle': 'A few details, then you can sign in with WeChat',
|
'wechat.register.subtitle': 'WeChat name and a default password are ready — confirm to finish',
|
||||||
'wechat.register.success': 'Registered',
|
'wechat.register.success': 'Registered',
|
||||||
'wechat.register.successDesc': 'Your account is ready and linked to this WeChat',
|
'wechat.register.successDesc':
|
||||||
'wechat.register.submit': 'Create account',
|
'Account linked to this WeChat. Sign in with customer code or WeChat QR.',
|
||||||
|
'wechat.register.submit': 'Confirm',
|
||||||
'wechat.register.submitting': 'Submitting…',
|
'wechat.register.submitting': 'Submitting…',
|
||||||
'wechat.register.alreadyBound': 'This WeChat is already linked. Please sign in.',
|
'wechat.register.alreadyBound': 'This WeChat is already linked. Please sign in.',
|
||||||
'wechat.register.alreadyBoundTip': 'Sign in with WeChat QR or your account password',
|
'wechat.register.alreadyBoundTip': 'Sign in with WeChat QR or your account password',
|
||||||
@ -20,20 +21,20 @@ export default {
|
|||||||
'wechat.register.password': 'Password',
|
'wechat.register.password': 'Password',
|
||||||
'wechat.register.customerCodePlaceholder': 'Enter customer code',
|
'wechat.register.customerCodePlaceholder': 'Enter customer code',
|
||||||
'wechat.register.customerNamePlaceholder': 'What should we call you',
|
'wechat.register.customerNamePlaceholder': 'What should we call you',
|
||||||
'wechat.register.phonePlaceholder': 'For notices and recovery',
|
'wechat.register.phonePlaceholder': 'Enter phone number',
|
||||||
'wechat.register.emailPlaceholder': 'For invoices and notices',
|
'wechat.register.emailPlaceholder': 'Enter email',
|
||||||
'wechat.register.passwordPlaceholder': 'Set a password',
|
'wechat.register.passwordPlaceholder': 'Set a password',
|
||||||
'wechat.register.passwordMinLength': 'At least 6 characters',
|
'wechat.register.passwordMinLength': 'At least 6 characters',
|
||||||
'wechat.register.passwordLetterAndDigit': 'Must include letters and numbers',
|
'wechat.register.passwordLetterAndDigit': 'Must include letters and numbers',
|
||||||
'wechat.register.passwordTipPrefix': 'Default password prefilled: ',
|
'wechat.register.passwordTipPrefix': 'Default password ',
|
||||||
'wechat.register.passwordTipSuffix': '. Keep it or change it.',
|
'wechat.register.passwordTipSuffix': ' — keep it or change it',
|
||||||
'wechat.register.hint': 'After registering, sign in with WeChat QR or password',
|
'wechat.register.hint': 'After registering, sign in with WeChat QR or customer code + password',
|
||||||
'wechat.register.goCenter': 'Open customer center',
|
'wechat.register.goCenter': 'Open customer center',
|
||||||
'wechat.register.goLogin': 'Sign in',
|
'wechat.register.goLogin': 'Sign in',
|
||||||
'wechat.register.hasAccount': 'Already have an account?',
|
'wechat.register.hasAccount': 'Already have an account?',
|
||||||
'wechat.register.goBind': 'Bind WeChat',
|
'wechat.register.goBind': 'Bind WeChat',
|
||||||
'wechat.register.wxUser': 'WeChat user',
|
'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.referrerHint': 'Invited by {name} ({code})',
|
||||||
'wechat.register.invalidRef': 'Invite code is invalid or disabled. Ask your inviter for a new link.',
|
'wechat.register.invalidRef': 'Invite code is invalid or disabled. Ask your inviter for a new link.',
|
||||||
'wechat.register.openInWechat': 'Open in WeChat',
|
'wechat.register.openInWechat': 'Open in WeChat',
|
||||||
|
|||||||
@ -2,11 +2,11 @@ export default {
|
|||||||
'wechat.common.loading': '正在获取微信授权…',
|
'wechat.common.loading': '正在获取微信授权…',
|
||||||
'wechat.common.retry': '重新授权',
|
'wechat.common.retry': '重新授权',
|
||||||
|
|
||||||
'wechat.register.title': '完善资料,完成注册',
|
'wechat.register.title': '确认注册',
|
||||||
'wechat.register.subtitle': '只需填几项信息,之后可用微信扫码登录',
|
'wechat.register.subtitle': '已带入微信昵称与初始密码,确认后即可完成',
|
||||||
'wechat.register.success': '注册成功',
|
'wechat.register.success': '注册成功',
|
||||||
'wechat.register.successDesc': '账号已创建并绑定当前微信,可直接进入客户中心',
|
'wechat.register.successDesc': '账号已创建并绑定当前微信;登录可用客户编号或微信扫码',
|
||||||
'wechat.register.submit': '完成注册',
|
'wechat.register.submit': '确认注册',
|
||||||
'wechat.register.submitting': '提交中…',
|
'wechat.register.submitting': '提交中…',
|
||||||
'wechat.register.alreadyBound': '该微信已绑定账号,请直接登录',
|
'wechat.register.alreadyBound': '该微信已绑定账号,请直接登录',
|
||||||
'wechat.register.alreadyBoundTip': '请使用微信扫码或账号密码登录',
|
'wechat.register.alreadyBoundTip': '请使用微信扫码或账号密码登录',
|
||||||
@ -20,20 +20,20 @@ export default {
|
|||||||
'wechat.register.password': '登录密码',
|
'wechat.register.password': '登录密码',
|
||||||
'wechat.register.customerCodePlaceholder': '请输入客户编号',
|
'wechat.register.customerCodePlaceholder': '请输入客户编号',
|
||||||
'wechat.register.customerNamePlaceholder': '怎么称呼您',
|
'wechat.register.customerNamePlaceholder': '怎么称呼您',
|
||||||
'wechat.register.phonePlaceholder': '用于接收通知与找回密码',
|
'wechat.register.phonePlaceholder': '请输入手机号',
|
||||||
'wechat.register.emailPlaceholder': '用于接收账单与通知',
|
'wechat.register.emailPlaceholder': '请输入邮箱',
|
||||||
'wechat.register.passwordPlaceholder': '请设置登录密码',
|
'wechat.register.passwordPlaceholder': '请设置登录密码',
|
||||||
'wechat.register.passwordMinLength': '密码至少 6 位',
|
'wechat.register.passwordMinLength': '密码至少 6 位',
|
||||||
'wechat.register.passwordLetterAndDigit': '密码须同时包含字母和数字',
|
'wechat.register.passwordLetterAndDigit': '密码须同时包含字母和数字',
|
||||||
'wechat.register.passwordTipPrefix': '已为您预填初始密码 ',
|
'wechat.register.passwordTipPrefix': '初始密码 ',
|
||||||
'wechat.register.passwordTipSuffix': ',可直接使用或自行修改',
|
'wechat.register.passwordTipSuffix': ',可直接用,也可改成自己的',
|
||||||
'wechat.register.hint': '注册后可用微信扫码或账号密码登录',
|
'wechat.register.hint': '注册后可用微信扫码或客户编号+密码登录',
|
||||||
'wechat.register.goCenter': '进入客户中心',
|
'wechat.register.goCenter': '进入客户中心',
|
||||||
'wechat.register.goLogin': '去登录',
|
'wechat.register.goLogin': '去登录',
|
||||||
'wechat.register.hasAccount': '已有系统账号?',
|
'wechat.register.hasAccount': '已有系统账号?',
|
||||||
'wechat.register.goBind': '去绑定微信',
|
'wechat.register.goBind': '去绑定微信',
|
||||||
'wechat.register.wxUser': '微信用户',
|
'wechat.register.wxUser': '微信用户',
|
||||||
'wechat.register.wxReady': '微信已授权,请完善以下信息',
|
'wechat.register.wxReady': '微信已授权,核对后点确认即可',
|
||||||
'wechat.register.referrerHint': '您由 {name}({code})邀请注册',
|
'wechat.register.referrerHint': '您由 {name}({code})邀请注册',
|
||||||
'wechat.register.invalidRef': '邀请码无效或已停用,请联系邀请人或重新扫码',
|
'wechat.register.invalidRef': '邀请码无效或已停用,请联系邀请人或重新扫码',
|
||||||
'wechat.register.openInWechat': '请在微信中打开',
|
'wechat.register.openInWechat': '请在微信中打开',
|
||||||
|
|||||||
@ -67,6 +67,8 @@
|
|||||||
:src="wxUserInfo.headimgurl"
|
:src="wxUserInfo.headimgurl"
|
||||||
alt=""
|
alt=""
|
||||||
class="wechat-h5-hero__avatar"
|
class="wechat-h5-hero__avatar"
|
||||||
|
draggable="false"
|
||||||
|
@contextmenu.prevent
|
||||||
/>
|
/>
|
||||||
<div v-else class="wechat-h5-hero__avatar wechat-h5-hero__fallback">
|
<div v-else class="wechat-h5-hero__avatar wechat-h5-hero__fallback">
|
||||||
{{ avatarLetter }}
|
{{ avatarLetter }}
|
||||||
@ -124,24 +126,6 @@
|
|||||||
allow-clear
|
allow-clear
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</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-form-item field="password" :label="t('wechat.register.password')" required>
|
||||||
<a-input-password
|
<a-input-password
|
||||||
v-model="formState.password"
|
v-model="formState.password"
|
||||||
@ -151,7 +135,6 @@
|
|||||||
allow-clear
|
allow-clear
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<p class="wechat-h5-pwd-tip">
|
<p class="wechat-h5-pwd-tip">
|
||||||
{{ t('wechat.register.passwordTipPrefix') }}
|
{{ t('wechat.register.passwordTipPrefix') }}
|
||||||
<strong>{{ DEFAULT_CUSTOMER_PASSWORD }}</strong>
|
<strong>{{ DEFAULT_CUSTOMER_PASSWORD }}</strong>
|
||||||
@ -261,8 +244,6 @@ const referrerInfo = ref<{ name: string; code: string } | null>(null);
|
|||||||
|
|
||||||
const formState = reactive({
|
const formState = reactive({
|
||||||
customerName: '',
|
customerName: '',
|
||||||
phone: '',
|
|
||||||
email: '',
|
|
||||||
password: DEFAULT_CUSTOMER_PASSWORD,
|
password: DEFAULT_CUSTOMER_PASSWORD,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -273,8 +254,6 @@ const avatarLetter = computed(() => {
|
|||||||
|
|
||||||
const formRules: Record<string, FieldRule | FieldRule[]> = {
|
const formRules: Record<string, FieldRule | FieldRule[]> = {
|
||||||
customerName: [{ required: true, message: t('wechat.register.customerNamePlaceholder') }],
|
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: [
|
password: [
|
||||||
{ required: true, message: t('wechat.register.passwordPlaceholder') },
|
{ required: true, message: t('wechat.register.passwordPlaceholder') },
|
||||||
{ minLength: 6, message: t('wechat.register.passwordMinLength') },
|
{ minLength: 6, message: t('wechat.register.passwordMinLength') },
|
||||||
@ -349,10 +328,8 @@ async function handleSubmit() {
|
|||||||
|
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
try {
|
try {
|
||||||
await registerByWechat({
|
const result = await registerByWechat({
|
||||||
customerName: formState.customerName.trim(),
|
customerName: formState.customerName.trim(),
|
||||||
phone: formState.phone.trim(),
|
|
||||||
email: formState.email.trim(),
|
|
||||||
password: formState.password,
|
password: formState.password,
|
||||||
...(canBindInvite ? { inviteCode: inviteCode.value } : {}),
|
...(canBindInvite ? { inviteCode: inviteCode.value } : {}),
|
||||||
wechat: {
|
wechat: {
|
||||||
@ -364,11 +341,14 @@ async function handleSubmit() {
|
|||||||
});
|
});
|
||||||
clearStashedWeChatInviteRef();
|
clearStashedWeChatInviteRef();
|
||||||
Message.success(t('wechat.register.success'));
|
Message.success(t('wechat.register.success'));
|
||||||
const loggedIn = await loginCustomerAfterWechatAuth({
|
const account = String(result?.customerCode || '').trim();
|
||||||
account: formState.phone.trim(),
|
const loggedIn = account
|
||||||
password: formState.password,
|
? await loginCustomerAfterWechatAuth({
|
||||||
redirectPath: '/h5/me',
|
account,
|
||||||
});
|
password: formState.password,
|
||||||
|
redirectPath: '/h5/me',
|
||||||
|
})
|
||||||
|
: false;
|
||||||
if (!loggedIn) {
|
if (!loggedIn) {
|
||||||
success.value = true;
|
success.value = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,6 +59,11 @@
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
box-shadow: 0 8px 22px color-mix(in srgb, var(--app-primary-color) 18%, transparent);
|
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 {
|
.wechat-h5-hero__fallback {
|
||||||
@ -181,6 +186,12 @@
|
|||||||
color: #86909c;
|
color: #86909c;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
|
||||||
|
&--inline {
|
||||||
|
margin-top: 8px;
|
||||||
|
text-align: left;
|
||||||
|
color: #4e5969;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.wechat-h5-switch {
|
.wechat-h5-switch {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user