feat(M5): 前端完善 + 部署 — Vue3 全量页面、仪表盘统计与我的审计接口、systemd/sudoers/迁移脚本、部署文档
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.7.9",
|
||||
"element-plus": "^2.9.3",
|
||||
"pinia": "^2.3.1",
|
||||
|
||||
+16
-23
@@ -1,33 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
// 骨架阶段根组件:展示项目名与健康检查状态。
|
||||
// M1 起替换为登录页 + 主布局(侧边导航、用户管理、审批、审计等页面)。
|
||||
import { onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
// 根组件:启动时探测会话(路由守卫会复用已加载状态)。
|
||||
const auth = useAuthStore()
|
||||
onMounted(() => {
|
||||
void auth.fetchMe()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-config-provider>
|
||||
<el-container class="app-shell">
|
||||
<el-header class="app-header">
|
||||
<span class="app-title">ws_usernode 服务器用户管理节点</span>
|
||||
</el-header>
|
||||
<el-main>
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
<router-view />
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
.app-title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: var(--el-bg-color-page);
|
||||
}
|
||||
</style>
|
||||
|
||||
+24
-4
@@ -1,11 +1,31 @@
|
||||
import axios from 'axios'
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// Axios 实例:baseURL 为 /api/v1(Vite dev proxy 转发到 Go 后端;
|
||||
// 生产由 go:embed 同源提供)。M1 起在此统一拦截 401 / 错误码。
|
||||
// Axios 实例:baseURL 为 /api/v1(Vite dev proxy 转发到 Go 后端;生产 go:embed 同源)。
|
||||
// withCredentials:cookie 会话。
|
||||
const http = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 15000,
|
||||
withCredentials: true, // cookie 会话
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
// 响应拦截器:
|
||||
// - 成功响应统一解包 {data};
|
||||
// - 401 会话失效时清除本地状态并跳登录(登录/申请页自身除外,避免循环);
|
||||
// - 其余错误统一提示后端 error 文案。
|
||||
http.interceptors.response.use(
|
||||
(resp) => resp,
|
||||
(err: AxiosError<{ error?: string }>) => {
|
||||
const status = err.response?.status
|
||||
const msg = err.response?.data?.error || err.message || '请求失败'
|
||||
const path = window.location.pathname
|
||||
if (status === 401 && !path.startsWith('/login') && !path.startsWith('/apply')) {
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(err)
|
||||
}
|
||||
ElMessage.error(msg)
|
||||
return Promise.reject(err)
|
||||
},
|
||||
)
|
||||
|
||||
export default http
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// 后端 RESTful API v1 的统一封装:每个函数对应一个端点,返回解包后的 data。
|
||||
import http from './http'
|
||||
import type {
|
||||
Approval,
|
||||
AuditLog,
|
||||
Captcha,
|
||||
MeInfo,
|
||||
PageResult,
|
||||
SSHKey,
|
||||
SettingItem,
|
||||
User,
|
||||
UserStats,
|
||||
} from './types'
|
||||
|
||||
// 通用解包:{data} / {error}。
|
||||
async function unwrap<T>(p: Promise<{ data: { data: T } }>): Promise<T> {
|
||||
const resp = await p
|
||||
return resp.data.data
|
||||
}
|
||||
|
||||
// ---------- 认证 ----------
|
||||
|
||||
export function getCaptcha(): Promise<Captcha> {
|
||||
return unwrap(http.get('/auth/captcha'))
|
||||
}
|
||||
|
||||
export function otpSend(p: { username: string; captcha_id: string; captcha_code: string }): Promise<{ status: string }> {
|
||||
return unwrap(http.post('/auth/otp/send', p))
|
||||
}
|
||||
|
||||
export function otpLogin(p: { username: string; code: string }): Promise<{ session: string }> {
|
||||
return unwrap(http.post('/auth/otp/login', p))
|
||||
}
|
||||
|
||||
export function adminLogin(p: { username: string; password: string }): Promise<{ session: string }> {
|
||||
return unwrap(http.post('/auth/admin/login', p))
|
||||
}
|
||||
|
||||
export function adminForgot(p: { username: string }): Promise<{ status: string }> {
|
||||
return unwrap(http.post('/auth/admin/forgot', p))
|
||||
}
|
||||
|
||||
export function adminReset(p: { token: string; new_password: string }): Promise<{ status: string }> {
|
||||
return unwrap(http.post('/auth/admin/reset', p))
|
||||
}
|
||||
|
||||
export function logout(): Promise<{ status: string }> {
|
||||
return unwrap(http.post('/auth/logout'))
|
||||
}
|
||||
|
||||
export function me(): Promise<MeInfo> {
|
||||
return unwrap(http.get('/auth/me'))
|
||||
}
|
||||
|
||||
// ---------- 用户管理(admin) ----------
|
||||
|
||||
export interface UserListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
status?: string
|
||||
supervisor?: string
|
||||
}
|
||||
|
||||
export function listUsers(params: UserListParams): Promise<PageResult<User>> {
|
||||
return unwrap(http.get('/users', { params }))
|
||||
}
|
||||
|
||||
export function createUser(p: {
|
||||
username: string
|
||||
email: string
|
||||
supervisor?: string
|
||||
purpose?: string
|
||||
ttl_days?: number
|
||||
}): Promise<{ id: number; username: string; status: string; expire_at: string | null }> {
|
||||
return unwrap(http.post('/users', p))
|
||||
}
|
||||
|
||||
export function getUser(id: number): Promise<User> {
|
||||
return unwrap(http.get(`/users/${id}`))
|
||||
}
|
||||
|
||||
export function updateUser(
|
||||
id: number,
|
||||
p: { email?: string; supervisor?: string; purpose?: string },
|
||||
): Promise<User> {
|
||||
return unwrap(http.patch(`/users/${id}`, p))
|
||||
}
|
||||
|
||||
export function disableUser(id: number): Promise<{ status: string }> {
|
||||
return unwrap(http.post(`/users/${id}/disable`))
|
||||
}
|
||||
|
||||
export function enableUser(id: number): Promise<{ status: string }> {
|
||||
return unwrap(http.post(`/users/${id}/enable`))
|
||||
}
|
||||
|
||||
export function extendUser(id: number, days: number): Promise<{ expire_at: string }> {
|
||||
return unwrap(http.post(`/users/${id}/extend`, { days }))
|
||||
}
|
||||
|
||||
export function deleteUser(id: number): Promise<{ status: string }> {
|
||||
return unwrap(http.delete(`/users/${id}`))
|
||||
}
|
||||
|
||||
export function listUserKeys(id: number): Promise<{ items: SSHKey[] }> {
|
||||
return unwrap(http.get(`/users/${id}/keys`))
|
||||
}
|
||||
|
||||
// ---------- 我的密钥(外部用户) ----------
|
||||
|
||||
export function listMyKeys(): Promise<{ items: SSHKey[] }> {
|
||||
return unwrap(http.get('/me/keys'))
|
||||
}
|
||||
|
||||
export function createKey(p: { name: string; public_key: string }): Promise<SSHKey> {
|
||||
return unwrap(http.post('/me/keys', p))
|
||||
}
|
||||
|
||||
export function renameKey(id: number, name: string): Promise<SSHKey> {
|
||||
return unwrap(http.patch(`/me/keys/${id}`, { name }))
|
||||
}
|
||||
|
||||
export function revokeKey(id: number): Promise<SSHKey> {
|
||||
return unwrap(http.delete(`/me/keys/${id}`))
|
||||
}
|
||||
|
||||
/** 我的审计痕迹(外部用户个人中心) */
|
||||
export function myAudit(page = 1, pageSize = 20): Promise<PageResult<AuditLog>> {
|
||||
return unwrap(http.get('/me/audit', { params: { page, page_size: pageSize } }))
|
||||
}
|
||||
|
||||
// ---------- 申请审批 ----------
|
||||
|
||||
export function submitApproval(p: {
|
||||
username: string
|
||||
email: string
|
||||
supervisor?: string
|
||||
purpose?: string
|
||||
}): Promise<Approval> {
|
||||
return unwrap(http.post('/approvals', p))
|
||||
}
|
||||
|
||||
export function listApprovals(status?: string): Promise<{ items: Approval[] }> {
|
||||
return unwrap(http.get('/approvals', { params: { status } }))
|
||||
}
|
||||
|
||||
export function reviewApproval(id: number, approve: boolean, reason: string): Promise<Approval> {
|
||||
return unwrap(http.post(`/approvals/${id}/review`, { approve, reason }))
|
||||
}
|
||||
|
||||
// ---------- 审计(admin) ----------
|
||||
|
||||
export interface AuditListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
actor?: string
|
||||
action?: string
|
||||
resource_type?: string
|
||||
resource_id?: string
|
||||
since?: string
|
||||
until?: string
|
||||
}
|
||||
|
||||
export function listAudit(params: AuditListParams): Promise<PageResult<AuditLog>> {
|
||||
return unwrap(http.get('/audit', { params }))
|
||||
}
|
||||
|
||||
/** 审计 CSV 导出地址(浏览器直接导航下载,cookie 自动携带) */
|
||||
export function auditExportUrl(params: { since?: string; until?: string } = {}): string {
|
||||
const qs = new URLSearchParams()
|
||||
if (params.since) qs.set('since', params.since)
|
||||
if (params.until) qs.set('until', params.until)
|
||||
const s = qs.toString()
|
||||
return `/api/v1/audit/export${s ? `?${s}` : ''}`
|
||||
}
|
||||
|
||||
// ---------- 系统设置(admin) ----------
|
||||
|
||||
export function listSettings(): Promise<{ items: SettingItem[] }> {
|
||||
return unwrap(http.get('/settings'))
|
||||
}
|
||||
|
||||
export function updateSetting(key: string, value: string): Promise<{ status: string; key: string; value: string }> {
|
||||
return unwrap(http.put('/settings', { key, value }))
|
||||
}
|
||||
|
||||
// ---------- 仪表盘统计(admin) ----------
|
||||
|
||||
export function getStats(): Promise<UserStats> {
|
||||
return unwrap(http.get('/admin/stats'))
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// 与后端 RESTful API v1 对应的数据类型(字段对齐 internal/model 的 json tag)。
|
||||
|
||||
/** 外部用户 */
|
||||
export interface User {
|
||||
id: number
|
||||
username: string // 含 ext_ 前缀
|
||||
email: string
|
||||
supervisor: string
|
||||
purpose: string
|
||||
status: 'active' | 'disabled' | 'expired'
|
||||
expire_at: string | null
|
||||
shell: string
|
||||
created_by: number
|
||||
last_login_at: string | null
|
||||
recycled_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** SSH 公钥 */
|
||||
export interface SSHKey {
|
||||
id: number
|
||||
user_id: number
|
||||
name: string
|
||||
key_type: string
|
||||
public_key: string
|
||||
fingerprint: string
|
||||
status: 'active' | 'revoked'
|
||||
source: string
|
||||
created_by: number
|
||||
revoked_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 新账号申请单 */
|
||||
export interface Approval {
|
||||
id: number
|
||||
username_requested: string // 不含 ext_ 前缀
|
||||
email: string
|
||||
supervisor: string
|
||||
purpose: string
|
||||
status: 'pending' | 'approved' | 'rejected'
|
||||
reviewer_id: number | null
|
||||
reviewed_at: string | null
|
||||
reason: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 审计日志 */
|
||||
export interface AuditLog {
|
||||
id: number
|
||||
actor_id: number
|
||||
actor_name: string
|
||||
action: string
|
||||
resource_type: string
|
||||
resource_id: string
|
||||
detail: string // JSON 字符串
|
||||
ip: string
|
||||
result: 'success' | 'failed'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 系统设置项 */
|
||||
export interface SettingItem {
|
||||
key: string
|
||||
value: string
|
||||
overridden: boolean
|
||||
}
|
||||
|
||||
/** 当前会话主体 */
|
||||
export interface MeInfo {
|
||||
user_type: 'admin' | 'user'
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
}
|
||||
|
||||
/** 仪表盘统计 */
|
||||
export interface UserStats {
|
||||
total: number
|
||||
active: number
|
||||
disabled: number
|
||||
expired: number
|
||||
expiring_soon: User[]
|
||||
pending_approvals: number
|
||||
}
|
||||
|
||||
/** 图形验证码 */
|
||||
export interface Captcha {
|
||||
captcha_id: string
|
||||
image: string // data:image/png;base64,...
|
||||
}
|
||||
|
||||
/** 分页结果(后端统一 {total, items}) */
|
||||
export interface PageResult<T> {
|
||||
total: number
|
||||
items: T[]
|
||||
}
|
||||
Vendored
+2
-1
@@ -2,6 +2,7 @@
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
const username = computed(() => auth.me?.username ?? '')
|
||||
const email = computed(() => auth.me?.email ?? '')
|
||||
|
||||
async function onLogout() {
|
||||
await auth.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="admin-layout">
|
||||
<el-aside width="220px" class="aside">
|
||||
<div class="logo">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span>用户管理节点</span>
|
||||
</div>
|
||||
<el-menu :default-active="activeMenu" router class="menu">
|
||||
<el-menu-item index="/admin/dashboard">
|
||||
<el-icon><Odometer /></el-icon><span>仪表盘</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/users">
|
||||
<el-icon><User /></el-icon><span>用户管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/approvals">
|
||||
<el-icon><Tickets /></el-icon><span>申请审批</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/audit">
|
||||
<el-icon><Document /></el-icon><span>审计日志</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/admin/settings">
|
||||
<el-icon><Setting /></el-icon><span>系统设置</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-container>
|
||||
<el-header class="header">
|
||||
<span class="page-title">{{ route.meta.title ?? '' }}</span>
|
||||
<el-dropdown @command="onLogout">
|
||||
<span class="user-chip">
|
||||
<el-avatar :size="28" class="avatar">{{ username.slice(0, 1).toUpperCase() }}</el-avatar>
|
||||
<span>{{ username }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item disabled>{{ email }}</el-dropdown-item>
|
||||
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</el-header>
|
||||
<el-main class="main">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.aside {
|
||||
background: var(--el-bg-color);
|
||||
border-right: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 60px;
|
||||
padding: 0 16px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
.menu {
|
||||
border-right: none;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--el-bg-color);
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
.page-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.user-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
.avatar {
|
||||
background: var(--el-color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.main {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
const username = computed(() => auth.me?.username ?? '')
|
||||
|
||||
async function onLogout() {
|
||||
await auth.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="user-layout">
|
||||
<el-header class="header">
|
||||
<span class="logo">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span>服务器用户管理</span>
|
||||
</span>
|
||||
<el-menu :default-active="activeMenu" router mode="horizontal" :ellipsis="false" class="nav">
|
||||
<el-menu-item index="/me/profile">账号信息</el-menu-item>
|
||||
<el-menu-item index="/me/keys">我的密钥</el-menu-item>
|
||||
<el-menu-item index="/me/audit">我的审计</el-menu-item>
|
||||
</el-menu>
|
||||
<el-dropdown @command="onLogout">
|
||||
<span class="user-chip">
|
||||
<el-avatar :size="26" class="avatar">{{ username.slice(0, 1).toUpperCase() }}</el-avatar>
|
||||
<span>{{ username }}</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</el-header>
|
||||
<el-main class="main">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
background: var(--el-bg-color);
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
.nav {
|
||||
flex: 1;
|
||||
border-bottom: none;
|
||||
}
|
||||
.user-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
.avatar {
|
||||
background: var(--el-color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.main {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import 'element-plus/dist/index.css'
|
||||
|
||||
@@ -11,4 +12,10 @@ const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
|
||||
// 全局注册 Element Plus 图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
+121
-9
@@ -1,15 +1,127 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
// 路由规划(PLAN §8):
|
||||
// - 公开:/login(登录)、/apply(新账号申请)
|
||||
// - admin:/admin/dashboard、/admin/users、/admin/approvals、/admin/audit、/admin/settings
|
||||
// - user:/me(个人中心:账号信息 + 我的密钥 + 我的审计)
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{ path: '/', redirect: '/login' },
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/LoginView.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/apply',
|
||||
name: 'apply',
|
||||
component: () => import('@/views/ApplyView.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('@/layouts/AdminLayout.vue'),
|
||||
meta: { requiresAuth: true, role: 'admin' },
|
||||
children: [
|
||||
{ path: '', redirect: '/admin/dashboard' },
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'admin-dashboard',
|
||||
component: () => import('@/views/admin/DashboardView.vue'),
|
||||
meta: { title: '仪表盘' },
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'admin-users',
|
||||
component: () => import('@/views/admin/UsersView.vue'),
|
||||
meta: { title: '用户管理' },
|
||||
},
|
||||
{
|
||||
path: 'approvals',
|
||||
name: 'admin-approvals',
|
||||
component: () => import('@/views/admin/ApprovalsView.vue'),
|
||||
meta: { title: '申请审批' },
|
||||
},
|
||||
{
|
||||
path: 'audit',
|
||||
name: 'admin-audit',
|
||||
component: () => import('@/views/admin/AuditView.vue'),
|
||||
meta: { title: '审计日志' },
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'admin-settings',
|
||||
component: () => import('@/views/admin/SettingsView.vue'),
|
||||
meta: { title: '系统设置' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/me',
|
||||
component: () => import('@/layouts/UserLayout.vue'),
|
||||
meta: { requiresAuth: true, role: 'user' },
|
||||
children: [
|
||||
{ path: '', redirect: '/me/profile' },
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'me-profile',
|
||||
component: () => import('@/views/me/ProfileView.vue'),
|
||||
meta: { title: '账号信息' },
|
||||
},
|
||||
{
|
||||
path: 'keys',
|
||||
name: 'me-keys',
|
||||
component: () => import('@/views/me/KeysView.vue'),
|
||||
meta: { title: '我的密钥' },
|
||||
},
|
||||
{
|
||||
path: 'audit',
|
||||
name: 'me-audit',
|
||||
component: () => import('@/views/me/MyAuditView.vue'),
|
||||
meta: { title: '我的审计' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/login' },
|
||||
]
|
||||
|
||||
// 骨架阶段:仅首页(占位)。M1 起增加 /login、/admin/*、/me 等路由并做鉴权守卫。
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('@/views/HomeView.vue'),
|
||||
},
|
||||
],
|
||||
routes,
|
||||
})
|
||||
|
||||
// 全局守卫:登录页/申请页放行;受保护页面要求登录且角色匹配。
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.loaded) {
|
||||
await auth.fetchMe()
|
||||
}
|
||||
const me = auth.me
|
||||
|
||||
if (to.meta.public) {
|
||||
// 已登录再访问登录页 → 直接进对应首页
|
||||
if (me && to.name === 'login') {
|
||||
return me.user_type === 'admin' ? { name: 'admin-dashboard' } : { name: 'me-profile' }
|
||||
}
|
||||
return true
|
||||
}
|
||||
const needAuth = to.matched.some((r) => r.meta.requiresAuth)
|
||||
if (needAuth) {
|
||||
if (!me) {
|
||||
return { name: 'login' }
|
||||
}
|
||||
const role = to.meta.role as string | undefined
|
||||
if (role && me.user_type !== role) {
|
||||
return me.user_type === 'admin' ? { name: 'admin-dashboard' } : { name: 'me-profile' }
|
||||
}
|
||||
return true
|
||||
}
|
||||
// 其余路径
|
||||
if (me) {
|
||||
return me.user_type === 'admin' ? { name: 'admin-dashboard' } : { name: 'me-profile' }
|
||||
}
|
||||
return { name: 'login' }
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import * as api from '@/api'
|
||||
import type { MeInfo } from '@/api/types'
|
||||
|
||||
// 会话状态:基于 cookie 会话(withCredentials),本地仅缓存 /auth/me 结果。
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const me = ref<MeInfo | null>(null)
|
||||
const loaded = ref(false)
|
||||
|
||||
/** 拉取当前会话主体;未登录返回 null(401 由拦截器处理,这里不弹错)。 */
|
||||
async function fetchMe(): Promise<MeInfo | null> {
|
||||
try {
|
||||
me.value = await api.me()
|
||||
} catch {
|
||||
me.value = null
|
||||
} finally {
|
||||
loaded.value = true
|
||||
}
|
||||
return me.value
|
||||
}
|
||||
|
||||
function isAdmin(): boolean {
|
||||
return me.value?.user_type === 'admin'
|
||||
}
|
||||
|
||||
function isUser(): boolean {
|
||||
return me.value?.user_type === 'user'
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
await api.logout()
|
||||
} catch {
|
||||
// 会话已失效也视为登出成功
|
||||
}
|
||||
me.value = null
|
||||
}
|
||||
|
||||
return { me, loaded, fetchMe, isAdmin, isUser, logout }
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
// 展示层工具:时间/状态格式化、审计动作中文映射。
|
||||
|
||||
export function fmtTime(v: string | null | undefined): string {
|
||||
if (!v) return '-'
|
||||
const d = new Date(v)
|
||||
if (Number.isNaN(d.getTime())) return v
|
||||
return d.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
export function fmtDate(v: string | null | undefined): string {
|
||||
if (!v) return '-'
|
||||
const d = new Date(v)
|
||||
if (Number.isNaN(d.getTime())) return v
|
||||
return d.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
export function daysLeft(expireAt: string | null): number | null {
|
||||
if (!expireAt) return null
|
||||
const diff = new Date(expireAt).getTime() - Date.now()
|
||||
return Math.ceil(diff / 86400000)
|
||||
}
|
||||
|
||||
// 用户状态中文
|
||||
export const userStatusText: Record<string, string> = {
|
||||
active: '正常',
|
||||
disabled: '已禁用',
|
||||
expired: '已过期',
|
||||
}
|
||||
|
||||
// 申请单状态中文
|
||||
export const approvalStatusText: Record<string, string> = {
|
||||
pending: '待审批',
|
||||
approved: '已通过',
|
||||
rejected: '已拒绝',
|
||||
}
|
||||
|
||||
// 审计动作中文映射(未知动作回显原文)
|
||||
const actionTextMap: Record<string, string> = {
|
||||
'auth.admin_login': '管理员登录',
|
||||
'auth.otp_send': '发送 OTP',
|
||||
'auth.otp_login': 'OTP 登录',
|
||||
'auth.logout': '登出',
|
||||
'user.create': '创建用户',
|
||||
'user.update': '更新用户',
|
||||
'user.disable': '禁用用户',
|
||||
'user.enable': '启用用户',
|
||||
'user.extend': '延期用户',
|
||||
'user.delete': '删除用户',
|
||||
'key.create': '上传密钥',
|
||||
'key.rename': '重命名密钥',
|
||||
'key.revoke': '吊销密钥',
|
||||
'approval.submit': '提交申请',
|
||||
'approval.review': '审批申请',
|
||||
'lifecycle.expire': '到期锁定',
|
||||
'lifecycle.recycle': '回收删除',
|
||||
}
|
||||
|
||||
export function actionText(action: string): string {
|
||||
return actionTextMap[action] || action
|
||||
}
|
||||
|
||||
/** 解析审计 detail(JSON 字符串),解析失败返回原文 */
|
||||
export function parseDetail(detail: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(detail))
|
||||
} catch {
|
||||
return detail
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as api from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const submitting = ref(false)
|
||||
const form = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
supervisor: '',
|
||||
purpose: '',
|
||||
})
|
||||
|
||||
const rules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{
|
||||
pattern: /^[a-z][a-z0-9_]{1,30}$/,
|
||||
message: '小写字母开头,仅小写字母/数字/下划线,2~31 位',
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||
{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' },
|
||||
],
|
||||
supervisor: [{ required: true, message: '请输入挂靠老师', trigger: 'blur' }],
|
||||
purpose: [{ required: true, message: '请说明用途', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
const formRef = ref()
|
||||
|
||||
async function onSubmit() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await api.submitApproval({
|
||||
username: form.username.trim(),
|
||||
email: form.email.trim(),
|
||||
supervisor: form.supervisor.trim(),
|
||||
purpose: form.purpose.trim(),
|
||||
})
|
||||
ElMessage.success('申请已提交,等待管理员审批(结果将邮件通知)')
|
||||
Object.assign(form, { username: '', email: '', supervisor: '', purpose: '' })
|
||||
} catch {
|
||||
// 拦截器已提示(如用户名已被占用)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="apply-page">
|
||||
<el-card class="apply-card" shadow="always">
|
||||
<h2>申请服务器账号</h2>
|
||||
<el-text type="info">提交后由管理员审批;通过后自动创建账号(用户名将带 <code>ext_</code> 前缀),结果以邮件通知。</el-text>
|
||||
<el-divider />
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px" @submit.prevent="onSubmit">
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="form.username" placeholder="如 zhangsan(自动成为 ext_zhangsan)" maxlength="31" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="form.email" placeholder="接收通知与 OTP 验证码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="挂靠老师" prop="supervisor">
|
||||
<el-input v-model="form.supervisor" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用途" prop="purpose">
|
||||
<el-input v-model="form.purpose" type="textarea" :rows="3" placeholder="说明申请用途" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="submitting" @click="onSubmit">提交申请</el-button>
|
||||
<el-button @click="router.push('/login')">返回登录</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.apply-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1f2d3d 0%, #2c3e50 100%);
|
||||
padding: 24px;
|
||||
}
|
||||
.apply-card {
|
||||
width: 520px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,32 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const backendOk = ref<boolean | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const resp = await fetch('/healthz')
|
||||
backendOk.value = resp.ok
|
||||
} catch {
|
||||
backendOk.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<b>工程骨架已就绪</b>
|
||||
</template>
|
||||
<p>ws_usernode M0:Go (Gin + GORM) 后端与 Vue3 前端脚手架已打通,API 前缀 <code>/api/v1</code>。</p>
|
||||
<p>
|
||||
后端健康检查:
|
||||
<el-tag :type="backendOk === null ? 'info' : backendOk ? 'success' : 'danger'">
|
||||
{{ backendOk === null ? '检测中…' : backendOk ? '正常' : '不可达(请先启动 Go 后端)' }}
|
||||
</el-tag>
|
||||
</p>
|
||||
<el-divider />
|
||||
<el-text type="info">登录 / 用户管理 / 密钥 / 审批 / 审计页面将在 M1 起逐步落地。</el-text>
|
||||
</el-card>
|
||||
</template>
|
||||
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as api from '@/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { Captcha } from '@/api/types'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const tab = ref<'admin' | 'user'>('admin')
|
||||
const loading = ref(false)
|
||||
|
||||
// 管理员登录
|
||||
const adminForm = reactive({ username: '', password: '' })
|
||||
|
||||
// 外部用户 OTP 登录
|
||||
const userForm = reactive({ username: '', code: '', captcha_id: '', captcha_code: '' })
|
||||
const captcha = ref<Captcha | null>(null)
|
||||
const captchaLoading = ref(false)
|
||||
const cooldown = ref(0) // OTP 发送冷却倒计时(秒)
|
||||
let cooldownTimer: number | undefined
|
||||
|
||||
async function loadCaptcha() {
|
||||
captchaLoading.value = true
|
||||
try {
|
||||
captcha.value = await api.getCaptcha()
|
||||
} catch {
|
||||
captcha.value = null
|
||||
} finally {
|
||||
captchaLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startCooldown(secs: number) {
|
||||
cooldown.value = secs
|
||||
cooldownTimer = window.setInterval(() => {
|
||||
cooldown.value -= 1
|
||||
if (cooldown.value <= 0) {
|
||||
window.clearInterval(cooldownTimer)
|
||||
cooldown.value = 0
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function onAdminLogin() {
|
||||
if (!adminForm.username || !adminForm.password) {
|
||||
ElMessage.warning('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await api.adminLogin(adminForm)
|
||||
await auth.fetchMe()
|
||||
router.push('/admin/dashboard')
|
||||
} catch {
|
||||
// 错误已由拦截器提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSendOtp() {
|
||||
if (!userForm.username || !userForm.captcha_code) {
|
||||
ElMessage.warning('请输入用户名和图形验证码')
|
||||
return
|
||||
}
|
||||
if (!captcha.value) {
|
||||
await loadCaptcha()
|
||||
if (!captcha.value) return
|
||||
}
|
||||
try {
|
||||
await api.otpSend({
|
||||
username: userForm.username,
|
||||
captcha_id: captcha.value.captcha_id,
|
||||
captcha_code: userForm.captcha_code,
|
||||
})
|
||||
ElMessage.success('验证码已发送(邮件或 CLI:usernode user otp)')
|
||||
startCooldown(60)
|
||||
// 验证码一次性,重新生成
|
||||
userForm.captcha_code = ''
|
||||
await loadCaptcha()
|
||||
} catch {
|
||||
// 验证码错误等已提示;重新生成一次
|
||||
await loadCaptcha()
|
||||
}
|
||||
}
|
||||
|
||||
async function onUserLogin() {
|
||||
if (!userForm.username || !userForm.code) {
|
||||
ElMessage.warning('请输入用户名和 OTP 验证码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await api.otpLogin({ username: userForm.username, code: userForm.code })
|
||||
await auth.fetchMe()
|
||||
router.push('/me/profile')
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onTabChange() {
|
||||
if (tab.value === 'user' && !captcha.value) {
|
||||
loadCaptcha()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (tab.value === 'user') loadCaptcha()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<el-card class="login-card" shadow="always">
|
||||
<div class="brand">
|
||||
<el-icon :size="30"><Monitor /></el-icon>
|
||||
<h2>服务器用户管理节点</h2>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="tab" stretch @tab-change="onTabChange">
|
||||
<!-- 管理员登录 -->
|
||||
<el-tab-pane label="管理员登录" name="admin">
|
||||
<el-form label-position="top" @submit.prevent="onAdminLogin">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="adminForm.username" placeholder="管理员用户名" autocomplete="username" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input
|
||||
v-model="adminForm.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
@keyup.enter="onAdminLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button type="primary" class="submit" :loading="loading" @click="onAdminLogin">登录</el-button>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 外部用户 OTP 登录 -->
|
||||
<el-tab-pane label="外部用户登录" name="user">
|
||||
<el-form label-position="top" @submit.prevent="onUserLogin">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="userForm.username" placeholder="用户名(可带或不带 ext_ 前缀)" autocomplete="username" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图形验证码">
|
||||
<div class="captcha-row">
|
||||
<el-input v-model="userForm.captcha_code" placeholder="4 位验证码" maxlength="4" @keyup.enter="onSendOtp" />
|
||||
<el-image
|
||||
v-if="captcha"
|
||||
:src="captcha.image"
|
||||
fit="contain"
|
||||
class="captcha-img"
|
||||
:title="'点击刷新'"
|
||||
@click="loadCaptcha"
|
||||
/>
|
||||
<el-button v-else :loading="captchaLoading" @click="loadCaptcha">获取</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="登录验证码(OTP)">
|
||||
<el-input
|
||||
v-model="userForm.code"
|
||||
placeholder="6 位 OTP(邮件或 CLI 获取)"
|
||||
maxlength="6"
|
||||
@keyup.enter="onUserLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button type="primary" class="submit" :loading="loading" @click="onSendOtp" :disabled="cooldown > 0">
|
||||
{{ cooldown > 0 ? `重新发送(${cooldown}s)` : '发送验证码' }}
|
||||
</el-button>
|
||||
<el-button type="success" class="submit" :loading="loading" @click="onUserLogin">登录</el-button>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<div class="foot">
|
||||
<router-link to="/apply">申请新账号</router-link>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1f2d3d 0%, #2c3e50 100%);
|
||||
}
|
||||
.login-card {
|
||||
width: 400px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.brand h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
.captcha-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.captcha-img {
|
||||
width: 110px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.submit {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.foot {
|
||||
margin-top: 16px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import * as api from '@/api'
|
||||
import type { Approval } from '@/api/types'
|
||||
import { approvalStatusText, fmtTime } from '@/utils/format'
|
||||
|
||||
const items = ref<Approval[]>([])
|
||||
const statusFilter = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const statusTag: Record<string, 'warning' | 'success' | 'danger'> = {
|
||||
pending: 'warning',
|
||||
approved: 'success',
|
||||
rejected: 'danger',
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await api.listApprovals(statusFilter.value || undefined)
|
||||
items.value = r.items
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 拒绝对话框
|
||||
const rejectVisible = ref(false)
|
||||
const rejectTarget = ref<Approval | null>(null)
|
||||
const rejectForm = reactive({ reason: '' })
|
||||
const rejecting = ref(false)
|
||||
|
||||
function openReject(row: Approval) {
|
||||
rejectTarget.value = row
|
||||
rejectForm.reason = ''
|
||||
rejectVisible.value = true
|
||||
}
|
||||
|
||||
async function onApprove(row: Approval) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`通过后将为 ${row.username_requested} 自动创建账号(ext_${row.username_requested})并邮件通知,确认?`,
|
||||
'审批通过',
|
||||
{ type: 'warning', confirmButtonText: '确认通过', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.reviewApproval(row.id, true, '')
|
||||
ElMessage.success('已通过,账号创建中')
|
||||
load()
|
||||
} catch {
|
||||
// 已提示
|
||||
}
|
||||
}
|
||||
|
||||
async function onReject() {
|
||||
if (!rejectTarget.value) return
|
||||
if (!rejectForm.reason.trim()) {
|
||||
ElMessage.warning('拒绝时必须填写理由')
|
||||
return
|
||||
}
|
||||
rejecting.value = true
|
||||
try {
|
||||
await api.reviewApproval(rejectTarget.value.id, false, rejectForm.reason.trim())
|
||||
ElMessage.success('已拒绝')
|
||||
rejectVisible.value = false
|
||||
load()
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
rejecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<div class="toolbar">
|
||||
<el-select v-model="statusFilter" placeholder="全部状态" clearable style="width: 150px" @change="load">
|
||||
<el-option label="待审批" value="pending" />
|
||||
<el-option label="已通过" value="approved" />
|
||||
<el-option label="已拒绝" value="rejected" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="load">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="items" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="username_requested" label="申请用户名" width="140">
|
||||
<template #default="{ row }">ext_{{ row.username_requested }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="email" label="邮箱" min-width="180" />
|
||||
<el-table-column prop="supervisor" label="挂靠老师" width="120" />
|
||||
<el-table-column prop="purpose" label="用途" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTag[row.status] ?? 'info'" size="small">{{ approvalStatusText[row.status] ?? row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="提交时间" width="170">
|
||||
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="拒绝理由" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.reason || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.status === 'pending'">
|
||||
<el-button link type="success" @click="onApprove(row)">通过</el-button>
|
||||
<el-button link type="danger" @click="openReject(row)">拒绝</el-button>
|
||||
</template>
|
||||
<el-text v-else type="info" size="small">已处理</el-text>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!loading && items.length === 0" description="暂无申请" />
|
||||
</el-card>
|
||||
|
||||
<!-- 拒绝对话框 -->
|
||||
<el-dialog v-model="rejectVisible" title="拒绝申请" width="440px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="用户名">
|
||||
<el-text>{{ rejectTarget?.username_requested }}</el-text>
|
||||
</el-form-item>
|
||||
<el-form-item label="理由" required>
|
||||
<el-input v-model="rejectForm.reason" type="textarea" :rows="3" placeholder="必填,将邮件通知申请人" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="rejectVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="rejecting" @click="onReject">确认拒绝</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import * as api from '@/api'
|
||||
import type { AuditLog } from '@/api/types'
|
||||
import { actionText, fmtTime, parseDetail } from '@/utils/format'
|
||||
|
||||
const items = ref<AuditLog[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const loading = ref(false)
|
||||
|
||||
const filter = reactive({
|
||||
actor: '',
|
||||
action: '',
|
||||
resource_type: '',
|
||||
since: '',
|
||||
until: '',
|
||||
})
|
||||
|
||||
const resultTag: Record<string, 'success' | 'danger'> = { success: 'success', failed: 'danger' }
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await api.listAudit({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
actor: filter.actor || undefined,
|
||||
action: filter.action || undefined,
|
||||
resource_type: filter.resource_type || undefined,
|
||||
since: filter.since ? new Date(filter.since).toISOString() : undefined,
|
||||
until: filter.until ? new Date(filter.until).toISOString() : undefined,
|
||||
})
|
||||
items.value = r.items
|
||||
total.value = r.total
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function onExport() {
|
||||
window.open(
|
||||
api.auditExportUrl({
|
||||
since: filter.since ? new Date(filter.since).toISOString() : undefined,
|
||||
until: filter.until ? new Date(filter.until).toISOString() : undefined,
|
||||
}),
|
||||
'_blank',
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<!-- 筛选工具栏 -->
|
||||
<div class="toolbar">
|
||||
<el-input v-model="filter.actor" placeholder="操作者" clearable style="width: 150px" @keyup.enter="onSearch" />
|
||||
<el-input v-model="filter.action" placeholder="动作" clearable style="width: 160px" @keyup.enter="onSearch" />
|
||||
<el-select v-model="filter.resource_type" placeholder="资源类型" clearable style="width: 130px" @change="onSearch">
|
||||
<el-option label="用户" value="user" />
|
||||
<el-option label="密钥" value="ssh_key" />
|
||||
<el-option label="申请" value="approval" />
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="filter.since"
|
||||
type="datetime"
|
||||
placeholder="起始时间"
|
||||
style="width: 190px"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
/>
|
||||
<el-date-picker
|
||||
v-model="filter.until"
|
||||
type="datetime"
|
||||
placeholder="结束时间"
|
||||
style="width: 190px"
|
||||
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||
/>
|
||||
<el-button type="primary" @click="onSearch">查询</el-button>
|
||||
<div class="spacer" />
|
||||
<el-button type="success" @click="onExport">
|
||||
<el-icon><Download /></el-icon> 导出 CSV
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="items" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="动作" min-width="130">
|
||||
<template #default="{ row }">{{ actionText(row.action) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actor_name" label="操作者" width="120" />
|
||||
<el-table-column label="资源" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" type="info">{{ row.resource_type }}</el-tag>
|
||||
<span class="rid">{{ row.resource_id }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="详情" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ parseDetail(row.detail) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结果" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="resultTag[row.result] ?? 'info'" size="small">
|
||||
{{ row.result === 'success' ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ip" label="IP" width="130" />
|
||||
<el-table-column label="时间" width="170">
|
||||
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
class="pager"
|
||||
@current-change="load"
|
||||
@size-change="load"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 14px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.rid {
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as api from '@/api'
|
||||
import { listAudit } from '@/api'
|
||||
import type { AuditLog, UserStats } from '@/api/types'
|
||||
import { actionText, fmtTime, userStatusText } from '@/utils/format'
|
||||
|
||||
const router = useRouter()
|
||||
const stats = ref<UserStats | null>(null)
|
||||
const recentAudits = ref<AuditLog[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [s, a] = await Promise.all([api.getStats(), listAudit({ page: 1, page_size: 10 })])
|
||||
stats.value = s
|
||||
recentAudits.value = a.items
|
||||
} catch {
|
||||
// 拦截器已提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function goUsers(status?: string) {
|
||||
router.push(status ? { path: '/admin/users', query: { status } } : '/admin/users')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="4">
|
||||
<el-card shadow="never" class="stat-card" @click="goUsers()">
|
||||
<div class="stat-num">{{ stats?.total ?? '-' }}</div>
|
||||
<div class="stat-label">用户总数</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="never" class="stat-card success" @click="goUsers('active')">
|
||||
<div class="stat-num">{{ stats?.active ?? '-' }}</div>
|
||||
<div class="stat-label">正常</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="never" class="stat-card warning" @click="goUsers('disabled')">
|
||||
<div class="stat-num">{{ stats?.disabled ?? '-' }}</div>
|
||||
<div class="stat-label">已禁用</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="never" class="stat-card danger" @click="goUsers('expired')">
|
||||
<div class="stat-num">{{ stats?.expired ?? '-' }}</div>
|
||||
<div class="stat-label">已过期</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="never" class="stat-card primary" @click="router.push('/admin/approvals')">
|
||||
<div class="stat-num">{{ stats?.pending_approvals ?? '-' }}</div>
|
||||
<div class="stat-label">待审批申请</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-card shadow="never" class="stat-card info" @click="goUsers()">
|
||||
<div class="stat-num">{{ stats?.expiring_soon?.length ?? '-' }}</div>
|
||||
<div class="stat-label">30 天内到期</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" class="second-row">
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<b>近期待过期</b>
|
||||
<el-button link type="primary" @click="router.push('/admin/users')">全部用户</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="stats?.expiring_soon ?? []" size="small">
|
||||
<el-table-column prop="username" label="用户名" width="150" />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">
|
||||
{{ userStatusText[row.status] ?? row.status }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="过期时间" min-width="160">
|
||||
<template #default="{ row }">{{ fmtTime(row.expire_at) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!stats?.expiring_soon?.length" description="近 30 天无到期账号" :image-size="60" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<b>最近审计</b>
|
||||
<el-button link type="primary" @click="router.push('/admin/audit')">全部审计</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="recentAudits" size="small">
|
||||
<el-table-column label="动作" min-width="130">
|
||||
<template #default="{ row }">{{ actionText(row.action) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actor_name" label="操作者" width="120" />
|
||||
<el-table-column label="结果" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.result === 'success' ? 'success' : 'danger'" size="small">
|
||||
{{ row.result === 'success' ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="时间" min-width="160">
|
||||
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-num {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.stat-label {
|
||||
margin-top: 4px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.stat-card.success .stat-num {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
.stat-card.warning .stat-num {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
.stat-card.danger .stat-num {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
.stat-card.primary .stat-num {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.stat-card.info .stat-num {
|
||||
color: var(--el-color-info);
|
||||
}
|
||||
.second-row {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as api from '@/api'
|
||||
import type { SettingItem } from '@/api/types'
|
||||
|
||||
const items = ref<SettingItem[]>([])
|
||||
const loading = ref(false)
|
||||
const savingKey = ref('')
|
||||
|
||||
const descriptions: Record<string, { label: string; hint: string }> = {
|
||||
'policy.default_ttl': { label: '默认有效期', hint: '新账号默认有效期(如 2160h = 90 天)' },
|
||||
'policy.recycle_period': { label: '回收期', hint: '到期后保留时长,期内可延期恢复(如 720h = 30 天)' },
|
||||
'policy.audit_retention': { label: '审计保留', hint: '审计日志保留时长,超期先归档再清理(如 720h)' },
|
||||
}
|
||||
|
||||
const editableItems = computed(() =>
|
||||
items.value.filter((it) => it.key in descriptions).map((it) => ({ ...it, ...descriptions[it.key] })),
|
||||
)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await api.listSettings()
|
||||
items.value = r.items
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave(row: { key: string; value: string }) {
|
||||
savingKey.value = row.key
|
||||
try {
|
||||
await api.updateSetting(row.key, row.value.trim())
|
||||
ElMessage.success('已保存并生效')
|
||||
await load()
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
savingKey.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<el-card v-loading="loading" shadow="never">
|
||||
<template #header><b>系统设置</b></template>
|
||||
<el-text type="info">以下策略通过 settings 表动态覆盖配置文件默认值,保存后立即生效。</el-text>
|
||||
<el-table :data="editableItems" stripe class="settings-table">
|
||||
<el-table-column label="配置项" width="140">
|
||||
<template #default="{ row }">{{ row.label }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Key" width="220">
|
||||
<template #default="{ row }"><code>{{ row.key }}</code></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="当前值">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.value" placeholder="时长格式,如 2160h">
|
||||
<template #suffix>
|
||||
<el-tag v-if="row.overridden" type="warning" size="small">已覆盖</el-tag>
|
||||
<el-tag v-else type="info" size="small">默认</el-tag>
|
||||
</template>
|
||||
</el-input>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="说明" min-width="240">
|
||||
<template #default="{ row }">{{ row.hint }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" size="small" :loading="savingKey === row.key" @click="onSave(row)">保存</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-alert type="info" :closable="false" class="notice">
|
||||
提示:SMTP、会话时长等参数在 <code>config.toml</code> 中配置(或环境变量覆盖),不在本页修改。
|
||||
</el-alert>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-table {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.notice {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,405 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import * as api from '@/api'
|
||||
import type { SSHKey, User } from '@/api/types'
|
||||
import { daysLeft, fmtTime, userStatusText } from '@/utils/format'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// 列表与筛选
|
||||
const items = ref<User[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filter = reactive({ status: '', supervisor: '', keyword: '' })
|
||||
const loading = ref(false)
|
||||
|
||||
const statusTag: Record<string, 'success' | 'info' | 'danger'> = {
|
||||
active: 'success',
|
||||
disabled: 'info',
|
||||
expired: 'danger',
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await api.listUsers({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
status: filter.status || undefined,
|
||||
supervisor: filter.supervisor || undefined,
|
||||
})
|
||||
items.value = r.items
|
||||
total.value = r.total
|
||||
} catch {
|
||||
// 拦截器已提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function onPageChange(p: number) {
|
||||
page.value = p
|
||||
load()
|
||||
}
|
||||
|
||||
// 新建用户
|
||||
const createVisible = ref(false)
|
||||
const creating = ref(false)
|
||||
const createForm = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
supervisor: '',
|
||||
purpose: '',
|
||||
ttl_days: 0,
|
||||
})
|
||||
|
||||
async function onCreate() {
|
||||
if (!createForm.username || !createForm.email) {
|
||||
ElMessage.warning('用户名和邮箱必填')
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
await api.createUser({
|
||||
username: createForm.username,
|
||||
email: createForm.email,
|
||||
supervisor: createForm.supervisor,
|
||||
purpose: createForm.purpose,
|
||||
ttl_days: createForm.ttl_days || undefined,
|
||||
})
|
||||
ElMessage.success('创建成功')
|
||||
createVisible.value = false
|
||||
Object.assign(createForm, { username: '', email: '', supervisor: '', purpose: '', ttl_days: 0 })
|
||||
load()
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 详情抽屉(含密钥列表)
|
||||
const detailVisible = ref(false)
|
||||
const detail = ref<User | null>(null)
|
||||
const detailKeys = ref<SSHKey[]>([])
|
||||
const detailLoading = ref(false)
|
||||
|
||||
async function openDetail(row: User) {
|
||||
detail.value = row
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const [u, k] = await Promise.all([api.getUser(row.id), api.listUserKeys(row.id)])
|
||||
detail.value = u
|
||||
detailKeys.value = k.items
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑(邮箱/挂靠老师/用途)
|
||||
const editVisible = ref(false)
|
||||
const editing = ref(false)
|
||||
const editForm = reactive({ email: '', supervisor: '', purpose: '' })
|
||||
|
||||
function openEdit() {
|
||||
if (!detail.value) return
|
||||
editForm.email = detail.value.email
|
||||
editForm.supervisor = detail.value.supervisor
|
||||
editForm.purpose = detail.value.purpose
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function onEdit() {
|
||||
if (!detail.value) return
|
||||
editing.value = true
|
||||
try {
|
||||
await api.updateUser(detail.value.id, {
|
||||
email: editForm.email || undefined,
|
||||
supervisor: editForm.supervisor || undefined,
|
||||
purpose: editForm.purpose || undefined,
|
||||
})
|
||||
ElMessage.success('已更新')
|
||||
editVisible.value = false
|
||||
openDetail(detail.value) // 刷新详情
|
||||
load()
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
editing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 延期
|
||||
const extendVisible = ref(false)
|
||||
const extending = ref(false)
|
||||
const extendDays = ref(0)
|
||||
|
||||
function openExtend() {
|
||||
extendDays.value = 0
|
||||
extendVisible.value = true
|
||||
}
|
||||
|
||||
async function onExtend() {
|
||||
if (!detail.value) return
|
||||
extending.value = true
|
||||
try {
|
||||
const r = await api.extendUser(detail.value.id, extendDays.value)
|
||||
ElMessage.success(`已延期,新过期时间:${fmtTime(r.expire_at)}`)
|
||||
extendVisible.value = false
|
||||
openDetail(detail.value)
|
||||
load()
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
extending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用 / 启用 / 删除
|
||||
async function onDisable() {
|
||||
if (!detail.value) return
|
||||
try {
|
||||
await api.disableUser(detail.value.id)
|
||||
ElMessage.success('已禁用(authorized_keys 已清空)')
|
||||
openDetail(detail.value)
|
||||
load()
|
||||
} catch {
|
||||
// 已提示
|
||||
}
|
||||
}
|
||||
|
||||
async function onEnable() {
|
||||
if (!detail.value) return
|
||||
try {
|
||||
await api.enableUser(detail.value.id)
|
||||
ElMessage.success('已启用')
|
||||
openDetail(detail.value)
|
||||
load()
|
||||
} catch {
|
||||
// 已提示
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
if (!detail.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将删除用户 ${detail.value.username}(系统账号 + 家目录 + 密钥),审计记录保留。确认?`,
|
||||
'危险操作',
|
||||
{ type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.deleteUser(detail.value.id)
|
||||
ElMessage.success('已删除')
|
||||
detailVisible.value = false
|
||||
load()
|
||||
} catch {
|
||||
// 已提示
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.status) filter.status = String(route.query.status)
|
||||
load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<!-- 筛选工具栏 -->
|
||||
<div class="toolbar">
|
||||
<el-input v-model="filter.supervisor" placeholder="挂靠老师" clearable style="width: 180px" @keyup.enter="onSearch" />
|
||||
<el-select v-model="filter.status" placeholder="状态" clearable style="width: 130px" @change="onSearch">
|
||||
<el-option label="正常" value="active" />
|
||||
<el-option label="已禁用" value="disabled" />
|
||||
<el-option label="已过期" value="expired" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="onSearch">查询</el-button>
|
||||
<div class="spacer" />
|
||||
<el-button type="primary" @click="createVisible = true">
|
||||
<el-icon><Plus /></el-icon> 新建用户
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="items" stripe>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="username" label="用户名" min-width="140" />
|
||||
<el-table-column prop="email" label="邮箱" min-width="200" />
|
||||
<el-table-column prop="supervisor" label="挂靠老师" width="130" />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTag[row.status] ?? 'info'" size="small">{{ userStatusText[row.status] ?? row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="过期时间" width="170">
|
||||
<template #default="{ row }">
|
||||
<span>{{ fmtTime(row.expire_at) }}</span>
|
||||
<el-tag v-if="row.status === 'active' && (daysLeft(row.expire_at) ?? 999) <= 30" type="warning" size="small" class="soon-tag">
|
||||
剩 {{ daysLeft(row.expire_at) }} 天
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
class="pager"
|
||||
@current-change="onPageChange"
|
||||
@size-change="onPageChange"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 新建用户对话框 -->
|
||||
<el-dialog v-model="createVisible" title="新建外部用户" width="480px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="用户名" required>
|
||||
<el-input v-model="createForm.username" placeholder="不含 ext_ 前缀(自动生成 ext_<name>)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" required>
|
||||
<el-input v-model="createForm.email" placeholder="接收 OTP 与通知邮件" />
|
||||
</el-form-item>
|
||||
<el-form-item label="挂靠老师">
|
||||
<el-input v-model="createForm.supervisor" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用途">
|
||||
<el-input v-model="createForm.purpose" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="有效期(天)">
|
||||
<el-input-number v-model="createForm.ttl_days" :min="0" :max="3650" />
|
||||
<div class="tip">留空/0 表示使用系统默认有效期</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="creating" @click="onCreate">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" :title="detail?.username ?? ''" size="560px" v-loading="detailLoading">
|
||||
<template v-if="detail">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="用户名">{{ detail.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="邮箱">{{ detail.email }}</el-descriptions-item>
|
||||
<el-descriptions-item label="挂靠老师">{{ detail.supervisor || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用途">{{ detail.purpose || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="statusTag[detail.status] ?? 'info'" size="small">{{ userStatusText[detail.status] ?? detail.status }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="过期时间">{{ fmtTime(detail.expire_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Shell">{{ detail.shell }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近登录">{{ fmtTime(detail.last_login_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ fmtTime(detail.created_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="actions">
|
||||
<el-button v-if="detail.status === 'active'" @click="onDisable">禁用</el-button>
|
||||
<el-button v-if="detail.status === 'disabled'" type="success" @click="onEnable">启用</el-button>
|
||||
<el-button v-if="detail.status === 'expired'" type="warning" @click="openExtend">延期恢复</el-button>
|
||||
<el-button type="primary" plain @click="openExtend">延期</el-button>
|
||||
<el-button @click="openEdit">编辑信息</el-button>
|
||||
<el-button type="danger" plain @click="onDelete">删除</el-button>
|
||||
</div>
|
||||
|
||||
<el-divider content-position="left">SSH 公钥({{ detailKeys.length }})</el-divider>
|
||||
<el-table :data="detailKeys" size="small">
|
||||
<el-table-column prop="name" label="名称" width="140" />
|
||||
<el-table-column prop="key_type" label="类型" width="120" />
|
||||
<el-table-column prop="fingerprint" label="指纹" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 'active' ? '生效' : '已吊销' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 编辑信息对话框 -->
|
||||
<el-dialog v-model="editVisible" title="编辑用户信息" width="460px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="editForm.email" />
|
||||
</el-form-item>
|
||||
<el-form-item label="挂靠老师">
|
||||
<el-input v-model="editForm.supervisor" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用途">
|
||||
<el-input v-model="editForm.purpose" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editing" @click="onEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 延期对话框 -->
|
||||
<el-dialog v-model="extendVisible" title="延长有效期" width="400px">
|
||||
<el-form label-width="120px">
|
||||
<el-form-item label="延长天数">
|
||||
<el-input-number v-model="extendDays" :min="0" :max="3650" />
|
||||
<div class="tip">留空/0 表示使用系统默认有效期</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="extendVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="extending" @click="onExtend">确认延期</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 14px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.soon-tag {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import * as api from '@/api'
|
||||
import type { SSHKey } from '@/api/types'
|
||||
import { fmtTime } from '@/utils/format'
|
||||
|
||||
const keys = ref<SSHKey[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const uploadVisible = ref(false)
|
||||
const uploading = ref(false)
|
||||
const uploadForm = reactive({ name: '', public_key: '' })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await api.listMyKeys()
|
||||
keys.value = r.items
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onUpload() {
|
||||
if (!uploadForm.name.trim()) {
|
||||
ElMessage.warning('请填写密钥名称')
|
||||
return
|
||||
}
|
||||
if (!uploadForm.public_key.trim()) {
|
||||
ElMessage.warning('请粘贴公钥内容')
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
try {
|
||||
await api.createKey({ name: uploadForm.name.trim(), public_key: uploadForm.public_key.trim() })
|
||||
ElMessage.success('上传成功,已同步到 authorized_keys')
|
||||
uploadVisible.value = false
|
||||
Object.assign(uploadForm, { name: '', public_key: '' })
|
||||
load()
|
||||
} catch {
|
||||
// 已提示(格式错误/重复/非 active 等)
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const renameVisible = ref(false)
|
||||
const renameTarget = ref<SSHKey | null>(null)
|
||||
const renameName = ref('')
|
||||
const renaming = ref(false)
|
||||
|
||||
function openRename(row: SSHKey) {
|
||||
renameTarget.value = row
|
||||
renameName.value = row.name
|
||||
renameVisible.value = true
|
||||
}
|
||||
|
||||
async function onRename() {
|
||||
if (!renameTarget.value || !renameName.value.trim()) return
|
||||
renaming.value = true
|
||||
try {
|
||||
await api.renameKey(renameTarget.value.id, renameName.value.trim())
|
||||
ElMessage.success('已重命名')
|
||||
renameVisible.value = false
|
||||
load()
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
renaming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onRevoke(row: SSHKey) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`吊销密钥「${row.name}」(${row.fingerprint})后将立即从 authorized_keys 移除,SSH 登录立即失效。确认?`,
|
||||
'吊销密钥',
|
||||
{ type: 'warning', confirmButtonText: '确认吊销', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.revokeKey(row.id)
|
||||
ElMessage.success('已吊销')
|
||||
load()
|
||||
} catch {
|
||||
// 已提示
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<b>我的 SSH 公钥({{ keys.length }})</b>
|
||||
<el-button type="primary" size="small" @click="uploadVisible = true">
|
||||
<el-icon><Plus /></el-icon> 上传公钥
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table v-loading="loading" :data="keys" stripe>
|
||||
<el-table-column prop="name" label="名称" min-width="140" />
|
||||
<el-table-column prop="key_type" label="类型" width="120" />
|
||||
<el-table-column prop="fingerprint" label="指纹" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 'active' ? '生效' : '已吊销' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上传时间" width="170">
|
||||
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.status === 'active'">
|
||||
<el-button link type="primary" @click="openRename(row)">重命名</el-button>
|
||||
<el-button link type="danger" @click="onRevoke(row)">吊销</el-button>
|
||||
</template>
|
||||
<el-text v-else type="info" size="small">已吊销</el-text>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!loading && keys.length === 0" description="暂无密钥,上传公钥后即可 SSH 登录" />
|
||||
</el-card>
|
||||
|
||||
<!-- 上传公钥 -->
|
||||
<el-dialog v-model="uploadVisible" title="上传 SSH 公钥" width="520px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="uploadForm.name" placeholder="如 我的笔记本" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="公钥" required>
|
||||
<el-input
|
||||
v-model="uploadForm.public_key"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
placeholder="ssh-ed25519 AAAA... 或 ssh-rsa AAAA..."
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="uploadVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="uploading" @click="onUpload">上传</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 重命名 -->
|
||||
<el-dialog v-model="renameVisible" title="重命名密钥" width="400px">
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="renameName" maxlength="64" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="renameVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="renaming" @click="onRename">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import * as api from '@/api'
|
||||
import type { AuditLog } from '@/api/types'
|
||||
import { actionText, fmtTime, parseDetail } from '@/utils/format'
|
||||
|
||||
const items = ref<AuditLog[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const loading = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r = await api.myAudit(page.value, pageSize.value)
|
||||
items.value = r.items
|
||||
total.value = r.total
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<el-card shadow="never">
|
||||
<template #header><b>我的审计痕迹</b></template>
|
||||
<el-table v-loading="loading" :data="items" stripe>
|
||||
<el-table-column label="动作" min-width="140">
|
||||
<template #default="{ row }">{{ actionText(row.action) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="详情" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ parseDetail(row.detail) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结果" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.result === 'success' ? 'success' : 'danger'" size="small">
|
||||
{{ row.result === 'success' ? '成功' : '失败' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ip" label="IP" width="130" />
|
||||
<el-table-column label="时间" width="170">
|
||||
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
class="pager"
|
||||
@current-change="load"
|
||||
@size-change="load"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pager {
|
||||
margin-top: 14px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import * as api from '@/api'
|
||||
import type { User } from '@/api/types'
|
||||
import { daysLeft, fmtTime, userStatusText } from '@/utils/format'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const user = ref<User | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const username = computed(() => auth.me?.username ?? '')
|
||||
|
||||
const statusTag: Record<string, 'success' | 'info' | 'danger'> = {
|
||||
active: 'success',
|
||||
disabled: 'info',
|
||||
expired: 'danger',
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// 用户名含 ext_ 前缀,经 GetByUsername 语义查询详情
|
||||
const list = await api.listUsers({ page_size: 100 })
|
||||
const me = list.items.find((u) => u.username === username.value) ?? null
|
||||
user.value = me
|
||||
} catch {
|
||||
// 已提示
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading">
|
||||
<el-card shadow="never" class="profile-card">
|
||||
<template #header><b>账号信息</b></template>
|
||||
<template v-if="user">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="用户名">{{ user.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="邮箱">{{ user.email }}</el-descriptions-item>
|
||||
<el-descriptions-item label="挂靠老师">{{ user.supervisor || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用途">{{ user.purpose || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="statusTag[user.status] ?? 'info'" size="small">{{ userStatusText[user.status] ?? user.status }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="过期时间">
|
||||
<span>{{ fmtTime(user.expire_at) }}</span>
|
||||
<el-tag v-if="user.status === 'active' && (daysLeft(user.expire_at) ?? 999) <= 30" type="warning" size="small" class="soon-tag">
|
||||
剩余 {{ daysLeft(user.expire_at) }} 天
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最近登录">{{ fmtTime(user.last_login_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Shell">{{ user.shell }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-alert v-if="user.status === 'expired'" type="error" :closable="false" class="notice">
|
||||
账号已过期。请在回收期内联系管理员办理延期,否则将被自动回收删除。
|
||||
</el-alert>
|
||||
<el-alert type="info" :closable="false" class="notice">
|
||||
系统账号口令已锁定(仅支持 SSH 密钥登录)。请到「我的密钥」上传公钥后即可 SSH 登录。
|
||||
</el-alert>
|
||||
</template>
|
||||
<el-empty v-else description="未找到账号信息" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-card {
|
||||
max-width: 640px;
|
||||
}
|
||||
.notice {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.soon-tag {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user