@baishuyun/ai-design 2.1.0-alpha.4 → 2.1.0

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 (72) hide show
  1. package/.storybook/preview.ts +2 -0
  2. package/CHANGELOG.md +179 -28
  3. package/package.json +8 -9
  4. package/scripts/post-build.cjs +32 -0
  5. package/src/components/agent-drawer.tsx +119 -0
  6. package/src/components/agent-helper/agent-entry-card/index.tsx +3 -8
  7. package/src/components/agent-helper/agent-helper-card/index.tsx +3 -3
  8. package/src/components/agent-helper/app-select/index.tsx +119 -84
  9. package/src/components/agent-helper/condition-input/index.tsx +18 -21
  10. package/src/components/agent-helper/condition-item/index.tsx +1 -0
  11. package/src/components/agent-helper/field-edit-preference-select/index.tsx +31 -4
  12. package/src/components/agent-helper/field-value-input/dept-select.tsx +447 -0
  13. package/src/components/agent-helper/field-value-input/index.tsx +3 -2
  14. package/src/components/agent-helper/field-value-input/picker-inputs.tsx +20 -18
  15. package/src/components/agent-helper/form-select/index.tsx +12 -25
  16. package/src/components/agent-helper/relational-operator-select/index.tsx +37 -23
  17. package/src/components/agent-helper/table/index.tsx +6 -2
  18. package/src/components/agent-helper/team-wide-form-select/data-adapter.ts +109 -0
  19. package/src/components/agent-helper/team-wide-form-select/index.tsx +289 -0
  20. package/src/components/agent-helper/team-wide-form-select/tree-panel.tsx +180 -0
  21. package/src/components/agent-helper/team-wide-form-select/types.ts +36 -0
  22. package/src/components/agent-helper/team-wide-form-select/use-form-tree.ts +108 -0
  23. package/src/components/agent-helper/trigger-select/index.tsx +1 -1
  24. package/src/components/agent-select.tsx +115 -35
  25. package/src/components/bot-avatar-name.tsx +1 -1
  26. package/src/components/bs-entry-select/build-tree.ts +0 -6
  27. package/src/components/bs-entry-select/entry-item.tsx +7 -11
  28. package/src/components/bs-entry-select/other-app-section.tsx +2 -4
  29. package/src/components/bs-entry-select/scroll-slider.tsx +6 -11
  30. package/src/components/bs-icons.tsx +752 -206
  31. package/src/components/card.tsx +55 -10
  32. package/src/components/circle-checker.tsx +18 -3
  33. package/src/components/collapsible-txt-msg.tsx +18 -13
  34. package/src/components/fake-bot-msg.tsx +18 -7
  35. package/src/components/field-checker-list-msg.tsx +4 -3
  36. package/src/components/fields-design-info-table.tsx +9 -26
  37. package/src/components/fields-portal.tsx +2 -2
  38. package/src/components/fields-previewer.tsx +1 -0
  39. package/src/components/font-icon.tsx +20 -9
  40. package/src/components/form-info-editor.tsx +21 -6
  41. package/src/components/legacy-slot.tsx +11 -16
  42. package/src/components/memoized-markdown.tsx +32 -19
  43. package/src/components/message-content.tsx +7 -3
  44. package/src/components/output-config-editor.tsx +39 -25
  45. package/src/components/primary-confirm-btn.tsx +6 -1
  46. package/src/components/scroll-to-bottom-btn.tsx +3 -2
  47. package/src/components/shadow-content.tsx +19 -0
  48. package/src/components/shadow-portal.tsx +23 -0
  49. package/src/components/ui/dialog.tsx +4 -1
  50. package/src/components/ui/drawer.tsx +3 -1
  51. package/src/components/ui/enhanced-drawer.tsx +134 -0
  52. package/src/components/ui/popover.tsx +3 -2
  53. package/src/components/ui/select.tsx +3 -2
  54. package/src/components/ui/tooltip.tsx +3 -2
  55. package/src/components/user-txt-msg-bubble.tsx +41 -19
  56. package/src/components/warning-msg.tsx +29 -12
  57. package/src/const/shadow.ts +17 -0
  58. package/src/const/ui.ts +2 -2
  59. package/src/hooks/use-column-appear.ts +1 -1
  60. package/src/hooks/use-portal-container.ts +6 -3
  61. package/src/hooks/use-shadow-boundary.ts +67 -0
  62. package/src/hooks/use-shadow.ts +24 -7
  63. package/src/index.ts +21 -0
  64. package/src/stories/AppSelect.stories.tsx +44 -0
  65. package/src/stories/FieldValueInput.stories.tsx +6 -3
  66. package/src/stories/FormSelect.stories.tsx +40 -0
  67. package/src/stories/IconfontPicker.stories.tsx +106 -0
  68. package/src/stories/RelationalOperatorSelect.stories.tsx +27 -0
  69. package/src/stories/TeamWideFormSelect.stories.tsx +362 -0
  70. package/src/stories/UserTextMsgBubble.stories.ts +28 -0
  71. package/src/style.css +28 -1
  72. package/style.css +172 -130
