Compare commits

...

14 Commits

Author SHA1 Message Date
chansee97
8417432343 fix: 移除自动代理环境名选择 2025-08-30 22:32:46 +08:00
tu6ge
f2e82e725f
chore: add naive-ui-intelligence in vscode plugin recommendations (#53) 2025-08-21 16:32:51 +08:00
chansee97
ac9d7e84e5 fix: 修改全局地址映射名字 2025-08-20 17:46:46 +08:00
chansee97
050ab3e7ed fix: 修复正式环境地址错误 2025-08-20 17:43:28 +08:00
chansee97
79501a53f0 fix: 移除test环境示例 2025-08-19 23:15:31 +08:00
chansee97
501b64e884 fix: 精简路由,重新规范路径 2025-08-18 23:53:25 +08:00
chansee97
03a7891ed4 fix: 修复动态路由重定向问题 2025-08-18 22:40:29 +08:00
chansee97
e66e4fb17c fix: active menu error 2025-08-08 14:32:33 +08:00
chansee97
ade869ea30 fix: use suspense for apploading 2025-08-05 22:24:14 +08:00
chansee97
a1085e1922 fix: full-content padding error 2025-08-05 16:44:49 +08:00
chansee97
2535252310 fix: improve mobile layout 2025-08-02 23:19:34 +08:00
chansee97
228ccaee5b chore: updata deps 2025-08-02 01:11:43 +08:00
chansee97
3144acbc39 feat: adapted mobile screen 2025-08-02 01:06:47 +08:00
chansee97
1b4639d5d8 fix: full content error 2025-08-01 11:33:49 +08:00
59 changed files with 886 additions and 833 deletions

2
.env
View File

@ -20,7 +20,7 @@ VITE_STORAGE_PREFIX =
VITE_COPYRIGHT_INFO = Copyright © 2024 chansee97
# 自动刷新token
VITE_AUTO_REFRESH_TOKEN = Y
VITE_AUTO_REFRESH_TOKEN = N
# 默认多语言 enUS | zhCN
VITE_DEFAULT_LANG = enUS

View File

@ -1,2 +1,2 @@
# 是否开启服务接口代理 Y | N
VITE_HTTP_PROXY=Y
VITE_HTTP_PROXY=N

View File

@ -1,6 +0,0 @@
# 是否开启压缩资源
VITE_BUILD_COMPRESS=N
# 压缩算法 gzip | brotliCompress | deflate | deflateRaw
VITE_COMPRESS_TYPE=gzip

View File

@ -10,6 +10,7 @@
"antfu.iconify",
"kisstkondoros.vscode-gutter-preview",
"antfu.unocss",
"vue.volar"
"vue.volar",
"tu6ge.naive-ui-intelligence"
]
}

View File

