Files
xk-admin/apps/web-antd/src/util/address-index.ts

497 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
RegionPickerChild,
RegionPickerSearchHit,
} from '#/api/region-picker';
import { ref } from 'vue';
import {
getRegionChildren,
resolveRegionPair,
searchRegions,
} from '#/api/region-picker';
export interface AddressNode {
value: number;
label: string;
pvalue?: number;
hasChildren?: boolean;
level?: number;
}
export interface ResolvedAddress {
provinceId?: number;
cityId?: number;
districtId?: number;
province?: AddressNode;
city?: AddressNode;
district?: AddressNode;
pathLabel?: string;
}
export type AddressValue = [number, number];
export type RegionLevel = 'city' | 'district' | 'province';
export interface BreadcrumbItem {
level: RegionLevel;
id: number;
label: string;
}
export interface SelectionContext {
provinceId?: number;
cityId?: number;
districtId?: number;
}
export interface AddressSearchHit {
level: RegionLevel;
node: AddressNode;
pathLabel: string;
complete: boolean;
path?: { id: number; level: number; name: string; }[];
}
export interface AddressListRow {
level: RegionLevel;
node: AddressNode;
label: string;
complete: boolean;
}
/** 回显文案缓存变更计数,供 computed 依赖 */
export const addressDisplayTick = ref(0);
const displayCache = new Map<string, string>();
const pairCache = new Map<string, ResolvedAddress>();
function cacheKey(provinceId: number, leafId: number) {
return `${provinceId}_${leafId}`;
}
function levelFromNumber(level: number): RegionLevel {
if (level <= 1) return 'province';
if (level === 2) return 'city';
return 'district';
}
function toNode(row: {
has_children?: boolean;
id: number;
level?: number;
name: string;
pid?: number;
}): AddressNode {
return {
value: row.id,
label: row.name,
pvalue: row.pid,
level: row.level,
hasChildren: !!row.has_children,
};
}
/** 组装对外 address 值 [provinceId, leafId] */
export function buildAddressOutput(
provinceId?: number,
cityId?: number,
districtId?: number,
): AddressValue | undefined {
if (!provinceId) {
return undefined;
}
const leafId = districtId ?? cityId;
if (!leafId) {
return undefined;
}
return [provinceId, leafId];
}
/**
* 同步解析:只读本地缓存(由 ensureResolvedAddress 填充)
* 为什么:表格 formatter 是同步的,不能直接 await
*/
export function resolveAddressValue(
value?: AddressValue | null,
): ResolvedAddress {
if (!value || value.length < 2) {
return {};
}
const [provinceId, leafId] = value;
return (
pairCache.get(cacheKey(provinceId, leafId)) ?? {
provinceId,
cityId: leafId,
}
);
}
/**
* 异步解析并写入缓存(含软删节点,后端 resolve 不过滤 deleted_at)
*/
export async function ensureResolvedAddress(
value?: AddressValue | null,
): Promise<ResolvedAddress> {
if (!value || value.length < 2) {
return {};
}
const [provinceId, leafId] = value;
const key = cacheKey(provinceId, leafId);
if (pairCache.has(key)) {
return pairCache.get(key)!;
}
try {
const res = await resolveRegionPair(provinceId, leafId);
const resolved: ResolvedAddress = {
provinceId,
cityId: res.city?.id ?? (res.district ? undefined : leafId),
districtId: res.district?.id,
province: res.province
? toNode(res.province as RegionPickerChild)
: undefined,
city: res.city
? {
value: res.city.id,
label: res.city.name,
pvalue: res.city.pid,
level: res.city.level,
}
: undefined,
district: res.district
? {
value: res.district.id,
label: res.district.name,
pvalue: res.district.pid,
level: res.district.level,
}
: undefined,
pathLabel: res.path_label || '',
};
// 两段存法下 city_id 就是叶子:若只有 district,把 leaf 记在 districtId
if (res.district && !res.city) {
resolved.districtId = leafId;
} else if (res.district) {
resolved.cityId = res.city?.id;
resolved.districtId = res.district.id;
} else {
resolved.cityId = leafId;
}
pairCache.set(key, resolved);
if (resolved.pathLabel) {
displayCache.set(key, resolved.pathLabel);
}
addressDisplayTick.value += 1;
return resolved;
} catch {
return { provinceId, cityId: leafId };
}
}
/** 同步展示:优先缓存文案;未命中时触发异步加载并先返回空串 */
export function formatAddressDisplay(value?: AddressValue | null): string {
// 依赖 tick,缓存更新后 computed 会重算
void addressDisplayTick.value;
if (!value || value.length < 2) {
return '';
}
const [provinceId, leafId] = value;
const key = cacheKey(provinceId, leafId);
if (displayCache.has(key)) {
return displayCache.get(key)!;
}
void ensureResolvedAddress(value);
return '';
}
export function formatBreadcrumbItems(
resolved: ResolvedAddress,
): BreadcrumbItem[] {
const items: BreadcrumbItem[] = [];
if (resolved.province) {
items.push({
level: 'province',
id: resolved.province.value,
label: resolved.province.label,
});
}
if (resolved.city) {
items.push({
level: 'city',
id: resolved.city.value,
label: resolved.city.label,
});
}
if (resolved.district) {
items.push({
level: 'district',
id: resolved.district.value,
label: resolved.district.label,
});
}
return items;
}
export function buildBreadcrumbFromContext(
ctx: SelectionContext,
labels?: { city?: string; district?: string; province?: string; },
): BreadcrumbItem[] {
const items: BreadcrumbItem[] = [];
if (ctx.provinceId) {
items.push({
level: 'province',
id: ctx.provinceId,
label: labels?.province || String(ctx.provinceId),
});
}
if (ctx.cityId) {
items.push({
level: 'city',
id: ctx.cityId,
label: labels?.city || String(ctx.cityId),
});
}
if (ctx.districtId) {
items.push({
level: 'district',
id: ctx.districtId,
label: labels?.district || String(ctx.districtId),
});
}
return items;
}
/** 拉取某一层选项 */
export async function fetchLevelOptions(
level: RegionLevel,
ctx: SelectionContext,
): Promise<AddressNode[]> {
let pid = 0;
if (level === 'city') {
pid = ctx.provinceId ?? 0;
} else if (level === 'district') {
pid = ctx.cityId ?? 0;
}
const rows = await getRegionChildren(pid);
return rows.map((r) => toNode(r));
}
export function levelOptionsToRows(
level: RegionLevel,
options: AddressNode[],
): AddressListRow[] {
return options.map((node) => ({
level,
node,
label: node.label,
// 无子节点则可完成;有子节点继续下钻
complete: !node.hasChildren,
}));
}
export async function searchAddressHits(
keyword: string,
): Promise<AddressSearchHit[]> {
const kw = keyword.trim();
if (!kw) {
return [];
}
const hits = await searchRegions(kw, 30);
return hits.map((h: RegionPickerSearchHit) => ({
level: levelFromNumber(h.level),
node: toNode(h),
pathLabel: h.path_label || h.name,
complete: !!h.complete,
path: h.path,
}));
}
export function searchHitsToRows(hits: AddressSearchHit[]): AddressListRow[] {
return hits.map((hit) => ({
level: hit.level,
node: hit.node,
label: hit.pathLabel,
complete: hit.complete,
}));
}
export function applySearchHit(
hit: AddressSearchHit,
_ctx: SelectionContext,
): SelectionContext {
const next: SelectionContext = {};
const path = hit.path ?? [];
for (const node of path) {
if (node.level === 1) {
next.provinceId = node.id;
} else if (node.level === 2) {
next.cityId = node.id;
} else if (node.level === 3) {
next.districtId = node.id;
}
}
// 兜底:path 不完整时用命中节点本身
if (hit.level === 'province') {
next.provinceId = hit.node.value;
} else if (hit.level === 'city') {
next.cityId = hit.node.value;
if (!next.provinceId) {
next.provinceId = hit.node.pvalue;
}
} else {
next.districtId = hit.node.value;
if (!next.cityId) {
next.cityId = hit.node.pvalue;
}
}
return next;
}
export function getNextLevel(
level: RegionLevel,
_ctx: SelectionContext,
hasChildren?: boolean,
): RegionLevel | undefined {
if (hasChildren === false) {
return undefined;
}
if (level === 'province') {
return 'city';
}
if (level === 'city') {
return 'district';
}
return undefined;
}
export function getInitialPickerLevel(resolved: ResolvedAddress): RegionLevel {
if (resolved.districtId) {
return 'district';
}
if (resolved.cityId) {
return 'city';
}
return 'province';
}
export function canCompleteAfterSelect(
level: RegionLevel,
_ctx: SelectionContext,
item: AddressNode,
): boolean {
if (level === 'district') {
return true;
}
return !item.hasChildren;
}
export function rowHasNextLevel(row: AddressListRow): boolean {
return !row.complete && !!row.node.hasChildren;
}
export function truncateContextToBreadcrumb(
ctx: SelectionContext,
item: BreadcrumbItem,
): SelectionContext {
if (item.level === 'province') {
return { provinceId: item.id };
}
if (item.level === 'city') {
return { provinceId: ctx.provinceId, cityId: item.id };
}
return {
provinceId: ctx.provinceId,
cityId: ctx.cityId,
districtId: item.id,
};
}
export function getLevelSearchPlaceholder(level: RegionLevel): string {
if (level === 'province') {
return '搜索省(地名/首拼/全拼)';
}
if (level === 'city') {
return '搜索市(地名/首拼/全拼)';
}
return '搜索区县(地名/首拼/全拼)';
}
/** 兼容旧调用:直辖市判断改为「是否直接有区」由 hasChildren 驱动,此函数保留空实现 */
export function isMunicipalityProvince(_provinceId?: number): boolean {
return false;
}
/**
* 从中文省市区解析为选择器值 [provinceId, leafId]
* 为什么:region-picker/search 是对单条 name LIKE,整串「浙江省杭州市西湖区」搜不到;
* 必须用区/市/省逐级短词搜索,再按 path_label 与识别结果对齐打分取最优
*/
export async function resolveAddressPairFromNames(
province?: string,
city?: string,
area?: string,
): Promise<AddressValue | undefined> {
const p = String(province || '').trim();
const c = String(city || '').trim();
const a = String(area || '').trim();
if (!p && !c && !a) {
return undefined;
}
// 优先搜索最细一级,命中率最高
const keywords = [a, c, p].filter(Boolean);
let bestHit: AddressSearchHit | null = null;
let bestScore = -1;
for (const kw of keywords) {
const hits = await searchAddressHits(kw);
for (const hit of hits) {
const score = scoreRegionHit(hit, p, c, a);
if (score > bestScore) {
bestScore = score;
bestHit = hit;
}
}
// 已有高分完整命中可提前结束
if (bestHit && bestScore >= 6 && bestHit.complete) {
break;
}
}
if (!bestHit || bestScore <= 0) {
return undefined;
}
const ctx = applySearchHit(bestHit, {});
return buildAddressOutput(ctx.provinceId, ctx.cityId, ctx.districtId);
}
/** 命中项与识别省市区的对齐分:path 含省/市/区、complete、叶子名精确匹配加分 */
function scoreRegionHit(
hit: AddressSearchHit,
province: string,
city: string,
area: string,
): number {
const label = `${hit.pathLabel || ''} ${hit.node?.label || ''}`;
let score = 0;
if (area) {
if (hit.node?.label === area || label.includes(area)) score += 4;
else if (label.includes(area.replace(/(区|县|市|旗)$/, ''))) score += 2;
}
if (city) {
if (label.includes(city)) score += 3;
else if (label.includes(city.replace(/市$/, ''))) score += 2;
}
if (province) {
if (label.includes(province)) score += 3;
else if (
label.includes(
province
.replaceAll(/特别行政区|自治区|壮族|回族|维吾尔/g, '')
.replace(/(省|市)$/, ''),
)
) {
score += 2;
}
}
if (hit.complete) score += 1;
return score;
}