初始化

This commit is contained in:
zg
2026-07-15 16:13:27 +08:00
parent 6d6f559793
commit c4023c1623
276 changed files with 42622 additions and 0 deletions

View File

@@ -0,0 +1,557 @@
<template>
<div class="admin-shell">
<!-- ===== 侧边栏树形菜单 ===== -->
<aside class="sidebar" :class="{ open: mobileSidebarOpen }">
<!-- Logo -->
<div class="sidebar-logo" @click="router.push('/admin/dashboard')">
<svg class="logo-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
<span class="logo-text">Mokee</span>
</div>
<!-- 菜单树 -->
<nav class="sidebar-nav">
<!-- 首页 -->
<div
class="menu-item single"
:class="{ active: activeMenu === '/admin/dashboard' }"
@click="openPage('/admin/dashboard', '首页')"
>
<el-icon :size="18"><HomeFilled /></el-icon>
<span>首页</span>
</div>
<!-- 系统管理 -->
<div class="menu-group" :class="{ open: menuOpen.system }">
<div class="menu-item parent" :class="{ active: activeGroup === 'system' }" @click="menuOpen.system = !menuOpen.system">
<el-icon :size="18"><Monitor /></el-icon>
<span>系统管理</span>
<el-icon :size="14" class="arrow" :class="{ rotated: menuOpen.system }"><ArrowRight /></el-icon>
</div>
<div class="sub-items" v-show="menuOpen.system">
<div class="menu-item child" :class="{ active: activeMenu === '/admin/system' }" @click="openPage('/admin/system', '系统列表')">
<span>系统列表</span>
</div>
<div class="menu-item child" :class="{ active: activeMenu === '/admin/api' }" @click="openPage('/admin/api', '接口管理')">
<span>接口管理</span>
</div>
<div class="menu-item child" :class="{ active: activeMenu === '/admin/dict' }" @click="openPage('/admin/dict', '数据字典')">
<span>数据字典</span>
</div>
<div class="menu-item child" :class="{ active: activeMenu === '/admin/file' }" @click="openPage('/admin/file', '文件管理')">
<span>文件管理</span>
</div>
<div class="menu-item child" :class="{ active: activeMenu === '/admin/email' }" @click="openPage('/admin/email', '邮件管理')">
<span>邮件管理</span>
</div>
</div>
</div>
<!-- 用户权限 -->
<div class="menu-group" :class="{ open: menuOpen.user }">
<div class="menu-item parent" :class="{ active: activeGroup === 'user' }" @click="menuOpen.user = !menuOpen.user">
<el-icon :size="18"><UserFilled /></el-icon>
<span>用户权限</span>
<el-icon :size="14" class="arrow" :class="{ rotated: menuOpen.user }"><ArrowRight /></el-icon>
</div>
<div class="sub-items" v-show="menuOpen.user">
<div class="menu-item child" :class="{ active: activeMenu === '/admin/user' }" @click="openPage('/admin/user', '用户管理')">
<span>用户管理</span>
</div>
<div class="menu-item child" :class="{ active: activeMenu === '/admin/role' }" @click="openPage('/admin/role', '角色管理')">
<span>角色管理</span>
</div>
<div class="menu-item child" :class="{ active: activeMenu === '/admin/menu' }" @click="openPage('/admin/menu', '菜单管理')">
<span>菜单管理</span>
</div>
</div>
</div>
<!-- 日志审计 -->
<div class="menu-group" :class="{ open: menuOpen.log }">
<div class="menu-item parent" :class="{ active: activeGroup === 'log' }" @click="menuOpen.log = !menuOpen.log">
<el-icon :size="18"><Document /></el-icon>
<span>日志审计</span>
<el-icon :size="14" class="arrow" :class="{ rotated: menuOpen.log }"><ArrowRight /></el-icon>
</div>
<div class="sub-items" v-show="menuOpen.log">
<div class="menu-item child" :class="{ active: activeMenu === '/admin/log/login' }" @click="openPage('/admin/log/login', '登录日志')">
<span>登录日志</span>
</div>
<div class="menu-item child" :class="{ active: activeMenu === '/admin/log/operation' }" @click="openPage('/admin/log/operation', '操作日志')">
<span>操作日志</span>
</div>
<div class="menu-item child" :class="{ active: activeMenu === '/admin/log/token' }" @click="openPage('/admin/log/token', 'Token日志')">
<span>Token日志</span>
</div>
</div>
</div>
</nav>
<!-- 底部 -->
<div class="sidebar-footer">
<div class="menu-item single" @click="router.push('/select')">
<el-icon :size="18"><Switch /></el-icon>
<span>切换系统</span>
</div>
</div>
</aside>
<!-- 移动端遮罩 -->
<div :class="['sidebar-overlay', { show: mobileSidebarOpen }]" @click="mobileSidebarOpen = false" />
<!-- ===== 右侧主体 ===== -->
<div class="main-area">
<!-- 顶栏 -->
<header class="topbar">
<div class="topbar-left">
<span class="hamburger" @click="mobileSidebarOpen = !mobileSidebarOpen">
<el-icon :size="20"><component :is="mobileSidebarOpen ? Fold : Expand" /></el-icon>
</span>
<el-breadcrumb separator="/" class="breadcrumb-desk">
<el-breadcrumb-item :to="{ path: '/admin/dashboard' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-if="breadcrumbParent">{{ breadcrumbParent }}</el-breadcrumb-item>
<el-breadcrumb-item v-if="breadcrumbCurrent">{{ breadcrumbCurrent }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="topbar-right">
<el-dropdown trigger="hover" placement="bottom-end">
<div class="user-chip">
<el-avatar :size="28" :src="authStore.userInfo?.avatar">
{{ authStore.userInfo?.realName?.charAt(0) || 'U' }}
</el-avatar>
<span class="user-name">{{ authStore.userInfo?.realName || authStore.userInfo?.username || '用户' }}</span>
<el-icon :size="12" class="user-chevron"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="router.push('/admin/personal')">
<el-icon><User /></el-icon>个人中心
</el-dropdown-item>
<el-dropdown-item @click="router.push('/select')">
<el-icon><Switch /></el-icon>切换系统
</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">
<el-icon><SwitchButton /></el-icon>退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
<!-- 标签栏 -->
<div class="tab-bar" v-if="tabsStore.tabs.length > 0">
<div class="tab-list" ref="tabListRef">
<div
v-for="tab in tabsStore.tabs"
:key="tab.path"
:class="['tab-chip', { active: tabsStore.activeTab === tab.path }]"
@click="switchTab(tab)"
@contextmenu.prevent="showMenu($event, tab)"
>
<span class="tab-title">{{ tab.title }}</span>
<el-icon
v-if="tabsStore.tabs.length > 1"
class="tab-close"
:size="12"
@click.stop="closeTab(tab.path)"
><Close /></el-icon>
</div>
</div>
<el-dropdown trigger="click" @command="handleTabCmd" class="tab-more">
<span class="tab-more-btn">
<el-icon :size="14"><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="closeCurrent">
<el-icon><Close /></el-icon>关闭当前
</el-dropdown-item>
<el-dropdown-item command="closeOthers">
<el-icon><Remove /></el-icon>关闭其他
</el-dropdown-item>
<el-dropdown-item command="closeAll">
<el-icon><CircleClose /></el-icon>关闭全部
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<!-- 右键菜单 -->
<Teleport to="body">
<div
v-if="ctxMenu.visible"
class="ctx-overlay"
@click="ctxMenu.visible = false"
>
<div
class="ctx-menu"
:style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px' }"
@click.stop
>
<div class="ctx-item" @click="closeTab(ctxMenu.tab?.path); ctxMenu.visible = false">
<el-icon :size="14"><Close /></el-icon>关闭
</div>
<div class="ctx-item" @click="closeOthers(ctxMenu.tab?.path); ctxMenu.visible = false">
<el-icon :size="14"><Remove /></el-icon>关闭其他
</div>
<div class="ctx-item" @click="closeAll(); ctxMenu.visible = false">
<el-icon :size="14"><CircleClose /></el-icon>关闭全部
</div>
</div>
</div>
</Teleport>
<!-- 内容区 -->
<main class="content">
<router-view :key="tabsStore.activeTab" />
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, watch, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
HomeFilled, Monitor, UserFilled, Document,
ArrowRight, Switch, ArrowDown, SwitchButton,
User, Close, Remove, CircleClose, Expand, Fold,
} from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { useTabsStore } from '@/stores/tabs'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const tabsStore = useTabsStore()
const tabListRef = ref<HTMLElement>()
const mobileSidebarOpen = ref(false)
// 菜单展开状态
const menuOpen = reactive({ system: true, user: true, log: true })
// 当前高亮
const activeMenu = computed(() => {
const p = route.path
const known = ['/admin/dashboard', '/admin/system', '/admin/api', '/admin/dict', '/admin/file', '/admin/email',
'/admin/user', '/admin/role', '/admin/menu', '/admin/log/login', '/admin/log/operation', '/admin/log/token']
return known.find(k => p.startsWith(k)) || p
})
const activeGroup = computed(() => {
const p = route.path
if (p.startsWith('/admin/system') || p.startsWith('/admin/api') || p.startsWith('/admin/dict') || p.startsWith('/admin/file') || p.startsWith('/admin/email')) return 'system'
if (p.startsWith('/admin/user') || p.startsWith('/admin/role') || p.startsWith('/admin/menu')) return 'user'
if (p.startsWith('/admin/log')) return 'log'
return ''
})
// 面包屑
const breadcrumbMap: Record<string, { parent?: string; current: string }> = {
'/admin/dashboard': { current: '首页' },
'/admin/system': { parent: '系统管理', current: '系统列表' },
'/admin/api': { parent: '系统管理', current: '接口管理' },
'/admin/dict': { parent: '系统管理', current: '数据字典' },
'/admin/file': { parent: '系统管理', current: '文件管理' },
'/admin/email': { parent: '系统管理', current: '邮件管理' },
'/admin/user': { parent: '用户权限', current: '用户管理' },
'/admin/role': { parent: '用户权限', current: '角色管理' },
'/admin/menu': { parent: '用户权限', current: '菜单管理' },
'/admin/log/login': { parent: '日志审计', current: '登录日志' },
'/admin/log/operation': { parent: '日志审计', current: '操作日志' },
'/admin/log/token': { parent: '日志审计', current: 'Token日志' },
'/admin/user/form': { parent: '用户权限', current: '用户表单' },
'/admin/role/form': { parent: '用户权限', current: '角色表单' },
'/admin/menu/form': { parent: '用户权限', current: '菜单表单' },
'/admin/system/form': { parent: '系统管理', current: '系统表单' },
'/admin/api/form': { parent: '系统管理', current: '接口表单' },
'/admin/dict/form': { parent: '系统管理', current: '字典表单' },
'/admin/personal': { current: '个人中心' },
}
const breadcrumbParent = computed(() => {
const key = Object.keys(breadcrumbMap).find(k => route.path.startsWith(k))
return key ? breadcrumbMap[key].parent : undefined
})
const breadcrumbCurrent = computed(() => {
const key = Object.keys(breadcrumbMap).find(k => route.path.startsWith(k))
return key ? breadcrumbMap[key].current : undefined
})
// ===== 标签页 =====
function openPage(path: string, title: string) {
tabsStore.addTab({ path, meta: { title }, query: {} } as any)
router.push(path)
mobileSidebarOpen.value = false
}
function switchTab(tab: any) {
router.push(tab.path)
}
function closeTab(path: string) {
tabsStore.removeTab(path)
if (tabsStore.activeTab) {
router.push(tabsStore.activeTab)
} else {
router.push('/admin/dashboard')
tabsStore.addTab({ path: '/admin/dashboard', meta: { title: '首页' }, query: {} } as any)
}
}
function closeOthers(path?: string) {
if (path) tabsStore.closeOthers(path)
}
function closeAll() {
tabsStore.closeAll()
router.push('/admin/dashboard')
tabsStore.addTab({ path: '/admin/dashboard', meta: { title: '首页' }, query: {} } as any)
}
function handleTabCmd(cmd: string) {
const cur = tabsStore.activeTab
if (cmd === 'closeCurrent' && cur) closeTab(cur)
else if (cmd === 'closeOthers' && cur) closeOthers(cur)
else if (cmd === 'closeAll') closeAll()
}
// 右键菜单
const ctxMenu = reactive({ visible: false, x: 0, y: 0, tab: null as any })
function showMenu(e: MouseEvent, tab: any) {
ctxMenu.visible = true
ctxMenu.x = e.clientX
ctxMenu.y = e.clientY
ctxMenu.tab = tab
}
// 路由变化自动加标签
watch(() => route.path, (p) => {
if (p.startsWith('/admin') && p !== '/admin') {
tabsStore.addTab(route)
}
})
onMounted(async () => {
// 跨域名跳转后 Pinia 状态丢失,重新从服务端加载用户信息
await authStore.fetchUserInfo()
if (route.path !== '/admin' && route.path !== '/admin/dashboard') {
tabsStore.addTab(route)
}
// 首页默认标签
if (!tabsStore.tabs.find((t: any) => t.path === '/admin/dashboard')) {
tabsStore.addTab({ path: '/admin/dashboard', meta: { title: '首页' }, query: {} } as any)
}
if (route.path === '/admin') {
router.push('/admin/dashboard')
}
})
async function handleLogout() {
try { await authStore.logout() } catch {}
tabsStore.closeAll()
// 直接完整跳转登录页,避免 router.push 触发组件 API 请求
window.location.href = import.meta.env.VITE_LOGIN_URL || '/login'
}
</script>
<style scoped>
/* ===== 整体布局 ===== */
.admin-shell { display:flex; height:100vh; overflow:hidden; background:var(--bg-page); }
/* ===== 侧边栏 ===== */
.sidebar {
width: 240px; flex-shrink:0;
background: var(--bg-surface);
border-right: 1px solid var(--border-color);
display:flex; flex-direction:column; overflow:hidden; z-index:50;
}
.sidebar-logo {
display:flex; align-items:center; gap:10px;
height: 58px; padding:0 20px;
border-bottom: 1px solid var(--border-light);
cursor:pointer; flex-shrink:0;
}
.logo-svg { width:22px; height:22px; color:var(--color-primary); flex-shrink:0; }
.logo-text { font-size:18px; font-weight:700; color:var(--text-primary); letter-spacing:-0.3px; }
.sidebar-nav { flex:1; overflow-y:auto; padding:16px 12px; }
.sidebar-footer { padding:8px 12px 16px; border-top:1px solid var(--border-light); flex-shrink:0; }
/* ===== 菜单项 ===== */
.menu-item {
display:flex; align-items:center; gap:10px;
height:42px; padding:0 14px;
border-radius: var(--radius-md);
cursor:pointer; font-size:14px; font-weight:500;
color: var(--text-secondary);
transition: all var(--transition-fast);
white-space:nowrap;
}
.menu-item:hover { background:var(--bg-hover); color:var(--text-primary); }
.menu-item.active { background:var(--color-primary-bg); color:var(--color-primary); font-weight:600; }
.menu-item.single { margin-bottom:4px; }
.menu-item.parent {
font-weight:600; font-size:14px; color:var(--text-primary);
margin-top:2px;
}
.menu-item.child {
padding-left:48px; height:38px;
font-size:14px; font-weight:400;
color: var(--text-muted);
margin:1px 0;
}
.menu-item.child:hover { color:var(--text-primary); }
.menu-item.child.active { color:var(--color-primary); font-weight:500; }
/* 展开箭头 */
.arrow { margin-left:auto; transition:transform var(--transition-fast); color:var(--text-muted); flex-shrink:0; }
.arrow.rotated { transform:rotate(90deg); }
.sub-items { overflow:hidden; padding-bottom:4px; }
/* ===== 分隔线(菜单组之间) ===== */
.menu-group { padding:4px 0; }
.menu-group + .menu-group { border-top:1px solid var(--border-light); }
/* ===== 主体区域 ===== */
.main-area { flex:1; display:flex; flex-direction:column; overflow:hidden; min-width:0; }
/* ===== 顶栏 ===== */
.topbar {
display:flex; align-items:center; justify-content:space-between;
height:58px; background:var(--bg-surface);
border-bottom:1px solid var(--border-color);
padding:0 24px; flex-shrink:0;
}
.topbar-left { display:flex; align-items:center; }
.topbar-right { display:flex; align-items:center; }
.user-chip {
display:flex; align-items:center; gap:8px;
padding:5px 12px 5px 5px; border-radius:24px;
cursor:pointer; transition:all var(--transition-fast);
border:1px solid transparent;
}
.user-chip:hover { background:var(--bg-hover); border-color:var(--border-color); }
.user-name {
font-size:14px; font-weight:500; color:var(--text-secondary);
max-width:100px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.user-chevron { color:var(--text-muted); flex-shrink:0; }
/* ===== 标签栏 ===== */
.tab-bar {
display:flex; align-items:center;
height:42px; background:var(--bg-surface);
border-bottom:1px solid var(--border-color);
padding:0 12px; flex-shrink:0; gap:4px;
}
.tab-list { display:flex; flex:1; overflow-x:auto; overflow-y:hidden; height:100%; align-items:center; gap:2px; }
.tab-list::-webkit-scrollbar { height:2px; }
.tab-chip {
display:flex; align-items:center; gap:6px;
height:34px; padding:0 12px;
border-radius:var(--radius-sm);
cursor:pointer; font-size:13px;
color:var(--text-muted);
transition:all var(--transition-fast);
white-space:nowrap; flex-shrink:0;
}
.tab-chip:hover { background:var(--bg-hover); color:var(--text-secondary); }
.tab-chip.active { background:var(--color-primary-bg); color:var(--color-primary); font-weight:500; }
.tab-title { max-width:120px; overflow:hidden; text-overflow:ellipsis; }
.tab-close { border-radius:3px; padding:1px; opacity:0; transition:all var(--transition-fast); }
.tab-chip:hover .tab-close { opacity:1; }
.tab-close:hover { background:var(--border-color); color:var(--text-primary); }
.tab-more { flex-shrink:0; }
.tab-more-btn {
display:flex; align-items:center; justify-content:center;
width:30px; height:30px; border-radius:var(--radius-sm);
cursor:pointer; color:var(--text-muted);
transition:all var(--transition-fast);
}
.tab-more-btn:hover { background:var(--bg-hover); color:var(--text-secondary); }
/* ===== 右键菜单 ===== */
.ctx-overlay { position:fixed; inset:0; z-index:9998; }
.ctx-menu {
position:fixed; z-index:9999;
background:var(--bg-surface); border:1px solid var(--border-color);
border-radius:var(--radius-md); box-shadow:var(--shadow-lg);
min-width:150px; padding:4px;
}
.ctx-item {
display:flex; align-items:center; gap:8px;
padding:9px 14px; font-size:14px; cursor:pointer;
color:var(--text-secondary); border-radius:var(--radius-sm);
transition:all var(--transition-fast);
}
.ctx-item:hover { background:var(--bg-hover); color:var(--text-primary); }
/* ===== 内容区 ===== */
.content { flex:1; overflow-y:auto; padding:24px; }
/* ===== 汉堡按钮 ===== */
.hamburger {
display: none; align-items: center; justify-content: center;
width: 36px; height: 36px; border-radius: var(--radius-md);
cursor: pointer; color: var(--text-secondary);
margin-right: 8px; flex-shrink: 0;
}
.hamburger:hover { background: var(--bg-hover); color: var(--color-primary); }
/* ===== 侧边栏遮罩 ===== */
.sidebar-overlay {
display: none;
position: fixed; inset: 0; z-index: 99;
background: rgba(0,0,0,0.4);
}
/* ===== 移动端适配 ===== */
@media (max-width: 768px) {
.hamburger { display: flex; }
.breadcrumb-desk { display: none; }
/* 侧边栏滑出 */
.sidebar {
position: fixed; top: 0; left: 0; bottom: 0; z-index: 100;
width: 260px !important;
transform: translateX(-100%);
transition: transform 0.25s ease;
box-shadow: none;
}
.sidebar.open {
transform: translateX(0);
}
.sidebar-overlay.show { display: block; }
/* 主体全宽 */
.main-area { width: 100%; }
/* 顶栏 */
.topbar { padding: 0 12px; height: 50px; }
.user-chip { padding: 4px 8px 4px 4px; }
.user-name { display: none; }
/* 标签栏 */
.tab-bar { height: 36px; padding: 0 6px; }
.tab-chip { height: 28px; padding: 0 8px; font-size: 12px; }
.tab-title { max-width: 80px; }
/* 内容区 */
.content { padding: 12px; }
}
</style>

View File

@@ -0,0 +1,353 @@
<template>
<div class="dashboard">
<!-- 页面标题 -->
<div class="page-title">
<h2>概览</h2>
<p>网关平台运行状态一览</p>
</div>
<!-- 统计卡片 -->
<div class="stat-grid">
<div class="stat-card" v-for="card in statCards" :key="card.label">
<div class="stat-icon-box" :style="{ background: card.bg, color: card.color }">
<el-icon :size="22"><component :is="card.icon" /></el-icon>
</div>
<div class="stat-body">
<div class="stat-label">{{ card.label }}</div>
<div class="stat-value">
<el-skeleton v-if="loading" animated style="width:40px;height:28px" />
<span v-else>{{ card.value }}</span>
</div>
</div>
</div>
</div>
<!-- 图表 + 服务器信息 -->
<div class="info-grid">
<!-- 登录趋势 -->
<div class="chart-panel">
<div class="panel-header">
<div class="panel-title">
<el-icon :size="16" color="var(--color-primary)"><DataLine /></el-icon>
<span>近30天登录趋势</span>
</div>
</div>
<div class="panel-body">
<el-skeleton v-if="chartLoading" animated :rows="8" />
<template v-else>
<div ref="chartRef" class="chart-box"></div>
<el-empty v-if="!hasData" description="暂无登录数据" :image-size="80" />
</template>
</div>
</div>
<!-- 服务器状态 -->
<div class="server-panel">
<div class="panel-header">
<div class="panel-title">
<el-icon :size="16" color="var(--color-success)"><Monitor /></el-icon>
<span>服务器状态</span>
</div>
<el-tag size="small" type="success" effect="plain" round>运行中</el-tag>
</div>
<div class="panel-body">
<el-skeleton v-if="loading" animated :rows="6" />
<div v-else class="server-metrics">
<div class="metric-item">
<span class="metric-label">CPU 核心</span>
<span class="metric-value">{{ stats.serverInfo?.availableProcessors || '-' }}</span>
</div>
<div class="metric-item">
<span class="metric-label">已用内存</span>
<span class="metric-value">{{ stats.serverInfo?.totalMemory || '-' }}</span>
</div>
<div class="metric-item">
<span class="metric-label">可用内存</span>
<span class="metric-value highlight">{{ stats.serverInfo?.freeMemory || '-' }}</span>
</div>
<div class="metric-item">
<span class="metric-label">最大内存</span>
<span class="metric-value">{{ stats.serverInfo?.maxMemory || '-' }}</span>
</div>
<div class="metric-item">
<span class="metric-label">Java 版本</span>
<span class="metric-value">{{ stats.serverInfo?.javaVersion || '-' }}</span>
</div>
<div class="metric-item">
<span class="metric-label">操作系统</span>
<span class="metric-value">{{ stats.serverInfo?.osName || '-' }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick, onBeforeUnmount } from 'vue'
import { Monitor, User, DataLine, Connection } from '@element-plus/icons-vue'
import request from '@/utils/request'
const stats = reactive<any>({ serverInfo: {} })
const chartRef = ref()
const hasData = ref(false)
const loading = ref(true)
const chartLoading = ref(true)
let chartInstance: any = null
const statCards = ref([
{ label: '系统总数', value: 0, icon: Monitor, bg: '#eef2ff', color: '#6366f1' },
{ label: '用户总数', value: 0, icon: User, bg: '#ecfdf5', color: '#10b981' },
{ label: '今日登录', value: 0, icon: DataLine, bg: '#fffbeb', color: '#f59e0b' },
{ label: '当前在线', value: 0, icon: Connection, bg: '#fef2f2', color: '#ef4444' },
])
onMounted(async () => {
try {
const res = await request.get('/zgapi/v1/admin/dashboard/stats')
Object.assign(stats, res.data)
statCards.value[0].value = stats.systemCount || 0
statCards.value[1].value = stats.userCount || 0
statCards.value[2].value = stats.todayLoginCount || 0
statCards.value[3].value = stats.onlineCount || 0
await nextTick()
if (stats.loginTrend?.length) {
hasData.value = true
renderChart()
}
} catch {} finally {
loading.value = false
chartLoading.value = false
}
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
if (chartInstance) chartInstance.dispose()
})
function handleResize() {
chartInstance?.resize()
}
async function renderChart() {
if (!chartRef.value) return
const echartsModule: any = await import('echarts')
const echarts = echartsModule.default || echartsModule
chartInstance = echarts.init(chartRef.value)
chartInstance.setOption({
tooltip: {
trigger: 'axis',
backgroundColor: '#fff',
borderColor: '#e2e8f0',
textStyle: { color: '#1e293b', fontSize: 13 },
boxShadow: '0 4px 16px rgba(0,0,0,0.08)',
padding: [12, 16],
},
grid: { left: 40, right: 20, top: 16, bottom: 48 },
xAxis: {
type: 'category',
data: stats.loginTrend.map((d: any) => d.date),
axisLabel: { rotate: 45, fontSize: 11, color: '#94a3b8' },
axisLine: { lineStyle: { color: '#e2e8f0' } },
axisTick: { show: false },
},
yAxis: {
type: 'value',
minInterval: 1,
axisLabel: { fontSize: 11, color: '#94a3b8' },
splitLine: { lineStyle: { color: '#f1f5f9', type: 'dashed' } },
},
series: [{
data: stats.loginTrend.map((d: any) => d.count),
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 5,
showSymbol: false,
emphasis: { showSymbol: true, symbolSize: 8 },
lineStyle: { width: 2.5, color: '#6366f1' },
itemStyle: { color: '#6366f1' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(99,102,241,0.12)' },
{ offset: 1, color: 'rgba(99,102,241,0.0)' },
]),
},
}],
})
}
</script>
<style scoped>
.dashboard {
/* 占满宽度 */
}
.page-title {
margin-bottom: 24px;
}
.page-title h2 {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.3px;
margin-bottom: 4px;
}
.page-title p {
font-size: 14px;
color: var(--text-muted);
}
/* ===== 统计卡片 ===== */
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: #fff;
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-xs);
transition: all var(--transition-base);
}
.stat-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.stat-icon-box {
width: 44px;
height: 44px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-body {
flex: 1;
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 4px;
font-weight: 500;
}
.stat-value {
font-size: 26px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
letter-spacing: -0.5px;
}
/* ===== 信息网格 ===== */
.info-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 16px;
}
.chart-panel,
.server-panel {
background: #fff;
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-xs);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-light);
}
.panel-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
}
.panel-body {
padding: 20px;
}
.chart-box {
height: 300px;
}
/* ===== 服务器指标 ===== */
.server-metrics {
display: flex;
flex-direction: column;
gap: 0;
}
.metric-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid var(--border-light);
}
.metric-item:last-child {
border-bottom: none;
}
.metric-label {
font-size: 13px;
color: var(--text-muted);
font-weight: 500;
}
.metric-value {
font-size: 13px;
color: var(--text-secondary);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.metric-value.highlight {
color: var(--color-success);
}
/* ===== 响应式 ===== */
@media (max-width: 1024px) {
.stat-grid {
grid-template-columns: repeat(2, 1fr);
}
.info-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.stat-grid { grid-template-columns: 1fr; }
.chart-box { height: 220px; }
.panel-body { padding: 12px; }
}
</style>

View File

@@ -0,0 +1,302 @@
<template>
<div class="personal-page">
<div class="profile-grid">
<!-- 头像 + 基本信息 -->
<div class="profile-card">
<div class="card-header">
<el-icon :size="16"><User /></el-icon>
<span>基本信息</span>
</div>
<div class="card-body">
<!-- 头像上传 -->
<div class="avatar-section">
<div class="avatar-wrapper" @click="triggerUpload">
<el-avatar :size="80" :src="form.avatar" class="profile-avatar">
{{ (form.realName || form.username || 'U').charAt(0) }}
</el-avatar>
<div class="avatar-overlay">
<el-icon :size="20"><Camera /></el-icon>
</div>
</div>
<input ref="fileInputRef" type="file" accept="image/*" style="display:none" @change="handleAvatarUpload" />
<div class="avatar-info">
<div class="avatar-name">{{ form.realName || form.username }}</div>
<div class="avatar-role">{{ form.post || '普通用户' }}</div>
<el-button size="small" text type="primary" @click="triggerUpload">
<el-icon><Upload /></el-icon>更换头像
</el-button>
</div>
</div>
<el-form :model="form" label-width="80px" label-position="left" class="info-form">
<el-form-item label="用户名">
<el-input :model-value="form.username" disabled />
</el-form-item>
<el-form-item label="姓名">
<el-input v-model="form.realName" placeholder="请输入真实姓名" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="form.phone" placeholder="请输入手机号" />
</el-form-item>
<el-form-item label="岗位">
<el-input v-model="form.post" placeholder="请输入岗位" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="saveProfile">
<el-icon><Check /></el-icon>保存信息
</el-button>
</el-form-item>
</el-form>
</div>
</div>
<!-- 修改密码 + 账户信息 -->
<div class="right-cards">
<!-- 修改密码 -->
<div class="profile-card">
<div class="card-header">
<el-icon :size="16"><Lock /></el-icon>
<span>修改密码</span>
</div>
<div class="card-body">
<el-form ref="pwdFormRef" :model="pwdForm" :rules="pwdRules" label-width="80px" label-position="left">
<el-form-item label="原密码" prop="oldPwd">
<el-input v-model="pwdForm.oldPwd" type="password" placeholder="请输入原密码" show-password />
</el-form-item>
<el-form-item label="新密码" prop="newPwd">
<el-input v-model="pwdForm.newPwd" type="password" placeholder="至少6位" show-password />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPwd">
<el-input v-model="pwdForm.confirmPwd" type="password" placeholder="再次输入" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="changingPwd" @click="changePassword">
<el-icon><Key /></el-icon>修改密码
</el-button>
</el-form-item>
</el-form>
</div>
</div>
<!-- 账户信息 -->
<div class="profile-card">
<div class="card-header">
<el-icon :size="16"><InfoFilled /></el-icon>
<span>账户信息</span>
</div>
<div class="card-body">
<div class="meta-list">
<div class="meta-row">
<span class="meta-label">用户ID</span>
<code class="meta-value mono">{{ form.userId?.substring(0, 12) || '-' }}...</code>
</div>
<div class="meta-row">
<span class="meta-label">用户名</span>
<span class="meta-value">{{ form.username || '-' }}</span>
</div>
<div class="meta-row">
<span class="meta-label">注册时间</span>
<span class="meta-value">{{ fmtDate(form.createdAt) }}</span>
</div>
<div class="meta-row">
<span class="meta-label">最后登录</span>
<span class="meta-value">{{ fmtDate(form.lastLoginTime) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { User, Lock, Key, Check, InfoFilled, Camera, Upload } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const fileInputRef = ref<HTMLInputElement>()
const form = reactive({
userId: '' as string | null,
username: '',
realName: '',
email: '',
phone: '',
avatar: '',
post: '',
createdAt: '',
lastLoginTime: '',
})
const saving = ref(false)
function fmtDate(s: string | null | undefined): string {
if (!s) return '-'
try {
const d = new Date(s)
if (isNaN(d.getTime())) return s
return d.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
} catch { return s }
}
onMounted(async () => {
try {
const res = await request.get('/zgapi/v1/auth/user/info')
const d = res.data
Object.assign(form, {
userId: d.userId ?? '',
username: d.username ?? '',
realName: d.realName ?? '',
email: d.email ?? '',
phone: d.phone ?? '',
avatar: d.avatar ?? '',
post: d.post ?? '',
createdAt: d.createdAt ?? '',
lastLoginTime: d.lastLoginTime ?? '',
})
setTimeout(() => pwdFormRef.value?.clearValidate(), 0)
} catch { /* ignore */ }
})
function triggerUpload() {
fileInputRef.value?.click()
}
async function handleAvatarUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
const fd = new FormData()
fd.append('file', file)
fd.append('fileType', 'avatar')
fd.append('userId', form.userId || '')
try {
const res = await request.post('/zgapi/v1/admin/file/upload', fd)
// 文件存入数据库后用文件ID构建访问路径
const fileId = res.data?.id
if (!fileId) { ElMessage.error('上传返回异常'); return }
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || ''
const avatarUrl = `${gatewayUrl}/zgapi/v1/admin/file/${fileId}/view`
form.avatar = avatarUrl
// 更新用户头像字段
await request.put('/zgapi/v1/admin/user', {
id: form.userId,
avatar: avatarUrl,
})
// 同步刷新 store 中的用户信息(也会更新 Redis TokenInfo
await authStore.fetchUserInfo()
ElMessage.success('头像已更新')
} catch { ElMessage.error('上传失败') }
}
async function saveProfile() {
saving.value = true
try {
await request.put('/zgapi/v1/admin/user', {
id: form.userId,
username: form.username,
realName: form.realName,
email: form.email,
phone: form.phone,
post: form.post,
})
await authStore.fetchUserInfo()
ElMessage.success('个人信息已更新')
} catch { /* ignore */ } finally {
saving.value = false
}
}
// 密码
const pwdFormRef = ref<FormInstance>()
const changingPwd = ref(false)
const pwdForm = reactive({ oldPwd: '', newPwd: '', confirmPwd: '' })
const pwdRules: FormRules = {
oldPwd: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
newPwd: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '密码至少6位', trigger: 'blur' },
],
confirmPwd: [
{ required: true, message: '请再次输入', trigger: 'blur' },
{ validator: (_r: any, v: string, cb: any) => v !== pwdForm.newPwd ? cb(new Error('两次密码不一致')) : cb(), trigger: 'blur' },
],
}
async function changePassword() {
const valid = await pwdFormRef.value?.validate().catch(() => false)
if (!valid) return
changingPwd.value = true
try {
await request.put('/zgapi/v1/auth/password/update', null, {
params: { oldPwd: pwdForm.oldPwd, newPwd: pwdForm.newPwd },
})
ElMessage.success('密码修改成功,即将重新登录')
pwdForm.oldPwd = ''; pwdForm.newPwd = ''; pwdForm.confirmPwd = ''
setTimeout(() => {
authStore.logout()
window.location.href = '/login'
}, 1500)
} catch { /* ignore */ } finally {
changingPwd.value = false
}
}
</script>
<style scoped>
.personal-page { max-width: 960px; }
.profile-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.right-cards { display: flex; flex-direction: column; gap: 20px; }
.profile-card {
background: var(--bg-surface); border-radius: var(--radius-lg);
border: 1px solid var(--border-color); box-shadow: var(--shadow-xs); overflow: hidden;
}
.card-header {
display: flex; align-items: center; gap: 8px;
padding: 16px 20px; border-bottom: 1px solid var(--border-light);
font-weight: 600; font-size: 14px; color: var(--text-primary);
}
.card-body { padding: 24px 20px; }
/* 头像 */
.avatar-section { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; padding-bottom: 20px; border-bottom: 1px solid var(--border-light); }
.avatar-wrapper { position: relative; cursor: pointer; flex-shrink: 0; }
.profile-avatar { box-shadow: 0 4px 12px rgba(99,102,241,0.2); }
.avatar-overlay {
position: absolute; inset: 0; border-radius: 50%;
background: rgba(0,0,0,0.35); display: flex; align-items: center; justify-content: center;
opacity: 0; transition: opacity var(--transition-fast); color: #fff;
}
.avatar-wrapper:hover .avatar-overlay { opacity: 1; }
.avatar-name { font-size: 18px; font-weight: 600; color: var(--text-primary); margin-bottom: 2px; }
.avatar-role { font-size: 13px; color: var(--text-muted); margin-bottom: 8px; }
/* 表单 */
.info-form :deep(.el-form-item) { margin-bottom: 18px; }
.info-form :deep(.el-form-item:last-child) { margin-bottom: 0; padding-top: 4px; }
/* 元信息 */
.meta-list { display: flex; flex-direction: column; }
.meta-row { display: flex; justify-content: space-between; align-items: center; padding: 14px 0; border-bottom: 1px solid var(--border-light); }
.meta-row:last-child { border-bottom: none; }
.meta-label { font-size: 13px; color: var(--text-muted); font-weight: 500; }
.meta-value { font-size: 13px; color: var(--text-secondary); font-weight: 600; }
.meta-value.mono { font-family: 'SF Mono', 'Consolas', monospace; font-size: 12px; }
@media (max-width: 768px) {
.profile-grid { grid-template-columns: 1fr; gap: 12px; }
.personal-page { padding: 0; }
.avatar-section { flex-direction: column; text-align: center; }
.avatar-info { align-items: center; }
.card-body { padding: 16px; }
}
</style>

