@lark-apaas/coding-template-nestjs-react-fullstack 0.1.55-alpha.20261101083729 → 0.1.56

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 (25) hide show
  1. package/package.json +1 -1
  2. package/template/client/src/components/business-ui/README.md +0 -2
  3. package/template/package-lock.json +13 -529
  4. package/template/package.json +4 -15
  5. package/template/client/src/components/business-ui/record-comments/README.md +0 -83
  6. package/template/client/src/components/business-ui/record-comments/app-source.ts +0 -182
  7. package/template/client/src/components/business-ui/record-comments/assets/reaction-sprite.png +0 -0
  8. package/template/client/src/components/business-ui/record-comments/comment-panel.contract.ts +0 -223
  9. package/template/client/src/components/business-ui/record-comments/comment-panel.tsx +0 -202
  10. package/template/client/src/components/business-ui/record-comments/index.ts +0 -7
  11. package/template/client/src/components/business-ui/record-comments/internal/composer.tsx +0 -529
  12. package/template/client/src/components/business-ui/record-comments/internal/content.tsx +0 -213
  13. package/template/client/src/components/business-ui/record-comments/internal/discussion.tsx +0 -535
  14. package/template/client/src/components/business-ui/record-comments/internal/editor.tsx +0 -675
  15. package/template/client/src/components/business-ui/record-comments/internal/icons.tsx +0 -148
  16. package/template/client/src/components/business-ui/record-comments/internal/images.tsx +0 -155
  17. package/template/client/src/components/business-ui/record-comments/internal/mentions.tsx +0 -163
  18. package/template/client/src/components/business-ui/record-comments/internal/model.ts +0 -581
  19. package/template/client/src/components/business-ui/record-comments/internal/mutations.ts +0 -170
  20. package/template/client/src/components/business-ui/record-comments/internal/reaction-catalog.ts +0 -196
  21. package/template/client/src/components/business-ui/record-comments/internal/reactions.tsx +0 -681
  22. package/template/client/src/components/business-ui/record-comments/internal/styles.css +0 -1109
  23. package/template/client/src/components/business-ui/record-comments/internal/suggestions.tsx +0 -456
  24. package/template/client/src/components/business-ui/record-comments/internal/view-types.ts +0 -82
  25. package/template/client/src/components/business-ui/record-comments/types.ts +0 -118
