@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.
Files changed (36) 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 +139 -0
  5. package/template/client/src/components/business-ui/comment/app-source.ts +175 -0
  6. package/template/client/src/components/business-ui/comment/ccm-document-source.ts +252 -0
  7. package/template/client/src/components/business-ui/comment/comment-content.tsx +235 -0
  8. package/template/client/src/components/business-ui/comment/comment-input.tsx +191 -0
  9. package/template/client/src/components/business-ui/comment/comment-provider.tsx +40 -0
  10. package/template/client/src/components/business-ui/comment/content.ts +329 -0
  11. package/template/client/src/components/business-ui/comment/document-icon.tsx +104 -0
  12. package/template/client/src/components/business-ui/comment/document-icons.ts +19 -0
  13. package/template/client/src/components/business-ui/comment/dom-editor.ts +581 -0
  14. package/template/client/src/components/business-ui/comment/editor.tsx +496 -0
  15. package/template/client/src/components/business-ui/comment/entity-store.ts +97 -0
  16. package/template/client/src/components/business-ui/comment/image-attachments.tsx +374 -0
  17. package/template/client/src/components/business-ui/comment/mention-appearance.ts +35 -0
  18. package/template/client/src/components/business-ui/comment/mention-provider.tsx +73 -0
  19. package/template/client/src/components/business-ui/comment/mention-suggestions.tsx +312 -0
  20. package/template/client/src/components/business-ui/comment/types.ts +154 -0
  21. package/template/client/src/components/business-ui/comment/use-mention-search.ts +107 -0
  22. package/template/client/src/components/business-ui/user-display/user-display.tsx +0 -13
  23. package/template/client/src/components/business-ui/user-profile/user-profile.tsx +146 -397
  24. package/template/client/src/components/business-ui/user-select/user-pill.tsx +0 -13
  25. package/template/client/src/hooks/use-example.ts +45 -36
  26. package/template/client/src/pages/ExamplePage/ExamplePage.tsx +7 -8
  27. package/template/client/src/tailwind-theme.css +5 -6
  28. package/template/eslint.config.js +0 -6
  29. package/template/package-lock.json +94 -73
  30. package/template/package.json +5 -4
  31. package/template/server/modules/hello/hello.controller.ts +4 -8
  32. package/template/server/modules/hello/hello.service.ts +3 -5
  33. package/template/shared/api.interface.ts +1 -8
  34. package/template/client/src/components/business-ui/user-profile/lark-card-pool.ts +0 -217
  35. package/template/client/src/lib/api-error.ts +0 -36
  36. package/template/server/modules/hello/hello.dto.ts +0 -21
@@ -1,217 +0,0 @@
1
- /**
2
- * 飞书名片 `<tt-profile>` 的 DOM 池。
3
- *
4
- * 为什么 DOM 能被复用:
5
- * 飞书 h5-js-sdk 的 `tt-profile` 在 `customRender()` 里把 React 渲染进元素自身
6
- * (`ReactDOM.render(..., this)`),但 `disconnectedCallback()` 卸载的是
7
- * `this.shadowRoot` —— 两者不是同一个节点,`unmountComponentAtNode` 打空,
8
- * 所以把元素从文档里摘下来并不会销毁它的内容。又因为基类的 `componentRender()`
9
- * 被 `_hasMounted` 一次性闸门挡住,重新挂回文档也不会触发二次渲染。
10
- * 净效果:摘下来存着、下次挂回去,内容原样还在,且不会重新发请求。
11
- *
12
- * ⚠️ 这依赖 SDK 当前实现(h5-js-sdk 1.2.21)。升级 SDK 后必须重新验证:
13
- * 把一个已渲染的 `<tt-profile>` `remove()` 再 `appendChild()` 回去,
14
- * 内容是否仍在、交互是否仍可用。失效时本模块应整体降级为「每次重建」。
15
- */
16
-
17
- /** 池容量上限,超出按最近使用顺序淘汰 */
18
- /* 暂时放大到 20:观察同时备更多人时的内存与渲染开销,确认后再定最终值 */
19
- let maxEntries = 20;
20
-
21
- /**
22
- * 调整缓存策略:需要同时备好更多人的卡片时调大 maxEntries。
23
- *
24
- * 没有 TTL —— 每次打开都会重新渲染一次做更新,备用卡只决定「打开瞬间
25
- * 显示什么」,不决定数据新鲜度。
26
- */
27
- export function configureCardPool(options: { maxEntries?: number }): void {
28
- if (typeof options.maxEntries === 'number') maxEntries = options.maxEntries;
29
- }
30
-
31
- /** 飞书名片渲染完成的标志节点:姓名有文字即认为内容已就绪(骨架屏阶段为空) */
32
- const READY_SELECTOR = '.larkw-userprofile__profile__alias-or-username-text';
33
-
34
- /** 等待渲染完成的上限,超时按失败处理,不替换当前展示的卡片 */
35
- const READY_TIMEOUT_MS = 8 * 1000;
36
-
37
- /**
38
- * 备用卡池:每个 openId 一张**已渲染好、但从未显示过**的活 `<tt-profile>`,
39
- * 停在离屏容器里。
40
- *
41
- * 为什么不直接缓存用户看过的那张:活元素会被用户操作 —— 滚动、点进上级的
42
- * 名片、点开个性签名编辑框。这些状态一半在 DOM(inline 样式,能读能写),
43
- * 一半在飞书 React 的内部 state(外部读不到也写不回)。既然无法可靠地把它
44
- * 恢复成初始态,就干脆不复用它,而是另外备一张从没被碰过的。
45
- *
46
- * 代价是一个开关周期要渲染两次:一次换上去做更新,一次补充下次的备用卡。
47
- */
48
- const pool = new Map<string, Element>();
49
-
50
- /** 取走某人的备用卡(取走即消耗,调用方负责挂载)。没有则返回 null。 */
51
- export function takeSpare(openId: string): Element | null {
52
- const spare = pool.get(openId);
53
- if (!spare) return null;
54
- pool.delete(openId);
55
- return spare;
56
- }
57
-
58
- /**
59
- * 停放一张备用卡。元素应当停留在离屏容器里,不要挂进可见区域 ——
60
- * 一旦被用户看到就可能被操作,就不再是初始态了。
61
- */
62
- export function putSpare(openId: string, live: Element): void {
63
- pool.get(openId)?.remove();
64
- pool.delete(openId);
65
- pool.set(openId, live);
66
-
67
- while (pool.size > maxEntries) {
68
- const oldest = pool.keys().next().value;
69
- if (oldest === undefined) break;
70
- pool.get(oldest)?.remove();
71
- pool.delete(oldest);
72
- }
73
- }
74
-
75
- /** 某人是否已经备好卡(用于决定要不要补渲染) */
76
- export function hasSpare(openId: string): boolean {
77
- return pool.has(openId);
78
- }
79
-
80
- /** 同一个人同时在途的渲染任务,用于去重 */
81
- const inflight = new Map<string, Promise<Element | null>>();
82
-
83
- /**
84
- * 保证同一个人同时只有一次渲染在途:重复调用复用同一个 Promise。
85
- *
86
- * 这不是优化而是正确性要求 —— effect 会被重复执行(React dev 下的双调用、
87
- * 依赖变化、popover 快速开关),没有这层去重就会创建出多个 `<tt-profile>`,
88
- * 每个都往飞书发一次请求。
89
- *
90
- * key 由调用方给 —— 同一个人的「打开后更新」和「补充备用卡」是两件独立的
91
- * 事,不能互相去重,所以用 `<openId>:update` / `<openId>:spare` 区分。
92
- */
93
- export function ensureRender(
94
- key: string,
95
- create: () => Promise<Element | null>,
96
- ): Promise<Element | null> {
97
- const existing = inflight.get(key);
98
- if (existing) return existing;
99
-
100
- const task = create().finally(() => {
101
- inflight.delete(key);
102
- });
103
-
104
- inflight.set(key, task);
105
- return task;
106
- }
107
-
108
- /** 丢弃某人的备用卡(渲染出错或需要强制重建时) */
109
- export function dropSpare(openId: string): void {
110
- pool.get(openId)?.remove();
111
- pool.delete(openId);
112
- }
113
-
114
- /** 清空整个池。页面级登出 / 切租户时调用。 */
115
- export function clearCardPool(): void {
116
- pool.forEach((el) => {
117
- el.remove();
118
- });
119
- pool.clear();
120
- }
121
-
122
- /**
123
- * 等待一个 `<tt-profile>` 从骨架屏变成真实内容。
124
- * 超时返回 false —— 调用方据此放弃替换,继续展示旧卡片。
125
- */
126
- export function waitForCardReady(
127
- el: Element,
128
- timeoutMs: number = READY_TIMEOUT_MS,
129
- ): Promise<boolean> {
130
- const isReady = () => {
131
- const node = el.querySelector(READY_SELECTOR);
132
- return Boolean(node?.textContent?.trim());
133
- };
134
-
135
- if (isReady()) return Promise.resolve(true);
136
-
137
- return new Promise<boolean>((resolve) => {
138
- const observer = new MutationObserver(() => {
139
- if (!isReady()) return;
140
- clearTimeout(timer);
141
- observer.disconnect();
142
- resolve(true);
143
- });
144
-
145
- const timer = setTimeout(() => {
146
- observer.disconnect();
147
- resolve(false);
148
- }, timeoutMs);
149
-
150
- observer.observe(el, {
151
- childList: true,
152
- subtree: true,
153
- characterData: true,
154
- });
155
- });
156
- }
157
-
158
- /**
159
- * 取「卡片里本人那一屏」的文字,用来判断本人的数据有没有被改动过。
160
- *
161
- * 不能直接用整张卡的 textContent —— 飞书名片是个卡片栈,用户点进上级的
162
- * 名片时会往里压入第二屏,整张卡的文字就变了,但**本人的数据一个字没动**。
163
- * 把那种情况误判成「数据变了」会白白丢弃备用卡,下次打开退回完整 loading。
164
- *
165
- * 栈里第一个 `.larkw-userprofile__manager__profile` 就是本人那一屏。
166
- * 找不到这个结构时退回整张卡,宁可偶尔多丢一次缓存,也不要漏掉真实改动。
167
- */
168
- export function ownProfileText(el: Element): string {
169
- const own =
170
- el.querySelector('.larkw-userprofile__manager__profile') ?? el;
171
- return own.textContent?.trim() ?? '';
172
- }
173
-
174
- const OFFSCREEN_ID = 'lark-profile-offscreen';
175
-
176
- /**
177
- * 离屏渲染容器:后台渲染的 `<tt-profile>` 先在这里成形,就绪后再挂到
178
- * 可见位置,这样用户看不到骨架屏。
179
- *
180
- * 用绝对定位移出视口而不是 `display:none` —— 后者会让元素没有布局盒,
181
- * 飞书名片内部依赖尺寸的逻辑可能不工作。
182
- */
183
- /**
184
- * 为「一次渲染」开一个专属的离屏插槽。
185
- *
186
- * SDK 的 render() 只是把元素 append 到给定容器,不返回它,我们只能从容器
187
- * 里取。多次渲染共用一个容器时(命中备用卡后,「打开后更新」和「预备下一张」
188
- * 是并发的;一页多张卡片时更多),谁 append 在前无法保证,按「最后一个子节点」
189
- * 取会张冠李戴 —— 两次渲染可能拿到同一个元素,甚至取到别人的名片。
190
- *
191
- * 每次渲染独占一个插槽,容器里就只会有本次创建的那一个。
192
- */
193
- export function createOffscreenSlot(): HTMLElement {
194
- const slot = document.createElement('div');
195
- getOffscreenHost().appendChild(slot);
196
- return slot;
197
- }
198
-
199
- export function getOffscreenHost(): HTMLElement {
200
- const existing = document.getElementById(OFFSCREEN_ID);
201
- if (existing) return existing;
202
-
203
- const host = document.createElement('div');
204
- host.id = OFFSCREEN_ID;
205
- host.setAttribute('aria-hidden', 'true');
206
- host.style.cssText = [
207
- 'position:absolute',
208
- 'top:0',
209
- 'left:-10000px',
210
- 'width:320px',
211
- 'height:520px',
212
- 'overflow:hidden',
213
- 'pointer-events:none',
214
- ].join(';');
215
- document.body.appendChild(host);
216
- return host;
217
- }
@@ -1,36 +0,0 @@
1
- /**
2
- * 从接口错误中取出给用户看的文案(catch、toast.error、页面错误态统一用它)。
3
- *
4
- * 后端异常结构:{ error: { code, message, fieldErrors?: { 字段: ['错误'] } } },顶层没有 message。
5
- * 读取顺序:error.response.data.error.message → 第一条 fieldErrors 文案 → 调用方兜底文案。
6
- * 不读 axios 的 error.message(它是 "Request failed with status code 400" 这类英文)。
7
- */
8
- interface ApiErrorBody {
9
- error?: {
10
- code?: string;
11
- message?: string;
12
- fieldErrors?: Record<string, string[] | undefined>;
13
- };
14
- }
15
-
16
- export function getApiErrorMessage(
17
- error: unknown,
18
- fallback = '操作失败,请稍后重试',
19
- ): string {
20
- if (typeof error !== 'object' || error === null) return fallback;
21
- const body = (error as { response?: { data?: ApiErrorBody } }).response?.data;
22
- const detail = body?.error;
23
-
24
- if (typeof detail?.message === 'string' && detail.message.trim()) {
25
- return detail.message;
26
- }
27
-
28
- for (const messages of Object.values(detail?.fieldErrors ?? {})) {
29
- const first = Array.isArray(messages)
30
- ? messages.find((m) => typeof m === 'string' && m.trim())
31
- : undefined;
32
- if (first) return first;
33
- }
34
-
35
- return fallback;
36
- }
@@ -1,21 +0,0 @@
1
- // /* 模板代码 仅作示例 */
2
- // import { Transform, Type } from 'class-transformer';
3
- // import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
4
- // import { RECORD_TYPES } from '@shared/api.interface';
5
-
6
- // // 查询参数原始值都是字符串:数字字段加 @Type(() => Number)
7
- // // 可选筛选字段:前端「全部」可能发出空串,读原始值 obj[key],空串当未传
8
- // export class HelloListQueryDto {
9
- // @IsOptional()
10
- // @Type(() => Number)
11
- // @IsInt()
12
- // @Min(1)
13
- // @Max(100)
14
- // limit?: number;
15
-
16
- // @IsOptional()
17
- // @Transform(({ obj, key, value }) => (obj[key] === '' ? undefined : value))
18
- // @IsString()
19
- // @IsIn(RECORD_TYPES)
20
- // type?: 'meeting' | 'memo';
21
- // }