@lark-apaas/coding-template-nestjs-react-fullstack 0.1.59-alpha.20261105074252 → 0.1.60-alpha.20261105085132

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 +681 -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 +1440 -207
  26. package/template/package.json +17 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/coding-template-nestjs-react-fullstack",
3
- "version": "0.1.59-alpha.20261105074252",
3
+ "version": "0.1.60-alpha.20261105085132",
4
4
  "description": "NestJS + React fullstack template for miaoda coding",
5
5
  "type": "module",
6
6
  "files": [
@@ -1,5 +1,7 @@
1
1
  # 业务组件文档
2
2
 
3
+ 记录评论组件入口:`@/components/business-ui/record-comments`。使用前阅读 [组件接口与接入说明](record-comments/README.md)。评论请求与分页游标留在宿主,组件负责回复、编辑删除、人员提及、乐观更新及可选表情/文档引用。
4
+
3
5
  本文档包含用户选择、部门选择、群组选择、用户展示等业务组件的使用说明。
4
6
 
5
7
  ---
@@ -0,0 +1,141 @@
1
+ # Record comments
2
+
3
+ 可组合的受控记录评论组件,推荐入口 `CommentsProvider`、`CommentList`、`CommentComposer`;保留完整布局入口 `CommentPanel` 兼容旧接入。公共合同见 `comment-panel.contract.ts`、`types.ts`,统一从 `index.ts` 导入;目录内 internal 实现、集中样式和 `assets/reaction-sprite.png` 必须整体分发。不依赖旧 comment 目录,不包含测试应用、插件实例或表格配置。模板已配置 React、Tiptap 3.20.0、floating-ui 及 ui primitives 依赖。
4
+
5
+ 组件管理现有样式、回复归属、编辑器、人员弹层、菜单、分页触发和乐观更新;Provider 不产生 DOM,标题、卡片、滚动容器与输入框位置由宿主组合;评论 CRUD、身份、权限、插件调用、服务端缓存、分页游标、表情及文档请求由宿主提供。
6
+
7
+ ## Agent 接入与维护路径
8
+
9
+ 普通接入只读本说明和 `comment-panel.contract.ts`,从目录入口导入;人员/文档来源的详细类型按需查 `types.ts`。新增页面只在宿主 hook 中实现读取与写入,不需要遍历或修改内部编辑器。
10
+
11
+ | 位置 | 职责 |
12
+ | --- | --- |
13
+ | `index.ts` | 唯一推荐导入入口:组件、公共类型、目录适配器和 URL 工具 |
14
+ | `comment-panel.contract.ts` / `types.ts` | 评论受控接口及提及来源类型 |
15
+ | `comment-panel.tsx` | 公共 Provider、列表与输入框入口;受控数据、分页和乐观更新;兼容完整面板 |
16
+ | `app-source.ts` | 可选的妙搭人员目录适配器,不含评论 CRUD |
17
+ | `extensions.ts` | Provider 级能力注册、纯文档来源组合;不含请求实现和全局注册表 |
18
+ | `internal/model.ts` / `mutations.ts` | 内容转换、命令校验、时间/提及规则、乐观更新状态 |
19
+ | `internal/discussion.tsx` / `composer.tsx` | 共享回复状态、列表/输入装配、评论菜单、行内编辑和编辑器 |
20
+ | `internal/editor.tsx` / `suggestions.tsx` / `mentions.tsx` | Tiptap、候选选择与引用解析缓存 |
21
+ | `internal/content.tsx` / `icons.tsx` / `images.tsx` / `reactions.tsx` | 内容展示、图标、图片扩展和表情回应 |
22
+ | `internal/view-types.ts` / `reaction-catalog.ts` / `styles.css` | 内部视图类型、静态表情数据、集中样式 |
23
+
24
+ `internal/` 只由组件实现与组件测试引用;应用代码不深引其内部文件。文档适配器如需 URL 校验/识别,从入口(纯数据适配器也可从 `app-source.ts`)导入 `safeUrl/documentRefFromUrl`。样式由公共入口一次加载,分发时保留整个目录和 assets;不需要逐个导入 CSS。修复内部行为时沿上表定位一个职责,不为每个小工具再拆文件。
25
+
26
+ ## 接入
27
+
28
+ 必填 `scopeKey/recordID/comments/mentionSource`。读取与写入回调放在 Provider,一次传入;列表和输入框通过同一 Provider 自动联动。完整类型见 `CommentsProviderProps`、`CommentListProps`、`CommentComposerProps` 和 `CommentExtension`,旧 `CommentPanelProps` 保持兼容。
29
+
30
+ ```tsx
31
+ import { useMemo } from 'react';
32
+ import {
33
+ CommentsProvider, CommentList, CommentComposer, createAppCommentSource,
34
+ type CommentsProviderProps,
35
+ } from '@/components/business-ui/record-comments';
36
+
37
+ type Props = Omit<CommentsProviderProps, 'mentionSource' | 'children'>;
38
+ export function RecordComments(props: Props) {
39
+ const source = useMemo(() => createAppCommentSource(props.scopeKey), [props.scopeKey]);
40
+ return (
41
+ <CommentsProvider {...props} mentionSource={source}>
42
+ <section className="flex h-full min-h-0 flex-col">
43
+ <h2 className="px-4 py-3">讨论</h2>
44
+ <div className="min-h-0 flex-1 overflow-y-auto">
45
+ <CommentList />
46
+ </div>
47
+ <div className="p-4">
48
+ <CommentComposer placeholder="添加评论…" />
49
+ </div>
50
+ </section>
51
+ </CommentsProvider>
52
+ );
53
+ }
54
+ ```
55
+
56
+ - `CommentsProvider` 只提供一个记录/身份的共享会话,不产生布局 DOM。切换 `scopeKey/recordID` 会清理回复目标、编辑草稿、乐观层和人员解析会话;旧异步结果不会进入新会话。
57
+ - `CommentList` 包含讨论串、消息菜单、行内编辑、读取状态与分页触发,不包含标题和主输入框。列表靠内容自然撑开,滚动/高度由宿主安排;可只挂载列表,此时不显示无法联动的回复入口。编辑/删除仍按宿主传入的回调和权限开放。
58
+ - `CommentComposer` 包含输入、@ 选择、发送及当前回复联动,不包含面板 footer 的外围间距。可只挂载输入框,Provider 仍传 `comments={[]}` 与真实 `onAddComment`,用于新增讨论。同一 Provider 只挂载一个主输入框;行内编辑器不受此限制。支持 `placeholder/disabled`。
59
+ - 两个视图分别支持 `className/style`、普通 div 属性和 DOM ref,标记为 `data-slot="comment-list"` / `data-slot="comment-composer"`。主题样式由各自视图提供,无需给宿主加内部样式类。输入框通过 React Portal 放入 Sheet/Dialog 仍能共享状态;人员候选弹层留在所在模态容器内。
60
+ - 点击列表的回复自动定位输入框、插入作者提及,并保留原 commentID;新增/编辑/删除和回读去重共用原有乐观层。宿主不要再实现一份 replyTarget 或临时消息队列。行内编辑继续在目标消息位置完成。
61
+ - 保留 `<CommentPanel {...props} />` 的原有标题、列表和 footer 布局,不改变旧项目传参。普通新页面推荐上述组合式入口;旧模板如果只有 CommentPanel,继续使用它,升级整个组件后再切换,禁止从 internal 深引来拼凑新入口。
62
+
63
+ 数据和回调接宿主真实 API hook。`createAppCommentSource` 是可选的妙搭人员目录适配器,不含评论请求;其他目录直接实现 `CommentDataSource`。默认没有空查询推荐,可由宿主补 `recommend`。裸 @ 没有推荐结果时不弹空菜单;输入关键词后才展示搜索结果或官方无结果文案,读取失败仍保留恢复入口。旧来源若通过空 query 的 search 返回推荐结果,仍正常显示。scopeKey 必须覆盖应用、租户、身份、插件、数据源及权限;上述上下文变化时更换。mentionSource 用 useMemo 稳定,scopeKey/recordID 变化会清理旧会话。
64
+
65
+ `commentID/replyID` 保留字符串;`createTime/updateTime` 为 Unix 秒。每条 comments 是讨论,第一条 reply 为正文,其余为同讨论回复。回复必须带原 commentID,不拆成独立消息。缺失 mention.userID 保留缺失引用,不猜人员。写 content 只含 text、mention.userID、link.url,不含编辑器元数据或文件。
66
+
67
+ 编辑/删除须显式提供 `getReplyPermissions`,缺省隐藏;前端菜单不替代后端鉴权。消息菜单点击“删除”即发起请求,无二次确认;始终传所选 replyID(含首条),只删除该消息。删除首条后第二条在原 commentID 下顺位成为正文,保留其 replyID/作者/时间,其他回复不变;最后一条删除后移除空讨论。省略 replyID 的历史整串删除仅供显式外部调用,组件菜单不使用。
68
+
69
+ ## 乐观更新与时间
70
+
71
+ 写回调只负责真实写入,成功返回真实 ID(删除返回 commentDeleted);后续回读单独执行,不因回读失败 reject 已提交写入,不在宿主重复插入临时消息。
72
+
73
+ 新增/回复立即显示并清空编辑器,编辑立即显示新文本并退出编辑。提交期间隐藏时间;新增/回复纯文字消息等待 500ms 后显示蓝色 loading,编辑不显示该 spinner;成功立即结束等待,不等回读。明确失败保留灰色内容、错误图标/文案与点击重试。未确认消息禁止继续编辑、回复或回应,其他消息与输入正常可用。
74
+
75
+ 普通 Error 仅表示确认未应用;超时/连接中断可能已写入时抛 `Object.assign(new Error(message), {outcome:'unknown'})`,组件保留内容并阻止盲目重发,宿主先刷新核对。只有确认请求未发出时使用 not-applied。
76
+
77
+ 匹配快照接管前,本地层保留消息防闪回;接管后去重。宿主须保护缓存读取顺序,组件不能从无版本号数组识别旧快照。传入 comments 不会被修改。卸载清理本地预览,旧回调不能更新新会话。
78
+
79
+ 显示时间取 `updateTime ?? createTime`,编辑标记取 `updateTime > createTime`,排序仍用创建时间。编辑确认、回读前临时用本次操作时间,回读后以服务端回复级 updateTime 为准。服务端新编辑可接管已确认草稿。秒级时间不是严格版本号,缺 updateTime 不能伪造历史编辑时间。
80
+
81
+ ## 扩展注册
82
+
83
+ 新接入把可选能力注册到当前 Provider。这些是业务能力扩展;Tiptap 的内部节点与插件仍由组件管理,无需宿主注册编辑器节点。未注册时不出现对应操作入口(旧 feature props 仍按兼容规则生效)。
84
+
85
+ ```tsx
86
+ import { useMemo } from 'react';
87
+ import {
88
+ CommentsProvider, CommentList, CommentComposer,
89
+ commentReactions, commentDocuments,
90
+ } from '@/components/business-ui/record-comments';
91
+
92
+ // reactions/documents 是宿主提供的真实能力,不能用 mock 冒充已接入。
93
+ const extensions = useMemo(() => [
94
+ commentReactions(reactions),
95
+ commentDocuments(documents),
96
+ ], [reactions, documents]);
97
+
98
+ <CommentsProvider {...props} mentionSource={people} extensions={extensions}>
99
+ <CommentList />
100
+ <CommentComposer />
101
+ </CommentsProvider>
102
+ ```
103
+
104
+ | 注册函数 | 宿主提供 | 启用效果 |
105
+ | --- | --- | --- |
106
+ | `commentReactions(feature)` | `CommentReactionFeature`:读取、添加/撤回及可选人员分页 | 显示回应数据;只有提供 onChange 且权限允许时可切换 |
107
+ | `commentDocuments(source)` | 当前用户授权的 `CommentDocumentSource`:搜索、推荐、解析 | 自动合并人员与文档来源,启用文档候选;写入仍为 link.url |
108
+ | `commentImages(feature)` | `CommentImageFeature`:图片回显和完整图片写入命令 | 可回显;只有提供 onAddComment 才出现上传入口 |
109
+
110
+ - 注册仅提供配置,不在注册时请求或创建全局状态;不同 Provider 互不影响。同一种扩展只注册一次,重复注册会明确报错,不按顺序悄悄替换。
111
+ - `extensions` 和数据来源用 useMemo 稳定;权限/身份变化时更换 scopeKey。文档注册接收独立的授权来源,mentionSource 传人员来源即可,无需额外 withDocumentSource 或 enableDocuments。
112
+ - `reactions/images/enableDocuments` 及 `withDocumentSource` 保留兼容;同种扩展显式注册时以扩展为准,其余未注册能力沿用旧参数。新接入不要同时用两种方式配置同一能力。
113
+ - 当前多维表格插件仍未接入图片,所以默认不要注册 commentImages。扩展不提供上传接口、不突破权限,也不能把 File/blob URL 塞进评论 content。
114
+ - `CommentPanel` 也支持 extensions,旧布局可逐步迁移,无需同时改页面布局和后端。
115
+
116
+ ## 分页和扩展
117
+
118
+ 宿主首屏读取;传 `pagination={hasMore,loading,error,onLoadMore}` 时组件接近底部自动触发。宿主保留游标,先追加 comments 再 resolve,失败 reject。组件有即时并发锁和连续自动加载上限,可滚动或手动继续。不传 pagination 表示外部已提供完整列表。
119
+
120
+ - **表情**:推荐通过 commentReactions 注册 getReactions/onChange(兼容直接传 reactions),可选 canChange/loadUsers。缺回应数据表示未知,不能从截断 userIDs 推断 mine。宿主按回复串行,显式添加/撤回;成功更新缓存,失败只回退目标,回读失败不回退成功动作。历史表情键保留。
121
+ - **文档**:宿主实现当前用户授权的 CommentDocumentSource,通过 commentDocuments 注册;兼容旧 withDocumentSource + enableDocuments 接入。推荐/搜索/解析均外置,持久化仅 link.url。无权限不得回退 owner 查询私密标题,插入引用不改变分享权限。文档不可用时保留人员结果。
122
+ - **图片**:当前多维表格插件未接入,默认不注册 commentImages、不传 images。该扩展仅面向宿主自备完整上传/持久化/回读能力,不得把 File/blob URL 写入插件 content。预览保留到快照接管或会话卸载。
123
+ - **人员卡片/链接**:由 onMentionClick/getCommentLink 承接,缺回调不展示无效操作。
124
+
125
+ 完整 Action 映射、身份约束和接入验收见配套 `plugin-guide/references/record-comments.md`。模板不含 mock 路由;仅 mock 通过不能声称真实链路完成。
126
+
127
+ 人员提及悬浮沿用官方颜色:普通人员浅蓝底、自身蓝色胶囊变亮、无权限人员灰底。样式由 internal/styles.css 的组件专属类提供,不依赖宿主 accent 配色。
128
+
129
+
130
+ ## 交互接入约束
131
+
132
+ 宿主复用组件已有交互,不额外包装删除确认、成功 toast 或“已显示全部评论”提示。复制链接成功后只关闭菜单;编辑操作按钮保持“确认”,等待态不新增“保存中”文字。回复自己也会插入自身提及;未知作者不能伪造提及。保持人员搜索和提及悬浮样式,不用宿主全局 button/a/p 样式覆盖组件。
133
+
134
+ 保留读写失败的恢复入口和无障碍状态;未知写入结果不得开放盲目重试。新增提示或改变状态流前,应取得官方对应状态的操作证据;本地受控故障测试不等同于真实 Action 验收,未覆盖的权限/网络/图片能力需明确标注。
135
+
136
+
137
+ 初始无数据时,加载、空态和读取错误共用同一个 118px 状态占位(沿用原空态高度),长错误在占位内滚动;状态切换不会移动输入框。已有评论的后台读取错误保留列表并使用紧凑提示。宿主仍控制整体 height/maxHeight,不需要为三种状态分别补高度。重试失败后,外部 error 清除会同时清理旧的重试错误。
138
+
139
+ 空列表的分页提示也计入同一个读取占位区;后续页耗尽不会额外收缩面板。受限高度容器中该区域可随可用空间收缩,长分页错误在区域内滚动。
140
+
141
+ 若宿主在身份或权限查询完成后才提供写回调,输入框会稍后挂载;宿主可通过 `style={{ minHeight: 'min(250px, 60vh)', maxHeight: '60vh' }}` 预留整体空间,避免身份加载引起的高度变化。不要为占位提前开放写入能力。
@@ -0,0 +1,93 @@
1
+ import type { CommentDataSource, MentionOption, MentionResult } from './types';
2
+ import { getI18nText } from '../utils/user';
3
+ import { listUsersByIds, searchUsers } from '../api/users/service';
4
+
5
+ function avatarUrl(avatar: unknown): string | undefined {
6
+ if (typeof avatar === 'string') return avatar;
7
+ if (avatar && typeof avatar === 'object' && 'image' in avatar) {
8
+ const image = avatar.image;
9
+ if (
10
+ image &&
11
+ typeof image === 'object' &&
12
+ 'large' in image &&
13
+ typeof image.large === 'string'
14
+ )
15
+ return image.large;
16
+ }
17
+ return undefined;
18
+ }
19
+
20
+ /** The same Miaoda user IDs and built-in service used by UserDisplay. */
21
+ export function createAppCommentSource(scopeKey: string): CommentDataSource {
22
+ return {
23
+ scopeKey,
24
+ async search({ query, cursor, signal }) {
25
+ signal.throwIfAborted();
26
+ // The built-in directory has no empty-query recommendations. In a mixed
27
+ // source, leave that branch empty so document recommendations still load.
28
+ if (!query) return { items: [] };
29
+ const offset = Number(cursor || 0);
30
+ if (!Number.isSafeInteger(offset) || offset < 0)
31
+ throw new Error('分页参数无效');
32
+ const response = await searchUsers({ query, offset, pageSize: 20 });
33
+ signal.throwIfAborted();
34
+ if (String(response.status_code) !== '0')
35
+ throw new Error('人员搜索失败,请重试');
36
+ const users = response.data.userList ?? [];
37
+ const items: MentionOption[] = users.flatMap((user) => {
38
+ // Unregistered contacts have no Miaoda ID; never substitute a Lark ID.
39
+ const id = String(user.userID || '');
40
+ if (!/^[1-9]\d*$/.test(id)) return [];
41
+ return [
42
+ {
43
+ ref: { kind: 'user' as const, idType: 'apaas-user' as const, id },
44
+ label: getI18nText(user.name) || '未命名人员',
45
+ avatarUrl: avatarUrl(user.avatar),
46
+ description: getI18nText(user.department?.name) || user.tenantName,
47
+ isExternal: user.userType === '_externalUser',
48
+ },
49
+ ];
50
+ });
51
+ const nextOffset = offset + users.length;
52
+ return {
53
+ items,
54
+ nextCursor:
55
+ users.length && nextOffset < response.data.total
56
+ ? String(nextOffset)
57
+ : undefined,
58
+ };
59
+ },
60
+ async resolve(refs, signal) {
61
+ signal.throwIfAborted();
62
+ const ids = refs.flatMap((ref) =>
63
+ ref.kind === 'user' && ref.idType === 'apaas-user' ? [ref.id] : [],
64
+ );
65
+ const response = ids.length ? await listUsersByIds(ids) : undefined;
66
+ signal.throwIfAborted();
67
+ const base = response?.data?.baseResp;
68
+ if (base?.statusCode)
69
+ throw new Error(base.statusMessage || '人员信息加载失败');
70
+ if (ids.length && !response?.data?.userInfoMap)
71
+ throw new Error('人员信息返回格式无效');
72
+ const people = response?.data?.userInfoMap || {};
73
+ return refs.map((ref): MentionResult => {
74
+ if (ref.kind !== 'user' || ref.idType !== 'apaas-user')
75
+ return { ref, status: 'error' };
76
+ const user = people[ref.id];
77
+ if (!user) return { ref, status: 'not-found' };
78
+ return {
79
+ ref,
80
+ status: 'resolved',
81
+ label: getI18nText(user.name) || '未命名人员',
82
+ avatarUrl: avatarUrl(user.avatar),
83
+ description: getI18nText(user.department?.name),
84
+ isExternal: user.userType === '_externalUser',
85
+ };
86
+ });
87
+ },
88
+ };
89
+ }
90
+
91
+ export { withDocumentSource } from './extensions';
92
+
93
+ export { documentRefFromUrl, safeUrl } from './internal/model';
@@ -0,0 +1,240 @@
1
+ import type { CommentDataSource, CommentMentionClickHandler } from './types';
2
+ import type { CommentExtension } from './extensions';
3
+ import type { ComponentPropsWithoutRef, CSSProperties, ReactNode } from 'react';
4
+
5
+ /**
6
+ * Public contract for the controlled CommentPanel entry point.
7
+ * Requests, credentials, pagination cursors and server caches belong to the host.
8
+ * The panel owns optimistic drafts and per-message mutation state.
9
+ */
10
+
11
+
12
+
13
+
14
+ export type CommentReadContent =
15
+ | { type: 'text'; text: string }
16
+ | { type: 'mention'; userID?: number }
17
+ | { type: 'link'; url: string };
18
+
19
+ export type CommentWriteContent =
20
+ | { type: 'text'; text: string }
21
+ | { type: 'mention'; userID: number }
22
+ | { type: 'link'; url: string };
23
+
24
+ export interface RecordCommentReply {
25
+ replyID: string;
26
+ creator?: number;
27
+ /** Unix seconds, not milliseconds. */
28
+ createTime: number;
29
+ /** Authoritative per-reply last update, Unix seconds. Omit when unavailable. */
30
+ updateTime?: number;
31
+ content: CommentReadContent[];
32
+ /** Omission means reactions were not loaded or are unavailable in this identity mode. */
33
+ reactions?: Array<{ key: string; count: number; mine: boolean; userIDs: number[] }>;
34
+ }
35
+
36
+ export interface RecordComment {
37
+ commentID: string;
38
+ creator?: number;
39
+ createTime: number;
40
+ isSolved: boolean;
41
+ /** First reply is the body; subsequent replies belong to the same discussion. */
42
+ replies: RecordCommentReply[];
43
+ }
44
+
45
+ export interface ListRecordCommentsInput {
46
+ recordID: string;
47
+ commentID?: string;
48
+ needReaction?: boolean;
49
+ pageSize?: number;
50
+ pageToken?: string;
51
+ }
52
+
53
+ export interface RecordCommentPage {
54
+ comments: RecordComment[];
55
+ hasMore: boolean;
56
+ pageToken: string;
57
+ }
58
+
59
+ export interface AddRecordCommentInput {
60
+ recordID: string;
61
+ /** Omit for a new discussion. Never send an empty string. */
62
+ commentID?: string;
63
+ content: CommentWriteContent[];
64
+ }
65
+
66
+ export interface UpdateRecordCommentInput {
67
+ recordID: string;
68
+ commentID: string;
69
+ replyID: string;
70
+ content: CommentWriteContent[];
71
+ }
72
+
73
+ export interface DeleteRecordCommentInput {
74
+ recordID: string;
75
+ commentID: string;
76
+ /** Always supplied by the panel to delete one message, including the body.
77
+ * Omission is a legacy explicit whole-discussion command for external callers. */
78
+ replyID?: string;
79
+ }
80
+
81
+ export interface CommentIDs {
82
+ commentID: string;
83
+ replyID: string;
84
+ }
85
+
86
+ export interface DeleteCommentResult {
87
+ commentID: string;
88
+ commentDeleted: boolean;
89
+ }
90
+
91
+ export interface CommentTarget {
92
+ recordID: string;
93
+ commentID: string;
94
+ replyID: string;
95
+ }
96
+
97
+ export interface CommentPermissionContext extends CommentTarget {
98
+ comment: RecordComment;
99
+ reply: RecordCommentReply;
100
+ isBody: boolean;
101
+ }
102
+
103
+ export interface CommentPagination {
104
+ hasMore: boolean;
105
+ /** Host state, in addition to the panel's immediate in-flight guard. */
106
+ loading?: boolean;
107
+ /** An external page-load failure. Does not replace already loaded comments. */
108
+ error?: string;
109
+ /** Append to comments before resolving; reject on failure. Cursors stay outside. */
110
+ onLoadMore: () => Promise<void>;
111
+ }
112
+
113
+ export interface CommentReaction {
114
+ emojiKey: string;
115
+ count: number;
116
+ /** Authoritative membership. Never infer this from a truncated users list. */
117
+ reactedByMe: boolean;
118
+ previewUserIDs?: readonly number[];
119
+ }
120
+
121
+ export interface ChangeCommentReactionInput extends CommentTarget {
122
+ emojiKey: string;
123
+ action: 'add' | 'remove';
124
+ }
125
+
126
+ export interface CommentReactionFeature {
127
+ canChange?: (target: CommentTarget) => boolean;
128
+ getReactions: (target: CommentTarget) => readonly CommentReaction[];
129
+ /** Host updates its controlled reaction data before resolving. */
130
+ onChange?: (input: ChangeCommentReactionInput) => Promise<void>;
131
+ /** Optional paginated users; an absent loader means no complete-members claim. */
132
+ loadUsers?: (
133
+ input: CommentTarget & { emojiKey: string; cursor?: string },
134
+ signal: AbortSignal,
135
+ ) => Promise<{ userIDs: number[]; nextCursor?: string }>;
136
+ }
137
+
138
+ export interface CommentImage {
139
+ id: string;
140
+ url: string;
141
+ name?: string;
142
+ width?: number;
143
+ height?: number;
144
+ }
145
+
146
+ /** A retained image or a newly selected local file. Never a Bitable Action element. */
147
+ export type CommentImageInput =
148
+ | { kind: 'existing'; image: CommentImage }
149
+ | { kind: 'local'; localID: string; file: File };
150
+
151
+ /** Separate image-bearing command: content may be empty when images are present. */
152
+ export interface AddImageCommentInput {
153
+ recordID: string;
154
+ commentID?: string;
155
+ content: CommentWriteContent[];
156
+ images: readonly CommentImageInput[];
157
+ }
158
+
159
+ export interface UpdateImageCommentInput extends AddImageCommentInput {
160
+ commentID: string;
161
+ replyID: string;
162
+ }
163
+
164
+ export interface CommentImageFeature {
165
+ /** UI cannot derive images from the official plugin's empty content array. */
166
+ getImages: (target: CommentTarget) => readonly CommentImage[];
167
+ /** Enables file selection for new comments/replies. Owns the entire command. */
168
+ onAddComment?: (input: AddImageCommentInput) => Promise<CommentIDs>;
169
+ /** Required to edit an existing image-bearing reply without losing its images. */
170
+ onUpdateComment?: (input: UpdateImageCommentInput) => Promise<CommentIDs>;
171
+ }
172
+
173
+ /** Plain Error means a rejected write. Mark outcome=unknown for a timeout or
174
+ * transport failure that may have committed; the panel then prevents blind retry. */
175
+ export interface CommentMutationFailure extends Error {
176
+ outcome?: 'not-applied' | 'unknown';
177
+ }
178
+
179
+ export interface CommentPanelProps {
180
+ /** Stable app/tenant/identity/data-source scope. No credentials. */
181
+ scopeKey: string;
182
+ recordID: string;
183
+ /** Controlled server data; the panel never mutates this array. */
184
+ comments: readonly RecordComment[];
185
+ /** Externally supplied @ lookup/resolution; contains no comment CRUD methods. */
186
+ mentionSource: CommentDataSource;
187
+ /** Enable document suggestions only with a user-authorized document source. Writes remain link elements. */
188
+ enableDocuments?: boolean;
189
+ /** Miaoda user ID; used for presentation, not authorization. */
190
+ currentUserID?: number;
191
+
192
+ /** Initial read is initiated by the host. */
193
+ loading?: boolean;
194
+ /** Initial/background read error; existing comments remain visible. */
195
+ error?: string;
196
+ onRetry?: () => Promise<void>;
197
+ /** Omit when the host has already supplied the entire list. */
198
+ pagination?: CommentPagination;
199
+
200
+ /** Resolve with real IDs on write success; comments may be updated later.
201
+ * The panel immediately renders a local overlay, owns pending/error/retry state,
202
+ * and reconciles it when matching controlled data arrives. Refresh failures must
203
+ * not reject a committed write. Mark ambiguous failures with outcome=unknown. */
204
+ onAddComment?: (input: AddRecordCommentInput) => Promise<CommentIDs>;
205
+ onUpdateComment?: (input: UpdateRecordCommentInput) => Promise<CommentIDs>;
206
+ onDeleteComment?: (input: DeleteRecordCommentInput) => Promise<DeleteCommentResult>;
207
+ /** Absent policy means edit/delete are hidden. Backend remains authoritative. */
208
+ getReplyPermissions?: (
209
+ context: CommentPermissionContext,
210
+ ) => { canEdit: boolean; canDelete: boolean };
211
+
212
+ /** Absent callback means no copy-link menu item. */
213
+ getCommentLink?: (target: CommentTarget) => string | undefined;
214
+ onMentionClick?: CommentMentionClickHandler;
215
+ /** Requests remain external. Prefer extensions; direct feature props remain compatible. */
216
+ reactions?: CommentReactionFeature;
217
+ images?: CommentImageFeature;
218
+ /** Stable, per-session registrations. One of each kind; registered kinds override legacy props. */
219
+ extensions?: readonly CommentExtension[];
220
+
221
+ /** Default: nearest scrollable ancestor of the bottom sentinel, else viewport. */
222
+ getScrollContainer?: () => HTMLElement | null;
223
+ className?: string;
224
+ /** Layout only; current internal design tokens and visual styles are preserved. */
225
+ style?: CSSProperties;
226
+ }
227
+
228
+ /** One record/identity session. Shares behavior without rendering a layout element. */
229
+ export interface CommentsProviderProps extends Omit<CommentPanelProps, 'className' | 'style'> {
230
+ children: ReactNode;
231
+ }
232
+
233
+ /** The host owns the heading, outer scroll region and surrounding layout. */
234
+ export type CommentListProps = Omit<ComponentPropsWithoutRef<'div'>, 'children' | 'dangerouslySetInnerHTML'>;
235
+
236
+ /** One primary composer per provider; inline edit composers stay inside the list. */
237
+ export interface CommentComposerProps extends CommentListProps {
238
+ placeholder?: string;
239
+ disabled?: boolean;
240
+ }