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

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 (50) hide show
  1. package/package.json +1 -1
  2. package/template/client/src/components/business-ui/README.md +2 -10
  3. package/template/client/src/components/business-ui/api/user-profiles/queries.ts +7 -1
  4. package/template/client/src/components/business-ui/record-comments/README.md +83 -0
  5. package/template/client/src/components/business-ui/{comment → record-comments}/app-source.ts +18 -11
  6. package/template/client/src/components/business-ui/record-comments/assets/reaction-sprite.png +0 -0
  7. package/template/client/src/components/business-ui/record-comments/comment-panel.contract.ts +223 -0
  8. package/template/client/src/components/business-ui/record-comments/comment-panel.tsx +202 -0
  9. package/template/client/src/components/business-ui/record-comments/index.ts +7 -0
  10. package/template/client/src/components/business-ui/record-comments/internal/composer.tsx +527 -0
  11. package/template/client/src/components/business-ui/{comment/comment-content.tsx → record-comments/internal/content.tsx} +20 -42
  12. package/template/client/src/components/business-ui/record-comments/internal/discussion.tsx +535 -0
  13. package/template/client/src/components/business-ui/{comment → record-comments/internal}/editor.tsx +311 -55
  14. package/template/client/src/components/business-ui/{comment/document-icons.ts → record-comments/internal/icons.tsx} +129 -0
  15. package/template/client/src/components/business-ui/record-comments/internal/images.tsx +155 -0
  16. package/template/client/src/components/business-ui/{comment/entity-store.ts → record-comments/internal/mentions.tsx} +73 -7
  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 +450 -0
  23. package/template/client/src/components/business-ui/record-comments/internal/view-types.ts +82 -0
  24. package/template/client/src/components/business-ui/{comment → record-comments}/types.ts +2 -2
  25. package/template/client/src/components/business-ui/user-display/user-display.tsx +13 -0
  26. package/template/client/src/components/business-ui/user-profile/lark-card-pool.ts +217 -0
  27. package/template/client/src/components/business-ui/user-profile/user-profile.tsx +397 -146
  28. package/template/client/src/components/business-ui/user-select/user-pill.tsx +13 -0
  29. package/template/client/src/hooks/use-example.ts +36 -45
  30. package/template/client/src/lib/api-error.ts +36 -0
  31. package/template/client/src/pages/ExamplePage/ExamplePage.tsx +8 -7
  32. package/template/client/src/tailwind-theme.css +6 -5
  33. package/template/eslint.config.js +6 -0
  34. package/template/package-lock.json +77 -122
  35. package/template/package.json +8 -8
  36. package/template/server/modules/hello/hello.controller.ts +8 -4
  37. package/template/server/modules/hello/hello.dto.ts +21 -0
  38. package/template/server/modules/hello/hello.service.ts +5 -3
  39. package/template/shared/api.interface.ts +8 -1
  40. package/template/client/src/components/business-ui/comment/README.md +0 -109
  41. package/template/client/src/components/business-ui/comment/ccm-document-source.ts +0 -252
  42. package/template/client/src/components/business-ui/comment/comment-input.tsx +0 -190
  43. package/template/client/src/components/business-ui/comment/comment-provider.tsx +0 -40
  44. package/template/client/src/components/business-ui/comment/content.ts +0 -327
  45. package/template/client/src/components/business-ui/comment/document-icon.tsx +0 -104
  46. package/template/client/src/components/business-ui/comment/entity-mention.ts +0 -276
  47. package/template/client/src/components/business-ui/comment/mention-appearance.ts +0 -35
  48. package/template/client/src/components/business-ui/comment/mention-provider.tsx +0 -73
  49. package/template/client/src/components/business-ui/comment/mention-suggestions.tsx +0 -312
  50. package/template/client/src/components/business-ui/comment/use-mention-search.ts +0 -107
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.20261030085054",
3
+ "version": "0.1.55-alpha.20261101050259",
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
  ---
@@ -517,13 +519,3 @@ function BasicExample() {
517
519
  return <UserProfile value="user-123" />;
518
520
  }
