初始化
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user