@@ -1,535 +0,0 @@
1
- import type { DiscussionReply, DiscussionImage, DiscussionPanelProps } from './view-types';
2
- import type { MentionRef } from '../types';
3
- import { COMMENT_THEME_CLASS, formatCommentTime, commentFailureMessage } from './model';
4
- import { DiscussionIcon } from './icons';
5
- import { DiscussionImageViewer } from './images';
6
- import { DiscussionComposer } from './composer';
7
- import { CommentReactionEntry, CommentReactionList } from './reactions';
8
- import { CommentContent } from './content';
9
- import { MentionProvider, useMentionEntity } from './mentions';
10
- import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
11
- import { LockKeyhole } from 'lucide-react';
12
- import { useEffect, useRef, useState } from 'react';
13
-
14
- function PersonAvatar({
15
- refValue,
16
- small,
17
- onClick,
18
- }: {
19
- refValue: MentionRef;
20
- small?: boolean;
21
- onClick?: (anchor: HTMLElement) => void;
22
- }) {
23
- const person = useMentionEntity(refValue);
24
- return (
25
- <button
26
- type="button"
27
- className="replica-author-avatar"
28
- data-small={small || undefined}
29
- aria-label={`查看${person?.status === 'resolved' ? person.label : '评论者'}的人员卡片`}
30
- onClick={(event) => onClick?.(event.currentTarget)}
31
- >
32
- <svg viewBox="0 0 72 72" aria-hidden="true">
33
- <circle fill="#BBBFC4" cx="36" cy="36" r="36" />
34
- <path
35
- d="M30 37h12c5.523 0 12 4.286 12 12v4a2 2 0 01-2 2H20a2 2 0 01-2-2v-4c0-7.708 6.477-12 12-12zm6-2c-5.523 0-10-4.477-10-10s4.477-10 10-10 10 4.477 10 10-4.477 10-10 10z"
36
- fill="#FFF"
37
- />
38
- </svg>
39
- {person?.status === 'resolved' && person.avatarUrl && (
40
- <img src={person.avatarUrl} alt="" />
41
- )}
42
- </button>
43
- );
44
- }
45
- function CommentTime({ time, edited }: { time: number; edited?: boolean }) {
46
- const [, tick] = useState(0);
47
- useEffect(() => {
48
- const timer = window.setInterval(() => tick((value) => value + 1), 20000);
49
- return () => clearInterval(timer);
50
- }, []);
51
- // Derive from the current timestamp on every render; an edit must not wait
52
- // for the next relative-time timer before showing its new time.
53
- const label = formatCommentTime(time);
54
- return (
55
- <time
56
- className="replica-time"
57
- dateTime={new Date(time * 1000).toISOString()}
58
- title={new Date(time * 1000).toLocaleString('zh-CN')}
59
- >
60
- {label}
61
- {edited ? ' (编辑过)' : ''}
62
- </time>
63
- );
64
- }
65
- function ReplyItem({
66
- comment,
67
- commentID,
68
- child,
69
- panel,
70
- replying,
71
- onReply,
72
- onPreviewImage,
73
- replyDisabled,
74
- }: {
75
- comment: DiscussionReply;
76
- commentID: string;
77
- child: boolean;
78
- panel: DiscussionPanelProps;
79
- replying: boolean;
80
- onReply: () => void;
81
- onPreviewImage: (images: DiscussionImage[], index: number) => void;
82
- replyDisabled?: boolean;
83
- }) {
84
- const person = useMentionEntity(comment.author || { kind: 'user', idType: 'snapshot', id: 'unknown' });
85
- const [editing, setEditing] = useState(false);
86
- const [editedLocally, setEditedLocally] = useState(false);
87
- const [menu, setMenu] = useState(false);
88
- const [picker, setPicker] = useState(false);
89
- const [error, setError] = useState('');
90
- const reactionBusy = useRef(false);
91
- const deleteBusy = useRef(false);
92
- const [reacting, setReacting] = useState(false);
93
- const owned = Boolean(
94
- panel.currentUser && comment.author &&
95
- [panel.currentUser.id, ...(panel.currentUserAliases || [])].includes(
96
- comment.author.id,
97
- ),
98
- );
99
- const mutation = comment.mutation;
100
- const mutationBlocked = Boolean(mutation || comment.mutationBlocked);
101
- const pendingAdd = mutation?.kind === 'add' && mutation.phase === 'sending';
102
- const [showPending, setShowPending] = useState(false);
103
- useEffect(() => {
104
- setShowPending(false);
105
- if (!pendingAdd) return;
106
- const timer = setTimeout(() => setShowPending(true), 500);
107
- return () => clearTimeout(timer);
108
- }, [mutation?.id, pendingAdd]);
109
- const mutationFailed = mutation?.phase === 'failed' || mutation?.phase === 'uncertain';
110
- const mutationError = /network|timeout|网络|超时|结果未知/i.test(mutation?.message ?? '')
111
- ? '网络异常,请稍后重试' : /权限/.test(mutation?.message ?? '') ? '暂无权限评论此文档' : mutation?.kind === 'edit' ? '评论修改失败' : mutation?.kind === 'delete' ? '评论删除失败' : '评论发送失败';
112
- const canEdit = !mutationBlocked && (comment.permissions?.canEdit ?? owned);
113
- const canDelete = !mutationBlocked && (comment.permissions?.canDelete ?? owned);
114
- const canCopyLink = !mutationBlocked && (comment.permissions?.canCopyLink ?? owned);
115
- useEffect(() => { if (!canEdit) setEditing(false); }, [canEdit]);
116
- const canCompose = !mutationBlocked && (panel.allowCompose ?? Boolean(panel.currentUser));
117
- const canReact = !mutationBlocked && (comment.permissions?.canReact ?? true) && (panel.allowReact ?? Boolean(panel.currentUser));
118
- const handleToggleReaction = async (key: string) => {
119
- if (reactionBusy.current || !canReact) return;
120
- reactionBusy.current = true;
121
- setReacting(true);
122
- setError('');
123
- try { await panel.onToggleReaction(commentID, comment.replyID, key); }
124
- catch (cause) { setError(commentFailureMessage(cause, '回应失败,请重试')); }
125
- finally { reactionBusy.current = false; setReacting(false); }
126
- };
127
- const handleProfile = (anchor: HTMLElement) =>
128
- comment.author && panel.onMentionClick?.(comment.author, anchor);
129
- return (
130
- <article
131
- className={`replica-reply ${child ? 'replica-reply-child' : ''} ${replying || editing ? 'replica-reply-target' : ''}`}
132
- tabIndex={0}
133
- data-comment-id={commentID}
134
- data-reply-id={comment.replyID}
135
- data-tools-open={menu || picker || undefined}
136
- data-mutation-state={mutation?.phase}
137
- aria-busy={mutation?.phase === 'sending' || undefined}
138
- >
139
- <PersonAvatar
140
- refValue={comment.author || { kind: 'user', idType: 'snapshot', id: 'unknown' }}
141
- small={child}
142
- onClick={handleProfile}
143
- />
144
- <div className="replica-reply-main">
145
- <div className="replica-reply-info">
146
- <button
147
- type="button"
148
- className="replica-author-name"
149
- onClick={(event) => handleProfile(event.currentTarget)}
150
- >
151
- {person?.status === 'resolved' ? person.label : comment.author && !person ? '加载中…' : '未知用户'}
152
- </button>
153
- {mutation?.phase !== 'sending' && !mutationFailed && <CommentTime time={comment.updateTime ?? comment.createTime} edited={comment.edited || editedLocally} />}
154
- {comment.private && (
155
- <LockKeyhole
156
- size={12}
157
- className="replica-private-marker"
158
- aria-label="私有评论"
159
- />
160
- )}
161
- <div className="replica-reply-controls" hidden={editing}>
162
- {panel.showReactions !== false && canReact && <CommentReactionEntry
163
- disabled={reacting}
164
- onToggle={(key) => { void handleToggleReaction(key); }}
165
- onOpenChange={setPicker}
166
- />}
167
- {canCompose && <button
168
- type="button"
169
- className="replica-icon-button"
170
- aria-label="回复评论"
171
- title="回复"
172
- disabled={replyDisabled}
173
- onClick={onReply}
174
- >
175
- <DiscussionIcon name="reply" />
176
- </button>}
177
- {(canEdit || canDelete || canCopyLink) && (
178
- <Popover open={menu} onOpenChange={setMenu}>
179
- <PopoverTrigger asChild>
180
- <button
181
- type="button"
182
- className="replica-icon-button"
183
- aria-label="更多评论操作"
184
- title="更多"
185
- >
186
- <DiscussionIcon name="more" />
187
- </button>
188
- </PopoverTrigger>
189
- <PopoverContent
190
- align="end"
191
- sideOffset={6}
192
- data-replica-comment-overlay
193
- className={`${COMMENT_THEME_CLASS} replica-action-menu`}
194
- >
195
- <div
196
- role="menu"
197
- aria-label="评论操作"
198
- onKeyDown={(event) => {
199
- if (
200
- !['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(
201
- event.key,
202
- )
203
- )
204
- return;
205
- const items = Array.from(
206
- event.currentTarget.querySelectorAll<HTMLButtonElement>(
207
- '[role="menuitem"]',
208
- ),
209
- );
210
- const index = items.indexOf(
211
- event.target as HTMLButtonElement,
212
- );
213
- const next =
214
- event.key === 'Home'
215
- ? 0
216
- : event.key === 'End'
217
- ? items.length - 1
218
- : (index +
219
- (event.key === 'ArrowDown' ? 1 : -1) +
220
- items.length) %
221
- items.length;
222
- event.preventDefault();
223
- items[next]?.focus();
224
- }}
225
- >
226
- {canEdit && (
227
- <button
228
- type="button"
229
- role="menuitem"
230
- onClick={() => {
231
- setMenu(false);
232
- setEditing(true);
233
- }}
234
- >
235
- 编辑
236
- </button>
237
- )}
238
- {canCopyLink && (
239
- <button
240
- type="button"
241
- role="menuitem"
242
- onClick={async () => {
243
- try {
244
- const fallback = new URL(window.location.href);
245
- fallback.searchParams.set('comment_id', commentID);
246
- fallback.searchParams.set('reply_id', comment.replyID);
247
- const link =
248
- panel.getCommentLink?.(commentID, comment) ||
249
- comment.link ||
250
- fallback.href;
251
- await navigator.clipboard.writeText(link);
252
- setMenu(false);
253
- } catch {
254
- setError('无法访问剪贴板,请重试复制链接');
255
- }
256
- }}
257
- >
258
- 复制链接
259
- </button>
260
- )}
261
- {canDelete && (
262
- <button
263
- type="button"
264
- role="menuitem"
265
- onClick={async () => {
266
- if (deleteBusy.current || !canDelete) return;
267
- deleteBusy.current = true;
268
- setMenu(false);
269
- setError('');
270
- try { await panel.onDelete(commentID, comment.replyID); }
271
- catch (cause) { setError(commentFailureMessage(cause, '删除失败,请重试')); }
272
- finally { deleteBusy.current = false; }
273
- }}
274
- >
275
- 删除
276
- </button>
277
- )}
278
- </div>
279
- </PopoverContent>
280
- </Popover>
281
- )}
282
- </div>
283
- </div>
284
- {editing ? (
285
- <DiscussionComposer
286
- key={comment.replyID}
287
- sessionKey={`edit:${comment.replyID}`}
288
- editing
289
- initialValue={comment.value}
290
- initialImages={comment.images}
291
- onCancel={() => setEditing(false)}
292
- enableDocuments={panel.enableDocuments}
293
- enableImages={panel.enableEditImages ?? panel.enableImages}
294
- enablePrivateComments={panel.enablePrivateComments}
295
- onSubmit={async (draft) => {
296
- await panel.onEdit(commentID, comment.replyID, draft.value, draft.images);
297
- if (!panel.optimisticMutations) setEditedLocally(true);
298
- setEditing(false);
299
- }}
300
- />
301
- ) : (
302
- <>
303
- <CommentContent
304
- value={comment.value}
305
- className="replica-reply-body"
306
- onMentionClick={panel.onMentionClick}
307
- trailing={pendingAdd && showPending && !comment.images.length ? (
308
- <span className="replica-mutation-loading" role="status" aria-label="正在提交评论">
309
- <svg viewBox="0 0 100 100" aria-hidden="true">
310
- <circle cx="50" cy="50" r="35" stroke="currentColor" strokeWidth="11" strokeLinecap="round" fill="none" />
311
- </svg>
312
- </span>
313
- ) : undefined}
314
- />
315
- {comment.images.length > 0 && (
316
- <div className="replica-message-images">
317
- {comment.images.map((image, index) => (
318
- <button
319
- type="button"
320
- key={image.id}
321
- aria-label={`预览图片 ${image.name}`}
322
- onClick={() => onPreviewImage(comment.images, index)}
323
- >
324
- <img src={image.url} alt={image.name} />
325
- </button>
326
- ))}
327
- </div>
328
- )}
329
- <div className="replica-reactions">
330
- <CommentReactionList
331
- replyId={comment.replyID}
332
- reactions={comment.reactions}
333
- onToggle={
334
- canReact && !reacting
335
- ? (key) => { void handleToggleReaction(key); }
336
- : undefined
337
- }
338
- loadDetails={panel.loadReactionDetails
339
- ? (signal) => panel.loadReactionDetails!(commentID, comment.replyID, signal)
340
- : undefined}
341
- onUserClick={(id, anchor) =>
342
- panel.onMentionClick?.(
343
- panel.resolveUserRef?.(id) || {
344
- kind: 'user',
345
- idType: 'apaas-user',
346
- id,
347
- },
348
- anchor,
349
- )
350
- }
351
- />
352
- </div>
353
- </>
354
- )}
355
- {mutation && mutationFailed && <div className="replica-mutation-error" role="alert">
356
- <svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true">
357
- <path d="M8 16A8 8 0 118 0a8 8 0 010 16z" fill="currentColor" />
358
- <path d="M7 4l.48 6H8.5L9 4s.277-1-1-1C6.723 3 7 4 7 4z" fill="#fff" />
359
- <circle fill="#fff" cx="8" cy="12" r="1" />
360
- </svg>
361
- <span>{mutationError}{mutation.phase === 'failed' && <button type="button" onClick={() => panel.onRetryMutation?.(mutation.id)}>点击重试</button>}</span>
362
- </div>}
363
- {error && (
364
- <p role="alert" className="replica-error">
365
- {error}
366
- </p>
367
- )}
368
- </div>
369
- </article>
370
- );
371
- }
372
-
373
- function DiscussionPanelContent(panel: DiscussionPanelProps) {
374
- const [replyTarget, setReplyTarget] = useState<{ commentID: string; reply: DiscussionReply } | null>(
375
- null,
376
- );
377
- const [replyRevision, setReplyRevision] = useState(0);
378
- const [gallery, setGallery] = useState<{
379
- images: DiscussionImage[];
380
- index: number;
381
- } | null>(null);
382
- const [composing, setComposing] = useState(false);
383
- const composeBusy = useRef(false);
384
- const list = useRef<HTMLDivElement>(null);
385
- useEffect(() => {
386
- const locate = () => {
387
- const id = new URL(window.location.href).searchParams.get('reply_id');
388
- list.current
389
- ?.querySelectorAll('.replica-reply-linked')
390
- .forEach((element) => element.classList.remove('replica-reply-linked'));
391
- if (!id) return;
392
- const target = list.current?.querySelector<HTMLElement>(
393
- `[data-reply-id="${CSS.escape(id)}"]`,
394
- );
395
- if (target) {
396
- target.classList.add('replica-reply-linked');
397
- target.scrollIntoView({ block: 'nearest' });
398
- }
399
- };
400
- locate();
401
- window.addEventListener('popstate', locate);
402
- return () => window.removeEventListener('popstate', locate);
403
- }, []);
404
- const allowCompose = panel.allowCompose ?? Boolean(panel.currentUser);
405
- useEffect(() => { if (!allowCompose) setReplyTarget(null); }, [allowCompose]);
406
- const replies = panel.comments.flatMap((thread) => thread.replies);
407
- const previousIds = useRef(new Set(replies.map((item) => item.replyID)));
408
- const replyPerson = useMentionEntity(
409
- replyTarget
410
- ? replyTarget.reply.author || { kind: 'user', idType: 'snapshot', id: 'unknown' }
411
- : panel.currentUser || { kind: 'user', idType: 'snapshot', id: 'none' },
412
- );
413
- useEffect(() => {
414
- const added = replies.filter((item) => !previousIds.current.has(item.replyID));
415
- const newest = added.at(-1);
416
- if (newest && panel.autoScrollNewReplies !== false)
417
- list.current
418
- ?.querySelector(`[data-reply-id="${CSS.escape(newest.replyID)}"]`)
419
- ?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
420
- previousIds.current = new Set(replies.map((item) => item.replyID));
421
- if (
422
- gallery &&
423
- !replies.some((comment) =>
424
- comment.images.some((item) => item.id === gallery.images[gallery.index]?.id),
425
- )
426
- )
427
- setGallery(null);
428
- if (
429
- replyTarget &&
430
- !panel.comments.some((thread) => thread.commentID === replyTarget.commentID && thread.replies.some((reply) => reply.replyID === replyTarget.reply.replyID))
431
- )
432
- setReplyTarget(null);
433
- }, [panel.comments, replyTarget, gallery]);
434
- return (
435
- <section
436
- className={`${COMMENT_THEME_CLASS} replica-discussion ${panel.className || ''}`}
437
- aria-label="评论区域"
438
- >
439
- <header className="replica-discussion-header">
440
- 评论 ({replies.length})
441
- </header>
442
- <div className="replica-discussion-list" ref={list}>
443
- {panel.listHeader}
444
- {panel.comments.map((thread) => (
445
- <div className="replica-thread" key={thread.commentID} data-thread-id={thread.commentID}>
446
- {thread.isSolved && <span className="replica-panel-solved">已解决</span>}
447
- {thread.replies.map((comment, index) => (
448
- <ReplyItem
449
- key={comment.replyID}
450
- comment={comment}
451
- commentID={thread.commentID}
452
- child={index > 0}
453
- panel={panel}
454
- replying={replyTarget?.commentID === thread.commentID && replyTarget.reply.replyID === comment.replyID}
455
- replyDisabled={composing}
456
- onReply={() => {
457
- if (composeBusy.current) return;
458
- setReplyTarget({ commentID: thread.commentID, reply: comment });
459
- setReplyRevision((value) => value + 1);
460
- }}
461
- onPreviewImage={(images, index) => setGallery({ images, index })}
462
- />
463
- ))}
464
- </div>
465
- ))}
466
- {!replies.length && (
467
- panel.emptyState === undefined ? <div className="replica-empty">当前记录暂无评论</div> : panel.emptyState
468
- )}
469
- {panel.listFooter}
470
- </div>
471
- {allowCompose && <div className="replica-discussion-footer">
472
- <DiscussionComposer
473
- sessionKey="new"
474
- commentID={replyTarget?.commentID}
475
- replyIntentKey={
476
- replyTarget ? `${replyTarget.commentID}:${replyTarget.reply.replyID}:${replyRevision}` : undefined
477
- }
478
- replyMention={
479
- replyTarget?.reply.author
480
- ? {
481
- type: 'mention',
482
- ref: replyTarget.reply.author,
483
- label:
484
- replyPerson?.status === 'resolved'
485
- ? replyPerson.label
486
- : undefined,
487
- }
488
- : undefined
489
- }
490
- enableDocuments={panel.enableDocuments}
491
- enableImages={panel.enableImages}
492
- enablePrivateComments={panel.enablePrivateComments}
493
- replyName={
494
- replyTarget
495
- ? replyPerson?.status === 'resolved'
496
- ? replyPerson.label
497
- : '评论者'
498
- : undefined
499
- }
500
- placeholder={
501
- replyTarget
502
- ? `回复 ${replyPerson?.status === 'resolved' ? replyPerson.label : '评论者'}...`
503
- : '添加新的评论...'
504
- }
505
- onCancel={() => setReplyTarget(null)}
506
- onSubmit={async (draft) => {
507
- composeBusy.current = true;
508
- setComposing(true);
509
- try { await panel.onSubmit(draft); setReplyTarget(null); }
510
- finally { composeBusy.current = false; setComposing(false); }
511
- }}
512
- />
513
- </div>}
514
- {gallery && (
515
- <DiscussionImageViewer
516
- images={gallery.images}
517
- initialIndex={gallery.index}
518
- onClose={() => setGallery(null)}
519
- />
520
- )}
521
- </section>
522
- );
523
- }
524
- export function DiscussionPanel(props: DiscussionPanelProps) {
525
- return (
526
- <MentionProvider
527
- source={props.source}
528
- currentUserID={props.currentUser?.id}
529
- highlightUserIDs={props.highlightUserIDs}
530
- resolveUserRef={props.resolveUserRef}
531
- >
532
- <DiscussionPanelContent {...props} />
533
- </MentionProvider>
534
- );
535
- }