xto-fronted 0.8.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -54,3 +54,22 @@ export declare const appConfig: {
54
54
  apiBaseUrl: import('vue').Ref<string, string>;
55
55
  basePath: import('vue').Ref<string, string>;
56
56
  };
57
+ export interface AppUiFeatures {
58
+ showTabs?: boolean;
59
+ showBreadcrumb?: boolean;
60
+ showFooter?: boolean;
61
+ }
62
+ export interface AppUiConfig {
63
+ /** 默认布局模式 */
64
+ layout?: 'sidebar' | 'top' | 'mix';
65
+ /** 默认主题色 */
66
+ primaryColor?: string;
67
+ /** 默认深浅主题 */
68
+ theme?: 'light' | 'dark';
69
+ /** 默认功能开关 */
70
+ features?: AppUiFeatures;
71
+ /** 是否允许用户在界面右上角自行修改(false 时锁定设置入口) */
72
+ allowUserOverride?: boolean;
73
+ }
74
+ export declare function setServerUiConfig(config: AppUiConfig): void;
75
+ export declare function getServerUiConfig(): AppUiConfig;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xto-fronted",
3
- "version": "0.8.3",
3
+ "version": "0.9.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "XTO 前端应用框架",
package/src/api/system.ts CHANGED
@@ -51,6 +51,12 @@ export function getMenuTree(appId?: string) {
51
51
  return http.get<MenuItem[]>(`/user/v1.0/menu/get-menu?appId=${id}`)
52
52
  }
53
53
 
54
+ // 获取应用界面配置(免登录:布局模式/主题色/功能开关的按应用默认值)
55
+ export function getAppUiConfig(appId?: string) {
56
+ const id = appId || getAppId()
57
+ return http.get<Record<string, any>>('/user/v1.0/app/ui-config', { params: { appId: id } })
58
+ }
59
+
54
60
  // 创建菜单