View File

@@ -0,0 +1,216 @@
<template>
<div class="form-page">
<!-- 页面头部 -->
<el-page-header @back="handleBack" :title="isEdit ? '编辑字典' : '新增字典'" />
<el-divider />
<!-- 表单卡片 -->
<el-card class="form-card" shadow="never">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<!-- 基本信息 -->
<el-divider content-position="left">
<span class="form-section-title">基本信息</span>
</el-divider>
<el-form-item label="字典类型" prop="dictType">
<el-select
v-model="form.dictType"
placeholder="请选择或输入"
filterable
allow-create
:disabled="isEdit"
style="width: 100%"
>
<el-option
v-for="t in dictTypeList"
:key="t.dictType"
:label="t.dictType"
:value="t.dictType"
/>
</el-select>
</el-form-item>
<el-form-item label="标签" prop="dictLabel">
<el-input v-model="form.dictLabel" placeholder="请输入标签" />
</el-form-item>
<el-form-item label="值" prop="dictValue">
<el-input v-model="form.dictValue" placeholder="请输入值" />
</el-form-item>
<!-- 其他信息 -->
<el-divider content-position="left">
<span class="form-section-title">其他信息</span>
</el-divider>
<el-form-item label="排序" prop="sortOrder">
<el-input-number v-model="form.sortOrder" :min="0" :max="9999" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
</el-form-item>
<!-- 按钮区域 -->
<el-divider />
<div class="form-footer">
<el-button @click="handleBack">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
interface DictTypeItem {
dictType: string
}
const route = useRoute()
const router = useRouter()
const isEdit = ref(false)
const editId = ref<string>()
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
/** 字典类型列表 */
const dictTypeList = ref<DictTypeItem[]>([])
/** 表单数据 */
const form = reactive({
dictType: '',
dictLabel: '',
dictValue: '',
sortOrder: 0,
status: 1,
})
/** 校验规则 */
const rules: FormRules = {
dictType: [{ required: true, message: '请选择或输入字典类型', trigger: 'blur' }],
dictLabel: [{ required: true, message: '请输入标签', trigger: 'blur' }],
dictValue: [{ required: true, message: '请输入值', trigger: 'blur' }],
sortOrder: [{ required: true, message: '请输入排序', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}
/** 加载字典类型列表 */
async function fetchDictTypeList() {
try {
const res = await request.get('/zgapi/v1/admin/dict/type/list')
dictTypeList.value = res.data ?? []
} catch {
dictTypeList.value = []
}
}
/** 加载已有数据 */
async function loadData() {
const id = route.params.id as string
if (!id) {
isEdit.value = false
// 新增模式,可从 query 预填字典类型
const queryDictType = route.query.dictType
if (queryDictType) {
form.dictType = String(queryDictType)
}
return
}
isEdit.value = true
editId.value = id
try {
const res = await request.get(`/zgapi/v1/admin/dict/${editId.value}`)
const data = res.data
if (data) {
Object.assign(form, {
dictType: data.dictType ?? '',
dictLabel: data.dictLabel ?? '',
dictValue: data.dictValue ?? '',
sortOrder: data.sortOrder ?? 0,
status: data.status ?? 1,
})
setTimeout(() => formRef.value?.clearValidate(), 0)
}
} catch {
ElMessage.error('加载字典信息失败')
router.back()
}
}
function handleBack() {
router.back()
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
if (isEdit.value) {
await request.put(`/zgapi/v1/admin/dict/${editId.value}`, form)
ElMessage.success('编辑成功')
} else {
await request.post('/zgapi/v1/admin/dict', form)
ElMessage.success('新增成功')
}
router.back()
} catch {
// 错误由拦截器处理
} finally {
submitLoading.value = false
}
}
onMounted(async () => {
await fetchDictTypeList()
await loadData()
setTimeout(() => formRef.value?.clearValidate(), 0)
})
</script>
<style scoped>
.form-page {
display: flex;
flex-direction: column;
gap: 0;
}
:deep(.el-page-header) {
margin-bottom: 4px;
}
:deep(.el-page-header + .el-divider) {
margin: 0 0 20px 0;
}
.form-card {
max-width: 700px;
margin: 0 auto;
width: 100%;
}
.form-section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.form-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 768px) {
.form-card { max-width: 100%; }
.form-footer { flex-direction: column; }
.form-footer .el-button { width: 100%; }
:deep(.el-form-item__label) { width: 70px !important; }
}
</style>

