feat(v2.7): 接入协作治理前端体验
This commit is contained in:
@@ -90,6 +90,8 @@ export const api = {
|
||||
patch: <T>(path: string, data: unknown) =>
|
||||
request<T>(path, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
deleteWithBody: <T>(path: string, data: unknown) =>
|
||||
request<T>(path, { method: 'DELETE', body: JSON.stringify(data) }),
|
||||
postRaw: async <T>(path: string, data: unknown, timeoutMs = 120000): Promise<T> => {
|
||||
// 调用 AI 类长耗时接口时使用,跳过 checkApi 短路(确保走真实请求)
|
||||
const controller = new AbortController();
|
||||
|
||||
20
apps/web/lib/comment-mentions.test.ts
Normal file
20
apps/web/lib/comment-mentions.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { extractMentionNames, mergeMentionMemberIds } from './comment-mentions';
|
||||
|
||||
test('extractMentionNames reads @memberName mentions from comment content', () => {
|
||||
assert.deepEqual(extractMentionNames('请 @Alice 和 @张三 看一下'), ['Alice', '张三']);
|
||||
});
|
||||
|
||||
test('mergeMentionMemberIds keeps text mention matches before explicit selection and dedupes', () => {
|
||||
const ids = mergeMentionMemberIds({
|
||||
content: '请 @Alice 看一下',
|
||||
explicitMemberIds: ['m-bob', 'm-alice'],
|
||||
members: [
|
||||
{ id: 'm-alice', name: 'Alice' },
|
||||
{ id: 'm-bob', name: 'Bob' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(ids, ['m-alice', 'm-bob']);
|
||||
});
|
||||
36
apps/web/lib/comment-mentions.ts
Normal file
36
apps/web/lib/comment-mentions.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface CommentMentionMember {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface MergeMentionMemberIdsInput {
|
||||
content: string;
|
||||
explicitMemberIds: string[];
|
||||
members: CommentMentionMember[];
|
||||
}
|
||||
|
||||
export function extractMentionNames(content: string): string[] {
|
||||
const names: string[] = [];
|
||||
const pattern = /@([\p{L}\p{N}_\-.]+)/gu;
|
||||
for (const match of content.matchAll(pattern)) {
|
||||
if (match[1]) names.push(match[1]);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
export function mergeMentionMemberIds(input: MergeMentionMemberIdsInput): string[] {
|
||||
const ids = new Set<string>();
|
||||
const names = new Set(extractMentionNames(input.content).map(normalizeName));
|
||||
for (const member of input.members) {
|
||||
if (names.has(normalizeName(member.name))) ids.add(member.id);
|
||||
}
|
||||
for (const id of input.explicitMemberIds) {
|
||||
const normalized = id.trim();
|
||||
if (normalized) ids.add(normalized);
|
||||
}
|
||||
return Array.from(ids);
|
||||
}
|
||||
|
||||
function normalizeName(name: string): string {
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
20
apps/web/lib/notification.test.ts
Normal file
20
apps/web/lib/notification.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { countUnreadNotifications, sortNotificationsNewestFirst } from './notification';
|
||||
|
||||
test('countUnreadNotifications counts only rows without readAt', () => {
|
||||
assert.equal(countUnreadNotifications([
|
||||
{ id: 'n-1', readAt: null } as any,
|
||||
{ id: 'n-2', readAt: '2026-07-08T08:00:00.000Z' } as any,
|
||||
{ id: 'n-3' } as any,
|
||||
]), 2);
|
||||
});
|
||||
|
||||
test('sortNotificationsNewestFirst keeps newest createdAt first', () => {
|
||||
const sorted = sortNotificationsNewestFirst([
|
||||
{ id: 'old', createdAt: '2026-07-07T08:00:00.000Z' } as any,
|
||||
{ id: 'new', createdAt: '2026-07-08T08:00:00.000Z' } as any,
|
||||
]);
|
||||
|
||||
assert.deepEqual(sorted.map((item) => item.id), ['new', 'old']);
|
||||
});
|
||||
32
apps/web/lib/notification.ts
Normal file
32
apps/web/lib/notification.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export type NotificationType = 'assignment' | 'mention' | 'risk_alert' | 'overdue_item';
|
||||
|
||||
export interface NotificationRecord {
|
||||
id: string;
|
||||
recipientId: string;
|
||||
actorId?: string | null;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
body?: string;
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
resourceVersionId?: string | null;
|
||||
productId?: string | null;
|
||||
projectId?: string | null;
|
||||
versionId?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
readAt?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function countUnreadNotifications(items: Pick<NotificationRecord, 'readAt'>[]): number {
|
||||
return items.filter((item) => !item.readAt).length;
|
||||
}
|
||||
|
||||
export function sortNotificationsNewestFirst<T extends Pick<NotificationRecord, 'createdAt'>>(items: T[]): T[] {
|
||||
return [...items].sort((a, b) => dateValue(b.createdAt) - dateValue(a.createdAt));
|
||||
}
|
||||
|
||||
function dateValue(value: string): number {
|
||||
const time = new Date(value).getTime();
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
@@ -54,6 +54,22 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
|
||||
},
|
||||
{ module: 'member', moduleLabel: '成员', category: 'main', actions: std4('member') },
|
||||
{ module: 'role', moduleLabel: '角色', category: 'main', actions: std4('role') },
|
||||
{
|
||||
module: 'management',
|
||||
moduleLabel: '管理驾驶舱',
|
||||
category: 'main',
|
||||
actions: [
|
||||
{ action: 'view', label: '查看', permission: 'management:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
module: 'governance',
|
||||
moduleLabel: '治理设置',
|
||||
category: 'main',
|
||||
actions: [
|
||||
{ action: 'manage', label: '管理', permission: 'governance:manage' },
|
||||
],
|
||||
},
|
||||
{ module: 'version.req', moduleLabel: '需求 Tab', category: 'version_tab', actions: stdTab('version.req') },
|
||||
{ module: 'version.research', moduleLabel: '调研 Tab', category: 'version_tab', actions: stdTab('version.research') },
|
||||
{ module: 'version.product_plan', moduleLabel: '产品方案 Tab', category: 'version_tab', actions: stdTab('version.product_plan') },
|
||||
@@ -76,6 +92,7 @@ export const DEFAULT_ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||
...std4('requirement').map((a) => a.permission),
|
||||
'version.req:view', 'version.req:manage',
|
||||
'version.product_plan:view', 'version.product_plan:manage',
|
||||
'management:view',
|
||||
'xiaobao.warning:view', 'xiaobao.warning:manage',
|
||||
'overtime:view', 'member:view', 'role:view',
|
||||
'version.research:view', 'version.ui_plan:view', 'version.devtask:view',
|
||||
|
||||
Reference in New Issue
Block a user