feat(account): map notification preferences to surfaces

This commit is contained in:
Albert 2026-04-26 12:39:28 +08:00
parent 9ba9876811
commit 5a86007e4c
3 changed files with 187 additions and 5 deletions

View File

@ -26,6 +26,21 @@ export interface AccountNotificationPreference {
} }
export type AccountNotificationSettings = Record<AccountNotificationKey, AccountNotificationPreference>; export type AccountNotificationSettings = Record<AccountNotificationKey, AccountNotificationPreference>;
export type AccountNotificationConsumptionSurface = "change_review" | "execution_bridge" | "release_growth" | "system_failure";
export interface AccountNotificationConsumption {
key: AccountNotificationKey;
surface: AccountNotificationConsumptionSurface;
title: string;
productArea: string;
trigger: string;
enabled: boolean;
showInAppReminder: boolean;
desktopPreference: boolean;
emailDigestPreference: boolean;
statusLabel: string;
detail: string;
}
export interface AccountSettings { export interface AccountSettings {
profile: AccountProfileSettings; profile: AccountProfileSettings;
@ -76,6 +91,32 @@ export const accountNotificationKeys: AccountNotificationKey[] = [
"failureAlert", "failureAlert",
]; ];
const accountNotificationConsumptionMeta: Record<
AccountNotificationKey,
{ surface: AccountNotificationConsumptionSurface; productArea: string; trigger: string }
> = {
pendingConfirmation: {
surface: "change_review",
productArea: "待确认中心",
trigger: "AI 生成草案、ChangeSet 待审、写入项目记忆前的人类拍板提醒。",
},
executionWriteback: {
surface: "execution_bridge",
productArea: "Execution Bridge",
trigger: "branch、commit、PR、测试进展或执行回写事件进入 dev-sync 时的页面内提醒。",
},
releaseGate: {
surface: "release_growth",
productArea: "Release / Growth",
trigger: "发布门禁阻塞、校验通过、风险项需要处理时的页面内提醒。",
},
failureAlert: {
surface: "system_failure",
productArea: "全局失败提醒",
trigger: "AI 调用、同步任务、执行回写或外部集成失败时的页面内失败提示。",
},
};
export function createDefaultAccountSettings(seed: AccountSettingsSeed = {}): AccountSettings { export function createDefaultAccountSettings(seed: AccountSettingsSeed = {}): AccountSettings {
return { return {
profile: { profile: {
@ -137,6 +178,13 @@ function cacheAccountSettingsSnapshot(settings: AccountSettings): void {
function mergeAccountSettings(base: AccountSettings, stored: Partial<AccountSettings> | null): AccountSettings { function mergeAccountSettings(base: AccountSettings, stored: Partial<AccountSettings> | null): AccountSettings {
if (!stored) return base; if (!stored) return base;
const mergedNotifications = accountNotificationKeys.reduce((result, key) => {
result[key] = {
...base.notifications[key],
...(stored.notifications?.[key] || {}),
};
return result;
}, {} as AccountNotificationSettings);
return { return {
...base, ...base,
...stored, ...stored,
@ -148,13 +196,45 @@ function mergeAccountSettings(base: AccountSettings, stored: Partial<AccountSett
...base.aiPreferences, ...base.aiPreferences,
...stored.aiPreferences, ...stored.aiPreferences,
}, },
notifications: { notifications: mergedNotifications,
...base.notifications,
...stored.notifications,
},
}; };
} }
export function shouldShowAccountNotification(
settings: AccountSettings | Partial<AccountSettings> | null | undefined,
key: AccountNotificationKey
): boolean {
const normalized = mergeAccountSettings(createDefaultAccountSettings(), settings || null);
return Boolean(normalized.notifications[key].enabled);
}
export function getAccountNotificationConsumption(
settings: AccountSettings | Partial<AccountSettings> | null | undefined
): AccountNotificationConsumption[] {
const normalized = mergeAccountSettings(createDefaultAccountSettings(), settings || null);
return accountNotificationKeys.map((key) => {
const preference = normalized.notifications[key];
const label = accountNotificationLabels[key];
const meta = accountNotificationConsumptionMeta[key];
const enabled = Boolean(preference.enabled);
return {
key,
surface: meta.surface,
title: label.title,
productArea: meta.productArea,
trigger: meta.trigger,
enabled,
showInAppReminder: enabled,
desktopPreference: enabled && Boolean(preference.desktop),
emailDigestPreference: enabled && Boolean(preference.emailDigest),
statusLabel: enabled ? "页面内提醒显示" : "页面内提醒静默",
detail: enabled
? `${meta.productArea} 会显示该类页面内提醒;桌面/邮件只记录偏好,不代表系统级推送已接入。`
: `${meta.productArea} 不显示该类页面内提醒;底层对象、状态和审计记录仍保留。`,
};
});
}
export async function getAccountSettings(seed?: AccountSettingsSeed): Promise<AccountSettings> { export async function getAccountSettings(seed?: AccountSettingsSeed): Promise<AccountSettings> {
const defaults = createDefaultAccountSettings(seed); const defaults = createDefaultAccountSettings(seed);
const localSettings = mergeAccountSettings(defaults, readStoredAccountSettings()); const localSettings = mergeAccountSettings(defaults, readStoredAccountSettings());

View File

@ -17,8 +17,10 @@ import { Link } from "react-router-dom";
import { import {
accountNotificationKeys, accountNotificationKeys,
accountNotificationLabels, accountNotificationLabels,
getAccountNotificationConsumption,
getAccountSettings, getAccountSettings,
updateAccountSettings, updateAccountSettings,
type AccountNotificationConsumption,
type AccountAiPreferenceSettings, type AccountAiPreferenceSettings,
type AccountAnswerDepth, type AccountAnswerDepth,
type AccountEvidenceDisplay, type AccountEvidenceDisplay,
@ -559,8 +561,12 @@ function NotificationFields({
mobile?: boolean; mobile?: boolean;
onPatchNotification: (key: AccountNotificationKey, patch: Partial<AccountSettings["notifications"][AccountNotificationKey]>) => void; onPatchNotification: (key: AccountNotificationKey, patch: Partial<AccountSettings["notifications"][AccountNotificationKey]>) => void;
}) { }) {
const consumption = getAccountNotificationConsumption({
notifications,
});
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<NotificationConsumptionPreview items={consumption} mobile={mobile} />
{accountNotificationKeys.map((key) => { {accountNotificationKeys.map((key) => {
const item = notifications[key]; const item = notifications[key];
const label = accountNotificationLabels[key]; const label = accountNotificationLabels[key];
@ -598,6 +604,51 @@ function NotificationFields({
); );
} }
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>
<span
className={cn(
"shrink-0 rounded-full px-2.5 py-1 text-[11px] font-semibold",
item.showInAppReminder ? "bg-[#eef4ff] text-[#2463EB]" : "bg-slate-100 text-slate-500"
)}
>
{item.showInAppReminder ? "显示" : "静默"}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-slate-500">{item.detail}</div>
</div>
))}
</div>
</section>
);
}
function ChannelToggle({ function ChannelToggle({
label, label,
checked, checked,

View File

@ -1,5 +1,11 @@
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { createDefaultAccountSettings, getAccountSettings, updateAccountSettings } from "@/api/accountSettings"; import {
createDefaultAccountSettings,
getAccountNotificationConsumption,
getAccountSettings,
shouldShowAccountNotification,
updateAccountSettings,
} from "@/api/accountSettings";
describe("account settings api", () => { describe("account settings api", () => {
const storageSetItem = Storage.prototype.setItem; const storageSetItem = Storage.prototype.setItem;
@ -56,6 +62,51 @@ describe("account settings api", () => {
expect(restored.notifications.failureAlert.enabled).toBe(false); expect(restored.notifications.failureAlert.enabled).toBe(false);
}); });
it("exposes notification preferences as product surface consumption rules", () => {
const defaults = createDefaultAccountSettings();
const settings = {
...defaults,
notifications: {
...defaults.notifications,
executionWriteback: { enabled: false, desktop: false, emailDigest: false },
releaseGate: { enabled: true, desktop: false, emailDigest: true },
},
};
const consumption = getAccountNotificationConsumption(settings);
const execution = consumption.find((item) => item.key === "executionWriteback");
const release = consumption.find((item) => item.key === "releaseGate");
expect(shouldShowAccountNotification(settings, "pendingConfirmation")).toBe(true);
expect(shouldShowAccountNotification(settings, "executionWriteback")).toBe(false);
expect(execution?.productArea).toBe("Execution Bridge");
expect(execution?.showInAppReminder).toBe(false);
expect(execution?.statusLabel).toBe("页面内提醒静默");
expect(release?.productArea).toBe("Release / Growth");
expect(release?.showInAppReminder).toBe(true);
expect(release?.emailDigestPreference).toBe(true);
});
it("falls back missing notification fields to safe defaults", async () => {
window.localStorage.setItem(
"pmflow:account-settings:v1",
JSON.stringify({
notifications: {
failureAlert: { enabled: false },
},
})
);
const restored = await getAccountSettings();
const consumption = getAccountNotificationConsumption(restored);
expect(restored.notifications.pendingConfirmation.desktop).toBe(true);
expect(restored.notifications.failureAlert.enabled).toBe(false);
expect(restored.notifications.failureAlert.desktop).toBe(true);
expect(shouldShowAccountNotification(restored, "failureAlert")).toBe(false);
expect(consumption.find((item) => item.key === "failureAlert")?.detail).toContain("仍保留");
});
it("fails explicitly when local persistence is unavailable", async () => { it("fails explicitly when local persistence is unavailable", async () => {
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("storage denied"); throw new Error("storage denied");