View File

@@ -0,0 +1,284 @@
<template>
<div class="page-wrapper">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline>
<el-form-item label="字典类型">
<el-select v-model="query.dictType" placeholder="全部" clearable style="width: 200px" @change="handleSearch">
<el-option
v-for="t in dictTypeList"
:key="t.dictType"
:label="t.dictType"
:value="t.dictType"
/>
</el-select>
</el-form-item>
<el-form-item label="标签">
<el-input v-model="query.dictLabel" placeholder="请输入标签" clearable @clear="handleSearch" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增字典
</el-button>
<el-button @click="handleAddType">
<el-icon><FolderAdd /></el-icon>
新增字典类型
</el-button>
</div>
<!-- 表格 -->
<el-card shadow="never">
<el-table :data="tableData" border stripe v-loading="loading" style="width: 100%">
<el-table-column prop="dictType" label="字典类型" width="160" />
<el-table-column prop="dictLabel" label="标签" width="150" />
<el-table-column prop="dictValue" label="值" width="150" />
<el-table-column prop="sortOrder" label="排序" width="80" align="center" />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="220" align="center" class-name="ops-col">
<template #default="{ row }">
<div class="actions">
<el-button type="primary" size="small" plain @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" size="small" plain @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无数据,请选择字典类型或新增" :image-size="100" />
</template>
</el-table>
</el-card>
<!-- 新增字典类型弹窗 -->
<el-dialog
v-model="typeDialogVisible"
title="新增字典类型"
width="450px"
:close-on-click-modal="false"
>
<el-form ref="typeFormRef" :model="typeForm" :rules="typeRules" label-width="100px">
<el-form-item label="字典类型" prop="dictType">
<el-input v-model="typeForm.dictType" placeholder="请输入字典类型编码" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="typeDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="typeSubmitLoading" @click="submitAddType">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, FolderAdd } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
/** 字典项类型 */
interface DictItem {
id?: string
dictType: string
dictLabel: string
dictValue: string
sortOrder: number
status: number
}
interface DictTypeItem {
dictType: string
}
// --- 查询 ---
const query = reactive({
dictType: '',
dictLabel: '',
})
const tableData = ref<DictItem[]>([])
const dictTypeList = ref<DictTypeItem[]>([])
const loading = ref(false)
/** 获取字典类型列表 */
async function fetchDictTypeList() {
try {
const res = await request.get('/zgapi/v1/admin/dict/type/list')
dictTypeList.value = res.data ?? []
} catch {
dictTypeList.value = []
}
}
/** 根据字典类型获取数据 */
async function fetchData() {
if (!query.dictType) {
tableData.value = []
return
}
loading.value = true
try {
const res = await request.get(`/zgapi/v1/admin/dict/type/${query.dictType}`)
let dataList = res.data ?? []
// 如果有 dictLabel 筛选,前端过滤
if (query.dictLabel) {
dataList = dataList.filter((item: DictItem) =>
item.dictLabel.includes(query.dictLabel)
)
}
tableData.value = dataList
} catch {
tableData.value = []
} finally {
loading.value = false
}
}
function handleSearch() {
fetchData()
}
function handleReset() {
query.dictType = ''
query.dictLabel = ''
tableData.value = []
}
// --- 新增/编辑(路由跳转到独立表单页)---
const router = useRouter()
function handleAdd() {
let url = '/admin/dict/form'
if (query.dictType) {
url += '?dictType=' + query.dictType
}
router.push(url)
}
function handleEdit(row: DictItem) {
router.push('/admin/dict/form/' + row.id)
}
// --- 删除 ---
function handleDelete(row: DictItem) {
ElMessageBox.confirm(`确认删除字典"${row.dictLabel}"吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消',
}).then(async () => {
try {
await request.delete(`/zgapi/v1/admin/dict/${row.id}`)
ElMessage.success('删除成功')
fetchDictTypeList()
fetchData()
} catch {
// ignore
}
}).catch(() => {})
}
// --- 新增字典类型 ---
const typeDialogVisible = ref(false)
const typeSubmitLoading = ref(false)
const typeFormRef = ref<FormInstance>()
const typeForm = reactive({
dictType: '',
})
const typeRules: FormRules = {
dictType: [{ required: true, message: '请输入字典类型编码', trigger: 'blur' }],
}
function handleAddType() {
typeForm.dictType = ''
setTimeout(() => typeFormRef.value?.clearValidate(), 0)
typeDialogVisible.value = true
}
async function submitAddType() {
const valid = await typeFormRef.value?.validate().catch(() => false)
if (!valid) return
typeSubmitLoading.value = true
try {
await request.post('/zgapi/v1/admin/dict', {
dictType: typeForm.dictType,
dictLabel: '',
dictValue: '',
sortOrder: 0,
status: 1,
})
ElMessage.success('新增字典类型成功')
typeDialogVisible.value = false
fetchDictTypeList()
} catch {
// ignore
} finally {
typeSubmitLoading.value = false
}
}
// 初始化
fetchDictTypeList()
</script>
<style scoped>
.page-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
}
.card-header-title {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
color: #303133;
}
.toolbar {
display: flex;
justify-content: flex-start;
gap: 8px;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
@media (max-width: 768px) {
.page-wrapper { gap: 10px; }
.page-toolbar { flex-direction: column; gap: 10px; }
.page-toolbar .el-select,
.page-toolbar .el-input { width: 100% !important; }
}
</style>

View File

@@ -0,0 +1,357 @@
<template>
<div class="email-page">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline class="search-form">
<el-form-item label="关键词">
<el-input v-model="query.keyword" placeholder="收件人或主题" clearable @keyup.enter="handleSearch" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" placeholder="全部" clearable style="width:120px">
<el-option label="全部" value="" />
<el-option label="成功" :value="1" />
<el-option label="失败" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button-group>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>搜索
</el-button>
<el-button @click="handleReset">
<el-icon><RefreshLeft /></el-icon>重置
</el-button>
</el-button-group>
</el-form-item>
</el-form>
</el-card>
<!-- 操作栏 -->
<div class="page-toolbar">
<div class="toolbar-left">
<el-button type="primary" @click="openSendDialog" v-permission="'system:email:send'">
<el-icon><Promotion /></el-icon>发送邮件
</el-button>
</div>
<div class="toolbar-right">
<el-button @click="fetchData" :icon="RefreshRight" :loading="loading">刷新</el-button>
</div>
</div>
<!-- 发送记录表格 -->
<el-card shadow="never">
<el-table :data="list" v-loading="loading" stripe class="email-table">
<el-table-column prop="toAddress" label="收件人" min-width="180" show-overflow-tooltip />
<el-table-column prop="subject" label="主题" min-width="200" show-overflow-tooltip />
<el-table-column label="格式" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.contentType === 'html' ? '' : 'info'">
{{ row.contentType === 'html' ? 'HTML' : '文本' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.status === 1 ? 'success' : 'danger'" effect="dark">
{{ row.status === 1 ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="发送时间" width="170" />
<el-table-column label="操作" width="120" align="center" class-name="ops-col">
<template #default="{ row }">
<div class="actions">
<el-button size="small" text @click="showDetail(row)" class="btn-text">
<el-icon><View /></el-icon><span class="btn-label">详情</span>
</el-button>
<el-popconfirm title="确定删除?" @confirm="del(row.id)">
<template #reference>
<el-button size="small" text type="danger" class="btn-text">
<el-icon><Delete /></el-icon><span class="btn-label">删除</span>
</el-button>
</template>
</el-popconfirm>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无邮件发送记录" :image-size="100" />
</template>
</el-table>
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="prev, pager, next"
@current-change="fetchData"
small
/>
</div>
</el-card>
<!-- 发送弹窗 -->
<el-dialog v-model="sendVisible" title="发送邮件" width="620px" destroy-on-close class="send-dialog">
<el-form :model="sendForm" label-width="70px">
<el-form-item label="收件人" required>
<el-input v-model="sendForm.to" placeholder="user@example.com" />
</el-form-item>
<el-form-item label="主题" required>
<el-input v-model="sendForm.subject" placeholder="邮件主题" />
</el-form-item>
<el-form-item label="格式">
<el-radio-group v-model="sendForm.contentType">
<el-radio value="html">HTML</el-radio>
<el-radio value="text">纯文本</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="内容" required>
<el-input v-model="sendForm.content" type="textarea" rows="8" placeholder="邮件内容HTML格式支持标签" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="sendVisible = false">取消</el-button>
<el-button type="primary" :loading="sending" @click="doSend">发送</el-button>
</template>
</el-dialog>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="邮件详情" width="660px" destroy-on-close class="detail-dialog">
<template v-if="detail">
<div class="detail-meta">
<span>收件人{{ detail.toAddress }}</span>
<span>时间{{ detail.createdAt }}</span>
<el-tag size="small" :type="detail.status === 1 ? 'success' : 'danger'" effect="dark">
{{ detail.status === 1 ? '成功' : '失败' }}
</el-tag>
</div>
<div class="detail-subject">{{ detail.subject }}</div>
<div class="detail-body" v-if="detail.contentType === 'html'" v-html="detail.content" />
<pre class="detail-body" v-else>{{ detail.content }}</pre>
<div v-if="detail.errorMsg" class="detail-error">错误{{ detail.errorMsg }}</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, RefreshLeft, RefreshRight, Promotion, View, Delete } from '@element-plus/icons-vue'
import request from '@/utils/request'
interface EmailItem {
id: string; toAddress: string; subject: string
content: string; contentType: string; status: number
errorMsg: string; createdAt: string
}
const loading = ref(false)
const list = ref<EmailItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
// 搜索条件
const query = reactive({
keyword: '',
status: '' as number | '',
})
const sendVisible = ref(false)
const sending = ref(false)
const sendForm = reactive({ to: '', subject: '', content: '', contentType: 'html' })
const detailVisible = ref(false)
const detail = ref<EmailItem | null>(null)
async function fetchData() {
loading.value = true
try {
const res = await request.post('/zgapi/v1/admin/email/page', {
page: page.value,
pageSize: pageSize.value,
keyword: query.keyword || undefined,
status: query.status !== '' ? query.status : undefined,
})
list.value = res.data?.records || []
total.value = res.data?.total || 0
} catch { /* 拦截器已提示 */ } finally { loading.value = false }
}
function handleSearch() {
page.value = 1
fetchData()
}
function handleReset() {
query.keyword = ''
query.status = ''
page.value = 1
fetchData()
}
function openSendDialog() {
sendForm.to = ''; sendForm.subject = ''; sendForm.content = ''; sendForm.contentType = 'html'
sendVisible.value = true
}
async function doSend() {
if (!sendForm.to || !sendForm.subject || !sendForm.content) {
ElMessage.warning('请填写完整信息'); return
}
sending.value = true
try {
await request.post('/zgapi/v1/admin/email/send', { ...sendForm })
ElMessage.success('邮件已发送')
sendVisible.value = false
fetchData()
} catch { /* 拦截器已提示 */ } finally { sending.value = false }
}
async function showDetail(row: EmailItem) {
try {
const res = await request.get(`/zgapi/v1/admin/email/${row.id}`)
detail.value = res.data
detailVisible.value = true
} catch { /* ignore */ }
}
async function del(id: string) {
try {
await request.delete(`/zgapi/v1/admin/email/${id}`)
ElMessage.success('已删除')
if (list.value.length === 1 && page.value > 1) page.value--
fetchData()
} catch { /* ignore */ }
}
onMounted(() => fetchData())
</script>
<style scoped>
.email-page { max-width: 1100px; }
/* ====== 搜索卡片 ====== */
.search-card { margin-bottom: 16px; }
.card-header-title {
display: flex; align-items: center; gap: 6px;
font-size: 14px; font-weight: 600; color: var(--text-primary);
}
.search-form { margin-bottom: 0; }
.search-form .el-form-item { margin-bottom: 0; }
/* ====== 工具栏 ====== */
.page-toolbar {
display: flex; justify-content: space-between;
align-items: center; margin-bottom: 16px;
}
.toolbar-left { display: flex; gap: 8px; }
.toolbar-right { display: flex; gap: 8px; }
/* ====== 表格 ====== */
.email-table { width: 100%; }
.actions { display: flex; gap: 0; justify-content: center; }
.btn-text { padding: 5px 6px; }
/* ====== 分页 ====== */
.pagination-wrapper {
display: flex; justify-content: center; margin-top: 20px;
}
/* ====== 详情弹窗 ====== */
.detail-meta {
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
padding-bottom: 12px; border-bottom: 1px solid var(--border-color, #e4e7ed);
font-size: 13px; color: var(--text-muted, #909399);
}
.detail-subject {
font-size: 18px; font-weight: 600; color: var(--text-primary, #303133);
margin: 16px 0 12px; word-break: break-all;
}
.detail-body {
background: var(--bg-page, #f5f7fa); padding: 16px;
border-radius: 8px; font-size: 14px; line-height: 1.7;
max-height: 400px; overflow: auto; word-break: break-word;
}
.detail-body pre { white-space: pre-wrap; word-break: break-all; margin: 0; font-family: inherit; }
.detail-error {
margin-top: 12px; padding: 10px; color: #ef4444;
background: #fef2f2; border-radius: 6px; font-size: 13px; word-break: break-all;
}
/* ====== 移动端适配 ====== */
@media (max-width: 768px) {
/* 搜索表单 — 垂直排列 */
.search-form {
display: flex; flex-direction: column; gap: 8px;
}
.search-form .el-form-item {
width: 100%; margin-right: 0; display: block;
}
.search-form .el-input,
.search-form .el-select {
width: 100% !important;
}
.search-form .el-button-group {
width: 100%; display: flex;
}
.search-form .el-button-group .el-button {
flex: 1;
}
/* 工具栏 — 垂直排列 */
.page-toolbar {
flex-direction: column; gap: 10px; align-items: stretch;
}
.toolbar-left, .toolbar-right {
width: 100%;
}
.toolbar-left .el-button,
.toolbar-right .el-button {
width: 100%;
}
/* 表格 — 横向滚动 + 缩小列宽 */
.email-table {
font-size: 13px;
}
/* 操作列 — 手机端只显示图标 */
.btn-label { display: none; }
.btn-text { padding: 5px 4px; }
.actions { gap: 0; }
/* 弹窗 — 占满屏幕 */
:deep(.send-dialog), :deep(.detail-dialog) {
width: 95vw !important; max-width: 95vw;
}
:deep(.send-dialog .el-dialog__body),
:deep(.detail-dialog .el-dialog__body) {
padding: 16px;
}
/* 详情内容适配 */
.detail-subject { font-size: 16px; }
.detail-body {
max-height: 300px; font-size: 13px; padding: 12px;
}
/* 分页居中 */
.pagination-wrapper {
justify-content: center; flex-wrap: wrap;
}
}
@media (max-width: 480px) {
.email-table { font-size: 12px; }
.detail-meta { flex-direction: column; align-items: flex-start; gap: 6px; }
}
</style>

View File

@@ -0,0 +1,361 @@
<template>
<div class="file-page">
<!-- 顶部操作栏 -->
<div class="page-toolbar">
<div class="toolbar-left">
<el-select v-model="filterType" placeholder="文件类型" clearable style="width:140px" @change="fetchData">
<el-option label="头像" value="avatar" />
<el-option label="图标" value="icon" />
<el-option label="文档" value="doc" />
<el-option label="其他" value="other" />
</el-select>
</div>
<div class="toolbar-right">
<el-button type="primary" @click="uploadVisible = true" v-permission="'system:file:upload'">
<el-icon><Upload /></el-icon>上传文件
</el-button>
<el-button @click="fetchData" :icon="RefreshRight">刷新</el-button>
</div>
</div>
<!-- 上传弹窗 -->
<el-dialog v-model="uploadVisible" title="上传文件" width="460px" destroy-on-close>
<el-form :model="uploadForm" label-width="80px">
<el-form-item label="文件类型">
<el-select v-model="uploadForm.fileType" style="width:100%">
<el-option label="头像" value="avatar" />
<el-option label="图标" value="icon" />
<el-option label="文档" value="doc" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
<el-form-item label="选择文件">
<div class="upload-area" @click="triggerUpload" v-if="!uploadForm.file">
<el-icon :size="32" color="#94a3b8"><UploadFilled /></el-icon>
<p>点击选择文件</p>
</div>
<div class="upload-file" v-else>
<el-icon><Document /></el-icon>
<span>{{ uploadForm.file.name }}</span>
<span class="upload-size">{{ formatSize(uploadForm.file.size) }}</span>
<el-button size="small" text type="danger" @click="uploadForm.file = null">
<el-icon><Close /></el-icon>
</el-button>
</div>
<input ref="fileInputRef" type="file" style="display:none" @change="onFileSelected" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="uploadVisible = false">取消</el-button>
<el-button type="primary" :loading="uploading" :disabled="!uploadForm.file" @click="doUpload">
开始上传
</el-button>
</template>
</el-dialog>
<!-- 文件网格 -->
<div class="file-grid" v-loading="loading">
<div
v-for="item in fileList"
:key="item.id"
class="file-card"
@click="preview(item)"
>
<!-- 预览区域 -->
<div class="file-preview">
<template v-if="isImage(item.mimeType)">
<img
:src="getFileUrl(item.id)"
:alt="item.fileName"
class="file-thumb"
loading="lazy"
/>
</template>
<template v-else-if="item.fileType === 'doc'">
<el-icon :size="48" color="#6366f1"><Document /></el-icon>
</template>
<template v-else>
<el-icon :size="48" color="#94a3b8"><FolderOpened /></el-icon>
</template>
</div>
<!-- 信息 -->
<div class="file-info">
<div class="file-name" :title="item.fileName">{{ item.fileName }}</div>
<div class="file-meta">
<el-tag size="small" type="info">{{ item.fileType || 'other' }}</el-tag>
<span class="file-size">{{ formatSize(item.fileSize) }}</span>
</div>
</div>
<!-- 操作 -->
<div class="file-actions" @click.stop>
<el-button size="small" text @click="preview(item)">
<el-icon><View /></el-icon>
</el-button>
<el-popconfirm title="确定删除此文件?" @confirm="handleDelete(item.id)">
<template #reference>
<el-button size="small" text type="danger">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-popconfirm>
</div>
</div>
</div>
<el-empty v-if="!loading && fileList.length === 0" description="暂无文件" />
<!-- 分页 -->
<div class="page-footer" v-if="total > 0">
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="prev, pager, next"
@current-change="fetchData"
small
/>
</div>
<!-- 预览弹窗 -->
<el-dialog v-model="previewVisible" title="文件预览" width="720px" destroy-on-close>
<div class="preview-body">
<template v-if="previewItem && isImage(previewItem.mimeType)">
<img :src="getFileUrl(previewItem.id)" style="max-width:100%;max-height:60vh;display:block;margin:0 auto" />
</template>
<template v-else>
<el-empty description="非图片文件,无法预览" />
</template>
<div class="preview-meta" v-if="previewItem">
<div class="meta-line"><span class="meta-label">文件名</span>{{ previewItem.fileName }}</div>
<div class="meta-line"><span class="meta-label">类型</span>{{ previewItem.mimeType || '-' }}</div>
<div class="meta-line"><span class="meta-label">大小</span>{{ formatSize(previewItem.fileSize) }}</div>
<div class="meta-line"><span class="meta-label">上传时间</span>{{ previewItem.createdAt }}</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { RefreshRight, Document, FolderOpened, View, Delete, Upload, UploadFilled, Close } from '@element-plus/icons-vue'
import request from '@/utils/request'
interface FileItem {
id: string
fileName: string
fileType: string
fileSize: number
mimeType: string
md5: string
createdAt: string
}
const loading = ref(false)
const fileList = ref<FileItem[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filterType = ref('')
const previewVisible = ref(false)
const previewItem = ref<FileItem | null>(null)
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || ''
// ===== 上传 =====
const uploadVisible = ref(false)
const uploading = ref(false)
const fileInputRef = ref<HTMLInputElement>()
const uploadForm = reactive({ fileType: 'other', file: null as File | null })
function triggerUpload() {
fileInputRef.value?.click()
}
function onFileSelected(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) uploadForm.file = file
}
async function doUpload() {
if (!uploadForm.file) return
uploading.value = true
try {
const fd = new FormData()
fd.append('file', uploadForm.file)
fd.append('fileType', uploadForm.fileType)
await request.post('/zgapi/v1/admin/file/upload', fd)
ElMessage.success('上传成功')
uploadVisible.value = false
uploadForm.file = null
fetchData()
} catch { /* ignore */ } finally {
uploading.value = false
}
}
function getFileUrl(id: string) {
return `${gatewayUrl}/zgapi/v1/admin/file/${id}/view`
}
function isImage(mimeType: string | null | undefined) {
return mimeType ? mimeType.startsWith('image/') : false
}
function formatSize(bytes: number | null) {
if (!bytes) return '0 B'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1048576).toFixed(1) + ' MB'
}
async function fetchData() {
loading.value = true
try {
const res = await request.post('/zgapi/v1/admin/file/page', {
page: page.value,
pageSize: pageSize.value,
fileType: filterType.value || undefined,
})
fileList.value = res.data?.records || []
total.value = res.data?.total || 0
} catch { /* 拦截器已提示 */ } finally {
loading.value = false
}
}
function preview(item: FileItem) {
previewItem.value = item
previewVisible.value = true
}
async function handleDelete(id: string) {
try {
await request.delete(`/zgapi/v1/admin/file/${id}`)
ElMessage.success('已删除')
if (fileList.value.length === 1 && page.value > 1) page.value--
fetchData()
} catch { /* ignore */ }
}
onMounted(() => fetchData())
</script>
<style scoped>
.file-page { max-width: 1100px; }
/* 工具栏 */
.page-toolbar { display:flex; justify-content:space-between; align-items:center; margin-bottom:20px; }
.toolbar-left { display:flex; gap:12px; }
.page-footer { display:flex; justify-content:center; margin-top:24px; }
/* 文件网格 */
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.file-card {
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
overflow: hidden;
cursor: pointer;
transition: all var(--transition-fast);
}
.file-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-primary);
}
/* 预览区 */
.file-preview {
width: 100%;
height: 160px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-page);
overflow: hidden;
}
.file-thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
/* 信息区 */
.file-info {
padding: 12px;
}
.file-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 8px;
}
.file-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.file-size {
font-size: 12px;
color: var(--text-muted);
}
/* 操作 */
.file-actions {
display: flex;
justify-content: flex-end;
padding: 0 8px 8px;
gap: 4px;
}
/* 上传弹窗 */
.upload-area {
width: 100%; height: 120px;
border: 2px dashed var(--border-color);
border-radius: var(--radius-md);
display: flex; flex-direction: column;
align-items: center; justify-content: center;
cursor: pointer; transition: all var(--transition-fast);
color: var(--text-muted); font-size: 13px; gap: 8px;
}
.upload-area:hover { border-color: var(--color-primary); background: var(--color-primary-bg); }
.upload-file {
display: flex; align-items: center; gap: 10px;
padding: 12px; background: var(--bg-page); border-radius: var(--radius-md);
font-size: 13px;
}
.upload-size { color: var(--text-muted); font-size: 12px; margin-left: auto; }
/* 移动端 */
@media (max-width: 768px) {
.file-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; }
.file-preview { height: 120px; }
.file-name { font-size: 12px; }
.page-toolbar { flex-direction: column; gap: 10px; }
}
/* 预览弹窗 */
.preview-meta {
margin-top: 20px;
padding: 16px;
background: var(--bg-page);
border-radius: var(--radius-md);
}
.meta-line {
font-size: 13px;
color: var(--text-secondary);
line-height: 2;
}
.meta-label {
color: var(--text-muted);
}
</style>

View File

@@ -0,0 +1,185 @@
<template>
<div class="page-wrapper">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline>
<el-form-item label="用户名">
<el-input v-model="query.username" placeholder="请输入用户名" clearable />
</el-form-item>
<el-form-item label="登录状态">
<el-select v-model="query.status" placeholder="全部" clearable style="width: 120px">
<el-option label="全部" value="" />
<el-option label="成功" :value="1" />
<el-option label="失败" :value="0" />
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="query.dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 360px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 表格 -->
<el-card shadow="never">
<el-table :data="tableData" border stripe v-loading="loading" style="width: 100%">
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="loginIp" label="登录IP" width="150" />
<el-table-column prop="createdAt" label="登录时间" width="180">
<template #default="{ row }">
{{ row.createdAt ? new Date(row.createdAt).toLocaleString('zh-CN') : '-' }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">
{{ row.status === 1 ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="browser" label="浏览器" min-width="140" show-overflow-tooltip />
<el-table-column prop="os" label="操作系统" min-width="150" show-overflow-tooltip />
<el-table-column prop="failReason" label="失败原因" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
{{ row.failReason || '-' }}
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无登录日志" :image-size="100" />
</template>
</el-table>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="fetchData"
@size-change="fetchData"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { Search } from '@element-plus/icons-vue'
import request from '@/utils/request'
/** 登录日志类型 */
interface LoginLogItem {
username: string
loginIp: string
loginTime: string
status: number
browser: string
os: string
failReason: string
}
// --- 查询 ---
const query = reactive({
page: 1,
pageSize: 10,
username: '',
status: '' as string | number,
dateRange: [] as string[],
})
const tableData = ref<LoginLogItem[]>([])
const total = ref(0)
const loading = ref(false)
async function fetchData() {
loading.value = true
try {
const params: Record<string, unknown> = {
page: query.page,
pageSize: query.pageSize,
}
if (query.username) params.username = query.username
if (query.status !== '') params.status = query.status
if (query.dateRange && query.dateRange.length === 2) {
params.startTime = query.dateRange[0]
params.endTime = query.dateRange[1]
}
const res = await request.post('/zgapi/v1/admin/log/login/page', params)
tableData.value = res.data?.records ?? []
total.value = res.data?.total ?? 0
} catch {
// ignore
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchData()
}
function handleReset() {
query.username = ''
query.status = ''
query.dateRange = []
query.page = 1
fetchData()
}
// 初始化
fetchData()
</script>
<style scoped>
.page-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
}
.card-header-title {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
color: #303133;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
@media (max-width: 768px) {
.page-wrapper { gap: 10px; }
.page-toolbar { flex-direction: column; gap: 10px; }
.page-toolbar .el-select,
.page-toolbar .el-date-picker { width: 100% !important; }
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<div class="tokenlog-page">
<div class="page-toolbar">
<el-select v-model="filterConsumed" placeholder="消费状态" clearable style="width:140px" @change="fetchData">
<el-option label="未消费" :value="0" />
<el-option label="已消费" :value="1" />
</el-select>
<el-button @click="fetchData" :icon="RefreshRight">刷新</el-button>
</div>
<el-table :data="list" v-loading="loading" stripe>
<el-table-column prop="systemCode" label="系统编码" width="120" />
<el-table-column prop="tokenPreview" label="Token" min-width="220" show-overflow-tooltip />
<el-table-column label="消费状态" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.consumed === 1 ? 'success' : 'warning'">
{{ row.consumed === 1 ? '已消费' : '未消费' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="acquiredAt" label="获取时间" width="170" />
<el-table-column prop="consumedAt" label="消费时间" width="170">
<template #default="{ row }">{{ row.consumedAt || '-' }}</template>
</el-table-column>
<el-table-column prop="consumeApi" label="消费接口" width="220" show-overflow-tooltip>
<template #default="{ row }">{{ row.consumeApi || '-' }}</template>
</el-table-column>
<el-table-column prop="consumeIp" label="消费IP" width="140">
<template #default="{ row }">{{ row.consumeIp || '-' }}</template>
</el-table-column>
</el-table>
<div class="page-footer" v-if="total > 0">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total"
layout="prev,pager,next" @current-change="fetchData" small />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { RefreshRight } from '@element-plus/icons-vue'
import request from '@/utils/request'
interface TokenLog {
id: string; systemCode: string; tokenPreview: string
consumed: number; acquiredAt: string; consumedAt: string
consumeApi: string; consumeIp: string
}
const loading = ref(false)
const list = ref<TokenLog[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(15)
const filterConsumed = ref<number | null>(null)
async function fetchData() {
loading.value = true
try {
const res = await request.post('/zgapi/v1/admin/email/token-log/page', {
page: page.value, pageSize: pageSize.value,
consumed: filterConsumed.value !== null ? filterConsumed.value : undefined,
})
list.value = res.data?.records || []
total.value = res.data?.total || 0
} catch {} finally { loading.value = false }
}
onMounted(() => fetchData())
</script>
<style scoped>
.tokenlog-page { max-width: 1100px; }
.page-toolbar { display:flex; justify-content:space-between; align-items:center; margin-bottom:20px; }
.page-footer { display:flex; justify-content:center; margin-top:24px; }
@media (max-width: 768px) {
.page-toolbar { flex-direction: column; gap: 10px; }
.page-toolbar .el-select { width: 100% !important; }
}
</style>

View File

@@ -0,0 +1,314 @@
<template>
<div class="page-wrapper">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline>
<el-form-item label="用户名">
<el-input v-model="query.username" placeholder="请输入用户名" clearable />
</el-form-item>
<el-form-item label="操作模块">
<el-input v-model="query.module" placeholder="请输入操作模块" clearable />
</el-form-item>
<el-form-item label="操作类型">
<el-select v-model="query.operateType" placeholder="全部" clearable style="width: 140px">
<el-option label="全部" value="" />
<el-option label="新增" value="INSERT" />
<el-option label="修改" value="UPDATE" />
<el-option label="删除" value="DELETE" />
<el-option label="查询" value="SELECT" />
<el-option label="其他" value="OTHER" />
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="query.dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 360px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 表格 -->
<el-card shadow="never">
<el-table :data="tableData" border stripe v-loading="loading" style="width: 100%">
<el-table-column type="expand">
<template #default="{ row }">
<div class="expand-detail">
<div class="detail-section" v-if="row.requestBody">
<h4>请求体</h4>
<pre>{{ formatJson(row.requestBody) }}</pre>
</div>
<div class="detail-section" v-if="row.errorMsg">
<h4>错误信息</h4>
<pre class="error-msg">{{ row.errorMsg }}</pre>
</div>
<div v-if="!row.requestBody && !row.errorMsg" class="empty-detail">暂无详情</div>
</div>
</template>
</el-table-column>
<el-table-column prop="username" label="操作人" width="120" />
<el-table-column prop="module" label="模块" width="140" show-overflow-tooltip />
<el-table-column prop="operateType" label="操作类型" width="100" align="center">
<template #default="{ row }">
<el-tag
:type="operateTypeTag(row.operateType)"
size="small"
effect="dark"
>
{{ row.operateType }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="requestUrl" label="请求地址" min-width="200" show-overflow-tooltip />
<el-table-column prop="requestMethod" label="请求方式" width="100" align="center">
<template #default="{ row }">
<el-tag
:type="methodTag(row.requestMethod)"
size="small"
effect="dark"
>
{{ row.requestMethod }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="responseStatus" label="响应状态码" width="110" align="center">
<template #default="{ row }">
<el-tag
:type="row.responseStatus === 200 ? 'success' : 'danger'"
size="small"
effect="dark"
>
{{ row.responseStatus ?? '-' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="costTime" label="耗时(ms)" width="100" align="center">
<template #default="{ row }">
<el-tag
:type="row.costTime > 1000 ? 'warning' : row.costTime > 500 ? '' : 'success'"
size="small"
>
{{ row.costTime ?? '-' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="operateTime" label="操作时间" width="180">
<template #default="{ row }">
{{ row.operateTime ? new Date(row.operateTime).toLocaleString('zh-CN') : '-' }}
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无操作日志" :image-size="100" />
</template>
</el-table>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="fetchData"
@size-change="fetchData"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { Search } from '@element-plus/icons-vue'
import request from '@/utils/request'
/** 操作日志类型 */
interface OperationLogItem {
username: string
module: string
operateType: string
requestUrl: string
requestMethod: string
requestBody?: string
responseStatus: number
costTime: number
operateTime: string
errorMsg?: string
}
// --- 查询 ---
const query = reactive({
page: 1,
pageSize: 10,
username: '',
module: '',
operateType: '',
dateRange: [] as string[],
})
const tableData = ref<OperationLogItem[]>([])
const total = ref(0)
const loading = ref(false)
async function fetchData() {
loading.value = true
try {
const params: Record<string, unknown> = {
page: query.page,
pageSize: query.pageSize,
}
if (query.username) params.username = query.username
if (query.module) params.module = query.module
if (query.operateType) params.operateType = query.operateType
if (query.dateRange && query.dateRange.length === 2) {
params.startTime = query.dateRange[0]
params.endTime = query.dateRange[1]
}
const res = await request.post('/zgapi/v1/admin/log/operation/page', params)
tableData.value = res.data?.records ?? []
total.value = res.data?.total ?? 0
} catch {
// ignore
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchData()
}
function handleReset() {
query.username = ''
query.module = ''
query.operateType = ''
query.dateRange = []
query.page = 1
fetchData()
}
/** 操作类型 tag 颜色 */
function operateTypeTag(type: string) {
const map: Record<string, string> = {
INSERT: 'success',
UPDATE: 'warning',
DELETE: 'danger',
SELECT: '',
OTHER: 'info',
}
return map[type] || 'info'
}
/** 请求方式 tag 颜色 */
function methodTag(method: string) {
const map: Record<string, string> = {
GET: 'success',
POST: 'primary',
PUT: 'warning',
DELETE: 'danger',
PATCH: 'info',
}
return map[method] || 'info'
}
/** 格式化 JSON */
function formatJson(str: string) {
try {
return JSON.stringify(JSON.parse(str), null, 2)
} catch {
return str
}
}
// 初始化
fetchData()
</script>
<style scoped>
.page-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
}
.card-header-title {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
color: #303133;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.expand-detail {
padding: 16px 28px;
}
.detail-section {
margin-bottom: 14px;
}
.detail-section h4 {
margin-bottom: 8px;
font-size: 14px;
font-weight: 600;
color: #303133;
}
.detail-section pre {
background: #f5f7fa;
padding: 14px;
border-radius: 6px;
font-size: 13px;
line-height: 1.6;
max-height: 240px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
border: 1px solid #ebeef5;
}
.error-msg {
color: #f56c6c;
}
.empty-detail {
color: #c0c4cc;
text-align: center;
padding: 20px 0;
}
@media (max-width: 768px) {
.page-wrapper { gap: 10px; }
.page-toolbar { flex-direction: column; gap: 10px; }
.page-toolbar .el-select,
.page-toolbar .el-date-picker { width: 100% !important; }
.expand-detail { padding: 12px; }
}
</style>

View File

@@ -0,0 +1,325 @@
<template>
<div class="form-page">
<!-- 页面头部 -->
<el-page-header @back="handleBack" :title="isEdit ? '编辑菜单' : '新增菜单'" />
<el-divider />
<!-- 表单卡片 -->
<el-card class="form-card" shadow="never">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<!-- 基本信息 -->
<el-divider content-position="left">
<span class="form-section-title">基本信息</span>
</el-divider>
<!-- 所属系统新增时可切换编辑时展示不可修改 -->
<el-form-item label="所属系统" prop="systemId">
<el-select
v-model="form.systemId"
placeholder="请选择系统"
:disabled="isEdit"
style="width: 100%"
@change="onSystemChange"
>
<el-option
v-for="sys in systemList"
:key="sys.systemId"
:label="sys.systemName"
:value="sys.systemId"
/>
</el-select>
</el-form-item>
<el-form-item label="上级菜单">
<el-tree-select
v-model="form.parentId"
:data="parentTreeData"
:props="{ value: 'id', label: 'menuName', children: 'children' }"
node-key="id"
placeholder="请选择上级菜单(留空为顶级)"
clearable
check-strictly
style="width: 100%"
/>
</el-form-item>
<el-form-item label="菜单名称" prop="menuName">
<el-input v-model="form.menuName" placeholder="请输入菜单名称" />
</el-form-item>
<el-form-item label="菜单类型" prop="menuType">
<el-radio-group v-model="form.menuType">
<el-radio-button :value="1">目录</el-radio-button>
<el-radio-button :value="2">菜单</el-radio-button>
<el-radio-button :value="3">按钮</el-radio-button>
</el-radio-group>
</el-form-item>
<!-- 按钮类型只需权限标识 -->
<template v-if="form.menuType === 3">
<el-divider content-position="left">
<span class="form-section-title">按钮权限</span>
</el-divider>
<el-form-item label="权限标识" prop="permission">
<el-input v-model="form.permission" placeholder="如: system:user:add" />
</el-form-item>
</template>
<!-- 目录/菜单类型显示路由和图标 -->
<template v-else>
<el-divider content-position="left">
<span class="form-section-title">其他信息</span>
</el-divider>
<el-form-item label="路由路径" :prop="form.menuType !== 1 ? 'menuPath' : undefined">
<el-input v-model="form.menuPath" placeholder="请输入路由路径" />
</el-form-item>
<el-form-item label="组件路径" v-if="form.menuType === 2">
<el-input v-model="form.component" placeholder="请输入组件路径" />
</el-form-item>
<el-form-item label="图标">
<IconPicker v-model="form.icon" />
</el-form-item>
<el-form-item label="权限标识" v-if="form.menuType === 2">
<el-input v-model="form.permission" placeholder="可选,按钮权限标识" />
</el-form-item>
</template>
<el-form-item label="排序" prop="sortOrder">
<el-input-number v-model="form.sortOrder" :min="0" :max="9999" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
</el-form-item>
<!-- 按钮区域 -->
<el-divider />
<div class="form-footer">
<el-button @click="handleBack">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
import IconPicker from '@/components/IconPicker.vue'
interface SystemItem {
id: string
systemId: string
systemName: string
}
const iconList = [
'HomeFilled','Monitor','User','UserFilled','Document','Notebook','Tickets',
'Setting','Menu','List','Link','Collection','Avatar','DataLine','Connection',
'Switch','SwitchButton','ArrowRight','ArrowDown','Search','Plus','Edit',
'Delete','Check','Close','CircleClose','Remove','Expand','Fold',
'Key','Lock','InfoFilled','CircleCheckFilled',
]
interface MenuNode {
id: string
menuName: string
children?: MenuNode[]
}
const route = useRoute()
const router = useRouter()
const isEdit = ref(false)
const editId = ref<string>()
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
/** 系统列表 */
const systemList = ref<SystemItem[]>([])
/** 上级菜单树 */
const parentTreeData = ref<MenuNode[]>([])
/** 表单数据 */
const form = reactive({
systemId: undefined as string | undefined,
parentId: null as string | null,
menuName: '',
menuPath: '',
component: '',
icon: '',
menuType: 2,
permission: '',
sortOrder: 0,
status: 1,
})
/** 校验规则(按钮类型需要权限标识,目录/菜单需要路由路径) */
const rules = computed<FormRules>(() => ({
systemId: [{ required: true, message: '请选择所属系统', trigger: 'change' }],
menuName: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
permission: form.menuType === 3
? [{ required: true, message: '请输入权限标识', trigger: 'blur' }]
: [],
menuPath: form.menuType === 2
? [{ required: true, message: '请输入路由路径', trigger: 'blur' }]
: [],
menuType: [{ required: true, message: '请选择菜单类型', trigger: 'change' }],
sortOrder: [{ required: true, message: '请输入排序', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}))
/** 加载系统列表 */
async function fetchSystemList() {
try {
const res = await request.get('/zgapi/v1/auth/system/list')
systemList.value = res.data ?? []
} catch {
systemList.value = []
}
}
/** 加载菜单树(用于上级菜单选择) */
async function loadMenuTree(systemId: string) {
if (!systemId) {
parentTreeData.value = []
return
}
try {
const res = await request.get('/zgapi/v1/admin/menu/tree', { params: { systemId } })
parentTreeData.value = res.data ?? []
} catch {
parentTreeData.value = []
}
}
/** 切换系统时重新加载菜单树 */
function onSystemChange(systemId: string | undefined) {
form.parentId = null
if (systemId) {
loadMenuTree(systemId)
} else {
parentTreeData.value = []
}
}
/** 加载已有数据(编辑模式) */
async function loadData() {
const id = route.params.id as string
if (!id) {
// 新增模式,从 query 读取预设参数
isEdit.value = false
const querySystemId = route.query.systemId as string | undefined
const queryParentId = route.query.parentId as string | undefined
if (querySystemId) {
form.systemId = querySystemId
loadMenuTree(form.systemId)
}
if (queryParentId) {
form.parentId = queryParentId
}
return
}
isEdit.value = true
editId.value = id
try {
const res = await request.get(`/zgapi/v1/admin/menu/${editId.value}`)
const data = res.data
if (data) {
form.systemId = data.systemId
// 先加载菜单树,再赋值其他字段(确保 tree-select 数据就绪)
if (data.systemId) {
await loadMenuTree(data.systemId)
}
Object.assign(form, {
parentId: data.parentId ?? null,
menuName: data.menuName ?? '',
menuPath: data.menuPath ?? '',
component: data.component ?? '',
icon: data.icon ?? '',
menuType: data.menuType ?? 2,
permission: data.permission ?? '',
sortOrder: data.sortOrder ?? 0,
status: data.status ?? 1,
})
setTimeout(() => formRef.value?.clearValidate(), 0)
}
} catch {
ElMessage.error('加载菜单信息失败')
router.back()
}
}
function handleBack() {
router.back()
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
if (isEdit.value) {
await request.put(`/zgapi/v1/admin/menu/${editId.value}`, form)
ElMessage.success('编辑成功')
} else {
await request.post('/zgapi/v1/admin/menu', form)
ElMessage.success('新增成功')
}
router.back()
} catch {
// 错误由拦截器处理
} finally {
submitLoading.value = false
}
}
onMounted(async () => {
await fetchSystemList()
await loadData()
setTimeout(() => formRef.value?.clearValidate(), 0)
})
</script>
<style scoped>
.form-page {
display: flex;
flex-direction: column;
gap: 0;
}
:deep(.el-page-header) {
margin-bottom: 4px;
}
:deep(.el-page-header + .el-divider) {
margin: 0 0 20px 0;
}
.form-card {
max-width: 700px;
margin: 0 auto;
width: 100%;
}
.form-section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.form-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 768px) {
.form-card { max-width: 100%; }
.form-footer { flex-direction: column; }
.form-footer .el-button { width: 100%; }
:deep(.el-form-item__label) { width: 70px !important; }
}
</style>

View File

@@ -0,0 +1,246 @@
<template>
<div class="page-wrapper">
<el-card shadow="never">
<template #header>
<div class="card-header-title">
<el-icon><Menu /></el-icon>
<span>菜单管理</span>
</div>
</template>
<div class="menu-toolbar">
<span class="toolbar-label">选择系统</span>
<el-select v-model="currentSystemId" placeholder="请选择系统" style="width: 240px" @change="fetchMenuTree">
<el-option v-for="sys in systemList" :key="sys.systemId" :label="sys.systemName" :value="sys.systemId" />
</el-select>
<el-button type="primary" @click="openForm(null)" :disabled="!currentSystemId">
<el-icon><Plus /></el-icon>新增菜单
</el-button>
<el-button @click="toggleExpand">
<el-icon><Expand /></el-icon>
{{ isExpandAll ? '折叠全部' : '展开全部' }}
</el-button>
</div>
</el-card>
<el-card shadow="never">
<el-table ref="tableRef" :data="menuTreeData" border row-key="id"
:tree-props="{ children: 'children' }" :default-expand-all="true" v-loading="loading" style="width:100%">
<el-table-column prop="menuName" label="菜单名称" width="200" />
<el-table-column prop="icon" label="图标" width="80" align="center">
<template #default="{ row }">
<img v-if="row.icon && (row.icon.startsWith('http') || row.icon.startsWith('/zgapi'))" :src="row.icon" style="width:18px;height:18px;object-fit:contain" />
<el-icon v-else-if="row.icon" :size="18"><component :is="getIconComponent(row.icon)" /></el-icon>
<span v-else style="color:var(--text-muted)">-</span>
</template>
</el-table-column>
<el-table-column prop="path" label="路由路径" width="180" show-overflow-tooltip />
<el-table-column prop="component" label="组件" width="180" show-overflow-tooltip />
<el-table-column prop="menuType" label="类型" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.menuType === 1 ? '' : row.menuType === 2 ? 'success' : 'warning'" effect="dark" size="small">
{{ menuTypeMap[row.menuType] ?? '未知' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="permission" label="权限标识" width="160" show-overflow-tooltip />
<el-table-column prop="sortOrder" label="排序" width="80" align="center" />
<el-table-column prop="status" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="280" align="center" class-name="ops-col">
<template #default="{ row }">
<div class="actions">
<el-button type="primary" size="small" plain @click="openForm(row)">新增子级</el-button>
<el-button type="primary" size="small" plain @click="openForm(null, row)">编辑</el-button>
<el-button type="danger" size="small" plain @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 表单弹窗 -->
<el-dialog v-model="dlg.visible" :title="dlg.title" width="600px" :close-on-click-modal="false" @closed="resetForm">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="上级菜单" v-if="!dlg.isEdit">
<el-tree-select v-model="form.parentId" :data="parentTree" :props="{ value:'id', label:'menuName', children:'children' }"
node-key="id" placeholder="留空为顶级菜单" clearable check-strictly style="width:100%" />
</el-form-item>
<el-form-item label="菜单名称" prop="menuName">
<el-input v-model="form.menuName" placeholder="请输入菜单名称" />
</el-form-item>
<el-form-item label="菜单类型" prop="menuType">
<el-radio-group v-model="form.menuType">
<el-radio-button :value="1">目录</el-radio-button>
<el-radio-button :value="2">菜单</el-radio-button>
<el-radio-button :value="3">按钮</el-radio-button>
</el-radio-group>
</el-form-item>
<template v-if="form.menuType === 3">
<el-form-item label="权限标识" prop="permission">
<el-input v-model="form.permission" placeholder="如: system:user:add" />
</el-form-item>
</template>
<template v-else>
<el-form-item label="路由路径" :prop="form.menuType === 2 ? 'menuPath' : undefined">
<el-input v-model="form.menuPath" placeholder="请输入路由路径" />
</el-form-item>
<el-form-item label="组件路径" v-if="form.menuType === 2">
<el-input v-model="form.component" placeholder="请输入组件路径" />
</el-form-item>
<el-form-item label="图标">
<IconPicker v-model="form.icon" />
</el-form-item>
</template>
<el-form-item label="排序" prop="sortOrder">
<el-input-number v-model="form.sortOrder" :min="0" :max="9999" />
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dlg.visible = false">取消</el-button>
<el-button type="primary" :loading="dlg.loading" @click="submitForm">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Expand, Menu } from '@element-plus/icons-vue'
import { getIconComponent } from '@/utils/icons'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
import IconPicker from '@/components/IconPicker.vue'
const menuTypeMap: Record<number, string> = { 1: '目录', 2: '菜单', 3: '按钮' }
interface MenuItem {
id?: string; parentId: string | null; menuName: string; menuPath: string; path?: string
component: string; icon: string; menuType: number; permission: string
sortOrder: number; status: number; children?: MenuItem[]
}
// 系统列表
const systemList = ref<any[]>([])
const currentSystemId = ref<string>()
const menuTreeData = ref<MenuItem[]>([])
const loading = ref(false)
const isExpandAll = ref(true)
const tableRef = ref()
const parentTree = ref<any[]>([])
onMounted(async () => {
try {
const res = await request.get('/zgapi/v1/auth/system/list')
systemList.value = res.data ?? []
// 默认选 gateway 系统
const gw = systemList.value.find((s: any) => s.systemCode === 'gateway')
if (gw) { currentSystemId.value = gw.systemId; await fetchMenuTree() }
} catch { /* ignore */ }
})
async function fetchMenuTree() {
if (!currentSystemId.value) { menuTreeData.value = []; return }
loading.value = true
try {
const res = await request.get('/zgapi/v1/admin/menu/tree', { params: { systemId: currentSystemId.value } })
menuTreeData.value = res.data ?? []
parentTree.value = res.data ?? []
} catch { menuTreeData.value = [] } finally { loading.value = false }
}
function toggleExpand() {
isExpandAll.value = !isExpandAll.value
const t = tableRef.value; if (!t) return
const toggle = (rows: any[]) => rows.forEach(r => { t.toggleRowExpansion(r, isExpandAll.value); if (r.children?.length) toggle(r.children) })
toggle(menuTreeData.value)
}
// 表单弹窗
const dlg = reactive({ visible: false, title: '', isEdit: false, loading: false })
const formRef = ref<FormInstance>()
const editId = ref<string>()
const form = reactive({ parentId: null as string | null, menuName: '', menuPath: '', component: '', icon: '', menuType: 2, permission: '', sortOrder: 0, status: 1 })
const rules = computed<FormRules>(() => ({
menuName: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
permission: form.menuType === 3 ? [{ required: true, message: '请输入权限标识', trigger: 'blur' }] : [],
menuPath: form.menuType === 2 ? [{ required: true, message: '请输入路由路径', trigger: 'blur' }] : [],
}))
function resetForm() {
Object.assign(form, { parentId: null, menuName: '', menuPath: '', component: '', icon: '', menuType: 2, permission: '', sortOrder: 0, status: 1 })
editId.value = undefined
// 清除校验状态,避免弹窗立即显示错误
setTimeout(() => formRef.value?.clearValidate(), 0)
}
function openForm(parent: MenuItem | null, edit?: MenuItem) {
resetForm()
dlg.visible = true
if (edit) {
// 编辑模式
dlg.title = '编辑菜单'
dlg.isEdit = true
editId.value = edit.id
Object.assign(form, {
menuName: edit.menuName, menuType: edit.menuType, menuPath: edit.path || '',
component: edit.component || '', icon: edit.icon || '',
permission: edit.permission || '', sortOrder: edit.sortOrder, status: edit.status,
})
} else {
dlg.title = parent ? '新增子菜单' : '新增菜单'
dlg.isEdit = false
if (parent) form.parentId = parent.id || null
}
}
async function submitForm() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
dlg.loading = true
try {
const payload: any = { ...form, systemId: currentSystemId.value }
if (editId.value) {
await request.put('/zgapi/v1/admin/menu', { ...payload, id: editId.value })
ElMessage.success('编辑成功')
} else {
await request.post('/zgapi/v1/admin/menu', payload)
ElMessage.success('新增成功')
}
dlg.visible = false
fetchMenuTree()
} catch { /* ignore */ } finally { dlg.loading = false }
}
function handleDelete(row: MenuItem) {
ElMessageBox.confirm(`确认删除菜单"${row.menuName}"吗?`, '删除确认', { type: 'warning' }).then(async () => {
try { await request.delete(`/zgapi/v1/admin/menu/${row.id}`); ElMessage.success('删除成功'); fetchMenuTree() } catch { /* ignore */ }
}).catch(() => {})
}
</script>
<style scoped>
.page-wrapper { display:flex; flex-direction:column; gap:16px; }
.card-header-title { display:flex; align-items:center; gap:8px; font-weight:600; font-size:14px; color:var(--text-primary); }
.menu-toolbar { display:flex; align-items:center; gap:12px; flex-wrap:wrap; }
.toolbar-label { font-size:14px; font-weight:500; color:var(--text-secondary); }
.actions { display:flex; gap:8px; flex-wrap:wrap; justify-content:center; }
@media (max-width: 768px) {
.page-wrapper { gap: 10px; }
.page-toolbar { flex-direction: column; gap: 10px; }
.page-toolbar .el-select { width: 100% !important; }
.menu-toolbar { flex-direction: column; align-items: stretch; }
}
</style>

View File

@@ -0,0 +1,205 @@
<template>
<div class="form-page">
<!-- 页面头部 -->
<el-page-header @back="handleBack" :title="isEdit ? '编辑角色' : '新增角色'" />
<el-divider />
<!-- 表单卡片 -->
<el-card class="form-card" shadow="never">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<!-- 基本信息 -->
<el-divider content-position="left">
<span class="form-section-title">基本信息</span>
</el-divider>
<el-form-item label="角色名称" prop="roleName">
<el-input v-model="form.roleName" placeholder="请输入角色名称" />
</el-form-item>
<el-form-item label="角色编码" prop="roleCode">
<el-input v-model="form.roleCode" placeholder="请输入角色编码" :disabled="isEdit" />
</el-form-item>
<el-form-item label="所属系统" prop="systemId">
<el-select v-model="form.systemId" placeholder="请选择系统" style="width: 100%">
<el-option
v-for="sys in systemList"
:key="sys.systemId"
:label="sys.systemName"
:value="sys.systemId"
/>
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
</el-form-item>
<!-- 其他信息 -->
<el-divider content-position="left">
<span class="form-section-title">其他信息</span>
</el-divider>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入描述" />
</el-form-item>
<!-- 按钮区域 -->
<el-divider />
<div class="form-footer">
<el-button @click="handleBack">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
interface SystemItem {
id: string
systemId: string
systemName: string
}
const route = useRoute()
const router = useRouter()
const isEdit = ref(false)
const editId = ref<string>()
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
/** 系统列表(下拉用) */
const systemList = ref<SystemItem[]>([])
/** 表单数据 */
const form = reactive({
roleName: '',
roleCode: '',
systemId: undefined as string | undefined,
description: '',
status: 1,
})
/** 校验规则 */
const rules: FormRules = {
roleName: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
roleCode: [{ required: true, message: '请输入角色编码', trigger: 'blur' }],
systemId: [{ required: true, message: '请选择所属系统', trigger: 'change' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}
/** 加载系统列表 */
async function fetchSystemList() {
try {
const res = await request.get('/zgapi/v1/auth/system/list')
systemList.value = res.data ?? []
} catch {
systemList.value = []
}
}
/** 加载已有数据(编辑模式) */
async function loadData() {
const id = route.params.id as string
if (!id) {
isEdit.value = false
return
}
isEdit.value = true
editId.value = id
try {
const res = await request.get(`/zgapi/v1/admin/role/${editId.value}`)
const data = res.data
if (data) {
Object.assign(form, {
roleName: data.roleName ?? '',
roleCode: data.roleCode ?? '',
systemId: data.systemId,
description: data.description ?? '',
status: data.status ?? 1,
})
setTimeout(() => formRef.value?.clearValidate(), 0)
}
} catch {
ElMessage.error('加载角色信息失败')
router.back()
}
}
function handleBack() {
router.back()
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
if (isEdit.value) {
await request.put(`/zgapi/v1/admin/role/${editId.value}`, form)
ElMessage.success('编辑成功')
} else {
await request.post('/zgapi/v1/admin/role', form)
ElMessage.success('新增成功')
}
router.back()
} catch {
// 错误由拦截器处理
} finally {
submitLoading.value = false
}
}
onMounted(async () => {
await fetchSystemList()
await loadData()
setTimeout(() => formRef.value?.clearValidate(), 0)
})
</script>
<style scoped>
.form-page {
display: flex;
flex-direction: column;
gap: 0;
}
:deep(.el-page-header) {
margin-bottom: 4px;
}
:deep(.el-page-header + .el-divider) {
margin: 0 0 20px 0;
}
.form-card {
max-width: 700px;
margin: 0 auto;
width: 100%;
}
.form-section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.form-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 768px) {
.form-card { max-width: 100%; }
.form-footer { flex-direction: column; }
.form-footer .el-button { width: 100%; }
:deep(.el-form-item__label) { width: 70px !important; }
}
</style>

View File

@@ -0,0 +1,315 @@
<template>
<div class="page-wrapper">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline>
<el-form-item label="角色名称">
<el-input v-model="query.roleName" placeholder="请输入角色名称" clearable />
</el-form-item>
<el-form-item label="所属系统">
<el-select v-model="query.systemId" placeholder="全部" clearable style="width: 180px">
<el-option
v-for="sys in systemList"
:key="sys.systemId"
:label="sys.systemName"
:value="sys.systemId"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增角色
</el-button>
</div>
<!-- 表格 -->
<el-card shadow="never">
<el-table :data="tableData" border stripe v-loading="loading" style="width: 100%">
<el-table-column prop="roleName" label="角色名称" width="140" />
<el-table-column prop="roleCode" label="角色编码" width="140" />
<el-table-column prop="systemName" label="所属系统" width="150" />
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="310" align="center" class-name="ops-col">
<template #default="{ row }">
<div class="actions">
<el-button type="primary" size="small" plain @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" size="small" plain @click="handleDelete(row)">删除</el-button>
<el-button type="success" size="small" plain @click="handleAssignMenu(row)">分配菜单</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无角色数据" :image-size="100" />
</template>
</el-table>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="fetchData"
@size-change="fetchData"
/>
</div>
</el-card>
<!-- 分配菜单弹窗 -->
<el-dialog
v-model="menuVisible"
title="分配菜单"
width="500px"
:close-on-click-modal="false"
>
<el-tree
ref="menuTreeRef"
:data="menuTreeData"
node-key="id"
show-checkbox
default-expand-all
:props="{ label: 'menuName', children: 'children' }"
style="max-height: 400px; overflow-y: auto"
/>
<template #footer>
<el-button @click="menuVisible = false">取消</el-button>
<el-button type="primary" :loading="menuSubmitLoading" @click="submitAssignMenu">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus } from '@element-plus/icons-vue'
import type { ElTree } from 'element-plus'
import request from '@/utils/request'
/** 角色类型 */
interface RoleItem {
id?: string
roleName: string
roleCode: string
systemId: string | undefined
systemName?: string
description: string
status: number
}
interface SystemItem {
id: string
systemId: string
systemName: string
}
interface MenuNode {
id: string
menuName: string
children?: MenuNode[]
}
// --- 查询 ---
const query = reactive({
page: 1,
pageSize: 10,
roleName: '',
systemId: undefined as string | undefined,
})
const tableData = ref<RoleItem[]>([])
const total = ref(0)
const loading = ref(false)
const systemList = ref<SystemItem[]>([])
async function fetchSystemList() {
try {
const res = await request.get('/zgapi/v1/auth/system/list')
systemList.value = res.data ?? []
} catch {
// ignore
}
}
async function fetchData() {
loading.value = true
try {
const params: Record<string, unknown> = {
page: query.page,
pageSize: query.pageSize,
}
if (query.roleName) params.roleName = query.roleName
if (query.systemId != null) params.systemId = query.systemId
const res = await request.post('/zgapi/v1/admin/role/page', params)
tableData.value = res.data?.records ?? []
total.value = res.data?.total ?? 0
} catch {
// ignore
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchData()
}
function handleReset() {
query.roleName = ''
query.systemId = undefined
query.page = 1
fetchData()
}
// --- 新增/编辑(路由跳转到独立表单页)---
const router = useRouter()
function handleAdd() {
router.push('/admin/role/form')
}
function handleEdit(row: RoleItem) {
router.push('/admin/role/form/' + row.id)
}
function handleDelete(row: RoleItem) {
ElMessageBox.confirm(`确认删除角色"${row.roleName}"吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消',
}).then(async () => {
try {
await request.delete(`/zgapi/v1/admin/role/${row.id}`)
ElMessage.success('删除成功')
fetchData()
} catch {
// ignore
}
}).catch(() => {})
}
// --- 分配菜单 ---
const menuVisible = ref(false)
const menuTreeData = ref<MenuNode[]>([])
const menuSubmitLoading = ref(false)
const menuRoleId = ref<string>()
const menuTreeRef = ref<InstanceType<typeof ElTree>>()
async function handleAssignMenu(row: RoleItem) {
menuRoleId.value = row.id
const sysId = row.systemId
if (!sysId) {
ElMessage.warning('该角色未关联系统,无法分配菜单')
return
}
menuVisible.value = true
menuTreeData.value = []
// 并行加载菜单树 + 已分配的菜单ID
try {
const [treeRes, checkedRes] = await Promise.all([
request.get('/zgapi/v1/admin/menu/tree', { params: { systemId: sysId } }),
request.get(`/zgapi/v1/admin/role/${row.id}/menu`),
])
menuTreeData.value = treeRes.data ?? []
// 预选已分配的菜单
const checkedIds: string[] = checkedRes.data ?? []
// 等 DOM 更新后设置勾选
await new Promise(r => setTimeout(r, 50))
menuTreeRef.value?.setCheckedKeys(checkedIds)
} catch {
ElMessage.error('加载菜单树失败')
}
}
async function submitAssignMenu() {
const checkedKeys = menuTreeRef.value?.getCheckedKeys() as string[]
const halfCheckedKeys = menuTreeRef.value?.getHalfCheckedKeys() as string[]
const menuIds = [...checkedKeys, ...halfCheckedKeys]
menuSubmitLoading.value = true
try {
await request.post(`/zgapi/v1/admin/role/${menuRoleId.value}/menu`, menuIds)
ElMessage.success('分配菜单成功')
menuVisible.value = false
} catch {
// ignore
} finally {
menuSubmitLoading.value = false
}
}
// 初始化
fetchData()
fetchSystemList()
</script>
<style scoped>
.page-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
}
.card-header-title {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
color: #303133;
}
.toolbar {
display: flex;
justify-content: flex-start;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
@media (max-width: 768px) {
.page-wrapper { gap: 10px; }
.page-toolbar { flex-direction: column; gap: 10px; }
.page-toolbar .el-select { width: 100% !important; }
}
</style>

View File

@@ -0,0 +1,206 @@
<template>
<div class="form-page">
<!-- 页面头部返回按钮 + 标题 + 分隔线 -->
<el-page-header @back="handleBack" :title="isEdit ? '编辑接口' : '新增接口'" />
<el-divider />
<!-- 表单卡片 -->
<el-card class="form-card" shadow="never">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="所属系统" prop="systemId">
<el-select v-model="form.systemId" placeholder="请选择系统" style="width: 100%">
<el-option
v-for="s in systemOptions"
:key="s.id"
:label="s.systemName"
:value="s.id"
/>
</el-select>
</el-form-item>
<el-form-item label="接口路径" prop="apiPath">
<el-input v-model="form.apiPath" placeholder="请输入接口路径,如 /zgapi/v1/admin/user" />
</el-form-item>
<el-form-item label="请求方式" prop="apiMethod">
<el-select v-model="form.apiMethod" placeholder="请选择请求方式" style="width: 100%">
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
<el-option label="DELETE" value="DELETE" />
</el-select>
</el-form-item>
<el-form-item label="接口名称" prop="apiName">
<el-input v-model="form.apiName" placeholder="请输入接口名称" />
</el-form-item>
<el-form-item label="接口描述" prop="apiDesc">
<el-input v-model="form.apiDesc" type="textarea" :rows="2" placeholder="请输入接口描述(可选)" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
</el-form-item>
<!-- 按钮区域固定在底部 -->
<el-divider />
<div class="form-footer">
<el-button @click="handleBack">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
/** 系统选项 */
interface SystemOption {
id: string
systemName: string
}
const route = useRoute()
const router = useRouter()
/** 是否为编辑模式(有 id 参数) */
const isEdit = ref(false)
const editId = ref<string>()
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
const systemOptions = ref<SystemOption[]>([])
/** 表单数据 */
const form = reactive({
systemId: null as string | null,
apiPath: '',
apiMethod: '',
apiName: '',
apiDesc: '',
status: 1,
})
/** 表单校验规则 */
const rules: FormRules = {
systemId: [{ required: true, message: '请选择所属系统', trigger: 'change' }],
apiPath: [{ required: true, message: '请输入接口路径', trigger: 'blur' }],
apiMethod: [{ required: true, message: '请选择请求方式', trigger: 'change' }],
apiName: [{ required: true, message: '请输入接口名称', trigger: 'blur' }],
}
/** 加载系统列表 */
async function loadSystems() {
try {
const res = await request.post('/zgapi/v1/admin/system/page', { page: 1, pageSize: 100, status: 1 })
systemOptions.value = res.data?.records ?? []
} catch {
// 错误由拦截器处理
}
}
/** 加载已有数据(编辑模式) */
async function loadData() {
const id = route.params.id as string
if (!id) {
isEdit.value = false
// 新增模式:从 query 参数取 systemId
const sysId = route.query.systemId as string | undefined
if (sysId) {
form.systemId = sysId
}
return
}
isEdit.value = true
editId.value = id
try {
const res = await request.get(`/zgapi/v1/admin/system/api/${editId.value}`)
const data = res.data
if (data) {
Object.assign(form, {
systemId: data.systemId ?? null,
apiPath: data.apiPath ?? '',
apiMethod: data.apiMethod ?? '',
apiName: data.apiName ?? '',
status: data.status ?? 1,
})
setTimeout(() => formRef.value?.clearValidate(), 0)
}
} catch {
ElMessage.error('加载接口信息失败')
router.back()
}
}
/** 返回上一页 */
function handleBack() {
router.back()
}
/** 提交表单 */
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
if (isEdit.value) {
await request.put(`/zgapi/v1/admin/system/api/${editId.value}`, form)
ElMessage.success('编辑成功')
} else {
await request.post('/zgapi/v1/admin/system/api', form)
ElMessage.success('新增成功')
}
router.back()
} catch {
// 错误由拦截器处理
} finally {
submitLoading.value = false
}
}
onMounted(async () => {
await loadSystems()
await loadData()
setTimeout(() => formRef.value?.clearValidate(), 0)
})
</script>
<style scoped>
.form-page {
display: flex;
flex-direction: column;
gap: 0;
}
/* 页头分隔线 */
:deep(.el-page-header) {
margin-bottom: 4px;
}
:deep(.el-page-header + .el-divider) {
margin: 0 0 20px 0;
}
.form-card {
max-width: 700px;
margin: 0 auto;
width: 100%;
}
/* 按钮区域 */
.form-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 768px) {
.form-card { max-width: 100%; }
.form-footer { flex-direction: column; }
.form-footer .el-button { width: 100%; }
:deep(.el-form-item__label) { width: 70px !important; }
}
</style>

