Browse Source

fix: harden geographic select data handling

pull/11882/head
afc163 1 month ago
parent
commit
b04aab7b38
  1. 1
      cloudflare-worker/package.json
  2. 87
      cloudflare-worker/src/routes/geographic.ts
  3. 7
      package-lock.json
  4. 1
      package.json
  5. 7
      src/pages/account/center/Center.style.ts
  6. 10
      src/pages/account/settings/_mock.ts
  7. 130
      src/pages/account/settings/components/base.test.tsx
  8. 66
      src/pages/account/settings/components/base.tsx
  9. 6
      src/pages/account/settings/data.d.ts
  10. 1784
      src/pages/account/settings/geographic/city.json
  11. 138
      src/pages/account/settings/geographic/province.json
  12. 22
      src/pages/account/settings/service.test.ts
  13. 4
      src/pages/account/settings/service.ts
  14. 52
      src/utils/chinaDivision.test.ts
  15. 69
      src/utils/chinaDivision.ts

1
cloudflare-worker/package.json

@ -8,6 +8,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"china-division": "^2.7.0",
"hono": "^4.6.0"
},
"devDependencies": {

87
cloudflare-worker/src/routes/geographic.ts

@ -1,89 +1,15 @@
import { Hono } from 'hono';
import {
getCityOptions,
provinceOptions,
} from '../../../src/utils/chinaDivision';
const app = new Hono();
// Province data - simplified with key/label format
const provinceData = [
{ label: '北京市', key: '110000' },
{ label: '天津市', key: '120000' },
{ label: '河北省', key: '130000' },
{ label: '山西省', key: '140000' },
{ label: '内蒙古自治区', key: '150000' },
{ label: '辽宁省', key: '210000' },
{ label: '吉林省', key: '220000' },
{ label: '黑龙江省', key: '230000' },
{ label: '上海市', key: '310000' },
{ label: '江苏省', key: '320000' },
{ label: '浙江省', key: '330000' },
{ label: '安徽省', key: '340000' },
{ label: '福建省', key: '350000' },
{ label: '江西省', key: '360000' },
{ label: '山东省', key: '370000' },
{ label: '河南省', key: '410000' },
{ label: '湖北省', key: '420000' },
{ label: '湖南省', key: '430000' },
{ label: '广东省', key: '440000' },
{ label: '广西壮族自治区', key: '450000' },
{ label: '海南省', key: '460000' },
{ label: '重庆市', key: '500000' },
{ label: '四川省', key: '510000' },
{ label: '贵州省', key: '520000' },
{ label: '云南省', key: '530000' },
{ label: '西藏自治区', key: '540000' },
{ label: '陕西省', key: '610000' },
{ label: '甘肃省', key: '620000' },
{ label: '青海省', key: '630000' },
{ label: '宁夏回族自治区', key: '640000' },
{ label: '新疆维吾尔自治区', key: '650000' },
{ label: '台湾省', key: '710000' },
{ label: '香港特别行政区', key: '810000' },
{ label: '澳门特别行政区', key: '820000' },
];
// City data - moved outside handler for performance
const cityData: Record<string, { label: string; key: string }[]> = {
'110000': [{ label: '市辖区', key: '110100' }],
'120000': [{ label: '市辖区', key: '120100' }],
'310000': [{ label: '市辖区', key: '310100' }],
'330000': [
{ label: '杭州市', key: '330100' },
{ label: '宁波市', key: '330200' },
{ label: '温州市', key: '330300' },
{ label: '嘉兴市', key: '330400' },
{ label: '湖州市', key: '330500' },
{ label: '绍兴市', key: '330600' },
{ label: '金华市', key: '330700' },
{ label: '衢州市', key: '330800' },
{ label: '舟山市', key: '330900' },
{ label: '台州市', key: '331000' },
{ label: '丽水市', key: '331100' },
],
'440000': [
{ label: '广州市', key: '440100' },
{ label: '韶关市', key: '440200' },
{ label: '深圳市', key: '440300' },
{ label: '珠海市', key: '440400' },
{ label: '汕头市', key: '440500' },
{ label: '佛山市', key: '440600' },
{ label: '江门市', key: '440700' },
{ label: '湛江市', key: '440800' },
{ label: '茂名市', key: '440900' },
{ label: '肇庆市', key: '441200' },
{ label: '惠州市', key: '441300' },
{ label: '梅州市', key: '441400' },
{ label: '汕尾市', key: '441500' },
{ label: '河源市', key: '441600' },
{ label: '阳江市', key: '441700' },
{ label: '清远市', key: '441800' },
{ label: '东莞市', key: '441900' },
{ label: '中山市', key: '442000' },
],
};
// GET /api/geographic/province
app.get('/province', (c) => {
return c.json({
data: provinceData,
data: provinceOptions,
});
});
@ -91,9 +17,8 @@ app.get('/province', (c) => {
app.get('/city/:province', (c) => {
const province = c.req.param('province');
// Return data for the province, or empty array if not found
return c.json({
data: cityData[province] || [],
data: getCityOptions(province),
});
});

7
package-lock.json

@ -18,6 +18,7 @@
"@tanstack/react-query": "^5.101.0",
"antd": "^6.4.3",
"antd-style": "^4.1.0",
"china-division": "^2.7.0",
"clsx": "^2.1.1",
"d3": "^7.9.0",
"dayjs": "^1.11.20",
@ -32616,6 +32617,12 @@
"peerDependencies": {
"zod": "^3.18.0"
}
},
"node_modules/china-division": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/china-division/-/china-division-2.7.0.tgz",
"integrity": "sha512-4uUPAT+1WfqDh5jytq7omdCmHNk3j+k76zEG/2IqaGcYB90c2SwcixttcypdsZ3T/9tN1TTpBDoeZn+Yw/qBEA==",
"license": "MIT"
}
}
}

