Skip to content

Commit bbc0f5e

Browse files
feat: Add import/export, document linking, data analytics, keyboard shortcuts, and share page permission checks
- Import: Support .md/.html/.docx file import with client-side parsing (markdown-it, mammoth) - Export: Extract shared Tiptap extension list for generateJSON compatibility - Document Link: [[ trigger for cross-document linking with suggestion popup - Analytics: Space dashboard (/spaces/[id]/analytics) with growth trends, top docs/members, action distribution - Analytics: Document read stats badge (UV/PV) in editor header - Analytics: Personal productivity cards on Dashboard (weekly create/edit + total reads) - Keyboard Shortcuts: Dialog accessible from editor more menu - Share Page: Permission-aware document link clicks (login prompt / no-access dialog) - Login: Support returnTo query parameter for post-login redirect - Documents API: Permission guard on GET /documents/:id for non-space members - Plan: Update stage-7 progress, add stage-8 AI writing plan, add stage-9 pre-launch checklist Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6c3b996 commit bbc0f5e

31 files changed

Lines changed: 2904 additions & 225 deletions

apps/api/src/activity/activity.controller.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,22 @@ export class ActivityController {
6060
limit ? parseInt(limit, 10) : 30,
6161
);
6262
}
63+
64+
@Get('space/:spaceId/stats')
65+
@ApiOperation({ summary: '获取空间统计数据' })
66+
getSpaceStats(@Param('spaceId') spaceId: string) {
67+
return this.activityService.getSpaceStats(spaceId);
68+
}
69+
70+
@Get('document/:documentId/stats')
71+
@ApiOperation({ summary: '获取文档阅读统计' })
72+
getDocumentStats(@Param('documentId') documentId: string) {
73+
return this.activityService.getDocumentStats(documentId);
74+
}
75+
76+
@Get('my/stats')
77+
@ApiOperation({ summary: '获取个人生产力统计' })
78+
getMyStats(@Req() req: any) {
79+
return this.activityService.getUserProductivityStats(req.user.id);
80+
}
6381
}

apps/api/src/activity/activity.service.ts

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,258 @@ export class ActivityService {
188188
};
189189
}
190190

