feat(account): add notification intensity contract

This commit is contained in:
Albert 2026-04-26 14:50:33 +08:00
parent e4edfac782
commit 6bfc3795ae
4 changed files with 402 additions and 31 deletions

View File

@ -807,6 +807,32 @@ P0 公共壳冻结已完成,下一批可以进入 `Batch P1` 三模块并行
## 8. 进度台账 ## 8. 进度台账
### 2026-04-26 / account-p7-review-notification-intensity / implemented
完成内容:
- 将 Account Settings 通知偏好从简单开关扩展为本地 UI 提醒表达合同:每类提醒继续保留 `enabled / desktop / emailDigest` 兼容字段,同时新增 `reminderIntensity=silent|normal|prominent` 与 `evidenceDisplay=compact|expanded`。
- 新增 `getAccountReminderConsumption(settings, event)`,覆盖 `change_review_high_risk / iteration_draft_ready / release_blocked / execution_stalled`,稳定输出 `surface / notificationKey / reminderIntensity / evidenceDisplay / showInAppReminder / showProminentReminder / evidencePolicy / auditPolicy / notificationTransport=in_app_only`。
- 默认值向后兼容 P1-P6:旧数据缺少新字段时,旧开启等价 `normal + compact`,旧关闭等价 `silent + compact`;偏好关闭只静默页面内提醒,不隐藏 ChangeSet、recommendation、release evidence、execution 状态或审计状态。
- `AccountSettingsPanel` 通知偏好区新增提醒强度和证据展示配置;桌面提醒、邮件摘要仍只记录偏好,不代表真实邮件、桌面推送或系统通知中心已接入。
- 本轮只扩展前端本地 helper 和页面设置,不改后端 schema、migration、router、PMFlow OS store、Space 或 Main Flow 页面。
涉及文件:
- `frontend/src/api/accountSettings.ts`
- `frontend/src/features/account-settings/AccountSettingsPanel.tsx`
- `frontend/src/features/account-settings/accountSettings.test.ts`
- `CLAUDE.md`
- `docs/architecture/CODE_REFERENCE_INDEX.md`
- `docs/architecture/ARCHITECTURE_INVENTORY.md`
- `docs/PMFLOW_PRODUCT_DELIVERY_STANDARD.md`
验证:
- `cd frontend && npm run test -- src/features/account-settings/accountSettings.test.ts` 通过:`11 passed`。
- `cd frontend && npm run typecheck` 通过。
- `cd frontend && npm run build` 通过;Vite 仍提示既有 manual chunk circular warning(`ai-steward / sstc / charts / governance` chunk 环),构建成功。
### 2026-04-26 / big-module-integration-p6 / done ### 2026-04-26 / big-module-integration-p6 / done
完成内容: 完成内容:

View File

