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

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 (30) hide show
  1. package/package.json +1 -1
  2. package/template/client/src/components/business-ui/README.md +10 -0
  3. package/template/client/src/components/business-ui/api/user-profiles/queries.ts +1 -7
  4. package/template/client/src/components/business-ui/comment/README.md +177 -0
  5. package/template/client/src/components/business-ui/comment/ccm-document-source.ts +252 -0
  6. package/template/client/src/components/business-ui/comment/comment-content.tsx +231 -0
  7. package/template/client/src/components/business-ui/comment/comment-input.tsx +536 -0
  8. package/template/client/src/components/business-ui/comment/comment-provider.tsx +213 -0
  9. package/template/client/src/components/business-ui/comment/content.ts +261 -0
  10. package/template/client/src/components/business-ui/comment/dom-editor.ts +582 -0
  11. package/template/client/src/components/business-ui/comment/image-attachments.tsx +375 -0
  12. package/template/client/src/components/business-ui/comment/mention-provider.tsx +168 -0
  13. package/template/client/src/components/business-ui/comment/mention-suggestions.tsx +411 -0
  14. package/template/client/src/components/business-ui/comment/mention-view.tsx +171 -0
  15. package/template/client/src/components/business-ui/comment/types.ts +149 -0
  16. package/template/client/src/components/business-ui/user-display/user-display.tsx +0 -13
  17. package/template/client/src/components/business-ui/user-profile/user-profile.tsx +146 -397
  18. package/template/client/src/components/business-ui/user-select/user-pill.tsx +0 -13
  19. package/template/client/src/hooks/use-example.ts +45 -36
  20. package/template/client/src/pages/ExamplePage/ExamplePage.tsx +7 -8
  21. package/template/client/src/tailwind-theme.css +5 -6
  22. package/template/eslint.config.js +0 -6
  23. package/template/package-lock.json +94 -73
  24. package/template/package.json +5 -4
  25. package/template/server/modules/hello/hello.controller.ts +4 -8
  26. package/template/server/modules/hello/hello.service.ts +3 -5
  27. package/template/shared/api.interface.ts +1 -8
  28. package/template/client/src/components/business-ui/user-profile/lark-card-pool.ts +0 -217
  29. package/template/client/src/lib/api-error.ts +0 -36
  30. package/template/server/modules/hello/hello.dto.ts +0 -21