@@ -1,27 +1,27 @@
1
1
  import { useState, useCallback, useMemo } from 'react';
2
- import type { relationalOperator } from '@baishuyun/types';
3
- import { OPERATOR_LABEL_MAP } from '@baishuyun/ai-feat-utils';
2
+ import type { FieldType, relationalOperator } from '@baishuyun/types';
3
+ import { getOperatorLabel } from '@baishuyun/ai-feat-utils';
4
4
  import { Popover, PopoverTrigger, PopoverContent } from '../../ui/popover';
5
5
  import { cn } from '../../../lib/utils';
6
6
  import { transCls } from '../../../const/ui';
7
7
 
8
- const ALL_OPTIONS: Array<{ value: relationalOperator; label: string }> = [
9
- { value: 'eq', label: OPERATOR_LABEL_MAP.eq },
10
- { value: 'neq', label: OPERATOR_LABEL_MAP.neq },
11
- { value: 'gt', label: OPERATOR_LABEL_MAP.gt },
12
- { value: 'lt', label: OPERATOR_LABEL_MAP.lt },
13
- { value: 'gte', label: OPERATOR_LABEL_MAP.gte },
14
- { value: 'lte', label: OPERATOR_LABEL_MAP.lte },
15
- { value: 'contains', label: OPERATOR_LABEL_MAP.contains },
16
- { value: 'not_contains', label: OPERATOR_LABEL_MAP.not_contains },
17
- { value: 'in', label: OPERATOR_LABEL_MAP.in },
18
- { value: 'nin', label: OPERATOR_LABEL_MAP.nin },
19
- { value: 'all', label: OPERATOR_LABEL_MAP.all },
20
- { value: 'empty', label: OPERATOR_LABEL_MAP.empty },
21
- { value: 'not_empty', label: OPERATOR_LABEL_MAP.not_empty },
22
- { value: 'range', label: OPERATOR_LABEL_MAP.range },
23
- { value: 'like', label: OPERATOR_LABEL_MAP.like },
24
- { value: 'unlike', label: OPERATOR_LABEL_MAP.unlike },
8
+ const ALL_OPERATORS: relationalOperator[] = [
9
+ 'eq',
10
+ 'neq',
11
+ 'gt',
12
+ 'lt',
13
+ 'gte',
14
+ 'lte',
15
+ 'contains',
16
+ 'not_contains',
17
+ 'in',
18
+ 'nin',
19
+ 'all',
20
+ 'empty',
21
+ 'not_empty',
22
+ 'range',
23
+ 'like',
24
+ 'unlike',
25
25
  ];
26
26
 
27
27
  export interface IRelationalOperatorSelectProps {
@@ -29,6 +29,13 @@ export interface IRelationalOperatorSelectProps {
29
29
  defaultValue?: relationalOperator;
30
30
  onChange?: (value: relationalOperator) => void;
31
31
  operators?: relationalOperator[];
32
+ /**
33
+ * 字段类型:同一操作符在不同字段类型下描述不同
34
+ * (如部门多选的 in 为「包含任意一个」),未提供时使用默认文案
35
+ */
36
+ fieldType?: FieldType;
37
+ /** 逐操作符的文案覆盖,优先级高于 fieldType 推导 */
38
+ operatorLabels?: Partial<Record<relationalOperator, string>>;
32
39
  borderless?: boolean;
33
40
  disabled?: boolean;
34
41
  className?: string;
@@ -39,6 +46,8 @@ export const RelationalOperatorSelect = ({
39
46
  defaultValue = 'eq',
40
47
  onChange,
41
48
  operators,
49
+ fieldType,
50
+ operatorLabels,
42
51
  borderless = false,
43
52
  disabled = false,
44
53
  className,
@@ -48,10 +57,15 @@ export const RelationalOperatorSelect = ({
48
57
  const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
49
58
 
50
59
  const options = useMemo(() => {
51
- if (!operators || operators.length === 0) return ALL_OPTIONS;
52
- const set = new Set(operators);
53
- return ALL_OPTIONS.filter((o) => set.has(o.value));
54
- }, [operators]);
60
+ const list =
61
+ !operators || operators.length === 0
62
+ ? ALL_OPERATORS
63
+ : ALL_OPERATORS.filter((o) => new Set(operators).has(o));
64
+ return list.map((value) => ({
65
+ value,
66
+ label: operatorLabels?.[value] ?? getOperatorLabel(value, fieldType),
67
+ }));
68
+ }, [operators, fieldType, operatorLabels]);
55
69
 
56
70
  const resolvedValue = (() => {
57
71
  const v = controlledValue !== undefined ? controlledValue : internalValue;
@@ -47,9 +47,13 @@ export function DataTable<TData>({
47
47
  return (
48
48
  <div
49
49
  className={cn(
50
- 'rounded-[10px] border border-[#e0e0e0] overflow-hidden bg-white',
50
+ 'rounded-[10px] border border-[#e0e0e0] overflow-auto bg-white',
51
51
  className,
52
52
  )}
53
+ // 抽屉(vaul/Radix Dialog)经 react-remove-scroll 在 document 级 preventDefault
54
+ // wheel 事件;shadow DOM 下其 contains 判定失效,内部滚动会被误杀。
55
+ // 阻断冒泡使其不可见,恢复触摸板滚动(同 form-select 的处理先例)。
56
+ onWheel={(e) => e.stopPropagation()}
53
57
  >
54
58
  <table className="w-full h-full border-collapse">
55
59
  <thead>
@@ -59,7 +63,7 @@ export function DataTable<TData>({
59
63
  <th
60
64
  key={header.id}
61
65
  className={cn(
62
- 'bg-[#f5f7fa] h-[34px] px-[10px] text-left text-[14px] font-normal text-[#030303] border-b border-[#e0e0e0] align-middle whitespace-nowrap',
66
+ 'sticky top-0 z-[1] bg-[#f5f7fa] h-[34px] px-[10px] text-left text-[14px] font-normal text-[#030303] border-b border-[#e0e0e0] align-middle whitespace-nowrap',
63
67
  index !== 0 && 'border-l border-[#e0e0e0]',
64
68
  )}
65
69
  style={{
@@ -0,0 +1,109 @@
1
+ import type {
2
+ IAllAppsAndFormsData,
3
+ IAppFormListEntry,
4
+ IAppWithFormList,
5
+ } from '@baishuyun/types';
6
+ import type { TeamWideFormTreeNode, TeamWideFormTreeSection } from './types';
7
+
8
+ /**
9
+ * 数据适配:/dashboard/app/form/list 返回 → 树形结构。
10
+ *
11
+ * 树层级:应用分组 → 应用 → 表单分组 → 表单(表单分组可嵌套)。
12
+ * 传入 currentAppId 时拆成「当前应用表单 / 其他应用表单」两个 section,
13
+ * 当前应用的条目不再带应用/分组层级(平铺到 section 根)。
14
+ */
15
+
16
+ /** 条目(含嵌套分组)→ 树节点 */
17
+ function entryToNode(entry: IAppFormListEntry): TeamWideFormTreeNode {
18
+ const isGroup = entry.type === 'group';
19
+ return {
20
+ id: entry.entryId,
21
+ label: entry.name,
22
+ type: isGroup ? 'formGroup' : 'form',
23
+ formIcon: entry.icon,
24
+ data: isGroup ? undefined : entry,
25
+ children: entry.child?.map(entryToNode),
26
+ };
27
+ }
28
+
29
+ /** 应用 formList → 表单/分组树节点列表(聚合 form/dash/aggregate/etl 各类) */
30
+ function appFormNodes(app: IAppWithFormList): TeamWideFormTreeNode[] {
31
+ const fl = app.formList;
32
+ if (!fl) return [];
33
+
34
+ const nodes: TeamWideFormTreeNode[] = [];
35
+ const pushEntries = (entries: IAppFormListEntry[] | null) => {
36
+ if (entries?.length) {
37
+ nodes.push(...entries.map(entryToNode));
38
+ }
39
+ };
40
+
41
+ pushEntries(fl.form);
42
+ pushEntries(fl.dash);
43
+ pushEntries(fl.aggregate_table);
44
+ pushEntries(fl.etl_table);
45
+
46
+ return nodes;
47
+ }
48
+
49
+ /** 应用 → 树节点(含其子树) */
50
+ function appToNode(app: IAppWithFormList): TeamWideFormTreeNode {
51
+ return {
52
+ id: app._id,
53
+ label: app.appname,
54
+ type: 'app',
55
+ appIcon: app.style?.icon,
56
+ children: appFormNodes(app),
57
+ };
58
+ }
59
+
60
+ /**
61
+ * 全量数据 → 树形 section 列表。
62
+ * - 不传 currentAppId:单个 'all' section,全量树(应用分组→应用→表单分组→表单)
63
+ * - 传 currentAppId:'current'(当前应用条目,无应用层级)+ 'other'(其余应用全量树)
64
+ */
65
+ export function appsAndFormsToTree(
66
+ data: IAllAppsAndFormsData | null | undefined,
67
+ currentAppId?: string
68
+ ): TeamWideFormTreeSection[] {
69
+ const groups = data?.items ?? [];
70
+
71
+ if (!currentAppId) {
72
+ return [
73
+ {
74
+ key: 'all',
75
+ nodes: groups.map((group) => ({
76
+ id: group._id,
77
+ label: group.name,
78
+ type: 'appGroup' as const,
79
+ children: (group.apps ?? []).map(appToNode),
80
+ })),
81
+ },
82
+ ];
83
+ }
84
+
85
+ // 当前应用:只取它的条目树(无应用/分组层级)
86
+ let currentNodes: TeamWideFormTreeNode[] = [];
87
+ for (const group of groups) {
88
+ const target = (group.apps ?? []).find((a) => a._id === currentAppId);
89
+ if (target) {
90
+ currentNodes = appFormNodes(target);
91
+ break;
92
+ }
93
+ }
94
+
95
+ // 其他应用:全量树,剔除当前应用(空应用分组保留)
96
+ const otherNodes: TeamWideFormTreeNode[] = groups.map((group) => ({
97
+ id: group._id,
98
+ label: group.name,
99
+ type: 'appGroup' as const,
100
+ children: (group.apps ?? [])
101
+ .filter((a) => a._id !== currentAppId)
102
+ .map(appToNode),
103
+ }));
104
+
105
+ return [
106
+ { key: 'current', title: '当前应用表单', nodes: currentNodes },
107
+ { key: 'other', title: '其他应用表单', nodes: otherNodes },
108
+ ];
109
+ }
@@ -0,0 +1,289 @@
1
+ import { useCallback, useMemo, useRef, useState } from 'react';
2
+ import { cn } from '../../../lib/utils';
3
+ import { transCls } from '../../../const/ui';
4
+ import { Popover, PopoverTrigger, PopoverContent } from '../../ui/popover';
5
+ import { EnhancedDrawer } from '../../ui/enhanced-drawer';
6
+ import { appsAndFormsToTree } from './data-adapter';
7
+ import { TeamWideFormTreePanel } from './tree-panel';
8
+ import type { IAllAppsAndFormsData } from '@baishuyun/types';
9
+ import type {
10
+ TeamWideFormSelectChangeResult,
11
+ TeamWideFormTreeNode,
12
+ } from './types';
13
+
14
+ // ── Shared ────────────────────────────────────────────────────────
15
+
16
+ /** 下拉箭头(trigger 用) */
17
+ const DropdownChevron = ({ open }: { open: boolean }) => (
18
+ <svg
19
+ width="16" height="16" viewBox="0 0 16 16" fill="none"
20
+ className={cn('shrink-0', transCls)}
21
+ style={{ transform: open ? 'rotate(180deg)' : 'rotate(0deg)' }}
22
+ >
23
+ <path d="M4 6L8 10L12 6" stroke="#666666" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
24
+ </svg>
25
+ );
26
+
27
+ /** 在 sections 里按 entryId 找选中节点的 label / formIcon */
28
+ function findSelected(
29
+ sections: ReturnType<typeof appsAndFormsToTree>,
30
+ entryId?: string
31
+ ): { label: string; formIcon?: number } | null {
32
+ if (!entryId) return null;
33
+ let found: { label: string; formIcon?: number } | null = null;
34
+ const walk = (nodes: TeamWideFormTreeNode[]) => {
35
+ for (const n of nodes) {
36
+ if (found) return;
37
+ if (n.type === 'form' && n.id === entryId) {
38
+ found = { label: n.label, formIcon: n.formIcon };
39
+ return;
40
+ }
41
+ if (n.children) walk(n.children);
42
+ }
43
+ };
44
+ sections.forEach((s) => walk(s.nodes));
45
+ return found;
46
+ }
47
+
48
+ interface BaseProps {
49
+ /** 全量应用+表单数据(/dashboard/app/form/list 返回的 data 字段) */
50
+ data: IAllAppsAndFormsData | null | undefined;
51
+ /** 当前应用 ID:传入后按「当前应用表单 / 其他应用表单」分组展示 */
52
+ currentAppId?: string;
53
+ /** 选中的 entryId(受控) */
54
+ value?: string;
55
+ /** 选中回调(返回 appId + entryId + 原始条目) */
56
+ onChange?: (result: TeamWideFormSelectChangeResult) => void;
57
+ searchPlaceholder?: string;
58
+ className?: string;
59
+ }
60
+
61
+ // ── 1. 下拉选择 ───────────────────────────────────────────────────
62
+
63
+ export interface TeamWideFormSelectProps extends BaseProps {
64
+ /** 非受控默认值 */
65
+ defaultValue?: string;
66
+ placeholder?: string;
67
+ disabled?: boolean;
68
+ }
69
+
70
+ /**
71
+ * 全量表单选择(下拉形式):数据源为一次拉全的应用+表单接口,
72
+ * 树形展示 应用分组→应用→表单分组→表单。
73
+ */
74
+ export const TeamWideFormSelect = ({
75
+ data,
76
+ currentAppId,
77
+ value: controlledValue,
78
+ defaultValue,
79
+ onChange,
80
+ placeholder = '请选择表单',
81
+ searchPlaceholder,
82
+ disabled = false,
83
+ className,
84
+ }: TeamWideFormSelectProps) => {
85
+ const [open, setOpen] = useState(false);
86
+ const [internalValue, setInternalValue] = useState(defaultValue ?? '');
87
+ const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
88
+ const [triggerWidth, setTriggerWidth] = useState(0);
89
+ const triggerBtnRef = useRef<HTMLButtonElement | null>(null);
90
+
91
+ const value = controlledValue !== undefined ? controlledValue : internalValue;
92
+ const sections = useMemo(
93
+ () => appsAndFormsToTree(data, currentAppId),
94
+ [data, currentAppId]
95
+ );
96
+ const selected = useMemo(
97
+ () => findSelected(sections, value),
98
+ [sections, value]
99
+ );
100
+
101
+ const setTriggerRef = useCallback((node: HTMLButtonElement | null) => {
102
+ triggerBtnRef.current = node;
103
+ if (node) setTriggerWidth(node.offsetWidth);
104
+ if (!node) {
105
+ setPortalContainer(null);
106
+ return;
107
+ }
108
+ const rootNode = node.getRootNode();
109
+ if (rootNode instanceof ShadowRoot) {
110
+ const existing = rootNode.querySelector<HTMLElement>(
111
+ '[data-bs-team-form-select-portal]'
112
+ );
113
+ if (existing) {
114
+ setPortalContainer(existing);
115
+ return;
116
+ }
117
+ const container = document.createElement('div');
118
+ container.setAttribute('data-bs-team-form-select-portal', 'true');
119
+ rootNode.appendChild(container);
120
+ setPortalContainer(container);
121
+ } else {
122
+ setPortalContainer(null);
123
+ }
124
+ }, []);
125
+
126
+ const handleChange = useCallback(
127
+ (result: TeamWideFormSelectChangeResult) => {
128
+ if (controlledValue === undefined) setInternalValue(result.entryId);
129
+ onChange?.(result);
130
+ setOpen(false);
131
+ },
132
+ [controlledValue, onChange]
133
+ );
134
+
135
+ return (
136
+ <div className={cn('relative', className)}>
137
+ <Popover
138
+ open={open}
139
+ onOpenChange={(next) => {
140
+ if (disabled) return;
141
+ setOpen(next);
142
+ if (next && triggerBtnRef.current) {
143
+ setTriggerWidth(triggerBtnRef.current.offsetWidth);
144
+ }
145
+ }}
146
+ >
147
+ <PopoverTrigger asChild>
148
+ <button
149
+ ref={setTriggerRef}
150
+ type="button"
151
+ disabled={disabled}
152
+ className={cn(
153
+ 'flex items-center justify-between w-full h-[34px] gap-2.5 px-[10px]',
154
+ 'bg-white border border-[#E0E0E0] rounded-[4px]',
155
+ 'text-left cursor-pointer hover:border-[#0265FF]',
156
+ disabled && 'opacity-50 cursor-not-allowed hover:border-[#E0E0E0]',
157
+ open && 'border-[#0265FF]',
158
+ transCls
159
+ )}
160
+ >
161
+ <span
162
+ className={cn(
163
+ 'text-[14px] leading-[normal] truncate',
164
+ selected ? 'text-[#030303]' : 'text-[#999999]'
165
+ )}
166
+ >
167
+ {selected?.label ?? placeholder}
168
+ </span>
169
+ <DropdownChevron open={open} />
170
+ </button>
171
+ </PopoverTrigger>
172
+ <PopoverContent
173
+ align="start"
174
+ sideOffset={4}
175
+ portalContainer={portalContainer}
176
+ className="!border-0 rounded-[10px] shadow-[0px_0px_5px_rgba(0,0,0,0.04)] p-[12px]"
177
+ style={{ minWidth: triggerWidth || undefined }}
178
+ >
179
+ <TeamWideFormTreePanel
180
+ sections={sections}
181
+ value={value}
182
+ onSelect={handleChange}
183
+ searchPlaceholder={searchPlaceholder}
184
+ />
185
+ </PopoverContent>
186
+ </Popover>
187
+ </div>
188
+ );
189
+ };
190
+
191
+ // ── 2. 左侧滑入抽屉 ───────────────────────────────────────────────
192
+
193
+ export interface TeamWideFormDrawerProps extends BaseProps {
194
+ /** 受控开关 */
195
+ open?: boolean;
196
+ /** 非受控默认开关 */
197
+ defaultOpen?: boolean;
198
+ onOpenChange?: (open: boolean) => void;
199
+ /** 触发器(可选;不传则完全由 open 控制) */
200
+ trigger?: React.ReactNode;
201
+ /** 数据加载状态:加载中/失败时替代树区域展示(不传则按 data 直接渲染树) */
202
+ status?: 'loading' | 'error';
203
+ /** status === 'error' 时展示重试按钮,点击回调 */
204
+ onRetry?: () => void;
205
+ }
206
+
207
+ /**
208
+ * 全量表单选择(左侧滑入抽屉):选中表单后自动关闭。
209
+ * 视觉对齐 Figma:左侧 300px 白色面板 + 半透明蒙版。
210
+ */
211
+ export const TeamWideFormDrawer = ({
212
+ data,
213
+ currentAppId,
214
+ value,
215
+ onChange,
216
+ open: controlledOpen,
217
+ defaultOpen,
218
+ onOpenChange,
219
+ searchPlaceholder,
220
+ trigger,
221
+ status,
222
+ onRetry,
223
+ }: TeamWideFormDrawerProps) => {
224
+ const [internalOpen, setInternalOpen] = useState(defaultOpen ?? false);
225
+ const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
226
+
227
+ const sections = useMemo(
228
+ () => appsAndFormsToTree(data, currentAppId),
229
+ [data, currentAppId]
230
+ );
231
+
232
+ const setOpen = useCallback(
233
+ (next: boolean) => {
234
+ if (controlledOpen === undefined) setInternalOpen(next);
235
+ onOpenChange?.(next);
236
+ },
237
+ [controlledOpen, onOpenChange]
238
+ );
239
+
240
+ const handleChange = useCallback(
241
+ (result: TeamWideFormSelectChangeResult) => {
242
+ onChange?.(result);
243
+ setOpen(false);
244
+ },
245
+ [onChange, setOpen]
246
+ );
247
+
248
+ return (
249
+ <>
250
+ {trigger && <span onClick={() => setOpen(true)}>{trigger}</span>}
251
+ <EnhancedDrawer
252
+ open={open}
253
+ onOpenChange={setOpen}
254
+ direction="left"
255
+ width={300}
256
+ title="切换表单"
257
+ >
258
+ <div className="h-full overflow-hidden p-[12px]">
259
+ {status === 'error' ? (
260
+ <div className="flex flex-col items-center justify-center h-full gap-[12px]">
261
+ <span className="text-[12px] text-[#999999]">应用/表单加载失败</span>
262
+ {onRetry && (
263
+ <button
264
+ type="button"
265
+ onClick={onRetry}
266
+ className="h-[30px] px-[16px] rounded-[4px] border border-[#E0E0E0] text-[14px] text-[#0265FF] active:bg-[#F0F6FF]"
267
+ >
268
+ 重试
269
+ </button>
270
+ )}
271
+ </div>
272
+ ) : status === 'loading' ? (
273
+ <div className="flex items-center justify-center h-full text-[12px] text-[#999999]">
274
+ 加载中…
275
+ </div>
276
+ ) : (
277
+ <TeamWideFormTreePanel
278
+ sections={sections}
279
+ value={value}
280
+ onSelect={handleChange}
281
+ searchPlaceholder={searchPlaceholder}
282
+ maxHeight="calc(100vh - 60px)"
283
+ />
284
+ )}
285
+ </div>
286
+ </EnhancedDrawer>
287
+ </>
288
+ );
289
+ };
@@ -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
+ };