@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,235 @@
1
+ import { DocumentIcon } from './document-icon';
2
+ import { fromApiContent } from './content';
3
+ import {
4
+ USER_MENTION_CLASS,
5
+ INPUT_USER_MENTION_CLASS,
6
+ INPUT_USER_MENTION_LABEL_CLASS,
7
+ COMMENT_THEME_CLASS,
8
+ USER_MENTION_SELF_CLASS,
9
+ atUserLabel,
10
+ USER_MENTION_LABEL_CLASS,
11
+ DOCUMENT_MENTION_CLASS,
12
+ DOCUMENT_MENTION_UNREADABLE_CLASS,
13
+ } from './mention-appearance';
14
+ import * as React from 'react';
15
+ import { cn } from '@/lib/utils';
16
+ import {
17
+ mentionLabel,
18
+ normalizeValue,
19
+ safeUrl,
20
+ } from './content';
21
+ import {
22
+ useMentionContext,
23
+ useMentionEntity,
24
+ useResolveComment,
25
+ } from './mention-provider';
26
+ import type {
27
+ CommentMentionClickHandler,
28
+ CommentApiContent,
29
+ CommentPart,
30
+ CommentValue,
31
+ } from './types';
32
+
33
+ function Mention({
34
+ part,
35
+ onMentionClick,
36
+ mentionVariant,
37
+ }: {
38
+ part: Extract<CommentPart, { type: 'mention' }>;
39
+ onMentionClick?: CommentMentionClickHandler;
40
+ mentionVariant?: 'text' | 'pill';
41
+ }) {
42
+ const { strict, store, currentUserID } = useMentionContext();
43
+ const result = useMentionEntity(part.ref);
44
+ const label = mentionLabel(part.ref, part.label, result, strict);
45
+ if (part.ref.kind === 'document') {
46
+ const resolved = result?.status === 'resolved' ? result : undefined;
47
+ const documentHref = safeUrl(
48
+ resolved?.href || (!strict ? part.href : undefined),
49
+ );
50
+ const metadata =
51
+ resolved?.document || (!strict ? part.document : undefined);
52
+ const title = resolved
53
+ ? resolved.label
54
+ : result?.status === 'forbidden' ||
55
+ result?.status === 'not-found' ||
56
+ strict
57
+ ? label
58
+ : part.label;
59
+ const body = (
60
+ <>
61
+ {title ? (
62
+ <DocumentIcon refValue={part.ref} metadata={metadata} />
63
+ ) : null}
64
+ <span>{title || documentHref || '文档'}</span>
65
+ </>
66
+ );
67
+ if (documentHref)
68
+ return (
69
+ <a
70
+ className={
71
+ result?.status === 'forbidden' || resolved?.readable === false
72
+ ? DOCUMENT_MENTION_UNREADABLE_CLASS
73
+ : DOCUMENT_MENTION_CLASS
74
+ }
75
+ href={documentHref}
76
+ target="_blank"
77
+ rel="noopener noreferrer"
78
+ data-doclink="true"
79
+ data-doclink-token={part.ref.id}
80
+ data-doclink-type={part.ref.resourceType}
81
+ onClick={(event) => event.stopPropagation()}
82
+ onMouseDown={(event) => event.preventDefault()}
83
+ >
84
+ {body}
85
+ </a>
86
+ );
87
+ if (result?.status === 'error')
88
+ return (
89
+ <button
90
+ type="button"
91
+ className={DOCUMENT_MENTION_CLASS}
92
+ title="信息加载失败,点击重试"
93
+ onClick={() => store.ensure([part.ref], true)}
94
+ >
95
+ {body}
96
+ </button>
97
+ );
98
+ return <span className={DOCUMENT_MENTION_CLASS}>{body}</span>;
99
+ }
100
+ const userLabelClass =
101
+ mentionVariant === 'pill'
102
+ ? INPUT_USER_MENTION_LABEL_CLASS
103
+ : part.ref.id === currentUserID
104
+ ? USER_MENTION_SELF_CLASS
105
+ : USER_MENTION_LABEL_CLASS;
106
+ const body = (
107
+ <span
108
+ className={cn(
109
+ userLabelClass,
110
+ result?.status === 'forbidden' && 'text-muted-foreground',
111
+ )}
112
+ >
113
+ {atUserLabel(label)}
114
+ </span>
115
+ );
116
+ const className =
117
+ mentionVariant === 'pill' ? INPUT_USER_MENTION_CLASS : USER_MENTION_CLASS;
118
+ if (
119
+ onMentionClick &&
120
+ result?.status !== 'forbidden' &&
121
+ result?.status !== 'not-found'
122
+ )
123
+ return (
124
+ <button
125
+ type="button"
126
+ className={className}
127
+ onClick={(event) => onMentionClick(part.ref, event.currentTarget)}
128
+ >
129
+ {body}
130
+ </button>
131
+ );
132
+ if (result?.status === 'error')
133
+ return (
134
+ <button
135
+ type="button"
136
+ className={className}
137
+ title="信息加载失败,点击重试"
138
+ onClick={() => store.ensure([part.ref], true)}
139
+ >
140
+ {body}
141
+ </button>
142
+ );
143
+ return <span className={className}>{body}</span>;
144
+ }
145
+
146
+ export type CommentContentProps = {
147
+ className?: string;
148
+ onMentionClick?: CommentMentionClickHandler;
149
+ mentionVariant?: 'text' | 'pill';
150
+ } & (
151
+ | { value: CommentValue; content?: never }
152
+ | { content: readonly CommentApiContent[]; value?: never }
153
+ );
154
+
155
+ /** Pass API content with userID only; metadata is fetched internally in batches. */
156
+ export function CommentContent(props: CommentContentProps) {
157
+ const { className, onMentionClick, mentionVariant = 'text' } = props;
158
+ let normalized: CommentValue;
159
+ try {
160
+ normalized = props.value
161
+ ? normalizeValue(props.value)
162
+ : fromApiContent(props.content!);
163
+ } catch {
164
+ return (
165
+ <p role="alert" className="text-sm text-destructive">
166
+ 评论内容格式无效,无法展示
167
+ </p>
168
+ );
169
+ }
170
+ return React.createElement(ResolvedContent, {
171
+ value: normalized,
172
+ className,
173
+ onMentionClick,
174
+ mentionVariant,
175
+ });
176
+ }
177
+
178
+ function ResolvedContent({
179
+ value: normalized,
180
+ className,
181
+ onMentionClick,
182
+ mentionVariant,
183
+ }: {
184
+ value: CommentValue;
185
+ className?: string;
186
+ onMentionClick?: CommentMentionClickHandler;
187
+ mentionVariant?: 'text' | 'pill';
188
+ }) {
189
+ useResolveComment(normalized);
190
+ return (
191
+ <div
192
+ className={cn(
193
+ COMMENT_THEME_CLASS,
194
+ 'text-foreground text-sm leading-[22px]',
195
+ className,
196
+ )}
197
+ >
198
+ <div className="whitespace-pre-wrap break-words">
199
+ {normalized.content.map((part, index) =>
200
+ part.type === 'text' ? (
201
+ part.text
202
+ ) : part.type === 'break' ? (
203
+ <br key={index} />
204
+ ) : part.type === 'link' ? (
205
+ <a
206
+ key={index}
207
+ href={safeUrl(part.url)}
208
+ target="_blank"
209
+ rel="noopener noreferrer"
210
+ className="break-words text-primary no-underline hover:underline"
211
+ onClick={(event) => event.stopPropagation()}
212
+ >
213
+ {part.url}
214
+ </a>
215
+ ) : part.type === 'unresolved-mention' ? (
216
+ <span
217
+ key={index}
218
+ className="text-subtle-foreground"
219
+ title="原评论缺少被提及对象的信息"
220
+ >
221
+ 引用信息缺失
222
+ </span>
223
+ ) : (
224
+ <Mention
225
+ key={index}
226
+ part={part}
227
+ onMentionClick={onMentionClick}
228
+ mentionVariant={mentionVariant}
229
+ />
230
+ ),
231
+ )}
232
+ </div>
233
+ </div>
234
+ );
235
+ }
@@ -0,0 +1,191 @@
1
+ import {
2
+ useEffect,
3
+ useImperativeHandle,
4
+ useCallback,
5
+ useRef,
6
+ useState,
7
+ type ComponentType,
8
+ } from 'react';
9
+ import * as React from 'react';
10
+ import { ErrorBoundary } from 'react-error-boundary';
11
+ import { Button } from '@/components/ui/button';
12
+ import { cn } from '@/lib/utils';
13
+ import { COMMENT_THEME_CLASS } from './mention-appearance';
14
+ import { CommentContent } from './comment-content';
15
+ import { useMentionContext } from './mention-provider';
16
+ import type {
17
+ CommentInputProps,
18
+ CommentInputHandle,
19
+ CommentEditorProps,
20
+ } from './types';
21
+ import { emptyComment, normalizeValue, readApiContent } from './content';
22
+
23
+ let modulePromise: Promise<typeof import('./editor')> | undefined;
24
+ function loadEditor() {
25
+ // Reset rejected imports so explicit retry actually executes import again.
26
+ return (modulePromise ||= import('./editor').catch((error) => {
27
+ modulePromise = undefined;
28
+ throw error;
29
+ }));
30
+ }
31
+ export function preloadCommentEditor() {
32
+ void loadEditor().catch(() => {});
33
+ }
34
+
35
+ /** Public entry keeps editing and uploads deferred. A session change resets all work. */
36
+ export function CommentInput(props: CommentInputProps) {
37
+ const { store } = useMentionContext();
38
+ try {
39
+ normalizeValue(props.value);
40
+ } catch {
41
+ return (
42
+ <p role="alert" className="text-sm text-destructive">
43
+ 评论内容格式无效,无法编辑
44
+ </p>
45
+ );
46
+ }
47
+ return (
48
+ <ErrorBoundary
49
+ key={JSON.stringify([store.source.scopeKey, props.documentKey])}
50
+ fallbackRender={({ resetErrorBoundary }) => (
51
+ <div
52
+ role="alert"
53
+ className="rounded-lg border p-3 text-sm text-destructive"
54
+ >
55
+ 编辑器发生错误,输入内容已保留。
56
+ <Button
57
+ type="button"
58
+ variant="outline"
59
+ size="sm"
60
+ onClick={resetErrorBoundary}
61
+ >
62
+ 重新加载
63
+ </Button>
64
+ </div>
65
+ )}
66
+ >
67
+ <InputSession {...props} />
68
+ </ErrorBoundary>
69
+ );
70
+ }
71
+
72
+ function InputSession(props: CommentInputProps) {
73
+ const [Editor, setEditor] =
74
+ React.useState<ComponentType<CommentEditorProps> | null>(null);
75
+ const latest = useRef(props);
76
+ latest.current = props;
77
+ const editorRef = useRef<CommentInputHandle | null>(null);
78
+ const editable = useRef(true);
79
+ const [enabled, setEnabled] = useState(true);
80
+ const loadingRef = useRef(false);
81
+ const [loading, setLoading] = useState(false);
82
+ const [error, setError] = useState('');
83
+ const alive = useRef(true);
84
+ useEffect(() => {
85
+ alive.current = true;
86
+ return () => {
87
+ alive.current = false;
88
+ };
89
+ }, []);
90
+ const activate = useCallback(async () => {
91
+ if (loadingRef.current || latest.current.disabled || !editable.current)
92
+ return;
93
+ loadingRef.current = true;
94
+ setLoading(true);
95
+ setError('');
96
+ try {
97
+ const module = await loadEditor();
98
+ if (alive.current) setEditor(() => module.default);
99
+ } catch {
100
+ if (alive.current) setError('编辑器加载失败,请重试');
101
+ } finally {
102
+ loadingRef.current = false;
103
+ if (alive.current) setLoading(false);
104
+ }
105
+ }, []);
106
+ useImperativeHandle(
107
+ props.ref,
108
+ () => ({
109
+ getContent: () =>
110
+ editorRef.current?.getContent() ?? readApiContent(latest.current.value),
111
+ getImages: () => editorRef.current?.getImages() ?? [],
112
+ clearImages() {
113
+ if (!alive.current) return;
114
+ editorRef.current?.clearImages();
115
+ },
116
+ clear() {
117
+ if (!alive.current) return;
118
+ if (editorRef.current) editorRef.current.clear();
119
+ else {
120
+ const value = emptyComment();
121
+ latest.current = { ...latest.current, value };
122
+ latest.current.onChange(value);
123
+ }
124
+ },
125
+ setEditable(next) {
126
+ if (!alive.current) return;
127
+ editable.current = next;
128
+ editorRef.current?.setEditable(next);
129
+ setEnabled(next);
130
+ },
131
+ focus() {
132
+ if (!alive.current) return;
133
+ if (editorRef.current) editorRef.current.focus();
134
+ else void activate();
135
+ },
136
+ }),
137
+ [activate],
138
+ );
139
+ const { ref: _ref, ...editorProps } = props;
140
+ if (Editor)
141
+ return (
142
+ <Editor
143
+ {...editorProps}
144
+ disabled={props.disabled || !enabled}
145
+ editorRef={editorRef}
146
+ />
147
+ );
148
+ return (
149
+ <div className={cn(COMMENT_THEME_CLASS, props.className)}>
150
+ <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">
151
+ <div
152
+ aria-hidden
153
+ className="pointer-events-none w-full text-sm leading-[22px]"
154
+ >
155
+ {loading || error ? (
156
+ <span className="text-muted-foreground">
157
+ {loading ? '正在加载编辑器…' : '重试加载编辑器'}
158
+ </span>
159
+ ) : props.value.content.length ? (
160
+ <CommentContent value={props.value} mentionVariant="pill" />
161
+ ) : (
162
+ <span className="text-muted-foreground">
163
+ {props.placeholder || '输入评论'}
164
+ </span>
165
+ )}
166
+ </div>
167
+ <Button
168
+ type="button"
169
+ variant="ghost"
170
+ className="no-default-hover-elevate no-default-active-elevate absolute inset-0 h-full min-h-0 w-full rounded-[6px] p-0"
171
+ disabled={props.disabled || !enabled || loading}
172
+ aria-busy={loading}
173
+ onPointerEnter={preloadCommentEditor}
174
+ onFocus={preloadCommentEditor}
175
+ onClick={() => {
176
+ void activate();
177
+ }}
178
+ >
179
+ <span className="sr-only">
180
+ {error ? '重试加载编辑器' : props.placeholder || '输入评论'}
181
+ </span>
182
+ </Button>
183
+ </div>
184
+ {error ? (
185
+ <p role="alert" className="mt-2 text-xs text-destructive">
186
+ {error}
187
+ </p>
188
+ ) : null}
189
+ </div>
190
+ );
191
+ }
@@ -0,0 +1,40 @@
1
+ import { useMemo, type ReactNode } from 'react';
2
+ import * as React from 'react';
3
+ import { getAppId } from '../user-select/utils';
4
+ import { useCurrentUserProfile } from '@lark-apaas/client-toolkit/hooks/useCurrentUserProfile';
5
+ import { createAppCommentSource, withDocumentSource } from './app-source';
6
+ import { MentionProvider } from './mention-provider';
7
+ import type { CommentDataSource, CommentDocumentSource } from './types';
8
+
9
+ /** Configure the built-in UserDisplay data service once for the input and list. */
10
+ export function CommentProvider({
11
+ children,
12
+ scopeKey,
13
+ currentUserID,
14
+ recommend,
15
+ documents,
16
+ }: {
17
+ children: ReactNode;
18
+ scopeKey?: string;
19
+ currentUserID?: string;
20
+ recommend?: CommentDataSource['recommend'];
21
+ documents?: CommentDocumentSource;
22
+ }) {
23
+ const user = useCurrentUserProfile();
24
+ const scope = JSON.stringify([
25
+ typeof window === 'undefined' ? null : getAppId(window.location.pathname),
26
+ scopeKey || user.user_id || 'pending',
27
+ ]);
28
+ const source = useMemo(() => {
29
+ const people = { ...createAppCommentSource(scope), recommend };
30
+ return documents ? withDocumentSource(people, documents) : people;
31
+ }, [scope, recommend, documents]);
32
+ return (
33
+ <MentionProvider
34
+ source={source}
35
+ currentUserID={currentUserID || user.user_id}
36
+ >
37
+ {children}
38
+ </MentionProvider>
39
+ );
40
+ }