fix(frontend): complete settings MVP structure
This commit is contained in:
@@ -1587,6 +1587,18 @@ button:disabled { cursor: default; opacity: .45; }
|
|||||||
.theme-menu-check { display: inline-grid; margin-left: auto; place-items: center; color: var(--color-action); }
|
.theme-menu-check { display: inline-grid; margin-left: auto; place-items: center; color: var(--color-action); }
|
||||||
.theme-menu-check svg { width: 15px; height: 15px; }
|
.theme-menu-check svg { width: 15px; height: 15px; }
|
||||||
.account-menu-separator { height: 1px; margin: var(--space-2) var(--space-3); background: var(--color-border); }
|
.account-menu-separator { height: 1px; margin: var(--space-2) var(--space-3); background: var(--color-border); }
|
||||||
|
|
||||||
|
.account-info-list { display: grid; gap: var(--space-3); margin: var(--space-5) 0 0; }
|
||||||
|
.account-info-list > div { display: grid; grid-template-columns: 88px minmax(0, 1fr); gap: var(--space-3); padding: var(--space-3) 0; border-top: 1px solid var(--color-border); }
|
||||||
|
.account-info-list dt { color: var(--color-ink-secondary); font-size: var(--type-caption); }
|
||||||
|
.account-info-list dd { min-width: 0; margin: 0; color: var(--color-ink); font-size: var(--type-body-sm); }
|
||||||
|
.account-info-list dd small { display: block; margin-top: var(--space-1); color: var(--color-ink-tertiary); font-size: var(--type-caption); }
|
||||||
|
.theme-preference-panel { display: grid; gap: var(--space-4); margin-top: var(--space-2); }
|
||||||
|
.theme-preference-options { display: grid; gap: var(--space-2); }
|
||||||
|
.theme-preference-option { min-height: 44px; display: grid; grid-template-columns: 20px minmax(0, 1fr) 18px; align-items: center; gap: var(--space-3); padding: 0 var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); cursor: pointer; text-align: left; }
|
||||||
|
.theme-preference-option:hover, .theme-preference-option[aria-pressed="true"] { border-color: var(--color-action); background: var(--color-action-soft); }
|
||||||
|
.theme-preference-option > svg { width: 18px; height: 18px; color: var(--color-ink-tertiary); }
|
||||||
|
.theme-preference-option > svg:last-child { color: var(--color-action); }
|
||||||
.account-menu-danger, .account-menu-danger > svg { color: var(--color-danger); }
|
.account-menu-danger, .account-menu-danger > svg { color: var(--color-danger); }
|
||||||
.account-menu-danger[data-highlighted] { background: var(--color-danger-muted); }
|
.account-menu-danger[data-highlighted] { background: var(--color-danger-muted); }
|
||||||
|
|
||||||
@@ -1598,6 +1610,14 @@ button:disabled { cursor: default; opacity: .45; }
|
|||||||
.account-modal { width: min(100%, 560px); max-height: min(84dvh, 760px); overflow-y: auto; padding: var(--space-8); border: 1px solid var(--color-border); border-radius: var(--radius-xl); background: var(--color-canvas); box-shadow: var(--shadow-elevated); animation: account-dialog-enter 180ms var(--ease-out) both; }
|
.account-modal { width: min(100%, 560px); max-height: min(84dvh, 760px); overflow-y: auto; padding: var(--space-8); border: 1px solid var(--color-border); border-radius: var(--radius-xl); background: var(--color-canvas); box-shadow: var(--shadow-elevated); animation: account-dialog-enter 180ms var(--ease-out) both; }
|
||||||
.profile-modal { width: min(100%, 680px); scroll-padding-block-start: calc(var(--space-8) + 72px); }
|
.profile-modal { width: min(100%, 680px); scroll-padding-block-start: calc(var(--space-8) + 72px); }
|
||||||
.chart-library-modal { width: min(100%, 760px); scroll-padding-block-start: calc(var(--space-8) + 72px); }
|
.chart-library-modal { width: min(100%, 760px); scroll-padding-block-start: calc(var(--space-8) + 72px); }
|
||||||
|
.account-settings-shell { display: grid; grid-template-columns: 176px minmax(0, 1fr); gap: var(--space-8); align-items: start; }
|
||||||
|
.settings-dialog-nav { display: grid; gap: var(--space-1); position: sticky; top: 0; }
|
||||||
|
.settings-dialog-nav-item { min-height: 44px; display: grid; grid-template-columns: 18px minmax(0, 1fr) 16px; align-items: center; gap: var(--space-2); padding: 0 var(--space-3); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-ink-secondary); cursor: pointer; text-align: left; }
|
||||||
|
.settings-dialog-nav-item:hover, .settings-dialog-nav-item[aria-current="page"] { background: var(--color-canvas-muted); color: var(--color-ink); }
|
||||||
|
.settings-dialog-nav-item[aria-current="page"] { box-shadow: inset 2px 0 0 var(--color-action); }
|
||||||
|
.settings-dialog-nav-item > svg { width: 18px; height: 18px; color: var(--color-ink-tertiary); }
|
||||||
|
.settings-dialog-nav-item > svg:last-child { width: 15px; height: 15px; margin-left: auto; }
|
||||||
|
.settings-dialog-content { min-width: 0; }
|
||||||
.chart-library-modal .account-modal-header { position: sticky; z-index: 1; top: 0; margin: calc(var(--space-8) * -1) calc(var(--space-8) * -1) var(--space-3); padding: var(--space-8) var(--space-8) var(--space-5); border-bottom: 1px solid var(--color-border); background: var(--color-canvas); }
|
.chart-library-modal .account-modal-header { position: sticky; z-index: 1; top: 0; margin: calc(var(--space-8) * -1) calc(var(--space-8) * -1) var(--space-3); padding: var(--space-8) var(--space-8) var(--space-5); border-bottom: 1px solid var(--color-border); background: var(--color-canvas); }
|
||||||
.settings-dialog-copy { margin: 0 0 var(--space-5); color: var(--color-ink-secondary); font-size: var(--type-body-sm); line-height: 1.6; }
|
.settings-dialog-copy { margin: 0 0 var(--space-5); color: var(--color-ink-secondary); font-size: var(--type-body-sm); line-height: 1.6; }
|
||||||
.profile-modal .account-modal-header { position: sticky; z-index: 1; top: 0; margin: calc(var(--space-8) * -1) calc(var(--space-8) * -1) var(--space-5); padding: var(--space-8) var(--space-8) var(--space-5); border-bottom: 1px solid var(--color-border); background: var(--color-canvas); }
|
.profile-modal .account-modal-header { position: sticky; z-index: 1; top: 0; margin: calc(var(--space-8) * -1) calc(var(--space-8) * -1) var(--space-5); padding: var(--space-8) var(--space-8) var(--space-5); border-bottom: 1px solid var(--color-border); background: var(--color-canvas); }
|
||||||
@@ -1777,7 +1797,13 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
|||||||
:root { --composer-reserve: 116px; }
|
:root { --composer-reserve: 116px; }
|
||||||
.select-content { max-height: min(320px, 56dvh); }
|
.select-content { max-height: min(320px, 56dvh); }
|
||||||
.select-list { max-height: min(288px, 48dvh) !important; }
|
.select-list { max-height: min(288px, 48dvh) !important; }
|
||||||
.account-modal { max-height: calc(100dvh - var(--space-8)); padding: var(--space-6); }
|
.account-modal-overlay { padding: 0; }
|
||||||
|
.account-modal { width: 100%; max-height: 100dvh; min-height: 100dvh; border-radius: 0; padding: var(--space-6); }
|
||||||
|
.account-settings-shell { grid-template-columns: 1fr; gap: var(--space-4); }
|
||||||
|
.settings-dialog-nav { position: static; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-1); padding-bottom: var(--space-3); border-bottom: 1px solid var(--color-border); }
|
||||||
|
.settings-dialog-nav-item { min-height: 40px; grid-template-columns: 1fr; justify-items: center; gap: 2px; padding: var(--space-2); text-align: center; font-size: var(--type-caption); }
|
||||||
|
.settings-dialog-nav-item > svg:last-child { display: none; }
|
||||||
|
.settings-dialog-nav-item[aria-current="page"] { box-shadow: inset 0 -2px 0 var(--color-action); }
|
||||||
.profile-modal .account-modal-header { margin: calc(var(--space-6) * -1) calc(var(--space-6) * -1) var(--space-4); padding: var(--space-6) var(--space-6) var(--space-4); }
|
.profile-modal .account-modal-header { margin: calc(var(--space-6) * -1) calc(var(--space-6) * -1) var(--space-4); padding: var(--space-6) var(--space-6) var(--space-4); }
|
||||||
.chart-library-modal .account-modal-header { margin: calc(var(--space-6) * -1) calc(var(--space-6) * -1) var(--space-4); padding: var(--space-6) var(--space-6) var(--space-4); }
|
.chart-library-modal .account-modal-header { margin: calc(var(--space-6) * -1) calc(var(--space-6) * -1) var(--space-4); padding: var(--space-6) var(--space-6) var(--space-4); }
|
||||||
.avatar-editor { grid-template-columns: 72px minmax(0, 1fr); gap: var(--space-4); }
|
.avatar-editor { grid-template-columns: 72px minmax(0, 1fr); gap: var(--space-4); }
|
||||||
|
|||||||
+39
-18
@@ -8,6 +8,7 @@ import { ArrowDown, ArrowUpRight, Sparkles } from "lucide-react";
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { FormEvent, KeyboardEvent } from "react";
|
import type { FormEvent, KeyboardEvent } from "react";
|
||||||
import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay";
|
import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay";
|
||||||
|
import { ThemePreferencePanel } from "@/components/theme-preference-menu";
|
||||||
import { AppSidebar } from "@/components/app-sidebar";
|
import { AppSidebar } from "@/components/app-sidebar";
|
||||||
import { UserAvatar } from "@/components/user-avatar";
|
import { UserAvatar } from "@/components/user-avatar";
|
||||||
import {
|
import {
|
||||||
@@ -283,7 +284,7 @@ type Account = {
|
|||||||
profile: unknown;
|
profile: unknown;
|
||||||
};
|
};
|
||||||
type OnboardingStep = "name" | "birth" | "place" | "rectification";
|
type OnboardingStep = "name" | "birth" | "place" | "rectification";
|
||||||
type AccountDialog = "profile" | "chart-library" | "logout";
|
type AccountDialog = "profile" | "chart-library" | "general" | "logout";
|
||||||
type DailyStarlanguageCard = { trend: string; action: string; caution: string };
|
type DailyStarlanguageCard = { trend: string; action: string; caution: string };
|
||||||
type DailyStarlanguageApiResponse = {
|
type DailyStarlanguageApiResponse = {
|
||||||
status?: "ok" | "unavailable" | "unauthenticated";
|
status?: "ok" | "unavailable" | "unauthenticated";
|
||||||
@@ -368,12 +369,14 @@ const themes = defaultGuidedJyotishTopics;
|
|||||||
const accountDialogTitles = {
|
const accountDialogTitles = {
|
||||||
profile: "个人资料",
|
profile: "个人资料",
|
||||||
"chart-library": "星盘资料",
|
"chart-library": "星盘资料",
|
||||||
|
general: "通用设置",
|
||||||
logout: "退出登录?",
|
logout: "退出登录?",
|
||||||
} as const satisfies Record<AccountDialog, string>;
|
} as const satisfies Record<AccountDialog, string>;
|
||||||
|
|
||||||
const accountDialogClasses = {
|
const accountDialogClasses = {
|
||||||
profile: "profile-modal",
|
profile: "profile-modal",
|
||||||
"chart-library": "chart-library-modal",
|
"chart-library": "chart-library-modal",
|
||||||
|
general: "general-modal",
|
||||||
logout: "logout-modal",
|
logout: "logout-modal",
|
||||||
} as const satisfies Record<AccountDialog, string>;
|
} as const satisfies Record<AccountDialog, string>;
|
||||||
|
|
||||||
@@ -1223,6 +1226,7 @@ export default function Home() {
|
|||||||
const [otherProfileDraft, setOtherProfileDraft] = useState<Profile>(emptyProfile);
|
const [otherProfileDraft, setOtherProfileDraft] = useState<Profile>(emptyProfile);
|
||||||
const [otherChartRelationship, setOtherChartRelationship] = useState<Exclude<ChartRelationship, "self">>("other");
|
const [otherChartRelationship, setOtherChartRelationship] = useState<Exclude<ChartRelationship, "self">>("other");
|
||||||
const [editingChartId, setEditingChartId] = useState<string | null>(null);
|
const [editingChartId, setEditingChartId] = useState<string | null>(null);
|
||||||
|
const [editingSelfChart, setEditingSelfChart] = useState(false);
|
||||||
const [synastryReportCard, setSynastryReportCard] = useState<SynastryReportCard | null>(null);
|
const [synastryReportCard, setSynastryReportCard] = useState<SynastryReportCard | null>(null);
|
||||||
const [synastryHistory, setSynastryHistory] = useState<SynastryReportCard[]>([]);
|
const [synastryHistory, setSynastryHistory] = useState<SynastryReportCard[]>([]);
|
||||||
const [dailyStarlanguage, setDailyStarlanguage] = useState<DailyStarlanguageState>({ kind: "pending" });
|
const [dailyStarlanguage, setDailyStarlanguage] = useState<DailyStarlanguageState>({ kind: "pending" });
|
||||||
@@ -2413,10 +2417,14 @@ export default function Home() {
|
|||||||
setAccountMenuOpen(false);
|
setAccountMenuOpen(false);
|
||||||
setAccountError("");
|
setAccountError("");
|
||||||
if (dialog === "profile") {
|
if (dialog === "profile") {
|
||||||
setProfileDraft(profile);
|
|
||||||
setProfileNotice("");
|
setProfileNotice("");
|
||||||
setAvatarNotice("");
|
setAvatarNotice("");
|
||||||
}
|
}
|
||||||
|
if (dialog === "chart-library") {
|
||||||
|
setProfileDraft(profile);
|
||||||
|
setProfileNotice("");
|
||||||
|
setEditingSelfChart(false);
|
||||||
|
}
|
||||||
setActiveAccountDialog(dialog);
|
setActiveAccountDialog(dialog);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3974,6 +3982,8 @@ export default function Home() {
|
|||||||
dialogClass: activeAccountDialog ? accountDialogClasses[activeAccountDialog] : "",
|
dialogClass: activeAccountDialog ? accountDialogClasses[activeAccountDialog] : "",
|
||||||
signingOut,
|
signingOut,
|
||||||
close: closeAccountDialog,
|
close: closeAccountDialog,
|
||||||
|
navigate: (dialog) => openAccountDialog(dialog, dialogReturnTarget.current),
|
||||||
|
openRedeem: () => router.push(membershipHref("account-menu")),
|
||||||
overlayRef: accountDialog,
|
overlayRef: accountDialog,
|
||||||
closeButtonRef: closeButton,
|
closeButtonRef: closeButton,
|
||||||
renderProfile() {
|
renderProfile() {
|
||||||
@@ -4019,23 +4029,16 @@ export default function Home() {
|
|||||||
{avatarNotice && <p className="form-success avatar-notice" role="status">{avatarNotice}</p>}
|
{avatarNotice && <p className="form-success avatar-notice" role="status">{avatarNotice}</p>}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
<section className="sheet-section birth-section">
|
<section className="sheet-section account-info-section" aria-labelledby="account-info-title">
|
||||||
<div className="section-heading"><b>出生资料</b><small>加密传输并保存到云端,用于此账号的所有对话</small></div>
|
<div className="section-heading"><b id="account-info-title">账户信息</b><small>登录与账户识别信息</small></div>
|
||||||
<div className="default-chart-card" aria-label="当前默认星盘">
|
<dl className="account-info-list">
|
||||||
<div>
|
<div><dt>昵称</dt><dd>{profile.name.trim() || "尚未设置"}<small>与“我的星盘”名称同步</small></dd></div>
|
||||||
<span>当前默认星盘</span>
|
<div><dt>登录邮箱</dt><dd>{account.user.email || "尚未读取邮箱"}</dd></div>
|
||||||
<strong>{profileDraft.name.trim() || "未命名"}</strong>
|
</dl>
|
||||||
<small>角色:本人</small>
|
|
||||||
</div>
|
|
||||||
<button className="button-secondary" type="button" onClick={() => openAccountDialog("chart-library")}>管理星盘库</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
{profileNotice && <p className="form-success" role="status">{profileNotice}</p>}
|
<div className="dialog-actions">
|
||||||
<form className="profile-form" onSubmit={saveProfile}>
|
<button className="button-secondary" type="button" onClick={() => openAccountDialog("chart-library")}>管理星盘资料</button>
|
||||||
<ProfileFields value={profileDraft} onChange={setProfileDraft} nameInputId="profile-name" />
|
</div>
|
||||||
<button className="button-primary save-profile" type="submit" disabled={profileSaving || !account}>{profileSaving ? "保存中" : "保存出生资料"}</button>
|
|
||||||
</form>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -4056,8 +4059,22 @@ export default function Home() {
|
|||||||
<small>{record.profile.date || "出生日期待补全"} · {profileBirthTimeLabel(record.profile)} · {profilePlaceLabel(record.profile)}</small>
|
<small>{record.profile.date || "出生日期待补全"} · {profileBirthTimeLabel(record.profile)} · {profilePlaceLabel(record.profile)}</small>
|
||||||
<small>{profileBirthTimeStatusLabel(record.profile)} · {formatChartUpdatedAt(record.updatedAt)}</small>
|
<small>{profileBirthTimeStatusLabel(record.profile)} · {formatChartUpdatedAt(record.updatedAt)}</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="chart-library-actions">
|
||||||
|
<button className="button-secondary" type="button" onClick={() => { setProfileDraft(record.profile); setEditingSelfChart(true); }} disabled={profileSaving || editingChartId !== null}>编辑本人资料</button>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
|
{editingSelfChart && (
|
||||||
|
<form className="profile-form chart-library-form" onSubmit={saveProfile}>
|
||||||
|
<div className="section-heading"><b>编辑本人星盘</b><small>这份资料会用于默认解盘与新对话。</small></div>
|
||||||
|
<ProfileFields value={profileDraft} onChange={setProfileDraft} nameInputId="self-profile-name" />
|
||||||
|
{profileNotice && <p className="form-success" role="status">{profileNotice}</p>}
|
||||||
|
<div className="dialog-actions">
|
||||||
|
<button className="button-secondary" type="button" onClick={() => { setEditingSelfChart(false); setProfileDraft(profile); }}>取消编辑</button>
|
||||||
|
<button className="button-primary save-profile" type="submit" disabled={!account || profileSaving}>{profileSaving ? "保存中" : "保存本人资料"}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
{chartLibrary.filter((record) => record.role === "self").length === 0 && <p className="empty-library-copy">请先在个人资料中补全你的出生资料。</p>}
|
{chartLibrary.filter((record) => record.role === "self").length === 0 && <p className="empty-library-copy">请先在个人资料中补全你的出生资料。</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="chart-library-group">
|
<div className="chart-library-group">
|
||||||
@@ -4139,6 +4156,9 @@ export default function Home() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
renderGeneral() {
|
||||||
|
return <ThemePreferencePanel />;
|
||||||
|
},
|
||||||
renderLogout() {
|
renderLogout() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -4199,6 +4219,7 @@ export default function Home() {
|
|||||||
onAddChart={() => openAccountDialog("chart-library")}
|
onAddChart={() => openAccountDialog("chart-library")}
|
||||||
onOpenProfile={() => openAccountDialog("profile")}
|
onOpenProfile={() => openAccountDialog("profile")}
|
||||||
onOpenChartLibrary={() => openAccountDialog("chart-library")}
|
onOpenChartLibrary={() => openAccountDialog("chart-library")}
|
||||||
|
onOpenGeneral={() => openAccountDialog("general")}
|
||||||
onOpenRedeem={() => router.push(membershipHref("account-menu"))}
|
onOpenRedeem={() => router.push(membershipHref("account-menu"))}
|
||||||
onOpenLogout={() => openAccountDialog("logout")}
|
onOpenLogout={() => openAccountDialog("logout")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,20 +1,35 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { X } from "lucide-react";
|
import { ChevronRight, Settings, UserRound, Users, WalletCards, X } from "lucide-react";
|
||||||
import { memo, type MutableRefObject, type ReactNode } from "react";
|
import { memo, type MutableRefObject, type ReactNode } from "react";
|
||||||
|
|
||||||
|
export type AccountSettingsDialog = "profile" | "chart-library" | "general";
|
||||||
|
|
||||||
export type AccountOverlayModel = Readonly<{
|
export type AccountOverlayModel = Readonly<{
|
||||||
title: string;
|
title: string;
|
||||||
dialogClass: string;
|
dialogClass: string;
|
||||||
signingOut: boolean;
|
signingOut: boolean;
|
||||||
close: () => void;
|
close: () => void;
|
||||||
|
navigate: (dialog: AccountSettingsDialog) => void;
|
||||||
|
openRedeem: () => void;
|
||||||
overlayRef: MutableRefObject<HTMLElement | null>;
|
overlayRef: MutableRefObject<HTMLElement | null>;
|
||||||
closeButtonRef: MutableRefObject<HTMLButtonElement | null>;
|
closeButtonRef: MutableRefObject<HTMLButtonElement | null>;
|
||||||
renderProfile: () => ReactNode;
|
renderProfile: () => ReactNode;
|
||||||
renderChartLibrary: () => ReactNode;
|
renderChartLibrary: () => ReactNode;
|
||||||
|
renderGeneral: () => ReactNode;
|
||||||
renderLogout: () => ReactNode;
|
renderLogout: () => ReactNode;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
const settingsItems: ReadonlyArray<{
|
||||||
|
dialog: AccountSettingsDialog;
|
||||||
|
label: string;
|
||||||
|
icon: typeof UserRound;
|
||||||
|
}> = [
|
||||||
|
{ dialog: "profile", label: "个人资料", icon: UserRound },
|
||||||
|
{ dialog: "chart-library", label: "星盘资料", icon: Users },
|
||||||
|
{ dialog: "general", label: "通用设置", icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
export const AccountDialogOverlay = memo(function AccountDialogOverlay({
|
export const AccountDialogOverlay = memo(function AccountDialogOverlay({
|
||||||
open,
|
open,
|
||||||
dialog,
|
dialog,
|
||||||
@@ -22,7 +37,7 @@ export const AccountDialogOverlay = memo(function AccountDialogOverlay({
|
|||||||
modelRef,
|
modelRef,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
dialog: "profile" | "chart-library" | "logout" | null;
|
dialog: "profile" | "chart-library" | "general" | "logout" | null;
|
||||||
openEpoch: number;
|
openEpoch: number;
|
||||||
modelRef: MutableRefObject<AccountOverlayModel | null>;
|
modelRef: MutableRefObject<AccountOverlayModel | null>;
|
||||||
}>) {
|
}>) {
|
||||||
@@ -31,6 +46,13 @@ export const AccountDialogOverlay = memo(function AccountDialogOverlay({
|
|||||||
if (!model) return null;
|
if (!model) return null;
|
||||||
void openEpoch;
|
void openEpoch;
|
||||||
|
|
||||||
|
const isSettingsDialog = dialog !== "logout";
|
||||||
|
const renderSettingsContent = () => {
|
||||||
|
if (dialog === "profile") return model.renderProfile();
|
||||||
|
if (dialog === "chart-library") return model.renderChartLibrary();
|
||||||
|
return model.renderGeneral();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="account-modal-overlay" onMouseDown={model.close}>
|
<div className="account-modal-overlay" onMouseDown={model.close}>
|
||||||
<section
|
<section
|
||||||
@@ -54,9 +76,31 @@ export const AccountDialogOverlay = memo(function AccountDialogOverlay({
|
|||||||
<X aria-hidden="true" />
|
<X aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
{dialog === "profile" && model.renderProfile()}
|
{isSettingsDialog ? (
|
||||||
{dialog === "chart-library" && model.renderChartLibrary()}
|
<div className="account-settings-shell">
|
||||||
{dialog === "logout" && model.renderLogout()}
|
<nav className="settings-dialog-nav" aria-label="设置分区">
|
||||||
|
{settingsItems.map(({ dialog: itemDialog, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
className="settings-dialog-nav-item"
|
||||||
|
key={itemDialog}
|
||||||
|
type="button"
|
||||||
|
aria-current={dialog === itemDialog ? "page" : undefined}
|
||||||
|
onClick={() => model.navigate(itemDialog)}
|
||||||
|
>
|
||||||
|
<Icon aria-hidden="true" />
|
||||||
|
<span>{label}</span>
|
||||||
|
<ChevronRight aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button className="settings-dialog-nav-item" type="button" onClick={model.openRedeem}>
|
||||||
|
<WalletCards aria-hidden="true" />
|
||||||
|
<span>账户与点数</span>
|
||||||
|
<ChevronRight aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
<div className="settings-dialog-content">{renderSettingsContent()}</div>
|
||||||
|
</div>
|
||||||
|
) : model.renderLogout()}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Clock3,
|
Clock3,
|
||||||
FileText,
|
FileText,
|
||||||
Gift,
|
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Settings,
|
||||||
|
WalletCards,
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
Star,
|
Star,
|
||||||
@@ -72,6 +73,7 @@ export type AppSidebarProps = {
|
|||||||
onAddChart: () => void;
|
onAddChart: () => void;
|
||||||
onOpenProfile: () => void;
|
onOpenProfile: () => void;
|
||||||
onOpenChartLibrary: () => void;
|
onOpenChartLibrary: () => void;
|
||||||
|
onOpenGeneral: () => void;
|
||||||
onOpenRedeem: () => void;
|
onOpenRedeem: () => void;
|
||||||
onOpenLogout: () => void;
|
onOpenLogout: () => void;
|
||||||
};
|
};
|
||||||
@@ -94,6 +96,7 @@ export function AppSidebar({
|
|||||||
onAddChart,
|
onAddChart,
|
||||||
onOpenProfile,
|
onOpenProfile,
|
||||||
onOpenChartLibrary,
|
onOpenChartLibrary,
|
||||||
|
onOpenGeneral,
|
||||||
onOpenRedeem,
|
onOpenRedeem,
|
||||||
onOpenLogout,
|
onOpenLogout,
|
||||||
}: AppSidebarProps) {
|
}: AppSidebarProps) {
|
||||||
@@ -308,8 +311,11 @@ export function AppSidebar({
|
|||||||
<Menu.Item className="account-menu-item" onClick={onOpenChartLibrary}>
|
<Menu.Item className="account-menu-item" onClick={onOpenChartLibrary}>
|
||||||
<Users aria-hidden="true" /><span>星盘资料</span><ChevronRight aria-hidden="true" />
|
<Users aria-hidden="true" /><span>星盘资料</span><ChevronRight aria-hidden="true" />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item className="account-menu-item" onClick={onOpenGeneral}>
|
||||||
|
<Settings aria-hidden="true" /><span>通用设置</span><ChevronRight aria-hidden="true" />
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item className="account-menu-item" onClick={onOpenRedeem}>
|
<Menu.Item className="account-menu-item" onClick={onOpenRedeem}>
|
||||||
<Gift aria-hidden="true" /><span>兑换点数</span><small>{account.credits} 点</small>
|
<WalletCards aria-hidden="true" /><span>账户与点数</span><small>{account.credits} 点</small>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Separator className="account-menu-separator" />
|
<Menu.Separator className="account-menu-separator" />
|
||||||
<ThemePreferenceMenu />
|
<ThemePreferenceMenu />
|
||||||
|
|||||||
@@ -23,15 +23,44 @@ function OptionIcon({ value }: { readonly value: ThemePreference }) {
|
|||||||
return <Monitor aria-hidden="true" />;
|
return <Monitor aria-hidden="true" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemePreferenceMenu() {
|
function useThemePreference() {
|
||||||
// The stored choice is browser state, not React state: read it through the
|
return useSyncExternalStore(
|
||||||
// external store so the server snapshot stays "system" (no hydration
|
|
||||||
// mismatch) and a change in another tab lands here too.
|
|
||||||
const preference = useSyncExternalStore(
|
|
||||||
subscribeThemePreference,
|
subscribeThemePreference,
|
||||||
readThemePreference,
|
readThemePreference,
|
||||||
() => "system" as ThemePreference,
|
() => "system" as ThemePreference,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemePreferencePanel() {
|
||||||
|
const preference = useThemePreference();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="theme-preference-panel" aria-labelledby="theme-preference-title">
|
||||||
|
<div className="section-heading">
|
||||||
|
<b id="theme-preference-title">外观主题</b>
|
||||||
|
<small>选择 Jyotisha 的显示方式</small>
|
||||||
|
</div>
|
||||||
|
<div className="theme-preference-options" role="group" aria-label="外观主题">
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
className="theme-preference-option"
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={preference === option.value}
|
||||||
|
onClick={() => applyThemePreference(option.value)}
|
||||||
|
>
|
||||||
|
<OptionIcon value={option.value} />
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{preference === option.value ? <Check aria-hidden="true" /> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemePreferenceMenu() {
|
||||||
|
const preference = useThemePreference();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu.Group className="theme-menu">
|
<Menu.Group className="theme-menu">
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ test("a closed account overlay does not render profile or logout content", () =>
|
|||||||
dialogClass: "profile-modal",
|
dialogClass: "profile-modal",
|
||||||
signingOut: false,
|
signingOut: false,
|
||||||
close() {},
|
close() {},
|
||||||
|
navigate() {},
|
||||||
|
openRedeem() {},
|
||||||
overlayRef,
|
overlayRef,
|
||||||
closeButtonRef,
|
closeButtonRef,
|
||||||
renderProfile() {
|
renderProfile() {
|
||||||
@@ -28,6 +30,9 @@ test("a closed account overlay does not render profile or logout content", () =>
|
|||||||
renderChartLibrary() {
|
renderChartLibrary() {
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
renderGeneral() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
renderLogout() {
|
renderLogout() {
|
||||||
logoutRenders += 1;
|
logoutRenders += 1;
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||||
|
const sidebar = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8");
|
||||||
|
const overlay = readFileSync(new URL("../src/components/account-dialog-overlay.tsx", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
function between(source: string, start: string, end: string) {
|
||||||
|
const startIndex = source.indexOf(start);
|
||||||
|
const endIndex = source.indexOf(end, startIndex + start.length);
|
||||||
|
assert.notEqual(startIndex, -1, `missing start marker: ${start}`);
|
||||||
|
assert.notEqual(endIndex, -1, `missing end marker: ${end}`);
|
||||||
|
return source.slice(startIndex, endIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("account menu exposes the four MVP settings sections", () => {
|
||||||
|
assert.match(sidebar, /<span>个人资料<\/span>/);
|
||||||
|
assert.match(sidebar, /<span>星盘资料<\/span>/);
|
||||||
|
assert.match(sidebar, /<span>通用设置<\/span>/);
|
||||||
|
assert.match(sidebar, /<span>账户与点数<\/span>/);
|
||||||
|
assert.match(sidebar, /onOpenGeneral/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("personal profile is limited to account basics and links to chart settings", () => {
|
||||||
|
const profile = between(page, " renderProfile() {", " renderChartLibrary() {");
|
||||||
|
assert.match(profile, /账户信息/);
|
||||||
|
assert.match(profile, /登录邮箱/);
|
||||||
|
assert.match(profile, /管理星盘资料/);
|
||||||
|
assert.doesNotMatch(profile, /<ProfileFields/);
|
||||||
|
assert.doesNotMatch(profile, /出生资料/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the chart library owns self-profile editing and keeps other-chart save separate", () => {
|
||||||
|
const charts = between(page, " renderChartLibrary() {", " renderGeneral() {");
|
||||||
|
assert.match(charts, /编辑本人资料/);
|
||||||
|
assert.match(charts, /onSubmit=\{saveProfile\}/);
|
||||||
|
assert.match(charts, /onSubmit=\{saveOtherChart\}/);
|
||||||
|
assert.match(charts, /setEditingSelfChart\(true\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the general dialog renders the shared theme preference panel", () => {
|
||||||
|
assert.match(overlay, /dialog: "profile" \| "chart-library" \| "general" \| "logout"/);
|
||||||
|
assert.match(overlay, /renderGeneral: \(\) => ReactNode/);
|
||||||
|
assert.match(overlay, /return model\.renderGeneral\(\)/);
|
||||||
|
assert.match(page, /renderGeneral\(\) \{\s*return <ThemePreferencePanel \/>;/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user