feat(frontend): use offline global birth place hierarchy

This commit is contained in:
Jesse_Chen
2026-08-30 15:18:19 +08:00
parent f9a00dc568
commit 5c1d095f69
238 changed files with 655 additions and 42 deletions
+228 -35
View File
@@ -14,17 +14,20 @@ import {
hasVirtualCityLevel,
resolveBirthPlaceNode,
} from "@/lib/china-birth-place";
import { worldCountries, type WorldCountryData } from "@/data/world-countries";
/** Sentinel for "this city has districts but the user cannot name one". */
const cityCentreOption = "__city_centre__";
const cityCentreLabel = "不确定,用市区中心";
export type BirthPlaceSelection = {
countryCode: string;
provinceCode: string;
cityCode: string;
districtCode: string;
label: string;
placeType: "province" | "city" | "district";
provider: "china_locations" | "world_location_table";
providerPlaceId: string;
latitude: number;
longitude: number;
@@ -33,6 +36,7 @@ export type BirthPlaceSelection = {
};
export type SavedBirthPlace = {
countryCode?: string;
provinceCode: string;
cityCode: string;
districtCode: string;
@@ -54,8 +58,47 @@ type TimezoneState =
| { status: "ready"; timezoneId: string; timezoneOffset: number | null }
| { status: "error" };
/** The outcome of one lookup, tagged with the place it was requested for. */
type TimezoneOutcome = { key: string; state: Extract<TimezoneState, { status: "ready" | "error" }> };
type BirthPlaceDraft = {
countryCode: string;
provinceCode: string;
cityCode: string;
districtCode: string;
cityCentre: boolean;
};
type ResolvedPickerPlace = {
countryCode: string;
provinceCode: string;
cityCode: string;
districtCode: string;
label: string;
placeType: "province" | "city" | "district";
provider: BirthPlaceSelection["provider"];
providerPlaceId: string;
latitude: number;
longitude: number;
};
function draftFromValue(value: SavedBirthPlace): BirthPlaceDraft {
const countryCode = value.countryCode?.trim().toUpperCase() || "CN";
if (countryCode === "CN") {
return {
countryCode,
...birthPlaceDraftFromLevels(value),
};
}
return {
countryCode,
provinceCode: value.provinceCode,
cityCode: value.cityCode,
districtCode: value.districtCode,
cityCentre: false,
};
}
function countryLabel(countryCode: string) {
return worldCountries.find((item) => item.code === countryCode)?.name ?? countryCode;
}
export function BirthPlacePicker({
value,
@@ -64,9 +107,8 @@ export function BirthPlacePicker({
disabled = false,
onChange,
}: BirthPlacePickerProps) {
const { provinceCode, cityCode, districtCode } = value;
const countryCode = value.countryCode?.trim().toUpperCase() || "CN";
const fieldPrefix = useId();
// Read by the lookup effect so that a re-rendered parent does not restart it.
const onChangeRef = useRef(onChange);
const savedRef = useRef(value);
useEffect(() => {
@@ -74,61 +116,124 @@ export function BirthPlacePicker({
savedRef.current = value;
});
const externalKey = `${provinceCode}/${cityCode}/${districtCode}`;
const [draft, setDraft] = useState(() => birthPlaceDraftFromLevels({ provinceCode, cityCode, districtCode }));
const externalKey = `${countryCode}/${value.provinceCode}/${value.cityCode}/${value.districtCode}`;
const [draft, setDraft] = useState<BirthPlaceDraft>(() => draftFromValue(value));
const [syncedKey, setSyncedKey] = useState(externalKey);
const [globalData, setGlobalData] = useState<WorldCountryData | null>(null);
const [globalLoadErrorCountry, setGlobalLoadErrorCountry] = useState("");
const [outcome, setOutcome] = useState<TimezoneOutcome | null>(null);
const [retryToken, setRetryToken] = useState(0);
// Adopt a profile that was loaded or replaced outside this picker.
if (syncedKey !== externalKey) {
setSyncedKey(externalKey);
if (provinceCode) setDraft(birthPlaceDraftFromLevels({ provinceCode, cityCode, districtCode }));
setDraft(draftFromValue(value));
}
const province = findProvinceNode(draft.provinceCode);
const isChina = draft.countryCode === "CN";
const province = isChina ? findProvinceNode(draft.provinceCode) : undefined;
const virtualCity = Boolean(province && hasVirtualCityLevel(province));
const city = province && virtualCity ? province.cities[0] : findCityNode(province, draft.cityCode);
const districts = city?.districts ?? [];
const districtChosen = Boolean(draft.districtCode) && districts.some((item) => item.code === draft.districtCode);
// Levels that offer no choice are skipped, so completeness stops at the deepest real level.
const complete = Boolean(city) && (districts.length === 0 || districtChosen || draft.cityCentre);
const resolved = complete
const chinaComplete = Boolean(province && city) && (districts.length === 0 || districtChosen || draft.cityCentre);
const chinaResolved = isChina && chinaComplete
? resolveBirthPlaceNode({
provinceCode: draft.provinceCode,
cityCode: city?.code ?? "",
districtCode: districtChosen ? draft.districtCode : "",
})
: null;
// The offset the profile already carries is only authoritative while the birth
// moment it was computed against is unchanged.
useEffect(() => {
if (draft.countryCode === "CN") return;
const controller = new AbortController();
fetch(`/data/world-locations/${draft.countryCode.toLowerCase()}.json`, {
signal: controller.signal,
cache: "force-cache",
})
.then((response) => {
if (!response.ok) throw new Error("location_data_unavailable");
return response.json() as Promise<WorldCountryData>;
})
.then((data) => {
if (data.code !== draft.countryCode) throw new Error("location_data_invalid");
setGlobalData(data);
setGlobalLoadErrorCountry("");
})
.catch(() => {
if (controller.signal.aborted) return;
setGlobalData(null);
setGlobalLoadErrorCountry(draft.countryCode);
});
return () => controller.abort();
}, [draft.countryCode]);
const globalRegion = !isChina && globalData?.code === draft.countryCode
? globalData.regions.find((item) => item.code === draft.provinceCode)
: undefined;
const globalCity = globalRegion?.cities.find((item) => item.code === draft.cityCode);
const globalComplete = Boolean(globalRegion)
&& (globalRegion?.cities.length === 0 || Boolean(globalCity));
const globalResolved: ResolvedPickerPlace | null = !isChina && globalComplete && globalRegion
? {
countryCode: draft.countryCode,
provinceCode: globalRegion.code,
cityCode: globalCity?.code ?? "",
districtCode: "",
label: [countryLabel(draft.countryCode), globalRegion.name, globalCity?.name]
.filter((item, index, items): item is string => Boolean(item) && items.indexOf(item) === index)
.join(" · "),
placeType: globalCity ? "city" : "province",
provider: "world_location_table",
providerPlaceId: `world:${draft.countryCode}:${globalRegion.code}:${globalCity?.code ?? ""}`,
latitude: globalCity?.latitude ?? globalRegion.latitude,
longitude: globalCity?.longitude ?? globalRegion.longitude,
}
: null;
const resolved: ResolvedPickerPlace | null = chinaResolved
? {
countryCode: "CN",
provinceCode: chinaResolved.province.code,
cityCode: chinaResolved.city.code,
districtCode: chinaResolved.district?.code ?? "",
label: chinaResolved.label,
placeType: chinaResolved.placeType,
provider: "china_locations",
providerPlaceId: chinaResolved.node.code,
latitude: chinaResolved.latitude,
longitude: chinaResolved.longitude,
}
: globalResolved;
// The offset the profile already carries is only authoritative while the birth moment is unchanged.
const birthMoment = `${birthDate}/${birthTime}`;
const [mountedMoment] = useState(birthMoment);
const savedIsCurrent = resolved !== null
&& mountedMoment === birthMoment
&& value.timezoneId.trim() !== ""
&& provinceCode === resolved.province.code
&& cityCode === resolved.city.code
&& districtCode === (resolved.district?.code ?? "");
&& countryCode === resolved.countryCode
&& value.provinceCode === resolved.provinceCode
&& value.cityCode === resolved.cityCode
&& value.districtCode === resolved.districtCode;
const syncKey = savedIsCurrent
? "saved"
: resolved
? `${resolved.node.code}/${birthMoment}/${retryToken}`
? `${resolved.providerPlaceId}/${birthMoment}/${retryToken}`
: "empty";
useEffect(() => {
if (syncKey === "saved") return;
if (syncKey === "empty") {
const saved = savedRef.current;
if (saved.provinceCode || saved.timezoneId) onChangeRef.current(null);
if (saved.countryCode !== "CN" || saved.provinceCode || saved.timezoneId) onChangeRef.current(null);
return;
}
const target = resolved as NonNullable<typeof resolved>;
const controller = new AbortController();
(async () => {
try {
const response = await fetch("/api/locations/timezone", {
@@ -148,15 +253,16 @@ export function BirthPlacePicker({
const timezoneId = typeof payload.timezoneId === "string" ? payload.timezoneId.trim() : "";
if (!timezoneId) throw new Error("timezone_unavailable");
const timezoneOffset = typeof payload.timezoneOffset === "number" ? payload.timezoneOffset : null;
setOutcome({ key: syncKey, state: { status: "ready", timezoneId, timezoneOffset } });
onChangeRef.current({
provinceCode: target.province.code,
cityCode: target.city.code,
districtCode: target.district?.code ?? "",
countryCode: target.countryCode,
provinceCode: target.provinceCode,
cityCode: target.cityCode,
districtCode: target.districtCode,
label: target.label,
placeType: target.placeType,
providerPlaceId: target.node.code,
provider: target.provider,
providerPlaceId: target.providerPlaceId,
latitude: target.latitude,
longitude: target.longitude,
timezoneId,
@@ -168,14 +274,20 @@ export function BirthPlacePicker({
onChangeRef.current(null);
}
})();
return () => controller.abort();
// syncKey collapses the node plus the birth moment the historical offset depends on.
}, [syncKey]); // eslint-disable-line react-hooks/exhaustive-deps
function chooseCountry(nextCode: string) {
setDraft(nextCode === "CN"
? { countryCode: "CN", ...birthPlaceDraftFromLevels({ provinceCode: "", cityCode: "", districtCode: "" }) }
: { countryCode: nextCode, provinceCode: "", cityCode: "", districtCode: "", cityCentre: false });
}
function chooseProvince(nextCode: string) {
const nextProvince = findProvinceNode(nextCode);
setDraft({
countryCode: "CN",
provinceCode: nextCode,
cityCode: nextProvince && hasVirtualCityLevel(nextProvince) ? nextProvince.cities[0].code : "",
districtCode: "",
@@ -195,6 +307,10 @@ export function BirthPlacePicker({
}));
}
function chooseGlobalRegion(nextCode: string) {
setDraft((current) => ({ ...current, provinceCode: nextCode, cityCode: "", districtCode: "", cityCentre: false }));
}
// A place with no lookup recorded against it is still being looked up.
const settled: TimezoneState = syncKey === "empty"
? { status: "idle" }
@@ -203,38 +319,71 @@ export function BirthPlacePicker({
: outcome?.key === syncKey
? outcome.state
: { status: "loading" };
const globalIsLoading = !isChina
&& globalData?.code !== draft.countryCode
&& globalLoadErrorCountry !== draft.countryCode;
const globalHasError = !isChina && globalLoadErrorCountry === draft.countryCode;
const statusText = settled.status === "loading"
? "正在确认该地点的时区…"
: settled.status === "ready" && resolved
? `${resolved.label} · ${formatBirthPlaceCoordinate(resolved.latitude, resolved.longitude)} · ${formatBirthPlaceTimezone(settled.timezoneId, settled.timezoneOffset)}`
: settled.status === "error"
? "时区服务暂时不可用,稍后重试即可。"
: "按省、市、区县逐级选择即可。";
: globalIsLoading
? "正在加载该国家的地区列表…"
: globalHasError
? "该国家的地区列表暂时不可用,请稍后重试。"
: isChina
? "按省、市、区县逐级选择即可。"
: "海外地点按国家、地区、城市逐级选择,不使用搜索。";
return (
<div className="birth-place-picker">
<div className="birth-place-levels">
<label htmlFor={`${fieldPrefix}-province`}>
<span> / </span>
<label htmlFor={`${fieldPrefix}-country`}>
<span> / </span>
<Select
value={draft.provinceCode || null}
value={draft.countryCode || null}
disabled={disabled}
onValueChange={(next) => { if (typeof next === "string") chooseProvince(next); }}
onValueChange={(next) => { if (typeof next === "string") chooseCountry(next); }}
>
<SelectTrigger id={`${fieldPrefix}-province`} aria-label="出生省份">
<SelectTrigger id={`${fieldPrefix}-country`} aria-label="出生国家或地区">
<SelectValue placeholder="请选择">
{(selected) => findProvinceNode(String(selected ?? ""))?.name ?? "请选择"}
{(selected) => selected === "CN" ? "中国" : countryLabel(String(selected ?? "")) || "请选择"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{chinaProvinces.map((item) => (
<SelectItem value="CN"></SelectItem>
{worldCountries.filter((item) => item.code !== "CN").map((item) => (
<SelectItem key={item.code} value={item.code}>{item.name}</SelectItem>
))}
</SelectContent>
</Select>
</label>
{province && !virtualCity && (
{isChina && (
<label htmlFor={`${fieldPrefix}-province`}>
<span> / </span>
<Select
value={draft.provinceCode || null}
disabled={disabled}
onValueChange={(next) => { if (typeof next === "string") chooseProvince(next); }}
>
<SelectTrigger id={`${fieldPrefix}-province`} aria-label="出生省份">
<SelectValue placeholder="请选择">
{(selected) => findProvinceNode(String(selected ?? ""))?.name ?? "请选择"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{chinaProvinces.map((item) => (
<SelectItem key={item.code} value={item.code}>{item.name}</SelectItem>
))}
</SelectContent>
</Select>
</label>
)}
{isChina && province && !virtualCity && (
<label htmlFor={`${fieldPrefix}-city`}>
<span> / </span>
<Select
@@ -256,7 +405,7 @@ export function BirthPlacePicker({
</label>
)}
{city && districts.length > 0 && (
{isChina && city && districts.length > 0 && (
<label htmlFor={`${fieldPrefix}-district`}>
<span> / </span>
<Select
@@ -280,6 +429,50 @@ export function BirthPlacePicker({
</Select>
</label>
)}
{!isChina && globalData && (
<label htmlFor={`${fieldPrefix}-region`}>
<span> / / </span>
<Select
value={draft.provinceCode || null}
disabled={disabled}
onValueChange={(next) => { if (typeof next === "string") chooseGlobalRegion(next); }}
>
<SelectTrigger id={`${fieldPrefix}-region`} aria-label="出生州省或地区">
<SelectValue placeholder="请选择">
{(selected) => globalData.regions.find((item) => item.code === String(selected ?? ""))?.name ?? "请选择"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{globalData.regions.map((item) => (
<SelectItem key={item.code} value={item.code}>{item.name}</SelectItem>
))}
</SelectContent>
</Select>
</label>
)}
{!isChina && globalRegion && globalRegion.cities.length > 0 && (
<label htmlFor={`${fieldPrefix}-global-city`}>
<span></span>
<Select
value={draft.cityCode || null}
disabled={disabled}
onValueChange={(next) => { if (typeof next === "string") chooseCity(next); }}
>
<SelectTrigger id={`${fieldPrefix}-global-city`} aria-label="出生海外城市">
<SelectValue placeholder="请选择">
{((selected) => globalRegion.cities.find((item) => item.code === String(selected ?? ""))?.name ?? "请选择")}
</SelectValue>
</SelectTrigger>
<SelectContent>
{globalRegion.cities.map((item) => (
<SelectItem key={item.code} value={item.code}>{item.name}</SelectItem>
))}
</SelectContent>
</Select>
</label>
)}
</div>
<p className="birth-place-note"> / </p>