@jiangood/open-admin 3.1.10 → 3.1.12

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 (59) hide show
  1. package/package.json +3 -3
  2. package/src/framework/GlobalData.ts +4 -0
  3. package/src/framework/biz/OrgTree.tsx +1 -1
  4. package/src/framework/biz/RoleTree.tsx +1 -1
  5. package/src/framework/biz/UserPicker.tsx +4 -4
  6. package/src/framework/biz/index.tsx +4 -4
  7. package/src/framework/components/CronEditor/index.tsx +318 -0
  8. package/src/framework/components/LinkButton/index.tsx +4 -0
  9. package/src/framework/components/OrgSwitcher/index.tsx +2 -2
  10. package/src/framework/components/Page/index.tsx +6 -14
  11. package/src/framework/components/ProTable/index.tsx +3 -1
  12. package/src/framework/components/ThemeSettings/index.tsx +5 -38
  13. package/src/framework/components/index.ts +1 -0
  14. package/src/framework/config/index.ts +12 -0
  15. package/src/framework/config/layoutConfig.ts +47 -8
  16. package/src/framework/config/themeConfig.ts +93 -25
  17. package/src/framework/config/uiDefaults.ts +26 -0
  18. package/src/framework/fields/FieldEditor/index.tsx +1 -1
  19. package/src/framework/fields/FieldUploadFile/index.tsx +1 -1
  20. package/src/framework/fields/FieldUploadImage/index.tsx +42 -10
  21. package/src/framework/fields/FieldUserPicker/index.tsx +3 -3
  22. package/src/framework/fields/orgTree.ts +1 -1
  23. package/src/framework/router/index.ts +1 -0
  24. package/src/framework/router/matcher.ts +19 -4
  25. package/src/framework/utils/AvatarUtils.ts +64 -0
  26. package/src/framework/utils/CronUtils.ts +562 -0
  27. package/src/framework/utils/HttpClient.ts +5 -5
  28. package/src/framework/utils/PageUtils.ts +38 -7
  29. package/src/framework/utils/TitleUtils.ts +38 -0
  30. package/src/framework/utils/UrlUtils.ts +3 -3
  31. package/src/framework/utils/index.ts +3 -0
  32. package/src/layouts/admin/HeaderRight.jsx +62 -16
  33. package/src/layouts/admin/SinglePageLayout.jsx +13 -2
  34. package/src/layouts/admin/TabLayout.jsx +38 -19
  35. package/src/layouts/admin/index.jsx +12 -3
  36. package/src/layouts/index.tsx +52 -15
  37. package/src/pages/404.jsx +4 -0
  38. package/src/pages/article/$code.jsx +5 -1
  39. package/src/pages/login.jsx +157 -0
  40. package/src/pages/{public/login.less → login.less} +16 -1
  41. package/src/pages/public/login.jsx +11 -145
  42. package/src/pages/standalone/{forceUpdatePwd.jsx → force-update-pwd.jsx} +10 -4
  43. package/src/pages/system/article/index.jsx +1 -1
  44. package/src/pages/system/config/index.jsx +335 -0
  45. package/src/pages/system/file/index.jsx +3 -3
  46. package/src/pages/system/job/index.jsx +62 -19
  47. package/src/pages/system/log/index.jsx +1 -1
  48. package/src/pages/system/online/index.jsx +95 -0
  49. package/src/pages/system/org/index.jsx +7 -7
  50. package/src/pages/system/role/index.jsx +8 -8
  51. package/src/pages/system/role/{rolePerm.jsx → role-perm.jsx} +9 -4
  52. package/src/pages/system/user/{userPerm.jsx → UserPermModal.jsx} +3 -3
  53. package/src/pages/system/user/index.jsx +6 -6
  54. package/src/pages/user-center/AvatarPicker.jsx +76 -0
  55. package/src/pages/{userCenter/permView.jsx → user-center/PermView.jsx} +48 -25
  56. package/src/pages/user-center/RecentOps.jsx +36 -0
  57. package/src/pages/user-center/index.jsx +393 -0
  58. package/vite-plugin/index.js +64 -6
  59. package/src/pages/userCenter/index.jsx +0 -93
