@kne/system-layout 0.2.0-alpha.22 → 0.2.0-alpha.24

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/README.md CHANGED
@@ -161,18 +161,25 @@ render(<BaseExample />);
161
161
  ```
162
162
 
163
163
  - Page 组件
164
- - Page 组件的各种功能:返回按钮、自定义内容、函数式子组件、加载状态、navbar/toolbar 控制等
164
+ - Page 组件的各种功能:返回按钮、自定义内容、仅头像 / 头像+操作、函数式子组件、加载状态、navbar/toolbar 控制等;内容区加长,便于在手机预览中滚动查看导航栏胶囊态
165
165
  - _SystemLayout(@kne/current-lib_system-layout)[import * as _SystemLayout from "@kne/system-layout"],(@kne/current-lib_system-layout/dist/index.css),antd(antd)
166
166
 
167
167
  ```jsx
168
168
  const { default: SystemLayout, Page } = _SystemLayout;
169
169
  const { useState } = React;
170
- const { Segmented, Button, Flex, Card, Alert, Descriptions, Empty } = antd;
170
+ const { Segmented, Button, Flex, Card, Alert, Descriptions, Empty, Tag, Typography } = antd;
171
+ const { Text } = Typography;
172
+
173
+ const DEMO_AVATAR = 'https://api.dicebear.com/7.x/avataaars/svg?seed=Lucy';
171
174
 
172
175
  const MODE_OPTIONS = [
173
176
  { label: '基础', value: 'basic' },
174
177
  { label: '返回', value: 'back' },
175
178
  { label: 'Extra', value: 'extra' },
179
+ { label: '仅头像', value: 'avatarOnly' },
180
+ { label: '头像+操作', value: 'avatarActions' },
181
+ { label: '无头像', value: 'noAvatar' },
182
+ { label: '无右侧', value: 'noRight' },
176
183
  { label: '无填充', value: 'noPadding' },
177
184
  { label: '函数式', value: 'function' },
178
185
  { label: '无导航', value: 'noNavbar' },
@@ -182,13 +189,37 @@ const MODE_OPTIONS = [
182
189
  const MODE_TIPS = {
183
190
  basic: '标准页面:标题栏 + 操作按钮组 + 内容区。',
184
191
  back: '已启用返回按钮(back),点击会调用 navigate(-1)。',
185
- extra: '已自定义 extra 内容,替代默认的按钮组。',
192
+ extra: '已自定义 extra 内容;移动端仍会在右侧保留头像。',
193
+ avatarOnly: '仅展示用户头像:无 buttonProps / extra,移动端右侧只有头像胶囊。',
194
+ avatarActions: '头像 + 额外操作:buttonProps 收起到「更多」,与头像并排;滚动后可看胶囊态。',
195
+ noAvatar: '无用户头像:userInfo 不传 avatar,移动端右侧仅保留操作(更多)或为空。',
196
+ noRight: '无右侧操作与头像:不传 buttonProps / extra / avatar,滚动后仅左侧标题胶囊。',
186
197
  noPadding: '已移除内容区内边距(noPadding),适合放置全屏地图、表格等。',
187
198
  function: '函数式子组件模式:Page 将渲染控制权交给 children 函数。',
188
199
  noNavbar: '已隐藏顶部导航栏(navbar=false)。',
189
200
  noToolbar: '已隐藏底部工具栏(toolbar=false)。'
190
201
  };
191
202
 
203
+ const DEFAULT_BUTTON_PROPS = {
204
+ showLength: 1,
205
+ list: [
206
+ { type: 'primary', children: 'New' },
207
+ { children: 'Options' },
208
+ { children: 'Export' }
209
+ ]
210
+ };
211
+
212
+ const SCROLL_DEMO_ITEMS = [
213
+ { title: '滚动观察点 1', tip: '继续向下滚动,观察顶部导航是否变为胶囊态' },
214
+ { title: '滚动观察点 2', tip: '超过导航高度后,标题栏应收成毛玻璃胶囊' },
215
+ { title: '滚动观察点 3', tip: '右侧操作区(更多 + 头像)也应出现悬浮胶囊' },
216
+ { title: '滚动观察点 4', tip: '回顶后导航应恢复常态白底样式' },
217
+ { title: '滚动观察点 5', tip: '可切换「仅头像 / 头像+操作」,对比右侧胶囊内容' },
218
+ { title: '滚动观察点 6', tip: '手机预览下请在设备框内滚动,而不是滚动外层文档' },
219
+ { title: '滚动观察点 7', tip: '胶囊态按钮为 link 样式,常态不强制改写按钮外观' },
220
+ { title: '滚动观察点 8', tip: '内容足够长即可反复验证固定与收缩效果' }
221
+ ];
222
+
192
223
  const ModeSwitcher = ({ mode, setMode }) => (
193
224
  <Segmented block value={mode} onChange={setMode} options={MODE_OPTIONS} />
194
225
  );
@@ -211,12 +242,61 @@ const FunctionParams = () => (
211
242
  ]} />
212
243
  );
