feat(account): map notification preferences to surfaces
This commit is contained in:
parent
9ba9876811
commit
5a86007e4c
|
|
@ -26,6 +26,21 @@ export interface 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 {
|
||||
profile: AccountProfileSettings;
|
||||
|
|
@ -76,6 +91,32 @@ export const accountNotificationKeys: AccountNotificationKey[] = [
|
|||
"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 {
|
||||
return {
|
||||
profile: {
|
||||
|
|
@ -137,6 +178,13 @@ function cacheAccountSettingsSnapshot(settings: AccountSettings): void {
|
|||
|
||||
function mergeAccountSettings(base: AccountSettings, stored: Partial<AccountSettings> | null): AccountSettings {
|
||||
if (!stored) return base;
|
||||
const mergedNotifications = accountNotificationKeys.reduce((result, key) => {
|
||||
result[key] = {
|
||||
...base.notifications[key],
|
||||
...(stored.notifications?.[key] || {}),
|
||||
};
|
||||
return result;
|
||||
}, {} as AccountNotificationSettings);
|
||||
return {
|
||||
...base,
|
||||
...stored,
|
||||
|
|
@ -148,13 +196,45 @@ function mergeAccountSettings(base: AccountSettings, stored: Partial<AccountSett
|
|||
...base.aiPreferences,
|
||||
...stored.aiPreferences,
|
||||
},
|
||||
notifications: {
|
||||
...base.notifications,
|
||||
...stored.notifications,
|
||||
},
|
||||
notifications: mergedNotifications,
|
||||
};
|
||||
}
|
||||
|
||||
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> {
|
||||
const defaults = createDefaultAccountSettings(seed);
|
||||
const localSettings = mergeAccountSettings(defaults, readStoredAccountSettings());
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@ import { Link } from "react-router-dom";
|
|||
import {
|
||||
accountNotificationKeys,
|
||||
accountNotificationLabels,
|
||||
getAccountNotificationConsumption,
|
||||
getAccountSettings,
|
||||
updateAccountSettings,
|
||||
type AccountNotificationConsumption,
|
||||
type AccountAiPreferenceSettings,
|
||||
type AccountAnswerDepth,
|
||||
type AccountEvidenceDisplay,
|
||||
|
|
@ -559,8 +561,12 @@ function NotificationFields({
|
|||
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];
|
||||
|
|
@ -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({
|
||||
label,
|
||||
checked,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
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", () => {
|
||||
const storageSetItem = Storage.prototype.setItem;
|
||||
|
|
@ -56,6 +62,51 @@ describe("account settings api", () => {
|
|||
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 () => {
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("storage denied");
|
||||
|
|
|
|||
Loading…
Reference in New Issue