diff --git a/frontend/src/api/accountSettings.ts b/frontend/src/api/accountSettings.ts index a073c766..d931f5cc 100644 --- a/frontend/src/api/accountSettings.ts +++ b/frontend/src/api/accountSettings.ts @@ -26,6 +26,21 @@ export interface AccountNotificationPreference { } export type AccountNotificationSettings = Record; +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 | 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 | null | undefined, + key: AccountNotificationKey +): boolean { + const normalized = mergeAccountSettings(createDefaultAccountSettings(), settings || null); + return Boolean(normalized.notifications[key].enabled); +} + +export function getAccountNotificationConsumption( + settings: AccountSettings | Partial | 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 { const defaults = createDefaultAccountSettings(seed); const localSettings = mergeAccountSettings(defaults, readStoredAccountSettings()); diff --git a/frontend/src/features/account-settings/AccountSettingsPanel.tsx b/frontend/src/features/account-settings/AccountSettingsPanel.tsx index 1e64ca40..a41f44c3 100644 --- a/frontend/src/features/account-settings/AccountSettingsPanel.tsx +++ b/frontend/src/features/account-settings/AccountSettingsPanel.tsx @@ -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) => void; }) { + const consumption = getAccountNotificationConsumption({ + notifications, + }); return (
+ {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 ( +
+
+ + + +
+
页面内提醒消费说明
+

+ 这些开关只控制 PMFlow 页面里的提醒展示:待确认、执行回写、发布门禁和失败提醒。当前不代表系统级桌面推送、邮件推送或真实通知中心已接入。 +

+
+
+
+ {items.map((item) => ( +
+
+
+
{item.productArea}
+
{item.trigger}
+
+ + {item.showInAppReminder ? "显示" : "静默"} + +
+
{item.detail}
+
+ ))} +
+
+ ); +} + function ChannelToggle({ label, checked, diff --git a/frontend/src/features/account-settings/accountSettings.test.ts b/frontend/src/features/account-settings/accountSettings.test.ts index 3600ed13..8c583862 100644 --- a/frontend/src/features/account-settings/accountSettings.test.ts +++ b/frontend/src/features/account-settings/accountSettings.test.ts @@ -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");