880 lines
34 KiB
TypeScript
880 lines
34 KiB
TypeScript
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 || "紧凑";
|
||
}
|