feat: GlassCard双视图、数据看板重构、地址粘贴识别、AI平台与素材库增强
This commit is contained in:
15
apps/web-antd/src/util/address-dict-api.ts
Normal file
15
apps/web-antd/src/util/address-dict-api.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 地址粘贴识别字典 API(xk-api)
|
||||
* 姓氏表来自后端 config/xk.php → address.common_surnames
|
||||
*/
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'address-dict/';
|
||||
|
||||
/**
|
||||
* 拉取姓氏表(复姓在前)
|
||||
* requestClient 已解包 result,直接得到 { surnames: string[] }
|
||||
*/
|
||||
export async function getAddressSurnames() {
|
||||
return requestClient.get<{ surnames: string[] }>(`${prefix}surnames`);
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import type {
|
||||
RegionPickerChild,
|
||||
RegionPickerSearchHit,
|
||||
} from '#/api/region-picker';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import {
|
||||
getRegionChildren,
|
||||
resolveRegionPair,
|
||||
searchRegions,
|
||||
type RegionPickerChild,
|
||||
type RegionPickerSearchHit,
|
||||
} from '#/api/region-picker';
|
||||
|
||||
export interface AddressNode {
|
||||
@@ -28,7 +31,7 @@ export interface ResolvedAddress {
|
||||
|
||||
export type AddressValue = [number, number];
|
||||
|
||||
export type RegionLevel = 'province' | 'city' | 'district';
|
||||
export type RegionLevel = 'city' | 'district' | 'province';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
level: RegionLevel;
|
||||
@@ -47,7 +50,7 @@ export interface AddressSearchHit {
|
||||
node: AddressNode;
|
||||
pathLabel: string;
|
||||
complete: boolean;
|
||||
path?: { id: number; name: string; level: number }[];
|
||||
path?: { id: number; level: number; name: string; }[];
|
||||
}
|
||||
|
||||
export interface AddressListRow {
|
||||
@@ -74,11 +77,11 @@ function levelFromNumber(level: number): RegionLevel {
|
||||
}
|
||||
|
||||
function toNode(row: {
|
||||
has_children?: boolean;
|
||||
id: number;
|
||||
level?: number;
|
||||
name: string;
|
||||
pid?: number;
|
||||
level?: number;
|
||||
has_children?: boolean;
|
||||
}): AddressNode {
|
||||
return {
|
||||
value: row.id,
|
||||
@@ -201,7 +204,9 @@ export function formatAddressDisplay(value?: AddressValue | null): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
export function formatBreadcrumbItems(resolved: ResolvedAddress): BreadcrumbItem[] {
|
||||
export function formatBreadcrumbItems(
|
||||
resolved: ResolvedAddress,
|
||||
): BreadcrumbItem[] {
|
||||
const items: BreadcrumbItem[] = [];
|
||||
if (resolved.province) {
|
||||
items.push({
|
||||
@@ -229,7 +234,7 @@ export function formatBreadcrumbItems(resolved: ResolvedAddress): BreadcrumbItem
|
||||
|
||||
export function buildBreadcrumbFromContext(
|
||||
ctx: SelectionContext,
|
||||
labels?: { province?: string; city?: string; district?: string },
|
||||
labels?: { city?: string; district?: string; province?: string; },
|
||||
): BreadcrumbItem[] {
|
||||
const items: BreadcrumbItem[] = [];
|
||||
if (ctx.provinceId) {
|
||||
@@ -415,3 +420,77 @@ export function getLevelSearchPlaceholder(level: RegionLevel): string {
|
||||
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;
|
||||
}
|
||||
|
||||
34
apps/web-antd/src/util/address-recognizer.d.ts
vendored
Normal file
34
apps/web-antd/src/util/address-recognizer.d.ts
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
/** address-recognizer.js 的类型声明,供 TS 引用 */
|
||||
export const ADDRESS_PASTE_EXAMPLES: string[];
|
||||
|
||||
export function confidenceMark(score: number): string;
|
||||
|
||||
export function setCommonSurnames(list: string | string[]): void;
|
||||
|
||||
export function ensureSurnamesLoaded(
|
||||
fetcher?: () => Promise<
|
||||
null | string[] | undefined | { surnames?: string[] }
|
||||
>,
|
||||
): Promise<void>;
|
||||
|
||||
export function recognizeAddress(text: string): {
|
||||
area: string;
|
||||
city: string;
|
||||
confidence: {
|
||||
detail: number;
|
||||
name: number;
|
||||
phone: number;
|
||||
region: number;
|
||||
};
|
||||
detail: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
province: string;
|
||||
};
|
||||
|
||||
export class AddressRecognizer {
|
||||
constructor(tree?: unknown);
|
||||
recognize(raw: string): ReturnType<typeof recognizeAddress>;
|
||||
}
|
||||
|
||||
export const addressRecognizer: AddressRecognizer;
|
||||
843
apps/web-antd/src/util/address-recognizer.js
Normal file
843
apps/web-antd/src/util/address-recognizer.js
Normal file
@@ -0,0 +1,843 @@
|
||||
/**
|
||||
* 收货地址粘贴识别引擎(本地纯文本解析)
|
||||
* 从乱序/多分隔符文本中提取:姓名、电话、省市区、详细地址,并给出 0~1 置信度
|
||||
* 省市区依赖 region-names.json 名称树打分匹配;识别失败字段留空,不抛错
|
||||
*/
|
||||
import regionTree from './region-names.json';
|
||||
|
||||
/** 示例文本:点击后填入并触发识别,覆盖乱序/关键词/直辖市/电商多行等场景 */
|
||||
export const ADDRESS_PASTE_EXAMPLES = [
|
||||
'张三 13800138000 浙江省杭州市西湖区文三路100号',
|
||||
'收货人:李四 电话:15912345678 北京市朝阳区建国路88号SOHO现代城A座1201',
|
||||
'王五,广东省深圳市南山区科技园南路,联系电话13812345678',
|
||||
'收件人赵六 上海市浦东新区张江高科技园区碧波路690号 021-50801000',
|
||||
'杭州西湖区文二西路1号 陈七 18600001111',
|
||||
'欧阳娜娜 138 0013 8000 浙江省杭州市西湖区文三路100号 310000',
|
||||
'收货人:司马懿\n手机号码:+86-159-1234-5678转1234\n所在地区:北京市朝阳区\n详细地址:建国路88号SOHO现代城A座1201',
|
||||
'收件人:诸葛孔明\n电话:18600001111\n地址:广东省深圳市南山区科技园南路1号',
|
||||
];
|
||||
|
||||
/**
|
||||
* 离线兜底姓氏(接口失败时仍可识别常见姓名)
|
||||
* 接口成功后由 setCommonSurnames 替换为后端 config 全量表
|
||||
*/
|
||||
const FALLBACK_SURNAMES_STR =
|
||||
'赵钱孙李周吴郑王冯陈褚卫蒋沈韩杨朱秦尤许何吕施张孔曹严华金魏陶姜戚谢邹喻柏水窦章云苏潘葛奚范彭郎鲁韦昌马苗凤花方俞任袁柳酆鲍史唐费廉岑薛雷贺倪汤滕殷罗毕郝邬安常乐于时傅皮卞齐康伍余元卜顾孟平黄和穆萧尹姚邵湛汪祁毛禹狄米贝明臧计伏成戴谈宋茅庞熊纪舒屈项祝董梁杜阮蓝闵席季麻强贾路娄危江童颜郭梅盛林刁钟徐邱骆高夏蔡田樊胡凌霍虞万支柯昝管卢莫经房裘缪干解应宗丁宣贲邓郁单杭洪包诸左石崔吉钮龚程嵇邢滑裴陆荣翁荀羊於惠甄曲家封芮羿储靳汲邴糜松井段富巫乌焦巴弓牧隗山谷车侯宓蓬全郗班仰秋仲伊宫宁仇栾暴甘钭厉戎祖武符刘景詹束龙叶幸司韶郜黎蓟薄印宿白怀蒲邰从鄂索咸籍赖卓蔺屠蒙池乔阴鬱胥能苍双闻莘党翟谭贡劳逄姬申扶堵冉宰郦雍郤璩桑桂濮牛寿通边扈燕冀郏浦尚农温别庄晏柴瞿阎充慕连茹习宦艾鱼容向古易慎戈廖庾终暨居衡步都耿满弘匡国文寇广禄阙东欧殳沃利蔚越夔隆师巩厍聂晁勾敖融冷訾辛阚那简饶空曾毋沙乜养鞠须丰巢关蒯相查后荆红游竺权逯盖益桓公万俟司马上官欧阳夏侯诸葛闻人东方赫连皇甫尉迟公羊澹台公冶宗政濮阳淳于单于太叔申屠公孙仲孙轩辕令狐钟离宇文长孙慕容鲜于闾丘司徒司空亓官司寇仉督子车颛孙端木巫马公西漆雕乐正壤驷公良拓跋夹谷宰父谷梁晋楚闫法汝鄢涂钦段干百里东郭南门呼延归海羊舌微生岳帅缑亢况後有琴梁丘左丘东门西门商牟佘佴伯赏南宫墨哈谯笪年爱阳佟';
|
||||
|
||||
/**
|
||||
* 将姓氏源整理为「字数降序」数组,供前缀匹配先命中复姓
|
||||
* @param {string|string[]} source
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function buildSurnameList(source) {
|
||||
const seen = {};
|
||||
const list = [];
|
||||
const pushOne = (s) => {
|
||||
const t = String(s || '').trim();
|
||||
if (!t || seen[t]) return;
|
||||
seen[t] = true;
|
||||
list.push(t);
|
||||
};
|
||||
if (Array.isArray(source)) {
|
||||
for (let i = 0; i < source.length; i++) pushOne(source[i]);
|
||||
} else {
|
||||
const str = String(source || '');
|
||||
// 复姓片段(2~4 字)优先从连续汉字串中按已知复姓切出较难,兜底串用单字+尾部复姓字面
|
||||
const compounds = [
|
||||
'万俟',
|
||||
'司马',
|
||||
'上官',
|
||||
'欧阳',
|
||||
'夏侯',
|
||||
'诸葛',
|
||||
'闻人',
|
||||
'东方',
|
||||
'赫连',
|
||||
'皇甫',
|
||||
'尉迟',
|
||||
'公羊',
|
||||
'澹台',
|
||||
'公冶',
|
||||
'宗政',
|
||||
'濮阳',
|
||||
'淳于',
|
||||
'单于',
|
||||
'太叔',
|
||||
'申屠',
|
||||
'公孙',
|
||||
'仲孙',
|
||||
'轩辕',
|
||||
'令狐',
|
||||
'钟离',
|
||||
'宇文',
|
||||
'长孙',
|
||||
'慕容',
|
||||
'鲜于',
|
||||
'闾丘',
|
||||
'司徒',
|
||||
'司空',
|
||||
'亓官',
|
||||
'司寇',
|
||||
'仉督',
|
||||
'子车',
|
||||
'颛孙',
|
||||
'端木',
|
||||
'巫马',
|
||||
'公西',
|
||||
'漆雕',
|
||||
'乐正',
|
||||
'壤驷',
|
||||
'公良',
|
||||
'拓跋',
|
||||
'夹谷',
|
||||
'宰父',
|
||||
'谷梁',
|
||||
'晋楚',
|
||||
'闫法',
|
||||
'汝鄢',
|
||||
'涂钦',
|
||||
'段干',
|
||||
'百里',
|
||||
'东郭',
|
||||
'南门',
|
||||
'呼延',
|
||||
'归海',
|
||||
'羊舌',
|
||||
'微生',
|
||||
'岳帅',
|
||||
'缑亢',
|
||||
'况后',
|
||||
'况後',
|
||||
'有琴',
|
||||
'梁丘',
|
||||
'左丘',
|
||||
'东门',
|
||||
'西门',
|
||||
'商牟',
|
||||
'佘佴',
|
||||
'伯赏',
|
||||
'南宫',
|
||||
'墨哈',
|
||||
'谯笪',
|
||||
'年爱',
|
||||
'阳佟',
|
||||
];
|
||||
for (let i = 0; i < compounds.length; i++) {
|
||||
if (str.indexOf(compounds[i]) >= 0) pushOne(compounds[i]);
|
||||
}
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const ch = str.charAt(i);
|
||||
if (/[\u4E00-\u9FA5]/.test(ch)) pushOne(ch);
|
||||
}
|
||||
}
|
||||
list.sort((a, b) => b.length - a.length || 0);
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 当前生效姓氏表(字数降序);可被后端接口覆盖 */
|
||||
let surnameList = buildSurnameList(FALLBACK_SURNAMES_STR);
|
||||
/** ensureSurnamesLoaded 的会话内 Promise 缓存 */
|
||||
let surnamesLoadPromise = null;
|
||||
let surnamesLoadedFromRemote = false;
|
||||
|
||||
/**
|
||||
* 注入姓氏表(后端 address-dict/surnames 返回的数组)
|
||||
* @param {string[]|string} list
|
||||
*/
|
||||
export function setCommonSurnames(list) {
|
||||
if (!list || (Array.isArray(list) && list.length === 0)) return;
|
||||
surnameList = buildSurnameList(list);
|
||||
surnamesLoadedFromRemote = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 首次识别前拉取姓氏;同会话只请求一次;失败则保留兜底表
|
||||
* @param {() => Promise<{ surnames?: string[] }|string[]|null|undefined>} fetcher
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function ensureSurnamesLoaded(fetcher) {
|
||||
if (surnamesLoadedFromRemote) return Promise.resolve();
|
||||
if (!fetcher || typeof fetcher !== 'function') return Promise.resolve();
|
||||
if (surnamesLoadPromise) return surnamesLoadPromise;
|
||||
surnamesLoadPromise = Promise.resolve()
|
||||
.then(() => fetcher())
|
||||
.then((res) => {
|
||||
const list = Array.isArray(res)
|
||||
? res
|
||||
: res && Array.isArray(res.surnames)
|
||||
? res.surnames
|
||||
: null;
|
||||
if (list && list.length > 0) setCommonSurnames(list);
|
||||
})
|
||||
.catch(() => {
|
||||
// 网络失败静默降级,继续用兜底姓氏
|
||||
})
|
||||
.then(() => {});
|
||||
return surnamesLoadPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 候选姓名是否以已知姓开头(复姓优先)
|
||||
* @param {string} name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasKnownSurname(name) {
|
||||
const n = String(name || '');
|
||||
for (let i = 0; i < surnameList.length; i++) {
|
||||
const s = surnameList[i];
|
||||
if (s && n.indexOf(s) === 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 排除的地址/机构关键词(含易被误判为姓名的地名碎片) */
|
||||
const NAME_BLOCK_WORDS = [
|
||||
'收货人',
|
||||
'收件人',
|
||||
'联系人',
|
||||
'详细地址',
|
||||
'地址',
|
||||
'电话',
|
||||
'手机',
|
||||
'公司',
|
||||
'学校',
|
||||
'小区',
|
||||
'大厦',
|
||||
'广场',
|
||||
'中心',
|
||||
'医院',
|
||||
'药店',
|
||||
'诊所',
|
||||
'街道',
|
||||
'科技',
|
||||
'高科',
|
||||
'园区',
|
||||
'步行',
|
||||
'解放',
|
||||
'只有',
|
||||
'姓名',
|
||||
'省',
|
||||
'市',
|
||||
'区',
|
||||
'县',
|
||||
'路',
|
||||
'号',
|
||||
'栋',
|
||||
'楼',
|
||||
'单元',
|
||||
'室',
|
||||
'村',
|
||||
'镇',
|
||||
'乡',
|
||||
'巷',
|
||||
'弄',
|
||||
];
|
||||
|
||||
const KEYWORD_STRIP = [
|
||||
'收货人',
|
||||
'收件人',
|
||||
'联系人',
|
||||
'收货',
|
||||
'收件',
|
||||
'联系电话',
|
||||
'联系方式',
|
||||
'手机号码',
|
||||
'手机号',
|
||||
'手机',
|
||||
'电话',
|
||||
'详细地址',
|
||||
'地址',
|
||||
'姓名',
|
||||
'收',
|
||||
];
|
||||
|
||||
/**
|
||||
* 置信度标记:≥0.8 ✓ / 0.5~0.8 ? / <0.5 ✗
|
||||
* @param {number} score
|
||||
* @returns {string}
|
||||
*/
|
||||
export function confidenceMark(score) {
|
||||
const s = Number(score) || 0;
|
||||
if (s >= 0.8) return '✓';
|
||||
if (s >= 0.5) return '?';
|
||||
return '✗';
|
||||
}
|
||||
|
||||
/**
|
||||
* 空识别结果
|
||||
* @returns {object}
|
||||
*/
|
||||
function emptyResult() {
|
||||
return {
|
||||
name: '',
|
||||
phone: '',
|
||||
province: '',
|
||||
city: '',
|
||||
area: '',
|
||||
detail: '',
|
||||
confidence: { name: 0, phone: 0, region: 0, detail: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成名称简称(去行政区后缀)
|
||||
* @param {string} name
|
||||
* @returns {string}
|
||||
*/
|
||||
function toShort(name) {
|
||||
return String(name || '')
|
||||
.replaceAll(/特别行政区|自治区|壮族|回族|维吾尔/g, '')
|
||||
.replaceAll(/(省|市|区|县|旗|州|盟)$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* AddressRecognizer:一次性解析粘贴文本
|
||||
*/
|
||||
export class AddressRecognizer {
|
||||
constructor(tree) {
|
||||
this.tree = Array.isArray(tree) && tree.length > 0 ? tree : regionTree;
|
||||
this._index = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 懒构建省市区索引,加速全称/简称查找
|
||||
* @returns {{ provinces: Array, byShort: Map }}
|
||||
*/
|
||||
_ensureIndex() {
|
||||
if (this._index) return this._index;
|
||||
const provinces = [];
|
||||
for (let i = 0; i < this.tree.length; i++) {
|
||||
const p = this.tree[i];
|
||||
const pShort = p.short || toShort(p.name);
|
||||
const cities = [];
|
||||
const children = p.children || [];
|
||||
// 直辖市:子节点直接是区县,虚拟一层「市辖区」式城市为空,区挂在 province 下
|
||||
const looksLikeDistrict =
|
||||
children.length > 0 &&
|
||||
children.every((c) => {
|
||||
const n = c.name || '';
|
||||
return (
|
||||
/[区县市旗]$/.test(n) && !(c.children && c.children.length > 0)
|
||||
);
|
||||
});
|
||||
if (looksLikeDistrict) {
|
||||
cities.push({
|
||||
name: p.name,
|
||||
short: pShort,
|
||||
isMunicipality: true,
|
||||
districts: children.map((d) => ({
|
||||
name: d.name,
|
||||
short: d.short || toShort(d.name),
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
for (let j = 0; j < children.length; j++) {
|
||||
const c = children[j];
|
||||
const districts = (c.children || []).map((d) => ({
|
||||
name: d.name,
|
||||
short: d.short || toShort(d.name),
|
||||
}));
|
||||
cities.push({
|
||||
name: c.name,
|
||||
short: c.short || toShort(c.name),
|
||||
isMunicipality: false,
|
||||
districts,
|
||||
});
|
||||
}
|
||||
}
|
||||
provinces.push({ name: p.name, short: pShort, cities });
|
||||
}
|
||||
this._index = { provinces };
|
||||
return this._index;
|
||||
}
|
||||
|
||||
/**
|
||||
* 区级:全称、简称、去区/县后再试
|
||||
*/
|
||||
_findDistrictHit(text, full, short) {
|
||||
const hit = this._findNameHit(text, full, short);
|
||||
if (hit) return hit;
|
||||
const stripped = String(full || '').replaceAll(/(区|县|市|旗)$/g, '');
|
||||
if (stripped && stripped.length >= 2 && text.indexOf(stripped) >= 0) {
|
||||
return { kind: 'stripped', len: stripped.length };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在文本中查找全称或简称命中
|
||||
* @returns {{ kind: string, len: number }|null}
|
||||
*/
|
||||
_findNameHit(text, full, short) {
|
||||
if (full && text.indexOf(full) >= 0) {
|
||||
return { kind: 'full', len: full.length };
|
||||
}
|
||||
if (short && short.length >= 2 && text.indexOf(short) >= 0) {
|
||||
return { kind: 'short', len: short.length };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_nameAfterPhone(text, phoneInfo) {
|
||||
if (!phoneInfo || !phoneInfo.value) return null;
|
||||
const raw = phoneInfo.raw || phoneInfo.value;
|
||||
const idx = text.indexOf(raw);
|
||||
if (idx === -1) return null;
|
||||
const after = text.slice(idx + raw.length, idx + raw.length + 16);
|
||||
const m = after.match(/^\s*[,,、]?\s*([\u4E00-\u9FA5]{2,6})/);
|
||||
if (!m) return null;
|
||||
const chunk = m[1];
|
||||
for (let len = 2; len <= chunk.length; len++) {
|
||||
const cand = chunk.slice(0, len);
|
||||
if (this.isValidName(cand)) {
|
||||
return { value: cand, confidence: len <= 3 ? 0.8 : 0.7 };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从手机号前取名:短到长尝试,避免「张三杭州」整段误判;支持复姓最长 6 字
|
||||
*/
|
||||
_nameBeforePhone(text, phoneInfo) {
|
||||
if (!phoneInfo || !phoneInfo.value) return null;
|
||||
const idx = text.indexOf(phoneInfo.raw || phoneInfo.value);
|
||||
if (idx <= 0) return null;
|
||||
const before = text.slice(Math.max(0, idx - 16), idx);
|
||||
const m = before.match(/([\u4E00-\u9FA5]{2,6})\s*$/);
|
||||
if (!m) return null;
|
||||
const chunk = m[1];
|
||||
for (let len = 2; len <= chunk.length; len++) {
|
||||
const cand = chunk.slice(chunk.length - len);
|
||||
if (this.isValidName(cand)) {
|
||||
return { value: cand, confidence: len <= 3 ? 0.85 : 0.75 };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_nameBeforePhoneKeyword(text) {
|
||||
const re =
|
||||
/([\u4E00-\u9FA5]{2,6})\s*(?:电话|手机|联系电话|联系方式|手机号)/;
|
||||
const m = text.match(re);
|
||||
if (m) return { value: m[1], confidence: 0.78 };
|
||||
return null;
|
||||
}
|
||||
|
||||
_nameByKeyword(text) {
|
||||
// 支持「收件人:张三」「收件人张三」「收 李四」等;长度放宽到 6 以兼容复姓+名
|
||||
const re =
|
||||
/(?:收货人|联系人|收件人|姓名)\s*[::\s]?\s*([\u4E00-\u9FA5]{2,6})|(?:^|[,,、\s])收\s*[::\s]?\s*([\u4E00-\u9FA5]{2,6})/;
|
||||
const m = text.match(re);
|
||||
if (m) {
|
||||
const val = m[1] || m[2];
|
||||
if (val) return { value: val, confidence: 0.92 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_nameFallback(text, phoneInfo) {
|
||||
let rest = text;
|
||||
if (phoneInfo && phoneInfo.raw) {
|
||||
rest = rest.split(phoneInfo.raw).join(' ');
|
||||
}
|
||||
if (phoneInfo && phoneInfo.value) {
|
||||
rest = rest.split(phoneInfo.value).join(' ');
|
||||
}
|
||||
rest = rest.replaceAll(/[0-9+\-()\s]/g, ' ');
|
||||
const parts = rest.match(/[\u4E00-\u9FA5]{2,6}/g) || [];
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (this.isValidName(parts[i])) {
|
||||
return { value: parts[i], confidence: 0.55 };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 权重:省 3、市 2、区 2;全称 +0.5,简称按长度
|
||||
*/
|
||||
_regionScore(pHit, cHit, dHit) {
|
||||
let score = 0;
|
||||
if (pHit) {
|
||||
score += 3;
|
||||
if (pHit.kind === 'full') score += 0.5;
|
||||
}
|
||||
if (cHit && cHit.kind !== 'muni') {
|
||||
score += 2;
|
||||
if (cHit.kind === 'full') score += 0.4;
|
||||
}
|
||||
if (cHit && cHit.kind === 'muni') {
|
||||
score += 0.5;
|
||||
}
|
||||
if (dHit) {
|
||||
score += 2;
|
||||
if (dHit.kind === 'full') score += 0.4;
|
||||
else if (dHit.kind === 'stripped') score += 0.1;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* 详细地址:剥离已识别字段、邮编、虚拟号分机与关键词后清理
|
||||
*/
|
||||
extractDetail(text, parts) {
|
||||
let rest = String(text || '');
|
||||
const removeList = [
|
||||
parts.phoneRaw || parts.phone,
|
||||
parts.phone,
|
||||
parts.name,
|
||||
parts.province,
|
||||
parts.city,
|
||||
parts.area,
|
||||
].filter(Boolean);
|
||||
// 也去掉简称形式
|
||||
if (parts.province) removeList.push(toShort(parts.province));
|
||||
if (parts.city) removeList.push(toShort(parts.city));
|
||||
if (parts.area) {
|
||||
removeList.push(
|
||||
toShort(parts.area),
|
||||
String(parts.area).replace(/(区|县)$/g, ''),
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < removeList.length; i++) {
|
||||
const piece = removeList[i];
|
||||
if (piece) rest = rest.split(piece).join(' ');
|
||||
}
|
||||
for (let i = 0; i < KEYWORD_STRIP.length; i++) {
|
||||
rest = rest.split(KEYWORD_STRIP[i]).join(' ');
|
||||
}
|
||||
// 邮编:独立 6 位数字(前后非数字)
|
||||
rest = rest.replaceAll(/(^|[^\d])\d{6}(?=[^\d]|$)/g, '$1');
|
||||
// 虚拟号/转分机残留
|
||||
if (parts.ext) {
|
||||
rest = rest.split(`转${parts.ext}`).join(' ');
|
||||
rest = rest.split(String(parts.ext)).join(' ');
|
||||
}
|
||||
rest = rest.replaceAll(/转\s*\d{3,6}/g, ' ');
|
||||
rest = rest
|
||||
.replaceAll(/[::,,、;;。.!!??\-_/\\|]+/g, ' ')
|
||||
.replaceAll(/\s+/g, ' ')
|
||||
.trim();
|
||||
// 智能截断:去掉开头残留的短地名/无意义碎片
|
||||
rest = rest.replace(/^[\u4E00-\u9FA5]{1}\s+/, '');
|
||||
rest = rest.replaceAll(/^(只有|以及|还有|和)\s*/g, '');
|
||||
if (!rest || rest.length < 2) {
|
||||
return { value: '', confidence: 0 };
|
||||
}
|
||||
// 纯语气词不算详细地址
|
||||
if (/^(只有|姓名|地址)$/.test(rest)) {
|
||||
return { value: '', confidence: 0 };
|
||||
}
|
||||
// 含路/街/号等特征则置信度更高
|
||||
let conf = 0.6;
|
||||
if (/[路街巷道号弄苑园村镇乡楼栋单元室]/.test(rest)) conf = 0.85;
|
||||
else if (rest.length >= 6) conf = 0.7;
|
||||
return { value: rest, confidence: conf };
|
||||
}
|
||||
|
||||
/**
|
||||
* 姓名 5 策略按优先级
|
||||
* @param {string} text
|
||||
* @param {{ value: string }} phoneInfo
|
||||
*/
|
||||
extractName(text, phoneInfo) {
|
||||
const strategies = [
|
||||
() => this._nameByKeyword(text),
|
||||
() => this._nameBeforePhone(text, phoneInfo),
|
||||
() => this._nameAfterPhone(text, phoneInfo),
|
||||
() => this._nameBeforePhoneKeyword(text),
|
||||
() => this._nameFallback(text, phoneInfo),
|
||||
];
|
||||
for (let i = 0; i < strategies.length; i++) {
|
||||
const hit = strategies[i]();
|
||||
if (hit && hit.value && this.isValidName(hit.value)) {
|
||||
return {
|
||||
value: hit.value,
|
||||
confidence: hit.confidence || Math.max(0.55, 0.95 - i * 0.1),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { value: '', confidence: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 按优先级提取电话:手机(含空格/横线/+86)> 座机 > 400/800
|
||||
* value 输出连续 11 位;raw 保留原文片段便于从 detail 剔除;ext 为转分机
|
||||
* @param {string} text
|
||||
* @returns {{ value: string, confidence: number, raw: string, ext: string }}
|
||||
*/
|
||||
extractPhone(text) {
|
||||
const src = String(text || '');
|
||||
// 宽松匹配带空格/横线的手机,再规范化
|
||||
const looseMobile = src.match(
|
||||
/(?:\+?86[-\s]?)?(1[3-9](?:[-\s]?\d){9})(?:\s*转\s*\d{3,6})?/,
|
||||
);
|
||||
if (looseMobile) {
|
||||
const raw = looseMobile[0];
|
||||
const digits = raw.replaceAll(/\D/g, '');
|
||||
let mobile = digits;
|
||||
if (mobile.indexOf('86') === 0 && mobile.length >= 13) {
|
||||
mobile = mobile.slice(2);
|
||||
}
|
||||
mobile = mobile.slice(0, 11);
|
||||
const extMatch = raw.match(/转\s*(\d{3,6})/);
|
||||
const ext = extMatch ? extMatch[1] : '';
|
||||
if (/^1[3-9]\d{9}$/.test(mobile)) {
|
||||
return { value: mobile, confidence: 0.95, raw, ext };
|
||||
}
|
||||
}
|
||||
const compact = src.replaceAll(/[\s\-()()]/g, '').replace(/^\+?86/, '');
|
||||
const compactMobile = compact.match(/1[3-9]\d{9}/);
|
||||
if (compactMobile) {
|
||||
const mobile = compactMobile[0];
|
||||
const idx = compact.indexOf(mobile);
|
||||
const after = compact.slice(idx + 11, idx + 20);
|
||||
const extMatch = after.match(/^转(\d{3,6})/);
|
||||
return {
|
||||
value: mobile,
|
||||
confidence: 0.92,
|
||||
raw: mobile,
|
||||
ext: extMatch ? extMatch[1] : '',
|
||||
};
|
||||
}
|
||||
const landline = src.match(/0\d{2,3}[-\s]?\d{7,8}/);
|
||||
if (landline) {
|
||||
return {
|
||||
value: landline[0].replaceAll(/\s+/g, '-'),
|
||||
confidence: 0.85,
|
||||
raw: landline[0],
|
||||
ext: '',
|
||||
};
|
||||
}
|
||||
const service = src.match(/[48]00[-\s]?\d{3,4}[-\s]?\d{3,4}/);
|
||||
if (service) {
|
||||
return {
|
||||
value: service[0].replaceAll(/\s+/g, '-'),
|
||||
confidence: 0.75,
|
||||
raw: service[0],
|
||||
ext: '',
|
||||
};
|
||||
}
|
||||
return { value: '', confidence: 0, raw: '', ext: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 省市区三级联动名称树打分匹配,取总分最高
|
||||
* @param {string} text
|
||||
* @returns {{ province: string, city: string, area: string, confidence: number }}
|
||||
*/
|
||||
extractRegion(text) {
|
||||
const { provinces } = this._ensureIndex();
|
||||
let best = { score: 0, province: '', city: '', area: '', hits: 0 };
|
||||
const compact = text.replaceAll(/\s+/g, '');
|
||||
for (let i = 0; i < provinces.length; i++) {
|
||||
const p = provinces[i];
|
||||
const pHit = this._findNameHit(compact, p.name, p.short);
|
||||
for (let j = 0; j < p.cities.length; j++) {
|
||||
const c = p.cities[j];
|
||||
const cHit = this._findNameHit(
|
||||
compact,
|
||||
c.isMunicipality ? '' : c.name,
|
||||
c.isMunicipality ? '' : c.short,
|
||||
);
|
||||
const districts = c.districts || [];
|
||||
if (districts.length === 0) {
|
||||
const score = this._regionScore(pHit, cHit, null);
|
||||
if (score > best.score) {
|
||||
best = {
|
||||
score,
|
||||
province: pHit ? p.name : '',
|
||||
city: cHit && !c.isMunicipality ? c.name : '',
|
||||
area: '',
|
||||
hits: (pHit ? 1 : 0) + (cHit ? 1 : 0),
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (let k = 0; k < districts.length; k++) {
|
||||
const d = districts[k];
|
||||
const dHit = this._findDistrictHit(compact, d.name, d.short);
|
||||
// 允许省略任一级:至少命中区,或省+市
|
||||
if (!pHit && !cHit && !dHit) continue;
|
||||
if (!pHit && !dHit && cHit) {
|
||||
// 仅市也可能,但需有一定把握
|
||||
}
|
||||
const score = this._regionScore(
|
||||
pHit,
|
||||
cHit ||
|
||||
(c.isMunicipality && dHit ? { kind: 'muni', len: 0 } : null),
|
||||
dHit,
|
||||
);
|
||||
if (score > best.score) {
|
||||
const hitAny = !!(pHit || cHit || dHit);
|
||||
best = {
|
||||
score,
|
||||
province: hitAny ? p.name : '',
|
||||
city: c.isMunicipality
|
||||
? ''
|
||||
: cHit
|
||||
? c.name
|
||||
: dHit && pHit
|
||||
? c.name
|
||||
: '',
|
||||
area: dHit ? d.name : '',
|
||||
hits:
|
||||
(pHit || hitAny ? 1 : 0) +
|
||||
(cHit || (c.isMunicipality && dHit) ? 1 : 0) +
|
||||
(dHit ? 1 : 0),
|
||||
};
|
||||
// 直辖市:命中区则省必填、市留空
|
||||
if (c.isMunicipality && dHit) {
|
||||
best.province = p.name;
|
||||
best.city = '';
|
||||
best.area = d.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 仅省+市(无区)
|
||||
if (pHit || cHit) {
|
||||
const score = this._regionScore(pHit, cHit, null);
|
||||
if (score > best.score && (pHit || cHit)) {
|
||||
best = {
|
||||
score,
|
||||
province: pHit ? p.name : p.name,
|
||||
city: c.isMunicipality ? '' : cHit ? c.name : '',
|
||||
area: '',
|
||||
hits: (pHit ? 1 : 0) + (cHit ? 1 : 0),
|
||||
};
|
||||
if (!pHit && cHit && !c.isMunicipality) {
|
||||
best.province = p.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 仅省
|
||||
if (pHit) {
|
||||
const score = this._regionScore(pHit, null, null);
|
||||
if (score > best.score) {
|
||||
best = {
|
||||
score,
|
||||
province: p.name,
|
||||
city: '',
|
||||
area: '',
|
||||
hits: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best.score <= 0) {
|
||||
return { province: '', city: '', area: '', confidence: 0 };
|
||||
}
|
||||
// 归一化置信度:满分约 3+2+2=7,全称加权更高
|
||||
let conf = Math.min(0.98, best.score / 7);
|
||||
if (best.hits >= 3) conf = Math.max(conf, 0.9);
|
||||
else if (best.hits === 2) conf = Math.max(conf, 0.75);
|
||||
else conf = Math.max(conf, 0.55);
|
||||
return {
|
||||
province: best.province,
|
||||
city: best.city,
|
||||
area: best.area,
|
||||
confidence: conf,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 姓名有效性:2~6 字、已知姓(复姓优先前缀匹配)、无地址特征
|
||||
* @param {string} name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isValidName(name) {
|
||||
const n = String(name || '').trim();
|
||||
if (n.length < 2 || n.length > 6) return false;
|
||||
if (!/^[\u4E00-\u9FA5]+$/.test(n)) return false;
|
||||
if (!hasKnownSurname(n)) return false;
|
||||
for (let i = 0; i < NAME_BLOCK_WORDS.length; i++) {
|
||||
if (n.indexOf(NAME_BLOCK_WORDS[i]) >= 0) return false;
|
||||
}
|
||||
// 含行政区/路号特征则不是姓名(扩展姓表后「杭州」等更易误判)
|
||||
if (/[路街号栋楼单元室省市县区州盟旗]/.test(n)) return false;
|
||||
if (/\d/.test(n)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 电商多行模板:把「收货人:xxx」等标签行展平成可识别的连续文本
|
||||
* 无换行/无标签时原样返回,避免破坏乱序单行粘贴
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
normalizeEcommerceText(text) {
|
||||
const src = String(text || '');
|
||||
if (src.indexOf('\n') < 0 && src.indexOf('\r') < 0) return src;
|
||||
const labelRe =
|
||||
/^(?:收货人|收件人|联系人|姓名|手机号码|手机号|手机|联系电话|联系方式|电话|所在地区|详细地址|地址)\s*[::]\s*(.+)$/;
|
||||
const lines = src.split(/\r?\n/);
|
||||
const parts = [];
|
||||
let hitLabel = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = String(lines[i] || '').trim();
|
||||
if (!line) continue;
|
||||
const m = line.match(labelRe);
|
||||
if (m && m[1]) {
|
||||
hitLabel = true;
|
||||
parts.push(m[1].trim());
|
||||
} else {
|
||||
parts.push(line);
|
||||
}
|
||||
}
|
||||
return hitLabel ? parts.join(' ') : src.replaceAll(/\r?\n/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 主入口:识别整段文本
|
||||
* 先展开电商多行标签,再按 电话→省市区→姓名→详情 顺序抽取
|
||||
* @param {string} raw
|
||||
* @returns {object}
|
||||
*/
|
||||
recognize(raw) {
|
||||
const text = this.normalizeEcommerceText(String(raw || '').trim());
|
||||
if (!text) return emptyResult();
|
||||
try {
|
||||
const phoneInfo = this.extractPhone(text);
|
||||
const regionInfo = this.extractRegion(text);
|
||||
const nameInfo = this.extractName(text, phoneInfo);
|
||||
const detailInfo = this.extractDetail(text, {
|
||||
phone: phoneInfo.value,
|
||||
phoneRaw: phoneInfo.raw,
|
||||
ext: phoneInfo.ext || '',
|
||||
province: regionInfo.province,
|
||||
city: regionInfo.city,
|
||||
area: regionInfo.area,
|
||||
name: nameInfo.value,
|
||||
});
|
||||
return {
|
||||
name: nameInfo.value || '',
|
||||
phone: phoneInfo.value || '',
|
||||
province: regionInfo.province || '',
|
||||
city: regionInfo.city || '',
|
||||
area: regionInfo.area || '',
|
||||
detail: detailInfo.value || '',
|
||||
confidence: {
|
||||
name: nameInfo.confidence || 0,
|
||||
phone: phoneInfo.confidence || 0,
|
||||
region: regionInfo.confidence || 0,
|
||||
detail: detailInfo.confidence || 0,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return emptyResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 默认单例,便于页面直接调用 */
|
||||
export const addressRecognizer = new AddressRecognizer(regionTree);
|
||||
|
||||
/**
|
||||
* 便捷方法
|
||||
* @param {string} text
|
||||
*/
|
||||
export function recognizeAddress(text) {
|
||||
return addressRecognizer.recognize(text);
|
||||
}
|
||||
5587
apps/web-antd/src/util/region-names.json
Normal file
5587
apps/web-antd/src/util/region-names.json
Normal file
File diff suppressed because it is too large
Load Diff
64
apps/web-antd/src/util/ship-address-copy.ts
Normal file
64
apps/web-antd/src/util/ship-address-copy.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 收货地址一键复制文案:{名字} {手机号} {省市区}{详细地址}
|
||||
*/
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
export type ShipAddressLike = {
|
||||
area?: string;
|
||||
city?: string;
|
||||
detail?: string;
|
||||
detail_address?: string;
|
||||
express_address?: string;
|
||||
express_mobile?: string;
|
||||
express_name?: string;
|
||||
express_region?: string;
|
||||
mobile?: string;
|
||||
name?: string;
|
||||
phone?: string;
|
||||
province?: string;
|
||||
region?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 拼装复制文本;订单场景兼容 address + express_* 双轨
|
||||
*/
|
||||
export function formatShipAddressCopyText(
|
||||
addr?: null | ShipAddressLike,
|
||||
fallback?: null | ShipAddressLike,
|
||||
): string {
|
||||
const a = addr || {};
|
||||
const f = fallback || {};
|
||||
const name = String(a.name || f.express_name || f.name || '').trim();
|
||||
const mobile = String(
|
||||
a.mobile || a.phone || f.express_mobile || f.mobile || '',
|
||||
).trim();
|
||||
let region = String(a.region || '').trim();
|
||||
if (!region) {
|
||||
region = [a.province, a.city, a.area].filter(Boolean).join('');
|
||||
}
|
||||
if (!region) {
|
||||
region = String(f.express_region || f.region || '').trim();
|
||||
}
|
||||
const detail = String(
|
||||
a.detail_address || a.detail || f.express_address || f.detail_address || '',
|
||||
).trim();
|
||||
const place = region + detail;
|
||||
return [name, mobile, place].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
/** 复制到剪贴板并 toast */
|
||||
export async function copyShipAddressText(text: string): Promise<boolean> {
|
||||
const raw = String(text || '').trim();
|
||||
if (!raw) {
|
||||
message.warning('暂无地址可复制');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(raw);
|
||||
message.success('已复制');
|
||||
return true;
|
||||
} catch {
|
||||
message.error('复制失败');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user