@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
@@ -0,0 +1,312 @@
1
+ import * as React from 'react';
2
+ import { useLayoutEffect } from 'react';
3
+ import { createPortal } from 'react-dom';
4
+ import { autoUpdate } from '@floating-ui/dom';
5
+ import { LoaderCircle } from 'lucide-react';
6
+ import { DocumentIcon } from './document-icon';
7
+ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
8
+ import { Button } from '@/components/ui/button';
9
+ import { ScrollArea } from '@/components/ui/scroll-area';
10
+ import { cn } from '@/lib/utils';
11
+ import { COMMENT_THEME_CLASS } from './mention-appearance';
12
+ import { mentionKey, safeUrl } from './content';
13
+ import type { MentionOption } from './types';
14
+
15
+ function HighlightedLabel({ label, query }: { label: string; query: string }) {
16
+ const index = query ? label.toLowerCase().indexOf(query.toLowerCase()) : -1;
17
+ if (index < 0) return <>{label}</>;
18
+ return (
19
+ <>
20
+ {label.slice(0, index)}
21
+ <span className="text-primary">
22
+ {label.slice(index, index + query.length)}
23
+ </span>
24
+ {label.slice(index + query.length)}
25
+ </>
26
+ );
27
+ }
28
+
29
+ type Props = {
30
+ container?: HTMLElement;
31
+ anchor: () => DOMRect | null;
32
+ editorElement: HTMLElement;
33
+ id: string;
34
+ items: MentionOption[];
35
+ selected: number;
36
+ query?: string;
37
+ onSelect: (item: MentionOption) => void;
38
+ onHover: (index: number) => void;
39
+ loading: boolean;
40
+ error?: string;
41
+ retry: () => void;
42
+ loadMore: () => void;
43
+ hasMore: boolean;
44
+ };
45
+
46
+ /** Source: miaoda UdMentionPlugin + UD MentionPanel/OptionItem/index.less.
47
+ * hiddenTab=true, footer=false, 360px width, 48px rows, 2px row gap,
48
+ * 8px vertical list padding, 8px row margin, 8px avatar gap.
49
+ */
50
+ export function MentionSuggestions({
51
+ container,
52
+ anchor,
53
+ editorElement,
54
+ id,
55
+ items,
56
+ selected,
57
+ query = '',
58
+ onSelect,
59
+ onHover,
60
+ loading,
61
+ error,
62
+ retry,
63
+ loadMore,
64
+ hasMore,
65
+ }: Props) {
66
+ const panel = React.useRef<HTMLDivElement>(null);
67
+ const initialLoading = loading && !items.length;
68
+ const list = React.useRef<HTMLDivElement>(null);
69
+ useLayoutEffect(() => {
70
+ const element = panel.current;
71
+ if (!element) return;
72
+ const reference = {
73
+ contextElement: editorElement,
74
+ getBoundingClientRect: () =>
75
+ anchor() || editorElement.getBoundingClientRect(),
76
+ };
77
+ const update = () => {
78
+ const rect = reference.getBoundingClientRect();
79
+ const viewport = document.documentElement;
80
+ const width = viewport.clientWidth || window.innerWidth;
81
+ const height = viewport.clientHeight || window.innerHeight;
82
+ const below = height - rect.bottom;
83
+ const above = rect.top;
84
+ // Match getMentionPositionInfo's preferred 417px box, including when the list is short.
85
+ const up = below < 431 && (above >= 431 || above > below);
86
+ const maxHeight = Math.max(64, Math.min(417, (up ? above : below) - 14));
87
+ const boxWidth = Math.min(360, Math.max(0, width - 24));
88
+ element.style.width = `${boxWidth}px`;
89
+ const computed = window.getComputedStyle(element);
90
+ const borderHeight =
91
+ (parseFloat(computed.borderTopWidth) || 0) +
92
+ (parseFloat(computed.borderBottomWidth) || 0);
93
+ // The actual list includes padding, row margins and wrapped status text.
94
+ // Its measured size must fit inside the panel's borders, not underneath them.
95
+ const contentHeight = list.current?.getBoundingClientRect().height || 0;
96
+ const naturalHeight = Math.max(
97
+ 64,
98
+ Math.ceil(contentHeight + borderHeight),
99
+ );
100
+ const boxHeight = Math.min(naturalHeight, maxHeight);
101
+ const preferLeft = width - rect.left < 372 && rect.right >= 372;
102
+ const left = Math.max(
103
+ 12,
104
+ Math.min(
105
+ preferLeft ? rect.right - boxWidth : rect.left,
106
+ width - boxWidth - 12,
107
+ ),
108
+ );
109
+ Object.assign(element.style, {
110
+ left: `${left}px`,
111
+ top: `${up ? Math.max(12, rect.top - boxHeight - 2) : rect.bottom + 2}px`,
112
+ width: `${boxWidth}px`,
113
+ height: `${boxHeight}px`,
114
+ visibility: 'visible',
115
+ });
116
+ };
117
+ const stopPositioning = autoUpdate(reference, element, update);
118
+ const observer =
119
+ typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update);
120
+ if (list.current) observer?.observe(list.current);
121
+ return () => {
122
+ stopPositioning();
123
+ observer?.disconnect();
124
+ };
125
+ }, [
126
+ anchor,
127
+ editorElement,
128
+ container,
129
+ items,
130
+ initialLoading,
131
+ error,
132
+ hasMore,
133
+ query,
134
+ ]);
135
+ useLayoutEffect(() => {
136
+ panel.current
137
+ ?.querySelector('[aria-selected="true"]')
138
+ ?.scrollIntoView({ block: 'nearest' });
139
+ }, [selected, items]);
140
+ return createPortal(
141
+ <div
142
+ ref={panel}
143
+ data-comment-suggestions
144
+ className={cn(
145
+ COMMENT_THEME_CLASS,
146
+ 'fixed z-50 overflow-hidden rounded-[6px] border border-border bg-popover text-popover-foreground shadow-[0_6px_24px_rgba(31,35,41,0.08),0_10px_15px_-3px_rgba(0,0,0,0.1),0_4px_6px_-4px_rgba(0,0,0,0.1)]',
147
+ )}
148
+ style={{
149
+ visibility: 'hidden',
150
+ width: 360,
151
+ minHeight: 64,
152
+ maxHeight: 417,
153
+ }}
154
+ onPointerDown={(event) => event.preventDefault()}
155
+ onWheel={(event) => event.stopPropagation()}
156
+ >
157
+ <span role="status" className="sr-only">
158
+ {loading ? '正在搜索' : `${items.length} 个结果`}
159
+ </span>
160
+ <ScrollArea className="h-full w-full">
161
+ <div
162
+ ref={list}
163
+ id={id}
164
+ role="listbox"
165
+ aria-label="提及搜索结果"
166
+ aria-busy={loading}
167
+ className="py-2"
168
+ >
169
+ {items.map((item, index) => {
170
+ const active = selected === index;
171
+ return (
172
+ <div
173
+ key={mentionKey(item.ref)}
174
+ id={`${id}-${index}`}
175
+ role="option"
176
+ aria-selected={active}
177
+ onPointerMove={() => {
178
+ if (!active) onHover(index);
179
+ }}
180
+ onClick={() => {
181
+ onSelect(item);
182
+ }}
183
+ className={cn(
184
+ 'mx-2 mb-0.5 flex h-12 cursor-pointer items-center gap-2 rounded-[4px] px-2 py-1',
185
+ active && 'bg-[var(--comment-hover)]',
186
+ !active && 'hover:bg-[var(--comment-hover)]',
187
+ )}
188
+ >
189
+ {item.ref.kind === 'user' ? (
190
+ <Avatar className="size-8 shrink-0">
191
+ <AvatarImage src={safeUrl(item.avatarUrl)} alt="" />
192
+ <AvatarFallback className="bg-primary text-sm text-primary-foreground">
193
+ {Array.from(item.label.trim())[0] || '@'}
194
+ </AvatarFallback>
195
+ </Avatar>
196
+ ) : (
197
+ <DocumentIcon
198
+ refValue={item.ref}
199
+ metadata={item.document}
200
+ variant="option"
201
+ />
202
+ )}
203
+ <div className="min-w-0 flex-1">
204
+ <div
205
+ className="truncate text-sm font-normal leading-[22px] text-foreground"
206
+ title={item.label}
207
+ >
208
+ <HighlightedLabel label={item.label} query={query} />
209
+ {item.ref.kind === 'document' && item.isExternal ? (
210
+ <span className="ml-1 rounded-[2px] border border-border px-1 text-xs text-subtle-foreground">
211
+ 外部
212
+ </span>
213
+ ) : null}
214
+ </div>
215
+ {item.description ? (
216
+ <div
217
+ className="truncate text-xs leading-5 text-muted-foreground"
218
+ title={item.description}
219
+ >
220
+ {item.description}
221
+ </div>
222
+ ) : null}
223
+ </div>
224
+ </div>
225
+ );
226
+ })}
227
+ {initialLoading ? (
228
+ <div role="status" aria-label="正在搜索" className="px-4">
229
+ {[0, 1, 2, 3].map((key) => (
230
+ <div
231
+ key={key}
232
+ aria-hidden
233
+ className="flex h-12 animate-pulse items-center gap-2 py-1"
234
+ >
235
+ <span className="size-8 shrink-0 rounded-full bg-muted" />
236
+ <span className="space-y-1.5">
237
+ <span className="block h-3.5 w-[70px] rounded-md bg-muted" />
238
+ <span className="block h-3.5 w-[200px] max-w-full rounded-md bg-muted" />
239
+ </span>
240
+ </div>
241
+ ))}
242
+ </div>
243
+ ) : null}
244
+ {!items.length && !loading && !error ? (
245
+ <div
246
+ role="status"
247
+ className="px-4 py-4 text-sm leading-[22px] text-foreground"
248
+ >
249
+ {query ? '没有匹配结果,换个关键词试试吧' : '列表为空'}
250
+ </div>
251
+ ) : null}
252
+ {error ? (
253
+ <div role="alert" className="px-4 py-4 text-sm leading-[22px]">
254
+ <button
255
+ type="button"
256
+ className="inline text-left"
257
+ disabled={loading}
258
+ onClick={retry}
259
+ title="点击重新加载"
260
+ >
261
+ <span
262
+ aria-hidden
263
+ className="mr-2 inline-block not-italic [line-height:0]"
264
+ style={{ color: 'var(--comment-warning)' }}
265
+ >
266
+ <svg
267
+ aria-hidden
268
+ viewBox="0 0 24 24"
269
+ fill="none"
270
+ style={{
271
+ width: '1em',
272
+ height: '1em',
273
+ display: 'inline-block',
274
+ verticalAlign: '-0.14em',
275
+ }}
276
+ >
277
+ <path
278
+ d="M23 12c0 6.075-4.925 11-11 11S1 18.075 1 12 5.925 1 12 1s11 4.925 11 11ZM12 7a1 1 0 0 0-1 1v5a1 1 0 1 0 2 0V8a1 1 0 0 0-1-1Zm0 8a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"
279
+ fill="currentColor"
280
+ />
281
+ </svg>
282
+ </span>
283
+ 加载失败,请检查网络后重试
284
+ </button>
285
+ </div>
286
+ ) : null}
287
+ {hasMore ? (
288
+ <Button
289
+ type="button"
290
+ variant="ghost"
291
+ size="sm"
292
+ className="mx-3 my-2 text-primary"
293
+ disabled={loading}
294
+ onClick={loadMore}
295
+ >
296
+ 加载更多
297
+ </Button>
298
+ ) : null}
299
+ </div>
300
+ </ScrollArea>
301
+ {loading && items.length ? (
302
+ <div className="absolute inset-0 flex items-center justify-center bg-popover/60">
303
+ <LoaderCircle
304
+ aria-label="正在搜索"
305
+ className="size-5 animate-spin text-primary"
306
+ />
307
+ </div>
308
+ ) : null}
309
+ </div>,
310
+ container || document.body,
311
+ );
312
+ }
@@ -0,0 +1,154 @@
1
+ import type { Ref } from 'react';
2
+
3
+ export type MentionRef =
4
+ | { kind: 'user'; id: string; idType: 'apaas-user' }
5
+ | { kind: 'document'; id: string; resourceType: string };
6
+
7
+ export interface MentionOption {
8
+ ref: MentionRef;
9
+ label: string;
10
+ avatarUrl?: string;
11
+ href?: string;
12
+ description?: string;
13
+ document?: DocumentMentionMetadata;
14
+ isExternal?: boolean;
15
+ /** Known host-document embed permission; a grey link still opens the access-request page. */
16
+ readable?: boolean;
17
+ }
18
+
19
+ export interface DocumentMentionMetadata {
20
+ subType?: number;
21
+ iconInfo?: string;
22
+ }
23
+
24
+ export type MentionResult =
25
+ | (MentionOption & { status: 'resolved' })
26
+ | { ref: MentionRef; status: 'not-found' | 'forbidden' | 'error' };
27
+
28
+ export type CommentPart =
29
+ | { type: 'text'; text: string }
30
+ | { type: 'break' }
31
+ | { type: 'link'; url: string }
32
+ | { type: 'unresolved-mention' }
33
+ | {
34
+ type: 'mention';
35
+ ref: MentionRef;
36
+ label?: string;
37
+ href?: string;
38
+ document?: DocumentMentionMetadata;
39
+ };
40
+
41
+ /** A host supplies authenticated document search and metadata, independently of user IDs. */
42
+ export type CommentDocumentSource = Pick<
43
+ CommentDataSource,
44
+ 'scopeKey' | 'search' | 'resolve' | 'recommend'
45
+ >;
46
+
47
+ export interface CommentValue {
48
+ version: 1;
49
+ content: CommentPart[];
50
+ }
51
+
52
+ /** Confirmed reply.content protocol. Editor metadata is not part of the request. */
53
+ export type CommentApiContent =
54
+ | { type: 'text'; text: string }
55
+ | { type: 'mention'; userID?: number }
56
+ | { type: 'link'; url: string };
57
+
58
+ export interface CommentSubmitPayload {
59
+ content: CommentApiContent[];
60
+ }
61
+
62
+ /** Storage result, not an assumed Base comment image protocol. */
63
+ export interface CommentImageResult {
64
+ id: string;
65
+ url: string;
66
+ filePath?: string;
67
+ bucketId?: string;
68
+ }
69
+
70
+ export interface CommentImage {
71
+ id: string;
72
+ name: string;
73
+ size: number;
74
+ previewUrl: string;
75
+ status: 'uploading' | 'uploaded' | 'error';
76
+ result?: CommentImageResult;
77
+ error?: string;
78
+ }
79
+
80
+ export type CommentImageUploader = (
81
+ file: File,
82
+ options: { signal: AbortSignal },
83
+ ) => Promise<CommentImageResult>;
84
+
85
+ export type MentionKind = 'all' | 'user' | 'document';
86
+
87
+ /** Keep this object stable. Change scopeKey when the user/tenant/app/permissions change. */
88
+ export interface CommentDataSource {
89
+ scopeKey: string;
90
+ /** Bare @ default list, independent of keyword search. Failures must reject. */
91
+ recommend?: (args: {
92
+ kind: MentionKind;
93
+ cursor?: string;
94
+ signal: AbortSignal;
95
+ }) => Promise<{ items: MentionOption[]; nextCursor?: string }>;
96
+ /** Legacy sources without recommend can still handle an empty query here. */
97
+ search(args: {
98
+ query: string;
99
+ kind: MentionKind;
100
+ cursor?: string;
101
+ signal: AbortSignal;
102
+ }): Promise<{ items: MentionOption[]; nextCursor?: string }>;
103
+ resolve(refs: MentionRef[], signal: AbortSignal): Promise<MentionResult[]>;
104
+ }
105
+
106
+ export interface CommentInputProps {
107
+ ref?: Ref<CommentInputHandle>;
108
+ documentKey: string;
109
+ value: CommentValue;
110
+ /** Synchronously set the controlled value; debounce persistence, not this callback. */
111
+ onChange: (value: CommentValue) => void;
112
+ /** A keyboard intent only. The host owns requests, buttons, loading and errors. */
113
+ onSubmitRequest?: () => void;
114
+ disabled?: boolean;
115
+ enableDocuments?: boolean;
116
+ /** Images are attachments below the body, never inline editor nodes. */
117
+ enableImages?: boolean;
118
+ uploadImage?: CommentImageUploader;
119
+ maxImages?: number;
120
+ /** Bytes; defaults to 10 MiB. */
121
+ maxImageSize?: number;
122
+ onImagesChange?: (images: CommentImage[]) => void;
123
+ /** Optional controlled permission intent. The host performs actual grants. */
124
+ grantMentionAccess?: boolean;
125
+ onGrantMentionAccessChange?: (checked: boolean) => void;
126
+ placeholder?: string;
127
+ submitMode?: 'enter' | 'mod-enter';
128
+ className?: string;
129
+ /** Mount the suggestion panel inside a Dialog/Sheet when appropriate. */
130
+ portalContainer?: HTMLElement;
131
+ }
132
+
133
+ export interface CommentInputHandle {
134
+ /** Read the latest wire content. Does not validate whether the host should send. */
135
+ getContent(): CommentApiContent[];
136
+ getImages(): CommentImage[];
137
+ /** Clear the body as an undoable edit; keep the same editing session. */
138
+ clear(): void;
139
+ /** Attachments have their own lifecycle, independent of body undo history. */
140
+ clearImages(): void;
141
+ setEditable(editable: boolean): void;
142
+ focus(): void;
143
+ }
144
+
145
+ /** Internal bridge; the public entry stays available before the editing module has loaded. */
146
+ export interface CommentEditorProps extends Omit<CommentInputProps, 'ref'> {
147
+ editorRef: Ref<CommentInputHandle>;
148
+ }
149
+
150
+ /** The host decides how to display a profile or navigate a reference. */
151
+ export type CommentMentionClickHandler = (
152
+ ref: MentionRef,
153
+ anchor: HTMLElement,
154
+ ) => void;
@@ -0,0 +1,107 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { mentionKey } from './content';
3
+ import type {
4
+ CommentDataSource,
5
+ MentionKind,
6
+ MentionOption,
7
+ } from './types';
8
+
9
+ interface Result {
10
+ key: string;
11
+ items: MentionOption[];
12
+ nextCursor?: string;
13
+ loading: boolean;
14
+ error?: string;
15
+ }
16
+ export function useMentionSearch(
17
+ source: CommentDataSource,
18
+ session: { id: number; query: string } | null,
19
+ kind: MentionKind,
20
+ ) {
21
+ const key = JSON.stringify([
22
+ source.scopeKey,
23
+ session?.id,
24
+ session?.query,
25
+ kind,
26
+ ]);
27
+ const [state, setState] = useState<Result>({
28
+ key: '',
29
+ items: [],
30
+ loading: false,
31
+ });
32
+ const controller = useRef<AbortController | null>(null);
33
+ const currentKey = useRef(key);
34
+ currentKey.current = key;
35
+ const runRef = useRef<(cursor?: string) => void>(() => {});
36
+ useEffect(() => {
37
+ const abort = new AbortController();
38
+ controller.current = abort;
39
+ let pending = false;
40
+ const run = async (cursor?: string) => {
41
+ if (!session || pending) return;
42
+ pending = true;
43
+ setState((previous) => ({
44
+ key,
45
+ items: cursor && previous.key === key ? previous.items : [],
46
+ loading: true,
47
+ }));
48
+ try {
49
+ const args = { kind, cursor, signal: abort.signal };
50
+ const result =
51
+ !session.query && source.recommend
52
+ ? await source.recommend(args)
53
+ : await source.search({ ...args, query: session.query });
54
+ if (abort.signal.aborted || currentKey.current !== key) return;
55
+ setState((previous) => {
56
+ const unique = new Map(
57
+ (cursor ? previous.items : []).map((item) => [
58
+ mentionKey(item.ref),
59
+ item,
60
+ ]),
61
+ );
62
+ for (const item of result.items)
63
+ unique.set(mentionKey(item.ref), item);
64
+ return {
65
+ key,
66
+ items: [...unique.values()],
67
+ nextCursor: result.nextCursor,
68
+ loading: false,
69
+ };
70
+ });
71
+ } catch (error) {
72
+ if (!abort.signal.aborted && currentKey.current === key)
73
+ setState((previous) => ({
74
+ ...previous,
75
+ key,
76
+ loading: false,
77
+ error: error instanceof Error ? error.message : '搜索失败,请重试',
78
+ nextCursor: cursor,
79
+ }));
80
+ } finally {
81
+ pending = false;
82
+ }
83
+ };
84
+ runRef.current = (cursor) => {
85
+ void run(cursor);
86
+ };
87
+ const timer = session
88
+ ? setTimeout(
89
+ () => {
90
+ void run();
91
+ },
92
+ session.query ? 200 : 0,
93
+ )
94
+ : undefined;
95
+ return () => {
96
+ clearTimeout(timer);
97
+ abort.abort();
98
+ };
99
+ }, [source, key]);
100
+ const result =
101
+ state.key === key ? state : { key, items: [], loading: Boolean(session) };
102
+ return {
103
+ ...result,
104
+ loadMore: () => runRef.current(result.nextCursor),
105
+ retry: () => runRef.current(),
106
+ };
107
+ }
@@ -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} />