@lark-apaas/coding-template-nestjs-react-fullstack 0.1.60-alpha.20261105142153 → 0.1.60-beta.0

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 (33) 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 +30 -546
  4. package/template/package.json +3 -14
  5. package/template/scripts/build.sh +39 -22
  6. package/template/scripts/prune-smart.js +20 -1
  7. package/template/client/src/components/business-ui/comments/README.md +0 -101
  8. package/template/client/src/components/business-ui/comments/comments.tsx +0 -670
  9. package/template/client/src/components/business-ui/comments/contracts.ts +0 -256
  10. package/template/client/src/components/business-ui/comments/index.ts +0 -20
  11. package/template/client/src/components/business-ui/comments/use-comments.ts +0 -492
  12. package/template/client/src/components/business-ui/record-comments/README.md +0 -141
  13. package/template/client/src/components/business-ui/record-comments/app-source.ts +0 -93
  14. package/template/client/src/components/business-ui/record-comments/assets/reaction-sprite.png +0 -0
  15. package/template/client/src/components/business-ui/record-comments/comment-panel.contract.ts +0 -240
  16. package/template/client/src/components/business-ui/record-comments/comment-panel.tsx +0 -231
  17. package/template/client/src/components/business-ui/record-comments/extensions.ts +0 -130
  18. package/template/client/src/components/business-ui/record-comments/index.ts +0 -9
  19. package/template/client/src/components/business-ui/record-comments/internal/composer.tsx +0 -531
  20. package/template/client/src/components/business-ui/record-comments/internal/content.tsx +0 -213
  21. package/template/client/src/components/business-ui/record-comments/internal/discussion.tsx +0 -523
  22. package/template/client/src/components/business-ui/record-comments/internal/editor.tsx +0 -681
  23. package/template/client/src/components/business-ui/record-comments/internal/icons.tsx +0 -148
  24. package/template/client/src/components/business-ui/record-comments/internal/images.tsx +0 -155
  25. package/template/client/src/components/business-ui/record-comments/internal/mentions.tsx +0 -163
  26. package/template/client/src/components/business-ui/record-comments/internal/model.ts +0 -581
  27. package/template/client/src/components/business-ui/record-comments/internal/mutations.ts +0 -198
  28. package/template/client/src/components/business-ui/record-comments/internal/reaction-catalog.ts +0 -196
  29. package/template/client/src/components/business-ui/record-comments/internal/reactions.tsx +0 -683
  30. package/template/client/src/components/business-ui/record-comments/internal/styles.css +0 -1109
  31. package/template/client/src/components/business-ui/record-comments/internal/suggestions.tsx +0 -456
  32. package/template/client/src/components/business-ui/record-comments/internal/view-types.ts +0 -91
  33. package/template/client/src/components/business-ui/record-comments/types.ts +0 -121
