@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.
- package/package.json +3 -3
- package/src/framework/GlobalData.ts +4 -0
- package/src/framework/biz/OrgTree.tsx +1 -1
- package/src/framework/biz/RoleTree.tsx +1 -1
- package/src/framework/biz/UserPicker.tsx +4 -4
- package/src/framework/biz/index.tsx +4 -4
- package/src/framework/components/CronEditor/index.tsx +318 -0
- package/src/framework/components/LinkButton/index.tsx +4 -0
- package/src/framework/components/OrgSwitcher/index.tsx +2 -2
- package/src/framework/components/Page/index.tsx +6 -14
- package/src/framework/components/ProTable/index.tsx +3 -1
- package/src/framework/components/ThemeSettings/index.tsx +5 -38
- package/src/framework/components/index.ts +1 -0
- package/src/framework/config/index.ts +12 -0
- package/src/framework/config/layoutConfig.ts +47 -8
- package/src/framework/config/themeConfig.ts +93 -25
- package/src/framework/config/uiDefaults.ts +26 -0
- package/src/framework/fields/FieldEditor/index.tsx +1 -1
- package/src/framework/fields/FieldUploadFile/index.tsx +1 -1
- package/src/framework/fields/FieldUploadImage/index.tsx +42 -10
- package/src/framework/fields/FieldUserPicker/index.tsx +3 -3
- package/src/framework/fields/orgTree.ts +1 -1
- package/src/framework/router/index.ts +1 -0
- package/src/framework/router/matcher.ts +19 -4
- package/src/framework/utils/AvatarUtils.ts +64 -0
- package/src/framework/utils/CronUtils.ts +562 -0
- package/src/framework/utils/HttpClient.ts +5 -5
- package/src/framework/utils/PageUtils.ts +38 -7
- package/src/framework/utils/TitleUtils.ts +38 -0
- package/src/framework/utils/UrlUtils.ts +3 -3
- package/src/framework/utils/index.ts +3 -0
- package/src/layouts/admin/HeaderRight.jsx +62 -16
- package/src/layouts/admin/SinglePageLayout.jsx +13 -2
- package/src/layouts/admin/TabLayout.jsx +38 -19
- package/src/layouts/admin/index.jsx +12 -3
- package/src/layouts/index.tsx +52 -15
- package/src/pages/404.jsx +4 -0
- package/src/pages/article/$code.jsx +5 -1
- package/src/pages/login.jsx +157 -0
- package/src/pages/{public/login.less → login.less} +16 -1
- package/src/pages/public/login.jsx +11 -145
- package/src/pages/standalone/{forceUpdatePwd.jsx → force-update-pwd.jsx} +10 -4
- package/src/pages/system/article/index.jsx +1 -1
- package/src/pages/system/config/index.jsx +335 -0
- package/src/pages/system/file/index.jsx +3 -3
- package/src/pages/system/job/index.jsx +62 -19
- package/src/pages/system/log/index.jsx +1 -1
- package/src/pages/system/online/index.jsx +95 -0
- package/src/pages/system/org/index.jsx +7 -7
- package/src/pages/system/role/index.jsx +8 -8
- package/src/pages/system/role/{rolePerm.jsx → role-perm.jsx} +9 -4
- package/src/pages/system/user/{userPerm.jsx → UserPermModal.jsx} +3 -3
- package/src/pages/system/user/index.jsx +6 -6
- package/src/pages/user-center/AvatarPicker.jsx +76 -0
- package/src/pages/{userCenter/permView.jsx → user-center/PermView.jsx} +48 -25
- package/src/pages/user-center/RecentOps.jsx +36 -0
- package/src/pages/user-center/index.jsx +393 -0
- package/vite-plugin/index.js +64 -6
- 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
|
|
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
|
|
20
|
+
return null;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
|
|
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
|
-
/**
|
|
42
|
+
/** 当前页签模式:'tabs' 多页签 / 'single' 单页 */
|
|
25
43
|
export function getLayoutMode(): LayoutMode {
|
|
26
44
|
return currentLayoutMode;
|
|
27
45
|
}
|
|
28
46
|
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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: '
|
|
104
|
-
name: '
|
|
106
|
+
key: 'graphite',
|
|
107
|
+
name: '石墨灰',
|
|
105
108
|
colors: {
|
|
106
|
-
colorPrimary: '#
|
|
107
|
-
siderBg: '#
|
|
108
|
-
siderSubBg: '#
|
|
109
|
-
siderTriggerBg: '#
|
|
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: '
|
|
115
|
-
name: '
|
|
117
|
+
key: 'indigo',
|
|
118
|
+
name: '靛蓝',
|
|
116
119
|
colors: {
|
|
117
|
-
colorPrimary: '#
|
|
118
|
-
siderBg: '#
|
|
119
|
-
siderSubBg: '#
|
|
120
|
-
siderTriggerBg: '#
|
|
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: '
|
|
159
|
-
name: '
|
|
172
|
+
key: 'green',
|
|
173
|
+
name: '翠竹',
|
|
160
174
|
colors: {
|
|
161
|
-
colorPrimary: '#
|
|
162
|
-
siderBg: '#
|
|
163
|
-
siderSubBg: '#
|
|
164
|
-
siderTriggerBg: '#
|
|
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
|
|
196
|
-
if (typeof window === 'undefined') return
|
|
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
|
|
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
|
-
/**
|
|
550
|
+
/**
|
|
551
|
+
* 恢复默认:主题色回到参数设置的全局默认,密度 / 圆角 / 明暗复位(不影响布局,布局由 resetAllSettings 统一处理)。
|
|
552
|
+
* 主题色会同时清除本地覆盖,之后管理员调整全局默认值时重新生效。
|
|
553
|
+
*/
|
|
486
554
|
export function resetUserTheme() {
|
|
487
|
-
presetKey =
|
|
555
|
+
presetKey = defaultPresetKey;
|
|
488
556
|
densityKey = DEFAULT_DENSITY_KEY;
|
|
489
557
|
radiusKey = DEFAULT_RADIUS_KEY;
|
|
490
558
|
currentMode = 'light';
|
|
491
|
-
|
|
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/
|
|
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/
|
|
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/
|
|
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:
|
|
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={
|
|
436
|
-
height={
|
|
437
|
-
style={{objectFit: 'cover', borderRadius:
|
|
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:
|
|
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
|
-
|
|
466
|
-
<
|
|
467
|
-
|
|
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/
|
|
17
|
+
/** 分页数据源地址,默认 admin/sys-user/page */
|
|
18
18
|
requestUrl?: string;
|
|
19
|
-
/** 按 id 查询用户详情地址(用于回显),默认 admin/
|
|
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/
|
|
55
|
+
getUsersUrl = () => this.props.usersUrl || 'admin/sys-user/by-ids';
|
|
56
56
|
|
|
57
57
|
// 根据 value(ids) 拉取并补全用户详情,按 ids 顺序展示
|
|
58
58
|
syncUsers = () => {
|
|
@@ -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
|
+
}
|