@@ -0,0 +1,213 @@
1
+ import { useMemo, type ReactNode } from 'react';
2
+ import * as React from 'react';
3
+ import { getAppId } from '../user-select/utils';
4
+ import { useCurrentUserProfile } from '@lark-apaas/client-toolkit/hooks/useCurrentUserProfile';
5
+ import { MentionProvider } from './mention-provider';
6
+ import type {
7
+ CommentDataSource,
8
+ CommentDocumentSource,
9
+ MentionOption,
10
+ MentionResult,
11
+ } 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
+
49
+ function avatarUrl(avatar: unknown): string | undefined {
50
+ if (typeof avatar === 'string') return avatar;
51
+ if (avatar && typeof avatar === 'object' && 'image' in avatar) {
52
+ const image = avatar.image;
53
+ if (
54
+ image &&
55
+ typeof image === 'object' &&
56
+ 'large' in image &&
57
+ typeof image.large === 'string'
58
+ )
59
+ return image.large;
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ /** The same Miaoda user IDs and built-in service used by UserDisplay. */
65
+ export function createAppCommentSource(scopeKey: string): CommentDataSource {
66
+ return {
67
+ scopeKey,
68
+ async search({ query, cursor, signal }) {
69
+ signal.throwIfAborted();
70
+ // The built-in directory has no empty-query recommendations. In a mixed
71
+ // source, leave that branch empty so document recommendations still load.
72
+ if (!query) return { items: [] };
73
+ const offset = Number(cursor || 0);
74
+ if (!Number.isSafeInteger(offset) || offset < 0)
75
+ throw new Error('分页参数无效');
76
+ const response = await searchUsers({ query, offset, pageSize: 20 });
77
+ signal.throwIfAborted();
78
+ if (String(response.status_code) !== '0')
79
+ throw new Error('人员搜索失败,请重试');
80
+ const users = response.data.userList ?? [];
81
+ const items: MentionOption[] = users.flatMap((user) => {
82
+ // Unregistered contacts have no Miaoda ID; never substitute a Lark ID.
83
+ const id = String(user.userID || '');
84
+ if (!/^[1-9]\d*$/.test(id)) return [];
85
+ return [
86
+ {
87
+ ref: { kind: 'user' as const, idType: 'apaas-user' as const, id },
88
+ label: getI18nText(user.name) || '未命名人员',
89
+ avatarUrl: avatarUrl(user.avatar),
90
+ description: getI18nText(user.department?.name) || user.tenantName,
91
+ },
92
+ ];
93
+ });
94
+ const nextOffset = offset + users.length;
95
+ return {
96
+ items,
97
+ nextCursor:
98
+ users.length && nextOffset < response.data.total
99
+ ? String(nextOffset)
100
+ : undefined,
101
+ };
102
+ },
103
+ async resolve(refs, signal) {
104
+ signal.throwIfAborted();
105
+ const ids = refs.flatMap((ref) =>
106
+ ref.kind === 'user' && ref.idType === 'apaas-user' ? [ref.id] : [],
107
+ );
108
+ const response = ids.length ? await listUsersByIds(ids) : undefined;
109
+ signal.throwIfAborted();
110
+ const base = response?.data?.baseResp;
111
+ if (base?.statusCode)
112
+ throw new Error(base.statusMessage || '人员信息加载失败');
113
+ if (ids.length && !response?.data?.userInfoMap)
114
+ throw new Error('人员信息返回格式无效');
115
+ const people = response?.data?.userInfoMap || {};
116
+ return refs.map((ref): MentionResult => {
117
+ if (ref.kind !== 'user' || ref.idType !== 'apaas-user')
118
+ return { ref, status: 'error' };
119
+ const user = people[ref.id];
120
+ if (!user) return { ref, status: 'not-found' };
121
+ return {
122
+ ref,
123
+ status: 'resolved',
124
+ label: getI18nText(user.name) || '未命名人员',
125
+ avatarUrl: avatarUrl(user.avatar),
126
+ };
127
+ });
128
+ },
129
+ };
130
+ }
131
+
132
+ /** Keep Miaoda people IDs and cloud-document tokens in their own services. */
133
+ export function withDocumentSource(
134
+ people: CommentDataSource,
135
+ documents: CommentDocumentSource,
136
+ ): CommentDataSource {
137
+ const load = async (
138
+ args: Parameters<CommentDataSource['search']>[0],
139
+ recommend: boolean,
140
+ ) => {
141
+ const fetchSource = (
142
+ source: CommentDataSource | CommentDocumentSource,
143
+ kind: 'user' | 'document',
144
+ cursor?: string,
145
+ ) =>
146
+ recommend && source.recommend
147
+ ? source.recommend({ ...args, kind, cursor })
148
+ : source.search({ ...args, kind, cursor });
149
+ if (args.kind === 'user') return fetchSource(people, 'user', args.cursor);
150
+ if (args.kind === 'document')
151
+ return fetchSource(documents, 'document', args.cursor);
152
+ const cursor: { user?: string; document?: string } = args.cursor
153
+ ? JSON.parse(args.cursor)
154
+ : {};
155
+ if (
156
+ !cursor ||
157
+ typeof cursor !== 'object' ||
158
+ [cursor.user, cursor.document].some(
159
+ (value) => value !== undefined && typeof value !== 'string',
160
+ )
161
+ )
162
+ throw new Error('分页参数无效');
163
+ const exhausted: Awaited<ReturnType<CommentDataSource['search']>> = {
164
+ items: [],
165
+ };
166
+ const [users, docs] = await Promise.all([
167
+ !args.cursor || cursor.user !== undefined
168
+ ? fetchSource(people, 'user', cursor.user)
169
+ : exhausted,
170
+ !args.cursor || cursor.document !== undefined
171
+ ? fetchSource(documents, 'document', cursor.document)
172
+ : exhausted,
173
+ ]);
174
+ return {
175
+ items: [
176
+ ...users.items.filter((item) => item.ref.kind === 'user'),
177
+ ...docs.items.filter((item) => item.ref.kind === 'document'),
178
+ ],
179
+ nextCursor:
180
+ users.nextCursor || docs.nextCursor
181
+ ? JSON.stringify({
182
+ user: users.nextCursor,
183
+ document: docs.nextCursor,
184
+ })
185
+ : undefined,
186
+ };
187
+ };
188
+ return {
189
+ ...people,
190
+ scopeKey: JSON.stringify([people.scopeKey, documents.scopeKey]),
191
+ search: (args) => load(args, false),
192
+ recommend: (args) => load({ ...args, query: '' }, true),
193
+ async resolve(refs, signal) {
194
+ const users = refs.filter((ref) => ref.kind === 'user');
195
+ const docs = refs.filter((ref) => ref.kind === 'document');
196
+ // One failed service must not hide successful metadata from the other.
197
+ const results = await Promise.allSettled([
198
+ users.length ? people.resolve(users, signal) : [],
199
+ docs.length ? documents.resolve(docs, signal) : [],
200
+ ]);
201
+ signal.throwIfAborted();
202
+ const byKey = new Map<string, MentionResult>();
203
+ for (const result of results)
204
+ if (result.status === 'fulfilled')
205
+ for (const item of result.value)
206
+ byKey.set(mentionKey(item.ref), item);
207
+ return refs.map(
208
+ (ref) =>
209
+ byKey.get(mentionKey(ref)) || { ref, status: 'error' as const },
210
+ );
211
+ },
212
+ };
213
+ }
@@ -0,0 +1,261 @@
1
+ import type {
2
+ CommentPart,
3
+ CommentApiContent,
4
+ CommentValue,
5
+ MentionRef,
6
+ MentionResult,
7
+ } from './types';
8
+
9
+ export const emptyComment = (): CommentValue => ({ version: 1, content: [] });
10
+
11
+ export function mentionKey(ref: MentionRef): string {
12
+ return JSON.stringify([
13
+ ref.kind,
14
+ ref.kind === 'user' ? ref.idType : ref.resourceType,
15
+ ref.id,
16
+ ]);
17
+ }
18
+
19
+ export function isMentionRef(value: unknown): value is MentionRef {
20
+ if (!value || typeof value !== 'object') return false;
21
+ const ref = value as Record<string, unknown>;
22
+ return (
23
+ typeof ref.id === 'string' &&
24
+ ref.id.length > 0 &&
25
+ ((ref.kind === 'user' && ref.idType === 'apaas-user') ||
26
+ (ref.kind === 'document' &&
27
+ typeof ref.resourceType === 'string' &&
28
+ ref.resourceType.length > 0))
29
+ );
30
+ }
31
+
32
+ export function safeUrl(url?: string): string | undefined {
33
+ if (!url) return undefined;
34
+ if (
35
+ url.startsWith('/') &&
36
+ !url.startsWith('//') &&
37
+ !/[\\\s\u0000-\u001f]/.test(url)
38
+ )
39
+ return url;
40
+ try {
41
+ const parsed = new URL(url);
42
+ return parsed.protocol === 'https:' || parsed.protocol === 'http:'
43
+ ? parsed.href
44
+ : undefined;
45
+ } catch {
46
+ return undefined;
47
+ }
48
+ }
49
+
50
+ export function normalizeValue(value: CommentValue): CommentValue {
51
+ if (!value || value.version !== 1 || !Array.isArray(value.content)) {
52
+ throw new Error('不支持的评论内容格式');
53
+ }
54
+ const content: CommentPart[] = [];
55
+ const text = (s: string) => {
56
+ if (!s) return;
57
+ const previous = content[content.length - 1];
58
+ if (previous?.type === 'text') previous.text += s;
59
+ else content.push({ type: 'text', text: s });
60
+ };
61
+ for (const part of value.content) {
62
+ if (part.type === 'text' && typeof part.text === 'string') {
63
+ part.text
64
+ .replace(/\r\n?/g, '\n')
65
+ .split('\n')
66
+ .forEach((line, i) => {
67
+ if (i) content.push({ type: 'break' });
68
+ text(line);
69
+ });
70
+ } else if (part.type === 'break') content.push({ type: 'break' });
71
+ else if (part.type === 'link' && typeof part.url === 'string')
72
+ content.push({ type: 'link', url: part.url });
73
+ else if (part.type === 'unresolved-mention')
74
+ content.push({ type: 'unresolved-mention' });
75
+ else if (part.type === 'mention' && isMentionRef(part.ref)) {
76
+ if (part.label !== undefined && typeof part.label !== 'string')
77
+ throw new Error('评论引用名称无效');
78
+ content.push({
79
+ type: 'mention',
80
+ ref: { ...part.ref },
81
+ ...(part.label ? { label: part.label } : {}),
82
+ ...(part.ref.kind === 'document' ? documentSnapshot(part) : {}),
83
+ });
84
+ } else throw new Error('评论内容包含无效节点');
85
+ }
86
+ return { version: 1, content };
87
+ }
88
+
89
+ /** Preserve document presentation through editing/clipboard without trusting HTML or URLs. */
90
+ export function documentSnapshot(value: {
91
+ href?: unknown;
92
+ document?: unknown;
93
+ }) {
94
+ const href = typeof value.href === 'string' ? safeUrl(value.href) : undefined;
95
+ const meta =
96
+ value.document && typeof value.document === 'object'
97
+ ? (value.document as Record<string, unknown>)
98
+ : {};
99
+ const subType =
100
+ typeof meta.subType === 'number' && Number.isSafeInteger(meta.subType)
101
+ ? meta.subType
102
+ : undefined;
103
+ const iconInfo =
104
+ typeof meta.iconInfo === 'string' ? meta.iconInfo : undefined;
105
+ return {
106
+ ...(href ? { href } : {}),
107
+ ...(subType !== undefined || iconInfo
108
+ ? {
109
+ document: {
110
+ ...(subType !== undefined ? { subType } : {}),
111
+ ...(iconInfo ? { iconInfo } : {}),
112
+ },
113
+ }
114
+ : {}),
115
+ };
116
+ }
117
+
118
+ export function toPlainText(value: CommentValue): string {
119
+ return value.content
120
+ .map((part) =>
121
+ part.type === 'text'
122
+ ? part.text
123
+ : part.type === 'break'
124
+ ? '\n'
125
+ : part.type === 'link'
126
+ ? part.url
127
+ : part.type === 'unresolved-mention'
128
+ ? '引用信息缺失'
129
+ : part.ref.kind === 'user'
130
+ ? `@${part.label || '用户'}`
131
+ : part.label || part.href || '文档',
132
+ )
133
+ .join('');
134
+ }
135
+
136
+ export function collectMentionRefs(value: CommentValue): MentionRef[] {
137
+ const refs = new Map<string, MentionRef>();
138
+ for (const part of value.content)
139
+ if (part.type === 'mention') refs.set(mentionKey(part.ref), part.ref);
140
+ return [...refs.values()];
141
+ }
142
+
143
+ export function mentionLabel(
144
+ ref: MentionRef,
145
+ label: string | undefined,
146
+ result?: MentionResult,
147
+ strict = false,
148
+ ): string {
149
+ if (result?.status === 'resolved')
150
+ return result.label || (ref.kind === 'user' ? '用户' : '文档');
151
+ if (result?.status === 'forbidden')
152
+ return ref.kind === 'user' ? '无权查看的用户' : '无权查看的文档';
153
+ if (result?.status === 'not-found')
154
+ return ref.kind === 'user' ? '用户不存在' : '文档已失效';
155
+ return (!strict && label) || (ref.kind === 'user' ? '用户' : '文档');
156
+ }
157
+
158
+ const types: Record<string, string> = {
159
+ doc: '1',
160
+ docx: '22',
161
+ sheets: '3',
162
+ sheet: '3',
163
+ base: '8',
164
+ bitable: '8',
165
+ wiki: '16',
166
+ mindnotes: '11',
167
+ mindnote: '11',
168
+ slides: '30',
169
+ file: '12',
170
+ whiteboard: '38',
171
+ };
172
+
173
+ /** Only recognised cloud-document hosts/routes become metadata references. Other URLs stay links. */
174
+ export function documentRefFromUrl(url: string): MentionRef | undefined {
175
+ try {
176
+ const parsed = new URL(url);
177
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')
178
+ return undefined;
179
+ if (
180
+ ![
181
+ 'feishu.cn',
182
+ 'feishu-boe.cn',
183
+ 'feishu-pre.cn',
184
+ 'larkoffice.com',
185
+ 'larksuite.com',
186
+ 'doubao.com',
187
+ ].some(
188
+ (domain) =>
189
+ parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`),
190
+ )
191
+ )
192
+ return undefined;
193
+ const match =
194
+ /^\/(doc|docx|sheets?|base|bitable|wiki|mindnotes?|slides|file|whiteboard)\/([a-zA-Z0-9_-]+)(?:\/|$)/.exec(
195
+ parsed.pathname,
196
+ );
197
+ return match
198
+ ? { kind: 'document', id: match[2], resourceType: types[match[1]] }
199
+ : undefined;
200
+ } catch {
201
+ return undefined;
202
+ }
203
+ }
204
+
205
+ /** Read the exact text/mention/link protocol without send-state validation. */
206
+ export function readApiContent(value: CommentValue): CommentApiContent[] {
207
+ const content: CommentApiContent[] = [];
208
+ for (const part of normalizeValue(value).content) {
209
+ if (part.type === 'text' || part.type === 'break') {
210
+ const text = part.type === 'text' ? part.text : '\n';
211
+ const previous = content.at(-1);
212
+ if (previous?.type === 'text') previous.text += text;
213
+ else content.push({ type: 'text', text });
214
+ } else if (part.type === 'unresolved-mention')
215
+ content.push({ type: 'mention' });
216
+ else if (part.type === 'link')
217
+ content.push({ type: 'link', url: part.url });
218
+ else if (part.ref.kind === 'document')
219
+ content.push({ type: 'link', url: part.href || '' });
220
+ else content.push({ type: 'mention', userID: apiUserID(part.ref.id) });
221
+ }
222
+ return content;
223
+ }
224
+
225
+ function apiUserID(id: string): number {
226
+ const number = Number(id);
227
+ if (
228
+ !/^[1-9]\d*$/.test(id) ||
229
+ !Number.isSafeInteger(number) ||
230
+ String(number) !== id
231
+ )
232
+ throw new Error('人员 userID 必须是可精确表示的正整数');
233
+ return number;
234
+ }
235
+
236
+ export function fromApiContent(
237
+ content: readonly CommentApiContent[],
238
+ ): CommentValue {
239
+ if (!Array.isArray(content)) throw new Error('评论 content 必须是数组');
240
+ const parts = content.map((part): CommentPart => {
241
+ if (part?.type === 'text' && typeof part.text === 'string')
242
+ return { type: 'text', text: part.text };
243
+ if (part?.type === 'mention') {
244
+ if (part.userID == null) return { type: 'unresolved-mention' };
245
+ const id = String(part.userID);
246
+ apiUserID(id);
247
+ return {
248
+ type: 'mention',
249
+ ref: { kind: 'user', id, idType: 'apaas-user' },
250
+ };
251
+ }
252
+ if (part?.type === 'link' && typeof part.url === 'string') {
253
+ const ref = documentRefFromUrl(part.url);
254
+ return ref
255
+ ? { type: 'mention', ref, href: part.url }
256
+ : { type: 'link', url: part.url };
257
+ }
258
+ throw new Error('评论 content 包含未支持的节点');
259
+ });
260
+ return normalizeValue({ version: 1, content: parts });
261
+ }