完善UI
This commit is contained in:
+6
-6
@@ -4,16 +4,16 @@ services:
|
|||||||
container_name: platform-a-postgres
|
container_name: platform-a-postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: platform_a
|
POSTGRES_DB: platform
|
||||||
POSTGRES_USER: platform_a
|
POSTGRES_USER: platform
|
||||||
POSTGRES_PASSWORD: platform_a_password
|
POSTGRES_PASSWORD: platform_password
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- platform_a_postgres_data:/var/lib/postgresql
|
- platform_a_postgres_data:/var/lib/postgresql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U platform_a -d platform_a"]
|
test: ["CMD-SHELL", "pg_isready -U platform -d platform"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -22,13 +22,13 @@ services:
|
|||||||
image: redis:8
|
image: redis:8
|
||||||
container_name: platform-a-redis
|
container_name: platform-a-redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: ["redis-server", "--requirepass", "platform_a_redis_password", "--appendonly", "yes"]
|
command: ["redis-server", "--requirepass", "platform_redis_password", "--appendonly", "yes"]
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- platform_a_redis_data:/data
|
- platform_a_redis_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "-a", "platform_a_redis_password", "ping"]
|
test: ["CMD", "redis-cli", "-a", "platform_redis_password", "ping"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@@ -6,19 +6,19 @@ ktor:
|
|||||||
port: 8080
|
port: 8080
|
||||||
|
|
||||||
app:
|
app:
|
||||||
name: "Platform A"
|
name: "Platform"
|
||||||
env: "local"
|
env: "local"
|
||||||
|
|
||||||
database:
|
database:
|
||||||
url: "jdbc:postgresql://localhost:5432/platform_a"
|
url: "jdbc:postgresql://localhost:5432/platform"
|
||||||
user: "platform_a"
|
user: "platform"
|
||||||
password: "platform_a_password"
|
password: "platform_password"
|
||||||
maximumPoolSize: 16
|
maximumPoolSize: 16
|
||||||
minimumIdle: 4
|
minimumIdle: 4
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
url: "redis://127.0.0.1:6379"
|
url: "redis://127.0.0.1:6379"
|
||||||
password: "platform_a_redis_password"
|
password: "platform_redis_password"
|
||||||
|
|
||||||
security:
|
security:
|
||||||
jwt:
|
jwt:
|
||||||
|
|||||||
@@ -78,10 +78,10 @@ function onClose(path: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tab-chip-active {
|
.tab-chip-active {
|
||||||
background: #111827;
|
background: #2563eb;
|
||||||
border-color: #111827;
|
border-color: #2563eb;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
box-shadow: 0 8px 16px rgba(15, 23, 42, 0.12);
|
box-shadow: 0 8px 16px rgba(37, 99, 235, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-title {
|
.tab-title {
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-shell">
|
<div class="page-shell">
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-4">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-4">
|
||||||
<div class="soft-stat"><n-statistic label="用户总数" :value="userTotal" /></div>
|
<div class="soft-stat"><n-statistic label="可见菜单" :value="visibleMenuTotal" /></div>
|
||||||
<div class="soft-stat"><n-statistic label="组织总数" :value="orgTotal" /></div>
|
<div class="soft-stat"><n-statistic label="可访问页面" :value="visiblePageTotal" /></div>
|
||||||
<div class="soft-stat"><n-statistic label="角色总数" :value="roleTotal" /></div>
|
|
||||||
<div class="soft-stat"><n-statistic label="菜单总数" :value="menuTotal" /></div>
|
|
||||||
<div class="soft-stat"><n-statistic label="今日操作数" :value="todayOperationCount" /></div>
|
|
||||||
<div class="soft-stat">
|
<div class="soft-stat">
|
||||||
<n-statistic label="权限码数量" :value="authStore.permissions.length" />
|
<n-statistic label="权限码数量" :value="authStore.permissions.length" />
|
||||||
</div>
|
</div>
|
||||||
@@ -17,54 +14,20 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useQuery } from '@tanstack/vue-query'
|
|
||||||
import { listUsersApi } from '@/api/system/user'
|
|
||||||
import { listOrgsApi } from '@/api/system/org'
|
|
||||||
import { listRolesApi } from '@/api/system/role'
|
|
||||||
import { listMenusApi } from '@/api/system/menu'
|
|
||||||
import { listOperationLogsApi } from '@/api/logs'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { statusLabel } from '@/utils/display'
|
import { statusLabel } from '@/utils/display'
|
||||||
|
import type { MenuNode } from '@/types/auth'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const usersQuery = useQuery({
|
function flattenMenus(nodes: MenuNode[]): MenuNode[] {
|
||||||
queryKey: ['dashboard', 'users-count'],
|
return nodes.flatMap((item) => [item, ...flattenMenus(item.children ?? [])])
|
||||||
queryFn: () => listUsersApi({ page: 1, pageSize: 1 })
|
}
|
||||||
})
|
|
||||||
const orgsQuery = useQuery({
|
|
||||||
queryKey: ['dashboard', 'orgs-count'],
|
|
||||||
queryFn: listOrgsApi
|
|
||||||
})
|
|
||||||
const rolesQuery = useQuery({
|
|
||||||
queryKey: ['dashboard', 'roles-count'],
|
|
||||||
queryFn: () => listRolesApi({ page: 1, pageSize: 1 })
|
|
||||||
})
|
|
||||||
const menusQuery = useQuery({
|
|
||||||
queryKey: ['dashboard', 'menus-count'],
|
|
||||||
queryFn: listMenusApi
|
|
||||||
})
|
|
||||||
const operationLogQuery = useQuery({
|
|
||||||
queryKey: ['dashboard', 'operation-logs-today'],
|
|
||||||
queryFn: () => listOperationLogsApi({ page: 1, pageSize: 200 })
|
|
||||||
})
|
|
||||||
|
|
||||||
const userTotal = computed(() => usersQuery.data.value?.total ?? 0)
|
const visibleMenus = computed(() => flattenMenus(authStore.menus).filter((item) => item.visible))
|
||||||
const orgTotal = computed(() => orgsQuery.data.value?.length ?? 0)
|
const visibleMenuTotal = computed(() => visibleMenus.value.length)
|
||||||
const roleTotal = computed(() => rolesQuery.data.value?.total ?? 0)
|
const visiblePageTotal = computed(
|
||||||
const menuTotal = computed(() => {
|
() => visibleMenus.value.filter((item) => item.type === 'MENU' && item.path).length
|
||||||
const flat = (nodes: { children?: unknown[] }[]): number =>
|
|
||||||
nodes.reduce(
|
|
||||||
(acc, item) => acc + 1 + flat((item.children as { children?: unknown[] }[]) ?? []),
|
|
||||||
0
|
|
||||||
)
|
)
|
||||||
return flat(menusQuery.data.value ?? [])
|
|
||||||
})
|
|
||||||
const todayOperationCount = computed(() => {
|
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
|
||||||
return (operationLogQuery.data.value?.items ?? []).filter((item) =>
|
|
||||||
item.createdAt.startsWith(today)
|
|
||||||
).length
|
|
||||||
})
|
|
||||||
const currentStatus = computed(() => statusLabel(authStore.user?.status))
|
const currentStatus = computed(() => statusLabel(authStore.user?.status))
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -6,6 +6,13 @@
|
|||||||
<n-button type="primary" @click="openCreate()">新增菜单</n-button>
|
<n-button type="primary" @click="openCreate()">新增菜单</n-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body card-body-fill tree-card-body">
|
<div class="card-body card-body-fill tree-card-body">
|
||||||
|
<div class="menu-tree-header">
|
||||||
|
<span>菜单名称</span>
|
||||||
|
<span>类型</span>
|
||||||
|
<span>状态</span>
|
||||||
|
<span>属性</span>
|
||||||
|
<span>操作</span>
|
||||||
|
</div>
|
||||||
<n-tree
|
<n-tree
|
||||||
block-line
|
block-line
|
||||||
expand-on-click
|
expand-on-click
|
||||||
@@ -232,9 +239,9 @@ async function save() {
|
|||||||
|
|
||||||
function renderLabel(payload: { option: unknown }) {
|
function renderLabel(payload: { option: unknown }) {
|
||||||
const node = payload.option as MenuTreeNode
|
const node = payload.option as MenuTreeNode
|
||||||
return h('div', { class: 'flex w-full items-center justify-between py-2' }, [
|
return h('div', { class: 'menu-tree-row' }, [
|
||||||
h('div', { class: 'flex items-center gap-2' }, [
|
h('div', { class: 'menu-tree-title', title: node.title }, node.title),
|
||||||
h('span', node.title),
|
h('div', { class: 'menu-tree-type' }, [
|
||||||
h(
|
h(
|
||||||
NTag,
|
NTag,
|
||||||
{
|
{
|
||||||
@@ -242,17 +249,21 @@ function renderLabel(payload: { option: unknown }) {
|
|||||||
type: node.type === 'BUTTON' ? 'warning' : node.type === 'CATALOG' ? 'info' : 'success'
|
type: node.type === 'BUTTON' ? 'warning' : node.type === 'CATALOG' ? 'info' : 'success'
|
||||||
},
|
},
|
||||||
{ default: () => menuTypeLabel(node.type) }
|
{ default: () => menuTypeLabel(node.type) }
|
||||||
),
|
)
|
||||||
|
]),
|
||||||
|
h('div', { class: 'menu-tree-status' }, [
|
||||||
h(
|
h(
|
||||||
NTag,
|
NTag,
|
||||||
{ size: 'small', type: node.status === 'ENABLED' ? 'success' : 'warning' },
|
{ size: 'small', type: node.status === 'ENABLED' ? 'success' : 'warning' },
|
||||||
{ default: () => statusLabel(node.status) }
|
{ default: () => statusLabel(node.status) }
|
||||||
),
|
)
|
||||||
|
]),
|
||||||
|
h('div', { class: 'menu-tree-built-in' }, [
|
||||||
node.builtIn
|
node.builtIn
|
||||||
? h(NTag, { size: 'small', type: 'primary' }, { default: () => '基础内置' })
|
? h(NTag, { size: 'small', type: 'primary' }, { default: () => '基础内置' })
|
||||||
: null
|
: null
|
||||||
]),
|
]),
|
||||||
h(NSpace, { size: 6 }, () => [
|
h(NSpace, { size: 6, class: 'menu-tree-actions' }, () => [
|
||||||
h(
|
h(
|
||||||
NButton,
|
NButton,
|
||||||
{ size: 'small', tertiary: true, class: 'action-btn', onClick: () => openCreate(node.id) },
|
{ size: 'small', tertiary: true, class: 'action-btn', onClick: () => openCreate(node.id) },
|
||||||
@@ -294,7 +305,7 @@ function renderLabel(payload: { option: unknown }) {
|
|||||||
|
|
||||||
function renderSwitcherIcon(payload: { option: unknown }) {
|
function renderSwitcherIcon(payload: { option: unknown }) {
|
||||||
const node = payload.option as MenuTreeNode
|
const node = payload.option as MenuTreeNode
|
||||||
if (node.type !== 'CATALOG') {
|
if (!node.children?.length) {
|
||||||
return h('span', { class: 'tree-switcher-empty' })
|
return h('span', { class: 'tree-switcher-empty' })
|
||||||
}
|
}
|
||||||
return h(ChevronRight, { size: 16, strokeWidth: 2.25, class: 'tree-switcher-icon' })
|
return h(ChevronRight, { size: 16, strokeWidth: 2.25, class: 'tree-switcher-icon' })
|
||||||
@@ -306,6 +317,18 @@ function renderSwitcherIcon(payload: { option: unknown }) {
|
|||||||
padding: 8px 10px 12px;
|
padding: 8px 10px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu-tree-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(240px, 1fr) 100px 92px 92px 250px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 14px 8px 42px;
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.n-tree-node-switcher) {
|
:deep(.n-tree-node-switcher) {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 42px;
|
height: 42px;
|
||||||
@@ -338,4 +361,33 @@ function renderSwitcherIcon(payload: { option: unknown }) {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.menu-tree-row) {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 1fr) 92px 92px 92px 272px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.menu-tree-title) {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.menu-tree-type),
|
||||||
|
:deep(.menu-tree-status),
|
||||||
|
:deep(.menu-tree-built-in) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.menu-tree-actions) {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+151
-37
@@ -2,12 +2,15 @@
|
|||||||
<n-layout has-sider class="app-root app-backdrop">
|
<n-layout has-sider class="app-root app-backdrop">
|
||||||
<n-layout-sider
|
<n-layout-sider
|
||||||
collapse-mode="width"
|
collapse-mode="width"
|
||||||
:collapsed-width="72"
|
:collapsed-width="isFullscreen ? 0 : 72"
|
||||||
:width="248"
|
:width="siderWidth"
|
||||||
:collapsed="authStore.layoutCollapsed"
|
:collapsed="isFullscreen || authStore.layoutCollapsed"
|
||||||
class="layout-sider"
|
class="layout-sider"
|
||||||
:class="{ 'layout-sider-collapsed': authStore.layoutCollapsed }"
|
:class="{
|
||||||
show-trigger
|
'layout-sider-collapsed': isSiderCollapsed,
|
||||||
|
'layout-sider-hidden': isFullscreen
|
||||||
|
}"
|
||||||
|
:show-trigger="!isFullscreen"
|
||||||
@collapse="authStore.layoutCollapsed = true"
|
@collapse="authStore.layoutCollapsed = true"
|
||||||
@expand="authStore.layoutCollapsed = false"
|
@expand="authStore.layoutCollapsed = false"
|
||||||
>
|
>
|
||||||
@@ -18,20 +21,22 @@
|
|||||||
<div class="menu-wrap">
|
<div class="menu-wrap">
|
||||||
<AppMenu />
|
<AppMenu />
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!isSiderCollapsed"
|
||||||
|
class="sider-resizer"
|
||||||
|
title="拖动调整菜单宽度"
|
||||||
|
@pointerdown="startSiderResize"
|
||||||
|
></div>
|
||||||
</n-layout-sider>
|
</n-layout-sider>
|
||||||
|
|
||||||
<n-layout class="app-main">
|
<n-layout class="app-main" :class="{ 'app-main-fullscreen': isFullscreen }">
|
||||||
<header class="app-header">
|
<header v-if="!isFullscreen" class="app-header">
|
||||||
<div class="header-main">
|
<div class="header-main">
|
||||||
<div class="title-group">
|
|
||||||
<h1>{{ currentTitle }}</h1>
|
|
||||||
<AppBreadcrumb />
|
|
||||||
</div>
|
|
||||||
<div class="summary-actions">
|
|
||||||
<n-button
|
<n-button
|
||||||
tertiary
|
tertiary
|
||||||
circle
|
circle
|
||||||
size="small"
|
size="small"
|
||||||
|
class="header-refresh"
|
||||||
title="刷新当前页面"
|
title="刷新当前页面"
|
||||||
@click="authStore.refreshTab(route.path)"
|
@click="authStore.refreshTab(route.path)"
|
||||||
>
|
>
|
||||||
@@ -39,7 +44,26 @@
|
|||||||
<n-icon><RefreshCw /></n-icon>
|
<n-icon><RefreshCw /></n-icon>
|
||||||
</template>
|
</template>
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-tag type="info" size="small">{{ authStore.user?.username ?? '访客' }}</n-tag>
|
<n-button
|
||||||
|
tertiary
|
||||||
|
circle
|
||||||
|
size="small"
|
||||||
|
class="header-fullscreen"
|
||||||
|
:title="isFullscreen ? '退出全屏' : '全屏'"
|
||||||
|
@click="toggleFullscreen"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon>
|
||||||
|
<Minimize2 v-if="isFullscreen" />
|
||||||
|
<Maximize2 v-else />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
<div class="breadcrumb-group">
|
||||||
|
<AppBreadcrumb />
|
||||||
|
</div>
|
||||||
|
<div class="summary-actions">
|
||||||
|
<n-tag type="info" size="small">{{ userDisplayName }}</n-tag>
|
||||||
<n-button tertiary size="small" @click="handleLogout">退出</n-button>
|
<n-button tertiary size="small" @click="handleLogout">退出</n-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,8 +89,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { RefreshCw } from 'lucide-vue-next'
|
import { Maximize2, Minimize2, RefreshCw } from 'lucide-vue-next'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import AppMenu from '@/components/AppMenu.vue'
|
import AppMenu from '@/components/AppMenu.vue'
|
||||||
@@ -79,8 +103,18 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const appTitle = appEnv.appTitle
|
const appTitle = appEnv.appTitle
|
||||||
|
const isFullscreen = ref(false)
|
||||||
const currentTitle = computed(() => (route.meta.title as string | undefined) ?? '工作台')
|
const SIDER_WIDTH_KEY = 'platform.layout.siderWidth'
|
||||||
|
const MIN_SIDER_WIDTH = 200
|
||||||
|
const MAX_SIDER_WIDTH = 360
|
||||||
|
const DEFAULT_SIDER_WIDTH = 248
|
||||||
|
const storedSiderWidth = Number(localStorage.getItem(SIDER_WIDTH_KEY))
|
||||||
|
const siderWidth = ref(
|
||||||
|
Number.isFinite(storedSiderWidth)
|
||||||
|
? Math.min(MAX_SIDER_WIDTH, Math.max(MIN_SIDER_WIDTH, storedSiderWidth))
|
||||||
|
: DEFAULT_SIDER_WIDTH
|
||||||
|
)
|
||||||
|
const isSiderCollapsed = computed(() => isFullscreen.value || authStore.layoutCollapsed)
|
||||||
|
|
||||||
const cachedRouteNames = computed(() =>
|
const cachedRouteNames = computed(() =>
|
||||||
router
|
router
|
||||||
@@ -89,6 +123,11 @@ const cachedRouteNames = computed(() =>
|
|||||||
.map((item) => item.name as string)
|
.map((item) => item.name as string)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const userDisplayName = computed(() => {
|
||||||
|
const nickname = authStore.user?.nickname?.trim()
|
||||||
|
return nickname || authStore.user?.username || '访客'
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.fullPath,
|
() => route.fullPath,
|
||||||
() => {
|
() => {
|
||||||
@@ -104,6 +143,47 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function syncFullscreenState() {
|
||||||
|
isFullscreen.value = Boolean(document.fullscreenElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
syncFullscreenState()
|
||||||
|
document.addEventListener('fullscreenchange', syncFullscreenState)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('fullscreenchange', syncFullscreenState)
|
||||||
|
stopSiderResize()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function toggleFullscreen() {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
await document.exitFullscreen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await document.documentElement.requestFullscreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSiderResize(event: PointerEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
document.body.classList.add('sider-resizing')
|
||||||
|
window.addEventListener('pointermove', resizeSider)
|
||||||
|
window.addEventListener('pointerup', stopSiderResize)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeSider(event: PointerEvent) {
|
||||||
|
const nextWidth = Math.min(MAX_SIDER_WIDTH, Math.max(MIN_SIDER_WIDTH, event.clientX))
|
||||||
|
siderWidth.value = nextWidth
|
||||||
|
localStorage.setItem(SIDER_WIDTH_KEY, String(nextWidth))
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSiderResize() {
|
||||||
|
document.body.classList.remove('sider-resizing')
|
||||||
|
window.removeEventListener('pointermove', resizeSider)
|
||||||
|
window.removeEventListener('pointerup', stopSiderResize)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await authStore.logout()
|
await authStore.logout()
|
||||||
resetDynamicRoutes()
|
resetDynamicRoutes()
|
||||||
@@ -134,40 +214,37 @@ async function handleLogout() {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 16px 18px 18px;
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main-fullscreen .layout-content {
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header {
|
.app-header {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
border: 1px solid #e2e8f0;
|
border-bottom: 1px solid #e2e8f0;
|
||||||
border-radius: 10px;
|
background: #ffffff;
|
||||||
background: rgba(255, 255, 255, 0.92);
|
padding: 10px 18px 8px;
|
||||||
box-shadow: 0 14px 34px rgba(15, 23, 42, 0.06);
|
|
||||||
padding: 12px 14px 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-main {
|
.header-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 12px;
|
||||||
gap: 16px;
|
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-group {
|
.header-refresh,
|
||||||
min-width: 0;
|
.header-fullscreen {
|
||||||
display: flex;
|
flex: 0 0 auto;
|
||||||
align-items: baseline;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-group h1 {
|
.breadcrumb-group {
|
||||||
margin: 0;
|
flex: 1 1 auto;
|
||||||
color: #0f172a;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 700;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
white-space: nowrap;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-actions {
|
.summary-actions {
|
||||||
@@ -181,7 +258,7 @@ async function handleLogout() {
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding-top: 14px;
|
padding: 16px 18px 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-scroll {
|
.content-scroll {
|
||||||
@@ -195,10 +272,15 @@ async function handleLogout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.layout-sider {
|
.layout-sider {
|
||||||
|
position: relative;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border-right: 1px solid #e2e8f0;
|
border-right: 1px solid #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.layout-sider-hidden {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.brand-mark {
|
.brand-mark {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -231,6 +313,38 @@ async function handleLogout() {
|
|||||||
padding: 12px 10px;
|
padding: 12px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sider-resizer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: -3px;
|
||||||
|
z-index: 3;
|
||||||
|
width: 6px;
|
||||||
|
height: 100%;
|
||||||
|
cursor: col-resize;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sider-resizer::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 2px;
|
||||||
|
width: 2px;
|
||||||
|
height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sider-resizer:hover::after,
|
||||||
|
:global(.sider-resizing) .sider-resizer::after {
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.sider-resizing) {
|
||||||
|
cursor: col-resize;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
.layout-sider-collapsed .brand-mark {
|
.layout-sider-collapsed .brand-mark {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div class="login-page">
|
||||||
class="relative flex min-h-screen items-center justify-center overflow-hidden bg-slate-100 px-4"
|
<div class="login-background"></div>
|
||||||
>
|
<n-card class="login-card">
|
||||||
<div
|
|
||||||
class="absolute inset-0 bg-[radial-gradient(circle_at_20%_10%,rgba(53,109,255,0.12),transparent_34%),radial-gradient(circle_at_80%_90%,rgba(14,165,233,0.12),transparent_32%)]"
|
|
||||||
></div>
|
|
||||||
<n-card class="relative z-10 w-full max-w-md rounded-2xl border border-slate-200 shadow-panel">
|
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="text-2xl font-semibold text-slate-900">通用管理平台</div>
|
<div class="text-2xl font-semibold text-slate-900">通用管理平台</div>
|
||||||
<div class="mt-1 text-sm text-slate-500">中后台管理系统</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -29,8 +24,6 @@
|
|||||||
>登录</n-button
|
>登录</n-button
|
||||||
>
|
>
|
||||||
</n-form>
|
</n-form>
|
||||||
|
|
||||||
<div class="mt-4 text-xs text-slate-500">默认账号:admin / Admin@123456</div>
|
|
||||||
</n-card>
|
</n-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -50,8 +43,8 @@ const authStore = useAuthStore()
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const formRef = ref<FormInst | null>(null)
|
const formRef = ref<FormInst | null>(null)
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
username: 'admin',
|
username: '',
|
||||||
password: 'Admin@123456'
|
password: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
@@ -72,3 +65,83 @@ async function onSubmit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 24px clamp(24px, 9vw, 128px) 24px 16px;
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
42deg,
|
||||||
|
rgba(226, 252, 244, 0.98),
|
||||||
|
rgba(224, 244, 255, 0.96),
|
||||||
|
rgba(241, 255, 250, 0.98),
|
||||||
|
rgba(230, 247, 255, 0.96),
|
||||||
|
rgba(226, 252, 244, 0.98)
|
||||||
|
),
|
||||||
|
#f5fbff;
|
||||||
|
background-size: 320% 320%;
|
||||||
|
animation: loginGradientFlow 18s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-background {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(14, 116, 144, 0.05) 1px, transparent 1px),
|
||||||
|
linear-gradient(180deg, rgba(15, 118, 110, 0.04) 1px, transparent 1px),
|
||||||
|
linear-gradient(145deg, transparent 0%, rgba(255, 255, 255, 0.76) 46%, transparent 74%);
|
||||||
|
background-size:
|
||||||
|
42px 42px,
|
||||||
|
42px 42px,
|
||||||
|
100% 100%;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(115deg, rgba(255, 255, 255, 0.82), transparent 35%),
|
||||||
|
linear-gradient(295deg, rgba(204, 251, 241, 0.34), transparent 42%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 448px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.78);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
box-shadow: 0 24px 60px rgba(15, 118, 110, 0.12);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loginGradientFlow {
|
||||||
|
0% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
background-position: 100% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-position: 0% 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.login-page {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
description="你当前没有访问该页面的权限,请联系管理员。"
|
description="你当前没有访问该页面的权限,请联系管理员。"
|
||||||
>
|
>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
<n-space justify="center">
|
||||||
<n-button type="primary" @click="router.push('/workbench')">返回工作台</n-button>
|
<n-button type="primary" @click="router.push('/workbench')">返回工作台</n-button>
|
||||||
|
<n-button @click="handleLogout">退出登录</n-button>
|
||||||
|
</n-space>
|
||||||
</template>
|
</template>
|
||||||
</n-result>
|
</n-result>
|
||||||
</div>
|
</div>
|
||||||
@@ -14,6 +17,15 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { resetDynamicRoutes } from '@/router'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await authStore.logout()
|
||||||
|
resetDynamicRoutes()
|
||||||
|
await router.replace('/login')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user