View File

@@ -0,0 +1,280 @@
<template>
<div class="page-wrapper">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline>
<el-form-item label="所属系统">
<el-select v-model="query.systemId" placeholder="请选择系统" clearable style="width: 200px" @change="handleSearch">
<el-option
v-for="s in systemOptions"
:key="s.id"
:label="s.systemName"
:value="s.id"
/>
</el-select>
</el-form-item>
<el-form-item label="接口路径">
<el-input v-model="query.apiPath" placeholder="请输入接口路径" clearable />
</el-form-item>
<el-form-item label="请求方式">
<el-select v-model="query.apiMethod" placeholder="全部" clearable style="width: 120px">
<el-option label="全部" value="" />
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
<el-option label="DELETE" value="DELETE" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增接口
</el-button>
</div>
<!-- 表格 -->
<el-card shadow="never">
<el-table :data="tableData" border stripe v-loading="loading" style="width: 100%">
<el-table-column prop="apiPath" label="接口路径" min-width="200" show-overflow-tooltip />
<el-table-column prop="apiMethod" label="请求方式" width="100" align="center">
<template #default="{ row }">
<el-tag :type="methodTagType(row.apiMethod)" effect="dark" size="small">
{{ row.apiMethod }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="apiName" label="接口名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="apiDesc" label="接口描述" min-width="180" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="220" align="center" class-name="ops-col">
<template #default="{ row }">
<div class="actions">
<el-button type="primary" size="small" plain @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" size="small" plain @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
<!-- 空状态 -->
<template #empty>
<el-empty description="暂无接口数据" :image-size="100" />
</template>
</el-table>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="fetchData"
@size-change="fetchData"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus } from '@element-plus/icons-vue'
import request from '@/utils/request'
/** 接口类型 */
interface ApiItem {
id?: string
systemId: string
apiPath: string
apiMethod: string
apiName: string
status: number
}
/** 系统选项 */
interface SystemOption {
id: string
systemName: string
}
// --- 查询 ---
const query = reactive({
page: 1,
pageSize: 10,
systemId: undefined as string | undefined,
apiPath: '',
apiMethod: '',
})
const tableData = ref<ApiItem[]>([])
const total = ref(0)
const loading = ref(false)
const systemOptions = ref<SystemOption[]>([])
// --- 加载系统列表(用于下拉框) ---
async function loadSystems() {
try {
const res = await request.post('/zgapi/v1/admin/system/page', { page: 1, pageSize: 100, status: 1 })
systemOptions.value = res.data?.records ?? []
// 从 localStorage 取当前用户所在系统,设为默认选中
const sysId = localStorage.getItem('currentSystemId')
if (sysId) {
query.systemId = sysId
}
} catch {
// 错误由拦截器处理
}
}
// --- 请求方式对应 el-tag 颜色 ---
function methodTagType(method: string) {
const map: Record<string, string> = {
GET: 'success',
POST: 'warning',
PUT: 'primary',
DELETE: 'danger',
}
return map[method] || 'info'
}
// --- 数据加载 ---
async function fetchData() {
loading.value = true
try {
const params: Record<string, unknown> = {
page: query.page,
pageSize: query.pageSize,
}
if (query.systemId != null) params.systemId = query.systemId
if (query.apiPath) params.apiPath = query.apiPath
if (query.apiMethod) params.apiMethod = query.apiMethod
const res = await request.post('/zgapi/v1/admin/system/api/page', params)
tableData.value = res.data?.records ?? []
total.value = res.data?.total ?? 0
} catch {
// 错误由拦截器处理
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchData()
}
function handleReset() {
query.systemId = undefined
query.apiPath = ''
query.apiMethod = ''
query.page = 1
fetchData()
}
// --- 新增/编辑 ---
const router = useRouter()
function handleAdd() {
// 携带当前选中的 systemId
const query: Record<string, string> = {}
if (query.systemId != null) {
query.systemId = String(query.systemId)
}
router.push({ path: '/admin/api/form', query })
}
function handleEdit(row: ApiItem) {
router.push('/admin/api/form/' + row.id)
}
// --- 删除 ---
function handleDelete(row: ApiItem) {
ElMessageBox.confirm(`确认删除接口"${row.apiPath}"吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消',
}).then(async () => {
try {
await request.delete(`/zgapi/v1/admin/system/api/${row.id}`)
ElMessage.success('删除成功')
fetchData()
} catch {
// 错误由拦截器处理
}
}).catch(() => {})
}
// 初始化
onMounted(() => {
loadSystems().then(() => {
fetchData()
})
})
</script>
<style scoped>
.page-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
}
.card-header-title {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
color: #303133;
}
.toolbar {
display: flex;
justify-content: flex-start;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
@media (max-width: 768px) {
.page-wrapper { gap: 10px; }
.page-toolbar { flex-direction: column; gap: 10px; }
.page-toolbar .el-select { width: 100% !important; }
}
</style>

View File

@@ -0,0 +1,201 @@
<template>
<div class="form-page">
<!-- 页面头部返回按钮 + 标题 + 分隔线 -->
<el-page-header @back="handleBack" :title="isEdit ? '编辑系统' : '新增系统'" />
<el-divider />
<!-- 表单卡片 -->
<el-card class="form-card" shadow="never">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<!-- 基本信息 -->
<el-divider content-position="left">
<span class="form-section-title">基本信息</span>
</el-divider>
<el-form-item label="系统编码" prop="systemCode">
<el-input v-model="form.systemCode" placeholder="请输入系统编码" :disabled="isEdit" />
</el-form-item>
<el-form-item label="系统名称" prop="systemName">
<el-input v-model="form.systemName" placeholder="请输入系统名称" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
</el-form-item>
<!-- 其他信息 -->
<el-divider content-position="left">
<span class="form-section-title">其他信息</span>
</el-divider>
<el-form-item label="前端地址" prop="frontUrl">
<el-input v-model="form.frontUrl" placeholder="请输入前端地址" />
</el-form-item>
<el-form-item label="后端地址">
<el-input v-model="form.backendUrl" placeholder="请输入后端地址" />
</el-form-item>
<el-form-item label="图标">
<IconPicker v-model="form.icon" />
</el-form-item>
<el-form-item label="排序" prop="sortOrder">
<el-input-number v-model="form.sortOrder" :min="0" :max="9999" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="请输入描述" />
</el-form-item>
<!-- 按钮区域固定在底部 -->
<el-divider />
<div class="form-footer">
<el-button @click="handleBack">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
import IconPicker from '@/components/IconPicker.vue'
const route = useRoute()
const router = useRouter()
/** 是否为编辑模式(有 id 参数) */
const isEdit = ref(false)
const editId = ref<string>()
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
/** 表单数据 */
const form = reactive({
systemCode: '',
systemName: '',
frontUrl: '',
backendUrl: '',
icon: '',
sortOrder: 0,
status: 1,
description: '',
})
/** 表单校验规则 */
const rules: FormRules = {
systemCode: [{ required: true, message: '请输入系统编码', trigger: 'blur' }],
systemName: [{ required: true, message: '请输入系统名称', trigger: 'blur' }],
frontUrl: [{ required: true, message: '请输入前端地址', trigger: 'blur' }],
sortOrder: [{ required: true, message: '请输入排序', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}
/** 加载已有数据(编辑模式) */
async function loadData() {
const id = route.params.id as string
if (!id) {
isEdit.value = false
return
}
isEdit.value = true
editId.value = id
try {
const res = await request.get(`/zgapi/v1/admin/system/${editId.value}`)
const data = res.data
if (data) {
Object.assign(form, {
systemCode: data.systemCode ?? '',
systemName: data.systemName ?? '',
frontUrl: data.frontUrl ?? '',
backendUrl: data.backendUrl ?? '',
icon: data.icon ?? '',
sortOrder: data.sortOrder ?? 0,
status: data.status ?? 1,
description: data.description ?? '',
})
setTimeout(() => formRef.value?.clearValidate(), 0)
}
} catch {
ElMessage.error('加载系统信息失败')
router.back()
}
}
/** 返回上一页 */
function handleBack() {
router.back()
}
/** 提交表单 */
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
if (isEdit.value) {
await request.put('/zgapi/v1/admin/system', { ...form, id: editId.value })
ElMessage.success('编辑成功')
} else {
await request.post('/zgapi/v1/admin/system', form)
ElMessage.success('新增成功')
}
router.back()
} catch {
// 错误由拦截器处理
} finally {
submitLoading.value = false
}
}
onMounted(async () => {
await loadData()
setTimeout(() => formRef.value?.clearValidate(), 0)
})
</script>
<style scoped>
.form-page {
display: flex;
flex-direction: column;
gap: 0;
}
/* 页头分隔线 */
:deep(.el-page-header) {
margin-bottom: 4px;
}
:deep(.el-page-header + .el-divider) {
margin: 0 0 20px 0;
}
.form-card {
max-width: 700px;
margin: 0 auto;
width: 100%;
}
.form-section-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
/* 按钮区域 */
.form-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
@media (max-width: 768px) {
.form-card { max-width: 100%; }
.form-footer { flex-direction: column; }
.form-footer .el-button { width: 100%; }
:deep(.el-form-item__label) { width: 70px !important; }
}
</style>

View File

@@ -0,0 +1,238 @@
<template>
<div class="page-wrapper">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline>
<el-form-item label="系统编码">
<el-input v-model="query.systemCode" placeholder="请输入系统编码" clearable />
</el-form-item>
<el-form-item label="系统名称">
<el-input v-model="query.systemName" placeholder="请输入系统名称" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" placeholder="全部" clearable style="width: 120px">
<el-option label="全部" value="" />
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" v-permission="'system:system:add'" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增系统
</el-button>
</div>
<!-- 表格 -->
<el-card shadow="never">
<el-table :data="tableData" border stripe v-loading="loading" style="width: 100%">
<el-table-column prop="systemCode" label="系统编码" width="140" />
<el-table-column prop="systemName" label="系统名称" width="160" />
<el-table-column prop="icon" label="图标" width="70" align="center">
<template #default="{ row }">
<img v-if="row.icon && (row.icon.startsWith('http') || row.icon.startsWith('/zgapi'))" :src="row.icon" style="width:20px;height:20px;object-fit:contain" />
<el-icon v-else-if="row.icon" :size="20"><component :is="getIconComponent(row.icon)" /></el-icon>
<span v-else style="color:var(--text-muted)">-</span>
</template>
</el-table-column>
<el-table-column prop="frontUrl" label="前端地址" min-width="180" show-overflow-tooltip />
<el-table-column prop="backendUrl" label="后端真实地址" min-width="180" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="290" align="center" class-name="ops-col">
<template #default="{ row }">
<div class="actions">
<el-button type="primary" size="small" plain @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" size="small" plain @click="handleDelete(row)">删除</el-button>
<el-button type="success" size="small" plain @click="openDoc(row)">文档</el-button>
</div>
</template>
</el-table-column>
<!-- 空状态 -->
<template #empty>
<el-empty description="暂无系统数据" :image-size="100" />
</template>
</el-table>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="fetchData"
@size-change="fetchData"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus } from '@element-plus/icons-vue'
import { getIconComponent } from '@/utils/icons'
import request from '@/utils/request'
/** 系统类型 */
interface SystemItem {
id?: string
systemCode: string
systemName: string
frontUrl: string
backendUrl: string
icon: string
description: string
docToken: string
status: number
}
// --- 查询 ---
const query = reactive({
page: 1,
pageSize: 10,
systemCode: '',
systemName: '',
status: '' as string | number,
})
const tableData = ref<SystemItem[]>([])
const total = ref(0)
const loading = ref(false)
async function fetchData() {
loading.value = true
try {
const params: Record<string, unknown> = {
page: query.page,
pageSize: query.pageSize,
}
if (query.systemCode) params.systemCode = query.systemCode
if (query.systemName) params.systemName = query.systemName
if (query.status !== '') params.status = query.status
const res = await request.post('/zgapi/v1/admin/system/page', params)
tableData.value = res.data?.records ?? []
total.value = res.data?.total ?? 0
} catch {
// 错误由拦截器处理
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchData()
}
function handleReset() {
query.systemCode = ''
query.systemName = ''
query.status = ''
query.page = 1
fetchData()
}
// --- 新增/编辑(路由跳转到独立表单页)---
const router = useRouter()
function handleAdd() {
router.push('/admin/system/form')
}
function openDoc(row: SystemItem) {
window.open('/docs/' + row.docToken, '_blank')
}
function handleEdit(row: SystemItem) {
router.push('/admin/system/form/' + row.id)
}
// --- 删除 ---
function handleDelete(row: SystemItem) {
ElMessageBox.confirm(`确认删除系统"${row.systemName}"吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消',
}).then(async () => {
try {
await request.delete(`/zgapi/v1/admin/system/${row.id}`)
ElMessage.success('删除成功')
fetchData()
} catch {
// 错误由拦截器处理
}
}).catch(() => {})
}
// 初始化
fetchData()
</script>
<style scoped>
.page-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
}
.card-header-title {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
color: #303133;
}
.toolbar {
display: flex;
justify-content: flex-start;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
@media (max-width: 768px) {
.page-wrapper { gap: 10px; }
.page-toolbar { flex-direction: column; gap: 10px; }
.page-toolbar .el-select,
.page-toolbar .el-input { width: 100% !important; }
}
</style>

View File

@@ -0,0 +1,309 @@
<template>
<div class="form-page">
<el-page-header @back="handleBack" :title="isEdit ? '编辑用户' : '新增用户'" />
<el-divider />
<el-card shadow="never" class="form-card">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<!-- ===== 基本信息 ===== -->
<el-divider content-position="left"><span class="section-label">基本信息</span></el-divider>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="请输入用户名" :disabled="isEdit" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="真实姓名" prop="realName">
<el-input v-model="form.realName" placeholder="请输入真实姓名" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="手机号">
<el-input v-model="form.phone" placeholder="请输入手机号" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="岗位">
<el-input v-model="form.post" placeholder="请输入岗位" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item v-if="!isEdit" label="密码" prop="password">
<el-input v-model="form.password" type="password" placeholder="请输入密码" show-password />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="状态" prop="status">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
</el-form-item>
<!-- ===== 系统 & 角色绑定仅编辑模式 ===== -->
<template v-if="isEdit">
<el-divider content-position="left"><span class="section-label">系统绑定</span></el-divider>
<el-form-item label="绑定系统">
<el-checkbox-group v-model="bindSystemIds" @change="onSystemChange">
<el-checkbox
v-for="sys in allSystems"
:key="sys.systemId"
:label="sys.systemId"
>
{{ sys.systemName }}
<el-tag size="small" type="info" style="margin-left:4px">{{ sys.systemCode }}</el-tag>
</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-divider content-position="left"><span class="section-label">角色分配</span></el-divider>
<p class="hint-text">先选择系统再为该用户分配该系统下的角色</p>
<el-form-item label="选择系统">
<el-select
v-model="roleSystemId"
placeholder="请选择系统"
clearable
style="width:280px"
@change="onRoleSystemChange"
>
<el-option
v-for="sys in allSystems"
:key="sys.systemId"
:label="sys.systemName + ' (' + sys.systemCode + ')'"
:value="sys.systemId"
/>
</el-select>
</el-form-item>
<el-form-item v-if="roleSystemId" label="分配角色">
<el-checkbox-group v-model="assignRoleIds">
<el-checkbox
v-for="r in currentRoleList"
:key="r.id"
:label="r.id"
>
{{ r.roleName }}
<span style="color:var(--text-muted);font-size:12px;margin-left:4px">{{ r.roleCode }}</span>
</el-checkbox>
</el-checkbox-group>
<el-empty v-if="currentRoleList.length === 0" description="该系统下暂无角色" :image-size="60" />
</el-form-item>
</template>
<!-- ===== 按钮 ===== -->
<el-divider />
<div class="form-footer">
<el-button @click="handleBack">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">保存</el-button>
</div>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import request from '@/utils/request'
const route = useRoute()
const router = useRouter()
const isEdit = ref(false)
const editId = ref<string>()
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
const form = reactive({
username: '',
realName: '',
email: '',
phone: '',
post: '',
password: '',
status: 1,
})
const rules = computed<FormRules>(() => ({
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
realName: [{ required: true, message: '请输入真实姓名', trigger: 'blur' }],
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }],
password: isEdit.value ? [] : [{ required: true, message: '请输入密码', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}))
// ===== 系统绑定 =====
const allSystems = ref<any[]>([])
const bindSystemIds = ref<string[]>([])
async function fetchAllSystems() {
try {
const res = await request.get('/zgapi/v1/auth/system/list')
allSystems.value = res.data ?? []
} catch { /* ignore */ }
}
async function fetchUserSystems() {
if (!editId.value) return
try {
const res = await request.get(`/zgapi/v1/admin/user/${editId.value}/system`)
bindSystemIds.value = res.data ?? []
} catch { /* ignore */ }
}
async function onSystemChange(_val: string[]) {
// 用户取消勾选系统时,清除该系统下的角色选择
await saveSystems()
}
async function saveSystems() {
if (!editId.value) return
try {
await request.post(`/zgapi/v1/admin/user/${editId.value}/system`, bindSystemIds.value)
} catch { /* ignore */ }
}
// ===== 角色分配 =====
const roleSystemId = ref<string>()
const assignRoleIds = ref<string[]>([])
const currentRoleList = ref<any[]>([])
async function onRoleSystemChange(sysId: string | undefined) {
assignRoleIds.value = []
if (!sysId) { currentRoleList.value = []; return }
// 加载该系统下的角色列表
try {
const res = await request.post('/zgapi/v1/admin/role/page', { page: 1, pageSize: 999, systemId: sysId })
currentRoleList.value = res.data?.records ?? []
} catch {
currentRoleList.value = []
}
// 加载用户在该系统下的已有角色
if (!editId.value) return
try {
const res = await request.get(`/zgapi/v1/admin/user/${editId.value}/role/${sysId}`)
assignRoleIds.value = res.data ?? []
} catch { /* ignore */ }
}
async function saveRoles() {
if (!editId.value || !roleSystemId.value) return
try {
await request.post(`/zgapi/v1/admin/user/${editId.value}/role`, assignRoleIds.value, {
params: { systemId: roleSystemId.value }
})
ElMessage.success('角色已保存')
} catch { /* ignore */ }
}
// ===== 加载与提交 =====
async function loadData() {
const id = route.params.id as string
if (!id) { isEdit.value = false; return }
isEdit.value = true
editId.value = id
try {
const res = await request.get(`/zgapi/v1/admin/user/${editId.value}`)
const data = res.data
if (data) {
Object.assign(form, {
username: data.username ?? '',
realName: data.realName ?? '',
email: data.email ?? '',
phone: data.phone ?? '',
post: data.post ?? '',
password: '',
status: data.status ?? 1,
})
setTimeout(() => formRef.value?.clearValidate(), 0)
}
} catch {
ElMessage.error('加载用户信息失败')
router.back()
}
}
function handleBack() { router.back() }
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
const payload: any = {
username: form.username,
realName: form.realName,
email: form.email,
phone: form.phone,
post: form.post,
status: form.status,
}
if (isEdit.value) {
await request.put('/zgapi/v1/admin/user', { ...payload, id: editId.value })
// 保存系统绑定
await saveSystems()
// 如果有选择角色系统,保存角色
if (roleSystemId.value) await saveRoles()
ElMessage.success('用户信息已保存')
} else {
await request.post('/zgapi/v1/admin/user', { ...payload, password: form.password })
ElMessage.success('新增成功')
}
router.back()
} catch (e: any) {
const msg = e?.response?.data?.msg || e?.message
if (msg && msg !== '请求失败') ElMessage.error(msg)
} finally {
submitLoading.value = false
}
}
onMounted(async () => {
await fetchAllSystems()
await loadData()
if (isEdit.value) {
await fetchUserSystems()
}
})
</script>
<style scoped>
.form-page { display:flex; flex-direction:column; }
:deep(.el-page-header) { margin-bottom:4px; }
:deep(.el-page-header + .el-divider) { margin:0 0 20px; }
.form-card { max-width:860px; margin:0 auto; width:100%; }
.section-label { font-size:15px; font-weight:600; color:var(--text-primary); }
.hint-text { font-size:13px; color:var(--text-muted); margin-bottom:16px; }
.form-footer { display:flex; justify-content:flex-end; gap:12px; }
@media (max-width: 768px) {
.form-card { max-width: 100%; padding: 0 8px; }
.form-footer { flex-direction: column; }
.form-footer .el-button { width: 100%; }
}
</style>

