功能(提醒):统一项目提醒中心与桌面通知链路
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

增加通用提醒状态、数据库迁移和定时同步,覆盖风险时效、文件回执、项目里程碑、访视窗口与协作申请。

新增网页端和桌面端提醒中心、真实投递诊断与固定隐私通知正文,并补齐登录来源聚合、测试和说明文档。
This commit is contained in:
Cheng Zhou
2026-07-16 16:50:34 +08:00
parent 88bd0c5942
commit 3e77127687
50 changed files with 2894 additions and 247 deletions
+2 -2
View File
@@ -362,8 +362,8 @@ const verifySourceSafety = async () => {
const verifyNotificationBoundary = async () => {
const source = await readFile(resolve(sourceDir, "runtime/notifications.ts"), "utf8");
assert(source.includes('title: "CTMS 文件更新"'), "Desktop notification title must stay generic.");
assert(source.includes('body: "有新的文件版本待查看"'), "Desktop notification body must stay generic.");
assert(source.includes('title: "CTMS 待办提醒"'), "Desktop notification title must stay generic.");
assert(source.includes('body: "有新的业务提醒待查看"'), "Desktop notification body must stay generic.");
assert(!/showSystemNotification\s*=\s*async\s*\([^)]*[a-zA-Z]/.test(source), "Desktop notification body must not accept dynamic business content.");
};
+2 -2
View File
@@ -1,5 +1,5 @@
import { apiGet, apiPost, apiPut } from "./axios";
import type { NotificationItem } from "../types/notifications";
import type { GeneralNotificationItem } from "../types/notifications";
export interface DesktopNotificationSubscription {
enabled: boolean;
@@ -9,7 +9,7 @@ export interface DesktopNotificationSubscription {
export interface DesktopNotificationClaim {
claim_token?: string | null;
lease_expires_at?: string | null;
items: NotificationItem[];
items: GeneralNotificationItem[];
}
export const getDesktopNotificationSubscription = () =>
+7 -2
View File
@@ -11,18 +11,23 @@ describe("general notification API", () => {
it("loads an uncached recipient feed and updates read state", async () => {
const {
listGeneralNotifications,
markAllGeneralNotificationsRead,
markGeneralNotificationRead,
} = await import("./notifications");
listGeneralNotifications("study-1", 8);
listGeneralNotifications("study-1", { limit: 8, unread_only: true });
markGeneralNotificationRead("study-1", "notification-1");
markAllGeneralNotificationsRead("study-1");
expect(apiGet).toHaveBeenCalledWith(
"/api/v1/studies/study-1/notifications/feed",
{ params: { limit: 8 }, cache: false, disableRequestDedupe: true },
{ params: { limit: 8, unread_only: true }, cache: false, disableRequestDedupe: true },
);
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/notifications/notification-1/read",
);
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/notifications/read-all",
);
});
});
+19 -6
View File
@@ -1,14 +1,27 @@
import { apiGet, apiPost } from "./axios";
import type { GeneralNotificationFeed, GeneralNotificationItem } from "../types/notifications";
export const listGeneralNotifications = (studyId: string, limit = 10) =>
apiGet<GeneralNotificationFeed>(`/api/v1/studies/${studyId}/notifications/feed`, {
params: { limit },
cache: false,
disableRequestDedupe: true,
});
export interface GeneralNotificationQuery {
skip?: number;
limit?: number;
category?: string;
unread_only?: boolean;
requires_action?: boolean;
}
export const listGeneralNotifications = (
studyId: string,
query: GeneralNotificationQuery = {},
) => apiGet<GeneralNotificationFeed>(`/api/v1/studies/${studyId}/notifications/feed`, {
params: { limit: 10, ...query },
cache: false,
disableRequestDedupe: true,
});
export const markGeneralNotificationRead = (studyId: string, notificationId: string) =>
apiPost<GeneralNotificationItem>(
`/api/v1/studies/${studyId}/notifications/${notificationId}/read`,
);
export const markAllGeneralNotificationsRead = (studyId: string) =>
apiPost<void>(`/api/v1/studies/${studyId}/notifications/read-all`);
@@ -260,6 +260,9 @@ describe("ApiEndpointPermissions.vue", () => {
]);
expect((wrapper.vm as any).filteredOperations.filter((operation: any) => operation.operation_key === "subject_aes:read")).toHaveLength(2);
expect((wrapper.vm as any).filteredOperations.filter((operation: any) => operation.operation_key === "subject_pds:list")).toHaveLength(2);
expect((wrapper.vm as any).displayOperations).toHaveLength(5);
expect((wrapper.vm as any).totalPermissionCount).toBe(3);
expect((wrapper.vm as any).filteredPermissionCount).toBe(3);
});
it("keeps contract fee permissions as one module section", () => {
@@ -6,7 +6,7 @@
<span class="toolbar-title">有效权限矩阵</span>
<el-tag size="small" effect="plain" type="info">只读</el-tag>
</div>
<span class="toolbar-result">显示 {{ filteredOperations.length }} / {{ displayOperations.length }} 项权限</span>
<span class="toolbar-result">显示 {{ filteredPermissionCount }} / {{ totalPermissionCount }} 项权限</span>
</div>
<div class="toolbar-filters">
<el-input
@@ -266,6 +266,12 @@ const filteredOperations = computed(() => {
return filtered;
});
const countUniquePermissions = (rows: DisplayOperation[]): number =>
new Set(rows.map((operation) => operation.operation_key)).size;
const totalPermissionCount = computed(() => countUniquePermissions(displayOperations.value));
const filteredPermissionCount = computed(() => countUniquePermissions(filteredOperations.value));
// 权限类型、模块与模块细分列合并行
const spanMethod = ({ row, rowIndex, columnIndex }: { row: DisplayOperation; rowIndex: number; column: any; columnIndex: number }) => {
if (columnIndex === 0) {
+34
View File
@@ -296,6 +296,18 @@
<div v-else class="reminder-empty">
{{ headerRemindersLoading ? TEXT.common.loading : TEXT.common.labels.projectRemindersEmpty }}
</div>
<div class="reminder-actions">
<el-dropdown-item command="notification:center" class="reminder-footer">
查看全部提醒
</el-dropdown-item>
<el-dropdown-item
v-if="headerReminderTotal > 0"
command="notification:read-all"
class="reminder-footer is-secondary"
>
全部已读
</el-dropdown-item>
</div>
</div>
</el-dropdown-menu>
</template>
@@ -2148,6 +2160,28 @@ useDesktopShortcuts(
text-align: center;
}
.reminder-actions {
display: grid;
grid-template-columns: 1fr 1fr;
margin-top: 6px;
border-top: 1px solid rgba(138, 155, 176, 0.2);
}
.reminder-actions .reminder-footer:only-child {
grid-column: 1 / -1;
}
.reminder-footer {
justify-content: center;
color: #2f6fed !important;
font-size: 12px;
font-weight: 700;
}
.reminder-footer.is-secondary {
color: #718096 !important;
}
.activity-button {
position: relative;
}
+22 -8
View File
@@ -15,6 +15,7 @@ const readTauriLibSource = () => readSource(resolve(__dirname, "../../src-tauri/
const readDesktopPreferencesSource = () => readSource(resolve(__dirname, "../views/DesktopPreferences.vue"));
const readDesktopServerSettingsSource = () => readSource(resolve(__dirname, "../views/DesktopServerSettings.vue"));
const readDesktopUpdateManagerSource = () => readSource(resolve(__dirname, "../session/desktopUpdateManager.ts"));
const readDesktopNotificationManagerSource = () => readSource(resolve(__dirname, "../session/desktopNotificationManager.ts"));
const readDesktopActivityCenterSource = () => readSource(resolve(__dirname, "../session/desktopActivityCenter.ts"));
const readProfileSettingsSource = () => readSource(resolve(__dirname, "../views/ProfileSettings.vue"));
const readLoginSource = () => readSource(resolve(__dirname, "../views/Login.vue"));
@@ -484,9 +485,10 @@ describe("desktop layout shell", () => {
expect(navigation).toContain("item.children?.length ? item.children : [item]");
});
it("keeps notification and update preferences lightweight", () => {
it("shows the real notification delivery path and keeps native notification access constrained", () => {
const serverSettings = readDesktopServerSettingsSource();
const preferences = readDesktopPreferencesSource();
const desktopNotificationManager = readDesktopNotificationManagerSource();
const desktopUpdateManager = readDesktopUpdateManagerSource();
const desktopLayout = readDesktopLayoutSource();
@@ -503,17 +505,25 @@ describe("desktop layout shell", () => {
expect(preferences).not.toContain("服务器连通性正常");
expect(preferences).not.toContain("服务器连接已确认");
expect(preferences).toContain("auth.logout({ rememberCurrentStudy: false })");
expect(preferences).not.toContain("系统通知已开启");
expect(preferences).not.toContain("系统通知已关闭");
expect(preferences).not.toContain("已授权");
expect(preferences).toContain("showNotificationPermissionTag");
expect(preferences).toContain("系统权限:{{ notificationPermissionText }}");
expect(preferences).toContain("服务端订阅");
expect(preferences).toContain("真实业务路径");
expect(preferences).toContain("授权并开启");
expect(preferences).toContain("重新检测权限");
expect(preferences).toContain("立即检查业务提醒");
expect(preferences).toContain("系统设置 → 通知 → CTMS");
expect(preferences).toContain("设置 → 系统 → 通知 → CTMS");
expect(preferences).toContain("showSystemNotificationProbe");
expect(preferences).toContain("sendDesktopNotificationTest");
expect(preferences).toContain("测试通知");
expect(preferences).not.toContain("通知诊断");
expect(preferences).toContain("发送测试通知");
expect(preferences).toContain("openProjectNotificationCenter");
expect(preferences).toContain("listenDesktopNotificationStatus");
expect(preferences).not.toContain("更新诊断");
expect(preferences).not.toContain("listenDesktopNotificationDiagnostics");
expect(preferences).not.toContain(["@tauri-apps", "plugin-notification"].join("/"));
expect(desktopNotificationManager).toContain("claimDesktopNotifications");
expect(desktopNotificationManager).toContain("acknowledgeDesktopNotifications");
expect(desktopNotificationManager).toContain('state: "permission-required"');
expect(desktopNotificationManager).toContain('state: deliveredIds.length ? "delivered" : "ready"');
expect(preferences).toContain("listenDesktopUpdateStatus");
expect(preferences).toContain("promptForPendingDesktopUpdate");
expect(preferences).toContain("当前已是最新版本");
@@ -603,6 +613,10 @@ describe("desktop layout shell", () => {
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-table td.el-table__cell");
expect(styles).toContain("padding: 7px 10px;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-pagination");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-radio-button__inner");
expect(styles).toContain("display: inline-flex;");
expect(styles).toContain("align-items: center;");
expect(styles).toContain("justify-content: center;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-drawer__body");
expect(styles).toContain(":root[data-ctms-theme=\"dark\"] .desktop-workbench .desktop-route-shell .hero-stat");
});
+27 -1
View File
@@ -289,7 +289,18 @@
<div v-else class="header-reminder-empty">
{{ headerRemindersLoading ? TEXT.common.loading : TEXT.common.labels.projectRemindersEmpty }}
</div>
<div class="header-reminder-actions">
<el-dropdown-item command="notification:center" class="header-reminder-footer">
查看全部提醒
</el-dropdown-item>
<el-dropdown-item
v-if="headerReminderTotal > 0"
command="notification:read-all"
class="header-reminder-footer is-secondary"
>
全部已读
</el-dropdown-item>
</div>
</div>
</el-dropdown-menu>
</template>
@@ -1848,6 +1859,21 @@ useDesktopShortcuts(
font-weight: 600;
}
.header-reminder-actions {
display: grid;
grid-template-columns: 1fr 1fr;
margin-top: 6px;
border-top: 1px solid rgba(148, 163, 184, 0.2);
}
.header-reminder-actions .header-reminder-footer:only-child {
grid-column: 1 / -1;
}
.header-reminder-footer.is-secondary {
color: #64748b !important;
}
.collapse-btn {
border: 1px solid transparent;
color: var(--ctms-text-secondary);
@@ -10,11 +10,15 @@ describe("project notification feed contract", () => {
it("uses recipient notifications for both web and desktop reminder bells", () => {
expect(source).toContain("listGeneralNotifications");
expect(source).toContain("markGeneralNotificationRead");
expect(source).toContain("markAllGeneralNotificationsRead");
expect(source).toContain('/project/notifications');
expect(source).toContain("POLL_INTERVAL_MS = 60_000");
expect(source).toContain("PROJECT_NOTIFICATIONS_CHANGED_EVENT");
expect(webLayout).toContain("useProjectNotifications()");
expect(desktopLayout).toContain("useProjectNotifications()");
expect(webLayout).not.toContain("fetchOverdueAesCount");
expect(desktopLayout).not.toContain("fetchOverdueAesCount");
expect(webLayout).toContain('command="notification:center"');
expect(desktopLayout).toContain('command="notification:center"');
});
});
@@ -1,6 +1,10 @@
import { computed, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { listGeneralNotifications, markGeneralNotificationRead } from "../api/notifications";
import {
listGeneralNotifications,
markAllGeneralNotificationsRead,
markGeneralNotificationRead,
} from "../api/notifications";
import { useStudyStore } from "../store/study";
import type { GeneralNotificationFeed, GeneralNotificationItem } from "../types/notifications";
@@ -32,7 +36,7 @@ export const useProjectNotifications = () => {
const route = useRoute();
const router = useRouter();
const headerRemindersLoading = ref(false);
const feed = ref<GeneralNotificationFeed>({ unread_count: 0, items: [] });
const feed = ref<GeneralNotificationFeed>({ unread_count: 0, total_count: 0, items: [] });
let requestId = 0;
let pollTimer: number | undefined;
@@ -40,12 +44,12 @@ export const useProjectNotifications = () => {
const studyId = study.currentStudy?.id;
const currentRequest = ++requestId;
if (!studyId || route.path.startsWith("/admin")) {
feed.value = { unread_count: 0, items: [] };
feed.value = { unread_count: 0, total_count: 0, items: [] };
return;
}
headerRemindersLoading.value = true;
try {
const { data } = await listGeneralNotifications(studyId, 10);
const { data } = await listGeneralNotifications(studyId, { limit: 10 });
if (currentRequest === requestId && study.currentStudy?.id === studyId) feed.value = data;
} catch {
// 短时网络异常时保留当前内存中的提醒,避免角标在轮询失败时闪烁消失。
@@ -67,6 +71,17 @@ export const useProjectNotifications = () => {
const headerReminderBadgeValue = computed(() => headerReminderTotal.value > 99 ? "99+" : headerReminderTotal.value);
const handleReminderCommand = async (command: string) => {
if (command === "notification:center") {
await router.push("/project/notifications");
return;
}
if (command === "notification:read-all") {
const studyId = study.currentStudy?.id;
if (!studyId || feed.value.unread_count <= 0) return;
await markAllGeneralNotificationsRead(studyId);
await loadHeaderReminders();
return;
}
if (!command.startsWith("notification:")) return;
const notificationId = command.slice("notification:".length);
const item = feed.value.items.find((candidate) => candidate.id === notificationId);
@@ -75,10 +90,15 @@ export const useProjectNotifications = () => {
if (!item.read_at) {
item.read_at = new Date().toISOString();
feed.value.unread_count = Math.max(0, feed.value.unread_count - 1);
await markGeneralNotificationRead(studyId, item.id).catch(() => {
const response = await markGeneralNotificationRead(studyId, item.id).catch(() => {
item.read_at = null;
feed.value.unread_count += 1;
return null;
});
if (response?.data.resolved_at) {
feed.value.items = feed.value.items.filter((candidate) => candidate.id !== item.id);
feed.value.total_count = Math.max(0, feed.value.total_count - 1);
}
}
if (item.action_path?.startsWith("/")) await router.push(item.action_path);
};
+1 -1
View File
@@ -99,7 +99,7 @@ export const TEXT = {
dataUpdatedAt: "数据",
projectReminders: "提醒",
projectRemindersSubtitle: "业务通知与待办",
projectRemindersEmpty: "暂无未处理通知",
projectRemindersEmpty: "暂无业务提醒",
overdueAes: "逾期 AE",
overdueAesDesc: "需关注安全性事件处理时效",
overdueMonitoringIssues: "逾期监查问题",
+14
View File
@@ -0,0 +1,14 @@
import { createApp, defineComponent } from "vue";
import { describe, expect, it } from "vitest";
import { installElementPlus } from "./elementPlus";
describe("installElementPlus", () => {
it("registers radio group subcomponents used by the monitoring time selector", () => {
const app = createApp(defineComponent({ template: "<div />" }));
installElementPlus(app);
expect(app.component("ElRadioGroup")).toBeDefined();
expect(app.component("ElRadioButton")).toBeDefined();
});
});
+3 -1
View File
@@ -127,6 +127,8 @@ const components = [
];
export const installElementPlus = (app: App): void => {
for (const component of components) app.use(component);
for (const component of components) {
if (component.name) app.component(component.name, component);
}
app.use(ElLoading);
};
+7
View File
@@ -31,6 +31,7 @@ const EmailSettings = () => import("../views/admin/EmailSettings.vue");
const ProjectDetail = () => import("../views/admin/ProjectDetail.vue");
const ProjectOverview = () => import("../views/ia/ProjectOverview.vue");
const ProjectMilestones = () => import("../views/ia/ProjectMilestones.vue");
const ProjectNotifications = () => import("../views/ProjectNotifications.vue");
const FeeContracts = () => import("../views/fees/ContractFees.vue");
const FeeContractDetail = () => import("../views/fees/ContractFeeDetail.vue");
const DrugShipments = () => import("../views/ia/DrugShipments.vue");
@@ -149,6 +150,12 @@ const routes: RouteRecordRaw[] = [
component: ProjectMilestones,
meta: { title: TEXT.menu.projectMilestones, requiresStudy: true },
},
{
path: "project/notifications",
name: "ProjectNotifications",
component: ProjectNotifications,
meta: { title: "提醒中心", requiresStudy: true },
},
{
path: "fees/contracts",
name: "FeeContracts",
+17 -1
View File
@@ -1,5 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getNotificationPermission, requestNotificationPermission, showSystemNotificationProbe } from "./notifications";
import {
getNotificationPermission,
requestNotificationPermission,
showSystemNotification,
showSystemNotificationProbe,
} from "./notifications";
const isPermissionGrantedMock = vi.hoisted(() => vi.fn());
const requestPermissionMock = vi.hoisted(() => vi.fn());
@@ -96,4 +101,15 @@ describe("notification runtime", () => {
body: "系统通知已可用",
});
});
it("keeps business details out of the desktop system notification", async () => {
enableTauriRuntime();
setBrowserNotificationPermission("granted");
expect(await showSystemNotification()).toBe(true);
expect(notificationDispatchMock).toHaveBeenCalledWith({
title: "CTMS 待办提醒",
body: "有新的业务提醒待查看",
});
});
});
+4 -4
View File
@@ -7,9 +7,9 @@ type StaticNotificationPayload = {
body: string;
};
const fileUpdateNotification: StaticNotificationPayload = {
title: "CTMS 文件更新",
body: "有新的文件版本待查看",
const businessReminderNotification: StaticNotificationPayload = {
title: "CTMS 待办提醒",
body: "有新的业务提醒待查看",
};
const notificationProbe: StaticNotificationPayload = {
@@ -53,7 +53,7 @@ const sendStaticSystemNotification = async (payload: StaticNotificationPayload):
};
export const showSystemNotification = async (): Promise<boolean> =>
sendStaticSystemNotification(fileUpdateNotification);
sendStaticSystemNotification(businessReminderNotification);
export const showSystemNotificationProbe = async (): Promise<boolean> =>
sendStaticSystemNotification(notificationProbe);
@@ -55,7 +55,11 @@ describe("desktop notification manager", () => {
showNotificationMock
.mockResolvedValueOnce(true)
.mockRejectedValueOnce(new Error("notification failed"));
const { initDesktopNotificationManager, triggerDesktopNotificationPoll } = await import("./desktopNotificationManager");
const {
getDesktopNotificationStatus,
initDesktopNotificationManager,
triggerDesktopNotificationPoll,
} = await import("./desktopNotificationManager");
initDesktopNotificationManager();
triggerDesktopNotificationPoll();
@@ -64,6 +68,11 @@ describe("desktop notification manager", () => {
expect(showNotificationMock).toHaveBeenCalledTimes(2);
expect(acknowledgeNotificationsMock).toHaveBeenCalledWith("claim-token", ["notification-1"]);
expect(acknowledgeNotificationsMock).toHaveBeenCalledTimes(1);
expect(getDesktopNotificationStatus()).toMatchObject({
state: "error",
lastDeliveredCount: 0,
consecutiveFailures: 1,
});
});
it("does not acknowledge notifications when the system dispatch does not happen", async () => {
@@ -83,7 +92,11 @@ describe("desktop notification manager", () => {
it("does not claim notifications before operating system permission is granted", async () => {
getPermissionMock.mockResolvedValue("prompt");
const { initDesktopNotificationManager, triggerDesktopNotificationPoll } = await import("./desktopNotificationManager");
const {
getDesktopNotificationStatus,
initDesktopNotificationManager,
triggerDesktopNotificationPoll,
} = await import("./desktopNotificationManager");
initDesktopNotificationManager();
triggerDesktopNotificationPoll();
@@ -91,5 +104,54 @@ describe("desktop notification manager", () => {
expect(claimNotificationsMock).not.toHaveBeenCalled();
expect(acknowledgeNotificationsMock).not.toHaveBeenCalled();
expect(getDesktopNotificationStatus()).toMatchObject({
state: "permission-required",
lastDeliveredCount: 0,
consecutiveFailures: 0,
});
});
it("publishes real delivery progress without exposing claimed reminder content", async () => {
const {
getDesktopNotificationStatus,
initDesktopNotificationManager,
listenDesktopNotificationStatus,
triggerDesktopNotificationPoll,
} = await import("./desktopNotificationManager");
const states: string[] = [];
const unlisten = listenDesktopNotificationStatus((snapshot) => states.push(snapshot.state));
initDesktopNotificationManager();
triggerDesktopNotificationPoll();
await vi.runOnlyPendingTimersAsync();
expect(states).toEqual(expect.arrayContaining(["idle", "polling", "delivered"]));
expect(getDesktopNotificationStatus()).toMatchObject({
state: "delivered",
lastDeliveredCount: 2,
consecutiveFailures: 0,
});
expect(getDesktopNotificationStatus().lastCheckedAt).toBeTruthy();
expect(getDesktopNotificationStatus().lastDeliveredAt).toBeTruthy();
unlisten();
});
it("reports a disabled server subscription without claiming reminders", async () => {
getSubscriptionMock.mockResolvedValue({ data: { enabled: false } });
const {
getDesktopNotificationStatus,
initDesktopNotificationManager,
triggerDesktopNotificationPoll,
} = await import("./desktopNotificationManager");
initDesktopNotificationManager();
triggerDesktopNotificationPoll();
await vi.runOnlyPendingTimersAsync();
expect(claimNotificationsMock).not.toHaveBeenCalled();
expect(getDesktopNotificationStatus()).toMatchObject({
state: "subscription-disabled",
lastDeliveredCount: 0,
});
});
});
@@ -13,9 +13,39 @@ import {
const POLL_INTERVAL_MS = 60_000;
const MAX_BACKOFF_MS = 15 * 60_000;
export type DesktopNotificationDeliveryState =
| "idle"
| "signed-out"
| "permission-required"
| "subscription-disabled"
| "polling"
| "ready"
| "delivered"
| "error";
export interface DesktopNotificationStatusSnapshot {
state: DesktopNotificationDeliveryState;
lastCheckedAt?: string;
lastDeliveredAt?: string;
lastDeliveredCount: number;
consecutiveFailures: number;
}
let initialized = false;
let timer: number | null = null;
let failureCount = 0;
let status: DesktopNotificationStatusSnapshot = {
state: "idle",
lastDeliveredCount: 0,
consecutiveFailures: 0,
};
const statusListeners = new Set<(snapshot: DesktopNotificationStatusSnapshot) => void>();
const publishStatus = (patch: Partial<DesktopNotificationStatusSnapshot>) => {
status = { ...status, ...patch };
const snapshot = { ...status };
statusListeners.forEach((listener) => listener(snapshot));
};
const schedule = (delay: number) => {
if (timer !== null) window.clearTimeout(timer);
@@ -23,22 +53,41 @@ const schedule = (delay: number) => {
};
const poll = async () => {
timer = null;
if (!getToken()) {
failureCount = 0;
publishStatus({
state: "signed-out",
lastCheckedAt: new Date().toISOString(),
consecutiveFailures: 0,
});
schedule(POLL_INTERVAL_MS);
return;
}
try {
const permission = await getNotificationPermission();
if (permission !== "granted") {
failureCount = 0;
publishStatus({
state: "permission-required",
lastCheckedAt: new Date().toISOString(),
consecutiveFailures: 0,
});
schedule(POLL_INTERVAL_MS);
return;
}
const { data: subscription } = await getDesktopNotificationSubscription();
if (!subscription.enabled) {
failureCount = 0;
publishStatus({
state: "subscription-disabled",
lastCheckedAt: new Date().toISOString(),
consecutiveFailures: 0,
});
schedule(POLL_INTERVAL_MS);
return;
}
publishStatus({ state: "polling" });
const { data } = await claimDesktopNotifications();
const deliveredIds: string[] = [];
let deliveryFailed = false;
@@ -60,13 +109,36 @@ const poll = async () => {
throw new Error("desktop notification delivery failed");
}
failureCount = 0;
const checkedAt = new Date().toISOString();
publishStatus({
state: deliveredIds.length ? "delivered" : "ready",
lastCheckedAt: checkedAt,
lastDeliveredAt: deliveredIds.length ? checkedAt : status.lastDeliveredAt,
lastDeliveredCount: deliveredIds.length || status.lastDeliveredCount,
consecutiveFailures: 0,
});
schedule(POLL_INTERVAL_MS);
} catch {
failureCount += 1;
publishStatus({
state: "error",
lastCheckedAt: new Date().toISOString(),
consecutiveFailures: failureCount,
});
schedule(Math.min(POLL_INTERVAL_MS * 2 ** failureCount, MAX_BACKOFF_MS));
}
};
export const getDesktopNotificationStatus = (): DesktopNotificationStatusSnapshot => ({ ...status });
export const listenDesktopNotificationStatus = (
listener: (snapshot: DesktopNotificationStatusSnapshot) => void,
): (() => void) => {
statusListeners.add(listener);
listener(getDesktopNotificationStatus());
return () => statusListeners.delete(listener);
};
export const initDesktopNotificationManager = (): void => {
if (initialized || !isTauriRuntime()) return;
initialized = true;
@@ -84,4 +156,9 @@ export const stopDesktopNotificationManager = (): void => {
timer = null;
initialized = false;
failureCount = 0;
publishStatus({
state: "idle",
lastDeliveredCount: 0,
consecutiveFailures: 0,
});
};
+4
View File
@@ -930,9 +930,13 @@ body {
}
.desktop-workbench .desktop-route-shell .el-radio-button__inner {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 26px;
padding: 5px 10px;
border-radius: 5px;
line-height: 1;
}
.desktop-workbench .desktop-route-shell .el-drawer__header {
+4
View File
@@ -25,11 +25,15 @@ export interface GeneralNotificationItem {
action_path?: string | null;
source_type: string;
source_id: string;
requires_action: boolean;
due_at?: string | null;
read_at?: string | null;
resolved_at?: string | null;
created_at: string;
}
export interface GeneralNotificationFeed {
unread_count: number;
total_count: number;
items: GeneralNotificationItem[];
}
+327 -34
View File
@@ -153,24 +153,84 @@
</section>
<section v-else-if="activeSectionId === 'notifications'" key="notifications" class="preference-panel">
<div class="preference-row">
<div>
<div class="row-title">系统通知</div>
<div class="row-desc">不含项目详情的文件更新提示</div>
<div class="notification-settings">
<div class="preference-row notification-primary-row">
<div>
<div class="row-title">系统通知</div>
<div class="row-desc">开启后定期领取服务端真实业务提醒系统弹窗不显示业务详情</div>
</div>
<div class="row-control">
<el-tag size="small" :type="notificationPermissionTagType">
系统权限{{ notificationPermissionText }}
</el-tag>
<el-switch
:model-value="desktopNotificationsEnabled"
:loading="desktopNotificationLoading"
aria-label="系统通知服务端订阅"
@change="onDesktopNotificationChange"
/>
</div>
</div>
<div class="row-control">
<el-switch
v-model="desktopNotificationsEnabled"
:loading="desktopNotificationLoading"
@change="onDesktopNotificationChange"
<div class="notification-guidance" :class="`is-${notificationGuidanceTone}`">
<el-alert
:type="notificationGuidanceTone"
:title="notificationGuidanceTitle"
:description="notificationGuidanceDescription"
show-icon
:closable="false"
/>
<el-tag v-if="showNotificationPermissionTag" size="small" :type="notificationPermissionTagType">
{{ notificationPermissionText }}
</el-tag>
<el-button size="small" :loading="desktopNotificationTestLoading" @click="sendDesktopNotificationTest">
<el-icon><Bell /></el-icon>
<span>测试通知</span>
</el-button>
<div class="notification-status-grid" aria-label="系统通知投递状态">
<span>系统权限</span>
<strong>{{ notificationPermissionText }}</strong>
<span>服务端订阅</span>
<strong>{{ desktopNotificationsEnabled ? "已开启" : "未开启" }}</strong>
<span>真实业务路径</span>
<strong>{{ notificationDeliveryStatusText }}</strong>
<span>最近检查</span>
<strong>{{ notificationLastCheckedText }}</strong>
<span>最近投递</span>
<strong>{{ notificationLastDeliveredText }}</strong>
</div>
<p class="notification-privacy-note">
系统弹窗固定显示CTMS 待办提醒项目文件AE监查问题和受试者信息仅在登录后的提醒中心展示
</p>
<div class="notification-actions">
<el-button
v-if="notificationPermission === 'prompt'"
type="primary"
:loading="desktopNotificationLoading"
@click="authorizeAndEnableDesktopNotifications"
>
授权并开启
</el-button>
<el-button
v-else-if="notificationPermission === 'denied'"
:loading="desktopNotificationLoading"
@click="refreshDesktopNotificationPermission"
>
重新检测权限
</el-button>
<el-button
v-if="notificationPermission === 'granted' && desktopNotificationsEnabled"
:loading="desktopNotificationCheckLoading"
@click="checkDesktopBusinessNotifications"
>
立即检查业务提醒
</el-button>
<el-button
:disabled="notificationPermission === 'unsupported'"
:loading="desktopNotificationTestLoading"
@click="sendDesktopNotificationTest"
>
<el-icon><Bell /></el-icon>
<span>发送测试通知</span>
</el-button>
<el-button text @click="openProjectNotificationCenter">打开提醒中心</el-button>
</div>
</div>
</div>
</section>
@@ -253,7 +313,12 @@ import {
promptForPendingDesktopUpdate,
type DesktopUpdateStatusSnapshot,
} from "../session/desktopUpdateManager";
import { triggerDesktopNotificationPoll } from "../session/desktopNotificationManager";
import {
getDesktopNotificationStatus,
listenDesktopNotificationStatus,
triggerDesktopNotificationPoll,
type DesktopNotificationStatusSnapshot,
} from "../session/desktopNotificationManager";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
@@ -309,8 +374,10 @@ const desktopShortcuts = ref<DesktopShortcutPreferences>(readDesktopShortcutPref
const capturingShortcutAction = ref<DesktopShortcutAction | null>(null);
const shortcutCaptureElement = ref<HTMLElement | null>(null);
const notificationPermission = ref<NotificationPermissionState>("unsupported");
const desktopNotificationStatus = ref<DesktopNotificationStatusSnapshot>(getDesktopNotificationStatus());
const desktopUpdateStatus = ref<DesktopUpdateStatusSnapshot>(getDesktopUpdateStatus());
let updateStatusUnlisten: (() => void) | undefined;
let notificationStatusUnlisten: (() => void) | undefined;
let connectionPendingTimer: number | undefined;
const themeOptions = [
{ value: "system" as const, label: "跟随系统", icon: Monitor },
@@ -345,19 +412,95 @@ const clientMetadataRows = computed(() => [
]);
const notificationPermissionText = computed(() => {
if (notificationPermission.value === "granted") return "已授权";
if (notificationPermission.value === "denied") return "已拒绝";
if (notificationPermission.value === "prompt") return "待授权";
return "不可用";
});
const showNotificationPermissionTag = computed(() => notificationPermission.value !== "granted");
const notificationPermissionTagType = computed(() => {
if (notificationPermission.value === "granted") return "success";
if (notificationPermission.value === "denied") return "danger";
if (notificationPermission.value === "prompt") return "warning";
return "info";
});
const desktopNotificationCheckLoading = computed(() => desktopNotificationStatus.value.state === "polling");
const formatNotificationTime = (value?: string) => {
if (!value) return "尚未检查";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "尚未检查";
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
};
const notificationDeliveryStatusText = computed(() => {
const labels: Record<DesktopNotificationStatusSnapshot["state"], string> = {
idle: "等待桌面通知服务启动",
"signed-out": "登录后自动检查",
"permission-required": "等待系统授权",
"subscription-disabled": "等待开启服务端订阅",
polling: "正在调用真实业务提醒接口",
ready: "链路正常,暂无待投递提醒",
delivered: "已完成领取、系统投递和服务端确认",
error: "检查失败,将自动退避重试",
};
return labels[desktopNotificationStatus.value.state];
});
const notificationLastCheckedText = computed(() =>
formatNotificationTime(desktopNotificationStatus.value.lastCheckedAt),
);
const notificationLastDeliveredText = computed(() => {
if (!desktopNotificationStatus.value.lastDeliveredAt) return "本次运行尚无投递";
return `${formatNotificationTime(desktopNotificationStatus.value.lastDeliveredAt)} · ${desktopNotificationStatus.value.lastDeliveredCount}`;
});
const notificationSystemSettingsPath = computed(() => {
if (clientMetadata.platform === "macos") return "系统设置 → 通知 → CTMS → 打开“允许通知”";
if (clientMetadata.platform === "windows") return "设置 → 系统 → 通知 → CTMS → 打开通知";
return "系统通知设置 → CTMS → 允许通知";
});
const notificationGuidanceTone = computed<"success" | "warning" | "error" | "info">(() => {
if (notificationPermission.value === "unsupported") return "info";
if (notificationPermission.value === "denied") return "error";
if (notificationPermission.value === "prompt") return "warning";
if (desktopNotificationStatus.value.state === "error") return "error";
return desktopNotificationsEnabled.value ? "success" : "warning";
});
const notificationGuidanceTitle = computed(() => {
if (notificationPermission.value === "unsupported") return "当前客户端不支持系统通知";
if (notificationPermission.value === "denied") return "需要在系统设置中恢复 CTMS 通知权限";
if (notificationPermission.value === "prompt") return "允许 CTMS 发送系统通知";
if (!desktopNotificationsEnabled.value) return "系统权限已就绪,服务端订阅尚未开启";
if (desktopNotificationStatus.value.state === "error") return "真实业务提醒检查失败";
if (desktopNotificationStatus.value.state === "delivered") return "真实业务提醒已完成系统投递";
return "真实业务提醒已开启";
});
const notificationGuidanceDescription = computed(() => {
if (notificationPermission.value === "unsupported") return "请在 CTMS Desktop 中配置系统通知。";
if (notificationPermission.value === "denied") {
return `macOS/Windows 不会再次弹出授权框。请前往:${notificationSystemSettingsPath.value},返回后点击“重新检测权限”。`;
}
if (notificationPermission.value === "prompt") {
return "点击“授权并开启”后,系统会请求一次通知权限;允许后才会开启当前账号的服务端订阅。";
}
if (!desktopNotificationsEnabled.value) return "打开右侧开关后,客户端会定期领取并确认当前账号的待投递业务提醒。";
if (desktopNotificationStatus.value.state === "error") return "客户端会自动重试;也可以检查网络后立即重新检查。";
return "客户端每分钟检查一次;系统弹窗只作安全提示,业务处理仍在登录后的提醒中心完成。";
});
const desktopUpdateDescription = computed(() => {
const pending = desktopUpdateStatus.value.pendingUpdate;
if (desktopUpdateStatus.value.checking) return "正在检查签名更新";
@@ -621,38 +764,94 @@ const saveDesktopServerConfig = async () => {
};
const loadNotificationState = async () => {
notificationPermission.value = await getNotificationPermission();
try {
notificationPermission.value = await getNotificationPermission();
} catch {
notificationPermission.value = "unsupported";
}
try {
const { data } = await getDesktopNotificationSubscription();
desktopNotificationsEnabled.value = data.enabled;
} catch {
desktopNotificationsEnabled.value = false;
}
triggerDesktopNotificationPoll();
};
const updateDesktopNotificationSubscription = async (enabled: boolean) => {
if (desktopNotificationLoading.value) return false;
const previousEnabled = desktopNotificationsEnabled.value;
desktopNotificationLoading.value = true;
try {
if (enabled) {
notificationPermission.value = await getNotificationPermission();
if (notificationPermission.value === "prompt") {
notificationPermission.value = await requestNotificationPermission();
}
if (notificationPermission.value !== "granted") {
ElMessage.warning(`系统通知权限未开启:${notificationSystemSettingsPath.value}`);
return false;
}
}
const { data } = await setDesktopNotificationSubscription(enabled);
desktopNotificationsEnabled.value = data.enabled;
triggerDesktopNotificationPoll();
return true;
} catch (error: any) {
desktopNotificationsEnabled.value = previousEnabled;
ElMessage.error(error?.response?.data?.detail || "系统通知设置失败");
return false;
} finally {
desktopNotificationLoading.value = false;
}
};
const onDesktopNotificationChange = async (value: string | number | boolean) => {
await updateDesktopNotificationSubscription(Boolean(value));
};
const authorizeAndEnableDesktopNotifications = async () => {
await updateDesktopNotificationSubscription(true);
};
const refreshDesktopNotificationPermission = async () => {
if (desktopNotificationLoading.value) return;
desktopNotificationLoading.value = true;
try {
if (value) {
notificationPermission.value = await requestNotificationPermission();
if (notificationPermission.value !== "granted") {
desktopNotificationsEnabled.value = false;
ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知");
return;
}
notificationPermission.value = await getNotificationPermission();
if (notificationPermission.value === "granted") {
triggerDesktopNotificationPoll();
ElMessage.success("已检测到系统通知权限");
return;
}
const { data } = await setDesktopNotificationSubscription(Boolean(value));
desktopNotificationsEnabled.value = data.enabled;
if (data.enabled) triggerDesktopNotificationPoll();
} catch (error: any) {
desktopNotificationsEnabled.value = !Boolean(value);
ElMessage.error(error?.response?.data?.detail || "系统通知设置失败");
ElMessage.warning(`仍未检测到系统通知权限:${notificationSystemSettingsPath.value}`);
} catch {
notificationPermission.value = "unsupported";
ElMessage.error("系统通知权限检测失败");
} finally {
desktopNotificationLoading.value = false;
}
};
const checkDesktopBusinessNotifications = async () => {
if (desktopNotificationCheckLoading.value) return;
try {
notificationPermission.value = await getNotificationPermission();
if (notificationPermission.value !== "granted") {
ElMessage.warning(`系统通知权限未开启:${notificationSystemSettingsPath.value}`);
return;
}
if (!desktopNotificationsEnabled.value) {
await updateDesktopNotificationSubscription(true);
return;
}
triggerDesktopNotificationPoll();
ElMessage.info("已发起真实业务提醒检查");
} catch {
ElMessage.error("业务提醒检查失败");
}
};
const sendDesktopNotificationTest = async () => {
if (desktopNotificationTestLoading.value) return;
desktopNotificationTestLoading.value = true;
@@ -662,7 +861,7 @@ const sendDesktopNotificationTest = async () => {
notificationPermission.value = await requestNotificationPermission();
}
if (notificationPermission.value !== "granted") {
ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知");
ElMessage.warning(`系统通知权限未开启${notificationSystemSettingsPath.value}`);
return;
}
const sent = await showSystemNotificationProbe();
@@ -678,6 +877,11 @@ const sendDesktopNotificationTest = async () => {
}
};
const openProjectNotificationCenter = () => {
emit("close-request");
void router.push("/project/notifications");
};
const checkDesktopUpdateNow = async () => {
if (desktopUpdateChecking.value) return;
desktopUpdateChecking.value = true;
@@ -765,6 +969,9 @@ onMounted(() => {
updateStatusUnlisten = listenDesktopUpdateStatus((status) => {
desktopUpdateStatus.value = status;
});
notificationStatusUnlisten = listenDesktopNotificationStatus((status) => {
desktopNotificationStatus.value = status;
});
void loadNotificationState();
});
@@ -773,6 +980,7 @@ onBeforeUnmount(() => {
window.removeEventListener("pointerdown", cancelDesktopShortcutCaptureOnPointerDown, { capture: true });
clearConnectionPendingTimer();
updateStatusUnlisten?.();
notificationStatusUnlisten?.();
});
</script>
@@ -1090,6 +1298,7 @@ h3 {
.preferences-panel-enter-active .connection-alert,
.preferences-panel-enter-active .connection-diagnostic,
.preferences-panel-enter-active .preference-row,
.preferences-panel-enter-active .notification-guidance,
.preferences-panel-enter-active .shortcut-settings,
.preferences-panel-enter-active .metadata-panel {
animation: preference-card-rise 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
@@ -1287,6 +1496,75 @@ h3 {
flex-shrink: 0;
}
/* =====================================================
* 系统通知授权与真实投递状态
* ===================================================== */
.notification-settings {
display: grid;
gap: 10px;
}
.notification-primary-row {
align-items: flex-start;
}
.notification-guidance {
display: grid;
gap: 14px;
padding: 14px 16px 16px;
border: 1px solid var(--pref-border-card);
border-radius: 10px;
background: var(--pref-bg-card);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.05), 0 1px 1px rgba(15, 23, 42, 0.03);
}
.notification-guidance :deep(.el-alert) {
align-items: flex-start;
border-radius: 8px;
}
.notification-guidance :deep(.el-alert__description) {
line-height: 1.55;
}
.notification-status-grid {
display: grid;
grid-template-columns: 84px minmax(0, 1fr);
gap: 8px 12px;
padding: 0 2px;
}
.notification-status-grid span {
color: var(--pref-text-muted);
font-size: 11.5px;
}
.notification-status-grid strong {
min-width: 0;
color: var(--pref-text-primary);
font-size: 11.5px;
font-weight: 620;
overflow-wrap: anywhere;
}
.notification-privacy-note {
margin: 0;
padding: 10px 12px;
border-radius: 8px;
background: #f7f9fc;
color: var(--pref-text-secondary);
font-size: 11.5px;
line-height: 1.55;
}
.notification-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
}
/* =====================================================
* 快捷键
* ===================================================== */
@@ -1528,6 +1806,7 @@ code {
:global([data-ctms-theme="dark"] .desktop-preferences .server-config-form),
:global([data-ctms-theme="dark"] .desktop-preferences .connection-diagnostic),
:global([data-ctms-theme="dark"] .desktop-preferences .preference-row),
:global([data-ctms-theme="dark"] .desktop-preferences .notification-guidance),
:global([data-ctms-theme="dark"] .desktop-preferences .shortcut-settings),
:global([data-ctms-theme="dark"] .desktop-preferences .metadata-panel) {
border-color: var(--pref-border-card);
@@ -1547,6 +1826,19 @@ code {
color: var(--pref-text-primary);
}
:global([data-ctms-theme="dark"] .desktop-preferences .notification-status-grid span) {
color: var(--pref-text-muted);
}
:global([data-ctms-theme="dark"] .desktop-preferences .notification-status-grid strong) {
color: var(--pref-text-primary);
}
:global([data-ctms-theme="dark"] .desktop-preferences .notification-privacy-note) {
background: #0d1520;
color: var(--pref-text-secondary);
}
:global([data-ctms-theme="dark"] .desktop-preferences .theme-segmented) {
@@ -1593,6 +1885,7 @@ code {
.preference-panel :deep(.el-button),
.connection-alert,
.connection-diagnostic,
.notification-guidance,
.connection-feedback-enter-active,
.connection-feedback-leave-active,
.preferences-header-enter-active,
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const source = readFileSync(resolve(__dirname, "./ProjectNotifications.vue"), "utf8");
describe("project notification center contract", () => {
it("keeps read state separate from actionable business state", () => {
expect(source).toContain("已读不代表业务已完成");
expect(source).toContain("requires_action");
expect(source).not.toContain("include_resolved");
expect(source).toContain("markAllGeneralNotificationsRead");
expect(source).toContain('category.startsWith("VISIT_WINDOW_")');
expect(source).toContain('return "访视窗口"');
});
});
+739
View File
@@ -0,0 +1,739 @@
<template>
<section class="notification-page" aria-label="提醒中心已读不代表业务已完成">
<div class="notification-page__canvas">
<header class="notification-page__hero">
<div class="notification-page__header">
<div class="notification-page__intro">
<div class="notification-page__title-row">
<span class="notification-page__title-icon" aria-hidden="true">
<el-icon><Bell /></el-icon>
</span>
<h1>提醒中心</h1>
</div>
</div>
<div class="notification-page__summary" aria-label="提醒概览">
<div
class="notification-summary-item notification-summary-item--unread"
>
<span class="notification-summary-item__icon" aria-hidden="true">
<el-icon><BellFilled /></el-icon>
</span>
<span class="notification-summary-item__label">未读</span>
<strong>{{ feed.unread_count }}</strong>
</div>
<div class="notification-summary-item">
<span class="notification-summary-item__icon" aria-hidden="true">
<el-icon><Tickets /></el-icon>
</span>
<span class="notification-summary-item__label">当前</span>
<strong>{{ feed.total_count }}</strong>
</div>
</div>
<div class="notification-page__actions">
<el-button :icon="Refresh" :loading="loading" @click="loadNotifications">
刷新
</el-button>
<el-button
type="primary"
:icon="CircleCheck"
:disabled="feed.unread_count === 0"
:loading="markingAllRead"
@click="markAllRead"
>
全部标为已读
</el-button>
</div>
</div>
</header>
<section class="notification-content" aria-label="提醒列表">
<div class="notification-toolbar">
<div class="notification-toolbar__main">
<div class="notification-toolbar__title">
<strong>提醒列表</strong>
</div>
<el-radio-group v-model="filter" aria-label="提醒筛选" @change="resetAndLoad">
<el-radio-button value="active">当前提醒</el-radio-button>
<el-radio-button value="unread">仅未读</el-radio-button>
<el-radio-button value="action">仅待处理</el-radio-button>
</el-radio-group>
</div>
</div>
<div v-loading="loading" class="notification-list" aria-live="polite">
<button
v-for="item in feed.items"
:key="item.id"
type="button"
class="notification-row"
:class="{ 'is-read': item.read_at, 'is-resolved': item.resolved_at }"
@click="openNotification(item)"
>
<span class="notification-row__mark" :class="`is-${priorityTone(item.priority)}`"></span>
<span class="notification-row__main">
<span class="notification-row__heading">
<strong>{{ item.title }}</strong>
<el-tag size="small" effect="plain">{{ categoryLabel(item.category) }}</el-tag>
<el-tag
v-if="item.requires_action && !item.resolved_at"
size="small"
type="warning"
effect="light"
>
待处理
</el-tag>
<el-tag v-else-if="item.resolved_at" size="small" type="info" effect="plain">
已结束
</el-tag>
</span>
<span class="notification-row__message">{{ item.message }}</span>
<span v-if="item.due_at" class="notification-row__due">
截止{{ formatDateTime(item.due_at) }}
</span>
</span>
<span class="notification-row__meta">
<span>{{ formatDateTime(item.created_at) }}</span>
<small :class="{ 'is-unread': !item.read_at }">
<span v-if="!item.read_at" aria-hidden="true"></span>
{{ item.read_at ? "已读" : "未读" }}
</small>
</span>
</button>
<el-empty
v-if="!loading && feed.items.length === 0"
class="notification-empty"
:image-size="88"
>
<template #description>
<div class="notification-empty__copy">
<strong>{{ emptyState }}</strong>
</div>
</template>
<el-button
v-if="filter !== 'active'"
text
type="primary"
@click="showAllNotifications"
>
查看当前提醒
</el-button>
</el-empty>
</div>
<div v-if="feed.total_count > pageSize" class="notification-pagination">
<el-pagination
v-model:current-page="page"
background
layout="prev, pager, next"
:page-size="pageSize"
:total="feed.total_count"
@current-change="loadNotifications"
/>
</div>
</section>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Bell, BellFilled, CircleCheck, Refresh, Tickets } from "@element-plus/icons-vue";
import {
listGeneralNotifications,
markAllGeneralNotificationsRead,
markGeneralNotificationRead,
type GeneralNotificationQuery,
} from "../api/notifications";
import {
notifyProjectNotificationsChanged,
PROJECT_NOTIFICATIONS_CHANGED_EVENT,
} from "../composables/useProjectNotifications";
import { useStudyStore } from "../store/study";
import type { GeneralNotificationFeed, GeneralNotificationItem } from "../types/notifications";
type NotificationFilter = "active" | "unread" | "action";
const router = useRouter();
const study = useStudyStore();
const loading = ref(false);
const markingAllRead = ref(false);
const filter = ref<NotificationFilter>("active");
const page = ref(1);
const pageSize = 20;
const feed = ref<GeneralNotificationFeed>({ unread_count: 0, total_count: 0, items: [] });
let requestId = 0;
const emptyState = computed(() => {
if (filter.value === "unread") return "没有未读提醒";
if (filter.value === "action") return "没有待处理事项";
return "当前暂无提醒";
});
const queryForFilter = (): GeneralNotificationQuery => {
const query: GeneralNotificationQuery = {
skip: (page.value - 1) * pageSize,
limit: pageSize,
};
if (filter.value === "unread") query.unread_only = true;
if (filter.value === "action") query.requires_action = true;
return query;
};
const loadNotifications = async () => {
const studyId = study.currentStudy?.id;
const currentRequest = ++requestId;
if (!studyId) {
feed.value = { unread_count: 0, total_count: 0, items: [] };
return;
}
loading.value = true;
try {
const { data } = await listGeneralNotifications(studyId, queryForFilter());
if (currentRequest === requestId && study.currentStudy?.id === studyId) feed.value = data;
} catch {
//
} finally {
if (currentRequest === requestId) loading.value = false;
}
};
const resetAndLoad = () => {
page.value = 1;
void loadNotifications();
};
const showAllNotifications = () => {
filter.value = "active";
resetAndLoad();
};
const markAllRead = async () => {
const studyId = study.currentStudy?.id;
if (!studyId || markingAllRead.value) return;
markingAllRead.value = true;
try {
await markAllGeneralNotificationsRead(studyId);
notifyProjectNotificationsChanged();
await loadNotifications();
ElMessage.success("已将当前项目提醒全部标为已读");
} finally {
markingAllRead.value = false;
}
};
const openNotification = async (item: GeneralNotificationItem) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (!item.read_at && !item.resolved_at) {
const response = await markGeneralNotificationRead(studyId, item.id).catch(() => null);
if (response) notifyProjectNotificationsChanged();
}
if (item.action_path?.startsWith("/")) {
await router.push(item.action_path);
return;
}
await loadNotifications();
};
const priorityTone = (priority: GeneralNotificationItem["priority"]) => {
if (priority === "URGENT") return "danger";
if (priority === "HIGH") return "warning";
return "info";
};
const categoryLabel = (category: string) => {
if (category.startsWith("RISK_") || category.includes("MONITORING")) return "风险时效";
if (category.startsWith("DOCUMENT_")) return "文件回执";
if (category.startsWith("MILESTONE_")) return "项目里程碑";
if (category.startsWith("VISIT_WINDOW_")) return "访视窗口";
if (category.startsWith("COLLABORATION_")) return "在线协作";
return "业务通知";
};
const formatDateTime = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
};
const handleNotificationChange = () => { void loadNotifications(); };
watch(() => study.currentStudy?.id, resetAndLoad);
onMounted(() => {
void loadNotifications();
window.addEventListener("focus", handleNotificationChange);
window.addEventListener(PROJECT_NOTIFICATIONS_CHANGED_EVENT, handleNotificationChange);
});
onBeforeUnmount(() => {
window.removeEventListener("focus", handleNotificationChange);
window.removeEventListener(PROJECT_NOTIFICATIONS_CHANGED_EVENT, handleNotificationChange);
});
</script>
<style scoped>
.notification-page {
--notification-accent: var(--ctms-primary, #3f5d75);
--notification-accent-soft: rgba(63, 93, 117, 0.1);
min-height: 100%;
background: var(--ctms-bg-base, #f9fafb);
color: var(--ctms-text-main, #172033);
}
.notification-page__canvas {
width: min(100%, 1560px);
min-height: 100%;
margin: 0 auto;
display: grid;
grid-template-rows: auto minmax(320px, 1fr);
gap: 0;
align-content: stretch;
padding: clamp(10px, 1.4vw, 18px);
}
.notification-page__hero {
padding: 6px 4px 12px;
border-bottom: 1px solid var(--ctms-border-color, #e5e7eb);
}
.notification-page__header {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: auto minmax(260px, 1fr) auto;
align-items: center;
gap: 18px;
}
.notification-page__intro {
min-width: 0;
}
.notification-page__title-row {
display: flex;
align-items: center;
gap: 10px;
}
.notification-page__title-icon {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid rgba(63, 93, 117, 0.12);
border-radius: 9px;
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 4px 12px rgba(38, 72, 98, 0.06);
color: var(--notification-accent);
font-size: 17px;
}
.notification-page__header h1 {
margin: 0;
color: var(--ctms-text-main, #172033);
font-size: 22px;
font-weight: 750;
letter-spacing: -0.02em;
line-height: 1.2;
}
.notification-page__actions {
display: flex;
flex: 0 0 auto;
gap: 10px;
}
.notification-page__summary {
display: flex;
align-items: center;
justify-content: start;
}
.notification-summary-item {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 32px;
padding: 0 18px;
border-right: 1px solid var(--ctms-border-color, #e5e7eb);
}
.notification-summary-item:first-child {
padding-left: 0;
}
.notification-summary-item:last-child {
padding-right: 0;
border-right: 0;
}
.notification-summary-item__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
color: var(--ctms-text-secondary, #64748b);
font-size: 15px;
}
.notification-summary-item--unread .notification-summary-item__icon {
color: var(--notification-accent);
}
.notification-summary-item__label {
min-width: 0;
color: var(--ctms-text-regular, #334155);
font-size: 12px;
font-weight: 650;
}
.notification-summary-item strong {
min-width: 1ch;
margin-left: 4px;
color: var(--ctms-text-main, #172033);
font-size: 19px;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.notification-content {
display: flex;
height: 100%;
min-height: 320px;
flex-direction: column;
background: transparent;
}
.notification-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 4px;
border-bottom: 1px solid var(--el-border-color-lighter);
background: transparent;
color: var(--ctms-text-secondary, #64748b);
font-size: 12px;
}
.notification-toolbar__main {
display: flex;
min-width: 0;
align-items: center;
gap: 12px;
}
.notification-toolbar__title {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}
.notification-toolbar__title strong {
color: var(--ctms-text-main, #172033);
font-size: 14px;
}
.notification-toolbar :deep(.el-radio-button__inner) {
min-height: 28px;
padding: 5px 12px;
font-weight: 550;
box-shadow: none;
}
.notification-list {
position: relative;
display: flex;
min-height: 240px;
flex: 1;
flex-direction: column;
padding: 0;
}
.notification-row {
width: 100%;
display: grid;
grid-template-columns: 8px minmax(0, 1fr) auto;
gap: 12px;
align-items: flex-start;
padding: 12px 10px;
border: 0;
border-bottom: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
transition: background-color 160ms ease, box-shadow 160ms ease;
}
.notification-row:hover {
background: var(--ctms-neutral-100, #f5f7fa);
box-shadow: inset 3px 0 0 rgba(63, 93, 117, 0.24);
}
.notification-row:focus-visible {
outline: 2px solid var(--notification-accent);
outline-offset: -2px;
}
.notification-row.is-read {
opacity: 0.76;
}
.notification-row.is-resolved {
opacity: 0.58;
}
.notification-row__mark {
width: 8px;
height: 8px;
margin-top: 7px;
border-radius: 999px;
background: var(--notification-accent);
box-shadow: 0 0 0 4px var(--notification-accent-soft);
}
.notification-row__mark.is-danger {
background: var(--ctms-danger, #c24b4b);
box-shadow: 0 0 0 4px rgba(194, 75, 75, 0.1);
}
.notification-row__mark.is-warning {
background: var(--ctms-warning, #c58b2a);
box-shadow: 0 0 0 4px rgba(197, 139, 42, 0.11);
}
.notification-row__main,
.notification-row__meta {
display: flex;
flex-direction: column;
}
.notification-row__main { gap: 6px; }
.notification-row__heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.notification-row__heading strong {
color: var(--ctms-text-main, #172033);
font-size: 14px;
font-weight: 650;
}
.notification-row__message,
.notification-row__due,
.notification-row__meta {
color: var(--ctms-text-secondary, #64748b);
font-size: 12px;
}
.notification-row__message {
line-height: 1.6;
}
.notification-row__due {
color: #a16207;
font-weight: 550;
}
.notification-row__meta {
align-items: flex-end;
gap: 6px;
white-space: nowrap;
}
.notification-row__meta small {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 8px;
border-radius: 999px;
background: var(--ctms-neutral-100, #f5f7fa);
color: var(--ctms-text-secondary, #64748b);
font-size: 10px;
}
.notification-row__meta small.is-unread {
background: var(--notification-accent-soft);
color: var(--notification-accent);
font-weight: 650;
}
.notification-row__meta small > span {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
}
.notification-empty {
display: flex;
min-height: 240px;
flex: 1;
align-items: center;
justify-content: center;
padding: 22px 16px 28px;
}
.notification-empty :deep(.el-empty__image) {
opacity: 0.72;
filter: saturate(0.55);
}
.notification-empty :deep(.el-empty__description) {
margin-top: 14px;
}
.notification-empty__copy { text-align: center; }
.notification-empty__copy strong {
color: var(--ctms-text-regular, #334155);
font-size: 14px;
font-weight: 650;
}
.notification-empty :deep(.el-empty__bottom) {
margin-top: 8px;
}
.notification-pagination {
display: flex;
justify-content: flex-end;
padding: 14px 20px 18px;
border-top: 1px solid var(--el-border-color-lighter);
}
:global([data-ctms-theme="dark"]) .notification-page {
--notification-accent-soft: rgba(143, 183, 212, 0.14);
background: var(--ctms-bg-base);
}
:global([data-ctms-theme="dark"]) .notification-page__title-icon {
border-color: rgba(143, 183, 212, 0.16);
background: rgba(17, 24, 39, 0.72);
}
:global(.desktop-workbench) .notification-page__canvas {
padding: 10px;
}
:global(.desktop-workbench) .notification-page__hero {
padding: 4px 2px 10px;
}
@media (max-width: 1100px) {
.notification-page__header {
grid-template-columns: 1fr auto;
}
.notification-page__summary {
grid-column: 1 / -1;
grid-row: 2;
}
}
@media (max-width: 767px) {
.notification-page__canvas {
grid-template-rows: auto minmax(320px, 1fr);
padding: 8px;
}
.notification-page__hero {
padding: 4px 2px 10px;
}
.notification-toolbar,
.notification-toolbar__main {
flex-direction: column;
align-items: stretch;
}
.notification-page__header {
grid-template-columns: 1fr;
gap: 10px;
}
.notification-page__actions {
width: 100%;
}
.notification-page__actions :deep(.el-button) {
flex: 1;
}
.notification-page__summary {
grid-column: 1;
grid-row: auto;
}
.notification-toolbar {
gap: 8px;
padding: 9px 10px;
}
.notification-toolbar__main {
gap: 12px;
}
.notification-toolbar :deep(.el-radio-group) {
display: flex;
width: 100%;
}
.notification-toolbar :deep(.el-radio-button) {
flex: 1;
}
.notification-toolbar :deep(.el-radio-button__inner) {
width: 100%;
padding-right: 8px;
padding-left: 8px;
}
.notification-row {
grid-template-columns: 8px minmax(0, 1fr);
padding: 16px 8px;
}
.notification-row__meta {
grid-column: 2;
align-items: flex-start;
flex-direction: row;
align-items: center;
}
}
@media (max-width: 520px) {
.notification-page__title-icon {
width: 34px;
height: 34px;
}
.notification-page__header h1 {
font-size: 21px;
}
.notification-summary-item {
min-height: 30px;
padding: 0 12px;
}
}
</style>
@@ -43,7 +43,7 @@
<strong>{{ scope.row.client_type === 'desktop' ? '桌面端' : '网页端' }}</strong>
<span>{{ clientDescription(scope.row) }}</span>
<el-tag v-if="scope.row.grouped_count > 1" type="info" effect="plain" size="small">
合并 {{ scope.row.grouped_count }}
累计 {{ scope.row.grouped_count }} 次会话
</el-tag>
</div>
</template>
@@ -76,6 +76,10 @@ import { ElMessage } from "element-plus";
import { fetchUserLoginActivities } from "../../api/users";
import type { UserInfo, UserLoginActivity } from "../../types/api";
import { displayDateTime } from "../../utils/display";
import {
groupLoginActivitiesBySource,
type DisplayLoginActivity,
} from "./loginActivitySources";
const props = defineProps<{
modelValue: boolean;
@@ -95,8 +99,6 @@ const visibleProxy = computed({
set: (value: boolean) => emit("update:modelValue", value),
});
type DisplayLoginActivity = UserLoginActivity & { grouped_count: number };
const resolvedActivityStatus = (item: UserLoginActivity) => {
if (item.activity_status) return item.activity_status;
if (item.ended_at) return "ENDED";
@@ -120,30 +122,9 @@ const activityStatusDescription = (item: UserLoginActivity) => {
};
const clientDescription = (item: UserLoginActivity) =>
[item.client_platform, item.client_version].filter(Boolean).join(" · ") || "--";
const activitySourceKey = (item: UserLoginActivity) => {
if (!item.login_ip) return `session:${item.id}`;
return [item.login_ip, item.client_type, item.client_platform || "", item.client_source || ""].join("|");
};
const sourceActivities = computed<DisplayLoginActivity[]>(() => {
const rows: DisplayLoginActivity[] = [];
const groupedHistory = new Map<string, DisplayLoginActivity>();
activities.value.forEach((item) => {
const row: DisplayLoginActivity = { ...item, grouped_count: 1 };
if (resolvedActivityStatus(item) === "ONLINE") {
rows.push(row);
return;
}
const key = activitySourceKey(item);
const existing = groupedHistory.get(key);
if (existing) {
existing.grouped_count += 1;
return;
}
groupedHistory.set(key, row);
rows.push(row);
});
return rows;
});
const sourceActivities = computed<DisplayLoginActivity[]>(() =>
groupLoginActivitiesBySource(activities.value, resolvedActivityStatus),
);
const displayActivities = computed<DisplayLoginActivity[]>(() =>
activityViewMode.value === "source"
? sourceActivities.value
@@ -22,7 +22,7 @@ describe("admin user login status", () => {
expect(drawer).toContain("resolvedActivityStatus");
expect(drawer).toContain("最近来源");
expect(drawer).toContain("全部会话");
expect(drawer).toContain("activitySourceKey");
expect(drawer).toContain("groupLoginActivitiesBySource");
expect(drawer).toContain("grouped_count");
expect(drawer).toContain("scope.row.ended_at");
expect(drawer).not.toContain("const isRecent");
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import type { UserLoginActivity } from "../../types/api";
import { activitySourceKey, groupLoginActivitiesBySource } from "./loginActivitySources";
const activity = (overrides: Partial<UserLoginActivity>): UserLoginActivity => ({
id: "session-1",
client_type: "web",
client_platform: "macos",
client_version: "0.1.0",
login_ip: "192.168.97.1",
login_at: "2026-07-16T02:23:04Z",
last_seen_at: "2026-07-16T03:08:32Z",
activity_status: "ENDED",
...overrides,
});
describe("login activity sources", () => {
it("uses only client type and a recorded IP as the source identity", () => {
expect(activitySourceKey(activity({ client_platform: "windows", client_source: "installer" }))).toBe(
"web|192.168.97.1",
);
expect(activitySourceKey(activity({ id: "legacy", login_ip: null }))).toBe("session:legacy");
});
it("merges online and historical sessions from the same client and IP", () => {
const rows = groupLoginActivitiesBySource(
[
activity({
id: "current",
login_at: "2026-07-16T07:22:42Z",
last_seen_at: "2026-07-16T07:53:00Z",
activity_status: "ONLINE",
ended_at: null,
}),
activity({ id: "history", ended_at: "2026-07-16T03:08:32Z" }),
activity({
id: "desktop",
client_type: "desktop",
login_at: "2026-07-15T08:42:02Z",
last_seen_at: "2026-07-16T07:53:42Z",
activity_status: "ONLINE",
ended_at: null,
}),
],
(item) => item.activity_status!,
);
expect(rows).toHaveLength(2);
expect(rows[0]).toMatchObject({ id: "desktop", grouped_count: 1, activity_status: "ONLINE" });
expect(rows[1]).toMatchObject({
id: "current",
grouped_count: 2,
login_at: "2026-07-16T02:23:04Z",
last_seen_at: "2026-07-16T07:53:00Z",
activity_status: "ONLINE",
ended_at: null,
});
});
it("does not merge sessions whose IP was not recorded", () => {
const rows = groupLoginActivitiesBySource(
[activity({ id: "legacy-1", login_ip: null }), activity({ id: "legacy-2", login_ip: null })],
(item) => item.activity_status!,
);
expect(rows).toHaveLength(2);
});
});
@@ -0,0 +1,56 @@
import type { UserLoginActivity } from "../../types/api";
export type DisplayLoginActivity = UserLoginActivity & { grouped_count: number };
type ActivityStatus = NonNullable<UserLoginActivity["activity_status"]>;
const activityTime = (value: string) => new Date(value).getTime();
export const activitySourceKey = (item: UserLoginActivity) => {
if (!item.login_ip) return `session:${item.id}`;
return [item.client_type, item.login_ip].join("|");
};
export const groupLoginActivitiesBySource = (
activities: UserLoginActivity[],
resolveStatus: (item: UserLoginActivity) => ActivityStatus,
): DisplayLoginActivity[] => {
const grouped = new Map<
string,
{ row: DisplayLoginActivity; firstLoginAt: string; hasOnlineSession: boolean }
>();
activities.forEach((item) => {
const key = activitySourceKey(item);
const status = resolveStatus(item);
const existing = grouped.get(key);
if (!existing) {
grouped.set(key, {
row: { ...item, activity_status: status, grouped_count: 1 },
firstLoginAt: item.login_at,
hasOnlineSession: status === "ONLINE",
});
return;
}
existing.row.grouped_count += 1;
existing.hasOnlineSession ||= status === "ONLINE";
if (activityTime(item.login_at) < activityTime(existing.firstLoginAt)) {
existing.firstLoginAt = item.login_at;
}
if (activityTime(item.last_seen_at) > activityTime(existing.row.last_seen_at)) {
const groupedCount = existing.row.grouped_count;
existing.row = { ...item, activity_status: status, grouped_count: groupedCount };
}
});
return Array.from(grouped.values())
.map(({ row, firstLoginAt, hasOnlineSession }) => ({
...row,
login_at: firstLoginAt,
activity_status: hasOnlineSession ? "ONLINE" : row.activity_status,
ended_at: hasOnlineSession ? null : row.ended_at,
end_reason: hasOnlineSession ? null : row.end_reason,
}))
.sort((left, right) => activityTime(right.last_seen_at) - activityTime(left.last_seen_at));
};