@lark-apaas/coding-template-nestjs-react-fullstack 0.1.56 → 0.1.57-alpha.20261103143319

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 (26) hide show
  1. package/package.json +1 -1
  2. package/template/client/src/components/business-ui/README.md +2 -0
  3. package/template/client/src/components/business-ui/record-comments/README.md +141 -0
  4. package/template/client/src/components/business-ui/record-comments/app-source.ts +93 -0
  5. package/template/client/src/components/business-ui/record-comments/assets/reaction-sprite.png +0 -0
  6. package/template/client/src/components/business-ui/record-comments/comment-panel.contract.ts +240 -0
  7. package/template/client/src/components/business-ui/record-comments/comment-panel.tsx +231 -0
  8. package/template/client/src/components/business-ui/record-comments/extensions.ts +129 -0
  9. package/template/client/src/components/business-ui/record-comments/index.ts +9 -0
  10. package/template/client/src/components/business-ui/record-comments/internal/composer.tsx +529 -0
  11. package/template/client/src/components/business-ui/record-comments/internal/content.tsx +213 -0
  12. package/template/client/src/components/business-ui/record-comments/internal/discussion.tsx +517 -0
  13. package/template/client/src/components/business-ui/record-comments/internal/editor.tsx +675 -0
  14. package/template/client/src/components/business-ui/record-comments/internal/icons.tsx +148 -0
  15. package/template/client/src/components/business-ui/record-comments/internal/images.tsx +155 -0
  16. package/template/client/src/components/business-ui/record-comments/internal/mentions.tsx +163 -0
  17. package/template/client/src/components/business-ui/record-comments/internal/model.ts +581 -0
  18. package/template/client/src/components/business-ui/record-comments/internal/mutations.ts +170 -0
  19. package/template/client/src/components/business-ui/record-comments/internal/reaction-catalog.ts +196 -0
  20. package/template/client/src/components/business-ui/record-comments/internal/reactions.tsx +681 -0
  21. package/template/client/src/components/business-ui/record-comments/internal/styles.css +1109 -0
  22. package/template/client/src/components/business-ui/record-comments/internal/suggestions.tsx +456 -0
  23. package/template/client/src/components/business-ui/record-comments/internal/view-types.ts +84 -0
  24. package/template/client/src/components/business-ui/record-comments/types.ts +118 -0
  25. package/template/package-lock.json +529 -13
  26. package/template/package.json +15 -4
