功能(提醒):统一项目提醒中心与桌面通知链路
增加通用提醒状态、数据库迁移和定时同步,覆盖风险时效、文件回执、项目里程碑、访视窗口与协作申请。 新增网页端和桌面端提醒中心、真实投递诊断与固定隐私通知正文,并补齐登录来源聚合、测试和说明文档。
This commit is contained in:
@@ -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.");
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -99,7 +99,7 @@ export const TEXT = {
|
||||
dataUpdatedAt: "数据",
|
||||
projectReminders: "提醒",
|
||||
projectRemindersSubtitle: "业务通知与待办",
|
||||
projectRemindersEmpty: "暂无未处理通知",
|
||||
projectRemindersEmpty: "暂无业务提醒",
|
||||
overdueAes: "逾期 AE",
|
||||
overdueAesDesc: "需关注安全性事件处理时效",
|
||||
overdueMonitoringIssues: "逾期监查问题",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "有新的业务提醒待查看",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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 "访视窗口"');
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
};
|
||||
Reference in New Issue
Block a user