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

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 (30) 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 +177 -0
  5. package/template/client/src/components/business-ui/comment/ccm-document-source.ts +252 -0
  6. package/template/client/src/components/business-ui/comment/comment-content.tsx +231 -0
  7. package/template/client/src/components/business-ui/comment/comment-input.tsx +536 -0
  8. package/template/client/src/components/business-ui/comment/comment-provider.tsx +213 -0
  9. package/template/client/src/components/business-ui/comment/content.ts +261 -0
  10. package/template/client/src/components/business-ui/comment/dom-editor.ts +582 -0
  11. package/template/client/src/components/business-ui/comment/image-attachments.tsx +375 -0
  12. package/template/client/src/components/business-ui/comment/mention-provider.tsx +168 -0
  13. package/template/client/src/components/business-ui/comment/mention-suggestions.tsx +411 -0
  14. package/template/client/src/components/business-ui/comment/mention-view.tsx +171 -0
  15. package/template/client/src/components/business-ui/comment/types.ts +149 -0
  16. package/template/client/src/components/business-ui/user-display/user-display.tsx +0 -13
  17. package/template/client/src/components/business-ui/user-profile/user-profile.tsx +146 -397
  18. package/template/client/src/components/business-ui/user-select/user-pill.tsx +0 -13
  19. package/template/client/src/hooks/use-example.ts +45 -36
  20. package/template/client/src/pages/ExamplePage/ExamplePage.tsx +7 -8
  21. package/template/client/src/tailwind-theme.css +5 -6
  22. package/template/eslint.config.js +0 -6
  23. package/template/package-lock.json +94 -73
  24. package/template/package.json +5 -4
  25. package/template/server/modules/hello/hello.controller.ts +4 -8
  26. package/template/server/modules/hello/hello.service.ts +3 -5
  27. package/template/shared/api.interface.ts +1 -8
  28. package/template/client/src/components/business-ui/user-profile/lark-card-pool.ts +0 -217
  29. package/template/client/src/lib/api-error.ts +0 -36
  30. package/template/server/modules/hello/hello.dto.ts +0 -21
