497 lines
12 KiB
TypeScript
497 lines
12 KiB
TypeScript
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;
|
||
}
|