Fix learn 404, coin animation, full regions, and AI key errors.

Use numeric /learn/{num} routes, register Tailwind coin animations with方孔铜钱 UI, expand to 34 provinces, and surface missing OPENAI_API_KEY clearly.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-10 21:32:20 +08:00
parent 96b659fbe5
commit a487b17165
15 changed files with 931 additions and 159 deletions
@@ -6,29 +6,50 @@ import MarkdownContent from "@/components/learn/markdown-content";
import {
getGuaName,
getGuaNumber,
guaNumFromMark,
listGuaMarks,
markFromNum,
readLearnMarkdown,
stripFrontmatter,
} from "@/lib/content/zhouyi";
export async function generateStaticParams() {
const marks = await listGuaMarks("traditional");
return marks.map((guaMark) => ({ guaMark }));
async function resolveGuaMark(slug: string): Promise<string | null> {
const decoded = decodeURIComponent(slug);
if (/^\d{1,2}$/.test(decoded)) {
return markFromNum(decoded, "traditional");
}
const marks = await listGuaMarks("traditional");
if (marks.includes(decoded)) {
return decoded;
}
if (/^\d{2}\./.test(decoded)) {
return marks.find((m) => m === decoded || m.startsWith(decoded.split(".")[0] + ".")) ?? null;
}
return null;
}
export async function generateStaticParams() {
return Array.from({ length: 64 }, (_, i) => ({
slug: String(i + 1).padStart(2, "0"),
}));
}
export const dynamicParams = true;
export default async function GuaDetailPage({
params,
}: {
params: Promise<{ guaMark: string }>;
params: Promise<{ slug: string }>;
}) {
const { guaMark } = await params;
const marks = await listGuaMarks("traditional");
if (!marks.includes(guaMark)) {
const { slug } = await params;
const guaMark = await resolveGuaMark(slug);
if (!guaMark) {
notFound();
}
const raw = await readLearnMarkdown(guaMark, "traditional");
const content = stripFrontmatter(raw);
const num = guaNumFromMark(guaMark);
return (
<PageShell className="max-w-3xl px-4 py-8">
@@ -41,7 +62,7 @@ export default async function GuaDetailPage({
{getGuaNumber(guaMark)} · {getGuaName(guaMark)}
</div>
<MarkdownContent content={content} variant="traditional" />
<GuaFooter guaMark={guaMark} />
<GuaFooter guaMark={guaMark} guaNum={num} />
</PageShell>
);
}
@@ -6,24 +6,41 @@ import MarkdownContent from "@/components/learn/markdown-content";
import {
getGuaName,
getGuaNumber,
guaNumFromMark,
listGuaMarks,
markFromNum,
readLearnMarkdown,
stripFrontmatter,
} from "@/lib/content/zhouyi";
export async function generateStaticParams() {
const marks = await listGuaMarks("simplified");
return marks.map((guaMark) => ({ guaMark }));
async function resolveGuaMark(slug: string): Promise<string | null> {
const decoded = decodeURIComponent(slug);
if (/^\d{1,2}$/.test(decoded)) {
return markFromNum(decoded, "simplified");
}
const marks = await listGuaMarks("simplified");
if (marks.includes(decoded)) {
return decoded;
}
return null;
}
export async function generateStaticParams() {
return Array.from({ length: 64 }, (_, i) => ({
slug: String(i + 1).padStart(2, "0"),
}));
}
export const dynamicParams = true;
export default async function GuaOtherDetailPage({
params,
}: {
params: Promise<{ guaMark: string }>;
params: Promise<{ slug: string }>;
}) {
const { guaMark } = await params;
const marks = await listGuaMarks("simplified");
if (!marks.includes(guaMark)) {
const { slug } = await params;
const guaMark = await resolveGuaMark(slug);
if (!guaMark) {
notFound();
}
@@ -41,7 +58,7 @@ export default async function GuaOtherDetailPage({
{getGuaNumber(guaMark)} · {getGuaName(guaMark)}
</div>
<MarkdownContent content={content} variant="simplified" />
<GuaFooter guaMark={guaMark} />
<GuaFooter guaMark={guaMark} guaNum={guaNumFromMark(guaMark)} />
</PageShell>
);
}
+3 -3
View File
@@ -2,7 +2,7 @@ import Link from "next/link";
import PageShell from "@/components/page-shell";
import {
getGuaName,
getGuaNumber,
guaNumFromMark,
listGuaMarks,
} from "@/lib/content/zhouyi";
@@ -35,11 +35,11 @@ export default async function LearnOtherPage() {
{guaMarks.map((mark) => (
<tr key={mark} className="border-t">
<td className="px-4 py-2 font-mono text-muted-foreground">
{getGuaNumber(mark)}
{guaNumFromMark(mark)}
</td>
<td className="px-4 py-2">
<Link
href={`/learn/other/${encodeURIComponent(mark)}`}
href={`/learn/other/${guaNumFromMark(mark)}`}
className="text-primary underline-offset-4 hover:underline"
>
{getGuaName(mark)}
+3 -3
View File
@@ -2,7 +2,7 @@ import Link from "next/link";
import PageShell from "@/components/page-shell";
import {
getGuaName,
getGuaNumber,
guaNumFromMark,
listGuaMarks,
} from "@/lib/content/zhouyi";
@@ -35,11 +35,11 @@ export default async function LearnPage() {
{guaMarks.map((mark) => (
<tr key={mark} className="border-t">
<td className="px-4 py-2 font-mono text-muted-foreground">
{getGuaNumber(mark)}
{guaNumFromMark(mark)}
</td>
<td className="px-4 py-2">
<Link
href={`/learn/${encodeURIComponent(mark)}`}
href={`/learn/${guaNumFromMark(mark)}`}
className="text-primary underline-offset-4 hover:underline"
>
{getGuaName(mark)}
+23 -18
View File
@@ -2,7 +2,13 @@ import React, { useEffect, useState } from "react";
import clsx from "clsx";
const rotationDuration = 3800;
const bezier = "cubic-bezier(0.645,0.045,0.355,1)";
const COIN_ANIM: Record<string, string> = {
"front-front": "animate-coin-front-front",
"front-back": "animate-coin-front-back",
"back-front": "animate-coin-back-front",
"back-back": "animate-coin-back-back",
};
function Coin(props: {
frontList: boolean[];
@@ -15,7 +21,6 @@ function Coin(props: {
if (!props.rotation) {
return;
}
const id = setTimeout(() => {
setLastFront(props.frontList);
props.onTransitionEnd();
@@ -37,23 +42,30 @@ function Coin(props: {
);
}
/** 方孔铜钱造型 */
function CoinFace({ side }: { side: "front" | "back" }) {
const isFront = side === "front";
return (
<div
className={clsx(
"absolute inset-0 flex items-center justify-center rounded-full border-[3px] shadow-inner",
"absolute inset-0 rounded-full border-[3px] shadow-md",
isFront
? "border-amber-500 bg-gradient-to-br from-amber-200 to-amber-400 text-amber-950"
: "border-stone-500 bg-gradient-to-br from-stone-300 to-stone-500 text-stone-800",
? "border-amber-600 bg-gradient-to-br from-amber-300 via-amber-400 to-amber-600"
: "border-stone-600 bg-gradient-to-br from-stone-400 via-stone-500 to-stone-700",
)}
style={{
backfaceVisibility: "hidden",
transform: isFront ? undefined : "rotateY(180deg)",
}}
>
<span className="select-none text-lg font-bold sm:text-xl">
{isFront ? "正" : "反"}
<div className="absolute inset-[18%] rounded-sm border-2 border-amber-800/40 bg-amber-900/10" />
<span
className={clsx(
"absolute bottom-1 left-0 right-0 text-center text-[10px] font-bold sm:text-xs",
isFront ? "text-amber-950/80" : "text-stone-200/90",
)}
>
{isFront ? "乾" : "坤"}
</span>
</div>
);
@@ -64,24 +76,17 @@ function CoinItem(props: {
lastFront: boolean;
rotation: boolean;
}) {
let animate = "";
if (props.rotation) {
animate = `animate-[coin-${getFront(props.lastFront)}-${getFront(
props.front,
)}_${rotationDuration / 1000}s_${bezier}]`;
}
const animKey = `${getFront(props.lastFront)}-${getFront(props.front)}`;
const animClass = props.rotation ? COIN_ANIM[animKey] : "";
return (
<div
className="h-16 w-16 sm:h-20 sm:w-20"
style={{ perspective: "800px" }}
>
<div className="h-[4.5rem] w-[4.5rem] sm:h-20 sm:w-20" style={{ perspective: "900px" }}>
<div
style={{
transform: `rotateY(${props.front ? 0 : 180}deg)`,
transformStyle: "preserve-3d",
}}
className={clsx("relative h-full w-full", animate)}
className={clsx("relative h-full w-full", animClass)}
>
<CoinFace side="front" />
<CoinFace side="back" />
+10 -2
View File
@@ -1,12 +1,20 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Sparkles } from "lucide-react";
import { guaNumFromMark } from "@/lib/content/zhouyi";
export default function GuaFooter({ guaMark }: { guaMark: string }) {
export default function GuaFooter({
guaMark,
guaNum,
}: {
guaMark: string;
guaNum?: string;
}) {
const num = guaNum ?? guaNumFromMark(guaMark);
return (
<div className="mt-8 flex flex-wrap gap-3 border-t pt-6">
<Button asChild size="sm">
<Link href={`/liuyao?gua=${encodeURIComponent(guaMark)}`}>
<Link href={`/liuyao?gua=${num}`}>
<Sparkles size={16} className="mr-1" />
</Link>
+5 -2
View File
@@ -24,9 +24,12 @@ function resolveLearnHref(
if (href.endsWith("/index.md")) {
const mark = href.replace(/\/index\.md$/, "").replace(/^\.\//, "");
if (mark.startsWith("other/")) {
return `/learn/other/${mark.slice("other/".length)}`;
const folder = mark.slice("other/".length);
const num = folder.split(".")[0];
return `/learn/other/${num}`;
}
return `${base}/${mark}`;
const num = mark.split(".")[0];
return `${base}/${num}`;
}
return href;
}
+6 -3
View File
@@ -1,4 +1,6 @@
import React from "react";
import Link from "next/link";
import { guaNumFromMark } from "@/lib/content/zhouyi";
export interface ResultObj {
guaTitle: string;
@@ -8,16 +10,17 @@ export interface ResultObj {
}
function Result(props: ResultObj) {
const guaNum = guaNumFromMark(props.guaMark);
return (
<div className="flex flex-col items-start justify-center gap-2 sm:gap-3">
{props.guaTitle}
<a
<Link
className="group flex items-center gap-1 font-medium text-primary/80 underline underline-offset-4 transition-colors hover:text-primary/100"
href={`/learn/${props.guaMark}`}
href={`/learn/${guaNum}`}
>
<div className="mt-1 h-[90%] w-1.5 bg-blue-400/80 transition-colors group-hover:bg-blue-400/100" />
<span>{props.guaResult}</span>
</a>
</Link>
<span className="text-sm italic text-muted-foreground">
{props.guaChange}
</span>
+26 -31
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { bool } from "aimless.js";
import Coin from "@/components/coin";
import Hexagram from "@/components/hexagram";
@@ -13,7 +13,7 @@ import {
YAO_LABELS,
} from "@/lib/calc/hexagram";
const AUTO_DELAY = 600;
const AUTO_DELAY = 800;
interface HexagramInputProps {
mode: "online" | "offline";
@@ -30,43 +30,30 @@ export default function HexagramInput({
}: HexagramInputProps) {
const [frontList, setFrontList] = useState([true, true, true]);
const [rotation, setRotation] = useState(false);
const [hexagramList, setHexagramList] = useState<
GuaResult["list"]
>([]);
const [count, setCount] = useState(0);
const [hexagramList, setHexagramList] = useState<GuaResult["list"]>([]);
const [offlineCounts, setOfflineCounts] = useState<(number | null)[]>(
Array(6).fill(null),
);
const complete = hexagramList.length === 6;
useEffect(() => {
setHexagramList([]);
setCount(0);
setOfflineCounts(Array(6).fill(null));
setRotation(false);
}, [mode]);
useEffect(() => {
if (
mode !== "online" ||
!enabled ||
rotation ||
hexagramList.length >= 6 ||
count >= 6
) {
return;
}
const timer = setTimeout(startOnlineCast, AUTO_DELAY);
return () => clearTimeout(timer);
}, [mode, enabled, rotation, count, hexagramList.length]);
function finishList(list: GuaResult["list"]) {
const finishList = useCallback(
(list: GuaResult["list"]) => {
const result = computeGuaResult(list);
if (result) {
onResult({ list, result });
}
}
},
[onResult],
);
function onTransitionEnd() {
const onTransitionEnd = useCallback(() => {
setRotation(false);
const frontCount = frontList.reduce(
(acc, val) => (val ? acc + 1 : acc),
@@ -86,16 +73,23 @@ export default function HexagramInput({
}
return newList;
});
}
}, [frontList, finishList]);
function startOnlineCast() {
const startOnlineCast = useCallback(() => {
if (rotation || !enabled || hexagramList.length >= 6) {
return;
}
setFrontList([bool(), bool(), bool()]);
setRotation(true);
setCount((c) => c + 1);
}, [rotation, enabled, hexagramList.length]);
useEffect(() => {
if (mode !== "online" || !enabled || rotation || complete) {
return;
}
const timer = setTimeout(startOnlineCast, AUTO_DELAY);
return () => clearTimeout(timer);
}, [mode, enabled, rotation, complete, hexagramList.length, startOnlineCast]);
function handleOfflineSelect(index: number, frontCount: number) {
const next = [...offlineCounts];
@@ -114,13 +108,11 @@ export default function HexagramInput({
function handleReset() {
setHexagramList([]);
setCount(0);
setOfflineCounts(Array(6).fill(null));
setRotation(false);
onClear();
}
const complete = hexagramList.length === 6;
const offlineReady = offlineCounts.every((v) => v !== null);
if (!enabled) {
@@ -133,7 +125,7 @@ export default function HexagramInput({
return (
<div className="flex w-full flex-col items-center gap-4">
{mode === "online" && !complete && (
{mode === "online" && (
<>
<Coin
onTransitionEnd={onTransitionEnd}
@@ -143,9 +135,12 @@ export default function HexagramInput({
<span className="text-lg font-medium">
🎲 {" "}
<span className="font-mono text-xl font-bold text-orange-500">
{count === 0 ? "-/-" : `${count}/6`}
{complete ? "6/6" : `${hexagramList.length + (rotation ? 1 : 0)}/6`}
</span>{" "}
{rotation && (
<span className="ml-2 text-sm text-muted-foreground"></span>
)}
</span>
</>
)}
+3 -3
View File
@@ -71,10 +71,10 @@ export default function RegionSelect({
))}
</select>
</div>
{cityOptional && provinceCode && (
{cityOptional && provinceCode && location && (
<p className="text-xs text-muted-foreground">
使
{location ? `${location.name}${location.longitude}°)` : "—"}
使
{location.name}{location.longitude}°
</p>
)}
</div>
+4
View File
@@ -18,6 +18,10 @@ export async function streamAIResponse(
system: string,
user: string,
): Promise<{ data?: ReturnType<typeof createStreamableValue<string>>["value"]; error?: string }> {
if (!process.env.OPENAI_API_KEY?.trim()) {
return { error: "未配置 OPENAI_API_KEY,请在 .env.local 或 Docker env_file 中设置" };
}
const stream = createStreamableValue<string>();
try {
+15
View File
@@ -21,6 +21,21 @@ export async function listGuaMarks(
.sort();
}
/** 序号 slug,如 "35" */
export function guaNumFromMark(guaMark: string): string {
return guaMark.split(".")[0];
}
/** 由序号解析目录名,如 "35" → "35.火地晋" */
export async function markFromNum(
num: string,
variant: LearnVariant = "traditional",
): Promise<string | null> {
const padded = num.padStart(2, "0").slice(-2);
const marks = await listGuaMarks(variant);
return marks.find((m) => m.startsWith(`${padded}.`)) ?? null;
}
export async function readGuaMarkdown(guaMark: string): Promise<string> {
const filePath = path.join(DOCS_ROOT, guaMark, "index.md");
return fs.readFile(filePath, "utf-8");
+700 -69
View File
@@ -3,105 +3,736 @@
"name": "北京市",
"longitude": 116.4074,
"children": {
"110101": { "name": "东城区", "longitude": 116.4164 },
"110105": { "name": "朝阳区", "longitude": 116.4434 },
"110108": { "name": "海淀区", "longitude": 116.2983 }
"110101": {
"name": "东城区",
"longitude": 116.4164
},
"110105": {
"name": "朝阳区",
"longitude": 116.4434
},
"110108": {
"name": "海淀区",
"longitude": 116.2983
},
"110114": {
"name": "昌平区",
"longitude": 116.2312
}
}
},
"120000": {
"name": "天津市",
"longitude": 117.201,
"children": {
"120101": {
"name": "和平区",
"longitude": 117.2147
},
"120103": {
"name": "河西区",
"longitude": 117.2234
},
"120110": {
"name": "东丽区",
"longitude": 117.3143
},
"120116": {
"name": "滨海新区",
"longitude": 117.7105
}
}
},
"130000": {
"name": "河北省",
"longitude": 114.5149,
"children": {
"130100": {
"name": "石家庄市",
"longitude": 114.5149
},
"130200": {
"name": "唐山市",
"longitude": 118.1802
},
"130300": {
"name": "秦皇岛市",
"longitude": 119.6005
},
"130600": {
"name": "保定市",
"longitude": 115.4648
},
"131000": {
"name": "廊坊市",
"longitude": 116.6838
}
}
},
"140000": {
"name": "山西省",
"longitude": 112.5624,
"children": {
"140100": {
"name": "太原市",
"longitude": 112.5624
},
"140200": {
"name": "大同市",
"longitude": 113.3001
},
"140500": {
"name": "晋城市",
"longitude": 112.8513
},
"140700": {
"name": "晋中市",
"longitude": 112.7528
}
}
},
"150000": {
"name": "内蒙古自治区",
"longitude": 111.7652,
"children": {
"150100": {
"name": "呼和浩特市",
"longitude": 111.7652
},
"150200": {
"name": "包头市",
"longitude": 109.8403
},
"150400": {
"name": "赤峰市",
"longitude": 118.8869
},
"150500": {
"name": "通辽市",
"longitude": 122.2434
}
}
},
"210000": {
"name": "辽宁省",
"longitude": 123.4315,
"children": {
"210100": {
"name": "沈阳市",
"longitude": 123.4315
},
"210200": {
"name": "大连市",
"longitude": 121.6147
},
"210300": {
"name": "鞍山市",
"longitude": 122.9946
},
"210600": {
"name": "丹东市",
"longitude": 124.3545
}
}
},
"220000": {
"name": "吉林省",
"longitude": 125.3235,
"children": {
"220100": {
"name": "长春市",
"longitude": 125.3235
},
"220200": {
"name": "吉林市",
"longitude": 126.5494
},
"220300": {
"name": "四平市",
"longitude": 124.3505
},
"222400": {
"name": "延边州",
"longitude": 129.5132
}
}
},
"230000": {
"name": "黑龙江省",
"longitude": 126.6425,
"children": {
"230100": {
"name": "哈尔滨市",
"longitude": 126.6425
},
"230200": {
"name": "齐齐哈尔市",
"longitude": 123.9182
},
"230600": {
"name": "大庆市",
"longitude": 125.1031
},
"231000": {
"name": "牡丹江市",
"longitude": 129.6332
}
}
},
"310000": {
"name": "上海市",
"longitude": 121.4737,
"children": {
"310101": { "name": "黄浦区", "longitude": 121.4903 },
"310115": { "name": "浦东新区", "longitude": 121.5447 },
"310104": { "name": "徐汇区", "longitude": 121.4365 }
}
"310101": {
"name": "黄浦区",
"longitude": 121.4903
},
"440000": {
"name": "广东省",
"longitude": 113.2665,
"children": {
"440100": { "name": "广州市", "longitude": 113.2644 },
"440300": { "name": "深圳市", "longitude": 114.0579 },
"440600": { "name": "佛山市", "longitude": 113.1214 }
}
"310104": {
"name": "徐汇区",
"longitude": 121.4365
},
"330000": {
"name": "浙江省",
"longitude": 120.1536,
"children": {
"330100": { "name": "杭州市", "longitude": 120.1551 },
"330200": { "name": "宁波市", "longitude": 121.5503 },
"330300": { "name": "温州市", "longitude": 120.6994 }
"310115": {
"name": "浦东新区",
"longitude": 121.5447
},
"310117": {
"name": "松江区",
"longitude": 121.2277
}
}
},
"320000": {
"name": "江苏省",
"longitude": 118.7969,
"children": {
"320100": { "name": "南京市", "longitude": 118.7969 },
"320500": { "name": "苏州市", "longitude": 120.5853 },
"320200": { "name": "无锡市", "longitude": 120.3119 }
"320100": {
"name": "南京市",
"longitude": 118.7969
},
"320200": {
"name": "无锡市",
"longitude": 120.3119
},
"320300": {
"name": "徐州市",
"longitude": 117.1848
},
"320500": {
"name": "苏州市",
"longitude": 120.5853
},
"320600": {
"name": "南通市",
"longitude": 120.8945
}
}
},
"510000": {
"name": "四川省",
"longitude": 104.0665,
"330000": {
"name": "浙江省",
"longitude": 120.1536,
"children": {
"510100": { "name": "成都市", "longitude": 104.0665 },
"510700": { "name": "绵阳市", "longitude": 104.6796 }
"330100": {
"name": "杭州市",
"longitude": 120.1551
},
"330200": {
"name": "宁波市",
"longitude": 121.5503
},
"330300": {
"name": "温州市",
"longitude": 120.6994
},
"330400": {
"name": "嘉兴市",
"longitude": 120.7555
},
"330700": {
"name": "金华市",
"longitude": 119.6474
}
}
},
"420000": {
"name": "湖北省",
"longitude": 114.3419,
"340000": {
"name": "安徽省",
"longitude": 117.283,
"children": {
"420100": { "name": "武汉市", "longitude": 114.3055 },
"420500": { "name": "宜昌市", "longitude": 111.2865 }
}
"340100": {
"name": "合肥市",
"longitude": 117.283
},
"610000": {
"name": "陕西省",
"longitude": 108.9398,
"children": {
"610100": { "name": "西安市", "longitude": 108.9398 },
"610300": { "name": "宝鸡市", "longitude": 107.2376 }
}
"340200": {
"name": "芜湖市",
"longitude": 118.4329
},
"370000": {
"name": "山东省",
"longitude": 117.0009,
"children": {
"370100": { "name": "济南市", "longitude": 117.1205 },
"370200": { "name": "青岛市", "longitude": 120.3826 },
"370300": { "name": "淄博市", "longitude": 118.0550 },
"370600": { "name": "烟台市", "longitude": 121.4479 },
"370700": { "name": "潍坊市", "longitude": 119.1619 },
"371300": { "name": "临沂市", "longitude": 118.3565 }
}
"340300": {
"name": "蚌埠市",
"longitude": 117.3889
},
"430000": {
"name": "湖南省",
"longitude": 112.9834,
"children": {
"430100": { "name": "长沙市", "longitude": 112.9388 },
"430200": { "name": "株洲市", "longitude": 113.1340 }
"341200": {
"name": "阜阳市",
"longitude": 115.8142
}
},
"500000": {
"name": "重庆市",
"longitude": 106.5516,
"children": {
"500103": { "name": "渝中区", "longitude": 106.5629 },
"500112": { "name": "渝北区", "longitude": 106.6304 }
}
},
"350000": {
"name": "福建省",
"longitude": 119.2965,
"children": {
"350100": { "name": "福州市", "longitude": 119.2965 },
"350200": { "name": "厦门市", "longitude": 118.0894 }
"350100": {
"name": "福州市",
"longitude": 119.2965
},
"350200": {
"name": "厦门市",
"longitude": 118.0894
},
"350500": {
"name": "泉州市",
"longitude": 118.6757
},
"350600": {
"name": "漳州市",
"longitude": 117.6471
}
}
},
"360000": {
"name": "江西省",
"longitude": 115.9092,
"children": {
"360100": {
"name": "南昌市",
"longitude": 115.9092
},
"360400": {
"name": "九江市",
"longitude": 115.9928
},
"360700": {
"name": "赣州市",
"longitude": 114.935
},
"360900": {
"name": "宜春市",
"longitude": 114.4168
}
}
},
"370000": {
"name": "山东省",
"longitude": 117.0009,
"children": {
"370100": {
"name": "济南市",
"longitude": 117.1205
},
"370200": {
"name": "青岛市",
"longitude": 120.3826
},
"370300": {
"name": "淄博市",
"longitude": 118.055
},
"370600": {
"name": "烟台市",
"longitude": 121.4479
},
"370700": {
"name": "潍坊市",
"longitude": 119.1619
},
"371300": {
"name": "临沂市",
"longitude": 118.3565
},
"371400": {
"name": "德州市",
"longitude": 116.3575
},
"371500": {
"name": "聊城市",
"longitude": 115.9855
}
}
},
"410000": {
"name": "河南省",
"longitude": 113.6254,
"children": {
"410100": {
"name": "郑州市",
"longitude": 113.6254
},
"410300": {
"name": "洛阳市",
"longitude": 112.454
},
"410700": {
"name": "新乡市",
"longitude": 113.9268
},
"411300": {
"name": "南阳市",
"longitude": 112.5288
},
"411400": {
"name": "商丘市",
"longitude": 115.6564
}
}
},
"420000": {
"name": "湖北省",
"longitude": 114.3419,
"children": {
"420100": {
"name": "武汉市",
"longitude": 114.3055
},
"420500": {
"name": "宜昌市",
"longitude": 111.2865
},
"420600": {
"name": "襄阳市",
"longitude": 112.1226
},
"421000": {
"name": "荆州市",
"longitude": 112.239
}
}
},
"430000": {
"name": "湖南省",
"longitude": 112.9834,
"children": {
"430100": {
"name": "长沙市",
"longitude": 112.9388
},
"430200": {
"name": "株洲市",
"longitude": 113.134
},
"430300": {
"name": "湘潭市",
"longitude": 112.944
},
"430600": {
"name": "岳阳市",
"longitude": 113.1289
},
"430700": {
"name": "常德市",
"longitude": 111.6985
}
}
},
"440000": {
"name": "广东省",
"longitude": 113.2665,
"children": {
"440100": {
"name": "广州市",
"longitude": 113.2644
},
"440300": {
"name": "深圳市",
"longitude": 114.0579
},
"440400": {
"name": "珠海市",
"longitude": 113.5765
},
"440600": {
"name": "佛山市",
"longitude": 113.1214
},
"441300": {
"name": "惠州市",
"longitude": 114.4162
},
"441900": {
"name": "东莞市",
"longitude": 113.7518
},
"442000": {
"name": "中山市",
"longitude": 113.3928
}
}
},
"450000": {
"name": "广西壮族自治区",
"longitude": 108.3275,
"children": {
"450100": {
"name": "南宁市",
"longitude": 108.3275
},
"450300": {
"name": "桂林市",
"longitude": 110.299
},
"450500": {
"name": "北海市",
"longitude": 109.1201
},
"450700": {
"name": "钦州市",
"longitude": 108.6544
}
}
},
"460000": {
"name": "海南省",
"longitude": 110.3492,
"children": {
"460100": {
"name": "海口市",
"longitude": 110.3492
},
"460200": {
"name": "三亚市",
"longitude": 109.5119
},
"469006": {
"name": "万宁市",
"longitude": 110.3911
}
}
},
"500000": {
"name": "重庆市",
"longitude": 106.5516,
"children": {
"500103": {
"name": "渝中区",
"longitude": 106.5629
},
"500106": {
"name": "沙坪坝区",
"longitude": 106.4569
},
"500112": {
"name": "渝北区",
"longitude": 106.6304
},
"500117": {
"name": "合川区",
"longitude": 106.2656
}
}
},
"510000": {
"name": "四川省",
"longitude": 104.0665,
"children": {
"510100": {
"name": "成都市",
"longitude": 104.0665
},
"510500": {
"name": "泸州市",
"longitude": 105.4433
},
"510700": {
"name": "绵阳市",
"longitude": 104.6796
},
"511300": {
"name": "南充市",
"longitude": 106.1107
},
"511500": {
"name": "宜宾市",
"longitude": 104.6432
}
}
},
"520000": {
"name": "贵州省",
"longitude": 106.7135,
"children": {
"520100": {
"name": "贵阳市",
"longitude": 106.7135
},
"520300": {
"name": "遵义市",
"longitude": 106.9274
},
"520500": {
"name": "毕节市",
"longitude": 105.285
}
}
},
"530000": {
"name": "云南省",
"longitude": 102.71,
"children": {
"530100": {
"name": "昆明市",
"longitude": 102.71
},
"530300": {
"name": "曲靖市",
"longitude": 103.7962
},
"532500": {
"name": "红河州",
"longitude": 103.384
},
"532900": {
"name": "大理州",
"longitude": 100.2257
}
}
},
"540000": {
"name": "西藏自治区",
"longitude": 91.1172,
"children": {
"540100": {
"name": "拉萨市",
"longitude": 91.1172
},
"540200": {
"name": "日喀则市",
"longitude": 88.8851
}
}
},
"610000": {
"name": "陕西省",
"longitude": 108.9398,
"children": {
"610100": {
"name": "西安市",
"longitude": 108.9398
},
"610300": {
"name": "宝鸡市",
"longitude": 107.2376
},
"610400": {
"name": "咸阳市",
"longitude": 108.7093
},
"610800": {
"name": "榆林市",
"longitude": 109.7346
}
}
},
"620000": {
"name": "甘肃省",
"longitude": 103.8343,
"children": {
"620100": {
"name": "兰州市",
"longitude": 103.8343
},
"620500": {
"name": "天水市",
"longitude": 105.7249
},
"620700": {
"name": "张掖市",
"longitude": 100.4498
}
}
},
"630000": {
"name": "青海省",
"longitude": 101.7801,
"children": {
"630100": {
"name": "西宁市",
"longitude": 101.7801
},
"632800": {
"name": "海西州",
"longitude": 97.3701
}
}
},
"640000": {
"name": "宁夏回族自治区",
"longitude": 106.2586,
"children": {
"640100": {
"name": "银川市",
"longitude": 106.2586
},
"640200": {
"name": "石嘴山市",
"longitude": 106.3833
}
}
},
"650000": {
"name": "新疆维吾尔自治区",
"longitude": 87.6177,
"children": {
"650100": {
"name": "乌鲁木齐市",
"longitude": 87.6177
},
"650200": {
"name": "克拉玛依市",
"longitude": 84.8739
},
"652900": {
"name": "阿克苏地区",
"longitude": 80.2606
}
}
},
"710000": {
"name": "台湾省",
"longitude": 121.5091,
"children": {
"710100": {
"name": "台北市",
"longitude": 121.5654
},
"710200": {
"name": "高雄市",
"longitude": 120.3014
}
}
},
"810000": {
"name": "香港特别行政区",
"longitude": 114.1694,
"children": {
"810001": {
"name": "中西区",
"longitude": 114.1544
},
"810012": {
"name": "湾仔区",
"longitude": 114.1829
}
}
},
"820000": {
"name": "澳门特别行政区",
"longitude": 113.5439,
"children": {
"820001": {
"name": "花地玛堂区",
"longitude": 113.5491
},
"820003": {
"name": "大堂区",
"longitude": 113.5536
}
}
}
}
+62
View File
@@ -0,0 +1,62 @@
/**
* 生成 lib/data/regions.json — 全国省级 + 主要城市
* 运行:node scripts/generate-regions.mjs
*/
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** [code, name, lon, cities: [code, name, lon][]] */
const DATA = [
["110000", "北京市", 116.4074, [["110101", "东城区", 116.4164], ["110105", "朝阳区", 116.4434], ["110108", "海淀区", 116.2983], ["110114", "昌平区", 116.2312]]],
["120000", "天津市", 117.2010, [["120101", "和平区", 117.2147], ["120103", "河西区", 117.2234], ["120110", "东丽区", 117.3143], ["120116", "滨海新区", 117.7105]]],
["130000", "河北省", 114.5149, [["130100", "石家庄市", 114.5149], ["130200", "唐山市", 118.1802], ["130300", "秦皇岛市", 119.6005], ["130600", "保定市", 115.4648], ["131000", "廊坊市", 116.6838]]],
["140000", "山西省", 112.5624, [["140100", "太原市", 112.5624], ["140200", "大同市", 113.3001], ["140500", "晋城市", 112.8513], ["140700", "晋中市", 112.7528]]],
["150000", "内蒙古自治区", 111.7652, [["150100", "呼和浩特市", 111.7652], ["150200", "包头市", 109.8403], ["150400", "赤峰市", 118.8869], ["150500", "通辽市", 122.2434]]],
["210000", "辽宁省", 123.4315, [["210100", "沈阳市", 123.4315], ["210200", "大连市", 121.6147], ["210300", "鞍山市", 122.9946], ["210600", "丹东市", 124.3545]]],
["220000", "吉林省", 125.3235, [["220100", "长春市", 125.3235], ["220200", "吉林市", 126.5494], ["220300", "四平市", 124.3505], ["222400", "延边州", 129.5132]]],
["230000", "黑龙江省", 126.6425, [["230100", "哈尔滨市", 126.6425], ["230600", "大庆市", 125.1031], ["231000", "牡丹江市", 129.6332], ["230200", "齐齐哈尔市", 123.9182]]],
["310000", "上海市", 121.4737, [["310101", "黄浦区", 121.4903], ["310104", "徐汇区", 121.4365], ["310115", "浦东新区", 121.5447], ["310117", "松江区", 121.2277]]],
["320000", "江苏省", 118.7969, [["320100", "南京市", 118.7969], ["320200", "无锡市", 120.3119], ["320500", "苏州市", 120.5853], ["320300", "徐州市", 117.1848], ["320600", "南通市", 120.8945]]],
["330000", "浙江省", 120.1536, [["330100", "杭州市", 120.1551], ["330200", "宁波市", 121.5503], ["330300", "温州市", 120.6994], ["330400", "嘉兴市", 120.7555], ["330700", "金华市", 119.6474]]],
["340000", "安徽省", 117.2830, [["340100", "合肥市", 117.2830], ["340200", "芜湖市", 118.4329], ["340300", "蚌埠市", 117.3889], ["341200", "阜阳市", 115.8142]]],
["350000", "福建省", 119.2965, [["350100", "福州市", 119.2965], ["350200", "厦门市", 118.0894], ["350500", "泉州市", 118.6757], ["350600", "漳州市", 117.6471]]],
["360000", "江西省", 115.9092, [["360100", "南昌市", 115.9092], ["360400", "九江市", 115.9928], ["360700", "赣州市", 114.9350], ["360900", "宜春市", 114.4168]]],
["370000", "山东省", 117.0009, [["370100", "济南市", 117.1205], ["370200", "青岛市", 120.3826], ["370300", "淄博市", 118.0550], ["370600", "烟台市", 121.4479], ["370700", "潍坊市", 119.1619], ["371300", "临沂市", 118.3565], ["371400", "德州市", 116.3575], ["371500", "聊城市", 115.9855]]],
["410000", "河南省", 113.6254, [["410100", "郑州市", 113.6254], ["410300", "洛阳市", 112.4540], ["410700", "新乡市", 113.9268], ["411300", "南阳市", 112.5288], ["411400", "商丘市", 115.6564]]],
["420000", "湖北省", 114.3419, [["420100", "武汉市", 114.3055], ["420500", "宜昌市", 111.2865], ["420600", "襄阳市", 112.1226], ["421000", "荆州市", 112.2390]]],
["430000", "湖南省", 112.9834, [["430100", "长沙市", 112.9388], ["430200", "株洲市", 113.1340], ["430300", "湘潭市", 112.9440], ["430600", "岳阳市", 113.1289], ["430700", "常德市", 111.6985]]],
["440000", "广东省", 113.2665, [["440100", "广州市", 113.2644], ["440300", "深圳市", 114.0579], ["440400", "珠海市", 113.5765], ["440600", "佛山市", 113.1214], ["441300", "惠州市", 114.4162], ["441900", "东莞市", 113.7518], ["442000", "中山市", 113.3928]]],
["450000", "广西壮族自治区", 108.3275, [["450100", "南宁市", 108.3275], ["450300", "桂林市", 110.2990], ["450500", "北海市", 109.1201], ["450700", "钦州市", 108.6544]]],
["460000", "海南省", 110.3492, [["460100", "海口市", 110.3492], ["460200", "三亚市", 109.5119], ["469006", "万宁市", 110.3911]]],
["500000", "重庆市", 106.5516, [["500103", "渝中区", 106.5629], ["500112", "渝北区", 106.6304], ["500106", "沙坪坝区", 106.4569], ["500117", "合川区", 106.2656]]],
["510000", "四川省", 104.0665, [["510100", "成都市", 104.0665], ["510700", "绵阳市", 104.6796], ["511300", "南充市", 106.1107], ["511500", "宜宾市", 104.6432], ["510500", "泸州市", 105.4433]]],
["520000", "贵州省", 106.7135, [["520100", "贵阳市", 106.7135], ["520300", "遵义市", 106.9274], ["520500", "毕节市", 105.2850]]],
["530000", "云南省", 102.7100, [["530100", "昆明市", 102.7100], ["530300", "曲靖市", 103.7962], ["532900", "大理州", 100.2257], ["532500", "红河州", 103.3840]]],
["540000", "西藏自治区", 91.1172, [["540100", "拉萨市", 91.1172], ["540200", "日喀则市", 88.8851]]],
["610000", "陕西省", 108.9398, [["610100", "西安市", 108.9398], ["610300", "宝鸡市", 107.2376], ["610400", "咸阳市", 108.7093], ["610800", "榆林市", 109.7346]]],
["620000", "甘肃省", 103.8343, [["620100", "兰州市", 103.8343], ["620500", "天水市", 105.7249], ["620700", "张掖市", 100.4498]]],
["630000", "青海省", 101.7801, [["630100", "西宁市", 101.7801], ["632800", "海西州", 97.3701]]],
["640000", "宁夏回族自治区", 106.2586, [["640100", "银川市", 106.2586], ["640200", "石嘴山市", 106.3833]]],
["650000", "新疆维吾尔自治区", 87.6177, [["650100", "乌鲁木齐市", 87.6177], ["650200", "克拉玛依市", 84.8739], ["652900", "阿克苏地区", 80.2606]]],
["710000", "台湾省", 121.5091, [["710100", "台北市", 121.5654], ["710200", "高雄市", 120.3014]]],
["810000", "香港特别行政区", 114.1694, [["810001", "中西区", 114.1544], ["810012", "湾仔区", 114.1829]]],
["820000", "澳门特别行政区", 113.5439, [["820001", "花地玛堂区", 113.5491], ["820003", "大堂区", 113.5536]]],
];
const regions = {};
for (const [pCode, pName, pLon, cities] of DATA) {
regions[pCode] = {
name: pName,
longitude: pLon,
children: Object.fromEntries(
cities.map(([cCode, cName, cLon]) => [cCode, { name: cName, longitude: cLon }]),
),
};
}
const out = path.join(__dirname, "../lib/data/regions.json");
fs.writeFileSync(out, JSON.stringify(regions, null, 2) + "\n", "utf-8");
console.log("Wrote", out, "provinces:", Object.keys(regions).length);
+8
View File
@@ -84,6 +84,14 @@ module.exports = {
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"coin-front-front":
"coin-front-front 3.8s cubic-bezier(0.645,0.045,0.355,1) forwards",
"coin-front-back":
"coin-front-back 3.8s cubic-bezier(0.645,0.045,0.355,1) forwards",
"coin-back-front":
"coin-back-front 3.8s cubic-bezier(0.645,0.045,0.355,1) forwards",
"coin-back-back":
"coin-back-back 3.8s cubic-bezier(0.645,0.045,0.355,1) forwards",
},
},
},