@lark-apaas/coding-template-nestjs-react-fullstack 0.1.54 → 0.1.55-alpha.20261030035108
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 +10 -0
- package/template/client/src/components/business-ui/api/user-profiles/queries.ts +1 -7
- package/template/client/src/components/business-ui/comment/README.md +139 -0
- package/template/client/src/components/business-ui/comment/app-source.ts +175 -0
- package/template/client/src/components/business-ui/comment/ccm-document-source.ts +252 -0
- package/template/client/src/components/business-ui/comment/comment-content.tsx +235 -0
- package/template/client/src/components/business-ui/comment/comment-input.tsx +191 -0
- package/template/client/src/components/business-ui/comment/comment-provider.tsx +40 -0
- package/template/client/src/components/business-ui/comment/content.ts +329 -0
- package/template/client/src/components/business-ui/comment/document-icon.tsx +104 -0
- package/template/client/src/components/business-ui/comment/document-icons.ts +19 -0
- package/template/client/src/components/business-ui/comment/dom-editor.ts +581 -0
- package/template/client/src/components/business-ui/comment/editor.tsx +496 -0
- package/template/client/src/components/business-ui/comment/entity-store.ts +97 -0
- package/template/client/src/components/business-ui/comment/image-attachments.tsx +374 -0
- package/template/client/src/components/business-ui/comment/mention-appearance.ts +35 -0
- package/template/client/src/components/business-ui/comment/mention-provider.tsx +73 -0
- package/template/client/src/components/business-ui/comment/mention-suggestions.tsx +312 -0
- package/template/client/src/components/business-ui/comment/types.ts +154 -0
- package/template/client/src/components/business-ui/comment/use-mention-search.ts +107 -0
- package/template/client/src/components/business-ui/user-display/user-display.tsx +0 -13
- package/template/client/src/components/business-ui/user-profile/user-profile.tsx +146 -397
- package/template/client/src/components/business-ui/user-select/user-pill.tsx +0 -13
- package/template/client/src/hooks/use-example.ts +45 -36
- package/template/client/src/pages/ExamplePage/ExamplePage.tsx +7 -8
- package/template/client/src/tailwind-theme.css +5 -6
- package/template/eslint.config.js +0 -6
- package/template/package-lock.json +94 -73
- package/template/package.json +5 -4
- package/template/server/modules/hello/hello.controller.ts +4 -8
- package/template/server/modules/hello/hello.service.ts +3 -5
- package/template/shared/api.interface.ts +1 -8
- package/template/client/src/components/business-ui/user-profile/lark-card-pool.ts +0 -217
- package/template/client/src/lib/api-error.ts +0 -36
- package/template/server/modules/hello/hello.dto.ts +0 -21
package/package.json
CHANGED
|
@@ -517,3 +517,13 @@ function BasicExample() {
|
|
|
517
517
|
return <UserProfile value="user-123" />;
|
|
518
518
|
}
|
|
519
519
|
```
|
|
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,13 +10,7 @@ export const userProfileQueries = {
|
|
|
10
10
|
queryOptions({
|
|
11
11
|
queryKey: [...userProfileQueries.all(), 'byId', accountType, userId],
|
|
12
12
|
queryFn: ({ signal }) => fetchUserProfile(userId, accountType, signal),
|
|
13
|
-
|
|
14
|
-
// ——「打开渲染缓存、后台乐观更新」。缓存命中时 isPending 为 false,
|
|
15
|
-
// 卡片瞬间渲出不转圈;新数据回来后 React 只 patch 变化的字段,不闪不跳。
|
|
16
|
-
//
|
|
17
|
-
// 之前是 60s:那段窗口内连请求都不发,远端改了最多要等 60 秒才看得到。
|
|
18
|
-
staleTime: 0,
|
|
19
|
-
// gcTime 决定缓存能留多久 —— 它才是「秒出」的来源,不要跟着一起调小。
|
|
13
|
+
staleTime: 1 * 60 * 1000,
|
|
20
14
|
gcTime: 5 * 60 * 1000,
|
|
21
15
|
enabled: !!userId,
|
|
22
16
|
}),
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# 评论内容与输入组件
|
|
2
|
+
|
|
3
|
+
面向 AI 生成的多维表格评论界面,只提供两个基础组件:
|
|
4
|
+
|
|
5
|
+
- `CommentContent`:直接渲染 `reply.content`,内部补全人员与文档信息。
|
|
6
|
+
- `CommentInput`:使用 contenteditable div 编辑文字、@人员与文档,支持独立图片附件区,暴露 ref API。按钮、请求、loading 和发送错误由页面组合。
|
|
7
|
+
|
|
8
|
+
作者、时间、评论线程、回复、分页和解决状态均由业务页面处理。组件不包含这些业务包装。
|
|
9
|
+
|
|
10
|
+
代码集中在 `business-ui/comment/`,输入、只读展示、数据源与类型均从该目录的对应文件导入。
|
|
11
|
+
|
|
12
|
+
## 内容协议
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
type CommentApiContent =
|
|
16
|
+
| { type: 'text'; text: string }
|
|
17
|
+
| { type: 'mention'; userID?: number }
|
|
18
|
+
| { type: 'link'; url: string };
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
userID 是妙搭用户 ID,与 UserDisplay 一致。缺失 userID 的历史 mention 显示“引用信息缺失”,读取时仍原样返回,是否提交由页面决定。数字 ID 不得静默舍入。
|
|
22
|
+
|
|
23
|
+
文档通过 link(url) 提交,内部 token、类型、标题和图标不进入接口数据。识别出的飞书文档 URL 可按 token 补全元数据;其他 URL 显示为普通链接。图片是正文下方的独立附件,不混入这三个 content 类型;旧 CCM `<at>` 内容格式不在组件范围内。
|
|
24
|
+
|
|
25
|
+
## 使用
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
import { useRef, useState } from 'react';
|
|
29
|
+
import { CommentInput } from '@/components/business-ui/comment/comment-input';
|
|
30
|
+
import { CommentContent } from '@/components/business-ui/comment/comment-content';
|
|
31
|
+
import { CommentProvider } from '@/components/business-ui/comment/comment-provider';
|
|
32
|
+
import { emptyComment } from '@/components/business-ui/comment/content';
|
|
33
|
+
import type { CommentInputHandle, CommentSubmitPayload } from '@/components/business-ui/comment/types';
|
|
34
|
+
|
|
35
|
+
export function CommentExample({ save }: { save: (body: CommentSubmitPayload) => Promise<void> }) {
|
|
36
|
+
const inputRef = useRef<CommentInputHandle>(null);
|
|
37
|
+
const busy = useRef(false);
|
|
38
|
+
const [value, setValue] = useState(emptyComment);
|
|
39
|
+
const [sending, setSending] = useState(false);
|
|
40
|
+
const [error, setError] = useState('');
|
|
41
|
+
const handleSend = async () => {
|
|
42
|
+
const input = inputRef.current;
|
|
43
|
+
if (!input || busy.current) return;
|
|
44
|
+
busy.current = true;
|
|
45
|
+
input.setEditable(false);
|
|
46
|
+
setSending(true);
|
|
47
|
+
setError('');
|
|
48
|
+
try {
|
|
49
|
+
await save({ content: input.getContent() });
|
|
50
|
+
input.clear();
|
|
51
|
+
} catch (cause) {
|
|
52
|
+
setError(cause instanceof Error ? cause.message : '发送失败,请重试');
|
|
53
|
+
} finally {
|
|
54
|
+
input.setEditable(true);
|
|
55
|
+
busy.current = false;
|
|
56
|
+
setSending(false);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
return <CommentProvider>
|
|
60
|
+
<CommentInput ref={inputRef} documentKey="new" value={value}
|
|
61
|
+
onChange={setValue} onSubmitRequest={() => { void handleSend(); }} />
|
|
62
|
+
<button disabled={sending} onClick={() => inputRef.current?.clear()}>取消</button>
|
|
63
|
+
<button disabled={sending} onClick={() => { void handleSend(); }}>发送</button>
|
|
64
|
+
{error ? <p role="alert">{error}</p> : null}
|
|
65
|
+
<CommentContent value={value} />
|
|
66
|
+
</CommentProvider>;
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
只读接口内容用 `<CommentContent content={reply.content} />`;回填编辑用 `fromApiContent(reply.content)`。受控 onChange 需同步更新 value;documentKey 仅在切换草稿时改变。
|
|
71
|
+
|
|
72
|
+
## Ref 与快捷键
|
|
73
|
+
|
|
74
|
+
- `getContent()`:读取最新接口 content,不做发送状态校验;编辑器尚未加载时也可读取草稿。
|
|
75
|
+
- `clear()`:普通的正文清空操作,保留撤销历史,不重建编辑会话。
|
|
76
|
+
- `getImages()`:读取附件状态与上传结果的快照,不自动转换成多维表格图片协议。
|
|
77
|
+
- `clearImages()`:删除全部附件并取消/忽略上传,释放本地预览地址。附件独立于正文撤销历史。
|
|
78
|
+
- `setEditable(boolean)`:控制正文、上传按钮及附件删除/重试;disabled prop 是外部硬禁用。
|
|
79
|
+
- `focus()`:聚焦输入框,必要时加载编辑器。
|
|
80
|
+
- `onSubmitRequest`:Enter(或 submitMode="mod-enter")通知页面;输入法确认和 @候选选择优先处理,未配置时 Enter 换行。
|
|
81
|
+
|
|
82
|
+
入口动态 import div 编辑器,首次激活时挂载;不依赖 Tiptap/ProseMirror。加载失败可重试。popover 挂在 Dialog/Sheet 时可传 portalContainer。React 管理外框、候选面板与附件,正文 DOM 由 DivEditor 独立管理,避免受控回写破坏光标和输入法。
|
|
83
|
+
|
|
84
|
+
## 搜索与回显
|
|
85
|
+
|
|
86
|
+
CommentProvider 默认复用 UserDisplay 的人员服务。页面传 content/userID 即可回显,不用自行查询姓名和头像。同页输入与列表共享批量解析、缓存及请求去重。自定义服务使用 MentionProvider source;切换用户、租户或权限范围时重建 source/scopeKey。
|
|
87
|
+
|
|
88
|
+
recommend 用于刚输入 @ 或清空关键词;search 防抖 200ms,并忽略取消及过期响应。内置人员服务不提供空关键词推荐,需要宿主提供 `<CommentProvider recommend={loadPeople}>`。
|
|
89
|
+
|
|
90
|
+
文档独立配置 `<CommentProvider documents={documents}>`,输入框再开启 enableDocuments:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import { createCcmDocumentSource, createBrowserCcmRequest } from '@/components/business-ui/comment/ccm-document-source';
|
|
94
|
+
const documents = createCcmDocumentSource({
|
|
95
|
+
scopeKey: currentUserScope, token: hostDocumentToken, objType: hostDocumentType,
|
|
96
|
+
request: createBrowserCcmRequest(authenticatedProxyPath),
|
|
97
|
+
search: searchCloudDocuments, // 可选:宿主提供完整文档搜索
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
在 useMemo 内创建并保持数据源稳定。宿主负责已鉴权的同源代理和文档上下文;组件不会创建代理。默认列表复用 recommend.v2,回显使用 meta/batch,嵌入权限使用 4096 权限位;无权限灰显仍保留原链接以便申请访问。未注入 search 时用 recommend.v2 的 content 查询,召回与 miaoda 桌面统一搜索可能不同。
|
|
102
|
+
|
|
103
|
+
## 外观与人员卡片
|
|
104
|
+
|
|
105
|
+
保留 miaoda 的布局:输入区最小 34px、上下 5px 留白、22px 行高;输入人员为蓝底白字 rounded-full 胶囊。只读人员为蓝色 @姓名,本人高亮。候选面板 360px 宽、417px 最大高度,48px 行高、32px 图标,隐藏分类栏。
|
|
106
|
+
|
|
107
|
+
文档候选使用 UD V2 彩色类型图标,wiki 按子类型;行内 14px 图标与标题,新窗打开链接。只读与输入回显共享样式,文档卡片预渲染不在范围内。
|
|
108
|
+
|
|
109
|
+
CommentContent 的 onMentionClick(ref, anchor) 仅透出人员点击,由消费页面复用 UserProfile。元数据加载前也可点击,回显更新保持锚点 DOM 稳定。
|
|
110
|
+
|
|
111
|
+
在生成应用中运行 `npm run type:check:client` 和 `npm run build:client`;浏览器中另行验证中文输入法、键盘候选操作、撤销、回填和弹层位置。类型检查与构建不代表真实接口鉴权或视觉验收完成。
|
|
112
|
+
|
|
113
|
+
## 图片附件与权限意图
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
<CommentInput
|
|
117
|
+
ref={inputRef}
|
|
118
|
+
documentKey="new"
|
|
119
|
+
value={value}
|
|
120
|
+
onChange={setValue}
|
|
121
|
+
enableDocuments
|
|
122
|
+
enableImages
|
|
123
|
+
onImagesChange={setImages}
|
|
124
|
+
grantMentionAccess={grantMentionAccess}
|
|
125
|
+
onGrantMentionAccessChange={setGrantMentionAccess}
|
|
126
|
+
/>
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
图片区按参考图排列在正文下方,64px 缩略图、圆角与右上角删除按钮;点击缩略图可预览。选择文件、粘贴或拖入正文均追加到附件区,不占正文光标位置。支持 PNG/JPEG/WebP/GIF/AVIF/BMP;默认最多 9 张、单张 10 MiB,通过 maxImages/maxImageSize 调整。最多 3 个并发上传,上传中显示加载状态,失败可重试。
|
|
130
|
+
|
|
131
|
+
默认延迟导入模板已有的 api/files/service 上传到妙搭存储。可通过 uploadImage(file, { signal }) 注入自定义服务,返回 `{ id, url, filePath?, bucketId? }`。删除、clearImages、切换 documentKey、切换数据源作用域和卸载都会取消或忽略迟到的上传结果;默认 SDK 不支持真正取消网络请求。删除和卸载时释放 Object URL;失败文件仅保留到重试或删除。
|
|
132
|
+
|
|
133
|
+
getImages 返回 `{ id, name, size, previewUrl, status, result?, error? }[]`:id 是本地附件 ID,result.id 才是服务端文件 ID;previewUrl 是临时 Blob URL,不能提交或持久化。页面通过 onImagesChange 判断是否仍有 uploading/error 状态,自行控制发送按钮,并把 result 映射到真实接口。当前未确认多维表格图片提交协议,因此 getContent 继续只返回 text/mention/link,CommentContent 继续只渲染正文。
|
|
134
|
+
|
|
135
|
+
如需在发送成功后重置整个输入区,依次调用 clear() 与 clearImages()。这两个方法不执行发送校验,也不处理发送失败;失败时是否保留内容由页面决定。切换 documentKey 表示新会话,正文撤销和附件都重置。
|
|
136
|
+
|
|
137
|
+
“授予提及的人阅读权限”仅在传入 onGrantMentionAccessChange 时显示,属于受控的业务意图,组件不直接调用文档授权接口。消费页面负责结合勾选值执行真实授权。
|
|
138
|
+
|
|
139
|
+
正文使用统一的自有撤销历史,覆盖文字、引用插入/删除、粘贴和 clear。连续文字合并为一组,输入法组合输入作为一组;元数据回显不产生编辑历史,外部替换 value 会重置正文历史。保留旧结构转换函数供已有调用方过渡,新实现不使用它们。
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { listUsersByIds, searchUsers } from '../api/users/service';
|
|
2
|
+
import { getI18nText } from '../utils/user';
|
|
3
|
+
import { mentionKey } from './content';
|
|
4
|
+
import type {
|
|
5
|
+
CommentDataSource,
|
|
6
|
+
CommentDocumentSource,
|
|
7
|
+
MentionOption,
|
|
8
|
+
MentionResult,
|
|
9
|
+
} from './types';
|
|
10
|
+
|
|
11
|
+
function avatarUrl(avatar: unknown): string | undefined {
|
|
12
|
+
if (typeof avatar === 'string') return avatar;
|
|
13
|
+
if (avatar && typeof avatar === 'object' && 'image' in avatar) {
|
|
14
|
+
const image = avatar.image;
|
|
15
|
+
if (
|
|
16
|
+
image &&
|
|
17
|
+
typeof image === 'object' &&
|
|
18
|
+
'large' in image &&
|
|
19
|
+
typeof image.large === 'string'
|
|
20
|
+
)
|
|
21
|
+
return image.large;
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The same Miaoda user IDs and built-in service used by UserDisplay. */
|
|
27
|
+
export function createAppCommentSource(scopeKey: string): CommentDataSource {
|
|
28
|
+
return {
|
|
29
|
+
scopeKey,
|
|
30
|
+
async search({ query, cursor, signal }) {
|
|
31
|
+
signal.throwIfAborted();
|
|
32
|
+
// The built-in directory has no empty-query recommendations. In a mixed
|
|
33
|
+
// source, leave that branch empty so document recommendations still load.
|
|
34
|
+
if (!query) return { items: [] };
|
|
35
|
+
const offset = Number(cursor || 0);
|
|
36
|
+
if (!Number.isSafeInteger(offset) || offset < 0)
|
|
37
|
+
throw new Error('分页参数无效');
|
|
38
|
+
const response = await searchUsers({ query, offset, pageSize: 20 });
|
|
39
|
+
signal.throwIfAborted();
|
|
40
|
+
if (String(response.status_code) !== '0')
|
|
41
|
+
throw new Error('人员搜索失败,请重试');
|
|
42
|
+
const users = response.data.userList ?? [];
|
|
43
|
+
const items: MentionOption[] = users.flatMap((user) => {
|
|
44
|
+
// Unregistered contacts have no Miaoda ID; never substitute a Lark ID.
|
|
45
|
+
const id = String(user.userID || '');
|
|
46
|
+
if (!/^[1-9]\d*$/.test(id)) return [];
|
|
47
|
+
return [
|
|
48
|
+
{
|
|
49
|
+
ref: { kind: 'user' as const, idType: 'apaas-user' as const, id },
|
|
50
|
+
label: getI18nText(user.name) || '未命名人员',
|
|
51
|
+
avatarUrl: avatarUrl(user.avatar),
|
|
52
|
+
description: getI18nText(user.department?.name) || user.tenantName,
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
});
|
|
56
|
+
const nextOffset = offset + users.length;
|
|
57
|
+
return {
|
|
58
|
+
items,
|
|
59
|
+
nextCursor:
|
|
60
|
+
users.length && nextOffset < response.data.total
|
|
61
|
+
? String(nextOffset)
|
|
62
|
+
: undefined,
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
async resolve(refs, signal) {
|
|
66
|
+
signal.throwIfAborted();
|
|
67
|
+
const ids = refs.flatMap((ref) =>
|
|
68
|
+
ref.kind === 'user' && ref.idType === 'apaas-user' ? [ref.id] : [],
|
|
69
|
+
);
|
|
70
|
+
const response = ids.length ? await listUsersByIds(ids) : undefined;
|
|
71
|
+
signal.throwIfAborted();
|
|
72
|
+
const base = response?.data?.baseResp;
|
|
73
|
+
if (base?.statusCode)
|
|
74
|
+
throw new Error(base.statusMessage || '人员信息加载失败');
|
|
75
|
+
if (ids.length && !response?.data?.userInfoMap)
|
|
76
|
+
throw new Error('人员信息返回格式无效');
|
|
77
|
+
const people = response?.data?.userInfoMap || {};
|
|
78
|
+
return refs.map((ref): MentionResult => {
|
|
79
|
+
if (ref.kind !== 'user' || ref.idType !== 'apaas-user')
|
|
80
|
+
return { ref, status: 'error' };
|
|
81
|
+
const user = people[ref.id];
|
|
82
|
+
if (!user) return { ref, status: 'not-found' };
|
|
83
|
+
return {
|
|
84
|
+
ref,
|
|
85
|
+
status: 'resolved',
|
|
86
|
+
label: getI18nText(user.name) || '未命名人员',
|
|
87
|
+
avatarUrl: avatarUrl(user.avatar),
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Keep Miaoda people IDs and cloud-document tokens in their own services. */
|
|
95
|
+
export function withDocumentSource(
|
|
96
|
+
people: CommentDataSource,
|
|
97
|
+
documents: CommentDocumentSource,
|
|
98
|
+
): CommentDataSource {
|
|
99
|
+
const load = async (
|
|
100
|
+
args: Parameters<CommentDataSource['search']>[0],
|
|
101
|
+
recommend: boolean,
|
|
102
|
+
) => {
|
|
103
|
+
const fetchSource = (
|
|
104
|
+
source: CommentDataSource | CommentDocumentSource,
|
|
105
|
+
kind: 'user' | 'document',
|
|
106
|
+
cursor?: string,
|
|
107
|
+
) =>
|
|
108
|
+
recommend && source.recommend
|
|
109
|
+
? source.recommend({ ...args, kind, cursor })
|
|
110
|
+
: source.search({ ...args, kind, cursor });
|
|
111
|
+
if (args.kind === 'user') return fetchSource(people, 'user', args.cursor);
|
|
112
|
+
if (args.kind === 'document')
|
|
113
|
+
return fetchSource(documents, 'document', args.cursor);
|
|
114
|
+
const cursor: { user?: string; document?: string } = args.cursor
|
|
115
|
+
? JSON.parse(args.cursor)
|
|
116
|
+
: {};
|
|
117
|
+
if (
|
|
118
|
+
!cursor ||
|
|
119
|
+
typeof cursor !== 'object' ||
|
|
120
|
+
[cursor.user, cursor.document].some(
|
|
121
|
+
(value) => value !== undefined && typeof value !== 'string',
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
throw new Error('分页参数无效');
|
|
125
|
+
const exhausted: Awaited<ReturnType<CommentDataSource['search']>> = {
|
|
126
|
+
items: [],
|
|
127
|
+
};
|
|
128
|
+
const [users, docs] = await Promise.all([
|
|
129
|
+
!args.cursor || cursor.user !== undefined
|
|
130
|
+
? fetchSource(people, 'user', cursor.user)
|
|
131
|
+
: exhausted,
|
|
132
|
+
!args.cursor || cursor.document !== undefined
|
|
133
|
+
? fetchSource(documents, 'document', cursor.document)
|
|
134
|
+
: exhausted,
|
|
135
|
+
]);
|
|
136
|
+
return {
|
|
137
|
+
items: [
|
|
138
|
+
...users.items.filter((item) => item.ref.kind === 'user'),
|
|
139
|
+
...docs.items.filter((item) => item.ref.kind === 'document'),
|
|
140
|
+
],
|
|
141
|
+
nextCursor:
|
|
142
|
+
users.nextCursor || docs.nextCursor
|
|
143
|
+
? JSON.stringify({
|
|
144
|
+
user: users.nextCursor,
|
|
145
|
+
document: docs.nextCursor,
|
|
146
|
+
})
|
|
147
|
+
: undefined,
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
return {
|
|
151
|
+
...people,
|
|
152
|
+
scopeKey: JSON.stringify([people.scopeKey, documents.scopeKey]),
|
|
153
|
+
search: (args) => load(args, false),
|
|
154
|
+
recommend: (args) => load({ ...args, query: '' }, true),
|
|
155
|
+
async resolve(refs, signal) {
|
|
156
|
+
const users = refs.filter((ref) => ref.kind === 'user');
|
|
157
|
+
const docs = refs.filter((ref) => ref.kind === 'document');
|
|
158
|
+
// One failed service must not hide successful metadata from the other.
|
|
159
|
+
const results = await Promise.allSettled([
|
|
160
|
+
users.length ? people.resolve(users, signal) : [],
|
|
161
|
+
docs.length ? documents.resolve(docs, signal) : [],
|
|
162
|
+
]);
|
|
163
|
+
signal.throwIfAborted();
|
|
164
|
+
const byKey = new Map<string, MentionResult>();
|
|
165
|
+
for (const result of results)
|
|
166
|
+
if (result.status === 'fulfilled')
|
|
167
|
+
for (const item of result.value)
|
|
168
|
+
byKey.set(mentionKey(item.ref), item);
|
|
169
|
+
return refs.map(
|
|
170
|
+
(ref) =>
|
|
171
|
+
byKey.get(mentionKey(ref)) || { ref, status: 'error' as const },
|
|
172
|
+
);
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { mentionKey, safeUrl } from './content';
|
|
2
|
+
import type {
|
|
3
|
+
CommentDocumentSource,
|
|
4
|
+
MentionOption,
|
|
5
|
+
MentionRef,
|
|
6
|
+
MentionResult,
|
|
7
|
+
} from './types';
|
|
8
|
+
|
|
9
|
+
interface DocumentEntity {
|
|
10
|
+
title?: string;
|
|
11
|
+
url?: string;
|
|
12
|
+
sub_type?: number | string;
|
|
13
|
+
icon_info?: string;
|
|
14
|
+
edit_time?: number;
|
|
15
|
+
is_external?: boolean;
|
|
16
|
+
title_secure_key_deleted?: boolean;
|
|
17
|
+
}
|
|
18
|
+
interface CcmResponse {
|
|
19
|
+
code: number;
|
|
20
|
+
msg?: string;
|
|
21
|
+
data?: {
|
|
22
|
+
entities?: { notes?: Record<string, DocumentEntity> };
|
|
23
|
+
result_list?: { token: string; type: number }[];
|
|
24
|
+
perms?: Record<string, number>;
|
|
25
|
+
[token: string]: unknown;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export type CcmRequest = (
|
|
29
|
+
path: string,
|
|
30
|
+
options: {
|
|
31
|
+
signal: AbortSignal;
|
|
32
|
+
form?: Record<string, string | number | undefined>;
|
|
33
|
+
json?: unknown;
|
|
34
|
+
},
|
|
35
|
+
) => Promise<CcmResponse>;
|
|
36
|
+
|
|
37
|
+
function documentFields(entity: DocumentEntity) {
|
|
38
|
+
const subType =
|
|
39
|
+
entity.sub_type === undefined ? undefined : Number(entity.sub_type);
|
|
40
|
+
return {
|
|
41
|
+
label: entity.title || '未命名文档',
|
|
42
|
+
href: safeUrl(entity.url),
|
|
43
|
+
description: entity.edit_time
|
|
44
|
+
? `最后更新:${formatDocumentTime(entity.edit_time < 1e12 ? entity.edit_time : entity.edit_time / 1000)}`
|
|
45
|
+
: undefined,
|
|
46
|
+
isExternal: entity.is_external === true,
|
|
47
|
+
document: {
|
|
48
|
+
subType: Number.isSafeInteger(subType) ? subType : undefined,
|
|
49
|
+
iconInfo: entity.icon_info,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Document-only CCM adapter. The host supplies document context and authenticated requests. */
|
|
55
|
+
export function createCcmDocumentSource(options: {
|
|
56
|
+
scopeKey: string;
|
|
57
|
+
token: string;
|
|
58
|
+
objType: number;
|
|
59
|
+
request: CcmRequest;
|
|
60
|
+
search?: CommentDocumentSource['search'];
|
|
61
|
+
}): CommentDocumentSource {
|
|
62
|
+
const { request, token, objType } = options;
|
|
63
|
+
const recommend: NonNullable<CommentDocumentSource['recommend']> = async (
|
|
64
|
+
args,
|
|
65
|
+
) => searchRecommendations('', args.signal);
|
|
66
|
+
async function searchRecommendations(query: string, signal: AbortSignal) {
|
|
67
|
+
const response = await request('/space/api/mention/recommend.v2/', {
|
|
68
|
+
signal,
|
|
69
|
+
form: {
|
|
70
|
+
token,
|
|
71
|
+
obj_type: objType,
|
|
72
|
+
source: objType,
|
|
73
|
+
type: '0,1,3,7,8,11,12,16,22',
|
|
74
|
+
content: query || undefined,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
signal.throwIfAborted();
|
|
78
|
+
if (response.code !== 0 || !response.data)
|
|
79
|
+
throw new Error(response.msg || '文档搜索失败');
|
|
80
|
+
const items: MentionOption[] = [];
|
|
81
|
+
for (const item of response.data.result_list || []) {
|
|
82
|
+
const entity = response.data.entities?.notes?.[item.token];
|
|
83
|
+
if (item.type !== 0 && entity)
|
|
84
|
+
items.push({
|
|
85
|
+
ref: {
|
|
86
|
+
kind: 'document',
|
|
87
|
+
id: item.token,
|
|
88
|
+
resourceType: String(item.type),
|
|
89
|
+
},
|
|
90
|
+
...documentFields(entity),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return { items };
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
scopeKey: JSON.stringify([options.scopeKey, token, objType]),
|
|
97
|
+
recommend,
|
|
98
|
+
search:
|
|
99
|
+
options.search ||
|
|
100
|
+
(({ query, signal }) => searchRecommendations(query, signal)),
|
|
101
|
+
async resolve(refs, signal) {
|
|
102
|
+
const docs = refs.filter(
|
|
103
|
+
(ref): ref is Extract<MentionRef, { kind: 'document' }> =>
|
|
104
|
+
ref.kind === 'document' && /^\d+$/.test(ref.resourceType),
|
|
105
|
+
);
|
|
106
|
+
const results = new Map<string, MentionResult>();
|
|
107
|
+
for (let offset = 0; offset < docs.length; offset += 100) {
|
|
108
|
+
const batch = docs.slice(offset, offset + 100);
|
|
109
|
+
const tokens: Record<string, string[]> = {};
|
|
110
|
+
for (const ref of batch) {
|
|
111
|
+
const type = ref.resourceType === '1' ? '2' : ref.resourceType;
|
|
112
|
+
(tokens[type] ||= []).push(ref.id);
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const response = await request('/space/api/meta/batch', {
|
|
116
|
+
signal,
|
|
117
|
+
json: { tokens },
|
|
118
|
+
});
|
|
119
|
+
for (const ref of batch) {
|
|
120
|
+
const entity = response.data?.[ref.id] as
|
|
121
|
+
| DocumentEntity
|
|
122
|
+
| undefined;
|
|
123
|
+
results.set(
|
|
124
|
+
mentionKey(ref),
|
|
125
|
+
response.code === 0 && entity && !entity.title_secure_key_deleted
|
|
126
|
+
? { ref, status: 'resolved', ...documentFields(entity) }
|
|
127
|
+
: { ref, status: response.code === 4 ? 'forbidden' : 'error' },
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
signal.throwIfAborted();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Check the host's embed permission; a denial greys the link but keeps its access-request path.
|
|
135
|
+
for (let offset = 0; offset < docs.length; offset += 10) {
|
|
136
|
+
const batch = docs.slice(offset, offset + 10);
|
|
137
|
+
try {
|
|
138
|
+
const response = await request(
|
|
139
|
+
'/space/api/suite/permission/objects/embed/',
|
|
140
|
+
{
|
|
141
|
+
signal,
|
|
142
|
+
json: {
|
|
143
|
+
token,
|
|
144
|
+
type: objType,
|
|
145
|
+
perm: 4096,
|
|
146
|
+
child_objects: Object.fromEntries(
|
|
147
|
+
batch.map((ref) => [
|
|
148
|
+
ref.id,
|
|
149
|
+
Number(ref.resourceType === '1' ? '2' : ref.resourceType),
|
|
150
|
+
]),
|
|
151
|
+
),
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
if (response.code === 0 && response.data)
|
|
156
|
+
for (const ref of batch) {
|
|
157
|
+
const result = results.get(mentionKey(ref));
|
|
158
|
+
const permission = response.data.perms?.[ref.id];
|
|
159
|
+
if (result?.status === 'resolved')
|
|
160
|
+
results.set(mentionKey(ref), {
|
|
161
|
+
...result,
|
|
162
|
+
readable:
|
|
163
|
+
typeof permission === 'number' &&
|
|
164
|
+
Boolean(permission & 4096),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
signal.throwIfAborted();
|
|
169
|
+
} // Unknown permission does not become a denial.
|
|
170
|
+
}
|
|
171
|
+
signal.throwIfAborted();
|
|
172
|
+
return refs.map(
|
|
173
|
+
(ref) =>
|
|
174
|
+
results.get(mentionKey(ref)) || { ref, status: 'error' as const },
|
|
175
|
+
);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function formatDocumentTime(createTime: number) {
|
|
181
|
+
const date = new Date(createTime * 1000);
|
|
182
|
+
const now = new Date();
|
|
183
|
+
const seconds = Math.max(
|
|
184
|
+
0,
|
|
185
|
+
Math.round((now.getTime() - date.getTime()) / 1000),
|
|
186
|
+
);
|
|
187
|
+
if (date.toDateString() === now.toDateString()) {
|
|
188
|
+
if (seconds <= 44) return '刚刚';
|
|
189
|
+
if (seconds <= 89) return '1 分钟前';
|
|
190
|
+
const minutes = Math.round(seconds / 60);
|
|
191
|
+
if (minutes <= 44) return `${minutes} 分钟前`;
|
|
192
|
+
if (minutes <= 89) return '1 小时前';
|
|
193
|
+
return `${Math.round(minutes / 60)} 小时前`;
|
|
194
|
+
}
|
|
195
|
+
const time = date.toLocaleTimeString('zh-CN', {
|
|
196
|
+
hour: '2-digit',
|
|
197
|
+
minute: '2-digit',
|
|
198
|
+
hour12: false,
|
|
199
|
+
});
|
|
200
|
+
const yesterday = new Date(now);
|
|
201
|
+
yesterday.setDate(now.getDate() - 1);
|
|
202
|
+
if (date.toDateString() === yesterday.toDateString()) return `昨天 ${time}`;
|
|
203
|
+
return date.getFullYear() === now.getFullYear()
|
|
204
|
+
? `${date.getMonth() + 1}月${date.getDate()}日 ${time}`
|
|
205
|
+
: `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The host must expose authenticated CCM routes under this same-origin proxy path. */
|
|
209
|
+
export function createBrowserCcmRequest(basePath = ''): CcmRequest {
|
|
210
|
+
if (
|
|
211
|
+
basePath &&
|
|
212
|
+
(!basePath.startsWith('/') ||
|
|
213
|
+
basePath.startsWith('//') ||
|
|
214
|
+
/[?#\\\s]/.test(basePath))
|
|
215
|
+
)
|
|
216
|
+
throw new Error('云文档代理必须是同源路径');
|
|
217
|
+
return async (path, options) => {
|
|
218
|
+
if (!path.startsWith('/space/api/')) throw new Error('不支持的云文档接口');
|
|
219
|
+
const cookie = document.cookie
|
|
220
|
+
.split(';')
|
|
221
|
+
.map((part) => part.trim())
|
|
222
|
+
.find((part) => part.startsWith('_csrf_token='));
|
|
223
|
+
const headers: Record<string, string> = {};
|
|
224
|
+
if (cookie)
|
|
225
|
+
headers['x-csrftoken'] = decodeURIComponent(
|
|
226
|
+
cookie.slice('_csrf_token='.length),
|
|
227
|
+
);
|
|
228
|
+
let body: string;
|
|
229
|
+
if (options.form) {
|
|
230
|
+
headers['content-type'] = 'application/x-www-form-urlencoded';
|
|
231
|
+
const form = new URLSearchParams();
|
|
232
|
+
for (const [key, value] of Object.entries(options.form))
|
|
233
|
+
if (value !== undefined) form.set(key, String(value));
|
|
234
|
+
body = form.toString();
|
|
235
|
+
} else {
|
|
236
|
+
headers['content-type'] = 'application/json';
|
|
237
|
+
body = JSON.stringify(options.json);
|
|
238
|
+
}
|
|
239
|
+
const response = await fetch(`${basePath.replace(/\/$/, '')}${path}`, {
|
|
240
|
+
method: 'POST',
|
|
241
|
+
headers,
|
|
242
|
+
body,
|
|
243
|
+
signal: options.signal,
|
|
244
|
+
credentials: 'include',
|
|
245
|
+
});
|
|
246
|
+
if (!response.ok) throw new Error('云文档服务请求失败,请重试');
|
|
247
|
+
const result: CcmResponse = await response.json();
|
|
248
|
+
if (typeof result.code !== 'number')
|
|
249
|
+
throw new Error('云文档服务响应格式无效');
|
|
250
|
+
return result;
|
|
251
|
+
};
|
|
252
|
+
}
|