@baishuyun/ai-design 2.0.40 → 2.0.42
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/.storybook/preview.ts +2 -0
- package/CHANGELOG.md +17 -0
- package/package.json +4 -4
- package/src/components/agent-drawer.tsx +85 -0
- package/src/components/agent-helper/team-wide-form-select/data-adapter.ts +109 -0
- package/src/components/agent-helper/team-wide-form-select/index.tsx +289 -0
- package/src/components/agent-helper/team-wide-form-select/tree-panel.tsx +180 -0
- package/src/components/agent-helper/team-wide-form-select/types.ts +36 -0
- package/src/components/agent-helper/team-wide-form-select/use-form-tree.ts +108 -0
- package/src/components/font-icon.tsx +13 -10
- package/src/components/ui/enhanced-drawer.tsx +134 -0
- package/src/index.ts +18 -0
- package/src/stories/IconfontPicker.stories.tsx +106 -0
- package/src/stories/TeamWideFormSelect.stories.tsx +362 -0
- package/style.css +6 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { useCallback } from 'react';
|
|
2
|
+
import { cn } from '../../../lib/utils';
|
|
3
|
+
import { transCls } from '../../../const/ui';
|
|
4
|
+
import { SearchIcon } from '../../bs-icons';
|
|
5
|
+
import { AppIcon, FontIcon } from '../../font-icon';
|
|
6
|
+
import { useFormTree } from './use-form-tree';
|
|
7
|
+
import type {
|
|
8
|
+
TeamWideFormSelectChangeResult,
|
|
9
|
+
TeamWideFormTreeNode,
|
|
10
|
+
TeamWideFormTreeSection,
|
|
11
|
+
} from './types';
|
|
12
|
+
|
|
13
|
+
// ── Inline Icons(与 app-select / form-select 保持一致) ──────────
|
|
14
|
+
|
|
15
|
+
/** 文件夹/分组图标 */
|
|
16
|
+
const FolderIcon = () => (
|
|
17
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
|
18
|
+
<path
|
|
19
|
+
d="M13.9004 15H2.65039C2.25257 15 1.87104 14.842 1.58973 14.5607C1.30843 14.2794 1.15039 13.8978 1.15039 13.5V4.5C1.15039 4.10218 1.30843 3.72064 1.58973 3.43934C1.87104 3.15804 2.25257 3 2.65039 3H7.15039L8.65039 4.5H13.9004C14.0974 4.5 14.2924 4.5388 14.4744 4.61418C14.6564 4.68956 14.8218 4.80005 14.9611 4.93934C15.1003 5.07863 15.2108 5.24399 15.2862 5.42597C15.3616 5.60796 15.4004 5.80302 15.4004 6H2.65039V13.5L4.25539 7.5H16.0754C16.1927 7.49595 16.3093 7.51947 16.4158 7.56867C16.5223 7.61788 16.6158 7.69139 16.6888 7.78331C16.7617 7.87523 16.8121 7.98298 16.8358 8.0979C16.8595 8.21282 16.856 8.33171 16.8254 8.445L15.3704 13.875C15.2865 14.1999 15.0961 14.4873 14.8296 14.6913C14.5631 14.8952 14.2359 15.0039 13.9004 15Z"
|
|
20
|
+
fill="#6F81BD"
|
|
21
|
+
/>
|
|
22
|
+
</svg>
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
/** 展开/收起箭头 */
|
|
26
|
+
const ToggleIcon = ({ expanded }: { expanded: boolean }) => (
|
|
27
|
+
<svg
|
|
28
|
+
width="18"
|
|
29
|
+
height="18"
|
|
30
|
+
viewBox="0 0 18 18"
|
|
31
|
+
fill="none"
|
|
32
|
+
className={cn('shrink-0', transCls)}
|
|
33
|
+
style={{ transform: expanded ? 'rotate(0deg)' : 'rotate(-90deg)' }}
|
|
34
|
+
>
|
|
35
|
+
<path
|
|
36
|
+
d="M6 8L9 11L12 8"
|
|
37
|
+
stroke="#666666"
|
|
38
|
+
strokeWidth="1.2"
|
|
39
|
+
strokeLinecap="round"
|
|
40
|
+
strokeLinejoin="round"
|
|
41
|
+
/>
|
|
42
|
+
</svg>
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// ── Props ─────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
export interface TeamWideFormTreePanelProps {
|
|
48
|
+
sections: TeamWideFormTreeSection[];
|
|
49
|
+
/** 当前选中的 entryId */
|
|
50
|
+
value?: string;
|
|
51
|
+
onSelect: (result: TeamWideFormSelectChangeResult) => void;
|
|
52
|
+
searchPlaceholder?: string;
|
|
53
|
+
/** 树区最大高度,超出滚动(数字按 px,或任意 CSS 长度如 calc(100vh-60px)) */
|
|
54
|
+
maxHeight?: number | string;
|
|
55
|
+
className?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Component ─────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 全量表单选择的树形主体(搜索 + 树),下拉与抽屉两种交互共用。
|
|
62
|
+
* 视觉对齐 Figma:行高 34px、每级缩进 22px、选中行 bg #EFF0F6。
|
|
63
|
+
*/
|
|
64
|
+
export const TeamWideFormTreePanel = ({
|
|
65
|
+
sections,
|
|
66
|
+
value,
|
|
67
|
+
onSelect,
|
|
68
|
+
searchPlaceholder = '输入应用/表单名称来搜索',
|
|
69
|
+
maxHeight = 400,
|
|
70
|
+
className,
|
|
71
|
+
}: TeamWideFormTreePanelProps) => {
|
|
72
|
+
const {
|
|
73
|
+
searchQuery,
|
|
74
|
+
setSearchQuery,
|
|
75
|
+
filteredSections,
|
|
76
|
+
isExpanded,
|
|
77
|
+
toggleExpanded,
|
|
78
|
+
searching,
|
|
79
|
+
hasResult,
|
|
80
|
+
} = useFormTree(sections);
|
|
81
|
+
|
|
82
|
+
const handleSelect = useCallback(
|
|
83
|
+
(node: TeamWideFormTreeNode) => {
|
|
84
|
+
const hasChildren = node.children && node.children.length > 0;
|
|
85
|
+
// 分组/应用行:切换展开;叶子(表单):选中
|
|
86
|
+
if (hasChildren || node.type !== 'form') {
|
|
87
|
+
if (hasChildren) toggleExpanded(node.id);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (node.data) {
|
|
91
|
+
onSelect({
|
|
92
|
+
appId: node.data.appId,
|
|
93
|
+
entryId: node.data.entryId,
|
|
94
|
+
form: node.data,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
[onSelect, toggleExpanded]
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const renderNodeRow = (node: TeamWideFormTreeNode, depth: number): React.ReactNode => {
|
|
102
|
+
const hasChildren = node.children && node.children.length > 0;
|
|
103
|
+
const expanded = searching || isExpanded(node.id);
|
|
104
|
+
const isSelected = node.type === 'form' && value === node.id;
|
|
105
|
+
const leftPad = depth * 22 + 10; // 10, 32, 54, 78, 102...
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div key={node.id}>
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
onClick={() => handleSelect(node)}
|
|
112
|
+
className={cn(
|
|
113
|
+
'flex items-center gap-[6px] w-full h-[34px] rounded-[4px] text-left pr-[10px] py-[10px]',
|
|
114
|
+
transCls,
|
|
115
|
+
isSelected ? 'bg-[#EFF0F6] text-[#030303]' : 'text-[#030303] hover:bg-[#EFF0F6]'
|
|
116
|
+
)}
|
|
117
|
+
style={{ paddingLeft: `${leftPad}px` }}
|
|
118
|
+
>
|
|
119
|
+
{hasChildren && <ToggleIcon expanded={expanded} />}
|
|
120
|
+
{node.type === 'appGroup' || node.type === 'formGroup' ? <FolderIcon /> : null}
|
|
121
|
+
{node.type === 'app' && node.appIcon != null && <AppIcon name={node.appIcon} />}
|
|
122
|
+
{node.type === 'form' && node.formIcon != null && <FontIcon name={node.formIcon} />}
|
|
123
|
+
<span className="text-[14px] truncate leading-[normal] flex-1 min-w-0">{node.label}</span>
|
|
124
|
+
</button>
|
|
125
|
+
|
|
126
|
+
{hasChildren && expanded && (
|
|
127
|
+
<div>{node.children!.map((child) => renderNodeRow(child, depth + 1))}</div>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div className={cn('flex flex-col gap-[4px]', className)}>
|
|
135
|
+
{/* 搜索框 */}
|
|
136
|
+
<div className="flex items-center gap-[6px] border border-[#E0E0E0] rounded-[4px] h-[30px] px-[10px] py-[5px]">
|
|
137
|
+
<SearchIcon />
|
|
138
|
+
<input
|
|
139
|
+
type="text"
|
|
140
|
+
value={searchQuery}
|
|
141
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
142
|
+
placeholder={searchPlaceholder}
|
|
143
|
+
className="flex-1 outline-none text-[14px] placeholder:text-[#999999] bg-transparent min-w-0 text-[#030303]"
|
|
144
|
+
/>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
{/* 树区(data-vaul-no-drag:滚动交给原生;抽屉侧 modal=false + dismissible=false
|
|
148
|
+
关掉 body 滚动锁与 vaul 拖拽指针捕获,此处无需额外覆盖) */}
|
|
149
|
+
<div
|
|
150
|
+
className="overflow-y-auto"
|
|
151
|
+
style={{ maxHeight, scrollbarWidth: 'none' }}
|
|
152
|
+
data-vaul-no-drag
|
|
153
|
+
>
|
|
154
|
+
{hasResult ? (
|
|
155
|
+
filteredSections.map((section) => (
|
|
156
|
+
<div key={section.key}>
|
|
157
|
+
{/* section 标题(当前应用表单 / 其他应用表单) */}
|
|
158
|
+
{section.title && (
|
|
159
|
+
<div className="flex flex-col justify-between h-[32px] pr-[10px] pt-[9px]">
|
|
160
|
+
<div className="flex items-center gap-[6px]">
|
|
161
|
+
<FolderIcon />
|
|
162
|
+
<span className="text-[12px] text-[#666666] leading-[normal]">
|
|
163
|
+
{section.title}
|
|
164
|
+
</span>
|
|
165
|
+
</div>
|
|
166
|
+
<div className="h-px bg-[#E0E0E0] mt-[7px]" />
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
{section.nodes.map((node) => renderNodeRow(node, 0))}
|
|
170
|
+
</div>
|
|
171
|
+
))
|
|
172
|
+
) : (
|
|
173
|
+
<div className="flex items-center justify-center h-[60px] text-[12px] text-[#999999]">
|
|
174
|
+
{searching ? '未找到相关应用/表单' : '暂无可选表单'}
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { IAppFormListEntry } from '@baishuyun/types';
|
|
2
|
+
|
|
3
|
+
// ── Tree Node ─────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export type TeamWideFormNodeType = 'appGroup' | 'app' | 'formGroup' | 'form';
|
|
6
|
+
|
|
7
|
+
export interface TeamWideFormTreeNode {
|
|
8
|
+
id: string;
|
|
9
|
+
label: string;
|
|
10
|
+
type: TeamWideFormNodeType;
|
|
11
|
+
/** app 节点的图标名(imgbg-bsy-app-N,AppIcon) */
|
|
12
|
+
appIcon?: number | string;
|
|
13
|
+
/** form/formGroup 节点的图标 number(FontIcon) */
|
|
14
|
+
formIcon?: number;
|
|
15
|
+
/** form 叶子节点的原始条目数据 */
|
|
16
|
+
data?: IAppFormListEntry;
|
|
17
|
+
children?: TeamWideFormTreeNode[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ── Section(按当前应用分组展示) ──────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export interface TeamWideFormTreeSection {
|
|
23
|
+
key: 'all' | 'current' | 'other';
|
|
24
|
+
/** 分组标题('当前应用表单' / '其他应用表单');'all' 无标题 */
|
|
25
|
+
title?: string;
|
|
26
|
+
nodes: TeamWideFormTreeNode[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Change Result ─────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export interface TeamWideFormSelectChangeResult {
|
|
32
|
+
appId: string;
|
|
33
|
+
entryId: string;
|
|
34
|
+
/** 选中的原始条目(含 type/icon/hasCoop 等) */
|
|
35
|
+
form: IAppFormListEntry;
|
|
36
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
import type { TeamWideFormTreeNode, TeamWideFormTreeSection } from './types';
|
|
3
|
+
|
|
4
|
+
/** 收集所有含子节点的 id(默认全部展开——树层级深,折叠会隐藏目标表单) */
|
|
5
|
+
function collectExpandableIds(nodes: TeamWideFormTreeNode[]): Set<string> {
|
|
6
|
+
const ids = new Set<string>();
|
|
7
|
+
const walk = (list: TeamWideFormTreeNode[]) => {
|
|
8
|
+
for (const n of list) {
|
|
9
|
+
if (n.children && n.children.length > 0) {
|
|
10
|
+
ids.add(n.id);
|
|
11
|
+
walk(n.children);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
walk(nodes);
|
|
16
|
+
return ids;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 按关键字过滤树:命中自身或任一后代即保留(保留祖先链) */
|
|
20
|
+
function filterTree(
|
|
21
|
+
nodes: TeamWideFormTreeNode[],
|
|
22
|
+
query: string
|
|
23
|
+
): TeamWideFormTreeNode[] {
|
|
24
|
+
const q = query.trim().toLowerCase();
|
|
25
|
+
if (!q) return nodes;
|
|
26
|
+
|
|
27
|
+
const walk = (list: TeamWideFormTreeNode[]): TeamWideFormTreeNode[] => {
|
|
28
|
+
const result: TeamWideFormTreeNode[] = [];
|
|
29
|
+
for (const node of list) {
|
|
30
|
+
const selfMatch = node.label.toLowerCase().includes(q);
|
|
31
|
+
const filteredChildren = node.children ? walk(node.children) : [];
|
|
32
|
+
if (selfMatch || filteredChildren.length > 0) {
|
|
33
|
+
result.push({
|
|
34
|
+
...node,
|
|
35
|
+
children:
|
|
36
|
+
filteredChildren.length > 0 ? filteredChildren : node.children,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
};
|
|
42
|
+
return walk(nodes);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 树形面板状态:展开/收起 + 搜索过滤。
|
|
47
|
+
* 搜索时强制展开(让命中的深层节点可见)。
|
|
48
|
+
*/
|
|
49
|
+
export function useFormTree(sections: TeamWideFormTreeSection[]) {
|
|
50
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
51
|
+
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set());
|
|
52
|
+
|
|
53
|
+
const isExpanded = useCallback(
|
|
54
|
+
(id: string) => !collapsedIds.has(id),
|
|
55
|
+
[collapsedIds]
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const toggleExpanded = useCallback((id: string) => {
|
|
59
|
+
setCollapsedIds((prev) => {
|
|
60
|
+
const next = new Set(prev);
|
|
61
|
+
if (next.has(id)) next.delete(id);
|
|
62
|
+
else next.add(id);
|
|
63
|
+
return next;
|
|
64
|
+
});
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
const filteredSections = useMemo(() => {
|
|
68
|
+
const searching = searchQuery.trim().length > 0;
|
|
69
|
+
return sections.map((section) => ({
|
|
70
|
+
...section,
|
|
71
|
+
nodes: filterTree(section.nodes, searchQuery),
|
|
72
|
+
searching,
|
|
73
|
+
}));
|
|
74
|
+
}, [sections, searchQuery]);
|
|
75
|
+
|
|
76
|
+
const totalVisible = useMemo(
|
|
77
|
+
() =>
|
|
78
|
+
filteredSections.reduce(
|
|
79
|
+
(sum, s) => sum + countLeaves(s.nodes),
|
|
80
|
+
0
|
|
81
|
+
),
|
|
82
|
+
[filteredSections]
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
searchQuery,
|
|
87
|
+
setSearchQuery,
|
|
88
|
+
filteredSections,
|
|
89
|
+
isExpanded,
|
|
90
|
+
toggleExpanded,
|
|
91
|
+
/** 搜索中(此时忽略折叠状态,全部展开) */
|
|
92
|
+
searching: searchQuery.trim().length > 0,
|
|
93
|
+
hasResult: totalVisible > 0,
|
|
94
|
+
expandableIds: useMemo(
|
|
95
|
+
() => collectExpandableIds(sections.reduce((acc, s) => acc.concat(s.nodes), [] as TeamWideFormTreeNode[])),
|
|
96
|
+
[sections]
|
|
97
|
+
),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function countLeaves(nodes: TeamWideFormTreeNode[]): number {
|
|
102
|
+
let count = 0;
|
|
103
|
+
for (const n of nodes) {
|
|
104
|
+
if (n.children && n.children.length > 0) count += countLeaves(n.children);
|
|
105
|
+
else count += 1;
|
|
106
|
+
}
|
|
107
|
+
return count;
|
|
108
|
+
}
|
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
import { getIconUnicode } from "@baishuyun/ai-feat-utils";
|
|
2
|
-
|
|
3
|
-
function getFormIconClass(icon: number): string {
|
|
4
|
-
const isNew = icon >= 25;
|
|
5
|
-
const newStr = isNew ? '-new' : '';
|
|
6
|
-
return `node-icon icon-bsy-form${newStr} icon${newStr}-bsy-form-${icon}`;
|
|
7
|
-
}
|
|
1
|
+
import { getIconUnicode, FORM_ICON_UNICODE_MAP } from "@baishuyun/ai-feat-utils";
|
|
8
2
|
|
|
9
3
|
export const AppIcon = ({
|
|
10
4
|
name
|
|
@@ -31,7 +25,10 @@ export const FontIcon = ({
|
|
|
31
25
|
style?: React.CSSProperties;
|
|
32
26
|
color?: string;
|
|
33
27
|
}) => {
|
|
34
|
-
|
|
28
|
+
// 判空:只有 null/undefined 才算「无图标」。
|
|
29
|
+
// 注意不能用 !name——icon 为 0 是合法图标(表单默认 icon 0),
|
|
30
|
+
// !0 为真会把它误判成无图标,导致绝大多数表单图标不渲染。
|
|
31
|
+
if (!code && (name === undefined || name === null)) {
|
|
35
32
|
return null;
|
|
36
33
|
}
|
|
37
34
|
|
|
@@ -47,10 +44,16 @@ export const FontIcon = ({
|
|
|
47
44
|
|
|
48
45
|
const iconCode = code || getIconUnicode(name?.toString() || '');
|
|
49
46
|
if (!iconCode) {
|
|
47
|
+
// 数字表单图标:不用 .icon-bsy-form-N 的 :before 内码(部分环境
|
|
48
|
+
// shadow DOM / WebView 中 :before content 无法生成),改为把 unicode
|
|
49
|
+
// 字符直接放进文本内容(与 sdk-icon 同法),font-family 走 finex/finex-new
|
|
50
|
+
const unicode = FORM_ICON_UNICODE_MAP[name!];
|
|
51
|
+
const fontFamily = name! >= 25 ? 'finex-new' : 'finex';
|
|
50
52
|
return (
|
|
51
53
|
<i
|
|
52
|
-
className={
|
|
53
|
-
style={{ fontSize: 16, lineHeight: 1 }}
|
|
54
|
+
className={className}
|
|
55
|
+
style={{ fontSize: 16, lineHeight: 1, fontStyle: 'normal', fontFamily, color: '#0265ff', ...style }}
|
|
56
|
+
dangerouslySetInnerHTML={{ __html: unicode ? `&#x${unicode};` : '' }}
|
|
54
57
|
/>
|
|
55
58
|
)
|
|
56
59
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { cn } from '../../lib/utils';
|
|
4
|
+
import { Drawer, DrawerContent, DrawerTitle } from './drawer';
|
|
5
|
+
|
|
6
|
+
export type EnhancedDrawerDirection = 'left' | 'right' | 'top' | 'bottom';
|
|
7
|
+
|
|
8
|
+
export interface EnhancedDrawerProps {
|
|
9
|
+
/** 受控开关 */
|
|
10
|
+
open?: boolean;
|
|
11
|
+
/** 非受控默认开关 */
|
|
12
|
+
defaultOpen?: boolean;
|
|
13
|
+
onOpenChange?: (open: boolean) => void;
|
|
14
|
+
/** 滑入方向,默认 right */
|
|
15
|
+
direction?: EnhancedDrawerDirection;
|
|
16
|
+
/** 侧边抽屉宽度(left/right 生效),默认 300 */
|
|
17
|
+
width?: number;
|
|
18
|
+
/** 无障碍标题(sr-only 展示) */
|
|
19
|
+
title?: string;
|
|
20
|
+
/** 是否显示遮罩,默认 true */
|
|
21
|
+
showOverlay?: boolean;
|
|
22
|
+
/** 点击遮罩是否关闭,默认 true */
|
|
23
|
+
overlayClosable?: boolean;
|
|
24
|
+
/** 遮罩点击回调(额外行为,如打点) */
|
|
25
|
+
onOverlayClick?: () => void;
|
|
26
|
+
/** 遮罩附加样式 */
|
|
27
|
+
overlayStyle?: React.CSSProperties;
|
|
28
|
+
/** 抽屉面板附加 className */
|
|
29
|
+
className?: string;
|
|
30
|
+
children?: React.ReactNode;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 增强版抽屉(基于 vaul,面向 shadow DOM / WebView 环境的封装)。
|
|
35
|
+
*
|
|
36
|
+
* 统一处理了以下兼容问题,业务组件无需再各自处理:
|
|
37
|
+
* 1. `modal={false}` —— 关闭 Radix Dialog 的 body 滚动锁(react-remove-scroll 的
|
|
38
|
+
* touchmove preventDefault 在 shadow DOM 里因事件目标 retarget 无法识别抽屉内
|
|
39
|
+
* 滚动容器,会锁死滚动);同时 vaul 的 Overlay 在 modal=false 时返回 null。
|
|
40
|
+
* 2. `dismissible={false}` —— 关闭 vaul 拖拽关闭,避免 onPress 每次 pointerdown 都
|
|
41
|
+
* setPointerCapture 禁掉原生触摸滚动。
|
|
42
|
+
* 3. 手动渲染遮罩(vaul 遮罩因 modal=false 消失),点遮罩关闭走受控路径
|
|
43
|
+
* (绕过被 dismissible 拦掉的 vaul 关闭路径)。
|
|
44
|
+
*
|
|
45
|
+
* 内容区若需滚动,业务组件在自身滚动容器上加 `data-vaul-no-drag`(保险,
|
|
46
|
+
* dismissible=false 已阻止拖拽,此标记可防未来语义变化)。
|
|
47
|
+
*/
|
|
48
|
+
export const EnhancedDrawer = ({
|
|
49
|
+
open: controlledOpen,
|
|
50
|
+
defaultOpen,
|
|
51
|
+
onOpenChange,
|
|
52
|
+
direction = 'right',
|
|
53
|
+
width = 300,
|
|
54
|
+
title,
|
|
55
|
+
showOverlay = true,
|
|
56
|
+
overlayClosable = true,
|
|
57
|
+
onOverlayClick,
|
|
58
|
+
overlayStyle,
|
|
59
|
+
className,
|
|
60
|
+
children,
|
|
61
|
+
}: EnhancedDrawerProps) => {
|
|
62
|
+
const [internalOpen, setInternalOpen] = useState(defaultOpen ?? false);
|
|
63
|
+
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
|
64
|
+
|
|
65
|
+
const setOpen = useCallback(
|
|
66
|
+
(next: boolean) => {
|
|
67
|
+
if (controlledOpen === undefined) setInternalOpen(next);
|
|
68
|
+
onOpenChange?.(next);
|
|
69
|
+
},
|
|
70
|
+
[controlledOpen, onOpenChange]
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// 遮罩 portal 容器:抽屉内容经 vaul portal 进所在 shadow root,遮罩需与之同层
|
|
74
|
+
// 才能正确叠在抽屉下方(z 更低)且覆盖全屏
|
|
75
|
+
const [maskContainer, setMaskContainer] = useState<Element | null>(null);
|
|
76
|
+
const maskHostRef = useCallback((node: HTMLSpanElement | null) => {
|
|
77
|
+
if (!node) {
|
|
78
|
+
setMaskContainer(null);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const root = node.getRootNode();
|
|
82
|
+
setMaskContainer(
|
|
83
|
+
root instanceof ShadowRoot ? (root as unknown as Element) : document.body
|
|
84
|
+
);
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
const handleOverlayClick = useCallback(() => {
|
|
88
|
+
onOverlayClick?.();
|
|
89
|
+
if (overlayClosable) setOpen(false);
|
|
90
|
+
}, [onOverlayClick, overlayClosable, setOpen]);
|
|
91
|
+
|
|
92
|
+
const isHorizontal = direction === 'left' || direction === 'right';
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<>
|
|
96
|
+
<span ref={maskHostRef} style={{ display: 'none' }} />
|
|
97
|
+
|
|
98
|
+
{showOverlay &&
|
|
99
|
+
open &&
|
|
100
|
+
maskContainer &&
|
|
101
|
+
createPortal(
|
|
102
|
+
<div
|
|
103
|
+
onClick={handleOverlayClick}
|
|
104
|
+
aria-hidden="true"
|
|
105
|
+
style={{
|
|
106
|
+
position: 'fixed',
|
|
107
|
+
inset: 0,
|
|
108
|
+
background: 'rgba(0,0,0,0.3)',
|
|
109
|
+
zIndex: 40,
|
|
110
|
+
...overlayStyle,
|
|
111
|
+
}}
|
|
112
|
+
/>,
|
|
113
|
+
maskContainer
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
<Drawer
|
|
117
|
+
open={open}
|
|
118
|
+
onOpenChange={setOpen}
|
|
119
|
+
direction={direction}
|
|
120
|
+
modal={false}
|
|
121
|
+
dismissible={false}
|
|
122
|
+
>
|
|
123
|
+
<DrawerContent
|
|
124
|
+
className={cn(className)}
|
|
125
|
+
hideOverlay
|
|
126
|
+
style={isHorizontal ? { width: `${width}px`, maxWidth: `${width}px` } : undefined}
|
|
127
|
+
>
|
|
128
|
+
{title != null && <DrawerTitle className="sr-only">{title}</DrawerTitle>}
|
|
129
|
+
{children}
|
|
130
|
+
</DrawerContent>
|
|
131
|
+
</Drawer>
|
|
132
|
+
</>
|
|
133
|
+
);
|
|
134
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ export { EmptyAgentIcon } from './components/icons/empty-agent-icon';
|
|
|
26
26
|
export * from './components/ui/button';
|
|
27
27
|
export * from './components/ui/dialog';
|
|
28
28
|
export * from './components/ui/drawer';
|
|
29
|
+
export * from './components/ui/enhanced-drawer';
|
|
29
30
|
export * from './components/ui/popover';
|
|
30
31
|
export * from './components/ui/tooltip';
|
|
31
32
|
export * from './components/ui/spinner';
|
|
@@ -41,6 +42,8 @@ export { AbsFullscreenGradientBg } from './components/abs-fullscreen-gradient-bg
|
|
|
41
42
|
export { AgentSelect } from './components/agent-select';
|
|
42
43
|
export type { AgentSelectAgent, AgentSelectProps } from './components/agent-select';
|
|
43
44
|
export { AgentScrollSelector } from './components/agent-scroll-selector';
|
|
45
|
+
export { AgentDrawer } from './components/agent-drawer';
|
|
46
|
+
export type { AgentDrawerProps } from './components/agent-drawer';
|
|
44
47
|
export type { AgentScrollSelectorProps } from './components/agent-scroll-selector';
|
|
45
48
|
export { AttachmentPartGroup } from './components/attachment-part-group';
|
|
46
49
|
export { AttachmentPart } from './components/attachment-part';
|
|
@@ -87,6 +90,21 @@ export type {
|
|
|
87
90
|
TriggerCardConfig,
|
|
88
91
|
} from './components/agent-helper/trigger-select';
|
|
89
92
|
export { FormSelect } from './components/agent-helper/form-select';
|
|
93
|
+
export {
|
|
94
|
+
TeamWideFormSelect,
|
|
95
|
+
TeamWideFormDrawer,
|
|
96
|
+
} from './components/agent-helper/team-wide-form-select';
|
|
97
|
+
export type {
|
|
98
|
+
TeamWideFormSelectProps,
|
|
99
|
+
TeamWideFormDrawerProps,
|
|
100
|
+
} from './components/agent-helper/team-wide-form-select';
|
|
101
|
+
export type {
|
|
102
|
+
TeamWideFormTreeNode,
|
|
103
|
+
TeamWideFormTreeSection,
|
|
104
|
+
TeamWideFormSelectChangeResult,
|
|
105
|
+
} from './components/agent-helper/team-wide-form-select/types';
|
|
106
|
+
export { appsAndFormsToTree } from './components/agent-helper/team-wide-form-select/data-adapter';
|
|
107
|
+
|
|
90
108
|
export {
|
|
91
109
|
AppSelect,
|
|
92
110
|
groupsToAppTreeNodes,
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { FontIcon } from '../components/font-icon';
|
|
4
|
+
import { copyTextToClipboard } from '../lib/utils';
|
|
5
|
+
import { ICON_UNICODE_MAP, getIconUnicode } from '@baishuyun/ai-feat-utils';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* iconfont(sdk-icon 字体)图标浏览:
|
|
9
|
+
* 枚举 ICON_UNICODE_MAP 全部命名图标,支持搜索、点击复制图标名,
|
|
10
|
+
* 方便给 <FontIcon code={getIconUnicode(name)} /> 选图标。
|
|
11
|
+
*/
|
|
12
|
+
const IconfontPicker = () => {
|
|
13
|
+
const [keyword, setKeyword] = useState('');
|
|
14
|
+
const [copied, setCopied] = useState<string | null>(null);
|
|
15
|
+
|
|
16
|
+
const names = useMemo(() => {
|
|
17
|
+
const all = Object.keys(ICON_UNICODE_MAP);
|
|
18
|
+
const kw = keyword.trim().toLowerCase();
|
|
19
|
+
return kw ? all.filter((n) => n.toLowerCase().includes(kw)) : all;
|
|
20
|
+
}, [keyword]);
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div style={{ padding: 16, maxWidth: 960 }}>
|
|
24
|
+
<input
|
|
25
|
+
value={keyword}
|
|
26
|
+
onChange={(e) => setKeyword(e.target.value)}
|
|
27
|
+
placeholder="搜索图标名(如 image / resourcelibrary / Scan)"
|
|
28
|
+
style={{
|
|
29
|
+
width: '100%',
|
|
30
|
+
height: 34,
|
|
31
|
+
padding: '0 10px',
|
|
32
|
+
marginBottom: 16,
|
|
33
|
+
border: '1px solid #E0E0E0',
|
|
34
|
+
borderRadius: 4,
|
|
35
|
+
fontSize: 14,
|
|
36
|
+
outline: 'none',
|
|
37
|
+
boxSizing: 'border-box',
|
|
38
|
+
}}
|
|
39
|
+
/>
|
|
40
|
+
<div
|
|
41
|
+
style={{
|
|
42
|
+
display: 'grid',
|
|
43
|
+
gridTemplateColumns: 'repeat(auto-fill, minmax(96px, 1fr))',
|
|
44
|
+
gap: 8,
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
{names.map((name) => (
|
|
48
|
+
<button
|
|
49
|
+
key={name}
|
|
50
|
+
type="button"
|
|
51
|
+
title={`点击复制:${name}`}
|
|
52
|
+
onClick={async () => {
|
|
53
|
+
await copyTextToClipboard(name);
|
|
54
|
+
setCopied(name);
|
|
55
|
+
setTimeout(() => setCopied((c) => (c === name ? null : c)), 800);
|
|
56
|
+
}}
|
|
57
|
+
style={{
|
|
58
|
+
display: 'flex',
|
|
59
|
+
flexDirection: 'column',
|
|
60
|
+
alignItems: 'center',
|
|
61
|
+
gap: 6,
|
|
62
|
+
padding: '12px 4px',
|
|
63
|
+
background: copied === name ? '#F0F6FF' : '#fff',
|
|
64
|
+
border: `1px solid ${copied === name ? '#0265FF' : '#E0E0E0'}`,
|
|
65
|
+
borderRadius: 8,
|
|
66
|
+
cursor: 'pointer',
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
<FontIcon
|
|
70
|
+
code={getIconUnicode(name)}
|
|
71
|
+
style={{ fontSize: 22, lineHeight: 1, color: '#121111' }}
|
|
72
|
+
/>
|
|
73
|
+
<span
|
|
74
|
+
style={{
|
|
75
|
+
fontSize: 11,
|
|
76
|
+
color: '#666',
|
|
77
|
+
wordBreak: 'break-all',
|
|
78
|
+
textAlign: 'center',
|
|
79
|
+
lineHeight: 1.2,
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
{name}
|
|
83
|
+
</span>
|
|
84
|
+
</button>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
{names.length === 0 && (
|
|
88
|
+
<div style={{ textAlign: 'center', color: '#999', padding: 24 }}>
|
|
89
|
+
未找到匹配「{keyword}」的图标
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const meta = {
|
|
97
|
+
title: 'Example/Icons/Iconfont Picker',
|
|
98
|
+
component: IconfontPicker,
|
|
99
|
+
tags: ['autodocs'],
|
|
100
|
+
parameters: { layout: 'fullscreen' },
|
|
101
|
+
} satisfies Meta<typeof IconfontPicker>;
|
|
102
|
+
|
|
103
|
+
export default meta;
|
|
104
|
+
type Story = StoryObj<typeof meta>;
|
|
105
|
+
|
|
106
|
+
export const Base: Story = {};
|