feat(frontend): use offline global birth place hierarchy
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user