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(); const pairCache = new Map(); 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 { 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 { 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 { 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 { 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; }