@lark-apaas/coding-template-nestjs-react-fullstack 0.1.56 → 0.1.57-alpha.20261103143319

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 (26) hide show
  1. package/package.json +1 -1
  2. package/template/client/src/components/business-ui/README.md +2 -0
  3. package/template/client/src/components/business-ui/record-comments/README.md +141 -0
  4. package/template/client/src/components/business-ui/record-comments/app-source.ts +93 -0
  5. package/template/client/src/components/business-ui/record-comments/assets/reaction-sprite.png +0 -0
  6. package/template/client/src/components/business-ui/record-comments/comment-panel.contract.ts +240 -0
  7. package/template/client/src/components/business-ui/record-comments/comment-panel.tsx +231 -0
  8. package/template/client/src/components/business-ui/record-comments/extensions.ts +129 -0
  9. package/template/client/src/components/business-ui/record-comments/index.ts +9 -0
  10. package/template/client/src/components/business-ui/record-comments/internal/composer.tsx +529 -0
  11. package/template/client/src/components/business-ui/record-comments/internal/content.tsx +213 -0
  12. package/template/client/src/components/business-ui/record-comments/internal/discussion.tsx +517 -0
  13. package/template/client/src/components/business-ui/record-comments/internal/editor.tsx +675 -0
  14. package/template/client/src/components/business-ui/record-comments/internal/icons.tsx +148 -0
  15. package/template/client/src/components/business-ui/record-comments/internal/images.tsx +155 -0
  16. package/template/client/src/components/business-ui/record-comments/internal/mentions.tsx +163 -0
  17. package/template/client/src/components/business-ui/record-comments/internal/model.ts +581 -0
  18. package/template/client/src/components/business-ui/record-comments/internal/mutations.ts +170 -0
  19. package/template/client/src/components/business-ui/record-comments/internal/reaction-catalog.ts +196 -0
  20. package/template/client/src/components/business-ui/record-comments/internal/reactions.tsx +681 -0
  21. package/template/client/src/components/business-ui/record-comments/internal/styles.css +1109 -0
  22. package/template/client/src/components/business-ui/record-comments/internal/suggestions.tsx +456 -0
  23. package/template/client/src/components/business-ui/record-comments/internal/view-types.ts +84 -0
  24. package/template/client/src/components/business-ui/record-comments/types.ts +118 -0
  25. package/template/package-lock.json +529 -13
  26. package/template/package.json +15 -4