55
61
  export function createMenu(data: Partial<MenuItem>) {
56
62
  return http.post<MenuItem>('/menu', data)
@@ -175,6 +175,12 @@ const handleDarkModeToggle = () => {
175
175
  drawerVisible.value = false
176
176
  }
177
177
 
178
+ // 恢复默认(清个人偏好,回到服务端按应用配置的默认值)
179
+ const handleResetDefaults = () => {
180
+ appStore.resetToServerDefaults()
181
+ drawerVisible.value = false
182
+ }
183
+
178
184
  // 切换全屏
179
185
  const toggleFullscreen = () => {
180
186
  if (!document.fullscreenElement) {
@@ -334,8 +340,8 @@ onUnmounted(() => {
334
340
  <Icon :name="isFullscreen ? 'fullscreen-exit' : 'fullscreen'" :size="16" />
335
341
  </div>
336
342
 
337
- <!-- 换肤设置 -->
338
- <div class="header__action" @click="openSettingsDrawer" title="换肤设置">
343
+ <!-- 换肤设置(allowUserOverride=false 时由应用管理员锁定) -->
344
+ <div v-if="appStore.allowUserOverride" class="header__action" @click="openSettingsDrawer" title="换肤设置">
339
345
  <Icon name="skin" :size="16" />
340
346
  </div>
341
347
 
@@ -467,6 +473,14 @@ onUnmounted(() => {
467
473
  </div>
468
474
  </div>
469
475
  </div>
476
+
477
+ <!-- 恢复默认:清掉个人偏好,回到管理员在用户中心配置的界面默认值 -->
478
+ <div class="settings-section">
479
+ <div class="settings-reset" @click="handleResetDefaults">
480
+ <Icon name="refresh" :size="14" />
481
+ <span>恢复默认</span>
482
+ </div>
483
+ </div>
470
484
  </div>
471
485
  </Drawer>
472
486
  </div>
@@ -985,6 +999,25 @@ onUnmounted(() => {
985
999
  }
986
1000
  }
987
1001
  }
1002
+
1003
+ .settings-reset {
1004
+ display: flex;
1005
+ align-items: center;
1006
+ justify-content: center;
1007
+ gap: 6px;
1008
+ height: 34px;
1009
+ border: 1px solid var(--xto-border-color, #D9DCE1);
1010
+ border-radius: 6px;
1011
+ font-size: 13px;
1012
+ color: var(--xto-color-text-regular, #4E5566);
1013
+ cursor: pointer;
1014
+ transition: all 0.2s;
1015
+
1016
+ &:hover {
1017
+ color: var(--xto-color-primary, #4F46E5);
1018
+ border-color: var(--xto-color-primary, #4F46E5);
1019
+ }
1020
+ }
988
1021
  }
989
1022
 
990
1023
  // 下拉动画
@@ -14,9 +14,11 @@ import { Drawer } from '@xto/feedback'
14
14
  const props = withDefaults(defineProps<{
15
15
  logoSrc?: string
16
16
  }>(), {
17
- logoSrc: withBasePath('/vite.svg')
18
17
  })
19
18
 
19
+ // logo 地址在渲染期解析:模块加载早于 createXtoApp 的运行时配置,不能作为默认值立即求值
20
+ const resolvedLogoSrc = computed(() => props.logoSrc || withBasePath('/vite.svg'))
21
+
20
22
  const route = useRoute()
21
23
  const router = useRouter()
22
24
  const menuStore = useMenuStore()
@@ -360,7 +362,7 @@ onUnmounted(() => {
360
362
  <div class="mix-top-menu">
361
363
  <!-- 左侧 Logo -->
362
364
  <div class="mix-top-menu__logo">
363
- <img :src="props.logoSrc" alt="Logo" class="mix-top-menu__logo-img" />
365
+ <img :src="resolvedLogoSrc" alt="Logo" class="mix-top-menu__logo-img" />
364
366
  <span class="mix-top-menu__logo-text">{{ appStore.appName }}</span>
365
367
  </div>
366
368
 
@@ -21,9 +21,11 @@ const props = withDefaults(defineProps<{
21
21
  menuList: () => [],
22
22
  showLogo: true,
23
23
  showUser: true,
24
- logoSrc: withBasePath('/vite.svg')
25
24
  })
26
25
 
26
+ // logo 地址在渲染期解析:模块加载早于 createXtoApp 的运行时配置,不能作为默认值立即求值
27
+ const resolvedLogoSrc = computed(() => props.logoSrc || withBasePath('/vite.svg'))
28
+
27
29
  const route = useRoute()
28
30
  const router = useRouter()
29
31
  const menuStore = useMenuStore()
@@ -130,7 +132,7 @@ const handleLogout = () => {
130
132
  <div class="sidebar" :class="{ 'sidebar--collapsed': isCollapsed }">
131
133
  <!-- Logo -->
132
134
  <div v-if="props.showLogo" class="sidebar__logo">
133
- <img :src="props.logoSrc" alt="Logo" class="sidebar__logo-img" />
135
+ <img :src="resolvedLogoSrc" alt="Logo" class="sidebar__logo-img" />
134
136
  <span v-show="!isCollapsed" class="sidebar__logo-text">{{ appStore.appName }}</span>
135
137
  </div>
136
138
 
@@ -14,9 +14,11 @@ import { Drawer } from '@xto/feedback'
14
14
  const props = withDefaults(defineProps<{
15
15
  logoSrc?: string
16
16
  }>(), {
17
- logoSrc: withBasePath('/vite.svg')
18
17
  })
19
18
 
19
+ // logo 地址在渲染期解析:模块加载早于 createXtoApp 的运行时配置,不能作为默认值立即求值
20
+ const resolvedLogoSrc = computed(() => props.logoSrc || withBasePath('/vite.svg'))
21
+
20
22
  const route = useRoute()
21
23
  const router = useRouter()
22
24
  const menuStore = useMenuStore()
@@ -303,7 +305,7 @@ onUnmounted(() => {
303
305
  <div class="top-menu">
304
306
  <!-- 左侧 Logo -->
305
307
  <div class="top-menu__logo">
306
- <img :src="props.logoSrc" alt="Logo" class="top-menu__logo-img" />
308
+ <img :src="resolvedLogoSrc" alt="Logo" class="top-menu__logo-img" />
307
309
  <span class="top-menu__logo-text">{{ appStore.appName }}</span>
308
310
  </div>
309
311
 
@@ -12,9 +12,11 @@ import MixTopMenu from './MixTopMenu.vue'
12
12
  const props = withDefaults(defineProps<{
13
13
  logoSrc?: string
14
14
  }>(), {
15
- logoSrc: withBasePath('/vite.svg')
16
15
  })
17
16
 
17
+ // logo 地址在渲染期解析:模块加载早于 createXtoApp 的运行时配置,不能作为默认值立即求值
18
+ const resolvedLogoSrc = computed(() => props.logoSrc || withBasePath('/vite.svg'))
19
+
18
20
  const appStore = useAppStore()
19
21
  const menuStore = useMenuStore()
20
22
 
@@ -52,7 +54,7 @@ const showMixSidebar = computed(() => {
52
54
  <!-- sidebar模式:左侧菜单 + 顶部Header + 主内容 -->
53
55
  <div v-if="layoutMode === 'sidebar'" class="layout layout--sidebar">
54
56
  <aside class="layout__aside" :style="{ width: sidebarWidth }">
55
- <Sidebar :menu-list="sidebarMenuList" :show-logo="true" :show-user="true" :logo-src="props.logoSrc" />
57
+ <Sidebar :menu-list="sidebarMenuList" :show-logo="true" :show-user="true" :logo-src="resolvedLogoSrc" />
56
58
  </aside>
57
59
  <div class="layout__main">
58
60
  <header class="layout__header">
@@ -67,7 +69,7 @@ const showMixSidebar = computed(() => {
67
69
  <!-- top模式:顶部菜单 + 主内容 -->
68
70
  <div v-if="layoutMode === 'top'" class="layout layout--top">
69
71
  <div class="layout__top-menu">
70
- <TopMenu :logo-src="props.logoSrc" />
72
+ <TopMenu :logo-src="resolvedLogoSrc" />
71
73
  </div>
72
74
  <div class="layout__main">
73
75
  <main class="layout__content">
@@ -80,7 +82,7 @@ const showMixSidebar = computed(() => {
80
82
  <div v-if="layoutMode === 'mix'" class="layout layout--mix">
81
83
  <!-- 顶部一级菜单 -->
82
84
  <div class="layout__mix-top-menu">
83
- <MixTopMenu :logo-src="props.logoSrc" />
85
+ <MixTopMenu :logo-src="resolvedLogoSrc" />
84
86
  </div>
85
87
  <!-- 下方:左侧子菜单 + 主内容 -->
86
88
  <div class="layout__mix-body">
package/src/index.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  import './style.scss'
3
3
 
4
4
  // 配置
5
- import { initAppConfig, getAppId, getClientId, getApiBaseUrl, loadRuntimeConfig } from './utils/config'
5
+ import { initAppConfig, getAppId, getClientId, getApiBaseUrl, loadRuntimeConfig, setServerUiConfig, type AppUiConfig } from './utils/config'
6
6
  import { setBasePath, setApiBaseUrl } from './utils/request'
7
+ import { getAppUiConfig } from './api/system'
7
8
  import type { LocaleCode } from '@xto/core/locale'
8
9
 
9
10
  // XtoConfig 类型定义
@@ -48,6 +49,23 @@ export async function createXtoApp(config: Partial<XtoConfig>) {
48
49
  setBasePath(config.basePath)
49
50
  }
50
51
 
52
+ // 拉取服务端按应用配置的界面默认值(免登录接口,xto-admin「界面设置」维护)
53
+ // 必须在 store 首次实例化前完成,失败/超时静默降级为内置默认
54
+ const resolvedAppId = config.appId || getAppId()
55
+ if (resolvedAppId) {
56
+ try {
57
+ const uiConfig = await Promise.race([
58
+ getAppUiConfig(resolvedAppId),
59
+ new Promise<null>((resolve) => setTimeout(() => resolve(null), 2000))
60
+ ])
61
+ if (uiConfig && typeof uiConfig === 'object') {
62
+ setServerUiConfig(uiConfig as AppUiConfig)
63
+ }
64
+ } catch (error) {
65
+ console.warn('加载应用界面配置失败,使用内置默认', error)
66
+ }
67
+ }
68
+
51
69
  // 设置应用名称和路径到 store
52
70
  import('./stores/app').then(({ useAppStore }) => {
53
71
  const appStore = useAppStore()
@@ -28,7 +28,9 @@ export function createLayoutRoute(
28
28
  options: LayoutRouteOptions = {}
29
29
  ): RouteRecordRaw {
30
30
  const indexPath = options.indexPath || '/dashboard'
31
- const logoSrc = options.logoSrc || withBasePath('/vite.svg')
31
+ // 注意:createLayoutRoute 常在模块顶层被调用(早于 createXtoApp 初始化运行时配置),
32
+ // logo 地址必须延迟到渲染时解析,不能在此处求值
33
+ const logoSrc = options.logoSrc
32
34
 
33
35
  // 添加 catch-all 路由,让404在Layout内部渲染,保持左侧菜单显示
34
36
  const catchAllRoute: RouteRecordRaw = {
@@ -44,7 +46,7 @@ export function createLayoutRoute(
44
46
  const LayoutWrapper = defineComponent({
45
47
  name: 'LayoutWrapper',
46
48
  render() {
47
- return h(Layout, { logoSrc })
49
+ return h(Layout, { logoSrc: logoSrc || withBasePath('/vite.svg') })
48
50
  }
49
51
  })
50
52
 
package/src/stores/app.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import { defineStore } from 'pinia'
6
6
  import { ref, computed, watch } from 'vue'
7
7
  import { local } from '@/utils/storage'
8
+ import { getServerUiConfig } from '@/utils/config'
8
9
 
9
10
  export type ThemeMode = 'light' | 'dark'
10
11
  export type LayoutMode = 'sidebar' | 'top' | 'mix'
@@ -17,19 +18,33 @@ function hexToRgb(hex: string): [number, number, number] | null {
17
18
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
18
19
  }
19
20
 
21
+ /** 读取用户显式保存过的偏好;未设置过返回 undefined(与「服务端默认」区分开) */
22
+ function readUserPreference<T>(key: string): T | undefined {
23
+ const value = local.get<T>(key)
24
+ return value === null || value === ('' as unknown as T) ? undefined : value
25
+ }
26
+
27
+ // 服务端按应用下发的默认值(xto-admin 界面配置),仅在用户未显式设置时生效
28
+ const serverUi = getServerUiConfig()
29
+ const serverFeatures = serverUi.features || {}
30
+
31
+ // 重置时需要清掉的用户偏好键(isCollapsed 属个人习惯,不随默认值重置)
32
+ const PREFERENCE_KEYS = ['layout', 'primaryColor', 'theme', 'isDark', 'showTabs', 'showBreadcrumb', 'showFooter'] as const
33
+
20
34
  export const useAppStore = defineStore('app', () => {
21
- // 状态
35
+ // 状态(优先级:用户本地偏好 > 服务端按应用默认 > 内置默认)
22
36
  const appName = ref<string>(local.get<string>('appName') || 'XTO App')
23
37
  const indexPath = ref<string>(local.get<string>('indexPath') || '/dashboard')
24
38
  const loginPath = ref<string>(local.get<string>('loginPath') || '/login')
25
- const isDark = ref<boolean>(local.get<boolean>('isDark') || false)
26
- const theme = ref<ThemeMode>(local.get<ThemeMode>('theme') || 'light')
27
- const layout = ref<LayoutMode>(local.get<LayoutMode>('layout') || 'sidebar')
39
+ const theme = ref<ThemeMode>(readUserPreference<ThemeMode>('theme') ?? serverUi.theme ?? 'light')
40
+ const isDark = ref<boolean>(theme.value === 'dark')
41
+ const layout = ref<LayoutMode>(readUserPreference<LayoutMode>('layout') ?? serverUi.layout ?? 'sidebar')
28
42
  const isCollapsed = ref<boolean>(local.get<boolean>('isCollapsed') || false)
29
- const showTabs = ref<boolean>(local.get<boolean>('showTabs') ?? true)
30
- const showFooter = ref<boolean>(local.get<boolean>('showFooter') ?? true)
31
- const showBreadcrumb = ref<boolean>(local.get<boolean>('showBreadcrumb') ?? true)
32
- const primaryColor = ref<string>(local.get<string>('primaryColor') || '#4F46E5')
43
+ const showTabs = ref<boolean>(readUserPreference<boolean>('showTabs') ?? serverFeatures.showTabs ?? true)
44
+ const showFooter = ref<boolean>(readUserPreference<boolean>('showFooter') ?? serverFeatures.showFooter ?? true)
45
+ const showBreadcrumb = ref<boolean>(readUserPreference<boolean>('showBreadcrumb') ?? serverFeatures.showBreadcrumb ?? true)
46
+ const primaryColor = ref<string>(readUserPreference<string>('primaryColor') ?? serverUi.primaryColor ?? '#4F46E5')
47
+ const allowUserOverride = ref<boolean>(serverUi.allowUserOverride !== false)
33
48
  const cachedViews = ref<string[]>([])
34
49
  const mixSubMenus = ref<any[]>([])
35
50
 
@@ -54,28 +69,33 @@ export const useAppStore = defineStore('app', () => {
54
69
  local.set('loginPath', path)
55
70
  }
56
71
 
57
- // 切换主题
72
+ // 应用深浅主题到 DOM(不写 localStorage,供初始化与重置使用)
73
+ const applyThemeClass = () => {
74
+ const html = document.documentElement
75
+ if (isDark.value) {
76
+ html.classList.add('dark')
77
+ } else {
78
+ html.classList.remove('dark')
79
+ }
80
+ }
81
+
82
+ // 切换主题(用户操作,持久化)
58
83
  const toggleTheme = () => {
59
84
  isDark.value = !isDark.value
60
85
  theme.value = isDark.value ? 'dark' : 'light'
61
86
  updateTheme()
62
87
  }
63
88
 
64
- // 设置主题
89
+ // 设置主题(用户操作,持久化)
65
90
  const setTheme = (mode: ThemeMode) => {
66
91
  theme.value = mode
67
92
  isDark.value = mode === 'dark'
68
93
  updateTheme()
69
94
  }
70
95
 
71
- // 更新主题
96
+ // 更新主题(用户操作路径:应用 DOM + 持久化)
72
97
  const updateTheme = () => {
73
- const html = document.documentElement
74
- if (isDark.value) {
75
- html.classList.add('dark')
76
- } else {
77
- html.classList.remove('dark')
78
- }
98
+ applyThemeClass()
79
99
  local.set('isDark', isDark.value)
80
100
  local.set('theme', theme.value)
81
101
  }
@@ -110,9 +130,8 @@ export const useAppStore = defineStore('app', () => {
110
130
  local.set('showBreadcrumb', showBreadcrumb.value)
111
131
  }
112
132
 
113
- // 设置主题色(同时生成 light-1..9 / dark-1..3 色阶,保证浅色变体跟随)
114
- const setPrimaryColor = (color: string) => {
115
- primaryColor.value = color
133
+ // 主题色 CSS 变量应用(含 light-1..9 / dark-1..3 色阶;不写 localStorage,供初始化与重置使用)
134
+ const applyPrimaryColorVars = (color: string) => {
116
135
  const root = document.documentElement
117
136
  root.style.setProperty('--xto-color-primary', color)
118
137
  const rgb = hexToRgb(color)
@@ -130,9 +149,29 @@ export const useAppStore = defineStore('app', () => {
130
149
  root.style.setProperty('--xto-color-primary-dark-3', mix(0.45, false))
131
150
  root.style.setProperty('--xto-color-ring', color)
132
151
  }
152
+ }
153
+
154
+ // 设置主题色(用户操作,持久化)
155
+ const setPrimaryColor = (color: string) => {
156
+ primaryColor.value = color
157
+ applyPrimaryColorVars(color)
133
158
  local.set('primaryColor', color)
134
159
  }
135
160
 
161
+ // 恢复默认:清掉本地偏好,回到服务端按应用配置(无配置则回内置默认)
162
+ const resetToServerDefaults = () => {
163
+ PREFERENCE_KEYS.forEach((key) => local.remove(key))
164
+ layout.value = serverUi.layout ?? 'sidebar'
165
+ primaryColor.value = serverUi.primaryColor ?? '#4F46E5'
166
+ theme.value = serverUi.theme ?? 'light'
167
+ isDark.value = theme.value === 'dark'
168
+ showTabs.value = serverFeatures.showTabs ?? true
169
+ showFooter.value = serverFeatures.showFooter ?? true
170
+ showBreadcrumb.value = serverFeatures.showBreadcrumb ?? true
171
+ applyThemeClass()
172
+ applyPrimaryColorVars(primaryColor.value)
173
+ }
174
+
136
175
  // 添加缓存页面
137
176
  const addCachedView = (name: string) => {
138
177
  if (!cachedViews.value.includes(name)) {
@@ -158,16 +197,14 @@ export const useAppStore = defineStore('app', () => {
158
197
  mixSubMenus.value = menus
159
198
  }
160
199
 
161
- // 初始化主题
200
+ // 初始化主题:只应用运行时效果,不持久化(避免把服务端默认值固化成用户偏好)
162
201
  const initTheme = () => {
163
- updateTheme()
164
- if (primaryColor.value !== '#4F46E5') {
165
- document.documentElement.style.setProperty('--xto-color-primary', primaryColor.value)
166
- }
202
+ applyThemeClass()
203
+ applyPrimaryColorVars(primaryColor.value)
167
204
  }
168
205
 
169
206
  // 监听主题变化
170
- watch(isDark, updateTheme)
207
+ watch(isDark, applyThemeClass)
171
208
 
172
209
  return {
173
210
  appName,
@@ -181,6 +218,7 @@ export const useAppStore = defineStore('app', () => {
181
218
  showFooter,
182
219
  showBreadcrumb,
183
220
  primaryColor,
221
+ allowUserOverride,
184
222
  cachedViews,
185
223
  mixSubMenus,
186
224
  themeClass,
@@ -195,6 +233,7 @@ export const useAppStore = defineStore('app', () => {
195
233
  toggleFooter,
196
234
  toggleBreadcrumb,
197
235
  setPrimaryColor,
236
+ resetToServerDefaults,
198
237
  addCachedView,
199
238
  removeCachedView,
200
239
  clearCachedViews,
@@ -168,4 +168,36 @@ export const appConfig = {
168
168
  clientId,
169
169
  apiBaseUrl,
170
170
  basePath
171
+ }
172
+
173
+ // ==================== 应用界面配置(服务端按应用下发的默认值) ====================
174
+
175
+ export interface AppUiFeatures {
176
+ showTabs?: boolean
177
+ showBreadcrumb?: boolean
178
+ showFooter?: boolean
179
+ }
180
+
181
+ export interface AppUiConfig {
182
+ /** 默认布局模式 */
183
+ layout?: 'sidebar' | 'top' | 'mix'
184
+ /** 默认主题色 */
185
+ primaryColor?: string
186
+ /** 默认深浅主题 */
187
+ theme?: 'light' | 'dark'
188
+ /** 默认功能开关 */
189
+ features?: AppUiFeatures
190
+ /** 是否允许用户在界面右上角自行修改(false 时锁定设置入口) */
191
+ allowUserOverride?: boolean
192
+ }
193
+
194
+ // 模块级保存,app store 首次实例化时读取(早于任何组件渲染,无闪烁)
195
+ let serverUiConfig: AppUiConfig = {}
196
+
197
+ export function setServerUiConfig(config: AppUiConfig): void {
198
+ serverUiConfig = config || {}
199
+ }
200
+
201
+ export function getServerUiConfig(): AppUiConfig {
202
+ return serverUiConfig
171
203
  }