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

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 (57) 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 +58 -0
  5. package/template/client/src/components/business-ui/{comment → record-comments}/app-source.ts +13 -3
  6. package/template/client/src/components/business-ui/record-comments/assets/reaction-sprite.png +0 -0
  7. package/template/client/src/components/business-ui/{comment → record-comments}/comment-content.tsx +17 -20
  8. package/template/client/src/components/business-ui/{comment → record-comments}/comment-input.tsx +1 -1
  9. package/template/client/src/components/business-ui/record-comments/comment-panel-model.ts +198 -0
  10. package/template/client/src/components/business-ui/record-comments/comment-panel-optimistic.ts +171 -0
  11. package/template/client/src/components/business-ui/record-comments/comment-panel.contract.ts +223 -0
  12. package/template/client/src/components/business-ui/record-comments/comment-panel.tsx +194 -0
  13. package/template/client/src/components/business-ui/record-comments/comment-reactions.css +220 -0
  14. package/template/client/src/components/business-ui/record-comments/comment-reactions.tsx +696 -0
  15. package/template/client/src/components/business-ui/{comment → record-comments}/content.ts +6 -3
  16. package/template/client/src/components/business-ui/record-comments/discussion-composer.tsx +363 -0
  17. package/template/client/src/components/business-ui/record-comments/discussion-image-viewer.tsx +105 -0
  18. package/template/client/src/components/business-ui/record-comments/discussion-panel.tsx +546 -0
  19. package/template/client/src/components/business-ui/record-comments/discussion-time.ts +28 -0
  20. package/template/client/src/components/business-ui/record-comments/discussion-types.ts +87 -0
  21. package/template/client/src/components/business-ui/record-comments/discussion.css +709 -0
  22. package/template/client/src/components/business-ui/{comment → record-comments}/editor.tsx +36 -17
  23. package/template/client/src/components/business-ui/{comment → record-comments}/entity-mention.ts +15 -10
  24. package/template/client/src/components/business-ui/record-comments/icons.tsx +28 -0
  25. package/template/client/src/components/business-ui/record-comments/image-files.ts +51 -0
  26. package/template/client/src/components/business-ui/record-comments/index.ts +7 -0
  27. package/template/client/src/components/business-ui/record-comments/mention-appearance.css +11 -0
  28. package/template/client/src/components/business-ui/record-comments/mention-appearance.ts +36 -0
  29. package/template/client/src/components/business-ui/{comment → record-comments}/mention-provider.tsx +9 -1
  30. package/template/client/src/components/business-ui/record-comments/mention-suggestions.css +125 -0
  31. package/template/client/src/components/business-ui/record-comments/mention-suggestions.tsx +331 -0
  32. package/template/client/src/components/business-ui/record-comments/reaction-catalog.ts +194 -0
  33. package/template/client/src/components/business-ui/{comment → record-comments}/types.ts +2 -2
  34. package/template/client/src/components/business-ui/{comment → record-comments}/use-mention-search.ts +25 -8
  35. package/template/client/src/components/business-ui/user-display/user-display.tsx +13 -0
  36. package/template/client/src/components/business-ui/user-profile/lark-card-pool.ts +217 -0
  37. package/template/client/src/components/business-ui/user-profile/user-profile.tsx +397 -146
  38. package/template/client/src/components/business-ui/user-select/user-pill.tsx +13 -0
  39. package/template/client/src/hooks/use-example.ts +36 -45
  40. package/template/client/src/lib/api-error.ts +36 -0
  41. package/template/client/src/pages/ExamplePage/ExamplePage.tsx +8 -7
  42. package/template/client/src/tailwind-theme.css +6 -5
  43. package/template/eslint.config.js +6 -0
  44. package/template/package-lock.json +77 -122
  45. package/template/package.json +8 -8
  46. package/template/server/modules/hello/hello.controller.ts +8 -4
  47. package/template/server/modules/hello/hello.dto.ts +21 -0
  48. package/template/server/modules/hello/hello.service.ts +5 -3
  49. package/template/shared/api.interface.ts +8 -1
  50. package/template/client/src/components/business-ui/comment/README.md +0 -109
  51. package/template/client/src/components/business-ui/comment/ccm-document-source.ts +0 -252
  52. package/template/client/src/components/business-ui/comment/comment-provider.tsx +0 -40
  53. package/template/client/src/components/business-ui/comment/mention-appearance.ts +0 -35
  54. package/template/client/src/components/business-ui/comment/mention-suggestions.tsx +0 -312
  55. /package/template/client/src/components/business-ui/{comment → record-comments}/document-icon.tsx +0 -0
  56. /package/template/client/src/components/business-ui/{comment → record-comments}/document-icons.ts +0 -0
  57. /package/template/client/src/components/business-ui/{comment → record-comments}/entity-store.ts +0 -0
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.20261101040602",
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,58 @@
1
+ # Record comments
2
+
3
+ 完整受控记录评论组件,入口 `CommentPanel`。公共合同见 `comment-panel.contract.ts`、`types.ts`,统一从 `index.ts` 导入;目录内实现、CSS 和 `assets/reaction-sprite.png` 必须整体分发。不依赖旧 comment 目录,不包含测试应用、插件实例或表格配置。模板已配置 React、Tiptap 3.20.0、floating-ui 及 ui primitives 依赖。
4
+
5
+ 组件管理现有样式、回复归属、编辑器、人员弹层、菜单、分页触发和乐观更新;评论 CRUD、身份、权限、插件调用、服务端缓存、分页游标、表情及文档请求由宿主提供。
6
+
7
+ ## 接入
8
+
9
+ 必填 `scopeKey/recordID/comments/mentionSource`。可选参数完整类型见 `CommentPanelProps`。
10
+
11
+ ```tsx
12
+ import { useMemo } from 'react';
13
+ import { CommentPanel, createAppCommentSource, type CommentPanelProps } from '@/components/business-ui/record-comments';
14
+
15
+ type Props = Omit<CommentPanelProps, 'mentionSource'>;
16
+ export function RecordComments(props: Props) {
17
+ const source = useMemo(() => createAppCommentSource(props.scopeKey), [props.scopeKey]);
18
+ return <CommentPanel {...props} mentionSource={source} />;
19
+ }
20
+ ```
21
+
22
+ 数据和回调接宿主真实 API hook。`createAppCommentSource` 是可选的妙搭人员目录适配器,不含评论请求;其他目录直接实现 `CommentDataSource`。默认没有空查询推荐,可由宿主补 `recommend`。scopeKey 必须覆盖应用、租户、身份、插件、数据源及权限;上述上下文变化时更换。mentionSource 用 useMemo 稳定,scopeKey/recordID 变化会清理旧会话。
23
+
24
+ `commentID/replyID` 保留字符串;`createTime/updateTime` 为 Unix 秒。每条 comments 是讨论,第一条 reply 为正文,其余为同讨论回复。回复必须带原 commentID,不拆成独立消息。缺失 mention.userID 保留缺失引用,不猜人员。写 content 只含 text、mention.userID、link.url,不含编辑器元数据或文件。
25
+
26
+ 编辑/删除须显式提供 `getReplyPermissions`,缺省隐藏;前端菜单不替代后端鉴权。消息菜单点击“删除”即发起请求,无二次确认;始终传所选 replyID(含首条),只删除该消息。删除首条后第二条在原 commentID 下顺位成为正文,保留其 replyID/作者/时间,其他回复不变;最后一条删除后移除空讨论。省略 replyID 的历史整串删除仅供显式外部调用,组件菜单不使用。
27
+
28
+ ## 乐观更新与时间
29
+
30
+ 写回调只负责真实写入,成功返回真实 ID(删除返回 commentDeleted);后续回读单独执行,不因回读失败 reject 已提交写入,不在宿主重复插入临时消息。
31
+
32
+ 新增/回复立即显示并清空编辑器,编辑立即显示新文本并退出编辑。提交期间隐藏时间;新增/回复纯文字消息等待 500ms 后显示蓝色 loading,编辑不显示该 spinner;成功立即结束等待,不等回读。明确失败保留灰色内容、错误图标/文案与点击重试。未确认消息禁止继续编辑、回复或回应,其他消息与输入正常可用。
33
+
34
+ 普通 Error 仅表示确认未应用;超时/连接中断可能已写入时抛 `Object.assign(new Error(message), {outcome:'unknown'})`,组件保留内容并阻止盲目重发,宿主先刷新核对。只有确认请求未发出时使用 not-applied。
35
+
36
+ 匹配快照接管前,本地层保留消息防闪回;接管后去重。宿主须保护缓存读取顺序,组件不能从无版本号数组识别旧快照。传入 comments 不会被修改。卸载清理本地预览,旧回调不能更新新会话。
37
+
38
+ 显示时间取 `updateTime ?? createTime`,编辑标记取 `updateTime > createTime`,排序仍用创建时间。编辑确认、回读前临时用本次操作时间,回读后以服务端回复级 updateTime 为准。服务端新编辑可接管已确认草稿。秒级时间不是严格版本号,缺 updateTime 不能伪造历史编辑时间。
39
+
40
+ ## 分页和扩展
41
+
42
+ 宿主首屏读取;传 `pagination={hasMore,loading,error,onLoadMore}` 时组件接近底部自动触发。宿主保留游标,先追加 comments 再 resolve,失败 reject。组件有即时并发锁和连续自动加载上限,可滚动或手动继续。不传 pagination 表示外部已提供完整列表。
43
+
44
+ - **表情**:传 reactions 的 getReactions/onChange,可选 canChange/loadUsers。缺回应数据表示未知,不能从截断 userIDs 推断 mine。宿主按回复串行,显式添加/撤回;成功更新缓存,失败只回退目标,回读失败不回退成功动作。历史表情键保留。
45
+ - **文档**:宿主实现当前用户授权的 CommentDocumentSource,用 withDocumentSource 与人员来源合并并传 enableDocuments。推荐/搜索/解析均外置,持久化仅 link.url。无权限不得回退 owner 查询私密标题,插入引用不改变分享权限。文档不可用时保留人员结果。
46
+ - **图片**:当前多维表格插件未接入,默认不传 images。该扩展仅面向宿主自备完整上传/持久化/回读能力,不得把 File/blob URL 写入插件 content。预览保留到快照接管或会话卸载。
47
+ - **人员卡片/链接**:由 onMentionClick/getCommentLink 承接,缺回调不展示无效操作。
48
+
49
+ 完整 Action 映射、身份约束和接入验收见配套 `plugin-guide/references/record-comments.md`。模板不含 mock 路由;仅 mock 通过不能声称真实链路完成。
50
+
51
+ 人员提及悬浮沿用官方颜色:普通人员浅蓝底、自身蓝色胶囊变亮、无权限人员灰底。样式由 mention-appearance.css 的组件专属类提供,不依赖宿主 accent 配色。
52
+
53
+
54
+ ## 交互接入约束
55
+
56
+ 宿主复用组件已有交互,不额外包装删除确认、成功 toast 或“已显示全部评论”提示。复制链接成功后只关闭菜单;编辑操作按钮保持“确认”,等待态不新增“保存中”文字。回复自己也会插入自身提及;未知作者不能伪造提及。保持人员搜索和提及悬浮样式,不用宿主全局 button/a/p 样式覆盖组件。
57
+
58
+ 保留读写失败的恢复入口和无障碍状态;未知写入结果不得开放盲目重试。新增提示或改变状态流前,应取得官方对应状态的操作证据;本地受控故障测试不等同于真实 Action 验收,未覆盖的权限/网络/图片能力需明确标注。
@@ -50,6 +50,7 @@ export function createAppCommentSource(scopeKey: string): CommentDataSource {
50
50
  label: getI18nText(user.name) || '未命名人员',
51
51
  avatarUrl: avatarUrl(user.avatar),
52
52
  description: getI18nText(user.department?.name) || user.tenantName,
53
+ isExternal: user.userType === '_externalUser',
53
54
  },