@@ -0,0 +1,681 @@
1
+ import spriteUrl from '../assets/reaction-sprite.png';
2
+ import { DEFAULT_USED_REACTIONS, REACTION_CATALOG, REACTION_SPRITE_SIZE } from './reaction-catalog';
3
+ import { safeUrl, COMMENT_THEME_CLASS } from './model';
4
+ import { useMentionContext, useMentionEntity } from './mentions';
5
+ import { cn } from '@/lib/utils';
6
+ import { Image } from '@/components/ui/image';
7
+ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
8
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
9
+ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
10
+ import { LoaderCircle } from 'lucide-react';
11
+ import * as React from 'react';
12
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
13
+
14
+ /** Independent of reply.content; the page owns persistence and toggle direction. */
15
+ export interface CommentReaction {
16
+ key: string;
17
+ count: number;
18
+ mine: boolean;
19
+ /** Miaoda user IDs. This can be the server's ahead subset, not the full count. */
20
+ users: readonly string[];
21
+ }
22
+
23
+ export interface CommentEmoji {
24
+ key: string;
25
+ label: string;
26
+ group?: string;
27
+ /** Authenticated/custom assets are supplied by the host, not fetched via a Lark gateway here. */
28
+ url?: string;
29
+ }
30
+
31
+ const catalog = new Map(REACTION_CATALOG.map((entry) => [entry[0], entry]));
32
+ // Keep the server key for explicit add/delete, alias only its visual asset.
33
+ catalog.set('MediumThumbsup', catalog.get('THUMBSUP')!);
34
+ export const DEFAULT_REACTION_EMOJIS: readonly CommentEmoji[] =
35
+ REACTION_CATALOG.map(([key, label]) => ({ key, label }));
36
+
37
+ export function CommentReactionEmoji({
38
+ code,
39
+ size = 24,
40
+ emoji,
41
+ }: {
42
+ code: string;
43
+ size?: number;
44
+ emoji?: CommentEmoji;
45
+ }) {
46
+ const entry = catalog.get(code);
47
+ const label = emoji?.label || entry?.[1] || code;
48
+ if (emoji?.url && safeUrl(emoji.url))
49
+ return (
50
+ <Image
51
+ src={safeUrl(emoji.url)}
52
+ alt={label}
53
+ draggable={false}
54
+ className="replica-comment-reaction-emoji"
55
+ style={{ height: size, width: 'auto' }}
56
+ />
57
+ );
58
+ if (!entry)
59
+ return (
60
+ <span className="text-xs" role="img" aria-label={label}>
61
+ [{label}]
62
+ </span>
63
+ );
64
+ const [, , x, y, width, height] = entry;
65
+ const ratio = size / height;
66
+ return (
67
+ <span
68
+ role="img"
69
+ aria-label={label}
70
+ className="replica-comment-reaction-emoji"
71
+ style={{
72
+ width: width * ratio,
73
+ height: size,
74
+ backgroundImage: `url(${spriteUrl})`,
75
+ backgroundSize: `${REACTION_SPRITE_SIZE[0] * ratio}px ${REACTION_SPRITE_SIZE[1] * ratio}px`,
76
+ backgroundPosition: `${-x * ratio}px ${-y * ratio}px`,
77
+ }}
78
+ />
79
+ );
80
+ }
81
+
82
+ /** Same 14px default/thumbs-up icon as miaoda/replica-comment-reaction-icon.tsx. */
83
+ function ReactionIcon() {
84
+ return (
85
+ <svg
86
+ width="14"
87
+ height="14"
88
+ viewBox="0 0 14 14"
89
+ fill="none"
90
+ aria-hidden="true"
91
+ >
92
+ <path
93
+ d="M1.75 12.833h-.583a.29.29 0 01-.292-.291V4.958a.292.292 0 01.292-.291h.583a.292.292 0 01.292.291v7.584a.292.292 0 01-.292.291zm7-8.167h2.501c1.74 0 2.14 1.536 1.74 2.688l-1.74 4.582a1.237 1.237 0 01-1.196.902H3.204a.293.293 0 01-.292-.291v-7.59a.292.292 0 01.292-.291h.6a.583.583 0 00.477-.248L6.702.973c.164-.26.607-.46 1.078-.246.708.322 1.554 1.025 1.554 2.19 0 .44-.195 1.023-.585 1.749z"
94
+ fill="currentColor"
95
+ />
96
+ </svg>
97
+ );
98
+ }
99
+
100
+ /** Source hover timings: 200ms open/close, 400ms suppression after selection. */
101
+ function useReactionHover() {
102
+ const [open, setOpen] = useState(false);
103
+ const opening = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
104
+ const closing = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
105
+ const until = useRef(0);
106
+ const cancel = () => {
107
+ clearTimeout(opening.current);
108
+ clearTimeout(closing.current);
109
+ opening.current = undefined;
110
+ closing.current = undefined;
111
+ };
112
+ useEffect(() => cancel, []);
113
+ return {
114
+ open,
115
+ change(next: boolean) {
116
+ cancel();
117
+ if (!next || Date.now() >= until.current) setOpen(next);
118
+ },
119
+ enter() {
120
+ clearTimeout(closing.current);
121
+ if (Date.now() < until.current || open || opening.current) return;
122
+ opening.current = setTimeout(() => {
123
+ opening.current = undefined;
124
+ setOpen(true);
125
+ }, 200);
126
+ },
127
+ leave() {
128
+ cancel();
129
+ closing.current = setTimeout(() => setOpen(false), 200);
130
+ },
131
+ close() {
132
+ cancel();
133
+ until.current = Date.now() + 400;
134
+ setOpen(false);
135
+ },
136
+ };
137
+ }
138
+
139
+ function ReactionPanel({
140
+ emojis,
141
+ scopeKey,
142
+ onSelect,
143
+ }: {
144
+ emojis: readonly CommentEmoji[];
145
+ scopeKey: string;
146
+ onSelect: (key: string) => void;
147
+ }) {
148
+ const storageKey = `miaoda-comment-replica-reactions:${scopeKey}`;
149
+ const [recent, setRecent] = useState<string[]>(() => {
150
+ try {
151
+ const saved: unknown = JSON.parse(
152
+ localStorage.getItem(storageKey) || 'null',
153
+ );
154
+ if (Array.isArray(saved) && saved.every((key) => typeof key === 'string'))
155
+ return saved;
156
+ } catch {
157
+ /* Storage is optional; preserve the source default order when unavailable. */
158
+ }
159
+ return DEFAULT_USED_REACTIONS;
160
+ });
161
+ const byKey = new Map(emojis.map((emoji) => [emoji.key, emoji]));
162
+ const groups = new Map<string, CommentEmoji[]>();
163
+ for (const emoji of emojis) {
164
+ const title = emoji.group || '默认表情';
165
+ const group = groups.get(title) || [];
166
+ group.push(emoji);
167
+ groups.set(title, group);
168
+ }
169
+ const select = (key: string) => {
170
+ const next = [key, ...recent.filter((item) => item !== key)].slice(0, 30);
171
+ setRecent(next);
172
+ try {
173
+ localStorage.setItem(storageKey, JSON.stringify(next));
174
+ } catch {
175
+ /* Selection works without storage. */
176
+ }
177
+ onSelect(key);
178
+ };
179
+ const keydown = (event: React.KeyboardEvent<HTMLDivElement>) => {
180
+ if (
181
+ ![
182
+ 'ArrowLeft',
183
+ 'ArrowRight',
184
+ 'ArrowDown',
185
+ 'ArrowUp',
186
+ 'Home',
187
+ 'End',
188
+ ].includes(event.key)
189
+ )
190
+ return;
191
+ const group = (event.target as HTMLElement).closest('[data-reaction-grid]');
192
+ const buttons = Array.from(
193
+ group?.querySelectorAll<HTMLButtonElement>('button') || [],
194
+ );
195
+ if (!buttons.length) return;
196
+ const index = buttons.indexOf(event.target as HTMLButtonElement);
197
+ const delta =
198
+ { ArrowLeft: -1, ArrowRight: 1, ArrowDown: 9, ArrowUp: -9 }[event.key] ||
199
+ 0;
200
+ const next =
201
+ event.key === 'Home'
202
+ ? 0
203
+ : event.key === 'End'
204
+ ? buttons.length - 1
205
+ : (index + delta + buttons.length) % buttons.length;
206
+ event.preventDefault();
207
+ buttons[next]?.focus();
208
+ };
209
+ const item = (emoji: CommentEmoji, size = 24) => {
210
+ const entry = catalog.get(emoji.key);
211
+ const width = entry
212
+ ? size === 28
213
+ ? (entry[4] / entry[5]) * size
214
+ : Math.max(36, (entry[4] / entry[5]) * size + 4)
215
+ : size;
216
+ return (
217
+ <button
218
+ key={emoji.key}
219
+ type="button"
220
+ aria-label={emoji.label}
221
+ title={emoji.label}
222
+ className="replica-comment-reaction-choice"
223
+ style={{ minWidth: width }}
224
+ onClick={() => select(emoji.key)}
225
+ >
226
+ <CommentReactionEmoji code={emoji.key} size={size} emoji={emoji} />
227
+ </button>
228
+ );
229
+ };
230
+ return (
231
+ <div
232
+ className="replica-comment-reaction-panel"
233
+ onKeyDown={keydown}
234
+ onClick={(event) => event.stopPropagation()}
235
+ >
236
+ <div className="replica-comment-reaction-recent">
237
+ <div className="replica-comment-reaction-used-title">最近使用</div>
238
+ <div className="replica-comment-reaction-used-grid" data-reaction-grid>
239
+ {recent
240
+ .filter((key) => byKey.has(key))
241
+ .slice(0, 7)
242
+ .flatMap((key) => {
243
+ const emoji = byKey.get(key);
244
+ return emoji ? [item(emoji, 28)] : [];
245
+ })}
246
+ </div>
247
+ </div>
248
+ <div className="replica-comment-reaction-scroll">
249
+ {[...groups].map(([title, group]) => (
250
+ <React.Fragment key={title}>
251
+ <div className="replica-comment-reaction-all-title">{title}</div>
252
+ <div className="replica-comment-reaction-grid" data-reaction-grid>
253
+ {group.map((emoji) => item(emoji))}
254
+ </div>
255
+ </React.Fragment>
256
+ ))}
257
+ </div>
258
+ </div>
259
+ );
260
+ }
261
+
262
+ export function CommentReactionEntry({
263
+ onToggle,
264
+ onOpenChange,
265
+ disabled,
266
+ emojis = DEFAULT_REACTION_EMOJIS,
267
+ }: {
268
+ onToggle: (key: string) => void;
269
+ onOpenChange?: (open: boolean) => void;
270
+ disabled?: boolean;
271
+ emojis?: readonly CommentEmoji[];
272
+ }) {
273
+ const { store } = useMentionContext();
274
+ const hover = useReactionHover();
275
+ const keyboardOpen = useRef(false);
276
+ useEffect(() => {
277
+ onOpenChange?.(hover.open);
278
+ }, [hover.open, onOpenChange]);
279
+ useEffect(() => {
280
+ if (disabled) hover.change(false);
281
+ }, [disabled]);
282
+ return (
283
+ <Popover
284
+ open={!disabled && hover.open}
285
+ onOpenChange={hover.change}
286
+ modal={false}
287
+ >
288
+ <PopoverTrigger asChild>
289
+ <button
290
+ type="button"
291
+ aria-label="表情回复"
292
+ title="表情回复(点击点赞,悬停选择)"
293
+ disabled={disabled}
294
+ className={cn(
295
+ COMMENT_THEME_CLASS,
296
+ 'inline-flex size-[14px] items-center justify-center text-[var(--subtle-foreground)] hover:text-foreground disabled:text-muted-foreground',
297
+ )}
298
+ onMouseEnter={() => {
299
+ keyboardOpen.current = false;
300
+ hover.enter();
301
+ }}
302
+ onMouseLeave={hover.leave}
303
+ onKeyDown={(event) => {
304
+ if (event.key === 'ArrowDown') {
305
+ event.preventDefault();
306
+ keyboardOpen.current = true;
307
+ hover.change(true);
308
+ }
309
+ }}
310
+ onClick={(event) => {
311
+ event.preventDefault();
312
+ event.stopPropagation();
313
+ hover.close();
314
+ onToggle('THUMBSUP');
315
+ }}
316
+ >
317
+ <ReactionIcon />
318
+ </button>
319
+ </PopoverTrigger>
320
+ {hover.open && !disabled ? (
321
+ <PopoverContent
322
+ align="end"
323
+ side="bottom"
324
+ sideOffset={6}
325
+ data-replica-comment-overlay
326
+ data-reaction-picker
327
+ className={cn(
328
+ COMMENT_THEME_CLASS,
329
+ 'w-auto max-w-[calc(100vw-24px)] overflow-hidden rounded-lg border-[0.5px] p-0 data-[state=closed]:animate-none!',
330
+ )}
331
+ onOpenAutoFocus={(event) => {
332
+ event.preventDefault();
333
+ if (keyboardOpen.current && event.target instanceof HTMLElement)
334
+ event.target
335
+ .querySelector<HTMLButtonElement>('.replica-comment-reaction-choice')
336
+ ?.focus();
337
+ }}
338
+ onMouseEnter={hover.enter}
339
+ onMouseLeave={hover.leave}
340
+ >
341
+ <ReactionPanel
342
+ key={store.source.scopeKey}
343
+ scopeKey={store.source.scopeKey}
344
+ emojis={emojis}
345
+ onSelect={(key) => {
346
+ hover.close();
347
+ onToggle(key);
348
+ }}
349
+ />
350
+ </PopoverContent>
351
+ ) : null}
352
+ </Popover>
353
+ );
354
+ }
355
+
356
+ type UserClick = (userID: string, anchor: HTMLElement) => void;
357
+ function ReactionPerson({
358
+ userID,
359
+ onUserClick,
360
+ avatar = false,
361
+ comma = false,
362
+ }: {
363
+ userID: string;
364
+ onUserClick?: UserClick;
365
+ avatar?: boolean;
366
+ comma?: boolean;
367
+ }) {
368
+ const { resolveUserRef } = useMentionContext();
369
+ const entity = useMentionEntity(resolveUserRef?.(userID) || {
370
+ kind: 'user', idType: 'apaas-user', id: userID,
371
+ });
372
+ const name: string =
373
+ entity?.status === 'resolved'
374
+ ? entity.label
375
+ : entity?.status === 'not-found'
376
+ ? '用户不存在'
377
+ : '用户';
378
+ const avatarUrl =
379
+ entity?.status === 'resolved' ? safeUrl(entity.avatarUrl) : undefined;
380
+ return (
381
+ <button
382
+ type="button"
383
+ className={avatar ? 'replica-comment-reaction-person' : 'replica-comment-reaction-user'}
384
+ onClick={(event) => {
385
+ event.stopPropagation();
386
+ onUserClick?.(userID, event.currentTarget);
387
+ }}
388
+ >
389
+ {avatar ? (
390
+ <Avatar className="mr-2.5 size-8 shrink-0">
391
+ <AvatarImage src={avatarUrl} alt="" />
392
+ <AvatarFallback>{Array.from(name)[0]}</AvatarFallback>
393
+ </Avatar>
394
+ ) : null}
395
+ <span className="truncate" title={name}>
396
+ {name}
397
+ {comma ? ',' : ''}
398
+ </span>
399
+ </button>
400
+ );
401
+ }
402
+
403
+ function ReactionBadge({
404
+ reaction,
405
+ emoji,
406
+ onToggle,
407
+ onMore,
408
+ onUserClick,
409
+ }: {
410
+ reaction: CommentReaction;
411
+ emoji?: CommentEmoji;
412
+ onToggle?: (key: string) => void;
413
+ onMore: () => void;
414
+ onUserClick?: UserClick;
415
+ }) {
416
+ const badge = useRef<HTMLDivElement>(null);
417
+ const [visible, setVisible] = useState(reaction.users.length);
418
+ useLayoutEffect(() => {
419
+ const element = badge.current;
420
+ if (!element?.parentElement) return;
421
+ const measure = () => {
422
+ const names = Array.from(
423
+ element.querySelectorAll<HTMLElement>('[data-measure-user]'),
424
+ );
425
+ let available = Math.max(0, element.parentElement!.clientWidth - 41);
426
+ let count = 0;
427
+ const max = names.length > 6 ? 5 : names.length;
428
+ const moreWidth = reaction.count > max ? 50 : 0;
429
+ for (const name of names.slice(0, max)) {
430
+ const width = name.getBoundingClientRect().width + 5;
431
+ if (count && width + moreWidth > available) break;
432
+ available -= width;
433
+ count++;
434
+ }
435
+ setVisible(count);
436
+ };
437
+ const observer = new ResizeObserver(measure);
438
+ observer.observe(element.parentElement);
439
+ observer.observe(element);
440
+ const labels = element.querySelector('.replica-comment-reaction-measure');
441
+ if (labels) observer.observe(labels);
442
+ measure();
443
+ return () => observer.disconnect();
444
+ }, [reaction.users, reaction.count]);
445
+ const count = Math.min(visible, reaction.users.length);
446
+ const remaining = Math.max(0, reaction.count - count);
447
+ return (
448
+ <div
449
+ ref={badge}
450
+ className="replica-comment-reaction-badge"
451
+ data-mine={reaction.mine || undefined}
452
+ onClick={(event) => event.stopPropagation()}
453
+ >
454
+ <button
455
+ type="button"
456
+ className="shrink-0"
457
+ aria-label={`${reaction.mine ? '撤销' : '添加'}${emoji?.label || catalog.get(reaction.key)?.[1] || reaction.key}`}
458
+ disabled={!onToggle}
459
+ onClick={() => onToggle?.(reaction.key)}
460
+ >
461
+ <CommentReactionEmoji code={reaction.key} size={18} emoji={emoji} />
462
+ </button>
463
+ <span className="replica-comment-reaction-separator" />
464
+ <div className="replica-comment-reaction-users">
465
+ {reaction.users.slice(0, count).map((userID, index) => (
466
+ <ReactionPerson
467
+ key={userID}
468
+ userID={userID}
469
+ comma={index < reaction.count - 1}
470
+ onUserClick={onUserClick}
471
+ />
472
+ ))}
473
+ </div>
474
+ {remaining ? (
475
+ <button
476
+ type="button"
477
+ className="replica-comment-reaction-more"
478
+ onClick={onMore}
479
+ >
480
+ +{remaining} 人
481
+ </button>
482
+ ) : null}
483
+ <div className="replica-comment-reaction-measure" aria-hidden="true">
484
+ {reaction.users.map((userID) => (
485
+ <span key={userID} data-measure-user>
486
+ <ReactionPerson userID={userID} comma />
487
+ </span>
488
+ ))}
489
+ </div>
490
+ </div>
491
+ );
492
+ }
493
+
494
+ export function CommentReactionList({
495
+ replyId,
496
+ reactions,
497
+ onToggle,
498
+ onUserClick,
499
+ loadDetails,
500
+ emojis = DEFAULT_REACTION_EMOJIS,
501
+ }: {
502
+ replyId: string;
503
+ reactions: readonly CommentReaction[];
504
+ onToggle?: (key: string) => void;
505
+ onUserClick?: UserClick;
506
+ /** Fetch full data when the list contains only an ahead subset. */
507
+ loadDetails?: (signal: AbortSignal) => Promise<readonly CommentReaction[]>;
508
+ emojis?: readonly CommentEmoji[];
509
+ }) {
510
+ const { store } = useMentionContext();
511
+ const sessionKey = JSON.stringify([store.source.scopeKey, replyId]);
512
+ const [target, setTarget] = useState<{
513
+ sessionKey: string;
514
+ key: string;
515
+ } | null>(null);
516
+ const activeKey = target?.sessionKey === sessionKey ? target.key : null;
517
+ const setActiveKey = (key: string | null) =>
518
+ setTarget(key === null ? null : { sessionKey, key });
519
+ const [full, setFull] = useState<{
520
+ sessionKey: string;
521
+ data: readonly CommentReaction[];
522
+ } | null>(null);
523
+ const [error, setError] = useState('');
524
+ const [retry, setRetry] = useState(0);
525
+ const latest = useRef({ reactions, loadDetails });
526
+ latest.current = { reactions, loadDetails };
527
+ const open =
528
+ activeKey !== null && reactions.some((reaction) => reaction.count > 0);
529
+ useEffect(() => {
530
+ setFull(null);
531
+ setError('');
532
+ if (!open) return;
533
+ const controller = new AbortController();
534
+ const load = latest.current.loadDetails;
535
+ if (!load) {
536
+ setFull({ sessionKey, data: latest.current.reactions });
537
+ return;
538
+ }
539
+ void load(controller.signal)
540
+ .then((data) => {
541
+ if (!controller.signal.aborted) setFull({ sessionKey, data });
542
+ })
543
+ .catch((cause: unknown) => {
544
+ if (!controller.signal.aborted)
545
+ setError(cause instanceof Error ? cause.message : '加载失败,请重试');
546
+ });
547
+ return () => controller.abort();
548
+ }, [open, sessionKey, retry]);
549
+ const data = loadDetails
550
+ ? full?.sessionKey === sessionKey
551
+ ? full.data
552
+ : null
553
+ : reactions;
554
+ const selected =
555
+ data?.find((reaction) => reaction.key === activeKey) || data?.[0];
556
+ if (!reactions.length) return null;
557
+ return (
558
+ <>
559
+ <div
560
+ className={cn(COMMENT_THEME_CLASS, 'mt-1 flex flex-wrap gap-1')}
561
+ data-replica-comment-reactions
562
+ >
563
+ {reactions
564
+ .filter((reaction) => reaction.count > 0)
565
+ .map((reaction) => (
566
+ <ReactionBadge
567
+ key={reaction.key}
568
+ reaction={reaction}
569
+ emoji={emojis.find((emoji) => emoji.key === reaction.key)}
570
+ onToggle={onToggle}
571
+ onMore={() => setActiveKey(reaction.key)}
572
+ onUserClick={onUserClick}
573
+ />
574
+ ))}
575
+ </div>
576
+ <Dialog
577
+ open={open}
578
+ onOpenChange={(next) => {
579
+ if (!next) setActiveKey(null);
580
+ }}
581
+ >
582
+ <DialogContent
583
+ data-replica-comment-overlay
584
+ className={cn(
585
+ COMMENT_THEME_CLASS,
586
+ 'w-auto max-w-[calc(100vw-24px)] gap-0 p-0 sm:max-w-none',
587
+ )}
588
+ onClick={(event) => event.stopPropagation()}
589
+ onInteractOutside={(event) => {
590
+ if (
591
+ event.target instanceof Element &&
592
+ event.target.closest('[data-replica-comment-overlay]')
593
+ )
594
+ event.preventDefault();
595
+ }}
596
+ aria-describedby={undefined}
597
+ >
598
+ <DialogHeader className="px-6 pb-2 pt-6 text-left">
599
+ <DialogTitle className="text-base font-medium leading-6">
600
+ 回应详情
601
+ </DialogTitle>
602
+ </DialogHeader>
603
+ <div className="px-6 pb-6">
604
+ <div className="replica-comment-reaction-detail">
605
+ {error ? (
606
+ <div
607
+ role="alert"
608
+ className="flex h-full items-center justify-center gap-2 text-sm"
609
+ >
610
+ <span>{error}</span>
611
+ <button
612
+ type="button"
613
+ className="text-primary"
614
+ onClick={() => setRetry((value) => value + 1)}
615
+ >
616
+ 重试
617
+ </button>
618
+ </div>
619
+ ) : !data ? (
620
+ <div className="flex h-full items-center justify-center">
621
+ <LoaderCircle
622
+ className="size-5 animate-spin text-primary"
623
+ aria-label="正在加载回应人员"
624
+ />
625
+ </div>
626
+ ) : (
627
+ <>
628
+ <div
629
+ className="replica-comment-reaction-tabs"
630
+ role="tablist"
631
+ aria-label="回应表情"
632
+ >
633
+ {data.map((reaction) => (
634
+ <button
635
+ type="button"
636
+ role="tab"
637
+ key={reaction.key}
638
+ aria-selected={selected?.key === reaction.key}
639
+ onClick={() => setActiveKey(reaction.key)}
640
+ >
641
+ <CommentReactionEmoji
642
+ code={reaction.key}
643
+ size={20}
644
+ emoji={emojis.find(
645
+ (emoji) => emoji.key === reaction.key,
646
+ )}
647
+ />
648
+ <span>{reaction.count}</span>
649
+ </button>
650
+ ))}
651
+ </div>
652
+ <div
653
+ className="replica-comment-reaction-detail-border"
654
+ role="tabpanel"
655
+ aria-label="回应人员"
656
+ >
657
+ <div className="replica-comment-reaction-detail-users">
658
+ {selected?.users.map((userID) => (
659
+ <ReactionPerson
660
+ key={userID}
661
+ userID={userID}
662
+ avatar
663
+ onUserClick={onUserClick}
664
+ />
665
+ ))}
666
+ </div>
667
+ {selected && selected.count > selected.users.length ? (
668
+ <p className="p-3 text-xs text-[var(--subtle-foreground)]">
669
+ 当前仅显示部分回应人员。
670
+ </p>
671
+ ) : null}
672
+ </div>
673
+ </>
674
+ )}
675
+ </div>
676
+ </div>
677
+ </DialogContent>
678
+ </Dialog>
679
+ </>
680
+ );
681
+ }