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

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