@lark-apaas/coding-template-nestjs-react-fullstack 0.1.55-alpha.20261030081308 → 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.
- package/package.json +1 -1
- package/template/client/src/components/business-ui/README.md +2 -10
- package/template/client/src/components/business-ui/api/user-profiles/queries.ts +7 -1
- package/template/client/src/components/business-ui/record-comments/README.md +58 -0
- package/template/client/src/components/business-ui/{comment/comment-provider.tsx → record-comments/app-source.ts} +16 -44
- package/template/client/src/components/business-ui/record-comments/assets/reaction-sprite.png +0 -0
- package/template/client/src/components/business-ui/{comment → record-comments}/comment-content.tsx +18 -17
- package/template/client/src/components/business-ui/record-comments/comment-input.tsx +190 -0
- package/template/client/src/components/business-ui/record-comments/comment-panel-model.ts +198 -0
- package/template/client/src/components/business-ui/record-comments/comment-panel-optimistic.ts +171 -0
- package/template/client/src/components/business-ui/record-comments/comment-panel.contract.ts +223 -0
- package/template/client/src/components/business-ui/record-comments/comment-panel.tsx +194 -0
- package/template/client/src/components/business-ui/record-comments/comment-reactions.css +220 -0
- package/template/client/src/components/business-ui/record-comments/comment-reactions.tsx +696 -0
- package/template/client/src/components/business-ui/{comment → record-comments}/content.ts +79 -10
- package/template/client/src/components/business-ui/record-comments/discussion-composer.tsx +363 -0
- package/template/client/src/components/business-ui/record-comments/discussion-image-viewer.tsx +105 -0
- package/template/client/src/components/business-ui/record-comments/discussion-panel.tsx +546 -0
- package/template/client/src/components/business-ui/record-comments/discussion-time.ts +28 -0
- package/template/client/src/components/business-ui/record-comments/discussion-types.ts +87 -0
- package/template/client/src/components/business-ui/record-comments/discussion.css +709 -0
- package/template/client/src/components/business-ui/record-comments/document-icon.tsx +104 -0
- package/template/client/src/components/business-ui/record-comments/document-icons.ts +19 -0
- package/template/client/src/components/business-ui/record-comments/editor.tsx +438 -0
- package/template/client/src/components/business-ui/record-comments/entity-mention.ts +281 -0
- package/template/client/src/components/business-ui/{comment/mention-provider.tsx → record-comments/entity-store.ts} +2 -73
- package/template/client/src/components/business-ui/record-comments/icons.tsx +28 -0
- package/template/client/src/components/business-ui/record-comments/image-files.ts +51 -0
- package/template/client/src/components/business-ui/record-comments/index.ts +7 -0
- package/template/client/src/components/business-ui/record-comments/mention-appearance.css +11 -0
- package/template/client/src/components/business-ui/record-comments/mention-appearance.ts +36 -0
- package/template/client/src/components/business-ui/record-comments/mention-provider.tsx +81 -0
- package/template/client/src/components/business-ui/record-comments/mention-suggestions.css +125 -0
- package/template/client/src/components/business-ui/record-comments/mention-suggestions.tsx +331 -0
- package/template/client/src/components/business-ui/record-comments/reaction-catalog.ts +194 -0
- package/template/client/src/components/business-ui/{comment → record-comments}/types.ts +7 -38
- package/template/client/src/components/business-ui/record-comments/use-mention-search.ts +124 -0
- package/template/client/src/components/business-ui/user-display/user-display.tsx +13 -0
- package/template/client/src/components/business-ui/user-profile/lark-card-pool.ts +217 -0
- package/template/client/src/components/business-ui/user-profile/user-profile.tsx +397 -146
- package/template/client/src/components/business-ui/user-select/user-pill.tsx +13 -0
- package/template/client/src/hooks/use-example.ts +36 -45
- package/template/client/src/lib/api-error.ts +36 -0
- package/template/client/src/pages/ExamplePage/ExamplePage.tsx +8 -7
- package/template/client/src/tailwind-theme.css +6 -5
- package/template/eslint.config.js +6 -0
- package/template/package-lock.json +572 -77
- package/template/package.json +15 -5
- package/template/server/modules/hello/hello.controller.ts +8 -4
- package/template/server/modules/hello/hello.dto.ts +21 -0
- package/template/server/modules/hello/hello.service.ts +5 -3
- package/template/shared/api.interface.ts +8 -1
- package/template/client/src/components/business-ui/comment/README.md +0 -177
- package/template/client/src/components/business-ui/comment/ccm-document-source.ts +0 -252
- package/template/client/src/components/business-ui/comment/comment-input.tsx +0 -536
- package/template/client/src/components/business-ui/comment/dom-editor.ts +0 -582
- package/template/client/src/components/business-ui/comment/image-attachments.tsx +0 -375
- package/template/client/src/components/business-ui/comment/mention-suggestions.tsx +0 -411
- package/template/client/src/components/business-ui/comment/mention-view.tsx +0 -171
package/package.json
CHANGED
|
@@ -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 快捷键回调,发送、取消按钮及请求状态由页面组合,原生 div 编辑器直接可编辑;支持选择/粘贴图片并在正文下方展示附件,使用 getImages/clearImages 管理。只读 `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
|
|
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 验收,未覆盖的权限/网络/图片能力需明确标注。
|
|
@@ -1,50 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import { useCurrentUserProfile } from '@lark-apaas/client-toolkit/hooks/useCurrentUserProfile';
|
|
5
|
-
import { MentionProvider } from './mention-provider';
|
|
1
|
+
import { listUsersByIds, searchUsers } from '../api/users/service';
|
|
2
|
+
import { getI18nText } from '../utils/user';
|
|
3
|
+
import { mentionKey } from './content';
|
|
6
4
|
import type {
|
|
7
5
|
CommentDataSource,
|
|
8
6
|
CommentDocumentSource,
|
|
9
7
|
MentionOption,
|
|
10
8
|
MentionResult,
|
|
11
9
|
} from './types';
|
|
12
|
-
import { listUsersByIds, searchUsers } from '../api/users/service';
|
|
13
|
-
import { getI18nText } from '../utils/user';
|
|
14
|
-
import { mentionKey } from './content';
|
|
15
|
-
|
|
16
|
-
/** Configure the built-in UserDisplay data service once for the input and list. */
|
|
17
|
-
export function CommentProvider({
|
|
18
|
-
children,
|
|
19
|
-
scopeKey,
|
|
20
|
-
currentUserID,
|
|
21
|
-
recommend,
|
|
22
|
-
documents,
|
|
23
|
-
}: {
|
|
24
|
-
children: ReactNode;
|
|
25
|
-
scopeKey?: string;
|
|
26
|
-
currentUserID?: string;
|
|
27
|
-
recommend?: CommentDataSource['recommend'];
|
|
28
|
-
documents?: CommentDocumentSource;
|
|
29
|
-
}) {
|
|
30
|
-
const user = useCurrentUserProfile();
|
|
31
|
-
const scope = JSON.stringify([
|
|
32
|
-
typeof window === 'undefined' ? null : getAppId(window.location.pathname),
|
|
33
|
-
scopeKey || user.user_id || 'pending',
|
|
34
|
-
]);
|
|
35
|
-
const source = useMemo(() => {
|
|
36
|
-
const people = { ...createAppCommentSource(scope), recommend };
|
|
37
|
-
return documents ? withDocumentSource(people, documents) : people;
|
|
38
|
-
}, [scope, recommend, documents]);
|
|
39
|
-
return (
|
|
40
|
-
<MentionProvider
|
|
41
|
-
source={source}
|
|
42
|
-
currentUserID={currentUserID || user.user_id}
|
|
43
|
-
>
|
|
44
|
-
{children}
|
|
45
|
-
</MentionProvider>
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
10
|
|
|
49
11
|
function avatarUrl(avatar: unknown): string | undefined {
|
|
50
12
|
if (typeof avatar === 'string') return avatar;
|
|
@@ -88,6 +50,7 @@ export function createAppCommentSource(scopeKey: string): CommentDataSource {
|
|
|
88
50
|
label: getI18nText(user.name) || '未命名人员',
|
|
89
51
|
avatarUrl: avatarUrl(user.avatar),
|
|
90
52
|
description: getI18nText(user.department?.name) || user.tenantName,
|
|
53
|
+
isExternal: user.userType === '_externalUser',
|
|
91
54
|
},
|
|
92
55
|
];
|
|
93
56
|
});
|
|
@@ -123,6 +86,8 @@ export function createAppCommentSource(scopeKey: string): CommentDataSource {
|
|
|
123
86
|
status: 'resolved',
|
|
124
87
|
label: getI18nText(user.name) || '未命名人员',
|
|
125
88
|
avatarUrl: avatarUrl(user.avatar),
|
|
89
|
+
description: getI18nText(user.department?.name),
|
|
90
|
+
isExternal: user.userType === '_externalUser',
|
|
126
91
|
};
|
|
127
92
|
});
|
|
128
93
|
},
|
|
@@ -163,14 +128,21 @@ export function withDocumentSource(
|
|
|
163
128
|
const exhausted: Awaited<ReturnType<CommentDataSource['search']>> = {
|
|
164
129
|
items: [],
|
|
165
130
|
};
|
|
166
|
-
const
|
|
167
|
-
|
|
131
|
+
const requestUsers = !args.cursor || cursor.user !== undefined;
|
|
132
|
+
const requestDocs = !args.cursor || cursor.document !== undefined;
|
|
133
|
+
const pages = await Promise.allSettled([
|
|
134
|
+
requestUsers
|
|
168
135
|
? fetchSource(people, 'user', cursor.user)
|
|
169
136
|
: exhausted,
|
|
170
|
-
|
|
137
|
+
requestDocs
|
|
171
138
|
? fetchSource(documents, 'document', cursor.document)
|
|
172
139
|
: exhausted,
|
|
173
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;
|
|
174
146
|
return {
|
|
175
147
|
items: [
|
|
176
148
|
...users.items.filter((item) => item.ref.kind === 'user'),
|
|
Binary file
|
package/template/client/src/components/business-ui/{comment → record-comments}/comment-content.tsx
RENAMED
|
@@ -1,16 +1,14 @@
|
|
|
1
|
+
import { DocumentIcon } from './document-icon';
|
|
1
2
|
import { fromApiContent } from './content';
|
|
2
3
|
import {
|
|
3
|
-
DocumentIcon,
|
|
4
4
|
USER_MENTION_CLASS,
|
|
5
5
|
INPUT_USER_MENTION_CLASS,
|
|
6
|
-
INPUT_USER_MENTION_LABEL_CLASS,
|
|
7
6
|
COMMENT_THEME_CLASS,
|
|
8
|
-
|
|
7
|
+
userMentionLabelClass,
|
|
9
8
|
atUserLabel,
|
|
10
|
-
USER_MENTION_LABEL_CLASS,
|
|
11
9
|
DOCUMENT_MENTION_CLASS,
|
|
12
10
|
DOCUMENT_MENTION_UNREADABLE_CLASS,
|
|
13
|
-
} from './mention-
|
|
11
|
+
} from './mention-appearance';
|
|
14
12
|
import * as React from 'react';
|
|
15
13
|
import { cn } from '@/lib/utils';
|
|
16
14
|
import { mentionLabel, normalizeValue, safeUrl } from './content';
|
|
@@ -30,12 +28,15 @@ function Mention({
|
|
|
30
28
|
part,
|
|
31
29
|
onMentionClick,
|
|
32
30
|
mentionVariant,
|
|
31
|
+
trailing,
|
|
33
32
|
}: {
|
|
34
33
|
part: Extract<CommentPart, { type: 'mention' }>;
|
|
35
34
|
onMentionClick?: CommentMentionClickHandler;
|
|
36
35
|
mentionVariant?: 'text' | 'pill';
|
|
36
|
+
trailing?: React.ReactNode;
|
|
37
37
|
}) {
|
|
38
|
-
const { strict, store, currentUserID } =
|
|
38
|
+
const { strict, store, currentUserID, highlightUserIDs } =
|
|
39
|
+
useMentionContext();
|
|
39
40
|
const result = useMentionEntity(part.ref);
|
|
40
41
|
const label = mentionLabel(part.ref, part.label, result, strict);
|
|
41
42
|
if (part.ref.kind === 'document') {
|
|
@@ -93,18 +94,13 @@ function Mention({
|
|
|
93
94
|
);
|
|
94
95
|
return <span className={DOCUMENT_MENTION_CLASS}>{body}</span>;
|
|
95
96
|
}
|
|
96
|
-
const userLabelClass =
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
? USER_MENTION_SELF_CLASS
|
|
101
|
-
: USER_MENTION_LABEL_CLASS;
|
|
97
|
+
const userLabelClass = userMentionLabelClass(
|
|
98
|
+
part.ref.id === currentUserID || Boolean(highlightUserIDs?.includes(part.ref.id)),
|
|
99
|
+
result?.status === 'forbidden',
|
|
100
|
+
);
|
|
102
101
|
const body = (
|
|
103
102
|
<span
|
|
104
|
-
className={
|
|
105
|
-
userLabelClass,
|
|
106
|
-
result?.status === 'forbidden' && 'text-muted-foreground',
|
|
107
|
-
)}
|
|
103
|
+
className={userLabelClass}
|
|
108
104
|
>
|
|
109
105
|
{atUserLabel(label)}
|
|
110
106
|
</span>
|
|
@@ -143,6 +139,7 @@ export type CommentContentProps = {
|
|
|
143
139
|
className?: string;
|
|
144
140
|
onMentionClick?: CommentMentionClickHandler;
|
|
145
141
|
mentionVariant?: 'text' | 'pill';
|
|
142
|
+
trailing?: React.ReactNode;
|
|
146
143
|
} & (
|
|
147
144
|
| { value: CommentValue; content?: never }
|
|
148
145
|
| { content: readonly CommentApiContent[]; value?: never }
|
|
@@ -150,7 +147,7 @@ export type CommentContentProps = {
|
|
|
150
147
|
|
|
151
148
|
/** Pass API content with userID only; metadata is fetched internally in batches. */
|
|
152
149
|
export function CommentContent(props: CommentContentProps) {
|
|
153
|
-
const { className, onMentionClick, mentionVariant = 'text' } = props;
|
|
150
|
+
const { className, onMentionClick, mentionVariant = 'text', trailing } = props;
|
|
154
151
|
let normalized: CommentValue;
|
|
155
152
|
try {
|
|
156
153
|
normalized = props.value
|
|
@@ -168,6 +165,7 @@ export function CommentContent(props: CommentContentProps) {
|
|
|
168
165
|
className,
|
|
169
166
|
onMentionClick,
|
|
170
167
|
mentionVariant,
|
|
168
|
+
trailing,
|
|
171
169
|
});
|
|
172
170
|
}
|
|
173
171
|
|
|
@@ -176,11 +174,13 @@ function ResolvedContent({
|
|
|
176
174
|
className,
|
|
177
175
|
onMentionClick,
|
|
178
176
|
mentionVariant,
|
|
177
|
+
trailing,
|
|
179
178
|
}: {
|
|
180
179
|
value: CommentValue;
|
|
181
180
|
className?: string;
|
|
182
181
|
onMentionClick?: CommentMentionClickHandler;
|
|
183
182
|
mentionVariant?: 'text' | 'pill';
|
|
183
|
+
trailing?: React.ReactNode;
|
|
184
184
|
}) {
|
|
185
185
|
useResolveComment(normalized);
|
|
186
186
|
return (
|
|
@@ -225,6 +225,7 @@ function ResolvedContent({
|
|
|
225
225
|
/>
|
|
226
226
|
),
|
|
227
227
|
)}
|
|
228
|
+
{trailing}
|
|
228
229
|
</div>
|
|
229
230
|
</div>
|
|
230
231
|
);
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useImperativeHandle,
|
|
4
|
+
useCallback,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type ComponentType,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import * as React from 'react';
|
|
10
|
+
import { ErrorBoundary } from 'react-error-boundary';
|
|
11
|
+
import { Button } from '@/components/ui/button';
|
|
12
|
+
import { cn } from '@/lib/utils';
|
|
13
|
+
import { COMMENT_THEME_CLASS } from './mention-appearance';
|
|
14
|
+
import { CommentContent } from './comment-content';
|
|
15
|
+
import { useMentionContext } from './mention-provider';
|
|
16
|
+
import type {
|
|
17
|
+
CommentInputProps,
|
|
18
|
+
CommentInputHandle,
|
|
19
|
+
CommentEditorProps,
|
|
20
|
+
} from './types';
|
|
21
|
+
import {
|
|
22
|
+
emptyComment,
|
|
23
|
+
normalizeValue,
|
|
24
|
+
readApiContent,
|
|
25
|
+
} from './content';
|
|
26
|
+
|
|
27
|
+
let modulePromise: Promise<typeof import('./editor')> | undefined;
|
|
28
|
+
function loadEditor() {
|
|
29
|
+
// Reset rejected imports so explicit retry actually executes import again.
|
|
30
|
+
return (modulePromise ||= import('./editor').catch((error) => {
|
|
31
|
+
modulePromise = undefined;
|
|
32
|
+
throw error;
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
export function preloadCommentEditor() {
|
|
36
|
+
void loadEditor().catch(() => {});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Public entry contains no static import of Tiptap. A session change resets all work. */
|
|
40
|
+
export function CommentInput(props: CommentInputProps) {
|
|
41
|
+
const { store } = useMentionContext();
|
|
42
|
+
try {
|
|
43
|
+
normalizeValue(props.value);
|
|
44
|
+
} catch {
|
|
45
|
+
return (
|
|
46
|
+
<p role="alert" className="text-sm text-destructive">
|
|
47
|
+
评论内容格式无效,无法编辑
|
|
48
|
+
</p>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return (
|
|
52
|
+
<ErrorBoundary
|
|
53
|
+
key={JSON.stringify([store.source.scopeKey, props.documentKey])}
|
|
54
|
+
fallbackRender={({ resetErrorBoundary }) => (
|
|
55
|
+
<div
|
|
56
|
+
role="alert"
|
|
57
|
+
className="rounded-lg border p-3 text-sm text-destructive"
|
|
58
|
+
>
|
|
59
|
+
编辑器发生错误,输入内容已保留。
|
|
60
|
+
<Button
|
|
61
|
+
type="button"
|
|
62
|
+
variant="outline"
|
|
63
|
+
size="sm"
|
|
64
|
+
onClick={resetErrorBoundary}
|
|
65
|
+
>
|
|
66
|
+
重新加载
|
|
67
|
+
</Button>
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
>
|
|
71
|
+
<InputSession {...props} />
|
|
72
|
+
</ErrorBoundary>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function InputSession(props: CommentInputProps) {
|
|
77
|
+
const [Editor, setEditor] =
|
|
78
|
+
React.useState<ComponentType<CommentEditorProps> | null>(null);
|
|
79
|
+
const latest = useRef(props);
|
|
80
|
+
latest.current = props;
|
|
81
|
+
const editorRef = useRef<CommentInputHandle | null>(null);
|
|
82
|
+
const editable = useRef(true);
|
|
83
|
+
const [enabled, setEnabled] = useState(true);
|
|
84
|
+
const loadingRef = useRef(false);
|
|
85
|
+
const [loading, setLoading] = useState(false);
|
|
86
|
+
const [error, setError] = useState('');
|
|
87
|
+
const alive = useRef(true);
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
alive.current = true;
|
|
90
|
+
return () => {
|
|
91
|
+
alive.current = false;
|
|
92
|
+
};
|
|
93
|
+
}, []);
|
|
94
|
+
const activate = useCallback(async () => {
|
|
95
|
+
if (loadingRef.current || latest.current.disabled || !editable.current)
|
|
96
|
+
return;
|
|
97
|
+
loadingRef.current = true;
|
|
98
|
+
setLoading(true);
|
|
99
|
+
setError('');
|
|
100
|
+
try {
|
|
101
|
+
const module = await loadEditor();
|
|
102
|
+
if (alive.current) setEditor(() => module.default);
|
|
103
|
+
} catch {
|
|
104
|
+
if (alive.current) setError('编辑器加载失败,请重试');
|
|
105
|
+
} finally {
|
|
106
|
+
loadingRef.current = false;
|
|
107
|
+
if (alive.current) setLoading(false);
|
|
108
|
+
}
|
|
109
|
+
}, []);
|
|
110
|
+
useImperativeHandle(
|
|
111
|
+
props.ref,
|
|
112
|
+
() => ({
|
|
113
|
+
getContent: () =>
|
|
114
|
+
editorRef.current?.getContent() ?? readApiContent(latest.current.value),
|
|
115
|
+
clear() {
|
|
116
|
+
if (!alive.current) return;
|
|
117
|
+
if (editorRef.current) editorRef.current.clear();
|
|
118
|
+
else {
|
|
119
|
+
const value = emptyComment();
|
|
120
|
+
latest.current = { ...latest.current, value };
|
|
121
|
+
latest.current.onChange(value);
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
setEditable(next) {
|
|
125
|
+
if (!alive.current) return;
|
|
126
|
+
editable.current = next;
|
|
127
|
+
editorRef.current?.setEditable(next && !latest.current.disabled);
|
|
128
|
+
setEnabled(next);
|
|
129
|
+
},
|
|
130
|
+
focus() {
|
|
131
|
+
if (!alive.current) return;
|
|
132
|
+
if (editorRef.current) editorRef.current.focus();
|
|
133
|
+
else void activate();
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
[activate],
|
|
137
|
+
);
|
|
138
|
+
const { ref: _ref, ...editorProps } = props;
|
|
139
|
+
if (Editor)
|
|
140
|
+
return (
|
|
141
|
+
<Editor
|
|
142
|
+
{...editorProps}
|
|
143
|
+
disabled={props.disabled || !enabled}
|
|
144
|
+
editorRef={editorRef}
|
|
145
|
+
/>
|
|
146
|
+
);
|
|
147
|
+
return (
|
|
148
|
+
<div className={cn(COMMENT_THEME_CLASS, 'replica-input', props.className)}>
|
|
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
|
+
<div
|
|
151
|
+
aria-hidden
|
|
152
|
+
className="pointer-events-none w-full text-sm leading-[22px]"
|
|
153
|
+
>
|
|
154
|
+
{loading || error ? (
|
|
155
|
+
<span className="text-muted-foreground">
|
|
156
|
+
{loading ? '正在加载编辑器…' : '重试加载编辑器'}
|
|
157
|
+
</span>
|
|
158
|
+
) : props.value.content.length ? (
|
|
159
|
+
<CommentContent value={props.value} mentionVariant="pill" />
|
|
160
|
+
) : (
|
|
161
|
+
<span className="text-muted-foreground">
|
|
162
|
+
{props.placeholder || '输入评论'}
|
|
163
|
+
</span>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
<Button
|
|
167
|
+
type="button"
|
|
168
|
+
variant="ghost"
|
|
169
|
+
className="no-default-hover-elevate no-default-active-elevate absolute inset-0 h-full min-h-0 w-full rounded-[6px] p-0"
|
|
170
|
+
disabled={props.disabled || !enabled || loading}
|
|
171
|
+
aria-busy={loading}
|
|
172
|
+
onPointerEnter={preloadCommentEditor}
|
|
173
|
+
onFocus={preloadCommentEditor}
|
|
174
|
+
onClick={() => {
|
|
175
|
+
void activate();
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
<span className="sr-only">
|
|
179
|
+
{error ? '重试加载编辑器' : props.placeholder || '输入评论'}
|
|
180
|
+
</span>
|
|
181
|
+
</Button>
|
|
182
|
+
</div>
|
|
183
|
+
{error ? (
|
|
184
|
+
<p role="alert" className="mt-2 text-xs text-destructive">
|
|
185
|
+
{error}
|
|
186
|
+
</p>
|
|
187
|
+
) : null}
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|