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

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 (36) hide show
  1. package/package.json +1 -1
  2. package/template/client/src/components/business-ui/README.md +10 -0
  3. package/template/client/src/components/business-ui/api/user-profiles/queries.ts +1 -7
  4. package/template/client/src/components/business-ui/comment/README.md +139 -0
  5. package/template/client/src/components/business-ui/comment/app-source.ts +175 -0
  6. package/template/client/src/components/business-ui/comment/ccm-document-source.ts +252 -0
  7. package/template/client/src/components/business-ui/comment/comment-content.tsx +235 -0
  8. package/template/client/src/components/business-ui/comment/comment-input.tsx +191 -0
  9. package/template/client/src/components/business-ui/comment/comment-provider.tsx +40 -0
  10. package/template/client/src/components/business-ui/comment/content.ts +329 -0
  11. package/template/client/src/components/business-ui/comment/document-icon.tsx +104 -0
  12. package/template/client/src/components/business-ui/comment/document-icons.ts +19 -0
  13. package/template/client/src/components/business-ui/comment/dom-editor.ts +581 -0
  14. package/template/client/src/components/business-ui/comment/editor.tsx +496 -0
  15. package/template/client/src/components/business-ui/comment/entity-store.ts +97 -0
  16. package/template/client/src/components/business-ui/comment/image-attachments.tsx +374 -0
  17. package/template/client/src/components/business-ui/comment/mention-appearance.ts +35 -0
  18. package/template/client/src/components/business-ui/comment/mention-provider.tsx +73 -0
  19. package/template/client/src/components/business-ui/comment/mention-suggestions.tsx +312 -0
  20. package/template/client/src/components/business-ui/comment/types.ts +154 -0
  21. package/template/client/src/components/business-ui/comment/use-mention-search.ts +107 -0
  22. package/template/client/src/components/business-ui/user-display/user-display.tsx +0 -13
  23. package/template/client/src/components/business-ui/user-profile/user-profile.tsx +146 -397
  24. package/template/client/src/components/business-ui/user-select/user-pill.tsx +0 -13
  25. package/template/client/src/hooks/use-example.ts +45 -36
  26. package/template/client/src/pages/ExamplePage/ExamplePage.tsx +7 -8
  27. package/template/client/src/tailwind-theme.css +5 -6
  28. package/template/eslint.config.js +0 -6
  29. package/template/package-lock.json +94 -73
  30. package/template/package.json +5 -4
  31. package/template/server/modules/hello/hello.controller.ts +4 -8
  32. package/template/server/modules/hello/hello.service.ts +3 -5
  33. package/template/shared/api.interface.ts +1 -8
  34. package/template/client/src/components/business-ui/user-profile/lark-card-pool.ts +0 -217
  35. package/template/client/src/lib/api-error.ts +0 -36
  36. package/template/server/modules/hello/hello.dto.ts +0 -21
