PMFlow/frontend/src/features/account-settings/AccountSettingsPanel.tsx

880 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Bell,
Bot,
Check,
ChevronDown,
LogOut,
Mail,
Repeat2,
ShieldCheck,
SlidersHorizontal,
UserRound,
} from "lucide-react";
import { Link } from "react-router-dom";
import {
accountNotificationKeys,
accountNotificationLabels,
getAccountNotificationConsumption,
getAccountSettings,
updateAccountSettings,
type AccountNotificationConsumption,
type AccountAiPreferenceSettings,
type AccountAnswerDepth,
type AccountEvidenceDisplay,
type AccountLanguage,
type AccountNotificationEvidenceDisplay,
type AccountNotificationKey,
type AccountProfileSettings,
type AccountReminderIntensity,
type AccountSettings,
type AccountSettingsSaveScope,
} from "@/api/accountSettings";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { MobileBottomSheet } from "@/features/pmflow-os/components/FeedbackPrimitives";
import { MobileBottomNav } from "@/features/pmflow-os/components/MobileBottomNav";
import { PMFlowBrand } from "@/features/pmflow-os/components/chrome";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/stores/auth";
type MobileSheetKey = "profile" | "ai" | "notifications" | null;
const answerDepthOptions: Array<{ value: AccountAnswerDepth; title: string; detail: string }> = [
{ value: "concise", title: "简洁", detail: "只给结论和必要依据" },
{ value: "balanced", title: "标准", detail: "结论、依据、下一步都保留" },
{ value: "deep", title: "深入", detail: "展开证据链、风险和取舍" },
];
const evidenceOptions: Array<{ value: AccountEvidenceDisplay; title: string; detail: string }> = [
{ value: "summary", title: "摘要", detail: "只展示来源摘要" },
{ value: "inline", title: "内联", detail: "关键结论旁展示证据" },
{ value: "full", title: "完整", detail: "保留来源、片段和验证状态" },
];
const languageOptions: Array<{ value: AccountLanguage; title: string; detail: string }> = [
{ value: "zh-CN", title: "中文", detail: "界面、AI 回复和通知默认中文" },
{ value: "en-US", title: "English", detail: "English replies when product context allows it" },
];
const reminderIntensityOptions: Array<{ value: AccountReminderIntensity; title: string; detail: string }> = [
{ value: "silent", title: "静默", detail: "不显示页面内提醒,业务对象和证据仍保留" },
{ value: "normal", title: "普通", detail: "在对应页面显示标准提醒" },
{ value: "prominent", title: "强提醒", detail: "用于高风险、阻塞或长时间停滞场景" },
];
const notificationEvidenceOptions: Array<{ value: AccountNotificationEvidenceDisplay; title: string; detail: string }> = [
{ value: "compact", title: "紧凑", detail: "提醒旁只显示关键证据摘要" },
{ value: "expanded", title: "展开", detail: "提醒旁展开来源、状态和审计线索" },
];
function getDisplayName(user: ReturnType<typeof useAuthStore.getState>["user"]) {
return user?.name || user?.username || "张子墨";
}
function getDisplayRole(user: ReturnType<typeof useAuthStore.getState>["user"]) {
if (user?.role === "admin") return "管理员";
if (user?.role === "manager") return "项目负责人";
return user?.role || "产品负责人";
}
function getAvatarSeed(user: ReturnType<typeof useAuthStore.getState>["user"], displayName: string) {
return encodeURIComponent(user?.id || user?.username || displayName || "pmflow-account");
}
function formatUpdatedAt(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "尚未保存";
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
export function AccountSettingsPanel({ onLogout }: { onLogout: () => void }) {
const queryClient = useQueryClient();
const user = useAuthStore((state) => state.user);
const seed = useMemo(
() => ({
displayName: getDisplayName(user),
roleTitle: getDisplayRole(user),
email: user?.email || "",
}),
[user]
);
const settingsQuery = useQuery({
queryKey: ["account-settings", seed.displayName, seed.roleTitle, seed.email],
queryFn: () => getAccountSettings(seed),
});
const [draft, setDraft] = useState<AccountSettings | null>(null);
const [activeSheet, setActiveSheet] = useState<MobileSheetKey>(null);
useEffect(() => {
if (settingsQuery.data) setDraft(settingsQuery.data);
}, [settingsQuery.data]);
const saveMutation = useMutation({
mutationFn: updateAccountSettings,
onSuccess: (saved) => {
setDraft(saved);
queryClient.setQueryData(["account-settings", seed.displayName, seed.roleTitle, seed.email], saved);
},
});
const isDirty = Boolean(
draft && settingsQuery.data && JSON.stringify(draft) !== JSON.stringify(settingsQuery.data)
);
const avatarSrc = `https://api.dicebear.com/7.x/avataaars/svg?seed=${getAvatarSeed(user, draft?.profile.displayName || seed.displayName)}`;
const patchProfile = (patch: Partial<AccountProfileSettings>) => {
setDraft((current) => current ? { ...current, profile: { ...current.profile, ...patch } } : current);
};
const patchAi = (patch: Partial<AccountAiPreferenceSettings>) => {
setDraft((current) => current ? { ...current, aiPreferences: { ...current.aiPreferences, ...patch } } : current);
};
const patchNotification = (key: AccountNotificationKey, patch: Partial<AccountSettings["notifications"][AccountNotificationKey]>) => {
setDraft((current) =>
current
? {
...current,
notifications: {
...current.notifications,
[key]: { ...current.notifications[key], ...patch },
},
}
: current
);
};
const save = () => {
if (!draft) return;
saveMutation.mutate(draft);
};
const saveStatus: SaveStatus = saveMutation.isError
? "failed"
: draft?.saveScope === "account"
? "account"
: "local";
if (settingsQuery.isLoading || !draft) {
return <AccountSettingsSkeleton />;
}
return (
<>
<DesktopAccountSettings
avatarSrc={avatarSrc}
draft={draft}
isDirty={isDirty}
isSaving={saveMutation.isPending}
saveStatus={saveStatus}
onLogout={onLogout}
onPatchAi={patchAi}
onPatchNotification={patchNotification}
onPatchProfile={patchProfile}
onSave={save}
/>
<MobileAccountSettings
avatarSrc={avatarSrc}
draft={draft}
activeSheet={activeSheet}
isDirty={isDirty}
isSaving={saveMutation.isPending}
saveStatus={saveStatus}
onOpenSheet={setActiveSheet}
onLogout={onLogout}
onPatchAi={patchAi}
onPatchNotification={patchNotification}
onPatchProfile={patchProfile}
onSave={save}
/>
</>
);
}
function DesktopAccountSettings({
avatarSrc,
draft,
isDirty,
isSaving,
saveStatus,
onLogout,
onPatchAi,
onPatchNotification,
onPatchProfile,
onSave,
}: {
avatarSrc: string;
draft: AccountSettings;
isDirty: boolean;
isSaving: boolean;
saveStatus: SaveStatus;
onLogout: () => void;
onPatchAi: (patch: Partial<AccountAiPreferenceSettings>) => void;
onPatchNotification: (key: AccountNotificationKey, patch: Partial<AccountSettings["notifications"][AccountNotificationKey]>) => void;
onPatchProfile: (patch: Partial<AccountProfileSettings>) => void;
onSave: () => void;
}) {
return (
<div className="hidden min-h-full bg-white md:block">
<div className="mx-auto w-full max-w-[1180px] px-8 py-8 xl:max-w-[1320px]">
<div className="mb-7 flex items-center justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-[#2463EB]">我的</p>
<h1 className="mt-2 text-2xl font-semibold text-slate-950">个人资料与偏好</h1>
<p className="mt-2 text-sm text-slate-500">这些设置会影响 PMFlow 的默认语言、AI 回复形态、证据展示和提醒节奏。</p>
</div>
<div className="flex items-center gap-3">
<Link to="/space" className="rounded-lg border border-[#dfe7f3] px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-[#2463EB] hover:text-[#2463EB] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2463EB]/30">
返回个人空间
</Link>
<Button
className="h-10 rounded-lg bg-[#2463EB] px-5 font-semibold text-white hover:bg-[#1f58d5]"
disabled={!isDirty || isSaving}
onClick={onSave}
>
{getSaveButtonLabel({ isDirty, isSaving, saveStatus })}
</Button>
</div>
</div>
<div className="grid gap-5 lg:grid-cols-[340px_minmax(0,1fr)]">
<aside className="space-y-5">
<ProfileSummary avatarSrc={avatarSrc} draft={draft} />
<SaveStatusPanel saveStatus={saveStatus} />
<section className="rounded-lg border border-[#e6edf7] bg-[#f8fbff] p-5">
<div className="flex items-start gap-3">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-white text-[#2463EB]">
<ShieldCheck className="h-5 w-5" />
</span>
<div>
<h2 className="text-base font-semibold text-slate-950">账户操作边界</h2>
<p className="mt-2 text-sm leading-6 text-slate-500">本页不修改 OAuth、JWT 或权限核心。退出登录只清理当前浏览器会话,草案、待确认和项目记忆仍按原链路保留。</p>
</div>
</div>
<div className="mt-5 grid gap-2">
<ActionButton icon={<Repeat2 className="h-4 w-4" />} label="切换账号" detail="保留当前空间上下文" />
<ActionButton icon={<LogOut className="h-4 w-4" />} label="退出登录" detail="AI 进度与待确认项保留" danger onClick={onLogout} />
</div>
</section>
</aside>
<main className="space-y-5">
<SettingsSection icon={<UserRound className="h-5 w-5" />} title="个人资料" detail="用于 PMFlow 内的称呼、空间标识和通知展示。">
<ProfileFields profile={draft.profile} onPatchProfile={onPatchProfile} />
</SettingsSection>
<SettingsSection icon={<Bot className="h-5 w-5" />} title="AI 偏好" detail="控制默认回答深度和证据呈现,不影响运行时模型路由。">
<AiPreferenceFields aiPreferences={draft.aiPreferences} onPatchAi={onPatchAi} />
</SettingsSection>
<SettingsSection icon={<Bell className="h-5 w-5" />} title="通知偏好" detail="优先覆盖需要人类拍板、执行回写、发布门禁和失败提醒。">
<NotificationFields notifications={draft.notifications} onPatchNotification={onPatchNotification} />
</SettingsSection>
</main>
</div>
</div>
</div>
);
}
function MobileAccountSettings({
avatarSrc,
draft,
activeSheet,
isDirty,
isSaving,
saveStatus,
onOpenSheet,
onLogout,
onPatchAi,
onPatchNotification,
onPatchProfile,
onSave,
}: {
avatarSrc: string;
draft: AccountSettings;
activeSheet: MobileSheetKey;
isDirty: boolean;
isSaving: boolean;
saveStatus: SaveStatus;
onOpenSheet: (key: MobileSheetKey) => void;
onLogout: () => void;
onPatchAi: (patch: Partial<AccountAiPreferenceSettings>) => void;
onPatchNotification: (key: AccountNotificationKey, patch: Partial<AccountSettings["notifications"][AccountNotificationKey]>) => void;
onPatchProfile: (patch: Partial<AccountProfileSettings>) => void;
onSave: () => void;
}) {
return (
<div className="min-h-screen bg-white px-4 pb-32 pt-5 text-slate-950 md:hidden">
<div className="flex items-center justify-between">
<PMFlowBrand compact />
<button type="button" className="rounded-lg border border-[#e6edf7] px-3 py-2 text-sm font-semibold text-slate-700">
个人空间 <ChevronDown className="ml-1 inline h-3.5 w-3.5" />
</button>
</div>
<div className="mt-7">
<ProfileSummary avatarSrc={avatarSrc} draft={draft} mobile />
</div>
<div className="mt-4">
<SaveStatusPanel saveStatus={saveStatus} mobile />
</div>
<MobileGroup title="设置">
<MobileSettingRow icon={<UserRound className="h-5 w-5" />} title="个人资料" detail={`${draft.profile.displayName} · ${draft.profile.roleTitle}`} onClick={() => onOpenSheet("profile")} />
<MobileSettingRow icon={<SlidersHorizontal className="h-5 w-5" />} title="AI 偏好" detail={`${getAnswerDepthTitle(draft.aiPreferences.answerDepth)} · ${getEvidenceTitle(draft.aiPreferences.evidenceDisplay)}`} onClick={() => onOpenSheet("ai")} />
<MobileSettingRow icon={<Bell className="h-5 w-5" />} title="通知偏好" detail={`${getEnabledNotificationCount(draft)} 类提醒已开启 · ${getProminentNotificationCount(draft)} 类强提醒`} onClick={() => onOpenSheet("notifications")} />
</MobileGroup>
<MobileGroup title="账号操作">
<MobileSettingRow icon={<Repeat2 className="h-5 w-5" />} title="切换账号" detail="保留当前空间上下文" />
<MobileSettingRow icon={<LogOut className="h-5 w-5" />} title="退出登录" detail="草案与待确认事项保留" danger onClick={onLogout} />
</MobileGroup>
<div className="fixed inset-x-0 bottom-[68px] z-40 border-t border-[#e6edf7] bg-white/95 px-4 py-3 backdrop-blur md:hidden">
<Button
className="h-12 w-full rounded-lg bg-[#2463EB] text-base font-semibold text-white hover:bg-[#1f58d5]"
disabled={!isDirty || isSaving}
onClick={onSave}
>
{getSaveButtonLabel({ isDirty, isSaving, saveStatus })}
</Button>
</div>
<MobileBottomNav />
<MobileBottomSheet
open={activeSheet === "profile"}
title="个人资料"
detail="用于空间标识、通知展示和 AI 对你的默认称呼。"
onClose={() => onOpenSheet(null)}
>
<div className="mt-6 max-h-[56vh] overflow-y-auto pr-1">
<ProfileFields profile={draft.profile} onPatchProfile={onPatchProfile} mobile />
</div>
</MobileBottomSheet>
<MobileBottomSheet
open={activeSheet === "ai"}
title="AI 偏好"
detail="偏好只影响 PMFlow 的默认回复形态,不改变模型路由和权限。"
onClose={() => onOpenSheet(null)}
>
<div className="mt-6 max-h-[56vh] overflow-y-auto pr-1">
<AiPreferenceFields aiPreferences={draft.aiPreferences} onPatchAi={onPatchAi} mobile />
</div>
</MobileBottomSheet>
<MobileBottomSheet
open={activeSheet === "notifications"}
title="通知偏好"
detail="优先处理需要你拍板、发布受阻或执行链路异常的提醒。"
onClose={() => onOpenSheet(null)}
>
<div className="mt-6 max-h-[56vh] overflow-y-auto pr-1">
<NotificationFields notifications={draft.notifications} onPatchNotification={onPatchNotification} mobile />
</div>
</MobileBottomSheet>
</div>
);
}
function ProfileSummary({ avatarSrc, draft, mobile = false }: { avatarSrc: string; draft: AccountSettings; mobile?: boolean }) {
return (
<section className={cn("rounded-lg border border-[#e6edf7] bg-white p-5", !mobile && "shadow-[0_12px_34px_rgba(15,23,42,0.04)]")}>
<div className="flex items-center gap-4">
<img src={avatarSrc} alt={`${draft.profile.displayName}头像`} className="h-16 w-16 rounded-full bg-[#eef4ff]" />
<div className="min-w-0">
<div className="truncate text-xl font-semibold text-slate-950">{draft.profile.displayName}</div>
<div className="mt-1 truncate text-sm text-slate-500">{draft.profile.roleTitle} · 个人空间</div>
<div className="mt-3 inline-flex items-center gap-1.5 rounded-full bg-[#eef4ff] px-3 py-1 text-xs font-semibold text-[#2463EB]">
<Check className="h-3.5 w-3.5" />
{draft.profile.defaultLanguage === "zh-CN" ? "默认中文" : "Default English"}
</div>
</div>
</div>
<div className="mt-5 border-t border-[#eef2f8] pt-4 text-xs text-slate-500">上次保存:{formatUpdatedAt(draft.updatedAt)}</div>
</section>
);
}
function SettingsSection({ icon, title, detail, children }: { icon: ReactNode; title: string; detail: string; children: ReactNode }) {
return (
<section className="rounded-lg border border-[#e6edf7] bg-white p-5 shadow-[0_12px_34px_rgba(15,23,42,0.04)]">
<div className="mb-5 flex items-start gap-3">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[#eef4ff] text-[#2463EB]">{icon}</span>
<div>
<h2 className="text-lg font-semibold text-slate-950">{title}</h2>
<p className="mt-1 text-sm leading-6 text-slate-500">{detail}</p>
</div>
</div>
{children}
</section>
);
}
type SaveStatus = AccountSettingsSaveScope | "failed";
function getSaveStatusCopy(saveStatus: SaveStatus): {
title: string;
detail: string;
wrap: string;
iconWrap: string;
titleClass: string;
Icon: ComponentType<{ className?: string }>;
} {
if (saveStatus === "account") {
return {
title: "已保存到当前账号",
detail: "这些偏好已和当前登录身份绑定,后续可由系统能力消费。",
wrap: "border-[#cfe0ff] bg-[#eef4ff]",
iconWrap: "bg-white text-[#2463EB]",
titleClass: "text-[#2459d3]",
Icon: ShieldCheck,
};
}
if (saveStatus === "failed") {
return {
title: "保存失败",
detail: "当前偏好还没有写入本机暂存。请重试,或检查浏览器存储权限。",
wrap: "border-rose-100 bg-rose-50",
iconWrap: "bg-white text-rose-600",
titleClass: "text-rose-700",
Icon: AlertTriangle,
};
}
return {
title: "仅本机暂存",
detail: "当前 V1 不写云端账号设置;刷新本机浏览器会保留,换设备不会同步。",
wrap: "border-[#e6edf7] bg-[#f8fbff]",
iconWrap: "bg-white text-slate-500",
titleClass: "text-slate-700",
Icon: Check,
};
}
function getSaveButtonLabel({
isDirty,
isSaving,
saveStatus,
}: {
isDirty: boolean;
isSaving: boolean;
saveStatus: SaveStatus;
}) {
if (isSaving) return "保存中";
if (isDirty && saveStatus === "failed") return "重试保存";
if (isDirty) return "保存设置";
if (saveStatus === "account") return "已保存到当前账号";
if (saveStatus === "failed") return "保存失败";
return "仅本机暂存";
}
function SaveStatusPanel({ saveStatus, mobile = false }: { saveStatus: SaveStatus; mobile?: boolean }) {
const status = getSaveStatusCopy(saveStatus);
return (
<section className={cn("rounded-lg border px-4 py-3 text-sm", status.wrap, !mobile && "shadow-[0_12px_34px_rgba(15,23,42,0.03)]")}>
<div className="flex items-start gap-3">
<span className={cn("mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg", status.iconWrap)}>
<status.Icon className="h-4 w-4" />
</span>
<div className="min-w-0">
<div className={cn("font-semibold", status.titleClass)}>{status.title}</div>
<div className="mt-1 leading-6 text-slate-500">{status.detail}</div>
</div>
</div>
</section>
);
}
function ProfileFields({
profile,
mobile = false,
onPatchProfile,
}: {
profile: AccountProfileSettings;
mobile?: boolean;
onPatchProfile: (patch: Partial<AccountProfileSettings>) => void;
}) {
return (
<div className={cn("grid gap-4", !mobile && "sm:grid-cols-2")}>
<LabeledControl label="显示名称">
<Input
value={profile.displayName}
onChange={(event) => onPatchProfile({ displayName: event.target.value })}
className="h-11 rounded-lg border-[#dfe7f3] focus-visible:ring-[#2463EB]/30"
/>
</LabeledControl>
<LabeledControl label="角色称谓">
<Input
value={profile.roleTitle}
onChange={(event) => onPatchProfile({ roleTitle: event.target.value })}
className="h-11 rounded-lg border-[#dfe7f3] focus-visible:ring-[#2463EB]/30"
/>
</LabeledControl>
<LabeledControl label="邮箱">
<div className="relative">
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
value={profile.email}
onChange={(event) => onPatchProfile({ email: event.target.value })}
className="h-11 rounded-lg border-[#dfe7f3] pl-9 focus-visible:ring-[#2463EB]/30"
placeholder="用于通知摘要"
/>
</div>
</LabeledControl>
<LabeledControl label="默认语言">
<SegmentedOptions
options={languageOptions}
value={profile.defaultLanguage}
onChange={(value) => onPatchProfile({ defaultLanguage: value })}
/>
</LabeledControl>
</div>
);
}
function AiPreferenceFields({
aiPreferences,
mobile = false,
onPatchAi,
}: {
aiPreferences: AccountAiPreferenceSettings;
mobile?: boolean;
onPatchAi: (patch: Partial<AccountAiPreferenceSettings>) => void;
}) {
return (
<div className={cn("grid gap-5", !mobile && "xl:grid-cols-2")}>
<LabeledControl label="AI 回答深度">
<SegmentedOptions
options={answerDepthOptions}
value={aiPreferences.answerDepth}
onChange={(value) => onPatchAi({ answerDepth: value })}
/>
</LabeledControl>
<LabeledControl label="证据展示偏好">
<SegmentedOptions
options={evidenceOptions}
value={aiPreferences.evidenceDisplay}
onChange={(value) => onPatchAi({ evidenceDisplay: value })}
/>
</LabeledControl>
</div>
);
}
function NotificationFields({
notifications,
mobile = false,
onPatchNotification,
}: {
notifications: AccountSettings["notifications"];
mobile?: boolean;
onPatchNotification: (key: AccountNotificationKey, patch: Partial<AccountSettings["notifications"][AccountNotificationKey]>) => void;
}) {
const consumption = getAccountNotificationConsumption({
notifications,
});
return (
<div className="space-y-3">
<NotificationConsumptionPreview items={consumption} mobile={mobile} />
{accountNotificationKeys.map((key) => {
const item = notifications[key];
const label = accountNotificationLabels[key];
const reminderIntensity = item.reminderIntensity || (item.enabled ? "normal" : "silent");
const evidenceDisplay = item.evidenceDisplay || "compact";
return (
<div key={key} className={cn("rounded-lg border border-[#e6edf7] bg-white p-4", mobile && "px-3")}>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="text-base font-semibold text-slate-950">{label.title}</div>
<div className="mt-1 text-sm leading-6 text-slate-500">{label.detail}</div>
</div>
<Switch
checked={reminderIntensity !== "silent" && item.enabled}
onCheckedChange={(enabled) =>
onPatchNotification(key, {
enabled,
reminderIntensity: enabled ? (reminderIntensity === "silent" ? "normal" : reminderIntensity) : "silent",
})
}
className="mt-1 data-[state=checked]:bg-[#2463EB]"
/>
</div>
<div className="mt-4 grid gap-4 xl:grid-cols-2">
<LabeledControl label="提醒强度">
<SegmentedOptions
options={reminderIntensityOptions}
value={reminderIntensity}
onChange={(reminderIntensity) =>
onPatchNotification(key, {
reminderIntensity,
enabled: reminderIntensity !== "silent",
})
}
/>
</LabeledControl>
<LabeledControl label="证据展示">
<SegmentedOptions
options={notificationEvidenceOptions}
value={evidenceDisplay}
onChange={(evidenceDisplay) => onPatchNotification(key, { evidenceDisplay })}
/>
</LabeledControl>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-2">
<ChannelToggle
label="桌面提醒"
checked={reminderIntensity !== "silent" && item.enabled && item.desktop}
disabled={reminderIntensity === "silent" || !item.enabled}
onChange={(desktop) => onPatchNotification(key, { desktop })}
/>
<ChannelToggle
label="邮件摘要"
checked={reminderIntensity !== "silent" && item.enabled && item.emailDigest}
disabled={reminderIntensity === "silent" || !item.enabled}
onChange={(emailDigest) => onPatchNotification(key, { emailDigest })}
/>
</div>
</div>
);
})}
</div>
);
}
function NotificationConsumptionPreview({
items,
mobile = false,
}: {
items: AccountNotificationConsumption[];
mobile?: boolean;
}) {
return (
<section className={cn("rounded-lg border border-[#dbe6ff] bg-[#f8fbff] p-4", mobile && "px-3")}>
<div className="flex items-start gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-white text-[#2463EB]">
<Bell className="h-4 w-4" />
</span>
<div className="min-w-0">
<div className="text-sm font-semibold text-slate-950">页面内提醒消费说明</div>
<p className="mt-1 text-xs leading-5 text-slate-500">
这些偏好只控制 PMFlow 页面里的提醒强度和证据展开方式:待确认、执行回写、发布门禁和失败提醒。当前不代表系统级桌面推送、邮件推送或真实通知中心已接入。
</p>
</div>
</div>
<div className={cn("mt-4 grid gap-2", !mobile && "sm:grid-cols-2")}>
{items.map((item) => (
<div key={item.key} className="rounded-lg border border-[#e6edf7] bg-white px-3 py-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-slate-900">{item.productArea}</div>
<div className="mt-1 text-xs leading-5 text-slate-500">{item.trigger}</div>
<div className="mt-2 text-xs font-semibold text-slate-500">
{getReminderIntensityTitle(item.reminderIntensity || (item.enabled ? "normal" : "silent"))} · {getNotificationEvidenceTitle(item.evidenceDisplay || "compact")}
</div>
</div>
<span
className={cn(
"shrink-0 rounded-full px-2.5 py-1 text-[11px] font-semibold",
item.showProminentReminder
? "bg-orange-50 text-orange-600"
: item.showInAppReminder
? "bg-[#eef4ff] text-[#2463EB]"
: "bg-slate-100 text-slate-500"
)}
>
{item.showProminentReminder ? "强提醒" : item.showInAppReminder ? "显示" : "静默"}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-slate-500">{item.detail}</div>
</div>
))}
</div>
</section>
);
}
function ChannelToggle({
label,
checked,
disabled,
onChange,
}: {
label: string;
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<label className={cn("flex min-h-10 items-center justify-between rounded-lg border px-3 text-sm font-semibold", disabled ? "border-[#eef2f8] bg-slate-50 text-slate-400" : "border-[#e6edf7] bg-[#f8fbff] text-slate-700")}>
{label}
<Switch checked={checked} disabled={disabled} onCheckedChange={onChange} className="data-[state=checked]:bg-[#2463EB]" />
</label>
);
}
function SegmentedOptions<T extends string>({
options,
value,
onChange,
}: {
options: Array<{ value: T; title: string; detail: string }>;
value: T;
onChange: (value: T) => void;
}) {
return (
<div className="grid gap-2">
{options.map((option) => {
const selected = value === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={cn(
"flex min-h-[58px] w-full items-center justify-between gap-3 rounded-lg border px-4 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2463EB]/30",
selected ? "border-[#2463EB] bg-[#eef4ff] text-[#174cba]" : "border-[#e6edf7] bg-white text-slate-700 hover:border-[#cfe0ff] hover:bg-[#f8fbff]"
)}
>
<span>
<span className="block text-sm font-semibold">{option.title}</span>
<span className={cn("mt-1 block text-xs leading-5", selected ? "text-[#2459d3]" : "text-slate-500")}>{option.detail}</span>
</span>
<span className={cn("flex h-5 w-5 shrink-0 items-center justify-center rounded-full border", selected ? "border-[#2463EB] bg-[#2463EB] text-white" : "border-[#d5deea] text-transparent")}>
<Check className="h-3.5 w-3.5" />
</span>
</button>
);
})}
</div>
);
}
function LabeledControl({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="block">
<span className="mb-2 block text-sm font-semibold text-slate-700">{label}</span>
{children}
</div>
);
}
function ActionButton({
icon,
label,
detail,
danger,
onClick,
}: {
icon: ReactNode;
label: string;
detail: string;
danger?: boolean;
onClick?: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full items-center gap-3 rounded-lg border px-3 py-3 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2463EB]/30",
danger ? "border-rose-200 bg-rose-50 text-rose-600 hover:bg-rose-100" : "border-[#e6edf7] bg-white text-slate-800 hover:border-[#cfe0ff]"
)}
>
<span className={cn("flex h-9 w-9 shrink-0 items-center justify-center rounded-lg", danger ? "bg-white text-rose-500" : "bg-[#eef4ff] text-[#2463EB]")}>{icon}</span>
<span className="min-w-0">
<span className="block text-sm font-semibold">{label}</span>
<span className="mt-0.5 block truncate text-xs text-slate-500">{detail}</span>
</span>
</button>
);
}
function MobileGroup({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="mt-7">
<h2 className="mb-3 text-lg font-semibold text-slate-950">{title}</h2>
<div className="space-y-3">{children}</div>
</section>
);
}
function MobileSettingRow({
icon,
title,
detail,
danger,
onClick,
}: {
icon: ReactNode;
title: string;
detail?: string;
danger?: boolean;
onClick?: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex min-h-[64px] w-full items-center justify-between gap-3 rounded-lg border px-4 text-left transition active:scale-[0.99]",
danger ? "border-rose-200 bg-rose-50 text-rose-600" : "border-[#e6edf7] bg-white text-slate-900"
)}
>
<span className="flex min-w-0 items-center gap-3">
<span className={cn("flex h-10 w-10 shrink-0 items-center justify-center rounded-lg", danger ? "bg-white text-rose-500" : "bg-[#eef4ff] text-[#2463EB]")}>{icon}</span>
<span className="min-w-0">
<span className="block text-base font-semibold">{title}</span>
{detail ? <span className="mt-0.5 block truncate text-sm text-slate-500">{detail}</span> : null}
</span>
</span>
<span className={danger ? "text-rose-300" : "text-slate-300"}>›</span>
</button>
);
}
function AccountSettingsSkeleton() {
return (
<div className="min-h-screen bg-white px-5 py-8">
<div className="mx-auto max-w-[1180px] animate-pulse">
<div className="h-8 w-48 rounded-lg bg-slate-100" />
<div className="mt-4 h-4 w-80 max-w-full rounded bg-slate-100" />
<div className="mt-8 grid gap-5 lg:grid-cols-[340px_minmax(0,1fr)]">
<div className="h-56 rounded-lg bg-slate-100" />
<div className="space-y-5">
<div className="h-52 rounded-lg bg-slate-100" />
<div className="h-52 rounded-lg bg-slate-100" />
</div>
</div>
</div>
</div>
);
}
function getEnabledNotificationCount(settings: AccountSettings) {
return accountNotificationKeys.filter((key) => settings.notifications[key].enabled).length;
}
function getProminentNotificationCount(settings: AccountSettings) {
return accountNotificationKeys.filter((key) => settings.notifications[key].reminderIntensity === "prominent").length;
}
function getAnswerDepthTitle(value: AccountAnswerDepth) {
return answerDepthOptions.find((option) => option.value === value)?.title || "标准";
}
function getEvidenceTitle(value: AccountEvidenceDisplay) {
return evidenceOptions.find((option) => option.value === value)?.title || "内联";
}
function getReminderIntensityTitle(value: AccountReminderIntensity) {
return reminderIntensityOptions.find((option) => option.value === value)?.title || "普通";
}
function getNotificationEvidenceTitle(value: AccountNotificationEvidenceDisplay) {
return notificationEvidenceOptions.find((option) => option.value === value)?.title || "紧凑";
}