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

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 (23) hide show
  1. package/package.json +1 -1
  2. package/template/client/src/components/business-ui/README.md +1 -1
  3. package/template/client/src/components/business-ui/comment/README.md +18 -86
  4. package/template/client/src/components/business-ui/comment/app-source.ts +175 -0
  5. package/template/client/src/components/business-ui/comment/comment-content.tsx +7 -3
  6. package/template/client/src/components/business-ui/comment/comment-input.tsx +119 -465
  7. package/template/client/src/components/business-ui/comment/comment-provider.tsx +2 -175
  8. package/template/client/src/components/business-ui/comment/content.ts +74 -8
  9. package/template/client/src/components/business-ui/comment/document-icon.tsx +104 -0
  10. package/template/client/src/components/business-ui/comment/document-icons.ts +19 -0
  11. package/template/client/src/components/business-ui/comment/editor.tsx +419 -0
  12. package/template/client/src/components/business-ui/comment/entity-mention.ts +276 -0
  13. package/template/client/src/components/business-ui/comment/entity-store.ts +97 -0
  14. package/template/client/src/components/business-ui/comment/mention-appearance.ts +35 -0
  15. package/template/client/src/components/business-ui/comment/mention-provider.tsx +2 -97
  16. package/template/client/src/components/business-ui/comment/mention-suggestions.tsx +4 -103
  17. package/template/client/src/components/business-ui/comment/types.ts +5 -36
  18. package/template/client/src/components/business-ui/comment/use-mention-search.ts +107 -0
  19. package/template/package-lock.json +540 -0
  20. package/template/package.json +11 -1
  21. package/template/client/src/components/business-ui/comment/dom-editor.ts +0 -582
  22. package/template/client/src/components/business-ui/comment/image-attachments.tsx +0 -375
  23. package/template/client/src/components/business-ui/comment/mention-view.tsx +0 -171
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/coding-template-nestjs-react-fullstack",
3
- "version": "0.1.55-alpha.20261030081308",
3
+ "version": "0.1.55-alpha.20261030085054",
4
4
  "description": "NestJS + React fullstack template for miaoda coding",
5
5
  "type": "module",
