新增 IDE 官网静态资源与 D3 分区域地图布局,构建时复制至 dist/site;同步应收应付、代发打包、订单详情等页面优化。 Co-authored-by: Cursor <cursoragent@cursor.com>
349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
/**
|
||
* HandsontablePro 表头筛选 → 列表 API 入参适配。
|
||
* 仅映射现有 DTO 能承接的字段;高级运算优先走 filters[](P1)。
|
||
*/
|
||
import {
|
||
parseFilterValue,
|
||
toServerFilterItem,
|
||
toServerSearchValue,
|
||
type ServerFilterItem,
|
||
type ToServerFilterOptions,
|
||
} from '@/components/HandsontablePro/filterValue';
|
||
import { MAX_HEADER_FILTERS, clampBatchValues } from '@/utils/headerFilterLimits';
|
||
|
||
export type HeaderSearchParams = Record<string, unknown>;
|
||
|
||
function asTrimmedString(v: unknown): string {
|
||
if (v == null) return '';
|
||
return String(v).trim();
|
||
}
|
||
|
||
/** 取首个非空明文检索值(contains/equals) */
|
||
export function pickHeaderText(params: HeaderSearchParams, keys: string[]): string {
|
||
for (const key of keys) {
|
||
const plain = toServerSearchValue(asTrimmedString(params[key]));
|
||
if (plain) return plain;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/** 日期列:支持 field / fieldStart+fieldEnd / "start,end" */
|
||
export function pickDateRange(
|
||
params: HeaderSearchParams,
|
||
field: string,
|
||
): { start?: string; end?: string } {
|
||
const startKey = `${field}Start`;
|
||
const endKey = `${field}End`;
|
||
let start = asTrimmedString(params[startKey]);
|
||
let end = asTrimmedString(params[endKey]);
|
||
if (!start && !end) {
|
||
const raw = asTrimmedString(params[field]);
|
||
if (raw.includes(',')) {
|
||
const [s, e] = raw.split(',');
|
||
start = (s || '').trim();
|
||
end = (e || '').trim();
|
||
}
|
||
}
|
||
return {
|
||
start: start || undefined,
|
||
end: end || undefined,
|
||
};
|
||
}
|
||
|
||
type FieldMapEntry = string | { field: string; options?: ToServerFilterOptions };
|
||
|
||
/** 将多列表头编码值转为 filters[](字段名可映射) */
|
||
export function collectServerFilters(
|
||
params: HeaderSearchParams,
|
||
fieldMap: Record<string, FieldMapEntry>,
|
||
): ServerFilterItem[] {
|
||
const items: ServerFilterItem[] = [];
|
||
const seen = new Set<string>();
|
||
for (const [paramKey, mapEntry] of Object.entries(fieldMap)) {
|
||
const apiField = typeof mapEntry === 'string' ? mapEntry : mapEntry.field;
|
||
const options = typeof mapEntry === 'string' ? undefined : mapEntry.options;
|
||
if (seen.has(apiField) && !asTrimmedString(params[paramKey])) continue;
|
||
const item = toServerFilterItem(asTrimmedString(params[paramKey]), apiField, options);
|
||
if (!item) continue;
|
||
const idx = items.findIndex((x) => x.field === apiField);
|
||
if (idx >= 0) items.splice(idx, 1);
|
||
items.push(item);
|
||
seen.add(apiField);
|
||
if (items.length >= MAX_HEADER_FILTERS) break;
|
||
}
|
||
return items.map((item) => {
|
||
if (item.values?.length) {
|
||
const { values } = clampBatchValues(item.values);
|
||
return { ...item, values, value: values.join(',') };
|
||
}
|
||
return item;
|
||
});
|
||
}
|
||
|
||
const ORDER_NUMBER_FIELD_MAP: Record<string, FieldMapEntry> = {
|
||
waybillNo: { field: 'serviceNo', options: { preferExact: true, multiAsIn: true } },
|
||
serviceNo: { field: 'serviceNo', options: { preferExact: true, multiAsIn: true } },
|
||
customerOrderNo: { field: 'clientNo', options: { preferExact: true, multiAsIn: true } },
|
||
clientNo: { field: 'clientNo', options: { preferExact: true, multiAsIn: true } },
|
||
trackingNo: { field: 'trackNo', options: { preferExact: true, multiAsIn: true } },
|
||
trackNo: { field: 'trackNo', options: { preferExact: true, multiAsIn: true } },
|
||
downstreamServiceNo: {
|
||
field: 'downstreamServiceNo',
|
||
options: { preferExact: true, multiAsIn: true },
|
||
},
|
||
};
|
||
|
||
const ORDER_EXTRA_FIELD_MAP: Record<string, FieldMapEntry> = {
|
||
orderStatus: { field: 'orderStatus', options: { multiAsIn: true } },
|
||
fulfillmentType: { field: 'fulfillmentType', options: { multiAsIn: true } },
|
||
shippingMethod: 'productCode',
|
||
serviceChannel: 'channelCode',
|
||
isPaid: { field: 'isPaid', options: { multiAsIn: true } },
|
||
receivableAmount: { field: 'payableAmount', options: { asNumber: true } },
|
||
payableAmount: { field: 'payableAmount', options: { asNumber: true } },
|
||
predeclareFailReason: 'predeclareFailReason',
|
||
allocationPendingReason: 'allocationPendingReason',
|
||
};
|
||
|
||
export interface OrderListHeaderQuery {
|
||
keyword?: string;
|
||
createTimeStart?: string;
|
||
createTimeEnd?: string;
|
||
filters: ServerFilterItem[];
|
||
}
|
||
|
||
/**
|
||
* 订单列表表头 → API。
|
||
* - 单号/状态/金额等走 filters[];无字段级筛选时单号回落 keyword
|
||
* - 创建时间走 createTimeStart/End(与工具栏合并由调用方决定)
|
||
*/
|
||
export function adaptOrderListHeaderFilters(
|
||
params: HeaderSearchParams,
|
||
): OrderListHeaderQuery {
|
||
const numberFilters = collectServerFilters(params, ORDER_NUMBER_FIELD_MAP);
|
||
const extraFilters = collectServerFilters(params, ORDER_EXTRA_FIELD_MAP);
|
||
const filters = [...numberFilters, ...extraFilters];
|
||
|
||
// 日期:优先双写 Start/End;若仅有单边比较编码则进 filters
|
||
const { start, end } = pickDateRange(params, 'createTime');
|
||
for (const dateKey of ['payTime', 'shipTime', 'receiveTime', 'signTime'] as const) {
|
||
const range = pickDateRange(params, dateKey);
|
||
if (range.start || range.end) {
|
||
if (range.start && range.end) {
|
||
filters.push({
|
||
field: dateKey,
|
||
op: 'between',
|
||
value: range.start,
|
||
value2: range.end,
|
||
});
|
||
} else if (range.start) {
|
||
filters.push({ field: dateKey, op: 'gte', value: range.start });
|
||
} else if (range.end) {
|
||
filters.push({ field: dateKey, op: 'lte', value: range.end });
|
||
}
|
||
} else {
|
||
const item = toServerFilterItem(asTrimmedString(params[dateKey]), dateKey);
|
||
if (item) filters.push(item);
|
||
}
|
||
}
|
||
|
||
// 无字段级单号筛选时,用任一单号列明文作为 keyword(兼容旧后端;精确查询优先走 filters)
|
||
let keyword: string | undefined;
|
||
if (!numberFilters.length) {
|
||
const text = pickHeaderText(params, Object.keys(ORDER_NUMBER_FIELD_MAP));
|
||
keyword = text || undefined;
|
||
} else {
|
||
// 已有精确 filters 时不再附带模糊 keyword,避免 AND 冲突
|
||
keyword = undefined;
|
||
}
|
||
|
||
return {
|
||
keyword,
|
||
createTimeStart: start,
|
||
createTimeEnd: end,
|
||
filters,
|
||
};
|
||
}
|
||
|
||
export interface CustomerListHeaderQuery {
|
||
keyword?: string;
|
||
salesman?: string;
|
||
customerService?: string;
|
||
status?: number;
|
||
}
|
||
|
||
/** 客户列表表头 → 现有 CustomerPaginatedInput */
|
||
export function adaptCustomerListHeaderFilters(
|
||
params: HeaderSearchParams,
|
||
): CustomerListHeaderQuery {
|
||
const keyword =
|
||
pickHeaderText(params, [
|
||
'customerCode',
|
||
'customerName',
|
||
'email',
|
||
'phone',
|
||
'wechatNickname',
|
||
]) || undefined;
|
||
const salesman = pickHeaderText(params, ['salesPerson', 'salesman']) || undefined;
|
||
const customerService = pickHeaderText(params, ['customerService']) || undefined;
|
||
|
||
let status: number | undefined;
|
||
const statusRaw = asTrimmedString(params.status);
|
||
if (statusRaw) {
|
||
const { op, value } = parseFilterValue(statusRaw);
|
||
if (op === 'contains' || op === 'equals') {
|
||
const first = value.split(',')[0]?.trim() || '';
|
||
const n = Number(first);
|
||
if (!Number.isNaN(n)) status = n;
|
||
}
|
||
}
|
||
|
||
return { keyword, salesman, customerService, status };
|
||
}
|
||
|
||
export interface AccountFlowHeaderQuery {
|
||
keyword?: string;
|
||
bizType?: string;
|
||
createTimeStart?: string;
|
||
createTimeEnd?: string;
|
||
amountMin?: number;
|
||
amountMax?: number;
|
||
}
|
||
|
||
/** 账户流水表头 → AmountMin/Max + 时间 */
|
||
export function adaptAccountFlowHeaderFilters(
|
||
params: HeaderSearchParams,
|
||
): AccountFlowHeaderQuery {
|
||
const keyword =
|
||
pickHeaderText(params, ['serialNumber', 'bizNo', 'remark']) || undefined;
|
||
const bizType = pickHeaderText(params, ['bizType', 'bizTypeName']) || undefined;
|
||
const { start, end } = pickDateRange(params, 'createTime');
|
||
|
||
let amountMin: number | undefined;
|
||
let amountMax: number | undefined;
|
||
const amountRaw = asTrimmedString(params.amount);
|
||
if (amountRaw) {
|
||
const { op, value } = parseFilterValue(amountRaw);
|
||
const n = Number(value);
|
||
if (!Number.isNaN(n) && value !== '') {
|
||
if (op === 'gte' || op === 'gt') amountMin = n;
|
||
else if (op === 'lte' || op === 'lt') amountMax = n;
|
||
else if (op === 'equals' || op === 'contains') {
|
||
amountMin = n;
|
||
amountMax = n;
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
keyword,
|
||
bizType,
|
||
createTimeStart: start,
|
||
createTimeEnd: end,
|
||
amountMin,
|
||
amountMax,
|
||
};
|
||
}
|
||
|
||
/** 仓储 keyword 列表:SKU/包裹等 */
|
||
export function adaptKeywordHeaderFilters(
|
||
params: HeaderSearchParams,
|
||
keys: string[],
|
||
): string | undefined {
|
||
return pickHeaderText(params, keys) || undefined;
|
||
}
|
||
|
||
/** 轨迹列表:补齐 createTime 区间 */
|
||
export function adaptTrackOrderDateRange(
|
||
params: HeaderSearchParams,
|
||
fallback?: { createTimeStart?: string; createTimeEnd?: string },
|
||
): { createTimeStart?: string; createTimeEnd?: string } {
|
||
const { start, end } = pickDateRange(params, 'createTime');
|
||
return {
|
||
createTimeStart: start || fallback?.createTimeStart || undefined,
|
||
createTimeEnd: end || fallback?.createTimeEnd || undefined,
|
||
};
|
||
}
|
||
|
||
/** 给列定义打上服务端筛选标记 */
|
||
export function withServerFilterColumns<T extends { key: string }>(
|
||
columns: T[],
|
||
serverKeys: Iterable<string>,
|
||
extras?: Partial<T> | ((col: T) => Partial<T>),
|
||
): T[] {
|
||
const set = new Set(serverKeys);
|
||
return columns.map((col) => {
|
||
if (!set.has(col.key)) return col;
|
||
const extra = typeof extras === 'function' ? extras(col) : extras || {};
|
||
return { ...col, serverFilter: true, ...extra };
|
||
});
|
||
}
|
||
|
||
/** 可排序列标记(remote sort) */
|
||
export function withSortableColumns<T extends { key: string }>(
|
||
columns: T[],
|
||
sortableKeys: Iterable<string>,
|
||
): T[] {
|
||
const set = new Set(sortableKeys);
|
||
return columns.map((col) => (set.has(col.key) ? { ...col, sortable: true } : col));
|
||
}
|
||
|
||
export interface ReceivablePayableHeaderQuery {
|
||
keyword?: string;
|
||
feeType?: string;
|
||
costItem?: string;
|
||
chargingStage?: string;
|
||
customerCode?: string;
|
||
isPaid?: number;
|
||
}
|
||
|
||
const RECEIVABLE_PAYABLE_STAGE_LABEL_MAP: Record<string, string> = {
|
||
预报: 'forecast',
|
||
入库: 'inbound',
|
||
出库: 'outbound',
|
||
};
|
||
|
||
/** 应收应付表头 → 列表 API */
|
||
export function adaptReceivablePayableHeaderFilters(
|
||
params: HeaderSearchParams,
|
||
): ReceivablePayableHeaderQuery {
|
||
const keyword =
|
||
pickHeaderText(params, ['serviceNo', 'clientNo', 'trackNo']) || undefined;
|
||
|
||
let feeType = pickHeaderText(params, ['feeType', 'feeTypeName']) || undefined;
|
||
if (feeType === '应收') feeType = 'receivable';
|
||
if (feeType === '应付') feeType = 'payable';
|
||
|
||
let chargingStage =
|
||
pickHeaderText(params, ['chargingStage', 'chargingStageName']) || undefined;
|
||
if (chargingStage && RECEIVABLE_PAYABLE_STAGE_LABEL_MAP[chargingStage]) {
|
||
chargingStage = RECEIVABLE_PAYABLE_STAGE_LABEL_MAP[chargingStage];
|
||
}
|
||
|
||
const costItem = pickHeaderText(params, ['costItem']) || undefined;
|
||
const customerCode = pickHeaderText(params, ['customerCode']) || undefined;
|
||
const isPaid = parseReceivablePayableIsPaid(params);
|
||
|
||
return {
|
||
keyword,
|
||
feeType: feeType && feeType !== 'all' ? feeType : undefined,
|
||
costItem: costItem && costItem !== 'all' ? costItem : undefined,
|
||
chargingStage: chargingStage && chargingStage !== 'all' ? chargingStage : undefined,
|
||
customerCode,
|
||
isPaid,
|
||
};
|
||
}
|
||
|
||
function parseReceivablePayableIsPaid(params: HeaderSearchParams): number | undefined {
|
||
const raw = pickHeaderText(params, ['isPaid']);
|
||
if (!raw) return undefined;
|
||
if (raw === '1' || raw === '已支付') return 1;
|
||
if (raw === '0' || raw === '未支付') return 0;
|
||
const first = raw.split(',')[0]?.trim() ?? '';
|
||
if (first === '1' || first === '已支付') return 1;
|
||
if (first === '0' || first === '未支付') return 0;
|
||
const n = Number(first);
|
||
if (n === 0 || n === 1) return n;
|
||
return undefined;
|
||
}
|