191+
// ==================== Stats / Analytics ====================
192+
193+
/**
194+
* 空间级统计数据
195+
*/
196+
async getSpaceStats(spaceId: string) {
197+
const now = new Date();
198+
const weekStart = new Date(now);
199+
weekStart.setDate(now.getDate() - now.getDay());
200+
weekStart.setHours(0, 0, 0, 0);
201+
202+
const thirtyDaysAgo = new Date(now);
203+
thirtyDaysAgo.setDate(now.getDate() - 30);
204+
205+
const [
206+
docCount,
207+
memberCount,
208+
totalVisitsAgg,
209+
weeklyActions,
210+
actionDistribution,
211+
topDocuments,
212+
topMembers,
213+
] = await Promise.all([
214+
// 文档总数
215+
this.prisma.document.count({
216+
where: { spaceId, deletedAt: null },
217+
}),
218+
// 成员数
219+
this.prisma.spacePermission.count({
220+
where: { spaceId },
221+
}),
222+
// 总阅读量
223+
this.prisma.documentVisit.aggregate({
224+
where: { spaceId },
225+
_sum: { visitCount: true },
226+
}),
227+
// 本周活跃操作数
228+
this.prisma.activityLog.count({
229+
where: { spaceId, createdAt: { gte: weekStart } },
230+
}),
231+
// 操作类型分布
232+
this.prisma.activityLog.groupBy({
233+
by: ['action'],
234+
where: { spaceId },
235+
_count: true,
236+
}),
237+
// 热门文档 Top 10
238+
this.prisma.documentVisit.groupBy({
239+
by: ['documentId'],
240+
where: { spaceId },
241+
_sum: { visitCount: true },
242+
orderBy: { _sum: { visitCount: 'desc' } },
243+
take: 10,
244+
}),
245+
// 活跃成员 Top 10(近 30 天)
246+
this.prisma.activityLog.groupBy({
247+
by: ['userId'],
248+
where: { spaceId, createdAt: { gte: thirtyDaysAgo } },
249+
_count: true,
250+
orderBy: { _count: { userId: 'desc' } },
251+
take: 10,
252+
}),
253+
]);
254+
255+
// 30 天文档增长趋势
256+
const docGrowth = await this.prisma.document.groupBy({
257+
by: ['createdAt'],
258+
where: {
259+
spaceId,
260+
deletedAt: null,
261+
createdAt: { gte: thirtyDaysAgo },
262+
},
263+
_count: true,
264+
orderBy: { createdAt: 'asc' },
265+
});
266+
267+
// 按日聚合文档增长
268+
const growthByDay: Record<string, number> = {};
269+
for (const item of docGrowth) {
270+
const day = new Date(item.createdAt).toISOString().slice(0, 10);
271+
growthByDay[day] = (growthByDay[day] || 0) + item._count;
272+
}
273+
const docGrowthTrend = [];
274+
for (let i = 29; i >= 0; i--) {
275+
const d = new Date(now);
276+
d.setDate(now.getDate() - i);
277+
const key = d.toISOString().slice(0, 10);
278+
docGrowthTrend.push({ date: key, count: growthByDay[key] || 0 });
279+
}
280+
281+
// 填充热门文档标题
282+
const docIds = topDocuments.map((d) => d.documentId);
283+
const docs = docIds.length
284+
? await this.prisma.document.findMany({
285+
where: { id: { in: docIds } },
286+
select: { id: true, title: true },
287+
})
288+
: [];
289+
const docMap = new Map(docs.map((d) => [d.id, d.title]));
290+
291+
// 填充活跃成员名称
292+
const userIds = topMembers.map((m) => m.userId);
293+
const users = userIds.length
294+
? await this.prisma.user.findMany({
295+
where: { id: { in: userIds } },
296+
select: { id: true, name: true, avatarUrl: true },
297+
})
298+
: [];
299+
const userMap = new Map(users.map((u) => [u.id, u]));
300+
301+
return {
302+
overview: {
303+
docCount,
304+
memberCount,
305+
totalViews: totalVisitsAgg._sum.visitCount || 0,
306+
weeklyActions,
307+
},
308+
docGrowthTrend,
309+
topDocuments: topDocuments.map((d) => ({
310+
documentId: d.documentId,
311+
title: docMap.get(d.documentId) || '未知文档',
312+
views: d._sum.visitCount || 0,
313+
})),
314+
topMembers: topMembers.map((m) => ({
315+
userId: m.userId,
316+
name: userMap.get(m.userId)?.name || '未知用户',
317+
avatarUrl: userMap.get(m.userId)?.avatarUrl || null,
318+
actions: m._count,
319+
})),
320+
actionDistribution: actionDistribution.map((a) => ({
321+
action: a.action,
322+
count: a._count,
323+
})),
324+
};
325+
}
326+
327+
/**
328+
* 文档级阅读统计
329+
*/
330+
async getDocumentStats(documentId: string) {
331+
const now = new Date();
332+
const sevenDaysAgo = new Date(now);
333+
sevenDaysAgo.setDate(now.getDate() - 7);
334+
335+
const [uvCount, pvAgg, recentVisits] = await Promise.all([
336+
// UV:独立访客数
337+
this.prisma.documentVisit.count({
338+
where: { documentId },
339+
}),
340+
// PV:总访问次数
341+
this.prisma.documentVisit.aggregate({
342+
where: { documentId },
343+
_sum: { visitCount: true },
344+
}),
345+
// 近 7 天访问记录(按最近访问时间过滤)
346+
this.prisma.documentVisit.findMany({
347+
where: {
348+
documentId,
349+
lastVisitAt: { gte: sevenDaysAgo },
350+
},
351+
select: { lastVisitAt: true, visitCount: true },
352+
}),
353+
]);
354+
355+
// 按日聚合近 7 天访问
356+
const visitsByDay: Record<string, number> = {};
357+
for (const v of recentVisits) {
358+
const day = new Date(v.lastVisitAt).toISOString().slice(0, 10);
359+
visitsByDay[day] = (visitsByDay[day] || 0) + 1;
360+
}
361+
const dailyTrend = [];
362+
for (let i = 6; i >= 0; i--) {
363+
const d = new Date(now);
364+
d.setDate(now.getDate() - i);
365+
const key = d.toISOString().slice(0, 10);
366+
dailyTrend.push({ date: key, count: visitsByDay[key] || 0 });
367+
}
368+
369+
return {
370+
uv: uvCount,
371+
pv: pvAgg._sum.visitCount || 0,
372+
dailyTrend,
373+
};
374+
}
375+
376+
/**
377+
* 个人生产力统计
378+
*/
379+
async getUserProductivityStats(userId: string) {
380+
const now = new Date();
381+
382+
const thisWeekStart = new Date(now);
383+
thisWeekStart.setDate(now.getDate() - now.getDay());
384+
thisWeekStart.setHours(0, 0, 0, 0);
385+
386+
const lastWeekStart = new Date(thisWeekStart);
387+
lastWeekStart.setDate(lastWeekStart.getDate() - 7);
388+
389+
const [
390+
thisWeekCreated,
391+
lastWeekCreated,
392+
thisWeekEdited,
393+
lastWeekEdited,
394+
totalReadsAgg,
395+
] = await Promise.all([
396+
this.prisma.activityLog.count({
397+
where: {
398+
userId,
399+
action: 'CREATE',
400+
entityType: 'DOCUMENT',
401+
createdAt: { gte: thisWeekStart },
402+
},
403+
}),
404+
this.prisma.activityLog.count({
405+
where: {
406+
userId,
407+
action: 'CREATE',
408+
entityType: 'DOCUMENT',
409+
createdAt: { gte: lastWeekStart, lt: thisWeekStart },
410+
},
411+
}),
412+
this.prisma.activityLog.count({
413+
where: {
414+
userId,
415+
action: 'UPDATE',
416+
entityType: 'DOCUMENT',
417+
createdAt: { gte: thisWeekStart },
418+
},
419+
}),
420+
this.prisma.activityLog.count({
421+
where: {
422+
userId,
423+
action: 'UPDATE',
424+
entityType: 'DOCUMENT',
425+
createdAt: { gte: lastWeekStart, lt: thisWeekStart },
426+
},
427+
}),
428+
this.prisma.documentVisit.aggregate({
429+
where: { document: { createdBy: userId } },
430+
_sum: { visitCount: true },
431+
}),
432+
]);
433+
434+
return {
435+
thisWeekCreated,
436+
lastWeekCreated,
437+
thisWeekEdited,
438+
lastWeekEdited,
439+
totalReads: totalReadsAgg._sum.visitCount || 0,
440+
};
441+
}
442+
191443
/**
192444
* 清理超过 90 天的旧日志(可由定时任务调用)
193445
*/