6
6
  "files": [
@@ -522,7 +522,7 @@ function BasicExample() {
522
522
 
523
523
  `comment/comment-content.tsx` 提供不依赖 Tiptap 的只读展示,配合 `comment/mention-provider.tsx` 批量补全人员和文档信息。
524
524
 
525
- 评论输入框已内置。从 `@/components/business-ui/comment/comment-input` 直接导入 `CommentInput`,组件提供 getContent/clear/setEditable/focus ref API 和 onSubmitRequest 快捷键回调,发送、取消按钮及请求状态由页面组合,原生 div 编辑器直接可编辑;支持选择/粘贴图片并在正文下方展示附件,使用 getImages/clearImages 管理。只读 `CommentContent` 不依赖编辑器。作者、时间、线程与接口调用由消费页面组合。
525
+ 评论输入框已内置。从 `@/components/business-ui/comment/comment-input` 直接导入 `CommentInput`,组件提供 getContent/clear/setEditable/focus ref API 和 onSubmitRequest 快捷键回调,发送、取消按钮及请求状态由页面组合,首次激活时动态加载最小 Tiptap 编辑器。只读 `CommentContent` 不依赖编辑器。作者、时间、线程与接口调用由消费页面组合。
526
526
 
527
527
  数据源和真实服务的接入要求见 [CommentInput 使用说明](./comment/README.md)。输入与只读列表应共享同一 CommentProvider;CCM 的 Lark ID 与模板的 apaas ID 不能混用。
528
528
 
@@ -3,7 +3,7 @@
3
3
  面向 AI 生成的多维表格评论界面,只提供两个基础组件:
4
4
 
5
5
  - `CommentContent`:直接渲染 `reply.content`,内部补全人员与文档信息。
6
- - `CommentInput`:使用 contenteditable div 编辑文字、@人员与文档,支持独立图片附件区,暴露 ref API。按钮、请求、loading 和发送错误由页面组合。
6
+ - `CommentInput`:编辑文字、@人员与文档,暴露 ref API。按钮、请求、loading 和发送错误由页面组合。
7
7
 
8
8
  作者、时间、评论线程、回复、分页和解决状态均由业务页面处理。组件不包含这些业务包装。
9
9
 
@@ -20,7 +20,7 @@ type CommentApiContent =
20
20
 
21
21
  userID 是妙搭用户 ID,与 UserDisplay 一致。缺失 userID 的历史 mention 显示“引用信息缺失”,读取时仍原样返回,是否提交由页面决定。数字 ID 不得静默舍入。
22
22
 
23
- 文档通过 link(url) 提交,内部 token、类型、标题和图标不进入接口数据。识别出的飞书文档 URL 可按 token 补全元数据;其他 URL 显示为普通链接。图片是正文下方的独立附件,不混入这三个 content 类型;旧 CCM `<at>` 内容格式不在组件范围内。
23
+ 文档通过 link(url) 提交,内部 token、类型、标题和图标不进入接口数据。识别出的飞书文档 URL 可按 token 补全元数据;其他 URL 显示为普通链接。图片和旧 CCM `<at>` 内容格式不在组件范围内。
24
24
 
25
25
  ## 使用
26
26
 
@@ -30,16 +30,9 @@ import { CommentInput } from '@/components/business-ui/comment/comment-input';
30
30
  import { CommentContent } from '@/components/business-ui/comment/comment-content';
31
31
  import { CommentProvider } from '@/components/business-ui/comment/comment-provider';
32
32
  import { emptyComment } from '@/components/business-ui/comment/content';
33
- import type {
34
- CommentInputHandle,
35
- CommentSubmitPayload,
36
- } from '@/components/business-ui/comment/types';
37
-
38
- export function CommentExample({
39
- save,
40
- }: {
41
- save: (body: CommentSubmitPayload) => Promise<void>;
42
- }) {
33
+ import type { CommentInputHandle, CommentSubmitPayload } from '@/components/business-ui/comment/types';
34
+
35
+ export function CommentExample({ save }: { save: (body: CommentSubmitPayload) => Promise<void> }) {
43
36
  const inputRef = useRef<CommentInputHandle>(null);
44
37
  const busy = useRef(false);
45
38
  const [value, setValue] = useState(emptyComment);
@@ -63,32 +56,14 @@ export function CommentExample({
63
56
  setSending(false);
64
57
  }
65
58
  };
66
- return (
67
- <CommentProvider>
68
- <CommentInput
69
- ref={inputRef}
70
- documentKey="new"
71
- value={value}
72
- onChange={setValue}
73
- onSubmitRequest={() => {
74
- void handleSend();
75
- }}
76
- />
77
- <button disabled={sending} onClick={() => inputRef.current?.clear()}>
78
- 取消
79
- </button>
80
- <button
81
- disabled={sending}
82
- onClick={() => {
83
- void handleSend();
84
- }}
85
- >
86
- 发送
87
- </button>
88
- {error ? <p role="alert">{error}</p> : null}
89
- <CommentContent value={value} />
90
- </CommentProvider>
91
- );
59
+ return <CommentProvider>
60
+ <CommentInput ref={inputRef} documentKey="new" value={value}
61
+ onChange={setValue} onSubmitRequest={() => { void handleSend(); }} />
62
+ <button disabled={sending} onClick={() => inputRef.current?.clear()}>取消</button>
63
+ <button disabled={sending} onClick={() => { void handleSend(); }}>发送</button>
64
+ {error ? <p role="alert">{error}</p> : null}
65
+ <CommentContent value={value} />
66
+ </CommentProvider>;
92
67
  }
93
68
  ```
94
69
 
@@ -98,13 +73,11 @@ export function CommentExample({
98
73
 
99
74
  - `getContent()`:读取最新接口 content,不做发送状态校验;编辑器尚未加载时也可读取草稿。
100
75
  - `clear()`:普通的正文清空操作,保留撤销历史,不重建编辑会话。
101
- - `getImages()`:读取附件状态与上传结果的快照,不自动转换成多维表格图片协议。
102
- - `clearImages()`:删除全部附件并取消/忽略上传,释放本地预览地址。附件独立于正文撤销历史。
103
- - `setEditable(boolean)`:控制正文、上传按钮及附件删除/重试;disabled prop 是外部硬禁用。
104
- - `focus()`:聚焦输入框。
76
+ - `setEditable(boolean)`:控制编辑;disabled prop 是外部硬禁用。
77
+ - `focus()`:聚焦输入框,必要时加载编辑器。
105
78
  - `onSubmitRequest`:Enter(或 submitMode="mod-enter")通知页面;输入法确认和 @候选选择优先处理,未配置时 Enter 换行。
106
79
 
107
- 原生 div 编辑器直接挂载,输入区无需二次点击激活;不依赖 Tiptap/ProseMirror。图片存储 SDK 仍按需导入。popover 挂在 Dialog/Sheet 时可传 portalContainer。React 管理外框、候选面板与附件,正文 DOM 由 DivEditor 独立管理,避免受控回写破坏光标和输入法。
80
+ 入口动态 import 最小 Tiptap 3.20.0,首次激活时挂载编辑器;只读组件不导入 Tiptap。加载失败可重试。popover 挂在 Dialog/Sheet 时可传 portalContainer。
108
81
 
109
82
  ## 搜索与回显
110
83
 
@@ -115,14 +88,9 @@ recommend 用于刚输入 @ 或清空关键词;search 防抖 200ms,并忽略
115
88
  文档独立配置 `<CommentProvider documents={documents}>`,输入框再开启 enableDocuments:
116
89
 
117
90
  ```ts
118
- import {
119
- createCcmDocumentSource,
120
- createBrowserCcmRequest,
121
- } from '@/components/business-ui/comment/ccm-document-source';
91
+ import { createCcmDocumentSource, createBrowserCcmRequest } from '@/components/business-ui/comment/ccm-document-source';
122
92
  const documents = createCcmDocumentSource({
123
- scopeKey: currentUserScope,
124
- token: hostDocumentToken,
125
- objType: hostDocumentType,
93
+ scopeKey: currentUserScope, token: hostDocumentToken, objType: hostDocumentType,
126
94
  request: createBrowserCcmRequest(authenticatedProxyPath),
127
95
  search: searchCloudDocuments, // 可选:宿主提供完整文档搜索
128
96
  });
@@ -139,39 +107,3 @@ const documents = createCcmDocumentSource({
139
107
  CommentContent 的 onMentionClick(ref, anchor) 仅透出人员点击,由消费页面复用 UserProfile。元数据加载前也可点击,回显更新保持锚点 DOM 稳定。
140
108
 
141
109
  在生成应用中运行 `npm run type:check:client` 和 `npm run build:client`;浏览器中另行验证中文输入法、键盘候选操作、撤销、回填和弹层位置。类型检查与构建不代表真实接口鉴权或视觉验收完成。
142
-
143
- ## 图片附件与权限意图
144
-
145
- ```tsx
146
- <CommentInput
147
- ref={inputRef}
148
- documentKey="new"
149
- value={value}
150
- onChange={setValue}
151
- enableDocuments
152
- enableImages
153
- onImagesChange={setImages}
154
- grantMentionAccess={grantMentionAccess}
155
- onGrantMentionAccessChange={setGrantMentionAccess}
156
- />
157
- ```
158
-
159
- 图片区按参考图排列在正文下方,64px 缩略图、圆角与右上角删除按钮;点击缩略图可预览。选择文件、粘贴或拖入正文均追加到附件区,不占正文光标位置。支持 PNG/JPEG/WebP/GIF/AVIF/BMP;默认最多 9 张、单张 10 MiB,通过 maxImages/maxImageSize 调整。最多 3 个并发上传,上传中显示加载状态,失败可重试。
160
-
161
- 默认延迟导入模板已有的 api/files/service 上传到妙搭存储。可通过 uploadImage(file, { signal }) 注入自定义服务,返回 `{ id, url, filePath?, bucketId? }`。删除、clearImages、切换 documentKey、切换数据源作用域和卸载都会取消或忽略迟到的上传结果;默认 SDK 不支持真正取消网络请求。删除和卸载时释放 Object URL;失败文件仅保留到重试或删除。
162
-
163
- getImages 返回 `{ id, name, size, previewUrl, status, result?, error? }[]`:id 是本地附件 ID,result.id 才是服务端文件 ID;previewUrl 是临时 Blob URL,不能提交或持久化。页面通过 onImagesChange 判断是否仍有 uploading/error 状态,自行控制发送按钮,并把 result 映射到真实接口。当前未确认多维表格图片提交协议,因此 getContent 继续只返回 text/mention/link,CommentContent 继续只渲染正文。
164
-
165
- 如需在发送成功后重置整个输入区,依次调用 clear() 与 clearImages()。这两个方法不执行发送校验,也不处理发送失败;失败时是否保留内容由页面决定。切换 documentKey 表示新会话,正文撤销和附件都重置。
166
-
167
- “授予提及的人阅读权限”仅在传入 onGrantMentionAccessChange 时显示,属于受控的业务意图,组件不直接调用文档授权接口。消费页面负责结合勾选值执行真实授权。
168
-
169
- 正文使用统一的自有撤销历史,覆盖文字、引用插入/删除、粘贴和 clear。连续文字合并为一组,输入法组合输入作为一组;元数据回显不产生编辑历史,外部替换 value 会重置正文历史。正文直接使用 CommentValue,不保留 Tiptap 文档结构转换层。
170
-
171
- ## 文件组织
172
-
173
- comment-input.tsx 提供校验、错误边界与原生编辑交互,dom-editor.ts 管理正文与撤销,image-attachments.tsx 管理独立附件。comment-content.tsx 独立提供只读展示,不依赖编辑器。
174
-
175
- mention-provider.tsx 放上下文和缓存;comment-provider.tsx 放默认人员服务、文档混合数据源与便捷 Provider。mention-suggestions.tsx 放候选面板和搜索 Hook;mention-view.tsx 放引用样式与文档图标。content.ts/types.ts 放协议转换与类型,ccm-document-source.ts 是可选的宿主文档适配器。
176
-
177
- preloadCommentEditor 保留为兼容空操作;编辑器直接挂载后无需预加载。
@@ -0,0 +1,175 @@
1
+ import { listUsersByIds, searchUsers } from '../api/users/service';
2
+ import { getI18nText } from '../utils/user';
3
+ import { mentionKey } from './content';
4
+ import type {
5
+ CommentDataSource,
6
+ CommentDocumentSource,
7
+ MentionOption,
8
+ MentionResult,
9
+ } from './types';
10
+
11
+ function avatarUrl(avatar: unknown): string | undefined {
12
+ if (typeof avatar === 'string') return avatar;
13
+ if (avatar && typeof avatar === 'object' && 'image' in avatar) {
14
+ const image = avatar.image;
15
+ if (
16
+ image &&
17
+ typeof image === 'object' &&
18
+ 'large' in image &&
19
+ typeof image.large === 'string'
20
+ )
21
+ return image.large;
22
+ }
23
+ return undefined;
24
+ }
25
+
26
+ /** The same Miaoda user IDs and built-in service used by UserDisplay. */
27
+ export function createAppCommentSource(scopeKey: string): CommentDataSource {
28
+ return {
29
+ scopeKey,
30
+ async search({ query, cursor, signal }) {
31
+ signal.throwIfAborted();
32
+ // The built-in directory has no empty-query recommendations. In a mixed
33
+ // source, leave that branch empty so document recommendations still load.
34
+ if (!query) return { items: [] };
35
+ const offset = Number(cursor || 0);
36
+ if (!Number.isSafeInteger(offset) || offset < 0)
37
+ throw new Error('分页参数无效');
38
+ const response = await searchUsers({ query, offset, pageSize: 20 });
39
+ signal.throwIfAborted();
40
+ if (String(response.status_code) !== '0')
41
+ throw new Error('人员搜索失败,请重试');
42
+ const users = response.data.userList ?? [];
43
+ const items: MentionOption[] = users.flatMap((user) => {
44
+ // Unregistered contacts have no Miaoda ID; never substitute a Lark ID.
45
+ const id = String(user.userID || '');
46
+ if (!/^[1-9]\d*$/.test(id)) return [];
47
+ return [
48
+ {
49
+ ref: { kind: 'user' as const, idType: 'apaas-user' as const, id },
50
+ label: getI18nText(user.name) || '未命名人员',
51
+ avatarUrl: avatarUrl(user.avatar),
52
+ description: getI18nText(user.department?.name) || user.tenantName,
53
+ },
54
+ ];
55
+ });
56
+ const nextOffset = offset + users.length;
57
+ return {
58
+ items,
59
+ nextCursor:
60
+ users.length && nextOffset < response.data.total
61
+ ? String(nextOffset)
62
+ : undefined,
63
+ };
64
+ },
65
+ async resolve(refs, signal) {
66
+ signal.throwIfAborted();
67
+ const ids = refs.flatMap((ref) =>
68
+ ref.kind === 'user' && ref.idType === 'apaas-user' ? [ref.id] : [],
69
+ );
70
+ const response = ids.length ? await listUsersByIds(ids) : undefined;
71
+ signal.throwIfAborted();
72
+ const base = response?.data?.baseResp;
73
+ if (base?.statusCode)
74
+ throw new Error(base.statusMessage || '人员信息加载失败');
75
+ if (ids.length && !response?.data?.userInfoMap)
76
+ throw new Error('人员信息返回格式无效');
77
+ const people = response?.data?.userInfoMap || {};
78
+ return refs.map((ref): MentionResult => {
79
+ if (ref.kind !== 'user' || ref.idType !== 'apaas-user')
80
+ return { ref, status: 'error' };
81
+ const user = people[ref.id];
82
+ if (!user) return { ref, status: 'not-found' };
83
+ return {
84
+ ref,
85
+ status: 'resolved',
86
+ label: getI18nText(user.name) || '未命名人员',
87
+ avatarUrl: avatarUrl(user.avatar),
88
+ };
89
+ });
90
+ },
91
+ };
92
+ }
93
+
94
+ /** Keep Miaoda people IDs and cloud-document tokens in their own services. */
95
+ export function withDocumentSource(
96
+ people: CommentDataSource,
97
+ documents: CommentDocumentSource,
98
+ ): CommentDataSource {
99
+ const load = async (
100
+ args: Parameters<CommentDataSource['search']>[0],
101
+ recommend: boolean,
102
+ ) => {
103
+ const fetchSource = (
104
+ source: CommentDataSource | CommentDocumentSource,
105
+ kind: 'user' | 'document',
106
+ cursor?: string,
107
+ ) =>
108
+ recommend && source.recommend
109
+ ? source.recommend({ ...args, kind, cursor })
110
+ : source.search({ ...args, kind, cursor });
111
+ if (args.kind === 'user') return fetchSource(people, 'user', args.cursor);
112
+ if (args.kind === 'document')
113
+ return fetchSource(documents, 'document', args.cursor);
114
+ const cursor: { user?: string; document?: string } = args.cursor
115
+ ? JSON.parse(args.cursor)
116
+ : {};
117
+ if (
118
+ !cursor ||
119
+ typeof cursor !== 'object' ||
120
+ [cursor.user, cursor.document].some(
121
+ (value) => value !== undefined && typeof value !== 'string',
122
+ )
123
+ )
124
+ throw new Error('分页参数无效');
125
+ const exhausted: Awaited<ReturnType<CommentDataSource['search']>> = {
126
+ items: [],
127
+ };
128
+ const [users, docs] = await Promise.all([
129
+ !args.cursor || cursor.user !== undefined
130
+ ? fetchSource(people, 'user', cursor.user)
131
+ : exhausted,
132
+ !args.cursor || cursor.document !== undefined
133
+ ? fetchSource(documents, 'document', cursor.document)
134
+ : exhausted,
135
+ ]);
136
+ return {
137
+ items: [
138
+ ...users.items.filter((item) => item.ref.kind === 'user'),
139
+ ...docs.items.filter((item) => item.ref.kind === 'document'),
140
+ ],
141
+ nextCursor:
142
+ users.nextCursor || docs.nextCursor
143
+ ? JSON.stringify({
144
+ user: users.nextCursor,
145
+ document: docs.nextCursor,
146
+ })
147
+ : undefined,
148
+ };
149
+ };
150
+ return {
151
+ ...people,
152
+ scopeKey: JSON.stringify([people.scopeKey, documents.scopeKey]),
153
+ search: (args) => load(args, false),
154
+ recommend: (args) => load({ ...args, query: '' }, true),
155
+ async resolve(refs, signal) {
156
+ const users = refs.filter((ref) => ref.kind === 'user');
157
+ const docs = refs.filter((ref) => ref.kind === 'document');
158
+ // One failed service must not hide successful metadata from the other.
159
+ const results = await Promise.allSettled([
160
+ users.length ? people.resolve(users, signal) : [],
161
+ docs.length ? documents.resolve(docs, signal) : [],
162
+ ]);
163
+ signal.throwIfAborted();
164
+ const byKey = new Map<string, MentionResult>();
165
+ for (const result of results)
166
+ if (result.status === 'fulfilled')
167
+ for (const item of result.value)
168
+ byKey.set(mentionKey(item.ref), item);
169
+ return refs.map(
170
+ (ref) =>
171
+ byKey.get(mentionKey(ref)) || { ref, status: 'error' as const },
172
+ );
173
+ },
174
+ };
175
+ }
@@ -1,6 +1,6 @@
1
+ import { DocumentIcon } from './document-icon';
1
2
  import { fromApiContent } from './content';
2
3
  import {
3
- DocumentIcon,
4
4
  USER_MENTION_CLASS,
5
5
  INPUT_USER_MENTION_CLASS,
6
6
  INPUT_USER_MENTION_LABEL_CLASS,
@@ -10,10 +10,14 @@ import {
10
10
  USER_MENTION_LABEL_CLASS,
11
11
  DOCUMENT_MENTION_CLASS,
12
12
  DOCUMENT_MENTION_UNREADABLE_CLASS,
13
- } from './mention-view';
13
+ } from './mention-appearance';
14
14
  import * as React from 'react';
15
15
  import { cn } from '@/lib/utils';
16
- import { mentionLabel, normalizeValue, safeUrl } from './content';
16
+ import {
17
+ mentionLabel,
18
+ normalizeValue,
19
+ safeUrl,
20
+ } from './content';
17
21
  import {
18
22
  useMentionContext,
19
23
  useMentionEntity,