@allbluecn/web-app 0.4.17 → 0.4.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/package.json +10 -10
  2. package/src/components/billing/checkout-redirect.ts +1 -1
  3. package/src/components/changelog/changelog-page.tsx +1 -1
  4. package/src/components/layout/sidebar-03/app-sidebar.tsx +2 -3
  5. package/src/components/layout/sidebar-03/nav-notifications.tsx +115 -35
  6. package/src/components/layout/sidebar-03/user-menu.tsx +1 -1
  7. package/src/components/notifications/notification-list.tsx +232 -0
  8. package/src/components/notifications/notification-preferences.tsx +156 -0
  9. package/src/components/settings/ai/create-custom-provider-dialog.tsx +145 -0
  10. package/src/components/settings/ai/provider-grid.tsx +46 -25
  11. package/src/components/settings/personal-team/team/team-client.tsx +118 -15
  12. package/src/components/settings/personal-team/team/team-invitations.tsx +887 -416
  13. package/src/components/settings/personal-team/team/team-member-list.tsx +122 -136
  14. package/src/hooks/__tests__/use-notifications.test.ts +41 -0
  15. package/src/hooks/use-notifications.ts +119 -0
  16. package/src/lib/app-version.ts +4 -1
  17. package/src/lib/auth/auth.config.ts +17 -1
  18. package/src/lib/changelog/sdk-versions-types.ts +1 -0
  19. package/src/lib/changelog/sdk-versions.ts +29 -19
  20. package/src/lib/collections/ai/index.ts +0 -1
  21. package/src/lib/collections/index.ts +0 -1
  22. package/src/plugins/.gen-manifest.json +13 -0
  23. package/src/plugins/modules.gen.ts +20 -0
  24. package/src/plugins/plugins.gen.ts +20 -0
  25. package/src/plugins/ui.gen.ts +10 -0
  26. package/src/routeTree.gen.ts +70 -0
  27. package/src/routes/_login/login/route.tsx +10 -1
  28. package/src/routes/_login/register/route.lazy.tsx +3 -2
  29. package/src/routes/_login/register/route.tsx +5 -0
  30. package/src/routes/_nav/inspiration/route.tsx +8 -0
  31. package/src/routes/_nav/notifications/route.lazy.tsx +23 -0
  32. package/src/routes/_nav/notifications/route.tsx +8 -0
  33. package/src/routes/_nav/prd/$id/route.tsx +9 -0
  34. package/src/routes/_nav/prd/index.tsx +9 -0
  35. package/src/routes/_nav/prd/route.tsx +8 -0
  36. package/src/routes/_nav/settings/-settings-tabs.tsx +2 -1
  37. package/src/routes/_nav/settings/design/route.lazy.tsx +1 -1
  38. package/src/routes/_nav/settings/notifications/route.lazy.tsx +37 -0
  39. package/src/routes/_nav/settings/notifications/route.tsx +20 -0
  40. package/src/routes/_nav/settings/privacy/route.lazy.tsx +2 -2
  41. package/src/routes/_nav/settings/route.lazy.tsx +1 -1
  42. package/src/routes/_nav/tags/$id/route.tsx +8 -0
  43. package/src/routes/_nav/tags/index.tsx +8 -0
  44. package/src/routes/_nav/tags/route.tsx +8 -0
  45. package/src/routes/_nav/tags/settings/route.tsx +9 -0
  46. package/src/routes/api/notifications/stream.ts +88 -0
  47. package/src/routes/invite/$token/route.lazy.tsx +308 -44
  48. package/src/routes/invite/$token/route.tsx +16 -8
  49. package/src/routes/share/prd/$prdId/route.tsx +10 -0
  50. package/src/server/notifications/__tests__/notification-service.test.ts +114 -0
  51. package/src/server/notifications/__tests__/stream.test.ts +30 -0
  52. package/src/server/notifications/event-bus.ts +16 -0
  53. package/src/server/notifications/notification-service.ts +86 -0
  54. package/src/server/resources.ts +10 -2
  55. package/src/server/serverFns/__tests__/compliance.test.ts +6 -6
  56. package/src/server/serverFns/__tests__/notifications.test.ts +63 -0
  57. package/src/server/serverFns/__tests__/team-accept.test.ts +100 -12
  58. package/src/server/serverFns/__tests__/team-invitation.test.ts +130 -60
  59. package/src/server/serverFns/__tests__/team-join-request.test.ts +281 -0
  60. package/src/server/serverFns/__tests__/team-members.test.ts +39 -118
  61. package/src/{lib/collections/ai/ai-providers-collection.ts → server/serverFns/auth/setup.ts} +7 -12
  62. package/src/server/serverFns/billing.ts +23 -2
  63. package/src/server/serverFns/notifications.ts +155 -0
  64. package/src/server/serverFns/story/story-comments.ts +42 -0
  65. package/src/server/serverFns/team.ts +573 -53
  66. package/src/start.ts +23 -1
  67. package/vite.shared.ts +13 -0
