fix(people,ephemeris): style /people and make the ephemeris readable (BUG-1039)
- /people had zero CSS rules for its people-archive classes. Add the approved layout: list -> detail below 1024px (data-view, ?person= history entry so Back returns to the list), two columns at 1024px and above; avatar rows with the current person highlighted, time cards with a rectified state, readable ayanamsa name, three quick actions; delete moves to the bottom of the edit view with unchanged confirm/count logic. - Ephemeris: Chinese-first panchanga names from the brief's table only (panchanga-names-zh.ts, three listed aliases, unknown names stay Sanskrit), coloured 吉/中/凶 tags, N吉/M凶 counts and a fixed-rule day summary (pure function, no model call); the header ask button becomes a bottom hint card; transit rows show glyph, 「行星 在 星座」, house line and degree; the Lahiri note names the subject's own ayanamsa. - API, engine and database unchanged; /people and /ephemeris stay Static. - DESIGN, VOICE, BUG_HISTORY, CHANGELOG, BLOCKED, PROGRESS and a manual checklist updated. Two existing assertions changed, with 原值/新值/原因. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
13c70a162a
commit
2981da6fbc
@@ -22,14 +22,20 @@ import {
|
||||
import {
|
||||
EPHEMERIS_COPY,
|
||||
ephemerisAskDraft,
|
||||
formatDegree,
|
||||
ephemerisAyanamsaNote,
|
||||
formatEphemerisDate,
|
||||
formatHouse,
|
||||
panchangaComplete,
|
||||
panchangaDaySummary,
|
||||
panchangaDisplayRows,
|
||||
panchangaQualityCounts,
|
||||
qualityLabel,
|
||||
shiftEphemerisDate,
|
||||
parseEphemerisDate,
|
||||
toEphemerisDate,
|
||||
transitHouseLine,
|
||||
} from "@/lib/ephemeris-view";
|
||||
import { formatDegreeMinutes } from "@/components/chart-page/chart-format";
|
||||
import { PLANET_COLOR_CLASS, PLANET_GLYPH, planetGlyphKey } from "@/lib/planet-glyphs";
|
||||
|
||||
type PageState =
|
||||
| { phase: "ready"; payload: EphemerisOkResponse }
|
||||
@@ -238,19 +244,20 @@ export function EphemerisView(props: {
|
||||
const natalMissing = !failed && !pending && props.payload?.transits.natalRelativeNote === "missing_natal";
|
||||
const eventsUnavailable = !pending && !failed && (!props.payload || props.payload.events.status === "unavailable");
|
||||
const events = !pending && !failed && props.payload?.events.status === "ok" ? props.payload.events.items : [];
|
||||
const natalRelativeAvailable = !failed && !pending && props.payload?.transits.natalRelativeAvailable === true;
|
||||
const rows = panchangaDisplayRows(factors);
|
||||
const complete = panchangaComplete(factors);
|
||||
const summary = panchangaDaySummary(factors);
|
||||
const counts = panchangaQualityCounts(factors);
|
||||
const ayanamsaNote = ephemerisAyanamsaNote(props.payload?.transits.ayanamsa);
|
||||
const isToday = Boolean(props.date) && props.date === props.today;
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const selectedDay = props.date ? parseEphemerisDate(props.date) : undefined;
|
||||
|
||||
// D8: the page action no longer sits in the header. It is a quiet hint card
|
||||
// at the very bottom of the body (below).
|
||||
return (
|
||||
<SecondaryPageShell
|
||||
title={EPHEMERIS_COPY.title}
|
||||
actions={(
|
||||
<Button type="button" className="ephemeris-ask" onClick={props.onAsk}>
|
||||
{EPHEMERIS_COPY.ask}
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
<SecondaryPageShell title={EPHEMERIS_COPY.title}>
|
||||
<div className="ephemeris-body">
|
||||
<section className="ephemeris-hero">
|
||||
{/* `‹ 日期 ›`: the date is the subject, the two arrows are its handles.
|
||||
@@ -332,22 +339,37 @@ export function EphemerisView(props: {
|
||||
<h2 id="ephemeris-panchanga-heading">{EPHEMERIS_COPY.panchangaHeading}</h2>
|
||||
{failed ? (
|
||||
<p className="ephemeris-static">{EPHEMERIS_COPY.dateFailed}</p>
|
||||
) : factors.length === 5 ? (
|
||||
<dl className="ephemeris-panchanga">
|
||||
{factors.map((factor) => (
|
||||
<div className="ephemeris-panchanga-item" key={factor.key}>
|
||||
<dt>{factor.label}</dt>
|
||||
<dd>
|
||||
<strong>{factor.value}{factor.detail ? ` · ${factor.detail}` : ""}</strong>
|
||||
{qualityLabel(factor.quality) ? <small>{qualityLabel(factor.quality)}</small> : null}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : rows.length > 0 ? (
|
||||
<>
|
||||
{/* D7: fixed-rule sentence + counts; only when all five limbs came back. */}
|
||||
{summary ? <p className="ephemeris-summary">{summary}</p> : null}
|
||||
{complete ? (
|
||||
<p className="ephemeris-counts">
|
||||
<span className="ephemeris-quality is-subha">{counts.good} 吉</span>
|
||||
<span className="ephemeris-quality is-asubha">{counts.bad} 凶</span>
|
||||
</p>
|
||||
) : null}
|
||||
<dl className="ephemeris-panchanga">
|
||||
{rows.map((row) => (
|
||||
<div className="ephemeris-panchanga-item" key={row.key}>
|
||||
<dt>{row.label}</dt>
|
||||
<dd>
|
||||
<span className="ephemeris-panchanga-name">
|
||||
<strong>{row.primary}</strong>
|
||||
{row.secondary ? <small lang="sa-Latn">{row.secondary}</small> : null}
|
||||
</span>
|
||||
{row.quality ? (
|
||||
<span className={`ephemeris-quality is-${row.quality}`}>{qualityLabel(row.quality)}</span>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</>
|
||||
) : (
|
||||
<p className="ephemeris-static">{EPHEMERIS_COPY.panchangaUnavailable}</p>
|
||||
)}
|
||||
<p className="ephemeris-note">{EPHEMERIS_COPY.panchangaLahiriNote}</p>
|
||||
<p className="ephemeris-note">{ayanamsaNote}</p>
|
||||
</section>
|
||||
|
||||
<section className="ephemeris-section" aria-labelledby="ephemeris-transits-heading">
|
||||
@@ -356,24 +378,25 @@ export function EphemerisView(props: {
|
||||
{failed ? (
|
||||
<p className="ephemeris-static">{EPHEMERIS_COPY.dateFailed}</p>
|
||||
) : bodies.length > 0 ? (
|
||||
<div className="ephemeris-transits">
|
||||
<div className="ephemeris-transits-head" aria-hidden="true">
|
||||
<span>{EPHEMERIS_COPY.planet}</span>
|
||||
<span>{EPHEMERIS_COPY.sign}</span>
|
||||
<span>{EPHEMERIS_COPY.degree}</span>
|
||||
<span>{EPHEMERIS_COPY.natalHouse}</span>
|
||||
<span>{EPHEMERIS_COPY.motion}</span>
|
||||
</div>
|
||||
{bodies.map((body) => (
|
||||
<div className="ephemeris-transits-row" key={body.planet}>
|
||||
<span data-label={EPHEMERIS_COPY.planet}>{body.planetLabel}</span>
|
||||
<span data-label={EPHEMERIS_COPY.sign}>{body.signLabel}</span>
|
||||
<span data-label={EPHEMERIS_COPY.degree}>{formatDegree(body.degreeInSign)}</span>
|
||||
<span data-label={EPHEMERIS_COPY.natalHouse}>{formatHouse(body.natalHouse)}</span>
|
||||
<span data-label={EPHEMERIS_COPY.motion}>{body.retrograde ? EPHEMERIS_COPY.retrograde : EPHEMERIS_COPY.direct}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ul className="ephemeris-transits">
|
||||
{bodies.map((body) => {
|
||||
const glyph = planetGlyphKey(body.planet);
|
||||
const house = transitHouseLine(body.natalHouse, natalRelativeAvailable);
|
||||
const sub = [house, body.retrograde ? EPHEMERIS_COPY.retrograde : null].filter(Boolean).join(" · ");
|
||||
return (
|
||||
<li className="ephemeris-transits-row" key={body.planet}>
|
||||
<span className={glyph ? `ephemeris-transit-glyph ${PLANET_COLOR_CLASS[glyph]}` : "ephemeris-transit-glyph"} aria-hidden="true">
|
||||
{glyph ? PLANET_GLYPH[glyph] : ""}
|
||||
</span>
|
||||
<span className="ephemeris-transit-main">
|
||||
<span>{body.planetLabel} 在 {body.signLabel}</span>
|
||||
{sub ? <small>{sub}</small> : null}
|
||||
</span>
|
||||
<span className="ephemeris-transit-degree">{formatDegreeMinutes(body.degreeInSign)}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="ephemeris-static">{EPHEMERIS_COPY.transitsUnavailable}</p>
|
||||
)}
|
||||
@@ -398,6 +421,13 @@ export function EphemerisView(props: {
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="ephemeris-ask-card" aria-label={EPHEMERIS_COPY.ask}>
|
||||
<p>{EPHEMERIS_COPY.askPrompt}</p>
|
||||
<Button type="button" className="ephemeris-ask" onClick={props.onAsk}>
|
||||
{EPHEMERIS_COPY.ask}
|
||||
</Button>
|
||||
</aside>
|
||||
</div>
|
||||
</SecondaryPageShell>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { useEffect, useRef, useState, type Dispatch, type FormEvent, type SetStateAction } from "react";
|
||||
import { FileText, MessageSquareText, Orbit } from "lucide-react";
|
||||
|
||||
import { effectiveBirthDate } from "@/lib/effective-birth-date";
|
||||
import { ChartProfileForm } from "@/components/chart-profile-form";
|
||||
@@ -14,34 +15,69 @@ import {
|
||||
readProfile,
|
||||
} from "@/lib/home-profile";
|
||||
import { emptyProfile, type Profile } from "@/lib/home-types";
|
||||
import {
|
||||
peopleAvatarInitial,
|
||||
peopleAvatarTone,
|
||||
peopleAyanamsaLabel,
|
||||
peopleDisplayName,
|
||||
peopleRowMeta,
|
||||
peopleTimeRectified,
|
||||
} from "@/lib/people-archive-view";
|
||||
import { MAX_OTHER_SUBJECTS, MAX_SAVED_SUBJECTS } from "@/lib/subject-birth";
|
||||
|
||||
type PersonRow = {
|
||||
export type PersonRow = {
|
||||
readonly id: string;
|
||||
readonly role: "self" | "other";
|
||||
readonly profile: Profile;
|
||||
readonly incomplete: boolean;
|
||||
};
|
||||
|
||||
type Usage = { id: string; sessions: number; reports: number };
|
||||
export type Usage = { id: string; sessions: number; reports: number };
|
||||
const DELETE_USAGE_UNAVAILABLE = "暂时查不到这个人的对话数量,稍后再试";
|
||||
const DELETE_FAILED = "删除失败,请重试";
|
||||
/** `?person=new` marks the add form; a reload or Forward onto it shows the list. */
|
||||
const NEW_PERSON_PARAM = "new";
|
||||
|
||||
const LIMIT_COPY = "最多保存 5 个人的星盘";
|
||||
|
||||
/** D1: below this width the page is list → detail; at and above it, two columns. */
|
||||
const PEOPLE_TWO_COLUMN_QUERY = "(min-width: 1024px)";
|
||||
/** The detail view on a phone is a history entry, so the browser Back returns to the list. */
|
||||
const PEOPLE_DETAIL_PARAM = "person";
|
||||
|
||||
function incompleteProfile(profile: Profile): boolean {
|
||||
return missingOtherProfileStep(profile) !== null;
|
||||
}
|
||||
|
||||
function twoColumnLayout(): boolean {
|
||||
return typeof window.matchMedia === "function" && window.matchMedia(PEOPLE_TWO_COLUMN_QUERY).matches;
|
||||
}
|
||||
|
||||
function detailParam(): string | null {
|
||||
return new URLSearchParams(window.location.search).get(PEOPLE_DETAIL_PARAM);
|
||||
}
|
||||
|
||||
/** Rewrites the current entry's `?person=` (null removes it); no new history entry. */
|
||||
function replaceDetailParam(id: string | null) {
|
||||
if (detailParam() === null) return;
|
||||
const url = new URL(window.location.href);
|
||||
if (id === null) url.searchParams.delete(PEOPLE_DETAIL_PARAM);
|
||||
else url.searchParams.set(PEOPLE_DETAIL_PARAM, id);
|
||||
window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
|
||||
}
|
||||
|
||||
export function PeoplePage() {
|
||||
const subjectId = useCurrentSubject();
|
||||
const [people, setPeople] = useState<PersonRow[]>([]);
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "failed">("loading");
|
||||
const [selectedId, setSelectedId] = useState<string>("self");
|
||||
const [view, setView] = useState<"list" | "detail">("list");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<Profile>(emptyProfile);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [pendingDelete, setPendingDelete] = useState<Usage | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const pushedDetail = useRef(false);
|
||||
|
||||
async function load() {
|
||||
setStatus("loading");
|
||||
@@ -71,22 +107,81 @@ export function PeoplePage() {
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
// A reload on a phone detail URL opens that person again.
|
||||
const opened = detailParam();
|
||||
if (opened && opened !== NEW_PERSON_PARAM) {
|
||||
setSelectedId(opened);
|
||||
setView("detail");
|
||||
}
|
||||
void load();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
// Browser Back / Forward between the list and a detail entry.
|
||||
const onPopState = () => {
|
||||
const opened = detailParam();
|
||||
pushedDetail.current = false;
|
||||
setEditing(false);
|
||||
setPendingDelete(null);
|
||||
if (opened && opened !== NEW_PERSON_PARAM) {
|
||||
setSelectedId(opened);
|
||||
setView("detail");
|
||||
} else {
|
||||
setView("list");
|
||||
}
|
||||
};
|
||||
window.addEventListener("popstate", onPopState);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener("popstate", onPopState);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const selected = people.find((person) => person.id === selectedId) ?? people[0] ?? null;
|
||||
const otherCount = people.filter((person) => person.role === "other").length;
|
||||
const atLimit = otherCount >= MAX_OTHER_SUBJECTS;
|
||||
|
||||
function showDetail(id: string) {
|
||||
setSelectedId(id);
|
||||
setView("detail");
|
||||
if (twoColumnLayout() || pushedDetail.current) return;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(PEOPLE_DETAIL_PARAM, id || NEW_PERSON_PARAM);
|
||||
window.history.pushState(null, "", `${url.pathname}${url.search}${url.hash}`);
|
||||
pushedDetail.current = true;
|
||||
}
|
||||
|
||||
function showList() {
|
||||
setEditing(false);
|
||||
setPendingDelete(null);
|
||||
if (pushedDetail.current) {
|
||||
// popstate brings the list back, so Back and 「‹ 返回」 are one path.
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
setView("list");
|
||||
replaceDetailParam(null);
|
||||
}
|
||||
|
||||
function selectPerson(id: string) {
|
||||
setEditing(false);
|
||||
setPendingDelete(null);
|
||||
setNotice("");
|
||||
showDetail(id);
|
||||
}
|
||||
|
||||
function startAdd() {
|
||||
if (atLimit) return;
|
||||
setDraft(emptyProfile);
|
||||
setNotice("");
|
||||
setEditing(true);
|
||||
setSelectedId("");
|
||||
setPendingDelete(null);
|
||||
showDetail("");
|
||||
}
|
||||
|
||||
function startEdit(profile: Profile) {
|
||||
setDraft(profile);
|
||||
setNotice("");
|
||||
setPendingDelete(null);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
async function saveOther(event: FormEvent<HTMLFormElement>) {
|
||||
@@ -110,7 +205,10 @@ export function PeoplePage() {
|
||||
setNotice("");
|
||||
invalidateSubjectCatalog();
|
||||
await load();
|
||||
if (payload?.profile?.id) setSelectedId(payload.profile.id);
|
||||
if (payload?.profile?.id) {
|
||||
setSelectedId(payload.profile.id);
|
||||
replaceDetailParam(payload.profile.id);
|
||||
}
|
||||
} catch {
|
||||
setNotice("保存失败,请重试");
|
||||
} finally {
|
||||
@@ -142,7 +240,8 @@ export function PeoplePage() {
|
||||
return;
|
||||
}
|
||||
if (subjectId === selected.id) setCurrentSubject("self");
|
||||
setPendingDelete(null);
|
||||
// Back to the list; on a phone that also pops the detail history entry.
|
||||
showList();
|
||||
setSelectedId("self");
|
||||
invalidateSubjectCatalog();
|
||||
await load();
|
||||
@@ -163,84 +262,216 @@ export function PeoplePage() {
|
||||
</section>
|
||||
) : null}
|
||||
{status === "ready" ? (
|
||||
<div className="people-archive">
|
||||
<section className="people-archive-list" aria-label="星盘档案">
|
||||
{people.map((person) => (
|
||||
<button
|
||||
key={person.id}
|
||||
type="button"
|
||||
className={person.id === selected?.id && !editing ? "people-archive-row is-current" : "people-archive-row"}
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setPendingDelete(null);
|
||||
setSelectedId(person.id);
|
||||
}}
|
||||
>
|
||||
<b>{person.profile.name.trim() || (person.role === "self" ? "我" : "未命名资料")}</b>
|
||||
<small>{person.role === "self" ? "本人" : person.incomplete ? "这份资料还不完整" : profileBirthTimeLabel(person.profile)}</small>
|
||||
</button>
|
||||
))}
|
||||
<button className="button-secondary" type="button" disabled={atLimit} onClick={startAdd}>
|
||||
添加一个人
|
||||
</button>
|
||||
{atLimit ? <p className="form-error">{LIMIT_COPY}</p> : <p>还可以保存 {MAX_SAVED_SUBJECTS - people.length} 个人。</p>}
|
||||
</section>
|
||||
<section className="people-archive-detail" aria-label="档案详情">
|
||||
{editing ? (
|
||||
<ChartProfileForm
|
||||
title={selectedId ? "编辑人物" : "添加一个人"}
|
||||
description="称呼和出生资料。填报的出生时间保存后不能再改。"
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
nameInputId="people-archive-name"
|
||||
showAyanamsa
|
||||
onSubmit={(event) => void saveOther(event)}
|
||||
onCancel={() => setEditing(false)}
|
||||
submitLabel={saving ? "保存中" : "保存"}
|
||||
submitDisabled={saving || Boolean(missingOtherProfileStep(draft))}
|
||||
notice={notice}
|
||||
/>
|
||||
) : selected ? (
|
||||
<>
|
||||
<h2>{selected.profile.name.trim() || (selected.role === "self" ? "我" : "未命名资料")}</h2>
|
||||
{selected.incomplete ? <p>这份资料还不完整,补全后才能排盘。</p> : null}
|
||||
<p>出生日期:{effectiveBirthDate(selected.profile) || "出生日期待补全"}</p>
|
||||
<p>填报时间:{selected.profile.reportedTime || "还没填"}</p>
|
||||
<p>排盘时间:{profileBirthTimeLabel(selected.profile)}</p>
|
||||
<p>地点:{profilePlaceLabel(selected.profile)}</p>
|
||||
<p>岁差:{selected.profile.ayanamsa}</p>
|
||||
<div className="dialog-actions">
|
||||
<button type="button" className="button-secondary" onClick={() => openPerson(selected.id, "/?newChat=1")}>和 TA 对话</button>
|
||||
<button type="button" className="button-secondary" onClick={() => openPerson(selected.id, "/chart")}>看星盘</button>
|
||||
<button type="button" className="button-secondary" onClick={() => openPerson(selected.id, "/reports")}>生成报告</button>
|
||||
{selected.role === "other" ? (
|
||||
<button type="button" className="button-secondary" onClick={() => window.location.assign(`/?synastry=${selected.id}`)}>和我合盘</button>
|
||||
) : null}
|
||||
</div>
|
||||
{selected.role === "other" ? (
|
||||
<button type="button" className="button-secondary" onClick={() => {
|
||||
setDraft(selected.profile);
|
||||
setEditing(true);
|
||||
}}>编辑</button>
|
||||
) : null}
|
||||
{selected.role === "self" ? <p>本人不能删除。</p> : (
|
||||
<button type="button" className="button-primary danger-primary" onClick={() => void askDelete()}>删除这个人</button>
|
||||
)}
|
||||
{pendingDelete?.id === selected.id && selected.role === "other" ? (
|
||||
<div role="alertdialog" aria-label="确认删除">
|
||||
<p>将同时删除 {pendingDelete.sessions} 个对话和 {pendingDelete.reports} 份报告,无法恢复。</p>
|
||||
<div className="dialog-actions">
|
||||
<button type="button" className="button-secondary" onClick={() => setPendingDelete(null)}>取消</button>
|
||||
<button type="button" className="button-primary danger-primary" onClick={() => void confirmDelete()}>确认删除</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{notice ? <p className="form-error" role="alert">{notice}</p> : null}
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
<PeopleArchiveView
|
||||
people={people}
|
||||
subjectId={subjectId}
|
||||
selected={selected}
|
||||
selectedId={selectedId}
|
||||
view={view}
|
||||
editing={editing}
|
||||
draft={draft}
|
||||
notice={notice}
|
||||
pendingDelete={pendingDelete}
|
||||
saving={saving}
|
||||
atLimit={atLimit}
|
||||
onSelect={selectPerson}
|
||||
onBack={showList}
|
||||
onAdd={startAdd}
|
||||
onEdit={startEdit}
|
||||
onCancelEdit={() => setEditing(false)}
|
||||
onDraftChange={setDraft}
|
||||
saveOther={saveOther}
|
||||
askDelete={askDelete}
|
||||
onCancelDelete={() => setPendingDelete(null)}
|
||||
confirmDelete={confirmDelete}
|
||||
openPerson={openPerson}
|
||||
/>
|
||||
) : null}
|
||||
</SecondaryPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
function PeopleAvatar(props: { readonly person: PersonRow; readonly large?: boolean }) {
|
||||
const tone = peopleAvatarTone(props.person.id, props.person.role);
|
||||
return (
|
||||
<span className={`people-archive-avatar is-${tone}${props.large ? " is-large" : ""}`} aria-hidden="true">
|
||||
{peopleAvatarInitial(props.person.profile, props.person.role)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export type PeopleArchiveViewProps = {
|
||||
readonly people: readonly PersonRow[];
|
||||
readonly subjectId: string;
|
||||
readonly selected: PersonRow | null;
|
||||
readonly selectedId: string;
|
||||
readonly view: "list" | "detail";
|
||||
readonly editing: boolean;
|
||||
readonly draft: Profile;
|
||||
readonly notice: string;
|
||||
readonly pendingDelete: Usage | null;
|
||||
readonly saving: boolean;
|
||||
readonly atLimit: boolean;
|
||||
readonly onSelect: (id: string) => void;
|
||||
readonly onBack: () => void;
|
||||
readonly onAdd: () => void;
|
||||
readonly onEdit: (profile: Profile) => void;
|
||||
readonly onCancelEdit: () => void;
|
||||
readonly onDraftChange: Dispatch<SetStateAction<Profile>>;
|
||||
readonly saveOther: (event: FormEvent<HTMLFormElement>) => Promise<void>;
|
||||
readonly askDelete: () => Promise<void>;
|
||||
readonly onCancelDelete: () => void;
|
||||
readonly confirmDelete: () => Promise<void>;
|
||||
readonly openPerson: (id: string, href: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* D1–D4. Phone (< 1024px): `data-view` shows either the list or the detail.
|
||||
* Desktop: both columns, `data-view` ignored by CSS.
|
||||
*/
|
||||
export function PeopleArchiveView({
|
||||
people,
|
||||
subjectId,
|
||||
selected,
|
||||
selectedId,
|
||||
view,
|
||||
editing,
|
||||
draft,
|
||||
notice,
|
||||
pendingDelete,
|
||||
saving,
|
||||
atLimit,
|
||||
onSelect,
|
||||
onBack,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onCancelEdit,
|
||||
onDraftChange,
|
||||
saveOther,
|
||||
askDelete,
|
||||
onCancelDelete,
|
||||
confirmDelete,
|
||||
openPerson,
|
||||
}: PeopleArchiveViewProps) {
|
||||
const remaining = MAX_SAVED_SUBJECTS - people.length;
|
||||
const deleteNotice = notice === DELETE_USAGE_UNAVAILABLE || notice === DELETE_FAILED;
|
||||
return (
|
||||
<div className="people-archive" data-view={view}>
|
||||
<section className="people-archive-list" aria-label="星盘档案">
|
||||
<ul className="people-archive-rows">
|
||||
{people.map((person) => {
|
||||
const current = person.id === subjectId;
|
||||
const open = person.id === selected?.id && !editing;
|
||||
return (
|
||||
<li key={person.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`people-archive-row${current ? " is-current" : ""}${open ? " is-open" : ""}`}
|
||||
aria-current={current ? "true" : undefined}
|
||||
onClick={() => onSelect(person.id)}
|
||||
>
|
||||
<PeopleAvatar person={person} />
|
||||
<span className="people-archive-row-name">
|
||||
<b>{peopleDisplayName(person.profile, person.role)}</b>
|
||||
{person.role === "self" ? <span className="people-archive-tag">本人</span> : null}
|
||||
</span>
|
||||
<small className="people-archive-row-meta">
|
||||
{person.incomplete ? "这份资料还不完整" : peopleRowMeta(person.profile)}
|
||||
</small>
|
||||
<span className="people-archive-chevron" aria-hidden="true">›</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<button className="people-archive-add" type="button" disabled={atLimit} onClick={onAdd}>
|
||||
+ 添加一个人
|
||||
</button>
|
||||
{atLimit ? <p className="form-error">{LIMIT_COPY}</p> : <p className="people-archive-hint">还可以保存 {remaining} 个人</p>}
|
||||
</section>
|
||||
<section className="people-archive-detail" aria-label="档案详情">
|
||||
<button type="button" className="people-archive-back" onClick={onBack}>
|
||||
<span aria-hidden="true">‹</span> 返回
|
||||
</button>
|
||||
{editing ? (
|
||||
<ChartProfileForm
|
||||
title={selectedId ? "编辑人物" : "添加一个人"}
|
||||
description="称呼和出生资料。填报的出生时间保存后不能再改。"
|
||||
value={draft}
|
||||
onChange={onDraftChange}
|
||||
nameInputId="people-archive-name"
|
||||
showAyanamsa
|
||||
onSubmit={(event) => void saveOther(event)}
|
||||
onCancel={onCancelEdit}
|
||||
submitLabel={saving ? "保存中" : "保存"}
|
||||
submitDisabled={saving || Boolean(missingOtherProfileStep(draft))}
|
||||
notice={deleteNotice ? undefined : notice}
|
||||
/>
|
||||
) : selected ? (
|
||||
<div className="people-archive-card">
|
||||
<header className="people-archive-head">
|
||||
<PeopleAvatar person={selected} large />
|
||||
<div>
|
||||
<h2>{peopleDisplayName(selected.profile, selected.role)}</h2>
|
||||
<p>{selected.role === "self" ? `本人 · ${profilePlaceLabel(selected.profile)}` : profilePlaceLabel(selected.profile)}</p>
|
||||
</div>
|
||||
</header>
|
||||
{selected.incomplete ? <p className="people-archive-incomplete">这份资料还不完整,补全后才能排盘。</p> : null}
|
||||
<div className="people-archive-times">
|
||||
<div className="people-archive-time">
|
||||
<small>填报时间</small>
|
||||
<b>{selected.profile.reportedTime || "还没填"}</b>
|
||||
</div>
|
||||
<div className={peopleTimeRectified(selected.profile) ? "people-archive-time is-rectified" : "people-archive-time"}>
|
||||
<small>{peopleTimeRectified(selected.profile) ? "排盘时间 · 已校正" : "排盘时间"}</small>
|
||||
<b>{profileBirthTimeLabel(selected.profile)}</b>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="people-archive-facts">
|
||||
<dt>出生日期</dt>
|
||||
<dd>{effectiveBirthDate(selected.profile) || "出生日期待补全"}</dd>
|
||||
<dt>岁差</dt>
|
||||
<dd>{peopleAyanamsaLabel(selected.profile)}</dd>
|
||||
</dl>
|
||||
<div className="people-archive-actions">
|
||||
<button type="button" onClick={() => openPerson(selected.id, "/?newChat=1")}>
|
||||
<MessageSquareText size={18} strokeWidth={1.75} aria-hidden="true" />和 TA 对话
|
||||
</button>
|
||||
<button type="button" onClick={() => openPerson(selected.id, "/chart")}>
|
||||
<Orbit size={18} strokeWidth={1.75} aria-hidden="true" />看星盘
|
||||
</button>
|
||||
<button type="button" onClick={() => openPerson(selected.id, "/reports")}>
|
||||
<FileText size={18} strokeWidth={1.75} aria-hidden="true" />生成报告
|
||||
</button>
|
||||
</div>
|
||||
{selected.role === "other" ? (
|
||||
<button type="button" className="people-archive-secondary" onClick={() => window.location.assign(`/?synastry=${selected.id}`)}>和我合盘</button>
|
||||
) : null}
|
||||
<div className="people-archive-foot">
|
||||
{selected.role === "self" ? <p>本人不能删除</p> : (
|
||||
<button type="button" className="people-archive-secondary" onClick={() => onEdit(selected.profile)}>编辑</button>
|
||||
)}
|
||||
</div>
|
||||
{notice ? <p className="form-error" role="alert">{notice}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{/* D3: deleting someone lives at the bottom of their edit view; 本人 never gets it. */}
|
||||
{editing && selectedId && selected?.role === "other" ? (
|
||||
<div className="people-archive-danger">
|
||||
<button type="button" className="button-primary danger-primary" onClick={() => void askDelete()}>删除这个人</button>
|
||||
{pendingDelete?.id === selected.id && selected.role === "other" ? (
|
||||
<div role="alertdialog" aria-label="确认删除" className="people-archive-confirm">
|
||||
<p>将同时删除 {pendingDelete.sessions} 个对话和 {pendingDelete.reports} 份报告,无法恢复。</p>
|
||||
<div className="dialog-actions">
|
||||
<button type="button" className="button-secondary" onClick={onCancelDelete}>取消</button>
|
||||
<button type="button" className="button-primary danger-primary" onClick={() => void confirmDelete()}>确认删除</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{deleteNotice ? <p className="form-error" role="alert">{notice}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user