1
package.json

@ -45,6 +45,7 @@
"@tanstack/react-query": "^5.101.0",
"antd": "^6.4.3",
"antd-style": "^4.1.0",
"china-division": "^2.7.0",
"clsx": "^2.1.1",
"d3": "^7.9.0",
"dayjs": "^1.11.20",

7
src/pages/account/center/Center.style.ts

@ -5,7 +5,12 @@ const useStyles = createStyles(({ token }) => {
avatarHolder: {
marginBottom: '24px',
textAlign: 'center',
'& > img': { width: '104px', height: '104px', marginBottom: '20px' },
'& > img': {
display: 'block',
width: '104px',
height: '104px',
margin: '0 auto 20px',
},
},
name: {
marginBottom: '4px',

10
src/pages/account/settings/_mock.ts

@ -1,19 +1,19 @@
import type { Request, Response } from 'express';
import { defaultUser } from '../../../../mock/utils';
const city = require('./geographic/city.json');
const province = require('./geographic/province.json');
import { getCityOptions, provinceOptions } from '../../../utils/chinaDivision';
function getProvince(_: Request, res: Response) {
return res.json({
data: province,
data: provinceOptions,
});
}
function getCity(req: Request, res: Response) {
const provinceKey = req.params.province;
return res.json({
data: city[provinceKey as keyof typeof city],
data: getCityOptions(
Array.isArray(provinceKey) ? provinceKey[0] : provinceKey,
),
});
}

130
src/pages/account/settings/components/base.test.tsx

@ -5,15 +5,31 @@ import * as service from '../service';
import BaseView from './base';
const mocks = vi.hoisted(() => ({
form: {
setFieldValue: vi.fn(),
},
initialValues: undefined as Record<string, any> | undefined,
onValuesChange: undefined as
| ((changedValues: Record<string, unknown>) => void)
| undefined,
dependencyProvince: { label: '浙江省', value: '330000' },
requestResults: {} as Record<string, any>,
}));
vi.mock('@ant-design/pro-components', async () => {
const React = await import('react');
const ProForm = ({ children, initialValues }: any) => {
const ProForm = ({
children,
formRef,
initialValues,
onValuesChange,
}: any) => {
if (formRef) {
formRef.current = mocks.form;
}
mocks.initialValues = initialValues;
mocks.onValuesChange = onValuesChange;
return <form>{children}</form>;
};
@ -22,15 +38,15 @@ vi.mock('@ant-design/pro-components', async () => {
return {
ProForm,
ProFormDependency: ({ children }: any) => (
<div>{children({ province: { label: '浙江省', value: '330000' } })}</div>
<div>{children({ province: mocks.dependencyProvince })}</div>
),
ProFormFieldSet: ({ children }: any) => <div>{children}</div>,
ProFormSelect: ({ name, request }: any) => {
ProFormSelect: ({ name, params, request }: any) => {
React.useEffect(() => {
request?.().then((result: any) => {
request?.(params).then((result: any) => {
mocks.requestResults[name] = result;
});
}, [name, request]);
}, [name, params, request]);
return <div />;
},
ProFormText: () => <div />,
@ -82,6 +98,8 @@ describe('BaseView geographic selects', () => {
},
});
mocks.initialValues = undefined;
mocks.onValuesChange = undefined;
mocks.dependencyProvince = { label: '浙江省', value: '330000' };
mocks.requestResults = {};
vi.clearAllMocks();
@ -108,10 +126,10 @@ describe('BaseView geographic selects', () => {
},
});
vi.mocked(service.queryProvince).mockResolvedValue([
{ id: '330000', name: '浙江省' },
{ key: '330000', label: '浙江省' },
]);
vi.mocked(service.queryCity).mockResolvedValue([
{ id: '330100', name: '杭州市' },
{ key: '330100', label: '杭州市' },
]);
});
@ -134,6 +152,43 @@ describe('BaseView geographic selects', () => {
});
});
it('does not set incomplete geographic initial values', async () => {
vi.mocked(service.queryCurrent).mockResolvedValue({
data: {
address: '西湖区工专路 77 号',
avatar: '',
country: 'China',
email: 'antdesign@alipay.com',
geographic: {
province: { label: '浙江省' },
city: { label: '杭州市' },
},
group: '',
name: 'Ant Design',
notice: [],
notifyCount: 0,
phone: '0752-268888888',
signature: '',
tags: [],
title: '',
unreadCount: 0,
userid: '00000001',
} as any,
});
render(
<QueryClientProvider client={queryClient}>
<BaseView />
</QueryClientProvider>,
);
await waitFor(() => {
expect(mocks.initialValues?.name).toBe('Ant Design');
expect(mocks.initialValues?.province).toBeUndefined();
expect(mocks.initialValues?.city).toBeUndefined();
});
});
it('loads cities with the selected province value', async () => {
render(
<QueryClientProvider client={queryClient}>
@ -146,6 +201,24 @@ describe('BaseView geographic selects', () => {
});
});
it('clears city when province changes', async () => {
render(
<QueryClientProvider client={queryClient}>
<BaseView />
</QueryClientProvider>,
);
await waitFor(() => {
expect(mocks.onValuesChange).toBeTypeOf('function');
});
mocks.onValuesChange?.({
province: { label: '河北省', value: '130000' },
});
expect(mocks.form.setFieldValue).toHaveBeenCalledWith('city', undefined);
});
it('returns label/value option arrays for geographic selects', async () => {
render(
<QueryClientProvider client={queryClient}>
@ -162,4 +235,47 @@ describe('BaseView geographic selects', () => {
]);
});
});
it('supports local mock name/id geographic responses', async () => {
vi.mocked(service.queryProvince).mockResolvedValue([
{ id: '440000', name: '广东省' },
]);
vi.mocked(service.queryCity).mockResolvedValue([
{ id: '440300', name: '深圳市' },
]);
render(
<QueryClientProvider client={queryClient}>
<BaseView />
</QueryClientProvider>,
);
await waitFor(() => {
expect(mocks.requestResults.province).toEqual([
{ label: '广东省', value: '440000' },
]);
expect(mocks.requestResults.city).toEqual([
{ label: '深圳市', value: '440300' },
]);
});
});
it('falls back to local city options when remote city data is empty', async () => {
mocks.dependencyProvince = { label: '江苏省', value: '320000' };
vi.mocked(service.queryCity).mockResolvedValue([]);
render(
<QueryClientProvider client={queryClient}>
<BaseView />
</QueryClientProvider>,
);
await waitFor(() => {
expect(service.queryCity).toHaveBeenCalledWith('320000');
expect(mocks.requestResults.city).toContainEqual({
label: '南京市',
value: '320100',
});
});
});
});

66
src/pages/account/settings/components/base.tsx

@ -1,4 +1,5 @@
import { UploadOutlined } from '@ant-design/icons';
import type { ProFormInstance } from '@ant-design/pro-components';
import {
ProForm,
ProFormDependency,
@ -10,6 +11,8 @@ import {
import { useQuery } from '@tanstack/react-query';
import { Button, Input, message, Upload } from 'antd';
import React from 'react';
import { getCityOptions, provinceOptions } from '@/utils/chinaDivision';
import type { GeographicItemType } from '../data';
import { queryCity, queryCurrent, queryProvince } from '../service';
import useStyles from './index.style';
@ -28,15 +31,36 @@ const validatorPhone = (
};
const toSelectValue = (item?: { label?: string; key?: string }) =>
item
item?.key
? {
label: item.label,
value: item.key,
}
: undefined;
const toSelectOptions = (items: GeographicItemType[]) =>
items
.map((item) => {
const label = item.name ?? item.label;
const value = item.id ?? item.key;
return label && value ? { label, value } : undefined;
})
.filter((item): item is { label: string; value: string } => Boolean(item));
const handleFinish = async () => {
message.success('更新基本信息成功');
};
const BaseView: React.FC = () => {
const { styles } = useStyles();
const formRef = React.useRef<ProFormInstance>(undefined);
const handleValuesChange = (changedValues: Record<string, unknown>) => {
if ('province' in changedValues) {
formRef.current?.setFieldValue('city', undefined);
}
};
const { data: currentUser, isLoading: loading } = useQuery({
queryKey: ['current-user'],
@ -53,17 +77,16 @@ const BaseView: React.FC = () => {
}
return '';
};
const handleFinish = async () => {
message.success('更新基本信息成功');
};
return (
<div className={styles.baseView}>
{loading ? null : (
<>
<div className={styles.left}>
<ProForm
formRef={formRef}
layout="vertical"
onFinish={handleFinish}
onValuesChange={handleValuesChange}
submitter={{
searchConfig: {
submitText: '更新基本信息',
@ -129,8 +152,9 @@ const BaseView: React.FC = () => {
]}
/>
<ProForm.Group title="所在省市" size={8}>
<ProForm.Group size={8}>
<ProFormSelect
label="所在省市"
rules={[
{
required: true,
@ -143,20 +167,17 @@ const BaseView: React.FC = () => {
}}
name="province"
request={async () => {
return queryProvince().then((data) => {
return data.map((item) => {
return {
label: item.name,
value: item.id,
};
});
});
const options = toSelectOptions(await queryProvince());
return options.length
? options
: toSelectOptions(provinceOptions);
}}
/>
<ProFormDependency name={['province']}>
{({ province }) => {
return (
<ProFormSelect
label=" "
params={{
key: province?.value,
}}
@ -172,18 +193,17 @@ const BaseView: React.FC = () => {
labelInValue: true,
}}
disabled={!province}
request={async () => {
if (!province?.value) {
request={async (params) => {
if (!params.key) {
return [];
}
return queryCity(province.value).then((data) => {
return data.map((item) => {
return {
label: item.name,
value: item.id,
};
});
});
const provinceKey = String(params.key);
const options = toSelectOptions(
await queryCity(provinceKey),
);
return options.length
? options
: toSelectOptions(getCityOptions(provinceKey));
}}
/>
);

6
src/pages/account/settings/data.d.ts

@ -4,8 +4,10 @@ export type TagType = {
};
export type GeographicItemType = {
name: string;
id: string;
name?: string;
id?: string;
label?: string;
key?: string;
};
export type GeographicType = {

1784
src/pages/account/settings/geographic/city.json

File diff suppressed because it is too large

138
src/pages/account/settings/geographic/province.json

@ -1,138 +0,0 @@
[
{
"name": "北京市",
"id": "110000"
},
{
"name": "天津市",
"id": "120000"
},
{
"name": "河北省",
"id": "130000"
},
{
"name": "山西省",
"id": "140000"
},
{
"name": "内蒙古自治区",
"id": "150000"
},
{
"name": "辽宁省",
"id": "210000"
},
{
"name": "吉林省",
"id": "220000"
},
{
"name": "黑龙江省",
"id": "230000"
},
{
"name": "上海市",
"id": "310000"
},
{
"name": "江苏省",
"id": "320000"
},
{
"name": "浙江省",
"id": "330000"
},
{
"name": "安徽省",
"id": "340000"
},
{
"name": "福建省",
"id": "350000"
},
{
"name": "江西省",
"id": "360000"
},
{
"name": "山东省",
"id": "370000"
},
{
"name": "河南省",
"id": "410000"
},
{
"name": "湖北省",
"id": "420000"
},
{
"name": "湖南省",
"id": "430000"
},
{
"name": "广东省",
"id": "440000"
},
{
"name": "广西壮族自治区",
"id": "450000"
},
{
"name": "海南省",
"id": "460000"
},
{
"name": "重庆市",
"id": "500000"
},
{
"name": "四川省",
"id": "510000"
},
{
"name": "贵州省",
"id": "520000"
},
{
"name": "云南省",
"id": "530000"
},
{
"name": "西藏自治区",
"id": "540000"
},
{
"name": "陕西省",
"id": "610000"
},
{
"name": "甘肃省",
"id": "620000"
},
{
"name": "青海省",
"id": "630000"
},
{
"name": "宁夏回族自治区",
"id": "640000"
},
{
"name": "新疆维吾尔自治区",
"id": "650000"
},
{
"name": "台湾省",
"id": "710000"
},
{
"name": "香港特别行政区",
"id": "810000"
},
{
"name": "澳门特别行政区",
"id": "820000"
}
]

22
src/pages/account/settings/service.test.ts

@ -0,0 +1,22 @@
import { request } from '@umijs/max';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { queryCity } from './service';
vi.mock('@umijs/max', () => ({
request: vi.fn(),
}));
describe('account settings service', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(request).mockResolvedValue({ data: [] });
});
it('encodes province when requesting city options', async () => {
await queryCity('33/000?x=1');
expect(request).toHaveBeenCalledWith(
'/api/geographic/city/33%2F000%3Fx%3D1',
);
});
});

4
src/pages/account/settings/service.ts

@ -12,7 +12,9 @@ export async function queryProvince(): Promise<GeographicItemType[]> {
export async function queryCity(
province: string,
): Promise<GeographicItemType[]> {
return request(`/api/geographic/city/${province}`).then(({ data }) => data);
return request(`/api/geographic/city/${encodeURIComponent(province)}`).then(
({ data }) => data,
);
}
export async function query() {

52
src/utils/chinaDivision.test.ts

@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { getCityOptions, provinceOptions } from './chinaDivision';
describe('chinaDivision geographic options', () => {
it('keeps province values compatible with existing six-digit province ids', () => {
expect(provinceOptions).toContainEqual({
label: '浙江省',
key: '330000',
});
expect(provinceOptions).toContainEqual({
label: '台湾省',
key: '710000',
});
});
it('returns city options for ordinary provinces', () => {
expect(getCityOptions('130000')).toContainEqual({
label: '石家庄市',
key: '130100',
});
expect(getCityOptions('320000')).toContainEqual({
label: '南京市',
key: '320100',
});
expect(getCityOptions('32')).toContainEqual({
label: '南京市',
key: '320100',
});
});
it('returns district options for municipalities', () => {
expect(getCityOptions('110000')).toContainEqual({
label: '东城区',
key: '110101',
});
});
it('returns city options for Hong Kong, Macao and Taiwan', () => {
expect(getCityOptions('710000')).toContainEqual({
label: '台北市',
key: '710100',
});
expect(getCityOptions('810000')).toContainEqual({
label: '香港岛',
key: '810100',
});
expect(getCityOptions('820000')).toContainEqual({
label: '澳门半岛',
key: '820100',
});
});
});

69
src/utils/chinaDivision.ts

@ -0,0 +1,69 @@
import hkMoTwSource from 'china-division/dist/HK-MO-TW.json';
import pcaSource from 'china-division/dist/pca-code.json';
export type GeographicOption = {
label: string;
key: string;
};
type DivisionNode = {
code: string;
name: string;
children?: DivisionNode[];
};
const directAdminCodes = new Set(['11', '12', '31', '50']);
const specialRegions: GeographicOption[] = [
{ label: '台湾省', key: '710000' },
{ label: '香港特别行政区', key: '810000' },
{ label: '澳门特别行政区', key: '820000' },
];
const toProvinceKey = (code: string) => `${code}0000`;
const toCityKey = (code: string) => (code.length === 4 ? `${code}00` : code);
const toOption = (item: DivisionNode): GeographicOption => ({
label: item.name,
key: toCityKey(item.code),
});
export const provinceOptions: GeographicOption[] = [
...(pcaSource as DivisionNode[]).map((item) => ({
label: item.name,
key: toProvinceKey(item.code),
})),
...specialRegions,
];
export const getCityOptions = (provinceKey: string): GeographicOption[] => {
const provinceCode =
provinceKey.length === 2 ? provinceKey : provinceKey.slice(0, 2);
const province = (pcaSource as DivisionNode[]).find(
(item) => item.code === provinceCode,
);
if (province) {
const children = province.children ?? [];
const cityNodes = directAdminCodes.has(provinceCode)
? children.flatMap((item) => item.children ?? [item])
: children;
return cityNodes.map(toOption);
}
const specialRegion = specialRegions.find((item) => item.key === provinceKey);
if (!specialRegion) {
return [];
}
return Object.keys(
(hkMoTwSource as Record<string, Record<string, string[]>>)[
specialRegion.label
] ?? {},
).map((name, index) => ({
label: name,
key: `${provinceCode}${String(index + 1).padStart(2, '0')}00`,
}));
};
Loading…
Cancel
Save