@@ -33,10 +33,21 @@ vi.mock('@allbluecn/database', () => ({
33
33
  teamMembership: {
34
34
  findUnique: vi.fn(),
35
35
  findMany: vi.fn(),
36
+ findFirst: vi.fn(),
36
37
  delete: vi.fn(),
38
+ create: vi.fn(),
37
39
  count: vi.fn(),
38
40
  },
39
- teamInvitation: { count: vi.fn() },
41
+ teamInvitation: {
42
+ create: vi.fn(),
43
+ findFirst: vi.fn(),
44
+ findUnique: vi.fn(),
45
+ updateMany: vi.fn(),
46
+ findMany: vi.fn(),
47
+ update: vi.fn(),
48
+ count: vi.fn(),
49
+ deleteMany: vi.fn(),
50
+ },
40
51
  projectMember: { deleteMany: vi.fn() },
41
52
  $transaction: vi.fn(),
42
53
  },
@@ -55,17 +66,13 @@ vi.mock('@/server/audit', () => ({
55
66
  }))
56
67
 
57
68
  import { prisma } from '@allbluecn/database'
58
- import {
59
- listTeamMembersFn,
60
- removeTeamMemberFn,
61
- leaveTeamFn,
62
- listTeamUsersFn,
63
- } from '../team'
69
+ import { listTeamMembersFn } from '../team'
64
70
 
65
71
  describe('listTeamMembersFn', () => {
66
72
  beforeEach(() => vi.clearAllMocks())
67
73
 
68
- it('owner 列出成员', async () => {
74
+ it('已有 membership → 正常列出成员', async () => {
75
+ vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({ id: "m1", ownerId: "owner1" } as never)
69
76
  vi.mocked(prisma.teamMembership.findMany).mockResolvedValue([
70
77
  {
71
78
  id: 'm1',
@@ -78,124 +85,38 @@ describe('listTeamMembersFn', () => {
78
85
  ] as never)
79
86
  const result = await listTeamMembersFn()
80
87
  expect(result).toHaveLength(1)
81
- expect(result[0].seatType).toBe('member')
88
+ expect((result as any)[0].seatType).toBe('member')
89
+ expect((result as any)[0].memberId).toBe('u2')
82
90
  })
83
- })
84
-
85
- describe('removeTeamMemberFn', () => {
86
- beforeEach(() => vi.clearAllMocks())
87
-
88
- it('owner 移除成员 → 事务删 membership + 级联删 ProjectMember', async () => {
89
- vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({
90
- id: 'm1',
91
- ownerId: 'owner1',
92
- memberId: 'u2',
93
- seatType: 'member',
94
- } as never)
95
- let capturedTx: any
96
- vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => {
97
- capturedTx = {
98
- teamMembership: { delete: vi.fn().mockResolvedValue({}) },
99
- projectMember: { deleteMany: vi.fn().mockResolvedValue({ count: 3 }) },
100
- }
101
- return fn(capturedTx)
102
- })
103
- const result = await removeTeamMemberFn({ data: { membershipId: 'm1' } })
104
- expect(result.success).toBe(true)
105
- expect(capturedTx.projectMember.deleteMany).toHaveBeenCalledWith(
106
- expect.objectContaining({ where: { userId: 'u2' } }),
107
- )
108
- })
109
-
110
- it('非 owner 移除 → 拒绝', async () => {
111
- vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({
112
- id: 'm1',
113
- ownerId: 'other',
114
- memberId: 'u2',
115
- } as never)
116
- await expect(removeTeamMemberFn({ data: { membershipId: 'm1' } }))
117
- .rejects.toMatchObject({ message: expect.stringContaining('权限') })
118
- })
119
-
120
- it('成员不存在 → 拒绝', async () => {
121
- vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null)
122
- await expect(removeTeamMemberFn({ data: { membershipId: 'm999' } }))
123
- .rejects.toMatchObject({ message: expect.stringContaining('不存在') })
124
- })
125
- })
126
-
127
- describe('leaveTeamFn', () => {
128
- beforeEach(() => vi.clearAllMocks())
129
91
 
130
- it('未加入团队 → 拒绝', async () => {
92
+ it('无 membership → 自动创建 owner 记录', async () => {
131
93
  vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null)
132
- await expect(leaveTeamFn()).rejects.toMatchObject({ message: expect.stringContaining('未加入') })
133
- })
134
-
135
- it('已加入团队 → 退出并级联删 ProjectMember', async () => {
136
- vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({
137
- id: 'm1',
94
+ vi.mocked(prisma.teamMembership.create).mockResolvedValue({
95
+ id: 'm-new',
138
96
  ownerId: 'owner1',
139
97
  memberId: 'owner1',
98
+ seatType: 'owner',
99
+ joinedAt: new Date(),
100
+ member: { id: 'owner1', username: 'A', email: 'a@x.com', avatarConfig: null, bgShape: null, bgColor: null },
140
101
  } as never)
141
- let capturedTx: any
142
- vi.mocked(prisma.$transaction).mockImplementation(async (fn: any) => {
143
- capturedTx = {
144
- teamMembership: { delete: vi.fn().mockResolvedValue({}) },
145
- projectMember: { deleteMany: vi.fn().mockResolvedValue({ count: 2 }) },
146
- }
147
- return fn(capturedTx)
148
- })
149
- const result = await leaveTeamFn()
150
- expect(result.success).toBe(true)
151
- expect(capturedTx.projectMember.deleteMany).toHaveBeenCalledWith(
152
- expect.objectContaining({ where: { userId: 'owner1' } }),
102
+ const result = await listTeamMembersFn()
103
+ expect(prisma.teamMembership.create).toHaveBeenCalledWith(
104
+ expect.objectContaining({
105
+ data: expect.objectContaining({ ownerId: 'owner1', memberId: 'owner1', seatType: 'owner' }),
106
+ }),
153
107
  )
154
- })
155
- })
156
-
157
- describe('listTeamUsersFn', () => {
158
- beforeEach(() => vi.clearAllMocks())
159
-
160
- it('返回 owner + active 成员(含 seatType)', async () => {
161
- vi.mocked(prisma.user.findUnique).mockResolvedValue({
162
- id: 'owner1',
163
- username: 'A',
164
- email: 'a@x.com',
165
- avatarConfig: null,
166
- bgShape: null,
167
- bgColor: null,
168
- } as never)
169
- vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({
170
- ownerId: 'owner1',
171
- memberId: 'owner1',
172
- } as never)
173
- vi.mocked(prisma.teamMembership.findMany).mockResolvedValue([
174
- { seatType: 'member', member: { id: 'u2', username: 'B', email: 'b@x.com', avatarConfig: null, bgShape: null, bgColor: null } },
175
- { seatType: 'guest', member: { id: 'u3', username: 'C', email: 'c@x.com', avatarConfig: null, bgShape: null, bgColor: null } },
176
- ] as never)
177
- const result = await listTeamUsersFn()
178
- expect(result).toHaveLength(3)
108
+ expect(result).toHaveLength(1)
179
109
  expect(result[0].seatType).toBe('owner')
180
- expect(result[0].username).toBe('A')
181
- expect(result[1].seatType).toBe('member')
182
- expect(result[2].seatType).toBe('guest')
110
+ expect(result[0].memberId).toBe('owner1')
183
111
  })
184
112
 
185
- it('未加入团队的用户 → 返回自身作为 owner', async () => {
186
- vi.mocked(prisma.user.findUnique).mockResolvedValue({
187
- id: 'owner1',
188
- username: 'Solo',
189
- email: 'solo@x.com',
190
- avatarConfig: null,
191
- bgShape: null,
192
- bgColor: null,
193
- } as never)
194
- vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue(null)
195
- vi.mocked(prisma.teamMembership.findMany).mockResolvedValue([] as never)
196
- const result = await listTeamUsersFn()
197
- expect(result).toHaveLength(1)
198
- expect(result[0].seatType).toBe('owner')
199
- expect(result[0].username).toBe('Solo')
113
+ it('已有 membership → 不创建,直接查询', async () => {
114
+ vi.mocked(prisma.teamMembership.findUnique).mockResolvedValue({ id: "m1", ownerId: "owner1" } as never)
115
+ vi.mocked(prisma.teamMembership.findMany).mockResolvedValue([])
116
+ await listTeamMembersFn()
117
+ expect(prisma.teamMembership.create).not.toHaveBeenCalled()
118
+ expect(prisma.teamMembership.findMany).toHaveBeenCalledWith(
119
+ expect.objectContaining({ where: { ownerId: 'owner1' } }),
120
+ )
200
121
  })
201
- })
122
+ })
@@ -15,17 +15,12 @@
15
15
  // You should have received a copy of the GNU Affero General Public License
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
- import { createCollection } from '@tanstack/db'
19
- import { queryCollectionOptions } from '@tanstack/query-db-collection'
20
- import { listAiProvidersFn } from '@/server/serverFns/ai/providers'
21
- import { getQueryClient } from '@allbluecn/shared/lib/query-client-seam'
18
+ import { createServerFn } from '@tanstack/react-start'
19
+ import { prisma } from '@allbluecn/database'
22
20
 
23
- export const aiProvidersCollection = createCollection(
24
- queryCollectionOptions({
25
- id: 'ai-providers',
26
- queryKey: ['ai-providers'],
27
- queryFn: () => listAiProvidersFn(),
28
- getKey: (item) => item.meta.providerId,
29
- queryClient: getQueryClient(),
21
+ export const checkAdminExistsFn = createServerFn({ method: 'GET' }).handler(async () => {
22
+ const count = await prisma.user.count({
23
+ where: { role: 'SUPER_ADMIN' },
30
24
  })
31
- )
25
+ return { hasAdmin: count > 0 }
26
+ })
@@ -32,6 +32,17 @@ const requireBilling = () => {
32
32
  return billing
33
33
  }
34
34
 
35
+ async function emitBillingNotification(userId: string, type: string, title: string, body: string, link?: string) {
36
+ void (async () => {
37
+ try {
38
+ const { createNotification } = await import('@/server/notifications/notification-service')
39
+ await createNotification({ userId, category: 'billing', type, title, body, link })
40
+ } catch (e) {
41
+ console.error('[billing] 通知发送失败:', e instanceof Error ? e.message : 'unknown')
42
+ }
43
+ })()
44
+ }
45
+
35
46
  export const getEffectivePlanFn = createServerFn({ method: 'GET' }).handler(async () => {
36
47
  const request = getRequest()
37
48
  const session = await requireAuth(request)
@@ -92,10 +103,12 @@ export const createCustomerPortalSessionFn = createServerFn({ method: 'POST' }).
92
103
  const userId = session.user!.id!
93
104
 
94
105
  const baseUrl = process.env.VITE_APP_URL ?? 'http://localhost:3000'
95
- return requireBilling().createCustomerPortalSession({
106
+ const result = await requireBilling().createCustomerPortalSession({
96
107
  userId,
97
108
  returnUrl: `${baseUrl}/settings/billing`,
98
109
  })
110
+ await emitBillingNotification(userId, 'billing.portal_access', '客户门户已打开', '您已打开管理订阅的客户门户', '/settings/billing')
111
+ return result
99
112
  },
100
113
  )
101
114
 
@@ -159,9 +172,17 @@ export const updateSeatsFn = createServerFn({ method: 'POST' })
159
172
  )
160
173
  }
161
174
 
162
- return requireBilling().updateSeats({
175
+ const result = await requireBilling().updateSeats({
163
176
  userId,
164
177
  addonSeats: data.addonSeats,
165
178
  successUrl: data.successUrl ?? `${baseUrl}/settings/billing?checkout=success`,
166
179
  })
180
+ await emitBillingNotification(
181
+ userId,
182
+ 'billing.seats_changed',
183
+ '席位变更',
184
+ `席位已调整为 ${subscription.plan.maxSeats + data.addonSeats}`,
185
+ '/settings/billing',
186
+ )
187
+ return result
167
188
  })
@@ -0,0 +1,155 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { createServerFn } from '@tanstack/react-start'
3
+ import { getRequest } from '@tanstack/react-start/server'
4
+ import { z } from 'zod'
5
+ import { prisma } from '@allbluecn/database'
6
+ import { requireAuth } from '@/server/auth-helpers'
7
+ import {
8
+ NOTIFICATION_CATEGORIES,
9
+ type NotificationCategory,
10
+ type CategoryPreferences,
11
+ type NotificationItem,
12
+ type NotificationListResult,
13
+ } from '@allbluecn/shared'
14
+ import {
15
+ getOrCreatePreferences,
16
+ DEFAULT_CATEGORIES,
17
+ } from '@/server/notifications/notification-service'
18
+
19
+ const PAGE_SIZE = 20
20
+
21
+ const listSchema = z.object({
22
+ category: z.enum(NOTIFICATION_CATEGORIES).optional(),
23
+ cursor: z.string().optional(),
24
+ })
25
+
26
+ export const listNotificationsFn = createServerFn({ method: 'GET' })
27
+ .validator(listSchema)
28
+ .handler(async ({ data }): Promise<NotificationListResult> => {
29
+ const request = getRequest()
30
+ const session = await requireAuth(request)
31
+ const userId = session.user!.id!
32
+
33
+ const items = await prisma.notification.findMany({
34
+ where: {
35
+ userId,
36
+ ...(data.category ? { category: data.category } : {}),
37
+ },
38
+ orderBy: { createdAt: 'desc' },
39
+ take: PAGE_SIZE + 1,
40
+ ...(data.cursor ? { cursor: { id: data.cursor }, skip: 1 } : {}),
41
+ })
42
+
43
+ const hasMore = items.length > PAGE_SIZE
44
+ const page = (hasMore ? items.slice(0, PAGE_SIZE) : items).map(
45
+ (n): NotificationItem => ({
46
+ id: n.id,
47
+ category: n.category as NotificationCategory,
48
+ type: n.type,
49
+ title: n.title,
50
+ body: n.body,
51
+ link: n.link,
52
+ readAt: n.readAt ? n.readAt.toISOString() : null,
53
+ createdAt: n.createdAt.toISOString(),
54
+ }),
55
+ )
56
+ return { items: page, total: page.length, hasMore }
57
+ })
58
+
59
+ const markReadSchema = z.object({ id: z.string().min(1) })
60
+
61
+ export const markNotificationReadFn = createServerFn({ method: 'POST' })
62
+ .validator(markReadSchema)
63
+ .handler(async ({ data }) => {
64
+ const request = getRequest()
65
+ const session = await requireAuth(request)
66
+ const userId = session.user!.id!
67
+ await prisma.notification.updateMany({
68
+ where: { id: data.id, userId, readAt: null },
69
+ data: { readAt: new Date() },
70
+ })
71
+ return { success: true }
72
+ })
73
+
74
+ export const markAllNotificationsReadFn = createServerFn({ method: 'POST' })
75
+ .validator(z.object({}).optional())
76
+ .handler(async () => {
77
+ const request = getRequest()
78
+ const session = await requireAuth(request)
79
+ const userId = session.user!.id!
80
+ const result = await prisma.notification.updateMany({
81
+ where: { userId, readAt: null },
82
+ data: { readAt: new Date() },
83
+ })
84
+ return { success: true, count: result.count }
85
+ })
86
+
87
+ const deleteSchema = z.object({ id: z.string().min(1) })
88
+
89
+ export const deleteNotificationFn = createServerFn({ method: 'POST' })
90
+ .validator(deleteSchema)
91
+ .handler(async ({ data }) => {
92
+ const request = getRequest()
93
+ const session = await requireAuth(request)
94
+ const userId = session.user!.id!
95
+ await prisma.notification.deleteMany({ where: { id: data.id, userId } })
96
+ return { success: true }
97
+ })
98
+
99
+ export const getUnreadCountFn = createServerFn({ method: 'GET' })
100
+ .validator(z.object({}).optional())
101
+ .handler(async () => {
102
+ const request = getRequest()
103
+ const session = await requireAuth(request)
104
+ const userId = session.user!.id!
105
+ const count = await prisma.notification.count({
106
+ where: { userId, readAt: null },
107
+ })
108
+ return { count }
109
+ })
110
+
111
+ export const getNotificationPreferencesFn = createServerFn({ method: 'GET' })
112
+ .validator(z.object({}).optional())
113
+ .handler(async () => {
114
+ const request = getRequest()
115
+ const session = await requireAuth(request)
116
+ return getOrCreatePreferences(session.user!.id!)
117
+ })
118
+
119
+ const preferencesSchema = z.object({
120
+ categories: z.record(z.string(), z.boolean()),
121
+ dndEnabled: z.boolean(),
122
+ dndStart: z.string().regex(/^\d{2}:\d{2}$/),
123
+ dndEnd: z.string().regex(/^\d{2}:\d{2}$/),
124
+ })
125
+
126
+ export const updateNotificationPreferencesFn = createServerFn({
127
+ method: 'POST',
128
+ })
129
+ .validator(preferencesSchema)
130
+ .handler(async ({ data }) => {
131
+ const request = getRequest()
132
+ const session = await requireAuth(request)
133
+ const userId = session.user!.id!
134
+ const categories = { ...DEFAULT_CATEGORIES } as CategoryPreferences
135
+ for (const c of NOTIFICATION_CATEGORIES) {
136
+ if (typeof data.categories[c] === 'boolean') categories[c] = data.categories[c]
137
+ }
138
+ await prisma.notificationPreference.upsert({
139
+ where: { userId },
140
+ create: {
141
+ userId,
142
+ categories,
143
+ dndEnabled: data.dndEnabled,
144
+ dndStart: data.dndStart,
145
+ dndEnd: data.dndEnd,
146
+ },
147
+ update: {
148
+ categories,
149
+ dndEnabled: data.dndEnabled,
150
+ dndStart: data.dndStart,
151
+ dndEnd: data.dndEnd,
152
+ },
153
+ })
154
+ return { success: true }
155
+ })
@@ -22,6 +22,7 @@ import { prisma } from "@allbluecn/database";
22
22
  import { requireAuth } from "@/server/auth-helpers";
23
23
  import type { CommentWithReplies, ReactionGroup } from "@/components/story/types";
24
24
  import { USER_BRIEF_SELECT, castUserBrief, aggregateReactions } from "./types";
25
+ import { createNotification } from '@/server/notifications/notification-service'
25
26
 
26
27
  export const listCommentsFn = createServerFn({ method: "GET" })
27
28
  .validator(z.object({ storyId: z.string() }))
@@ -101,6 +102,47 @@ export const createCommentFn = createServerFn({ method: "POST" })
101
102
  },
102
103
  });
103
104
 
105
+ const commenter = await prisma.user.findUnique({
106
+ where: { id: userId },
107
+ select: { username: true },
108
+ })
109
+ const story = await prisma.story.findUnique({
110
+ where: { id: data.storyId },
111
+ select: { createdById: true },
112
+ })
113
+ if (story?.createdById && story.createdById !== userId) {
114
+ void createNotification({
115
+ userId: story.createdById,
116
+ category: 'collaboration',
117
+ type: 'collaboration.comment',
118
+ title: `${commenter?.username ?? '有人'} 评论了你的 Story`,
119
+ link: `/stories/${data.storyId}`,
120
+ })
121
+ }
122
+
123
+ const mentionMatches = data.content.match(/@([a-zA-Z0-9_\u4e00-\u9fa5]+)/g) ?? []
124
+ if (mentionMatches.length > 0) {
125
+ const mentionedNames = [...new Set(mentionMatches.map((m) => m.slice(1)))].filter(
126
+ (name) => name.toLowerCase() !== commenter?.username?.toLowerCase(),
127
+ )
128
+ if (mentionedNames.length > 0) {
129
+ const mentionedUsers = await prisma.user.findMany({
130
+ where: { username: { in: mentionedNames } },
131
+ select: { id: true },
132
+ })
133
+ for (const u of mentionedUsers) {
134
+ if (u.id === userId) continue
135
+ void createNotification({
136
+ userId: u.id,
137
+ category: 'collaboration',
138
+ type: 'collaboration.mention',
139
+ title: `${commenter?.username ?? '有人'} 在评论中提到了你`,
140
+ link: `/stories/${data.storyId}`,
141
+ })
142
+ }
143
+ }
144
+ }
145
+
104
146
  await prisma.storyActivity.create({
105
147
  data: {
106
148
  storyId: data.storyId,