@lark-apaas/coding-template-nestjs-react-fullstack 0.1.52-alpha.20261027094037 → 0.1.52
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/api/user-profiles/queries.ts +1 -7
- 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/package-lock.json +49 -58
- package/template/package.json +1 -1
- package/template/client/src/components/business-ui/user-profile/lark-card-pool.ts +0 -217
package/package.json
CHANGED
|
@@ -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
|
}),
|
|
@@ -13,18 +13,6 @@ import {
|
|
|
13
13
|
PopoverTrigger,
|
|
14
14
|
} from '@client/src/components/ui/popover';
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
* 阻止 Popover 打开时的自动聚焦。
|
|
18
|
-
*
|
|
19
|
-
* Radix 的 FocusScope 在弹层挂载时会把焦点给容器里第一个可聚焦元素。人员卡片
|
|
20
|
-
* 挂载那一刻内容就已经在了(飞书名片里有「消息」等按钮),于是一打开就有个
|
|
21
|
-
* 按钮带上焦点环。这是张只读预览卡,焦点应该留在触发器上 —— Radix 自己的
|
|
22
|
-
* HoverCard 也是这么处理的。
|
|
23
|
-
*/
|
|
24
|
-
const handleProfileOpenAutoFocus = (event: Event) => {
|
|
25
|
-
event.preventDefault();
|
|
26
|
-
};
|
|
27
|
-
|
|
28
16
|
export interface IUserDisplayProps {
|
|
29
17
|
/**
|
|
30
18
|
* 用户列表
|
|
@@ -100,7 +88,6 @@ export const UserDisplay: React.FC<IUserDisplayProps> = ({
|
|
|
100
88
|
<PopoverContent
|
|
101
89
|
align="start"
|
|
102
90
|
sideOffset={8}
|
|
103
|
-
onOpenAutoFocus={handleProfileOpenAutoFocus}
|
|
104
91
|
className="w-[320px] border-0 border-border/50 bg-card p-0 shadow-[0px_8px_24px_8px_rgba(31,35,41,0.04),0px_6px_12px_rgba(31,35,41,0.04),0px_4px_8px_-8px_rgba(31,35,41,0.06)]"
|
|
105
92
|
>
|
|
106
93
|
<UserProfile value={user} />
|
|
@@ -2,30 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
import { logger } from '@lark-apaas/client-toolkit/logger';
|
|
4
4
|
|
|
5
|
-
import React, {
|
|
6
|
-
useCallback,
|
|
7
|
-
useEffect,
|
|
8
|
-
useId,
|
|
9
|
-
useMemo,
|
|
10
|
-
useRef,
|
|
11
|
-
useState,
|
|
12
|
-
} from 'react';
|
|
5
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
13
6
|
import SHA1 from 'crypto-js/sha1';
|
|
14
7
|
|
|
15
|
-
import { getAssetsUrl } from '@client/src/components/business-ui/api/user-profiles/service';
|
|
16
|
-
import { useUserProfile } from '@client/src/components/business-ui/api/user-profiles/queries';
|
|
17
|
-
import { ErrorImage } from '@client/src/components/business-ui/user-profile/error-image';
|
|
18
8
|
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
getOffscreenHost,
|
|
24
|
-
hasSpare,
|
|
25
|
-
putSpare,
|
|
26
|
-
takeSpare,
|
|
27
|
-
waitForCardReady,
|
|
28
|
-
} from '@client/src/components/business-ui/user-profile/lark-card-pool';
|
|
9
|
+
fetchUserProfile,
|
|
10
|
+
getAssetsUrl,
|
|
11
|
+
} from '@client/src/components/business-ui/api/user-profiles/service';
|
|
12
|
+
import { ErrorImage } from '@client/src/components/business-ui/user-profile/error-image';
|
|
29
13
|
import { getEnv } from '@lark-apaas/client-toolkit/utils/getEnv';
|
|
30
14
|
import { useExternalScript } from '@client/src/components/business-ui/user-profile/user-external-script';
|
|
31
15
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
@@ -83,6 +67,8 @@ function generateRandomString(length: number): string {
|
|
|
83
67
|
return result;
|
|
84
68
|
}
|
|
85
69
|
|
|
70
|
+
type BaseUserProfileProps = BaseSimpleProfileProps | BaseOfficialProfileProps;
|
|
71
|
+
|
|
86
72
|
const ACCOUNT_STATUS = {
|
|
87
73
|
UNSPECIFIED: 0,
|
|
88
74
|
Inactive: 1,
|
|
@@ -104,31 +90,28 @@ const AccountStatusMap: Record<AccountStatus, string> = {
|
|
|
104
90
|
[ACCOUNT_STATUS.Resigned]: '已停用',
|
|
105
91
|
};
|
|
106
92
|
|
|
107
|
-
interface
|
|
108
|
-
readonly name?: string;
|
|
109
|
-
readonly avatar?: string;
|
|
110
|
-
readonly email?: string;
|
|
111
|
-
readonly userStatus: AccountStatus;
|
|
112
|
-
readonly userType: '_employee' | '_externalUser';
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
interface SimpleProfileData {
|
|
93
|
+
interface BaseSimpleProfileProps {
|
|
116
94
|
readonly useLarkCard: false;
|
|
117
|
-
readonly userProfileInfo:
|
|
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
|
+
};
|
|
118
103
|
}
|
|
119
104
|
|
|
120
|
-
interface
|
|
105
|
+
interface BaseOfficialProfileProps {
|
|
121
106
|
readonly useLarkCard: true;
|
|
122
|
-
readonly larkCardParam:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
readonly larkOpenID: string;
|
|
131
|
-
readonly targetLarkOpenID: string;
|
|
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
|
+
};
|
|
132
115
|
}
|
|
133
116
|
|
|
134
117
|
/**
|
|
@@ -139,55 +122,15 @@ export interface WithCommonResponse<T> {
|
|
|
139
122
|
data: T;
|
|
140
123
|
}
|
|
141
124
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
* 已注册的 handler 里。用模块级变量保证读到的始终是最新值。
|
|
152
|
-
*/
|
|
153
|
-
let currentRedirectURL = '';
|
|
154
|
-
let authHandlerRegistered = false;
|
|
155
|
-
|
|
156
|
-
function registerLarkAuthHandler(): void {
|
|
157
|
-
if (authHandlerRegistered) return;
|
|
158
|
-
if (typeof globalThis.webComponent === 'undefined') return;
|
|
159
|
-
authHandlerRegistered = true;
|
|
160
|
-
|
|
161
|
-
globalThis.webComponent.onAuthError(function (error: Error) {
|
|
162
|
-
let errorMessage: { msg?: { code?: number; msg?: string } } | undefined;
|
|
163
|
-
try {
|
|
164
|
-
errorMessage = JSON.parse(error.message);
|
|
165
|
-
} catch {
|
|
166
|
-
logger.info('webComponent onAuthError: 非 JSON 错误体', error);
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
// 存在token过期情况,但后端无法识别,需要前端这边主动识别error,并进行鉴权重定向
|
|
170
|
-
if (
|
|
171
|
-
errorMessage?.msg?.code === 20442 &&
|
|
172
|
-
errorMessage?.msg?.msg === 'jsapi-ticket not exist' &&
|
|
173
|
-
currentRedirectURL
|
|
174
|
-
) {
|
|
175
|
-
globalThis.location.replace(currentRedirectURL);
|
|
176
|
-
}
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
globalThis.webComponent.onError(function (error: Error) {
|
|
180
|
-
logger.info('webComponent onError', error);
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** 在指定容器里创建并渲染一个飞书名片,返回创建出来的 `<tt-profile>` 元素 */
|
|
185
|
-
async function renderLarkProfile(
|
|
186
|
-
param: LarkCardParam,
|
|
187
|
-
host: HTMLElement,
|
|
188
|
-
): Promise<Element | null> {
|
|
189
|
-
const { larkAppID, jsAPITicket, larkOpenID, targetLarkOpenID } = param;
|
|
190
|
-
|
|
125
|
+
async function renderLarkProfile({
|
|
126
|
+
larkAppID,
|
|
127
|
+
jsAPITicket,
|
|
128
|
+
larkOpenID,
|
|
129
|
+
cardRef,
|
|
130
|
+
targetLarkOpenID,
|
|
131
|
+
}: BaseOfficialProfileProps['larkCardParam'] & {
|
|
132
|
+
cardRef: React.RefObject<HTMLDivElement | null>;
|
|
133
|
+
}) {
|
|
191
134
|
const timestamp = Date.now().toString();
|
|
192
135
|
const nonceStr = generateRandomString(10);
|
|
193
136
|
const url = globalThis.location.href.split('#')[0] ?? '';
|
|
@@ -203,53 +146,20 @@ async function renderLarkProfile(
|
|
|
203
146
|
url,
|
|
204
147
|
jsApiList: ['user_profile'],
|
|
205
148
|
locale: ['zh_cn'],
|
|
206
|
-
...(getEnv() === 'BOE'
|
|
149
|
+
...(getEnv() === 'BOE'
|
|
150
|
+
? { env: 'feishu_boe' }
|
|
151
|
+
: {}),
|
|
207
152
|
});
|
|
208
153
|
|
|
209
|
-
await globalThis.webComponent.render(
|
|
154
|
+
const myComponent = await globalThis.webComponent.render(
|
|
210
155
|
'UserProfile',
|
|
211
|
-
{
|
|
212
|
-
|
|
156
|
+
{
|
|
157
|
+
openId: targetLarkOpenID,
|
|
158
|
+
},
|
|
159
|
+
cardRef.current,
|
|
213
160
|
);
|
|
214
161
|
|
|
215
|
-
|
|
216
|
-
return host.lastElementChild;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* 渲染一张完整可用的名片。
|
|
221
|
-
*
|
|
222
|
-
* `visibleHost` 非空时直接渲进可见容器(首次打开,让用户看到飞书自己的骨架屏);
|
|
223
|
-
* 否则开一个专属离屏插槽 —— 并发渲染共用容器会取错元素。插槽只用来隔离本次
|
|
224
|
-
* 渲染,拿到元素后就把它挪到离屏容器下并撤掉插槽,否则空壳 div 会累积。
|
|
225
|
-
*
|
|
226
|
-
* 放在模块级而不是 effect 内,是为了让「真正卸载时补备用卡」那条路径也能用,
|
|
227
|
-
* 不必把它写进 effect 的闭包、从而被迫放宽依赖。
|
|
228
|
-
*/
|
|
229
|
-
async function renderCard(
|
|
230
|
-
param: LarkCardParam,
|
|
231
|
-
visibleHost: HTMLElement | null,
|
|
232
|
-
): Promise<Element | null> {
|
|
233
|
-
const slot = visibleHost ? null : createOffscreenSlot();
|
|
234
|
-
const live = await renderLarkProfile(param, visibleHost ?? slot!);
|
|
235
|
-
|
|
236
|
-
const settle = () => {
|
|
237
|
-
if (!slot) return;
|
|
238
|
-
if (live && live.parentNode === slot) getOffscreenHost().appendChild(live);
|
|
239
|
-
slot.remove();
|
|
240
|
-
};
|
|
241
|
-
|
|
242
|
-
if (!live) {
|
|
243
|
-
settle();
|
|
244
|
-
return null;
|
|
245
|
-
}
|
|
246
|
-
if (!(await waitForCardReady(live))) {
|
|
247
|
-
live.remove();
|
|
248
|
-
settle();
|
|
249
|
-
return null;
|
|
250
|
-
}
|
|
251
|
-
settle();
|
|
252
|
-
return live;
|
|
162
|
+
return myComponent;
|
|
253
163
|
}
|
|
254
164
|
|
|
255
165
|
function UserProfile(props: UserProfileProps) {
|
|
@@ -262,30 +172,96 @@ function UserProfile(props: UserProfileProps) {
|
|
|
262
172
|
user_id ||
|
|
263
173
|
originUserId;
|
|
264
174
|
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
+
}, []);
|
|
270
199
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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);
|
|
274
208
|
|
|
275
|
-
|
|
276
|
-
|
|
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]);
|
|
277
241
|
|
|
278
|
-
|
|
242
|
+
useEffect(() => {
|
|
243
|
+
if (scriptStatus !== 'ready') return;
|
|
244
|
+
|
|
245
|
+
void fetchData();
|
|
246
|
+
}, [fetchData, scriptStatus]);
|
|
247
|
+
|
|
248
|
+
if (error) {
|
|
279
249
|
return (
|
|
280
|
-
<Card
|
|
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
|
+
>
|
|
281
254
|
<ErrorImage />
|
|
255
|
+
{/* <Button variant="outline" className="border-red-500 text-black hover:bg-red-50" onClick={fetchData}>
|
|
256
|
+
加载失败 请重试
|
|
257
|
+
</Button> */}
|
|
282
258
|
<div>
|
|
283
259
|
<span className="text-sm">加载失败 请</span>
|
|
284
260
|
<Button
|
|
285
261
|
size="sm"
|
|
286
262
|
variant="ghost"
|
|
287
263
|
className="pr-1 pl-1 text-primary! not-disabled:hover:bg-primary/10 not-disabled:hover:text-primary/90 focus-visible:bg-primary/20"
|
|
288
|
-
onClick={
|
|
264
|
+
onClick={fetchData}
|
|
289
265
|
>
|
|
290
266
|
重试
|
|
291
267
|
</Button>
|
|
@@ -293,269 +269,42 @@ function UserProfile(props: UserProfileProps) {
|
|
|
293
269
|
</Card>
|
|
294
270
|
);
|
|
295
271
|
}
|
|
296
|
-
|
|
297
|
-
// isPending 只在「无缓存且首次请求中」为真。
|
|
298
|
-
// 命中缓存时直接跳过 loading,卡片零延迟出现,后台静默 revalidate。
|
|
299
|
-
if (isPending || !data) {
|
|
300
|
-
return (
|
|
301
|
-
<Card className="flex h-130 w-80 flex-col items-center justify-center gap-6 border-0 p-0">
|
|
302
|
-
<Spinner className="size-8 animate-spin text-primary" />
|
|
303
|
-
</Card>
|
|
304
|
-
);
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// 业务仓的 tsconfig 继承自 fullstack-presets,是 strictNullChecks: false。
|
|
308
|
-
// TS 在该模式下不做判别联合窄化(本地现写的同形状联合一样窄化失败),
|
|
309
|
-
// 所以这里必须显式断言分支类型,不能简化成 data.userProfileInfo。
|
|
310
|
-
if (!data.useLarkCard) {
|
|
311
|
-
const { userProfileInfo } = data as SimpleProfileData;
|
|
312
|
-
return <SimpleUserProfile userProfileInfo={userProfileInfo} />;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
const { larkCardParam } = data as OfficialProfileData;
|
|
316
|
-
// key 绑 openId:切换到另一个人时整个重挂,备用卡引用、文字基线、loading
|
|
317
|
-
// 状态一并重置。否则 spareRef 的 `??=` 会把上一个人的卡片留下来复用。
|
|
318
272
|
return (
|
|
319
|
-
<
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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>
|
|
323
279
|
);
|
|
324
280
|
}
|
|
325
281
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
* 人员数据由 `<tt-profile>` 自己拉取,不经过我们的 state,因此这里无法做
|
|
330
|
-
* 字段级的 stale-while-revalidate。退而求其次:缓存整个已渲染的 DOM,
|
|
331
|
-
* 命中即刻展示;内容陈旧时在离屏容器里渲染一份新的,就绪后整体替换,
|
|
332
|
-
* 这样换新过程中用户看到的始终是旧卡片,不会出现骨架屏。
|
|
333
|
-
*/
|
|
334
|
-
function LarkProfileCard(props: { readonly larkCardParam: LarkCardParam }) {
|
|
335
|
-
const { larkCardParam } = props;
|
|
336
|
-
const { needRedirect, redirectURL, targetLarkOpenID } = larkCardParam;
|
|
337
|
-
|
|
338
|
-
// 「打开后更新」渲染出的元素由本实例独占 —— 它会被 attach 进本实例的容器,
|
|
339
|
-
// 取消时也由本实例回收。所以去重必须按实例隔离:ensureRender 的 key 是模块级的,
|
|
340
|
-
// 同一个人的两个实例共享一次渲染的话,两边会拿到同一个 DOM 元素,
|
|
341
|
-
// 后挂载的那个会把节点从前一个容器里抢走(前者变空白),先卸载的那个
|
|
342
|
-
// 还会把另一个正在显示的节点 remove 掉。
|
|
343
|
-
//
|
|
344
|
-
// 备用卡池仍然按人共享 —— 那条路径是 takeSpare 独占领取,不存在同一元素
|
|
345
|
-
// 被两个实例同时持有的问题。
|
|
346
|
-
const instanceId = useId();
|
|
347
|
-
|
|
348
|
-
const hostRef = useRef<HTMLDivElement>(null);
|
|
349
|
-
// 初值在渲染期就定下来:有备用卡就不该出现转圈。effect 在首次绘制之后
|
|
350
|
-
// 才跑,若初值恒为 true,命中缓存也会先闪一帧 loading。
|
|
351
|
-
const [showSpinner, setShowSpinner] = useState(() =>
|
|
352
|
-
!hasSpare(targetLarkOpenID),
|
|
353
|
-
);
|
|
354
|
-
const [renderFailed, setRenderFailed] = useState(false);
|
|
355
|
-
const [retryToken, setRetryToken] = useState(0);
|
|
356
|
-
|
|
357
|
-
// 这次挂载取到的备用卡。takeSpare 是「取走」语义,effect 可能重复执行,
|
|
358
|
-
// 不记在 ref 里第二次就拿不到,会误判成没有缓存而走完整 loading。
|
|
359
|
-
const spareRef = useRef<Element | null>(null);
|
|
360
|
-
// 当前挂在容器里的那一张。卸载清理要用它,而那时 hostRef 可能已被置空。
|
|
361
|
-
const shownRef = useRef<Element | null>(null);
|
|
362
|
-
// 挂上那一刻、本人那一屏的文字。卸载时比对,判断用户有没有改过本人的数据。
|
|
363
|
-
const attachedTextRef = useRef<string>('');
|
|
364
|
-
// 组件是否还活着。异步回调的归属是「组件实例」而不是「某一次 effect 执行」,
|
|
365
|
-
// 所以用它而不是 effect 内的 cancelled 标志 —— 否则依赖一变化,正在途中的
|
|
366
|
-
// 渲染结果就会被当成已取消而丢弃。
|
|
367
|
-
const aliveRef = useRef(true);
|
|
368
|
-
// 已经为哪一轮 retryToken 启动过渲染,保证依赖变化时不重复发起。
|
|
369
|
-
const startedRef = useRef(-1);
|
|
370
|
-
|
|
371
|
-
const scriptOptions = useMemo(
|
|
372
|
-
() => ({ onloadCallback: registerLarkAuthHandler }),
|
|
373
|
-
[],
|
|
374
|
-
);
|
|
375
|
-
const scriptStatus = useExternalScript(LARK_SDK_URL, scriptOptions);
|
|
376
|
-
|
|
377
|
-
// 卸载清理需要的值走 ref,避免为了它们把依赖写宽
|
|
378
|
-
const teardownRef = useRef({ param: larkCardParam, scriptReady: false });
|
|
379
|
-
teardownRef.current = {
|
|
380
|
-
param: larkCardParam,
|
|
381
|
-
scriptReady: scriptStatus === 'ready',
|
|
382
|
-
};
|
|
282
|
+
function LoadingComponent() {
|
|
283
|
+
return <Spinner className="size-8 animate-spin text-primary" />;
|
|
284
|
+
}
|
|
383
285
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
}
|
|
389
|
-
}, [needRedirect, redirectURL]);
|
|
390
|
-
|
|
391
|
-
/**
|
|
392
|
-
* 只在**真正卸载**时回收。
|
|
393
|
-
*
|
|
394
|
-
* 不能放进主 effect 的 cleanup —— 那个 cleanup 在任何依赖变化时都会跑
|
|
395
|
-
* (scriptStatus 从 loading 变 ready、react-query 刷新导致 larkCardParam
|
|
396
|
-
* 换引用,都是必经路径)。在那里 remove 掉正在显示的卡片,会造成可见的
|
|
397
|
-
* 闪白回退,而且被摘掉的若是「打开后更新」那张,它既不在池里也不在容器里,
|
|
398
|
-
* 直接成了孤儿。
|
|
399
|
-
*/
|
|
400
|
-
useEffect(() => {
|
|
401
|
-
aliveRef.current = true;
|
|
402
|
-
return () => {
|
|
403
|
-
aliveRef.current = false;
|
|
404
|
-
|
|
405
|
-
const shown = shownRef.current;
|
|
406
|
-
shownRef.current = null;
|
|
407
|
-
if (!shown) return;
|
|
408
|
-
|
|
409
|
-
const changed = ownProfileText(shown) !== attachedTextRef.current;
|
|
410
|
-
// 用过的那张直接丢弃 —— 它可能被滚动过、点进过别人的名片,
|
|
411
|
-
// 而这些状态有一半在飞书 React 内部,无法可靠恢复
|
|
412
|
-
shown.remove();
|
|
413
|
-
|
|
414
|
-
const { param, scriptReady } = teardownRef.current;
|
|
415
|
-
if (!changed || !scriptReady) return;
|
|
416
|
-
|
|
417
|
-
// 用户在卡片里改过本人的数据(最典型的是编辑个性签名)。已经备好的那张
|
|
418
|
-
// 是改动之前渲染的,内容已过时:丢掉,并立刻重新备一张 —— 只丢不补的话
|
|
419
|
-
// 下次打开会退回完整 loading。带时间戳的 key 绕开去重,确保拿到的是
|
|
420
|
-
// 改动之后重新渲染的结果。
|
|
421
|
-
dropSpare(param.targetLarkOpenID);
|
|
422
|
-
void ensureRender(
|
|
423
|
-
`${param.targetLarkOpenID}:spare:${String(Date.now())}`,
|
|
424
|
-
async () => {
|
|
425
|
-
const live = await renderCard(param, null);
|
|
426
|
-
if (live) putSpare(param.targetLarkOpenID, live);
|
|
427
|
-
return live;
|
|
428
|
-
},
|
|
429
|
-
).catch((error: unknown) => {
|
|
430
|
-
logger.info('re-prepare spare lark profile failed', error);
|
|
431
|
-
});
|
|
432
|
-
};
|
|
433
|
-
}, []);
|
|
286
|
+
function BaseUserProfile(props: BaseUserProfileProps) {
|
|
287
|
+
const { useLarkCard } = props;
|
|
288
|
+
if (!useLarkCard) {
|
|
289
|
+
const { userProfileInfo } = props as BaseSimpleProfileProps;
|
|
434
290
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
useEffect(() => {
|
|
438
|
-
if (needRedirect) return;
|
|
439
|
-
|
|
440
|
-
const host = hostRef.current;
|
|
441
|
-
if (!host) return;
|
|
442
|
-
|
|
443
|
-
currentRedirectURL = redirectURL ?? '';
|
|
444
|
-
// 脚本已存在时 useExternalScript 会跳过 onloadCallback,这里补注册一次
|
|
445
|
-
if (scriptStatus === 'ready') registerLarkAuthHandler();
|
|
446
|
-
|
|
447
|
-
const attach = (el: Element | null) => {
|
|
448
|
-
if (!aliveRef.current || !el) return;
|
|
449
|
-
host.replaceChildren(el);
|
|
450
|
-
shownRef.current = el;
|
|
451
|
-
attachedTextRef.current = ownProfileText(el);
|
|
452
|
-
setShowSpinner(false);
|
|
453
|
-
setRenderFailed(false);
|
|
454
|
-
};
|
|
455
|
-
|
|
456
|
-
if (!shownRef.current) {
|
|
457
|
-
// 备用卡是渲染好后一直停在离屏、从没显示过的,所以必然是初始态,
|
|
458
|
-
// 而且是活元素 —— 挂上即可交互,不是静态快照
|
|
459
|
-
spareRef.current ??= takeSpare(targetLarkOpenID);
|
|
460
|
-
if (spareRef.current) attach(spareRef.current);
|
|
461
|
-
}
|
|
291
|
+
return <SimpleUserProfile userProfileInfo={userProfileInfo} />;
|
|
292
|
+
}
|
|
462
293
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
if (!hadSpare) setShowSpinner(true);
|
|
469
|
-
|
|
470
|
-
// 打开后更新一次:再渲染一张,就绪后换上去,让这次看到的是最新数据。
|
|
471
|
-
// 有备用卡顶着就离屏渲染(用户看不到骨架屏),没有就直接渲进可见容器。
|
|
472
|
-
void ensureRender(
|
|
473
|
-
`${targetLarkOpenID}:update:${instanceId}:${String(retryToken)}`,
|
|
474
|
-
() => renderCard(larkCardParam, hadSpare ? null : host),
|
|
475
|
-
)
|
|
476
|
-
.then((live) => {
|
|
477
|
-
if (!aliveRef.current) {
|
|
478
|
-
// 组件已卸载。元素此时在常驻离屏容器里,既不会被 attach 也不进
|
|
479
|
-
// 受 maxEntries 约束的池 —— 放着不管会随反复开关一直累积。
|
|
480
|
-
live?.remove();
|
|
481
|
-
return;
|
|
482
|
-
}
|
|
483
|
-
if (live) {
|
|
484
|
-
attach(live);
|
|
485
|
-
return;
|
|
486
|
-
}
|
|
487
|
-
// 渲染没能就绪。已经挂着备用卡就保持现状;什么都没有的话必须收掉
|
|
488
|
-
// loading 并给出重试入口,否则会一直转圈。
|
|
489
|
-
if (!shownRef.current) {
|
|
490
|
-
setShowSpinner(false);
|
|
491
|
-
setRenderFailed(true);
|
|
492
|
-
}
|
|
493
|
-
})
|
|
494
|
-
.catch((error: unknown) => {
|
|
495
|
-
logger.info('update lark profile failed', error);
|
|
496
|
-
if (!aliveRef.current) return;
|
|
497
|
-
setShowSpinner(false);
|
|
498
|
-
if (!shownRef.current) setRenderFailed(true);
|
|
499
|
-
});
|
|
500
|
-
|
|
501
|
-
// 立刻开始备下一张。必须在「打开」时做而不是「关闭」时 —— 真实环境渲染
|
|
502
|
-
// 一张要走鉴权、render、网关取数、图片加载,几秒起步;放在关闭时做的话,
|
|
503
|
-
// 用户点快一点下次打开就还没备好,又掉回完整 loading。
|
|
504
|
-
if (!hasSpare(targetLarkOpenID)) {
|
|
505
|
-
void ensureRender(`${targetLarkOpenID}:spare`, async () => {
|
|
506
|
-
const live = await renderCard(larkCardParam, null);
|
|
507
|
-
if (live) putSpare(targetLarkOpenID, live);
|
|
508
|
-
return live;
|
|
509
|
-
}).catch((error: unknown) => {
|
|
510
|
-
logger.info('prepare spare lark profile failed', error);
|
|
511
|
-
});
|
|
294
|
+
const { larkCardParam } = props;
|
|
295
|
+
const { needRedirect, redirectURL } = larkCardParam;
|
|
296
|
+
if (needRedirect) {
|
|
297
|
+
if (!redirectURL) {
|
|
298
|
+
return null;
|
|
512
299
|
}
|
|
513
|
-
}, [
|
|
514
|
-
instanceId,
|
|
515
|
-
larkCardParam,
|
|
516
|
-
needRedirect,
|
|
517
|
-
redirectURL,
|
|
518
|
-
retryToken,
|
|
519
|
-
scriptStatus,
|
|
520
|
-
targetLarkOpenID,
|
|
521
|
-
]);
|
|
522
|
-
|
|
523
|
-
const handleRetryRender = useCallback(() => {
|
|
524
|
-
setRenderFailed(false);
|
|
525
|
-
setShowSpinner(true);
|
|
526
|
-
setRetryToken((n) => n + 1);
|
|
527
|
-
}, []);
|
|
528
|
-
|
|
529
|
-
if (needRedirect) return null;
|
|
530
300
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
{showSpinner && (
|
|
535
|
-
<Spinner className="absolute size-8 animate-spin text-primary" />
|
|
536
|
-
)}
|
|
537
|
-
{renderFailed && (
|
|
538
|
-
<div className="absolute flex flex-col items-center gap-4">
|
|
539
|
-
<ErrorImage />
|
|
540
|
-
<div>
|
|
541
|
-
<span className="text-sm">加载失败 请</span>
|
|
542
|
-
<Button
|
|
543
|
-
size="sm"
|
|
544
|
-
variant="ghost"
|
|
545
|
-
className="pr-1 pl-1 text-primary! not-disabled:hover:bg-primary/10 not-disabled:hover:text-primary/90 focus-visible:bg-primary/20"
|
|
546
|
-
onClick={handleRetryRender}
|
|
547
|
-
>
|
|
548
|
-
重试
|
|
549
|
-
</Button>
|
|
550
|
-
</div>
|
|
551
|
-
</div>
|
|
552
|
-
)}
|
|
553
|
-
</Card>
|
|
554
|
-
);
|
|
301
|
+
globalThis.location.replace(redirectURL);
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
555
304
|
}
|
|
556
305
|
|
|
557
306
|
function SimpleUserProfile(props: {
|
|
558
|
-
readonly userProfileInfo:
|
|
307
|
+
readonly userProfileInfo: BaseSimpleProfileProps['userProfileInfo'];
|
|
559
308
|
}) {
|
|
560
309
|
const { userProfileInfo } = props;
|
|
561
310
|
|
|
@@ -16,18 +16,6 @@ import {
|
|
|
16
16
|
PopoverTrigger,
|
|
17
17
|
} from '@client/src/components/ui/popover';
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
* 阻止 Popover 打开时的自动聚焦。
|
|
21
|
-
*
|
|
22
|
-
* Radix 的 FocusScope 在弹层挂载时会把焦点给容器里第一个可聚焦元素。人员卡片
|
|
23
|
-
* 挂载那一刻内容就已经在了(飞书名片里有「消息」等按钮),于是一打开就有个
|
|
24
|
-
* 按钮带上焦点环。这是张只读预览卡,焦点应该留在触发器上 —— Radix 自己的
|
|
25
|
-
* HoverCard 也是这么处理的。
|
|
26
|
-
*/
|
|
27
|
-
const handleProfileOpenAutoFocus = (event: Event) => {
|
|
28
|
-
event.preventDefault();
|
|
29
|
-
};
|
|
30
|
-
|
|
31
19
|
export type UserPillProps = {
|
|
32
20
|
/**
|
|
33
21
|
* 用户 ID,用于 UserProfile 展示
|
|
@@ -129,7 +117,6 @@ export const UserPill: React.FC<UserPillProps> = ({
|
|
|
129
117
|
side="bottom"
|
|
130
118
|
align="start"
|
|
131
119
|
sideOffset={8}
|
|
132
|
-
onOpenAutoFocus={handleProfileOpenAutoFocus}
|
|
133
120
|
className="w-80 p-0"
|
|
134
121
|
>
|
|
135
122
|
<UserProfile userId={userId} accountType={accountType} />
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"name": "fullstack-nestjs-template",
|
|
9
9
|
"version": "2.3.0",
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@lark-apaas/fullstack-nestjs-core": "^1.1.
|
|
11
|
+
"@lark-apaas/fullstack-nestjs-core": "^1.1.69",
|
|
12
12
|
"@nestjs/axios": "^4.0.1",
|
|
13
13
|
"@nestjs/cache-manager": "^3.1.3",
|
|
14
14
|
"@nestjs/common": "^10.4.20",
|
|
@@ -1110,9 +1110,9 @@
|
|
|
1110
1110
|
"license": "MIT"
|
|
1111
1111
|
},
|
|
1112
1112
|
"node_modules/@grpc/grpc-js": {
|
|
1113
|
-
"version": "1.14.
|
|
1114
|
-
"resolved": "https://registry.npmmirror.com/@grpc/grpc-js/-/grpc-js-1.14.
|
|
1115
|
-
"integrity": "sha512-
|
|
1113
|
+
"version": "1.14.6",
|
|
1114
|
+
"resolved": "https://registry.npmmirror.com/@grpc/grpc-js/-/grpc-js-1.14.6.tgz",
|
|
1115
|
+
"integrity": "sha512-SgC/B3/VZ8lTyOZ4Cwk6f1skWhmkNAaVfiX90qd5u1rRXGKk1//8FTUEN9GvOUq7rfWIqAYkA3CoyumYVpOS6g==",
|
|
1116
1116
|
"license": "Apache-2.0",
|
|
1117
1117
|
"dependencies": {
|
|
1118
1118
|
"@grpc/proto-loader": "^0.8.0",
|
|
@@ -1546,9 +1546,9 @@
|
|
|
1546
1546
|
}
|
|
1547
1547
|
},
|
|
1548
1548
|
"node_modules/@lark-apaas/cache-service": {
|
|
1549
|
-
"version": "0.1.
|
|
1550
|
-
"resolved": "https://registry.npmmirror.com/@lark-apaas/cache-service/-/cache-service-0.1.
|
|
1551
|
-
"integrity": "sha512-
|
|
1549
|
+
"version": "0.1.3",
|
|
1550
|
+
"resolved": "https://registry.npmmirror.com/@lark-apaas/cache-service/-/cache-service-0.1.3.tgz",
|
|
1551
|
+
"integrity": "sha512-PG6h0IixK6J1aMUOu8g5vR2Zgis7qq2TBv1XxA/tGvwcxaE23ytLCpQq7+cwcSLtXFji9O82iOaAkfoe4bpRWQ==",
|
|
1552
1552
|
"license": "MIT",
|
|
1553
1553
|
"dependencies": {
|
|
1554
1554
|
"@keyv/redis": "^5.1.6",
|
|
@@ -1562,8 +1562,8 @@
|
|
|
1562
1562
|
},
|
|
1563
1563
|
"peerDependencies": {
|
|
1564
1564
|
"@lark-apaas/nestjs-common": "^0.1.9",
|
|
1565
|
-
"@lark-apaas/nestjs-logger": "^1.0.
|
|
1566
|
-
"@lark-apaas/nestjs-observable": "^0.0.
|
|
1565
|
+
"@lark-apaas/nestjs-logger": "^1.0.19",
|
|
1566
|
+
"@lark-apaas/nestjs-observable": "^0.0.13",
|
|
1567
1567
|
"@nestjs/common": "^10.0.0",
|
|
1568
1568
|
"@nestjs/core": "^10.0.0"
|
|
1569
1569
|
}
|
|
@@ -1697,23 +1697,23 @@
|
|
|
1697
1697
|
}
|
|
1698
1698
|
},
|
|
1699
1699
|
"node_modules/@lark-apaas/fullstack-nestjs-core": {
|
|
1700
|
-
"version": "1.1.
|
|
1701
|
-
"resolved": "https://registry.npmmirror.com/@lark-apaas/fullstack-nestjs-core/-/fullstack-nestjs-core-1.1.
|
|
1702
|
-
"integrity": "sha512-
|
|
1700
|
+
"version": "1.1.69",
|
|
1701
|
+
"resolved": "https://registry.npmmirror.com/@lark-apaas/fullstack-nestjs-core/-/fullstack-nestjs-core-1.1.69.tgz",
|
|
1702
|
+
"integrity": "sha512-SBSBDVy0lSXdgVyTEMUnkLPig0jhdDEM8f0dU8KsI3r293dW579ZRNFtb7azkwY/ouo7TyKhVFFHn2DpOB2Yrg==",
|
|
1703
1703
|
"license": "MIT",
|
|
1704
1704
|
"dependencies": {
|
|
1705
|
-
"@lark-apaas/cache-service": "^0.1.
|
|
1705
|
+
"@lark-apaas/cache-service": "^0.1.3",
|
|
1706
1706
|
"@lark-apaas/file-service": "^0.1.2",
|
|
1707
|
-
"@lark-apaas/http-client": "^0.1.
|
|
1707
|
+
"@lark-apaas/http-client": "^0.1.9",
|
|
1708
1708
|
"@lark-apaas/nestjs-authnpaas": "^1.0.9",
|
|
1709
1709
|
"@lark-apaas/nestjs-authzpaas": "^0.1.16",
|
|
1710
1710
|
"@lark-apaas/nestjs-capability": "^0.1.14",
|
|
1711
1711
|
"@lark-apaas/nestjs-common": "^0.1.9",
|
|
1712
|
-
"@lark-apaas/nestjs-datapaas": "^1.0.
|
|
1712
|
+
"@lark-apaas/nestjs-datapaas": "^1.0.23",
|
|
1713
1713
|
"@lark-apaas/nestjs-http-forwarder": "^0.1.3",
|
|
1714
|
-
"@lark-apaas/nestjs-logger": "^1.0.
|
|
1714
|
+
"@lark-apaas/nestjs-logger": "^1.0.19",
|
|
1715
1715
|
"@lark-apaas/nestjs-mcp": "^0.1.2",
|
|
1716
|
-
"@lark-apaas/nestjs-observable": "^0.0.
|
|
1716
|
+
"@lark-apaas/nestjs-observable": "^0.0.13",
|
|
1717
1717
|
"@lark-apaas/nestjs-openapi-devtools": "^1.0.10",
|
|
1718
1718
|
"@lark-apaas/nestjs-trigger": "^0.0.3",
|
|
1719
1719
|
"@nestjs/axios": "^4.0.1",
|
|
@@ -1773,9 +1773,9 @@
|
|
|
1773
1773
|
}
|
|
1774
1774
|
},
|
|
1775
1775
|
"node_modules/@lark-apaas/http-client": {
|
|
1776
|
-
"version": "0.1.
|
|
1777
|
-
"resolved": "https://registry.npmmirror.com/@lark-apaas/http-client/-/http-client-0.1.
|
|
1778
|
-
"integrity": "sha512-
|
|
1776
|
+
"version": "0.1.9",
|
|
1777
|
+
"resolved": "https://registry.npmmirror.com/@lark-apaas/http-client/-/http-client-0.1.9.tgz",
|
|
1778
|
+
"integrity": "sha512-mcxd4skrQgMu6AYuy2TFe9Vvx9OSjAi3quONYLSEFq+LXe1IHaGkveuM0XlXBPxku5/pM1ryfIFP6fRImveQ2A==",
|
|
1779
1779
|
"license": "MIT",
|
|
1780
1780
|
"engines": {
|
|
1781
1781
|
"node": ">=18.0.0"
|
|
@@ -1887,9 +1887,9 @@
|
|
|
1887
1887
|
}
|
|
1888
1888
|
},
|
|
1889
1889
|
"node_modules/@lark-apaas/nestjs-datapaas": {
|
|
1890
|
-
"version": "1.0.
|
|
1891
|
-
"resolved": "https://registry.npmmirror.com/@lark-apaas/nestjs-datapaas/-/nestjs-datapaas-1.0.
|
|
1892
|
-
"integrity": "sha512-
|
|
1890
|
+
"version": "1.0.23",
|
|
1891
|
+
"resolved": "https://registry.npmmirror.com/@lark-apaas/nestjs-datapaas/-/nestjs-datapaas-1.0.23.tgz",
|
|
1892
|
+
"integrity": "sha512-7xCye2jg5O0qiw+l6L4j9HfrN5dbBcUTlJ1LhniX4RHi9Ds7tZlhLhsO+nqZrWoK9GXDc0jdZ61E7EMwHduJ4w==",
|
|
1893
1893
|
"license": "MIT",
|
|
1894
1894
|
"dependencies": {
|
|
1895
1895
|
"@types/pg": "^8.10.9",
|
|
@@ -1901,7 +1901,7 @@
|
|
|
1901
1901
|
},
|
|
1902
1902
|
"peerDependencies": {
|
|
1903
1903
|
"@lark-apaas/nestjs-common": "^0.1.9",
|
|
1904
|
-
"@lark-apaas/nestjs-observable": "^0.0.
|
|
1904
|
+
"@lark-apaas/nestjs-observable": "^0.0.13",
|
|
1905
1905
|
"@nestjs/common": "^10.0.0",
|
|
1906
1906
|
"@nestjs/core": "^10.0.0",
|
|
1907
1907
|
"drizzle-orm": "0.44.6"
|
|
@@ -1926,13 +1926,13 @@
|
|
|
1926
1926
|
}
|
|
1927
1927
|
},
|
|
1928
1928
|
"node_modules/@lark-apaas/nestjs-logger": {
|
|
1929
|
-
"version": "1.0.
|
|
1930
|
-
"resolved": "https://registry.npmmirror.com/@lark-apaas/nestjs-logger/-/nestjs-logger-1.0.
|
|
1931
|
-
"integrity": "sha512-
|
|
1929
|
+
"version": "1.0.19",
|
|
1930
|
+
"resolved": "https://registry.npmmirror.com/@lark-apaas/nestjs-logger/-/nestjs-logger-1.0.19.tgz",
|
|
1931
|
+
"integrity": "sha512-AGuEd0NiUS1TEywHV8TeG+KBZ0QoMbVuCi6exjIzIsHoSsGiy5fImAt5LV1osAG/OHYrdDWy1zYGZBVhUNYAaw==",
|
|
1932
1932
|
"license": "MIT",
|
|
1933
1933
|
"dependencies": {
|
|
1934
1934
|
"@lark-apaas/nestjs-common": "^0.1.9",
|
|
1935
|
-
"@lark-apaas/nestjs-observable": "^0.0.
|
|
1935
|
+
"@lark-apaas/nestjs-observable": "^0.0.13",
|
|
1936
1936
|
"pino": "^10.0.0",
|
|
1937
1937
|
"rotating-file-stream": "^3.2.9"
|
|
1938
1938
|
},
|
|
@@ -1965,13 +1965,13 @@
|
|
|
1965
1965
|
}
|
|
1966
1966
|
},
|
|
1967
1967
|
"node_modules/@lark-apaas/nestjs-observable": {
|
|
1968
|
-
"version": "0.0.
|
|
1969
|
-
"resolved": "https://registry.npmmirror.com/@lark-apaas/nestjs-observable/-/nestjs-observable-0.0.
|
|
1970
|
-
"integrity": "sha512-
|
|
1968
|
+
"version": "0.0.13",
|
|
1969
|
+
"resolved": "https://registry.npmmirror.com/@lark-apaas/nestjs-observable/-/nestjs-observable-0.0.13.tgz",
|
|
1970
|
+
"integrity": "sha512-HzrPsE1QdNRqk2PxArOazRJWkJCP5Y6IMnhrJWVCnQoPCyDewUpBAoU+15eqSgaXYOONrDiU3Dm9RfCdYt3w8A==",
|
|
1971
1971
|
"license": "MIT",
|
|
1972
1972
|
"dependencies": {
|
|
1973
1973
|
"@lark-apaas/nestjs-common": "^0.1.9",
|
|
1974
|
-
"@lark-apaas/observable": "^1.0.
|
|
1974
|
+
"@lark-apaas/observable": "^1.0.7"
|
|
1975
1975
|
},
|
|
1976
1976
|
"peerDependencies": {
|
|
1977
1977
|
"@nestjs/common": "^10.4.20",
|
|
@@ -2011,9 +2011,9 @@
|
|
|
2011
2011
|
}
|
|
2012
2012
|
},
|
|
2013
2013
|
"node_modules/@lark-apaas/observable": {
|
|
2014
|
-
"version": "1.0.
|
|
2015
|
-
"resolved": "https://registry.npmmirror.com/@lark-apaas/observable/-/observable-1.0.
|
|
2016
|
-
"integrity": "sha512-
|
|
2014
|
+
"version": "1.0.7",
|
|
2015
|
+
"resolved": "https://registry.npmmirror.com/@lark-apaas/observable/-/observable-1.0.7.tgz",
|
|
2016
|
+
"integrity": "sha512-PcUdzLmDTQ7VcP3mcN1IijT68BSVkvGD300DVOhuzCX3d0kPihAtR0khTacRoaiwPlUQdFhhtctKB3KRhKx2+g==",
|
|
2017
2017
|
"license": "MIT",
|
|
2018
2018
|
"dependencies": {
|
|
2019
2019
|
"@opentelemetry/api": "^1.9.0",
|
|
@@ -10999,9 +10999,9 @@
|
|
|
10999
10999
|
}
|
|
11000
11000
|
},
|
|
11001
11001
|
"node_modules/cjs-module-lexer": {
|
|
11002
|
-
"version": "2.
|
|
11003
|
-
"resolved": "https://registry.npmmirror.com/cjs-module-lexer/-/cjs-module-lexer-2.
|
|
11004
|
-
"integrity": "sha512-
|
|
11002
|
+
"version": "2.3.0",
|
|
11003
|
+
"resolved": "https://registry.npmmirror.com/cjs-module-lexer/-/cjs-module-lexer-2.3.0.tgz",
|
|
11004
|
+
"integrity": "sha512-lmNyBzi6iYiqyrExTctfmjd8tInx+dHu9PBomTfH5RXS53c27JMFJgzNv5TyAnm3PgGf+MLy0+Ws41/HlVxdKQ==",
|
|
11005
11005
|
"license": "MIT"
|
|
11006
11006
|
},
|
|
11007
11007
|
"node_modules/class-transformer": {
|
|
@@ -18600,9 +18600,9 @@
|
|
|
18600
18600
|
}
|
|
18601
18601
|
},
|
|
18602
18602
|
"node_modules/pino": {
|
|
18603
|
-
"version": "10.
|
|
18604
|
-
"resolved": "https://registry.npmmirror.com/pino/-/pino-10.
|
|
18605
|
-
"integrity": "sha512-
|
|
18603
|
+
"version": "10.4.0",
|
|
18604
|
+
"resolved": "https://registry.npmmirror.com/pino/-/pino-10.4.0.tgz",
|
|
18605
|
+
"integrity": "sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==",
|
|
18606
18606
|
"license": "MIT",
|
|
18607
18607
|
"dependencies": {
|
|
18608
18608
|
"@pinojs/redact": "^0.4.0",
|
|
@@ -18612,7 +18612,7 @@
|
|
|
18612
18612
|
"pino-std-serializers": "^7.0.0",
|
|
18613
18613
|
"process-warning": "^5.0.0",
|
|
18614
18614
|
"quick-format-unescaped": "^4.0.3",
|
|
18615
|
-
"real-require": "^0.
|
|
18615
|
+
"real-require": "^1.0.0",
|
|
18616
18616
|
"safe-stable-stringify": "^2.3.1",
|
|
18617
18617
|
"sonic-boom": "^4.0.1",
|
|
18618
18618
|
"thread-stream": "^4.0.0"
|
|
@@ -18964,9 +18964,9 @@
|
|
|
18964
18964
|
}
|
|
18965
18965
|
},
|
|
18966
18966
|
"node_modules/process-warning": {
|
|
18967
|
-
"version": "5.1.
|
|
18968
|
-
"resolved": "https://registry.npmmirror.com/process-warning/-/process-warning-5.1.
|
|
18969
|
-
"integrity": "sha512-
|
|
18967
|
+
"version": "5.1.1",
|
|
18968
|
+
"resolved": "https://registry.npmmirror.com/process-warning/-/process-warning-5.1.1.tgz",
|
|
18969
|
+
"integrity": "sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==",
|
|
18970
18970
|
"funding": [
|
|
18971
18971
|
{
|
|
18972
18972
|
"type": "github",
|
|
@@ -19931,13 +19931,10 @@
|
|
|
19931
19931
|
}
|
|
19932
19932
|
},
|
|
19933
19933
|
"node_modules/real-require": {
|
|
19934
|
-
"version": "0.
|
|
19935
|
-
"resolved": "https://registry.npmmirror.com/real-require/-/real-require-0.
|
|
19936
|
-
"integrity": "sha512-
|
|
19937
|
-
"license": "MIT"
|
|
19938
|
-
"engines": {
|
|
19939
|
-
"node": ">= 12.13.0"
|
|
19940
|
-
}
|
|
19934
|
+
"version": "1.0.0",
|
|
19935
|
+
"resolved": "https://registry.npmmirror.com/real-require/-/real-require-1.0.0.tgz",
|
|
19936
|
+
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
|
|
19937
|
+
"license": "MIT"
|
|
19941
19938
|
},
|
|
19942
19939
|
"node_modules/recharts": {
|
|
19943
19940
|
"version": "2.15.4",
|
|
@@ -22014,12 +22011,6 @@
|
|
|
22014
22011
|
"node": ">=20"
|
|
22015
22012
|
}
|
|
22016
22013
|
},
|
|
22017
|
-
"node_modules/thread-stream/node_modules/real-require": {
|
|
22018
|
-
"version": "1.0.0",
|
|
22019
|
-
"resolved": "https://registry.npmmirror.com/real-require/-/real-require-1.0.0.tgz",
|
|
22020
|
-
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
|
|
22021
|
-
"license": "MIT"
|
|
22022
|
-
},
|
|
22023
22014
|
"node_modules/throttle-debounce": {
|
|
22024
22015
|
"version": "5.0.2",
|
|
22025
22016
|
"resolved": "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
|
package/template/package.json
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"upgrade": "npx -y @lark-apaas/fullstack-cli sync --disable-gen-openapi"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@lark-apaas/fullstack-nestjs-core": "^1.1.
|
|
31
|
+
"@lark-apaas/fullstack-nestjs-core": "^1.1.69",
|
|
32
32
|
"@nestjs/axios": "^4.0.1",
|
|
33
33
|
"@nestjs/cache-manager": "^3.1.3",
|
|
34
34
|
"@nestjs/common": "^10.4.20",
|
|
@@ -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
|
-
}
|