merge: 集成V2.7 企业协作与管理治理

# Conflicts:
#	apps/server/src/app.module.ts
#	apps/web/components/layout/Sidebar.tsx
#	apps/web/lib/permissions.ts
#	docs/architecture.md
#	docs/decisions.md
#	docs/roadmap.md
This commit is contained in:
2026-07-08 18:10:08 +08:00
60 changed files with 3219 additions and 19 deletions

View File

@@ -92,6 +92,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();

View 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']);
});

View 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();
}

View 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']);
});

View 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;
}

View File

@@ -64,6 +64,22 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
{ module: 'role', moduleLabel: '角色', category: 'main', actions: std4('role') },
{ module: 'audit', moduleLabel: '审计', category: 'main', actions: [{ action: 'view', label: '查看', permission: 'audit:view' }] },
{ module: 'consistency', moduleLabel: '一致性', category: 'main', actions: [{ action: 'view', label: '查看', permission: 'consistency:view' }] },
{
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') },
@@ -86,6 +102,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',