This commit is contained in:
2026-10-25 23:33:42 +08:00
parent c5a6ada6a6
commit 7ec6bd789f
23 changed files with 1473 additions and 110 deletions
+312 -1
View File
@@ -1,3 +1,314 @@
{
"globals": {}
"globals": {
"Component": true,
"ComponentPublicInstance": true,
"ComputedRef": true,
"DirectiveBinding": true,
"EffectScope": true,
"ExtractDefaultPropTypes": true,
"ExtractPropTypes": true,
"ExtractPublicPropTypes": true,
"InjectionKey": true,
"MaybeRef": true,
"MaybeRefOrGetter": true,
"PropType": true,
"Ref": true,
"VNode": true,
"WritableComputedRef": true,
"acceptHMRUpdate": true,
"asyncComputed": true,
"autoResetRef": true,
"computed": true,
"computedAsync": true,
"computedEager": true,
"computedInject": true,
"computedWithControl": true,
"controlledComputed": true,
"controlledRef": true,
"createApp": true,
"createEventHook": true,
"createGlobalState": true,
"createInjectionState": true,
"createPinia": true,
"createReactiveFn": true,
"createReusableTemplate": true,
"createSharedComposable": true,
"createTemplatePromise": true,
"createUnrefFn": true,
"customRef": true,
"debouncedRef": true,
"debouncedWatch": true,
"defineAsyncComponent": true,
"defineComponent": true,
"defineStore": true,
"eagerComputed": true,
"effectScope": true,
"extendRef": true,
"getActivePinia": true,
"getCurrentInstance": true,
"getCurrentScope": true,
"h": true,
"ignorableWatch": true,
"inject": true,
"injectLocal": true,
"isDefined": true,
"isProxy": true,
"isReactive": true,
"isReadonly": true,
"isRef": true,
"makeDestructurable": true,
"mapActions": true,
"mapGetters": true,
"mapState": true,
"mapStores": true,
"mapWritableState": true,
"markRaw": true,
"nextTick": true,
"onActivated": true,
"onBeforeMount": true,
"onBeforeRouteLeave": true,
"onBeforeRouteUpdate": true,
"onBeforeUnmount": true,
"onBeforeUpdate": true,
"onClickOutside": true,
"onDeactivated": true,
"onErrorCaptured": true,
"onKeyStroke": true,
"onLongPress": true,
"onMounted": true,
"onRenderTracked": true,
"onRenderTriggered": true,
"onScopeDispose": true,
"onServerPrefetch": true,
"onStartTyping": true,
"onUnmounted": true,
"onUpdated": true,
"onWatcherCleanup": true,
"pausableWatch": true,
"provide": true,
"provideLocal": true,
"reactify": true,
"reactifyObject": true,
"reactive": true,
"reactiveComputed": true,
"reactiveOmit": true,
"reactivePick": true,
"readonly": true,
"ref": true,
"refAutoReset": true,
"refDebounced": true,
"refDefault": true,
"refThrottled": true,
"refWithControl": true,
"resolveComponent": true,
"resolveRef": true,
"resolveUnref": true,
"setActivePinia": true,
"setMapStoreSuffix": true,
"shallowReactive": true,
"shallowReadonly": true,
"shallowRef": true,
"storeToRefs": true,
"syncRef": true,
"syncRefs": true,
"templateRef": true,
"throttledRef": true,
"throttledWatch": true,
"toRaw": true,
"toReactive": true,
"toRef": true,
"toRefs": true,
"toValue": true,
"triggerRef": true,
"tryOnBeforeMount": true,
"tryOnBeforeUnmount": true,
"tryOnMounted": true,
"tryOnScopeDispose": true,
"tryOnUnmounted": true,
"unref": true,
"unrefElement": true,
"until": true,
"useActiveElement": true,
"useAnimate": true,
"useArrayDifference": true,
"useArrayEvery": true,
"useArrayFilter": true,
"useArrayFind": true,
"useArrayFindIndex": true,
"useArrayFindLast": true,
"useArrayIncludes": true,
"useArrayJoin": true,
"useArrayMap": true,
"useArrayReduce": true,
"useArraySome": true,
"useArrayUnique": true,
"useAsyncQueue": true,
"useAsyncState": true,
"useAttrs": true,
"useBase64": true,
"useBattery": true,
"useBluetooth": true,
"useBreakpoints": true,
"useBroadcastChannel": true,
"useBrowserLocation": true,
"useCached": true,
"useClipboard": true,
"useClipboardItems": true,
"useCloned": true,
"useColorMode": true,
"useConfirmDialog": true,
"useCounter": true,
"useCssModule": true,
"useCssVar": true,
"useCssVars": true,
"useCurrentElement": true,
"useCycleList": true,
"useDark": true,
"useDateFormat": true,
"useDebounce": true,
"useDebounceFn": true,
"useDebouncedRefHistory": true,
"useDeviceMotion": true,
"useDeviceOrientation": true,
"useDevicePixelRatio": true,
"useDevicesList": true,
"useDisplayMedia": true,
"useDocumentVisibility": true,
"useDraggable": true,
"useDropZone": true,
"useElementBounding": true,
"useElementByPoint": true,
"useElementHover": true,
"useElementSize": true,
"useElementVisibility": true,
"useEventBus": true,
"useEventListener": true,
"useEventSource": true,
"useEyeDropper": true,
"useFavicon": true,
"useFetch": true,
"useFileDialog": true,
"useFileSystemAccess": true,
"useFocus": true,
"useFocusWithin": true,
"useFps": true,
"useFullscreen": true,
"useGamepad": true,
"useGeolocation": true,
"useId": true,
"useIdle": true,
"useImage": true,
"useInfiniteScroll": true,
"useIntersectionObserver": true,
"useInterval": true,
"useIntervalFn": true,
"useKeyModifier": true,
"useLastChanged": true,
"useLink": true,
"useLocalStorage": true,
"useMagicKeys": true,
"useManualRefHistory": true,
"useMediaControls": true,
"useMediaQuery": true,
"useMemoize": true,
"useMemory": true,
"useModel": true,
"useMounted": true,
"useMouse": true,
"useMouseInElement": true,
"useMousePressed": true,
"useMutationObserver": true,
"useNavigatorLanguage": true,
"useNetwork": true,
"useNow": true,
"useObjectUrl": true,
"useOffsetPagination": true,
"useOnline": true,
"usePageLeave": true,
"useParallax": true,
"useParentElement": true,
"usePerformanceObserver": true,
"usePermission": true,
"usePointer": true,
"usePointerLock": true,
"usePointerSwipe": true,
"usePreferredColorScheme": true,
"usePreferredContrast": true,
"usePreferredDark": true,
"usePreferredLanguages": true,
"usePreferredReducedMotion": true,
"usePrevious": true,
"useRafFn": true,
"useRefHistory": true,
"useResizeObserver": true,
"useRoute": true,
"useRouter": true,
"useScreenOrientation": true,
"useScreenSafeArea": true,
"useScriptTag": true,
"useScroll": true,
"useScrollLock": true,
"useSessionStorage": true,
"useShare": true,
"useSlots": true,
"useSorted": true,
"useSpeechRecognition": true,
"useSpeechSynthesis": true,
"useStepper": true,
"useStorage": true,
"useStorageAsync": true,
"useStyleTag": true,
"useSupported": true,
"useSwipe": true,
"useTemplateRef": true,
"useTemplateRefsList": true,
"useTextDirection": true,
"useTextSelection": true,
"useTextareaAutosize": true,
"useThrottle": true,
"useThrottleFn": true,
"useThrottledRefHistory": true,
"useTimeAgo": true,
"useTimeout": true,
"useTimeoutFn": true,
"useTimeoutPoll": true,
"useTimestamp": true,
"useTitle": true,
"useToNumber": true,
"useToString": true,
"useToggle": true,
"useTransition": true,
"useUrlSearchParams": true,
"useUserMedia": true,
"useVModel": true,
"useVModels": true,
"useVibrate": true,
"useVirtualList": true,
"useWakeLock": true,
"useWebNotification": true,
"useWebSocket": true,
"useWebWorker": true,
"useWebWorkerFn": true,
"useWindowFocus": true,
"useWindowScroll": true,
"useWindowSize": true,
"watch": true,
"watchArray": true,
"watchAtMost": true,
"watchDebounced": true,
"watchDeep": true,
"watchEffect": true,
"watchIgnorable": true,
"watchImmediate": true,
"watchOnce": true,
"watchPausable": true,
"watchPostEffect": true,
"watchSyncEffect": true,
"watchThrottled": true,
"watchTriggerable": true,
"watchWithFilter": true,
"whenever": true,
"ElMessage": true,
"ElMessageBox": true
}
}
+12 -83
View File
@@ -29,6 +29,9 @@ importers:
pinia:
specifier: ^2.2.4
version: 2.3.1(typescript@5.5.4)(vue@3.5.44(typescript@5.5.4))
pinia-plugin-persistedstate:
specifier: ^3.2.3
version: 3.2.3(pinia@2.3.1(typescript@5.5.4)(vue@3.5.44(typescript@5.5.4)))
vue:
specifier: ^3.5.12
version: 3.5.44(typescript@5.5.4)
@@ -51,9 +54,6 @@ importers:
'@vitejs/plugin-vue':
specifier: ^5.1.4
version: 5.2.4(vite@5.4.21(@types/node@22.20.5)(sass@1.106.1))(vue@3.5.44(typescript@5.5.4))
autoprefixer:
specifier: ^10.4.20
version: 10.6.1(postcss@8.5.31)
eslint:
specifier: ^8.57.1
version: 8.57.1
@@ -768,24 +768,12 @@ packages:
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
autoprefixer@10.6.1:
resolution: {integrity: sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA==}
engines: {node: ^10 || ^12 || >=14}
hasBin: true
peerDependencies:
postcss: ^8.1.0
axios@1.20.0:
resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
baseline-browser-mapping@2.11.32:
resolution: {integrity: sha512-UvuxFU9P3hM29LrQdwu0cD6w6/+SFr+UlB9s/knI7+O2rK9xFxKUwSDWxGdbbqQ9/V7BJ5s0NI5nQAE4uJp3gQ==}
engines: {node: '>=6.0.0'}
hasBin: true
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
@@ -803,11 +791,6 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
browserslist@4.29.4:
resolution: {integrity: sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
@@ -816,9 +799,6 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001819:
resolution: {integrity: sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -920,9 +900,6 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
electron-to-chromium@1.5.464:
resolution: {integrity: sha512-nRa65aFge40viPAKxMKULIY8tcXKwKboJWNn4RoKwyWXJ6SP7BnrDGKUTzc64818jIciH0PK7LSF8QzZes19GA==}
element-plus@2.14.7:
resolution: {integrity: sha512-lrG7pZyCS+mpCNVpzxxyu5r35+T6V0hzkAeIwn7HA6to772XSTHKuYCZG51OfzlqaxGbNegLxs650zAF1j43gA==}
peerDependencies:
@@ -960,10 +937,6 @@ packages:
engines: {node: '>=12'}
hasBin: true
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
@@ -1099,9 +1072,6 @@ packages:
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
engines: {node: '>= 6'}
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
@@ -1378,10 +1348,6 @@ packages:
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-releases@2.0.60:
resolution: {integrity: sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ==}
engines: {node: '>=18'}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
@@ -1468,6 +1434,11 @@ packages:
engines: {node: '>=0.10'}
hasBin: true
pinia-plugin-persistedstate@3.2.3:
resolution: {integrity: sha512-Cm819WBj/s5K5DGw55EwbXDtx+EZzM0YR5AZbq9XE3u0xvXwvX2JnWoFpWIcdzISBHqy9H1UiSIUmXyXqWsQRQ==}
peerDependencies:
pinia: ^2.0.0
pinia@2.3.1:
resolution: {integrity: sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==}
peerDependencies:
@@ -1487,9 +1458,6 @@ packages:
resolution: {integrity: sha512-TEHFzb5KRDKaOgJ4TYE+0jyJtFbjLvkdVOu0fZ2b6MTWzoqZd4Le+61aEppWkvTlYePgOX4ATL1wsAIPMLTw6Q==}
engines: {node: '>=4'}
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@8.5.31:
resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==}
engines: {node: ^10 || ^12 || >=14}
@@ -1697,12 +1665,6 @@ packages:
resolution: {integrity: sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==}
engines: {node: '>=14.0.0'}
update-browserslist-db@1.3.4:
resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@@ -2387,15 +2349,6 @@ snapshots:
asynckit@0.4.0: {}
autoprefixer@10.6.1(postcss@8.5.31):
dependencies:
browserslist: 4.29.4
caniuse-lite: 1.0.30001819
fraction.js: 5.3.4
picocolors: 1.1.1
postcss: 8.5.31
postcss-value-parser: 4.2.0
axios@1.20.0:
dependencies:
follow-redirects: 1.16.1
@@ -2408,8 +2361,6 @@ snapshots:
balanced-match@1.0.2: {}
baseline-browser-mapping@2.11.32: {}
binary-extensions@2.3.0: {}
boolbase@1.0.0: {}
@@ -2427,14 +2378,6 @@ snapshots:
dependencies:
fill-range: 7.1.1
browserslist@4.29.4:
dependencies:
baseline-browser-mapping: 2.11.32
caniuse-lite: 1.0.30001819
electron-to-chromium: 1.5.464
node-releases: 2.0.60
update-browserslist-db: 1.3.4(browserslist@4.29.4)
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@@ -2442,8 +2385,6 @@ snapshots:
callsites@3.1.0: {}
caniuse-lite@1.0.30001819: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -2535,8 +2476,6 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
electron-to-chromium@1.5.464: {}
element-plus@2.14.7(vue@3.5.44(typescript@5.5.4)):
dependencies:
'@ctrl/tinycolor': 4.2.1
@@ -2603,8 +2542,6 @@ snapshots:
'@esbuild/win32-ia32': 0.21.5
'@esbuild/win32-x64': 0.21.5
escalade@3.2.0: {}
escape-string-regexp@4.0.0: {}
escape-string-regexp@5.0.0: {}
@@ -2777,8 +2714,6 @@ snapshots:
hasown: 2.0.4
mime-types: 2.1.35
fraction.js@5.3.4: {}
fs.realpath@1.0.0: {}
fsevents@2.3.3:
@@ -3044,8 +2979,6 @@ snapshots:
node-addon-api@7.1.1:
optional: true
node-releases@2.0.60: {}
normalize-path@3.0.0: {}
normalize-wheel-es@1.2.0: {}
@@ -3115,6 +3048,10 @@ snapshots:
pidtree@0.6.1: {}
pinia-plugin-persistedstate@3.2.3(pinia@2.3.1(typescript@5.5.4)(vue@3.5.44(typescript@5.5.4))):
dependencies:
pinia: 2.3.1(typescript@5.5.4)(vue@3.5.44(typescript@5.5.4))
pinia@2.3.1(typescript@5.5.4)(vue@3.5.44(typescript@5.5.4)):
dependencies:
'@vue/devtools-api': 6.6.4
@@ -3142,8 +3079,6 @@ snapshots:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss-value-parser@4.2.0: {}
postcss@8.5.31:
dependencies:
nanoid: 3.3.20
@@ -3370,12 +3305,6 @@ snapshots:
acorn: 8.19.0
webpack-virtual-modules: 0.6.2
update-browserslist-db@1.3.4(browserslist@4.29.4):
dependencies:
browserslist: 4.29.4
escalade: 3.2.0
picocolors: 1.1.1
uri-js@4.4.1:
dependencies:
punycode: 2.3.1
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import { useSettingsStore } from '@/store'
const settingsStore = useSettingsStore()
// 刷新后将持久化的主题色重新写入 CSS 变量
onMounted(() => settingsStore.setThemeColor(settingsStore.themeColor))
</script>
<template>
<el-config-provider :locale="zhCn">
<router-view />
</el-config-provider>
</template>
+11
View File
@@ -0,0 +1,11 @@
import request from '@/utils/request'
import type { DashboardSummary } from '@/types/dashboard'
const BASE_URL = '/dashboard'
export const DashboardAPI = {
/** 首页汇总数据 */
summary() {
return request<DashboardSummary>({ url: `${BASE_URL}/summary`, method: 'get' })
},
}
+5 -1
View File
@@ -10,7 +10,11 @@ function normalize(binding: DirectiveBinding<AuthValue>): string[] {
}
/** 校验不通过时直接移除 DOM 节点,而非仅禁用 */
function check(el: HTMLElement, binding: DirectiveBinding<AuthValue>, checker: (code: string) => boolean) {
function check(
el: HTMLElement,
binding: DirectiveBinding<AuthValue>,
checker: (code: string) => boolean,
) {
const codes = normalize(binding)
if (!codes.length) return
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { RouteRecordRaw } from 'vue-router'
import { constantRoutes } from '@/router'
import { usePermissionStore } from '@/store'
const permissionStore = usePermissionStore()
/**
* 需要缓存的页面:默认全部缓存,meta.keepAlive === false 时排除
* 注意:组件 name 必须与路由 name 一致,否则 include 失效
*/
const cachedViews = computed(() => {
const names: string[] = []
const walk = (routes: RouteRecordRaw[]) => {
routes.forEach((route) => {
if (route.meta?.hidden) return
if (route.name && route.meta?.keepAlive !== false) {
names.push(String(route.name))
}
if (route.children?.length) walk(route.children)
})
}
walk([...constantRoutes, ...permissionStore.routes])
return names
})
</script>
<template>
<section class="app-main">
<router-view v-slot="{ Component, route }">
<transition name="fade-transform" mode="out-in">
<keep-alive :include="cachedViews">
<component :is="Component" :key="route.path" />
</keep-alive>
</transition>
</router-view>
</section>
</template>
<style scoped lang="scss">
.app-main {
min-height: calc(100vh - #{$navbar-height});
padding: $container-padding;
background-color: $bg-page;
}
</style>
+111
View File
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { ArrowDown, User } from '@element-plus/icons-vue'
import { useFullscreen } from '@vueuse/core'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAppStore, useSettingsStore, useUserStore } from '@/store'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const userStore = useUserStore()
const settingsStore = useSettingsStore()
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
/** 面包屑:取 matched 中带标题的层级 */
const breadcrumbs = computed(() =>
route.matched.filter((item) => item.meta?.title).map((item) => item.meta.title as string),
)
function handleThemeChange(color: string | null) {
if (color) {
settingsStore.setThemeColor(color)
}
}
async function handleCommand(command: string) {
if (command === 'logout') {
await ElMessageBox.confirm('确认退出登录吗?', '提示', { type: 'warning' })
await userStore.logout()
ElMessage.success('已退出登录')
void router.replace('/login')
}
}
</script>
<template>
<div class="navbar">
<div class="navbar__left">
<el-icon class="navbar__icon" @click="appStore.toggleSidebar()">
<component :is="appStore.sidebarCollapsed ? 'Expand' : 'Fold'" />
</el-icon>
<el-breadcrumb separator="/">
<el-breadcrumb-item v-for="(title, index) in breadcrumbs" :key="index">
{{ title }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="navbar__right">
<el-color-picker
v-model="settingsStore.themeColor"
size="small"
@change="handleThemeChange"
/>
<el-icon class="navbar__icon" @click="toggleFullscreen()">
<component :is="isFullscreen ? 'Aim' : 'FullScreen'" />
</el-icon>
<el-dropdown trigger="click" @command="handleCommand">
<span class="navbar__user">
<el-icon><User /></el-icon>
<span>{{ userStore.nickname || userStore.userInfo?.username }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<style scoped lang="scss">
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
height: $navbar-height;
padding: 0 16px;
background-color: #fff;
border-bottom: 1px solid $border-color-light;
&__left,
&__right {
display: flex;
align-items: center;
gap: 12px;
}
&__icon {
font-size: 18px;
cursor: pointer;
color: $text-color-regular;
&:hover {
color: $color-primary;
}
}
&__user {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
outline: none;
color: $text-color-regular;
}
}
</style>
+45
View File
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useAppStore, useSettingsStore } from '@/store'
const appStore = useAppStore()
const settingsStore = useSettingsStore()
const title = computed(() => import.meta.env.VITE_APP_TITLE || 'Portal')
</script>
<template>
<div v-if="settingsStore.showLogo" class="sidebar-logo">
<span class="sidebar-logo__mark">P</span>
<span v-show="!appStore.sidebarCollapsed" class="sidebar-logo__title">{{ title }}</span>
</div>
</template>
<style scoped lang="scss">
.sidebar-logo {
@include flex-center;
height: $navbar-height;
gap: 8px;
border-bottom: 1px solid $border-color-light;
&__mark {
@include flex-center;
flex-shrink: 0;
width: 28px;
height: 28px;
font-weight: 600;
color: #fff;
background-color: $color-primary;
border-radius: 6px;
}
&__title {
@include text-ellipsis;
font-size: 15px;
font-weight: 600;
color: $text-color-primary;
}
}
</style>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { RouteRecordRaw } from 'vue-router'
const props = defineProps<{ item: RouteRecordRaw }>()
/** 需要渲染的子菜单(过滤隐藏项) */
const visibleChildren = computed(() => (props.item.children ?? []).filter((c) => !c.meta?.hidden))
/** 只有一个子路由且未声明 alwaysShow 时,直接渲染子路由 */
const singleChild = computed(() => {
if (visibleChildren.value.length !== 1) return null
return props.item.meta?.alwaysShow ? null : visibleChildren.value[0]
})
const hasChildren = computed(
() =>
visibleChildren.value.length > 1 ||
(visibleChildren.value.length === 1 && !singleChild.value),
)
</script>
<template>
<el-sub-menu v-if="hasChildren" :index="String(item.path)">
<template #title>
<el-icon v-if="item.meta?.icon"><component :is="item.meta.icon" /></el-icon>
<span>{{ item.meta?.title }}</span>
</template>
<SidebarItem v-for="child in visibleChildren" :key="child.path" :item="child" />
</el-sub-menu>
<el-menu-item v-else :index="String(singleChild ? singleChild.path : item.path)">
<el-icon v-if="item.meta?.icon"><component :is="item.meta.icon" /></el-icon>
<template #title>{{ singleChild ? singleChild.meta?.title : item.meta?.title }}</template>
</el-menu-item>
</template>
+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter, type RouteRecordRaw } from 'vue-router'
import { constantRoutes } from '@/router'
import { useAppStore, usePermissionStore } from '@/store'
import Logo from './Logo.vue'
import SidebarItem from './SidebarItem.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const permissionStore = usePermissionStore()
/**
* 菜单数据来源:首页骨架的静态子路由 + 后端返回的动态路由
*/
const menuRoutes = computed<RouteRecordRaw[]>(() => {
const root = constantRoutes.find((item) => item.path === '/')
const homeChildren = (root?.children ?? []).filter((item) => !item.meta?.hidden)
const dynamicRoutes = permissionStore.routes.filter((item) => !item.meta?.hidden)
return [...homeChildren, ...dynamicRoutes]
})
const activeMenu = computed(() => (route.meta.activeMenu as string | undefined) ?? route.path)
function handleSelect(index: string) {
if (index !== route.path) {
void router.push(index)
}
}
</script>
<template>
<div class="sidebar">
<Logo />
<el-scrollbar class="sidebar__scroll">
<el-menu
class="sidebar__menu"
:default-active="activeMenu"
:collapse="appStore.sidebarCollapsed"
:collapse-transition="false"
unique-opened
@select="handleSelect"
>
<SidebarItem v-for="item in menuRoutes" :key="item.path" :item="item" />
</el-menu>
</el-scrollbar>
</div>
</template>
<style scoped lang="scss">
.sidebar {
display: flex;
flex-direction: column;
height: 100%;
&__scroll {
flex: 1;
}
&__menu {
border-right: none;
}
}
</style>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { useAppStore } from '@/store'
import AppMain from './components/AppMain.vue'
import Navbar from './components/Navbar.vue'
import Sidebar from './components/Sidebar/index.vue'
const appStore = useAppStore()
</script>
<template>
<div class="app-wrapper" :class="{ 'app-wrapper--collapsed': appStore.sidebarCollapsed }">
<Sidebar class="app-wrapper__sidebar" />
<div class="app-wrapper__container">
<Navbar />
<AppMain />
</div>
</div>
</template>
<style scoped lang="scss">
.app-wrapper {
position: relative;
width: 100%;
height: 100%;
&__sidebar {
position: fixed;
top: 0;
left: 0;
z-index: $z-index-sidebar;
width: $sidebar-width;
height: 100%;
overflow: hidden;
background-color: $bg-sidebar;
border-right: 1px solid $border-color-light;
transition: width $transition-duration;
}
&__container {
min-height: 100%;
margin-left: $sidebar-width;
transition: margin-left $transition-duration;
}
&--collapsed {
.app-wrapper__sidebar {
width: $sidebar-width-collapsed;
}
.app-wrapper__container {
margin-left: $sidebar-width-collapsed;
}
}
}
</style>
+30
View File
@@ -0,0 +1,30 @@
import { createApp } from 'vue'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import { setupDirectives } from '@/directives'
import router from '@/router'
import pinia from '@/store'
import '@/styles/index.scss'
import '@/styles/element/index.scss'
// 以下组件为函数式调用,按需导入插件不会自动引入其样式
import 'element-plus/theme-chalk/el-message.css'
import 'element-plus/theme-chalk/el-message-box.css'
import 'element-plus/theme-chalk/el-notification.css'
import 'element-plus/theme-chalk/el-loading.css'
import 'nprogress/nprogress.css'
const app = createApp(App)
// 全局注册图标,支持菜单按名称字符串渲染
for (const [name, component] of Object.entries(ElementPlusIconsVue)) {
app.component(name, component)
}
app.use(pinia)
app.use(router)
setupDirectives(app)
void router.isReady().then(() => {
app.mount('#app')
})
+5 -1
View File
@@ -52,7 +52,11 @@ router.beforeEach(async (to, _from, next) => {
// 已登录访问登录页 → 回首页
if (to.path === '/login') {
next(userStore.token ? { path: '/' } : undefined)
if (userStore.token) {
next({ path: '/' })
} else {
next()
}
return
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Element Plus 主题覆盖
* 仅通过 CSS 变量覆盖,禁止使用 !important 打补丁
*/
:root {
--el-color-primary: #{$color-primary};
--el-font-size-base: 14px;
--el-border-radius-base: 4px;
--el-table-header-bg-color: #fafafa;
--el-table-header-text-color: #{$text-color-primary};
}
/* 顶部进度条 */
#nprogress .bar {
background: $color-primary;
}
/* 搜索区表单项间距 */
.el-form--inline .el-form-item {
margin-right: 12px;
}
+89
View File
@@ -0,0 +1,89 @@
* {
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
}
body {
font-family:
'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial,
sans-serif;
font-size: 14px;
color: $text-color-primary;
background-color: $bg-page;
-webkit-font-smoothing: antialiased;
}
a {
color: $color-primary;
text-decoration: none;
}
/* 页面容器:所有业务页面根节点统一使用 */
.app-container {
padding: $container-padding;
}
/* 工具栏:搜索栏与操作按钮之间 */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
}
/* 首屏 loading */
.app-loading {
@include flex-center;
height: 100%;
&__spinner {
width: 36px;
height: 36px;
border: 3px solid $border-color;
border-top-color: $color-primary;
border-radius: 50%;
animation: app-loading-rotate 0.8s linear infinite;
}
}
@keyframes app-loading-rotate {
to {
transform: rotate(360deg);
}
}
/* 滚动条 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background-color: rgba(144, 147, 153, 0.3);
border-radius: 3px;
}
/* 页面切换动画 */
.fade-transform-enter-active,
.fade-transform-leave-active {
transition: all $transition-duration;
}
.fade-transform-enter-from {
opacity: 0;
transform: translateX(-12px);
}
.fade-transform-leave-to {
opacity: 0;
transform: translateX(12px);
}
+13
View File
@@ -0,0 +1,13 @@
/** 仪表盘汇总数据 */
export interface DashboardSummary {
userTotal: number
orderTotal: number
gmv: number
pendingApproval: number
trend: TrendPoint[]
}
export interface TrendPoint {
date: string
value: number
}
+5 -1
View File
@@ -1,5 +1,7 @@
import type { RouteMeta } from 'vue-router'
/** 路由元信息(与后端菜单字段保持一致) */
export interface RouteMetaVO {
export interface RouteMetaVO extends RouteMeta {
title?: string
icon?: string
hidden?: boolean
@@ -7,6 +9,8 @@ export interface RouteMetaVO {
keepAlive?: boolean
/** 只有一个子路由时是否始终展示父级菜单 */
alwaysShow?: boolean
/** 高亮的菜单路径 */
activeMenu?: string
roles?: string[]
}
+29 -23
View File
@@ -1,9 +1,5 @@
import axios, {
AxiosError,
type AxiosInstance,
type AxiosRequestConfig,
type AxiosResponse,
} from 'axios'
import type { AxiosError } from 'axios'
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
import router from '@/router'
import type { ApiResponse } from '@/types/common'
import { getToken, removeToken } from '@/utils/storage'
@@ -36,7 +32,7 @@ function handleUnauthorized(msg: string): void {
})
}
// 请求拦截器
// 请求拦截器:注入 Token
service.interceptors.request.use((config) => {
const token = getToken()
if (token) {
@@ -45,27 +41,20 @@ service.interceptors.request.use((config) => {
return config
})
// 响应拦截器
// 响应拦截器:仅处理登录态与网络层异常
service.interceptors.response.use(
(response: AxiosResponse<ApiResponse>): unknown => {
// 文件流直接透传,不做业务码判断
(response: AxiosResponse<ApiResponse>) => {
if (response.config.responseType === 'blob') {
return response.data
}
const { code, data, msg } = response.data
if (code === SUCCESS_CODE) {
return data
return response
}
const { code, msg } = response.data
if (code === TOKEN_INVALID_CODE) {
handleUnauthorized(msg)
return Promise.reject(new Error(msg))
return Promise.reject(new Error(msg || '登录状态已过期'))
}
ElMessage.error(msg || '请求失败,请稍后重试')
return Promise.reject(new Error(msg || '请求失败'))
return response
},
(error: AxiosError<ApiResponse>) => {
const status = error.response?.status
@@ -84,9 +73,26 @@ service.interceptors.response.use(
)
/**
* 统一请求入口
* @returns 直接返回业务数据(已在响应拦截器中剥离外层结构)
* 统一请求入口:剥离外层响应结构,直接返回业务数据
* @param config 请求配置,responseType 为 blob 时直接返回文件流
*/
export default function request<T>(config: AxiosRequestConfig): Promise<T> {
return service.request<unknown, T>(config)
const { responseType, ...rest } = config
const finalConfig: AxiosRequestConfig = responseType ? { ...rest, responseType } : rest
const pending = service(finalConfig) as unknown as Promise<AxiosResponse<ApiResponse<T>>>
return pending.then((response) => {
if (responseType === 'blob') {
return response.data as T
}
const { code, data, msg } = response.data
if (code === SUCCESS_CODE) {
return data
}
ElMessage.error(msg || '请求失败,请稍后重试')
return Promise.reject(new Error(msg || '请求失败'))
})
}
+129
View File
@@ -0,0 +1,129 @@
<script setup lang="ts">
import { computed } from 'vue'
import { DashboardAPI } from '@/api/dashboard'
import type { DashboardSummary } from '@/types/dashboard'
defineOptions({ name: 'Dashboard' })
const loading = ref(false)
const summary = ref<DashboardSummary>()
const cards = computed(() => [
{ label: '用户总数', value: summary.value?.userTotal ?? 0, icon: 'User', color: '#409eff' },
{ label: '订单总数', value: summary.value?.orderTotal ?? 0, icon: 'Tickets', color: '#67c23a' },
{ label: '交易额', value: summary.value?.gmv ?? 0, icon: 'Money', color: '#e6a23c' },
{
label: '待审批',
value: summary.value?.pendingApproval ?? 0,
icon: 'Clock',
color: '#f56c6c',
},
])
/** 趋势柱状图最大高度基准 */
const maxTrendValue = computed(() =>
Math.max(1, ...(summary.value?.trend ?? []).map((item) => item.value)),
)
async function loadSummary() {
loading.value = true
try {
summary.value = await DashboardAPI.summary()
} finally {
loading.value = false
}
}
onMounted(loadSummary)
</script>
<template>
<div v-loading="loading" class="app-container dashboard">
<el-row :gutter="16">
<el-col v-for="card in cards" :key="card.label" :xs="24" :sm="12" :lg="6">
<el-card class="dashboard__card" shadow="never">
<div class="dashboard__card-body">
<el-icon :style="{ color: card.color }"><component :is="card.icon" /></el-icon>
<div>
<p class="dashboard__label">{{ card.label }}</p>
<p class="dashboard__value">{{ card.value }}</p>
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-card class="dashboard__trend" shadow="never">
<template #header>近 7 日趋势</template>
<div class="dashboard__chart">
<div v-for="point in summary?.trend ?? []" :key="point.date" class="dashboard__bar">
<div
class="dashboard__bar-inner"
:style="{ height: `${(point.value / maxTrendValue) * 100}%` }"
/>
<span class="dashboard__bar-label">{{ point.date }}</span>
</div>
</div>
</el-card>
</div>
</template>
<style scoped lang="scss">
.dashboard {
&__card {
margin-bottom: 16px;
}
&__card-body {
display: flex;
align-items: center;
gap: 16px;
.el-icon {
font-size: 32px;
}
}
&__label {
margin: 0;
font-size: 13px;
color: $text-color-secondary;
}
&__value {
margin: 4px 0 0;
font-size: 22px;
font-weight: 600;
}
&__chart {
display: flex;
align-items: flex-end;
gap: 16px;
height: 200px;
padding: 8px 0;
}
&__bar {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: flex-end;
height: 100%;
}
&__bar-inner {
width: 32px;
background-color: $color-primary;
border-radius: 4px 4px 0 0;
transition: height 0.3s;
}
&__bar-label {
margin-top: 8px;
font-size: 12px;
color: $text-color-secondary;
}
}
</style>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
defineOptions({ name: 'NotFound' })
const router = useRouter()
</script>
<template>
<div class="not-found">
<h1 class="not-found__code">404</h1>
<p class="not-found__desc">抱歉,您访问的页面不存在或没有访问权限</p>
<el-button type="primary" @click="router.replace('/')">返回首页</el-button>
</div>
</template>
<style scoped lang="scss">
.not-found {
@include flex-center;
flex-direction: column;
height: 100vh;
gap: 12px;
&__code {
margin: 0;
font-size: 72px;
color: $color-primary;
}
&__desc {
margin: 0;
color: $text-color-secondary;
}
}
</style>
+109
View File
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { Lock, User } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { LoginForm } from '@/types/auth'
import { useUserStore } from '@/store'
defineOptions({ name: 'Login' })
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const appTitle = import.meta.env.VITE_APP_TITLE
const formData = reactive<LoginForm>({ username: 'admin', password: '123456' })
const rules: FormRules<LoginForm> = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于 6 位', trigger: 'blur' },
],
}
const redirect = computed(() => (route.query.redirect as string | undefined) ?? '/')
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
await userStore.login(formData)
ElMessage.success('登录成功')
void router.replace(redirect.value)
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login">
<el-card class="login__panel" shadow="always">
<h2 class="login__title">{{ appTitle }}</h2>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
size="large"
@keyup.enter="handleSubmit"
>
<el-form-item prop="username">
<el-input v-model="formData.username" placeholder="请输入用户名" :prefix-icon="User" />
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="formData.password"
type="password"
show-password
placeholder="请输入密码"
:prefix-icon="Lock"
/>
</el-form-item>
<el-button type="primary" class="login__submit" :loading="loading" @click="handleSubmit">
登 录
</el-button>
</el-form>
<p class="login__tip">演示账号:admin / 123456(本地 mock 不校验密码)</p>
</el-card>
</div>
</template>
<style scoped lang="scss">
.login {
@include flex-center;
height: 100%;
background: linear-gradient(135deg, #2b5876, #4e4376);
&__panel {
width: 380px;
padding: 12px;
}
&__title {
margin: 0 0 24px;
font-size: 20px;
font-weight: 600;
text-align: center;
color: $text-color-primary;
}
&__submit {
width: 100%;
}
&__tip {
margin: 16px 0 0;
font-size: 12px;
text-align: center;
color: $text-color-secondary;
}
}
</style>
@@ -0,0 +1,119 @@
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import { nextTick } from 'vue'
import { UserAPI } from '@/api/system/user'
import { USER_STATUS_OPTIONS, UserStatusEnum } from '@/enums/user'
import type { UserForm as UserFormData } from '@/types/system/user'
defineOptions({ name: 'UserForm' })
const emit = defineEmits<{ (e: 'success'): void }>()
const visible = ref(false)
const confirmLoading = ref(false)
const formRef = ref<FormInstance>()
const formData = reactive<UserFormData>({
username: '',
nickname: '',
mobile: '',
email: '',
status: UserStatusEnum.ENABLED,
roleNames: [],
})
const title = computed(() => (formData.id ? '编辑用户' : '新增用户'))
const rules: FormRules<UserFormData> = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '长度在 3 到 20 个字符', trigger: 'blur' },
],
nickname: [{ required: true, message: '请输入昵称', trigger: 'blur' }],
mobile: [{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }],
email: [{ type: 'email', message: '请输入正确的邮箱', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}
function resetForm() {
Object.assign(formData, {
id: undefined,
username: '',
nickname: '',
mobile: '',
email: '',
status: UserStatusEnum.ENABLED,
roleNames: [],
})
void nextTick(() => formRef.value?.clearValidate())
}
async function open(id?: number) {
visible.value = true
resetForm()
if (!id) return
const detail = await UserAPI.detail(id)
Object.assign(formData, {
id: detail.id,
username: detail.username,
nickname: detail.nickname,
mobile: detail.mobile,
email: detail.email,
status: detail.status,
roleNames: detail.roleNames ?? [],
})
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
confirmLoading.value = true
try {
if (formData.id) {
await UserAPI.update(formData.id, formData)
} else {
await UserAPI.create(formData)
}
ElMessage.success('保存成功')
visible.value = false
emit('success')
} finally {
confirmLoading.value = false
}
}
defineExpose({ open })
</script>
<template>
<el-dialog v-model="visible" :title="title" width="520px" :close-on-click-modal="false">
<el-form ref="formRef" :model="formData" :rules="rules" label-width="80px">
<el-form-item label="用户名" prop="username">
<el-input v-model="formData.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="昵称" prop="nickname">
<el-input v-model="formData.nickname" placeholder="请输入昵称" />
</el-form-item>
<el-form-item label="手机号" prop="mobile">
<el-input v-model="formData.mobile" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="formData.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio v-for="option in USER_STATUS_OPTIONS" :key="option.value" :value="option.value">
{{ option.label }}
</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取 消</el-button>
<el-button type="primary" :loading="confirmLoading" @click="handleSubmit">确 定</el-button>
</template>
</el-dialog>
</template>
+173
View File
@@ -0,0 +1,173 @@
<script setup lang="ts">
import { Delete, Plus, Refresh, Search } from '@element-plus/icons-vue'
import { computed } from 'vue'
import { UserAPI } from '@/api/system/user'
import { USER_STATUS_OPTIONS, UserStatusEnum } from '@/enums/user'
import type { UserQuery, UserVO } from '@/types/system/user'
import UserForm from './components/UserForm.vue'
defineOptions({ name: 'User' })
const formRef = ref<InstanceType<typeof UserForm>>()
const loading = ref(false)
const total = ref(0)
const list = ref<UserVO[]>([])
const selectedIds = ref<number[]>([])
const queryParams = reactive<UserQuery>({
pageNum: 1,
pageSize: 10,
username: '',
status: undefined,
})
const selectedIdStr = computed(() => selectedIds.value.join(','))
/** 分页参数变化后需要重新拉取数据 */
watch([() => queryParams.pageNum, () => queryParams.pageSize], handleQuery)
async function handleQuery() {
loading.value = true
try {
const { list: rows, total: count } = await UserAPI.query(queryParams)
list.value = rows
total.value = count
} finally {
loading.value = false
}
}
function handleReset() {
Object.assign(queryParams, { pageNum: 1, pageSize: 10, username: '', status: undefined })
handleQuery()
}
function handleSelectionChange(selection: UserVO[]) {
selectedIds.value = selection.map((item) => item.id)
}
function openForm(row?: Partial<UserVO>) {
formRef.value?.open(row?.id)
}
async function handleDelete(row?: Partial<UserVO>) {
const ids = row ? String(row.id) : selectedIdStr.value
if (!ids) {
ElMessage.warning('请先选择要删除的数据')
return
}
await ElMessageBox.confirm('确认删除选中的数据吗?删除后不可恢复', '提示', { type: 'warning' })
await UserAPI.delete(ids)
// 删除末页最后一条时回退页码,避免出现空列表
const remain = total.value - ids.split(',').length
const maxPage = Math.max(1, Math.ceil(remain / queryParams.pageSize))
queryParams.pageNum = Math.min(queryParams.pageNum, maxPage)
ElMessage.success('删除成功')
handleQuery()
}
onMounted(handleQuery)
</script>
<template>
<div class="app-container">
<!-- 搜索栏 -->
<el-form :model="queryParams" inline @submit.prevent="handleQuery">
<el-form-item label="用户名">
<el-input
v-model="queryParams.username"
placeholder="请输入用户名"
clearable
style="width: 180px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryParams.status" placeholder="全部" clearable style="width: 120px">
<el-option
v-for="option in USER_STATUS_OPTIONS"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 工具栏 -->
<div class="toolbar">
<div>
<el-button v-hasPerm="'sys:user:add'" type="primary" :icon="Plus" @click="openForm()">
新增
</el-button>
<el-button
v-hasPerm="'sys:user:delete'"
type="danger"
:icon="Delete"
:disabled="!selectedIds.length"
@click="handleDelete()"
>
批量删除
</el-button>
</div>
</div>
<!-- 表格 -->
<el-table v-loading="loading" :data="list" border @selection-change="handleSelectionChange">
<el-table-column type="selection" width="50" align="center" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column prop="nickname" label="昵称" min-width="120" />
<el-table-column prop="deptName" label="部门" min-width="120" />
<el-table-column prop="mobile" label="手机号" min-width="130" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === UserStatusEnum.ENABLED ? 'success' : 'info'">
{{ row.status === UserStatusEnum.ENABLED ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="180" />
<el-table-column label="操作" width="150" fixed="right" align="center">
<template #default="{ row }">
<el-button v-hasPerm="'sys:user:edit'" link type="primary" @click="openForm(row)">
编辑
</el-button>
<el-button v-hasPerm="'sys:user:delete'" link type="danger" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无数据" />
</template>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="queryParams.pageNum"
v-model:page-size="queryParams.pageSize"
class="pagination"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
/>
<UserForm ref="formRef" @success="handleQuery" />
</div>
</template>
<style scoped lang="scss">
.pagination {
justify-content: flex-end;
margin-top: 16px;
}
</style>