692 lines
21 KiB
TypeScript
692 lines
21 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
|
||
import { Card } from "@/components/ui/Card";
|
||
import { TextSkeleton } from "@/components/ui/Skeleton";
|
||
import { ErrorState } from "@/components/shared/StateViews";
|
||
import { SundialDisk } from "@/components/time/SundialDisk";
|
||
import { charactersApi, timeApi } from "@/lib/api";
|
||
import { describeError } from "@/lib/utils/error";
|
||
import type {
|
||
AcquaintanceInfo,
|
||
Anniversary,
|
||
CreateAnniversaryRequest,
|
||
TimeConfig,
|
||
TimeSnapshotResponse,
|
||
} from "@/types/api";
|
||
|
||
// ============================================================
|
||
// 辰正 · 时态感知
|
||
// 实时时间展示 + 十二时辰流 + 自动刷新的后端数据
|
||
// ============================================================
|
||
|
||
// —— 十二时辰 ——
|
||
const SHICHEN_KEYS = [
|
||
"zi",
|
||
"chou",
|
||
"yin",
|
||
"mao",
|
||
"chen",
|
||
"si",
|
||
"wu",
|
||
"wei",
|
||
"shen",
|
||
"you",
|
||
"xu",
|
||
"hai",
|
||
] as const;
|
||
|
||
const SHICHEN_HOURS: Record<string, number> = {
|
||
chou: 2,
|
||
yin: 4,
|
||
mao: 6,
|
||
chen: 8,
|
||
si: 10,
|
||
wu: 12,
|
||
wei: 14,
|
||
shen: 16,
|
||
you: 18,
|
||
xu: 20,
|
||
hai: 22,
|
||
};
|
||
|
||
/** 由客户端本地时辰确定时辰 key(与后端逻辑一致) */
|
||
function shichenFromHour(hour: number): string {
|
||
if (hour >= 23 || hour < 1) return "zi";
|
||
// 取各时辰起始值,反向查找
|
||
let prev = "zi";
|
||
for (const key of SHICHEN_KEYS) {
|
||
const start = SHICHEN_HOURS[key];
|
||
if (hour >= start) prev = key;
|
||
else break;
|
||
}
|
||
return prev;
|
||
}
|
||
|
||
/** 获取目标时区的当前时间组件 */
|
||
function getLocalTimeInTimezone(timezone: string) {
|
||
const fmt = new Intl.DateTimeFormat("zh-CN", {
|
||
timeZone: timezone,
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
second: "2-digit",
|
||
hour12: false,
|
||
});
|
||
const p = fmt.formatToParts(new Date());
|
||
const g = (t: string) =>
|
||
parseInt(p.find((x) => x.type === t)?.value ?? "0", 10);
|
||
return {
|
||
year: g("year"),
|
||
month: g("month"),
|
||
day: g("day"),
|
||
hour: g("hour"),
|
||
minute: g("minute"),
|
||
second: g("second"),
|
||
};
|
||
}
|
||
|
||
function getDayPeriod(h: number): string {
|
||
if (h >= 0 && h < 5) return "凌晨";
|
||
if (h >= 5 && h < 7) return "清晨";
|
||
if (h >= 7 && h < 11) return "上午";
|
||
if (h >= 11 && h < 13) return "中午";
|
||
if (h >= 13 && h < 17) return "下午";
|
||
if (h >= 17 && h < 19) return "傍晚";
|
||
if (h >= 19 && h < 22) return "夜晚";
|
||
return "深夜";
|
||
}
|
||
|
||
const KIND_OPTIONS = [
|
||
{ value: "anniversary", label: "纪念日" },
|
||
{ value: "birthday", label: "生日" },
|
||
{ value: "custom", label: "自定义" },
|
||
];
|
||
|
||
// ============================================================
|
||
// 子组件
|
||
// ============================================================
|
||
|
||
/** 调制因子(设计稿 .factor 渐变轨道) */
|
||
function FactorFactor({
|
||
label,
|
||
value,
|
||
cls,
|
||
}: {
|
||
label: string;
|
||
value: number;
|
||
cls: string;
|
||
}) {
|
||
const pct = Math.round(Math.max(0, Math.min(1, value)) * 100);
|
||
return (
|
||
<div className={`factor ${cls}`}>
|
||
<span className="fk">{label}</span>
|
||
<div className="ftrack">
|
||
<div className="ffill" style={{ width: `${pct}%` }} />
|
||
</div>
|
||
<span className="fv">{pct}%</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 主页面
|
||
// ============================================================
|
||
|
||
export default function TimePage() {
|
||
// —— 后端数据 ——
|
||
const [snapshot, setSnapshot] = useState<TimeSnapshotResponse | null>(null);
|
||
const [acquaintance, setAcquaintance] = useState<AcquaintanceInfo | null>(
|
||
null,
|
||
);
|
||
const [config, setConfig] = useState<TimeConfig | null>(null);
|
||
const [currentMood, setCurrentMood] = useState<{
|
||
mood: string;
|
||
intensity: number;
|
||
} | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// —— 实时时钟 ——
|
||
const [, setNow] = useState<Date>(new Date());
|
||
const [, setPrevShichen] = useState<string | null>(null);
|
||
const [shichenChanged, setShichenChanged] = useState(false);
|
||
const shichenRef = useRef("");
|
||
|
||
// 每秒 tick
|
||
useEffect(() => {
|
||
const timer = setInterval(() => setNow(new Date()), 1000);
|
||
return () => clearInterval(timer);
|
||
}, []);
|
||
|
||
// 检测时辰变化
|
||
const timezone =
|
||
snapshot?.snapshot?.timezone ?? config?.user_timezone ?? "Asia/Shanghai";
|
||
const local = getLocalTimeInTimezone(timezone);
|
||
const currentShichen = shichenFromHour(local.hour);
|
||
|
||
useEffect(() => {
|
||
if (shichenRef.current && shichenRef.current !== currentShichen) {
|
||
setPrevShichen(shichenRef.current);
|
||
setShichenChanged(true);
|
||
setTimeout(() => setShichenChanged(false), 2000);
|
||
}
|
||
shichenRef.current = currentShichen;
|
||
}, [currentShichen]);
|
||
|
||
// —— 数据获取 ——
|
||
const fetchData = useCallback(async () => {
|
||
try {
|
||
const [s, acq, cfg] = await Promise.all([
|
||
timeApi.getTimeSnapshot(),
|
||
timeApi.getAcquaintance(),
|
||
timeApi.getTimeConfig(),
|
||
]);
|
||
setSnapshot(s);
|
||
setAcquaintance(acq);
|
||
setConfig(cfg);
|
||
} catch (e) {
|
||
if (!snapshot) setError(describeError(e));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [snapshot]);
|
||
|
||
useEffect(() => {
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
fetchData();
|
||
// 获取当前人物心情
|
||
charactersApi
|
||
.getActiveCharacter()
|
||
.then((c) => {
|
||
if (c.current_mood) setCurrentMood(c.current_mood);
|
||
})
|
||
.catch(() => {});
|
||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// 自动刷新:每 60 秒重新拉取后端数据(农历/节气/节日变化)
|
||
useEffect(() => {
|
||
if (loading) return;
|
||
const timer = setInterval(fetchData, 60_000);
|
||
return () => clearInterval(timer);
|
||
}, [fetchData, loading]);
|
||
|
||
// —— 渲染 ——
|
||
if (loading) {
|
||
return (
|
||
<div className="mx-auto max-w-5xl px-4 py-8">
|
||
<TextSkeleton lines={1} className="mb-8 w-48" />
|
||
<div className="grid grid-cols-1 gap-6 md:grid-cols-5">
|
||
<div className="md:col-span-3">
|
||
<div className="glass rounded-2xl p-8">
|
||
<TextSkeleton lines={3} />
|
||
</div>
|
||
</div>
|
||
<div className="md:col-span-2">
|
||
<div className="glass rounded-2xl p-5">
|
||
<TextSkeleton lines={6} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="glass mt-6 rounded-2xl p-5">
|
||
<TextSkeleton lines={2} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error && !snapshot) {
|
||
return <ErrorState message={error} onRetry={fetchData} />;
|
||
}
|
||
|
||
const s = snapshot?.snapshot;
|
||
const factors = snapshot?.factors;
|
||
const context_text = snapshot?.context_text;
|
||
|
||
// 实时时辰信息(优先用本地计算的,回退后端数据)
|
||
const displayShichen = currentShichen;
|
||
const displayDayPeriod = getDayPeriod(local.hour);
|
||
|
||
return (
|
||
<div className="mx-auto max-w-5xl px-4 py-8">
|
||
{/* ===== 页头 ===== */}
|
||
<header className="mb-8 flex flex-wrap items-end justify-between gap-4">
|
||
<div>
|
||
<div className="flex items-center gap-3">
|
||
<h1 className="font-serif text-2xl font-semibold text-fg">辰正</h1>
|
||
<span className="rounded-full bg-gold/10 px-2.5 py-0.5 text-[10px] font-medium tracking-wider text-gold">
|
||
时态感知
|
||
</span>
|
||
{shichenChanged && (
|
||
<span className="animate-pulse rounded-full bg-gold/30 px-2 py-0.5 text-[10px] text-gold">
|
||
更时
|
||
</span>
|
||
)}
|
||
</div>
|
||
{config && (
|
||
<p className="mt-1 text-xs text-muted">
|
||
{config.default_city} · {config.user_timezone}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2 text-[11px] text-muted">
|
||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-patina" />
|
||
辰光实时
|
||
</div>
|
||
</header>
|
||
|
||
{/* ===== 日晷时辰盘 + 调制因子(设计稿 .sundial-region) ===== */}
|
||
<div className="sundial-region">
|
||
{/* 日晷 */}
|
||
<SundialDisk
|
||
currentShichen={displayShichen}
|
||
hour={local.hour}
|
||
minute={local.minute}
|
||
lunarText={s?.lunar?.text}
|
||
festivals={s?.festivals}
|
||
solarTerm={s?.solar_term?.name}
|
||
/>
|
||
|
||
{/* 调制因子(设计稿 .factor-card) */}
|
||
{factors && (
|
||
<div className="factor-card">
|
||
<div className="mb-4 flex items-center gap-2">
|
||
<h3 className="font-serif text-sm font-semibold text-fg">
|
||
调制因子
|
||
</h3>
|
||
<span className="h-px flex-1 bg-rule" />
|
||
</div>
|
||
<div className="factor-list">
|
||
<FactorFactor
|
||
label="节日喜庆"
|
||
value={factors.festival_joy}
|
||
cls="f-joy"
|
||
/>
|
||
<FactorFactor
|
||
label="深夜脆弱"
|
||
value={factors.late_night_fragility}
|
||
cls="f-frag"
|
||
/>
|
||
<FactorFactor
|
||
label="重逢强度"
|
||
value={factors.reunion_intensity}
|
||
cls="f-reun"
|
||
/>
|
||
<FactorFactor
|
||
label="周末放松"
|
||
value={factors.weekend_relax}
|
||
cls="f-wknd"
|
||
/>
|
||
<FactorFactor
|
||
label="季节共鸣"
|
||
value={factors.season_resonance}
|
||
cls="f-season"
|
||
/>
|
||
<FactorFactor
|
||
label="同期共振"
|
||
value={factors.same_period_resonance}
|
||
cls="f-period"
|
||
/>
|
||
<FactorFactor
|
||
label="关怀倾向"
|
||
value={factors.care_suggest}
|
||
cls="f-care"
|
||
/>
|
||
</div>
|
||
|
||
{currentMood && (
|
||
<div className="mt-4 rounded-xl bg-glass/40 px-4 py-3">
|
||
<div className="mb-1 flex items-center gap-2">
|
||
<h3 className="font-serif text-sm font-semibold text-fg">
|
||
当前心情
|
||
</h3>
|
||
<span className="h-px flex-1 bg-rule" />
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-2xl">
|
||
{currentMood.mood === "joy"
|
||
? "😊"
|
||
: currentMood.mood === "sadness"
|
||
? "😢"
|
||
: currentMood.mood === "anger"
|
||
? "😠"
|
||
: currentMood.mood === "fear"
|
||
? "😨"
|
||
: currentMood.mood === "surprise"
|
||
? "😲"
|
||
: currentMood.mood === "disgust"
|
||
? "😣"
|
||
: "😐"}
|
||
</span>
|
||
<span className="font-serif text-base font-medium text-fg">
|
||
{currentMood.mood}
|
||
</span>
|
||
<span className="font-mono text-xs text-muted">
|
||
{Math.round(currentMood.intensity * 100)}%
|
||
</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{factors.care_kind && (
|
||
<div className="care-note">
|
||
<b>关怀</b> · {factors.care_kind}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* ===== 相识 / 离线 / 里程碑 快照格(设计稿 .snap-grid) ===== */}
|
||
{s && (
|
||
<div className="snap-grid">
|
||
<div className="snap-card">
|
||
<div className="k">相识</div>
|
||
<div className="v">
|
||
{s.acquaintance.total_days}
|
||
<small>天</small>
|
||
</div>
|
||
<div className="sub">{s.acquaintance.natural_language}</div>
|
||
</div>
|
||
<div className="snap-card">
|
||
<div className="k">离线</div>
|
||
<div className="v">
|
||
{Math.round(s.offline.seconds / 86400)}
|
||
<small>天</small>
|
||
</div>
|
||
<div className="sub">{s.offline.natural_language}</div>
|
||
</div>
|
||
<div className="snap-card milestone">
|
||
<div className="k">下个纪念日</div>
|
||
<div className="v">
|
||
{s.next_milestone?.days_until ?? "—"}
|
||
<small>天后</small>
|
||
</div>
|
||
<div className="sub">{s.next_milestone?.name ?? "无"}</div>
|
||
</div>
|
||
<div className="snap-card">
|
||
<div className="k">辰光</div>
|
||
<div className="v">
|
||
{displayShichen}
|
||
<small>· {displayDayPeriod}</small>
|
||
</div>
|
||
<div className="sub">{s.season ?? "—"}</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ===== 时态感知文本(全宽) ===== */}
|
||
{context_text && (
|
||
<Card variant="surface" className="p-5">
|
||
<div className="mb-3 flex items-center gap-2">
|
||
<h3 className="font-serif text-sm font-semibold text-fg">
|
||
时态感知
|
||
</h3>
|
||
<span className="h-px flex-1 bg-rule" />
|
||
</div>
|
||
<p className="whitespace-pre-wrap text-sm leading-relaxed text-fg/80">
|
||
{context_text}
|
||
</p>
|
||
</Card>
|
||
)}
|
||
|
||
{/* ===== 底部双栏:相识 + 纪念日管理(设计稿 .lower-region) ===== */}
|
||
<div className="lower-region">
|
||
{acquaintance && (
|
||
<Card variant="surface" className="p-6 event-main">
|
||
<div className="mb-3 flex items-center gap-2">
|
||
<h3 className="font-serif text-sm font-semibold text-fg">相识</h3>
|
||
<span className="h-px flex-1 bg-rule" />
|
||
</div>
|
||
<p className="font-serif text-4xl font-semibold text-fg">
|
||
{acquaintance.total_days}
|
||
<span className="ml-1 text-base font-normal text-muted">天</span>
|
||
</p>
|
||
<p className="mt-1 text-sm text-muted">
|
||
{acquaintance.natural_language}
|
||
</p>
|
||
</Card>
|
||
)}
|
||
<div className="anniv-rail">
|
||
<AnniversaryManager />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// 纪念日管理(保持原功能不变)
|
||
// ============================================================
|
||
|
||
function AnniversaryManager() {
|
||
const [anniversaries, setAnniversaries] = useState<Anniversary[]>([]);
|
||
const [showForm, setShowForm] = useState(false);
|
||
const [editingId, setEditingId] = useState<string | null>(null);
|
||
const [form, setForm] = useState<CreateAnniversaryRequest & { date: string }>(
|
||
{
|
||
name: "",
|
||
date: "",
|
||
kind: "anniversary",
|
||
calendar: "solar",
|
||
subject_type: "relationship",
|
||
source: "manual",
|
||
},
|
||
);
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const fetch = useCallback(async () => {
|
||
try {
|
||
const data = await timeApi.listAnniversaries();
|
||
setAnniversaries(data);
|
||
} catch {
|
||
/* silent */
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
fetch();
|
||
}, [fetch]);
|
||
|
||
function resetForm() {
|
||
setForm({
|
||
name: "",
|
||
date: "",
|
||
kind: "anniversary",
|
||
calendar: "solar",
|
||
subject_type: "relationship",
|
||
source: "manual",
|
||
});
|
||
setShowForm(false);
|
||
setEditingId(null);
|
||
setError(null);
|
||
}
|
||
|
||
async function handleCreate() {
|
||
if (!form.name.trim() || !form.date) {
|
||
setError("请填写名称与日期。");
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
await timeApi.createAnniversary(form);
|
||
resetForm();
|
||
await fetch();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "创建失败。");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function handleUpdate(id: string) {
|
||
if (!form.name.trim() || !form.date) {
|
||
setError("请填写名称与日期。");
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
await timeApi.updateAnniversary(id, form);
|
||
resetForm();
|
||
await fetch();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "更新失败。");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function handleDelete(id: string) {
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
await timeApi.deleteAnniversary(id);
|
||
await fetch();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "删除失败。");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
function startEdit(a: Anniversary) {
|
||
setEditingId(a.id);
|
||
setShowForm(false);
|
||
setForm({
|
||
name: a.name,
|
||
date: a.date,
|
||
kind: a.kind,
|
||
calendar: a.calendar,
|
||
subject_type: a.subject_type,
|
||
source: a.source,
|
||
});
|
||
setError(null);
|
||
}
|
||
|
||
const inputCls =
|
||
"w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg outline-none focus:border-patina/60 focus:shadow-[0_0_0_2px] focus:shadow-patina/20";
|
||
|
||
return (
|
||
<Card variant="surface" className="p-6">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="font-serif text-sm font-semibold text-fg">
|
||
纪念日({anniversaries.length})
|
||
</h3>
|
||
{!showForm && !editingId && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
resetForm();
|
||
setShowForm(true);
|
||
}}
|
||
className="rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
|
||
>
|
||
+ 新增
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{(showForm || editingId) && (
|
||
<div className="mt-3 space-y-2 rounded-lg border border-border-glass bg-glass/50 p-3">
|
||
<input
|
||
value={form.name}
|
||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||
placeholder="名称"
|
||
className={inputCls}
|
||
/>
|
||
<input
|
||
value={form.date}
|
||
onChange={(e) => setForm({ ...form, date: e.target.value })}
|
||
type="date"
|
||
className={inputCls}
|
||
/>
|
||
<div className="flex gap-2">
|
||
<select
|
||
value={form.kind}
|
||
onChange={(e) => setForm({ ...form, kind: e.target.value })}
|
||
className={inputCls}
|
||
>
|
||
{KIND_OPTIONS.map((o) => (
|
||
<option key={o.value} value={o.value}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
value={form.calendar}
|
||
onChange={(e) => setForm({ ...form, calendar: e.target.value })}
|
||
className={inputCls}
|
||
>
|
||
<option value="solar">公历</option>
|
||
<option value="lunar">农历</option>
|
||
</select>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={editingId ? () => handleUpdate(editingId) : handleCreate}
|
||
disabled={busy}
|
||
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
|
||
>
|
||
{editingId ? "保存" : "创建"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={resetForm}
|
||
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
|
||
>
|
||
取消
|
||
</button>
|
||
</div>
|
||
{error && <p className="text-xs text-cinnabar">{error}</p>}
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-3 space-y-2">
|
||
{anniversaries.length === 0 ? (
|
||
<p className="text-sm text-muted">暂无纪念日。</p>
|
||
) : (
|
||
anniversaries.map((a) => (
|
||
<div
|
||
key={a.id}
|
||
className="flex items-center justify-between rounded-lg bg-glass px-3 py-2"
|
||
>
|
||
<div className="min-w-0 flex-1">
|
||
<span className="text-sm text-fg">{a.name}</span>
|
||
<span className="ml-2 text-[11px] text-muted">{a.date}</span>
|
||
<span className="ml-2 rounded bg-gold/10 px-1.5 py-0.5 text-[10px] text-gold">
|
||
{a.kind}
|
||
</span>
|
||
</div>
|
||
<div className="ml-2 flex shrink-0 gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => startEdit(a)}
|
||
className="text-[11px] text-patina hover:underline"
|
||
>
|
||
编辑
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleDelete(a.id)}
|
||
disabled={busy}
|
||
className="text-[11px] text-cinnabar hover:underline disabled:opacity-50"
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</Card>
|
||
);
|
||
}
|