@@ -1,130 +0,0 @@
1
- import type { CommentImageFeature, CommentReactionFeature } from './comment-panel.contract';
2
- import type { CommentDataSource, CommentDocumentSource, MentionResult } from './types';
3
- import { mentionKey, documentRefFromUrl } from './internal/model';
4
-
5
- /** Per-provider capabilities. No global registration, credentials or requests. */
6
- export type CommentExtension =
7
- | { readonly type: 'reactions'; readonly options: CommentReactionFeature }
8
- | { readonly type: 'documents'; readonly source: CommentDocumentSource }
9
- | { readonly type: 'images'; readonly options: CommentImageFeature };
10
-
11
- export function commentReactions(options: CommentReactionFeature): CommentExtension {
12
- return { type: 'reactions', options };
13
- }
14
-
15
- export function commentDocuments(source: CommentDocumentSource): CommentExtension {
16
- return { type: 'documents', source };
17
- }
18
-
19
- /** Requires the host's upload/persistence/readback implementation; no Bitable image API is supplied. */
20
- export function commentImages(options: CommentImageFeature): CommentExtension {
21
- return { type: 'images', options };
22
- }
23
-
24
- /** Normalize registrations once per array. Never combine two implementations of the same capability. */
25
- export function commentExtensionFeatures(extensions: readonly CommentExtension[] = []) {
26
- const result: { reactions?: CommentReactionFeature; images?: CommentImageFeature; documents?: CommentDocumentSource } = {};
27
- const seen = new Set<string>();
28
- for (const extension of extensions) {
29
- if (seen.has(extension.type)) throw new Error(`Duplicate comment extension: ${extension.type}`);
30
- seen.add(extension.type);
31
- switch (extension.type) {
32
- case 'reactions': result.reactions = extension.options; break;
33
- case 'images': result.images = extension.options; break;
34
- case 'documents': result.documents = extension.source; break;
35
- default: throw new Error('Unsupported comment extension');
36
- }
37
- }
38
- return result;
39
- }
40
-
41
- /** Keep Miaoda people IDs and cloud-document tokens in their own services. */
42
- export function withDocumentSource(
43
- people: CommentDataSource,
44
- documents: CommentDocumentSource,
45
- ): CommentDataSource {
46
- const load = async (
47
- args: Parameters<CommentDataSource['search']>[0],
48
- recommend: boolean,
49
- ) => {
50
- const fetchSource = (
51
- source: CommentDataSource | CommentDocumentSource,
52
- kind: 'user' | 'document',
53
- cursor?: string,
54
- ) =>
55
- recommend && source.recommend
56
- ? source.recommend({ ...args, kind, cursor })
57
- : source.search({ ...args, kind, cursor });
58
- if (args.kind === 'user') return fetchSource(people, 'user', args.cursor);
59
- if (args.kind === 'document')
60
- return fetchSource(documents, 'document', args.cursor);
61
- const cursor: { user?: string; document?: string } = args.cursor
62
- ? JSON.parse(args.cursor)
63
- : {};
64
- if (
65
- !cursor ||
66
- typeof cursor !== 'object' ||
67
- [cursor.user, cursor.document].some(
68
- (value) => value !== undefined && typeof value !== 'string',
69
- )
70
- )
71
- throw new Error('分页参数无效');
72
- const exhausted: Awaited<ReturnType<CommentDataSource['search']>> = {
73
- items: [],
74
- };
75
- const requestUsers = !args.cursor || cursor.user !== undefined;
76
- const requestDocs = !args.cursor || cursor.document !== undefined;
77
- const pages = await Promise.allSettled([
78
- requestUsers
79
- ? fetchSource(people, 'user', cursor.user)
80
- : exhausted,
81
- requestDocs
82
- ? fetchSource(documents, 'document', cursor.document)
83
- : exhausted,
84
- ]);
85
- if ((!requestUsers || pages[0].status === 'rejected') && (!requestDocs || pages[1].status === 'rejected'))
86
- throw new Error('人员和文档搜索失败,请重试');
87
- // A disabled/unavailable document capability must not break existing @people.
88
- const users = pages[0].status === 'fulfilled' ? pages[0].value : exhausted;
89
- const docs = pages[1].status === 'fulfilled' ? pages[1].value : exhausted;
90
- return {
91
- items: [
92
- ...users.items.filter((item) => item.ref.kind === 'user'),
93
- ...docs.items.filter((item) => item.ref.kind === 'document'),
94
- ],
95
- nextCursor:
96
- users.nextCursor || docs.nextCursor
97
- ? JSON.stringify({
98
- user: users.nextCursor,
99
- document: docs.nextCursor,
100
- })
101
- : undefined,
102
- };
103
- };
104
- return {
105
- ...people,
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; }),
108
- search: (args) => load(args, false),
109
- recommend: (args) => load({ ...args, query: '' }, true),
110
- async resolve(refs, signal) {
111
- const users = refs.filter((ref) => ref.kind === 'user');
112
- const docs = refs.filter((ref) => ref.kind === 'document');
113
- // One failed service must not hide successful metadata from the other.
114
- const results = await Promise.allSettled([
115
- users.length ? people.resolve(users, signal) : [],
116
- docs.length ? documents.resolve(docs, signal) : [],
117
- ]);
118
- signal.throwIfAborted();
119
- const byKey = new Map<string, MentionResult>();
120
- for (const result of results)
121
- if (result.status === 'fulfilled')
122
- for (const item of result.value)
123
- byKey.set(mentionKey(item.ref), item);
124
- return refs.map(
125
- (ref) =>
126
- byKey.get(mentionKey(ref)) || { ref, status: 'error' as const },
127
- );
128
- },
129
- };
130
- }
@@ -1,9 +0,0 @@
1
- export { CommentPanel, CommentsProvider, CommentList, CommentComposer } from './comment-panel';
2
- export { createAppCommentSource } from './app-source';
3
- export { commentReactions, commentDocuments, commentImages, withDocumentSource } from './extensions';
4
- export type { CommentExtension } from './extensions';
5
- export type * from './comment-panel.contract';
6
- export type { CommentDataSource, CommentDocumentSource, CommentMentionClickHandler, MentionRef, MentionOption, MentionResult, MentionKind, DocumentMentionMetadata } from './types';
7
-
8
- // URL helpers for external document adapters; no requests or permissions inside.
9
- export { documentRefFromUrl, safeUrl } from './internal/model';
@@ -1,531 +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
- replyToMessageId,
191
- onCancel,
192
- onSubmit,
193
- disabled,
194
- enableDocuments,
195
- editing = false,
196
- enableImages = true,
197
- enablePrivateComments = true,
198
- }: {
199
- sessionKey: string;
200
- initialValue?: CommentValue;
201
- initialImages?: DiscussionImage[];
202
- placeholder?: string;
203
- replyName?: string;
204
- replyIntentKey?: string;
205
- commentID?: string;
206
- replyToMessageId?: string;
207
- replyMention?: Extract<CommentValue['content'][number], { type: 'mention' }>;
208
- onCancel?: () => void;
209
- onSubmit: (draft: DiscussionDraft) => Promise<void> | void;
210
- disabled?: boolean;
211
- enableDocuments?: boolean;
212
- editing?: boolean;
213
- enableImages?: boolean;
214
- enablePrivateComments?: boolean;
215
- }) {
216
- const [value, setValue] = useState<CommentValue>(
217
- initialValue || emptyComment,
218
- );
219
- const [images, setImages] = useState(initialImages);
220
- const [privateComment, setPrivate] = useState(false);
221
- const [sending, setSending] = useState(false);
222
- const [validating, setValidating] = useState(0);
223
- const [error, setError] = useState('');
224
- const input = useRef<CommentInputHandle>(null);
225
- const fileInput = useRef<HTMLInputElement>(null);
226
- const busy = useRef(false);
227
- const pending = useRef(0);
228
- const alive = useRef(true);
229
- const ownedUrls = useRef(new Set<string>());
230
- const ready =
231
- images.length > 0 ||
232
- value.content.some(
233
- (part) =>
234
- part.type === 'mention' ||
235
- part.type === 'link' ||
236
- (part.type === 'text' && part.text.trim()),
237
- );
238
- useEffect(() => {
239
- alive.current = true;
240
- return () => {
241
- alive.current = false;
242
- ownedUrls.current.forEach((url) => URL.revokeObjectURL(url));
243
- };
244
- }, []);
245
- useEffect(() => {
246
- if (editing || replyName) input.current?.focus();
247
- }, [editing, replyName]);
248
- const latestReplyMention = useRef(replyMention);
249
- latestReplyMention.current = replyMention;
250
- const latestValue = useRef(value);
251
- latestValue.current = value;
252
- const focusAfterReply = useRef(false);
253
- useEffect(() => {
254
- if (!replyIntentKey) return;
255
- const mention = latestReplyMention.current;
256
- const first = latestValue.current.content[0];
257
- if (
258
- mention &&
259
- !(first?.type === 'mention' && first.ref.id === mention.ref.id)
260
- ) {
261
- focusAfterReply.current = true;
262
- setValue((previous) => ({
263
- version: 1,
264
- content: [mention, { type: 'text', text: ' ' }, ...previous.content],
265
- }));
266
- } else input.current?.focus();
267
- }, [replyIntentKey]);
268
- useEffect(() => {
269
- if (focusAfterReply.current) {
270
- focusAfterReply.current = false;
271
- input.current?.focus();
272
- }
273
- }, [value]);
274
- const submit = async () => {
275
- if (!ready || busy.current || pending.current || disabled) return;
276
- busy.current = true;
277
- setSending(true);
278
- setError('');
279
- input.current?.setEditable(false);
280
- // The host owns successful attachments, including when submitting unmounts this editor.
281
- const transferred = images.filter((image) =>
282
- ownedUrls.current.delete(image.url),
283
- );
284
- try {
285
- await onSubmit({ value, images, private: privateComment, commentID, replyToMessageId });
286
- if (!alive.current) return;
287
- setImages([]);
288
- setValue(emptyComment());
289
- input.current?.clear();
290
- setPrivate(false);
291
- } catch (cause) {
292
- transferred.forEach((image) => {
293
- if (alive.current) ownedUrls.current.add(image.url);
294
- else URL.revokeObjectURL(image.url);
295
- });
296
- if (alive.current)
297
- setError(commentFailureMessage(cause, '发送失败,请重试'));
298
- } finally {
299
- busy.current = false;
300
- if (alive.current) {
301
- setSending(false);
302
- input.current?.setEditable(true);
303
- }
304
- }
305
- };
306
- const addFiles = async (files: FileList | File[]) => {
307
- if (!enableImages || disabled || busy.current) return;
308
- pending.current++;
309
- setValidating(pending.current);
310
- setError('');
311
- const result = await Promise.allSettled(
312
- Array.from(files).map((file) =>
313
- prepareCommentImage(file, ownedUrls.current),
314
- ),
315
- );
316
- const accepted = result.flatMap((item) =>
317
- item.status === 'fulfilled' ? [item.value] : [],
318
- );
319
- const failures = result.flatMap((item) =>
320
- item.status === 'rejected'
321
- ? [item.reason instanceof Error ? item.reason.message : '图片无法读取']
322
- : [],
323
- );
324
- pending.current--;
325
- if (!alive.current) {
326
- accepted.forEach((image) => {
327
- ownedUrls.current.delete(image.url);
328
- URL.revokeObjectURL(image.url);
329
- });
330
- return;
331
- }
332
- setImages((previous) => [...previous, ...accepted]);
333
- setError(failures.join(';'));
334
- setValidating(pending.current);
335
- input.current?.focus();
336
- };
337
- const removeImage = (image: DiscussionImage) => {
338
- if (ownedUrls.current.delete(image.url)) URL.revokeObjectURL(image.url);
339
- setImages((previous) => previous.filter((item) => item.id !== image.id));
340
- };
341
- const blocked = disabled || sending;
342
- return (
343
- <div
344
- className={`replica-composer-area ${editing ? 'replica-edit-area' : ''}`}
345
- onKeyDown={(event) => {
346
- // Escape may dismiss suggestions or a file chooser; it must not detach a reply.
347
- if (
348
- event.key === 'Escape' &&
349
- !event.defaultPrevented &&
350
- editing &&
351
- !busy.current
352
- )
353
- onCancel?.();
354
- }}
355
- >
356
- <div
357
- className={`replica-composer ${editing ? 'replica-composer-edit' : ''} ${privateComment ? 'replica-composer-private' : ''}`}
358
- onPasteCapture={(event) => {
359
- if (blocked || !enableImages) return;
360
- if (event.clipboardData.files.length) {
361
- event.preventDefault();
362
- event.stopPropagation();
363
- void addFiles(event.clipboardData.files);
364
- }
365
- }}
366
- onDragOver={(event) => {
367
- if (enableImages && event.dataTransfer.types.includes('Files'))
368
- event.preventDefault();
369
- }}
370
- onDropCapture={(event) => {
371
- if (enableImages && event.dataTransfer.files.length) {
372
- event.preventDefault();
373
- event.stopPropagation();
374
- if (!blocked) void addFiles(event.dataTransfer.files);
375
- }
376
- }}
377
- >
378
- <CommentInput
379
- ref={input}
380
- documentKey={sessionKey}
381
- value={value}
382
- onChange={setValue}
383
- placeholder={placeholder}
384
- disabled={blocked}
385
- enableDocuments={enableDocuments}
386
- submitMode="mod-enter"
387
- onSubmitRequest={() => {
388
- void submit();
389
- }}
390
- />
391
- {images.length > 0 && (
392
- <div className="replica-draft-images">
393
- {images.map((image) => (
394
- <div key={image.id} className="replica-draft-image">
395
- <img src={image.url} alt={image.name} />
396
- <button
397
- type="button"
398
- aria-label={`移除图片 ${image.name}`}
399
- disabled={sending}
400
- onClick={() => removeImage(image)}
401
- >
402
- <span>
403
- <X size={13} />
404
- </span>
405
- </button>
406
- </div>
407
- ))}
408
- </div>
409
- )}
410
- <div className="replica-composer-toolbar">
411
- {privateComment && (
412
- <span className="replica-private-hint">
413
- <LockKeyhole size={12} />
414
- 私有评论
415
- </span>
416
- )}
417
- <div className="replica-composer-tools">
418
- {enableImages && <><button
419
- type="button"
420
- className="replica-icon-button"
421
- title="添加图片"
422
- aria-label="添加图片"
423
- disabled={blocked}
424
- onClick={() => fileInput.current?.click()}
425
- >
426
- <DiscussionIcon name="image" width={16} height={16} />
427
- </button>
428
- <input
429
- ref={fileInput}
430
- type="file"
431
- hidden
432
- multiple
433
- accept={COMMENT_IMAGE_ACCEPT}
434
- onChange={(event) => {
435
- if (event.target.files) void addFiles(event.target.files);
436
- event.target.value = '';
437
- }}
438
- />
439
- </>}
440
- {enablePrivateComments && !replyName && !editing && (
441
- <Popover>
442
- <PopoverTrigger asChild>
443
- <button
444
- type="button"
445
- className="replica-icon-button"
446
- title="更多"
447
- aria-label="评论设置"
448
- disabled={blocked}
449
- >
450
- <DiscussionIcon name="more" />
451
- </button>
452
- </PopoverTrigger>
453
- <PopoverContent
454
- data-replica-comment-overlay
455
- side="top"
456
- align="end"
457
- sideOffset={8}
458
- className={`${COMMENT_THEME_CLASS} replica-private-menu`}
459
- >
460
- <div className="replica-private-heading">
461
- <LockKeyhole size={20} />
462
- <span>私有评论</span>
463
- <button
464
- type="button"
465
- className="replica-switch"
466
- role="switch"
467
- aria-label="私有评论"
468
- aria-checked={privateComment}
469
- onClick={() => setPrivate((previous) => !previous)}
470
- >
471
- <span />
472
- </button>
473
- </div>
474
- <p>
475
- 开启后,仅提及的协作者、多维表格所有者、有多维表格管理权限的用户、记录创建人和最近修改人可见。
476
- </p>
477
- </PopoverContent>
478
- </Popover>
479
- )}
480
- {!editing && (
481
- <>
482
- {(enableImages || (enablePrivateComments && !replyName)) && (
483
- <span className="replica-toolbar-divider" />
484
- )}
485
- <button
486
- type="button"
487
- className="replica-send-button"
488
- title="发送(⌘/Ctrl+Enter)"
489
- aria-label="发送评论"
490
- disabled={!ready || blocked || validating > 0}
491
- onClick={() => {
492
- void submit();
493
- }}
494
- >
495
- <DiscussionIcon name="send" width={16} height={16} />
496
- </button>
497
- </>
498
- )}
499
- </div>
500
- </div>
501
- </div>
502
- {editing && (
503
- <div className="replica-edit-actions">
504
- <button type="button" disabled={sending} onClick={onCancel}>
505
- 取消
506
- </button>
507
- <button
508
- type="button"
509
- aria-label="确认编辑"
510
- disabled={!ready || blocked || validating > 0}
511
- onClick={() => {
512
- void submit();
513
- }}
514
- >
515
- 确认
516
- </button>
517
- </div>
518
- )}
519
- {validating > 0 && (
520
- <span role="status" className="sr-only">
521
- 正在读取图片
522
- </span>
523
- )}
524
- {error && (
525
- <p className="replica-error" role="alert">
526
- {error}
527
- </p>
528
- )}
529
- </div>
530
- );
531
- }