@@ -0,0 +1,375 @@
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
+ if (!alive.current) return;
219
+ for (const image of state.current) release(image);
220
+ fileMap.current.clear();
221
+ publish([]);
222
+ setError('');
223
+ },
224
+ };
225
+ }
226
+
227
+ export function ImageAttachments({
228
+ images,
229
+ disabled,
230
+ remove,
231
+ retry,
232
+ }: {
233
+ images: CommentImage[];
234
+ disabled: boolean;
235
+ remove: (id: string) => void;
236
+ retry: (id: string) => void;
237
+ }) {
238
+ const [previewId, setPreviewId] = useState<string | null>(null);
239
+ const preview = images.find((image) => image.id === previewId);
240
+ return (
241
+ <>
242
+ {images.length ? (
243
+ <div className="my-2 flex flex-wrap gap-1.5" aria-label="评论图片">
244
+ {images.map((image) => (
245
+ <div
246
+ key={image.id}
247
+ className="relative size-16 overflow-hidden rounded-[6px] border border-border bg-muted"
248
+ data-image-status={image.status}
249
+ >
250
+ <button
251
+ type="button"
252
+ className="block size-full"
253
+ aria-label={`预览 ${image.name}`}
254
+ onClick={() => setPreviewId(image.id)}
255
+ >
256
+ <img
257
+ src={image.previewUrl}
258
+ alt={image.name}
259
+ className="size-full object-cover"
260
+ />
261
+ </button>
262
+ {image.status === 'uploading' ? (
263
+ <span
264
+ role="status"
265
+ aria-label={`${image.name} 正在上传`}
266
+ className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/25 text-white"
267
+ >
268
+ <LoaderCircle className="size-5 animate-spin" />
269
+ </span>
270
+ ) : null}
271
+ {image.status === 'error' ? (
272
+ <button
273
+ type="button"
274
+ disabled={disabled}
275
+ title={image.error}
276
+ aria-label={`重试上传 ${image.name}`}
277
+ onClick={() => retry(image.id)}
278
+ className="absolute inset-x-0 bottom-0 flex items-center justify-center gap-1 bg-black/65 py-1 text-[10px] text-white"
279
+ >
280
+ <RotateCcw className="size-3" />
281
+ 重试
282
+ </button>
283
+ ) : null}
284
+ <button
285
+ type="button"
286
+ disabled={disabled}
287
+ aria-label={`删除 ${image.name}`}
288
+ onClick={() => remove(image.id)}
289
+ 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"
290
+ >
291
+ <X className="size-4" />
292
+ </button>
293
+ </div>
294
+ ))}
295
+ </div>
296
+ ) : null}
297
+ <Dialog
298
+ open={Boolean(preview)}
299
+ onOpenChange={(open) => {
300
+ if (!open) setPreviewId(null);
301
+ }}
302
+ >
303
+ <DialogContent className="max-w-3xl">
304
+ <DialogTitle className="sr-only">
305
+ {preview?.name || '图片预览'}
306
+ </DialogTitle>
307
+ {preview ? (
308
+ <img
309
+ src={preview.previewUrl}
310
+ alt={preview.name}
311
+ className="max-h-[80vh] w-full object-contain"
312
+ />
313
+ ) : null}
314
+ </DialogContent>
315
+ </Dialog>
316
+ </>
317
+ );
318
+ }
319
+
320
+ export function AttachmentToolbar({
321
+ props,
322
+ disabled,
323
+ add,
324
+ }: {
325
+ props: CommentInputProps;
326
+ disabled: boolean;
327
+ add: (files: File[]) => void;
328
+ }) {
329
+ const fileInput = useRef<HTMLInputElement>(null);
330
+ return (
331
+ <div className="mt-2 flex min-h-6 items-center justify-between gap-3">
332
+ {props.onGrantMentionAccessChange ? (
333
+ <label className="flex cursor-pointer items-center gap-1.5 text-sm text-foreground">
334
+ <Checkbox
335
+ disabled={disabled}
336
+ checked={Boolean(props.grantMentionAccess)}
337
+ onCheckedChange={(checked) =>
338
+ props.onGrantMentionAccessChange?.(checked === true)
339
+ }
340
+ />
341
+ 授予提及的人阅读权限
342
+ </label>
343
+ ) : (
344
+ <span />
345
+ )}
346
+ {props.enableImages ? (
347
+ <>
348
+ <input
349
+ ref={fileInput}
350
+ type="file"
351
+ multiple
352
+ accept={IMAGE_ACCEPT}
353
+ disabled={disabled}
354
+ className="hidden"
355
+ aria-label="选择评论图片"
356
+ onChange={(event) => {
357
+ add(Array.from(event.currentTarget.files || []));
358
+ event.currentTarget.value = '';
359
+ }}
360
+ />
361
+ <button
362
+ type="button"
363
+ disabled={disabled}
364
+ title="上传图片"
365
+ aria-label="上传图片"
366
+ className="flex size-6 shrink-0 items-center justify-center rounded text-subtle-foreground hover:bg-accent disabled:opacity-50"
367
+ onClick={() => fileInput.current?.click()}
368
+ >
369
+ <ImagePlus className="size-[18px]" />
370
+ </button>
371
+ </>
372
+ ) : null}
373
+ </div>
374
+ );
375
+ }
@@ -0,0 +1,168 @@
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 { collectMentionRefs, mentionKey } from './content';
11
+ import type {
12
+ CommentDataSource,
13
+ CommentValue,
14
+ MentionRef,
15
+ MentionOption,
16
+ MentionResult,
17
+ } from './types';
18
+
19
+ const Context = createContext<{
20
+ store: EntityStore;
21
+ strict: boolean;
22
+ currentUserID?: string;
23
+ } | null>(null);
24
+
25
+ export function MentionProvider({
26
+ source,
27
+ snapshotPolicy = 'immediate',
28
+ currentUserID,
29
+ children,
30
+ }: {
31
+ source: CommentDataSource;
32
+ currentUserID?: string;
33
+ snapshotPolicy?: 'immediate' | 'after-resolve';
34
+ children: ReactNode;
35
+ }) {
36
+ const store = useMemo(
37
+ () => new EntityStore(source),
38
+ [source, source.scopeKey],
39
+ );
40
+ useEffect(() => () => store.clear(), [store]);
41
+ const context = useMemo(
42
+ () => ({
43
+ store,
44
+ strict: snapshotPolicy === 'after-resolve',
45
+ currentUserID,
46
+ }),
47
+ [store, snapshotPolicy, currentUserID],
48
+ );
49
+ return React.createElement(Context.Provider, { value: context }, children);
50
+ }
51
+
52
+ export function useMentionContext() {
53
+ const context = useContext(Context);
54
+ if (!context) throw new Error('评论组件需要放在 MentionProvider 中');
55
+ return context;
56
+ }
57
+
58
+ export function useMentionEntity(ref: MentionRef) {
59
+ const { store } = useMentionContext();
60
+ const key = mentionKey(ref);
61
+ useEffect(() => {
62
+ store.ensure([ref]);
63
+ }, [store, key]);
64
+ return useSyncExternalStore(
65
+ store.subscribe,
66
+ () => store.peek(ref),
67
+ () => undefined,
68
+ );
69
+ }
70
+
71
+ export function useResolveComment(value: CommentValue) {
72
+ const { store } = useMentionContext();
73
+ const refs = collectMentionRefs(value);
74
+ const keys = JSON.stringify(refs.map(mentionKey));
75
+ useEffect(() => {
76
+ store.ensure(refs);
77
+ }, [store, keys]);
78
+ }
79
+
80
+ /** One store per authenticated scope, shared by editors and all list items. */
81
+ export class EntityStore {
82
+ private cache = new Map<string, { value: MentionResult; until: number }>();
83
+ private listeners = new Set<() => void>();
84
+ private queue = new Map<string, MentionRef>();
85
+ private pending = new Set<string>();
86
+ private controllers = new Set<AbortController>();
87
+ private scheduled = false;
88
+ private generation = 0;
89
+
90
+ constructor(readonly source: CommentDataSource) {}
91
+ subscribe = (listener: () => void) => {
92
+ this.listeners.add(listener);
93
+ return () => {
94
+ this.listeners.delete(listener);
95
+ };
96
+ };
97
+ peek = (ref: MentionRef) => this.cache.get(mentionKey(ref))?.value;
98
+ seed(items: MentionOption[]) {
99
+ for (const item of items) this.write({ ...item, status: 'resolved' });
100
+ }
101
+ private write(value: MentionResult) {
102
+ const key = mentionKey(value.ref);
103
+ this.cache.delete(key);
104
+ this.cache.set(key, {
105
+ value,
106
+ until: Date.now() + (value.status === 'resolved' ? 300_000 : 30_000),
107
+ });
108
+ if (this.cache.size > 500)
109
+ this.cache.delete(this.cache.keys().next().value!);
110
+ for (const listener of this.listeners) listener();
111
+ }
112
+ ensure(refs: MentionRef[], force = false) {
113
+ for (const ref of refs) {
114
+ const key = mentionKey(ref);
115
+ if (
116
+ !this.pending.has(key) &&
117
+ (force || (this.cache.get(key)?.until || 0) <= Date.now())
118
+ )
119
+ this.queue.set(key, ref);
120
+ }
121
+ if (!this.scheduled && this.queue.size) {
122
+ this.scheduled = true;
123
+ queueMicrotask(() => {
124
+ this.scheduled = false;
125
+ void this.flush();
126
+ });
127
+ }
128
+ }
129
+ private async flush() {
130
+ const refs = [...this.queue.values()];
131
+ this.queue.clear();
132
+ const generation = this.generation;
133
+ for (let i = 0; i < refs.length; i += 50) {
134
+ const batch = refs.slice(i, i + 50);
135
+ batch.forEach((ref) => this.pending.add(mentionKey(ref)));
136
+ const controller = new AbortController();
137
+ this.controllers.add(controller);
138
+ void this.source
139
+ .resolve(batch, controller.signal)
140
+ .then((results) => {
141
+ if (generation !== this.generation) return;
142
+ const byKey = new Map(
143
+ results.map((item) => [mentionKey(item.ref), item]),
144
+ );
145
+ for (const ref of batch)
146
+ this.write(byKey.get(mentionKey(ref)) || { ref, status: 'error' });
147
+ })
148
+ .catch(() => {
149
+ if (generation === this.generation)
150
+ for (const ref of batch) this.write({ ref, status: 'error' });
151
+ })
152
+ .finally(() => {
153
+ this.controllers.delete(controller);
154
+ if (generation === this.generation)
155
+ batch.forEach((ref) => this.pending.delete(mentionKey(ref)));
156
+ });
157
+ }
158
+ }
159
+ clear() {
160
+ this.generation++;
161
+ for (const controller of this.controllers) controller.abort();
162
+ this.controllers.clear();
163
+ this.pending.clear();
164
+ this.queue.clear();
165
+ this.cache.clear();
166
+ for (const listener of this.listeners) listener();
167
+ }
168
+ }