@ -34,11 +34,9 @@ export interface ServiceProxyPluginOptions {
serviceConfig: FullServiceConfig
/** 代理路径前缀(可选,默认为 'proxy-' */
proxyPrefix?: string
/** 开发环境名称(可选,默认为 'development' */
devEnvName?: ServiceEnvType
/** 是否启用代理配置 */
enableProxy?: boolean
/** 环境变量名(可选,默认为 '__PROXY_MAPPING__' */
/** 环境变量名(可选,默认为 '__URL_MAP__' */
envName?: string
/** d.ts 类型文件生成路径(可选,如果传入路径则在该路径生成 d.ts 类型文件) */
dts?: string
@ -47,16 +45,15 @@ export interface ServiceProxyPluginOptions {
export default function createServiceProxyPlugin(options: ServiceProxyPluginOptions) {
const {
serviceConfig,
devEnvName = 'development',
proxyPrefix = 'proxy-',
enableProxy = true,
envName = '__PROXY_MAPPING__',
envName = '__URL_MAP__',
dts,
} = options
return {
name: 'vite-auto-proxy',
config(config: UserConfig, { command }: { mode: string, command: 'build' | 'serve' }) {
config(config: UserConfig, { mode, command }: { mode: string, command: 'build' | 'serve' }) {
// 只在开发环境serve命令时生成代理配置
const isDev = command === 'serve'
@ -66,9 +63,9 @@ export default function createServiceProxyPlugin(options: ServiceProxyPluginOpti
}
if (!enableProxy || !isDev) {
// 在非开发环境下生成原始地址映射path 和 rawPath 都是原始地址)
const rawMapping: ProxyMapping = {}
const envConfig = serviceConfig[devEnvName]
const envConfig = serviceConfig[mode]
if (envConfig) {
Object.entries(envConfig).forEach(([serviceName, serviceUrl]) => {
rawMapping[serviceName] = {
@ -76,7 +73,12 @@ export default function createServiceProxyPlugin(options: ServiceProxyPluginOpti
rawPath: serviceUrl,
}
})
console.warn(`[auto-proxy] 已加载 ${Object.keys(envConfig).length} 个服务地址`)
}
else {
console.warn(`[auto-proxy] 未找到环境 "${mode}" 的配置`)
}
config.define[envName] = JSON.stringify(rawMapping)
// 生成 d.ts 类型文件(如果指定了路径)
@ -86,9 +88,9 @@ export default function createServiceProxyPlugin(options: ServiceProxyPluginOpti
return
}
console.warn(`[auto-proxy] 已加载${devEnvName}模式 ${Object.keys(serviceConfig[devEnvName]).length} 个服务地址`)
console.warn(`[auto-proxy] 已加载${mode}模式 ${Object.keys(serviceConfig[mode]).length} 个服务地址`)
const { proxyConfig, proxyMapping } = generateProxyFromServiceConfig(serviceConfig, devEnvName, proxyPrefix)
const { proxyConfig, proxyMapping } = generateProxyFromServiceConfig(serviceConfig, mode, proxyPrefix)
Object.entries(proxyMapping).forEach(([serviceName, proxyItem]) => {
console.warn(`[auto-proxy] 服务: ${serviceName} | 代理地址: ${proxyItem.path} | 实际地址: ${proxyItem.rawPath}`)

View File

@ -83,7 +83,6 @@ export function createVitePlugins(env: ImportMetaEnv) {
AutoProxy({
enableProxy: env.VITE_HTTP_PROXY === 'Y',
serviceConfig,
devEnvName: 'dev',
dts: 'src/typings/auto-proxy.d.ts',
}),
]

View File

@ -9,7 +9,6 @@
</head>
<body>
<div id="appLoading"></div>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>

View File

@ -108,9 +108,6 @@
"QRCode": "QR code",
"about": "About",
"clipboard": "Clipboard",
"demo403": "403",
"demo404": "404",
"demo500": "500",
"dictionarySetting": "Dictionary settings",
"documents": "Document",
"documentsVite": "Vite",
@ -122,7 +119,6 @@
"editor": "Editor",
"editorMd": "MarkDown editor",
"editorRich": "Rich text editor",
"error": "Exception page",
"icons": "Icon",
"justSuper": "Supervisible",
"map": "Map",

View File

@ -151,10 +151,6 @@
"permission": "权限",
"permissionDemo": "权限示例",
"justSuper": "super可见",
"error": "异常页",
"demo403": "403",
"demo404": "404",
"demo500": "500",
"setting": "系统设置",
"accountSetting": "用户设置",
"dictionarySetting": "字典设置",

View File

@ -1,17 +0,0 @@
[build]
publish = "dist"
command = "vite build --mode prod"
[build.environment]
NODE_VERSION = "20"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
[[headers]]
for = "/manifest.webmanifest"
[headers.values]
Content-Type = "application/manifest+json"

View File

@ -1,7 +1,7 @@
{
"name": "nova-admin",
"type": "module",
"version": "0.9.15",
"version": "0.9.18",
"private": true,
"description": "a clean and concise back-end management template based on Vue3, Vite5, Typescript, and Naive UI.",
"author": {
@ -38,11 +38,9 @@
],
"scripts": {
"dev": "vite --mode dev --port 9980",
"dev:test": "vite --mode test",
"dev:prod": "vite --mode prod",
"build": "vite build --mode prod",
"dev:prod": "vite --mode production",
"build": "vite build",
"build:dev": "vite build --mode dev",
"build:test": "vite build --mode test",
"preview": "vite preview --port 9981",
"lint": "eslint . && vue-tsc --noEmit",
"lint:fix": "eslint . --fix",
@ -50,43 +48,43 @@
"sizecheck": "npx vite-bundle-visualizer"
},
"dependencies": {
"@vueuse/core": "^13.3.0",
"alova": "^3.3.2",
"@vueuse/core": "^13.6.0",
"alova": "^3.3.4",
"colord": "^2.9.3",
"echarts": "^5.6.0",
"md-editor-v3": "^5.6.1",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.3.0",
"pinia-plugin-persistedstate": "^4.4.1",
"pro-naive-ui": "^2.4.3",
"quill": "^2.0.3",
"radash": "^12.1.0",
"vue": "^3.5.16",
"radash": "^12.1.1",
"vue": "^3.5.18",
"vue-draggable-plus": "^0.6.0",
"vue-i18n": "^11.1.5",
"vue-i18n": "^11.1.11",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@antfu/eslint-config": "^4.14.1",
"@antfu/eslint-config": "^5.0.0",
"@iconify-json/icon-park-outline": "^1.2.2",
"@iconify/vue": "^4.3.0",
"@types/node": "^24.0.1",
"@vitejs/plugin-vue": "^5.2.4",
"@vitejs/plugin-vue-jsx": "^4.2.0",
"@iconify/vue": "^5.0.0",
"@types/node": "^24.1.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vitejs/plugin-vue-jsx": "^5.0.1",
"eslint": "^9.29.0",
"lint-staged": "^16.1.2",
"naive-ui": "^2.41.1",
"sass": "^1.86.3",
"simple-git-hooks": "^2.13.0",
"naive-ui": "^2.42.0",
"sass": "^1.89.2",
"simple-git-hooks": "^2.13.1",
"typescript": "^5.8.3",
"unocss": "^66.2.0",
"unocss": "^66.3.3",
"unplugin-auto-import": "^19.3.0",
"unplugin-icons": "^22.1.0",
"unplugin-vue-components": "^28.7.0",
"vite": "^6.3.5",
"unplugin-icons": "^22.2.0",
"unplugin-vue-components": "^28.8.0",
"vite": "^7.0.6",
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-vue-devtools": "7.7.6",
"vue-tsc": "^2.2.10"
"vite-plugin-vue-devtools": "8.0.0",
"vue-tsc": "^3.0.5"
},
"simple-git-hooks": {
"pre-commit": "pnpm lint-staged"

6
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,6 @@
ignoredBuiltDependencies:
- '@parcel/watcher'
- esbuild
- simple-git-hooks
- vue-demi
- unrs-resolver

View File

@ -1,12 +1,9 @@
/** 不同请求服务的环境配置 */
export const serviceConfig: Record<ServiceEnvType, Record<string, string>> = {
dev: {
url: 'https://mock.apifox.cn/m1/4071143-0-default',
url: 'http://localhost:3000',
},
test: {
url: 'https://mock.apifox.cn/m1/4071143-0-default',
},
prod: {
production: {
url: 'https://mock.apifox.cn/m1/4071143-0-default',
},
}

View File

@ -1,24 +1,18 @@
<script setup lang="ts">
import { naiveI18nOptions } from '@/utils'
import { darkTheme } from 'naive-ui'
import { useAppStore } from './store'
import AppMain from './AppMain.vue'
import AppLoading from './components/common/AppLoading.vue'
const appStore = useAppStore()
const naiveLocale = computed(() => {
return naiveI18nOptions[appStore.lang] ? naiveI18nOptions[appStore.lang] : naiveI18nOptions.enUS
},
)
// 使 Suspense
</script>
<template>
<n-config-provider
class="wh-full" inline-theme-disabled :theme="appStore.colorMode === 'dark' ? darkTheme : null"
:locale="naiveLocale.locale" :date-locale="naiveLocale.dateLocale" :theme-overrides="appStore.theme"
>
<naive-provider>
<router-view />
<Watermark :show-watermark="appStore.showWatermark" />
</naive-provider>
</n-config-provider>
<Suspense>
<!-- 异步组件 -->
<AppMain />
<!-- 加载状态 -->
<template #fallback>
<AppLoading />
</template>
</Suspense>
</template>

57
src/AppMain.vue Normal file
View File

@ -0,0 +1,57 @@
<script setup lang="ts">
import type { App } from 'vue'
import { installRouter } from '@/router'
import { installPinia } from '@/store'
import { naiveI18nOptions } from '@/utils'
import { darkTheme } from 'naive-ui'
import { useAppStore } from './store'
// Promise -
const initializationPromise = (async () => {
//
const app = getCurrentInstance()?.appContext.app
if (!app) {
throw new Error('Failed to get app instance')
}
// Pinia
await installPinia(app)
// Vue-router
await installRouter(app)
// /
const modules = import.meta.glob<{ install: (app: App) => void }>('./modules/*.ts', {
eager: true,
})
Object.values(modules).forEach(module => app.use(module))
return true
})()
// - 使 setup
await initializationPromise
const appStore = useAppStore()
const naiveLocale = computed(() => {
return naiveI18nOptions[appStore.lang] ? naiveI18nOptions[appStore.lang] : naiveI18nOptions.enUS
})
</script>
<template>
<n-config-provider
class="wh-full"
inline-theme-disabled
:theme="appStore.colorMode === 'dark' ? darkTheme : null"
:locale="naiveLocale.locale"
:date-locale="naiveLocale.dateLocale"
:theme-overrides="appStore.theme"
>
<naive-provider>
<router-view />
<Watermark :show-watermark="appStore.showWatermark" />
</naive-provider>
</n-config-provider>
</template>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 41 KiB

View File

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

View File

@ -1,36 +0,0 @@
<script setup lang="ts">
defineProps<{
/** 异常类型 403 404 500 */
type: '403' | '404' | '500'
}>()
const router = useRouter()
</script>
<template>
<div class="flex-col-center h-full">
<img
v-if="type === '403'"
src="@/assets/svg/error-403.svg"
alt=""
class="w-1/3"
>
<img
v-if="type === '404'"
src="@/assets/svg/error-404.svg"
alt=""
class="w-1/3"
>
<img
v-if="type === '500'"
src="@/assets/svg/error-500.svg"
alt=""
class="w-1/3"
>
<n-button
type="primary"
@click="router.push('/')"
>
{{ $t('app.backHome') }}
</n-button>
</div>
</template>

View File

@ -6,11 +6,15 @@ const routeStore = useRouteStore()
</script>
<template>
<n-el>
<n-el
class="h-full"
:class="[
appStore.layoutMode === 'full-content' ? 'p-0' : 'p-16px',
]"
style="background-color: var(--action-color);"
>
<router-view
v-slot="{ Component, route }"
class="flex-1 p-16px"
style="background-color: var(--action-color);"
>
<transition :name="appStore.transitionAnimation" mode="out-in">
<keep-alive :include="routeStore.cacheRoutes">

View File

@ -0,0 +1,38 @@
<script setup lang="ts">
import Search from '../header/Search.vue'
import Notices from '../header/Notices.vue'
import UserCenter from '../header/UserCenter.vue'
import Setting from './Setting.vue'
const showDrawer = defineModel<boolean>('show', { default: false })
</script>
<template>
<n-drawer
v-model:show="showDrawer"
:width="280"
placement="right"
:mask-closable="true"
:close-on-esc="true"
>
<n-drawer-content :native-scrollbar="false" :body-content-style="{ padding: '0' }">
<template #header>
<div class="flex items-center">
<UserCenter />
<div class="ml-auto" />
<Search />
<Notices />
</div>
</template>
<slot />
<template #footer>
<DarkModeSwitch />
<LangsSwitch />
<div class="ml-auto" />
<Setting />
</template>
</n-drawer-content>
</n-drawer>
</template>

View File

@ -1,7 +1,8 @@
<script setup lang="ts">
import { useBoolean } from '@/hooks'
import { useRouteStore } from '@/store'
import { useAppStore, useRouteStore } from '@/store'
const appStore = useAppStore()
const routeStore = useRouteStore()
//
@ -143,13 +144,14 @@ function handleMouseEnter(index: number) {
<template>
<CommonWrapper @click="openModal">
<icon-park-outline-search /><n-tag round size="small" class="font-mono cursor-pointer">
<icon-park-outline-search />
<n-tag v-if="!appStore.isMobile" round size="small" class="font-mono cursor-pointer">
CtrlK
</n-tag>
</CommonWrapper>
<n-modal
v-model:show="showModal"
class="w-560px fixed top-60px inset-x-0"
class="w-560px fixed top-60px inset-x-0 max-w-full"
size="small"
preset="card"
:segmented="{

View File

@ -61,7 +61,7 @@ function handleSelect(key: string | number) {
})
}
if (key === 'userCenter')
router.push('/userCenter')
router.push('/user-center')
if (key === 'guthub')
window.open('https://github.com/chansee97/nova-admin')

View File

@ -2,6 +2,7 @@ import BackTop from './common/BackTop.vue'
import Setting from './common/Setting.vue'
import SettingDrawer from './common/SettingDrawer.vue'
import Logo from './common/Logo.vue'
import MobileDrawer from './common/MobileDrawer.vue'
import Breadcrumb from './header/Breadcrumb.vue'
import CollapaseButton from './header/CollapaseButton.vue'
@ -18,6 +19,7 @@ export {
CollapaseButton,
FullScreen,
Logo,
MobileDrawer,
Notices,
Search,
Setting,

View File

@ -2,16 +2,49 @@
import { useAppStore } from '@/store'
const appStore = useAppStore()
let previousLayoutMode = appStore.layoutMode
function enterFullContent() {
previousLayoutMode = appStore.layoutMode
appStore.layoutMode = 'full-content'
}
function exitFullContent() {
// vertical
if (previousLayoutMode === 'full-content' || !previousLayoutMode) {
previousLayoutMode = 'vertical'
}
appStore.layoutMode = previousLayoutMode
}
</script>
<template>
<n-tooltip placement="bottom" trigger="hover">
<n-tooltip v-if="!appStore.isMobile" placement="bottom" trigger="hover">
<template #trigger>
<CommonWrapper @click="appStore.contentFullScreen = !appStore.contentFullScreen">
<icon-park-outline-off-screen-one v-if="appStore.contentFullScreen" />
<icon-park-outline-full-screen-one v-else />
<CommonWrapper @click="enterFullContent">
<icon-park-outline-full-screen-one />
</CommonWrapper>
</template>
<span>{{ $t('app.togglContentFullScreen') }}</span>
{{ $t('app.togglContentFullScreen') }}
</n-tooltip>
<Teleport to="body">
<div
v-if="appStore.layoutMode === 'full-content'"
class="fixed top-4 right-0 z-[9999]"
>
<n-tooltip placement="left" trigger="hover">
<template #trigger>
<n-el
class="bg-[var(--primary-color)] c-[var(--base-color)] rounded-l-lg shadow-lg p-2 cursor-pointer"
@click="exitFullContent"
>
<icon-park-outline-off-screen-one />
</n-el>
</template>
{{ $t('app.togglContentFullScreen') }}
</n-tooltip>
</div>
</Teleport>
</template>

View File

@ -6,6 +6,7 @@ import {
CollapaseButton,
FullScreen,
Logo,
MobileDrawer,
Notices,
Search,
Setting,
@ -14,12 +15,12 @@ import {
UserCenter,
} from './components'
import Content from './Content.vue'
import { ProLayout, useLayoutMenu } from 'pro-naive-ui'
const route = useRoute()
const appStore = useAppStore()
const routeStore = useRouteStore()
const { layoutMode } = storeToRefs(useAppStore())
const {
@ -31,16 +32,19 @@ const {
menus: routeStore.menus,
})
watch(() => route.path, (value) => {
activeKey.value = value
watch(() => route.path, () => {
activeKey.value = routeStore.activeMenu
}, { immediate: true })
//
const showMobileDrawer = ref(false)
const sidebarWidth = ref(240)
const sidebarCollapsedWidth = ref(64)
const hasHorizontalMenu = computed(() => ['horizontal', 'mixed-two-column', 'mixed-sidebar'].includes(layoutMode.value))
const hidenCollapaseButton = computed(() => ['horizontal'].includes(layoutMode.value))
const hidenCollapaseButton = computed(() => ['horizontal'].includes(layoutMode.value) || appStore.isMobile)
</script>
<template>
@ -48,7 +52,8 @@ const hidenCollapaseButton = computed(() => ['horizontal'].includes(layoutMode.v
<ProLayout
v-model:collapsed="appStore.collapsed"
:mode="layoutMode"
:show-logo="appStore.showLogo"
:is-mobile="appStore.isMobile"
:show-logo="appStore.showLogo && !appStore.isMobile"
:show-footer="appStore.showFooter"
:show-tabbar="appStore.showTabs"
nav-fixed
@ -65,10 +70,16 @@ const hidenCollapaseButton = computed(() => ['horizontal'].includes(layoutMode.v
</template>
<template #nav-left>
<div v-if="!hasHorizontalMenu || !hidenCollapaseButton" class="h-full flex-y-center gap-1 p-x-sm">
<CollapaseButton v-if="!hidenCollapaseButton" />
<Breadcrumb v-if="!hasHorizontalMenu" />
</div>
<template v-if="appStore.isMobile">
<Logo />
</template>
<template v-else>
<div v-if="!hasHorizontalMenu || !hidenCollapaseButton" class="h-full flex-y-center gap-1 p-x-sm">
<CollapaseButton v-if="!hidenCollapaseButton" />
<Breadcrumb v-if="!hasHorizontalMenu" />
</div>
</template>
</template>
<template #nav-center>
@ -79,13 +90,30 @@ const hidenCollapaseButton = computed(() => ['horizontal'].includes(layoutMode.v
<template #nav-right>
<div class="h-full flex-y-center gap-1 p-x-xl">
<Search />
<Notices />
<FullScreen />
<DarkModeSwitch />
<LangsSwitch />
<Setting />
<UserCenter />
<!-- 移动端只显示菜单按钮 -->
<template v-if="appStore.isMobile">
<n-button
quaternary
@click="showMobileDrawer = true"
>
<template #icon>
<n-icon size="18">
<icon-park-outline-hamburger-button />
</n-icon>
</template>
</n-button>
</template>
<!-- 桌面端显示完整功能组件 -->
<template v-else>
<Search />
<Notices />
<FullScreen />
<DarkModeSwitch />
<LangsSwitch />
<Setting />
<UserCenter />
</template>
</div>
</template>
@ -111,5 +139,10 @@ const hidenCollapaseButton = computed(() => ['horizontal'].includes(layoutMode.v
<Content />
<BackTop />
<SettingDrawer />
<!-- 移动端功能抽屉 -->
<MobileDrawer v-model:show="showMobileDrawer">
<n-menu v-bind="layout.verticalMenuProps" />
</MobileDrawer>
</ProLayout>
</template>

View File

@ -1,35 +1,5 @@
import type { App } from 'vue'
import { installRouter } from '@/router'
import { installPinia } from '@/store'
import AppVue from './App.vue'
import AppLoading from './components/common/AppLoading.vue'
import App from './App.vue'
async function setupApp() {
// 载入全局loading加载状态
const appLoading = createApp(AppLoading)
appLoading.mount('#appLoading')
// 创建vue实例
const app = createApp(AppVue)
// 注册模块Pinia
await installPinia(app)
// 注册模块 Vue-router
await installRouter(app)
/* 注册模块 指令/静态资源 */
Object.values(
import.meta.glob<{ install: (app: App) => void }>('./modules/*.ts', {
eager: true,
}),
).map(i => app.use(i))
// 卸载载入动画
appLoading.unmount()
// 挂载
app.mount('#app')
}
setupApp()
// 创建应用实例并挂载
const app = createApp(App)
app.mount('#app')

View File

@ -22,6 +22,19 @@ export function setupRouterGuard(router: Router) {
// 判断有无TOKEN,登录鉴权
const isLogin = Boolean(local.get('accessToken'))
// 处理根路由重定向
if (to.name === 'root') {
if (isLogin) {
// 已登录,重定向到首页
next({ path: import.meta.env.VITE_HOME_PATH, replace: true })
}
else {
// 未登录,重定向到登录页
next({ path: '/login', replace: true })
}
return
}
// 如果是login路由直接放行
if (to.name === 'login') {
// login页面不需要任何认证检查直接放行
@ -34,23 +47,31 @@ export function setupRouterGuard(router: Router) {
}
// 如果路由设置了requiresAuth为true且用户未登录重定向到登录页
else if (to.meta.requiresAuth === true && !isLogin) {
const redirect = to.name === '404' ? undefined : to.fullPath
const redirect = to.name === 'not-found' ? undefined : to.fullPath
next({ path: '/login', query: { redirect } })
return
}
// 判断路由有无进行初始化
if (!routeStore.isInitAuthRoute) {
await routeStore.initAuthRoute()
// 动态路由加载完回到根路由
if (to.name === '404') {
// 等待权限路由加载好了,回到之前的路由,否则404
next({
path: to.fullPath,
replace: true,
query: to.query,
hash: to.hash,
})
if (!routeStore.isInitAuthRoute && to.name !== 'login') {
try {
await routeStore.initAuthRoute()
// 动态路由加载完回到根路由
if (to.name === 'not-found') {
// 等待权限路由加载好了,回到之前的路由,否则404
next({
path: to.fullPath,
replace: true,
query: to.query,
hash: to.hash,
})
return
}
}
catch {
// 如果路由初始化失败(比如 401 错误),重定向到登录页
const redirect = to.fullPath !== '/' ? to.fullPath : undefined
next({ path: '/login', query: redirect ? { redirect } : undefined })
return
}
}

View File

@ -5,14 +5,13 @@ export const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'root',
redirect: '/appRoot',
children: [
],
},
{
path: '/login',
name: 'login',
component: () => import('@/views/login/index.vue'), // 注意这里要带上 文件后缀.vue
component: () => import('@/views/build-in/login/index.vue'), // 注意这里要带上 文件后缀.vue
meta: {
title: '登录',
withoutTab: true,
@ -21,7 +20,7 @@ export const routes: RouteRecordRaw[] = [
{
path: '/public',
name: 'publicAccess',
component: () => import('@/views/demo/publicAccess/index.vue'),
component: () => import('@/views/build-in/public-access/index.vue'),
meta: {
title: '公共访问示例',
requiresAuth: false,
@ -29,38 +28,19 @@ export const routes: RouteRecordRaw[] = [
},
},
{
path: '/403',
name: '403',
component: () => import('@/views/error/403/index.vue'),
meta: {
title: '用户无权限',
withoutTab: true,
},
},
{
path: '/404',
name: '404',
component: () => import('@/views/error/404/index.vue'),
path: '/not-found',
name: 'not-found',
component: () => import('@/views/build-in/not-found/index.vue'),
meta: {
title: '找不到页面',
icon: 'icon-park-outline:ghost',
withoutTab: true,
},
},
{
path: '/500',
name: '500',
component: () => import('@/views/error/500/index.vue'),
meta: {
title: '服务器错误',
icon: 'icon-park-outline:close-wifi',
withoutTab: true,
},
},
{
path: '/:pathMatch(.*)*',
component: () => import('@/views/error/404/index.vue'),
name: '404',
component: () => import('@/views/build-in/not-found/index.vue'),
name: 'not-found',
meta: {
title: '找不到页面',
icon: 'icon-park-outline:ghost',

View File

@ -19,7 +19,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
pinTab: true,
menuType: 'page',
componentPath: '/dashboard/workbench/index.vue',
id: 2,
id: 101,
pid: 1,
},
{
@ -30,7 +30,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'icon-park-outline:anchor',
menuType: 'page',
componentPath: '/dashboard/monitor/index.vue',
id: 3,
id: 102,
pid: 1,
},
{
@ -41,53 +41,53 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'icon-park-outline:list',
menuType: 'dir',
componentPath: null,
id: 4,
id: 2,
pid: null,
},
{
name: 'multi2',
path: '/multi/multi2',
path: '/multi/multi-2',
title: '多级菜单子页',
requiresAuth: true,
icon: 'icon-park-outline:list',
menuType: 'page',
componentPath: '/demo/multi/multi2/index.vue',
id: 6,
pid: 4,
componentPath: '/demo/multi/multi-2/index.vue',
id: 201,
pid: 2,
},
{
name: 'multi2Detail',
path: '/multi/multi2/detail',
name: 'multi2-detail',
path: '/multi/multi-2/detail',
title: '菜单详情页',
requiresAuth: false,
icon: 'icon-park-outline:list',
hide: true,
activeMenu: '/multi/multi2',
activeMenu: '/multi/multi-2',
menuType: 'page',
componentPath: '/demo/multi/multi2/detail/index.vue',
id: 7,
pid: 4,
componentPath: '/demo/multi/multi-2/detail/index.vue',
id: 20101,
pid: 2,
},
{
name: 'multi3',
path: '/multi/multi3',
path: '/multi/multi-3',
title: '多级菜单',
requiresAuth: true,
icon: 'icon-park-outline:list',
menuType: 'dir',
componentPath: null,
id: 8,
pid: 4,
id: 202,
pid: 2,
},
{
name: 'multi4',
path: '/multi/multi3/multi4',
path: '/multi/multi-3/multi-4',
title: '多级菜单3-1',
requiresAuth: true,
icon: 'icon-park-outline:list',
componentPath: '/demo/multi/multi3/multi4/index.vue',
id: 9,
pid: 8,
componentPath: '/demo/multi/multi-3/multi-4/index.vue',
id: 20201,
pid: 202,
},
{
name: 'list',
@ -97,28 +97,38 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'icon-park-outline:list-two',
menuType: 'dir',
componentPath: null,
id: 10,
id: 3,
pid: null,
},
{
name: 'commonList',
path: '/list/commonList',
path: '/list/common-list',
title: '常用列表',
requiresAuth: true,
icon: 'icon-park-outline:list-view',
componentPath: '/demo/list/commonList/index.vue',
id: 11,
pid: 10,
componentPath: '/demo/list/common-list/index.vue',
id: 301,
pid: 3,
},
{
name: 'cardList',
path: '/list/cardList',
path: '/list/card-list',
title: '卡片列表',
requiresAuth: true,
icon: 'icon-park-outline:view-grid-list',
componentPath: '/demo/list/cardList/index.vue',
id: 12,
pid: 10,
componentPath: '/demo/list/card-list/index.vue',
id: 302,
pid: 3,
},
{
name: 'draggableList',
path: '/list/draggable-list',
title: '拖拽列表',
requiresAuth: true,
icon: 'icon-park-outline:menu-fold',
componentPath: '/demo/list/draggable-list/index.vue',
id: 303,
pid: 3,
},
{
name: 'demo',
@ -128,7 +138,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'icon-park-outline:application-one',
menuType: 'dir',
componentPath: null,
id: 13,
id: 4,
pid: null,
},
{
@ -138,8 +148,8 @@ export const staticRoutes: AppRoute.RowRoute[] = [
requiresAuth: true,
icon: 'icon-park-outline:international',
componentPath: '/demo/fetch/index.vue',
id: 5,
pid: 13,
id: 401,
pid: 4,
},
{
name: 'echarts',
@ -148,8 +158,8 @@ export const staticRoutes: AppRoute.RowRoute[] = [
requiresAuth: true,
icon: 'icon-park-outline:chart-proportion',
componentPath: '/demo/echarts/index.vue',
id: 15,
pid: 13,
id: 402,
pid: 4,
},
{
name: 'map',
@ -159,8 +169,8 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'carbon:map',
keepAlive: true,
componentPath: '/demo/map/index.vue',
id: 17,
pid: 13,
id: 403,
pid: 4,
},
{
name: 'editor',
@ -170,8 +180,8 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'icon-park-outline:editor',
menuType: 'dir',
componentPath: null,
id: 18,
pid: 13,
id: 404,
pid: 4,
},
{
name: 'editorMd',
@ -180,8 +190,8 @@ export const staticRoutes: AppRoute.RowRoute[] = [
requiresAuth: true,
icon: 'ri:markdown-line',
componentPath: '/demo/editor/md/index.vue',
id: 19,
pid: 18,
id: 40401,
pid: 404,
},
{
name: 'editorRich',
@ -190,8 +200,8 @@ export const staticRoutes: AppRoute.RowRoute[] = [
requiresAuth: true,
icon: 'icon-park-outline:edit-one',
componentPath: '/demo/editor/rich/index.vue',
id: 20,
pid: 18,
id: 40402,
pid: 404,
},
{
name: 'clipboard',
@ -200,8 +210,8 @@ export const staticRoutes: AppRoute.RowRoute[] = [
requiresAuth: true,
icon: 'icon-park-outline:clipboard',
componentPath: '/demo/clipboard/index.vue',
id: 21,
pid: 13,
id: 405,
pid: 4,
},
{
name: 'icons',
@ -210,18 +220,38 @@ export const staticRoutes: AppRoute.RowRoute[] = [
requiresAuth: true,
icon: 'local:cool',
componentPath: '/demo/icons/index.vue',
id: 22,
pid: 13,
id: 406,
pid: 4,
},
{
name: 'QRCode',
path: '/demo/QRCode',
path: '/demo/qr-code',
title: '二维码',
requiresAuth: true,
icon: 'icon-park-outline:two-dimensional-code',
componentPath: '/demo/QRCode/index.vue',
id: 23,
pid: 13,
componentPath: '/demo/qr-code/index.vue',
id: 407,
pid: 4,
},
{
name: 'cascader',
path: '/demo/cascader',
title: '省市区联动',
requiresAuth: true,
icon: 'icon-park-outline:add-subset',
componentPath: '/demo/cascader/index.vue',
id: 408,
pid: 4,
},
{
name: 'dict',
path: '/demo/dict',
title: '字典示例',
requiresAuth: true,
icon: 'icon-park-outline:book-one',
componentPath: '/demo/dict/index.vue',
id: 409,
pid: 4,
},
{
name: 'documents',
@ -231,7 +261,7 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'icon-park-outline:file-doc',
menuType: 'dir',
componentPath: null,
id: 24,
id: 5,
pid: null,
},
{
@ -241,8 +271,8 @@ export const staticRoutes: AppRoute.RowRoute[] = [
requiresAuth: true,
icon: 'logos:vue',
componentPath: '/demo/documents/vue/index.vue',
id: 25,
pid: 24,
id: 501,
pid: 5,
},
{
name: 'documentsVite',
@ -251,169 +281,21 @@ export const staticRoutes: AppRoute.RowRoute[] = [
requiresAuth: true,
icon: 'logos:vitejs',
componentPath: '/demo/documents/vite/index.vue',
id: 26,
pid: 24,
id: 502,
pid: 5,
},
{
name: 'documentsVueuse',
path: '/documents/vueuse',
path: '/documents/vue-use',
title: 'VueUse外链',
requiresAuth: true,
icon: 'logos:vueuse',
href: 'https://vueuse.org/guide/',
componentPath: 'null',
id: 27,
pid: 24,
},
{
name: 'permission',
path: '/permission',
title: '权限',
requiresAuth: true,
icon: 'icon-park-outline:people-safe',
menuType: 'dir',
componentPath: null,
id: 28,
pid: null,
},
{
name: 'permissionDemo',
path: '/permission/permission',
title: '权限示例',
requiresAuth: true,
icon: 'icon-park-outline:right-user',
componentPath: '/demo/permission/permission/index.vue',
id: 29,
pid: 28,
},
{
name: 'justSuper',
path: '/permission/justSuper',
title: 'super可见',
requiresAuth: true,
roles: [
'super',
],
icon: 'icon-park-outline:wrong-user',
componentPath: '/demo/permission/justSuper/index.vue',
id: 30,
pid: 28,
},
{
name: 'error',
path: '/error',
title: '异常页',
requiresAuth: true,
icon: 'icon-park-outline:error-computer',
menuType: 'dir',
componentPath: null,
id: 31,
pid: null,
},
{
name: 'demo403',
path: '/error/403',
title: '403',
requiresAuth: true,
icon: 'carbon:error',
order: 3,
componentPath: '/error/403/index.vue',
id: 32,
pid: 31,
},
{
name: 'demo404',
path: '/error/404',
title: '404',
requiresAuth: true,
icon: 'icon-park-outline:error',
order: 2,
componentPath: '/error/404/index.vue',
id: 33,
pid: 31,
},
{
name: 'demo500',
path: '/error/500',
title: '500',
requiresAuth: true,
icon: 'carbon:data-error',
order: 1,
componentPath: '/error/500/index.vue',
id: 34,
pid: 31,
},
{
name: 'setting',
path: '/setting',
title: '系统设置',
requiresAuth: true,
icon: 'icon-park-outline:setting',
menuType: 'dir',
componentPath: null,
id: 35,
pid: null,
},
{
name: 'accountSetting',
path: '/setting/account',
title: '用户设置',
requiresAuth: true,
icon: 'icon-park-outline:every-user',
componentPath: '/setting/account/index.vue',
id: 36,
pid: 35,
},
{
name: 'dictionarySetting',
path: '/setting/dictionary',
title: '字典设置',
requiresAuth: true,
icon: 'icon-park-outline:book-one',
componentPath: '/setting/dictionary/index.vue',
id: 37,
pid: 35,
},
{
name: 'menuSetting',
path: '/setting/menu',
title: '菜单设置',
requiresAuth: true,
icon: 'icon-park-outline:application-menu',
componentPath: '/setting/menu/index.vue',
id: 38,
pid: 35,
},
{
name: 'userCenter',
path: '/userCenter',
title: '个人中心',
requiresAuth: true,
icon: 'carbon:user-avatar-filled-alt',
componentPath: '/demo/userCenter/index.vue',
id: 39,
pid: null,
},
{
name: 'about',
path: '/about',
title: '关于',
requiresAuth: true,
icon: 'icon-park-outline:info',
componentPath: '/demo/about/index.vue',
id: 40,
pid: null,
},
{
name: 'cascader',
path: '/demo/cascader',
title: '省市区联动',
requiresAuth: true,
icon: 'icon-park-outline:add-subset',
componentPath: '/demo/cascader/index.vue',
id: 41,
pid: 13,
id: 503,
pid: 5,
},
{
name: 'documentsNova',
path: '/documents/nova',
@ -422,28 +304,8 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'local:logo',
href: 'https://nova-admin-docs.netlify.app/',
componentPath: '2333333',
id: 42,
pid: 24,
},
{
name: 'dict',
path: '/demo/dict',
title: '字典示例',
requiresAuth: true,
icon: 'icon-park-outline:book-one',
componentPath: '/demo/dict/index.vue',
id: 43,
pid: 13,
},
{
name: 'draggableList',
path: '/list/draggableList',
title: '拖拽列表',
requiresAuth: true,
icon: 'icon-park-outline:menu-fold',
componentPath: '/demo/list/draggableList/index.vue',
id: 44,
pid: 10,
id: 504,
pid: 5,
},
{
name: 'documentsPublic',
@ -453,7 +315,104 @@ export const staticRoutes: AppRoute.RowRoute[] = [
icon: 'local:logo',
href: '/public',
componentPath: 'null',
id: 45,
pid: 24,
id: 505,
pid: 5,
},
{
name: 'permission',
path: '/permission',
title: '权限',
requiresAuth: true,
icon: 'icon-park-outline:people-safe',
menuType: 'dir',
componentPath: null,
id: 6,
pid: null,
},
{
name: 'permissionDemo',
path: '/permission/permission',
title: '权限示例',
requiresAuth: true,
icon: 'icon-park-outline:right-user',
componentPath: '/demo/permission/permission/index.vue',
id: 601,
pid: 6,
},
{
name: 'justSuper',
path: '/permission/just-super',
title: 'super可见',
requiresAuth: true,
roles: [
'super',
],
icon: 'icon-park-outline:wrong-user',
componentPath: '/demo/permission/just-super/index.vue',
id: 602,
pid: 6,
},
{
name: 'setting',
path: '/setting',
title: '系统设置',
requiresAuth: true,
icon: 'icon-park-outline:setting',
menuType: 'dir',
componentPath: null,
id: 7,
pid: null,
},
{
name: 'accountSetting',
path: '/setting/account',
title: '用户设置',
requiresAuth: true,
icon: 'icon-park-outline:every-user',
componentPath: '/setting/account/index.vue',
id: 701,
pid: 7,
},
{
name: 'dictionarySetting',
path: '/setting/dictionary',
title: '字典设置',
requiresAuth: true,
icon: 'icon-park-outline:book-one',
componentPath: '/setting/dictionary/index.vue',
id: 702,
pid: 7,
},
{
name: 'menuSetting',
path: '/setting/menu',
title: '菜单设置',
requiresAuth: true,
icon: 'icon-park-outline:application-menu',
componentPath: '/setting/menu/index.vue',
id: 703,
pid: 7,
},
{
name: 'about',
path: '/about',
title: '关于',
requiresAuth: true,
icon: 'icon-park-outline:info',
componentPath: '/demo/about/index.vue',
id: 8,
pid: null,
},
{
name: 'userCenter',
path: '/user-center',
title: '个人中心',
requiresAuth: true,
hide: true,
icon: 'carbon:user-avatar-filled-alt',
componentPath: '/build-in/user-center/index.vue',
id: 999,
pid: null,
},
]

View File

@ -19,9 +19,11 @@ const { onAuthRequired, onResponseRefreshToken } = createServerTokenAuthenticati
// 服务端判定token过期
refreshTokenOnSuccess: {
// 当服务端返回401时表示token过期
isExpired: (response, method) => {
isExpired: async (response, method) => {
const res = await response.clone().json()
const isExpired = method.meta && method.meta.isExpired
return response.status === 401 && !isExpired
return (response.status === 401 || res.code === 401) && !isExpired
},
// 当token过期时触发在此函数中触发刷新token

View File

@ -1,7 +1,7 @@
import { createAlovaInstance } from './alova'
export const request = createAlovaInstance({
baseURL: __PROXY_MAPPING__.url.path,
baseURL: __URL_MAP__.url.path,
})
export const blankInstance = createAlovaInstance({

View File

@ -17,6 +17,8 @@ const { system, store } = useColorMode({
emitAuto: true,
})
const isMobile = useMediaQuery('(max-width: 700px)')
export const useAppStore = defineStore('app-store', {
state: () => {
return {
@ -38,7 +40,6 @@ export const useAppStore = defineStore('app-store', {
showSetting: false,
transitionAnimation: 'fade-slide' as TransitionAnimation,
layoutMode: 'vertical' as ProLayoutMode,
contentFullScreen: false,
}
},
getters: {
@ -51,6 +52,9 @@ export const useAppStore = defineStore('app-store', {
fullScreen() {
return isFullscreen.value
},
isMobile() {
return isMobile.value
},
},
actions: {
// 重置所有设置
@ -68,8 +72,7 @@ export const useAppStore = defineStore('app-store', {
this.showBreadcrumbIcon = true
this.showWatermark = false
this.transitionAnimation = 'fade-slide'
this.layoutMode = 'leftMenu'
this.contentFullScreen = false
this.layoutMode = 'vertical'
// 重置所有配色
this.setPrimaryColor(this.primaryColor)

View File

@ -39,23 +39,22 @@ export const useRouteStore = defineStore('route-store', {
async initRouteInfo() {
if (import.meta.env.VITE_ROUTE_LOAD_MODE === 'dynamic') {
const userInfo = local.get('userInfo')
try {
// Get user's route
const result = await fetchUserRoutes({
id: 1,
})
if (!userInfo || !userInfo.id) {
const authStore = useAuthStore()
authStore.logout()
return
if (!result.isSuccess || !result.data) {
throw new Error('Failed to fetch user routes')
}
return result.data
}
catch (error) {
console.error('Failed to initialize route info:', error)
throw error
}
// Get user's route
const { data } = await fetchUserRoutes({
id: userInfo.id,
})
if (!data)
return
return data
}
else {
this.rowRoutes = staticRoutes
@ -65,25 +64,33 @@ export const useRouteStore = defineStore('route-store', {
async initAuthRoute() {
this.isInitAuthRoute = false
// Initialize route information
const rowRoutes = await this.initRouteInfo()
if (!rowRoutes) {
window.$message.error($t(`app.getRouteError`))
return
try {
// Initialize route information
const rowRoutes = await this.initRouteInfo()
if (!rowRoutes) {
const error = new Error('Failed to get route information')
window.$message.error($t(`app.getRouteError`))
throw error
}
this.rowRoutes = rowRoutes
// Generate actual route and insert
const routes = createRoutes(rowRoutes)
router.addRoute(routes)
// Generate side menu
this.menus = createMenus(rowRoutes)
// Generate the route cache
this.cacheRoutes = generateCacheRoutes(rowRoutes)
this.isInitAuthRoute = true
}
catch (error) {
// 重置状态并重新抛出错误
this.isInitAuthRoute = false
throw error
}
this.rowRoutes = rowRoutes
// Generate actual route and insert
const routes = createRoutes(rowRoutes)
router.addRoute(routes)
// Generate side menu
this.menus = createMenus(rowRoutes)
// Generate the route cache
this.cacheRoutes = generateCacheRoutes(rowRoutes)
this.isInitAuthRoute = true
},
},
})

View File

@ -1,10 +1,9 @@
/**
*
* - dev: 后台开发环境
* - test: 后台测试环境
* - prod: 后台生产环境
*/
type ServiceEnvType = 'dev' | 'test' | 'prod'
type ServiceEnvType = 'dev' | 'production'
interface ImportMetaEnv {
/** 项目基本地址 */

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { Login, Register, ResetPwd } from './components'
type IformType = 'login' | 'register' | 'resetPwd'
type IformType = 'login' | 'register' | 'resetPwd'
const formType: Ref<IformType> = ref('login')
const formComponets = {
login: Login,
@ -18,7 +18,7 @@ const appName = import.meta.env.VITE_APP_NAME
<DarkModeSwitch />
<LangsSwitch />
</div>
<n-el
<div
class="p-4xl h-full w-full sm:w-450px sm:h-unset"
style="background: var(--card-color);box-shadow: var(--box-shadow-1);"
>
@ -36,7 +36,7 @@ const appName = import.meta.env.VITE_APP_NAME
/>
</transition>
</div>
</n-el>
</div>
<div />
</n-el>

View File

@ -0,0 +1,19 @@
<script setup lang="ts">
const router = useRouter()
</script>
<template>
<div class="flex-col-center h-full">
<img
src="@/assets/svg/error-not-found.svg"
alt=""
class="w-1/3"
>
<n-button
type="primary"
@click="router.push('/')"
>
{{ $t('app.backHome') }}
</n-button>
</div>
</template>

View File

@ -1,8 +1,11 @@
<script setup lang="ts">
import { useAppStore } from '@/store'
import Chart from './components/chart.vue'
import Chart2 from './components/chart2.vue'
import Chart3 from './components/chart3.vue'
const appStore = useAppStore()
const tableData = [
{
id: 0,
@ -32,218 +35,224 @@ const tableData = [
</script>
<template>
<div>
<n-grid
:x-gap="16"
:y-gap="16"
>
<n-gi :span="6">
<n-card>
<n-space
justify="space-between"
align="center"
>
<n-statistic label="访问量">
<n-number-animation
:from="0"
:to="12039"
show-separator
/>
</n-statistic>
<n-icon
color="#de4307"
size="42"
>
<icon-park-outline-chart-histogram />
</n-icon>
</n-space>
<template #footer>
<n-space justify="space-between">
<span>累计访问数</span>
<span><n-number-animation
:from="0"
:to="322039"
show-separator
/></span>
</n-space>
</template>
</n-card>
</n-gi>
<n-gi :span="6">
<n-card>
<n-space
justify="space-between"
align="center"
>
<n-statistic label="下载量">
<n-number-animation
:from="0"
:to="12039"
show-separator
/>
</n-statistic>
<n-icon
color="#ffb549"
size="42"
>
<icon-park-outline-chart-graph />
</n-icon>
</n-space>
<template #footer>
<n-space justify="space-between">
<span>累计下载量</span>
<span><n-number-animation
:from="0"
:to="322039"
show-separator
/></span>
</n-space>
</template>
</n-card>
</n-gi>
<n-gi :span="6">
<n-card>
<n-space
justify="space-between"
align="center"
>
<n-statistic label="浏览量">
<n-number-animation
:from="0"
:to="12039"
show-separator
/>
</n-statistic>
<n-icon
color="#1687a7"
size="42"
>
<icon-park-outline-average />
</n-icon>
</n-space>
<template #footer>
<n-space justify="space-between">
<span>累计浏览量</span>
<span><n-number-animation
:from="0"
:to="322039"
show-separator
/></span>
</n-space>
</template>
</n-card>
</n-gi>
<n-gi :span="6">
<n-card>
<n-space
justify="space-between"
align="center"
>
<n-statistic label="注册量">
<n-number-animation
:from="0"
:to="12039"
show-separator
/>
</n-statistic>
<n-icon
color="#42218E"
size="42"
>
<icon-park-outline-chart-pie />
</n-icon>
</n-space>
<template #footer>
<n-space justify="space-between">
<span>累计注册量</span>
<span><n-number-animation
:from="0"
:to="322039"
show-separator
/></span>
</n-space>
</template>
</n-card>
</n-gi>
<n-gi :span="24">
<n-card content-style="padding: 0;">
<n-tabs
type="line"
size="large"
:tabs-padding="20"
pane-style="padding: 20px;"
>
<n-tab-pane name="流量趋势">
<Chart />
</n-tab-pane>
<n-tab-pane name="访问量趋势">
<Chart2 />
</n-tab-pane>
</n-tabs>
</n-card>
</n-gi>
<n-gi :span="8">
<n-card
title="访问来源"
:segmented="{
content: true,
}"
<n-grid
:x-gap="16"
:y-gap="16"
:cols="12"
item-responsive
responsive="screen"
>
<!-- 统计卡片 - 移动端每行2个桌面端每行4个 -->
<n-gi span="6 m:3">
<n-card>
<n-space
justify="space-between"
align="center"
>
<Chart3 />
</n-card>
</n-gi>
<n-gi :span="16">
<n-card
title="成交记录"
:segmented="{
content: true,
}"
>
<template #header-extra>
<n-button
type="primary"
quaternary
>
更多
</n-button>
</template>
<n-table
:bordered="false"
:single-line="false"
<n-statistic label="访问量">
<n-number-animation
:from="0"
:to="12039"
show-separator
/>
</n-statistic>
<n-icon
color="#de4307"
size="42"
>
<thead>
<tr>
<th>交易名称</th>
<th>开始时间</th>
<th>结束时间</th>
<th>进度</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in tableData"
:key="item.id"
>
<td>{{ item.name }}</td>
<td>{{ item.start }}</td>
<td>{{ item.end }}</td>
<td>{{ item.prograss }}%</td>
<td>
<n-tag
:bordered="false"
type="info"
>
{{ item.status }}
</n-tag>
</td>
</tr>
</tbody>
</n-table>
</n-card>
</n-gi>
</n-grid>
</div>
</template>
<icon-park-outline-chart-histogram />
</n-icon>
</n-space>
<template #footer>
<n-space justify="space-between">
<span>累计访问数</span>
<span><n-number-animation
:from="0"
:to="322039"
show-separator
/></span>
</n-space>
</template>
</n-card>
</n-gi>
<n-gi span="6 m:3">
<n-card>
<n-space
justify="space-between"
align="center"
>
<n-statistic label="下载量">
<n-number-animation
:from="0"
:to="12039"
show-separator
/>
</n-statistic>
<n-icon
color="#ffb549"
size="42"
>
<icon-park-outline-chart-graph />
</n-icon>
</n-space>
<template #footer>
<n-space justify="space-between">
<span>累计下载量</span>
<span><n-number-animation
:from="0"
:to="322039"
show-separator
/></span>
</n-space>
</template>
</n-card>
</n-gi>
<n-gi span="6 m:3">
<n-card>
<n-space
justify="space-between"
align="center"
>
<n-statistic label="浏览量">
<n-number-animation
:from="0"
:to="12039"
show-separator
/>
</n-statistic>
<n-icon
color="#1687a7"
size="42"
>
<icon-park-outline-average />
</n-icon>
</n-space>
<template #footer>
<n-space justify="space-between">
<span>累计浏览量</span>
<span><n-number-animation
:from="0"
:to="322039"
show-separator
/></span>
</n-space>
</template>
</n-card>
</n-gi>
<n-gi span="6 m:3">
<n-card>
<n-space
justify="space-between"
align="center"
>
<n-statistic label="注册量">
<n-number-animation
:from="0"
:to="12039"
show-separator
/>
</n-statistic>
<n-icon
color="#42218E"
size="42"
>
<icon-park-outline-chart-pie />
</n-icon>
</n-space>
<template #footer>
<n-space justify="space-between">
<span>累计注册量</span>
<span><n-number-animation
:from="0"
:to="322039"
show-separator
/></span>
</n-space>
</template>
</n-card>
</n-gi>
<!-- 图表区域 - 全宽显示 -->
<n-gi :span="12">
<n-card content-style="padding: 0;">
<n-tabs
type="line"
size="large"
:tabs-padding="20"
pane-style="padding: 20px;"
>
<n-tab-pane name="流量趋势">
<Chart />
</n-tab-pane>
<n-tab-pane name="访问量趋势">
<Chart2 />
</n-tab-pane>
</n-tabs>
</n-card>
</n-gi>
<style scoped></style>
<!-- 访问来源 - 移动端全宽桌面端1/3 -->
<n-gi span="12 m:4">
<n-card
title="访问来源"
:segmented="{
content: true,
}"
>
<Chart3 />
</n-card>
</n-gi>
<!-- 成交记录 - 移动端全宽桌面端2/3 -->
<n-gi span="12 m:8">
<n-card
title="成交记录"
:segmented="{
content: true,
}"
>
<template #header-extra>
<n-button
type="primary"
quaternary
>
更多
</n-button>
</template>
<n-table
:bordered="false"
:single-line="false"
:scroll-x="appStore.isMobile ? 600 : undefined"
>
<thead>
<tr>
<th>交易名称</th>
<th>开始时间</th>
<th>结束时间</th>
<th>进度</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in tableData"
:key="item.id"
>
<td>{{ item.name }}</td>
<td>{{ item.start }}</td>
<td>{{ item.end }}</td>
<td>{{ item.prograss }}%</td>
<td>
<n-tag
:bordered="false"
type="info"
>
{{ item.status }}
</n-tag>
</td>
</tr>
</tbody>
</n-table>
</n-card>
</n-gi>
</n-grid>
</template>

View File

@ -9,94 +9,102 @@ const { userInfo } = useAuthStore()
<n-grid
:x-gap="16"
:y-gap="16"
:cols="3"
item-responsive
responsive="screen"
>
<n-gi :span="16">
<!-- 左侧主要内容区 - 移动端全宽桌面端2/3 -->
<n-gi span="3 m:2">
<n-space
vertical
:size="16"
>
<!-- 图表区域 -->
<n-card style="--n-padding-left: 0;">
<Chart />
</n-card>
<n-card>
<n-grid
:x-gap="8"
:y-gap="8"
>
<n-gi :span="6">
<n-card>
<n-thing>
<template #avatar>
<n-el>
<n-icon-wrapper :size="46" color="var(--success-color)" :border-radius="999">
<nova-icon :size="26" icon="icon-park-outline:user" />
</n-icon-wrapper>
</n-el>
</template>
<template #header>
<n-statistic label="活跃用户">
<n-number-animation show-separator :from="0" :to="12039" />
</n-statistic>
</template>
</n-thing>
</n-card>
</n-gi>
<n-gi :span="6">
<n-card>
<n-thing>
<template #avatar>
<n-el>
<n-icon-wrapper :size="46" color="var(--success-color)" :border-radius="999">
<nova-icon :size="26" icon="icon-park-outline:every-user" />
</n-icon-wrapper>
</n-el>
</template>
<template #header>
<n-statistic label="用户">
<n-number-animation show-separator :from="0" :to="44039" />
</n-statistic>
</template>
</n-thing>
</n-card>
</n-gi>
<n-gi :span="6">
<n-card>
<n-thing>
<template #avatar>
<n-el>
<n-icon-wrapper :size="46" color="var(--success-color)" :border-radius="999">
<nova-icon :size="26" icon="icon-park-outline:preview-open" />
</n-icon-wrapper>
</n-el>
</template>
<template #header>
<n-statistic label="浏览量">
<n-number-animation show-separator :from="0" :to="551039" />
</n-statistic>
</template>
</n-thing>
</n-card>
</n-gi>
<n-gi :span="6">
<n-card>
<n-thing>
<template #avatar>
<n-el>
<n-icon-wrapper :size="46" color="var(--success-color)" :border-radius="999">
<nova-icon :size="26" icon="icon-park-outline:star" />
</n-icon-wrapper>
</n-el>
</template>
<template #header>
<n-statistic label="收藏数">
<n-number-animation show-separator :from="0" :to="7739" />
</n-statistic>
</template>
</n-thing>
</n-card>
</n-gi>
</n-grid>
</n-card>
<!-- 统计卡片区域 -->
<n-grid
:x-gap="16"
:y-gap="16"
:cols="4"
item-responsive
responsive="screen"
>
<n-gi span="2 l:1">
<n-card>
<n-thing>
<template #avatar>
<n-el>
<n-icon-wrapper :size="46" color="var(--success-color)" :border-radius="999">
<nova-icon :size="26" icon="icon-park-outline:user" />
</n-icon-wrapper>
</n-el>
</template>
<template #header>
<n-statistic label="活跃用户">
<n-number-animation show-separator :from="0" :to="12039" />
</n-statistic>
</template>
</n-thing>
</n-card>
</n-gi>
<n-gi span="2 l:1">
<n-card>
<n-thing>
<template #avatar>
<n-el>
<n-icon-wrapper :size="46" color="var(--success-color)" :border-radius="999">
<nova-icon :size="26" icon="icon-park-outline:every-user" />
</n-icon-wrapper>
</n-el>
</template>
<template #header>
<n-statistic label="用户">
<n-number-animation show-separator :from="0" :to="44039" />
</n-statistic>
</template>
</n-thing>
</n-card>
</n-gi>
<n-gi span="2 l:1">
<n-card>
<n-thing>
<template #avatar>
<n-el>
<n-icon-wrapper :size="46" color="var(--success-color)" :border-radius="999">
<nova-icon :size="26" icon="icon-park-outline:preview-open" />
</n-icon-wrapper>
</n-el>
</template>
<template #header>
<n-statistic label="浏览量">
<n-number-animation show-separator :from="0" :to="551039" />
</n-statistic>
</template>
</n-thing>
</n-card>
</n-gi>
<n-gi span="2 l:1">
<n-card>
<n-thing>
<template #avatar>
<n-el>
<n-icon-wrapper :size="46" color="var(--success-color)" :border-radius="999">
<nova-icon :size="26" icon="icon-park-outline:star" />
</n-icon-wrapper>
</n-el>
</template>
<template #header>
<n-statistic label="收藏数">
<n-number-animation show-separator :from="0" :to="7739" />
</n-statistic>
</template>
</n-thing>
</n-card>
</n-gi>
</n-grid>
<n-card title="动态">
<template #header-extra>
<n-button
@ -167,7 +175,9 @@ const { userInfo } = useAuthStore()
</n-card>
</n-space>
</n-gi>
<n-gi :span="8">
<!-- 右侧边栏 - 移动端全宽桌面端1/3 -->
<n-gi span="3 m:1">
<n-space
vertical
:size="16"
@ -226,11 +236,14 @@ const { userInfo } = useAuthStore()
</n-list-item>
</n-list>
</n-card>
<!-- 订单和待办统计 -->
<n-grid
:x-gap="8"
:y-gap="8"
:x-gap="16"
:y-gap="16"
:cols="2"
>
<n-gi :span="12">
<!-- 移动端和桌面端都是每行2个 -->
<n-gi :span="1">
<n-card>
<n-flex vertical align="center">
<n-text depth="3">
@ -245,7 +258,7 @@ const { userInfo } = useAuthStore()
</n-flex>
</n-card>
</n-gi>
<n-gi :span="12">
<n-gi :span="1">
<n-card>
<n-flex vertical align="center">
<n-text depth="3">

View File

@ -122,7 +122,7 @@ function handleResetSearch() {
model.value = { ...initialModel }
}
type ModalType = 'add' | 'edit'
type ModalType = 'add' | 'edit'
const modalType = ref<ModalType>('add')
function setModalType(type: ModalType) {
modalType.value = type

View File

@ -1,7 +0,0 @@
<script setup lang="ts"></script>
<template>
<ErrorTip type="403" />
</template>
<style lang="scss" scoped></style>

View File

@ -1,7 +0,0 @@
<script setup lang="ts"></script>
<template>
<ErrorTip type="404" />
</template>
<style lang="scss" scoped></style>

View File

@ -1,7 +0,0 @@
<script setup lang="ts"></script>
<template>
<ErrorTip type="500" />
</template>
<style lang="scss" scoped></style>

View File

@ -1,10 +1,10 @@
<script setup lang="tsx">
import type { DataTableColumns } from 'naive-ui'
import CopyText from '@/components/custom/CopyText.vue'
import { useBoolean } from '@/hooks'
import { fetchAllRoutes } from '@/service'
import { arrayToTree, createIcon } from '@/utils'
import { NButton, NPopconfirm, NSpace, NTag } from 'naive-ui'
import { renderProCopyableText } from 'pro-naive-ui'
import TableModal from './components/TableModal.vue'
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
@ -45,11 +45,7 @@ const columns: DataTableColumns<AppRoute.RowRoute> = [
{
title: '路径',
key: 'path',
render: (row) => {
return (
<CopyText value={row.path} />
)
},
render: row => renderProCopyableText(row.path),
},
{
title: '组件路径',