54
55
  ];
55
56
  });
@@ -85,6 +86,8 @@ export function createAppCommentSource(scopeKey: string): CommentDataSource {
85
86
  status: 'resolved',
86
87
  label: getI18nText(user.name) || '未命名人员',
87
88
  avatarUrl: avatarUrl(user.avatar),
89
+ description: getI18nText(user.department?.name),
90
+ isExternal: user.userType === '_externalUser',
88
91
  };
89
92
  });
90
93
  },
@@ -125,14 +128,21 @@ export function withDocumentSource(
125
128
  const exhausted: Awaited<ReturnType<CommentDataSource['search']>> = {
126
129
  items: [],
127
130
  };
128
- const [users, docs] = await Promise.all([
129
- !args.cursor || cursor.user !== undefined
131
+ const requestUsers = !args.cursor || cursor.user !== undefined;
132
+ const requestDocs = !args.cursor || cursor.document !== undefined;
133
+ const pages = await Promise.allSettled([
134
+ requestUsers
130
135
  ? fetchSource(people, 'user', cursor.user)
131
136
  : exhausted,
132
- !args.cursor || cursor.document !== undefined
137
+ requestDocs
133
138
  ? fetchSource(documents, 'document', cursor.document)
134
139
  : exhausted,
135
140
  ]);
141
+ if ((!requestUsers || pages[0].status === 'rejected') && (!requestDocs || pages[1].status === 'rejected'))
142
+ throw new Error('人员和文档搜索失败,请重试');
143
+ // A disabled/unavailable document capability must not break existing @people.
144
+ const users = pages[0].status === 'fulfilled' ? pages[0].value : exhausted;
145
+ const docs = pages[1].status === 'fulfilled' ? pages[1].value : exhausted;
136
146
  return {
137
147
  items: [
138
148
  ...users.items.filter((item) => item.ref.kind === 'user'),
@@ -3,21 +3,15 @@ import { fromApiContent } from './content';
3
3
  import {
4
4
  USER_MENTION_CLASS,
5
5
  INPUT_USER_MENTION_CLASS,
6
- INPUT_USER_MENTION_LABEL_CLASS,
7
6
  COMMENT_THEME_CLASS,
8
- USER_MENTION_SELF_CLASS,
7
+ userMentionLabelClass,
9
8
  atUserLabel,
10
- USER_MENTION_LABEL_CLASS,
11
9
  DOCUMENT_MENTION_CLASS,
12
10
  DOCUMENT_MENTION_UNREADABLE_CLASS,
13
11
  } from './mention-appearance';
14
12
  import * as React from 'react';
15
13
  import { cn } from '@/lib/utils';
16
- import {
17
- mentionLabel,
18
- normalizeValue,
19
- safeUrl,
20
- } from './content';
14
+ import { mentionLabel, normalizeValue, safeUrl } from './content';
21
15
  import {
22
16
  useMentionContext,
23
17
  useMentionEntity,
@@ -34,12 +28,15 @@ function Mention({
34
28
  part,
35
29
  onMentionClick,
36
30
  mentionVariant,
31
+ trailing,
37
32
  }: {
38
33
  part: Extract<CommentPart, { type: 'mention' }>;
39
34
  onMentionClick?: CommentMentionClickHandler;
40
35
  mentionVariant?: 'text' | 'pill';
36
+ trailing?: React.ReactNode;
41
37
  }) {
42
- const { strict, store, currentUserID } = useMentionContext();
38
+ const { strict, store, currentUserID, highlightUserIDs } =
39
+ useMentionContext();
43
40
  const result = useMentionEntity(part.ref);
44
41
  const label = mentionLabel(part.ref, part.label, result, strict);
45
42
  if (part.ref.kind === 'document') {
@@ -97,18 +94,13 @@ function Mention({
97
94
  );
98
95
  return <span className={DOCUMENT_MENTION_CLASS}>{body}</span>;
99
96
  }
100
- const userLabelClass =
101
- mentionVariant === 'pill'
102
- ? INPUT_USER_MENTION_LABEL_CLASS
103
- : part.ref.id === currentUserID
104
- ? USER_MENTION_SELF_CLASS
105
- : USER_MENTION_LABEL_CLASS;
97
+ const userLabelClass = userMentionLabelClass(
98
+ part.ref.id === currentUserID || Boolean(highlightUserIDs?.includes(part.ref.id)),
99
+ result?.status === 'forbidden',
100
+ );
106
101
  const body = (
107
102
  <span
108
- className={cn(
109
- userLabelClass,
110
- result?.status === 'forbidden' && 'text-muted-foreground',
111
- )}
103
+ className={userLabelClass}
112
104
  >
113
105
  {atUserLabel(label)}
114
106
  </span>
@@ -147,6 +139,7 @@ export type CommentContentProps = {
147
139
  className?: string;
148
140
  onMentionClick?: CommentMentionClickHandler;
149
141
  mentionVariant?: 'text' | 'pill';
142
+ trailing?: React.ReactNode;
150
143
  } & (
151
144
  | { value: CommentValue; content?: never }
152
145
  | { content: readonly CommentApiContent[]; value?: never }
@@ -154,7 +147,7 @@ export type CommentContentProps = {
154
147
 
155
148
  /** Pass API content with userID only; metadata is fetched internally in batches. */
156
149
  export function CommentContent(props: CommentContentProps) {
157
- const { className, onMentionClick, mentionVariant = 'text' } = props;
150
+ const { className, onMentionClick, mentionVariant = 'text', trailing } = props;
158
151
  let normalized: CommentValue;
159
152
  try {
160
153
  normalized = props.value
@@ -172,6 +165,7 @@ export function CommentContent(props: CommentContentProps) {
172
165
  className,
173
166
  onMentionClick,
174
167
  mentionVariant,
168
+ trailing,
175
169
  });
176
170
  }
177
171
 
@@ -180,11 +174,13 @@ function ResolvedContent({
180
174
  className,
181
175
  onMentionClick,
182
176
  mentionVariant,
177
+ trailing,
183
178
  }: {
184
179
  value: CommentValue;
185
180
  className?: string;
186
181
  onMentionClick?: CommentMentionClickHandler;
187
182
  mentionVariant?: 'text' | 'pill';
183
+ trailing?: React.ReactNode;
188
184
  }) {
189
185
  useResolveComment(normalized);
190
186
  return (
@@ -229,6 +225,7 @@ function ResolvedContent({
229
225
  />
230
226
  ),
231
227
  )}
228
+ {trailing}
232
229
  </div>
233
230
  </div>
234
231
  );
@@ -145,7 +145,7 @@ function InputSession(props: CommentInputProps) {
145
145
  />
146
146
  );
147
147
  return (
148
- <div className={cn(COMMENT_THEME_CLASS, props.className)}>
148
+ <div className={cn(COMMENT_THEME_CLASS, 'replica-input', props.className)}>
149
149
  <div className="relative flex min-h-[34px] flex-col justify-center rounded-[6px] border-[0.5px] border-border bg-card px-3 py-[5px] transition-colors hover:border-primary focus-within:border-primary">
150
150
  <div
151
151
  aria-hidden
@@ -0,0 +1,198 @@
1
+ import { fromApiContent, readApiContent } from './content';
2
+ import type { DiscussionDraft, DiscussionImage, DiscussionThread } from './discussion-types';
3
+ import type { MentionRef } from './types';
4
+ import type {
5
+ CommentImageInput, CommentPanelProps, CommentPagination, CommentTarget,
6
+ CommentWriteContent,
7
+ } from './comment-panel.contract';
8
+
9
+ const userRef = (id?: number): MentionRef | null =>
10
+ id != null && Number.isSafeInteger(id) && id > 0
11
+ ? { kind: 'user', idType: 'apaas-user', id: String(id) } : null;
12
+
13
+ export function buildPanelThreads(props: CommentPanelProps): DiscussionThread[] {
14
+ return props.comments.map((comment) => ({
15
+ commentID: comment.commentID,
16
+ creator: userRef(comment.creator),
17
+ createTime: comment.createTime,
18
+ isSolved: comment.isSolved,
19
+ replies: comment.replies.map((reply, index) => {
20
+ const target = { recordID: props.recordID, commentID: comment.commentID, replyID: reply.replyID };
21
+ const images = props.images?.getImages(target) ?? [];
22
+ const policy = props.getReplyPermissions?.({ ...target, comment, reply, isBody: index === 0 });
23
+ return {
24
+ replyID: reply.replyID,
25
+ author: userRef(reply.creator),
26
+ createTime: reply.createTime,
27
+ updateTime: reply.updateTime && reply.updateTime >= reply.createTime ? reply.updateTime : undefined,
28
+ edited: Boolean(reply.updateTime && reply.updateTime > reply.createTime),
29
+ value: fromApiContent(reply.content.map((part) => part.type === 'mention' &&
30
+ (!Number.isSafeInteger(part.userID) || (part.userID ?? 0) <= 0)
31
+ ? { type: 'mention' } : part)),
32
+ images: images.map((image) => ({ ...image, name: image.name ?? '图片' })),
33
+ reactions: (props.reactions?.getReactions(target) ?? []).map((reaction) => ({
34
+ key: reaction.emojiKey, count: reaction.count, mine: reaction.reactedByMe,
35
+ users: (reaction.previewUserIDs ?? []).map(String),
36
+ })),
37
+ permissions: {
38
+ canReact: props.reactions?.canChange?.(target) ?? true,
39
+ canEdit: Boolean(policy?.canEdit && (images.length
40
+ ? props.images?.onUpdateComment : props.onUpdateComment || props.images?.onUpdateComment)),
41
+ canDelete: Boolean(policy?.canDelete && props.onDeleteComment),
42
+ canCopyLink: Boolean(props.getCommentLink?.(target)),
43
+ },
44
+ };
45
+ }),
46
+ }));
47
+ }
48
+
49
+ function assertID(id: string) {
50
+ if (!/^\d+$/.test(id)) throw new Error('评论或回复 ID 必须是非空数字字符串');
51
+ }
52
+ function assertRecord(props: CommentPanelProps) {
53
+ if (!props.recordID) throw new Error('记录 ID 不能为空');
54
+ }
55
+ function writeContent(draft: DiscussionDraft, allowEmpty: boolean): CommentWriteContent[] {
56
+ const content = readApiContent(draft.value);
57
+ if ((!allowEmpty && !content.length) || content.length > 50)
58
+ throw new Error('评论正文需要包含 1 至 50 个内容元素');
59
+ for (const part of content) {
60
+ if (part.type === 'mention' && (!Number.isSafeInteger(part.userID) || (part.userID ?? 0) <= 0))
61
+ throw new Error('请移除或重新选择无法识别的 @ 人员');
62
+ if ((part.type === 'text' && !part.text) || (part.type === 'link' && !part.url))
63
+ throw new Error('评论文本或链接不能为空');
64
+ }
65
+ return content as CommentWriteContent[];
66
+ }
67
+ function imageInputs(images: DiscussionImage[]): CommentImageInput[] {
68
+ return images.map((image) => image.file
69
+ ? { kind: 'local', localID: image.id, file: image.file }
70
+ : { kind: 'existing', image: {
71
+ id: image.id, url: image.url, name: image.name, width: image.width, height: image.height,
72
+ } });
73
+ }
74
+ function releaseSubmittedPreviews(images: DiscussionImage[]) {
75
+ // Public image callbacks receive File, never the editor's temporary object URL.
76
+ for (const image of images)
77
+ if (image.file && image.url.startsWith('blob:')) URL.revokeObjectURL(image.url);
78
+ }
79
+ export function preparePanelComment(props: CommentPanelProps, draft: DiscussionDraft) {
80
+ assertRecord(props);
81
+ if (draft.commentID !== undefined) {
82
+ assertID(draft.commentID);
83
+ if (!props.comments.some((thread) => thread.commentID === draft.commentID))
84
+ throw new Error('原讨论已不存在,请重新选择回复对象');
85
+ }
86
+ const base = {
87
+ recordID: props.recordID,
88
+ ...(draft.commentID === undefined ? {} : { commentID: draft.commentID }),
89
+ content: writeContent(draft, draft.images.length > 0),
90
+ };
91
+ const imageCommand = draft.images.length > 0 || !props.onAddComment;
92
+ if (imageCommand) {
93
+ if (!props.images?.onAddComment) throw new Error('未提供图片评论发送能力');
94
+ const command = props.images.onAddComment;
95
+ const input = { ...base, images: imageInputs(draft.images) };
96
+ return () => command(input);
97
+ }
98
+ const command = props.onAddComment!;
99
+ return () => command(base);
100
+ }
101
+ export async function submitPanelComment(props: CommentPanelProps, draft: DiscussionDraft) {
102
+ const result = await preparePanelComment(props, draft)();
103
+ releaseSubmittedPreviews(draft.images);
104
+ return result;
105
+ }
106
+ function findTarget(props: CommentPanelProps, commentID: string, replyID: string) {
107
+ assertRecord(props); assertID(commentID); assertID(replyID);
108
+ const comment = props.comments.find((item) => item.commentID === commentID);
109
+ const reply = comment?.replies.find((item) => item.replyID === replyID);
110
+ if (!comment || !reply) throw new Error('原评论已不存在,请刷新后重试');
111
+ const target = { recordID: props.recordID, commentID, replyID };
112
+ const policy = props.getReplyPermissions?.({ ...target, comment, reply, isBody: comment.replies[0] === reply });
113
+ return { target, comment, reply, policy };
114
+ }
115
+ export function preparePanelEdit(
116
+ props: CommentPanelProps, commentID: string, replyID: string, draft: DiscussionDraft,
117
+ ) {
118
+ const { target, policy } = findTarget(props, commentID, replyID);
119
+ if (!policy?.canEdit) throw new Error('没有编辑该评论的权限');
120
+ const hadImages = Boolean(props.images?.getImages(target).length);
121
+ const base = { ...target, content: writeContent(draft, draft.images.length > 0) };
122
+ if (hadImages || draft.images.length || !props.onUpdateComment) {
123
+ if (!props.images?.onUpdateComment) throw new Error('未提供图片评论编辑能力');
124
+ const command = props.images.onUpdateComment;
125
+ const input = { ...base, images: imageInputs(draft.images) };
126
+ return () => command(input);
127
+ }
128
+ const command = props.onUpdateComment;
129
+ return () => command(base);
130
+ }
131
+ export async function editPanelComment(props: CommentPanelProps, commentID: string, replyID: string, draft: DiscussionDraft) {
132
+ const result = await preparePanelEdit(props, commentID, replyID, draft)();
133
+ releaseSubmittedPreviews(draft.images);
134
+ return result;
135
+ }
136
+ export function preparePanelDeletion(props: CommentPanelProps, commentID: string, replyID: string) {
137
+ const { target, policy } = findTarget(props, commentID, replyID);
138
+ if (!policy?.canDelete || !props.onDeleteComment) throw new Error('没有删除该评论的权限');
139
+ const command = props.onDeleteComment;
140
+ const input = { recordID: target.recordID, commentID, replyID };
141
+ return () => command(input);
142
+ }
143
+ export async function deletePanelComment(props: CommentPanelProps, commentID: string, replyID: string) {
144
+ return preparePanelDeletion(props, commentID, replyID)();
145
+ }
146
+
147
+ /** Shared by intersection, scroll and retry triggers; its lock is synchronous. */
148
+ export class CommentPageLoader {
149
+ pending = false;
150
+ error = '';
151
+ automaticLoads = 0;
152
+ async load(pagination: CommentPagination, manual = false): Promise<boolean> {
153
+ if (this.pending || pagination.loading || !pagination.hasMore) return false;
154
+ if (!manual && (this.error || pagination.error || this.automaticLoads >= 3)) return false;
155
+ if (manual) this.automaticLoads = 0;
156
+ else this.automaticLoads++;
157
+ this.pending = true;
158
+ this.error = '';
159
+ try {
160
+ await pagination.onLoadMore();
161
+ return true;
162
+ } catch (cause) {
163
+ this.error = cause instanceof Error ? cause.message : '加载失败,请重试';
164
+ return false;
165
+ } finally { this.pending = false; }
166
+ }
167
+ }
168
+
169
+ export async function loadPanelReactionDetails(props: CommentPanelProps, target: CommentTarget, signal: AbortSignal) {
170
+ const feature = props.reactions;
171
+ const reactions = feature?.getReactions(target) ?? [];
172
+ return Promise.all(reactions.map(async (reaction) => {
173
+ const users = new Set<number>();
174
+ let cursor: string | undefined;
175
+ const seen = new Set<string>();
176
+ if (!feature?.loadUsers) return {
177
+ key: reaction.emojiKey, count: reaction.count, mine: reaction.reactedByMe,
178
+ users: (reaction.previewUserIDs ?? []).map(String),
179
+ };
180
+ for (let page = 0; page < 100; page++) {
181
+ signal.throwIfAborted();
182
+ const result = await feature.loadUsers({ ...target, emojiKey: reaction.emojiKey, ...(cursor ? { cursor } : {}) }, signal);
183
+ result.userIDs.forEach((id) => users.add(id));
184
+ if (!result.nextCursor) return {
185
+ key: reaction.emojiKey, count: reaction.count, mine: reaction.reactedByMe, users: [...users].map(String),
186
+ };
187
+ if (seen.has(result.nextCursor)) throw new Error('回应人员分页游标未前进');
188
+ seen.add(result.nextCursor); cursor = result.nextCursor;
189
+ }
190
+ throw new Error('回应人员较多,请稍后重试');
191
+ }));
192
+ }
193
+
194
+ export function commentFailureMessage(cause: unknown, fallback: string): string {
195
+ const message = cause instanceof Error ? cause.message : fallback;
196
+ return cause instanceof Error && 'outcome' in cause && cause.outcome === 'unknown'
197
+ ? `${message};操作结果未确认,请先刷新核对后再操作` : message;
198
+ }
@@ -0,0 +1,171 @@
1
+ import { normalizeValue, readApiContent } from './content';
2
+ import { commentFailureMessage } from './comment-panel-model';
3
+ import type { CommentIDs, DeleteCommentResult, CommentMutationFailure } from './comment-panel.contract';
4
+ import type { DiscussionDraft, DiscussionImage, DiscussionReply, DiscussionThread } from './discussion-types';
5
+ import type { MentionRef } from './types';
6
+
7
+ export interface LocalCommentMutation {
8
+ id: string;
9
+ kind: 'add' | 'edit' | 'delete';
10
+ phase: 'sending' | 'failed' | 'uncertain' | 'confirmed';
11
+ message?: string;
12
+ }
13
+ interface Operation extends LocalCommentMutation {
14
+ thread: DiscussionThread;
15
+ commentID: string;
16
+ replyID: string;
17
+ draft?: DiscussionDraft;
18
+ author?: MentionRef;
19
+ createTime: number;
20
+ wholeThread?: boolean;
21
+ execute: () => Promise<CommentIDs | DeleteCommentResult>;
22
+ }
23
+ const contentKey = (reply: Pick<DiscussionReply, 'value'>) => JSON.stringify(readApiContent(reply.value));
24
+ const imageKey = (images: readonly DiscussionImage[]) => JSON.stringify(images.map(({ id, url }) => [id, url]));
25
+ const uncertain = (message: string) => Object.assign(new Error(message), { outcome: 'unknown' as const });
26
+
27
+ /** Component-local overlay. Never writes the host's data or performs a request itself. */
28
+ export class CommentMutations {
29
+ private operations: Operation[] = [];
30
+ private listeners = new Set<() => void>();
31
+ private version = 0;
32
+ private active = true;
33
+ subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
34
+ getVersion = () => this.version;
35
+ private changed() { if (this.active) { this.version++; this.listeners.forEach((listener) => listener()); } }
36
+ activate() { this.active = true; }
37
+ private release(operation: Operation) {
38
+ for (const image of operation.draft?.images ?? [])
39
+ if (image.file && image.url.startsWith('blob:')) URL.revokeObjectURL(image.url);
40
+ }
41
+ dispose() {
42
+ this.active = false;
43
+ this.operations.forEach((operation) => this.release(operation));
44
+ this.operations = [];
45
+ }
46
+ replyID(id: string) { return this.operations.find((operation) => operation.id === id)?.replyID ?? id; }
47
+ private assertAvailable(commentID: string, replyID?: string) {
48
+ if (!this.active) throw new Error('评论会话已关闭');
49
+ if (this.operations.some((operation) => operation.commentID === commentID &&
50
+ (operation.wholeThread || (operation.kind === 'add' && !operation.draft?.commentID) || operation.replyID === replyID)))
51
+ throw new Error('该评论仍在处理中,请先完成同步或处理失败提示');
52
+ }
53
+ private start(operation: Operation) {
54
+ this.operations.push(operation); this.changed();
55
+ void this.run(operation);
56
+ return operation.id;
57
+ }
58
+ add(thread: DiscussionThread | undefined, draft: DiscussionDraft, author: MentionRef | undefined, execute: () => Promise<CommentIDs>) {
59
+ if (thread) this.assertAvailable(thread.commentID);
60
+ else if (!this.active) throw new Error('评论会话已关闭');
61
+ const id = `local:${crypto.randomUUID()}`;
62
+ const createTime = Math.floor(Date.now() / 1000);
63
+ const commentID = thread?.commentID ?? `local:thread:${crypto.randomUUID()}`;
64
+ return this.start({ id, kind: 'add', phase: 'sending', commentID, replyID: id,
65
+ thread: thread ?? { commentID, creator: author ?? null, createTime, isSolved: false, replies: [] },
66
+ draft: { ...draft, commentID: thread?.commentID, value: normalizeValue(draft.value), images: [...draft.images] }, author, createTime, execute });
67
+ }
68
+ edit(thread: DiscussionThread, replyID: string, draft: DiscussionDraft, execute: () => Promise<CommentIDs>) {
69
+ this.assertAvailable(thread.commentID, replyID);
70
+ return this.start({ id: `local:${crypto.randomUUID()}`, kind: 'edit', phase: 'sending',
71
+ thread, commentID: thread.commentID, replyID, createTime: Math.floor(Date.now() / 1000),
72
+ draft: { ...draft, value: normalizeValue(draft.value), images: [...draft.images] }, execute });
73
+ }
74
+ delete(thread: DiscussionThread, replyID: string, execute: () => Promise<DeleteCommentResult>) {
75
+ // The UI always deletes one selected message, including the current body.
76
+ const wholeThread = false;
77
+ this.assertAvailable(thread.commentID, replyID);
78
+ return this.start({ id: `local:${crypto.randomUUID()}`, kind: 'delete', phase: 'sending',
79
+ thread, commentID: thread.commentID, replyID, wholeThread, createTime: Math.floor(Date.now() / 1000), execute });
80
+ }
81
+ private async run(operation: Operation) {
82
+ operation.phase = 'sending'; operation.message = undefined; this.changed();
83
+ try {
84
+ const result = await operation.execute();
85
+ if (!this.active) return;
86
+ if (!/^\d+$/.test(result.commentID)) throw uncertain('提交结果缺少有效评论 ID');
87
+ if (operation.kind !== 'add' || operation.draft?.commentID !== undefined) {
88
+ if (result.commentID !== operation.commentID) throw uncertain('提交结果与目标讨论不一致');
89
+ }
90
+ if (operation.kind === 'delete') {
91
+ if (!('commentDeleted' in result) || typeof result.commentDeleted !== 'boolean') throw uncertain('删除结果未确认');
92
+ operation.wholeThread = result.commentDeleted;
93
+ } else {
94
+ if (!('replyID' in result) || !/^\d+$/.test(result.replyID)) throw uncertain('提交结果缺少有效回复 ID');
95
+ if (operation.kind === 'edit' && result.replyID !== operation.replyID) throw uncertain('编辑结果与目标回复不一致');
96
+ operation.replyID = result.replyID;
97
+ }
98
+ operation.commentID = result.commentID;
99
+ operation.phase = 'confirmed';
100
+ } catch (cause) {
101
+ if (!this.active) return;
102
+ operation.phase = (cause as CommentMutationFailure | null)?.outcome === 'unknown' ? 'uncertain' : 'failed';
103
+ operation.message = commentFailureMessage(cause, '操作失败,请重试');
104
+ }
105
+ this.changed();
106
+ }
107
+ retry(id: string) {
108
+ const operation = this.operations.find((item) => item.id === id);
109
+ if (!operation || operation.phase !== 'failed' || !this.active) return;
110
+ void this.run(operation);
111
+ }
112
+ dismiss(id: string) {
113
+ const operation = this.operations.find((item) => item.id === id);
114
+ if (!operation || !['failed', 'uncertain'].includes(operation.phase)) return;
115
+ this.operations = this.operations.filter((item) => item !== operation);
116
+ this.release(operation); this.changed();
117
+ }
118
+ private acknowledged(operation: Operation, base: readonly DiscussionThread[]) {
119
+ const thread = base.find((item) => item.commentID === operation.commentID);
120
+ const reply = thread?.replies.find((item) => item.replyID === operation.replyID);
121
+ if (operation.kind === 'delete') return operation.wholeThread ? !thread : !reply;
122
+ if (!reply) return false;
123
+ if (operation.kind === 'add') return true;
124
+ const before = operation.thread.replies.find((item) => item.replyID === operation.replyID)!;
125
+ // A later authoritative edit wins even when another editor changed the text.
126
+ // Compare server revisions, never the optimistic client wall clock.
127
+ if (operation.phase === 'confirmed' && reply.updateTime !== undefined &&
128
+ reply.updateTime > (before.updateTime ?? before.createTime)) return true;
129
+ if (contentKey(reply) !== contentKey({ value: operation.draft!.value })) return false;
130
+ const images = operation.draft!.images;
131
+ if (imageKey(images) === imageKey(before.images)) return true;
132
+ return images.some((image) => image.file)
133
+ ? reply.images.length === images.length && imageKey(reply.images) !== imageKey(before.images)
134
+ : imageKey(reply.images) === imageKey(images);
135
+ }
136
+ reconcile(base: readonly DiscussionThread[]) {
137
+ const finished = this.operations.filter((operation) => (operation.phase === 'confirmed' ||
138
+ (operation.phase === 'uncertain' && operation.kind !== 'add')) && this.acknowledged(operation, base));
139
+ if (!finished.length) return;
140
+ this.operations = this.operations.filter((operation) => !finished.includes(operation));
141
+ finished.forEach((operation) => this.release(operation)); this.changed();
142
+ }
143
+ view(base: readonly DiscussionThread[]): DiscussionThread[] {
144
+ let threads = base.map((thread) => ({ ...thread, replies: [...thread.replies] }));
145
+ for (const operation of this.operations) {
146
+ let thread = threads.find((item) => item.commentID === operation.commentID);
147
+ if (operation.kind === 'delete' && operation.phase === 'confirmed') {
148
+ if (operation.wholeThread) threads = threads.filter((item) => item !== thread);
149
+ else if (thread) thread.replies = thread.replies.filter((reply) => reply.replyID !== operation.replyID);
150
+ continue;
151
+ }
152
+ if (!thread) {
153
+ thread = { ...operation.thread, commentID: operation.commentID, replies: [...operation.thread.replies] };
154
+ threads.push(thread);
155
+ }
156
+ const index = thread.replies.findIndex((item) => item.replyID === operation.replyID);
157
+ const before = thread.replies[index] ?? operation.thread.replies.find((item) => item.replyID === operation.replyID);
158
+ const mutation: LocalCommentMutation = { id: operation.id, kind: operation.kind, phase: operation.phase, message: operation.message };
159
+ const reply: DiscussionReply = operation.kind === 'add'
160
+ ? { replyID: operation.replyID, author: operation.author ?? null, createTime: operation.createTime,
161
+ value: operation.draft!.value, images: operation.draft!.images, reactions: [], mutation }
162
+ : { ...before!, ...(operation.draft ? { value: operation.draft.value, images: operation.draft.images,
163
+ edited: before?.edited || operation.phase === 'confirmed',
164
+ updateTime: operation.phase === 'confirmed' ? operation.createTime : before?.updateTime } : {}), mutation };
165
+ if (index < 0) thread.replies.push(reply); else thread.replies[index] = reply;
166
+ if ((operation.kind === 'delete' && operation.wholeThread) || (operation.kind === 'add' && !operation.draft?.commentID))
167
+ thread.replies = thread.replies.map((item) => ({ ...item, mutationBlocked: true }));
168
+ }
169
+ return threads.filter((thread) => thread.replies.length > 0);
170
+ }
171
+ }