@@ -0,0 +1,231 @@
1
+ import type { CommentComposerProps, CommentListProps, CommentPanelProps, CommentsProviderProps, CommentTarget } from './comment-panel.contract';
2
+ import { CommentMutations } from './internal/mutations';
3
+ import { buildPanelThreads, CommentPageLoader, preparePanelDeletion, preparePanelEdit, loadPanelReactionDetails, preparePanelComment } from './internal/model';
4
+ import { DiscussionComposerView, DiscussionLayout, DiscussionList, DiscussionProvider } from './internal/discussion';
5
+ import { commentExtensionFeatures, withDocumentSource } from './extensions';
6
+ import { COMMENT_THEME_CLASS } from './internal/model';
7
+ import { LoaderCircle } from 'lucide-react';
8
+ import { forwardRef, useCallback, useEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from 'react';
9
+ import './internal/styles.css';
10
+
11
+ function ReadStatus({ loading, error, onRetry, empty, hasMore }: Pick<CommentPanelProps, 'loading' | 'error' | 'onRetry'> & { empty: boolean; hasMore?: boolean }) {
12
+ const [failure, setFailure] = useState('');
13
+ const [retrying, setRetrying] = useState(false);
14
+ const busy = useRef(false);
15
+ // A successful external read must clear a previous retry failure as well.
16
+ useEffect(() => setFailure(''), [error]);
17
+ const pending = loading || retrying;
18
+ const message = failure || error;
19
+ if (!empty && !pending && !message) return null;
20
+ const state = pending ? 'loading' : message ? 'error' : 'empty';
21
+ return <div className={`replica-panel-status${empty ? ' replica-panel-placeholder' : ''}`}
22
+ data-state={state} role={state === 'error' ? 'alert' : 'status'} aria-busy={pending}>
23
+ <div className="replica-panel-status-content">
24
+ <span>{pending ? <LoaderCircle className="size-4 animate-spin text-primary" aria-label="正在加载评论" />
25
+ : message || (empty && !hasMore ? '当前记录暂无评论' : null)}</span>
26
+ {!pending && message && onRetry && <button type="button" onClick={async () => {
27
+ if (busy.current) return;
28
+ busy.current = true; setRetrying(true); setFailure('');
29
+ try { await onRetry(); }
30
+ catch (cause) { setFailure(cause instanceof Error ? cause.message : '刷新失败,请重试'); }
31
+ finally { busy.current = false; setRetrying(false); }
32
+ }}>重试刷新</button>}
33
+ </div>
34
+ </div>;
35
+ }
36
+
37
+ function nearestScrollContainer(node: HTMLElement): HTMLElement | null {
38
+ for (let parent = node.parentElement; parent; parent = parent.parentElement) {
39
+ if (/(auto|scroll)/.test(getComputedStyle(parent).overflowY) && parent.clientHeight > 0) return parent;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ function PaginationFooter({ pagination, loading, getScrollContainer }: Pick<CommentPanelProps, 'pagination' | 'loading' | 'getScrollContainer'>) {
45
+ const sentinel = useRef<HTMLDivElement>(null);
46
+ const root = useRef<HTMLElement | null>(null);
47
+ const [gate] = useState(() => new CommentPageLoader());
48
+ const [, refresh] = useReducer((value: number) => value + 1, 0);
49
+ const latest = useRef({ pagination, loading });
50
+ latest.current = { pagination, loading };
51
+ const alive = useRef(true);
52
+ const run = useRef((manual = false) => { void manual; });
53
+ run.current = (manual = false) => {
54
+ const current = latest.current;
55
+ if (!alive.current || current.loading || !current.pagination?.hasMore || current.pagination.loading || gate.pending) return;
56
+ if (!manual && (gate.error || current.pagination.error || gate.automaticLoads >= 3)) return;
57
+ const promise = gate.load(current.pagination, manual);
58
+ refresh();
59
+ void promise.finally(() => { if (alive.current) refresh(); });
60
+ };
61
+ const check = useCallback(() => {
62
+ const element = sentinel.current;
63
+ if (!element) return;
64
+ const bounds = element.getBoundingClientRect();
65
+ const viewport = root.current?.getBoundingClientRect();
66
+ const visibleTop = Math.max(0, viewport?.top ?? 0);
67
+ const visibleBottom = Math.min(window.innerHeight, viewport?.bottom ?? window.innerHeight);
68
+ if (visibleBottom > visibleTop && bounds.top <= visibleBottom + 200 && bounds.bottom >= visibleTop)
69
+ run.current();
70
+ }, []);
71
+ useEffect(() => {
72
+ alive.current = true;
73
+ const element = sentinel.current;
74
+ if (!element) return;
75
+ root.current = getScrollContainer ? getScrollContainer() : nearestScrollContainer(element);
76
+ const container = root.current ?? window;
77
+ const observer = new IntersectionObserver(() => check(), { root: root.current, rootMargin: '0px 0px 200px 0px' });
78
+ observer.observe(element);
79
+ const resize = new ResizeObserver(check);
80
+ resize.observe(root.current ?? document.documentElement);
81
+ const resume = (event: Event) => {
82
+ if (event instanceof WheelEvent && event.deltaY > 0) gate.automaticLoads = 0;
83
+ if (event instanceof KeyboardEvent && ['PageDown', 'End', 'ArrowDown'].includes(event.key)) gate.automaticLoads = 0;
84
+ check();
85
+ };
86
+ container.addEventListener('scroll', check, { passive: true });
87
+ if (root.current) window.addEventListener('scroll', check, { passive: true });
88
+ container.addEventListener('wheel', resume, { passive: true });
89
+ container.addEventListener('keydown', resume);
90
+ return () => {
91
+ alive.current = false;
92
+ observer.disconnect(); resize.disconnect();
93
+ container.removeEventListener('scroll', check);
94
+ window.removeEventListener('scroll', check);
95
+ container.removeEventListener('wheel', resume);
96
+ container.removeEventListener('keydown', resume);
97
+ };
98
+ }, [check, gate, getScrollContainer]);
99
+ useEffect(() => {
100
+ const frame = requestAnimationFrame(check);
101
+ return () => cancelAnimationFrame(frame);
102
+ });
103
+ const error = pagination?.hasMore && (gate.error || pagination.error);
104
+ const pending = gate.pending || pagination?.loading;
105
+ return <div ref={sentinel} className={pagination?.hasMore ? 'replica-panel-pagination' : undefined} aria-live="polite">
106
+ {pagination?.hasMore && (error ? <><span role="alert">{error}</span><button type="button" disabled={pending} onClick={() => run.current(true)}>重试加载</button></>
107
+ : pending ? <LoaderCircle className="size-4 animate-spin text-primary" aria-label="正在加载更多评论" />
108
+ : pagination?.hasMore ? <button type="button" disabled={loading} onClick={() => run.current(true)}>继续加载</button>
109
+ : null)}
110
+ </div>;
111
+ }
112
+
113
+ function PanelSession(props: CommentsProviderProps) {
114
+ const latest = useRef(props);
115
+ latest.current = props;
116
+ const locks = useRef(new Set<string>());
117
+ const [submitted, setSubmitted] = useState<string>();
118
+ const [mutations] = useState(() => new CommentMutations());
119
+ useSyncExternalStore(mutations.subscribe, mutations.getVersion, mutations.getVersion);
120
+ const base = buildPanelThreads(props);
121
+ const comments = mutations.view(base);
122
+ useEffect(() => {
123
+ mutations.activate();
124
+ return () => mutations.dispose();
125
+ }, [mutations]);
126
+ useEffect(() => { mutations.reconcile(base); }, [base, mutations]);
127
+ async function locked<T>(key: string, action: () => Promise<T>): Promise<T> {
128
+ if (locks.current.has(key)) throw new Error('该评论正在处理中,请稍后重试');
129
+ locks.current.add(key);
130
+ try { return await action(); }
131
+ finally { locks.current.delete(key); }
132
+ }
133
+ const allowCompose = Boolean(props.onAddComment || props.images?.onAddComment);
134
+ const currentUser = props.currentUserID == null ? undefined
135
+ : { kind: 'user' as const, idType: 'apaas-user' as const, id: String(props.currentUserID) };
136
+ return <DiscussionProvider panel={{
137
+ source: props.mentionSource,
138
+ currentUser,
139
+ comments,
140
+ allowCompose,
141
+ optimisticMutations: true,
142
+ onRetryMutation: (id) => mutations.retry(id),
143
+ showReactions: Boolean(props.reactions),
144
+ allowReact: Boolean(props.reactions?.onChange),
145
+ enableImages: Boolean(props.images?.onAddComment),
146
+ enableEditImages: Boolean(props.images?.onUpdateComment),
147
+ enablePrivateComments: false,
148
+ enableDocuments: props.enableDocuments ?? false,
149
+ autoScrollNewReplies: false,
150
+ emptyState: null,
151
+ submittedReplyID: submitted ? mutations.replyID(submitted) : undefined,
152
+ onScrolledToReply: () => setSubmitted(undefined),
153
+ listHeader: <ReadStatus loading={props.loading} error={props.error} onRetry={props.onRetry}
154
+ empty={!comments.some((thread) => thread.replies.length > 0)} hasMore={props.pagination?.hasMore} />,
155
+ listFooter: props.pagination && <PaginationFooter pagination={props.pagination}
156
+ loading={props.loading || Boolean(props.error)} getScrollContainer={props.getScrollContainer} />,
157
+ onMentionClick: props.onMentionClick,
158
+ getCommentLink: (commentID, reply) => props.getCommentLink?.({ recordID: props.recordID, commentID, replyID: reply.replyID }) ?? '',
159
+ onSubmit: (draft) => {
160
+ const current = latest.current;
161
+ preparePanelComment(current, draft); // Validate before accepting/clearing the draft.
162
+ const thread = base.find((item) => item.commentID === draft.commentID);
163
+ const id = mutations.add(thread, draft, currentUser, () => preparePanelComment(latest.current, draft)());
164
+ setSubmitted(id);
165
+ },
166
+ onEdit: (commentID, replyID, value, images) => {
167
+ const draft = { value, images, private: false };
168
+ preparePanelEdit(latest.current, commentID, replyID, draft);
169
+ const thread = base.find((item) => item.commentID === commentID)!;
170
+ mutations.edit(thread, replyID, draft, () => preparePanelEdit(latest.current, commentID, replyID, draft)());
171
+ },
172
+ onDelete: (commentID, replyID) => {
173
+ if (!replyID) throw new Error('未选择要删除的评论');
174
+ preparePanelDeletion(latest.current, commentID, replyID);
175
+ const thread = base.find((item) => item.commentID === commentID)!;
176
+ mutations.delete(thread, replyID, () => preparePanelDeletion(latest.current, commentID, replyID)());
177
+ },
178
+ onToggleReaction: async (commentID, replyID, emojiKey) => {
179
+ await locked(replyID, async () => {
180
+ const current = latest.current;
181
+ const feature = current.reactions;
182
+ if (!feature?.onChange) throw new Error('未提供回应写入能力');
183
+ const target: CommentTarget = { recordID: current.recordID, commentID, replyID };
184
+ if (feature.canChange?.(target) === false) throw new Error('表情状态尚未加载或当前身份不可用');
185
+ const existing = feature.getReactions(target).find((reaction) => reaction.emojiKey === emojiKey ||
186
+ (emojiKey === 'THUMBSUP' && reaction.emojiKey === 'MediumThumbsup'));
187
+ await feature.onChange({ ...target, emojiKey: existing?.emojiKey ?? emojiKey, action: existing?.reactedByMe ? 'remove' : 'add' });
188
+ });
189
+ },
190
+ loadReactionDetails: props.reactions?.loadUsers ? (commentID, replyID, signal) => loadPanelReactionDetails(
191
+ latest.current, { recordID: props.recordID, commentID, replyID }, signal,
192
+ ) : undefined,
193
+ }}>{props.children}</DiscussionProvider>;
194
+ }
195
+
196
+ /** Shared record session, with no layout DOM and no persistence implementation. */
197
+ export function CommentsProvider(props: CommentsProviderProps) {
198
+ const features = useMemo(() => commentExtensionFeatures(props.extensions), [props.extensions]);
199
+ const mentionSource = useMemo(() => features.documents
200
+ ? withDocumentSource(props.mentionSource, features.documents) : props.mentionSource,
201
+ [props.mentionSource, features.documents]);
202
+ return <PanelSession key={JSON.stringify([props.scopeKey, props.recordID])} {...props}
203
+ mentionSource={mentionSource} reactions={features.reactions ?? props.reactions}
204
+ images={features.images ?? props.images} enableDocuments={Boolean(features.documents) || props.enableDocuments} />;
205
+ }
206
+
207
+ /** The theme is scoped to this leaf; no panel heading, card or fixed height. */
208
+ export const CommentList = forwardRef<HTMLDivElement, CommentListProps>(function CommentList(
209
+ { className = '', style, ...props }, ref,
210
+ ) {
211
+ return <div {...props} ref={ref} data-slot="comment-list"
212
+ className={`${COMMENT_THEME_CLASS} replica-comment-panel replica-discussion ${className}`} style={{ height: 'auto', ...style }}>
213
+ <DiscussionList style={{ overflowY: 'visible' }} />
214
+ </div>;
215
+ });
216
+
217
+ export const CommentComposer = forwardRef<HTMLDivElement, CommentComposerProps>(function CommentComposer(
218
+ { placeholder, disabled, className = '', style, ...props }, ref,
219
+ ) {
220
+ return <div {...props} ref={ref} data-slot="comment-composer"
221
+ className={`${COMMENT_THEME_CLASS} replica-discussion ${className}`} style={{ height: 'auto', ...style }}>
222
+ <DiscussionComposerView placeholder={placeholder} disabled={disabled} />
223
+ </div>;
224
+ });
225
+
226
+ /** Compatibility composition with the existing heading, list and footer layout. */
227
+ export function CommentPanel({ className, style, ...props }: CommentPanelProps) {
228
+ return <CommentsProvider {...props}>
229
+ <div className={`replica-comment-panel ${className ?? ''}`} style={style}><DiscussionLayout /></div>
230
+ </CommentsProvider>;
231
+ }
@@ -0,0 +1,129 @@
1
+ import type { CommentImageFeature, CommentReactionFeature } from './comment-panel.contract';
2
+ import type { CommentDataSource, CommentDocumentSource, MentionResult } from './types';
3
+ import { mentionKey } 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
+ search: (args) => load(args, false),
108
+ recommend: (args) => load({ ...args, query: '' }, true),
109
+ async resolve(refs, signal) {
110
+ const users = refs.filter((ref) => ref.kind === 'user');
111
+ const docs = refs.filter((ref) => ref.kind === 'document');
112
+ // One failed service must not hide successful metadata from the other.
113
+ const results = await Promise.allSettled([
114
+ users.length ? people.resolve(users, signal) : [],
115
+ docs.length ? documents.resolve(docs, signal) : [],
116
+ ]);
117
+ signal.throwIfAborted();
118
+ const byKey = new Map<string, MentionResult>();
119
+ for (const result of results)
120
+ if (result.status === 'fulfilled')
121
+ for (const item of result.value)
122
+ byKey.set(mentionKey(item.ref), item);
123
+ return refs.map(
124
+ (ref) =>
125
+ byKey.get(mentionKey(ref)) || { ref, status: 'error' as const },
126
+ );
127
+ },
128
+ };
129
+ }
@@ -0,0 +1,9 @@
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';