1. 管理员管理模块拆分
2. 图片上传自动压缩(大于850kb) 3. 省市区选择组件重构,支持搜索
This commit is contained in:
664
apps/web-antd/src/util/address-index.ts
Normal file
664
apps/web-antd/src/util/address-index.ts
Normal file
@@ -0,0 +1,664 @@
|
||||
import { addressOption } from '#/util/address';
|
||||
|
||||
export interface AddressNode {
|
||||
value: number;
|
||||
label: string;
|
||||
pvalue?: number;
|
||||
}
|
||||
|
||||
export interface ProvinceIndex {
|
||||
province: AddressNode;
|
||||
children: AddressNode[];
|
||||
isMunicipality: boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedAddress {
|
||||
provinceId?: number;
|
||||
cityId?: number;
|
||||
districtId?: number;
|
||||
province?: AddressNode;
|
||||
city?: AddressNode;
|
||||
district?: AddressNode;
|
||||
}
|
||||
|
||||
export type AddressValue = [number, number];
|
||||
|
||||
export type RegionLevel = 'province' | 'city' | 'district';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 列表面板统一行 */
|
||||
export interface AddressListRow {
|
||||
level: RegionLevel;
|
||||
node: AddressNode;
|
||||
label: string;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
const MUNICIPALITY_LABELS = new Set([
|
||||
'北京市',
|
||||
'天津市',
|
||||
'上海市',
|
||||
'重庆市',
|
||||
]);
|
||||
|
||||
let provinceIndexCache: ProvinceIndex[] | null = null;
|
||||
|
||||
function isPrefectureCityLabel(label: string): boolean {
|
||||
return label.endsWith('市') && !label.endsWith('自治区');
|
||||
}
|
||||
|
||||
function isDistrictLikeLabel(label: string): boolean {
|
||||
return /(?:区|县|旗)$/.test(label);
|
||||
}
|
||||
|
||||
function detectMunicipality(province: AddressNode, children: AddressNode[]): boolean {
|
||||
if (MUNICIPALITY_LABELS.has(province.label)) {
|
||||
return true;
|
||||
}
|
||||
if (!province.label.endsWith('市') || children.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const districtCount = children.filter((c) => isDistrictLikeLabel(c.label)).length;
|
||||
return districtCount / children.length >= 0.8;
|
||||
}
|
||||
|
||||
function buildProvinceIndexList(): ProvinceIndex[] {
|
||||
return (addressOption as AddressNode[]).map((province) => {
|
||||
const children = (province as AddressNode & { children?: AddressNode[] })
|
||||
.children ?? [];
|
||||
return {
|
||||
province: {
|
||||
value: province.value,
|
||||
label: province.label,
|
||||
pvalue: province.pvalue,
|
||||
},
|
||||
children: children.map((c) => ({
|
||||
value: c.value,
|
||||
label: c.label,
|
||||
pvalue: c.pvalue,
|
||||
})),
|
||||
isMunicipality: detectMunicipality(province, children),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getProvinceIndexList(): ProvinceIndex[] {
|
||||
if (!provinceIndexCache) {
|
||||
provinceIndexCache = buildProvinceIndexList();
|
||||
}
|
||||
return provinceIndexCache;
|
||||
}
|
||||
|
||||
function findProvinceIndex(provinceId?: number): ProvinceIndex | undefined {
|
||||
if (!provinceId) {
|
||||
return undefined;
|
||||
}
|
||||
return getProvinceIndexList().find((p) => p.province.value === provinceId);
|
||||
}
|
||||
|
||||
function findChild(
|
||||
provinceIndex: ProvinceIndex,
|
||||
childId?: number,
|
||||
): AddressNode | undefined {
|
||||
if (!childId) {
|
||||
return undefined;
|
||||
}
|
||||
return provinceIndex.children.find((c) => c.value === childId);
|
||||
}
|
||||
|
||||
function getCityNamePrefix(label: string): string {
|
||||
return label.replace(/市$/, '');
|
||||
}
|
||||
|
||||
/** 省级列表 */
|
||||
export function getProvinces(): AddressNode[] {
|
||||
return getProvinceIndexList().map((p) => p.province);
|
||||
}
|
||||
|
||||
/** 市级列表(直辖市返回空) */
|
||||
export function getCities(provinceId?: number): AddressNode[] {
|
||||
const index = findProvinceIndex(provinceId);
|
||||
if (!index || index.isMunicipality) {
|
||||
return [];
|
||||
}
|
||||
return index.children;
|
||||
}
|
||||
|
||||
/** 区级列表 */
|
||||
export function getDistricts(
|
||||
provinceId?: number,
|
||||
cityId?: number,
|
||||
): AddressNode[] {
|
||||
const index = findProvinceIndex(provinceId);
|
||||
if (!index) {
|
||||
return [];
|
||||
}
|
||||
if (index.isMunicipality) {
|
||||
return index.children;
|
||||
}
|
||||
if (!cityId) {
|
||||
return [];
|
||||
}
|
||||
const city = findChild(index, cityId);
|
||||
if (!city || !isPrefectureCityLabel(city.label)) {
|
||||
return [];
|
||||
}
|
||||
const prefix = getCityNamePrefix(city.label);
|
||||
return index.children.filter(
|
||||
(c) =>
|
||||
c.value !== cityId &&
|
||||
!isPrefectureCityLabel(c.label) &&
|
||||
c.label.startsWith(prefix),
|
||||
);
|
||||
}
|
||||
|
||||
/** 当前层级候选列表 */
|
||||
export function getLevelOptions(
|
||||
level: RegionLevel,
|
||||
ctx: SelectionContext,
|
||||
): AddressNode[] {
|
||||
if (level === 'province') {
|
||||
return getProvinces();
|
||||
}
|
||||
if (level === 'city') {
|
||||
return getCities(ctx.provinceId);
|
||||
}
|
||||
return getDistricts(ctx.provinceId, ctx.cityId);
|
||||
}
|
||||
|
||||
/** 选择某级后的下一层级(无则 undefined) */
|
||||
export function getNextLevel(
|
||||
currentLevel: RegionLevel,
|
||||
ctx: SelectionContext,
|
||||
): RegionLevel | undefined {
|
||||
const index = findProvinceIndex(ctx.provinceId);
|
||||
if (!index) {
|
||||
return undefined;
|
||||
}
|
||||
if (currentLevel === 'province') {
|
||||
return index.isMunicipality ? 'district' : 'city';
|
||||
}
|
||||
if (currentLevel === 'city') {
|
||||
if (!ctx.cityId) {
|
||||
return undefined;
|
||||
}
|
||||
const districts = getDistricts(ctx.provinceId, ctx.cityId);
|
||||
return districts.length > 0 ? 'district' : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 当前层级选中后是否可直接完成(叶节点) */
|
||||
export function canCompleteAfterSelect(
|
||||
level: RegionLevel,
|
||||
ctx: SelectionContext,
|
||||
item: AddressNode,
|
||||
): boolean {
|
||||
const index = findProvinceIndex(ctx.provinceId);
|
||||
if (!index) {
|
||||
return false;
|
||||
}
|
||||
if (level === 'district') {
|
||||
return true;
|
||||
}
|
||||
if (level === 'city') {
|
||||
if (index.isMunicipality) {
|
||||
return false;
|
||||
}
|
||||
if (!isPrefectureCityLabel(item.label)) {
|
||||
return true;
|
||||
}
|
||||
return getDistricts(ctx.provinceId, item.value).length === 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 组装对外 address 值 */
|
||||
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];
|
||||
}
|
||||
|
||||
/** 由 address 反查省/市/区 */
|
||||
export function resolveAddressValue(
|
||||
value?: AddressValue | null,
|
||||
): ResolvedAddress {
|
||||
if (!value || value.length < 2) {
|
||||
return {};
|
||||
}
|
||||
const [provinceId, leafId] = value;
|
||||
const index = findProvinceIndex(provinceId);
|
||||
if (!index) {
|
||||
return { provinceId };
|
||||
}
|
||||
const leaf = findChild(index, leafId);
|
||||
if (!leaf) {
|
||||
return { provinceId, province: index.province };
|
||||
}
|
||||
|
||||
if (index.isMunicipality) {
|
||||
return {
|
||||
provinceId,
|
||||
districtId: leafId,
|
||||
province: index.province,
|
||||
district: leaf,
|
||||
};
|
||||
}
|
||||
|
||||
const isLeafDistrict =
|
||||
!isPrefectureCityLabel(leaf.label) && isDistrictLikeLabel(leaf.label);
|
||||
|
||||
if (isPrefectureCityLabel(leaf.label)) {
|
||||
return {
|
||||
provinceId,
|
||||
cityId: leafId,
|
||||
province: index.province,
|
||||
city: leaf,
|
||||
};
|
||||
}
|
||||
|
||||
if (isLeafDistrict) {
|
||||
for (const city of index.children.filter((c) =>
|
||||
isPrefectureCityLabel(c.label),
|
||||
)) {
|
||||
const prefix = getCityNamePrefix(city.label);
|
||||
if (leaf.label.startsWith(prefix)) {
|
||||
return {
|
||||
provinceId,
|
||||
cityId: city.value,
|
||||
districtId: leafId,
|
||||
province: index.province,
|
||||
city,
|
||||
district: leaf,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
provinceId,
|
||||
cityId: leafId,
|
||||
province: index.province,
|
||||
city: leaf,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
provinceId,
|
||||
cityId: leafId,
|
||||
province: index.province,
|
||||
city: leaf,
|
||||
};
|
||||
}
|
||||
|
||||
/** 面包屑展示项 */
|
||||
export function formatBreadcrumbItems(
|
||||
resolved: ResolvedAddress,
|
||||
): BreadcrumbItem[] {
|
||||
const items: BreadcrumbItem[] = [];
|
||||
if (resolved.provinceId && resolved.province) {
|
||||
items.push({
|
||||
level: 'province',
|
||||
id: resolved.provinceId,
|
||||
label: resolved.province.label,
|
||||
});
|
||||
}
|
||||
const index = findProvinceIndex(resolved.provinceId);
|
||||
if (index?.isMunicipality) {
|
||||
if (resolved.districtId && resolved.district) {
|
||||
items.push({
|
||||
level: 'district',
|
||||
id: resolved.districtId,
|
||||
label: resolved.district.label,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
if (resolved.cityId && resolved.city) {
|
||||
items.push({
|
||||
level: 'city',
|
||||
id: resolved.cityId,
|
||||
label: resolved.city.label,
|
||||
});
|
||||
}
|
||||
if (resolved.districtId && resolved.district) {
|
||||
items.push({
|
||||
level: 'district',
|
||||
id: resolved.districtId,
|
||||
label: resolved.district.label,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** 触发器展示文案 */
|
||||
export function formatAddressDisplay(value?: AddressValue | null): string {
|
||||
const items = formatBreadcrumbItems(resolveAddressValue(value));
|
||||
return items.map((i) => i.label).join(' / ');
|
||||
}
|
||||
|
||||
/** 当前层级搜索占位 */
|
||||
export function getLevelSearchPlaceholder(level: RegionLevel): string {
|
||||
const map: Record<RegionLevel, string> = {
|
||||
province: '搜索省',
|
||||
city: '搜索市/县',
|
||||
district: '搜索区',
|
||||
};
|
||||
return map[level];
|
||||
}
|
||||
|
||||
/** 模糊筛选 */
|
||||
export function filterAddressOptions(
|
||||
options: AddressNode[],
|
||||
keyword: string,
|
||||
): AddressNode[] {
|
||||
const q = keyword.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return options;
|
||||
}
|
||||
return options.filter((o) => o.label.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
/** 从已选上下文生成面包屑(面板内) */
|
||||
export function buildBreadcrumbFromContext(
|
||||
ctx: SelectionContext,
|
||||
): BreadcrumbItem[] {
|
||||
return formatBreadcrumbItems({
|
||||
provinceId: ctx.provinceId,
|
||||
cityId: ctx.cityId,
|
||||
districtId: ctx.districtId,
|
||||
province: findProvinceIndex(ctx.provinceId)?.province,
|
||||
city: findChild(findProvinceIndex(ctx.provinceId)!, ctx.cityId),
|
||||
district: findChild(findProvinceIndex(ctx.provinceId)!, ctx.districtId),
|
||||
});
|
||||
}
|
||||
|
||||
/** 根据面板内选中生成 ResolvedAddress */
|
||||
export function contextToResolved(ctx: SelectionContext): ResolvedAddress {
|
||||
const index = findProvinceIndex(ctx.provinceId);
|
||||
return {
|
||||
provinceId: ctx.provinceId,
|
||||
cityId: ctx.cityId,
|
||||
districtId: ctx.districtId,
|
||||
province: index?.province,
|
||||
city: index ? findChild(index, ctx.cityId) : undefined,
|
||||
district: index ? findChild(index, ctx.districtId) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 是否直辖市 */
|
||||
export function isMunicipalityProvince(provinceId?: number): boolean {
|
||||
return !!findProvinceIndex(provinceId)?.isMunicipality;
|
||||
}
|
||||
|
||||
/** 打开面板时的初始层级(有回填则进入待选级,否则省) */
|
||||
export function getInitialPickerLevel(
|
||||
resolved: ResolvedAddress,
|
||||
): RegionLevel {
|
||||
const index = findProvinceIndex(resolved.provinceId);
|
||||
if (!resolved.provinceId || !index) {
|
||||
return 'province';
|
||||
}
|
||||
if (index.isMunicipality) {
|
||||
return 'district';
|
||||
}
|
||||
if (!resolved.cityId) {
|
||||
return 'city';
|
||||
}
|
||||
const districts = getDistricts(resolved.provinceId, resolved.cityId);
|
||||
if (!resolved.districtId && districts.length > 0) {
|
||||
return 'district';
|
||||
}
|
||||
return 'city';
|
||||
}
|
||||
|
||||
/** 当前级候选转列表行 */
|
||||
export function levelOptionsToRows(
|
||||
level: RegionLevel,
|
||||
ctx: SelectionContext,
|
||||
): AddressListRow[] {
|
||||
return getLevelOptions(level, ctx).map((node) => ({
|
||||
level,
|
||||
node,
|
||||
label: node.label,
|
||||
complete: canCompleteAfterSelect(
|
||||
level,
|
||||
level === 'city'
|
||||
? { provinceId: ctx.provinceId, cityId: node.value }
|
||||
: level === 'district'
|
||||
? {
|
||||
provinceId: ctx.provinceId,
|
||||
cityId: ctx.cityId,
|
||||
districtId: node.value,
|
||||
}
|
||||
: ctx,
|
||||
node,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 跨级模糊搜索(含第三级区/县) */
|
||||
export function searchAddressHits(
|
||||
keyword: string,
|
||||
ctx: SelectionContext,
|
||||
): AddressSearchHit[] {
|
||||
const q = keyword.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const hits: AddressSearchHit[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const push = (hit: AddressSearchHit) => {
|
||||
const key = `${hit.level}-${hit.node.value}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
hits.push(hit);
|
||||
}
|
||||
};
|
||||
|
||||
if (!ctx.provinceId) {
|
||||
for (const p of getProvinces()) {
|
||||
if (p.label.toLowerCase().includes(q)) {
|
||||
push({
|
||||
level: 'province',
|
||||
node: p,
|
||||
pathLabel: p.label,
|
||||
complete: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
const index = findProvinceIndex(ctx.provinceId);
|
||||
if (!index) {
|
||||
return hits;
|
||||
}
|
||||
|
||||
if (index.isMunicipality) {
|
||||
for (const d of index.children) {
|
||||
if (d.label.toLowerCase().includes(q)) {
|
||||
push({
|
||||
level: 'district',
|
||||
node: d,
|
||||
pathLabel: `${index.province.label} / ${d.label}`,
|
||||
complete: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
const cityNode = ctx.cityId ? findChild(index, ctx.cityId) : undefined;
|
||||
|
||||
if (!ctx.cityId) {
|
||||
for (const child of index.children) {
|
||||
if (!child.label.toLowerCase().includes(q)) {
|
||||
continue;
|
||||
}
|
||||
const rowCtx = { provinceId: ctx.provinceId, cityId: child.value };
|
||||
push({
|
||||
level: 'city',
|
||||
node: child,
|
||||
pathLabel: `${index.province.label} / ${child.label}`,
|
||||
complete: canCompleteAfterSelect('city', rowCtx, child),
|
||||
});
|
||||
}
|
||||
for (const child of index.children.filter((c) => isPrefectureCityLabel(c.label))) {
|
||||
for (const d of getDistricts(ctx.provinceId, child.value)) {
|
||||
if (!d.label.toLowerCase().includes(q)) {
|
||||
continue;
|
||||
}
|
||||
push({
|
||||
level: 'district',
|
||||
node: d,
|
||||
pathLabel: `${index.province.label} / ${child.label} / ${d.label}`,
|
||||
complete: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
if (cityNode && cityNode.label.toLowerCase().includes(q)) {
|
||||
push({
|
||||
level: 'city',
|
||||
node: cityNode,
|
||||
pathLabel: `${index.province.label} / ${cityNode.label}`,
|
||||
complete: canCompleteAfterSelect('city', ctx, cityNode),
|
||||
});
|
||||
}
|
||||
|
||||
for (const d of getDistricts(ctx.provinceId, ctx.cityId)) {
|
||||
if (d.label.toLowerCase().includes(q)) {
|
||||
push({
|
||||
level: 'district',
|
||||
node: d,
|
||||
pathLabel: `${index.province.label} / ${cityNode?.label ?? ''} / ${d.label}`,
|
||||
complete: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return hits;
|
||||
}
|
||||
|
||||
/** 搜索命中转列表行 */
|
||||
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 {
|
||||
if (hit.level === 'province') {
|
||||
return {
|
||||
provinceId: hit.node.value,
|
||||
cityId: undefined,
|
||||
districtId: undefined,
|
||||
};
|
||||
}
|
||||
if (hit.level === 'city') {
|
||||
return {
|
||||
provinceId: ctx.provinceId,
|
||||
cityId: hit.node.value,
|
||||
districtId: undefined,
|
||||
};
|
||||
}
|
||||
if (hit.level === 'district' && findProvinceIndex(ctx.provinceId)?.isMunicipality) {
|
||||
return {
|
||||
provinceId: ctx.provinceId,
|
||||
cityId: undefined,
|
||||
districtId: hit.node.value,
|
||||
};
|
||||
}
|
||||
return {
|
||||
provinceId: ctx.provinceId,
|
||||
cityId: ctx.cityId,
|
||||
districtId: hit.node.value,
|
||||
};
|
||||
}
|
||||
|
||||
/** 高亮项是否有下一级 */
|
||||
export function rowHasNextLevel(
|
||||
row: AddressListRow,
|
||||
ctx: SelectionContext,
|
||||
): boolean {
|
||||
if (row.level === 'province') {
|
||||
return !!getNextLevel('province', {
|
||||
provinceId: row.node.value,
|
||||
cityId: undefined,
|
||||
districtId: undefined,
|
||||
});
|
||||
}
|
||||
if (row.level === 'city') {
|
||||
return !!getNextLevel('city', {
|
||||
provinceId: ctx.provinceId,
|
||||
cityId: row.node.value,
|
||||
districtId: undefined,
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 点击面包屑项后截断选中并返回新上下文 */
|
||||
export function truncateContextToBreadcrumb(
|
||||
ctx: SelectionContext,
|
||||
item: BreadcrumbItem,
|
||||
): SelectionContext {
|
||||
if (item.level === 'province') {
|
||||
return {
|
||||
provinceId: item.id,
|
||||
cityId: undefined,
|
||||
districtId: undefined,
|
||||
};
|
||||
}
|
||||
if (item.level === 'city') {
|
||||
return {
|
||||
provinceId: ctx.provinceId,
|
||||
cityId: item.id,
|
||||
districtId: undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
provinceId: ctx.provinceId,
|
||||
cityId: ctx.cityId,
|
||||
districtId: item.id,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user