@@ -4,29 +4,64 @@ export type LayoutMode = 'tabs' | 'single';
4
4
 
5
5
  const LAYOUT_MODE_KEY = 'oa-layout-tabs';
6
6
 
7
+ /** 页签模式(多页签)/ 单页模式 */
7
8
  export const DEFAULT_LAYOUT_MODE: LayoutMode = 'tabs';
8
9
 
9
10
  const LAYOUT_MODES: LayoutMode[] = ['tabs', 'single'];
10
11
 
11
- function readStoredLayoutMode(): LayoutMode {
12
- if (typeof window === 'undefined') return DEFAULT_LAYOUT_MODE;
12
+ function readStoredLayoutMode(): LayoutMode | null {
13
+ if (typeof window === 'undefined') return null;
13
14
  try {
14
15
  const mode = localStorage.getItem(LAYOUT_MODE_KEY);
15
16
  if (mode && LAYOUT_MODES.includes(mode as LayoutMode)) return mode as LayoutMode;
16
17
  } catch {
17
18
  // 隐私模式下 localStorage 不可用,忽略
18
19
  }
19
- return DEFAULT_LAYOUT_MODE;
20
+ return null;
20
21
  }
21
22
 
22
- let currentLayoutMode: LayoutMode = readStoredLayoutMode();
23
+ /** 用户是否在本地自定义过页签模式 */
24
+ export function hasStoredLayoutMode(): boolean {
25
+ return readStoredLayoutMode() !== null;
26
+ }
27
+
28
+ function removeStoredLayoutMode() {
29
+ if (typeof window === 'undefined') return;
30
+ try {
31
+ localStorage.removeItem(LAYOUT_MODE_KEY);
32
+ } catch {
33
+ // 忽略
34
+ }
35
+ }
36
+
37
+ /** 全局默认布局(来自参数设置,未自定义用户使用) */
38
+ let defaultLayoutMode: LayoutMode = DEFAULT_LAYOUT_MODE;
39
+
40
+ let currentLayoutMode: LayoutMode = readStoredLayoutMode() ?? DEFAULT_LAYOUT_MODE;
23
41
 
24
- /** 当前布局模式:'tabs' 多标签页 / 'single' 单页 */
42
+ /** 当前页签模式:'tabs' 多页签 / 'single' 单页 */
25
43
  export function getLayoutMode(): LayoutMode {
26
44
  return currentLayoutMode;
27
45
  }
28
46
 
29
- /** 设置布局模式,持久化并广播 layoutChange 供管理布局重渲染 */
47
+ export function getDefaultLayoutMode(): LayoutMode {
48
+ return defaultLayoutMode;
49
+ }
50
+
51
+ /**
52
+ * 应用参数设置下发的全局默认页签模式。
53
+ * 仅在用户本地没有自定义时生效;已自定义用户保持个人选择。
54
+ */
55
+ export function setDefaultLayoutMode(mode: LayoutMode) {
56
+ if (!LAYOUT_MODES.includes(mode)) return;
57
+ defaultLayoutMode = mode;
58
+ if (hasStoredLayoutMode() || currentLayoutMode === mode) return;
59
+
60
+ currentLayoutMode = mode;
61
+ EventBus.emit('layoutChange', mode);
62
+ }
63
+
64
+ /** 设置页签模式,持久化并广播 layoutChange 供管理布局重渲染 */
30
65
  export function setLayoutMode(mode: LayoutMode) {
31
66
  if (!LAYOUT_MODES.includes(mode) || currentLayoutMode === mode) return;
32
67
  currentLayoutMode = mode;
@@ -46,7 +81,11 @@ export function toggleLayoutMode(): LayoutMode {
46
81
  return next;
47
82
  }
48
83
 
49
- /** 恢复默认布局(多标签页) */
84
+ /** 恢复默认页签模式:清除本地覆盖,回到参数设置的全局默认 */
50
85
  export function resetLayoutMode() {
51
- setLayoutMode(DEFAULT_LAYOUT_MODE);
86
+ removeStoredLayoutMode();
87
+ if (currentLayoutMode === defaultLayoutMode) return;
88
+
89
+ currentLayoutMode = defaultLayoutMode;
90
+ EventBus.emit('layoutChange', currentLayoutMode);
52
91
  }
@@ -92,7 +92,10 @@ export const RADIUS_PRESETS: ThemeRadiusPreset[] = [
92
92
 
93
93
  export const DEFAULT_RADIUS_KEY: ThemeRadius = 'default';
94
94
 
95
- /** 可选主题预设;antd 预设不做任何颜色覆盖,保持 100% 原生观感 */
95
+ /**
96
+ * 可选主题预设;antd 预设不做任何颜色覆盖,保持 100% 原生观感。
97
+ * 按色相环顺序排列:蓝 → 蓝灰 → 靛蓝 → 紫 → 粉 → 红 → 橙 → 绿 → 青,便于用户按相近色比较选择。
98
+ */
96
99
  export const THEME_PRESETS: ThemePreset[] = [
97
100
  {
98
101
  key: 'antd',
@@ -100,24 +103,24 @@ export const THEME_PRESETS: ThemePreset[] = [
100
103
  colors: {},
101
104
  },
102
105
  {
103
- key: 'cyan',
104
- name: '青碧',
106
+ key: 'graphite',
107
+ name: '石墨灰',
105
108
  colors: {
106
- colorPrimary: '#13c2c2',
107
- siderBg: '#0C2E2E',
108
- siderSubBg: '#0C2E2E',
109
- siderTriggerBg: '#082222',
109
+ colorPrimary: '#2f3542',
110
+ siderBg: '#1B2026',
111
+ siderSubBg: '#1B2026',
112
+ siderTriggerBg: '#141A20',
110
113
  siderHoverBg: 'rgba(255,255,255,0.06)',
111
114
  },
112
115
  },
113
116
  {
114
- key: 'green',
115
- name: '翠竹',
117
+ key: 'indigo',
118
+ name: '靛蓝',
116
119
  colors: {
117
- colorPrimary: '#389e0d',
118
- siderBg: '#10291A',
119
- siderSubBg: '#10291A',
120
- siderTriggerBg: '#0B1E13',
120
+ colorPrimary: '#4f46e5',
121
+ siderBg: '#1E1B4B',
122
+ siderSubBg: '#1E1B4B',
123
+ siderTriggerBg: '#17143C',
121
124
  siderHoverBg: 'rgba(255,255,255,0.06)',
122
125
  },
123
126
  },
@@ -132,6 +135,17 @@ export const THEME_PRESETS: ThemePreset[] = [
132
135
  siderHoverBg: 'rgba(255,255,255,0.06)',
133
136
  },
134
137
  },
138
+ {
139
+ key: 'pink',
140
+ name: '樱花粉',
141
+ colors: {
142
+ colorPrimary: '#eb2f96',
143
+ siderBg: '#2B1024',
144
+ siderSubBg: '#2B1024',
145
+ siderTriggerBg: '#200B1A',
146
+ siderHoverBg: 'rgba(255,255,255,0.06)',
147
+ },
148
+ },
135
149
  {
136
150
  key: 'red',
137
151
  name: '中国红',
@@ -155,13 +169,24 @@ export const THEME_PRESETS: ThemePreset[] = [
155
169
  },
156
170
  },
157
171
  {
158
- key: 'graphite',
159
- name: '石墨灰',
172
+ key: 'green',
173
+ name: '翠竹',
160
174
  colors: {
161
- colorPrimary: '#2f3542',
162
- siderBg: '#1B2026',
163
- siderSubBg: '#1B2026',
164
- siderTriggerBg: '#141A20',
175
+ colorPrimary: '#389e0d',
176
+ siderBg: '#10291A',
177
+ siderSubBg: '#10291A',
178
+ siderTriggerBg: '#0B1E13',
179
+ siderHoverBg: 'rgba(255,255,255,0.06)',
180
+ },
181
+ },
182
+ {
183
+ key: 'cyan',
184
+ name: '青碧',
185
+ colors: {
186
+ colorPrimary: '#13c2c2',
187
+ siderBg: '#0C2E2E',
188
+ siderSubBg: '#0C2E2E',
189
+ siderTriggerBg: '#082222',
165
190
  siderHoverBg: 'rgba(255,255,255,0.06)',
166
191
  },
167
192
  },
@@ -174,6 +199,8 @@ const THEME_RADIUS_KEY = 'oa-theme-radius';
174
199
 
175
200
  /** 业务侧传入的颜色基线(由 setThemeColors 设置) */
176
201
  let businessColors: Partial<ThemeColors> | null = null;
202
+ /** 全局默认预设 key(来自参数设置,未自定义用户使用) */
203
+ let defaultPresetKey: string = DEFAULT_PRESET_KEY;
177
204
  /** 用户选择的预设 key(持久化到 localStorage) */
178
205
  let presetKey: string = readStoredPreset();
179
206
  let densityKey: ThemeDensity = readStoredDensity();
@@ -192,15 +219,24 @@ function readStoredMode(): ThemeMode {
192
219
  }
193
220
  }
194
221
 
195
- function readStoredPreset(): string {
196
- if (typeof window === 'undefined') return DEFAULT_PRESET_KEY;
222
+ function readStoredPresetKey(): string | null {
223
+ if (typeof window === 'undefined') return null;
197
224
  try {
198
225
  const key = localStorage.getItem(THEME_PRESET_KEY);
199
226
  if (key && THEME_PRESETS.some(p => p.key === key)) return key;
200
227
  } catch {
201
228
  // 隐私模式下 localStorage 不可用,忽略
202
229
  }
203
- return DEFAULT_PRESET_KEY;
230
+ return null;
231
+ }
232
+
233
+ function readStoredPreset(): string {
234
+ return readStoredPresetKey() ?? DEFAULT_PRESET_KEY;
235
+ }
236
+
237
+ /** 用户是否在本地自定义过主题色预设 */
238
+ export function hasStoredPreset(): boolean {
239
+ return readStoredPresetKey() !== null;
204
240
  }
205
241
 
206
242
  function persistPreset(key: string) {
@@ -243,6 +279,15 @@ function writeStored(key: string, value: string) {
243
279
  }
244
280
  }
245
281
 
282
+ function removeStored(key: string) {
283
+ if (typeof window === 'undefined') return;
284
+ try {
285
+ localStorage.removeItem(key);
286
+ } catch {
287
+ // 忽略
288
+ }
289
+ }
290
+
246
291
  function getActiveDensity(): ThemeDensityPreset | undefined {
247
292
  return DENSITY_PRESETS.find(p => p.key === densityKey);
248
293
  }
@@ -454,6 +499,26 @@ export function getActivePresetKey(): string {
454
499
  return presetKey;
455
500
  }
456
501
 
502
+ export function getDefaultPresetKey(): string {
503
+ return defaultPresetKey;
504
+ }
505
+
506
+ /**
507
+ * 应用参数设置下发的全局默认主题色。
508
+ * 仅在用户本地没有自定义预设时生效;已自定义用户保持个人选择,
509
+ * 但其「恢复默认」会回到这里设置的值。
510
+ */
511
+ export function setDefaultThemePreset(key: string) {
512
+ if (!THEME_PRESETS.some(p => p.key === key)) return;
513
+ defaultPresetKey = key;
514
+ if (hasStoredPreset() || presetKey === key) return;
515
+
516
+ presetKey = key;
517
+ invalidate();
518
+ syncCssVars(getToken());
519
+ EventBus.emit('themeChange', currentMode);
520
+ }
521
+
457
522
  /** 应用界面密度预设,持久化并广播 themeChange */
458
523
  export function applyDensityPreset(key: ThemeDensity) {
459
524
  if (!DENSITY_PRESETS.some(p => p.key === key)) return;
@@ -482,13 +547,16 @@ export function getActiveRadiusKey(): ThemeRadius {
482
547
  return radiusKey;
483
548
  }
484
549
 
485
- /** 恢复默认:颜色 / 密度 / 圆角 / 明暗 全部复位(不影响布局,布局由 resetAllSettings 统一处理) */
550
+ /**
551
+ * 恢复默认:主题色回到参数设置的全局默认,密度 / 圆角 / 明暗复位(不影响布局,布局由 resetAllSettings 统一处理)。
552
+ * 主题色会同时清除本地覆盖,之后管理员调整全局默认值时重新生效。
553
+ */
486
554
  export function resetUserTheme() {
487
- presetKey = DEFAULT_PRESET_KEY;
555
+ presetKey = defaultPresetKey;
488
556
  densityKey = DEFAULT_DENSITY_KEY;
489
557
  radiusKey = DEFAULT_RADIUS_KEY;
490
558
  currentMode = 'light';
491
- persistPreset(presetKey);
559
+ removeStored(THEME_PRESET_KEY);
492
560
  writeStored(THEME_DENSITY_KEY, densityKey);
493
561
  writeStored(THEME_RADIUS_KEY, radiusKey);
494
562
  writeStored(THEME_MODE_KEY, currentMode);
@@ -0,0 +1,26 @@
1
+ import { setDefaultThemePreset } from './themeConfig';
2
+ import { setDefaultLayoutMode } from './layoutConfig';
3
+
4
+ /** 参数设置中「默认界面」分组的配置键 */
5
+ export const UI_THEME_PRESET_KEY = 'sys.ui.theme-preset';
6
+ export const UI_TABS_ENABLED_KEY = 'sys.ui.tabs-enabled';
7
+
8
+ /**
9
+ * 应用后端下发的全局默认界面配置(来自 site-info.config)。
10
+ *
11
+ * 只对未在本地自定义过的用户生效;已自定义用户保持个人设置,
12
+ * 其「恢复默认」会回到这里设置的全局默认值。
13
+ */
14
+ export function applyServerUiDefaults(config?: Record<string, unknown> | null) {
15
+ if (!config) return;
16
+
17
+ const preset = config[UI_THEME_PRESET_KEY];
18
+ if (typeof preset === 'string') {
19
+ setDefaultThemePreset(preset);
20
+ }
21
+
22
+ const tabsEnabled = config[UI_TABS_ENABLED_KEY];
23
+ if (typeof tabsEnabled === 'boolean') {
24
+ setDefaultLayoutMode(tabsEnabled ? 'tabs' : 'single');
25
+ }
26
+ }
@@ -35,7 +35,7 @@ export class FieldEditor extends React.Component<FieldEditorProps, {loading: boo
35
35
  render() {
36
36
  const {loading} = this.state;
37
37
  const isPublic = this.props.isPublic ?? true;
38
- const uploadUrl = UrlUtils.contextPath('/admin/sysFile/upload') + '?isPublic=' + isPublic;
38
+ const uploadUrl = UrlUtils.contextPath('/admin/sys-file/upload') + '?isPublic=' + isPublic;
39
39
  const jsUrl = UrlUtils.contextPath('/admin/tinymce/tinymce.min.js');
40
40
  const {value, onChange, height} = this.props;
41
41
  const editorHeight = height || 300;
@@ -149,7 +149,7 @@ export class FieldUploadFile extends React.Component<FieldUploadFileProps, Field
149
149
  const {accept, fileList, maxCount} = this.state;
150
150
 
151
151
  return <Upload
152
- action={UrlUtils.contextPath('/admin/sysFile/upload')}
152
+ action={UrlUtils.contextPath('/admin/sys-file/upload')}
153
153
  data={{isPublic: this.state.isPublic}}
154
154
  listType={this.props.listType || 'picture-card'}
155
155
  fileList={fileList}
@@ -16,6 +16,14 @@ export interface FieldUploadImageProps extends FieldProps<string> {
16
16
  isPublic?: boolean;
17
17
  /** 接受的文件类型 */
18
18
  accept?: string;
19
+ /** 进入裁切工具时的默认裁切比例,默认 4:3;传 null 表示自由裁切 */
20
+ defaultCropRatio?: Dims | null;
21
+ /** 缩略图与触发按钮形状,圆形用于头像类场景,默认方形 */
22
+ shape?: 'square' | 'circle';
23
+ /** 缩略图与触发按钮的尺寸(px),默认 80 */
24
+ size?: number;
25
+ /** 触发按钮的提示文案,默认不显示提示 */
26
+ triggerTitle?: string;
19
27
  }
20
28
 
21
29
  interface Dims {
@@ -167,6 +175,7 @@ export class FieldUploadImage extends React.Component<FieldUploadImageProps, Fie
167
175
  constructor(props: FieldUploadImageProps) {
168
176
  super(props);
169
177
  ObjectUtils.copyPropertyIfPresent(props, this.state);
178
+ if (props.defaultCropRatio !== undefined) this.state.cropRatio = props.defaultCropRatio;
170
179
  if (props.value) this.state.objectNames = props.value.split(',');
171
180
  }
172
181
 
@@ -363,7 +372,7 @@ export class FieldUploadImage extends React.Component<FieldUploadImageProps, Fie
363
372
  fd.append('file', this.state.preview.cFile);
364
373
  fd.append('thumb', tFile);
365
374
  fd.append('isPublic', String(this.state.isPublic));
366
- const rs = await HttpClient.post('admin/sysFile/uploadImage', fd, null, {toastError: false});
375
+ const rs = await HttpClient.post('admin/sys-file/upload-image', fd, null, {toastError: false});
367
376
  this.setState(
368
377
  (prevState) => ({objectNames: [...prevState.objectNames, rs.data.objectName]}),
369
378
  () => this.props.onChange?.(this.state.objectNames.join(','))
@@ -395,6 +404,9 @@ export class FieldUploadImage extends React.Component<FieldUploadImageProps, Fie
395
404
 
396
405
  render() {
397
406
  const {objectNames, accept, compressWidth, compressSize, cropRatio, cropperReady, fullPreviewUrl, maxCount, modalOpen, originalUrl, preview, tool, uploading} = this.state;
407
+ const {shape = 'square', size = 80, triggerTitle} = this.props;
408
+ const round = shape === 'circle';
409
+ const boxRadius = round ? '50%' : 4;
398
410
 
399
411
  // 画布当前显示的图片
400
412
  const canvasImg = preview
@@ -412,8 +424,8 @@ export class FieldUploadImage extends React.Component<FieldUploadImageProps, Fie
412
424
  <div>
413
425
  {objectNames.map((name) => (
414
426
  <div key={name} style={{
415
- position: 'relative', display: 'inline-block', marginRight: 8, verticalAlign: 'top',
416
- width: 80, height: 80, borderRadius: 4, overflow: 'hidden', cursor: 'pointer',
427
+ position: 'relative', display: 'inline-block', marginRight: round ? 0 : 8, verticalAlign: 'top',
428
+ width: size, height: size, borderRadius: boxRadius, overflow: 'hidden', cursor: 'pointer',
417
429
  }}
418
430
  onMouseEnter={(e) => {
419
431
  const mask = e.currentTarget.querySelector('.oa-field-upload-img-mask') as HTMLElement;
@@ -432,14 +444,14 @@ export class FieldUploadImage extends React.Component<FieldUploadImageProps, Fie
432
444
  >
433
445
  <img
434
446
  src={UrlUtils.contextPath(`/file/${name}?thumb=1`)}
435
- width={80}
436
- height={80}
437
- style={{objectFit: 'cover', borderRadius: 4, display: 'block'}}
447
+ width={size}
448
+ height={size}
449
+ style={{objectFit: 'cover', borderRadius: boxRadius, display: 'block'}}
438
450
  alt={name}
439
451
  />
440
452
  <div style={{
441
453
  position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
442
- background: 'rgba(0,0,0,0.45)', opacity: 0, transition: 'opacity .3s', borderRadius: 4,
454
+ background: 'rgba(0,0,0,0.45)', opacity: 0, transition: 'opacity .3s', borderRadius: boxRadius,
443
455
  pointerEvents: 'none',
444
456
  }} className="oa-field-upload-img-mask">
445
457
  <Space size={12}>
@@ -462,9 +474,29 @@ export class FieldUploadImage extends React.Component<FieldUploadImageProps, Fie
462
474
  </div>
463
475
  ))}
464
476
  {objectNames.length < maxCount && (
465
- <Upload accept={accept} showUploadList={false} beforeUpload={this.handleBeforeUpload} multiple={false}>
466
- <Button icon={<PlusOutlined/>}>选择图片</Button>
467
- </Upload>
477
+ round ? (
478
+ <Upload accept={accept} showUploadList={false} beforeUpload={this.handleBeforeUpload} multiple={false}>
479
+ <button type="button"
480
+ title={triggerTitle}
481
+ aria-label={triggerTitle || '选择图片'}
482
+ style={{
483
+ width: size, height: size, padding: 0, boxSizing: 'border-box',
484
+ border: '2px dashed var(--oa-color-border, #d9d9d9)',
485
+ borderRadius: '50%',
486
+ background: 'var(--oa-color-fill-quaternary, #fafafa)',
487
+ color: 'var(--oa-color-text-secondary, rgba(0, 0, 0, 0.65))',
488
+ cursor: 'pointer', lineHeight: 0,
489
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
490
+ fontSize: 18,
491
+ }}>
492
+ <PlusOutlined/>
493
+ </button>
494
+ </Upload>
495
+ ) : (
496
+ <Upload accept={accept} showUploadList={false} beforeUpload={this.handleBeforeUpload} multiple={false}>
497
+ <Button icon={<PlusOutlined/>}>选择图片</Button>
498
+ </Upload>
499
+ )
468
500
  )}
469
501
  </div>
470
502
 
@@ -14,9 +14,9 @@ export interface FieldUserPickerProps extends FieldProps<string[]> {
14
14
  placeholder?: string;
15
15
  /** 弹窗标题 */
16
16
  title?: string;
17
- /** 分页数据源地址,默认 admin/sysUser/page */
17
+ /** 分页数据源地址,默认 admin/sys-user/page */
18
18
  requestUrl?: string;
19
- /** 按 id 查询用户详情地址(用于回显),默认 admin/sysUser/by-ids */
19
+ /** 按 id 查询用户详情地址(用于回显),默认 admin/sys-user/by-ids */
20
20
  usersUrl?: string;
21
21
  }
22
22
 
@@ -52,7 +52,7 @@ export class FieldUserPicker extends React.Component<FieldUserPickerProps, Field
52
52
  }
53
53
  }
54
54
 
55
- getUsersUrl = () => this.props.usersUrl || 'admin/sysUser/by-ids';
55
+ getUsersUrl = () => this.props.usersUrl || 'admin/sys-user/by-ids';
56
56
 
57
57
  // 根据 value(ids) 拉取并补全用户详情,按 ids 顺序展示
58
58
  syncUsers = () => {
@@ -1,3 +1,3 @@
1
1
  export function orgTreeUrl(type: string): string {
2
- return type === 'dept' ? '/admin/sysOrg/dept-tree' : '/admin/sysOrg/unit-tree';
2
+ return type === 'dept' ? '/admin/sys-org/dept-tree' : '/admin/sys-org/unit-tree';
3
3
  }
@@ -1,4 +1,5 @@
1
1
  export {history} from './history';
2
2
  export {registerRoutes, matchRoute} from './matcher';
3
+ export type {PageLayout, PageMeta, RouteDef, MatchResult} from './matcher';
3
4
  export {PageFrame} from './PageFrame';
4
5
  export {Link} from './Link';
@@ -4,9 +4,23 @@ function safeDecodeURIComponent(s: string): string {
4
4
  try { return decodeURIComponent(s); } catch { return s; }
5
5
  }
6
6
 
7
+ /** 页面布局:admin = 后台菜单布局;blank = 仅渲染页面内容 */
8
+ export type PageLayout = 'admin' | 'blank';
9
+
10
+ /** 页面元信息:页面文件通过 export const pageMeta 声明,由 vite 插件写入路由表 */
11
+ export interface PageMeta {
12
+ /** 页面标题:用于页签标题和浏览器标题;未设置时回退到后端菜单名 */
13
+ title?: string;
14
+ /** 是否需要登录,默认 true */
15
+ requiresAuth?: boolean;
16
+ /** 页面布局,默认 admin */
17
+ layout?: PageLayout;
18
+ }
19
+
7
20
  export interface RouteDef {
8
21
  path: string;
9
22
  component: ComponentType<unknown>;
23
+ meta?: PageMeta;
10
24
  }
11
25
 
12
26
  interface InternalRoute extends RouteDef {
@@ -16,6 +30,7 @@ interface InternalRoute extends RouteDef {
16
30
  export interface MatchResult {
17
31
  component: ComponentType<unknown>;
18
32
  params: Record<string, string>;
33
+ meta?: PageMeta;
19
34
  }
20
35
 
21
36
  const routes: InternalRoute[] = [];
@@ -27,8 +42,8 @@ function compile(path: string): InternalRoute['segments'] {
27
42
  }
28
43
 
29
44
  export function registerRoutes(defs: RouteDef[]): void {
30
- for (const {path, component} of defs) {
31
- routes.push({path, component, segments: compile(path)});
45
+ for (const {path, component, meta} of defs) {
46
+ routes.push({path, component, meta, segments: compile(path)});
32
47
  }
33
48
  }
34
49
 
@@ -51,8 +66,8 @@ export function matchRoute(pathname: string): MatchResult | null {
51
66
  }
52
67
  }
53
68
  if (!ok) continue;
54
- if (!dynamic) return {component: route.component, params};
55
- if (!best) best = {component: route.component, params};
69
+ if (!dynamic) return {component: route.component, params, meta: route.meta};
70
+ if (!best) best = {component: route.component, params, meta: route.meta};
56
71
  }
57
72
  return best;
58
73
  }
@@ -0,0 +1,64 @@
1
+ import {UrlUtils} from './UrlUtils';
2
+
3
+ /** 内置头像标识前缀,数据库中存 preset:avatar-1 这类值 */
4
+ const PRESET_PREFIX = 'preset:';
5
+
6
+ /** 内置头像文件命名规则,需与后端 AvatarPresets 保持一致 */
7
+ const PRESET_PATTERN = /^preset:avatar-(?:[1-9]|1[0-2])$/;
8
+
9
+ /** 十二生肖头像名称,需与后端 AvatarPresets 保持一致 */
10
+ const ZODIAC_LABELS = ['鼠', '牛', '虎', '兔', '龙', '蛇', '马', '羊', '猴', '鸡', '狗', '猪'];
11
+
12
+ export interface AvatarPreset {
13
+ key: string;
14
+ label: string;
15
+ url: string;
16
+ }
17
+
18
+ /**
19
+ * 默认头像已移除。头像为空时返回空串,由 AntD Avatar 的 icon 兜底显示人形占位。
20
+ *
21
+ * @deprecated 不再提供默认头像图片,保留该导出仅为兼容旧引用
22
+ */
23
+ export const DEFAULT_AVATAR_URL = '';
24
+
25
+ function presetUrl(key: string): string | null {
26
+ if (!PRESET_PATTERN.test(key)) {
27
+ return null;
28
+ }
29
+ return UrlUtils.contextPath(`/avatars/${key.substring(PRESET_PREFIX.length)}.svg`);
30
+ }
31
+
32
+ /**
33
+ * 接口不可用时的兜底列表,与后端 AvatarPresets 保持一致。
34
+ * 正常使用时优先读取 admin/public/avatar-presets 返回的数据。
35
+ */
36
+ export const FALLBACK_AVATAR_PRESETS: AvatarPreset[] = [
37
+ ...ZODIAC_LABELS.map((label, index) => {
38
+ const key = `${PRESET_PREFIX}avatar-${index + 1}`;
39
+ return {
40
+ key,
41
+ label,
42
+ url: presetUrl(key) as string,
43
+ };
44
+ }),
45
+ ];
46
+
47
+ export class AvatarUtils {
48
+
49
+ /** 是否为内置头像标识 */
50
+ static isPreset(avatar?: string | null): boolean {
51
+ return !!avatar && avatar.startsWith(PRESET_PREFIX);
52
+ }
53
+
54
+ /** 将数据库中的头像值转换为可访问的图片地址;为空或无效时返回空串,由调用方兜底占位 */
55
+ static resolve(avatar?: string | null): string {
56
+ if (!avatar) {
57
+ return '';
58
+ }
59
+ if (AvatarUtils.isPreset(avatar)) {
60
+ return presetUrl(avatar) || '';
61
+ }
62
+ return UrlUtils.contextPath(`/file/${avatar}?thumb=1`);
63
+ }
64
+ }