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

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,529 +0,0 @@
1
- import { emptyComment, normalizeValue, readApiContent, COMMENT_THEME_CLASS, commentFailureMessage } from './model';
2
- import type { CommentInputProps, CommentInputHandle, CommentEditorProps, CommentValue } from '../types';
3
- import { useMentionContext } from './mentions';
4
- import { CommentContent } from './content';
5
- import { cn } from '@/lib/utils';
6
- import { Button } from '@/components/ui/button';
7
- import { ErrorBoundary } from 'react-error-boundary';
8
- import * as React from 'react';
9
- import { useEffect, useImperativeHandle, useCallback, useRef, useState, type ComponentType } from 'react';
10
- import type { DiscussionDraft, DiscussionImage } from './view-types';
11
- import { COMMENT_IMAGE_ACCEPT, prepareCommentImage } from './images';
12
- import { DiscussionIcon } from './icons';
13
- import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
14
- import { LockKeyhole, X } from 'lucide-react';
15
-
16
- let modulePromise: Promise<typeof import('./editor')> | undefined;
17
- function loadEditor() {
18
- // Reset rejected imports so explicit retry actually executes import again.
19
- return (modulePromise ||= import('./editor').catch((error) => {
20
- modulePromise = undefined;
21
- throw error;
22
- }));
23
- }
24
- export function preloadCommentEditor() {
25
- void loadEditor().catch(() => {});
26
- }
27
-
28
- /** Public entry contains no static import of Tiptap. A session change resets all work. */
29
- export function CommentInput(props: CommentInputProps) {
30
- const { store } = useMentionContext();
31
- try {
32
- normalizeValue(props.value);
33
- } catch {
34
- return (
35
- <p role="alert" className="text-sm text-destructive">
36
- 评论内容格式无效,无法编辑
37
- </p>
38
- );
39
- }
40
- return (
41
- <ErrorBoundary
42
- key={JSON.stringify([store.source.scopeKey, props.documentKey])}
43
- fallbackRender={({ resetErrorBoundary }) => (
44
- <div
45
- role="alert"
46
- className="rounded-lg border p-3 text-sm text-destructive"
47
- >
48
- 编辑器发生错误,输入内容已保留。
49
- <Button
50
- type="button"
51
- variant="outline"
52
- size="sm"
53
- onClick={resetErrorBoundary}
54
- >
55
- 重新加载
56
- </Button>
57
- </div>
58
- )}
59
- >
60
- <InputSession {...props} />
61
- </ErrorBoundary>
62
- );
63
- }
64
-
65
- function InputSession(props: CommentInputProps) {
66
- const [Editor, setEditor] =
67
- React.useState<ComponentType<CommentEditorProps> | null>(null);
68
- const latest = useRef(props);
69
- latest.current = props;
70
- const editorRef = useRef<CommentInputHandle | null>(null);
71
- const editable = useRef(true);
72
- const [enabled, setEnabled] = useState(true);
73
- const loadingRef = useRef(false);
74
- const [loading, setLoading] = useState(false);
75
- const [error, setError] = useState('');
76
- const alive = useRef(true);
77
- useEffect(() => {
78
- alive.current = true;
79
- return () => {
80
- alive.current = false;
81
- };
82
- }, []);
83
- const activate = useCallback(async () => {
84
- if (loadingRef.current || latest.current.disabled || !editable.current)
85
- return;
86
- loadingRef.current = true;
87
- setLoading(true);
88
- setError('');
89
- try {
90
- const module = await loadEditor();
91
- if (alive.current) setEditor(() => module.default);
92
- } catch {
93
- if (alive.current) setError('编辑器加载失败,请重试');
94
- } finally {
95
- loadingRef.current = false;
96
- if (alive.current) setLoading(false);
97
- }
98
- }, []);
99
- useImperativeHandle(
100
- props.ref,
101
- () => ({
102
- getContent: () =>
103
- editorRef.current?.getContent() ?? readApiContent(latest.current.value),
104
- clear() {
105
- if (!alive.current) return;
106
- if (editorRef.current) editorRef.current.clear();
107
- else {
108
- const value = emptyComment();
109
- latest.current = { ...latest.current, value };
110
- latest.current.onChange(value);
111
- }
112
- },
113
- setEditable(next) {
114
- if (!alive.current) return;
115
- editable.current = next;
116
- editorRef.current?.setEditable(next && !latest.current.disabled);
117
- setEnabled(next);
118
- },
119
- focus() {
120
- if (!alive.current) return;
121
- if (editorRef.current) editorRef.current.focus();
122
- else void activate();
123
- },
124
- }),
125
- [activate],
126
- );
127
- const { ref: _ref, ...editorProps } = props;
128
- if (Editor)
129
- return (
130
- <Editor
131
- {...editorProps}
132
- disabled={props.disabled || !enabled}
133
- editorRef={editorRef}
134
- />
135
- );
136
- return (
137
- <div className={cn(COMMENT_THEME_CLASS, 'replica-input', props.className)}>
138
- <div className="relative flex min-h-[34px] flex-col justify-center rounded-[6px] border-[0.5px] border-border bg-card px-3 py-[5px] transition-colors hover:border-primary focus-within:border-primary">
139
- <div
140
- aria-hidden
141
- className="pointer-events-none w-full text-sm leading-[22px]"
142
- >
143
- {loading || error ? (
144
- <span className="text-muted-foreground">
145
- {loading ? '正在加载编辑器…' : '重试加载编辑器'}
146
- </span>
147
- ) : props.value.content.length ? (
148
- <CommentContent value={props.value} mentionVariant="pill" />
149
- ) : (
150
- <span className="text-muted-foreground">
151
- {props.placeholder || '输入评论'}
152
- </span>
153
- )}
154
- </div>
155
- <Button
156
- type="button"
157
- variant="ghost"
158
- className="no-default-hover-elevate no-default-active-elevate absolute inset-0 h-full min-h-0 w-full rounded-[6px] p-0"
159
- disabled={props.disabled || !enabled || loading}
160
- aria-busy={loading}
161
- onPointerEnter={preloadCommentEditor}
162
- onFocus={preloadCommentEditor}
163
- onClick={() => {
164
- void activate();
165
- }}
166
- >
167
- <span className="sr-only">
168
- {error ? '重试加载编辑器' : props.placeholder || '输入评论'}
169
- </span>
170
- </Button>
171
- </div>
172
- {error ? (
173
- <p role="alert" className="mt-2 text-xs text-destructive">
174
- {error}
175
- </p>
176
- ) : null}
177
- </div>
178
- );
179
- }
180
-
181
- export function DiscussionComposer({
182
- sessionKey,
183
- initialValue,
184
- initialImages = [],
185
- placeholder = '添加新的评论...',
186
- replyName,
187
- replyMention,
188
- replyIntentKey,
189
- commentID,
190
- onCancel,
191
- onSubmit,
192
- disabled,
193
- enableDocuments,
194
- editing = false,
195
- enableImages = true,
196
- enablePrivateComments = true,
197
- }: {
198
- sessionKey: string;
199
- initialValue?: CommentValue;
200
- initialImages?: DiscussionImage[];
201
- placeholder?: string;
202
- replyName?: string;
203
- replyIntentKey?: string;
204
- commentID?: string;
205
- replyMention?: Extract<CommentValue['content'][number], { type: 'mention' }>;
206
- onCancel?: () => void;
207
- onSubmit: (draft: DiscussionDraft) => Promise<void> | void;
208
- disabled?: boolean;
209
- enableDocuments?: boolean;
210
- editing?: boolean;
211
- enableImages?: boolean;
212
- enablePrivateComments?: boolean;
213
- }) {
214
- const [value, setValue] = useState<CommentValue>(
215
- initialValue || emptyComment,
216
- );
217
- const [images, setImages] = useState(initialImages);
218
- const [privateComment, setPrivate] = useState(false);
219
- const [sending, setSending] = useState(false);
220
- const [validating, setValidating] = useState(0);
221
- const [error, setError] = useState('');
222
- const input = useRef<CommentInputHandle>(null);
223
- const fileInput = useRef<HTMLInputElement>(null);
224
- const busy = useRef(false);
225
- const pending = useRef(0);
226
- const alive = useRef(true);
227
- const ownedUrls = useRef(new Set<string>());
228
- const ready =
229
- images.length > 0 ||
230
- value.content.some(
231
- (part) =>
232
- part.type === 'mention' ||
233
- part.type === 'link' ||
234
- (part.type === 'text' && part.text.trim()),
235
- );
236
- useEffect(() => {
237
- alive.current = true;
238
- return () => {
239
- alive.current = false;
240
- ownedUrls.current.forEach((url) => URL.revokeObjectURL(url));
241
- };
242
- }, []);
243
- useEffect(() => {
244
- if (editing || replyName) input.current?.focus();
245
- }, [editing, replyName]);
246
- const latestReplyMention = useRef(replyMention);
247
- latestReplyMention.current = replyMention;
248
- const latestValue = useRef(value);
249
- latestValue.current = value;
250
- const focusAfterReply = useRef(false);
251
- useEffect(() => {
252
- if (!replyIntentKey) return;
253
- const mention = latestReplyMention.current;
254
- const first = latestValue.current.content[0];
255
- if (
256
- mention &&
257
- !(first?.type === 'mention' && first.ref.id === mention.ref.id)
258
- ) {
259
- focusAfterReply.current = true;
260
- setValue((previous) => ({
261
- version: 1,
262
- content: [mention, { type: 'text', text: ' ' }, ...previous.content],
263
- }));
264
- } else input.current?.focus();
265
- }, [replyIntentKey]);
266
- useEffect(() => {
267
- if (focusAfterReply.current) {
268
- focusAfterReply.current = false;
269
- input.current?.focus();
270
- }
271
- }, [value]);
272
- const submit = async () => {
273
- if (!ready || busy.current || pending.current || disabled) return;
274
- busy.current = true;
275
- setSending(true);
276
- setError('');
277
- input.current?.setEditable(false);
278
- // The host owns successful attachments, including when submitting unmounts this editor.
279
- const transferred = images.filter((image) =>
280
- ownedUrls.current.delete(image.url),
281
- );
282
- try {
283
- await onSubmit({ value, images, private: privateComment, commentID });
284
- if (!alive.current) return;
285
- setImages([]);
286
- setValue(emptyComment());
287
- input.current?.clear();
288
- setPrivate(false);
289
- } catch (cause) {
290
- transferred.forEach((image) => {
291
- if (alive.current) ownedUrls.current.add(image.url);
292
- else URL.revokeObjectURL(image.url);
293
- });
294
- if (alive.current)
295
- setError(commentFailureMessage(cause, '发送失败,请重试'));
296
- } finally {
297
- busy.current = false;
298
- if (alive.current) {
299
- setSending(false);
300
- input.current?.setEditable(true);
301
- }
302
- }
303
- };
304
- const addFiles = async (files: FileList | File[]) => {
305
- if (!enableImages || disabled || busy.current) return;
306
- pending.current++;
307
- setValidating(pending.current);
308
- setError('');
309
- const result = await Promise.allSettled(
310
- Array.from(files).map((file) =>
311
- prepareCommentImage(file, ownedUrls.current),
312
- ),
313
- );
314
- const accepted = result.flatMap((item) =>
315
- item.status === 'fulfilled' ? [item.value] : [],
316
- );
317
- const failures = result.flatMap((item) =>
318
- item.status === 'rejected'
319
- ? [item.reason instanceof Error ? item.reason.message : '图片无法读取']
320
- : [],
321
- );
322
- pending.current--;
323
- if (!alive.current) {
324
- accepted.forEach((image) => {
325
- ownedUrls.current.delete(image.url);
326
- URL.revokeObjectURL(image.url);
327
- });
328
- return;
329
- }
330
- setImages((previous) => [...previous, ...accepted]);
331
- setError(failures.join(';'));
332
- setValidating(pending.current);
333
- input.current?.focus();
334
- };
335
- const removeImage = (image: DiscussionImage) => {
336
- if (ownedUrls.current.delete(image.url)) URL.revokeObjectURL(image.url);
337
- setImages((previous) => previous.filter((item) => item.id !== image.id));
338
- };
339
- const blocked = disabled || sending;
340
- return (
341
- <div
342
- className={`replica-composer-area ${editing ? 'replica-edit-area' : ''}`}
343
- onKeyDown={(event) => {
344
- // Escape may dismiss suggestions or a file chooser; it must not detach a reply.
345
- if (
346
- event.key === 'Escape' &&
347
- !event.defaultPrevented &&
348
- editing &&
349
- !busy.current
350
- )
351
- onCancel?.();
352
- }}
353
- >
354
- <div
355
- className={`replica-composer ${editing ? 'replica-composer-edit' : ''} ${privateComment ? 'replica-composer-private' : ''}`}
356
- onPasteCapture={(event) => {
357
- if (blocked || !enableImages) return;
358
- if (event.clipboardData.files.length) {
359
- event.preventDefault();
360
- event.stopPropagation();
361
- void addFiles(event.clipboardData.files);
362
- }
363
- }}
364
- onDragOver={(event) => {
365
- if (enableImages && event.dataTransfer.types.includes('Files'))
366
- event.preventDefault();
367
- }}
368
- onDropCapture={(event) => {
369
- if (enableImages && event.dataTransfer.files.length) {
370
- event.preventDefault();
371
- event.stopPropagation();
372
- if (!blocked) void addFiles(event.dataTransfer.files);
373
- }
374
- }}
375
- >
376
- <CommentInput
377
- ref={input}
378
- documentKey={sessionKey}
379
- value={value}
380
- onChange={setValue}
381
- placeholder={placeholder}
382
- disabled={blocked}
383
- enableDocuments={enableDocuments}
384
- submitMode="mod-enter"
385
- onSubmitRequest={() => {
386
- void submit();
387
- }}
388
- />
389
- {images.length > 0 && (
390
- <div className="replica-draft-images">
391
- {images.map((image) => (
392
- <div key={image.id} className="replica-draft-image">
393
- <img src={image.url} alt={image.name} />
394
- <button
395
- type="button"
396
- aria-label={`移除图片 ${image.name}`}
397
- disabled={sending}
398
- onClick={() => removeImage(image)}
399
- >
400
- <span>
401
- <X size={13} />
402
- </span>
403
- </button>
404
- </div>
405
- ))}
406
- </div>
407
- )}
408
- <div className="replica-composer-toolbar">
409
- {privateComment && (
410
- <span className="replica-private-hint">
411
- <LockKeyhole size={12} />
412
- 私有评论
413
- </span>
414
- )}
415
- <div className="replica-composer-tools">
416
- {enableImages && <><button
417
- type="button"
418
- className="replica-icon-button"
419
- title="添加图片"
420
- aria-label="添加图片"
421
- disabled={blocked}
422
- onClick={() => fileInput.current?.click()}
423
- >
424
- <DiscussionIcon name="image" width={16} height={16} />
425
- </button>
426
- <input
427
- ref={fileInput}
428
- type="file"
429
- hidden
430
- multiple
431
- accept={COMMENT_IMAGE_ACCEPT}
432
- onChange={(event) => {
433
- if (event.target.files) void addFiles(event.target.files);
434
- event.target.value = '';
435
- }}
436
- />
437
- </>}
438
- {enablePrivateComments && !replyName && !editing && (
439
- <Popover>
440
- <PopoverTrigger asChild>
441
- <button
442
- type="button"
443
- className="replica-icon-button"
444
- title="更多"
445
- aria-label="评论设置"
446
- disabled={blocked}
447
- >
448
- <DiscussionIcon name="more" />
449
- </button>
450
- </PopoverTrigger>
451
- <PopoverContent
452
- data-replica-comment-overlay
453
- side="top"
454
- align="end"
455
- sideOffset={8}
456
- className={`${COMMENT_THEME_CLASS} replica-private-menu`}
457
- >
458
- <div className="replica-private-heading">
459
- <LockKeyhole size={20} />
460
- <span>私有评论</span>
461
- <button
462
- type="button"
463
- className="replica-switch"
464
- role="switch"
465
- aria-label="私有评论"
466
- aria-checked={privateComment}
467
- onClick={() => setPrivate((previous) => !previous)}
468
- >
469
- <span />
470
- </button>
471
- </div>
472
- <p>
473
- 开启后,仅提及的协作者、多维表格所有者、有多维表格管理权限的用户、记录创建人和最近修改人可见。
474
- </p>
475
- </PopoverContent>
476
- </Popover>
477
- )}
478
- {!editing && (
479
- <>
480
- {(enableImages || (enablePrivateComments && !replyName)) && (
481
- <span className="replica-toolbar-divider" />
482
- )}
483
- <button
484
- type="button"
485
- className="replica-send-button"
486
- title="发送(⌘/Ctrl+Enter)"
487
- aria-label="发送评论"
488
- disabled={!ready || blocked || validating > 0}
489
- onClick={() => {
490
- void submit();
491
- }}
492
- >
493
- <DiscussionIcon name="send" width={16} height={16} />
494
- </button>
495
- </>
496
- )}
497
- </div>
498
- </div>
499
- </div>
500
- {editing && (
501
- <div className="replica-edit-actions">
502
- <button type="button" disabled={sending} onClick={onCancel}>
503
- 取消
504
- </button>
505
- <button
506
- type="button"
507
- aria-label="确认编辑"
508
- disabled={!ready || blocked || validating > 0}
509
- onClick={() => {
510
- void submit();
511
- }}
512
- >
513
- 确认
514
- </button>
515
- </div>
516
- )}
517
- {validating > 0 && (
518
- <span role="status" className="sr-only">
519
- 正在读取图片
520
- </span>
521
- )}
522
- {error && (
523
- <p className="replica-error" role="alert">
524
- {error}
525
- </p>
526
- )}
527
- </div>
528
- );
529
- }