@pnt-team/pnt-component-frontend 0.0.138 → 0.0.140

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 (68) hide show
  1. package/lib/cjs/api/cos.d.ts +103 -0
  2. package/lib/cjs/api/cos.js +146 -0
  3. package/lib/cjs/api/index.d.ts +23 -0
  4. package/lib/cjs/api/index.js +38 -0
  5. package/lib/cjs/api/types/index.d.ts +117 -0
  6. package/lib/cjs/api/types/index.js +17 -0
  7. package/lib/cjs/assets/css/tdesign-theme.css +584 -0
  8. package/lib/cjs/assets/images/uploadImg.png +0 -0
  9. package/lib/cjs/assets/svg/inputSearch.svg +4 -0
  10. package/lib/cjs/assets/svg/inputTip.svg +5 -0
  11. package/lib/cjs/components/PntRegionSelect/index.scss +4 -3
  12. package/lib/esm/api/cos.d.ts +103 -0
  13. package/lib/esm/api/cos.js +189 -0
  14. package/lib/esm/api/index.d.ts +23 -0
  15. package/lib/esm/api/index.js +23 -0
  16. package/lib/esm/api/types/index.d.ts +117 -0
  17. package/lib/esm/api/types/index.js +1 -0
  18. package/lib/esm/assets/css/tdesign-theme.css +584 -0
  19. package/lib/esm/assets/images/uploadImg.png +0 -0
  20. package/lib/esm/assets/svg/inputSearch.svg +4 -0
  21. package/lib/esm/assets/svg/inputTip.svg +5 -0
  22. package/lib/esm/assets/svg/inputWarnTip.svg +5 -0
  23. package/lib/esm/components/PntBusinessSelect/index.d.ts +192 -0
  24. package/lib/esm/components/PntBusinessSelect/index.js +513 -0
  25. package/lib/esm/components/PntBusinessSelect/index.scss +27 -0
  26. package/lib/esm/components/PntCosUploader/index.d.ts +134 -0
  27. package/lib/esm/components/PntCosUploader/index.js +300 -0
  28. package/lib/esm/components/PntGeneralLayout/index.d.ts +114 -0
  29. package/lib/esm/components/PntGeneralLayout/index.js +103 -0
  30. package/lib/esm/components/PntGeneralLayout/index.scss +121 -0
  31. package/lib/esm/components/PntImage/index.d.ts +93 -0
  32. package/lib/esm/components/PntImage/index.js +200 -0
  33. package/lib/esm/components/PntImage/index.scss +61 -0
  34. package/lib/esm/components/PntRegionSelect/ImportDialog.d.ts +29 -0
  35. package/lib/esm/components/PntRegionSelect/ImportDialog.js +244 -0
  36. package/lib/esm/components/PntRegionSelect/index.d.ts +39 -0
  37. package/lib/esm/components/PntRegionSelect/index.js +471 -0
  38. package/lib/esm/components/PntRegionSelect/index.scss +175 -0
  39. package/lib/esm/components/PntRegionSelect/types.d.ts +181 -0
  40. package/lib/esm/components/PntRegionSelect/types.js +77 -0
  41. package/lib/esm/components/PntSecureBox/index.d.ts +122 -0
  42. package/lib/esm/components/PntSecureBox/index.js +166 -0
  43. package/lib/esm/components/PntSecureBox/index.scss +23 -0
  44. package/lib/esm/components/PntTable/index.d.ts +14 -0
  45. package/lib/esm/components/PntTable/index.js +404 -0
  46. package/lib/esm/components/PntTable/index.scss +290 -0
  47. package/lib/esm/components/PntTable/types.d.ts +250 -0
  48. package/lib/esm/components/PntTable/types.js +81 -0
  49. package/lib/esm/components/PntTable/useTableCore.d.ts +38 -0
  50. package/lib/esm/components/PntTable/useTableCore.js +499 -0
  51. package/lib/esm/components/PntUpload/index.d.ts +149 -0
  52. package/lib/esm/components/PntUpload/index.js +276 -0
  53. package/lib/esm/components/PntUpload/index.scss +101 -0
  54. package/lib/esm/config.d.ts +97 -0
  55. package/lib/esm/config.js +60 -0
  56. package/lib/esm/global.css +2 -0
  57. package/lib/esm/global.d.ts +39 -0
  58. package/lib/esm/hooks/useHighSecurity.d.ts +88 -0
  59. package/lib/esm/hooks/useHighSecurity.js +250 -0
  60. package/lib/esm/index.d.ts +16 -0
  61. package/lib/esm/index.js +33 -0
  62. package/lib/esm/utils/const.d.ts +13 -0
  63. package/lib/esm/utils/const.js +23 -0
  64. package/lib/esm/utils/index.d.ts +6 -0
  65. package/lib/esm/utils/index.js +22 -0
  66. package/lib/umd/pnt-component-frontend.min.css +1 -0
  67. package/lib/umd/pnt-component-frontend.min.js +1 -0
  68. package/package.json +1 -1
