feat: Handsontable 混合表头与请求日志改造,并完善 H5 下单/登录体验
统一 HandsontablePro 液态筛选表头、批量输入与自动列增强;请求日志改用公共列表组件;同步收件地址解析与 H5/PC 下单相关改动。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
37d534edc3
commit
407a0f7be6
@ -320,6 +320,8 @@ itemList: [
|
||||
| `key` | `string` | 对应行数据字段名(如 `jobId`、`statusLabel`) |
|
||||
| `label` | `string` | 表头文案(支持 `t('...')`) |
|
||||
| `width` | `number` | 列宽(像素) |
|
||||
| `headerMode` | `'inline' \| 'icon' \| 'none'` | 混合表头:内嵌搜索 / 仅图标 / 纯展示;未设时由 `searchable` + `filterType` 推导 |
|
||||
| `searchable` / `searchField` / `multiValue` | — | 表头搜索;`multiValue` 显示「多」批量入口 |
|
||||
|
||||
**正确示例:**
|
||||
|
||||
|
||||
@ -1650,6 +1650,63 @@ function throwParcelCreateBusinessError(body: any, fallbackMsg: string): never {
|
||||
})
|
||||
}
|
||||
|
||||
export interface OrderReceiverParseAddressResult {
|
||||
receiver: {
|
||||
recipientsName?: string
|
||||
companyName?: string
|
||||
phone?: string
|
||||
telePhone?: string
|
||||
email?: string
|
||||
countryCode?: string
|
||||
province?: string
|
||||
city?: string
|
||||
streetNumber?: string
|
||||
streetAddress1?: string
|
||||
streetAddress2?: string
|
||||
streetAddress3?: string
|
||||
zipCode?: string
|
||||
}
|
||||
isComplete: boolean
|
||||
missingRequired: string[]
|
||||
missingRecommended: string[]
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
/** POST /order/receiver/parse-address — 粘贴地址 AI 解析 */
|
||||
export async function postOrderReceiverParseAddress(body: {
|
||||
rawText: string
|
||||
providerKey?: string
|
||||
model?: string
|
||||
}): Promise<OrderReceiverParseAddressResult> {
|
||||
const res = await axiosInstance.post(orderUrl('/order/receiver/parse-address'), body)
|
||||
assertBusinessOk(res.data)
|
||||
const data = unwrapApiData<any>(res.data)
|
||||
const receiver = data?.receiver ?? data?.Receiver ?? {}
|
||||
const pick = (a: string, b: string) =>
|
||||
String(receiver?.[a] ?? receiver?.[b] ?? '').trim() || undefined
|
||||
return {
|
||||
receiver: {
|
||||
recipientsName: pick('recipientsName', 'RecipientsName'),
|
||||
companyName: pick('companyName', 'CompanyName'),
|
||||
phone: pick('phone', 'Phone'),
|
||||
telePhone: pick('telePhone', 'TelePhone'),
|
||||
email: pick('email', 'Email'),
|
||||
countryCode: pick('countryCode', 'CountryCode'),
|
||||
province: pick('province', 'Province'),
|
||||
city: pick('city', 'City'),
|
||||
streetNumber: pick('streetNumber', 'StreetNumber'),
|
||||
streetAddress1: pick('streetAddress1', 'StreetAddress1'),
|
||||
streetAddress2: pick('streetAddress2', 'StreetAddress2'),
|
||||
streetAddress3: pick('streetAddress3', 'StreetAddress3'),
|
||||
zipCode: pick('zipCode', 'ZipCode'),
|
||||
},
|
||||
isComplete: Boolean(data?.isComplete ?? data?.IsComplete),
|
||||
missingRequired: (data?.missingRequired ?? data?.MissingRequired ?? []) as string[],
|
||||
missingRecommended: (data?.missingRecommended ?? data?.MissingRecommended ?? []) as string[],
|
||||
warnings: (data?.warnings ?? data?.Warnings ?? []) as string[],
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /order/parcel/create */
|
||||
export async function postOrderParcelCreate(body: Record<string, any>): Promise<any> {
|
||||
try {
|
||||
|
||||
2
src/components.d.ts
vendored
2
src/components.d.ts
vendored
@ -8,6 +8,7 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ActiveFilterBar: typeof import('./components/HandsontablePro/ActiveFilterBar.vue')['default']
|
||||
AiAdvisorShell: typeof import('./components/NanaAssistant/AiAdvisorShell.vue')['default']
|
||||
ApiTest: typeof import('./components/ApiTest/index.vue')['default']
|
||||
BasicLayoutLeftRight: typeof import('./components/BasicLayoutLeftRight/index.vue')['default']
|
||||
@ -27,6 +28,7 @@ declare module 'vue' {
|
||||
FormWrapper: typeof import('./components/global-setting/form-wrapper.vue')['default']
|
||||
GlobalSetting: typeof import('./components/global-setting/index.vue')['default']
|
||||
HandsontablePro: typeof import('./components/HandsontablePro/index.vue')['default']
|
||||
HeaderFilterPopover: typeof import('./components/HandsontablePro/HeaderFilterPopover.vue')['default']
|
||||
InterfaceDetail: typeof import('./components/InterfaceDetail/index.vue')['default']
|
||||
InvitePoster: typeof import('./components/InvitePoster.vue')['default']
|
||||
InviteQrImage: typeof import('./components/InviteQrImage.vue')['default']
|
||||
|
||||
124
src/components/HandsontablePro/ActiveFilterBar.vue
Normal file
124
src/components/HandsontablePro/ActiveFilterBar.vue
Normal file
@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
export type ActiveFilterItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
text: string;
|
||||
kind: 'filter' | 'sort';
|
||||
};
|
||||
|
||||
defineProps<{
|
||||
items: ActiveFilterItem[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
clear: [key: string];
|
||||
'clear-all': [];
|
||||
'item-click': [key: string];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="items.length" class="ht-active-filter-bar">
|
||||
<span class="ht-active-filter-bar__title">已筛选</span>
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="`${item.kind}-${item.key}`"
|
||||
type="button"
|
||||
class="ht-active-filter-bar__pill"
|
||||
:class="{ 'is-sort': item.kind === 'sort' }"
|
||||
:title="item.text"
|
||||
@click="emit('item-click', item.key)"
|
||||
>
|
||||
<span class="ht-active-filter-bar__pill-text">{{ item.label }} · {{ item.text }}</span>
|
||||
<span
|
||||
class="ht-active-filter-bar__pill-x"
|
||||
title="清除"
|
||||
@click.stop="emit('clear', item.key)"
|
||||
>×</span>
|
||||
</button>
|
||||
<button type="button" class="ht-active-filter-bar__clear" @click="emit('clear-all')">
|
||||
清除全部
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less">
|
||||
.ht-active-filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
background: color-mix(in srgb, var(--app-primary-color, #007979) 8%, #fff);
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ht-active-filter-bar__title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
.ht-active-filter-bar__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 280px;
|
||||
height: 24px;
|
||||
padding: 0 6px 0 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--app-primary-color, #007979) 35%, #e5e6eb);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: var(--app-primary-color, #007979);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&.is-sort {
|
||||
border-color: #00b42a;
|
||||
color: #00b42a;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #f7f8fa;
|
||||
}
|
||||
}
|
||||
|
||||
.ht-active-filter-bar__pill-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ht-active-filter-bar__pill-x {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 14px;
|
||||
text-align: center;
|
||||
border-radius: 2px;
|
||||
color: #86909c;
|
||||
|
||||
&:hover {
|
||||
background: #f2f3f5;
|
||||
color: #1d2129;
|
||||
}
|
||||
}
|
||||
|
||||
.ht-active-filter-bar__clear {
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #86909c;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
height: 24px;
|
||||
|
||||
&:hover {
|
||||
color: var(--app-primary-color, #007979);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1134
src/components/HandsontablePro/HeaderFilterPopover.vue
Normal file
1134
src/components/HandsontablePro/HeaderFilterPopover.vue
Normal file
File diff suppressed because it is too large
Load Diff
104
src/components/HandsontablePro/filterValue.ts
Normal file
104
src/components/HandsontablePro/filterValue.ts
Normal file
@ -0,0 +1,104 @@
|
||||
/** 表头条件过滤值编码:__EMPTY__ / __eq__:x / __ne__:x / __gt__:x / __gte__:x / __lt__:x / __lte__:x / 明文(包含) */
|
||||
|
||||
export type FilterOp = 'contains' | 'equals' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'empty';
|
||||
|
||||
const OP_PREFIX_RE = /^__(eq|ne|gt|gte|lt|lte)__:(.*)$/s;
|
||||
|
||||
const OP_LABEL: Record<FilterOp, string> = {
|
||||
contains: '包含',
|
||||
equals: '等于',
|
||||
ne: '不等于',
|
||||
gt: '大于',
|
||||
gte: '大于等于',
|
||||
lt: '小于',
|
||||
lte: '小于等于',
|
||||
empty: '为空',
|
||||
};
|
||||
|
||||
const PREFIX_TO_OP: Record<string, FilterOp> = {
|
||||
eq: 'equals',
|
||||
ne: 'ne',
|
||||
gt: 'gt',
|
||||
gte: 'gte',
|
||||
lt: 'lt',
|
||||
lte: 'lte',
|
||||
};
|
||||
|
||||
export function encodeFilterValue(op: FilterOp, value = ''): string {
|
||||
const v = (value || '').trim();
|
||||
if (op === 'empty') return '__EMPTY__';
|
||||
if (op === 'contains') return v;
|
||||
if (op === 'equals') return v ? `__eq__:${v}` : '';
|
||||
if (op === 'ne') return v ? `__ne__:${v}` : '';
|
||||
if (op === 'gt') return v ? `__gt__:${v}` : '';
|
||||
if (op === 'gte') return v ? `__gte__:${v}` : '';
|
||||
if (op === 'lt') return v ? `__lt__:${v}` : '';
|
||||
if (op === 'lte') return v ? `__lte__:${v}` : '';
|
||||
return v;
|
||||
}
|
||||
|
||||
export function parseFilterValue(raw: string): { op: FilterOp; value: string } {
|
||||
const text = (raw || '').trim();
|
||||
if (!text) return { op: 'contains', value: '' };
|
||||
if (text === '__EMPTY__') return { op: 'empty', value: '' };
|
||||
const m = text.match(OP_PREFIX_RE);
|
||||
if (m) {
|
||||
return { op: PREFIX_TO_OP[m[1]] || 'contains', value: m[2] ?? '' };
|
||||
}
|
||||
return { op: 'contains', value: text };
|
||||
}
|
||||
|
||||
export function formatFilterOpDisplay(raw: string): string {
|
||||
const { op, value } = parseFilterValue(raw);
|
||||
if (op === 'empty') return OP_LABEL.empty;
|
||||
if (!value) return '';
|
||||
if (op === 'contains') return value;
|
||||
return `${OP_LABEL[op]} ${value}`;
|
||||
}
|
||||
|
||||
/** 是否需要在当前页做本地条件匹配(服务端不支持的运算符) */
|
||||
export function needsLocalOpFilter(raw: string): boolean {
|
||||
const { op } = parseFilterValue(raw);
|
||||
return op === 'empty' || op === 'ne' || op === 'gt' || op === 'gte' || op === 'lt' || op === 'lte' || op === 'equals';
|
||||
}
|
||||
|
||||
/** 提交给后端的明文检索值(高级运算本地处理,不透传编码) */
|
||||
export function toServerSearchValue(raw: string): string {
|
||||
const { op, value } = parseFilterValue(raw);
|
||||
if (op === 'contains' || op === 'equals') return value;
|
||||
return '';
|
||||
}
|
||||
|
||||
function isEmptyCellValue(val: any): boolean {
|
||||
return val == null || String(val).trim() === '';
|
||||
}
|
||||
|
||||
export function matchFilterValue(cellVal: any, raw: string, asNumber = false): boolean {
|
||||
const { op, value } = parseFilterValue(raw);
|
||||
if (op === 'empty') return isEmptyCellValue(cellVal);
|
||||
if (!value) return true;
|
||||
|
||||
if (asNumber || op === 'gt' || op === 'gte' || op === 'lt' || op === 'lte') {
|
||||
const n = Number(value);
|
||||
const cv = Number(cellVal);
|
||||
if (Number.isNaN(n)) return true;
|
||||
if (Number.isNaN(cv) || isEmptyCellValue(cellVal)) return false;
|
||||
if (op === 'equals' || (asNumber && op === 'contains')) return cv === n;
|
||||
if (op === 'ne') return cv !== n;
|
||||
if (op === 'gt') return cv > n;
|
||||
if (op === 'gte') return cv >= n;
|
||||
if (op === 'lt') return cv < n;
|
||||
if (op === 'lte') return cv <= n;
|
||||
}
|
||||
|
||||
const cell = String(cellVal ?? '');
|
||||
const cellLower = cell.toLowerCase();
|
||||
const needle = value.toLowerCase();
|
||||
if (op === 'equals') return cellLower === needle;
|
||||
if (op === 'ne') return cellLower !== needle;
|
||||
if (op === 'gte') return cellLower >= needle;
|
||||
if (op === 'lte') return cellLower <= needle;
|
||||
if (op === 'gt') return cellLower > needle;
|
||||
if (op === 'lt') return cellLower < needle;
|
||||
return cellLower.includes(needle);
|
||||
}
|
||||
96
src/components/HandsontablePro/hybridHeader.ts
Normal file
96
src/components/HandsontablePro/hybridHeader.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import type { ColumnDef, FilterType, HeaderMode } from './types';
|
||||
|
||||
/** 操作/展示列:不生成混合表头 */
|
||||
const HEADER_NONE_KEY_RE =
|
||||
/^(action|actions|operation|operate|ops|view|retry|cancel|image|avatar|checkbox|settings|config|btn|button)s?$/i;
|
||||
|
||||
/** 日期列 */
|
||||
const DATE_KEY_RE =
|
||||
/(Time|Date|At)$|^(create|update|modify|pay|ship|sign|receive|finish|bind|eligible|settle|arrival|expected|valid|auth|token|pickup|processed|finished|newestTrack)/i;
|
||||
|
||||
/** 更精确的日期字段 */
|
||||
const DATE_KEY_STRICT_RE =
|
||||
/(Time|Date|At)$/i;
|
||||
|
||||
/** 数值列 */
|
||||
const NUMBER_KEY_RE =
|
||||
/(Qty|Count|Amount|Points|Weight|Balance|Rate|Price|Quota|Discount|Span|Sort|Size|Height|Width|Length|Volume)$|^(min|max|total|available|frozen|qty|count|amount|points|weight|balance|quota|sort|retry|fail|extra)/i;
|
||||
|
||||
function looksLikeDateKey(key: string): boolean {
|
||||
if (DATE_KEY_STRICT_RE.test(key)) return true;
|
||||
// newestTrackTime 等
|
||||
if (/trackTime|createTime|updateTime|payTime|shipTime/i.test(key)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function looksLikeNumberKey(key: string): boolean {
|
||||
if (looksLikeDateKey(key)) return false;
|
||||
return NUMBER_KEY_RE.test(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 为未配置混合表头的列自动补齐 headerMode / filterType 等。
|
||||
* - 已显式配置 headerMode / searchable / searchField / filterType 的列不覆盖
|
||||
* - 默认 serverFilter=false(当前页过滤);页面若要走服务端可自行设 serverFilter:true + searchParams/@search
|
||||
*/
|
||||
export function enrichHybridColumn(col: ColumnDef, enabled: boolean): ColumnDef {
|
||||
if (!enabled || !col?.key) return col;
|
||||
|
||||
// 已手工配置过:只补 searchField,不改模式
|
||||
const alreadyConfigured = !!(
|
||||
col.headerMode ||
|
||||
col.searchable ||
|
||||
col.searchField ||
|
||||
col.filterType ||
|
||||
col.filterable
|
||||
);
|
||||
if (alreadyConfigured) {
|
||||
if ((col.searchable || col.filterable || col.filterType || col.headerMode === 'inline' || col.headerMode === 'icon') && !col.searchField) {
|
||||
return { ...col, searchField: col.key };
|
||||
}
|
||||
return col;
|
||||
}
|
||||
|
||||
if (HEADER_NONE_KEY_RE.test(col.key)) {
|
||||
return { ...col, headerMode: 'none' as HeaderMode };
|
||||
}
|
||||
|
||||
if (looksLikeDateKey(col.key)) {
|
||||
return {
|
||||
...col,
|
||||
headerMode: 'icon' as HeaderMode,
|
||||
filterType: (col.filterType || 'date') as FilterType,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
searchField: col.key,
|
||||
sortable: col.sortable !== false,
|
||||
serverFilter: col.serverFilter !== undefined ? col.serverFilter : false,
|
||||
};
|
||||
}
|
||||
|
||||
if (looksLikeNumberKey(col.key)) {
|
||||
return {
|
||||
...col,
|
||||
headerMode: 'icon' as HeaderMode,
|
||||
filterType: (col.filterType || 'number') as FilterType,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
searchField: col.key,
|
||||
sortable: col.sortable !== false,
|
||||
serverFilter: col.serverFilter !== undefined ? col.serverFilter : false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...col,
|
||||
headerMode: 'inline' as HeaderMode,
|
||||
searchable: true,
|
||||
searchField: col.key,
|
||||
sortable: col.sortable !== false,
|
||||
serverFilter: col.serverFilter !== undefined ? col.serverFilter : false,
|
||||
};
|
||||
}
|
||||
|
||||
export function enrichHybridColumns(columns: ColumnDef[], enabled: boolean): ColumnDef[] {
|
||||
return (columns || []).map((c) => enrichHybridColumn(c, enabled));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,10 +1,23 @@
|
||||
export type HeaderMode = 'inline' | 'icon' | 'none';
|
||||
export type FilterType = 'text' | 'enum' | 'number' | 'date';
|
||||
export type SortOrder = 'asc' | 'desc' | null;
|
||||
export type SortMode = 'local' | 'remote';
|
||||
|
||||
export interface ColumnDef {
|
||||
key: string;
|
||||
label: string;
|
||||
width: number;
|
||||
/** 混合表头:内嵌输入 / 仅图标 / 纯展示;未设时由 searchable + filterType 推导 */
|
||||
headerMode?: HeaderMode;
|
||||
searchable?: boolean;
|
||||
searchField?: string;
|
||||
multiValue?: boolean;
|
||||
filterable?: boolean;
|
||||
filterType?: FilterType;
|
||||
filterOptions?: Array<{ label: string; value: string | number | boolean }>;
|
||||
sortable?: boolean;
|
||||
/** 默认 true;false 表示仅本页过滤 */
|
||||
serverFilter?: boolean;
|
||||
renderer?: (row: any, value: any) => string;
|
||||
className?: string;
|
||||
type?: string;
|
||||
@ -33,4 +46,19 @@ export interface HandsontableProProps {
|
||||
persistColumnOrderKey?: string;
|
||||
lockedColumnKeys?: string[];
|
||||
enableColumnOrderConfig?: boolean;
|
||||
enableHeaderSearch?: boolean;
|
||||
/** 未配置 headerMode 的列自动补齐混合表头;默认 true */
|
||||
autoHybridHeader?: boolean;
|
||||
/** 原生 Handsontable 下拉过滤(本页);默认关闭,改用自研 Popover */
|
||||
enableColumnFilter?: boolean;
|
||||
/** 表头排序箭头 */
|
||||
enableHeaderSort?: boolean;
|
||||
/** 表头筛选 ▾(自研 Popover) */
|
||||
enableHeaderFilter?: boolean;
|
||||
/** 表格上方已筛选条件条 */
|
||||
showActiveFilterBar?: boolean;
|
||||
/** local=排当前页;remote=仅 emit sort-change */
|
||||
sortMode?: SortMode;
|
||||
sortField?: string | null;
|
||||
sortOrder?: SortOrder;
|
||||
}
|
||||
|
||||
@ -76,7 +76,7 @@ export default function setupUserLoginInfoGuard(router: Router) {
|
||||
typeof to.query.redirect === 'string' ? to.query.redirect : '';
|
||||
const target = resolveCustomerEntryPath(redirect || CUSTOMER_H5_HOME_PATH);
|
||||
next({
|
||||
path: isLogin() ? target : '/h5/me',
|
||||
path: isLogin() ? target : '/h5/login',
|
||||
query: !isLogin() && redirect ? { redirect: target } : {},
|
||||
replace: true,
|
||||
});
|
||||
@ -111,7 +111,7 @@ export default function setupUserLoginInfoGuard(router: Router) {
|
||||
console.error('客户门户 SSO 登录失败', error);
|
||||
}
|
||||
next({
|
||||
path: preferH5Portal() ? '/h5/me' : '/',
|
||||
path: preferH5Portal() ? '/h5/login' : '/',
|
||||
query: {},
|
||||
replace: true,
|
||||
});
|
||||
@ -148,11 +148,11 @@ export default function setupUserLoginInfoGuard(router: Router) {
|
||||
return;
|
||||
}
|
||||
|
||||
// H5 客户中心:未登录统一引导到「我的」(含微信注册/绑定),避免跳 PC 登录大页
|
||||
// H5 客户中心:未登录统一引导到 /h5/login,避免「我的」再套一套登录页
|
||||
if (!isLogin() && String(to.path || '').startsWith('/h5')) {
|
||||
NProgress.done();
|
||||
next({
|
||||
path: '/h5/me',
|
||||
path: '/h5/login',
|
||||
query: { redirect: to.fullPath },
|
||||
replace: true,
|
||||
});
|
||||
|
||||
@ -64,7 +64,7 @@ const router = createRouter({
|
||||
const token =
|
||||
localStorage.getItem('rbac_access_token') ||
|
||||
localStorage.getItem('token');
|
||||
return token ? '/h5/orders' : '/h5/me';
|
||||
return token ? '/h5/orders' : '/h5/login';
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -111,7 +111,7 @@ const router = createRouter({
|
||||
name: 'H5Me',
|
||||
component: () => import('@/views/h5/customer-center/me/index.vue'),
|
||||
meta: {
|
||||
requiresAuth: false,
|
||||
requiresAuth: true,
|
||||
locale: 'h5.tab.me',
|
||||
h5HideHeader: true,
|
||||
},
|
||||
@ -190,7 +190,7 @@ const router = createRouter({
|
||||
const token =
|
||||
localStorage.getItem('rbac_access_token') ||
|
||||
localStorage.getItem('token');
|
||||
return token ? '/h5/orders' : '/h5/me';
|
||||
return token ? '/h5/orders' : '/h5/login';
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@ -206,11 +206,11 @@ export function resolvePostLogoutLogin(options?: {
|
||||
routeName = resolveLoginRouteNameFromPath(path)
|
||||
}
|
||||
|
||||
// 手机/微信客户:回 H5「我的」登录引导,不进 PC 登录大页
|
||||
// 手机/微信客户:回 H5 登录页,不进 PC 登录大页
|
||||
if (routeName === CUSTOMER_LOGIN_ROUTE_NAME && preferH5Portal()) {
|
||||
return {
|
||||
name: CUSTOMER_LOGIN_ROUTE_NAME,
|
||||
path: '/h5/me',
|
||||
path: '/h5/login',
|
||||
query: path.startsWith('/h5')
|
||||
? { redirect: path }
|
||||
: { redirect: resolveCustomerEntryPath(path) },
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -16,6 +16,7 @@
|
||||
:stretch-columns="true"
|
||||
:height="handsontableHeight"
|
||||
:enable-header-search="false"
|
||||
:auto-hybrid-header="false"
|
||||
:enable-column-filter="false"
|
||||
@action-click="handleActionClick"
|
||||
/>
|
||||
|
||||
@ -551,6 +551,15 @@
|
||||
>
|
||||
{{ t('h5.placeOrder.prev') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="step === 1"
|
||||
type="button"
|
||||
class="h5-po__btn is-ai"
|
||||
:disabled="submitting"
|
||||
@click="openReceiverAiParse"
|
||||
>
|
||||
{{ t('h5.placeOrder.aiParse') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="step < 2"
|
||||
type="button"
|
||||
@ -573,6 +582,56 @@
|
||||
</div>
|
||||
</a-drawer>
|
||||
|
||||
<a-modal
|
||||
v-model:visible="receiverAiVisible"
|
||||
:title="t('h5.placeOrder.aiParseTitle')"
|
||||
:footer="false"
|
||||
unmount-on-close
|
||||
width="92%"
|
||||
:mask-closable="!receiverParseLoading"
|
||||
>
|
||||
<div class="h5-po-ai">
|
||||
<p class="h5-po-ai__hint">{{ t('h5.placeOrder.aiParseHint') }}</p>
|
||||
<a-textarea
|
||||
v-model="receiverPasteText"
|
||||
class="h5-po-ai__textarea"
|
||||
:auto-size="{ minRows: 5, maxRows: 10 }"
|
||||
:placeholder="t('h5.placeOrder.aiParsePlaceholder')"
|
||||
:disabled="receiverParseLoading"
|
||||
/>
|
||||
<p
|
||||
v-if="receiverParseTip"
|
||||
class="h5-po-ai__tip"
|
||||
:class="`is-${receiverParseTip.tone}`"
|
||||
>
|
||||
{{ receiverParseTip.text }}
|
||||
</p>
|
||||
<div class="h5-po-ai__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="h5-po__btn is-ghost"
|
||||
:disabled="receiverParseLoading"
|
||||
@click="receiverAiVisible = false"
|
||||
>
|
||||
{{ t('h5.placeOrder.aiParseCancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h5-po__btn is-primary"
|
||||
:disabled="receiverParseLoading"
|
||||
@click="handleReceiverAiParse"
|
||||
>
|
||||
<span v-if="receiverParseLoading" class="h5-po__loading" aria-hidden="true" />
|
||||
{{
|
||||
receiverParseLoading
|
||||
? t('h5.placeOrder.aiParseLoading')
|
||||
: t('h5.placeOrder.aiParseConfirm')
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:visible="createFailVisible"
|
||||
:title="
|
||||
@ -658,7 +717,7 @@ import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Message, Modal } from '@arco-design/web-vue';
|
||||
import { IconClose } from '@arco-design/web-vue/es/icon';
|
||||
import { postOrderDelete, postOrderParcelCreate } from '@/api/order';
|
||||
import { postOrderDelete, postOrderParcelCreate, postOrderReceiverParseAddress } from '@/api/order';
|
||||
import { createSenderAddress, getSenderAddressList } from '@/api/order/senderAddress';
|
||||
import { getSalesProductList } from '@/api/channel/salesProduct';
|
||||
import { getDictListAll } from '@/api/system/dict';
|
||||
@ -708,6 +767,10 @@ const senderLoading = ref(false);
|
||||
const showSenderForm = ref(false);
|
||||
const savingSender = ref(false);
|
||||
const showReceiverMore = ref(false);
|
||||
const receiverAiVisible = ref(false);
|
||||
const receiverPasteText = ref('');
|
||||
const receiverParseLoading = ref(false);
|
||||
const receiverParseTip = ref<{ tone: 'ok' | 'warn' | 'danger'; text: string } | null>(null);
|
||||
/** 申报品「更多」展开状态,按 item._key */
|
||||
const openItemMoreKeys = ref<Set<string>>(new Set());
|
||||
const senderForm = reactive({
|
||||
@ -1062,6 +1125,107 @@ function goNext() {
|
||||
step.value += 1;
|
||||
}
|
||||
|
||||
function openReceiverAiParse() {
|
||||
receiverParseTip.value = null;
|
||||
receiverAiVisible.value = true;
|
||||
}
|
||||
|
||||
function applyParsedReceiver(parsed: Record<string, string | undefined>) {
|
||||
const assignKeys = [
|
||||
'recipientsName',
|
||||
'companyName',
|
||||
'phone',
|
||||
'telePhone',
|
||||
'email',
|
||||
'countryCode',
|
||||
'province',
|
||||
'city',
|
||||
'streetNumber',
|
||||
'streetAddress1',
|
||||
'streetAddress2',
|
||||
'streetAddress3',
|
||||
'zipCode',
|
||||
] as const;
|
||||
for (const key of assignKeys) {
|
||||
const val = trim(parsed[key] || '');
|
||||
if (val) {
|
||||
(form.receiver as Record<string, string>)[key] = val;
|
||||
}
|
||||
}
|
||||
const needMore = Boolean(
|
||||
trim(parsed.companyName || '') ||
|
||||
trim(parsed.telePhone || '') ||
|
||||
trim(parsed.email || '') ||
|
||||
trim(parsed.streetNumber || '') ||
|
||||
trim(parsed.streetAddress2 || '') ||
|
||||
trim(parsed.streetAddress3 || ''),
|
||||
);
|
||||
if (needMore) {
|
||||
showReceiverMore.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function buildReceiverParseTip(result: {
|
||||
isComplete: boolean;
|
||||
missingRequired: string[];
|
||||
missingRecommended: string[];
|
||||
warnings: string[];
|
||||
}) {
|
||||
const parts: string[] = [];
|
||||
if (result.missingRequired.length) {
|
||||
parts.push(`${t('h5.placeOrder.aiParseMissingRequired')}${result.missingRequired.join('、')}`);
|
||||
}
|
||||
if (result.missingRecommended.length) {
|
||||
parts.push(`${t('h5.placeOrder.aiParseMissingRecommended')}${result.missingRecommended.join('、')}`);
|
||||
}
|
||||
if (result.warnings.length) {
|
||||
parts.push(result.warnings.slice(0, 2).join(';'));
|
||||
}
|
||||
if (result.isComplete && !result.missingRecommended.length && !result.warnings.length) {
|
||||
receiverParseTip.value = { tone: 'ok', text: t('h5.placeOrder.aiParseComplete') };
|
||||
return;
|
||||
}
|
||||
if (result.isComplete) {
|
||||
receiverParseTip.value = {
|
||||
tone: 'warn',
|
||||
text: parts.length ? `${t('h5.placeOrder.aiParseRequiredOk')}${parts.join('。')}` : t('h5.placeOrder.aiParseRequiredOkShort'),
|
||||
};
|
||||
return;
|
||||
}
|
||||
receiverParseTip.value = {
|
||||
tone: 'danger',
|
||||
text: parts.length ? parts.join('。') : t('h5.placeOrder.aiParseIncomplete'),
|
||||
};
|
||||
}
|
||||
|
||||
async function handleReceiverAiParse() {
|
||||
const raw = receiverPasteText.value.trim();
|
||||
if (!raw) {
|
||||
Message.warning(t('h5.placeOrder.aiParseEmpty'));
|
||||
return;
|
||||
}
|
||||
if (receiverParseLoading.value) return;
|
||||
receiverParseLoading.value = true;
|
||||
receiverParseTip.value = null;
|
||||
try {
|
||||
const result = await postOrderReceiverParseAddress({ rawText: raw });
|
||||
applyParsedReceiver(result.receiver);
|
||||
buildReceiverParseTip(result);
|
||||
Message.success(t('h5.placeOrder.aiParseSuccess'));
|
||||
if (result.isComplete) {
|
||||
receiverAiVisible.value = false;
|
||||
}
|
||||
} catch (e: any) {
|
||||
receiverParseTip.value = {
|
||||
tone: 'danger',
|
||||
text: e?.message || t('h5.placeOrder.aiParseFailed'),
|
||||
};
|
||||
Message.error(e?.message || t('h5.placeOrder.aiParseFailed'));
|
||||
} finally {
|
||||
receiverParseLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goToStep(idx: number) {
|
||||
if (idx === step.value || submitting.value) return;
|
||||
if (idx < step.value) {
|
||||
@ -1558,6 +1722,10 @@ watch(
|
||||
submitting.value = false;
|
||||
showSenderForm.value = false;
|
||||
resetSenderForm();
|
||||
receiverAiVisible.value = false;
|
||||
receiverPasteText.value = '';
|
||||
receiverParseTip.value = null;
|
||||
receiverParseLoading.value = false;
|
||||
return;
|
||||
}
|
||||
resetForm();
|
||||
@ -2046,7 +2214,7 @@ watch(
|
||||
gap: 8px;
|
||||
|
||||
&.is-ghost {
|
||||
flex: 0 0 96px;
|
||||
flex: 0 0 88px;
|
||||
background: #f2f3f5;
|
||||
color: #4e5969;
|
||||
|
||||
@ -2055,6 +2223,20 @@ watch(
|
||||
}
|
||||
}
|
||||
|
||||
&.is-ai {
|
||||
flex: 0 0 auto;
|
||||
min-width: 88px;
|
||||
padding: 0 12px;
|
||||
background: color-mix(in srgb, var(--app-primary-color) 12%, #fff);
|
||||
color: var(--app-primary-color);
|
||||
border: 1px solid color-mix(in srgb, var(--app-primary-color) 35%, #e5e6eb);
|
||||
box-shadow: none;
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--app-primary-color) 18%, #fff);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-primary {
|
||||
background: var(--app-primary-color);
|
||||
color: #fff;
|
||||
@ -2075,6 +2257,58 @@ watch(
|
||||
}
|
||||
}
|
||||
|
||||
.h5-po-ai {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.h5-po-ai__hint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.h5-po-ai__textarea {
|
||||
:deep(textarea) {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.h5-po-ai__tip {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
|
||||
&.is-ok {
|
||||
color: #00a870;
|
||||
}
|
||||
|
||||
&.is-warn {
|
||||
color: #d48806;
|
||||
}
|
||||
|
||||
&.is-danger {
|
||||
color: #f53f3f;
|
||||
}
|
||||
}
|
||||
|
||||
.h5-po-ai__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
|
||||
.h5-po__btn {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.is-ghost {
|
||||
flex: 0 0 88px;
|
||||
}
|
||||
}
|
||||
|
||||
.h5-po__loading {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { IconWechat } from '@arco-design/web-vue/es/icon';
|
||||
import { isWeChatBrowser } from '@/utils/device';
|
||||
import { redirectToWeChatOAuth } from '@/utils/wechatOAuth';
|
||||
@ -12,11 +13,13 @@ const props = withDefaults(
|
||||
afterLoginPath?: string;
|
||||
long?: boolean;
|
||||
size?: 'mini' | 'small' | 'medium' | 'large';
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{
|
||||
afterLoginPath: '/h5/orders',
|
||||
long: true,
|
||||
long: false,
|
||||
size: 'large',
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
@ -25,8 +28,11 @@ const jumping = ref(false);
|
||||
const inWeChat = computed(() => isWeChatBrowser());
|
||||
|
||||
function handleClick() {
|
||||
if (jumping.value) return;
|
||||
if (!inWeChat.value) return;
|
||||
if (jumping.value || props.disabled) return;
|
||||
if (!inWeChat.value) {
|
||||
Message.warning(t('h5.login.openInWechat'));
|
||||
return;
|
||||
}
|
||||
jumping.value = true;
|
||||
redirectToWeChatOAuth('login', null, { afterLoginPath: props.afterLoginPath });
|
||||
}
|
||||
@ -35,37 +41,35 @@ function handleClick() {
|
||||
<template>
|
||||
<div class="wx-oauth-login">
|
||||
<a-button
|
||||
v-if="inWeChat"
|
||||
type="primary"
|
||||
class="wx-oauth-login__btn"
|
||||
:long="long"
|
||||
:size="size"
|
||||
:loading="jumping"
|
||||
:disabled="disabled || (!inWeChat && !jumping)"
|
||||
@click="handleClick"
|
||||
>
|
||||
<template #icon><icon-wechat /></template>
|
||||
{{ jumping ? t('h5.login.oauthRedirecting') : t('h5.login.oauthLogin') }}
|
||||
</a-button>
|
||||
<template v-else>
|
||||
<p class="wx-oauth-login__tip">{{ t('h5.login.openInWechat') }}</p>
|
||||
<a-button type="primary" class="wx-oauth-login__btn" :long="long" :size="size" disabled>
|
||||
<template #icon><icon-wechat /></template>
|
||||
{{ t('h5.login.oauthLogin') }}
|
||||
</a-button>
|
||||
</template>
|
||||
<p v-if="!inWeChat" class="wx-oauth-login__tip">{{ t('h5.login.openInWechat') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.wx-oauth-login {
|
||||
width: 100%;
|
||||
padding: 8px 0 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wx-oauth-login__btn {
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
width: 50%;
|
||||
min-width: 148px;
|
||||
height: 48px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
color: #fff !important;
|
||||
background: #07c160 !important;
|
||||
border-color: #07c160 !important;
|
||||
|
||||
@ -74,12 +78,18 @@ function handleClick() {
|
||||
background: #06ad56 !important;
|
||||
border-color: #06ad56 !important;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: #fff !important;
|
||||
background: #94d3ad !important;
|
||||
border-color: #94d3ad !important;
|
||||
}
|
||||
}
|
||||
|
||||
.wx-oauth-login__tip {
|
||||
margin: 0 0 12px;
|
||||
margin: 10px 0 0;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
@ -163,16 +163,18 @@ export default {
|
||||
'h5.wxQr.success': 'Signed in',
|
||||
|
||||
'h5.login.title': 'Sign in',
|
||||
'h5.login.brand': 'Customer Center',
|
||||
'h5.login.brand': 'IDE Customer Center',
|
||||
'h5.login.heroSub': 'Sign in with account password, or authorize in WeChat',
|
||||
'h5.login.tab.wechat': 'WeChat',
|
||||
'h5.login.tab.password': 'Account',
|
||||
'h5.login.switchPassword': 'Sign in with password',
|
||||
'h5.login.switchWechat': 'WeChat authorize sign-in',
|
||||
'h5.login.or': 'or',
|
||||
'h5.login.oauthLogin': 'Sign in with WeChat',
|
||||
'h5.login.oauthRedirecting': 'Opening WeChat authorization…',
|
||||
'h5.login.oauthLoggingIn': 'Authorized. Signing you in…',
|
||||
'h5.login.oauthFailed': 'WeChat sign-in failed. Please retry.',
|
||||
'h5.login.openInWechat': 'Open this page in WeChat, then tap the button below to authorize.',
|
||||
'h5.login.openInWechat': 'Open this page in WeChat to use WeChat authorize sign-in',
|
||||
'h5.login.accountPlaceholder': 'Phone / email / customer code',
|
||||
'h5.login.passwordPlaceholder': 'Enter password',
|
||||
'h5.login.accountRequired': 'Please enter account',
|
||||
@ -307,6 +309,24 @@ export default {
|
||||
'h5.placeOrder.err.itemLine': 'Item {n}: {msg}',
|
||||
'h5.placeOrder.prev': 'Back',
|
||||
'h5.placeOrder.next': 'Next',
|
||||
'h5.placeOrder.aiParse': 'AI parse',
|
||||
'h5.placeOrder.aiParseTitle': 'Paste address · AI parse',
|
||||
'h5.placeOrder.aiParseHint':
|
||||
'Paste name, phone, address and ZIP together — we will fill the recipient fields',
|
||||
'h5.placeOrder.aiParsePlaceholder':
|
||||
'e.g. John Smith, +1 310-555-0199, 1001 Sunset Blvd, Los Angeles, CA 90001, USA',
|
||||
'h5.placeOrder.aiParseCancel': 'Close',
|
||||
'h5.placeOrder.aiParseConfirm': 'AI parse',
|
||||
'h5.placeOrder.aiParseLoading': 'Parsing…',
|
||||
'h5.placeOrder.aiParseEmpty': 'Paste an address first',
|
||||
'h5.placeOrder.aiParseSuccess': 'Recipient fields updated',
|
||||
'h5.placeOrder.aiParseFailed': 'Failed to parse address',
|
||||
'h5.placeOrder.aiParseComplete': 'Address looks complete and was filled in',
|
||||
'h5.placeOrder.aiParseRequiredOk': 'Required fields OK. ',
|
||||
'h5.placeOrder.aiParseRequiredOkShort': 'Required fields OK — please double-check details',
|
||||
'h5.placeOrder.aiParseIncomplete': 'Still incomplete — please fill remaining fields',
|
||||
'h5.placeOrder.aiParseMissingRequired': 'Missing required: ',
|
||||
'h5.placeOrder.aiParseMissingRecommended': 'Suggested: ',
|
||||
'h5.placeOrder.submit': 'Submit order',
|
||||
'h5.placeOrder.submitting': 'Submitting…',
|
||||
'h5.placeOrder.successTitle': 'Order created',
|
||||
|
||||
@ -162,16 +162,18 @@ export default {
|
||||
'h5.wxQr.success': '登录成功',
|
||||
|
||||
'h5.login.title': '登录',
|
||||
'h5.login.brand': '客户中心',
|
||||
'h5.login.brand': 'IDE客户中心',
|
||||
'h5.login.heroSub': '使用账号密码登录,或在微信内一键授权',
|
||||
'h5.login.tab.wechat': '微信登录',
|
||||
'h5.login.tab.password': '账号登录',
|
||||
'h5.login.switchPassword': '账号密码登录',
|
||||
'h5.login.switchWechat': '微信授权登录',
|
||||
'h5.login.or': '或',
|
||||
'h5.login.oauthLogin': '微信授权登录',
|
||||
'h5.login.oauthRedirecting': '正在打开微信授权…',
|
||||
'h5.login.oauthLoggingIn': '授权成功,正在登录…',
|
||||
'h5.login.oauthFailed': '微信授权登录失败,请重试',
|
||||
'h5.login.openInWechat': '请在微信内打开本页,点击下方按钮一键授权登录',
|
||||
'h5.login.openInWechat': '请在微信内打开本页,即可使用微信授权登录',
|
||||
'h5.login.accountPlaceholder': '手机号 / 邮箱 / 客户编号',
|
||||
'h5.login.passwordPlaceholder': '请输入密码',
|
||||
'h5.login.accountRequired': '请输入账号',
|
||||
@ -306,6 +308,23 @@ export default {
|
||||
'h5.placeOrder.err.itemLine': '第 {n} 条:{msg}',
|
||||
'h5.placeOrder.prev': '上一步',
|
||||
'h5.placeOrder.next': '下一步',
|
||||
'h5.placeOrder.aiParse': 'AI解析',
|
||||
'h5.placeOrder.aiParseTitle': '粘贴地址 · AI 解析',
|
||||
'h5.placeOrder.aiParseHint': '把姓名、电话、地址、邮编整段粘贴进来,解析后自动填入收件信息',
|
||||
'h5.placeOrder.aiParsePlaceholder':
|
||||
'例:John Smith, +1 310-555-0199, 1001 Sunset Blvd, Los Angeles, CA 90001, USA\n或:张三 13800138000 广东省深圳市南山区科技园南路 XX 号 518000',
|
||||
'h5.placeOrder.aiParseCancel': '关闭',
|
||||
'h5.placeOrder.aiParseConfirm': 'AI解析',
|
||||
'h5.placeOrder.aiParseLoading': '解析中…',
|
||||
'h5.placeOrder.aiParseEmpty': '请先粘贴收件人地址文本',
|
||||
'h5.placeOrder.aiParseSuccess': '已解析并填入收件人信息',
|
||||
'h5.placeOrder.aiParseFailed': '地址解析失败,请稍后重试',
|
||||
'h5.placeOrder.aiParseComplete': '地址信息完整,已填入表单',
|
||||
'h5.placeOrder.aiParseRequiredOk': '必填已齐。',
|
||||
'h5.placeOrder.aiParseRequiredOkShort': '必填已齐,请确认地址细节',
|
||||
'h5.placeOrder.aiParseIncomplete': '解析后仍不完整,请手动补全',
|
||||
'h5.placeOrder.aiParseMissingRequired': '仍缺必填:',
|
||||
'h5.placeOrder.aiParseMissingRecommended': '建议补全:',
|
||||
'h5.placeOrder.submit': '提交下单',
|
||||
'h5.placeOrder.submitting': '提交中…',
|
||||
'h5.placeOrder.successTitle': '下单成功',
|
||||
|
||||
@ -15,90 +15,65 @@
|
||||
</section>
|
||||
|
||||
<section class="h5-login-card">
|
||||
<div class="h5-login-tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="h5-login-tab"
|
||||
:class="{ 'is-active': activeTab === 'wechat' }"
|
||||
:aria-selected="activeTab === 'wechat'"
|
||||
@click="switchToWechat"
|
||||
>
|
||||
{{ t('h5.login.tab.wechat') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="h5-login-tab"
|
||||
:class="{ 'is-active': activeTab === 'password' }"
|
||||
:aria-selected="activeTab === 'password'"
|
||||
@click="switchToPassword"
|
||||
>
|
||||
{{ t('h5.login.tab.password') }}
|
||||
</button>
|
||||
<div v-if="oauthLoading" class="h5-login-oauth-banner">
|
||||
<a-spin :size="18" />
|
||||
<span>{{ t('h5.login.oauthLoggingIn') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="h5-login-stage" :data-mode="activeTab">
|
||||
<div
|
||||
class="h5-login-pane"
|
||||
:class="{ 'is-active': activeTab === 'wechat' }"
|
||||
:aria-hidden="activeTab !== 'wechat'"
|
||||
<form class="h5-login-form" @submit.prevent="handleSubmit">
|
||||
<a-input
|
||||
v-model="form.account"
|
||||
size="large"
|
||||
allow-clear
|
||||
class="h5-login-input"
|
||||
:placeholder="t('h5.login.accountPlaceholder')"
|
||||
autocomplete="username"
|
||||
/>
|
||||
<a-input-password
|
||||
v-model="form.password"
|
||||
size="large"
|
||||
allow-clear
|
||||
class="h5-login-input"
|
||||
:placeholder="t('h5.login.passwordPlaceholder')"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
|
||||
<p v-if="errorBox" class="h5-login-error">{{ errorBox }}</p>
|
||||
|
||||
<a-checkbox v-model="agreementChecked" class="h5-login-agree">
|
||||
<span>
|
||||
{{ t('h5.login.agreePrefix') }}
|
||||
<a class="h5-login-link" @click.prevent="openAgreement('privacy')">
|
||||
{{ t('h5.login.privacy') }}
|
||||
</a>
|
||||
{{ t('h5.login.and') }}
|
||||
<a class="h5-login-link" @click.prevent="openAgreement('terms')">
|
||||
{{ t('h5.login.terms') }}
|
||||
</a>
|
||||
</span>
|
||||
</a-checkbox>
|
||||
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
long
|
||||
size="large"
|
||||
class="h5-login-submit"
|
||||
:loading="loading"
|
||||
:disabled="oauthLoading"
|
||||
>
|
||||
<WechatOAuthLoginButton :after-login-path="resolveRedirectPath()" />
|
||||
<p v-if="oauthLoading" class="h5-login-oauth-status">{{ t('h5.login.oauthLoggingIn') }}</p>
|
||||
<p v-else-if="errorBox && activeTab === 'wechat'" class="h5-login-error">{{ errorBox }}</p>
|
||||
</div>
|
||||
{{ loading ? t('h5.login.submitting') : t('h5.login.submit') }}
|
||||
</a-button>
|
||||
</form>
|
||||
|
||||
<form
|
||||
class="h5-login-pane h5-login-form"
|
||||
:class="{ 'is-active': activeTab === 'password' }"
|
||||
:aria-hidden="activeTab !== 'password'"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<a-input
|
||||
v-model="form.account"
|
||||
size="large"
|
||||
allow-clear
|
||||
class="h5-login-input"
|
||||
:placeholder="t('h5.login.accountPlaceholder')"
|
||||
autocomplete="username"
|
||||
/>
|
||||
<a-input-password
|
||||
v-model="form.password"
|
||||
size="large"
|
||||
allow-clear
|
||||
class="h5-login-input"
|
||||
:placeholder="t('h5.login.passwordPlaceholder')"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
|
||||
<p v-if="errorBox" class="h5-login-error">{{ errorBox }}</p>
|
||||
|
||||
<a-checkbox v-model="agreementChecked" class="h5-login-agree">
|
||||
<span>
|
||||
{{ t('h5.login.agreePrefix') }}
|
||||
<a class="h5-login-link" @click.prevent="openAgreement('privacy')">
|
||||
{{ t('h5.login.privacy') }}
|
||||
</a>
|
||||
{{ t('h5.login.and') }}
|
||||
<a class="h5-login-link" @click.prevent="openAgreement('terms')">
|
||||
{{ t('h5.login.terms') }}
|
||||
</a>
|
||||
</span>
|
||||
</a-checkbox>
|
||||
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
long
|
||||
size="large"
|
||||
class="h5-login-submit"
|
||||
:loading="loading"
|
||||
>
|
||||
{{ loading ? t('h5.login.submitting') : t('h5.login.submit') }}
|
||||
</a-button>
|
||||
</form>
|
||||
<div class="h5-login-divider">
|
||||
<span>{{ t('h5.login.or') }}</span>
|
||||
</div>
|
||||
|
||||
<WechatOAuthLoginButton
|
||||
:after-login-path="resolveRedirectPath()"
|
||||
:disabled="loading || oauthLoading"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div class="h5-login-extra">
|
||||
@ -130,7 +105,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { nextTick, onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
@ -157,7 +132,6 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const rbacUserStore = useRbacUserStore();
|
||||
|
||||
const activeTab = ref<'wechat' | 'password'>('wechat');
|
||||
const form = reactive({ account: '', password: '' });
|
||||
const agreementChecked = ref(false);
|
||||
const loading = ref(false);
|
||||
@ -177,26 +151,12 @@ const termsText = `一、使用须知
|
||||
二、服务说明
|
||||
系统展示的运费、时效为估算或报价结果,最终以订单确认与仓库计费为准。异常订单请及时联系客服或使用 AI 顾问。`;
|
||||
|
||||
function switchToWechat() {
|
||||
errorBox.value = '';
|
||||
activeTab.value = 'wechat';
|
||||
if (route.query.tab === 'password') {
|
||||
const { tab: _tab, ...rest } = route.query;
|
||||
router.replace({ path: route.path, query: rest });
|
||||
}
|
||||
}
|
||||
|
||||
function switchToPassword() {
|
||||
errorBox.value = '';
|
||||
activeTab.value = 'password';
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
router.replace('/h5/me');
|
||||
router.replace('/h5/orders');
|
||||
}
|
||||
|
||||
function goRegister() {
|
||||
@ -261,7 +221,7 @@ async function finishLogin() {
|
||||
async function consumeOAuthCode(code: string) {
|
||||
if (oauthLoading.value) return;
|
||||
oauthLoading.value = true;
|
||||
activeTab.value = 'wechat';
|
||||
errorBox.value = '';
|
||||
try {
|
||||
const result = await wxOAuthLogin(code);
|
||||
const ok = await rbacUserStore.loginByWxResult(result);
|
||||
@ -283,7 +243,7 @@ async function consumeOAuthCode(code: string) {
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (loading.value) return;
|
||||
if (loading.value || oauthLoading.value) return;
|
||||
errorBox.value = '';
|
||||
|
||||
if (!agreementChecked.value) {
|
||||
@ -322,20 +282,6 @@ async function handleSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
(tab) => {
|
||||
if (tab === 'password') {
|
||||
activeTab.value = 'password';
|
||||
errorBox.value = '';
|
||||
return;
|
||||
}
|
||||
activeTab.value = 'wechat';
|
||||
errorBox.value = '';
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
if (isLogin()) {
|
||||
router.replace(resolveRedirectPath());
|
||||
@ -348,7 +294,6 @@ onMounted(async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 微信内且未带 code:可一键拉起授权(用户点按钮亦可)
|
||||
if (isWeChatBrowser() && route.query.auto === '1') {
|
||||
redirectToWeChatOAuth('login', null, { afterLoginPath: resolveRedirectPath() });
|
||||
}
|
||||
@ -417,17 +362,17 @@ onMounted(async () => {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 16px calc(28px + env(safe-area-inset-bottom, 0px));
|
||||
padding: 12px 16px calc(28px + env(safe-area-inset-bottom, 0px));
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
animation: h5-login-rise 0.48s var(--login-ease) both;
|
||||
}
|
||||
|
||||
.h5-login-hero {
|
||||
text-align: center;
|
||||
padding: 0 8px 14px;
|
||||
padding: 8px 8px 16px;
|
||||
}
|
||||
|
||||
.h5-login-hero__logo {
|
||||
@ -457,87 +402,28 @@ onMounted(async () => {
|
||||
.h5-login-card {
|
||||
background: #fff;
|
||||
border-radius: 22px;
|
||||
padding: 14px 18px 20px;
|
||||
padding: 22px 18px 20px;
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.8) inset,
|
||||
0 12px 32px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.h5-login-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
padding: 4px;
|
||||
border-radius: 12px;
|
||||
background: #f2f3f5;
|
||||
}
|
||||
|
||||
.h5-login-tab {
|
||||
height: 36px;
|
||||
border: none;
|
||||
.h5-login-oauth-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin: -6px 0 14px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: #86909c;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.2s var(--login-ease),
|
||||
color 0.2s var(--login-ease),
|
||||
box-shadow 0.2s var(--login-ease);
|
||||
|
||||
&.is-active {
|
||||
background: #fff;
|
||||
color: var(--app-primary-color);
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
&:active:not(.is-active) {
|
||||
color: #4e5969;
|
||||
}
|
||||
}
|
||||
|
||||
.h5-login-stage {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.h5-login-pane {
|
||||
grid-area: 1 / 1;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transform: translate3d(16px, 0, 0) scale(0.985);
|
||||
transition:
|
||||
opacity 0.34s var(--login-ease),
|
||||
transform 0.34s var(--login-ease),
|
||||
visibility 0.34s;
|
||||
|
||||
&.is-active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.h5-login-stage[data-mode='password'] .h5-login-pane:not(.is-active) {
|
||||
transform: translate3d(-16px, 0, 0) scale(0.985);
|
||||
}
|
||||
|
||||
.h5-login-stage[data-mode='wechat'] .h5-login-pane:not(.is-active) {
|
||||
transform: translate3d(16px, 0, 0) scale(0.985);
|
||||
background: #f2f3f5;
|
||||
color: #4e5969;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.h5-login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.h5-login-input {
|
||||
@ -575,13 +461,6 @@ onMounted(async () => {
|
||||
animation: h5-login-rise 0.28s var(--login-ease) both;
|
||||
}
|
||||
|
||||
.h5-login-oauth-status {
|
||||
margin: 12px 0 0;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.h5-login-agree {
|
||||
margin: 4px 0 0;
|
||||
align-items: flex-start;
|
||||
@ -597,9 +476,9 @@ onMounted(async () => {
|
||||
|
||||
.h5-login-submit {
|
||||
margin-top: 16px;
|
||||
height: 50px !important;
|
||||
border-radius: 14px !important;
|
||||
font-size: 17px;
|
||||
height: 48px !important;
|
||||
border-radius: 12px !important;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 8px 18px color-mix(in srgb, var(--app-primary-color) 26%, transparent);
|
||||
transition: transform 0.2s var(--login-ease), box-shadow 0.2s var(--login-ease) !important;
|
||||
@ -609,6 +488,27 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
.h5-login-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 18px 0 10px;
|
||||
color: #c9cdd4;
|
||||
font-size: 12px;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: #e5e6eb;
|
||||
}
|
||||
|
||||
span {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.h5-login-extra {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
@ -683,17 +583,11 @@ onMounted(async () => {
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.h5-login-main,
|
||||
.h5-login-pane,
|
||||
.h5-login-tab,
|
||||
.h5-login-text-btn,
|
||||
.h5-login-error,
|
||||
.h5-login-submit {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.h5-login-pane {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,92 +1,42 @@
|
||||
<template>
|
||||
<div class="h5-me-page" :class="{ 'h5-me-page--guest': !loggedIn }">
|
||||
<!-- 访客:微信授权登录(无二维码);新用户走注册/绑定 -->
|
||||
<template v-if="!loggedIn">
|
||||
<section class="h5-me-guest h5-enter">
|
||||
<div class="h5-me-guest__brand">
|
||||
<img :src="logoImg" alt="" class="h5-me-guest__logo" />
|
||||
<h2 class="h5-me-guest__title">{{ t('h5.login.brand') }}</h2>
|
||||
</div>
|
||||
<div class="h5-me-page">
|
||||
<a-spin :loading="loading" class="h5-me-spin">
|
||||
<div v-if="loading && !profile.id" class="h5-me-skel">
|
||||
<div class="h5-me-skel__hero" />
|
||||
<div class="h5-me-skel__card" />
|
||||
<div class="h5-me-skel__card h5-me-skel__card--short" />
|
||||
</div>
|
||||
|
||||
<div class="h5-me-guest__card">
|
||||
<div class="h5-me-guest__tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="h5-me-guest__tab is-active"
|
||||
aria-selected="true"
|
||||
>
|
||||
{{ t('h5.login.tab.wechat') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="h5-me-guest__tab"
|
||||
aria-selected="false"
|
||||
@click="goPasswordLogin"
|
||||
>
|
||||
{{ t('h5.login.tab.password') }}
|
||||
</button>
|
||||
</div>
|
||||
<WechatOAuthLoginButton :after-login-path="resolvePostLoginPath()" />
|
||||
<p v-if="route.query.redirect" class="h5-me-tip">{{ t('h5.me.needLogin') }}</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<section class="h5-me-hero h5-enter" :class="{ 'is-low': isLowBalance }">
|
||||
<span v-if="levelBadge" class="h5-me-vip">{{ levelBadge }}</span>
|
||||
|
||||
<div class="h5-me-guest__extra">
|
||||
<p class="h5-me-guest__hint">{{ t('h5.login.newUserHint') }}</p>
|
||||
<div class="h5-me-guest__actions">
|
||||
<button type="button" class="h5-me-text-btn is-primary" @click="goWechatRegister">
|
||||
<icon-user-add :size="18" />
|
||||
<span>{{ t('h5.login.goRegister') }}</span>
|
||||
</button>
|
||||
<button type="button" class="h5-me-text-btn" @click="goWechatBind">
|
||||
<icon-link :size="18" />
|
||||
<span>{{ t('h5.login.goBind') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- 已登录 -->
|
||||
<template v-else>
|
||||
<a-spin :loading="loading" class="h5-me-spin">
|
||||
<div v-if="loading && !profile.id" class="h5-me-skel">
|
||||
<div class="h5-me-skel__hero" />
|
||||
<div class="h5-me-skel__card" />
|
||||
<div class="h5-me-skel__card h5-me-skel__card--short" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<section class="h5-me-hero h5-enter" :class="{ 'is-low': isLowBalance }">
|
||||
<span v-if="levelBadge" class="h5-me-vip">{{ levelBadge }}</span>
|
||||
|
||||
<div class="h5-me-hero__top">
|
||||
<img :src="displayAvatar" alt="" class="h5-me-avatar" />
|
||||
<div class="h5-me-hero__text">
|
||||
<div class="h5-me-hero__name-row">
|
||||
<h2 class="h5-me-name">{{ displayName }}</h2>
|
||||
<span class="h5-me-greet">{{ greetText }}</span>
|
||||
</div>
|
||||
<div class="h5-me-hero__meta">
|
||||
<button
|
||||
v-if="profile.customerCode"
|
||||
type="button"
|
||||
class="h5-me-code"
|
||||
@click="copyText(profile.customerCode)"
|
||||
>
|
||||
{{ t('h5.me.customerCode') }} {{ profile.customerCode }}
|
||||
<icon-copy />
|
||||
</button>
|
||||
<span v-if="profile.customerCode" class="h5-me-hero__dot" aria-hidden="true">·</span>
|
||||
<span class="h5-me-balance__label">{{ t('h5.me.balance') }}</span>
|
||||
<strong class="h5-me-balance__value">¥{{ formatMoney(accountBalance) }}</strong>
|
||||
<button type="button" class="h5-me-recharge" @click="goRecharge">
|
||||
{{ t('h5.me.recharge') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="h5-me-hero__top">
|
||||
<img :src="displayAvatar" alt="" class="h5-me-avatar" />
|
||||
<div class="h5-me-hero__text">
|
||||
<div class="h5-me-hero__name-row">
|
||||
<h2 class="h5-me-name">{{ displayName }}</h2>
|
||||
<span class="h5-me-greet">{{ greetText }}</span>
|
||||
</div>
|
||||
<div class="h5-me-hero__meta">
|
||||
<button
|
||||
v-if="profile.customerCode"
|
||||
type="button"
|
||||
class="h5-me-code"
|
||||
@click="copyText(profile.customerCode)"
|
||||
>
|
||||
{{ t('h5.me.customerCode') }} {{ profile.customerCode }}
|
||||
<icon-copy />
|
||||
</button>
|
||||
<span v-if="profile.customerCode" class="h5-me-hero__dot" aria-hidden="true">·</span>
|
||||
<span class="h5-me-balance__label">{{ t('h5.me.balance') }}</span>
|
||||
<strong class="h5-me-balance__value">¥{{ formatMoney(accountBalance) }}</strong>
|
||||
<button type="button" class="h5-me-recharge" @click="goRecharge">
|
||||
{{ t('h5.me.recharge') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="memberOverview" class="h5-me-hero__points">
|
||||
<div class="h5-me-hero__points-head">
|
||||
@ -221,14 +171,13 @@
|
||||
<H5PasswordSheet v-model:visible="pwdVisible" @success="onPasswordChanged" />
|
||||
<H5AddressSheet v-model:visible="addressVisible" />
|
||||
<H5PickupOrdersSheet v-model:visible="pickupVisible" @loaded="onPickupLoaded" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { preloadInvitePosterBg } from '@/utils/invitePoster';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Message, Modal } from '@arco-design/web-vue';
|
||||
import {
|
||||
@ -237,8 +186,6 @@ import {
|
||||
IconEdit,
|
||||
IconLock,
|
||||
IconLocation,
|
||||
IconUserAdd,
|
||||
IconLink,
|
||||
IconLanguage,
|
||||
IconWechat,
|
||||
} from '@arco-design/web-vue/es/icon';
|
||||
@ -249,18 +196,15 @@ import { getMyCoupons, getMemberOverview } from '@/api/marketing/portal';
|
||||
import { isLogin } from '@/utils/auth';
|
||||
import { resolveAvatarUrl } from '@/utils/avatar';
|
||||
import { getMapIdFromAccessToken } from '@/utils/jwt';
|
||||
import { h5Navigate, isWeChatBrowser } from '@/utils/device';
|
||||
import { redirectToWeChatOAuth } from '@/utils/wechatOAuth';
|
||||
import { h5Navigate } from '@/utils/device';
|
||||
import { jumpToWechatOfficialAccount } from '@/utils/wechatFollowJump';
|
||||
import { useUserStore } from '@/store';
|
||||
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
||||
import useLocale from '@/hooks/locale';
|
||||
import logoImg from '@/assets/logo2.png';
|
||||
import iconServiceCoupon from '@/assets/icons/h5/service-coupon.svg?url';
|
||||
import iconServiceInvite from '@/assets/icons/h5/service-invite.svg?url';
|
||||
import iconServiceCommission from '@/assets/icons/h5/service-commission.svg?url';
|
||||
import iconOrderPickup from '@/assets/icons/h5/order-pickup.svg?url';
|
||||
import WechatOAuthLoginButton from '../components/WechatOAuthLoginButton.vue';
|
||||
import H5JourneyGuide from '../components/H5JourneyGuide.vue';
|
||||
import H5CreateOrderFlow from '../components/H5CreateOrderFlow.vue';
|
||||
import H5RechargeSheet from '../components/H5RechargeSheet.vue';
|
||||
@ -278,12 +222,10 @@ const LOW_BALANCE_THRESHOLD = 100;
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const { changeLocale } = useLocale();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const rbacUserStore = useRbacUserStore();
|
||||
|
||||
const loggedIn = ref(isLogin());
|
||||
const loading = ref(false);
|
||||
const profile = ref<CustomerDto>({});
|
||||
const couponCount = ref(0);
|
||||
@ -439,35 +381,6 @@ async function copyText(text?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePostLoginPath() {
|
||||
const redirect = route.query.redirect;
|
||||
if (typeof redirect === 'string' && redirect.startsWith('/h5')) {
|
||||
return redirect;
|
||||
}
|
||||
return '/h5/orders';
|
||||
}
|
||||
|
||||
function goWechatRegister() {
|
||||
if (isWeChatBrowser()) {
|
||||
redirectToWeChatOAuth('register');
|
||||
return;
|
||||
}
|
||||
h5Navigate(router, '/wechat/register');
|
||||
}
|
||||
|
||||
function goWechatBind() {
|
||||
if (isWeChatBrowser()) {
|
||||
redirectToWeChatOAuth('bind');
|
||||
return;
|
||||
}
|
||||
h5Navigate(router, '/wechat/bind');
|
||||
}
|
||||
|
||||
function goPasswordLogin() {
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/h5/orders';
|
||||
h5Navigate(router, { path: '/h5/login', query: { redirect, tab: 'password' } });
|
||||
}
|
||||
|
||||
function goRecharge() {
|
||||
rechargeVisible.value = true;
|
||||
}
|
||||
@ -479,8 +392,7 @@ function onProfileSaved(payload: { customerName: string; phone: string; email: s
|
||||
async function onPasswordChanged() {
|
||||
await userStore.logout();
|
||||
rbacUserStore.logout();
|
||||
loggedIn.value = false;
|
||||
router.replace('/h5/me');
|
||||
router.replace('/h5/login');
|
||||
}
|
||||
|
||||
function toggleLocale() {
|
||||
@ -508,8 +420,7 @@ function handleLogout() {
|
||||
onOk: async () => {
|
||||
await userStore.logout();
|
||||
rbacUserStore.logout();
|
||||
loggedIn.value = false;
|
||||
router.replace('/h5/me');
|
||||
router.replace('/h5/login');
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -547,7 +458,7 @@ async function loadExtras() {
|
||||
}
|
||||
|
||||
async function loadProfile() {
|
||||
if (!loggedIn.value) return;
|
||||
if (!isLogin()) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const mapId = getMapIdFromAccessToken(rbacUserStore.accessToken);
|
||||
@ -562,7 +473,10 @@ async function loadProfile() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loggedIn.value = isLogin();
|
||||
if (!isLogin()) {
|
||||
router.replace({ path: '/h5/login', query: { redirect: '/h5/me' } });
|
||||
return;
|
||||
}
|
||||
void loadProfile();
|
||||
void preloadInvitePosterBg();
|
||||
});
|
||||
@ -578,10 +492,6 @@ onMounted(() => {
|
||||
padding: 20px 16px 28px;
|
||||
box-sizing: border-box;
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--me-teal) 12%, #f3f6f7) 0%, #f3f6f7 180px);
|
||||
|
||||
&--guest {
|
||||
padding-top: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.h5-me-spin {
|
||||
@ -1121,133 +1031,6 @@ onMounted(() => {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 访客(对齐 /h5/login) */
|
||||
.h5-me-guest {
|
||||
--me-login-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
padding: 8px 0 12px;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.h5-me-guest__brand {
|
||||
padding: 12px 8px 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.h5-me-guest__logo {
|
||||
display: block;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0 auto 10px;
|
||||
object-fit: contain;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 22px color-mix(in srgb, var(--me-teal) 18%, transparent);
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.h5-me-guest__title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--me-ink);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.h5-me-guest__card {
|
||||
background: #fff;
|
||||
border-radius: 22px;
|
||||
padding: 14px 18px 20px;
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.8) inset,
|
||||
0 12px 32px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.h5-me-guest__tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
padding: 4px;
|
||||
border-radius: 12px;
|
||||
background: #f2f3f5;
|
||||
}
|
||||
|
||||
.h5-me-guest__tab {
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: #86909c;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&.is-active {
|
||||
background: #fff;
|
||||
color: var(--me-teal);
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.h5-me-guest__extra {
|
||||
margin-top: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.h5-me-guest__hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
color: #86909c;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.h5-me-guest__actions {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.h5-me-text-btn {
|
||||
flex: 1;
|
||||
max-width: 168px;
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
color: #4e5969;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
&.is-primary {
|
||||
border-color: color-mix(in srgb, var(--me-teal) 35%, #e5e6eb);
|
||||
background: color-mix(in srgb, var(--me-teal) 10%, #fff);
|
||||
color: var(--me-teal);
|
||||
}
|
||||
}
|
||||
|
||||
.h5-me-tip {
|
||||
margin: 14px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--me-accent);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.h5-me-skel__hero,
|
||||
.h5-me-skel__card {
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
deleteOrderTemplateCustom,
|
||||
postOrderUpload,
|
||||
getOrderBatchJob,
|
||||
postOrderReceiverParseAddress,
|
||||
} from '@/api/order';
|
||||
import { getSenderAddressList } from '@/api/order/senderAddress';
|
||||
import { getSalesProductList } from '@/api/channel/salesProduct';
|
||||
@ -65,6 +66,13 @@
|
||||
const singleShowFieldErrors = ref(false);
|
||||
/** 收件人选填字段默认折叠 */
|
||||
const showRecipientOptional = ref(false);
|
||||
/** 粘贴地址 → AI 解析 */
|
||||
const receiverPasteText = ref('');
|
||||
const receiverParseLoading = ref(false);
|
||||
const receiverParseTip = ref<{
|
||||
tone: 'ok' | 'warn' | 'danger';
|
||||
text: string;
|
||||
} | null>(null);
|
||||
/** 申报表滚动面板高度(可拖拽调节) */
|
||||
const declarationPanelHeight = ref(280);
|
||||
const DECLARATION_PANEL_MIN = 160;
|
||||
@ -901,6 +909,108 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyParsedReceiver(parsed: Record<string, string | undefined>) {
|
||||
const assignKeys = [
|
||||
'recipientsName',
|
||||
'companyName',
|
||||
'phone',
|
||||
'telePhone',
|
||||
'email',
|
||||
'countryCode',
|
||||
'province',
|
||||
'city',
|
||||
'streetNumber',
|
||||
'streetAddress1',
|
||||
'streetAddress2',
|
||||
'streetAddress3',
|
||||
'zipCode',
|
||||
] as const;
|
||||
for (const key of assignKeys) {
|
||||
const val = formTrim(parsed[key] || '');
|
||||
if (val) {
|
||||
;(singleForm.receiver as Record<string, string>)[key] = val;
|
||||
}
|
||||
}
|
||||
const needOptional = Boolean(
|
||||
formTrim(parsed.companyName || '') ||
|
||||
formTrim(parsed.telePhone || '') ||
|
||||
formTrim(parsed.email || '') ||
|
||||
formTrim(parsed.province || '') ||
|
||||
formTrim(parsed.city || '') ||
|
||||
formTrim(parsed.streetNumber || '') ||
|
||||
formTrim(parsed.streetAddress2 || '') ||
|
||||
formTrim(parsed.streetAddress3 || ''),
|
||||
);
|
||||
if (needOptional) {
|
||||
showRecipientOptional.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function buildReceiverParseTip(result: {
|
||||
isComplete: boolean;
|
||||
missingRequired: string[];
|
||||
missingRecommended: string[];
|
||||
warnings: string[];
|
||||
}) {
|
||||
const parts: string[] = [];
|
||||
if (result.missingRequired.length) {
|
||||
parts.push(`仍缺必填:${result.missingRequired.join('、')}`);
|
||||
}
|
||||
if (result.missingRecommended.length) {
|
||||
parts.push(`建议补全:${result.missingRecommended.join('、')}`);
|
||||
}
|
||||
if (result.warnings.length) {
|
||||
parts.push(result.warnings.slice(0, 2).join(';'));
|
||||
}
|
||||
if (result.isComplete && !result.missingRecommended.length && !result.warnings.length) {
|
||||
receiverParseTip.value = { tone: 'ok', text: '地址信息完整,已填入下方字段,请确认后下单' };
|
||||
return;
|
||||
}
|
||||
if (result.isComplete) {
|
||||
receiverParseTip.value = {
|
||||
tone: 'warn',
|
||||
text: parts.length ? `必填已齐。${parts.join('。')}` : '必填已齐,请确认地址细节',
|
||||
};
|
||||
return;
|
||||
}
|
||||
receiverParseTip.value = {
|
||||
tone: 'danger',
|
||||
text: parts.length ? parts.join('。') : '解析后仍不完整,请手动补全',
|
||||
};
|
||||
}
|
||||
|
||||
async function handleReceiverAiParse() {
|
||||
const raw = receiverPasteText.value.trim();
|
||||
if (!raw) {
|
||||
Message.warning('请先粘贴收件人地址文本');
|
||||
return;
|
||||
}
|
||||
if (receiverParseLoading.value) return;
|
||||
receiverParseLoading.value = true;
|
||||
receiverParseTip.value = null;
|
||||
try {
|
||||
const result = await postOrderReceiverParseAddress({ rawText: raw });
|
||||
applyParsedReceiver(result.receiver);
|
||||
buildReceiverParseTip(result);
|
||||
Message.success('已解析并填入收件人信息');
|
||||
} catch (e: any) {
|
||||
receiverParseTip.value = {
|
||||
tone: 'danger',
|
||||
text: e?.message || '地址解析失败,请稍后重试',
|
||||
};
|
||||
Message.error(e?.message || '地址解析失败');
|
||||
} finally {
|
||||
receiverParseLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onReceiverPasteKeydown(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void handleReceiverAiParse();
|
||||
}
|
||||
}
|
||||
|
||||
function isSingleDeclarationComplete() {
|
||||
const lines = singleForm.items || [];
|
||||
if (!lines.length) return false;
|
||||
@ -2677,94 +2787,139 @@
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="single-info-card liquid-card"
|
||||
class="single-info-card liquid-card single-info-card--recipient"
|
||||
:ref="(el) => (singlePanelRefs.recipient = (el as HTMLElement | null))"
|
||||
>
|
||||
<header class="single-info-card__header">
|
||||
<span class="single-section-index">3</span>
|
||||
<span class="single-section-title">{{ t('order.placeOrder.single.stepRecipient') }}</span>
|
||||
</header>
|
||||
<div class="single-form-grid single-form-grid--compact">
|
||||
<a-form-item
|
||||
label="收件人名称"
|
||||
required
|
||||
:validate-status="fieldValidateStatus('receiver.recipientsName')"
|
||||
:help="fieldHelp('receiver.recipientsName')"
|
||||
>
|
||||
<a-input v-model="singleForm.receiver.recipientsName" />
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="收件人手机"
|
||||
required
|
||||
:validate-status="fieldValidateStatus('receiver.phone')"
|
||||
:help="fieldHelp('receiver.phone')"
|
||||
>
|
||||
<a-input v-model="singleForm.receiver.phone" />
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="国家"
|
||||
required
|
||||
:validate-status="fieldValidateStatus('receiver.countryCode')"
|
||||
:help="fieldHelp('receiver.countryCode')"
|
||||
>
|
||||
<a-select
|
||||
v-model="singleForm.receiver.countryCode"
|
||||
allow-search
|
||||
placeholder="请选择"
|
||||
:loading="singleReferenceLoading"
|
||||
>
|
||||
<a-option
|
||||
v-for="opt in singleCountryOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
|
||||
<div class="receiver-split">
|
||||
<aside class="receiver-split__paste">
|
||||
<div class="receiver-ai-paste">
|
||||
<div class="receiver-ai-paste__head">
|
||||
<span class="receiver-ai-paste__title">粘贴地址 · AI 解析</span>
|
||||
<span class="receiver-ai-paste__hint">中/英整段粘贴,Ctrl+Enter 解析</span>
|
||||
</div>
|
||||
<a-textarea
|
||||
v-model="receiverPasteText"
|
||||
class="receiver-ai-paste__textarea"
|
||||
:auto-size="{ minRows: 8, maxRows: 16 }"
|
||||
placeholder="例:John Smith, +1 310-555-0199, 1001 Sunset Blvd, Los Angeles, CA 90001, USA 或:张三 13800138000 广东省深圳市南山区科技园南路 XX 号 518000"
|
||||
@keydown="onReceiverPasteKeydown"
|
||||
/>
|
||||
<div class="receiver-ai-paste__actions">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="receiverParseLoading"
|
||||
@click="handleReceiverAiParse"
|
||||
>
|
||||
AI解析
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="receiverPasteText"
|
||||
size="small"
|
||||
@click="receiverPasteText = ''; receiverParseTip = null"
|
||||
>
|
||||
清空
|
||||
</a-button>
|
||||
</div>
|
||||
<p
|
||||
v-if="receiverParseTip"
|
||||
class="receiver-ai-paste__tip"
|
||||
:class="`is-${receiverParseTip.tone}`"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="邮编"
|
||||
required
|
||||
:validate-status="fieldValidateStatus('receiver.zipCode')"
|
||||
:help="fieldHelp('receiver.zipCode')"
|
||||
>
|
||||
<a-input v-model="singleForm.receiver.zipCode" />
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
class="span-2"
|
||||
label="地址1"
|
||||
required
|
||||
:validate-status="fieldValidateStatus('receiver.streetAddress1')"
|
||||
:help="fieldHelp('receiver.streetAddress1')"
|
||||
>
|
||||
<a-input v-model="singleForm.receiver.streetAddress1" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
<div class="recipient-optional-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="declaration-extra-btn"
|
||||
@click="showRecipientOptional = !showRecipientOptional"
|
||||
>
|
||||
{{
|
||||
showRecipientOptional
|
||||
? t('order.placeOrder.single.hideRecipientOptional')
|
||||
: t('order.placeOrder.single.showRecipientOptional')
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-show="showRecipientOptional"
|
||||
class="single-form-grid single-form-grid--compact recipient-optional-grid"
|
||||
>
|
||||
<a-form-item label="收件人公司"><a-input v-model="singleForm.receiver.companyName" /></a-form-item>
|
||||
<a-form-item label="收件人电话"><a-input v-model="singleForm.receiver.telePhone" /></a-form-item>
|
||||
<a-form-item label="收件人邮箱"><a-input v-model="singleForm.receiver.email" /></a-form-item>
|
||||
<a-form-item label="省/州"><a-input v-model="singleForm.receiver.province" /></a-form-item>
|
||||
<a-form-item label="城市"><a-input v-model="singleForm.receiver.city" /></a-form-item>
|
||||
<a-form-item label="门牌号"><a-input v-model="singleForm.receiver.streetNumber" /></a-form-item>
|
||||
<a-form-item label="地址2"><a-input v-model="singleForm.receiver.streetAddress2" /></a-form-item>
|
||||
<a-form-item label="地址3"><a-input v-model="singleForm.receiver.streetAddress3" /></a-form-item>
|
||||
{{ receiverParseTip.text }}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="receiver-split__form">
|
||||
<div class="single-form-grid single-form-grid--compact">
|
||||
<a-form-item
|
||||
label="收件人名称"
|
||||
required
|
||||
:validate-status="fieldValidateStatus('receiver.recipientsName')"
|
||||
:help="fieldHelp('receiver.recipientsName')"
|
||||
>
|
||||
<a-input v-model="singleForm.receiver.recipientsName" />
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="收件人手机"
|
||||
required
|
||||
:validate-status="fieldValidateStatus('receiver.phone')"
|
||||
:help="fieldHelp('receiver.phone')"
|
||||
>
|
||||
<a-input v-model="singleForm.receiver.phone" />
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="国家"
|
||||
required
|
||||
:validate-status="fieldValidateStatus('receiver.countryCode')"
|
||||
:help="fieldHelp('receiver.countryCode')"
|
||||
>
|
||||
<a-select
|
||||
v-model="singleForm.receiver.countryCode"
|
||||
allow-search
|
||||
placeholder="请选择"
|
||||
:loading="singleReferenceLoading"
|
||||
>
|
||||
<a-option
|
||||
v-for="opt in singleCountryOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="邮编"
|
||||
required
|
||||
:validate-status="fieldValidateStatus('receiver.zipCode')"
|
||||
:help="fieldHelp('receiver.zipCode')"
|
||||
>
|
||||
<a-input v-model="singleForm.receiver.zipCode" />
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
class="span-2"
|
||||
label="地址1"
|
||||
required
|
||||
:validate-status="fieldValidateStatus('receiver.streetAddress1')"
|
||||
:help="fieldHelp('receiver.streetAddress1')"
|
||||
>
|
||||
<a-input v-model="singleForm.receiver.streetAddress1" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
<div class="recipient-optional-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class="declaration-extra-btn"
|
||||
@click="showRecipientOptional = !showRecipientOptional"
|
||||
>
|
||||
{{
|
||||
showRecipientOptional
|
||||
? t('order.placeOrder.single.hideRecipientOptional')
|
||||
: t('order.placeOrder.single.showRecipientOptional')
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-show="showRecipientOptional"
|
||||
class="single-form-grid single-form-grid--compact recipient-optional-grid"
|
||||
>
|
||||
<a-form-item label="收件人公司"><a-input v-model="singleForm.receiver.companyName" /></a-form-item>
|
||||
<a-form-item label="收件人电话"><a-input v-model="singleForm.receiver.telePhone" /></a-form-item>
|
||||
<a-form-item label="收件人邮箱"><a-input v-model="singleForm.receiver.email" /></a-form-item>
|
||||
<a-form-item label="省/州"><a-input v-model="singleForm.receiver.province" /></a-form-item>
|
||||
<a-form-item label="城市"><a-input v-model="singleForm.receiver.city" /></a-form-item>
|
||||
<a-form-item label="门牌号"><a-input v-model="singleForm.receiver.streetNumber" /></a-form-item>
|
||||
<a-form-item label="地址2"><a-input v-model="singleForm.receiver.streetAddress2" /></a-form-item>
|
||||
<a-form-item label="地址3"><a-input v-model="singleForm.receiver.streetAddress3" /></a-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@ -3669,6 +3824,72 @@
|
||||
border-top: 1px dashed rgba(45, 140, 140, 0.18);
|
||||
}
|
||||
|
||||
.receiver-ai-paste {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px dashed color-mix(in srgb, var(--app-primary-color) 35%, #e5e6eb);
|
||||
background: color-mix(in srgb, var(--app-primary-color) 6%, #fff);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.receiver-ai-paste__head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.receiver-ai-paste__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
.receiver-ai-paste__hint {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.receiver-ai-paste__textarea {
|
||||
flex: 1;
|
||||
|
||||
:deep(textarea) {
|
||||
min-height: 160px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.receiver-ai-paste__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.receiver-ai-paste__tip {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
|
||||
&.is-ok {
|
||||
color: #00a870;
|
||||
}
|
||||
|
||||
&.is-warn {
|
||||
color: #d48806;
|
||||
}
|
||||
|
||||
&.is-danger {
|
||||
color: #f53f3f;
|
||||
}
|
||||
}
|
||||
|
||||
.declaration-card {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@ -4757,7 +4978,7 @@
|
||||
|
||||
.single-cards-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
@ -4768,6 +4989,27 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.single-info-card--recipient {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.receiver-split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 0.9fr) minmax(0, 1.2fr);
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.receiver-split__paste,
|
||||
.receiver-split__form {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.receiver-split__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.single-info-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -5340,11 +5582,21 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.receiver-split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.single-form-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.receiver-split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.single-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@ -81,7 +81,6 @@
|
||||
:stretch-columns="true"
|
||||
:height="handsontableHeight"
|
||||
:read-only="true"
|
||||
:enable-header-search="false"
|
||||
:enable-column-filter="false"
|
||||
:serial-no-header="t('system.announcement.table.index')"
|
||||
:action-column-header="t('system.announcement.table.actions')"
|
||||
|
||||
@ -38,6 +38,7 @@
|
||||
app: userStore.appKey,
|
||||
});
|
||||
const searchKeyword = ref('');
|
||||
const headerSearchParams = reactive<Record<string, any>>({});
|
||||
|
||||
const tableData = ref<any[]>([]);
|
||||
const totalCount = ref(0);
|
||||
@ -498,7 +499,7 @@
|
||||
:total="totalCount"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:search-params="{}"
|
||||
:search-params="headerSearchParams"
|
||||
:show-action-column="true"
|
||||
:action-column-position="'end'"
|
||||
:action-column-width="110"
|
||||
|
||||
@ -33,6 +33,7 @@
|
||||
app: userStore.appKey,
|
||||
});
|
||||
const searchKeyword = ref('');
|
||||
const headerSearchParams = reactive<Record<string, any>>({});
|
||||
|
||||
const tableData = ref<any[]>([]);
|
||||
const totalCount = ref(0);
|
||||
@ -345,7 +346,7 @@
|
||||
:total="totalCount"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:search-params="{}"
|
||||
:search-params="headerSearchParams"
|
||||
:show-action-column="true"
|
||||
:action-column-position="'end'"
|
||||
:action-column-width="110"
|
||||
|
||||
@ -67,6 +67,11 @@
|
||||
:columns="detailColumns"
|
||||
:data="detailTableData"
|
||||
:height="tableHeight"
|
||||
:enable-header-search="false"
|
||||
:auto-hybrid-header="false"
|
||||
:enable-header-filter="false"
|
||||
:enable-header-sort="false"
|
||||
:show-active-filter-bar="false"
|
||||
@selection-change="handleDetailSelectionChange"
|
||||
/>
|
||||
</div>
|
||||
@ -80,6 +85,11 @@
|
||||
:columns="pushRecordColumns"
|
||||
:data="pushRecordTableData"
|
||||
:height="tableHeight"
|
||||
:enable-header-search="false"
|
||||
:auto-hybrid-header="false"
|
||||
:enable-header-filter="false"
|
||||
:enable-header-sort="false"
|
||||
:show-active-filter-bar="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -93,6 +103,11 @@
|
||||
:data="logTableData"
|
||||
:height="tableHeight"
|
||||
:stretch-columns="true"
|
||||
:enable-header-search="false"
|
||||
:auto-hybrid-header="false"
|
||||
:enable-header-filter="false"
|
||||
:enable-header-sort="false"
|
||||
:show-active-filter-bar="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -106,6 +121,11 @@
|
||||
:columns="pullConfigColumns"
|
||||
:data="pullConfigTableData"
|
||||
:height="pullConfigHandsontableHeight"
|
||||
:enable-header-search="false"
|
||||
:auto-hybrid-header="false"
|
||||
:enable-header-filter="false"
|
||||
:enable-header-sort="false"
|
||||
:show-active-filter-bar="false"
|
||||
/>
|
||||
<a-empty
|
||||
v-else-if="!pullConfigLoading"
|
||||
|
||||
@ -2,7 +2,8 @@ import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { $baseMessage } from '@/utils';
|
||||
import { postGetTrackOrderList } from '@/api/TrackOrder/trackOrder';
|
||||
import { convertToTime } from '../utils';
|
||||
import { convertToTime } from '../utils';
|
||||
import { toServerSearchValue } from '@/components/HandsontablePro/filterValue';
|
||||
|
||||
export function useTrackOrderData(options: {
|
||||
userStore: any;
|
||||
@ -37,8 +38,9 @@ export function useTrackOrderData(options: {
|
||||
// 合并searchInfo和searchParams,优先使用searchParams中的值
|
||||
// 处理客户单号:如果是逗号分隔的字符串,转换为数组
|
||||
let orderNos: string[] = [];
|
||||
if (searchParams.orderNumber) {
|
||||
orderNos = searchParams.orderNumber.split(',').map(v => v.trim()).filter(Boolean);
|
||||
const orderNumberPlain = toServerSearchValue(searchParams.orderNumber || '');
|
||||
if (orderNumberPlain) {
|
||||
orderNos = orderNumberPlain.split(',').map((v: string) => v.trim()).filter(Boolean);
|
||||
} else if ((searchInfo as any).orderNos && Array.isArray((searchInfo as any).orderNos)) {
|
||||
orderNos = (searchInfo as any).orderNos;
|
||||
}
|
||||
@ -50,24 +52,24 @@ export function useTrackOrderData(options: {
|
||||
// 优先使用表头搜索的客户单号(已转换为数组),否则使用表单的客户单号
|
||||
orderNos: orderNos,
|
||||
trackOrderType: trackOrderType,
|
||||
customer: searchParams.customer || '',
|
||||
customer: toServerSearchValue(searchParams.customer || ''),
|
||||
// 添加所有新的搜索字段(尾程单号和其他单号保持逗号分隔的字符串格式)
|
||||
trackNo: searchParams.trackNo || '',
|
||||
otherNo: searchParams.otherNo || '',
|
||||
product: searchParams.product || '',
|
||||
channel: searchParams.channel || '',
|
||||
apiCode: searchParams.apiCode || '',
|
||||
countryCode: searchParams.countryCode || '',
|
||||
province: searchParams.province || '',
|
||||
city: searchParams.city || '',
|
||||
postcode: searchParams.postcode || '',
|
||||
email: searchParams.email || '',
|
||||
newestTrackStatus: searchParams.newestTrackStatus || '',
|
||||
newestTrackContent: searchParams.newestTrackContent || '',
|
||||
newestTrackEnContent: searchParams.newestTrackEnContent || '',
|
||||
trackNo: toServerSearchValue(searchParams.trackNo || ''),
|
||||
otherNo: toServerSearchValue(searchParams.otherNo || ''),
|
||||
product: toServerSearchValue(searchParams.product || ''),
|
||||
channel: toServerSearchValue(searchParams.channel || ''),
|
||||
apiCode: toServerSearchValue(searchParams.apiCode || ''),
|
||||
countryCode: toServerSearchValue(searchParams.countryCode || ''),
|
||||
province: toServerSearchValue(searchParams.province || ''),
|
||||
city: toServerSearchValue(searchParams.city || ''),
|
||||
postcode: toServerSearchValue(searchParams.postcode || ''),
|
||||
email: toServerSearchValue(searchParams.email || ''),
|
||||
newestTrackStatus: toServerSearchValue(searchParams.newestTrackStatus || ''),
|
||||
newestTrackContent: toServerSearchValue(searchParams.newestTrackContent || ''),
|
||||
newestTrackEnContent: toServerSearchValue(searchParams.newestTrackEnContent || ''),
|
||||
newestTrackTime: searchParams.newestTrackTime || '',
|
||||
newestTrackTimeZone: searchParams.newestTrackTimeZone || '',
|
||||
newestTrackPlace: searchParams.newestTrackPlace || '',
|
||||
newestTrackTimeZone: toServerSearchValue(searchParams.newestTrackTimeZone || ''),
|
||||
newestTrackPlace: toServerSearchValue(searchParams.newestTrackPlace || ''),
|
||||
isFinish: searchParams.isFinish !== '' ? searchParams.isFinish : undefined,
|
||||
createTimeStart: (searchParams as any).createTimeStart || (searchInfo as any).createTimeStart || '',
|
||||
createTimeEnd: (searchParams as any).createTimeEnd || (searchInfo as any).createTimeEnd || '',
|
||||
|
||||
@ -4,29 +4,63 @@ import { useI18n } from 'vue-i18n';
|
||||
export function useTableColumns() {
|
||||
const { t } = useI18n();
|
||||
|
||||
// headerMode:inline 内嵌搜 / icon 仅图标(条件面板) / none 纯展示
|
||||
const columns = computed(() => [
|
||||
{ key: 'customer', label: t('track.order.table.customerShortName'), width: 120, searchable: true, searchField: 'customer' },
|
||||
{ key: 'orderNumber', label: t('track.order.table.orderNo'), width: 180, searchable: true, searchField: 'orderNumber', multiValue: true },
|
||||
{ key: 'trackNo', label: t('track.order.table.trackNo'), width: 180, searchable: true, searchField: 'trackNo', multiValue: true },
|
||||
{ key: 'otherNo', label: t('track.order.table.otherNo'), width: 180, searchable: true, searchField: 'otherNo', multiValue: true },
|
||||
{ key: 'product', label: t('track.order.table.product'), width: 150, searchable: true, searchField: 'product' },
|
||||
{ key: 'channel', label: t('track.order.table.channel'), width: 150, searchable: true, searchField: 'channel' },
|
||||
{ key: 'apiCode', label: t('track.order.table.apiCode'), width: 150, searchable: true, searchField: 'apiCode' },
|
||||
{ key: 'countryCode', label: t('track.order.table.countryCode'), width: 100, searchable: true, searchField: 'countryCode' },
|
||||
{ key: 'isFinish', label: t('track.order.table.isComplete'), width: 100, searchable: true, searchField: 'isFinish' },
|
||||
{ key: 'newestTrackStatus', label: t('track.order.table.latestTrajectoryStatus'), width: 150, searchable: true, searchField: 'newestTrackStatus' },
|
||||
{ key: 'newestTrackContent', label: t('track.order.table.latestTrajectoryContent'), width: 250, searchable: true, searchField: 'newestTrackContent' },
|
||||
{ key: 'newestTrackEnContent', label: t('track.order.table.latestTrajectoryContentEn'), width: 250, searchable: true, searchField: 'newestTrackEnContent' },
|
||||
{ key: 'newestTrackTime', label: t('track.order.table.latestTrajectoryTime'), width: 180, searchable: true, searchField: 'newestTrackTime' },
|
||||
{ key: 'newestTrackTimeZone', label: t('track.order.table.latestTrajectoryTimezone'), width: 120, searchable: true, searchField: 'newestTrackTimeZone' },
|
||||
{ key: 'newestTrackPlace', label: t('track.order.table.latestTrajectoryLocation'), width: 200, searchable: true, searchField: 'newestTrackPlace' },
|
||||
{ key: 'province', label: t('track.order.table.province'), width: 120, searchable: true, searchField: 'province' },
|
||||
{ key: 'city', label: t('track.order.table.city'), width: 120, searchable: true, searchField: 'city' },
|
||||
{ key: 'postcode', label: t('track.order.table.postcode'), width: 100, searchable: true, searchField: 'postcode' },
|
||||
{ key: 'email', label: t('track.order.table.email'), width: 200, searchable: true, searchField: 'email' },
|
||||
{ key: 'estimatedDeliveryDate', label: t('track.order.table.estimatedDeliveryTime'), width: 180, searchable: false },
|
||||
{ key: 'destinationTimeZone', label: t('track.order.table.deliveryTimezone'), width: 120, searchable: false },
|
||||
{ key: 'createTime', label: t('track.order.table.createTime'), width: 180, searchable: true, searchField: 'createTime' },
|
||||
{ key: 'customer', label: t('track.order.table.customerShortName'), width: 140, searchable: true, searchField: 'customer', headerMode: 'inline' as const },
|
||||
{ key: 'orderNumber', label: t('track.order.table.orderNo'), width: 200, searchable: true, searchField: 'orderNumber', multiValue: true, headerMode: 'inline' as const },
|
||||
{ key: 'trackNo', label: t('track.order.table.trackNo'), width: 200, searchable: true, searchField: 'trackNo', multiValue: true, headerMode: 'inline' as const },
|
||||
{ key: 'otherNo', label: t('track.order.table.otherNo'), width: 200, searchable: true, searchField: 'otherNo', multiValue: true, headerMode: 'inline' as const },
|
||||
{ key: 'product', label: t('track.order.table.product'), width: 150, searchable: true, searchField: 'product', headerMode: 'inline' as const },
|
||||
{ key: 'channel', label: t('track.order.table.channel'), width: 150, searchable: true, searchField: 'channel', headerMode: 'inline' as const },
|
||||
{ key: 'apiCode', label: t('track.order.table.apiCode'), width: 150, searchable: true, searchField: 'apiCode', headerMode: 'inline' as const },
|
||||
{ key: 'countryCode', label: t('track.order.table.countryCode'), width: 110, searchable: true, searchField: 'countryCode', headerMode: 'inline' as const },
|
||||
{
|
||||
key: 'isFinish',
|
||||
label: t('track.order.table.isComplete'),
|
||||
width: 110,
|
||||
searchable: true,
|
||||
searchField: 'isFinish',
|
||||
headerMode: 'icon' as const,
|
||||
filterType: 'enum' as const,
|
||||
filterable: true,
|
||||
filterOptions: [
|
||||
{ label: '是', value: 'true' },
|
||||
{ label: '否', value: 'false' },
|
||||
],
|
||||
},
|
||||
{ key: 'newestTrackStatus', label: t('track.order.table.latestTrajectoryStatus'), width: 150, searchable: true, searchField: 'newestTrackStatus', headerMode: 'inline' as const },
|
||||
{ key: 'newestTrackContent', label: t('track.order.table.latestTrajectoryContent'), width: 250, searchable: true, searchField: 'newestTrackContent', headerMode: 'inline' as const },
|
||||
{ key: 'newestTrackEnContent', label: t('track.order.table.latestTrajectoryContentEn'), width: 250, searchable: true, searchField: 'newestTrackEnContent', headerMode: 'inline' as const },
|
||||
{
|
||||
key: 'newestTrackTime',
|
||||
label: t('track.order.table.latestTrajectoryTime'),
|
||||
width: 180,
|
||||
searchable: true,
|
||||
searchField: 'newestTrackTime',
|
||||
headerMode: 'icon' as const,
|
||||
filterType: 'date' as const,
|
||||
filterable: true,
|
||||
sortable: true,
|
||||
},
|
||||
{ key: 'newestTrackTimeZone', label: t('track.order.table.latestTrajectoryTimezone'), width: 120, searchable: true, searchField: 'newestTrackTimeZone', headerMode: 'inline' as const },
|
||||
{ key: 'newestTrackPlace', label: t('track.order.table.latestTrajectoryLocation'), width: 200, searchable: true, searchField: 'newestTrackPlace', headerMode: 'inline' as const },
|
||||
{ key: 'province', label: t('track.order.table.province'), width: 120, searchable: true, searchField: 'province', headerMode: 'inline' as const },
|
||||
{ key: 'city', label: t('track.order.table.city'), width: 120, searchable: true, searchField: 'city', headerMode: 'inline' as const },
|
||||
{ key: 'postcode', label: t('track.order.table.postcode'), width: 100, searchable: true, searchField: 'postcode', headerMode: 'inline' as const },
|
||||
{ key: 'email', label: t('track.order.table.email'), width: 200, searchable: true, searchField: 'email', headerMode: 'inline' as const },
|
||||
{ key: 'estimatedDeliveryDate', label: t('track.order.table.estimatedDeliveryTime'), width: 180, searchable: false, headerMode: 'none' as const, sortable: true },
|
||||
{ key: 'destinationTimeZone', label: t('track.order.table.deliveryTimezone'), width: 120, searchable: false, headerMode: 'none' as const },
|
||||
{
|
||||
key: 'createTime',
|
||||
label: t('track.order.table.createTime'),
|
||||
width: 180,
|
||||
searchable: true,
|
||||
searchField: 'createTime',
|
||||
headerMode: 'icon' as const,
|
||||
filterType: 'date' as const,
|
||||
filterable: true,
|
||||
sortable: true,
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
@ -33,6 +33,7 @@
|
||||
app: userStore.appKey,
|
||||
});
|
||||
const searchKeyword = ref('');
|
||||
const headerSearchParams = reactive<Record<string, any>>({});
|
||||
|
||||
const tableData = ref<any[]>([]);
|
||||
const totalCount = ref(0);
|
||||
@ -383,7 +384,7 @@
|
||||
:total="totalCount"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:search-params="{}"
|
||||
:search-params="headerSearchParams"
|
||||
:show-action-column="true"
|
||||
:action-column-position="'end'"
|
||||
:action-column-width="110"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user