feat: Handsontable 混合表头与请求日志改造,并完善 H5 下单/登录体验

统一 HandsontablePro 液态筛选表头、批量输入与自动列增强;请求日志改用公共列表组件;同步收件地址解析与 H5/PC 下单相关改动。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-30 20:09:42 +08:00
parent 37d534edc3
commit 407a0f7be6
28 changed files with 3668 additions and 1510 deletions

View File

@ -320,6 +320,8 @@ itemList: [
| `key` | `string` | 对应行数据字段名(如 `jobId`、`statusLabel`) | | `key` | `string` | 对应行数据字段名(如 `jobId`、`statusLabel`) |
| `label` | `string` | 表头文案(支持 `t('...')`) | | `label` | `string` | 表头文案(支持 `t('...')`) |
| `width` | `number` | 列宽(像素) | | `width` | `number` | 列宽(像素) |
| `headerMode` | `'inline' \| 'icon' \| 'none'` | 混合表头:内嵌搜索 / 仅图标 / 纯展示;未设时由 `searchable` + `filterType` 推导 |
| `searchable` / `searchField` / `multiValue` | — | 表头搜索;`multiValue` 显示「多」批量入口 |
**正确示例:** **正确示例:**

View File

@ -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 */ /** POST /order/parcel/create */
export async function postOrderParcelCreate(body: Record<string, any>): Promise<any> { export async function postOrderParcelCreate(body: Record<string, any>): Promise<any> {
try { try {

2
src/components.d.ts vendored
View File

@ -8,6 +8,7 @@ export {}
/* prettier-ignore */ /* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
ActiveFilterBar: typeof import('./components/HandsontablePro/ActiveFilterBar.vue')['default']
AiAdvisorShell: typeof import('./components/NanaAssistant/AiAdvisorShell.vue')['default'] AiAdvisorShell: typeof import('./components/NanaAssistant/AiAdvisorShell.vue')['default']
ApiTest: typeof import('./components/ApiTest/index.vue')['default'] ApiTest: typeof import('./components/ApiTest/index.vue')['default']
BasicLayoutLeftRight: typeof import('./components/BasicLayoutLeftRight/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'] FormWrapper: typeof import('./components/global-setting/form-wrapper.vue')['default']
GlobalSetting: typeof import('./components/global-setting/index.vue')['default'] GlobalSetting: typeof import('./components/global-setting/index.vue')['default']
HandsontablePro: typeof import('./components/HandsontablePro/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'] InterfaceDetail: typeof import('./components/InterfaceDetail/index.vue')['default']
InvitePoster: typeof import('./components/InvitePoster.vue')['default'] InvitePoster: typeof import('./components/InvitePoster.vue')['default']
InviteQrImage: typeof import('./components/InviteQrImage.vue')['default'] InviteQrImage: typeof import('./components/InviteQrImage.vue')['default']

View 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>

File diff suppressed because it is too large Load Diff

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

View 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

View File

@ -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 { export interface ColumnDef {
key: string; key: string;
label: string; label: string;
width: number; width: number;
/** 混合表头:内嵌输入 / 仅图标 / 纯展示;未设时由 searchable + filterType 推导 */
headerMode?: HeaderMode;
searchable?: boolean; searchable?: boolean;
searchField?: string; searchField?: string;
multiValue?: boolean; 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; renderer?: (row: any, value: any) => string;
className?: string; className?: string;
type?: string; type?: string;
@ -33,4 +46,19 @@ export interface HandsontableProProps {
persistColumnOrderKey?: string; persistColumnOrderKey?: string;
lockedColumnKeys?: string[]; lockedColumnKeys?: string[];
enableColumnOrderConfig?: boolean; 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;
} }

View File

@ -76,7 +76,7 @@ export default function setupUserLoginInfoGuard(router: Router) {
typeof to.query.redirect === 'string' ? to.query.redirect : ''; typeof to.query.redirect === 'string' ? to.query.redirect : '';
const target = resolveCustomerEntryPath(redirect || CUSTOMER_H5_HOME_PATH); const target = resolveCustomerEntryPath(redirect || CUSTOMER_H5_HOME_PATH);
next({ next({
path: isLogin() ? target : '/h5/me', path: isLogin() ? target : '/h5/login',
query: !isLogin() && redirect ? { redirect: target } : {}, query: !isLogin() && redirect ? { redirect: target } : {},
replace: true, replace: true,
}); });
@ -111,7 +111,7 @@ export default function setupUserLoginInfoGuard(router: Router) {
console.error('客户门户 SSO 登录失败', error); console.error('客户门户 SSO 登录失败', error);
} }
next({ next({
path: preferH5Portal() ? '/h5/me' : '/', path: preferH5Portal() ? '/h5/login' : '/',
query: {}, query: {},
replace: true, replace: true,
}); });
@ -148,11 +148,11 @@ export default function setupUserLoginInfoGuard(router: Router) {
return; return;
} }
// H5 客户中心:未登录统一引导到「我的」(含微信注册/绑定),避免跳 PC 登录大页 // H5 客户中心:未登录统一引导到 /h5/login,避免「我的」再套一套登录页
if (!isLogin() && String(to.path || '').startsWith('/h5')) { if (!isLogin() && String(to.path || '').startsWith('/h5')) {
NProgress.done(); NProgress.done();
next({ next({
path: '/h5/me', path: '/h5/login',
query: { redirect: to.fullPath }, query: { redirect: to.fullPath },
replace: true, replace: true,
}); });

View File

@ -64,7 +64,7 @@ const router = createRouter({
const token = const token =
localStorage.getItem('rbac_access_token') || localStorage.getItem('rbac_access_token') ||
localStorage.getItem('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', name: 'H5Me',
component: () => import('@/views/h5/customer-center/me/index.vue'), component: () => import('@/views/h5/customer-center/me/index.vue'),
meta: { meta: {
requiresAuth: false, requiresAuth: true,
locale: 'h5.tab.me', locale: 'h5.tab.me',
h5HideHeader: true, h5HideHeader: true,
}, },
@ -190,7 +190,7 @@ const router = createRouter({
const token = const token =
localStorage.getItem('rbac_access_token') || localStorage.getItem('rbac_access_token') ||
localStorage.getItem('token'); localStorage.getItem('token');
return token ? '/h5/orders' : '/h5/me'; return token ? '/h5/orders' : '/h5/login';
}, },
}, },
], ],

View File

@ -206,11 +206,11 @@ export function resolvePostLogoutLogin(options?: {
routeName = resolveLoginRouteNameFromPath(path) routeName = resolveLoginRouteNameFromPath(path)
} }
// 手机/微信客户:回 H5「我的」登录引导,不进 PC 登录大页 // 手机/微信客户:回 H5 登录页,不进 PC 登录大页
if (routeName === CUSTOMER_LOGIN_ROUTE_NAME && preferH5Portal()) { if (routeName === CUSTOMER_LOGIN_ROUTE_NAME && preferH5Portal()) {
return { return {
name: CUSTOMER_LOGIN_ROUTE_NAME, name: CUSTOMER_LOGIN_ROUTE_NAME,
path: '/h5/me', path: '/h5/login',
query: path.startsWith('/h5') query: path.startsWith('/h5')
? { redirect: path } ? { redirect: path }
: { redirect: resolveCustomerEntryPath(path) }, : { redirect: resolveCustomerEntryPath(path) },

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,7 @@
:stretch-columns="true" :stretch-columns="true"
:height="handsontableHeight" :height="handsontableHeight"
:enable-header-search="false" :enable-header-search="false"
:auto-hybrid-header="false"
:enable-column-filter="false" :enable-column-filter="false"
@action-click="handleActionClick" @action-click="handleActionClick"
/> />

View File

@ -551,6 +551,15 @@
> >
{{ t('h5.placeOrder.prev') }} {{ t('h5.placeOrder.prev') }}
</button> </button>
<button
v-if="step === 1"
type="button"
class="h5-po__btn is-ai"
:disabled="submitting"
@click="openReceiverAiParse"
>
{{ t('h5.placeOrder.aiParse') }}
</button>
<button <button
v-if="step < 2" v-if="step < 2"
type="button" type="button"
@ -573,6 +582,56 @@
</div> </div>
</a-drawer> </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 <a-modal
v-model:visible="createFailVisible" v-model:visible="createFailVisible"
:title=" :title="
@ -658,7 +717,7 @@ import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Message, Modal } from '@arco-design/web-vue'; import { Message, Modal } from '@arco-design/web-vue';
import { IconClose } from '@arco-design/web-vue/es/icon'; 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 { createSenderAddress, getSenderAddressList } from '@/api/order/senderAddress';
import { getSalesProductList } from '@/api/channel/salesProduct'; import { getSalesProductList } from '@/api/channel/salesProduct';
import { getDictListAll } from '@/api/system/dict'; import { getDictListAll } from '@/api/system/dict';
@ -708,6 +767,10 @@ const senderLoading = ref(false);
const showSenderForm = ref(false); const showSenderForm = ref(false);
const savingSender = ref(false); const savingSender = ref(false);
const showReceiverMore = 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 */ /** 申报品「更多」展开状态,按 item._key */
const openItemMoreKeys = ref<Set<string>>(new Set()); const openItemMoreKeys = ref<Set<string>>(new Set());
const senderForm = reactive({ const senderForm = reactive({
@ -1062,6 +1125,107 @@ function goNext() {
step.value += 1; 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) { function goToStep(idx: number) {
if (idx === step.value || submitting.value) return; if (idx === step.value || submitting.value) return;
if (idx < step.value) { if (idx < step.value) {
@ -1558,6 +1722,10 @@ watch(
submitting.value = false; submitting.value = false;
showSenderForm.value = false; showSenderForm.value = false;
resetSenderForm(); resetSenderForm();
receiverAiVisible.value = false;
receiverPasteText.value = '';
receiverParseTip.value = null;
receiverParseLoading.value = false;
return; return;
} }
resetForm(); resetForm();
@ -2046,7 +2214,7 @@ watch(
gap: 8px; gap: 8px;
&.is-ghost { &.is-ghost {
flex: 0 0 96px; flex: 0 0 88px;
background: #f2f3f5; background: #f2f3f5;
color: #4e5969; 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 { &.is-primary {
background: var(--app-primary-color); background: var(--app-primary-color);
color: #fff; 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 { .h5-po__loading {
width: 16px; width: 16px;
height: 16px; height: 16px;

View File

@ -1,6 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue';
import { IconWechat } from '@arco-design/web-vue/es/icon'; import { IconWechat } from '@arco-design/web-vue/es/icon';
import { isWeChatBrowser } from '@/utils/device'; import { isWeChatBrowser } from '@/utils/device';
import { redirectToWeChatOAuth } from '@/utils/wechatOAuth'; import { redirectToWeChatOAuth } from '@/utils/wechatOAuth';
@ -12,11 +13,13 @@ const props = withDefaults(
afterLoginPath?: string; afterLoginPath?: string;
long?: boolean; long?: boolean;
size?: 'mini' | 'small' | 'medium' | 'large'; size?: 'mini' | 'small' | 'medium' | 'large';
disabled?: boolean;
}>(), }>(),
{ {
afterLoginPath: '/h5/orders', afterLoginPath: '/h5/orders',
long: true, long: false,
size: 'large', size: 'large',
disabled: false,
}, },
); );
@ -25,8 +28,11 @@ const jumping = ref(false);
const inWeChat = computed(() => isWeChatBrowser()); const inWeChat = computed(() => isWeChatBrowser());
function handleClick() { function handleClick() {
if (jumping.value) return; if (jumping.value || props.disabled) return;
if (!inWeChat.value) return; if (!inWeChat.value) {
Message.warning(t('h5.login.openInWechat'));
return;
}
jumping.value = true; jumping.value = true;
redirectToWeChatOAuth('login', null, { afterLoginPath: props.afterLoginPath }); redirectToWeChatOAuth('login', null, { afterLoginPath: props.afterLoginPath });
} }
@ -35,37 +41,35 @@ function handleClick() {
<template> <template>
<div class="wx-oauth-login"> <div class="wx-oauth-login">
<a-button <a-button
v-if="inWeChat"
type="primary"
class="wx-oauth-login__btn" class="wx-oauth-login__btn"
:long="long" :long="long"
:size="size" :size="size"
:loading="jumping" :loading="jumping"
:disabled="disabled || (!inWeChat && !jumping)"
@click="handleClick" @click="handleClick"
> >
<template #icon><icon-wechat /></template> <template #icon><icon-wechat /></template>
{{ jumping ? t('h5.login.oauthRedirecting') : t('h5.login.oauthLogin') }} {{ jumping ? t('h5.login.oauthRedirecting') : t('h5.login.oauthLogin') }}
</a-button> </a-button>
<template v-else> <p v-if="!inWeChat" class="wx-oauth-login__tip">{{ t('h5.login.openInWechat') }}</p>
<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>
</div> </div>
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
.wx-oauth-login { .wx-oauth-login {
width: 100%; width: 100%;
padding: 8px 0 4px; display: flex;
flex-direction: column;
align-items: center;
} }
.wx-oauth-login__btn { .wx-oauth-login__btn {
height: 44px; width: 50%;
border-radius: 10px; min-width: 148px;
height: 48px;
border-radius: 999px;
font-weight: 600; font-weight: 600;
color: #fff !important;
background: #07c160 !important; background: #07c160 !important;
border-color: #07c160 !important; border-color: #07c160 !important;
@ -74,12 +78,18 @@ function handleClick() {
background: #06ad56 !important; background: #06ad56 !important;
border-color: #06ad56 !important; border-color: #06ad56 !important;
} }
&:disabled {
color: #fff !important;
background: #94d3ad !important;
border-color: #94d3ad !important;
}
} }
.wx-oauth-login__tip { .wx-oauth-login__tip {
margin: 0 0 12px; margin: 10px 0 0;
text-align: center; text-align: center;
font-size: 13px; font-size: 12px;
line-height: 1.5; line-height: 1.5;
color: #86909c; color: #86909c;
} }

View File

@ -163,16 +163,18 @@ export default {
'h5.wxQr.success': 'Signed in', 'h5.wxQr.success': 'Signed in',
'h5.login.title': 'Sign 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.wechat': 'WeChat',
'h5.login.tab.password': 'Account', 'h5.login.tab.password': 'Account',
'h5.login.switchPassword': 'Sign in with password', 'h5.login.switchPassword': 'Sign in with password',
'h5.login.switchWechat': 'WeChat authorize sign-in', 'h5.login.switchWechat': 'WeChat authorize sign-in',
'h5.login.or': 'or',
'h5.login.oauthLogin': 'Sign in with WeChat', 'h5.login.oauthLogin': 'Sign in with WeChat',
'h5.login.oauthRedirecting': 'Opening WeChat authorization…', 'h5.login.oauthRedirecting': 'Opening WeChat authorization…',
'h5.login.oauthLoggingIn': 'Authorized. Signing you in…', 'h5.login.oauthLoggingIn': 'Authorized. Signing you in…',
'h5.login.oauthFailed': 'WeChat sign-in failed. Please retry.', '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.accountPlaceholder': 'Phone / email / customer code',
'h5.login.passwordPlaceholder': 'Enter password', 'h5.login.passwordPlaceholder': 'Enter password',
'h5.login.accountRequired': 'Please enter account', 'h5.login.accountRequired': 'Please enter account',
@ -307,6 +309,24 @@ export default {
'h5.placeOrder.err.itemLine': 'Item {n}: {msg}', 'h5.placeOrder.err.itemLine': 'Item {n}: {msg}',
'h5.placeOrder.prev': 'Back', 'h5.placeOrder.prev': 'Back',
'h5.placeOrder.next': 'Next', '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.submit': 'Submit order',
'h5.placeOrder.submitting': 'Submitting…', 'h5.placeOrder.submitting': 'Submitting…',
'h5.placeOrder.successTitle': 'Order created', 'h5.placeOrder.successTitle': 'Order created',

View File

@ -162,16 +162,18 @@ export default {
'h5.wxQr.success': '登录成功', 'h5.wxQr.success': '登录成功',
'h5.login.title': '登录', 'h5.login.title': '登录',
'h5.login.brand': '客户中心', 'h5.login.brand': 'IDE客户中心',
'h5.login.heroSub': '使用账号密码登录,或在微信内一键授权',
'h5.login.tab.wechat': '微信登录', 'h5.login.tab.wechat': '微信登录',
'h5.login.tab.password': '账号登录', 'h5.login.tab.password': '账号登录',
'h5.login.switchPassword': '账号密码登录', 'h5.login.switchPassword': '账号密码登录',
'h5.login.switchWechat': '微信授权登录', 'h5.login.switchWechat': '微信授权登录',
'h5.login.or': '或',
'h5.login.oauthLogin': '微信授权登录', 'h5.login.oauthLogin': '微信授权登录',
'h5.login.oauthRedirecting': '正在打开微信授权…', 'h5.login.oauthRedirecting': '正在打开微信授权…',
'h5.login.oauthLoggingIn': '授权成功,正在登录…', 'h5.login.oauthLoggingIn': '授权成功,正在登录…',
'h5.login.oauthFailed': '微信授权登录失败,请重试', 'h5.login.oauthFailed': '微信授权登录失败,请重试',
'h5.login.openInWechat': '请在微信内打开本页,点击下方按钮一键授权登录', 'h5.login.openInWechat': '请在微信内打开本页,即可使用微信授权登录',
'h5.login.accountPlaceholder': '手机号 / 邮箱 / 客户编号', 'h5.login.accountPlaceholder': '手机号 / 邮箱 / 客户编号',
'h5.login.passwordPlaceholder': '请输入密码', 'h5.login.passwordPlaceholder': '请输入密码',
'h5.login.accountRequired': '请输入账号', 'h5.login.accountRequired': '请输入账号',
@ -306,6 +308,23 @@ export default {
'h5.placeOrder.err.itemLine': '第 {n} 条:{msg}', 'h5.placeOrder.err.itemLine': '第 {n} 条:{msg}',
'h5.placeOrder.prev': '上一步', 'h5.placeOrder.prev': '上一步',
'h5.placeOrder.next': '下一步', '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.submit': '提交下单',
'h5.placeOrder.submitting': '提交中…', 'h5.placeOrder.submitting': '提交中…',
'h5.placeOrder.successTitle': '下单成功', 'h5.placeOrder.successTitle': '下单成功',

View File

@ -15,90 +15,65 @@
</section> </section>
<section class="h5-login-card"> <section class="h5-login-card">
<div class="h5-login-tabs" role="tablist"> <div v-if="oauthLoading" class="h5-login-oauth-banner">
<button <a-spin :size="18" />
type="button" <span>{{ t('h5.login.oauthLoggingIn') }}</span>
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> </div>
<div class="h5-login-stage" :data-mode="activeTab"> <form class="h5-login-form" @submit.prevent="handleSubmit">
<div <a-input
class="h5-login-pane" v-model="form.account"
:class="{ 'is-active': activeTab === 'wechat' }" size="large"
:aria-hidden="activeTab !== 'wechat'" 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()" /> {{ loading ? t('h5.login.submitting') : t('h5.login.submit') }}
<p v-if="oauthLoading" class="h5-login-oauth-status">{{ t('h5.login.oauthLoggingIn') }}</p> </a-button>
<p v-else-if="errorBox && activeTab === 'wechat'" class="h5-login-error">{{ errorBox }}</p> </form>
</div>
<form <div class="h5-login-divider">
class="h5-login-pane h5-login-form" <span>{{ t('h5.login.or') }}</span>
: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> </div>
<WechatOAuthLoginButton
:after-login-path="resolveRedirectPath()"
:disabled="loading || oauthLoading"
/>
</section> </section>
<div class="h5-login-extra"> <div class="h5-login-extra">
@ -130,7 +105,7 @@
</template> </template>
<script lang="ts" setup> <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 { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue'; import { Message } from '@arco-design/web-vue';
@ -157,7 +132,6 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const rbacUserStore = useRbacUserStore(); const rbacUserStore = useRbacUserStore();
const activeTab = ref<'wechat' | 'password'>('wechat');
const form = reactive({ account: '', password: '' }); const form = reactive({ account: '', password: '' });
const agreementChecked = ref(false); const agreementChecked = ref(false);
const loading = ref(false); const loading = ref(false);
@ -177,26 +151,12 @@ const termsText = `一、使用须知
二、服务说明 二、服务说明
系统展示的运费、时效为估算或报价结果,最终以订单确认与仓库计费为准。异常订单请及时联系客服或使用 AI 顾问。`; 系统展示的运费、时效为估算或报价结果,最终以订单确认与仓库计费为准。异常订单请及时联系客服或使用 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() { function goBack() {
if (window.history.length > 1) { if (window.history.length > 1) {
router.back(); router.back();
return; return;
} }
router.replace('/h5/me'); router.replace('/h5/orders');
} }
function goRegister() { function goRegister() {
@ -261,7 +221,7 @@ async function finishLogin() {
async function consumeOAuthCode(code: string) { async function consumeOAuthCode(code: string) {
if (oauthLoading.value) return; if (oauthLoading.value) return;
oauthLoading.value = true; oauthLoading.value = true;
activeTab.value = 'wechat'; errorBox.value = '';
try { try {
const result = await wxOAuthLogin(code); const result = await wxOAuthLogin(code);
const ok = await rbacUserStore.loginByWxResult(result); const ok = await rbacUserStore.loginByWxResult(result);
@ -283,7 +243,7 @@ async function consumeOAuthCode(code: string) {
} }
async function handleSubmit() { async function handleSubmit() {
if (loading.value) return; if (loading.value || oauthLoading.value) return;
errorBox.value = ''; errorBox.value = '';
if (!agreementChecked.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 () => { onMounted(async () => {
if (isLogin()) { if (isLogin()) {
router.replace(resolveRedirectPath()); router.replace(resolveRedirectPath());
@ -348,7 +294,6 @@ onMounted(async () => {
return; return;
} }
// 微信内且未带 code:可一键拉起授权(用户点按钮亦可)
if (isWeChatBrowser() && route.query.auto === '1') { if (isWeChatBrowser() && route.query.auto === '1') {
redirectToWeChatOAuth('login', null, { afterLoginPath: resolveRedirectPath() }); redirectToWeChatOAuth('login', null, { afterLoginPath: resolveRedirectPath() });
} }
@ -417,17 +362,17 @@ onMounted(async () => {
width: 100%; width: 100%;
max-width: 420px; max-width: 420px;
margin: 0 auto; 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; box-sizing: border-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: flex-start;
animation: h5-login-rise 0.48s var(--login-ease) both; animation: h5-login-rise 0.48s var(--login-ease) both;
} }
.h5-login-hero { .h5-login-hero {
text-align: center; text-align: center;
padding: 0 8px 14px; padding: 8px 8px 16px;
} }
.h5-login-hero__logo { .h5-login-hero__logo {
@ -457,87 +402,28 @@ onMounted(async () => {
.h5-login-card { .h5-login-card {
background: #fff; background: #fff;
border-radius: 22px; border-radius: 22px;
padding: 14px 18px 20px; padding: 22px 18px 20px;
box-shadow: box-shadow:
0 1px 0 rgba(255, 255, 255, 0.8) inset, 0 1px 0 rgba(255, 255, 255, 0.8) inset,
0 12px 32px rgba(15, 23, 42, 0.06); 0 12px 32px rgba(15, 23, 42, 0.06);
} }
.h5-login-tabs { .h5-login-oauth-banner {
display: grid; display: flex;
grid-template-columns: 1fr 1fr; align-items: center;
gap: 4px; justify-content: center;
margin-bottom: 16px; gap: 8px;
padding: 4px; margin: -6px 0 14px;
border-radius: 12px; padding: 10px 12px;
background: #f2f3f5;
}
.h5-login-tab {
height: 36px;
border: none;
border-radius: 10px; border-radius: 10px;
background: transparent; background: #f2f3f5;
color: #86909c; color: #4e5969;
font-size: 14px; font-size: 13px;
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);
} }
.h5-login-form { .h5-login-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center;
min-height: 100%;
box-sizing: border-box;
} }
.h5-login-input { .h5-login-input {
@ -575,13 +461,6 @@ onMounted(async () => {
animation: h5-login-rise 0.28s var(--login-ease) both; 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 { .h5-login-agree {
margin: 4px 0 0; margin: 4px 0 0;
align-items: flex-start; align-items: flex-start;
@ -597,9 +476,9 @@ onMounted(async () => {
.h5-login-submit { .h5-login-submit {
margin-top: 16px; margin-top: 16px;
height: 50px !important; height: 48px !important;
border-radius: 14px !important; border-radius: 12px !important;
font-size: 17px; font-size: 16px;
font-weight: 600; font-weight: 600;
box-shadow: 0 8px 18px color-mix(in srgb, var(--app-primary-color) 26%, transparent); 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; 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 { .h5-login-extra {
margin-top: 20px; margin-top: 20px;
text-align: center; text-align: center;
@ -683,17 +583,11 @@ onMounted(async () => {
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.h5-login-main, .h5-login-main,
.h5-login-pane,
.h5-login-tab,
.h5-login-text-btn, .h5-login-text-btn,
.h5-login-error, .h5-login-error,
.h5-login-submit { .h5-login-submit {
animation: none !important; animation: none !important;
transition: none !important; transition: none !important;
} }
.h5-login-pane {
transform: none;
}
} }
</style> </style>

View File

@ -1,92 +1,42 @@
<template> <template>
<div class="h5-me-page" :class="{ 'h5-me-page--guest': !loggedIn }"> <div class="h5-me-page">
<!-- 访客:微信授权登录(无二维码);新用户走注册/绑定 --> <a-spin :loading="loading" class="h5-me-spin">
<template v-if="!loggedIn"> <div v-if="loading && !profile.id" class="h5-me-skel">
<section class="h5-me-guest h5-enter"> <div class="h5-me-skel__hero" />
<div class="h5-me-guest__brand"> <div class="h5-me-skel__card" />
<img :src="logoImg" alt="" class="h5-me-guest__logo" /> <div class="h5-me-skel__card h5-me-skel__card--short" />
<h2 class="h5-me-guest__title">{{ t('h5.login.brand') }}</h2> </div>
</div>
<div class="h5-me-guest__card"> <template v-else>
<div class="h5-me-guest__tabs" role="tablist"> <section class="h5-me-hero h5-enter" :class="{ 'is-low': isLowBalance }">
<button <span v-if="levelBadge" class="h5-me-vip">{{ levelBadge }}</span>
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>
<div class="h5-me-guest__extra"> <div class="h5-me-hero__top">
<p class="h5-me-guest__hint">{{ t('h5.login.newUserHint') }}</p> <img :src="displayAvatar" alt="" class="h5-me-avatar" />
<div class="h5-me-guest__actions"> <div class="h5-me-hero__text">
<button type="button" class="h5-me-text-btn is-primary" @click="goWechatRegister"> <div class="h5-me-hero__name-row">
<icon-user-add :size="18" /> <h2 class="h5-me-name">{{ displayName }}</h2>
<span>{{ t('h5.login.goRegister') }}</span> <span class="h5-me-greet">{{ greetText }}</span>
</button> </div>
<button type="button" class="h5-me-text-btn" @click="goWechatBind"> <div class="h5-me-hero__meta">
<icon-link :size="18" /> <button
<span>{{ t('h5.login.goBind') }}</span> v-if="profile.customerCode"
</button> type="button"
</div> class="h5-me-code"
</div> @click="copyText(profile.customerCode)"
</section> >
</template> {{ t('h5.me.customerCode') }} {{ profile.customerCode }}
<icon-copy />
<!-- 已登录 --> </button>
<template v-else> <span v-if="profile.customerCode" class="h5-me-hero__dot" aria-hidden="true">·</span>
<a-spin :loading="loading" class="h5-me-spin"> <span class="h5-me-balance__label">{{ t('h5.me.balance') }}</span>
<div v-if="loading && !profile.id" class="h5-me-skel"> <strong class="h5-me-balance__value">¥{{ formatMoney(accountBalance) }}</strong>
<div class="h5-me-skel__hero" /> <button type="button" class="h5-me-recharge" @click="goRecharge">
<div class="h5-me-skel__card" /> {{ t('h5.me.recharge') }}
<div class="h5-me-skel__card h5-me-skel__card--short" /> </button>
</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> </div>
</div> </div>
</div>
<div v-if="memberOverview" class="h5-me-hero__points"> <div v-if="memberOverview" class="h5-me-hero__points">
<div class="h5-me-hero__points-head"> <div class="h5-me-hero__points-head">
@ -221,14 +171,13 @@
<H5PasswordSheet v-model:visible="pwdVisible" @success="onPasswordChanged" /> <H5PasswordSheet v-model:visible="pwdVisible" @success="onPasswordChanged" />
<H5AddressSheet v-model:visible="addressVisible" /> <H5AddressSheet v-model:visible="addressVisible" />
<H5PickupOrdersSheet v-model:visible="pickupVisible" @loaded="onPickupLoaded" /> <H5PickupOrdersSheet v-model:visible="pickupVisible" @loaded="onPickupLoaded" />
</template>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { preloadInvitePosterBg } from '@/utils/invitePoster'; import { preloadInvitePosterBg } from '@/utils/invitePoster';
import { useRoute, useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Message, Modal } from '@arco-design/web-vue'; import { Message, Modal } from '@arco-design/web-vue';
import { import {
@ -237,8 +186,6 @@ import {
IconEdit, IconEdit,
IconLock, IconLock,
IconLocation, IconLocation,
IconUserAdd,
IconLink,
IconLanguage, IconLanguage,
IconWechat, IconWechat,
} from '@arco-design/web-vue/es/icon'; } from '@arco-design/web-vue/es/icon';
@ -249,18 +196,15 @@ import { getMyCoupons, getMemberOverview } from '@/api/marketing/portal';
import { isLogin } from '@/utils/auth'; import { isLogin } from '@/utils/auth';
import { resolveAvatarUrl } from '@/utils/avatar'; import { resolveAvatarUrl } from '@/utils/avatar';
import { getMapIdFromAccessToken } from '@/utils/jwt'; import { getMapIdFromAccessToken } from '@/utils/jwt';
import { h5Navigate, isWeChatBrowser } from '@/utils/device'; import { h5Navigate } from '@/utils/device';
import { redirectToWeChatOAuth } from '@/utils/wechatOAuth';
import { jumpToWechatOfficialAccount } from '@/utils/wechatFollowJump'; import { jumpToWechatOfficialAccount } from '@/utils/wechatFollowJump';
import { useUserStore } from '@/store'; import { useUserStore } from '@/store';
import { useRbacUserStore } from '@/store/modules/rbac-user'; import { useRbacUserStore } from '@/store/modules/rbac-user';
import useLocale from '@/hooks/locale'; import useLocale from '@/hooks/locale';
import logoImg from '@/assets/logo2.png';
import iconServiceCoupon from '@/assets/icons/h5/service-coupon.svg?url'; import iconServiceCoupon from '@/assets/icons/h5/service-coupon.svg?url';
import iconServiceInvite from '@/assets/icons/h5/service-invite.svg?url'; import iconServiceInvite from '@/assets/icons/h5/service-invite.svg?url';
import iconServiceCommission from '@/assets/icons/h5/service-commission.svg?url'; import iconServiceCommission from '@/assets/icons/h5/service-commission.svg?url';
import iconOrderPickup from '@/assets/icons/h5/order-pickup.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 H5JourneyGuide from '../components/H5JourneyGuide.vue';
import H5CreateOrderFlow from '../components/H5CreateOrderFlow.vue'; import H5CreateOrderFlow from '../components/H5CreateOrderFlow.vue';
import H5RechargeSheet from '../components/H5RechargeSheet.vue'; import H5RechargeSheet from '../components/H5RechargeSheet.vue';
@ -278,12 +222,10 @@ const LOW_BALANCE_THRESHOLD = 100;
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const { changeLocale } = useLocale(); const { changeLocale } = useLocale();
const route = useRoute();
const router = useRouter(); const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const rbacUserStore = useRbacUserStore(); const rbacUserStore = useRbacUserStore();
const loggedIn = ref(isLogin());
const loading = ref(false); const loading = ref(false);
const profile = ref<CustomerDto>({}); const profile = ref<CustomerDto>({});
const couponCount = ref(0); 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() { function goRecharge() {
rechargeVisible.value = true; rechargeVisible.value = true;
} }
@ -479,8 +392,7 @@ function onProfileSaved(payload: { customerName: string; phone: string; email: s
async function onPasswordChanged() { async function onPasswordChanged() {
await userStore.logout(); await userStore.logout();
rbacUserStore.logout(); rbacUserStore.logout();
loggedIn.value = false; router.replace('/h5/login');
router.replace('/h5/me');
} }
function toggleLocale() { function toggleLocale() {
@ -508,8 +420,7 @@ function handleLogout() {
onOk: async () => { onOk: async () => {
await userStore.logout(); await userStore.logout();
rbacUserStore.logout(); rbacUserStore.logout();
loggedIn.value = false; router.replace('/h5/login');
router.replace('/h5/me');
}, },
}); });
} }
@ -547,7 +458,7 @@ async function loadExtras() {
} }
async function loadProfile() { async function loadProfile() {
if (!loggedIn.value) return; if (!isLogin()) return;
loading.value = true; loading.value = true;
try { try {
const mapId = getMapIdFromAccessToken(rbacUserStore.accessToken); const mapId = getMapIdFromAccessToken(rbacUserStore.accessToken);
@ -562,7 +473,10 @@ async function loadProfile() {
} }
onMounted(() => { onMounted(() => {
loggedIn.value = isLogin(); if (!isLogin()) {
router.replace({ path: '/h5/login', query: { redirect: '/h5/me' } });
return;
}
void loadProfile(); void loadProfile();
void preloadInvitePosterBg(); void preloadInvitePosterBg();
}); });
@ -578,10 +492,6 @@ onMounted(() => {
padding: 20px 16px 28px; padding: 20px 16px 28px;
box-sizing: border-box; box-sizing: border-box;
background: linear-gradient(180deg, color-mix(in srgb, var(--me-teal) 12%, #f3f6f7) 0%, #f3f6f7 180px); background: linear-gradient(180deg, color-mix(in srgb, var(--me-teal) 12%, #f3f6f7) 0%, #f3f6f7 180px);
&--guest {
padding-top: 16px;
}
} }
.h5-me-spin { .h5-me-spin {
@ -1121,133 +1031,6 @@ onMounted(() => {
font-size: 16px; 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) { @media (prefers-reduced-motion: reduce) {
.h5-me-skel__hero, .h5-me-skel__hero,
.h5-me-skel__card { .h5-me-skel__card {

View File

@ -17,6 +17,7 @@
deleteOrderTemplateCustom, deleteOrderTemplateCustom,
postOrderUpload, postOrderUpload,
getOrderBatchJob, getOrderBatchJob,
postOrderReceiverParseAddress,
} from '@/api/order'; } from '@/api/order';
import { getSenderAddressList } from '@/api/order/senderAddress'; import { getSenderAddressList } from '@/api/order/senderAddress';
import { getSalesProductList } from '@/api/channel/salesProduct'; import { getSalesProductList } from '@/api/channel/salesProduct';
@ -65,6 +66,13 @@
const singleShowFieldErrors = ref(false); const singleShowFieldErrors = ref(false);
/** 收件人选填字段默认折叠 */ /** 收件人选填字段默认折叠 */
const showRecipientOptional = 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 declarationPanelHeight = ref(280);
const DECLARATION_PANEL_MIN = 160; const DECLARATION_PANEL_MIN = 160;
@ -901,6 +909,108 @@
return true; 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() { function isSingleDeclarationComplete() {
const lines = singleForm.items || []; const lines = singleForm.items || [];
if (!lines.length) return false; if (!lines.length) return false;
@ -2677,94 +2787,139 @@
</section> </section>
<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))" :ref="(el) => (singlePanelRefs.recipient = (el as HTMLElement | null))"
> >
<header class="single-info-card__header"> <header class="single-info-card__header">
<span class="single-section-index">3</span> <span class="single-section-index">3</span>
<span class="single-section-title">{{ t('order.placeOrder.single.stepRecipient') }}</span> <span class="single-section-title">{{ t('order.placeOrder.single.stepRecipient') }}</span>
</header> </header>
<div class="single-form-grid single-form-grid--compact">
<a-form-item <div class="receiver-split">
label="收件人名称" <aside class="receiver-split__paste">
required <div class="receiver-ai-paste">
:validate-status="fieldValidateStatus('receiver.recipientsName')" <div class="receiver-ai-paste__head">
:help="fieldHelp('receiver.recipientsName')" <span class="receiver-ai-paste__title">粘贴地址 · AI 解析</span>
> <span class="receiver-ai-paste__hint">中/英整段粘贴,Ctrl+Enter 解析</span>
<a-input v-model="singleForm.receiver.recipientsName" /> </div>
</a-form-item> <a-textarea
<a-form-item v-model="receiverPasteText"
label="收件人手机" class="receiver-ai-paste__textarea"
required :auto-size="{ minRows: 8, maxRows: 16 }"
:validate-status="fieldValidateStatus('receiver.phone')" placeholder="例:John Smith, +1 310-555-0199, 1001 Sunset Blvd, Los Angeles, CA 90001, USA&#10;或:张三 13800138000 广东省深圳市南山区科技园南路 XX 号 518000"
:help="fieldHelp('receiver.phone')" @keydown="onReceiverPasteKeydown"
> />
<a-input v-model="singleForm.receiver.phone" /> <div class="receiver-ai-paste__actions">
</a-form-item> <a-button
<a-form-item type="primary"
label="国家" size="small"
required :loading="receiverParseLoading"
:validate-status="fieldValidateStatus('receiver.countryCode')" @click="handleReceiverAiParse"
:help="fieldHelp('receiver.countryCode')" >
> AI解析
<a-select </a-button>
v-model="singleForm.receiver.countryCode" <a-button
allow-search v-if="receiverPasteText"
placeholder="请选择" size="small"
:loading="singleReferenceLoading" @click="receiverPasteText = ''; receiverParseTip = null"
> >
<a-option 清空
v-for="opt in singleCountryOptions" </a-button>
:key="opt.value" </div>
:value="opt.value" <p
v-if="receiverParseTip"
class="receiver-ai-paste__tip"
:class="`is-${receiverParseTip.tone}`"
> >
{{ opt.label }} {{ receiverParseTip.text }}
</a-option> </p>
</a-select> </div>
</a-form-item> </aside>
<a-form-item
label="邮编" <div class="receiver-split__form">
required <div class="single-form-grid single-form-grid--compact">
:validate-status="fieldValidateStatus('receiver.zipCode')" <a-form-item
:help="fieldHelp('receiver.zipCode')" label="收件人名称"
> required
<a-input v-model="singleForm.receiver.zipCode" /> :validate-status="fieldValidateStatus('receiver.recipientsName')"
</a-form-item> :help="fieldHelp('receiver.recipientsName')"
<a-form-item >
class="span-2" <a-input v-model="singleForm.receiver.recipientsName" />
label="地址1" </a-form-item>
required <a-form-item
:validate-status="fieldValidateStatus('receiver.streetAddress1')" label="收件人手机"
:help="fieldHelp('receiver.streetAddress1')" required
> :validate-status="fieldValidateStatus('receiver.phone')"
<a-input v-model="singleForm.receiver.streetAddress1" /> :help="fieldHelp('receiver.phone')"
</a-form-item> >
</div> <a-input v-model="singleForm.receiver.phone" />
<div class="recipient-optional-toggle"> </a-form-item>
<button <a-form-item
type="button" label="国家"
class="declaration-extra-btn" required
@click="showRecipientOptional = !showRecipientOptional" :validate-status="fieldValidateStatus('receiver.countryCode')"
> :help="fieldHelp('receiver.countryCode')"
{{ >
showRecipientOptional <a-select
? t('order.placeOrder.single.hideRecipientOptional') v-model="singleForm.receiver.countryCode"
: t('order.placeOrder.single.showRecipientOptional') allow-search
}} placeholder="请选择"
</button> :loading="singleReferenceLoading"
</div> >
<div <a-option
v-show="showRecipientOptional" v-for="opt in singleCountryOptions"
class="single-form-grid single-form-grid--compact recipient-optional-grid" :key="opt.value"
> :value="opt.value"
<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> {{ opt.label }}
<a-form-item label="收件人邮箱"><a-input v-model="singleForm.receiver.email" /></a-form-item> </a-option>
<a-form-item label="省/州"><a-input v-model="singleForm.receiver.province" /></a-form-item> </a-select>
<a-form-item label="城市"><a-input v-model="singleForm.receiver.city" /></a-form-item> </a-form-item>
<a-form-item label="门牌号"><a-input v-model="singleForm.receiver.streetNumber" /></a-form-item> <a-form-item
<a-form-item label="地址2"><a-input v-model="singleForm.receiver.streetAddress2" /></a-form-item> label="邮编"
<a-form-item label="地址3"><a-input v-model="singleForm.receiver.streetAddress3" /></a-form-item> 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> </div>
</section> </section>
</div> </div>
@ -3669,6 +3824,72 @@
border-top: 1px dashed rgba(45, 140, 140, 0.18); 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 { .declaration-card {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
@ -4757,7 +4978,7 @@
.single-cards-row { .single-cards-row {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
gap: 12px; gap: 12px;
align-items: stretch; align-items: stretch;
} }
@ -4768,6 +4989,27 @@
min-width: 0; 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 { .single-info-card__header {
display: flex; display: flex;
align-items: center; align-items: center;
@ -5340,11 +5582,21 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.receiver-split {
grid-template-columns: 1fr;
}
.single-form-grid { .single-form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
@media (max-width: 1100px) {
.receiver-split {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.single-form-grid { .single-form-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@ -81,7 +81,6 @@
:stretch-columns="true" :stretch-columns="true"
:height="handsontableHeight" :height="handsontableHeight"
:read-only="true" :read-only="true"
:enable-header-search="false"
:enable-column-filter="false" :enable-column-filter="false"
:serial-no-header="t('system.announcement.table.index')" :serial-no-header="t('system.announcement.table.index')"
:action-column-header="t('system.announcement.table.actions')" :action-column-header="t('system.announcement.table.actions')"

View File

@ -38,6 +38,7 @@
app: userStore.appKey, app: userStore.appKey,
}); });
const searchKeyword = ref(''); const searchKeyword = ref('');
const headerSearchParams = reactive<Record<string, any>>({});
const tableData = ref<any[]>([]); const tableData = ref<any[]>([]);
const totalCount = ref(0); const totalCount = ref(0);
@ -498,7 +499,7 @@
:total="totalCount" :total="totalCount"
:current-page="currentPage" :current-page="currentPage"
:page-size="pageSize" :page-size="pageSize"
:search-params="{}" :search-params="headerSearchParams"
:show-action-column="true" :show-action-column="true"
:action-column-position="'end'" :action-column-position="'end'"
:action-column-width="110" :action-column-width="110"

View File

@ -33,6 +33,7 @@
app: userStore.appKey, app: userStore.appKey,
}); });
const searchKeyword = ref(''); const searchKeyword = ref('');
const headerSearchParams = reactive<Record<string, any>>({});
const tableData = ref<any[]>([]); const tableData = ref<any[]>([]);
const totalCount = ref(0); const totalCount = ref(0);
@ -345,7 +346,7 @@
:total="totalCount" :total="totalCount"
:current-page="currentPage" :current-page="currentPage"
:page-size="pageSize" :page-size="pageSize"
:search-params="{}" :search-params="headerSearchParams"
:show-action-column="true" :show-action-column="true"
:action-column-position="'end'" :action-column-position="'end'"
:action-column-width="110" :action-column-width="110"

View File

@ -67,6 +67,11 @@
:columns="detailColumns" :columns="detailColumns"
:data="detailTableData" :data="detailTableData"
:height="tableHeight" :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" @selection-change="handleDetailSelectionChange"
/> />
</div> </div>
@ -80,6 +85,11 @@
:columns="pushRecordColumns" :columns="pushRecordColumns"
:data="pushRecordTableData" :data="pushRecordTableData"
:height="tableHeight" :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>
</div> </div>
@ -93,6 +103,11 @@
:data="logTableData" :data="logTableData"
:height="tableHeight" :height="tableHeight"
:stretch-columns="true" :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>
</div> </div>
@ -106,6 +121,11 @@
:columns="pullConfigColumns" :columns="pullConfigColumns"
:data="pullConfigTableData" :data="pullConfigTableData"
:height="pullConfigHandsontableHeight" :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 <a-empty
v-else-if="!pullConfigLoading" v-else-if="!pullConfigLoading"

View File

@ -2,7 +2,8 @@ import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { $baseMessage } from '@/utils'; import { $baseMessage } from '@/utils';
import { postGetTrackOrderList } from '@/api/TrackOrder/trackOrder'; import { postGetTrackOrderList } from '@/api/TrackOrder/trackOrder';
import { convertToTime } from '../utils'; import { convertToTime } from '../utils';
import { toServerSearchValue } from '@/components/HandsontablePro/filterValue';
export function useTrackOrderData(options: { export function useTrackOrderData(options: {
userStore: any; userStore: any;
@ -37,8 +38,9 @@ export function useTrackOrderData(options: {
// 合并searchInfo和searchParams,优先使用searchParams中的值 // 合并searchInfo和searchParams,优先使用searchParams中的值
// 处理客户单号:如果是逗号分隔的字符串,转换为数组 // 处理客户单号:如果是逗号分隔的字符串,转换为数组
let orderNos: string[] = []; let orderNos: string[] = [];
if (searchParams.orderNumber) { const orderNumberPlain = toServerSearchValue(searchParams.orderNumber || '');
orderNos = searchParams.orderNumber.split(',').map(v => v.trim()).filter(Boolean); if (orderNumberPlain) {
orderNos = orderNumberPlain.split(',').map((v: string) => v.trim()).filter(Boolean);
} else if ((searchInfo as any).orderNos && Array.isArray((searchInfo as any).orderNos)) { } else if ((searchInfo as any).orderNos && Array.isArray((searchInfo as any).orderNos)) {
orderNos = (searchInfo as any).orderNos; orderNos = (searchInfo as any).orderNos;
} }
@ -50,24 +52,24 @@ export function useTrackOrderData(options: {
// 优先使用表头搜索的客户单号(已转换为数组),否则使用表单的客户单号 // 优先使用表头搜索的客户单号(已转换为数组),否则使用表单的客户单号
orderNos: orderNos, orderNos: orderNos,
trackOrderType: trackOrderType, trackOrderType: trackOrderType,
customer: searchParams.customer || '', customer: toServerSearchValue(searchParams.customer || ''),
// 添加所有新的搜索字段(尾程单号和其他单号保持逗号分隔的字符串格式) // 添加所有新的搜索字段(尾程单号和其他单号保持逗号分隔的字符串格式)
trackNo: searchParams.trackNo || '', trackNo: toServerSearchValue(searchParams.trackNo || ''),
otherNo: searchParams.otherNo || '', otherNo: toServerSearchValue(searchParams.otherNo || ''),
product: searchParams.product || '', product: toServerSearchValue(searchParams.product || ''),
channel: searchParams.channel || '', channel: toServerSearchValue(searchParams.channel || ''),
apiCode: searchParams.apiCode || '', apiCode: toServerSearchValue(searchParams.apiCode || ''),
countryCode: searchParams.countryCode || '', countryCode: toServerSearchValue(searchParams.countryCode || ''),
province: searchParams.province || '', province: toServerSearchValue(searchParams.province || ''),
city: searchParams.city || '', city: toServerSearchValue(searchParams.city || ''),
postcode: searchParams.postcode || '', postcode: toServerSearchValue(searchParams.postcode || ''),
email: searchParams.email || '', email: toServerSearchValue(searchParams.email || ''),
newestTrackStatus: searchParams.newestTrackStatus || '', newestTrackStatus: toServerSearchValue(searchParams.newestTrackStatus || ''),
newestTrackContent: searchParams.newestTrackContent || '', newestTrackContent: toServerSearchValue(searchParams.newestTrackContent || ''),
newestTrackEnContent: searchParams.newestTrackEnContent || '', newestTrackEnContent: toServerSearchValue(searchParams.newestTrackEnContent || ''),
newestTrackTime: searchParams.newestTrackTime || '', newestTrackTime: searchParams.newestTrackTime || '',
newestTrackTimeZone: searchParams.newestTrackTimeZone || '', newestTrackTimeZone: toServerSearchValue(searchParams.newestTrackTimeZone || ''),
newestTrackPlace: searchParams.newestTrackPlace || '', newestTrackPlace: toServerSearchValue(searchParams.newestTrackPlace || ''),
isFinish: searchParams.isFinish !== '' ? searchParams.isFinish : undefined, isFinish: searchParams.isFinish !== '' ? searchParams.isFinish : undefined,
createTimeStart: (searchParams as any).createTimeStart || (searchInfo as any).createTimeStart || '', createTimeStart: (searchParams as any).createTimeStart || (searchInfo as any).createTimeStart || '',
createTimeEnd: (searchParams as any).createTimeEnd || (searchInfo as any).createTimeEnd || '', createTimeEnd: (searchParams as any).createTimeEnd || (searchInfo as any).createTimeEnd || '',

View File

@ -4,29 +4,63 @@ import { useI18n } from 'vue-i18n';
export function useTableColumns() { export function useTableColumns() {
const { t } = useI18n(); const { t } = useI18n();
// headerMode:inline 内嵌搜 / icon 仅图标(条件面板) / none 纯展示
const columns = computed(() => [ const columns = computed(() => [
{ key: 'customer', label: t('track.order.table.customerShortName'), width: 120, searchable: true, searchField: 'customer' }, { 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: 180, searchable: true, searchField: 'orderNumber', multiValue: true }, { 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: 180, searchable: true, searchField: 'trackNo', multiValue: true }, { 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: 180, searchable: true, searchField: 'otherNo', multiValue: true }, { 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' }, { 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' }, { 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' }, { 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: 100, searchable: true, searchField: 'countryCode' }, { 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: 100, searchable: true, searchField: 'isFinish' }, {
{ key: 'newestTrackStatus', label: t('track.order.table.latestTrajectoryStatus'), width: 150, searchable: true, searchField: 'newestTrackStatus' }, key: 'isFinish',
{ key: 'newestTrackContent', label: t('track.order.table.latestTrajectoryContent'), width: 250, searchable: true, searchField: 'newestTrackContent' }, label: t('track.order.table.isComplete'),
{ key: 'newestTrackEnContent', label: t('track.order.table.latestTrajectoryContentEn'), width: 250, searchable: true, searchField: 'newestTrackEnContent' }, width: 110,
{ key: 'newestTrackTime', label: t('track.order.table.latestTrajectoryTime'), width: 180, searchable: true, searchField: 'newestTrackTime' }, searchable: true,
{ key: 'newestTrackTimeZone', label: t('track.order.table.latestTrajectoryTimezone'), width: 120, searchable: true, searchField: 'newestTrackTimeZone' }, searchField: 'isFinish',
{ key: 'newestTrackPlace', label: t('track.order.table.latestTrajectoryLocation'), width: 200, searchable: true, searchField: 'newestTrackPlace' }, headerMode: 'icon' as const,
{ key: 'province', label: t('track.order.table.province'), width: 120, searchable: true, searchField: 'province' }, filterType: 'enum' as const,
{ key: 'city', label: t('track.order.table.city'), width: 120, searchable: true, searchField: 'city' }, filterable: true,
{ key: 'postcode', label: t('track.order.table.postcode'), width: 100, searchable: true, searchField: 'postcode' }, filterOptions: [
{ key: 'email', label: t('track.order.table.email'), width: 200, searchable: true, searchField: 'email' }, { label: '是', value: 'true' },
{ key: 'estimatedDeliveryDate', label: t('track.order.table.estimatedDeliveryTime'), width: 180, searchable: false }, { label: '否', value: '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: '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 { return {

View File

@ -33,6 +33,7 @@
app: userStore.appKey, app: userStore.appKey,
}); });
const searchKeyword = ref(''); const searchKeyword = ref('');
const headerSearchParams = reactive<Record<string, any>>({});
const tableData = ref<any[]>([]); const tableData = ref<any[]>([]);
const totalCount = ref(0); const totalCount = ref(0);
@ -383,7 +384,7 @@
:total="totalCount" :total="totalCount"
:current-page="currentPage" :current-page="currentPage"
:page-size="pageSize" :page-size="pageSize"
:search-params="{}" :search-params="headerSearchParams"
:show-action-column="true" :show-action-column="true"
:action-column-position="'end'" :action-column-position="'end'"
:action-column-width="110" :action-column-width="110"