@@ -0,0 +1,374 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import * as React from 'react';
3
+ import { ImagePlus, LoaderCircle, RotateCcw, X } from 'lucide-react';
4
+ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
5
+ import { Checkbox } from '@/components/ui/checkbox';
6
+ import { safeUrl } from './content';
7
+ import type {
8
+ CommentImage,
9
+ CommentImageUploader,
10
+ CommentInputProps,
11
+ } from './types';
12
+
13
+ export const IMAGE_ACCEPT =
14
+ 'image/png,image/jpeg,image/webp,image/gif,image/avif,image/bmp';
15
+ const imageTypes = new Set(IMAGE_ACCEPT.split(','));
16
+
17
+ const defaultUpload: CommentImageUploader = async (file, { signal }) => {
18
+ signal.throwIfAborted();
19
+ const { uploadFile } = await import('../api/files/service');
20
+ signal.throwIfAborted();
21
+ const result = await uploadFile(file);
22
+ // The storage SDK does not support cancellation; deleted/session-stale results are ignored.
23
+ signal.throwIfAborted();
24
+ return result;
25
+ };
26
+
27
+ export function useCommentImages(props: CommentInputProps) {
28
+ const latest = useRef(props);
29
+ latest.current = props;
30
+ const [images, setImages] = useState<CommentImage[]>([]);
31
+ const [error, setError] = useState('');
32
+ const state = useRef<CommentImage[]>([]);
33
+ const fileMap = useRef(new Map<string, File>());
34
+ const jobs = useRef(
35
+ new Map<
36
+ string,
37
+ { file: File; controller: AbortController; running: boolean }
38
+ >(),
39
+ );
40
+ const urls = useRef(new Set<string>());
41
+ const running = useRef(0);
42
+ const alive = useRef(true);
43
+ const publish = (next: CommentImage[]) => {
44
+ state.current = next;
45
+ if (alive.current) {
46
+ setImages(next);
47
+ latest.current.onImagesChange?.(
48
+ next.map((image) => ({
49
+ ...image,
50
+ result: image.result && { ...image.result },
51
+ })),
52
+ );
53
+ }
54
+ };
55
+ const pump = () => {
56
+ if (!alive.current) return;
57
+ for (const [id, job] of jobs.current) {
58
+ if (running.current >= 3) break;
59
+ if (job.running) continue;
60
+ job.running = true;
61
+ running.current++;
62
+ // Promise wrapping also turns a synchronous custom uploader throw into an error state.
63
+ void Promise.resolve()
64
+ .then(() =>
65
+ (latest.current.uploadImage || defaultUpload)(job.file, {
66
+ signal: job.controller.signal,
67
+ }),
68
+ )
69
+ .then((result) => {
70
+ if (
71
+ !alive.current ||
72
+ job.controller.signal.aborted ||
73
+ jobs.current.get(id) !== job
74
+ )
75
+ return;
76
+ if (!result?.id || !safeUrl(result.url))
77
+ throw new Error('图片上传结果缺少有效的文件标识或地址');
78
+ publish(
79
+ state.current.map((image) =>
80
+ image.id === id
81
+ ? {
82
+ ...image,
83
+ status: 'uploaded',
84
+ result: { ...result },
85
+ error: undefined,
86
+ }
87
+ : image,
88
+ ),
89
+ );
90
+ })
91
+ .catch((cause) => {
92
+ if (
93
+ !alive.current ||
94
+ job.controller.signal.aborted ||
95
+ jobs.current.get(id) !== job
96
+ )
97
+ return;
98
+ publish(
99
+ state.current.map((image) =>
100
+ image.id === id
101
+ ? {
102
+ ...image,
103
+ status: 'error',
104
+ error:
105
+ cause instanceof Error ? cause.message : '图片上传失败',
106
+ }
107
+ : image,
108
+ ),
109
+ );
110
+ })
111
+ .finally(() => {
112
+ running.current--;
113
+ if (jobs.current.get(id) === job) jobs.current.delete(id);
114
+ pump();
115
+ });
116
+ }
117
+ };
118
+ const release = (image: CommentImage) => {
119
+ jobs.current.get(image.id)?.controller.abort();
120
+ jobs.current.delete(image.id);
121
+ fileMap.current.delete(image.id);
122
+ URL.revokeObjectURL(image.previewUrl);
123
+ urls.current.delete(image.previewUrl);
124
+ };
125
+ useEffect(() => {
126
+ alive.current = true;
127
+ latest.current.onImagesChange?.([]);
128
+ return () => {
129
+ alive.current = false;
130
+ for (const job of jobs.current.values()) job.controller.abort();
131
+ jobs.current.clear();
132
+ fileMap.current.clear();
133
+ for (const url of urls.current) URL.revokeObjectURL(url);
134
+ urls.current.clear();
135
+ };
136
+ }, []);
137
+ return {
138
+ images,
139
+ error,
140
+ getImages: () =>
141
+ state.current.map((image) => ({
142
+ ...image,
143
+ result: image.result && { ...image.result },
144
+ })),
145
+ add(files: File[]) {
146
+ if (!alive.current || !latest.current.enableImages) return;
147
+ const max = latest.current.maxImages ?? 9;
148
+ const maxSize = latest.current.maxImageSize ?? 10 * 1024 * 1024;
149
+ const added: CommentImage[] = [];
150
+ const errors: string[] = [];
151
+ for (const file of files) {
152
+ if (state.current.length + added.length >= max) {
153
+ errors.push(`最多添加 ${max} 张图片`);
154
+ break;
155
+ }
156
+ if (!imageTypes.has(file.type)) {
157
+ errors.push(`${file.name || '图片'}:不支持的图片格式`);
158
+ continue;
159
+ }
160
+ if (!file.size || file.size > maxSize) {
161
+ errors.push(
162
+ `${file.name || '图片'}:图片不能为空且不能超过 ${Math.round(maxSize / 1024 / 1024)} MB`,
163
+ );
164
+ continue;
165
+ }
166
+ const id = crypto.randomUUID();
167
+ const previewUrl = URL.createObjectURL(file);
168
+ urls.current.add(previewUrl);
169
+ added.push({
170
+ id,
171
+ name: file.name || '粘贴的图片',
172
+ size: file.size,
173
+ previewUrl,
174
+ status: 'uploading',
175
+ });
176
+ jobs.current.set(id, {
177
+ file,
178
+ controller: new AbortController(),
179
+ running: false,
180
+ });
181
+ fileMap.current.set(id, file);
182
+ }
183
+ setError([...new Set(errors)].join(';'));
184
+ if (added.length) {
185
+ publish([...state.current, ...added]);
186
+ pump();
187
+ }
188
+ },
189
+ remove(id: string) {
190
+ const image = state.current.find((item) => item.id === id);
191
+ if (!image) return;
192
+ release(image);
193
+ publish(state.current.filter((item) => item.id !== id));
194
+ setError('');
195
+ pump();
196
+ },
197
+ retry(id: string) {
198
+ // Failed files remain available for explicit retry, without exposing File in public state.
199
+ const image = state.current.find((item) => item.id === id);
200
+ const file = fileMap.current.get(id);
201
+ if (!image || image.status !== 'error' || !file || jobs.current.has(id))
202
+ return;
203
+ jobs.current.set(id, {
204
+ file,
205
+ controller: new AbortController(),
206
+ running: false,
207
+ });
208
+ publish(
209
+ state.current.map((item) =>
210
+ item.id === id
211
+ ? { ...item, status: 'uploading', error: undefined }
212
+ : item,
213
+ ),
214
+ );
215
+ pump();
216
+ },
217
+ clear() {
218
+ for (const image of state.current) release(image);
219
+ fileMap.current.clear();
220
+ publish([]);
221
+ setError('');
222
+ },
223
+ };
224
+ }
225
+
226
+ export function ImageAttachments({
227
+ images,
228
+ disabled,
229
+ remove,
230
+ retry,
231
+ }: {
232
+ images: CommentImage[];
233
+ disabled: boolean;
234
+ remove: (id: string) => void;
235
+ retry: (id: string) => void;
236
+ }) {
237
+ const [previewId, setPreviewId] = useState<string | null>(null);
238
+ const preview = images.find((image) => image.id === previewId);
239
+ return (
240
+ <>
241
+ {images.length ? (
242
+ <div className="my-2 flex flex-wrap gap-1.5" aria-label="评论图片">
243
+ {images.map((image) => (
244
+ <div
245
+ key={image.id}
246
+ className="relative size-16 overflow-hidden rounded-[6px] border border-border bg-muted"
247
+ data-image-status={image.status}
248
+ >
249
+ <button
250
+ type="button"
251
+ className="block size-full"
252
+ aria-label={`预览 ${image.name}`}
253
+ onClick={() => setPreviewId(image.id)}
254
+ >
255
+ <img
256
+ src={image.previewUrl}
257
+ alt={image.name}
258
+ className="size-full object-cover"
259
+ />
260
+ </button>
261
+ {image.status === 'uploading' ? (
262
+ <span
263
+ role="status"
264
+ aria-label={`${image.name} 正在上传`}
265
+ className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/25 text-white"
266
+ >
267
+ <LoaderCircle className="size-5 animate-spin" />
268
+ </span>
269
+ ) : null}
270
+ {image.status === 'error' ? (
271
+ <button
272
+ type="button"
273
+ disabled={disabled}
274
+ title={image.error}
275
+ aria-label={`重试上传 ${image.name}`}
276
+ onClick={() => retry(image.id)}
277
+ className="absolute inset-x-0 bottom-0 flex items-center justify-center gap-1 bg-black/65 py-1 text-[10px] text-white"
278
+ >
279
+ <RotateCcw className="size-3" />
280
+ 重试
281
+ </button>
282
+ ) : null}
283
+ <button
284
+ type="button"
285
+ disabled={disabled}
286
+ aria-label={`删除 ${image.name}`}
287
+ onClick={() => remove(image.id)}
288
+ className="absolute right-0.5 top-0.5 flex size-5 items-center justify-center rounded-full bg-black/45 text-white hover:bg-black/65 disabled:opacity-50"
289
+ >
290
+ <X className="size-4" />
291
+ </button>
292
+ </div>
293
+ ))}
294
+ </div>
295
+ ) : null}
296
+ <Dialog
297
+ open={Boolean(preview)}
298
+ onOpenChange={(open) => {
299
+ if (!open) setPreviewId(null);
300
+ }}
301
+ >
302
+ <DialogContent className="max-w-3xl">
303
+ <DialogTitle className="sr-only">
304
+ {preview?.name || '图片预览'}
305
+ </DialogTitle>
306
+ {preview ? (
307
+ <img
308
+ src={preview.previewUrl}
309
+ alt={preview.name}
310
+ className="max-h-[80vh] w-full object-contain"
311
+ />
312
+ ) : null}
313
+ </DialogContent>
314
+ </Dialog>
315
+ </>
316
+ );
317
+ }
318
+
319
+ export function AttachmentToolbar({
320
+ props,
321
+ disabled,
322
+ add,
323
+ }: {
324
+ props: CommentInputProps;
325
+ disabled: boolean;
326
+ add: (files: File[]) => void;
327
+ }) {
328
+ const fileInput = useRef<HTMLInputElement>(null);
329
+ return (
330
+ <div className="mt-2 flex min-h-6 items-center justify-between gap-3">
331
+ {props.onGrantMentionAccessChange ? (
332
+ <label className="flex cursor-pointer items-center gap-1.5 text-sm text-foreground">
333
+ <Checkbox
334
+ disabled={disabled}
335
+ checked={Boolean(props.grantMentionAccess)}
336
+ onCheckedChange={(checked) =>
337
+ props.onGrantMentionAccessChange?.(checked === true)
338
+ }
339
+ />
340
+ 授予提及的人阅读权限
341
+ </label>
342
+ ) : (
343
+ <span />
344
+ )}
345
+ {props.enableImages ? (
346
+ <>
347
+ <input
348
+ ref={fileInput}
349
+ type="file"
350
+ multiple
351
+ accept={IMAGE_ACCEPT}
352
+ disabled={disabled}
353
+ className="hidden"
354
+ aria-label="选择评论图片"
355
+ onChange={(event) => {
356
+ add(Array.from(event.currentTarget.files || []));
357
+ event.currentTarget.value = '';
358
+ }}
359
+ />
360
+ <button
361
+ type="button"
362
+ disabled={disabled}
363
+ title="上传图片"
364
+ aria-label="上传图片"
365
+ className="flex size-6 shrink-0 items-center justify-center rounded text-subtle-foreground hover:bg-accent disabled:opacity-50"
366
+ onClick={() => fileInput.current?.click()}
367
+ >
368
+ <ImagePlus className="size-[18px]" />
369
+ </button>
370
+ </>
371
+ ) : null}
372
+ </div>
373
+ );
374
+ }
@@ -0,0 +1,35 @@
1
+ /** Values from miaoda/design themes.css and comment/utils/comment-at-style.ts. */
2
+ export const COMMENT_THEME_CLASS = [
3
+ 'font-sans text-sm leading-[22px]',
4
+ '[--foreground:#1f2329] [--background:#fff] [--card:#fff] [--card-foreground:#1f2329]',
5
+ '[--popover:#fff] [--popover-foreground:#1f2329] [--border:#dee0e3]',
6
+ '[--primary:#1456f0] [--primary-foreground:#fff] [--button-outline:#dee0e3]',
7
+ '[--primary-border:transparent] [--comment-warning:#ff811a] [--muted:#f2f3f5] [--muted-foreground:#8f959e]',
8
+ '[--accent:rgba(31,35,41,0.05)] [--accent-foreground:#1f2329] [--subtle-foreground:#646a73]',
9
+ '[--info:#1456f0] [--info-foreground:#f5f7ff] [--comment-hover:rgba(31,35,41,0.05)]',
10
+ 'dark:[--foreground:#f0f0f2] dark:[--background:#141414] dark:[--card:#292929]',
11
+ 'dark:[--card-foreground:#f0f0f2] dark:[--popover:#292929] dark:[--popover-foreground:#f0f0f2]',
12
+ 'dark:[--border:rgba(255,255,255,0.15)] dark:[--primary:#4c88ff]',
13
+ 'dark:[--muted:#292929] dark:[--muted-foreground:#a3a3a3] dark:[--subtle-foreground:#a3a3a3]',
14
+ 'dark:[--accent:rgba(255,255,255,0.05)] dark:[--accent-foreground:#f0f0f2]',
15
+ 'dark:[--comment-hover:rgba(255,255,255,0.08)]',
16
+ ].join(' ');
17
+
18
+ export const USER_MENTION_CLASS = 'inline-block max-w-[220px] px-0.5 align-top';
19
+ export const USER_MENTION_LABEL_CLASS =
20
+ 'mt-px block cursor-pointer truncate rounded-[4px] px-0.5 text-primary no-underline leading-5 hover:bg-accent';
21
+ export const USER_MENTION_SELF_CLASS =
22
+ 'mt-px block cursor-pointer truncate rounded px-1 bg-[var(--info)] text-[var(--info-foreground)] no-underline leading-5';
23
+ export const DOCUMENT_MENTION_CLASS =
24
+ 'mx-0.5 inline cursor-pointer break-words rounded-[2px] p-0.5 text-primary no-underline hover:bg-accent';
25
+ export const DOCUMENT_MENTION_UNREADABLE_CLASS =
26
+ 'mx-0.5 inline cursor-pointer break-words rounded-[2px] p-0.5 text-subtle-foreground no-underline hover:bg-muted';
27
+ export function atUserLabel(label: string) {
28
+ return /^\s*@/.test(label) ? label : `@${label}`;
29
+ }
30
+
31
+ /** Editable mentions are blue pills; rounded-full keeps semicircular ends for long names. */
32
+ export const INPUT_USER_MENTION_CLASS =
33
+ 'mx-0.5 inline-flex h-5 max-w-[220px] items-center rounded-full bg-primary px-1.5 align-baseline text-primary-foreground leading-5';
34
+ export const INPUT_USER_MENTION_LABEL_CLASS =
35
+ 'min-w-0 truncate leading-5 text-inherit';
@@ -0,0 +1,73 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useMemo,
6
+ useSyncExternalStore,
7
+ type ReactNode,
8
+ } from 'react';
9
+ import * as React from 'react';
10
+ import { EntityStore } from './entity-store';
11
+ import { collectMentionRefs, mentionKey } from './content';
12
+ import type { CommentDataSource, CommentValue, MentionRef } from './types';
13
+
14
+ const Context = createContext<{
15
+ store: EntityStore;
16
+ strict: boolean;
17
+ currentUserID?: string;
18
+ } | null>(null);
19
+
20
+ export function MentionProvider({
21
+ source,
22
+ snapshotPolicy = 'immediate',
23
+ currentUserID,
24
+ children,
25
+ }: {
26
+ source: CommentDataSource;
27
+ currentUserID?: string;
28
+ snapshotPolicy?: 'immediate' | 'after-resolve';
29
+ children: ReactNode;
30
+ }) {
31
+ const store = useMemo(
32
+ () => new EntityStore(source),
33
+ [source, source.scopeKey],
34
+ );
35
+ useEffect(() => () => store.clear(), [store]);
36
+ const context = useMemo(
37
+ () => ({
38
+ store,
39
+ strict: snapshotPolicy === 'after-resolve',
40
+ currentUserID,
41
+ }),
42
+ [store, snapshotPolicy, currentUserID],
43
+ );
44
+ return React.createElement(Context.Provider, { value: context }, children);
45
+ }
46
+
47
+ export function useMentionContext() {
48
+ const context = useContext(Context);
49
+ if (!context) throw new Error('评论组件需要放在 MentionProvider 中');
50
+ return context;
51
+ }
52
+
53
+ export function useMentionEntity(ref: MentionRef) {
54
+ const { store } = useMentionContext();
55
+ const key = mentionKey(ref);
56
+ useEffect(() => {
57
+ store.ensure([ref]);
58
+ }, [store, key]);
59
+ return useSyncExternalStore(
60
+ store.subscribe,
61
+ () => store.peek(ref),
62
+ () => undefined,
63
+ );
64
+ }
65
+
66
+ export function useResolveComment(value: CommentValue) {
67
+ const { store } = useMentionContext();
68
+ const refs = collectMentionRefs(value);
69
+ const keys = JSON.stringify(refs.map(mentionKey));
70
+ useEffect(() => {
71
+ store.ensure(refs);
72
+ }, [store, keys]);
73
+ }