@ -4,8 +4,15 @@ import { getAuthToken } from "@/lib/api";
export type AccountLanguage = "zh-CN" | "en-US"; export type AccountLanguage = "zh-CN" | "en-US";
export type AccountAnswerDepth = "concise" | "balanced" | "deep"; export type AccountAnswerDepth = "concise" | "balanced" | "deep";
export type AccountEvidenceDisplay = "summary" | "inline" | "full"; export type AccountEvidenceDisplay = "summary" | "inline" | "full";
export type AccountReminderIntensity = "silent" | "normal" | "prominent";
export type AccountNotificationEvidenceDisplay = "compact" | "expanded";
export type AccountNotificationKey = "pendingConfirmation" | "executionWriteback" | "releaseGate" | "failureAlert"; export type AccountNotificationKey = "pendingConfirmation" | "executionWriteback" | "releaseGate" | "failureAlert";
export type AccountSettingsSaveScope = "local" | "account"; export type AccountSettingsSaveScope = "local" | "account";
export type AccountReminderConsumptionEvent =
| "change_review_high_risk"
| "iteration_draft_ready"
| "release_blocked"
| "execution_stalled";
export type AccountPostReleaseNotificationEvent = export type AccountPostReleaseNotificationEvent =
| "feedback_received" | "feedback_received"
| "revenue_signal" | "revenue_signal"
@ -35,6 +42,8 @@ export interface AccountNotificationPreference {
enabled: boolean; enabled: boolean;
desktop: boolean; desktop: boolean;
emailDigest: boolean; emailDigest: boolean;
reminderIntensity?: AccountReminderIntensity;
evidenceDisplay?: AccountNotificationEvidenceDisplay;
} }
export type AccountNotificationSettings = Record<AccountNotificationKey, AccountNotificationPreference>; export type AccountNotificationSettings = Record<AccountNotificationKey, AccountNotificationPreference>;
@ -47,20 +56,46 @@ export interface AccountNotificationConsumption {
productArea: string; productArea: string;
trigger: string; trigger: string;
enabled: boolean; enabled: boolean;
reminderIntensity: AccountReminderIntensity;
evidenceDisplay: AccountNotificationEvidenceDisplay;
showInAppReminder: boolean; showInAppReminder: boolean;
showProminentReminder: boolean;
desktopPreference: boolean; desktopPreference: boolean;
emailDigestPreference: boolean; emailDigestPreference: boolean;
statusLabel: string; statusLabel: string;
detail: string; detail: string;
} }
export interface AccountReminderConsumptionContract {
event: AccountReminderConsumptionEvent;
notificationKey: AccountNotificationKey;
surface: Exclude<AccountNotificationConsumptionSurface, "system_failure">;
productArea: string;
title: string;
reason: string;
reminderIntensity: AccountReminderIntensity;
evidenceDisplay: AccountNotificationEvidenceDisplay;
showInAppReminder: boolean;
showProminentReminder: boolean;
desktopPreference: boolean;
emailDigestPreference: boolean;
notificationTransport: "in_app_only";
evidencePolicy: "preserve_business_evidence";
auditPolicy: "preserve_audit_state";
mutedDetail: string;
}
export interface AccountReleaseGateNotificationPreference { export interface AccountReleaseGateNotificationPreference {
event: AccountReleaseNotificationEvent; event: AccountReleaseNotificationEvent;
notificationKey: AccountNotificationKey; notificationKey: AccountNotificationKey;
surface: "release_growth" | "system_failure"; surface: "release_growth" | "system_failure";
inAppEnabled: boolean; inAppEnabled: boolean;
reminderIntensity: AccountReminderIntensity;
evidenceDisplay: AccountNotificationEvidenceDisplay;
showProminentReminder: boolean;
desktopPreference: boolean; desktopPreference: boolean;
emailDigestPreference: boolean; emailDigestPreference: boolean;
notificationTransport: "in_app_only";
title: string; title: string;
reason: string; reason: string;
} }
@ -72,8 +107,12 @@ export interface AccountPostReleaseNotificationPreference {
spaceReadable: boolean; spaceReadable: boolean;
inAppEnabled: boolean; inAppEnabled: boolean;
showInAppReminder: boolean; showInAppReminder: boolean;
reminderIntensity: AccountReminderIntensity;
evidenceDisplay: AccountNotificationEvidenceDisplay;
showProminentReminder: boolean;
desktopPreference: boolean; desktopPreference: boolean;
emailDigestPreference: boolean; emailDigestPreference: boolean;
notificationTransport: "in_app_only";
title: string; title: string;
reason: string; reason: string;
evidencePolicy: "preserve_business_evidence"; evidencePolicy: "preserve_business_evidence";
@ -156,6 +195,36 @@ const accountNotificationConsumptionMeta: Record<
}, },
}; };
const reminderConsumptionEventMeta: Record<
AccountReminderConsumptionEvent,
{ notificationKey: AccountNotificationKey; surface: Exclude<AccountNotificationConsumptionSurface, "system_failure">; productArea: string; reason: string }
> = {
change_review_high_risk: {
notificationKey: "pendingConfirmation",
surface: "change_review",
productArea: "Change Review",
reason: "高风险 ChangeSet 或 ChangeItem 进入待审时使用待确认偏好控制页面内提醒表达。",
},
iteration_draft_ready: {
notificationKey: "pendingConfirmation",
surface: "change_review",
productArea: "Change Review",
reason: "下一轮迭代建议草案准备好、需要人工拍板时使用待确认偏好控制页面内提醒表达。",
},
release_blocked: {
notificationKey: "releaseGate",
surface: "release_growth",
productArea: "Release / Growth",
reason: "发布、合规或增长验证受阻时使用发布门禁偏好控制页面内提醒表达。",
},
execution_stalled: {
notificationKey: "executionWriteback",
surface: "execution_bridge",
productArea: "Execution Bridge",
reason: "执行链路停滞、长时间无进展或等待运行端处理时使用执行回写偏好控制页面内提醒表达。",
},
};
const releaseNotificationEventMeta: Record< const releaseNotificationEventMeta: Record<
AccountReleaseNotificationEvent, AccountReleaseNotificationEvent,
{ notificationKey: AccountNotificationKey; surface: "release_growth" | "system_failure"; reason: string } { notificationKey: AccountNotificationKey; surface: "release_growth" | "system_failure"; reason: string }
@ -231,10 +300,34 @@ export function createDefaultAccountSettings(seed: AccountSettingsSeed = {}): Ac
evidenceDisplay: "inline", evidenceDisplay: "inline",
}, },
notifications: { notifications: {
pendingConfirmation: { enabled: true, desktop: true, emailDigest: false }, pendingConfirmation: {
executionWriteback: { enabled: true, desktop: true, emailDigest: false }, enabled: true,
releaseGate: { enabled: true, desktop: true, emailDigest: true }, desktop: true,
failureAlert: { enabled: true, desktop: true, emailDigest: true }, emailDigest: false,
reminderIntensity: "normal",
evidenceDisplay: "compact",
},
executionWriteback: {
enabled: true,
desktop: true,
emailDigest: false,
reminderIntensity: "normal",
evidenceDisplay: "compact",
},
releaseGate: {
enabled: true,
desktop: true,
emailDigest: true,
reminderIntensity: "normal",
evidenceDisplay: "compact",
},
failureAlert: {
enabled: true,
desktop: true,
emailDigest: true,
reminderIntensity: "normal",
evidenceDisplay: "compact",
},
}, },
saveScope: "local", saveScope: "local",
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
@ -280,10 +373,7 @@ 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) => { const mergedNotifications = accountNotificationKeys.reduce((result, key) => {
result[key] = { result[key] = normalizeNotificationPreference(base.notifications[key], stored.notifications?.[key]);
...base.notifications[key],
...(stored.notifications?.[key] || {}),
};
return result; return result;
}, {} as AccountNotificationSettings); }, {} as AccountNotificationSettings);
return { return {
@ -301,6 +391,49 @@ function mergeAccountSettings(base: AccountSettings, stored: Partial<AccountSett
}; };
} }
function normalizeNotificationPreference(
base: AccountNotificationPreference,
stored?: Partial<AccountNotificationPreference>
): AccountNotificationPreference {
const raw = {
...base,
...(stored || {}),
};
const storedIntensity = stored?.reminderIntensity;
const reminderIntensity: AccountReminderIntensity =
storedIntensity === "silent" || storedIntensity === "normal" || storedIntensity === "prominent"
? storedIntensity
: raw.enabled
? "normal"
: "silent";
const evidenceDisplay: AccountNotificationEvidenceDisplay =
raw.evidenceDisplay === "expanded" ? "expanded" : "compact";
return {
...raw,
enabled: reminderIntensity !== "silent",
desktop: reminderIntensity !== "silent" && Boolean(raw.desktop),
emailDigest: reminderIntensity !== "silent" && Boolean(raw.emailDigest),
reminderIntensity,
evidenceDisplay,
};
}
function getNotificationReminderIntensity(preference: AccountNotificationPreference): AccountReminderIntensity {
return preference.reminderIntensity === "silent" ||
preference.reminderIntensity === "normal" ||
preference.reminderIntensity === "prominent"
? preference.reminderIntensity
: preference.enabled
? "normal"
: "silent";
}
function getNotificationEvidenceDisplay(
preference: AccountNotificationPreference
): AccountNotificationEvidenceDisplay {
return preference.evidenceDisplay === "expanded" ? "expanded" : "compact";
}
export function shouldShowAccountNotification( export function shouldShowAccountNotification(
settings: AccountSettings | Partial<AccountSettings> | null | undefined, settings: AccountSettings | Partial<AccountSettings> | null | undefined,
key: AccountNotificationKey key: AccountNotificationKey
@ -317,7 +450,9 @@ export function getAccountNotificationConsumption(
const preference = normalized.notifications[key]; const preference = normalized.notifications[key];
const label = accountNotificationLabels[key]; const label = accountNotificationLabels[key];
const meta = accountNotificationConsumptionMeta[key]; const meta = accountNotificationConsumptionMeta[key];
const enabled = Boolean(preference.enabled); const reminderIntensity = getNotificationReminderIntensity(preference);
const evidenceDisplay = getNotificationEvidenceDisplay(preference);
const enabled = reminderIntensity !== "silent" && Boolean(preference.enabled);
return { return {
key, key,
surface: meta.surface, surface: meta.surface,
@ -325,17 +460,56 @@ export function getAccountNotificationConsumption(
productArea: meta.productArea, productArea: meta.productArea,
trigger: meta.trigger, trigger: meta.trigger,
enabled, enabled,
reminderIntensity,
evidenceDisplay,
showInAppReminder: enabled, showInAppReminder: enabled,
showProminentReminder: enabled && reminderIntensity === "prominent",
desktopPreference: enabled && Boolean(preference.desktop), desktopPreference: enabled && Boolean(preference.desktop),
emailDigestPreference: enabled && Boolean(preference.emailDigest), emailDigestPreference: enabled && Boolean(preference.emailDigest),
statusLabel: enabled ? "页面内提醒显示" : "页面内提醒静默", statusLabel: enabled
? reminderIntensity === "prominent"
? "页面内强提醒"
: "页面内提醒显示"
: "页面内提醒静默",
detail: enabled detail: enabled
? `${meta.productArea} 会显示该类页面内提醒;桌面/邮件只记录偏好,不代表系统级推送已接入。` ? `${meta.productArea} 会按 ${reminderIntensity === "prominent" ? "强提醒" : "普通提醒"} 展示页面内提醒,证据以 ${evidenceDisplay === "expanded" ? "展开" : "紧凑"} 模式呈现;桌面/邮件只记录偏好,不代表系统级推送已接入。`
: `${meta.productArea} 不显示该类页面内提醒;底层对象、状态和审计记录仍保留。`, : `${meta.productArea} 不显示该类页面内提醒;底层对象、状态和审计记录仍保留。`,
}; };
}); });
} }
export function getAccountReminderConsumption(
settings: AccountSettings | Partial<AccountSettings> | null | undefined,
event: AccountReminderConsumptionEvent
): AccountReminderConsumptionContract {
const normalized = mergeAccountSettings(createDefaultAccountSettings(), settings || null);
const meta = reminderConsumptionEventMeta[event];
const preference = normalized.notifications[meta.notificationKey];
const reminderIntensity = getNotificationReminderIntensity(preference);
const evidenceDisplay = getNotificationEvidenceDisplay(preference);
const enabled = reminderIntensity !== "silent" && Boolean(preference.enabled);
return {
event,
notificationKey: meta.notificationKey,
surface: meta.surface,
productArea: meta.productArea,
title: accountNotificationLabels[meta.notificationKey].title,
reason: meta.reason,
reminderIntensity,
evidenceDisplay,
showInAppReminder: enabled,
showProminentReminder: enabled && reminderIntensity === "prominent",
desktopPreference: enabled && Boolean(preference.desktop),
emailDigestPreference: enabled && Boolean(preference.emailDigest),
notificationTransport: "in_app_only",
evidencePolicy: "preserve_business_evidence",
auditPolicy: "preserve_audit_state",
mutedDetail: enabled
? "仅影响页面内提醒强度和证据展开方式;不接真实邮件、桌面推送或系统通知中心。"
: "仅静默页面内提醒;真实证据、ChangeSet、recommendation、执行状态和审计状态仍必须按业务页面展示。",
};
}
export function getReleaseGateNotificationPreference( export function getReleaseGateNotificationPreference(
settings: AccountSettings | Partial<AccountSettings> | null | undefined, settings: AccountSettings | Partial<AccountSettings> | null | undefined,
event: AccountReleaseNotificationEvent = "release_gate_blocked" event: AccountReleaseNotificationEvent = "release_gate_blocked"
@ -343,14 +517,20 @@ export function getReleaseGateNotificationPreference(
const normalized = mergeAccountSettings(createDefaultAccountSettings(), settings || null); const normalized = mergeAccountSettings(createDefaultAccountSettings(), settings || null);
const meta = releaseNotificationEventMeta[event]; const meta = releaseNotificationEventMeta[event];
const preference = normalized.notifications[meta.notificationKey]; const preference = normalized.notifications[meta.notificationKey];
const enabled = Boolean(preference.enabled); const reminderIntensity = getNotificationReminderIntensity(preference);
const evidenceDisplay = getNotificationEvidenceDisplay(preference);
const enabled = reminderIntensity !== "silent" && Boolean(preference.enabled);
return { return {
event, event,
notificationKey: meta.notificationKey, notificationKey: meta.notificationKey,
surface: meta.surface, surface: meta.surface,
inAppEnabled: enabled, inAppEnabled: enabled,
reminderIntensity,
evidenceDisplay,
showProminentReminder: enabled && reminderIntensity === "prominent",
desktopPreference: enabled && Boolean(preference.desktop), desktopPreference: enabled && Boolean(preference.desktop),
emailDigestPreference: enabled && Boolean(preference.emailDigest), emailDigestPreference: enabled && Boolean(preference.emailDigest),
notificationTransport: "in_app_only",
title: accountNotificationLabels[meta.notificationKey].title, title: accountNotificationLabels[meta.notificationKey].title,
reason: meta.reason, reason: meta.reason,
}; };
@ -363,7 +543,9 @@ export function getPostReleaseNotificationPreference(
const normalized = mergeAccountSettings(createDefaultAccountSettings(), settings || null); const normalized = mergeAccountSettings(createDefaultAccountSettings(), settings || null);
const meta = postReleaseNotificationEventMeta[event]; const meta = postReleaseNotificationEventMeta[event];
const preference = normalized.notifications[meta.notificationKey]; const preference = normalized.notifications[meta.notificationKey];
const enabled = Boolean(preference.enabled); const reminderIntensity = getNotificationReminderIntensity(preference);
const evidenceDisplay = getNotificationEvidenceDisplay(preference);
const enabled = reminderIntensity !== "silent" && Boolean(preference.enabled);
return { return {
event, event,
notificationKey: meta.notificationKey, notificationKey: meta.notificationKey,
@ -371,8 +553,12 @@ export function getPostReleaseNotificationPreference(
spaceReadable: true, spaceReadable: true,
inAppEnabled: enabled, inAppEnabled: enabled,
showInAppReminder: enabled, showInAppReminder: enabled,
reminderIntensity,
evidenceDisplay,
showProminentReminder: enabled && reminderIntensity === "prominent",
desktopPreference: enabled && Boolean(preference.desktop), desktopPreference: enabled && Boolean(preference.desktop),
emailDigestPreference: enabled && Boolean(preference.emailDigest), emailDigestPreference: enabled && Boolean(preference.emailDigest),
notificationTransport: "in_app_only",
title: accountNotificationLabels[meta.notificationKey].title, title: accountNotificationLabels[meta.notificationKey].title,
reason: meta.reason, reason: meta.reason,
evidencePolicy: "preserve_business_evidence", evidencePolicy: "preserve_business_evidence",

View File

@ -25,8 +25,10 @@ import {
type AccountAnswerDepth, type AccountAnswerDepth,
type AccountEvidenceDisplay, type AccountEvidenceDisplay,
type AccountLanguage, type AccountLanguage,
type AccountNotificationEvidenceDisplay,
type AccountNotificationKey, type AccountNotificationKey,
type AccountProfileSettings, type AccountProfileSettings,
type AccountReminderIntensity,
type AccountSettings, type AccountSettings,
type AccountSettingsSaveScope, type AccountSettingsSaveScope,
} from "@/api/accountSettings"; } from "@/api/accountSettings";
@ -58,6 +60,17 @@ const languageOptions: Array<{ value: AccountLanguage; title: string; detail: st
{ value: "en-US", title: "English", detail: "English replies when product context allows it" }, { 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"]) { function getDisplayName(user: ReturnType<typeof useAuthStore.getState>["user"]) {
return user?.name || user?.username || "张子墨"; return user?.name || user?.username || "张子墨";
} }
@ -312,7 +325,7 @@ function MobileAccountSettings({
<MobileGroup title="设置"> <MobileGroup title="设置">
<MobileSettingRow icon={<UserRound className="h-5 w-5" />} title="个人资料" detail={`${draft.profile.displayName} · ${draft.profile.roleTitle}`} onClick={() => onOpenSheet("profile")} /> <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={<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)} 类提醒已开启`} onClick={() => onOpenSheet("notifications")} /> <MobileSettingRow icon={<Bell className="h-5 w-5" />} title="通知偏好" detail={`${getEnabledNotificationCount(draft)} 类提醒已开启 · ${getProminentNotificationCount(draft)} 类强提醒`} onClick={() => onOpenSheet("notifications")} />
</MobileGroup> </MobileGroup>
<MobileGroup title="账号操作"> <MobileGroup title="账号操作">
@ -570,6 +583,8 @@ function NotificationFields({
{accountNotificationKeys.map((key) => { {accountNotificationKeys.map((key) => {
const item = notifications[key]; const item = notifications[key];
const label = accountNotificationLabels[key]; const label = accountNotificationLabels[key];
const reminderIntensity = item.reminderIntensity || (item.enabled ? "normal" : "silent");
const evidenceDisplay = item.evidenceDisplay || "compact";
return ( return (
<div key={key} className={cn("rounded-lg border border-[#e6edf7] bg-white p-4", mobile && "px-3")}> <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="flex items-start justify-between gap-4">
@ -578,22 +593,48 @@ function NotificationFields({
<div className="mt-1 text-sm leading-6 text-slate-500">{label.detail}</div> <div className="mt-1 text-sm leading-6 text-slate-500">{label.detail}</div>
</div> </div>
<Switch <Switch
checked={item.enabled} checked={reminderIntensity !== "silent" && item.enabled}
onCheckedChange={(enabled) => onPatchNotification(key, { enabled })} onCheckedChange={(enabled) =>
onPatchNotification(key, {
enabled,
reminderIntensity: enabled ? (reminderIntensity === "silent" ? "normal" : reminderIntensity) : "silent",
})
}
className="mt-1 data-[state=checked]:bg-[#2463EB]" className="mt-1 data-[state=checked]:bg-[#2463EB]"
/> />
</div> </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"> <div className="mt-4 grid gap-2 sm:grid-cols-2">
<ChannelToggle <ChannelToggle
label="桌面提醒" label="桌面提醒"
checked={item.desktop} checked={reminderIntensity !== "silent" && item.enabled && item.desktop}
disabled={!item.enabled} disabled={reminderIntensity === "silent" || !item.enabled}
onChange={(desktop) => onPatchNotification(key, { desktop })} onChange={(desktop) => onPatchNotification(key, { desktop })}
/> />
<ChannelToggle <ChannelToggle
label="邮件摘要" label="邮件摘要"
checked={item.emailDigest} checked={reminderIntensity !== "silent" && item.enabled && item.emailDigest}
disabled={!item.enabled} disabled={reminderIntensity === "silent" || !item.enabled}
onChange={(emailDigest) => onPatchNotification(key, { emailDigest })} onChange={(emailDigest) => onPatchNotification(key, { emailDigest })}
/> />
</div> </div>
@ -620,7 +661,7 @@ function NotificationConsumptionPreview({
<div className="min-w-0"> <div className="min-w-0">
<div className="text-sm font-semibold text-slate-950">页面内提醒消费说明</div> <div className="text-sm font-semibold text-slate-950">页面内提醒消费说明</div>
<p className="mt-1 text-xs leading-5 text-slate-500"> <p className="mt-1 text-xs leading-5 text-slate-500">
这些开关只控制 PMFlow 页面里的提醒展示:待确认、执行回写、发布门禁和失败提醒。当前不代表系统级桌面推送、邮件推送或真实通知中心已接入。 这些偏好只控制 PMFlow 页面里的提醒强度和证据展开方式:待确认、执行回写、发布门禁和失败提醒。当前不代表系统级桌面推送、邮件推送或真实通知中心已接入。
</p> </p>
</div> </div>
</div> </div>
@ -631,14 +672,21 @@ function NotificationConsumptionPreview({
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-semibold text-slate-900">{item.productArea}</div> <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-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> </div>
<span <span
className={cn( className={cn(
"shrink-0 rounded-full px-2.5 py-1 text-[11px] font-semibold", "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.showProminentReminder
? "bg-orange-50 text-orange-600"
: item.showInAppReminder
? "bg-[#eef4ff] text-[#2463EB]"
: "bg-slate-100 text-slate-500"
)} )}
> >
{item.showInAppReminder ? "显示" : "静默"} {item.showProminentReminder ? "强提醒" : item.showInAppReminder ? "显示" : "静默"}
</span> </span>
</div> </div>
<div className="mt-2 text-xs leading-5 text-slate-500">{item.detail}</div> <div className="mt-2 text-xs leading-5 text-slate-500">{item.detail}</div>
@ -810,6 +858,10 @@ function getEnabledNotificationCount(settings: AccountSettings) {
return accountNotificationKeys.filter((key) => settings.notifications[key].enabled).length; 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) { function getAnswerDepthTitle(value: AccountAnswerDepth) {
return answerDepthOptions.find((option) => option.value === value)?.title || "标准"; return answerDepthOptions.find((option) => option.value === value)?.title || "标准";
} }
@ -817,3 +869,11 @@ function getAnswerDepthTitle(value: AccountAnswerDepth) {
function getEvidenceTitle(value: AccountEvidenceDisplay) { function getEvidenceTitle(value: AccountEvidenceDisplay) {
return evidenceOptions.find((option) => option.value === value)?.title || "内联"; 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 || "紧凑";
}

View File

@ -2,6 +2,7 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { import {
createDefaultAccountSettings, createDefaultAccountSettings,
getAccountNotificationConsumption, getAccountNotificationConsumption,
getAccountReminderConsumption,
getAccountSettings, getAccountSettings,
getPostReleaseNotificationPreference, getPostReleaseNotificationPreference,
getReleaseGateNotificationPreference, getReleaseGateNotificationPreference,
@ -41,6 +42,8 @@ describe("account settings api", () => {
expect(settings.notifications.executionWriteback.enabled).toBe(true); expect(settings.notifications.executionWriteback.enabled).toBe(true);
expect(settings.notifications.releaseGate.enabled).toBe(true); expect(settings.notifications.releaseGate.enabled).toBe(true);
expect(settings.notifications.failureAlert.enabled).toBe(true); expect(settings.notifications.failureAlert.enabled).toBe(true);
expect(settings.notifications.pendingConfirmation.reminderIntensity).toBe("normal");
expect(settings.notifications.pendingConfirmation.evidenceDisplay).toBe("compact");
}); });
it("persists updated preferences through the api layer", async () => { it("persists updated preferences through the api layer", async () => {
@ -51,7 +54,13 @@ describe("account settings api", () => {
aiPreferences: { answerDepth: "deep", evidenceDisplay: "full" }, aiPreferences: { answerDepth: "deep", evidenceDisplay: "full" },
notifications: { notifications: {
...defaults.notifications, ...defaults.notifications,
failureAlert: { enabled: false, desktop: false, emailDigest: false }, failureAlert: {
...defaults.notifications.failureAlert,
enabled: false,
desktop: false,
emailDigest: false,
reminderIntensity: "silent" as const,
},
}, },
}); });
const restored = await getAccountSettings({ displayName: "陈雨" }); const restored = await getAccountSettings({ displayName: "陈雨" });
@ -70,8 +79,21 @@ describe("account settings api", () => {
...defaults, ...defaults,
notifications: { notifications: {
...defaults.notifications, ...defaults.notifications,
executionWriteback: { enabled: false, desktop: false, emailDigest: false }, executionWriteback: {
releaseGate: { enabled: true, desktop: false, emailDigest: true }, ...defaults.notifications.executionWriteback,
enabled: false,
desktop: false,
emailDigest: false,
reminderIntensity: "silent" as const,
},
releaseGate: {
...defaults.notifications.releaseGate,
enabled: true,
desktop: false,
emailDigest: true,
reminderIntensity: "prominent" as const,
evidenceDisplay: "expanded" as const,
},
}, },
}; };
@ -86,16 +108,73 @@ describe("account settings api", () => {
expect(execution?.statusLabel).toBe("页面内提醒静默"); expect(execution?.statusLabel).toBe("页面内提醒静默");
expect(release?.productArea).toBe("Release / Growth"); expect(release?.productArea).toBe("Release / Growth");
expect(release?.showInAppReminder).toBe(true); expect(release?.showInAppReminder).toBe(true);
expect(release?.showProminentReminder).toBe(true);
expect(release?.evidenceDisplay).toBe("expanded");
expect(release?.emailDigestPreference).toBe(true); expect(release?.emailDigestPreference).toBe(true);
}); });
it("returns stable reminder intensity contracts for review, release, and execution events", () => {
const defaults = createDefaultAccountSettings();
const settings = {
...defaults,
notifications: {
...defaults.notifications,
pendingConfirmation: {
...defaults.notifications.pendingConfirmation,
reminderIntensity: "prominent" as const,
evidenceDisplay: "expanded" as const,
},
releaseGate: {
...defaults.notifications.releaseGate,
reminderIntensity: "silent" as const,
enabled: false,
},
executionWriteback: {
...defaults.notifications.executionWriteback,
reminderIntensity: "normal" as const,
evidenceDisplay: "compact" as const,
},
},
};
const highRiskReview = getAccountReminderConsumption(settings, "change_review_high_risk");
const iterationDraft = getAccountReminderConsumption(settings, "iteration_draft_ready");
const releaseBlocked = getAccountReminderConsumption(settings, "release_blocked");
const executionStalled = getAccountReminderConsumption(settings, "execution_stalled");
expect(highRiskReview.notificationKey).toBe("pendingConfirmation");
expect(highRiskReview.surface).toBe("change_review");
expect(highRiskReview.reminderIntensity).toBe("prominent");
expect(highRiskReview.evidenceDisplay).toBe("expanded");
expect(highRiskReview.showProminentReminder).toBe(true);
expect(highRiskReview.notificationTransport).toBe("in_app_only");
expect(highRiskReview.evidencePolicy).toBe("preserve_business_evidence");
expect(highRiskReview.auditPolicy).toBe("preserve_audit_state");
expect(iterationDraft.notificationKey).toBe("pendingConfirmation");
expect(iterationDraft.showInAppReminder).toBe(true);
expect(releaseBlocked.notificationKey).toBe("releaseGate");
expect(releaseBlocked.surface).toBe("release_growth");
expect(releaseBlocked.showInAppReminder).toBe(false);
expect(releaseBlocked.mutedDetail).toContain("真实证据");
expect(executionStalled.notificationKey).toBe("executionWriteback");
expect(executionStalled.surface).toBe("execution_bridge");
expect(executionStalled.showInAppReminder).toBe(true);
expect(executionStalled.showProminentReminder).toBe(false);
});
it("maps release gate and release validation events to the releaseGate preference", () => { it("maps release gate and release validation events to the releaseGate preference", () => {
const defaults = createDefaultAccountSettings(); const defaults = createDefaultAccountSettings();
const settings = { const settings = {
...defaults, ...defaults,
notifications: { notifications: {
...defaults.notifications, ...defaults.notifications,
releaseGate: { enabled: true, desktop: false, emailDigest: true }, releaseGate: {
...defaults.notifications.releaseGate,
enabled: true,
desktop: false,
emailDigest: true,
reminderIntensity: "normal" as const,
},
}, },
}; };
@ -119,7 +198,13 @@ describe("account settings api", () => {
...defaults, ...defaults,
notifications: { notifications: {
...defaults.notifications, ...defaults.notifications,
failureAlert: { enabled: false, desktop: true, emailDigest: true }, failureAlert: {
...defaults.notifications.failureAlert,
enabled: false,
desktop: true,
emailDigest: true,
reminderIntensity: "silent" as const,
},
}, },
}; };
@ -139,8 +224,20 @@ describe("account settings api", () => {
...defaults, ...defaults,
notifications: { notifications: {
...defaults.notifications, ...defaults.notifications,
releaseGate: { enabled: true, desktop: false, emailDigest: true }, releaseGate: {
pendingConfirmation: { enabled: false, desktop: true, emailDigest: true }, ...defaults.notifications.releaseGate,
enabled: true,
desktop: false,
emailDigest: true,
reminderIntensity: "normal" as const,
},
pendingConfirmation: {
...defaults.notifications.pendingConfirmation,
enabled: false,
desktop: true,
emailDigest: true,
reminderIntensity: "silent" as const,
},
}, },
}; };
@ -191,7 +288,9 @@ describe("account settings api", () => {
expect(restored.notifications.pendingConfirmation.desktop).toBe(true); expect(restored.notifications.pendingConfirmation.desktop).toBe(true);
expect(restored.notifications.failureAlert.enabled).toBe(false); expect(restored.notifications.failureAlert.enabled).toBe(false);
expect(restored.notifications.failureAlert.desktop).toBe(true); expect(restored.notifications.failureAlert.desktop).toBe(false);
expect(restored.notifications.failureAlert.reminderIntensity).toBe("silent");
expect(restored.notifications.failureAlert.evidenceDisplay).toBe("compact");
expect(shouldShowAccountNotification(restored, "failureAlert")).toBe(false); expect(shouldShowAccountNotification(restored, "failureAlert")).toBe(false);
expect(consumption.find((item) => item.key === "failureAlert")?.detail).toContain("仍保留"); expect(consumption.find((item) => item.key === "failureAlert")?.detail).toContain("仍保留");
}); });