Add local chart library management

This commit is contained in:
732642856
2026-07-17 16:08:49 +08:00
parent 129033787d
commit e0138496be
2 changed files with 157 additions and 4 deletions
+9
View File
@@ -383,6 +383,15 @@ button:disabled { cursor: default; opacity: .45; }
.default-chart-card { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); margin-top: var(--space-5); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface-subtle); }
.default-chart-card span, .default-chart-card small { display: block; color: var(--color-ink-secondary); font-size: var(--type-caption); }
.default-chart-card strong { display: block; margin: 4px 0; color: var(--color-ink); font-size: var(--type-body); font-weight: 500; }
.chart-library-panel { display: grid; gap: var(--space-5); margin-top: var(--space-5); }
.chart-library-group { display: grid; gap: var(--space-3); }
.chart-library-group > b { color: var(--color-ink); font-size: var(--type-caption); font-weight: 600; }
.chart-library-item { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); }
.chart-library-item strong, .chart-library-item small { display: block; }
.chart-library-item strong { color: var(--color-ink); font-size: var(--type-body); font-weight: 500; }
.chart-library-item small, .chart-library-item > span, .empty-library-copy { color: var(--color-ink-secondary); font-size: var(--type-caption); }
.chart-library-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); }
.chart-library-form { padding-top: var(--space-4); border-top: 1px solid var(--color-border); }
input, select { width: 100%; min-height: 44px; padding: 0 12px; border: 1px solid var(--color-border-strong); color: var(--color-ink); border-color: var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-canvas); font-size: 14px; }
input:disabled, select:disabled { color: var(--color-ink-tertiary); background: var(--color-canvas-muted); }
+148 -4
View File
@@ -33,6 +33,12 @@ type Profile = {
cityCode: string;
districtCode: string;
};
type ChartLibraryRecord = {
id: string;
role: "self" | "other";
profile: Profile;
updatedAt: number;
};
type ChatSession = { id: string; title: string; theme: Theme; modelId: string; messages: Message[]; updatedAt: number };
type RequestError = { sessionId: string; message: string };
type StreamingReply = { sessionId: string; text: string };
@@ -166,6 +172,37 @@ function selectedBirthPlace(profile: Profile): BirthPlace | null {
return { label, lat: location.center[1], lon: location.center[0], tz: china.timezone };
}
function chartLibraryStorageKey(accountId: string) {
return `jyotisha_chart_library:${accountId}`;
}
function profileReadyForLibrary(profile: Profile) {
return !missingProfileStep(profile);
}
function buildSelfChartRecord(profile: Profile): ChartLibraryRecord {
return { id: "self", role: "self", profile, updatedAt: timestamp() };
}
function upsertSelfChart(library: ChartLibraryRecord[], profile: Profile) {
if (!profileReadyForLibrary(profile)) return library.filter((record) => record.role !== "self");
const others = library.filter((record) => record.role !== "self");
return [buildSelfChartRecord(profile), ...others];
}
function readChartLibrary(accountId: string): ChartLibraryRecord[] {
try {
const parsed = JSON.parse(localStorage.getItem(chartLibraryStorageKey(accountId)) || "[]") as ChartLibraryRecord[];
return Array.isArray(parsed) ? parsed.filter((record) => record?.id && record?.profile) : [];
} catch {
return [];
}
}
function profilePlaceLabel(profile: Profile) {
return selectedBirthPlace(profile)?.label || "地点未完整";
}
function missingProfileStep(profile: Profile): OnboardingStep | null {
if (!profile.name.trim()) return "name";
if (!profile.date || !profile.time) return "birth";
@@ -330,12 +367,12 @@ function BirthLocationFields({ value, onChange }: { value: Profile; onChange: (p
);
}
function ProfileFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) {
function ProfileFields({ value, onChange, nameInputId }: { value: Profile; onChange: (profile: Profile) => void; nameInputId?: string }) {
return (
<>
<label>
<span></span>
<input id="profile-name" required autoComplete="name" maxLength={80} placeholder="例如:林遥" value={value.name} onChange={(event) => onChange({ ...value, name: event.target.value })} />
<input id={nameInputId} required autoComplete="name" maxLength={80} placeholder="例如:林遥" value={value.name} onChange={(event) => onChange({ ...value, name: event.target.value })} />
</label>
<BirthMomentFields value={value} onChange={onChange} />
<BirthLocationFields value={value} onChange={onChange} />
@@ -428,6 +465,9 @@ async function fetchModelCatalog(signal?: AbortSignal) {
export default function Home() {
const [profile, setProfile] = useState<Profile>(emptyProfile);
const [profileDraft, setProfileDraft] = useState<Profile>(emptyProfile);
const [chartLibrary, setChartLibrary] = useState<ChartLibraryRecord[]>([]);
const [chartLibraryOpen, setChartLibraryOpen] = useState(false);
const [otherProfileDraft, setOtherProfileDraft] = useState<Profile>(emptyProfile);
const [profileOpen, setProfileOpen] = useState(false);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [profileNotice, setProfileNotice] = useState("");
@@ -491,6 +531,24 @@ export default function Home() {
}, [activeSessionId]);
const activeSuggestions = activeSession?.messages.reduce((latest, message) => message.role === "assistant" && message.suggestions?.length ? message.suggestions : latest, [] as string[]) ?? [];
const accountId = account?.user.id;
useEffect(() => {
if (!accountId) {
setChartLibrary([]);
return;
}
setChartLibrary(upsertSelfChart(readChartLibrary(accountId), profile));
}, [accountId, profile]);
useEffect(() => {
if (!accountId) return;
setChartLibrary((current) => {
const next = upsertSelfChart(current, profile);
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
return next;
});
}, [accountId, profile]);
const profileComplete = isProfileComplete(profile);
const onboardingPending = profileComplete && !onboarding && !onboardingError;
const currentOnboardingMessage = onboardingJustCompleted
@@ -932,6 +990,55 @@ export default function Home() {
if (!data) throw new Error("账户档案不存在,请重新登录后再试。");
}
function saveOtherChart(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const nextProfile = { ...otherProfileDraft, name: otherProfileDraft.name.trim() };
if (missingProfileStep(nextProfile)) {
setAccountError("请补全其他星盘的称呼、出生时间和出生地点。");
return;
}
if (!accountId) return;
const record: ChartLibraryRecord = {
id: globalThis.crypto.randomUUID(),
role: "other",
profile: nextProfile,
updatedAt: timestamp(),
};
setChartLibrary((current) => {
const next = [...upsertSelfChart(current, profile), record];
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
return next;
});
setOtherProfileDraft(emptyProfile);
setAccountError("");
setProfileNotice("已添加到星盘库。");
}
function deleteOtherChart(recordId: string) {
if (!accountId) return;
setChartLibrary((current) => {
const next = current.filter((record) => record.id !== recordId || record.role === "self");
localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next));
return next;
});
}
async function makeDefaultChart(record: ChartLibraryRecord) {
if (record.role !== "other" || profileSaving) return;
setProfileSaving(true);
setAccountError("");
try {
await persistProfile(record.profile);
setProfile(record.profile);
setProfileDraft(record.profile);
setProfileNotice("已设为当前默认星盘。");
} catch (caught) {
setAccountError(friendlyError(caught instanceof Error ? caught.message : "默认星盘保存失败"));
} finally {
setProfileSaving(false);
}
}
async function saveProfile(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!isProfileComplete(profileDraft) || !account || profileSaving) return;
@@ -1732,11 +1839,48 @@ export default function Home() {
<strong>{profileDraft.name.trim() || "未命名"}</strong>
<small></small>
</div>
<button className="button-secondary" type="button" onClick={() => profileDialog.current?.querySelector<HTMLInputElement>("#profile-name")?.focus()}></button>
<button className="button-secondary" type="button" onClick={() => setChartLibraryOpen((current) => !current)}></button>
</div>
{chartLibraryOpen && (
<div className="chart-library-panel" aria-label="星盘库">
<div className="chart-library-group">
<b></b>
{chartLibrary.filter((record) => record.role === "self").map((record) => (
<article className="chart-library-item" key={record.id}>
<div>
<strong>{record.profile.name || "未命名"}</strong>
<small>{record.profile.date} {record.profile.time} · {profilePlaceLabel(record.profile)}</small>
</div>
<span></span>
</article>
))}
</div>
<div className="chart-library-group">
<b></b>
{chartLibrary.filter((record) => record.role === "other").length === 0 && <p className="empty-library-copy"></p>}
{chartLibrary.filter((record) => record.role === "other").map((record) => (
<article className="chart-library-item" key={record.id}>
<div>
<strong>{record.profile.name || "未命名"}</strong>
<small>{record.profile.date} {record.profile.time} · {profilePlaceLabel(record.profile)}</small>
</div>
<div className="chart-library-actions">
<button className="button-secondary" type="button" onClick={() => void makeDefaultChart(record)} disabled={profileSaving}></button>
<button className="button-secondary danger-button" type="button" onClick={() => deleteOtherChart(record.id)}></button>
</div>
</article>
))}
</div>
<form className="profile-form chart-library-form" onSubmit={saveOtherChart}>
<div className="section-heading"><b></b><small></small></div>
<ProfileFields value={otherProfileDraft} onChange={setOtherProfileDraft} nameInputId="other-profile-name" />
<button className="button-primary save-profile" type="submit" disabled={!account}></button>
</form>
</div>
)}
{profileNotice && <p className="form-success" role="status">{profileNotice}</p>}
<form className="profile-form" onSubmit={saveProfile}>
<ProfileFields value={profileDraft} onChange={setProfileDraft} />
<ProfileFields value={profileDraft} onChange={setProfileDraft} nameInputId="profile-name" />
<button className="button-primary save-profile" type="submit" disabled={profileSaving || !account}>{profileSaving ? "保存中" : "保存出生资料"}</button>
</form>
</section>