519
521
  ```
520
-
521
- ## 评论输入与展示
522
-
523
- `comment/comment-content.tsx` 提供不依赖 Tiptap 的只读展示,配合 `comment/mention-provider.tsx` 批量补全人员和文档信息。
524
-
525
- 评论输入框已内置。从 `@/components/business-ui/comment/comment-input` 直接导入 `CommentInput`,组件提供 getContent/clear/setEditable/focus ref API 和 onSubmitRequest 快捷键回调,发送、取消按钮及请求状态由页面组合,首次激活时动态加载最小 Tiptap 编辑器。只读 `CommentContent` 不依赖编辑器。作者、时间、线程与接口调用由消费页面组合。
526
-
527
- 数据源和真实服务的接入要求见 [CommentInput 使用说明](./comment/README.md)。输入与只读列表应共享同一 CommentProvider;CCM 的 Lark ID 与模板的 apaas ID 不能混用。
528
-
529
- 人员服务使用 `CommentProvider` 的内置 UserDisplay 数据源。文档服务通过 `CommentProvider documents={documentSource}` 单独提供,再为输入框设置 `enableDocuments`;可复用 `createCcmDocumentSource` 接入 miaoda 的推荐、元数据与嵌入权限接口。文档显示类型图标与标题,点击新窗打开链接,编辑态与只读态共享样式。接口协议为 text、mention(userID) 和 link(url),文档输出 link 节点;缺少 userID 的历史 mention 使用可见占位,不影响同条评论的其他内容。仅支持这三类内容,不包含图片与旧 CCM 评论格式兼容。
@@ -10,7 +10,13 @@ export const userProfileQueries = {
10
10
  queryOptions({
11
11
  queryKey: [...userProfileQueries.all(), 'byId', accountType, userId],
12
12
  queryFn: ({ signal }) => fetchUserProfile(userId, accountType, signal),
13
- staleTime: 1 * 60 * 1000,
13
+ // staleTime 0:每次打开都在后台取一次新的,和飞书名片那条路保持一致
14
+ // ——「打开渲染缓存、后台乐观更新」。缓存命中时 isPending 为 false,
15
+ // 卡片瞬间渲出不转圈;新数据回来后 React 只 patch 变化的字段,不闪不跳。
16
+ //
17
+ // 之前是 60s:那段窗口内连请求都不发,远端改了最多要等 60 秒才看得到。
18
+ staleTime: 0,
19
+ // gcTime 决定缓存能留多久 —— 它才是「秒出」的来源,不要跟着一起调小。
14
20
  gcTime: 5 * 60 * 1000,
15
21
  enabled: !!userId,
16
22
  }),
@@ -0,0 +1,83 @@
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,12 +1,7 @@
1
- import { listUsersByIds, searchUsers } from '../api/users/service';
1
+ import type { CommentDataSource, CommentDocumentSource, MentionOption, MentionResult } from './types';
2
+ import { mentionKey } from './internal/model';
2
3
  import { getI18nText } from '../utils/user';
3
- import { mentionKey } from './content';
4
- import type {
5
- CommentDataSource,
6
- CommentDocumentSource,
7
- MentionOption,
8
- MentionResult,
9
- } from './types';
4
+ import { listUsersByIds, searchUsers } from '../api/users/service';
10
5
 
11
6
  function avatarUrl(avatar: unknown): string | undefined {
12
7
  if (typeof avatar === 'string') return avatar;
@@ -50,6 +45,7 @@ export function createAppCommentSource(scopeKey: string): CommentDataSource {
50
45
  label: getI18nText(user.name) || '未命名人员',
51
46
  avatarUrl: avatarUrl(user.avatar),
52
47
  description: getI18nText(user.department?.name) || user.tenantName,
48
+ isExternal: user.userType === '_externalUser',
53
49
  },
54
50
  ];
55
51
  });
@@ -85,6 +81,8 @@ export function createAppCommentSource(scopeKey: string): CommentDataSource {
85
81
  status: 'resolved',
86
82
  label: getI18nText(user.name) || '未命名人员',
87
83
  avatarUrl: avatarUrl(user.avatar),
84
+ description: getI18nText(user.department?.name),
85
+ isExternal: user.userType === '_externalUser',
88
86
  };
89
87
  });
90
88
  },
@@ -125,14 +123,21 @@ export function withDocumentSource(
125
123
  const exhausted: Awaited<ReturnType<CommentDataSource['search']>> = {
126
124
  items: [],
127
125
  };
128
- const [users, docs] = await Promise.all([
129
- !args.cursor || cursor.user !== undefined
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
130
  ? fetchSource(people, 'user', cursor.user)
131
131
  : exhausted,
132
- !args.cursor || cursor.document !== undefined
132
+ requestDocs
133
133
  ? fetchSource(documents, 'document', cursor.document)
134
134
  : exhausted,
135
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;
136
141
  return {
137
142
  items: [
138
143
  ...users.items.filter((item) => item.ref.kind === 'user'),
@@ -173,3 +178,5 @@ export function withDocumentSource(
173
178
  },
174
179
  };
175
180
  }
181
+
182
+ export { documentRefFromUrl, safeUrl } from './internal/model';
@@ -0,0 +1,223 @@
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
+ }
@@ -0,0 +1,202 @@
1
+ import type { CommentPanelProps, CommentTarget } from './comment-panel.contract';
2
+ import { CommentMutations } from './internal/mutations';
3
+ import { buildPanelThreads, CommentPageLoader, preparePanelDeletion, preparePanelEdit, loadPanelReactionDetails, preparePanelComment } from './internal/model';
4
+ import { DiscussionPanel } from './internal/discussion';
5
+ import { LoaderCircle } from 'lucide-react';
6
+ import { useCallback, useEffect, useReducer, useRef, useState, useSyncExternalStore } from 'react';
7
+ import './internal/styles.css';
8
+
9
+ function ReadStatus({ loading, error, onRetry, empty, hasMore }: Pick<CommentPanelProps, 'loading' | 'error' | 'onRetry'> & { empty: boolean; hasMore?: boolean }) {
10
+ const [failure, setFailure] = useState('');
11
+ const [retrying, setRetrying] = useState(false);
12
+ const busy = useRef(false);
13
+ // A successful external read must clear a previous retry failure as well.
14
+ useEffect(() => setFailure(''), [error]);
15
+ const pending = loading || retrying;
16
+ const message = failure || error;
17
+ if (!empty && !pending && !message) return null;
18
+ const state = pending ? 'loading' : message ? 'error' : 'empty';
19
+ return <div className={`replica-panel-status${empty ? ' replica-panel-placeholder' : ''}`}
20
+ data-state={state} role={state === 'error' ? 'alert' : 'status'} aria-busy={pending}>
21
+ <div className="replica-panel-status-content">
22
+ <span>{pending ? <LoaderCircle className="size-4 animate-spin text-primary" aria-label="正在加载评论" />
23
+ : message || (empty && !hasMore ? '当前记录暂无评论' : null)}</span>
24
+ {!pending && message && onRetry && <button type="button" onClick={async () => {
25
+ if (busy.current) return;
26
+ busy.current = true; setRetrying(true); setFailure('');
27
+ try { await onRetry(); }
28
+ catch (cause) { setFailure(cause instanceof Error ? cause.message : '刷新失败,请重试'); }
29
+ finally { busy.current = false; setRetrying(false); }
30
+ }}>重试刷新</button>}
31
+ </div>
32
+ </div>;
33
+ }
34
+
35
+ function nearestScrollContainer(node: HTMLElement): HTMLElement | null {
36
+ for (let parent = node.parentElement; parent; parent = parent.parentElement) {
37
+ if (/(auto|scroll)/.test(getComputedStyle(parent).overflowY) && parent.clientHeight > 0) return parent;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ function PaginationFooter({ pagination, loading, getScrollContainer }: Pick<CommentPanelProps, 'pagination' | 'loading' | 'getScrollContainer'>) {
43
+ const sentinel = useRef<HTMLDivElement>(null);
44
+ const root = useRef<HTMLElement | null>(null);
45
+ const [gate] = useState(() => new CommentPageLoader());
46
+ const [, refresh] = useReducer((value: number) => value + 1, 0);
47
+ const latest = useRef({ pagination, loading });
48
+ latest.current = { pagination, loading };
49
+ const alive = useRef(true);
50
+ const run = useRef((manual = false) => { void manual; });
51
+ run.current = (manual = false) => {
52
+ const current = latest.current;
53
+ if (!alive.current || current.loading || !current.pagination?.hasMore || current.pagination.loading || gate.pending) return;
54
+ if (!manual && (gate.error || current.pagination.error || gate.automaticLoads >= 3)) return;
55
+ const promise = gate.load(current.pagination, manual);
56
+ refresh();
57
+ void promise.finally(() => { if (alive.current) refresh(); });
58
+ };
59
+ const check = useCallback(() => {
60
+ const element = sentinel.current;
61
+ if (!element) return;
62
+ const bounds = element.getBoundingClientRect();
63
+ const viewport = root.current?.getBoundingClientRect();
64
+ const visibleTop = Math.max(0, viewport?.top ?? 0);
65
+ const visibleBottom = Math.min(window.innerHeight, viewport?.bottom ?? window.innerHeight);
66
+ if (visibleBottom > visibleTop && bounds.top <= visibleBottom + 200 && bounds.bottom >= visibleTop)
67
+ run.current();
68
+ }, []);
69
+ useEffect(() => {
70
+ alive.current = true;
71
+ const element = sentinel.current;
72
+ if (!element) return;
73
+ root.current = getScrollContainer ? getScrollContainer() : nearestScrollContainer(element);
74
+ const container = root.current ?? window;
75
+ const observer = new IntersectionObserver(() => check(), { root: root.current, rootMargin: '0px 0px 200px 0px' });
76
+ observer.observe(element);
77
+ const resize = new ResizeObserver(check);
78
+ resize.observe(root.current ?? document.documentElement);
79
+ const resume = (event: Event) => {
80
+ if (event instanceof WheelEvent && event.deltaY > 0) gate.automaticLoads = 0;
81
+ if (event instanceof KeyboardEvent && ['PageDown', 'End', 'ArrowDown'].includes(event.key)) gate.automaticLoads = 0;
82
+ check();
83
+ };
84
+ container.addEventListener('scroll', check, { passive: true });
85
+ if (root.current) window.addEventListener('scroll', check, { passive: true });
86
+ container.addEventListener('wheel', resume, { passive: true });
87
+ container.addEventListener('keydown', resume);
88
+ return () => {
89
+ alive.current = false;
90
+ observer.disconnect(); resize.disconnect();
91
+ container.removeEventListener('scroll', check);
92
+ window.removeEventListener('scroll', check);
93
+ container.removeEventListener('wheel', resume);
94
+ container.removeEventListener('keydown', resume);
95
+ };
96
+ }, [check, gate, getScrollContainer]);
97
+ useEffect(() => {
98
+ const frame = requestAnimationFrame(check);
99
+ return () => cancelAnimationFrame(frame);
100
+ });
101
+ const error = pagination?.hasMore && (gate.error || pagination.error);
102
+ const pending = gate.pending || pagination?.loading;
103
+ return <div ref={sentinel} className={pagination?.hasMore ? 'replica-panel-pagination' : undefined} aria-live="polite">
104
+ {pagination?.hasMore && (error ? <><span role="alert">{error}</span><button type="button" disabled={pending} onClick={() => run.current(true)}>重试加载</button></>
105
+ : pending ? <LoaderCircle className="size-4 animate-spin text-primary" aria-label="正在加载更多评论" />
106
+ : pagination?.hasMore ? <button type="button" disabled={loading} onClick={() => run.current(true)}>继续加载</button>
107
+ : null)}
108
+ </div>;
109
+ }
110
+
111
+ function PanelSession(props: CommentPanelProps) {
112
+ const latest = useRef(props);
113
+ latest.current = props;
114
+ const root = useRef<HTMLDivElement>(null);
115
+ const locks = useRef(new Set<string>());
116
+ const [submitted, setSubmitted] = useState<string>();
117
+ const [mutations] = useState(() => new CommentMutations());
118
+ useSyncExternalStore(mutations.subscribe, mutations.getVersion, mutations.getVersion);
119
+ const base = buildPanelThreads(props);
120
+ const comments = mutations.view(base);
121
+ useEffect(() => {
122
+ mutations.activate();
123
+ return () => mutations.dispose();
124
+ }, [mutations]);
125
+ useEffect(() => { mutations.reconcile(base); }, [base, mutations]);
126
+ useEffect(() => {
127
+ if (!submitted) return;
128
+ const target = root.current?.querySelector(`[data-reply-id="${CSS.escape(mutations.replyID(submitted))}"]`);
129
+ if (target) { target.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); setSubmitted(undefined); }
130
+ }, [comments, submitted, mutations]);
131
+ async function locked<T>(key: string, action: () => Promise<T>): Promise<T> {
132
+ if (locks.current.has(key)) throw new Error('该评论正在处理中,请稍后重试');
133
+ locks.current.add(key);
134
+ try { return await action(); }
135
+ finally { locks.current.delete(key); }
136
+ }
137
+ const allowCompose = Boolean(props.onAddComment || props.images?.onAddComment);
138
+ const currentUser = props.currentUserID == null ? undefined
139
+ : { kind: 'user' as const, idType: 'apaas-user' as const, id: String(props.currentUserID) };
140
+ return <div ref={root} className={`replica-comment-panel ${props.className ?? ''}`} style={props.style}>
141
+ <DiscussionPanel
142
+ source={props.mentionSource}
143
+ currentUser={currentUser}
144
+ comments={comments}
145
+ allowCompose={allowCompose}
146
+ optimisticMutations
147
+ onRetryMutation={(id) => mutations.retry(id)}
148
+ showReactions={Boolean(props.reactions)}
149
+ allowReact={Boolean(props.reactions?.onChange)}
150
+ enableImages={Boolean(props.images?.onAddComment)}
151
+ enableEditImages={Boolean(props.images?.onUpdateComment)}
152
+ enablePrivateComments={false}
153
+ enableDocuments={props.enableDocuments ?? false}
154
+ autoScrollNewReplies={false}
155
+ emptyState={null}
156
+ listHeader={<ReadStatus loading={props.loading} error={props.error} onRetry={props.onRetry}
157
+ empty={!comments.some((thread) => thread.replies.length > 0)} hasMore={props.pagination?.hasMore} />}
158
+ listFooter={props.pagination && <PaginationFooter pagination={props.pagination} loading={props.loading || Boolean(props.error)} getScrollContainer={props.getScrollContainer} />}
159
+ onMentionClick={props.onMentionClick}
160
+ getCommentLink={(commentID, reply) => props.getCommentLink?.({ recordID: props.recordID, commentID, replyID: reply.replyID }) ?? ''}
161
+ onSubmit={(draft) => {
162
+ const current = latest.current;
163
+ preparePanelComment(current, draft); // Validate before accepting/clearing the draft.
164
+ const thread = base.find((item) => item.commentID === draft.commentID);
165
+ const id = mutations.add(thread, draft, currentUser, () => preparePanelComment(latest.current, draft)());
166
+ setSubmitted(id);
167
+ }}
168
+ onEdit={(commentID, replyID, value, images) => {
169
+ const draft = { value, images, private: false };
170
+ preparePanelEdit(latest.current, commentID, replyID, draft);
171
+ const thread = base.find((item) => item.commentID === commentID)!;
172
+ mutations.edit(thread, replyID, draft, () => preparePanelEdit(latest.current, commentID, replyID, draft)());
173
+ }}
174
+ onDelete={(commentID, replyID) => {
175
+ if (!replyID) throw new Error('未选择要删除的评论');
176
+ preparePanelDeletion(latest.current, commentID, replyID);
177
+ const thread = base.find((item) => item.commentID === commentID)!;
178
+ mutations.delete(thread, replyID, () => preparePanelDeletion(latest.current, commentID, replyID)());
179
+ }}
180
+ onToggleReaction={async (commentID, replyID, emojiKey) => {
181
+ await locked(replyID, async () => {
182
+ const current = latest.current;
183
+ const feature = current.reactions;
184
+ if (!feature?.onChange) throw new Error('未提供回应写入能力');
185
+ const target: CommentTarget = { recordID: current.recordID, commentID, replyID };
186
+ if (feature.canChange?.(target) === false) throw new Error('表情状态尚未加载或当前身份不可用');
187
+ const existing = feature.getReactions(target).find((reaction) => reaction.emojiKey === emojiKey ||
188
+ (emojiKey === 'THUMBSUP' && reaction.emojiKey === 'MediumThumbsup'));
189
+ await feature.onChange({ ...target, emojiKey: existing?.emojiKey ?? emojiKey, action: existing?.reactedByMe ? 'remove' : 'add' });
190
+ });
191
+ }}
192
+ loadReactionDetails={props.reactions?.loadUsers ? (commentID, replyID, signal) => loadPanelReactionDetails(
193
+ latest.current, { recordID: props.recordID, commentID, replyID }, signal,
194
+ ) : undefined}
195
+ />
196
+ </div>;
197
+ }
198
+
199
+ /** Controlled full comment UI; all persistence is supplied by the host. */
200
+ export function CommentPanel(props: CommentPanelProps) {
201
+ return <PanelSession key={JSON.stringify([props.scopeKey, props.recordID])} {...props} />;
202
+ }
@@ -0,0 +1,7 @@
1
+ export { CommentPanel } from './comment-panel';
2
+ export { createAppCommentSource, withDocumentSource } from './app-source';
3
+ export type * from './comment-panel.contract';
4
+ export type { CommentDataSource, CommentDocumentSource, CommentMentionClickHandler, MentionRef, MentionOption, MentionResult, MentionKind, DocumentMentionMetadata } from './types';
5
+
6
+ // URL helpers for external document adapters; no requests or permissions inside.
7
+ export { documentRefFromUrl, safeUrl } from './internal/model';