@lark-apaas/coding-template-nestjs-react-fullstack 0.1.60-alpha.20261105085132 → 0.1.60-alpha.20261105123111

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/coding-template-nestjs-react-fullstack",
3
- "version": "0.1.60-alpha.20261105085132",
3
+ "version": "0.1.60-alpha.20261105123111",
4
4
  "description": "NestJS + React fullstack template for miaoda coding",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,36 @@
1
+ # 通用评论入口(OpenAPI pilot)
2
+
3
+ 与 `record-comments` 共享 UI/CSS 和乐观更新逻辑。新的公开数据契约不包含 Action、插件实例或飞书数字用户 ID。旧入口继续兼容;此试接入不搬迁全部内部文件,不自动迁移应用。
4
+
5
+ ```tsx
6
+ import { CommentsProvider, CommentList, CommentComposer, commentReactions } from '@/components/business-ui/comments';
7
+
8
+ <CommentsProvider
9
+ scopeKey="feishu:my-app:account"
10
+ subjectKey={documentToken}
11
+ threads={threads}
12
+ currentUser={currentUser}
13
+ actions={adapter.actions}
14
+ input={{ userMentions: ref => ref.namespace === userNamespace, links: true }}
15
+ actors={directory}
16
+ extensions={[commentReactions({ allowedKeys: ['THUMBSUP'], onChange: adapter.changeReaction })]}
17
+ >
18
+ <div className="min-h-0 flex-1 overflow-y-auto"><CommentList /></div>
19
+ <CommentComposer />
20
+ </CommentsProvider>
21
+ ```
22
+
23
+ `CommentsProvider` 不输出布局 DOM。也可用 `CommentPanel` 提供默认完整布局。类型以 `contracts.ts` 为准。
24
+
25
+ - `threads[].messages[]` 中第一条为正文。删除只针对选中消息;剩余消息原位提升,讨论 ID 不变。`canReply:false` 隐藏回复入口(例如 OpenAPI 整篇文档评论)。
26
+ - ID 一律非空字符串;人员用 `{kind:'user',namespace,id}`,namespace 应包含提供方、应用及 ID 类型。不能把 `ou_...` 转数字。时间为毫秒,缺 `updatedAt` 可以正常展示,不伪造编辑时间。
27
+ - 读取、权限、分页、缓存和认证在宿主。加载更多通过外部 `pagination.onLoadMore` / `repliesPagination` 请求。权限未知就不显示修改入口;后端仍须鉴权。
28
+ - `actions.create/update` 返回真实 `{threadId,messageId,message?}`。可选 message 为权威回读,用于接管乐观内容。`remove` 返回 `{threadId,threadDeleted?}`,缺少讨论状态时等待外部回读,不凭局部列表为空认定整串删除。
29
+ - 写成功就 resolve;后续刷新失败不能把成功写入 reject。确定未写入的失败可抛 `{outcome:'not-applied'}`;超时/响应丢失等抛 `{outcome:'unknown'}`,不能盲目重发。`context.operationId` 在一次重试中稳定,但宿主只有下游确实支持时才能把它当幂等保证。
30
+ - 外部读取需要请求序号和写入屏障:忽略写入前开始的过期读取。组件无法判断任意新数组是否更新,也不能独自解决远端最终一致性。
31
+ - 作者快照可只有 ref;姓名缺失交 `actors.resolve`。开启输入 @ 要同时提供目录及 `input.userMentions` 策略;自己回复不自动插入本人 @。
32
+ - 文档用 `commentDocuments({source,canInsert:true})`;source 负责解析 URL、搜索和权限状态。普通链接受 `input.links` 控制。无法解析、未授权、无权限是不同结果,不能把解析失败标成无权限。
33
+ - 回应用 `commentReactions({allowedKeys,onChange})`。`reactions:undefined` 表示未加载,`[]` 表示已知为空,`mine:undefined` 表示本人状态未知,不根据截断名单推断 false。此次 pilot 在任意回应 membership 未知时保守禁用该消息的回应修改;写入结果未知时保留本地状态并锁定,等待权威回读确认。
34
+ - `commentImages()` 只启用选择 UI,不提供上传。宿主自行完成上传、持久化和授权,再在 create/update 返回完整消息。此 OpenAPI 试接入没有图片功能;通用文件附件暂不编辑,避免丢失内容。
35
+
36
+ 真实 OpenAPI 示例和验收工具在 `scripts/__tests__/fixtures/record-comments/openapi/`(仓库测试资产,不随模板运行时携带)。不要让部署后的应用执行 lark-cli,也不要把 token/app secret 放到浏览器。生产应用用自己的后端 OAuth/OpenAPI SDK 替换示例 request。
@@ -0,0 +1,626 @@
1
+ import type {
2
+ CommentsProviderProps,
3
+ CommentThread,
4
+ CommentContent,
5
+ CommentDraft,
6
+ EntityRef,
7
+ UserRef,
8
+ CommentEntitySource,
9
+ CommentReaction,
10
+ CommentTarget,
11
+ CommentWriteResult,
12
+ CommentMessage,
13
+ } from './contracts';
14
+ import type {
15
+ CommentDataSource,
16
+ CommentValue,
17
+ MentionRef,
18
+ MentionOption,
19
+ } from '../record-comments/types';
20
+ import type {
21
+ DiscussionThread,
22
+ DiscussionDraft,
23
+ DiscussionReply,
24
+ } from '../record-comments/internal/view-types';
25
+ import {
26
+ useEffect,
27
+ useMemo,
28
+ useRef,
29
+ useState,
30
+ useSyncExternalStore,
31
+ } from 'react';
32
+ import { CommentMutations } from '../record-comments/internal/mutations';
33
+ import {
34
+ DiscussionProvider,
35
+ DiscussionLayout,
36
+ } from '../record-comments/internal/discussion';
37
+ import { ReadStatus, PaginationFooter } from '../record-comments/comment-panel';
38
+ import { withDocumentSource } from '../record-comments/extensions';
39
+ import { DEFAULT_REACTION_EMOJIS } from '../record-comments/internal/reactions';
40
+ import { normalizeValue } from '../record-comments/internal/model';
41
+
42
+ // Encoding is private UI identity, not a backend ID conversion. Namespace stays lossless.
43
+ const toRef = (ref: EntityRef): MentionRef => {
44
+ if (!ref.id || !ref.namespace) throw new Error('引用标识不能为空');
45
+ return ref.kind === 'user'
46
+ ? {
47
+ kind: 'user',
48
+ idType: 'generic',
49
+ id: JSON.stringify([ref.namespace, ref.id]),
50
+ }
51
+ : { kind: 'document', resourceType: ref.namespace, id: ref.id };
52
+ };
53
+ const fromRef = (ref: MentionRef): EntityRef => {
54
+ if (ref.kind === 'document')
55
+ return { kind: 'document', namespace: ref.resourceType, id: ref.id };
56
+ if (ref.idType !== 'generic')
57
+ return { kind: 'user', namespace: ref.idType, id: ref.id };
58
+ const [namespace, id] = JSON.parse(ref.id);
59
+ return { kind: 'user', namespace, id };
60
+ };
61
+ const value = (content: CommentContent): CommentValue =>
62
+ normalizeValue({
63
+ version: 1,
64
+ content: content.nodes.map((n) =>
65
+ n.type === 'mention'
66
+ ? { ...n, ref: toRef(n.ref) }
67
+ : n.type === 'unsupported'
68
+ ? { type: 'text', text: n.label }
69
+ : n,
70
+ ),
71
+ });
72
+ const content = (v: CommentValue): CommentContent => ({
73
+ version: 1,
74
+ nodes: normalizeValue(v).content.map((n) =>
75
+ n.type === 'mention' ? { ...n, ref: fromRef(n.ref) } : n,
76
+ ),
77
+ });
78
+ const fail = (message: string) =>
79
+ Object.assign(new Error(message), { outcome: 'not-applied' });
80
+ const reactionState = (items: CommentReaction[]) =>
81
+ JSON.stringify(
82
+ items
83
+ .map((r) => [r.key, r.count, r.mine])
84
+ .sort((a, b) => String(a[0]).localeCompare(String(b[0]))),
85
+ );
86
+ const key = (ref: EntityRef) =>
87
+ JSON.stringify([ref.kind, ref.namespace, ref.id]);
88
+ export function CommentsProvider(props: CommentsProviderProps) {
89
+ return (
90
+ <Session
91
+ key={JSON.stringify([props.scopeKey, props.subjectKey])}
92
+ {...props}
93
+ />
94
+ );
95
+ }
96
+ function Session(props: CommentsProviderProps) {
97
+ const latest = useRef(props);
98
+ latest.current = props;
99
+ const [mutations] = useState(
100
+ () =>
101
+ new CommentMutations({
102
+ opaqueIDs: true,
103
+ unknownDeletion: true,
104
+ unknownErrors: true,
105
+ removePending: true,
106
+ }),
107
+ );
108
+ useSyncExternalStore(
109
+ mutations.subscribe,
110
+ mutations.getVersion,
111
+ mutations.getVersion,
112
+ );
113
+ useEffect(() => {
114
+ mutations.activate();
115
+ return () => mutations.dispose();
116
+ }, [mutations]);
117
+ const abort = useRef(new AbortController());
118
+ useEffect(() => {
119
+ const controller = new AbortController();
120
+ abort.current = controller;
121
+ return () => controller.abort();
122
+ }, []);
123
+ const context = () => ({
124
+ operationId: crypto.randomUUID(),
125
+ signal: abort.current.signal,
126
+ });
127
+ const extensions = new Map(props.extensions?.map((x) => [x.type, x]));
128
+ if (extensions.size !== (props.extensions?.length ?? 0))
129
+ throw new Error('同种评论扩展只能注册一次');
130
+ const documents = props.extensions?.find((x) => x.type === 'documents');
131
+ const reactions = props.extensions?.find((x) => x.type === 'reactions');
132
+ const images = extensions.has('images');
133
+ const names = useRef(new Map<string, MentionOption>());
134
+ names.current = new Map();
135
+ for (const author of [
136
+ props.currentUser,
137
+ ...props.threads.flatMap((t) =>
138
+ t.messages.flatMap((m) => [
139
+ m.author,
140
+ ...(m.reactions ?? []).flatMap((r) => r.previewUsers ?? []),
141
+ ]),
142
+ ),
143
+ ])
144
+ if (author?.name)
145
+ names.current.set(key(author.ref), {
146
+ ref: toRef(author.ref),
147
+ label: author.name || '用户',
148
+ avatarUrl: author.avatarUrl,
149
+ });
150
+ const source = useMemo(() => {
151
+ function wrap(
152
+ external: CommentEntitySource | undefined,
153
+ user: boolean,
154
+ ): CommentDataSource {
155
+ const option = (o: import('./contracts').CommentEntityOption) => ({
156
+ ...o,
157
+ ref: toRef(o.ref),
158
+ });
159
+ const allowed = (o: import('./contracts').CommentEntityOption) =>
160
+ o.ref.kind === 'user'
161
+ ? Boolean(latest.current.input?.userMentions?.(o.ref as UserRef))
162
+ : true;
163
+ return {
164
+ scopeKey: props.scopeKey + (user ? ':actors' : ':documents'),
165
+ allowLinks: props.input?.links ?? false,
166
+ search: async (args) => {
167
+ if (user && !latest.current.input?.userMentions) return { items: [] };
168
+ const result = await external?.search?.(args);
169
+ return {
170
+ items: (result?.items ?? []).filter(allowed).map(option),
171
+ nextCursor: result?.nextCursor,
172
+ };
173
+ },
174
+ ...(external?.recommend
175
+ ? {
176
+ recommend: async (args) => {
177
+ if (user && !latest.current.input?.userMentions)
178
+ return { items: [] };
179
+ const r = await external.recommend!(args);
180
+ return {
181
+ items: r.items.filter(allowed).map(option),
182
+ nextCursor: r.nextCursor,
183
+ };
184
+ },
185
+ }
186
+ : {}),
187
+ parseLink: (url) => {
188
+ const ref = external?.parseUrl?.(url);
189
+ return ref
190
+ ? { type: 'mention', ref: toRef(ref), href: url }
191
+ : undefined;
192
+ },
193
+ resolve: async (refs, signal) => {
194
+ const found = new Map(
195
+ refs.flatMap((ref) => {
196
+ const r = names.current.get(key(fromRef(ref)));
197
+ return r
198
+ ? [
199
+ [
200
+ key(fromRef(ref)),
201
+ { ...r, status: 'resolved' as const },
202
+ ] as const,
203
+ ]
204
+ : [];
205
+ }),
206
+ );
207
+ const missing = refs.filter((r) => !found.has(key(fromRef(r))));
208
+ if (missing.length && external)
209
+ for (const r of await external.resolve(
210
+ missing.map(fromRef),
211
+ signal,
212
+ ))
213
+ found.set(key(r.ref), { ...r, ref: toRef(r.ref) } as never);
214
+ signal.throwIfAborted();
215
+ return refs.map(
216
+ (ref) =>
217
+ found.get(key(fromRef(ref))) ?? { ref, status: 'error' as const },
218
+ );
219
+ },
220
+ };
221
+ }
222
+ const people = wrap(props.actors, true);
223
+ return documents
224
+ ? {
225
+ ...withDocumentSource(people, wrap(documents.source, false)),
226
+ allowLinks: props.input?.links ?? false,
227
+ }
228
+ : people;
229
+ }, [props.actors, props.scopeKey, props.input?.links, documents?.source]);
230
+ const [submitted, setSubmitted] = useState<string>();
231
+ const [reactionVersion, setReactionVersion] = useState(0);
232
+ const reactionOverlays = useRef(
233
+ new Map<
234
+ string,
235
+ {
236
+ data: CommentReaction[];
237
+ before: CommentReaction[];
238
+ confirmed: boolean;
239
+ uncertain?: boolean;
240
+ key: string;
241
+ action: 'add' | 'remove';
242
+ }
243
+ >(),
244
+ );
245
+ const reactionBusy = useRef(new Set<string>());
246
+ const reactKey = (target: CommentTarget) =>
247
+ JSON.stringify([target.threadId, target.messageId]);
248
+ const dataToReaction = (data: CommentReaction[]) =>
249
+ data
250
+ .filter((r) => r.count > 0)
251
+ .map((r) => ({
252
+ key: r.key,
253
+ count: r.count,
254
+ mine: r.mine === true,
255
+ users: (r.previewUsers ?? []).map((u) => toRef(u.ref).id),
256
+ }));
257
+ const toMessage = (m: CommentMessage, threadId: string): DiscussionReply => {
258
+ const target = { threadId, messageId: m.id };
259
+ const overlay = reactionOverlays.current.get(reactKey(target));
260
+ const canEdit = Boolean(
261
+ m.permissions?.edit &&
262
+ props.actions?.update &&
263
+ !m.content.nodes.some((n) => n.type === 'unsupported') &&
264
+ (!m.attachments?.length ||
265
+ (images &&
266
+ m.attachments.every((a) => a.kind === 'image' && Boolean(a.url)))),
267
+ );
268
+ return {
269
+ replyID: m.id,
270
+ author: m.author ? toRef(m.author.ref) : null,
271
+ createTime: m.createdAt / 1000,
272
+ updateTime: m.updatedAt === undefined ? undefined : m.updatedAt / 1000,
273
+ edited: m.edited || Boolean(m.updatedAt && m.updatedAt > m.createdAt),
274
+ value: value({
275
+ version: 1,
276
+ nodes: [
277
+ ...m.content.nodes,
278
+ ...(m.attachments ?? [])
279
+ .filter((a) => a.kind !== 'image' || !a.url)
280
+ .map((a) => ({
281
+ type: 'unsupported' as const,
282
+ label: `[附件暂未接入:${a.name}]`,
283
+ })),
284
+ ],
285
+ }),
286
+ images: (m.attachments ?? [])
287
+ .filter((a) => a.kind === 'image' && a.url)
288
+ .map((a) => ({ ...a, url: a.url! })),
289
+ reactions: dataToReaction(overlay?.data ?? m.reactions ?? []),
290
+ permissions: {
291
+ canEdit,
292
+ canDelete: Boolean(m.permissions?.delete && props.actions?.remove),
293
+ canCopyLink: Boolean(props.getMessageLink?.(target)),
294
+ canReact: Boolean(
295
+ reactions?.options.onChange &&
296
+ m.permissions?.react &&
297
+ !overlay?.uncertain &&
298
+ m.reactions !== undefined &&
299
+ !m.reactions.some((r) => r.mine === undefined),
300
+ ),
301
+ },
302
+ };
303
+ };
304
+ const base: DiscussionThread[] = props.threads.map((t) => ({
305
+ commentID: t.id,
306
+ canReply: t.canReply,
307
+ creator: t.messages[0]?.author ? toRef(t.messages[0].author.ref) : null,
308
+ createTime: (t.messages[0]?.createdAt ?? 0) / 1000,
309
+ isSolved: !!t.resolved,
310
+ listFooter: t.repliesPagination?.hasMore ? (
311
+ <PaginationFooter pagination={t.repliesPagination} />
312
+ ) : undefined,
313
+ replies: t.messages.map((m) => toMessage(m, t.id)),
314
+ }));
315
+ useEffect(() => {
316
+ mutations.reconcile(base);
317
+ });
318
+ useEffect(() => {
319
+ for (const [k, o] of reactionOverlays.current)
320
+ if (o.confirmed) {
321
+ const [tid, mid] = JSON.parse(k);
322
+ const data = props.threads
323
+ .find((t) => t.id === tid)
324
+ ?.messages.find((m) => m.id === mid)?.reactions;
325
+ if (
326
+ data &&
327
+ (reactionState(data) === reactionState(o.data) ||
328
+ (o.action === 'add'
329
+ ? data.find((r) => r.key === o.key)?.mine === true
330
+ : !data.some((r) => r.key === o.key) ||
331
+ data.find((r) => r.key === o.key)?.mine === false))
332
+ ) {
333
+ reactionOverlays.current.delete(k);
334
+ reactionBusy.current.delete(k);
335
+ setReactionVersion((v) => v + 1);
336
+ }
337
+ }
338
+ }, [props.threads, reactionVersion]);
339
+ const readDraft = (
340
+ draft: DiscussionDraft,
341
+ mode: 'create' | 'update',
342
+ target: Partial<CommentTarget>,
343
+ ): CommentDraft => {
344
+ const result: CommentDraft = {
345
+ content: content(draft.value),
346
+ attachments: draft.images.map((i) =>
347
+ i.file
348
+ ? { kind: 'local', localId: i.id, file: i.file }
349
+ : {
350
+ kind: 'existing',
351
+ attachment: {
352
+ id: i.id,
353
+ kind: 'image',
354
+ name: i.name,
355
+ url: i.url,
356
+ width: i.width,
357
+ height: i.height,
358
+ },
359
+ },
360
+ ),
361
+ };
362
+ for (const n of result.content.nodes) {
363
+ if (n.type === 'link' && !latest.current.input?.links)
364
+ throw fail('当前接口不支持结构化链接');
365
+ if (
366
+ n.type === 'mention' &&
367
+ n.ref.kind === 'document' &&
368
+ !documents?.canInsert
369
+ )
370
+ throw fail('当前接口不支持插入文档引用');
371
+ if (n.type === 'unresolved-mention' || n.type === 'unsupported')
372
+ throw fail('请重新选择无法识别的引用');
373
+ if (
374
+ n.type === 'mention' &&
375
+ n.ref.kind === 'user' &&
376
+ !latest.current.input?.userMentions?.(n.ref as UserRef)
377
+ )
378
+ throw fail('当前接口不支持此人员引用');
379
+ }
380
+ const error = latest.current.validateDraft?.(result, { mode, ...target });
381
+ if (error) throw fail(error);
382
+ return result;
383
+ };
384
+ const accept = async (result: CommentWriteResult, target?: CommentTarget) => {
385
+ if (
386
+ target &&
387
+ (result.threadId !== target.threadId ||
388
+ result.messageId !== target.messageId)
389
+ )
390
+ throw Object.assign(new Error('写入目标不匹配'), { outcome: 'unknown' });
391
+ if (result.message && result.message.id !== result.messageId)
392
+ throw Object.assign(new Error('回读消息 ID 不匹配'), {
393
+ outcome: 'unknown',
394
+ });
395
+ return {
396
+ commentID: result.threadId,
397
+ replyID: result.messageId,
398
+ ...(result.message
399
+ ? { authoritativeReply: toMessage(result.message, result.threadId) }
400
+ : {}),
401
+ };
402
+ };
403
+ return (
404
+ <DiscussionProvider
405
+ panel={{
406
+ source,
407
+ currentUser: props.currentUser
408
+ ? toRef(props.currentUser.ref)
409
+ : undefined,
410
+ comments: mutations.view(base),
411
+ allowCompose: !!props.actions?.create,
412
+ optimisticMutations: true,
413
+ canMentionUser: (r) =>
414
+ r.kind === 'user' &&
415
+ Boolean(latest.current.input?.userMentions?.(fromRef(r) as UserRef)),
416
+ reactionEmojis: reactions?.options.allowedKeys
417
+ ? DEFAULT_REACTION_EMOJIS.filter((e) =>
418
+ reactions.options.allowedKeys!.includes(e.key),
419
+ )
420
+ : undefined,
421
+ showReactions: !!reactions,
422
+ allowReact: !!reactions?.options.onChange,
423
+ enableImages: images && !!props.actions?.create,
424
+ enableEditImages: images && !!props.actions?.update,
425
+ enableDocuments: documents?.canInsert ?? false,
426
+ submittedReplyID: submitted ? mutations.replyID(submitted) : undefined,
427
+ onScrolledToReply: () => setSubmitted(undefined),
428
+ onRetryMutation: (id) => mutations.retry(id),
429
+ autoScrollNewReplies: false,
430
+ listHeader: (
431
+ <ReadStatus
432
+ loading={props.loading}
433
+ error={props.error}
434
+ onRetry={props.onRetry}
435
+ empty={
436
+ !base.some((t) => t.replies.length) &&
437
+ !mutations.view(base).length
438
+ }
439
+ hasMore={props.pagination?.hasMore}
440
+ />
441
+ ),
442
+ listFooter: props.pagination ? (
443
+ <PaginationFooter
444
+ pagination={props.pagination}
445
+ loading={props.loading || !!props.error}
446
+ />
447
+ ) : undefined,
448
+ emptyState: null,
449
+ onMentionClick: (ref, anchor) => {
450
+ if (ref.kind === 'user')
451
+ props.onActorClick?.(fromRef(ref) as UserRef, anchor);
452
+ },
453
+ getCommentLink: (threadId, m) =>
454
+ props.getMessageLink?.({ threadId, messageId: m.replyID }) ?? '',
455
+ resolveUserRef: (id) => ({ kind: 'user', idType: 'generic', id }),
456
+ onSubmit: (draft) => {
457
+ if (
458
+ draft.commentID &&
459
+ !latest.current.threads.some(
460
+ (t) =>
461
+ t.id === draft.commentID && t.canReply !== false && !t.resolved,
462
+ )
463
+ )
464
+ throw fail('该讨论当前不可回复');
465
+ const input = readDraft(
466
+ draft,
467
+ 'create',
468
+ draft.commentID ? { threadId: draft.commentID } : {},
469
+ );
470
+ const ctx = context();
471
+ const id = mutations.add(
472
+ base.find((t) => t.commentID === draft.commentID),
473
+ draft,
474
+ props.currentUser ? toRef(props.currentUser.ref) : undefined,
475
+ async () =>
476
+ accept(
477
+ await latest.current.actions!.create!(
478
+ {
479
+ ...input,
480
+ ...(draft.commentID ? { threadId: draft.commentID } : {}),
481
+ },
482
+ ctx,
483
+ ),
484
+ ),
485
+ );
486
+ setSubmitted(id);
487
+ },
488
+ onEdit: (threadId, messageId, v, attachments) => {
489
+ const input = readDraft(
490
+ { value: v, images: attachments, private: false },
491
+ 'update',
492
+ { threadId, messageId },
493
+ );
494
+ const ctx = context();
495
+ mutations.edit(
496
+ base.find((t) => t.commentID === threadId)!,
497
+ messageId,
498
+ { value: v, images: attachments, private: false },
499
+ async () =>
500
+ accept(
501
+ await latest.current.actions!.update!(
502
+ { ...input, threadId, messageId },
503
+ ctx,
504
+ ),
505
+ { threadId, messageId },
506
+ ),
507
+ );
508
+ },
509
+ onDelete: (threadId, messageId) => {
510
+ if (!messageId) throw fail('未选择消息');
511
+ const ctx = context();
512
+ mutations.delete(
513
+ base.find((t) => t.commentID === threadId)!,
514
+ messageId,
515
+ async () => {
516
+ const r = await latest.current.actions!.remove!(
517
+ { threadId, messageId },
518
+ ctx,
519
+ );
520
+ return {
521
+ commentID: r.threadId,
522
+ ...(r.threadDeleted === undefined
523
+ ? {}
524
+ : { commentDeleted: r.threadDeleted }),
525
+ } as never;
526
+ },
527
+ );
528
+ },
529
+ onToggleReaction: async (threadId, messageId, emoji) => {
530
+ const target = { threadId, messageId };
531
+ const k = reactKey(target);
532
+ if (reactionBusy.current.has(k)) return;
533
+ const m = latest.current.threads
534
+ .find((t) => t.id === threadId)
535
+ ?.messages.find((m) => m.id === messageId);
536
+ if (
537
+ !m?.permissions?.react ||
538
+ !m.reactions ||
539
+ m.reactions.some((r) => r.mine === undefined) ||
540
+ !reactions?.options.onChange
541
+ )
542
+ throw fail('回应状态或权限未知');
543
+ const before = reactionOverlays.current.get(k)?.data ?? m.reactions;
544
+ const old = before.find((r) => r.key === emoji);
545
+ const action = old?.mine ? 'remove' : 'add';
546
+ const next = before.filter((r) => r.key !== emoji);
547
+ const count = (old?.count ?? 0) + (action === 'add' ? 1 : -1);
548
+ if (count > 0)
549
+ next.push({
550
+ key: emoji,
551
+ count,
552
+ mine: action === 'add',
553
+ previewUsers:
554
+ action === 'add' && props.currentUser
555
+ ? [
556
+ props.currentUser,
557
+ ...(old?.previewUsers ?? []).filter(
558
+ (u) => key(u.ref) !== key(props.currentUser!.ref),
559
+ ),
560
+ ]
561
+ : (old?.previewUsers ?? []).filter(
562
+ (u) =>
563
+ !props.currentUser ||
564
+ key(u.ref) !== key(props.currentUser.ref),
565
+ ),
566
+ });
567
+ reactionBusy.current.add(k);
568
+ reactionOverlays.current.set(k, {
569
+ data: next,
570
+ before,
571
+ confirmed: false,
572
+ key: emoji,
573
+ action,
574
+ });
575
+ setReactionVersion((v) => v + 1);
576
+ try {
577
+ const r = await reactions.options.onChange(
578
+ { ...target, key: emoji, action },
579
+ context(),
580
+ );
581
+ if (!abort.current.signal.aborted) {
582
+ reactionOverlays.current.set(k, {
583
+ data: r.reactions ?? next,
584
+ before,
585
+ confirmed: true,
586
+ key: emoji,
587
+ action,
588
+ });
589
+ setReactionVersion((v) => v + 1);
590
+ }
591
+ } catch (e) {
592
+ if (!abort.current.signal.aborted) {
593
+ if ((e as { outcome?: string })?.outcome === 'not-applied')
594
+ reactionOverlays.current.delete(k);
595
+ else
596
+ reactionOverlays.current.set(k, {
597
+ data: next,
598
+ before,
599
+ confirmed: true,
600
+ uncertain: true,
601
+ key: emoji,
602
+ action,
603
+ });
604
+ setReactionVersion((v) => v + 1);
605
+ }
606
+ throw e;
607
+ } finally {
608
+ if (!reactionOverlays.current.get(k)?.uncertain)
609
+ reactionBusy.current.delete(k);
610
+ }
611
+ },
612
+ }}
613
+ >
614
+ {props.children}
615
+ </DiscussionProvider>
616
+ );
617
+ }
618
+ export function CommentPanel(props: Omit<CommentsProviderProps, 'children'>) {
619
+ return (
620
+ <CommentsProvider {...props}>
621
+ <div className="replica-comment-panel">
622
+ <DiscussionLayout />
623
+ </div>
624
+ </CommentsProvider>
625
+ );
626
+ }
@@ -0,0 +1,165 @@
1
+ import type { ComponentPropsWithoutRef, ReactNode } from 'react';
2
+ export type EntityRef = {
3
+ kind: 'user' | 'document';
4
+ namespace: string;
5
+ id: string;
6
+ };
7
+ export type UserRef = EntityRef & { kind: 'user' };
8
+ export interface CommentAuthor {
9
+ ref: UserRef;
10
+ name?: string;
11
+ avatarUrl?: string;
12
+ }
13
+ export type CommentNode =
14
+ | { type: 'text'; text: string }
15
+ | { type: 'break' }
16
+ | { type: 'link'; url: string; label?: string }
17
+ | { type: 'mention'; ref: EntityRef; label?: string; href?: string }
18
+ | { type: 'unresolved-mention' }
19
+ | { type: 'unsupported'; label: string };
20
+ export interface CommentContent {
21
+ version: 1;
22
+ nodes: CommentNode[];
23
+ }
24
+ export interface CommentAttachment {
25
+ id: string;
26
+ kind: 'image' | 'file';
27
+ name: string;
28
+ url?: string;
29
+ mimeType?: string;
30
+ width?: number;
31
+ height?: number;
32
+ }
33
+ export type CommentAttachmentInput =
34
+ | { kind: 'existing'; attachment: CommentAttachment }
35
+ | { kind: 'local'; localId: string; file: File };
36
+ export interface CommentReaction {
37
+ key: string;
38
+ count: number;
39
+ mine?: boolean;
40
+ previewUsers?: CommentAuthor[];
41
+ }
42
+ export interface CommentMessage {
43
+ id: string;
44
+ author: CommentAuthor | null;
45
+ content: CommentContent;
46
+ createdAt: number;
47
+ updatedAt?: number;
48
+ edited?: boolean;
49
+ attachments?: CommentAttachment[];
50
+ reactions?: CommentReaction[];
51
+ permissions?: { edit: boolean; delete: boolean; react?: boolean };
52
+ }
53
+ export interface CommentPagination {
54
+ hasMore: boolean;
55
+ loading?: boolean;
56
+ error?: string;
57
+ onLoadMore(): Promise<void>;
58
+ }
59
+ export interface CommentThread {
60
+ id: string;
61
+ messages: CommentMessage[];
62
+ resolved?: boolean;
63
+ canReply?: boolean;
64
+ repliesPagination?: CommentPagination;
65
+ }
66
+ export interface CommentTarget {
67
+ threadId: string;
68
+ messageId: string;
69
+ }
70
+ export interface CommentDraft {
71
+ content: CommentContent;
72
+ attachments: CommentAttachmentInput[];
73
+ }
74
+ export interface CommentMutationContext {
75
+ operationId: string;
76
+ signal: AbortSignal;
77
+ }
78
+ export interface CommentWriteResult extends CommentTarget {
79
+ message?: CommentMessage;
80
+ }
81
+ export interface CommentActions {
82
+ create?(
83
+ input: CommentDraft & { threadId?: string },
84
+ context: CommentMutationContext,
85
+ ): Promise<CommentWriteResult>;
86
+ update?(
87
+ input: CommentDraft & CommentTarget,
88
+ context: CommentMutationContext,
89
+ ): Promise<CommentWriteResult>;
90
+ remove?(
91
+ target: CommentTarget,
92
+ context: CommentMutationContext,
93
+ ): Promise<{ threadId: string; threadDeleted?: boolean }>;
94
+ }
95
+ export interface CommentEntityOption {
96
+ ref: EntityRef;
97
+ label: string;
98
+ avatarUrl?: string;
99
+ description?: string;
100
+ href?: string;
101
+ readable?: boolean;
102
+ }
103
+ export type CommentEntityResult =
104
+ | (CommentEntityOption & { status: 'resolved' })
105
+ | { ref: EntityRef; status: 'forbidden' | 'not-found' | 'error' };
106
+ export interface CommentEntitySource {
107
+ search?(args: {
108
+ query: string;
109
+ cursor?: string;
110
+ signal: AbortSignal;
111
+ }): Promise<{ items: CommentEntityOption[]; nextCursor?: string }>;
112
+ recommend?(args: {
113
+ cursor?: string;
114
+ signal: AbortSignal;
115
+ }): Promise<{ items: CommentEntityOption[]; nextCursor?: string }>;
116
+ resolve(
117
+ refs: EntityRef[],
118
+ signal: AbortSignal,
119
+ ): Promise<CommentEntityResult[]>;
120
+ parseUrl?(url: string): EntityRef | undefined;
121
+ }
122
+ export interface CommentReactionFeature {
123
+ allowedKeys?: readonly string[];
124
+ onChange?(
125
+ input: CommentTarget & { key: string; action: 'add' | 'remove' },
126
+ context: CommentMutationContext,
127
+ ): Promise<{ reactions?: CommentReaction[] }>;
128
+ }
129
+ export type CommentExtension =
130
+ | { type: 'reactions'; options: CommentReactionFeature }
131
+ | { type: 'documents'; source: CommentEntitySource; canInsert: boolean }
132
+ | { type: 'images' };
133
+ export interface CommentsProviderProps {
134
+ scopeKey: string;
135
+ subjectKey: string;
136
+ threads: readonly CommentThread[];
137
+ currentUser?: CommentAuthor;
138
+ actions?: CommentActions;
139
+ loading?: boolean;
140
+ error?: string;
141
+ onRetry?: () => Promise<void>;
142
+ pagination?: CommentPagination;
143
+ actors?: CommentEntitySource;
144
+ input?: { userMentions?: (ref: UserRef) => boolean; links?: boolean };
145
+ extensions?: readonly CommentExtension[];
146
+ onActorClick?: (ref: UserRef, anchor: HTMLElement) => void;
147
+ getMessageLink?: (target: CommentTarget) => string | undefined;
148
+ validateDraft?: (
149
+ draft: CommentDraft,
150
+ target: {
151
+ mode: 'create' | 'update';
152
+ threadId?: string;
153
+ messageId?: string;
154
+ },
155
+ ) => string | undefined;
156
+ children: ReactNode;
157
+ }
158
+ export type CommentListProps = Omit<
159
+ ComponentPropsWithoutRef<'div'>,
160
+ 'children' | 'dangerouslySetInnerHTML'
161
+ >;
162
+ export type CommentComposerProps = CommentListProps & {
163
+ placeholder?: string;
164
+ disabled?: boolean;
165
+ };
@@ -0,0 +1,19 @@
1
+ export { CommentsProvider, CommentPanel } from './comments';
2
+ export { CommentList, CommentComposer } from '../record-comments/comment-panel';
3
+ export type * from './contracts';
4
+ import type {
5
+ CommentEntitySource,
6
+ CommentExtension,
7
+ CommentReactionFeature,
8
+ } from './contracts';
9
+ export const commentReactions = (
10
+ options: CommentReactionFeature = {},
11
+ ): CommentExtension => ({ type: 'reactions', options });
12
+ export const commentDocuments = ({
13
+ source,
14
+ canInsert = false,
15
+ }: {
16
+ source: CommentEntitySource;
17
+ canInsert?: boolean;
18
+ }): CommentExtension => ({ type: 'documents', source, canInsert });
19
+ export const commentImages = (): CommentExtension => ({ type: 'images' });
@@ -129,7 +129,7 @@ const extensions = useMemo(() => [
129
129
 
130
130
  ## 交互接入约束
131
131
 
132
- 宿主复用组件已有交互,不额外包装删除确认、成功 toast 或“已显示全部评论”提示。复制链接成功后只关闭菜单;编辑操作按钮保持“确认”,等待态不新增“保存中”文字。回复自己也会插入自身提及;未知作者不能伪造提及。保持人员搜索和提及悬浮样式,不用宿主全局 button/a/p 样式覆盖组件。
132
+ 宿主复用组件已有交互,不额外包装删除确认、成功 toast 或“已显示全部评论”提示。复制链接成功后只关闭菜单;编辑操作按钮保持“确认”,等待态不新增“保存中”文字。回复自己不自动插入本人提及;未知作者不能伪造提及。保持人员搜索和提及悬浮样式,不用宿主全局 button/a/p 样式覆盖组件。
133
133
 
134
134
  保留读写失败的恢复入口和无障碍状态;未知写入结果不得开放盲目重试。新增提示或改变状态流前,应取得官方对应状态的操作证据;本地受控故障测试不等同于真实 Action 验收,未覆盖的权限/网络/图片能力需明确标注。
135
135
 
@@ -8,7 +8,7 @@ import { LoaderCircle } from 'lucide-react';
8
8
  import { forwardRef, useCallback, useEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from 'react';
9
9
  import './internal/styles.css';
10
10
 
11
- function ReadStatus({ loading, error, onRetry, empty, hasMore }: Pick<CommentPanelProps, 'loading' | 'error' | 'onRetry'> & { empty: boolean; hasMore?: boolean }) {
11
+ export function ReadStatus({ loading, error, onRetry, empty, hasMore }: Pick<CommentPanelProps, 'loading' | 'error' | 'onRetry'> & { empty: boolean; hasMore?: boolean }) {
12
12
  const [failure, setFailure] = useState('');
13
13
  const [retrying, setRetrying] = useState(false);
14
14
  const busy = useRef(false);
@@ -41,7 +41,7 @@ function nearestScrollContainer(node: HTMLElement): HTMLElement | null {
41
41
  return null;
42
42
  }
43
43
 
44
- function PaginationFooter({ pagination, loading, getScrollContainer }: Pick<CommentPanelProps, 'pagination' | 'loading' | 'getScrollContainer'>) {
44
+ export function PaginationFooter({ pagination, loading, getScrollContainer }: Pick<CommentPanelProps, 'pagination' | 'loading' | 'getScrollContainer'>) {
45
45
  const sentinel = useRef<HTMLDivElement>(null);
46
46
  const root = useRef<HTMLElement | null>(null);
47
47
  const [gate] = useState(() => new CommentPageLoader());
@@ -1,6 +1,6 @@
1
1
  import type { CommentImageFeature, CommentReactionFeature } from './comment-panel.contract';
2
2
  import type { CommentDataSource, CommentDocumentSource, MentionResult } from './types';
3
- import { mentionKey } from './internal/model';
3
+ import { mentionKey, documentRefFromUrl } from './internal/model';
4
4
 
5
5
  /** Per-provider capabilities. No global registration, credentials or requests. */
6
6
  export type CommentExtension =
@@ -104,6 +104,7 @@ export function withDocumentSource(
104
104
  return {
105
105
  ...people,
106
106
  scopeKey: JSON.stringify([people.scopeKey, documents.scopeKey]),
107
+ parseLink: documents.parseLink ?? ((url) => { const ref = documentRefFromUrl(url); return ref ? { type: 'mention' as const, ref, href: url } : undefined; }),
107
108
  search: (args) => load(args, false),
108
109
  recommend: (args) => load({ ...args, query: '' }, true),
109
110
  async resolve(refs, signal) {
@@ -1,6 +1,6 @@
1
1
  import type { DiscussionReply, DiscussionImage, DiscussionPanelProps } from './view-types';
2
2
  import type { MentionRef } from '../types';
3
- import { COMMENT_THEME_CLASS, formatCommentTime, commentFailureMessage } from './model';
3
+ import { COMMENT_THEME_CLASS, formatCommentTime, commentFailureMessage, mentionKey } from './model';
4
4
  import { DiscussionIcon } from './icons';
5
5
  import { DiscussionImageViewer } from './images';
6
6
  import { DiscussionComposer } from './composer';
@@ -160,6 +160,7 @@ function ReplyItem({
160
160
  )}
161
161
  <div className="replica-reply-controls" hidden={editing}>
162
162
  {panel.showReactions !== false && canReact && <CommentReactionEntry
163
+ emojis={panel.reactionEmojis}
163
164
  disabled={reacting}
164
165
  onToggle={(key) => { void handleToggleReaction(key); }}
165
166
  onOpenChange={setPicker}
@@ -473,12 +474,14 @@ export const DiscussionList = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivE
473
474
  {panel.comments.map((thread) => (
474
475
  <div className="replica-thread" key={thread.commentID} data-thread-id={thread.commentID}>
475
476
  {thread.isSolved && <span className="replica-panel-solved">已解决</span>}
477
+ {thread.verificationPending && <div className="replica-panel-status" role="status" aria-label="正在核对讨论"><span>正在加载评论</span></div>}
476
478
  {thread.replies.map((comment, index) => (
477
479
  <ReplyItem key={comment.replyID} comment={comment} commentID={thread.commentID} child={index > 0}
478
- panel={itemPanel} replying={replyTarget?.commentID === thread.commentID && replyTarget.reply.replyID === comment.replyID}
480
+ panel={{...itemPanel, allowCompose: itemPanel.allowCompose && thread.canReply !== false}} replying={replyTarget?.commentID === thread.commentID && replyTarget.reply.replyID === comment.replyID}
479
481
  replyDisabled={composing} onReply={() => reply(thread.commentID, comment)}
480
482
  onPreviewImage={(images, index) => setGallery({ images, index })} />
481
483
  ))}
484
+ {thread.listFooter}
482
485
  </div>
483
486
  ))}
484
487
  {!replies.length && (panel.emptyState === undefined ? <div className="replica-empty">当前记录暂无评论</div> : panel.emptyState)}
@@ -495,9 +498,11 @@ export function DiscussionComposerView({ placeholder, disabled }: { placeholder?
495
498
  const replyPerson = useMentionEntity(replyTarget?.reply.author || { kind: 'user', idType: 'snapshot', id: 'unknown' });
496
499
  if (!allowCompose) return null;
497
500
  const name = replyTarget ? replyPerson?.status === 'resolved' ? replyPerson.label : '评论者' : undefined;
501
+ const author = replyTarget?.reply.author;
502
+ const mentionAuthor = author && (!panel.currentUser || mentionKey(author) !== mentionKey(panel.currentUser)) && (panel.canMentionUser?.(author) ?? true);
498
503
  return <DiscussionComposer sessionKey="new" commentID={replyTarget?.commentID}
499
504
  replyIntentKey={replyTarget ? `${replyTarget.commentID}:${replyTarget.reply.replyID}:${replyRevision}` : undefined}
500
- replyMention={replyTarget?.reply.author ? { type: 'mention', ref: replyTarget.reply.author,
505
+ replyMention={mentionAuthor && author ? { type: 'mention', ref: author,
501
506
  label: replyPerson?.status === 'resolved' ? replyPerson.label : undefined } : undefined}
502
507
  enableDocuments={panel.enableDocuments} enableImages={panel.enableImages} enablePrivateComments={panel.enablePrivateComments}
503
508
  replyName={name} placeholder={name ? `回复 ${name}...` : placeholder || '添加新的评论...'}
@@ -1,6 +1,6 @@
1
1
  import type { MentionOption, MentionRef, DocumentMentionMetadata, CommentEditorProps, CommentValue } from '../types';
2
2
  import type { EntityStore } from './mentions';
3
- import { INPUT_USER_MENTION_CLASS, userMentionLabelClass, atUserLabel, DOCUMENT_MENTION_CLASS, DOCUMENT_MENTION_UNREADABLE_CLASS, documentSnapshot, isMentionRef, mentionLabel, safeUrl, fromApiContent, readApiContent, fromEditorDocument, emptyComment, normalizeValue, toEditorDocument, toPlainText, COMMENT_THEME_CLASS } from './model';
3
+ import { INPUT_USER_MENTION_CLASS, userMentionLabelClass, atUserLabel, DOCUMENT_MENTION_CLASS, DOCUMENT_MENTION_UNREADABLE_CLASS, documentSnapshot, isMentionRef, mentionLabel, safeUrl, readApiContent, fromEditorDocument, emptyComment, normalizeValue, toEditorDocument, toPlainText, COMMENT_THEME_CLASS } from './model';
4
4
  import { documentEmoji, documentIconSvg } from './icons';
5
5
  import { Suggestion, type SuggestionProps, exitSuggestion } from '@tiptap/suggestion';
6
6
  import { Node, Extension } from '@tiptap/core';
@@ -502,9 +502,9 @@ export default function CommentEditor(props: CommentEditorProps) {
502
502
  const text = clipboardPlainText(data);
503
503
  if (text) {
504
504
  const pasted: CommentValue =
505
- /^https?:\/\/\S+$/.test(text) && safeUrl(text)
505
+ /^https?:\/\/\S+$/.test(text) && safeUrl(text) && store.source.allowLinks !== false
506
506
  ? props.enableDocuments
507
- ? fromApiContent([{ type: 'link', url: text }])
507
+ ? { version: 1, content: [store.source.parseLink?.(text) || { type: 'link', url: text }] }
508
508
  : { version: 1, content: [{ type: 'link', url: text }] }
509
509
  : { version: 1, content: [{ type: 'text', text }] };
510
510
  editor.commands.insertContent(
@@ -18,7 +18,7 @@ export function isMentionRef(value: unknown): value is MentionRef {
18
18
  return (
19
19
  typeof ref.id === 'string' &&
20
20
  ref.id.length > 0 &&
21
- ((ref.kind === 'user' && ['apaas-user', 'lark-user', 'snapshot'].includes(String(ref.idType))) ||
21
+ ((ref.kind === 'user' && ['apaas-user', 'lark-user', 'snapshot', 'generic'].includes(String(ref.idType))) ||
22
22
  (ref.kind === 'document' &&
23
23
  typeof ref.resourceType === 'string' &&
24
24
  ref.resourceType.length > 0))
@@ -1,7 +1,7 @@
1
1
  import type { MentionRef } from '../types';
2
2
  import type { DiscussionDraft, DiscussionImage, DiscussionReply, DiscussionThread } from './view-types';
3
3
  import type { CommentIDs, DeleteCommentResult, CommentMutationFailure } from '../comment-panel.contract';
4
- import { commentFailureMessage, normalizeValue, readApiContent } from './model';
4
+ import { commentFailureMessage, normalizeValue, mentionKey } from './model';
5
5
 
6
6
  export interface LocalCommentMutation {
7
7
  id: string;
@@ -17,14 +17,21 @@ interface Operation extends LocalCommentMutation {
17
17
  author?: MentionRef;
18
18
  createTime: number;
19
19
  wholeThread?: boolean;
20
+ deletionUnknown?: boolean;
21
+ authoritativeReply?: DiscussionReply;
20
22
  execute: () => Promise<CommentIDs | DeleteCommentResult>;
21
23
  }
22
- const contentKey = (reply: Pick<DiscussionReply, 'value'>) => JSON.stringify(readApiContent(reply.value));
24
+ const contentKey = (reply: Pick<DiscussionReply, 'value'>) => JSON.stringify(normalizeValue(reply.value).content.map(part =>
25
+ part.type === 'mention' ? part.ref.kind === 'document' && part.href ? ['link', part.href] : ['mention', mentionKey(part.ref)]
26
+ : part.type === 'link' ? ['link', part.url] : part.type === 'text' ? ['text', part.text] : [part.type]));
23
27
  const imageKey = (images: readonly DiscussionImage[]) => JSON.stringify(images.map(({ id, url }) => [id, url]));
24
28
  const uncertain = (message: string) => Object.assign(new Error(message), { outcome: 'unknown' as const });
25
29
 
26
30
  /** Component-local overlay. Never writes the host's data or performs a request itself. */
27
31
  export class CommentMutations {
32
+ private readonly options: { opaqueIDs?: boolean; unknownDeletion?: boolean; unknownErrors?: boolean; removePending?: boolean };
33
+ constructor(options: { opaqueIDs?: boolean; unknownDeletion?: boolean; unknownErrors?: boolean; removePending?: boolean } = {}) { this.options = options; }
34
+ private validID(id: unknown): id is string { return typeof id === 'string' && (this.options.opaqueIDs ? id.trim().length > 0 : /^\d+$/.test(id)); }
28
35
  private operations: Operation[] = [];
29
36
  private listeners = new Set<() => void>();
30
37
  private version = 0;
@@ -82,23 +89,27 @@ export class CommentMutations {
82
89
  try {
83
90
  const result = await operation.execute();
84
91
  if (!this.active) return;
85
- if (!/^\d+$/.test(result.commentID)) throw uncertain('提交结果缺少有效评论 ID');
92
+ if (!this.validID(result.commentID)) throw uncertain('提交结果缺少有效评论 ID');
86
93
  if (operation.kind !== 'add' || operation.draft?.commentID !== undefined) {
87
94
  if (result.commentID !== operation.commentID) throw uncertain('提交结果与目标讨论不一致');
88
95
  }
89
96
  if (operation.kind === 'delete') {
90
- if (!('commentDeleted' in result) || typeof result.commentDeleted !== 'boolean') throw uncertain('删除结果未确认');
91
- operation.wholeThread = result.commentDeleted;
97
+ if (!('commentDeleted' in result) || typeof result.commentDeleted !== 'boolean') {
98
+ if (!this.options.unknownDeletion) throw uncertain('删除结果未确认');
99
+ operation.deletionUnknown = true;
100
+ } else operation.wholeThread = result.commentDeleted;
92
101
  } else {
93
- if (!('replyID' in result) || !/^\d+$/.test(result.replyID)) throw uncertain('提交结果缺少有效回复 ID');
102
+ if (!('replyID' in result) || !this.validID(result.replyID)) throw uncertain('提交结果缺少有效回复 ID');
94
103
  if (operation.kind === 'edit' && result.replyID !== operation.replyID) throw uncertain('编辑结果与目标回复不一致');
95
104
  operation.replyID = result.replyID;
96
105
  }
106
+ if ('authoritativeReply' in result && result.authoritativeReply) operation.authoritativeReply = result.authoritativeReply as DiscussionReply;
97
107
  operation.commentID = result.commentID;
98
108
  operation.phase = 'confirmed';
99
109
  } catch (cause) {
100
110
  if (!this.active) return;
101
- operation.phase = (cause as CommentMutationFailure | null)?.outcome === 'unknown' ? 'uncertain' : 'failed';
111
+ const outcome = (cause as CommentMutationFailure | null)?.outcome;
112
+ operation.phase = outcome === 'unknown' || (this.options.unknownErrors && outcome !== 'not-applied') ? 'uncertain' : 'failed';
102
113
  operation.message = commentFailureMessage(cause, '操作失败,请重试');
103
114
  }
104
115
  this.changed();
@@ -125,7 +136,7 @@ export class CommentMutations {
125
136
  // Compare server revisions, never the optimistic client wall clock.
126
137
  if (operation.phase === 'confirmed' && reply.updateTime !== undefined &&
127
138
  reply.updateTime > (before.updateTime ?? before.createTime)) return true;
128
- if (contentKey(reply) !== contentKey({ value: operation.draft!.value })) return false;
139
+ if (contentKey(reply) !== contentKey(operation.authoritativeReply ?? { value: operation.draft!.value })) return false;
129
140
  const images = operation.draft!.images;
130
141
  if (imageKey(images) === imageKey(before.images)) return true;
131
142
  return images.some((image) => image.file)
@@ -143,9 +154,12 @@ export class CommentMutations {
143
154
  let threads = base.map((thread) => ({ ...thread, replies: [...thread.replies] }));
144
155
  for (const operation of this.operations) {
145
156
  let thread = threads.find((item) => item.commentID === operation.commentID);
146
- if (operation.kind === 'delete' && operation.phase === 'confirmed') {
157
+ if (operation.kind === 'delete' && (operation.phase === 'confirmed' || (this.options.removePending && operation.phase === 'sending'))) {
147
158
  if (operation.wholeThread) threads = threads.filter((item) => item !== thread);
148
- else if (thread) thread.replies = thread.replies.filter((reply) => reply.replyID !== operation.replyID);
159
+ else if (thread) {
160
+ thread.replies = thread.replies.filter((reply) => reply.replyID !== operation.replyID);
161
+ if (!thread.replies.length && (operation.deletionUnknown || operation.phase === 'sending')) thread.verificationPending = true;
162
+ }
149
163
  continue;
150
164
  }
151
165
  if (!thread) {
@@ -155,7 +169,7 @@ export class CommentMutations {
155
169
  const index = thread.replies.findIndex((item) => item.replyID === operation.replyID);
156
170
  const before = thread.replies[index] ?? operation.thread.replies.find((item) => item.replyID === operation.replyID);
157
171
  const mutation: LocalCommentMutation = { id: operation.id, kind: operation.kind, phase: operation.phase, message: operation.message };
158
- const reply: DiscussionReply = operation.kind === 'add'
172
+ const reply: DiscussionReply = operation.authoritativeReply ? {...operation.authoritativeReply, mutation} : operation.kind === 'add'
159
173
  ? { replyID: operation.replyID, author: operation.author ?? null, createTime: operation.createTime,
160
174
  value: operation.draft!.value, images: operation.draft!.images, reactions: [], mutation }
161
175
  : { ...before!, ...(operation.draft ? { value: operation.draft.value, images: operation.draft.images,
@@ -165,6 +179,6 @@ export class CommentMutations {
165
179
  if ((operation.kind === 'delete' && operation.wholeThread) || (operation.kind === 'add' && !operation.draft?.commentID))
166
180
  thread.replies = thread.replies.map((item) => ({ ...item, mutationBlocked: true }));
167
181
  }
168
- return threads.filter((thread) => thread.replies.length > 0);
182
+ return threads.filter((thread) => thread.replies.length > 0 || thread.verificationPending || thread.listFooter);
169
183
  }
170
184
  }
@@ -1,4 +1,4 @@
1
- import type { CommentReaction } from './reactions';
1
+ import type { CommentReaction, CommentEmoji } from './reactions';
2
2
  import type { CommentDataSource, CommentMentionClickHandler, CommentValue, MentionRef } from '../types';
3
3
  import type { LocalCommentMutation } from './mutations';
4
4
  import type { ReactNode } from 'react';
@@ -30,6 +30,9 @@ export interface DiscussionReply {
30
30
  reactions: CommentReaction[];
31
31
  }
32
32
  export interface DiscussionThread {
33
+ canReply?: boolean;
34
+ listFooter?: ReactNode;
35
+ verificationPending?: boolean;
33
36
  commentID: string;
34
37
  creator: MentionRef | null;
35
38
  createTime: number;
@@ -66,6 +69,7 @@ export interface DiscussionPanelProps {
66
69
  onRetryMutation?: (id: string) => void;
67
70
  allowReact?: boolean;
68
71
  showReactions?: boolean;
72
+ reactionEmojis?: readonly CommentEmoji[];
69
73
  enableImages?: boolean;
70
74
  enableEditImages?: boolean;
71
75
  enablePrivateComments?: boolean;
@@ -77,6 +81,7 @@ export interface DiscussionPanelProps {
77
81
  onMentionClick?: CommentMentionClickHandler;
78
82
  resolveUserRef?: (id: string) => MentionRef;
79
83
  getCommentLink?: (commentID: string, reply: DiscussionReply) => string;
84
+ canMentionUser?: (ref: MentionRef) => boolean;
80
85
  enableDocuments?: boolean;
81
86
  className?: string;
82
87
  submittedReplyID?: string;
@@ -1,7 +1,7 @@
1
1
  import type { Ref } from 'react';
2
2
 
3
3
  export type MentionRef =
4
- | { kind: 'user'; id: string; idType: 'apaas-user' | 'lark-user' | 'snapshot' }
4
+ | { kind: 'user'; id: string; idType: 'apaas-user' | 'lark-user' | 'snapshot' | 'generic' }
5
5
  | { kind: 'document'; id: string; resourceType: string };
6
6
 
7
7
  export interface MentionOption {
@@ -41,7 +41,7 @@ export type CommentPart =
41
41
  /** A host supplies authenticated document search and metadata, independently of user IDs. */
42
42
  export type CommentDocumentSource = Pick<
43
43
  CommentDataSource,
44
- 'scopeKey' | 'search' | 'resolve' | 'recommend'
44
+ 'scopeKey' | 'search' | 'resolve' | 'recommend' | 'parseLink'
45
45
  >;
46
46
 
47
47
  export interface CommentValue {
@@ -64,6 +64,9 @@ export type MentionKind = 'all' | 'user' | 'document';
64
64
  /** Keep this object stable. Change scopeKey when the user/tenant/app/permissions change. */
65
65
  export interface CommentDataSource {
66
66
  scopeKey: string;
67
+ /** Optional externally supplied URL-to-document reference conversion. */
68
+ allowLinks?: boolean;
69
+ parseLink?: (url: string) => CommentPart | undefined;
67
70
  /** Bare @ default list, independent of keyword search. Failures must reject. */
68
71
  recommend?: (args: {
69
72
  kind: MentionKind;