apps/api/src/documents/documents.controller.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,10 @@ export class DocumentsController {
5858
}
5959

6060
/**
61-
* Lightweight existence check — returns 204 if exists, 404 if not.
61+
* Lightweight existence + permission check — returns 204 if exists and user has access, 403/404 otherwise.
6262
* No body, no side effects (no visit recording).
6363
*/
64-
@UseGuards(JwtAuthGuard)
64+
@UseGuards(JwtAuthGuard, SpacePermissionGuard)
6565
@Get(':id/exists')
6666
@HttpCode(204)
6767
async checkExists(@Param('id') id: string) {

apps/api/src/documents/documents.service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ export class DocumentsService {
144144
select: {
145145
id: true,
146146
title: true,
147+
spaceId: true,
147148
parentId: true,
148149
order: true,
149150
createdAt: true,

apps/web/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@
7171
"lodash.throttle": "^4.1.1",
7272
"lowlight": "^3.3.0",
7373
"lucide-react": "^0.563.0",
74+
"mammoth": "^1.12.0",
75+
"markdown-it": "^14.1.1",
76+
"markdown-it-task-lists": "^2.1.1",
7477
"motion": "^12.34.4",
7578
"next": "16.1.6",
7679
"next-themes": "^0.4.6",
@@ -96,6 +99,7 @@
9699
"@tailwindcss/typography": "^0.5.15",
97100
"@types/d3": "^7.4.3",
98101
"@types/lodash.throttle": "^4.1.9",
102+
"@types/markdown-it": "^14.1.2",
99103
"@types/node": "^20.19.35",
100104
"@types/react": "^19.2.14",
101105
"@types/react-dom": "^19",

0 commit comments

Comments
 (0)