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 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());
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue