@lark-apaas/coding-template-nestjs-react-fullstack 0.1.51 → 0.1.52-alpha.20261027082821

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/coding-template-nestjs-react-fullstack",
3
- "version": "0.1.51",
3
+ "version": "0.1.52-alpha.20261027082821",
4
4
  "description": "NestJS + React fullstack template for miaoda coding",
5
5
  "type": "module",
6
6
  "files": [
@@ -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,216 @@
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
+ let maxEntries = 5;
19
+
20
+ /**
21
+ * 调整缓存策略:需要同时备好更多人的卡片时调大 maxEntries。
22
+ *
23
+ * 没有 TTL —— 每次打开都会重新渲染一次做更新,备用卡只决定「打开瞬间
24
+ * 显示什么」,不决定数据新鲜度。
25
+ */
26
+ export function configureCardPool(options: { maxEntries?: number }): void {
27
+ if (typeof options.maxEntries === 'number') maxEntries = options.maxEntries;
28
+ }
29
+
30
+ /** 飞书名片渲染完成的标志节点:姓名有文字即认为内容已就绪(骨架屏阶段为空) */
31
+ const READY_SELECTOR = '.larkw-userprofile__profile__alias-or-username-text';
32
+
33
+ /** 等待渲染完成的上限,超时按失败处理,不替换当前展示的卡片 */
34
+ const READY_TIMEOUT_MS = 8 * 1000;
35
+
36
+ /**
37
+ * 备用卡池:每个 openId 一张**已渲染好、但从未显示过**的活 `<tt-profile>`,
38
+ * 停在离屏容器里。
39
+ *
40
+ * 为什么不直接缓存用户看过的那张:活元素会被用户操作 —— 滚动、点进上级的
41
+ * 名片、点开个性签名编辑框。这些状态一半在 DOM(inline 样式,能读能写),
42
+ * 一半在飞书 React 的内部 state(外部读不到也写不回)。既然无法可靠地把它
43
+ * 恢复成初始态,就干脆不复用它,而是另外备一张从没被碰过的。
44
+ *
45
+ * 代价是一个开关周期要渲染两次:一次换上去做更新,一次补充下次的备用卡。
46
+ */
47
+ const pool = new Map<string, Element>();
48
+
49
+ /** 取走某人的备用卡(取走即消耗,调用方负责挂载)。没有则返回 null。 */
50
+ export function takeSpare(openId: string): Element | null {
51
+ const spare = pool.get(openId);
52
+ if (!spare) return null;
53
+ pool.delete(openId);
54
+ return spare;
55
+ }
56
+
57
+ /**
58
+ * 停放一张备用卡。元素应当停留在离屏容器里,不要挂进可见区域 ——
59
+ * 一旦被用户看到就可能被操作,就不再是初始态了。
60
+ */
61
+ export function putSpare(openId: string, live: Element): void {
62
+ pool.get(openId)?.remove();
63
+ pool.delete(openId);
64
+ pool.set(openId, live);
65
+
66
+ while (pool.size > maxEntries) {
67
+ const oldest = pool.keys().next().value;
68
+ if (oldest === undefined) break;
69
+ pool.get(oldest)?.remove();
70
+ pool.delete(oldest);
71
+ }
72
+ }
73
+
74
+ /** 某人是否已经备好卡(用于决定要不要补渲染) */
75
+ export function hasSpare(openId: string): boolean {
76
+ return pool.has(openId);
77
+ }
78
+
79
+ /** 同一个人同时在途的渲染任务,用于去重 */
80
+ const inflight = new Map<string, Promise<Element | null>>();
81
+
82
+ /**
83
+ * 保证同一个人同时只有一次渲染在途:重复调用复用同一个 Promise。
84
+ *
85
+ * 这不是优化而是正确性要求 —— effect 会被重复执行(React dev 下的双调用、
86
+ * 依赖变化、popover 快速开关),没有这层去重就会创建出多个 `<tt-profile>`,
87
+ * 每个都往飞书发一次请求。
88
+ *
89
+ * key 由调用方给 —— 同一个人的「打开后更新」和「补充备用卡」是两件独立的
90
+ * 事,不能互相去重,所以用 `<openId>:update` / `<openId>:spare` 区分。
91
+ */
92
+ export function ensureRender(
93
+ key: string,
94
+ create: () => Promise<Element | null>,
95
+ ): Promise<Element | null> {
96
+ const existing = inflight.get(key);
97
+ if (existing) return existing;
98
+
99
+ const task = create().finally(() => {
100
+ inflight.delete(key);
101
+ });
102
+
103
+ inflight.set(key, task);
104
+ return task;
105
+ }
106
+
107
+ /** 丢弃某人的备用卡(渲染出错或需要强制重建时) */
108
+ export function dropSpare(openId: string): void {
109
+ pool.get(openId)?.remove();
110
+ pool.delete(openId);
111
+ }
112
+
113
+ /** 清空整个池。页面级登出 / 切租户时调用。 */
114
+ export function clearCardPool(): void {
115
+ pool.forEach((el) => {
116
+ el.remove();
117
+ });
118
+ pool.clear();
119
+ }
120
+
121
+ /**
122
+ * 等待一个 `<tt-profile>` 从骨架屏变成真实内容。
123
+ * 超时返回 false —— 调用方据此放弃替换,继续展示旧卡片。
124
+ */
125
+ export function waitForCardReady(
126
+ el: Element,
127
+ timeoutMs: number = READY_TIMEOUT_MS,
128
+ ): Promise<boolean> {
129
+ const isReady = () => {
130
+ const node = el.querySelector(READY_SELECTOR);
131
+ return Boolean(node?.textContent?.trim());
132
+ };
133
+
134
+ if (isReady()) return Promise.resolve(true);
135
+
136
+ return new Promise<boolean>((resolve) => {
137
+ const observer = new MutationObserver(() => {
138
+ if (!isReady()) return;
139
+ clearTimeout(timer);
140
+ observer.disconnect();
141
+ resolve(true);
142
+ });
143
+
144
+ const timer = setTimeout(() => {
145
+ observer.disconnect();
146
+ resolve(false);
147
+ }, timeoutMs);
148
+
149
+ observer.observe(el, {
150
+ childList: true,
151
+ subtree: true,
152
+ characterData: true,
153
+ });
154
+ });
155
+ }
156
+
157
+ /**
158
+ * 取「卡片里本人那一屏」的文字,用来判断本人的数据有没有被改动过。
159
+ *
160
+ * 不能直接用整张卡的 textContent —— 飞书名片是个卡片栈,用户点进上级的
161
+ * 名片时会往里压入第二屏,整张卡的文字就变了,但**本人的数据一个字没动**。
162
+ * 把那种情况误判成「数据变了」会白白丢弃备用卡,下次打开退回完整 loading。
163
+ *
164
+ * 栈里第一个 `.larkw-userprofile__manager__profile` 就是本人那一屏。
165
+ * 找不到这个结构时退回整张卡,宁可偶尔多丢一次缓存,也不要漏掉真实改动。
166
+ */
167
+ export function ownProfileText(el: Element): string {
168
+ const own =
169
+ el.querySelector('.larkw-userprofile__manager__profile') ?? el;
170
+ return own.textContent?.trim() ?? '';
171
+ }
172
+
173
+ const OFFSCREEN_ID = 'lark-profile-offscreen';
174
+
175
+ /**
176
+ * 离屏渲染容器:后台渲染的 `<tt-profile>` 先在这里成形,就绪后再挂到
177
+ * 可见位置,这样用户看不到骨架屏。
178
+ *
179
+ * 用绝对定位移出视口而不是 `display:none` —— 后者会让元素没有布局盒,
180
+ * 飞书名片内部依赖尺寸的逻辑可能不工作。
181
+ */
182
+ /**
183
+ * 为「一次渲染」开一个专属的离屏插槽。
184
+ *
185
+ * SDK 的 render() 只是把元素 append 到给定容器,不返回它,我们只能从容器
186
+ * 里取。多次渲染共用一个容器时(命中备用卡后,「打开后更新」和「预备下一张」
187
+ * 是并发的;一页多张卡片时更多),谁 append 在前无法保证,按「最后一个子节点」
188
+ * 取会张冠李戴 —— 两次渲染可能拿到同一个元素,甚至取到别人的名片。
189
+ *
190
+ * 每次渲染独占一个插槽,容器里就只会有本次创建的那一个。
191
+ */
192
+ export function createOffscreenSlot(): HTMLElement {
193
+ const slot = document.createElement('div');
194
+ getOffscreenHost().appendChild(slot);
195
+ return slot;
196
+ }
197
+
198
+ export function getOffscreenHost(): HTMLElement {
199
+ const existing = document.getElementById(OFFSCREEN_ID);
200
+ if (existing) return existing;
201
+
202
+ const host = document.createElement('div');
203
+ host.id = OFFSCREEN_ID;
204
+ host.setAttribute('aria-hidden', 'true');
205
+ host.style.cssText = [
206
+ 'position:absolute',
207
+ 'top:0',
208
+ 'left:-10000px',
209
+ 'width:320px',
210
+ 'height:520px',
211
+ 'overflow:hidden',
212
+ 'pointer-events:none',
213
+ ].join(';');
214
+ document.body.appendChild(host);
215
+ return host;
216
+ }
@@ -5,11 +5,20 @@ import { logger } from '@lark-apaas/client-toolkit/logger';
5
5
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
6
6
  import SHA1 from 'crypto-js/sha1';
7
7
 
8
- import {
9
- fetchUserProfile,
10
- getAssetsUrl,
11
- } from '@client/src/components/business-ui/api/user-profiles/service';
8
+ import { getAssetsUrl } from '@client/src/components/business-ui/api/user-profiles/service';
9
+ import { useUserProfile } from '@client/src/components/business-ui/api/user-profiles/queries';
12
10
  import { ErrorImage } from '@client/src/components/business-ui/user-profile/error-image';
11
+ import {
12
+ dropSpare,
13
+ ownProfileText,
14
+ ensureRender,
15
+ createOffscreenSlot,
16
+ getOffscreenHost,
17
+ hasSpare,
18
+ putSpare,
19
+ takeSpare,
20
+ waitForCardReady,
21
+ } from '@client/src/components/business-ui/user-profile/lark-card-pool';
13
22
  import { getEnv } from '@lark-apaas/client-toolkit/utils/getEnv';
14
23
  import { useExternalScript } from '@client/src/components/business-ui/user-profile/user-external-script';
15
24
  import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
@@ -67,8 +76,6 @@ function generateRandomString(length: number): string {
67
76
  return result;
68
77
  }
69
78
 
70
- type BaseUserProfileProps = BaseSimpleProfileProps | BaseOfficialProfileProps;
71
-
72
79
  const ACCOUNT_STATUS = {
73
80
  UNSPECIFIED: 0,
74
81
  Inactive: 1,
@@ -90,28 +97,31 @@ const AccountStatusMap: Record<AccountStatus, string> = {
90
97
  [ACCOUNT_STATUS.Resigned]: '已停用',
91
98
  };
92
99
 
93
- interface BaseSimpleProfileProps {
100
+ interface SimpleProfileInfo {
101
+ readonly name?: string;
102
+ readonly avatar?: string;
103
+ readonly email?: string;
104
+ readonly userStatus: AccountStatus;
105
+ readonly userType: '_employee' | '_externalUser';
106
+ }
107
+
108
+ interface SimpleProfileData {
94
109
  readonly useLarkCard: false;
95
- readonly userProfileInfo: {
96
- readonly name?: string;
97
- readonly avatar?: string;
98
- readonly email?: string;
99
- //
100
- readonly userStatus: AccountStatus;
101
- readonly userType: '_employee' | '_externalUser';
102
- };
110
+ readonly userProfileInfo: SimpleProfileInfo;
103
111
  }
104
112
 
105
- interface BaseOfficialProfileProps {
113
+ interface OfficialProfileData {
106
114
  readonly useLarkCard: true;
107
- readonly larkCardParam: {
108
- readonly needRedirect?: boolean;
109
- readonly redirectURL?: string;
110
- readonly larkAppID: string;
111
- readonly jsAPITicket: string;
112
- readonly larkOpenID: string;
113
- readonly targetLarkOpenID: string;
114
- };
115
+ readonly larkCardParam: LarkCardParam;
116
+ }
117
+
118
+ interface LarkCardParam {
119
+ readonly needRedirect?: boolean;
120
+ readonly redirectURL?: string;
121
+ readonly larkAppID: string;
122
+ readonly jsAPITicket: string;
123
+ readonly larkOpenID: string;
124
+ readonly targetLarkOpenID: string;
115
125
  }
116
126
 
117
127
  /**
@@ -122,15 +132,55 @@ export interface WithCommonResponse<T> {
122
132
  data: T;
123
133
  }
124
134
 
125
- async function renderLarkProfile({
126
- larkAppID,
127
- jsAPITicket,
128
- larkOpenID,
129
- cardRef,
130
- targetLarkOpenID,
131
- }: BaseOfficialProfileProps['larkCardParam'] & {
132
- cardRef: React.RefObject<HTMLDivElement | null>;
133
- }) {
135
+ // 按需注入飞书 H5 JS SDK,仅在走飞书名片分支时才加载
136
+ const LARK_SDK_URL =
137
+ 'https://lf3-cdn-tos.bytegoofy.com/obj/goofy/locl/lark/external_js_sdk/h5-js-sdk-1.2.21.js';
138
+
139
+ /**
140
+ * 鉴权重定向地址放在模块级而不是组件 ref 里。
141
+ * 原因:useExternalScript 在「脚本已存在」时会跳过 onloadCallback,
142
+ * 所以 onAuthError 只会在页面首个实例挂载时注册一次。若闭包捕获的是
143
+ * 某个实例的 ref,该实例卸载后,后续实例的 redirectURL 就永远送不到
144
+ * 已注册的 handler 里。用模块级变量保证读到的始终是最新值。
145
+ */
146
+ let currentRedirectURL = '';
147
+ let authHandlerRegistered = false;
148
+
149
+ function registerLarkAuthHandler(): void {
150
+ if (authHandlerRegistered) return;
151
+ if (typeof globalThis.webComponent === 'undefined') return;
152
+ authHandlerRegistered = true;
153
+
154
+ globalThis.webComponent.onAuthError(function (error: Error) {
155
+ let errorMessage: { msg?: { code?: number; msg?: string } } | undefined;
156
+ try {
157
+ errorMessage = JSON.parse(error.message);
158
+ } catch {
159
+ logger.info('webComponent onAuthError: 非 JSON 错误体', error);
160
+ return;
161
+ }
162
+ // 存在token过期情况,但后端无法识别,需要前端这边主动识别error,并进行鉴权重定向
163
+ if (
164
+ errorMessage?.msg?.code === 20442 &&
165
+ errorMessage?.msg?.msg === 'jsapi-ticket not exist' &&
166
+ currentRedirectURL
167
+ ) {
168
+ globalThis.location.replace(currentRedirectURL);
169
+ }
170
+ });
171
+
172
+ globalThis.webComponent.onError(function (error: Error) {
173
+ logger.info('webComponent onError', error);
174
+ });
175
+ }
176
+
177
+ /** 在指定容器里创建并渲染一个飞书名片,返回创建出来的 `<tt-profile>` 元素 */
178
+ async function renderLarkProfile(
179
+ param: LarkCardParam,
180
+ host: HTMLElement,
181
+ ): Promise<Element | null> {
182
+ const { larkAppID, jsAPITicket, larkOpenID, targetLarkOpenID } = param;
183
+
134
184
  const timestamp = Date.now().toString();
135
185
  const nonceStr = generateRandomString(10);
136
186
  const url = globalThis.location.href.split('#')[0] ?? '';
@@ -146,20 +196,17 @@ async function renderLarkProfile({
146
196
  url,
147
197
  jsApiList: ['user_profile'],
148
198
  locale: ['zh_cn'],
149
- ...(getEnv() === 'BOE'
150
- ? { env: 'feishu_boe' }
151
- : {}),
199
+ ...(getEnv() === 'BOE' ? { env: 'feishu_boe' } : {}),
152
200
  });
153
201
 
154
- const myComponent = await globalThis.webComponent.render(
202
+ await globalThis.webComponent.render(
155
203
  'UserProfile',
156
- {
157
- openId: targetLarkOpenID,
158
- },
159
- cardRef.current,
204
+ { openId: targetLarkOpenID },
205
+ host,
160
206
  );
161
207
 
162
- return myComponent;
208
+ // SDK 把 <tt-profile> append 到 host 上,取最后一个即本次创建的
209
+ return host.lastElementChild;
163
210
  }
164
211
 
165
212
  function UserProfile(props: UserProfileProps) {
@@ -172,96 +219,30 @@ function UserProfile(props: UserProfileProps) {
172
219
  user_id ||
173
220
  originUserId;
174
221
 
175
- const cardRef = useRef<HTMLDivElement>(null);
176
- const [loading, setLoading] = useState<boolean>(true);
177
- const redirectURLRef = useRef<string>('');
178
- const [response, setResponse] = useState<BaseUserProfileProps>(
179
- {} as BaseUserProfileProps,
180
- );
181
- const [error, setError] = useState<boolean>(false);
182
-
183
- const onAuthError = useCallback(() => {
184
- globalThis.webComponent.onAuthError(function (error: Error) {
185
- const errorMessage = JSON.parse(error.message);
186
- // 存在token过期情况,但后端无法识别,需要前端这边主动识别error,并进行鉴权重定向
187
- if (
188
- errorMessage?.msg?.code === 20442 &&
189
- errorMessage?.msg?.msg === 'jsapi-ticket not exist'
190
- ) {
191
- globalThis.location.replace(redirectURLRef.current);
192
- }
193
- });
194
-
195
- globalThis.webComponent.onError(function (error: Error) {
196
- logger.info('webComponent onError', error);
197
- });
198
- }, []);
199
-
200
- // 按需注入飞书 H5 JS SDK,仅在组件使用时加载
201
- const larkSdkURl =
202
- 'https://lf3-cdn-tos.bytegoofy.com/obj/goofy/locl/lark/external_js_sdk/h5-js-sdk-1.2.21.js';
203
- const scriptOptions = useMemo(
204
- () => ({ onloadCallback: onAuthError }),
205
- [onAuthError],
206
- );
207
- const scriptStatus = useExternalScript(larkSdkURl, scriptOptions);
208
-
209
- const fetchData = useCallback(async () => {
210
- if (!userId) return;
211
-
212
- const controller = new AbortController();
213
-
214
- setLoading(true);
215
- setError(false);
216
-
217
- try {
218
- const data = await fetchUserProfile(userId, accountType, controller.signal);
219
- setResponse(data as BaseUserProfileProps);
220
-
221
- if (data.useLarkCard && !data.larkCardParam.needRedirect) {
222
- redirectURLRef.current = data.larkCardParam.redirectURL ?? '';
223
- await renderLarkProfile({
224
- ...data.larkCardParam,
225
- cardRef,
226
- });
227
- setLoading(false);
228
- } else {
229
- setLoading(false);
230
- }
231
- } catch (error: unknown) {
232
- if (error instanceof Error && error.name === 'AbortError') {
233
- // 直接退出,不处理 AbortError
234
- } else {
235
- // 最终的UI反馈
236
- setError(true);
237
- setLoading(false);
238
- }
239
- }
240
- }, [userId, accountType]);
222
+ const { data, isPending, isError, refetch } = useUserProfile({
223
+ userId: userId ?? '',
224
+ accountType,
225
+ enabled: Boolean(userId),
226
+ });
241
227
 
242
- useEffect(() => {
243
- if (scriptStatus !== 'ready') return;
228
+ const handleRetry = useCallback(() => {
229
+ void refetch();
230
+ }, [refetch]);
244
231
 
245
- void fetchData();
246
- }, [fetchData, scriptStatus]);
232
+ // 没有 userId 时不渲染任何东西。旧实现在这里会停在 loading 上永远转圈。
233
+ if (!userId) return null;
247
234
 
248
- if (error) {
235
+ if (isError) {
249
236
  return (
250
- <Card
251
- ref={cardRef}
252
- className="flex min-h-124 w-80 flex-col items-center justify-center gap-4 border-0 p-0"
253
- >
237
+ <Card className="flex min-h-124 w-80 flex-col items-center justify-center gap-4 border-0 p-0">
254
238
  <ErrorImage />
255
- {/* <Button variant="outline" className="border-red-500 text-black hover:bg-red-50" onClick={fetchData}>
256
- 加载失败 请重试
257
- </Button> */}
258
239
  <div>
259
240
  <span className="text-sm">加载失败 请</span>
260
241
  <Button
261
242
  size="sm"
262
243
  variant="ghost"
263
244
  className="pr-1 pl-1 text-primary! not-disabled:hover:bg-primary/10 not-disabled:hover:text-primary/90 focus-visible:bg-primary/20"
264
- onClick={fetchData}
245
+ onClick={handleRetry}
265
246
  >
266
247
  重试
267
248
  </Button>
@@ -269,42 +250,276 @@ function UserProfile(props: UserProfileProps) {
269
250
  </Card>
270
251
  );
271
252
  }
253
+
254
+ // isPending 只在「无缓存且首次请求中」为真。
255
+ // 命中缓存时直接跳过 loading,卡片零延迟出现,后台静默 revalidate。
256
+ if (isPending || !data) {
257
+ return (
258
+ <Card className="flex h-130 w-80 flex-col items-center justify-center gap-6 border-0 p-0">
259
+ <Spinner className="size-8 animate-spin text-primary" />
260
+ </Card>
261
+ );
262
+ }
263
+
264
+ // 业务仓的 tsconfig 继承自 fullstack-presets,是 strictNullChecks: false。
265
+ // TS 在该模式下不做判别联合窄化(本地现写的同形状联合一样窄化失败),
266
+ // 所以这里必须显式断言分支类型,不能简化成 data.userProfileInfo。
267
+ if (!data.useLarkCard) {
268
+ const { userProfileInfo } = data as SimpleProfileData;
269
+ return <SimpleUserProfile userProfileInfo={userProfileInfo} />;
270
+ }
271
+
272
+ const { larkCardParam } = data as OfficialProfileData;
273
+ // key 绑 openId:切换到另一个人时整个重挂,备用卡引用、文字基线、loading
274
+ // 状态一并重置。否则 spareRef 的 `??=` 会把上一个人的卡片留下来复用。
272
275
  return (
273
- <Card
274
- ref={cardRef}
275
- className="flex h-130 w-80 flex-col items-center justify-center gap-6 border-0 p-0"
276
- >
277
- {loading ? <LoadingComponent /> : <BaseUserProfile {...response} />}
278
- </Card>
276
+ <LarkProfileCard
277
+ key={larkCardParam.targetLarkOpenID}
278
+ larkCardParam={larkCardParam}
279
+ />
279
280
  );
280
281
  }
281
282
 
282
- function LoadingComponent() {
283
- return <Spinner className="size-8 animate-spin text-primary" />;
284
- }
283
+ /**
284
+ * 飞书名片分支。
285
+ *
286
+ * 人员数据由 `<tt-profile>` 自己拉取,不经过我们的 state,因此这里无法做
287
+ * 字段级的 stale-while-revalidate。退而求其次:缓存整个已渲染的 DOM,
288
+ * 命中即刻展示;内容陈旧时在离屏容器里渲染一份新的,就绪后整体替换,
289
+ * 这样换新过程中用户看到的始终是旧卡片,不会出现骨架屏。
290
+ */
291
+ function LarkProfileCard(props: { readonly larkCardParam: LarkCardParam }) {
292
+ const { larkCardParam } = props;
293
+ const { needRedirect, redirectURL, targetLarkOpenID } = larkCardParam;
285
294
 
286
- function BaseUserProfile(props: BaseUserProfileProps) {
287
- const { useLarkCard } = props;
288
- if (!useLarkCard) {
289
- const { userProfileInfo } = props as BaseSimpleProfileProps;
295
+ const hostRef = useRef<HTMLDivElement>(null);
296
+ // 初值在渲染期就定下来:有备用卡就不该出现转圈。effect 在首次绘制之后
297
+ // 才跑,若初值恒为 true,命中缓存也会先闪一帧 loading。
298
+ const [showSpinner, setShowSpinner] = useState(() =>
299
+ !hasSpare(larkCardParam.targetLarkOpenID),
300
+ );
290
301
 
291
- return <SimpleUserProfile userProfileInfo={userProfileInfo} />;
292
- }
302
+ // 这次挂载取到的备用卡。effect 可能在一次打开中跑多次(依赖变化、
303
+ // React 开发期双调用),而 takeSpare 是「取走」语义 —— 不记在 ref 里的话,
304
+ // 第二次就拿不到,会误判成没有缓存而走完整 loading。
305
+ const spareRef = useRef<Element | null>(null);
293
306
 
294
- const { larkCardParam } = props;
295
- const { needRedirect, redirectURL } = larkCardParam;
296
- if (needRedirect) {
297
- if (!redirectURL) {
298
- return null;
307
+ // 渲染失败(含就绪超时)。只在「屏幕上还什么都没有」时才需要告诉用户,
308
+ // 已经挂着备用卡的话保持现状即可。
309
+ const [renderFailed, setRenderFailed] = useState(false);
310
+ const [retryToken, setRetryToken] = useState(0);
311
+
312
+ // 挂上卡片那一刻、**本人那一屏**的文字。关闭时拿它和当前值比,就知道用户
313
+ // 有没有改过本人的数据(最典型的是编辑自己的个性签名)。只看本人那一屏,
314
+ // 是为了不把「点进上级的名片」这种纯导航误判成数据变化。
315
+ //
316
+ // 这是「同一个元素的前后两个时刻」比对,不是比两张独立渲染的卡 —— 后者
317
+ // 会被 CDN 分片轮换、图片懒加载时序等噪声污染,前者不会。
318
+ const attachedTextRef = useRef<string>('');
319
+
320
+ const scriptOptions = useMemo(
321
+ () => ({ onloadCallback: registerLarkAuthHandler }),
322
+ [],
323
+ );
324
+ const scriptStatus = useExternalScript(LARK_SDK_URL, scriptOptions);
325
+
326
+ // 需要走鉴权重定向时整页跳转,不渲染名片
327
+ useEffect(() => {
328
+ if (needRedirect && redirectURL) {
329
+ globalThis.location.replace(redirectURL);
299
330
  }
331
+ }, [needRedirect, redirectURL]);
300
332
 
301
- globalThis.location.replace(redirectURL);
302
- return null;
303
- }
333
+ useEffect(() => {
334
+ if (needRedirect) return;
335
+
336
+ const host = hostRef.current;
337
+ if (!host) return;
338
+
339
+ currentRedirectURL = redirectURL ?? '';
340
+
341
+ // 脚本已存在时 useExternalScript 会跳过 onloadCallback,这里补注册一次
342
+ if (scriptStatus === 'ready') registerLarkAuthHandler();
343
+
344
+ let cancelled = false;
345
+
346
+ /**
347
+ * 把元素挂到当前容器,并移除容器里原有的那张。
348
+ * 用 replaceChildren 而不是 appendChild —— 后台换新时新旧两张都存在,
349
+ * 必须显式只留一张,不能依赖 putCard 内部清理旧元素的顺序。
350
+ * effect 已失效则什么都不做,元素留在池里等下次打开。
351
+ */
352
+ const attach = (el: Element | null) => {
353
+ if (cancelled || !el) return;
354
+ host.replaceChildren(el);
355
+ attachedTextRef.current = ownProfileText(el);
356
+ setShowSpinner(false);
357
+ };
358
+
359
+ /**
360
+ * 渲染一张完整可用的名片。`visible` 为 true 时直接渲进可见容器(首次打开,
361
+ * 让用户看到飞书自己的骨架屏),否则开一个专属离屏插槽 —— 并发渲染共用
362
+ * 容器会取错元素。渲染不稳定(就绪超时)则判失败。
363
+ */
364
+ const renderInto = async (visible: boolean) => {
365
+ const slot = visible ? null : createOffscreenSlot();
366
+ const live = await renderLarkProfile(larkCardParam, slot ?? host);
367
+ // 插槽只用来隔离本次渲染,拿到元素后就把它挪到离屏容器下并撤掉插槽,
368
+ // 否则空壳 div 会一直累积
369
+ const settle = () => {
370
+ if (!slot) return;
371
+ if (live && live.parentNode === slot) getOffscreenHost().appendChild(live);
372
+ slot.remove();
373
+ };
374
+ if (!live) {
375
+ settle();
376
+ return null;
377
+ }
378
+ if (!(await waitForCardReady(live))) {
379
+ live.remove();
380
+ settle();
381
+ return null;
382
+ }
383
+ settle();
384
+ return live;
385
+ };
386
+
387
+ spareRef.current ??= takeSpare(targetLarkOpenID);
388
+ const spare = spareRef.current;
389
+
390
+ if (spare) {
391
+ // 备用卡是渲染好之后一直停在离屏、从没显示过的,所以必然是初始态,
392
+ // 而且它是活元素 —— 挂上即可交互,不是静态快照
393
+ attach(spare);
394
+ } else {
395
+ setShowSpinner(true);
396
+ }
397
+
398
+ if (scriptStatus === 'ready') {
399
+ // 打开后更新一次:再渲染一张,就绪后换上去,让这次看到的是最新数据。
400
+ // 有备用卡顶着就离屏渲染(用户看不到骨架屏),没有就直接渲进可见容器。
401
+ void ensureRender(`${targetLarkOpenID}:update`, () => renderInto(!spare))
402
+ .then((live) => {
403
+ if (cancelled) {
404
+ // 组件已卸载(用户在新卡渲染完成前关掉了弹窗)。此时元素已经被
405
+ // renderInto 挪进常驻的离屏容器,既不会被 attach,也不进受
406
+ // maxEntries 约束的备用卡池 —— 放着不管就会随着反复开关一直累积。
407
+ live?.remove();
408
+ return;
409
+ }
410
+ if (live) {
411
+ attach(live);
412
+ return;
413
+ }
414
+ // 渲染没能就绪。屏幕上已经挂着备用卡就保持现状;什么都没有的话
415
+ // 必须收掉 loading 并给出重试入口,否则会一直转圈。
416
+ if (!host.firstElementChild) {
417
+ setShowSpinner(false);
418
+ setRenderFailed(true);
419
+ }
420
+ })
421
+ .catch((error: unknown) => {
422
+ logger.info('update lark profile failed', error);
423
+ if (cancelled) return;
424
+ setShowSpinner(false);
425
+ if (!host.firstElementChild) setRenderFailed(true);
426
+ });
427
+
428
+ // 立刻开始备下一张。
429
+ //
430
+ // 这件事必须在「打开」时做,不能等到关闭 —— 真实环境里渲染一张要走
431
+ // 鉴权、render、飞书取数、图片加载,几秒起步。放在关闭时做的话,用户
432
+ // 稍微点快一点,下次打开就还没备好,又掉回完整 loading。放在打开时做,
433
+ // 它有「整个浏览时长 + 关闭后的间隔」来完成。
434
+ //
435
+ // 总开销不变,仍是一个开关周期两次渲染,只是把第二次提前了。
436
+ if (!hasSpare(targetLarkOpenID)) {
437
+ void ensureRender(`${targetLarkOpenID}:spare`, async () => {
438
+ const live = await renderInto(false);
439
+ if (live) putSpare(targetLarkOpenID, live);
440
+ return live;
441
+ }).catch((error: unknown) => {
442
+ logger.info('prepare spare lark profile failed', error);
443
+ });
444
+ }
445
+ }
446
+
447
+ return () => {
448
+ cancelled = true;
449
+
450
+ const shown = host.firstElementChild;
451
+ const changed =
452
+ !!shown && ownProfileText(shown) !== attachedTextRef.current;
453
+
454
+ // 用过的那张直接丢弃 —— 它可能被滚动过、点进过别人的名片,
455
+ // 而这些状态有一半在飞书 React 内部,我们无法可靠地恢复
456
+ shown?.remove();
457
+
458
+ if (!changed || scriptStatus !== 'ready') return;
459
+
460
+ // 用户在卡片里改过东西(最典型的是编辑自己的个性签名)。已经备好的
461
+ // 那张是改动之前渲染的,内容已经过时。
462
+ //
463
+ // 丢掉它,并**立刻重新备一张** —— 只丢不补的话,下次打开就退回完整
464
+ // loading,而「打开秒出」正是这套机制存在的理由。用带时间戳的 key
465
+ // 绕开去重,确保拿到的是改动之后重新渲染的结果。
466
+ dropSpare(targetLarkOpenID);
467
+ void ensureRender(
468
+ `${targetLarkOpenID}:spare:${String(Date.now())}`,
469
+ async () => {
470
+ const live = await renderInto(false);
471
+ if (live) putSpare(targetLarkOpenID, live);
472
+ return live;
473
+ },
474
+ ).catch((error: unknown) => {
475
+ logger.info('re-prepare spare lark profile failed', error);
476
+ });
477
+ };
478
+ }, [
479
+ larkCardParam,
480
+ needRedirect,
481
+ redirectURL,
482
+ retryToken,
483
+ scriptStatus,
484
+ targetLarkOpenID,
485
+ ]);
486
+
487
+ const handleRetryRender = useCallback(() => {
488
+ setRenderFailed(false);
489
+ setShowSpinner(true);
490
+ setRetryToken((n) => n + 1);
491
+ }, []);
492
+
493
+ if (needRedirect) return null;
494
+
495
+ return (
496
+ <Card className="relative flex h-130 w-80 flex-col items-center justify-center gap-6 border-0 p-0">
497
+ <div ref={hostRef} className="h-full w-full" />
498
+ {showSpinner && (
499
+ <Spinner className="absolute size-8 animate-spin text-primary" />
500
+ )}
501
+ {renderFailed && (
502
+ <div className="absolute flex flex-col items-center gap-4">
503
+ <ErrorImage />
504
+ <div>
505
+ <span className="text-sm">加载失败 请</span>
506
+ <Button
507
+ size="sm"
508
+ variant="ghost"
509
+ className="pr-1 pl-1 text-primary! not-disabled:hover:bg-primary/10 not-disabled:hover:text-primary/90 focus-visible:bg-primary/20"
510
+ onClick={handleRetryRender}
511
+ >
512
+ 重试
513
+ </Button>
514
+ </div>
515
+ </div>
516
+ )}
517
+ </Card>
518
+ );
304
519
  }
305
520
 
306
521
  function SimpleUserProfile(props: {
307
- readonly userProfileInfo: BaseSimpleProfileProps['userProfileInfo'];
522
+ readonly userProfileInfo: SimpleProfileInfo;
308
523
  }) {
309
524
  const { userProfileInfo } = props;
310
525
 
@@ -1,52 +1,43 @@
1
- // import { useState, useEffect } from 'react';
1
+ // /* 模板代码 仅作示例 */
2
+ // import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
3
+ // import { toast } from 'sonner';
2
4
  // import { axiosForBackend } from '@lark-apaas/client-toolkit/utils/getAxiosForBackend';
3
- // import { logger } from '@lark-apaas/client-toolkit/logger';
5
+ // import { getApiErrorMessage } from '@/lib/api-error';
6
+
7
+ // // 接口的请求 / 响应类型先写在 shared/api.interface.ts,再从 @shared/api.interface 导入
8
+ // interface RecordItem {
9
+ // id: string;
10
+ // title?: string;
11
+ // type?: string;
12
+ // creator?: string;
13
+ // speakDate?: string;
14
+ // }
4
15
 
5
- // interface RecordData {
6
- // title?: string,
7
- // type?: string,
8
- // creator?: string,
9
- // speakDate?: string,
10
- // record: any;
11
- // loading: boolean;
12
- // error?: string;
16
+ // interface RecordListParams {
17
+ // limit?: number;
18
+ // type?: string; // 筛选选「全部」时不传
13
19
  // }
14
20
 
15
- // export function useRecordData() {
16
- // const [data, setData] = useState<RecordData>({
17
- // record: {},
18
- // loading: true,
19
- // error: null,
21
+ // // 读:useQuery,状态用返回的 isLoading / isError / error,不手写 loading/error state
22
+ // export function useRecordList(params: RecordListParams) {
23
+ // return useQuery({
24
+ // queryKey: ['records', params],
25
+ // queryFn: () =>
26
+ // axiosForBackend
27
+ // .get<RecordItem[]>('/api/hello/test', { params })
28
+ // .then((r) => r.data),
20
29
  // });
30
+ // }
21
31
 
22
- // useEffect(() => {
23
- // fetchRecordData();
24
- // }, []);
25
-
26
- // const fetchRecordData = async () => {
27
- // try {
28
- // setData(prev => ({ ...prev, loading: true, error: null }));
29
-
30
- // // 使用axios实例获取数据
31
- // const response = await axiosForBackend.get('/api/hello');
32
-
33
- // // 提取响应数据中的 data 字段
34
- // const record = response.data.data;
35
-
36
- // setData({
37
- // record,
38
- // loading: false,
39
- // error: null,
40
- // });
41
- // } catch (error) {
42
- // logger.error('获取数据失败', error);
43
- // setData(prev => ({
44
- // ...prev,
45
- // loading: false,
46
- // error: '获取数据失败,请稍后重试',
47
- // }));
48
- // }
49
- // };
50
-
51
- // return { ...data, refetch: fetchRecordData };
32
+ // // 写:useMutation 必须 retry: false(避免重复提交),成功后刷新相关查询
33
+ // // method 和 path 必须与后端 controller 一致(此处假设后端有 POST /api/hello)
34
+ // export function useCreateRecord() {
35
+ // const queryClient = useQueryClient();
36
+ // return useMutation({
37
+ // mutationFn: (body: Omit<RecordItem, 'id'>) =>
38
+ // axiosForBackend.post<RecordItem>('/api/hello', body).then((r) => r.data),
39
+ // retry: false,
40
+ // onSuccess: () => queryClient.invalidateQueries({ queryKey: ['records'] }),
41
+ // onError: (error) => toast.error(getApiErrorMessage(error, '保存失败,请稍后重试')),
42
+ // });
52
43
  // }
@@ -0,0 +1,36 @@
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
+ }
@@ -3,13 +3,14 @@
3
3
  // import { Button } from '@/components/ui/button';
4
4
  // import { RefreshCw, TrendingUp, PieChart, Clock } from 'lucide-react';
5
5
  // import { Link } from 'react-router-dom';
6
- // import { useRecordData } from '@/hooks/use-example';
6
+ // import { useRecordList } from '@/hooks/use-example';
7
+ // import { getApiErrorMessage } from '@/lib/api-error';
7
8
 
8
9
  // export default function ExamplePage() {
9
- // const { record, loading, error, refetch } = useRecordData();
10
+ // const { data: records, isLoading, isError, error, refetch } = useRecordList({ limit: 5 });
10
11
  // const [dimension, setDimension] = useState<'month' | 'quarter' | 'year'>('month');
11
12
 
12
- // if (loading) {
13
+ // if (isLoading) {
13
14
  // return (
14
15
  // <div className="flex items-center justify-center h-64">
15
16
  // <div className="text-muted-foreground">加载中...</div>
@@ -17,11 +18,11 @@
17
18
  // );
18
19
  // }
19
20
 
20
- // if (error) {
21
+ // if (isError) {
21
22
  // return (
22
23
  // <div className="flex flex-col items-center justify-center h-64 space-y-4">
23
- // <div className="text-destructive">{error}</div>
24
- // <Button onClick={refetch} variant="outline" size="sm">
24
+ // <div className="text-destructive">{getApiErrorMessage(error, '获取数据失败,请稍后重试')}</div>
25
+ // <Button onClick={() => refetch()} variant="outline" size="sm">
25
26
  // <RefreshCw className="h-4 w-4 mr-2" />
26
27
  // 重试
27
28
  // </Button>
@@ -31,7 +32,7 @@
31
32
 
32
33
  // return (
33
34
  // <div className="space-y-6">
34
- // {/* 页面内容 */}
35
+ // {/* 页面内容,records 可能为空,渲染前检查 */}
35
36
  // </div>
36
37
  // );
37
38
  // }
@@ -213,11 +213,12 @@
213
213
  inset: -1px;
214
214
  }
215
215
 
216
- /* Does not work on elements with overflow:hidden! */
217
- .hover-elevate:not(.no-default-hover-elevate),
218
- .active-elevate:not(.no-default-active-elevate),
219
- .hover-elevate-2:not(.no-default-hover-elevate),
220
- .active-elevate-2:not(.no-default-active-elevate) {
216
+ /* Does not work on elements with overflow:hidden!
217
+ * Wrap in :where() to zero out specificity so Tailwind position utilities (absolute/fixed/sticky) can override */
218
+ :where(.hover-elevate:not(.no-default-hover-elevate)),
219
+ :where(.active-elevate:not(.no-default-active-elevate)),
220
+ :where(.hover-elevate-2:not(.no-default-hover-elevate)),
221
+ :where(.active-elevate-2:not(.no-default-active-elevate)) {
221
222
  position: relative;
222
223
  z-index: 0;
223
224
  }
@@ -1,16 +1,20 @@
1
1
  // /* 模板代码 仅作示例 */
2
- // import { Controller, Get, Post, Put, Delete, Body, Param, Query, Req, ParseIntPipe } from '@nestjs/common';
2
+ // import { Controller, Get, Query, Req } from '@nestjs/common';
3
3
  // import type { Request } from 'express';
4
+ // import { NeedLogin } from '@lark-apaas/fullstack-nestjs-core';
4
5
  // import { HelloService } from './hello.service';
6
+ // import { HelloListQueryDto } from './hello.dto';
5
7
 
8
+ // // 整个 controller 都需要登录:在类上写一次 @NeedLogin()
6
9
  // @Controller('api/hello')
10
+ // @NeedLogin()
7
11
  // export class HelloController {
8
12
  // constructor(private readonly helloService: HelloService) {}
9
13
 
14
+ // // 列表 / 筛选接口:用 @Query() 绑定 DTO,不逐个 @Query('xxx') + parseInt
10
15
  // @Get('test')
11
- // async test(@Req() req: Request, @Query('limit') limit?: string) {
16
+ // async test(@Req() req: Request, @Query() query: HelloListQueryDto) {
12
17
  // const { userId } = req.userContext;
13
- // const limitNum = limit ? parseInt(limit) : 5;
14
- // return this.helloService.test(userId, limitNum);
18
+ // return this.helloService.test(userId, query.limit ?? 5, query.type);
15
19
  // }
16
20
  // }
@@ -0,0 +1,21 @@
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
+ // }
@@ -2,7 +2,7 @@
2
2
  // import { Injectable, Inject, Logger } from '@nestjs/common';
3
3
  // import { DRIZZLE_DATABASE, type PostgresJsDatabase } from '@lark-apaas/fullstack-nestjs-core';
4
4
  // import { record } from '@server/database/schema';
5
- // import { eq, and, count, desc, sql, gte, lte, or } from 'drizzle-orm';
5
+ // import { eq, and, desc } from 'drizzle-orm';
6
6
 
7
7
  // @Injectable()
8
8
  // export class HelloService {
@@ -10,12 +10,14 @@
10
10
 
11
11
  // constructor(@Inject(DRIZZLE_DATABASE) private readonly db: PostgresJsDatabase) {}
12
12
 
13
- // async test(userId: string, limit: number = 5) {
13
+ // async test(userId: string, limit: number = 5, type?: string) {
14
14
  // try {
15
+ // const conditions = [eq(record.userProfile, userId)];
16
+ // if (type) conditions.push(eq(record.type, type));
15
17
  // const results = await this.db
16
18
  // .select()
17
19
  // .from(record)
18
- // .where(eq(record.userProfile, userId))
20
+ // .where(and(...conditions))
19
21
  // .orderBy(desc(record.speakDate))
20
22
  // .limit(limit);
21
23
 
@@ -1 +1,8 @@
1
- /* 前后端共享的类型写在这里 */
1
+ /* 前后端共享的类型写在这里 */
2
+
3
+ // 固定取值(状态、类型等)在这里定义一次,前后端都从这里导入,不另写取值数组或守卫:
4
+ // export const RECORD_TYPES = ['meeting', 'memo'] as const;
5
+ // export type RecordType = (typeof RECORD_TYPES)[number];
6
+ // export const isRecordType = (v: string): v is RecordType =>
7
+ // (RECORD_TYPES as readonly string[]).includes(v);
8
+ // export const RECORD_TYPE_LABELS: Record<RecordType, string> = { meeting: '会议', memo: '备忘' };