View File

@@ -0,0 +1,250 @@
<template>
<div class="page-wrapper">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline>
<el-form-item label="用户名">
<el-input v-model="query.username" placeholder="请输入用户名" clearable />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="query.email" placeholder="请输入邮箱" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" placeholder="全部" clearable style="width: 120px">
<el-option label="全部" value="" />
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button-group>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-button-group>
</el-form-item>
</el-form>
</el-card>
<!-- 操作栏 -->
<div class="toolbar">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增用户
</el-button>
</div>
<!-- 表格 -->
<el-card shadow="never">
<el-table :data="tableData" border stripe v-loading="loading" style="width: 100%">
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="realName" label="真实姓名" width="100" />
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
<el-table-column prop="phone" label="手机号" width="130" />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" effect="dark" size="small">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="lastLoginTime" label="最后登录时间" width="180">
<template #default="{ row }">
{{ row.lastLoginTime ? new Date(row.lastLoginTime).toLocaleString('zh-CN') : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center" class-name="ops-col">
<template #default="{ row }">
<div class="actions">
<el-button type="primary" size="small" plain @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" size="small" plain @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无用户数据" :image-size="100" />
</template>
</el-table>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="fetchData"
@size-change="fetchData"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus } from '@element-plus/icons-vue'
import request from '@/utils/request'
/** 用户类型 */
interface UserItem {
id?: string
username: string
realName: string
email: string
phone: string
post: string
password?: string
status: number
lastLoginTime?: string
}
interface SystemItem {
id: string
systemName: string
}
interface RoleItem {
id: string
roleName: string
}
// --- 查询 ---
const query = reactive({
page: 1,
pageSize: 10,
username: '',
email: '',
status: '' as string | number,
})
const tableData = ref<UserItem[]>([])
const total = ref(0)
const loading = ref(false)
async function fetchData() {
loading.value = true
try {
const params: Record<string, unknown> = {
page: query.page,
pageSize: query.pageSize,
}
if (query.username) params.username = query.username
if (query.email) params.email = query.email
if (query.status !== '') params.status = query.status
const res = await request.post('/zgapi/v1/admin/user/page', params)
tableData.value = res.data?.records ?? []
total.value = res.data?.total ?? 0
} catch {
// ignore
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchData()
}
function handleReset() {
query.username = ''
query.email = ''
query.status = ''
query.page = 1
fetchData()
}
// --- 新增/编辑(路由跳转到独立表单页)---
const router = useRouter()
function handleAdd() {
router.push('/admin/user/form')
}
function handleEdit(row: UserItem) {
router.push('/admin/user/form/' + row.id)
}
function handleDelete(row: UserItem) {
ElMessageBox.confirm(`确认删除用户"${row.username}"吗?`, '删除确认', {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消',
}).then(async () => {
try {
await request.delete(`/zgapi/v1/admin/user/${row.id}`)
ElMessage.success('删除成功')
fetchData()
} catch {
// ignore
}
}).catch(() => {})
}
// 初始化
fetchData()
</script>
<style scoped>
.page-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
}
.search-card {
border-radius: var(--radius-lg);
}
.search-card :deep(.el-card__header) {
padding: 14px 20px;
}
.card-header-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
}
.toolbar {
display: flex;
justify-content: flex-start;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
@media (max-width: 768px) {
.page-wrapper { gap: 10px; }
.page-toolbar { flex-direction: column; gap: 10px; }
.page-toolbar .el-select,
.page-toolbar .el-input { width: 100% !important; }
}
</style>

View File

@@ -0,0 +1,627 @@
<template>
<div class="doc-page">
<div class="doc-container">
<!-- ====== 页头 ====== -->
<header class="doc-hero">
<div class="doc-hero-left">
<svg class="doc-logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
<div>
<h1>{{ sys?.systemName || '系统' }} 接入文档</h1>
<p>Mokee Gateway 统一登录网关 · 系统对接完整指南</p>
</div>
</div>
<div class="doc-hero-right">
<el-tag size="large" type="primary" effect="dark" round>系统编码: {{ sys?.systemCode }}</el-tag>
</div>
</header>
<!-- ====== 系统信息 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">1</span>当前系统配置</h2>
<el-descriptions :column="2" border size="large">
<el-descriptions-item label="系统编码"><code class="inline-code">{{ sys?.systemCode }}</code></el-descriptions-item>
<el-descriptions-item label="系统名称">{{ sys?.systemName }}</el-descriptions-item>
<el-descriptions-item label="前端地址"><code class="inline-code">{{ sys?.frontUrl }}</code></el-descriptions-item>
<el-descriptions-item label="后端地址"><code class="inline-code">{{ sys?.backendUrl }}</code></el-descriptions-item>
<el-descriptions-item label="系统描述" :span="2">{{ sys?.description || '-' }}</el-descriptions-item>
</el-descriptions>
</section>
<!-- ====== 架构概览 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">2</span>架构概览</h2>
<div class="arch-diagram">
<pre class="arch-text">
业务前端 Mokee Gateway 业务后端
(Vue/React) gw.server.zgitm.com (任意语言)
携带 Token 验证 Token 从请求头读取:
+ SystemCode 识别系统编码 X-User-Id
转发请求 X-User-Name
注入用户信息头 X-System-Code
</pre>
</div>
<div class="flow-steps">
<div class="flow-step"><b></b> 用户访问业务前端 Token 跳转统一登录页</div>
<div class="flow-step"><b></b> 登录成功 获得 JWT Token 回跳到业务前端</div>
<div class="flow-step"><b></b> 业务前端所有 API 请求 网关 (携带 Authorization + X-System-Code)</div>
<div class="flow-step"><b></b> 网关验证 Token 注入用户信息请求头 转发到业务后端</div>
<div class="flow-step"><b></b> 业务后端从请求头读取用户信息 执行业务逻辑</div>
</div>
<div class="arch-warnings">
<div class="warn-item">
<b>🔴 关键规则一前端只调网关</b>
<p>业务前端的 <code>baseURL</code> 必须配置为 <b>网关地址</b> <code>{{ gatewayUrl }}</code><b>绝对不能</b>直接调用业务后端网关根据请求头 <code>X-System-Code</code> 自动识别并转发到对应后端</p>
</div>
<div class="warn-item">
<b>🔴 关键规则二后端必须开跨域 (CORS)</b>
<p>业务后端需要允许来自网关域名的跨域请求网关转发请求时 origin 是浏览器地址不是网关地址</p>
<p><b>Java Spring Boot 配置</b></p>
<div class="code-box"><pre><code>@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("*")
.allowedHeaders("*")
.allowCredentials(true);
}
}</code></pre></div>
</div>
<div class="warn-item">
<b>🔴 关键规则三业务后端不再做登录/鉴权</b>
<p>Token 验证由网关完成业务后端只需从请求头读取用户信息不需要再验证 Token不需要查询用户表</p>
</div>
</div>
</section>
<!-- ====== 前端接入 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">3</span>前端接入 (完整代码)</h2>
<h3 class="section-h3">3.1 安装依赖</h3>
<div class="code-box">
<div class="code-label">Shell</div>
<pre><code>npm install axios</code></pre>
</div>
<h3 class="section-h3">3.2 环境变量 (.env)</h3>
<div class="code-box">
<div class="code-label">.env</div>
<pre><code>VITE_GATEWAY_URL = {{ gatewayUrl }}
VITE_LOGIN_URL = {{ loginUrl }}
VITE_SYSTEM_CODE = {{ sys?.systemCode || 'YOUR_CODE' }}</code></pre>
</div>
<h3 class="section-h3">3.3 Axios 实例 (src/utils/request.ts)</h3>
<div class="code-box">
<div class="code-label">TypeScript</div>
<pre><code>import axios from 'axios'
import { ElMessage } from 'element-plus'
const SYSTEM_CODE = import.meta.env.VITE_SYSTEM_CODE
const request = axios.create({
baseURL: import.meta.env.VITE_GATEWAY_URL,
timeout: 30000,
})
// 请求拦截器:携带 Token 和系统编码
request.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// 关键:网关根据此头识别业务系统
config.headers['X-System-Code'] = SYSTEM_CODE
return config
})
// 响应拦截器401 自动跳转登录
request.interceptors.response.use(
res => res.data.code === 200 ? res.data : Promise.reject(res.data),
err => {
if (err.response?.status === 401) {
localStorage.removeItem('token')
window.location.href = `${import.meta.env.VITE_LOGIN_URL}?systemCode=${SYSTEM_CODE}`
}
return Promise.reject(err)
}
)
export default request</code></pre>
</div>
<h3 class="section-h3">3.4 路由守卫 (src/router/index.ts)</h3>
<div class="code-box">
<div class="code-label">TypeScript</div>
<pre><code>import { createRouter, createWebHistory } from 'vue-router'
const SYSTEM_CODE = import.meta.env.VITE_SYSTEM_CODE
const LOGIN_URL = import.meta.env.VITE_LOGIN_URL
const router = createRouter({
history: createWebHistory(),
routes: [ /* 你的路由 */ ],
})
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('token')
if (!token && to.path !== '/login') {
// 无 Token → 跳统一登录页
window.location.href = `${LOGIN_URL}?systemCode=${SYSTEM_CODE}`
return
}
next()
})
export default router</code></pre>
</div>
<h3 class="section-h3">3.5 登录成功回调处理</h3>
<p class="text-p">统一登录成功后网关会将 Token 写入 localStorage 并跳回业务前端业务前端无需处理登录请求只需从 localStorage 读取 Token</p>
<div class="code-box">
<div class="code-label">TypeScript</div>
<pre><code>// main.ts 或 App.vue onMounted
const token = localStorage.getItem('token')
if (token) {
// 用户已登录,正常渲染应用
// 如需获取用户信息,调用网关 API
const res = await request.get('/zgapi/v1/auth/user/info')
console.log('当前用户:', res.data)
}</code></pre>
</div>
</section>
<!-- ====== 后端接入 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">4</span>后端接入 (完整代码)</h2>
<h3 class="section-h3">4.1 网关转发到后端时注入的请求头</h3>
<el-table :data="headerTable" border stripe size="large" style="width:100%">
<el-table-column prop="name" label="请求头名称" width="200" />
<el-table-column prop="type" label="数据类型" width="120" />
<el-table-column prop="desc" label="说明" />
</el-table>
<p class="text-p" style="margin-top:16px"><b>重要</b>这些请求头由网关注入业务后端可以信任这些值不需要再验证 Token 或查询用户信息</p>
<h3 class="section-h3">4.2 Java UserContext 工具类</h3>
<p class="text-p"> ThreadLocal 缓存请求头业务代码零侵入获取用户信息</p>
<div class="code-box">
<div class="code-label">Java</div>
<pre><code>// ====== UserContext.java ======
import java.util.*;
public class UserContext {
private static final ThreadLocal&lt;Map&lt;String, String&gt;&gt; CTX = new ThreadLocal&lt;&gt;();
public static void set(Map&lt;String, String&gt; user) { CTX.set(user); }
public static Map&lt;String, String&gt; get() { return CTX.get(); }
public static void clear() { CTX.remove(); }
// 快捷方法
public static String getUserId() { return get().get("X-User-Id"); }
public static String getUserName() { return get().get("X-User-Name"); }
public static String getUserAccount(){ return get().get("X-User-Account"); }
public static String getSystemCode() { return get().get("X-System-Code"); }
public static String getSystemId() { return get().get("X-System-Id"); }
}
// ====== UserContextFilter.java ======
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.*;
@Component
public class UserContextFilter implements Filter {
private static final String[] HEADERS = {
"X-User-Id", "X-User-Name", "X-User-Account",
"X-User-Email", "X-User-Post", "X-User-Roles",
"X-System-Id", "X-System-Code", "X-System-Name"
};
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
Map&lt;String, String&gt; user = new HashMap&lt;&gt;();
for (String h : HEADERS) {
String v = request.getHeader(h);
if (v != null) user.put(h, v);
}
UserContext.set(user);
try {
chain.doFilter(req, res);
} finally {
UserContext.clear();
}
}
}
// ====== 业务代码中使用 ======
@RestController
public class OrderController {
@GetMapping("/api/orders")
public List&lt;Order&gt; list() {
String userId = UserContext.getUserId(); // "123"
String userName = UserContext.getUserName(); // "张三"
String systemCode = UserContext.getSystemCode(); // "msg"
return orderService.findByUser(userId, systemCode);
}
}</code></pre>
</div>
<h3 class="section-h3">4.3 Python Flask 中间件</h3>
<div class="code-box">
<div class="code-label">Python</div>
<pre><code># ====== middleware.py ======
from flask import request, g
USER_HEADERS = [
'X-User-Id', 'X-User-Name', 'X-User-Account',
'X-User-Email', 'X-User-Post', 'X-User-Roles',
'X-System-Id', 'X-System-Code', 'X-System-Name',
]
def user_context_middleware():
"""将网关注入的用户信息存到 Flask g 对象"""
for h in USER_HEADERS:
val = request.headers.get(h)
if val:
setattr(g, h.replace('-', '_').lower(), val)
# 注册到 app (Flask)
# app.before_request(user_context_middleware)
# ====== 业务代码中使用 ======
from flask import g
@app.route('/api/orders')
def list_orders():
user_id = g.x_user_id # "123"
user_name = g.x_user_name # "张三"
system_code = g.x_system_code # "msg"
return order_service.query(user_id, system_code)</code></pre>
</div>
<h3 class="section-h3">4.4 Go 中间件</h3>
<div class="code-box">
<div class="code-label">Go</div>
<pre><code>// ====== middleware.go ======
package middleware
import (
"context"
"net/http"
)
type contextKey string
func UserContext(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
for _, h := range []string{
"X-User-Id", "X-User-Name", "X-User-Account",
"X-User-Email", "X-User-Roles",
"X-System-Id", "X-System-Code",
} {
if v := r.Header.Get(h); v != "" {
ctx = context.WithValue(ctx, contextKey(h), v)
}
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// 快捷函数
func GetUserID(ctx context.Context) string {
return ctx.Value(contextKey("X-User-Id")).(string)
}
func GetSystemCode(ctx context.Context) string {
return ctx.Value(contextKey("X-System-Code")).(string)
}
// ====== main.go: 注册中间件 ======
// http.Handle("/api/", middleware.UserContext(yourHandler))
// ====== 业务代码 ======
func listOrders(w http.ResponseWriter, r *http.Request) {
userID := middleware.GetUserID(r.Context()) // "123"
systemCode := middleware.GetSystemCode(r.Context()) // "msg"
// ...
}</code></pre>
</div>
</section>
<!-- ====== API 接口参考 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">5</span>API 接口参考</h2>
<h3 class="section-h3">5.1 获取当前用户信息</h3>
<div class="api-card">
<div class="api-method get">GET</div>
<div class="api-path">/zgapi/v1/auth/user/info</div>
<div class="api-headers">
<b>请求头</b>Authorization: Bearer {token}
</div>
<div class="api-response">
<b>响应</b>
<div class="code-box"><pre><code>{
"code": 200,
"data": {
"userId": 1,
"username": "admin",
"realName": "管理员",
"email": "admin@zgitm.com",
"phone": "13800000000",
"avatar": null,
"post": "管理员"
}
}</code></pre></div>
</div>
</div>
<h3 class="section-h3">5.2 获取系统菜单路由</h3>
<div class="api-card">
<div class="api-method get">GET</div>
<div class="api-path">/zgapi/v1/auth/menu/router?systemCode={{ sys?.systemCode || 'YOUR_CODE' }}</div>
<div class="api-headers">
<b>请求头</b>Authorization: Bearer {token}
</div>
<div class="api-response">
<b>响应</b>树形菜单结构包含 path / name / component / children / meta( title, icon)
</div>
</div>
<h3 class="section-h3">5.3 退出登录</h3>
<div class="api-card">
<div class="api-method post">POST</div>
<div class="api-path">/zgapi/v1/auth/logout</div>
<div class="api-headers">
<b>请求头</b>Authorization: Bearer {token}
</div>
</div>
</section>
<!-- ====== 数据库初始化 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">6</span>数据库初始化</h2>
<p class="text-p">在网关平台的数据库执行以下 SQL{{ sys?.systemName }}创建默认菜单和角色</p>
<div class="code-box">
<div class="code-label">SQL</div>
<pre><code>{{ menuSql }}</code></pre>
</div>
</section>
<!-- ====== 测试清单 ====== -->
<section class="doc-section">
<h2 class="section-h2"><span class="s-num">7</span>对接测试清单</h2>
<div class="checklist">
<div v-for="(item, i) in testChecklist" :key="i" class="check-item">
<el-icon :size="20" color="var(--color-primary)"><component :is="getIconComponent(item.done ? 'CircleCheckFilled' : 'CircleClose')" /></el-icon>
<span>{{ item.text }}</span>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { CircleCheckFilled, CircleClose } from '@element-plus/icons-vue'
import { getIconComponent } from '@/utils/icons'
import request from '@/utils/request'
const route = useRoute()
const sys = ref<any>(null)
const token = route.params.token as string
const gatewayUrl = computed(() => import.meta.env.VITE_GATEWAY_URL || 'https://gw.server.zgitm.com')
const loginUrl = computed(() => import.meta.env.VITE_LOGIN_URL || 'https://login.user.zgitm.com')
const headerTable = [
{ name: 'X-User-Id', type: 'String', desc: '当前登录用户的唯一ID' },
{ name: 'X-User-Name', type: 'String', desc: '用户真实姓名' },
{ name: 'X-User-Account', type: 'String', desc: '用户登录账号' },
{ name: 'X-User-Email', type: 'String', desc: '用户邮箱' },
{ name: 'X-User-Post', type: 'String', desc: '用户岗位' },
{ name: 'X-User-Roles', type: 'JSON', desc: '用户角色列表 [{roleId, roleName, roleCode}]' },
{ name: 'X-System-Id', type: 'String', desc: '当前业务系统的数据库ID' },
{ name: 'X-System-Code', type: 'String', desc: '当前业务系统的唯一编码' },
{ name: 'X-System-Name', type: 'String', desc: '当前业务系统的显示名称' },
]
const menuSql = computed(() => {
const sc = sys.value?.systemCode || 'YOUR_SYSTEM_CODE'
const sn = sys.value?.systemName || '系统'
return `-- =============================================
-- ${sn} 菜单 & 角色初始化 SQL
-- 系统编码: ${sc}
-- 将 system_code 替换为你的编码后直接执行
-- =============================================
-- 1. 创建顶级菜单目录 (parent_id = 0)
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
SELECT s.id, 0, '系统管理', '/system', 'Layout', 'Setting', 1, 1, 1
FROM sys_system s WHERE s.system_code = '${sc}';
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
SELECT s.id, 0, '业务管理', '/biz', 'Layout', 'Document', 1, 2, 1
FROM sys_system s WHERE s.system_code = '${sc}';
-- 2. 创建子菜单 (parent_id = 对应父菜单ID)
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
SELECT s.id, m.id, '用户管理', '/system/user', 'system/user/index', 'User', 2, 1, 1
FROM sys_system s, sys_menu m
WHERE s.system_code = '${sc}' AND m.menu_name = '系统管理' AND m.system_id = s.id;
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
SELECT s.id, m.id, '角色管理', '/system/role', 'system/role/index', 'Avatar', 2, 2, 1
FROM sys_system s, sys_menu m
WHERE s.system_code = '${sc}' AND m.menu_name = '系统管理' AND m.system_id = s.id;
INSERT INTO sys_menu (system_id, parent_id, menu_name, menu_path, component, icon, menu_type, sort_order, status)
SELECT s.id, m.id, '订单管理', '/biz/order', 'biz/order/index', 'Tickets', 2, 1, 1
FROM sys_system s, sys_menu m
WHERE s.system_code = '${sc}' AND m.menu_name = '业务管理' AND m.system_id = s.id;
-- 3. 创建角色
INSERT INTO sys_role (system_id, role_name, role_code, description, status)
SELECT s.id, '管理员', 'admin', '拥有全部权限', 1
FROM sys_system s WHERE s.system_code = '${sc}';
INSERT INTO sys_role (system_id, role_name, role_code, description, status)
SELECT s.id, '普通用户', 'user', '拥有基本权限', 1
FROM sys_system s WHERE s.system_code = '${sc}';
-- 4. 将菜单分配给角色
INSERT INTO sys_role_menu (role_id, menu_id)
SELECT r.id, m.id
FROM sys_role r, sys_menu m, sys_system s
WHERE r.role_code = 'admin'
AND r.system_id = s.id
AND m.system_id = s.id
AND s.system_code = '${sc}';`
})
const testChecklist = computed(() => [
{ text: '访问业务前端首页 → 自动跳转到统一登录页', done: false },
{ text: '使用测试账号登录 → 成功进入业务前端', done: false },
{ text: '调用 GET /zgapi/v1/auth/user/info → 返回用户信息', done: false },
{ text: '业务后端 Controller 中 UserContext.getUserId() 能获取到用户ID', done: false },
{ text: '未放行的 API 请求被网关返回 403', done: false },
{ text: '退出登录后访问业务页面 → 重新跳转登录页', done: false },
])
onMounted(async () => {
try {
const res = await request.get('/zgapi/v1/admin/system/doc/' + token)
sys.value = res.data
} catch { /* ignore */ }
})
</script>
<style scoped>
/* ===== 整体 ===== */
.doc-page { min-height:100vh; background:var(--bg-page); padding:20px; }
.doc-container { max-width:960px; margin:0 auto; }
/* ===== 页头 ===== */
.doc-hero {
display:flex; align-items:center; justify-content:space-between;
padding:28px 32px; background:var(--bg-surface);
border-radius:var(--radius-xl); border:1px solid var(--border-color);
margin-bottom:24px; box-shadow:var(--shadow-sm);
}
.doc-hero-left { display:flex; align-items:center; gap:14px; }
.doc-logo { width:36px; height:36px; color:var(--color-primary); flex-shrink:0; }
.doc-hero-left h1 { font-size:22px; font-weight:700; margin:0 0 4px; color:var(--text-primary); }
.doc-hero-left p { font-size:14px; color:var(--text-muted); margin:0; }
/* ===== 章节 ===== */
.doc-section {
background:var(--bg-surface); border-radius:var(--radius-xl);
border:1px solid var(--border-color); padding:28px 32px;
margin-bottom:20px; box-shadow:var(--shadow-sm);
}
.section-h2 {
display:flex; align-items:center; gap:12px;
font-size:20px; font-weight:700; color:var(--text-primary);
margin:0 0 20px; padding-bottom:16px;
border-bottom:1px solid var(--border-light);
}
.s-num {
display:inline-flex; align-items:center; justify-content:center;
width:32px; height:32px; border-radius:var(--radius-md);
background:var(--color-primary); color:#fff;
font-size:15px; font-weight:700; flex-shrink:0;
}
.section-h3 {
font-size:16px; font-weight:600; color:var(--text-primary);
margin:24px 0 10px;
}
.section-h3:first-of-type { margin-top:0; }
/* ===== 文字 ===== */
.text-p { font-size:14px; color:var(--text-secondary); line-height:1.7; margin:0 0 12px; }
.inline-code {
background:var(--color-primary-bg); color:var(--color-primary);
padding:2px 8px; border-radius:4px; font-size:13px;
font-family:'SF Mono','Consolas',monospace;
}
/* ===== 架构图 ===== */
.arch-text {
background:#1e293b; color:#e2e8f0; padding:16px 20px;
border-radius:var(--radius-md); font-size:13px;
line-height:1.7; overflow-x:auto; white-space:pre;
font-family:'SF Mono','Consolas',monospace;
}
.flow-steps { margin-top:16px; display:flex; flex-direction:column; gap:10px; }
.flow-step { font-size:14px; color:var(--text-secondary); display:flex; align-items:flex-start; gap:8px; line-height:1.6; }
.flow-step b { color:var(--color-primary); flex-shrink:0; }
/* ===== 代码块 ===== */
.code-box {
position:relative; background:#1e293b; border-radius:var(--radius-md);
margin-bottom:16px; overflow:hidden;
}
.code-label {
position:absolute; top:8px; right:12px; z-index:1;
font-size:11px; color:rgba(255,255,255,0.45); font-weight:600;
letter-spacing:0.5px; text-transform:uppercase;
}
.code-box pre {
margin:0; padding:20px; overflow-x:auto;
}
.code-box code {
font-family:'SF Mono','Fira Code','Consolas',monospace;
font-size:13px; line-height:1.75; color:#e2e8f0; white-space:pre;
}
/* ===== API 卡片 ===== */
.api-card {
border:1px solid var(--border-color); border-radius:var(--radius-lg);
padding:20px; margin-bottom:16px;
}
.api-method {
display:inline-block; padding:2px 10px; border-radius:4px;
font-size:12px; font-weight:700; color:#fff; margin-bottom:8px;
}
.api-method.get { background:#10b981; }
.api-method.post { background:#6366f1; }
.api-method.put { background:#f59e0b; }
.api-path {
font-size:15px; font-weight:600; color:var(--text-primary);
font-family:'SF Mono','Consolas',monospace; margin-bottom:8px;
}
.api-headers { font-size:14px; color:var(--text-secondary); margin-bottom:8px; }
.api-response { font-size:14px; color:var(--text-secondary); }
/* ===== 测试清单 ===== */
.checklist { display:flex; flex-direction:column; gap:14px; }
.check-item { display:flex; align-items:center; gap:10px; font-size:14px; color:var(--text-secondary); }
/* 架构警告 */
.arch-warnings { margin-top:16px; display:flex; flex-direction:column; gap:12px; }
.warn-item {
background:#fef2f2; border:1px solid #fecaca; border-radius:var(--radius-md);
padding:14px 18px;
}
.warn-item b { color:#dc2626; font-size:14px; display:block; margin-bottom:4px; }
.warn-item p { color:var(--text-secondary); font-size:14px; margin:0 0 8px; line-height:1.7; }
@media (max-width:768px) {
.doc-hero { flex-direction:column; gap:12px; align-items:flex-start; }
.doc-section { padding:20px; }
}
</style>

View File

@@ -0,0 +1,281 @@
<template>
<div class="login-page">
<div class="login-container">
<!-- 左侧品牌区 -->
<div class="login-brand">
<div class="brand-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
</div>
<h1 class="brand-title">Mokee Gateway</h1>
<p class="brand-desc">统一登录网关管理平台</p>
<div class="brand-features">
<div class="feature-item">
<span class="feature-dot" />
<span>统一身份认证</span>
</div>
<div class="feature-item">
<span class="feature-dot" />
<span>智能网关转发</span>
</div>
<div class="feature-item">
<span class="feature-dot" />
<span>细粒度权限管控</span>
</div>
</div>
</div>
<!-- 右侧登录表单 -->
<div class="login-form-panel">
<div class="form-header">
<h2>欢迎回来</h2>
<p>请输入账号密码登录系统</p>
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
@keyup.enter="handleLogin"
>
<el-form-item prop="username">
<el-input
v-model="form.username"
placeholder="用户名"
:prefix-icon="User"
size="large"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
:prefix-icon="Lock"
size="large"
show-password
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
:loading="loading"
class="submit-btn"
@click="handleLogin"
>
</el-button>
</el-form-item>
</el-form>
</div>
</div>
<footer class="login-footer">
&copy; 2026 Mokee Gateway
</footer>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const form = reactive({ username: '', password: '' })
const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
async function handleLogin() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
await authStore.login(form.username, form.password)
// PROD: 登录后切到主应用域名,避免从 login 域名加载 JS
const webUrl = import.meta.env.VITE_WEB_URL
if (webUrl && webUrl !== window.location.origin) {
window.location.href = webUrl + '/select'
} else {
router.push('/select')
}
} catch (e: any) {
// 响应拦截器已展示通用错误(如 401/403/429
// 此处处理拦截器未覆盖的业务异常信息
const msg = e?.response?.data?.msg || e?.message
if (msg && msg !== '请求失败') ElMessage.error(msg)
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f8fafc;
padding: 24px;
}
.login-container {
display: flex;
width: 880px;
max-width: 100%;
min-height: 480px;
background: #fff;
border-radius: 20px;
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.04),
0 20px 60px -20px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
/* ===== 左侧品牌区 ===== */
.login-brand {
flex: 1;
background: linear-gradient(160deg, #4f46e5 0%, #6366f1 40%, #818cf8 100%);
padding: 48px 40px;
display: flex;
flex-direction: column;
justify-content: center;
color: #fff;
}
.brand-icon {
width: 52px;
height: 52px;
background: rgba(255, 255, 255, 0.18);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 28px;
backdrop-filter: blur(10px);
}
.brand-icon svg {
width: 28px;
height: 28px;
}
.brand-title {
font-size: 26px;
font-weight: 700;
letter-spacing: -0.5px;
margin-bottom: 6px;
}
.brand-desc {
font-size: 14px;
opacity: 0.8;
font-weight: 400;
margin-bottom: 40px;
}
.brand-features {
display: flex;
flex-direction: column;
gap: 14px;
}
.feature-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
opacity: 0.85;
}
.feature-dot {
width: 6px;
height: 6px;
background: rgba(255, 255, 255, 0.6);
border-radius: 50%;
flex-shrink: 0;
}
/* ===== 右侧登录表单 ===== */
.login-form-panel {
flex: 1;
padding: 48px 44px;
display: flex;
flex-direction: column;
justify-content: center;
}
.form-header {
margin-bottom: 32px;
}
.form-header h2 {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 6px;
letter-spacing: -0.3px;
}
.form-header p {
font-size: 14px;
color: var(--text-muted);
}
.submit-btn {
width: 100%;
height: 46px;
font-size: 15px;
letter-spacing: 3px;
border-radius: var(--radius-lg);
margin-top: 4px;
}
.login-footer {
margin-top: 28px;
color: var(--text-muted);
font-size: 12px;
}
/* ===== 响应式 ===== */
@media (max-width: 768px) {
.login-page { padding: 0; justify-content: flex-start; }
.login-container {
flex-direction: column;
min-height: 100vh;
border-radius: 0;
box-shadow: none;
width: 100%;
}
.login-brand {
padding: 40px 24px 24px;
text-align: center;
align-items: center;
}
.brand-icon { width: 44px; height: 44px; margin-bottom: 16px; }
.brand-icon svg { width: 22px; height: 22px; }
.brand-title { font-size: 22px; }
.brand-features { display: none; }
.brand-desc { margin-bottom: 0; }
.login-form-panel { padding: 24px 24px 40px; }
.form-header h2 { font-size: 20px; }
.login-footer { display: none; }
}
</style>

View File

@@ -0,0 +1,287 @@
<template>
<div class="select-page">
<!-- 顶栏 -->
<header class="select-topbar">
<div class="topbar-brand">
<svg class="brand-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
<span class="brand-text">Mokee Gateway</span>
</div>
<div class="topbar-actions">
<el-button v-if="authStore.isAdmin" type="primary" size="default" @click="router.push('/admin')">
<el-icon><Setting /></el-icon>后台管理
</el-button>
<el-button text @click="handleLogout" style="color: var(--text-muted)">
<el-icon><SwitchButton /></el-icon>退出
</el-button>
</div>
</header>
<!-- 内容 -->
<div class="select-body">
<div class="select-hero">
<el-avatar :size="56" :src="authStore.userInfo?.avatar" class="hero-avatar">
{{ authStore.userInfo?.realName?.charAt(0) || 'U' }}
</el-avatar>
<h2 class="hero-greeting">
你好{{ authStore.userInfo?.realName || authStore.userInfo?.username || '用户' }}
</h2>
<p class="hero-sub">请选择要访问的业务系统</p>
</div>
<div class="system-grid" v-if="authStore.systemList.length > 0">
<div
v-for="(sys, i) in authStore.systemList"
:key="sys.systemId"
class="system-card"
@click="handleSelectSystem(sys)"
>
<div class="card-icon-box" :style="{ background: isIconUrl(sys.icon) ? 'transparent' : gradients[i % gradients.length] }">
<img v-if="isIconUrl(sys.icon)" :src="sys.icon" class="card-icon-img" />
<el-icon v-else :size="28"><component :is="getIcon(sys.icon)" /></el-icon>
</div>
<div class="card-info">
<div class="card-title">{{ sys.systemName }}</div>
<div class="card-code">{{ sys.systemCode }}</div>
<div class="card-desc">{{ sys.description || '暂无描述' }}</div>
</div>
<el-icon class="card-arrow" :size="16"><ArrowRight /></el-icon>
</div>
</div>
<el-empty
v-else
description="暂无可用系统,请联系管理员配置"
:image-size="140"
>
<el-button type="primary" @click="authStore.fetchSystemList()">刷新</el-button>
</el-empty>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { SwitchButton, Grid, Setting, ArrowRight } from '@element-plus/icons-vue'
import { getIconComponent } from '@/utils/icons'
import { useAuthStore, type SystemInfo } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const gradients = [
'linear-gradient(135deg, #6366f1, #818cf8)',
'linear-gradient(135deg, #10b981, #34d399)',
'linear-gradient(135deg, #f59e0b, #fbbf24)',
'linear-gradient(135deg, #ef4444, #f87171)',
'linear-gradient(135deg, #8b5cf6, #a78bfa)',
'linear-gradient(135deg, #06b6d4, #22d3ee)',
'linear-gradient(135deg, #ec4899, #f472b6)',
'linear-gradient(135deg, #3b82f6, #60a5fa)',
]
onMounted(async () => {
await authStore.fetchUserInfo()
await authStore.fetchSystemList()
})
function getIcon(icon: string) {
return getIconComponent(icon) || Grid
}
/** 图标值是 URL上传的文件图标还是 Element Plus 组件名 */
function isIconUrl(v: string) {
return v && (v.startsWith('http') || v.startsWith('/zgapi'))
}
async function handleSelectSystem(sys: SystemInfo) {
try {
await authStore.selectSystem(sys.systemId)
if (sys.systemCode === 'gateway') {
// 跳转到主应用域名,避免从 login 域名加载大 JS
const webUrl = import.meta.env.VITE_WEB_URL || window.location.origin
window.location.href = webUrl + '/admin'
return
}
if (sys.frontUrl) {
window.location.href = sys.frontUrl
} else {
ElMessage.warning('该系统未配置前端地址')
}
} catch {
ElMessage.error('切换系统失败')
}
}
async function handleLogout() {
try { await authStore.logout() } catch {}
router.push('/login')
}
</script>
<style scoped>
.select-page {
min-height: 100vh;
background: #f8fafc;
display: flex;
flex-direction: column;
}
/* ===== 顶栏 ===== */
.select-topbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
background: #fff;
border-bottom: 1px solid var(--border-color);
padding: 0 24px;
position: sticky;
top: 0;
z-index: 100;
}
.topbar-brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand-svg {
width: 26px;
height: 26px;
color: var(--color-primary);
}
.brand-text {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.3px;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* ===== 内容 ===== */
.select-body {
flex: 1;
padding: 48px 40px 60px;
max-width: 960px;
width: 100%;
margin: 0 auto;
}
.select-hero {
text-align: center;
margin-bottom: 44px;
}
.hero-avatar {
margin-bottom: 16px;
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.2);
}
.hero-greeting {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 6px;
letter-spacing: -0.3px;
}
.hero-sub {
font-size: 15px;
color: var(--text-muted);
}
/* ===== 系统卡片网格 ===== */
.system-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.system-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: #fff;
border-radius: var(--radius-lg);
border: 1px solid var(--border-color);
cursor: pointer;
transition: all var(--transition-base);
box-shadow: var(--shadow-xs);
}
.system-card:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-lg);
border-color: #cbd5e1;
}
.card-icon-box {
width: 52px;
height: 52px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
}
.card-icon-img { width:40px; height:40px; object-fit:contain; }
.card-info {
flex: 1;
min-width: 0;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 2px;
}
.card-code {
font-size: 12px;
color: var(--text-muted);
font-family: 'SF Mono', 'Fira Code', monospace;
margin-bottom: 4px;
}
.card-desc {
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-arrow {
color: var(--text-muted);
flex-shrink: 0;
transition: all var(--transition-fast);
}
.system-card:hover .card-arrow {
color: var(--color-primary);
transform: translateX(3px);
}
@media (max-width: 768px) {
.select-body { padding: 24px 16px 40px; }
.select-hero { margin-bottom: 28px; }
.hero-greeting { font-size: 20px; }
.system-grid { grid-template-columns: 1fr; gap: 12px; }
.select-topbar { padding: 0 12px; }
}
</style>