213
244
 
245
+ const ScrollDemoList = () => (
246
+ <Flex vertical gap={12}>
247
+ <Alert
248
+ type="success"
249
+ showIcon
250
+ message="滚动演示"
251
+ description="在手机模式下向下滚动本页,可查看导航栏从常态切换为固定胶囊态的效果。"
252
+ />
253
+ {SCROLL_DEMO_ITEMS.map((item, index) => (
254
+ <Card
255
+ key={item.title}
256
+ size="small"
257
+ style={{ background: 'rgba(255,255,255,0.55)', minHeight: 120 }}
258
+ title={
259
+ <Flex align="center" gap={8}>
260
+ <Tag color="blue">{index + 1}</Tag>
261
+ <span>{item.title}</span>
262
+ </Flex>
263
+ }
264
+ >
265
+ <Text type="secondary">{item.tip}</Text>
266
+ <div style={{ marginTop: 12, height: 48, borderRadius: 8, background: 'rgba(22,119,255,0.06)' }} />
267
+ </Card>
268
+ ))}
269
+ </Flex>
270
+ );
271
+
272
+ const resolveButtonProps = mode => {
273
+ if (mode === 'avatarOnly' || mode === 'extra' || mode === 'noRight') {
274
+ return undefined;
275
+ }
276
+ if (mode === 'avatarActions' || mode === 'noAvatar') {
277
+ return {
278
+ showLength: 1,
279
+ list: [
280
+ { type: 'primary', children: '新建' },
281
+ { children: '导入' },
282
+ { children: '导出' }
283
+ ]
284
+ };
285
+ }
286
+ return DEFAULT_BUTTON_PROPS;
287
+ };
288
+
214
289
  const BaseExample = () => {
215
290
  const [mode, setMode] = useState('basic');
216
291
  const [loading, setLoading] = useState(false);
292
+ const buttonProps = resolveButtonProps(mode);
293
+ const userInfo =
294
+ mode === 'noAvatar' || mode === 'noRight'
295
+ ? { name: 'Lucy L', email: 'lucy@company.com' }
296
+ : { name: 'Lucy L', email: 'lucy@company.com', avatar: DEMO_AVATAR };
217
297
 
218
298
  return (
219
- <SystemLayout userInfo={{ name: 'Lucy L', email: 'lucy@company.com' }} menu={menu}>
299
+ <SystemLayout userInfo={userInfo} menu={menu}>
220
300
  <Page
221
301
  title="Page 组件演示"
222
302
  back={mode === 'back'}
@@ -224,14 +304,7 @@ const BaseExample = () => {
224
304
  noPadding={mode === 'noPadding'}
225
305
  navbar={mode !== 'noNavbar'}
226
306
  toolbar={mode !== 'noToolbar'}
227
- buttonProps={{
228
- showLength: 1,
229
- list: [
230
- { type: 'primary', children: 'New' },
231
- { children: 'Options' },
232
- { children: 'Export' }
233
- ]
234
- }}
307
+ buttonProps={buttonProps}
235
308
  >
236
309
  {mode === 'function'
237
310
  ? ({ render, pageLoading }) => {
@@ -256,6 +329,7 @@ const BaseExample = () => {
256
329
  >
257
330
  模拟加载(展示骨架屏)
258
331
  </Button>
332
+ <ScrollDemoList />
259
333
  </Flex>
260
334
  )
261
335
  });
@@ -264,12 +338,15 @@ const BaseExample = () => {
264
338
  <Flex vertical gap={16}>
265
339
  <ModeSwitcher mode={mode} setMode={setMode} />
266
340
  {mode === 'noPadding' ? (
267
- <Flex
268
- align="center"
269
- justify="center"
270
- style={{ background: 'rgba(0,0,0,0.04)', height: 300, borderRadius: 8 }}
271
- >
272
- <Empty description="无内边距内容区(noPadding)" />
341
+ <Flex vertical gap={12}>
342
+ <Flex
343
+ align="center"
344
+ justify="center"
345
+ style={{ background: 'rgba(0,0,0,0.04)', height: 300, borderRadius: 8 }}
346
+ >
347
+ <Empty description="无内边距内容区(noPadding)" />
348
+ </Flex>
349
+ <ScrollDemoList />
273
350
  </Flex>
274
351
  ) : (
275
352
  <>
@@ -277,10 +354,25 @@ const BaseExample = () => {
277
354
  <Card size="small" style={{ background: 'rgba(255,255,255,0.5)' }}>
278
355
  <Descriptions column={1} size="small" items={[
279
356
  { key: 'mode', label: '当前模式', children: mode },
357
+ {
358
+ key: 'avatar',
359
+ label: '头像',
360
+ children:
361
+ mode === 'avatarOnly'
362
+ ? '仅头像,无额外操作'
363
+ : mode === 'avatarActions'
364
+ ? '头像 + 更多操作'
365
+ : mode === 'noAvatar'
366
+ ? '无头像,仅更多操作'
367
+ : mode === 'noRight'
368
+ ? '无右侧操作与头像'
369
+ : '跟随当前模式'
370
+ },
280
371
  { key: 'desc', label: '说明', children: 'Page 提供标题栏、返回按钮、操作按钮组与自定义内容。' },
281
372
  { key: 'mobile', label: '移动端', children: '标题栏固定在顶部,向下滚动时收缩为胶囊样式。' }
282
373
  ]} />
283
374
  </Card>
375
+ <ScrollDemoList />
284
376
  </>
285
377
  )}
286
378
  </Flex>
@@ -778,6 +870,489 @@ render(<BaseExample />);
778
870
 
779
871
  ```
780
872
 
873
+ - 表格列表页
874
+ - 在 SystemLayout + Page 中接入 @kne/table-page:用 useScrollElement 绑定页面滚动实现 sticky 表头,覆盖 TablePage 自带的 tab / filter / search、分页、排序与批量操作,贴近招聘候选人列表场景
875
+ - _SystemLayout(@kne/current-lib_system-layout)[import * as _SystemLayout from "@kne/system-layout"],(@kne/current-lib_system-layout/dist/index.css),_TablePage(@kne/table-page)[import * as _TablePage from "@kne/table-page"],(@kne/table-page/dist/index.css),_ReactFilter(@kne/react-filter)[import * as _ReactFilter from "@kne/react-filter"],(@kne/react-filter/dist/index.css),antd(antd)
876
+
877
+ ```jsx
878
+ const { default: SystemLayout, Page, useScrollElement, useIsMobile } = _SystemLayout;
879
+ const { default: TablePage, Table } = _TablePage;
880
+ const { fields } = _ReactFilter;
881
+ const { SuperSelectFilterItem } = fields;
882
+ const { Flex, Tag, message, Button } = antd;
883
+ const { useMemo } = React;
884
+
885
+ const TOTAL = 96;
886
+ const PAGE_HEADER_HEIGHT = 48;
887
+
888
+ const STAGES = [
889
+ { value: 'screening', label: '简历筛选', color: 'default' },
890
+ { value: 'interview', label: '面试中', color: 'processing' },
891
+ { value: 'offer', label: '已发 Offer', color: 'success' },
892
+ { value: 'rejected', label: '未通过', color: 'error' }
893
+ ];
894
+
895
+ const DEPARTMENTS = ['技术部', '产品部', '设计部', '数据部', '运营部'];
896
+
897
+ const POSITIONS = [
898
+ { title: '高级前端工程师', dept: '技术部' },
899
+ { title: '产品经理', dept: '产品部' },
900
+ { title: 'UI/UX 设计师', dept: '设计部' },
901
+ { title: '数据分析师', dept: '数据部' },
902
+ { title: '增长运营专员', dept: '运营部' }
903
+ ];
904
+
905
+ const FIRST_NAMES = ['伟', '芳', '娜', '秀英', '敏', '静', '强', '磊', '洋', '艳', '勇', '军', '杰', '娟', '涛', '明'];
906
+ const SURNAMES = ['王', '李', '张', '刘', '陈', '杨', '赵', '黄', '周', '吴', '徐', '孙', '马', '朱', '胡', '林'];
907
+
908
+ const stageMap = Object.fromEntries(
909
+ STAGES.map(({ value, label, color }) => [value, { type: color === 'default' ? 'default' : color, text: label }])
910
+ );
911
+
912
+ const stageOptions = STAGES.map(({ value, label }) => ({ value, label }));
913
+ const departmentOptions = DEPARTMENTS.map(item => ({ value: item, label: item }));
914
+ const locationOptions = ['北京', '上海', '深圳', '杭州', '广州', '成都'].map(item => ({ value: item, label: item }));
915
+
916
+ const buildCandidate = index => {
917
+ const position = POSITIONS[index % POSITIONS.length];
918
+ const stage = STAGES[index % STAGES.length];
919
+ return {
920
+ id: &#96;CAND${String(index + 1).padStart(4, '0')}&#96;,
921
+ name: &#96;${SURNAMES[index % SURNAMES.length]}${FIRST_NAMES[(index * 3) % FIRST_NAMES.length]}&#96;,
922
+ position: position.title,
923
+ department: position.dept,
924
+ stage: stage.value,
925
+ matchScore: 60 + ((index * 7) % 40),
926
+ experience: 1 + (index % 12),
927
+ location: ['北京', '上海', '深圳', '杭州', '广州', '成都'][index % 6],
928
+ appliedAt: &#96;2024-${String((index % 12) + 1).padStart(2, '0')}-${String((index % 28) + 1).padStart(2, '0')}&#96;,
929
+ phone: &#96;138${String(index).padStart(8, '0')}&#96;,
930
+ email: &#96;candidate${index + 1}@mail.com&#96;
931
+ };
932
+ };
933
+
934
+ const normalizeFilterValue = value => {
935
+ if (value == null) {
936
+ return value;
937
+ }
938
+ return Array.isArray(value) ? value[0] : value;
939
+ };
940
+
941
+ const applyFilters = (list, data, requestParams) => {
942
+ const params = Object.assign({}, requestParams?.data, data);
943
+ let result = list;
944
+
945
+ if (params.keyword) {
946
+ const keyword = String(params.keyword).toLowerCase();
947
+ result = result.filter(
948
+ item => item.name.includes(params.keyword) || item.position.toLowerCase().includes(keyword) || item.id.toLowerCase().includes(keyword)
949
+ );
950
+ }
951
+
952
+ const stage = normalizeFilterValue(params.stage);
953
+ if (stage) {
954
+ result = result.filter(item => item.stage === stage);
955
+ }
956
+
957
+ const department = normalizeFilterValue(params.department);
958
+ if (department) {
959
+ result = result.filter(item => item.department === department);
960
+ }
961
+
962
+ const location = normalizeFilterValue(params.location);
963
+ if (location) {
964
+ result = result.filter(item => item.location === location);
965
+ }
966
+
967
+ return result;
968
+ };
969
+
970
+ const columns = [
971
+ {
972
+ name: 'id',
973
+ title: '候选人编号',
974
+ width: 140,
975
+ min: 120,
976
+ max: 200,
977
+ fixed: 'left',
978
+ renderType: 'main',
979
+ primary: true,
980
+ onClick: ({ item }) => {
981
+ message.info(&#96;查看候选人:${item}&#96;);
982
+ }
983
+ },
984
+ { name: 'name', title: '姓名', width: 100, min: 80, max: 140, renderType: 'main' },
985
+ { name: 'position', title: '应聘职位', width: 160, min: 120, max: 220 },
986
+ { name: 'department', title: '用人部门', width: 120, min: 100, max: 180 },
987
+ {
988
+ name: 'stage',
989
+ title: '阶段',
990
+ width: 110,
991
+ renderType: 'status',
992
+ getValueOf: item => stageMap[item.stage] || { type: 'default', text: item.stage }
993
+ },
994
+ {
995
+ name: 'matchScore',
996
+ title: '匹配度',
997
+ width: 100,
998
+ min: 80,
999
+ max: 140,
1000
+ sort: true,
1001
+ render: value => &#96;${value}%&#96;
1002
+ },
1003
+ { name: 'experience', title: '经验', width: 90, render: value => &#96;${value} 年&#96; },
1004
+ { name: 'location', title: '城市', width: 90 },
1005
+ { name: 'appliedAt', title: '投递日期', width: 120, format: 'date', sort: true },
1006
+ { name: 'phone', title: '手机号', width: 140, render: value => value.replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3') },
1007
+ { name: 'email', title: '邮箱', width: 200, ellipsis: true, hidden: true },
1008
+ {
1009
+ name: 'options',
1010
+ title: '操作',
1011
+ renderType: 'options',
1012
+ fixed: 'right',
1013
+ width: 160,
1014
+ min: 120,
1015
+ max: 200,
1016
+ getValueOf: item => {
1017
+ const actions = [
1018
+ { children: '查看', onClick: () => message.info(&#96;查看 ${item.name}&#96;) },
1019
+ { children: '安排面试', onClick: () => message.success(&#96;已为 ${item.name} 安排面试&#96;) }
1020
+ ];
1021
+ if (item.stage !== 'rejected' && item.stage !== 'offer') {
1022
+ actions.push({
1023
+ children: '淘汰',
1024
+ onClick: () => message.warning(&#96;已淘汰 ${item.name}&#96;)
1025
+ });
1026
+ }
1027
+ return actions;
1028
+ }
1029
+ }
1030
+ ];
1031
+
1032
+ const menu = {
1033
+ base: '/SystemLayout',
1034
+ items: [
1035
+ { path: '/', label: 'Onboarding', toolbar: true, icon: ({ active }) => (active ? 'home' : 'home_line') },
1036
+ { group: 'HIRING', path: '/hiring', label: 'Hiring Hub', toolbar: true, icon: 'icon-assignment_ind' },
1037
+ { group: 'HIRING', path: '/hiring/application', label: 'Application List', toolbar: true, icon: 'icon-assignment' },
1038
+ { group: 'PEOPLE', path: '/people', label: 'Management', toolbar: true, icon: 'icon-automation' }
1039
+ ]
1040
+ };
1041
+
1042
+ const CandidateTable = () => {
1043
+ const getScrollElement = useScrollElement();
1044
+ const isMobile = useIsMobile();
1045
+ const tableRef = React.useRef();
1046
+ const allCandidates = useMemo(() => Array.from({ length: TOTAL }, (_, index) => buildCandidate(index)), []);
1047
+ const { selectedRows, getRowSelection } = Table.useSelectedRow({ rowKey: 'id' });
1048
+ const { sortRender, mobileSortToolbar } = Table.useSort({
1049
+ defaultSort: [{ name: 'appliedAt', sort: 'DESC' }],
1050
+ onSortChange: newSort => {
1051
+ tableRef.current?.reload({
1052
+ data: { currentPage: 1, sort: newSort }
1053
+ });
1054
+ }
1055
+ });
1056
+
1057
+ return (
1058
+ <TablePage
1059
+ ref={tableRef}
1060
+ name="system-layout-candidate-table"
1061
+ sticky
1062
+ scrollTopInset={isMobile ? PAGE_HEADER_HEIGHT : 0}
1063
+ getScrollContainer={getScrollElement}
1064
+ scroll={{ x: 1400 }}
1065
+ size="large"
1066
+ renderMobile
1067
+ sortRender={sortRender}
1068
+ mobileSortToolbar={mobileSortToolbar}
1069
+ rowSelection={getRowSelection(allCandidates)}
1070
+ selectedRows={selectedRows}
1071
+ search={{ name: 'keyword', label: '关键词', placeholder: '搜索姓名/职位/编号', style: { width: 220 } }}
1072
+ tab={{
1073
+ name: 'stage',
1074
+ label: '阶段',
1075
+ list: stageOptions
1076
+ }}
1077
+ tabProps={{
1078
+ tabBarExtraContent: (
1079
+ <Button type="link" size="small" onClick={() => message.info('新建招聘需求')}>
1080
+ 新建需求
1081
+ </Button>
1082
+ )
1083
+ }}
1084
+ filter={{
1085
+ list: [
1086
+ [
1087
+ {
1088
+ type: SuperSelectFilterItem,
1089
+ props: { name: 'department', label: '部门', single: true, options: departmentOptions }
1090
+ },
1091
+ {
1092
+ type: SuperSelectFilterItem,
1093
+ props: { name: 'location', label: '城市', single: true, options: locationOptions }
1094
+ }
1095
+ ]
1096
+ ],
1097
+ displayLine: 1
1098
+ }}
1099
+ batchActions={[
1100
+ {
1101
+ key: 'export',
1102
+ label: '批量导出',
1103
+ onClick: ({ selectedRowKeys }) => {
1104
+ message.info(&#96;正在导出 ${selectedRowKeys.length} 位候选人&#96;);
1105
+ }
1106
+ },
1107
+ {
1108
+ key: 'notify',
1109
+ label: '批量通知',
1110
+ onClick: ({ selectedRowKeys }) => {
1111
+ message.success(&#96;已通知 ${selectedRowKeys.length} 位候选人&#96;);
1112
+ }
1113
+ }
1114
+ ]}
1115
+ pagination={{
1116
+ open: true,
1117
+ pageSize: 10,
1118
+ cachePageSize: false,
1119
+ showSizeChanger: true,
1120
+ showQuickJumper: true,
1121
+ pageSizeOptions: ['10', '20', '50']
1122
+ }}
1123
+ dataFormat={data => ({
1124
+ list: data.pageData,
1125
+ total: data.totalCount,
1126
+ data
1127
+ })}
1128
+ loader={({ data, requestParams }) => {
1129
+ const currentPage = Number(data?.currentPage ?? requestParams?.data?.currentPage) || 1;
1130
+ const perPage = Number(data?.perPage ?? requestParams?.data?.perPage) || 10;
1131
+ const sortParams = data?.sort ?? requestParams?.data?.sort ?? [{ name: 'appliedAt', sort: 'DESC' }];
1132
+ const filtered = applyFilters(allCandidates, data, requestParams);
1133
+ const sorted = sortParams.length ? Table.sortDataSource(filtered, sortParams, columns) : filtered;
1134
+ const start = (currentPage - 1) * perPage;
1135
+
1136
+ return new Promise(resolve => {
1137
+ setTimeout(() => {
1138
+ resolve({
1139
+ pageData: sorted.slice(start, start + perPage),
1140
+ totalCount: filtered.length
1141
+ });
1142
+ }, 400);
1143
+ });
1144
+ }}
1145
+ columns={columns}
1146
+ />
1147
+ );
1148
+ };
1149
+
1150
+ const BaseExample = () => {
1151
+ return (
1152
+ <SystemLayout userInfo={{ name: 'Lucy L', email: 'lucy@company.com' }} menu={menu}>
1153
+ <Page
1154
+ title="候选人列表"
1155
+ buttonProps={{
1156
+ showLength: 1,
1157
+ list: [
1158
+ { type: 'primary', children: '新建候选人' },
1159
+ { children: '导入简历' }
1160
+ ]
1161
+ }}
1162
+ >
1163
+ <Flex vertical gap={12}>
1164
+ <Flex gap={8} wrap="wrap" align="center">
1165
+ <Tag color="blue">Layout 滚动</Tag>
1166
+ <span style={{ color: '#666', fontSize: 13 }}>
1167
+ 通过 <code>useScrollElement</code> 绑定 Layout 滚动容器;使用 TablePage 自带的 <code>tab</code> / <code>filter</code> /{' '}
1168
+ <code>search</code> 与分页
1169
+ </span>
1170
+ </Flex>
1171
+ <CandidateTable />
1172
+ </Flex>
1173
+ </Page>
1174
+ </SystemLayout>
1175
+ );
1176
+ };
1177
+
1178
+ render(<BaseExample />);
1179
+
1180
+ ```
1181
+
1182
+ - Tenant 租户设置
1183
+ - 参考 telent-coach:Authenticate + SystemLayout + Tenant@Setting,演示公司/组织/用户/权限设置页;preset 已注册 components-admin,接口走 mock 数据
1184
+ - _SystemLayout(@kne/current-lib_system-layout)[import * as _SystemLayout from "@kne/system-layout"],(@kne/current-lib_system-layout/dist/index.css),remoteLoader(@kne/remote-loader),reactRouterDom(react-router-dom),icons(@ant-design/icons),antd(antd)
1185
+
1186
+ ```jsx
1187
+ const { default: SystemLayout, Page } = _SystemLayout;
1188
+ const { createWithRemoteLoader } = remoteLoader;
1189
+ const { Routes, Route, Navigate, useLocation } = reactRouterDom;
1190
+ const { UserSwitchOutlined, LogoutOutlined, PartitionOutlined, UserOutlined, SafetyCertificateOutlined, HomeOutlined } = icons;
1191
+ const { message } = antd;
1192
+
1193
+ /**
1194
+ * 参考 telent-coach/src/components/Tenant:
1195
+ * Authenticate 拉取租户用户信息 → SystemLayout 承载菜单 → Setting.* 用 Page 渲染设置页
1196
+ * 接口走 example/src/preset.js 中注册的 mock apis.tenant.*
1197
+ *
1198
+ * menu.base 取当前示例路由前缀(/:id/*),避免跳到 /SystemLayout 后丢失示例页
1199
+ */
1200
+ const TenantDemoInner = createWithRemoteLoader({
1201
+ modules: [
1202
+ 'components-admin:Tenant@Authenticate',
1203
+ 'components-admin:Tenant@Setting',
1204
+ 'components-core:Permissions',
1205
+ 'components-core:Global@SetGlobal'
1206
+ ]
1207
+ })(({ remoteModules, base }) => {
1208
+ const [Authenticate, Setting, Permissions, SetGlobal] = remoteModules;
1209
+
1210
+ return (
1211
+ <Authenticate>
1212
+ {({ global }) => {
1213
+ const { tenantUserInfo, tenant } = global;
1214
+ return (
1215
+ <SetGlobal globalKey="tenant" value={tenant}>
1216
+ <SetGlobal globalKey="userInfo" value={{ tenantUserInfo, tenant }}>
1217
+ <Permissions request={['tenant']} type="error">
1218
+ <SystemLayout
1219
+ logo={{ id: tenant?.logo, src: tenant?.logo }}
1220
+ userInfo={tenantUserInfo}
1221
+ background="linear-gradient(180deg, #E8DCDF, #E1D1E3, #DED7EF, #D5E0F1)"
1222
+ menu={{
1223
+ base,
1224
+ items: [
1225
+ {
1226
+ path: '/',
1227
+ label: '工作台',
1228
+ toolbar: true,
1229
+ icon: <HomeOutlined />
1230
+ },
1231
+ {
1232
+ group: 'tenantSetting',
1233
+ groupLabel: '系统设置',
1234
+ label: '公司信息',
1235
+ path: '/setting/company',
1236
+ icon: { type: 'gongsi' }
1237
+ },
1238
+ {
1239
+ group: 'tenantSetting',
1240
+ groupLabel: '系统设置',
1241
+ label: '组织架构',
1242
+ path: '/setting/org',
1243
+ icon: <PartitionOutlined />
1244
+ },
1245
+ {
1246
+ group: 'tenantSetting',
1247
+ groupLabel: '系统设置',
1248
+ label: '用户管理',
1249
+ path: '/setting/user',
1250
+ toolbar: true,
1251
+ icon: <UserOutlined />
1252
+ },
1253
+ {
1254
+ group: 'tenantSetting',
1255
+ groupLabel: '系统设置',
1256
+ label: '权限管理',
1257
+ path: '/setting/permission',
1258
+ icon: <SafetyCertificateOutlined />
1259
+ },
1260
+ {
1261
+ group: 'account',
1262
+ groupLabel: '账号',
1263
+ label: '切换租户',
1264
+ icon: <UserSwitchOutlined />,
1265
+ onClick: () => message.info('示例环境:切换租户(mock)')
1266
+ },
1267
+ {
1268
+ group: 'account',
1269
+ groupLabel: '账号',
1270
+ label: '退出登录',
1271
+ icon: <LogoutOutlined />,
1272
+ onClick: () => message.info('示例环境:退出登录(mock)')
1273
+ }
1274
+ ]
1275
+ }}
1276
+ >
1277
+ <Routes>
1278
+ <Route
1279
+ path={base}
1280
+ element={
1281
+ <Page title="工作台">
1282
+ <div style={{ padding: 16, color: '#666', lineHeight: 1.8 }}>
1283
+ <p>
1284
+ 当前租户:<strong>{tenant?.name}</strong>
1285
+ </p>
1286
+ <p>
1287
+ 当前用户:<strong>{tenantUserInfo?.name}</strong>({tenantUserInfo?.email})
1288
+ </p>
1289
+ <p>
1290
+ 左侧菜单进入「系统设置」可体验 components-admin:Tenant@Setting 的公司 / 组织 / 用户 / 权限页面,接口均走
1291
+ mock 数据。
1292
+ </p>
1293
+ </div>
1294
+ </Page>
1295
+ }
1296
+ />
1297
+ <Route
1298
+ path={&#96;${base}/setting/company&#96;}
1299
+ element={<Setting.Company>{({ title, children }) => <Page title={title}>{children}</Page>}</Setting.Company>}
1300
+ />
1301
+ <Route
1302
+ path={&#96;${base}/setting/org&#96;}
1303
+ element={
1304
+ <Setting.Org baseUrl={&#96;${base}/setting&#96;}>
1305
+ {({ title, children }) => <Page title={title}>{children}</Page>}
1306
+ </Setting.Org>
1307
+ }
1308
+ />
1309
+ <Route
1310
+ path={&#96;${base}/setting/user&#96;}
1311
+ element={
1312
+ <Setting.User>
1313
+ {({ title, titleExtra, children }) => (
1314
+ <Page title={title} extra={titleExtra}>
1315
+ {children}
1316
+ </Page>
1317
+ )}
1318
+ </Setting.User>
1319
+ }
1320
+ />
1321
+ <Route
1322
+ path={&#96;${base}/setting/permission&#96;}
1323
+ element={
1324
+ <Setting.Permission>
1325
+ {({ title, titleExtra, children }) => (
1326
+ <Page title={title} extra={titleExtra}>
1327
+ {children}
1328
+ </Page>
1329
+ )}
1330
+ </Setting.Permission>
1331
+ }
1332
+ />
1333
+ <Route path="*" element={<Navigate to={base} replace />} />
1334
+ </Routes>
1335
+ </SystemLayout>
1336
+ </Permissions>
1337
+ </SetGlobal>
1338
+ </SetGlobal>
1339
+ );
1340
+ }}
1341
+ </Authenticate>
1342
+ );
1343
+ });
1344
+
1345
+ const BaseExample = () => {
1346
+ const { pathname } = useLocation();
1347
+ // /:exampleId/... → 取第一段作为示例 base,保证菜单导航仍落在当前示例 :id/* 下
1348
+ const base = '/' + (pathname.replace(/^\//, '').split('/')[0] || 'Tenant 租户设置');
1349
+ return <TenantDemoInner base={base} />;
1350
+ };
1351
+
1352
+ render(<BaseExample />);
1353
+
1354
+ ```
1355
+
781
1356
  - 图标字体
782
1357
  - 项目内置的图标字体列表
783
1358
  - _fontList(./src/icons/fonts),(./src/icons/index),_modulesDev(@kne/modules-dev/dist/create-entry)