@@ -0,0 +1,192 @@
1
+ import type { BusinessSelectListResponseListItem } from "../../api/types";
2
+ import React, { FC } from 'react';
3
+ import type { SelectValue, TdSelectProps } from 'tdesign-react/es/select/type';
4
+ import './index.scss';
5
+ /** 下拉场景 */
6
+ type Scene = 'default' | 'sdk' | 'compliance_age' | 'custom_region';
7
+ /** 标签格式 */
8
+ type LabelFormat = 'name' | 'name_en' | 'name_id' | 'name_en_id';
9
+ interface PntBusinessSelectProps {
10
+ /**
11
+ * 当前选中值
12
+ * @description 单选为 string,多选为 string[]
13
+ */
14
+ value?: SelectValue;
15
+ /**
16
+ * 值变化回调
17
+ */
18
+ onChange?: TdSelectProps['onChange'];
19
+ /**
20
+ * 是否多选
21
+ * @default false
22
+ */
23
+ multiple?: boolean;
24
+ /**
25
+ * 下拉场景
26
+ * @description 后端按场景返回默认字段集
27
+ * @default 'default'
28
+ */
29
+ scene?: Scene;
30
+ /**
31
+ * 业务状态筛选
32
+ * @description 3-线上运营,4-接入中,5-中途结项,6-已下线
33
+ */
34
+ statuses?: number[];
35
+ /**
36
+ * 业务类型筛选
37
+ * @description 1-正式业务,2-测试业务
38
+ */
39
+ businessTypes?: number[];
40
+ /**
41
+ * 标签格式
42
+ * @description name-中文名,name_en-英文名,name_id-中文名(ID),name_en_id-英文名(ID)
43
+ * @default 'name'
44
+ */
45
+ labelFormat?: LabelFormat;
46
+ /**
47
+ * 占位提示文案
48
+ */
49
+ placeholder?: string;
50
+ /**
51
+ * 是否禁用整个选择器
52
+ * @default false
53
+ */
54
+ disabled?: boolean;
55
+ /**
56
+ * 是否可清除
57
+ * @default true
58
+ */
59
+ clearable?: boolean;
60
+ /**
61
+ * 是否可远程搜索
62
+ * @default true
63
+ */
64
+ filterable?: boolean;
65
+ /**
66
+ * 多选时最大可选数量
67
+ */
68
+ max?: number;
69
+ /**
70
+ * 每页条数
71
+ * @default 20
72
+ */
73
+ pageSize?: number;
74
+ /**
75
+ * 搜索防抖延迟(毫秒)
76
+ * @default 400
77
+ */
78
+ searchDebounce?: number;
79
+ /**
80
+ * 多选时最少折叠数量
81
+ * @description 超过此数量的选中项将折叠显示,透传至 TDesign Select
82
+ */
83
+ minCollapsedNum?: number;
84
+ /**
85
+ * 折叠项自定义渲染
86
+ * @description 透传至 TDesign Select collapsedItems
87
+ */
88
+ collapsedItems?: TdSelectProps['collapsedItems'];
89
+ /**
90
+ * 弹层属性透传
91
+ * @description 会与组件内部 onScrollToBottom 合并;常用 attach(挂载容器)、overlayClassName 等
92
+ */
93
+ popupProps?: Partial<TdSelectProps['popupProps']>;
94
+ /**
95
+ * 字段白名单
96
+ * @description 传给后端 fields 参数,指定返回字段;未传时后端按 scene 返回默认字段
97
+ */
98
+ fields?: string[];
99
+ /**
100
+ * 自定义 label 字段
101
+ * @description 从响应项的指定字段取 label;默认按 labelFormat 渲染
102
+ * 常用于消费方接口返回字段不是标准 name/name_en 的场景(如 game_name)
103
+ */
104
+ labelField?: string;
105
+ /**
106
+ * 自定义选项 label 渲染
107
+ * @description 返回 ReactNode 作为选项内容;优先级高于 labelFormat/labelField
108
+ * 常用于需要 ellipsis/样式包裹的场景
109
+ */
110
+ optionLabel?: (item: BusinessSelectListResponseListItem) => React.ReactNode;
111
+ /**
112
+ * 是否分页
113
+ * @description false 时一次性加载所有数据(pageNum=1, pageSize=0),不分页不远程搜索
114
+ * @default true
115
+ */
116
+ pagination?: boolean;
117
+ /**
118
+ * 选项加载完成回调
119
+ * @description 每次远程加载(首屏/搜索/翻页追加)成功后触发,返回当前全部选项;
120
+ * 不分页模式下首屏即全量数据,消费方可借此复用同一份数据,无需重复请求
121
+ */
122
+ onLoaded?: (options: BusinessSelectListResponseListItem[]) => void;
123
+ /**
124
+ * 自定义选项禁用逻辑
125
+ * @description 返回 true 时该选项禁用;与合规场景的 creatable 判断叠加(任一为 true 即禁用)
126
+ */
127
+ optionDisabled?: (item: BusinessSelectListResponseListItem) => boolean;
128
+ /**
129
+ * 自定义选项 tooltip 内容
130
+ * @description 返回非空字符串时该选项悬停显示 tooltip;常用于解释禁用原因
131
+ */
132
+ optionTooltip?: (item: BusinessSelectListResponseListItem) => string;
133
+ /**
134
+ * tooltip 位置
135
+ * @default 'top'
136
+ */
137
+ tooltipPlacement?: 'top' | 'bottom' | 'left' | 'right';
138
+ /**
139
+ * 自定义类名
140
+ */
141
+ className?: string;
142
+ /**
143
+ * 自定义样式
144
+ */
145
+ style?: React.CSSProperties;
146
+ }
147
+ /**
148
+ * 业务选择器组件
149
+ * @description 统一业务下拉选择器,内置对接后端通用接口 `/api/business/select/list`,
150
+ * 支持远程搜索、滚动分页加载、多选、场景化禁用(合规场景 creatable 字段)。
151
+ *
152
+ * @param {PntBusinessSelectProps} props - 组件属性
153
+ *
154
+ * @example
155
+ * ```tsx
156
+ * import { PntBusinessSelect } from '@pnt-team/pnt-component-frontend';
157
+ *
158
+ * // 最简用法
159
+ * <PntBusinessSelect
160
+ * value={gameId}
161
+ * onChange={(v) => setGameId(v as string)}
162
+ * />
163
+ *
164
+ * // SDK 定制场景 + 名称带 ID
165
+ * <PntBusinessSelect
166
+ * scene="sdk"
167
+ * labelFormat="name_id"
168
+ * value={gameId}
169
+ * onChange={(v) => setGameId(v as string)}
170
+ * />
171
+ *
172
+ * // 合规年龄场景(自动禁用 creatable=false 的业务)
173
+ * <PntBusinessSelect
174
+ * scene="compliance_age"
175
+ * value={gameId}
176
+ * onChange={(v) => setGameId(v as string)}
177
+ * />
178
+ *
179
+ * // 多选 + 状态过滤
180
+ * <PntBusinessSelect
181
+ * multiple
182
+ * statuses={[3, 4]}
183
+ * labelFormat="name_id"
184
+ * value={gameIds}
185
+ * onChange={(v) => setGameIds(v as string[])}
186
+ * />
187
+ * ```
188
+ *
189
+ * @returns {ReactElement} 业务选择器组件
190
+ */
191
+ declare const PntBusinessSelect: FC<PntBusinessSelectProps>;
192
+ export default PntBusinessSelect;
@@ -0,0 +1,513 @@
1
+ import { businessSelectList } from "../../api";
2
+ import { usePntConfig } from "../../config";
3
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+ import { Loading, Select, Tooltip } from 'tdesign-react';
5
+ import "./index.scss";
6
+
7
+ // ==========================================
8
+ // 类型定义
9
+ // ==========================================
10
+
11
+ /** 下拉场景 */
12
+
13
+ /** 标签格式 */
14
+ import { jsx as _jsx } from "react/jsx-runtime";
15
+ /**
16
+ * 业务选择器组件
17
+ * @description 统一业务下拉选择器,内置对接后端通用接口 `/api/business/select/list`,
18
+ * 支持远程搜索、滚动分页加载、多选、场景化禁用(合规场景 creatable 字段)。
19
+ *
20
+ * @param {PntBusinessSelectProps} props - 组件属性
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * import { PntBusinessSelect } from '@pnt-team/pnt-component-frontend';
25
+ *
26
+ * // 最简用法
27
+ * <PntBusinessSelect
28
+ * value={gameId}
29
+ * onChange={(v) => setGameId(v as string)}
30
+ * />
31
+ *
32
+ * // SDK 定制场景 + 名称带 ID
33
+ * <PntBusinessSelect
34
+ * scene="sdk"
35
+ * labelFormat="name_id"
36
+ * value={gameId}
37
+ * onChange={(v) => setGameId(v as string)}
38
+ * />
39
+ *
40
+ * // 合规年龄场景(自动禁用 creatable=false 的业务)
41
+ * <PntBusinessSelect
42
+ * scene="compliance_age"
43
+ * value={gameId}
44
+ * onChange={(v) => setGameId(v as string)}
45
+ * />
46
+ *
47
+ * // 多选 + 状态过滤
48
+ * <PntBusinessSelect
49
+ * multiple
50
+ * statuses={[3, 4]}
51
+ * labelFormat="name_id"
52
+ * value={gameIds}
53
+ * onChange={(v) => setGameIds(v as string[])}
54
+ * />
55
+ * ```
56
+ *
57
+ * @returns {ReactElement} 业务选择器组件
58
+ */
59
+ const PntBusinessSelect = props => {
60
+ const {
61
+ value,
62
+ onChange,
63
+ multiple = false,
64
+ scene = 'default',
65
+ statuses,
66
+ businessTypes,
67
+ labelFormat = 'name',
68
+ labelField,
69
+ optionLabel,
70
+ pagination = true,
71
+ onLoaded,
72
+ placeholder,
73
+ disabled,
74
+ clearable = true,
75
+ filterable = true,
76
+ max,
77
+ pageSize = 20,
78
+ searchDebounce = 400,
79
+ minCollapsedNum,
80
+ collapsedItems,
81
+ popupProps,
82
+ fields,
83
+ optionDisabled,
84
+ optionTooltip,
85
+ tooltipPlacement = 'top',
86
+ className,
87
+ style
88
+ } = props;
89
+
90
+ // ---- 内置请求函数(通过 PntProvider 注入的 http 实例) ----
91
+ const {
92
+ http
93
+ } = usePntConfig();
94
+ const requestFn = useCallback(params => businessSelectList(http, params), [http]);
95
+
96
+ // ---- 状态 ----
97
+ const [options, setOptions] = useState([]);
98
+ const [loading, setLoading] = useState(false);
99
+
100
+ // ---- Refs ----
101
+ const debounceRef = useRef();
102
+ const loadingRef = useRef(false);
103
+ /** 已选中项的完整数据缓存,确保搜索后选项仍能展示 label */
104
+ const selectedItemsRef = useRef(new Map());
105
+ /** 已发起过回填请求的 ID,避免重复请求 */
106
+ const requestedIdsRef = useRef(new Set());
107
+ /** 回填缓存更新标记,驱动 selectOptions 重新计算 */
108
+ const [cacheTick, setCacheTick] = useState(0);
109
+ /** onLoaded 用 ref 持有,避免消费方内联回调导致 fetchData 频繁重建 */
110
+ const onLoadedRef = useRef(onLoaded);
111
+ onLoadedRef.current = onLoaded;
112
+ /** 最新选项快照,供 fetchData 追加与 onLoaded 回调使用,避免进入请求依赖链 */
113
+ const optionsRef = useRef([]);
114
+ /** 请求序号:搜索/重置等替换请求发出后,在途的旧请求响应一律丢弃 */
115
+ const fetchSeqRef = useRef(0);
116
+ /** 翻页请求独立防重锁(不阻塞替换请求) */
117
+ const appendLoadingRef = useRef(false);
118
+ /** 当前列表元信息,触底回调据此校验,避免旧闭包的 keyword/pageNum 发错页 */
119
+ const listMetaRef = useRef({
120
+ keyword: '',
121
+ page: 0,
122
+ total: 0
123
+ });
124
+ /** 最新搜索词(输入即时同步,供关闭弹窗等场景读取) */
125
+ const keywordRef = useRef('');
126
+ /** 列表替换后的触底保护窗,吞掉内容骤短时浏览器 clamp 产生的残余滚动事件 */
127
+ const lastReplaceAtRef = useRef(0);
128
+ const SCROLL_GUARD_MS = 200;
129
+
130
+ // 数组类入参以内容序列化作为依赖,避免消费方内联字面量(fields={[...]})
131
+ // 每次渲染产生新引用导致 buildParams/fetchData 频繁重建、初始请求被反复触发
132
+ const fieldsKey = (fields || []).join(',');
133
+ const statusesKey = (statuses || []).join(',');
134
+ const businessTypesKey = (businessTypes || []).join(',');
135
+
136
+ // ---- 构建请求参数 ----
137
+ const buildParams = useCallback((kw, page) => ({
138
+ keyword: kw || undefined,
139
+ // 后端规则:pageNum > 0 才开启分页;不分页模式必须传 0
140
+ pageNum: pagination ? page : 0,
141
+ pageSize: pagination ? pageSize : 0,
142
+ scene,
143
+ statuses,
144
+ business_types: businessTypes,
145
+ fields
146
+ }),
147
+ // eslint-disable-next-line react-hooks/exhaustive-deps
148
+ [scene, statusesKey, businessTypesKey, pageSize, fieldsKey, pagination]);
149
+
150
+ // ---- 获取数据 ----
151
+ // append=true 为滚动翻页(追加),append=false 为初始化/搜索/重置(替换)
152
+ const fetchData = useCallback(async (kw, page, append) => {
153
+ if (append) {
154
+ if (appendLoadingRef.current || loadingRef.current) return;
155
+ const meta = listMetaRef.current;
156
+ if (meta.keyword !== kw) return;
157
+ if (page <= meta.page) return;
158
+ if (optionsRef.current.length >= meta.total) return;
159
+ appendLoadingRef.current = true;
160
+ }
161
+ const seq = ++fetchSeqRef.current;
162
+ loadingRef.current = true;
163
+ setLoading(true);
164
+ try {
165
+ const res = await requestFn(buildParams(kw, page));
166
+ if (seq !== fetchSeqRef.current) return;
167
+ if (res.code === 0 && res.data) {
168
+ const newList = res.data.list || [];
169
+ const resTotal = res.data.pagination?.total || 0;
170
+ let nextList;
171
+ if (append) {
172
+ const existed = new Set(optionsRef.current.map(o => o.game_id));
173
+ nextList = [...optionsRef.current, ...newList.filter(o => !existed.has(o.game_id))];
174
+ } else {
175
+ nextList = newList;
176
+ lastReplaceAtRef.current = Date.now();
177
+ }
178
+ optionsRef.current = nextList;
179
+ listMetaRef.current = {
180
+ keyword: kw,
181
+ page,
182
+ total: resTotal
183
+ };
184
+ setOptions(nextList);
185
+ onLoadedRef.current?.(nextList);
186
+ }
187
+ } catch {
188
+ // 请求失败静默处理,不阻塞主流程
189
+ } finally {
190
+ if (append) appendLoadingRef.current = false;
191
+ if (seq === fetchSeqRef.current) {
192
+ loadingRef.current = false;
193
+ setLoading(false);
194
+ }
195
+ }
196
+ }, [requestFn, buildParams]);
197
+
198
+ // ---- 远程搜索(防抖) ----
199
+ const handleSearch = useCallback(val => {
200
+ keywordRef.current = val;
201
+ if (debounceRef.current) clearTimeout(debounceRef.current);
202
+ debounceRef.current = setTimeout(() => {
203
+ fetchData(keywordRef.current, 1, false);
204
+ }, searchDebounce);
205
+ }, [fetchData, searchDebounce]);
206
+
207
+ // ---- 滚动触底加载下一页(全部读 ref,避免渲染闭包中的旧 keyword/pageNum) ----
208
+ const handleScrollToBottom = useCallback(() => {
209
+ // 列表刚替换后的短暂窗口内,触底多为滚动位置 clamp 的假事件,忽略
210
+ if (Date.now() - lastReplaceAtRef.current < SCROLL_GUARD_MS) return;
211
+ const meta = listMetaRef.current;
212
+ fetchData(meta.keyword, meta.page + 1, true);
213
+ }, [fetchData]);
214
+
215
+ // ---- 弹窗关闭时重置搜索 ----
216
+ const handlePopupVisibleChange = useCallback(visible => {
217
+ if (visible) return;
218
+ if (debounceRef.current) clearTimeout(debounceRef.current);
219
+ // 必须以「当前列表实际使用的关键词」为准,不能只看 keywordRef:
220
+ // 1) 单选选中一项后,TDesign 关闭弹窗时会先以 blur 触发 onSearch(''),
221
+ // 把 keywordRef 提前清空并挂一个重置 timer,随后本回调又会清掉该 timer,
222
+ // 若仅按 keywordRef 判断就会漏发重置请求;
223
+ // 2) 用户手动清空输入但未关闭下拉时,keywordRef 已为 '',但列表仍残留
224
+ // 上次搜索结果,关闭时必须重置回默认列表。
225
+ keywordRef.current = '';
226
+ if (listMetaRef.current.keyword) {
227
+ fetchData('', 1, false);
228
+ }
229
+ }, [fetchData]);
230
+
231
+ // ---- 初始加载 + 筛选条件变化时重新加载 ----
232
+ useEffect(() => {
233
+ fetchData('', 1, false);
234
+ }, [fetchData]);
235
+
236
+ // ---- 缓存已选中项(确保搜索后仍能展示 label) ----
237
+ useEffect(() => {
238
+ const selectedValues = (Array.isArray(value) ? value : value ? [value] : []).map(String);
239
+ options.forEach(item => {
240
+ if (item.game_id && selectedValues.includes(item.game_id)) {
241
+ selectedItemsRef.current.set(item.game_id, item);
242
+ }
243
+ });
244
+ }, [options, value]);
245
+
246
+ // ---- 编辑回显:已选 ID 不在当前分页、缓存也没有时,按 game_ids 拉取 ----
247
+ // 不分页模式已全量加载,缺失即查无此项,无需补拉
248
+ useEffect(() => {
249
+ if (!pagination) return;
250
+ const selectedValues = (Array.isArray(value) ? value : value ? [value] : []).map(String);
251
+ const optionIds = new Set(options.map(item => item.game_id).filter(Boolean));
252
+ const missingIds = selectedValues.filter(id => id && !optionIds.has(id) && !selectedItemsRef.current.has(id) && !requestedIdsRef.current.has(id));
253
+ if (missingIds.length === 0) return;
254
+ missingIds.forEach(id => requestedIdsRef.current.add(id));
255
+ let cancelled = false;
256
+ (async () => {
257
+ try {
258
+ const res = await requestFn({
259
+ ...buildParams('', 1),
260
+ pageNum: 1,
261
+ pageSize: missingIds.length,
262
+ game_ids: missingIds,
263
+ keyword: undefined
264
+ });
265
+ if (cancelled || res.code !== 0 || !res.data?.list) return;
266
+ res.data.list.forEach(item => {
267
+ if (item.game_id) selectedItemsRef.current.set(item.game_id, item);
268
+ });
269
+ setCacheTick(tick => tick + 1);
270
+ } catch {
271
+ // 回填失败时 tag 降级显示原始 ID,不阻塞主流程
272
+ }
273
+ })();
274
+ return () => {
275
+ cancelled = true;
276
+ };
277
+ }, [value, options, requestFn, buildParams, pagination]);
278
+
279
+ // ---- 组件卸载时清理防抖 ----
280
+ useEffect(() => {
281
+ return () => {
282
+ if (debounceRef.current) clearTimeout(debounceRef.current);
283
+ };
284
+ }, []);
285
+
286
+ // ---- 字符串标签(用于 tag 展示与过滤匹配) ----
287
+ // 不使用 optionLabel,因为它可能返回 ReactNode,无法作为 tag 文本
288
+ const getLabelText = useCallback(item => {
289
+ // 优先级:labelField > labelFormat
290
+ if (labelField) {
291
+ return item[labelField] || item.name || '';
292
+ }
293
+ const name = item.name || '';
294
+ const nameEn = item.name_en || '';
295
+ const gameId = item.game_id || '';
296
+ switch (labelFormat) {
297
+ case 'name_en':
298
+ return nameEn || name;
299
+ case 'name_id':
300
+ return `${name}(${gameId})`;
301
+ case 'name_en_id':
302
+ return `${nameEn}(${gameId})`;
303
+ default:
304
+ return name;
305
+ }
306
+ }, [labelFormat, labelField]);
307
+
308
+ // ---- 下拉选项富文本内容(可返回 ReactNode) ----
309
+ // 优先级:optionLabel > labelField > labelFormat
310
+ const formatLabel = useCallback(item => {
311
+ if (optionLabel) return optionLabel(item);
312
+ return getLabelText(item);
313
+ }, [optionLabel, getLabelText]);
314
+
315
+ // ---- 判断选项是否禁用(合规场景 creatable + 自定义 optionDisabled 叠加) ----
316
+ const isOptionDisabled = useCallback(item => {
317
+ if (scene === 'compliance_age' || scene === 'custom_region') {
318
+ if (item.creatable === false) return true;
319
+ }
320
+ return optionDisabled ? optionDisabled(item) : false;
321
+ }, [scene, optionDisabled]);
322
+
323
+ // ---- 计算选项 tooltip 内容 ----
324
+ const getOptionTooltip = useCallback(item => {
325
+ return optionTooltip ? optionTooltip(item) : '';
326
+ }, [optionTooltip]);
327
+
328
+ // ---- 构建最终选项(当前页 + 已选中项补充) ----
329
+ const selectOptions = useMemo(() => {
330
+ const map = new Map();
331
+
332
+ // 当前页选项
333
+ options.forEach(item => {
334
+ if (item.game_id) {
335
+ map.set(item.game_id, {
336
+ label: getLabelText(item),
337
+ content: formatLabel(item),
338
+ value: item.game_id,
339
+ disabled: isOptionDisabled(item),
340
+ name: item.name,
341
+ name_en: item.name_en,
342
+ game_id: item.game_id,
343
+ rawItem: item
344
+ });
345
+ }
346
+ });
347
+
348
+ // 已选中项(搜索结果不含时补充,确保 label 始终可展示)
349
+ const selectedValues = (Array.isArray(value) ? value : value ? [value] : []).map(String);
350
+ selectedValues.forEach(id => {
351
+ if (!map.has(id)) {
352
+ const item = selectedItemsRef.current.get(id);
353
+ if (item) {
354
+ map.set(id, {
355
+ label: getLabelText(item),
356
+ content: formatLabel(item),
357
+ value: id,
358
+ disabled: isOptionDisabled(item),
359
+ name: item.name,
360
+ name_en: item.name_en,
361
+ game_id: item.game_id,
362
+ rawItem: item
363
+ });
364
+ } else {
365
+ // 降级:无完整数据时显示原始 ID
366
+ map.set(id, {
367
+ label: id,
368
+ content: id,
369
+ value: id,
370
+ disabled: false
371
+ });
372
+ }
373
+ }
374
+ });
375
+ return Array.from(map.values());
376
+ // eslint-disable-next-line react-hooks/exhaustive-deps
377
+ }, [options, value, formatLabel, getLabelText, isOptionDisabled, cacheTick]);
378
+
379
+ // ---- 本地过滤(与后端 keyword 逻辑一致:name / name_en / game_id) ----
380
+ const localFilter = useCallback((filterWord, option) => {
381
+ if (!filterWord) return true;
382
+ const kw = filterWord.toLowerCase();
383
+ const opt = option;
384
+ // 优先按 rawItem 的所有字符串字段匹配(含 labelField 指定字段)
385
+ const item = opt?.rawItem;
386
+ if (item) {
387
+ return Object.values(item).some(v => typeof v === 'string' && v.toLowerCase().includes(kw));
388
+ }
389
+ return (opt?.name || '').toLowerCase().includes(kw) || (opt?.name_en || '').toLowerCase().includes(kw) || (opt?.game_id || '').includes(kw);
390
+ }, []);
391
+ const {
392
+ Option
393
+ } = Select;
394
+
395
+ // 是否启用 tooltip(仅当 optionTooltip 传入时生效)
396
+ const hasTooltip = !!optionTooltip;
397
+
398
+ // 一次性加载模式:仅本地过滤,不触发远程搜索;不分页时禁用滚动触底
399
+ const enableRemoteSearch = filterable && pagination;
400
+ const enableScrollBottom = pagination;
401
+
402
+ // 统一将 value 归一化为字符串,与 Option 的 value(game_id 字符串)匹配,
403
+ // 避免消费方传入 number / number[] 时 tag 无法正确回显
404
+ const selectValue = useMemo(() => {
405
+ if (value === undefined || value === null) return value;
406
+ return Array.isArray(value) ? value.map(String) : String(value);
407
+ }, [value]);
408
+
409
+ // 包装 onChange:TDesign 在 children 模式下会把 Option 的 children(optionLabel
410
+ // 返回的 ReactNode,dev 环境带 _owner Fiber,存在循环引用)一并放进
411
+ // context.selectedOptions,消费方直接展开后提交会导致 JSON.stringify 报
412
+ // "Converting circular structure to JSON"。这里统一替换为纯业务数据对象。
413
+
414
+ const handleChange = useCallback((val, context) => {
415
+ if (!onChange) return;
416
+ const values = (Array.isArray(val) ? val : val !== undefined && val !== null ? [val] : []).map(String);
417
+ const ctxOptions = context?.selectedOptions || [];
418
+ const selectedOptions = values.map(id => {
419
+ const raw = optionsRef.current.find(o => o.game_id === id) || selectedItemsRef.current.get(id);
420
+ if (raw) {
421
+ return {
422
+ ...raw,
423
+ value: raw.game_id,
424
+ label: getLabelText(raw)
425
+ };
426
+ }
427
+ // 兜底:从 TDesign 上下文剥离 React 字段(children/content)
428
+ const fromCtx = ctxOptions.find(o => String(o.value) === id);
429
+ if (fromCtx) {
430
+ const rest = {
431
+ ...fromCtx
432
+ };
433
+ delete rest.children;
434
+ delete rest.content;
435
+ return rest;
436
+ }
437
+ return {
438
+ value: id,
439
+ label: id
440
+ };
441
+ });
442
+ onChange(val, {
443
+ ...context,
444
+ selectedOptions: multiple ? selectedOptions : selectedOptions.slice(0, 1)
445
+ });
446
+ }, [onChange, multiple, getLabelText]);
447
+
448
+ // 点击 clear 按钮(X)时重置列表——TDesign 的 onClearValue 只 setInputValue,
449
+ // 不触发 handleInputChange / onSearch,所以需要主动重置。
450
+ const handleClear = useCallback(() => {
451
+ keywordRef.current = '';
452
+ if (debounceRef.current) clearTimeout(debounceRef.current);
453
+ if (listMetaRef.current.keyword) {
454
+ fetchData('', 1, false);
455
+ }
456
+ }, [fetchData]);
457
+ return /*#__PURE__*/_jsx(Select, {
458
+ value: selectValue,
459
+ onChange: handleChange,
460
+ filterable: filterable,
461
+ filter: enableRemoteSearch ? undefined : filterable ? localFilter : undefined,
462
+ onSearch: enableRemoteSearch ? handleSearch : undefined,
463
+ onClear: enableRemoteSearch ? handleClear : undefined,
464
+ loading: loading && options.length === 0,
465
+ multiple: multiple,
466
+ max: max,
467
+ clearable: clearable,
468
+ disabled: disabled,
469
+ placeholder: placeholder,
470
+ minCollapsedNum: minCollapsedNum,
471
+ collapsedItems: collapsedItems,
472
+ onPopupVisibleChange: handlePopupVisibleChange,
473
+ panelBottomContent: options.length > 0 && loading ? /*#__PURE__*/_jsx(Loading, {
474
+ loading: true,
475
+ size: "small"
476
+ }) : null,
477
+ popupProps: {
478
+ ...popupProps,
479
+ // 自动注入弹层样式类,消费方无需重复定义 overlayClassName
480
+ overlayClassName: ['pnt-business-select-popup', popupProps?.overlayClassName].filter(Boolean).join(' '),
481
+ onScrollToBottom: enableScrollBottom ? handleScrollToBottom : undefined
482
+ },
483
+ className: className ? `pnt-business-select ${className}` : 'pnt-business-select',
484
+ style: style,
485
+ children: selectOptions.map(opt => {
486
+ const item = opt.rawItem;
487
+ const tip = hasTooltip && item ? getOptionTooltip(item) : '';
488
+ // 透传业务字段:TDesign 会将 Option 的 props 合入 onChange 的
489
+ // context.selectedOptions,消费方依赖 name / sdk_customize_notifier 等字段;
490
+ // value / label / disabled 放在后面覆盖,保证以组件内部计算为准
491
+ const optionProps = {
492
+ ...(item || {}),
493
+ value: opt.value,
494
+ label: opt.label,
495
+ disabled: opt.disabled
496
+ };
497
+ // Tooltip 必须放在 Option 内部:Select 通过直接子节点识别 Option,
498
+ // 外层包裹会破坏选项注册;且禁用项的 hover 事件由内部元素接收才稳定
499
+ return /*#__PURE__*/_jsx(Option, {
500
+ ...optionProps,
501
+ children: tip ? /*#__PURE__*/_jsx(Tooltip, {
502
+ content: tip,
503
+ placement: tooltipPlacement,
504
+ children: /*#__PURE__*/_jsx("span", {
505
+ className: "pnt-business-select-option-label",
506
+ children: opt.content
507
+ })
508
+ }) : opt.content
509
+ }, opt.value);
510
+ })
511
+ });
512
+ };
513
+ export default PntBusinessSelect;