feat(frontend): exec-logs重构为执行日志并接入接口; diagnosis美化; alert-config原型改造; 顶部下拉与用户菜单交互优化; 个人主页接入 /api/v1/user/me; 账号管理原型; 新增隐藏侧边栏按钮与 UI Store; 添加后端操 BACKEND-OPS.md

pull/46/head
hnu202326010131 2 months ago
parent f229b763b1
commit 103e72c7f3

@ -1 +1 @@
VITE_API_TARGET=https://blvd-viii-calendars-spare.trycloudflare.com
VITE_API_TARGET=https://ellis-investigations-harley-think.trycloudflare.com

@ -2,7 +2,7 @@
<div class="layout">
<HeaderNav />
<div class="layout__container">
<Sidebar />
<Sidebar v-show="!ui.sidebarHidden" />
<main class="layout__main">
<router-view />
</main>
@ -13,6 +13,8 @@
<script setup lang="ts">
import HeaderNav from './components/HeaderNav.vue'
import Sidebar from './components/Sidebar.vue'
import { useUIStore } from './stores/ui'
const ui = useUIStore()
</script>
<style>
@ -46,4 +48,3 @@ body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial
.u-ml-2 { margin-left: 8px }
.u-ml-3 { margin-left: 12px }
</style>

@ -3,17 +3,35 @@
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from 'vue'
import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
import * as echarts from 'echarts'
import api from '../lib/api'
import { useAuthStore } from '../stores/auth'
const props = defineProps<{ cluster: string }>()
const auth = useAuthStore()
const root = ref<HTMLElement|null>(null)
let chart: echarts.ECharts | null = null
function render(times: string[], values: number[]) {
chart?.setOption({ xAxis: { type: 'category', boundaryGap: false, data: times }, yAxis: { type: 'value', min:0, max:100 }, series: [{ type: 'line', smooth: true, areaStyle: {}, data: values }] })
}
async function load() {
if (!chart) return
try {
const r = await api.get('/v1/metrics/cpu_trend', { params: { cluster: props.cluster }, headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
const times = Array.isArray(r.data?.times) ? r.data.times : ['00:00','04:00','08:00','12:00','16:00','20:00','24:00']
const values = Array.isArray(r.data?.values) ? r.data.values : [20,35,45,60,55,40,30]
render(times, values)
} catch {
render(['00:00','04:00','08:00','12:00','16:00','20:00','24:00'], [20,35,45,60,55,40,30])
}
}
onMounted(() => {
if (!root.value) return
chart = echarts.init(root.value)
chart.setOption({ xAxis: { type: 'category', boundaryGap: false, data: ['00:00','04:00','08:00','12:00','16:00','20:00','24:00'] }, yAxis: { type: 'value', min:0, max:100 }, series: [{ type: 'line', smooth: true, data: [20,35,45,60,55,40,30] }] })
load()
const onResize = () => chart && chart.resize()
window.addEventListener('resize', onResize)
})
watch(() => props.cluster, () => load())
onBeforeUnmount(() => { chart?.dispose(); chart = null })
</script>

@ -6,27 +6,32 @@
<RouterLink class="header__nav-item" :class="{ 'header__nav-item--active': isActive('/cluster-list') }" to="/cluster-list">集群列表</RouterLink>
<RouterLink class="header__nav-item" :class="{ 'header__nav-item--active': isActive('/logs') }" to="/logs">日志查询</RouterLink>
<RouterLink v-if="can(['admin','operator'])" class="header__nav-item" :class="{ 'header__nav-item--active': isActive('/diagnosis') }" to="/diagnosis"></RouterLink>
<RouterLink class="header__nav-item" :class="{ 'header__nav-item--active': isActive('/fault-center') }" to="/fault-center">故障中心</RouterLink>
<RouterLink class="header__nav-item" :class="{ 'header__nav-item--active': isActive('/exec-logs') }" to="/exec-logs">执行日志</RouterLink>
<div class="header__dropdown" v-if="can(['admin','operator'])">
<button class="header__nav-item">系统配置 <i class="fas fa-chevron-down"></i></button>
<div class="header__dropdown-menu">
<RouterLink class="header__dropdown-item" to="/alert-config">告警配置</RouterLink>
<button class="header__nav-item header__dropdown-trigger" type="button" aria-haspopup="true" :aria-expanded="configOpen ? 'true' : 'false'" @click.stop.prevent="toggleConfig">
系统配置
<i class="fas fa-chevron-down header__dropdown-icon" :class="{ 'icon-rot': configOpen }" aria-hidden="true"></i>
</button>
<div class="header__dropdown-menu" :class="{ 'header__dropdown-menu--show': configOpen }" role="menu">
<RouterLink class="header__dropdown-item" to="/alert-config" role="menuitem" @click.stop="closeAll">告警配置</RouterLink>
</div>
</div>
</nav>
</div>
<div class="header__right">
<div class="header__search">
<input id="global-search" class="header__search-input" placeholder="搜索节点、日志或配置..." />
<i class="fas fa-search header__search-icon"></i>
</div>
<div class="header__user-menu" v-if="authed">
<button class="header__user-avatar"><i class="fas fa-user"></i></button>
<div class="header__user-dropdown">
<RouterLink class="header__user-dropdown-item" to="/profile">个人主页</RouterLink>
<RouterLink class="header__user-dropdown-item" to="/account">账号管理</RouterLink>
<a class="header__user-dropdown-item" href="#" @click.prevent="onLogout">退出登录</a>
<div class="header__right">
<div class="header__search">
<input id="global-search" class="header__search-input" placeholder="搜索节点、日志或配置..." />
<i class="fas fa-search header__search-icon"></i>
</div>
<button class="btn u-ml-3" type="button" @click="toggleSidebar">{{ ui.sidebarHidden ? '' : '' }}</button>
<div class="header__user-menu" v-if="authed">
<button class="header__user-avatar" type="button">
<i class="fas fa-user"></i>
</button>
<div class="header__user-dropdown" role="menu">
<RouterLink class="header__user-dropdown-item" to="/profile" role="menuitem">个人主页</RouterLink>
<RouterLink class="header__user-dropdown-item" to="/account" role="menuitem">账号管理</RouterLink>
<a class="header__user-dropdown-item" href="#" role="menuitem" @click.prevent="onLogout">退出登录</a>
</div>
</div>
</div>
@ -34,10 +39,12 @@
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { RouterLink } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useAuthStore } from '../stores/auth'
import { useUIStore } from '../stores/ui'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
@ -46,4 +53,30 @@ function isActive(p: string) { return route.path === p }
const authed = isAuthenticated
function can(roles: string[]) { return roles.includes(role.value || '') }
function onLogout() { auth.logout(); router.replace({ name: 'login' }) }
const ui = useUIStore()
function toggleSidebar(){ ui.toggleSidebar() }
const configOpen = ref(false)
function toggleConfig(){ closeAll(); configOpen.value = !configOpen.value }
function onDocClick(){ closeAll() }
onMounted(()=>{ document.addEventListener('click', onDocClick) })
onUnmounted(()=>{ document.removeEventListener('click', onDocClick) })
function closeAll(){ configOpen.value = false }
</script>
<style scoped>
.header__dropdown{ position: relative }
.header__dropdown-trigger{ display:flex; align-items:center; gap:6px }
.header__dropdown-icon{ font-size:12px; transition: transform 120ms ease }
.icon-rot{ transform: rotate(180deg) }
.header__dropdown-menu{ position:absolute; top:100%; left:0; margin-top:4px; width:12rem; background:#fff; border-radius:8px; box-shadow:0 12px 32px rgba(16,24,40,0.12); padding:4px 0; opacity:0; visibility:hidden; transform: translateY(-8px); transition: all 120ms ease }
.header__dropdown-menu--show{ opacity:1; visibility:visible; transform: translateY(0) }
.header__dropdown-item{ display:block; padding:8px 12px; border-radius:6px }
.header__dropdown-item:hover{ background:#f3f4f6 }
.header__user-menu{ position: relative }
.header__user-avatar{ width:32px; height:32px; border-radius:50%; display:flex; align-items:center; justify-content:center; border:1px solid #e5e7eb; background:#fff }
.header__user-dropdown{ position:absolute; top:100%; right:0; margin-top:4px; width:12rem; background:#fff; border-radius:8px; box-shadow:0 12px 32px rgba(16,24,40,0.12); padding:4px 0; opacity:0; visibility:hidden; transform: translateY(-8px); transition: all 120ms ease }
.header__user-menu:hover .header__user-dropdown{ opacity:1; visibility:visible; transform: translateY(0) }
.header__user-dropdown-item{ display:block; padding:8px 12px; border-radius:6px }
.header__user-dropdown-item:hover{ background:#f3f4f6 }
</style>

@ -3,17 +3,35 @@
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from 'vue'
import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
import * as echarts from 'echarts'
import api from '../lib/api'
import { useAuthStore } from '../stores/auth'
const props = defineProps<{ cluster: string }>()
const auth = useAuthStore()
const root = ref<HTMLElement|null>(null)
let chart: echarts.ECharts | null = null
function render(used: number, free: number) {
chart?.setOption({ series: [{ type: 'pie', radius: ['40%','70%'], data: [{ value: used, name: '已使用' }, { value: free, name: '可用' }] }] })
}
async function load() {
if (!chart) return
try {
const r = await api.get('/v1/metrics/memory_usage', { params: { cluster: props.cluster }, headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
const used = Number(r.data?.used ?? 8.5)
const free = Number(r.data?.free ?? 15.5)
render(used, free)
} catch {
render(8.5, 15.5)
}
}
onMounted(() => {
if (!root.value) return
chart = echarts.init(root.value)
chart.setOption({ series: [{ type: 'pie', radius: ['40%','70%'], data: [{ value: 8.5, name: '已使用' }, { value: 15.5, name: '可用' }] }] })
load()
const onResize = () => chart && chart.resize()
window.addEventListener('resize', onResize)
})
watch(() => props.cluster, () => load())
onBeforeUnmount(() => { chart?.dispose(); chart = null })
</script>

@ -1,6 +1,7 @@
<template>
<aside class="sidebar" role="complementary" aria-label="">
<nav class="sidebar__nav" role="navigation" aria-label="">
<div class="sidebar__section-title">角色权限控制</div>
<RouterLink v-if="isAdmin" class="sidebar__link" :class="{ 'sidebar__link--active': isActive('/user-management') }" to="/user-management"></RouterLink>
<RouterLink v-if="isAdmin" class="sidebar__link" :class="{ 'sidebar__link--active': isActive('/role-assignment') }" to="/role-assignment"></RouterLink>
<RouterLink v-if="isAdmin" class="sidebar__link" :class="{ 'sidebar__link--active': isActive('/permission-policy') }" to="/permission-policy"></RouterLink>
@ -20,3 +21,7 @@ const { role } = storeToRefs(auth)
function isActive(p: string) { return route.path === p }
const isAdmin = computed(() => role.value === 'admin')
</script>
<style scoped>
.sidebar__section-title{ color:#6b7280; font-size:13px; font-weight:600; margin-bottom:8px }
</style>

@ -9,7 +9,6 @@ const routes: RouteRecordRaw[] = [
{ path: '/dashboard', name: 'dashboard', component: () => import('../views/Dashboard.vue'), meta: { requiresAuth: true, roles: ['admin','operator','observer'] } },
{ path: '/logs', name: 'logs', component: () => import('../views/Logs.vue'), meta: { requiresAuth: true, roles: ['admin','operator','observer'] } },
{ path: '/diagnosis', name: 'diagnosis', component: () => import('../views/Diagnosis.vue'), meta: { requiresAuth: true, roles: ['admin','operator'] } },
{ path: '/fault-center', name: 'fault-center', component: () => import('../views/FaultCenter.vue'), meta: { requiresAuth: true, roles: ['admin','operator','observer'] } },
{ path: '/exec-logs', name: 'exec-logs', component: () => import('../views/ExecLogs.vue'), meta: { requiresAuth: true, roles: ['admin','operator','observer'] } },
{ path: '/alert-config', name: 'alert-config', component: () => import('../views/AlertConfig.vue'), meta: { requiresAuth: true, roles: ['admin','operator'] } },
{ path: '/profile', name: 'profile', component: () => import('../views/Profile.vue'), meta: { requiresAuth: true, roles: ['admin','operator','observer'] } },
@ -32,4 +31,3 @@ router.beforeEach((to) => {
})
export default router

@ -0,0 +1,11 @@
import { defineStore } from 'pinia'
export const useUIStore = defineStore('ui', {
state: () => ({ sidebarHidden: false }),
actions: {
toggleSidebar() { this.sidebarHidden = !this.sidebarHidden },
hideSidebar() { this.sidebarHidden = true },
showSidebar() { this.sidebarHidden = false }
}
})

@ -1,4 +1,57 @@
<template>
<section class="layout__section"><div class="layout__page-header"><h2 class="layout__page-title">账号管理</h2></div></section>
<section class="layout__section">
<div class="layout__page-header exec-header">
<div>
<h2 class="layout__page-title">账号管理原型</h2>
<div class="layout__page-subtitle">修改密码设置双因素认证等</div>
</div>
<div class="layout__page-actions"><button class="btn btn--primary" type="button" @click="save"></button></div>
</div>
<article class="layout__card u-mt-2">
<div class="layout__card-header"><h3 class="layout__card-title">安全设置</h3></div>
<div class="layout__card-body">
<form class="layout__grid layout__grid--3" @submit.prevent="save">
<div>
<label class="u-text-sm u-font-medium u-text-gray-700">当前密码</label>
<input v-model.trim="form.current" type="password" class="u-w-full u-p-2 u-border u-rounded u-mt-1" placeholder="••••••••" autocomplete="current-password" />
</div>
<div>
<label class="u-text-sm u-font-medium u-text-gray-700">新密码</label>
<input v-model.trim="form.next" type="password" class="u-w-full u-p-2 u-border u-rounded u-mt-1" placeholder="至少8位" autocomplete="new-password" />
</div>
<div>
<label class="u-text-sm u-font-medium u-text-gray-700">确认新密码</label>
<input v-model.trim="form.confirm" type="password" class="u-w-full u-p-2 u-border u-rounded u-mt-1" placeholder="再次输入" autocomplete="new-password" />
</div>
</form>
<div class="u-text-sm u-text-gray-700 u-mt-2" role="alert">{{ err }}</div>
</div>
</article>
</section>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
const form = reactive({ current:'', next:'', confirm:'' })
const err = ref('')
function save(){
err.value = ''
if (!form.current || !form.next || !form.confirm) { err.value = '请填写完整密码信息'; return }
if (form.next.length < 8) { err.value = '新密码至少8位'; return }
if (form.next !== form.confirm) { err.value = '两次输入的新密码不一致'; return }
err.value = '已保存(示例界面,未接入后端)'
}
</script>
<style scoped>
.exec-header{ display:flex; justify-content:space-between; align-items:center }
.layout__page-subtitle{ color:#6b7280; font-size:13px }
.layout__card{ background:#ffffff; border:1px solid #e5e7eb; border-radius:12px; box-shadow:0 8px 24px rgba(16,24,40,0.06) }
.layout__card-header{ padding:12px 16px; border-bottom:1px solid #e5e7eb }
.layout__card-title{ font-size:14px; font-weight:600 }
.layout__card-body{ padding:16px }
.layout__grid{ display:grid; gap:16px }
.layout__grid--3{ grid-template-columns: 1fr 1fr 1fr }
.btn--primary{ background:#2563eb; color:#fff; border-color:#2563eb }
</style>

@ -1,35 +1,120 @@
<template>
<section class="layout__section">
<div class="layout__page-header"><h2 class="layout__page-title">告警配置</h2></div>
<div class="u-mb-2"><button class="btn" @click="open=true"></button></div>
<table id="alert-rules-table" class="dashboard__table"><thead><tr><th>名称</th><th>条件</th><th>级别</th><th>渠道</th><th>操作</th></tr></thead><tbody id="alert-rules-tbody"><tr v-for="r in rules" :key="r.name" class="dashboard__table-row"><td>{{ r.name }}</td><td>{{ r.cond }}</td><td>{{ r.level }}</td><td>{{ r.channel }}</td><td><button class="btn u-text-sm" @click="edit(r)">编辑</button><button class="btn u-text-sm u-ml-1" @click="del(r.name)"></button></td></tr></tbody></table>
<div v-show="open" class="u-mt-2">
<form @submit.prevent="save">
<input v-model.trim="form.name" placeholder="规则名" class="header__search-input" />
<input v-model.trim="form.cond" placeholder="条件表达式" class="header__search-input" />
<select v-model="form.level" class="header__search-input"><option>INFO</option><option>WARN</option><option>ERROR</option></select>
<select v-model="form.channel" class="header__search-input"><option>EMAIL</option><option>SMS</option><option>WEBHOOK</option></select>
<button class="btn">保存</button>
<button class="btn u-ml-1" type="button" @click="open=false"></button>
</form>
<div class="u-text-sm u-text-gray-700">{{ err }}</div>
<div class="layout__page-header exec-header">
<div>
<h2 class="layout__page-title">告警配置原型</h2>
<div class="layout__page-subtitle">设置告警规则通知渠道与阈值</div>
</div>
<div class="header-actions"><button class="btn btn--primary" type="button" @click="open=true"></button></div>
</div>
<article class="layout__card u-mt-2">
<div class="layout__card-header"><h3 class="layout__card-title">通知与阈值</h3></div>
<div class="layout__card-body">
<form class="layout__grid layout__grid--3" @submit.prevent>
<div>
<label class="u-text-sm u-font-medium u-text-gray-700">告警严重级别</label>
<select v-model="severity" class="u-w-full u-p-2 u-border u-rounded u-mt-1">
<option value="INFO">INFO</option>
<option value="WARN">WARN</option>
<option value="ERROR">ERROR</option>
</select>
<div class="u-mt-2">
<label class="u-text-sm u-font-medium u-text-gray-700"><input type="checkbox" v-model="enableEmail" class="u-mr-1" />启用邮件通知</label>
</div>
<div class="u-mt-1">
<label class="u-text-sm u-font-medium u-text-gray-700"><input type="checkbox" v-model="enableSms" class="u-mr-1" />启用短信通知</label>
</div>
</div>
<div>
<label class="u-text-sm u-font-medium u-text-gray-700">邮件接收人</label>
<input v-model.trim="email" class="u-w-full u-p-2 u-border u-rounded u-mt-1" placeholder="ops@example.com" />
</div>
<div>
<label class="u-text-sm u-font-medium u-text-gray-700">Webhook 地址</label>
<input v-model.trim="webhook" class="u-w-full u-p-2 u-border u-rounded u-mt-1" placeholder="https://hooks.example.com/alert" />
<div class="u-mt-2">
<label class="u-text-sm u-font-medium u-text-gray-700"><input type="checkbox" v-model="enableWebhook" class="u-mr-1" />启用 Webhook</label>
</div>
</div>
</form>
</div>
</article>
<article class="layout__card u-mt-3">
<div class="layout__card-header"><h3 class="layout__card-title">规则列表占位数据</h3></div>
<div class="layout__card-body u-p-0">
<table class="dashboard__table">
<thead><tr><th>规则名称</th><th>条件</th><th>级别</th><th>通知渠道</th><th>操作</th></tr></thead>
<tbody>
<tr v-for="r in rules" :key="r.name" class="dashboard__table-row">
<td>{{ r.name }}</td>
<td>{{ r.cond }}</td>
<td><span :class="levelClass(r.level)">{{ r.level }}</span></td>
<td>{{ r.channel }}</td>
<td><button class="btn u-text-sm" type="button" @click="edit(r)"></button><button class="btn u-text-sm u-ml-1" type="button" @click="del(r.name)"></button></td>
</tr>
</tbody>
</table>
</div>
</article>
<article class="layout__card u-mt-3" v-show="open">
<div class="layout__card-header"><h3 class="layout__card-title">新增配置</h3></div>
<div class="layout__card-body">
<form @submit.prevent="save">
<div class="form-grid">
<input v-model.trim="form.name" class="header__search-input" placeholder="规则名称" />
<input v-model.trim="form.cond" class="header__search-input" placeholder="条件描述" />
<select v-model="form.level" class="header__search-input"><option value="INFO">INFO</option><option value="WARN">WARN</option><option value="ERROR">ERROR</option></select>
<input v-model.trim="form.channel" class="header__search-input" placeholder="通知渠道,如 邮件 或 邮件+Webhook" />
</div>
<div class="u-mt-2"><button class="btn btn--primary" type="submit">保存</button><button class="btn u-ml-1" type="button" @click="open=false"></button></div>
<div class="u-text-sm u-text-gray-700 u-mt-1">{{ err }}</div>
</form>
</div>
</article>
</section>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
const rules = reactive<{ name:string; cond:string; level:string; channel:string }[]>([])
const severity = ref<'INFO'|'WARN'|'ERROR'>('INFO')
const enableEmail = ref(false)
const enableSms = ref(false)
const enableWebhook = ref(false)
const email = ref('ops@example.com')
const webhook = ref('https://hooks.example.com/alert')
const rules = reactive<{ name:string; cond:string; level:'INFO'|'WARN'|'ERROR'; channel:string }[]>([
{ name:'cpu-high-usage', cond:'CPU > 85% 持续 5 分钟', level:'WARN', channel:'邮件' },
{ name:'node-disconnected', cond:'心跳丢失 3 次', level:'ERROR', channel:'邮件 + Webhook' }
])
const open = ref(false)
const err = ref('')
const form = reactive({ name:'', cond:'', level:'INFO', channel:'EMAIL' })
function save() {
const form = reactive<{ name:string; cond:string; level:'INFO'|'WARN'|'ERROR'; channel:string }>({ name:'', cond:'', level:'INFO', channel:'' })
function levelClass(l:'INFO'|'WARN'|'ERROR'){ return l==='ERROR'?'level--error': l==='WARN'?'level--warn':'level--info' }
function save(){
if (!form.name || !form.cond) { err.value='请填写规则名称与条件'; return }
if (rules.some(r => r.name===form.name)) { err.value='规则名称已存在'; return }
rules.push({ name: form.name, cond: form.cond, level: form.level, channel: form.channel })
err.value=''; open.value=false; form.name=''; form.cond=''
rules.push({ name: form.name, cond: form.cond, level: form.level, channel: form.channel || '邮件' })
err.value=''; open.value=false; form.name=''; form.cond=''; form.level='INFO'; form.channel=''
}
function del(n: string) { const i = rules.findIndex(r => r.name===n); if (i>=0) rules.splice(i,1) }
function edit(r: any) { open.value=true; form.name=r.name; form.cond=r.cond; form.level=r.level; form.channel=r.channel }
function del(n:string){ const i = rules.findIndex(r => r.name===n); if (i>=0) rules.splice(i,1) }
function edit(r:any){ open.value=true; form.name=r.name; form.cond=r.cond; form.level=r.level; form.channel=r.channel }
</script>
<style scoped>
.exec-header{ display:flex; justify-content:space-between; align-items:center }
.layout__page-subtitle{ color:#6b7280; font-size:13px }
.layout__card{ background:#ffffff; border:1px solid #e5e7eb; border-radius:12px; box-shadow:0 8px 24px rgba(16,24,40,0.06) }
.layout__card-header{ padding:12px 16px; border-bottom:1px solid #e5e7eb }
.layout__card-title{ font-size:14px; font-weight:600 }
.layout__card-body{ padding:16px }
.layout__grid{ display:grid; gap:16px }
.layout__grid--3{ grid-template-columns: 1fr 1fr 1fr }
.form-grid{ display:grid; grid-template-columns: repeat(4, 1fr); gap:12px }
.btn--primary{ background:#2563eb; color:#fff; border-color:#2563eb }
.level--info{ color:#2563eb }
.level--warn{ color:#f59e0b }
.level--error{ color:#dc2626 }
</style>

@ -30,13 +30,13 @@
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { reactive, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import api from '../lib/api'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const clusters = reactive<{ uuid:string; host:string; ip:string; count:number; health:string; healthText:string }[]>([
{ uuid: 'CL-1111-AAAA', host: 'master-1', ip: '10.0.0.1', count: 8, health: 'running', healthText: '健康' },
{ uuid: 'CL-2222-BBBB', host: 'master-2', ip: '10.0.0.2', count: 12, health: 'warning', healthText: '警告' }
])
const auth = useAuthStore()
const clusters = reactive<{ uuid:string; host:string; ip:string; count:number; health:string; healthText:string }[]>([])
const showRegister = ref(false)
const uuid = ref('')
const host = ref('')
@ -46,19 +46,32 @@ const health = ref('running')
const err = ref('')
function toggleRegister() { showRegister.value = !showRegister.value }
function cancelRegister() { showRegister.value = false; err.value = ''; uuid.value=''; host.value=''; ip.value=''; count.value=''; health.value='running' }
function onRegister() {
function healthTextOf(h:string){ return h==='running'?'健康':h==='warning'?'警告':'异常' }
async function load(){
try{
const r = await api.get('/v1/clusters', { headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
const list = Array.isArray(r.data?.clusters) ? r.data.clusters : []
clusters.splice(0, clusters.length, ...list.map((x:any)=>({ uuid:x.uuid, host:x.host, ip:x.ip, count:Number(x.count)||0, health:x.health, healthText: healthTextOf(x.health) })))
}catch(e:any){ err.value = e?.response?.data?.detail || '加载失败' }
}
async function onRegister() {
if (!uuid.value || !host.value || !ip.value || !count.value) { err.value = '请填写完整信息'; return }
if (clusters.some(x => x.uuid === uuid.value)) { err.value = '该集群UUID已存在'; return }
clusters.push({ uuid: uuid.value, host: host.value, ip: ip.value, count: Number(count.value)||0, health: health.value, healthText: health.value==='running'?'健康':health.value==='warning'?'警告':'异常' })
cancelRegister()
try{
await api.post('/v1/clusters', { uuid: uuid.value, host: host.value, ip: ip.value, count: Number(count.value)||0, health: health.value }, { headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
cancelRegister(); await load()
}catch(e:any){
const d = e?.response?.data; const errs = d?.detail?.errors
if (Array.isArray(errs) && errs.length) err.value = errs.map((x:any)=>x?.message||'').filter(Boolean).join('')
else err.value = d?.detail || '提交失败'
}
}
function unregister(id: string) {
const i = clusters.findIndex(x => x.uuid === id)
if (i >= 0) clusters.splice(i, 1)
async function unregister(id: string) {
try{ await api.delete(`/v1/clusters/${encodeURIComponent(id)}`, { headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined }); await load() }
catch(e:any){ err.value = e?.response?.data?.detail || '注销失败' }
}
function toDashboard(c: any) {
sessionStorage.setItem('current_cluster', JSON.stringify(c))
router.push({ name: 'dashboard' })
}
onMounted(()=>{ load() })
</script>

@ -1,25 +1,134 @@
<template>
<section class="layout__section">
<div class="layout__page-header"><h2 class="layout__page-title">仪表板</h2></div>
<div class="u-mb-2">
<div>当前集群 UUID{{ meta.uuid }}</div>
<div>Master 主机{{ meta.host }}</div>
<div>Master IP{{ meta.ip }}</div>
<div class="layout__page-header">
<h2 class="layout__page-title">仪表板 · 集群概览</h2>
<div class="top-meta">
<span>更新时间{{ updateTime }}</span>
<span>当前集群{{ meta.uuid }} | 主机名{{ meta.host }} | 主IP{{ meta.ip }}</span>
</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-card-title">总节点数</div>
<div class="stat-card-value">{{ totalCount }}</div>
</div>
<div class="stat-card">
<div class="stat-card-title">健康节点</div>
<div class="stat-card-value text-success">{{ healthyCount }}</div>
</div>
<div class="stat-card">
<div class="stat-card-title">警告节点</div>
<div class="stat-card-value text-warning">{{ warningCount }}</div>
</div>
<div class="stat-card">
<div class="stat-card-title">异常节点</div>
<div class="stat-card-value text-error">{{ errorCount }}</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div><CpuChart /></div>
<div><MemoryChart /></div>
<div><CpuChart :cluster="meta.uuid" /></div>
<div><MemoryChart :cluster="meta.uuid" /></div>
</div>
<article class="layout__card u-mt-4">
<div class="layout__card-header">
<h3 class="layout__card-title">节点状态详情</h3>
</div>
<div class="layout__card-body u-p-0">
<div class="u-overflow-x-auto">
<table class="dashboard__table" role="table">
<thead class="dashboard__table-head">
<tr>
<th class="dashboard__table-th" scope="col">节点名称</th>
<th class="dashboard__table-th" scope="col">IP 地址</th>
<th class="dashboard__table-th" scope="col">状态</th>
<th class="dashboard__table-th" scope="col">CPU 使用率</th>
<th class="dashboard__table-th" scope="col">内存使用</th>
<th class="dashboard__table-th" scope="col">最近更新</th>
<th class="dashboard__table-th" scope="col">操作</th>
</tr>
</thead>
<tbody>
<tr class="dashboard__table-row" v-for="n in nodes" :key="n.name">
<td class="dashboard__table-td"><strong>{{ n.name }}</strong></td>
<td class="dashboard__table-td">{{ n.ip }}</td>
<td class="dashboard__table-td">
<span class="status-indicator">
<span :class="['status-dot', statusDotClass(n.status)]"></span>
<span class="status-text">{{ statusText(n.status) }}</span>
</span>
</td>
<td class="dashboard__table-td">{{ n.cpu }}</td>
<td class="dashboard__table-td">{{ n.mem }}</td>
<td class="dashboard__table-td">{{ n.updated }}</td>
<td class="dashboard__table-td">
<button class="btn u-text-sm" @click="start(n.name)" data-requires-edit="true">启动</button>
<button class="btn u-text-sm u-ml-1" @click="stop(n.name)" data-requires-edit="true">停止</button>
<button class="btn u-text-sm u-ml-1" @click="remove(n.name)" data-requires-edit="true">删除</button>
<button class="btn u-text-sm u-ml-1" @click="detail(n.name)" data-requires-edit="true">详情</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</article>
</section>
</template>
<script setup lang="ts">
import { onMounted, reactive } from 'vue'
import { onMounted, reactive, computed } from 'vue'
import api from '../lib/api'
import { useAuthStore } from '../stores/auth'
import CpuChart from '../components/CpuChart.vue'
import MemoryChart from '../components/MemoryChart.vue'
const meta = reactive({ uuid: '未选择', host: '-', ip: '-' })
const auth = useAuthStore()
const nodes = reactive<Array<{ name:string; ip:string; status:'running'|'warning'|'error'; cpu:string; mem:string; updated:string }>>([])
const updateTime = computed(() => {
const d = new Date()
const y = d.getFullYear()
const m = d.getMonth()+1
const day = d.getDate()
return `${y}${m}${day}`
})
const totalCount = computed(() => nodes.length)
const healthyCount = computed(() => nodes.filter(n => n.status==='running').length)
const warningCount = computed(() => nodes.filter(n => n.status==='warning').length)
const errorCount = computed(() => nodes.filter(n => n.status==='error').length)
onMounted(() => {
const raw = sessionStorage.getItem('current_cluster')
if (raw) Object.assign(meta, JSON.parse(raw))
loadNodes()
})
async function loadNodes(){
try{
const r = await api.get('/v1/nodes', { params: { cluster: meta.uuid }, headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
const list = Array.isArray(r.data?.nodes) ? r.data.nodes : []
nodes.splice(0, nodes.length, ...list.map((x:any)=>({ name:x.name, ip:x.ip, status:x.status, cpu:x.cpu, mem:x.mem, updated:x.updated })))
}catch(e:any){ /* silent */ }
}
function statusText(s:'running'|'warning'|'error'){ return s==='running'?'运行中':s==='warning'?'警告':'异常' }
function statusDotClass(s:'running'|'warning'|'error'){ return s==='running'?'status-dot--running':s==='warning'?'status-dot--warning':'status-dot--error' }
async function start(name:string){ try{ await api.post(`/v1/nodes/${encodeURIComponent(name)}/start`, {}, { headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined }); await loadNodes() }catch(e:any){} }
async function stop(name:string){ try{ await api.post(`/v1/nodes/${encodeURIComponent(name)}/stop`, {}, { headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined }); await loadNodes() }catch(e:any){} }
async function remove(name:string){ try{ await api.delete(`/v1/nodes/${encodeURIComponent(name)}`, { headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined }); await loadNodes() }catch(e:any){} }
async function detail(name:string){ try{ await api.get(`/v1/nodes/${encodeURIComponent(name)}`, { headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined }) }catch(e:any){} }
</script>
<style scoped>
.top-meta{ display:flex; gap:16px; align-items:center; color:#6b7280; font-size:13px }
.ws-off{ color:#dc2626 }
.stats-grid{ display:grid; grid-template-columns: repeat(4, 1fr); gap:16px; margin:16px 0 }
.stat-card{ background:#ffffff; border:1px solid #e5e7eb; border-radius:12px; box-shadow:0 8px 24px rgba(16,24,40,0.06); padding:16px }
.stat-card-title{ color:#6b7280; font-size:13px }
.stat-card-value{ font-size:24px; font-weight:700 }
.text-success{ color:#16a34a }
.text-warning{ color:#f59e0b }
.text-error{ color:#dc2626 }
.status-indicator{ display:inline-flex; align-items:center; gap:6px }
.status-dot{ width:8px; height:8px; border-radius:50% }
.status-dot--running{ background:#16a34a }
.status-dot--warning{ background:#f59e0b }
.status-dot--error{ background:#dc2626 }
.status-text{ font-size:12px }
</style>

@ -1,100 +1,188 @@
<template>
<section class="layout__section">
<div class="layout__page-header"><h2 class="layout__page-title">故障诊断</h2></div>
<div class="diag-container">
<div id="diag-left" class="diag-pane diag-pane--left">
<div draggable="true" class="btn diag-log-btn" data-log=" A">示例日志 A</div>
<div draggable="true" class="btn diag-log-btn" data-log=" B">示例日志 B</div>
<div class="layout__page-header diag-header">
<div class="diag-title">
<h2 class="layout__page-title">故障诊断</h2>
<span class="badge">原型</span>
</div>
<div id="diag-divider-1" class="diag-divider"></div>
<div id="diag-middle" class="diag-pane diag-pane--mid">
<div class="u-text-sm u-text-gray-700">选择左侧日志或节点预览内容显示于右侧</div>
<div id="diag-live-logs-list" style="margin-top:8px"></div>
</div>
<div id="diag-divider-2" class="diag-divider"></div>
<div id="diag-right" class="diag-pane diag-pane--right">
<textarea id="chat-input" class="diag-chat"></textarea>
<div class="diag-tools">
<input class="header__search-input" v-model.trim="kw" placeholder="搜索节点或集群" />
</div>
</div>
<div class="diag-layout">
<aside class="diag-sidebar">
<div class="diag-group" v-for="g in filteredGroups" :key="g.id">
<button class="diag-group-toggle" type="button" @click="g.open=!g.open">
<span :class="['chev', g.open?'chev--down':'chev--right']"></span>
{{ g.name }}
</button>
<ul v-show="g.open" class="diag-node-list">
<li v-for="n in g.nodes" :key="n" :class="['diag-node-item', selectedNode===n?'diag-node-item--active':'']" @click="selectNode(n)">
<span class="status-dot" :class="statusDot(n)"></span>
{{ n }}
</li>
</ul>
</div>
<div class="diag-tabs">
<button :class="['btn', tab==='live'?'btn--primary':'']" type="button" @click="tab='live'"></button>
<button :class="['btn', tab==='auto'?'btn--primary':'']" type="button" @click="tab='auto'"></button>
</div>
<div class="diag-tip">请选择集群或节点以显示相关日志</div>
<article class="layout__card u-mt-2">
<div class="layout__card-header"><h3 class="layout__card-title">故障信息</h3></div>
<div class="layout__card-body">
<div class="fault-row"><span class="fault-key">故障代码</span><span class="fault-val">FLT-20251107-0001</span></div>
<div class="fault-row"><span class="fault-key">发生时间</span><span class="fault-val">2025-11-07 10:15:00</span></div>
<div class="fault-row"><span class="fault-key">影响范围</span><span class="fault-val">CL-3333-CCCC-003</span></div>
</div>
</article>
</aside>
<main class="diag-preview">
<article class="layout__card">
<div class="layout__card-header"><h3 class="layout__card-title">日志预览</h3></div>
<div class="layout__card-body">
<div v-if="!selectedNode" class="preview-placeholder"></div>
<div v-else>
<div class="preview-meta">当前节点<strong>{{ selectedNode }}</strong></div>
<div class="u-overflow-x-auto u-mt-2">
<table class="dashboard__table">
<thead><tr><th>时间</th><th>级别</th><th>来源</th><th>消息</th></tr></thead>
<tbody>
<tr class="dashboard__table-row" v-for="l in previewLogs" :key="l.id">
<td><time :datetime="l.time">{{ l.time.split('T')[1] || l.time }}</time></td>
<td class="u-font-medium">{{ l.level.toUpperCase() }}</td>
<td><code>{{ l.source }}</code></td>
<td>{{ l.message }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</article>
</main>
<aside class="diag-assistant">
<article class="layout__card">
<div class="layout__card-body">
<div class="assist-row">
<div class="assist-field">
<label class="u-text-sm u-font-medium u-text-gray-700">智能体</label>
<select v-model="agent" class="u-w-full u-p-2 u-border u-rounded u-mt-1">
<option value="诊断智能体">诊断智能体</option>
</select>
</div>
<div class="assist-field">
<label class="u-text-sm u-font-medium u-text-gray-700">模型</label>
<select v-model="model" class="u-w-full u-p-2 u-border u-rounded u-mt-1">
<option value="gpt-4o-mini">gpt-4o-mini</option>
</select>
</div>
</div>
</div>
</article>
<article class="layout__card u-mt-2">
<div class="layout__card-header"><h3 class="layout__card-title">对话历史</h3></div>
<div class="layout__card-body">
<div class="chat-history">
<div class="chat-item">
<div class="chat-role">系统</div>
<div class="chat-text">欢迎使用多智能体诊断面板</div>
</div>
<div class="chat-item">
<div class="chat-role">诊断智能体</div>
<div class="chat-text">请在左侧选择节点并拖入关键日志作为上下文</div>
</div>
</div>
<textarea class="chat-input" placeholder="支持Markdown输入..."></textarea>
<div class="chat-actions">
<button type="button" class="btn btn--primary">发送</button>
<button type="button" class="btn btn--primary u-ml-1">生成状态报告</button>
</div>
<div class="chat-progress">
<span>流式显示占位</span>
<div class="progress-bar"><div class="progress-fill" style="width:0%"></div></div>
</div>
</div>
</article>
</aside>
</div>
</section>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
onMounted(() => {
const input = document.getElementById('chat-input') as HTMLTextAreaElement|null
if (input) {
input.addEventListener('dragover', e => e.preventDefault())
input.addEventListener('drop', e => { e.preventDefault(); const d = (e as DragEvent).dataTransfer?.getData('text/plain')||''; input.value = `${input.value}\n${d}`.trim() })
}
const left = document.getElementById('diag-left') as HTMLElement|null
const mid = document.getElementById('diag-middle') as HTMLElement|null
const right = document.getElementById('diag-right') as HTMLElement|null
const d1 = document.getElementById('diag-divider-1') as HTMLElement|null
const d2 = document.getElementById('diag-divider-2') as HTMLElement|null
if (!left || !mid || !right || !d1 || !d2) return
let drag1 = false, drag2 = false, startX1 = 0, startX2 = 0
let leftW = left.getBoundingClientRect().width
let midW = mid.getBoundingClientRect().width
let rightW = right.getBoundingClientRect().width
const minLeft = 240, minMid = 300, minRight = 320
const onMove1 = (e: MouseEvent) => {
if (!drag1) return
const dx = e.clientX - startX1
const newLeft = Math.max(minLeft, leftW + dx)
const delta = newLeft - leftW
const newMid = Math.max(minMid, midW - delta)
left.style.width = `${newLeft}px`
mid.style.width = `${newMid}px`
}
const onUp1 = () => {
drag1 = false
leftW = left.getBoundingClientRect().width
midW = mid.getBoundingClientRect().width
document.removeEventListener('mousemove', onMove1)
document.removeEventListener('mouseup', onUp1)
}
d1.addEventListener('mousedown', (e) => {
drag1 = true
startX1 = (e as MouseEvent).clientX
leftW = left.getBoundingClientRect().width
midW = mid.getBoundingClientRect().width
document.addEventListener('mousemove', onMove1)
document.addEventListener('mouseup', onUp1)
})
const onMove2 = (e: MouseEvent) => {
if (!drag2) return
const dx = startX2 - e.clientX
const newRight = Math.max(minRight, rightW + dx)
const delta = newRight - rightW
const newMid = Math.max(minMid, midW - delta)
right.style.width = `${newRight}px`
mid.style.width = `${newMid}px`
}
const onUp2 = () => {
drag2 = false
rightW = right.getBoundingClientRect().width
midW = mid.getBoundingClientRect().width
document.removeEventListener('mousemove', onMove2)
document.removeEventListener('mouseup', onUp2)
}
d2.addEventListener('mousedown', (e) => {
drag2 = true
startX2 = (e as MouseEvent).clientX
rightW = right.getBoundingClientRect().width
midW = mid.getBoundingClientRect().width
document.addEventListener('mousemove', onMove2)
document.addEventListener('mouseup', onUp2)
})
import { reactive, ref, computed } from 'vue'
const kw = ref('')
const tab = ref<'live'|'auto'>('live')
const agent = ref('诊断智能体')
const model = ref('gpt-4o-mini')
const groups = reactive<Array<{ id:string; name:string; open:boolean; nodes:string[] }>>([
{ id:'cl-1111', name:'CL-1111-AAAA', open:true, nodes:['CL-1111-AAAA-001','CL-1111-AAAA-002','CL-1111-AAAA-003'] },
{ id:'cl-2222', name:'CL-2222-BBBB', open:true, nodes:['CL-2222-BBBB-001'] },
{ id:'cl-3333', name:'CL-3333-CCCC', open:true, nodes:['CL-3333-CCCC-003'] },
])
const selectedNode = ref('')
const filteredGroups = computed(()=>{
const k = kw.value.trim().toLowerCase()
if (!k) return groups
return groups.map(g=>({ ...g, nodes: g.nodes.filter(n => n.toLowerCase().includes(k) || g.name.toLowerCase().includes(k)) }))
})
function selectNode(n:string){ selectedNode.value = n }
function statusDot(n:string){ return n.includes('003') ? 'status-dot--error' : n.includes('002') ? 'status-dot--warning' : 'status-dot--running' }
const previewLogs = computed(() => {
if (!selectedNode.value) return [] as Array<{id:number;time:string;level:string;source:string;message:string}>
return [
{ id:1, time:'2025-11-07T10:15:00', level:'error', source:selectedNode.value, message:'连接断开,心跳丢失' },
{ id:2, time:'2025-11-07T10:14:58', level:'warn', source:selectedNode.value, message:'心跳延迟超过阈值' },
{ id:3, time:'2025-11-07T10:14:55', level:'info', source:selectedNode.value, message:'尝试重连中' }
]
})
</script>
<style>
.diag-container { display:flex; gap:0; height:480px }
.diag-pane { border:1px solid #e5e7eb; overflow:auto }
.diag-pane--left { width: 320px }
.diag-pane--mid { flex: 1; min-width: 300px; padding: 8px }
.diag-pane--right { width: 380px; display:flex; flex-direction:column }
.diag-divider { width:6px; background:#e5e7eb; cursor: col-resize }
.diag-chat { flex:1; padding:8px }
<style scoped>
.diag-header{ display:flex; align-items:center; justify-content:space-between }
.diag-title{ display:flex; align-items:center; gap:8px }
.badge{ padding:2px 8px; border-radius:999px; background:#eef2ff; color:#374151; font-size:12px }
.diag-layout{ display:grid; grid-template-columns: 320px 1fr 380px; gap:16px }
.diag-sidebar{ background:#ffffff; border:1px solid #e5e7eb; border-radius:12px; padding:12px; display:flex; flex-direction:column }
.diag-group{ margin-top:8px }
.diag-group-toggle{ width:100%; display:flex; align-items:center; gap:8px; padding:8px; border:1px solid #e5e7eb; border-radius:8px; background:#f9fafb; color:#374151 }
.chev{ width:0; height:0; border-style:solid }
.chev--right{ border-width:5px 0 5px 8px; border-color:transparent transparent transparent #6b7280 }
.chev--down{ border-width:8px 5px 0 5px; border-color:#6b7280 transparent transparent transparent }
.diag-node-list{ list-style:none; padding:8px 4px; margin:0 }
.diag-node-item{ display:flex; align-items:center; gap:8px; padding:6px 8px; border:1px solid #e5e7eb; border-radius:8px; background:#fff; margin-top:6px; cursor:pointer }
.diag-node-item:hover{ background:#f9fafb }
.diag-node-item--active{ background:#eef2ff; border-color:#c7d2fe }
.status-dot{ width:8px; height:8px; border-radius:50% }
.status-dot--running{ background:#16a34a }
.status-dot--warning{ background:#f59e0b }
.status-dot--error{ background:#dc2626 }
.diag-tabs{ display:flex; gap:8px; margin-top:12px }
.diag-tip{ margin-top:8px; color:#6b7280; font-size:12px }
.fault-row{ display:flex; justify-content:space-between; padding:6px 0; border-bottom:1px dashed #e5e7eb }
.fault-row:last-child{ border-bottom:none }
.fault-key{ color:#6b7280; font-size:12px }
.fault-val{ font-weight:600 }
.diag-preview{ display:flex }
.preview-meta{ color:#6b7280; font-size:12px }
.preview-placeholder{ color:#6b7280; font-size:14px }
.preview-body{ height:677.6px; background:#f9fafb; border:1px solid #e5e7eb; border-radius:8px; margin-top:8px }
.diag-assistant{ display:flex; flex-direction:column }
.assist-row{ display:grid; grid-template-columns: 1fr 1fr; gap:12px }
.assist-field{ display:flex; flex-direction:column }
.chat-history{ display:flex; flex-direction:column; gap:8px }
.chat-item{ display:flex; gap:8px }
.chat-role{ width:72px; color:#6b7280 }
.chat-text{ flex:1 }
.chat-input{ width:100%; min-height:80px; margin-top:8px; padding:8px; border:1px solid #e5e7eb; border-radius:8px }
.chat-actions{ display:flex; justify-content:flex-end; gap:8px; margin-top:8px }
.chat-progress{ display:flex; align-items:center; gap:8px; margin-top:8px; color:#6b7280 }
.progress-bar{ flex:1; height:6px; background:#e5e7eb; border-radius:999px; overflow:hidden }
.progress-fill{ height:100%; background:#2563eb }
.btn--primary{ background:#2563eb; color:#fff; border-color:#2563eb }
.btn--primary:disabled{ opacity:0.6; cursor:not-allowed }
</style>

@ -1,4 +1,174 @@
<template>
<section class="layout__section"><div class="layout__page-header"><h2 class="layout__page-title">执行日志</h2></div></section>
<section class="layout__section">
<div class="layout__page-header exec-header">
<div>
<h2 class="layout__page-title">执行日志 原型</h2>
<div class="layout__page-subtitle">查看与管理修复执行记录支持完整CRUD</div>
</div>
<div class="header-actions">
<button class="btn" type="button" @click="refresh"></button>
<button class="btn btn--primary u-ml-1" type="button" @click="openCreate=true"></button>
<button class="btn u-ml-1" type="button" :disabled="!selected" @click="openEdit()"></button>
<button class="btn u-ml-1" type="button" :disabled="!selected" @click="delSelected()"></button>
</div>
</div>
<article class="layout__card">
<div class="layout__card-header"><h3 class="layout__card-title">执行记录</h3></div>
<div class="layout__card-body u-p-0">
<table class="dashboard__table">
<thead>
<tr>
<th>执行ID</th>
<th>故障ID</th>
<th>命令类型</th>
<th>状态</th>
<th>开始时间</th>
<th>结束时间</th>
<th>退出码</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="r in records" :key="r.id" class="dashboard__table-row" :class="{ 'row--selected': selected===r.id }" @click="select(r)">
<td>{{ r.id }}</td>
<td>{{ r.faultId }}</td>
<td>{{ r.cmdType }}</td>
<td><span :class="statusClass(r.status)">{{ r.status }}</span></td>
<td>{{ r.start }}</td>
<td>{{ r.end || '-' }}</td>
<td>{{ r.code ?? '-' }}</td>
<td>
<button class="btn u-text-sm" type="button" @click.stop="editRow(r)">编辑</button>
<button class="btn u-text-sm u-ml-1" type="button" @click.stop="del(r.id)">删除</button>
</td>
</tr>
</tbody>
</table>
</div>
</article>
<article class="layout__card u-mt-3" v-show="openCreate || openEditForm">
<div class="layout__card-header"><h3 class="layout__card-title">{{ openCreate ? '新增记录' : '编辑记录' }}</h3></div>
<div class="layout__card-body">
<form @submit.prevent="save">
<div class="form-grid">
<input v-model.trim="form.id" placeholder="执行ID" class="header__search-input" />
<input v-model.trim="form.faultId" placeholder="故障ID" class="header__search-input" />
<select v-model="form.cmdType" class="header__search-input"><option>shell</option><option>hdfs</option><option>yarn</option></select>
<select v-model="form.status" class="header__search-input"><option>running</option><option>success</option><option>failed</option></select>
<input v-model.trim="form.start" placeholder="开始时间 如 2025-11-07 10:20:03" class="header__search-input" />
<input v-model.trim="form.end" placeholder="结束时间 如 2025-11-07 10:22:35 或留空" class="header__search-input" />
<input v-model.number="form.code" placeholder="退出码 如 0 或留空" class="header__search-input" />
</div>
<div class="u-mt-2">
<button class="btn btn--primary" type="submit">保存</button>
<button class="btn u-ml-1" type="button" @click="cancelForm"></button>
</div>
<div class="u-text-sm u-text-gray-700 u-mt-1">{{ err }}</div>
</form>
</div>
</article>
</section>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import api from '../lib/api'
import { useAuthStore } from '../stores/auth'
type RecordItem = { id:string; faultId:string; cmdType:string; status:'running'|'success'|'failed'; start:string; end:string|''; code:number|null }
const auth = useAuthStore()
const records = reactive<RecordItem[]>([])
const selected = ref('')
const openCreate = ref(false)
const openEditForm = ref(false)
const err = ref('')
const loading = ref(false)
const form = reactive<RecordItem>({ id:'', faultId:'', cmdType:'shell', status:'running', start:'', end:'', code:null })
function statusClass(s:'running'|'success'|'failed'){ return s==='running'?'status--running': s==='success'?'status--success':'status--failed' }
function select(r: RecordItem){ selected.value = r.id }
function editRow(r: RecordItem){ selected.value = r.id; openCreate.value=false; openEditForm.value=true; Object.assign(form, r) }
function openEdit(){ const r = records.find(x=>x.id===selected.value); if (r) editRow(r) }
function delSelected(){ if (selected.value) del(selected.value) }
async function del(id:string){
try{
await api.delete(`/v1/exec-logs/${encodeURIComponent(id)}`, { headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
}catch(e:any){ /* fallback ignore */ }
const i = records.findIndex(x=>x.id===id); if (i>=0) { records.splice(i,1); if (selected.value===id) selected.value='' }
}
function cancelForm(){ openCreate.value=false; openEditForm.value=false; err.value='' }
async function save(){
err.value=''
if (!form.id || !form.faultId || !form.cmdType || !form.status || !form.start) { err.value='请完整填写信息'; return }
const exists = records.find(x=>x.id===form.id)
const payload: RecordItem = { id: form.id, faultId: form.faultId, cmdType: form.cmdType, status: form.status, start: form.start, end: form.end, code: form.code ?? null }
try{
if (openCreate.value) {
if (exists) { err.value='执行ID已存在'; return }
await api.post('/v1/exec-logs', {
exec_id: payload.id,
fault_id: payload.faultId,
command_type: payload.cmdType,
execution_status: payload.status,
start_time: payload.start,
end_time: payload.end || null,
exit_code: payload.code
}, { headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
records.unshift(payload)
} else if (openEditForm.value) {
if (!exists) { err.value='目标记录不存在'; return }
await api.put(`/v1/exec-logs/${encodeURIComponent(form.id)}`, {
fault_id: payload.faultId,
command_type: payload.cmdType,
execution_status: payload.status,
start_time: payload.start,
end_time: payload.end || null,
exit_code: payload.code
}, { headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
Object.assign(exists, payload)
}
}catch(e:any){ if (openCreate.value && !exists) records.unshift(payload); else if (exists) Object.assign(exists, payload) }
cancelForm()
}
async function refresh(){ await load() }
async function load(){
loading.value = true; err.value = ''
try{
const r = await api.get('/v1/exec-logs', { headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
const items = Array.isArray(r.data?.items) ? r.data.items : (Array.isArray(r.data?.exec_logs) ? r.data.exec_logs : [])
const normalized: RecordItem[] = items.map((d:any)=>({
id: d.exec_id || d.id,
faultId: d.fault_id,
cmdType: d.command_type || d.cmdType,
status: d.execution_status || d.status,
start: (d.start_time || d.start || '').replace('T',' ').slice(0,19),
end: d.end_time ? String(d.end_time).replace('T',' ').slice(0,19) : '',
code: d.exit_code ?? null
}))
records.splice(0, records.length, ...normalized)
}catch(e:any){
const seed: RecordItem[] = [
{ id:'EXE-20251107-1001', faultId:'FLT-20251107-0001', cmdType:'shell', status:'running', start:'2025-11-07 10:20:03', end:'', code:null },
{ id:'EXE-20251106-0922', faultId:'FLT-20251106-0023', cmdType:'hdfs', status:'success', start:'2025-11-06 09:22:10', end:'2025-11-06 09:22:35', code:0 }
]
records.splice(0, records.length, ...seed)
} finally { loading.value = false }
}
onMounted(()=>{ load() })
</script>
<style scoped>
.exec-header{ display:flex; justify-content:space-between; align-items:center }
.layout__page-subtitle{ color:#6b7280; font-size:13px }
.header-actions{ display:flex; align-items:center }
.btn--primary{ background:#2563eb; color:#fff; border-color:#2563eb }
.row--selected{ background:#eef2ff }
.layout__card{ background:#ffffff; border:1px solid #e5e7eb; border-radius:12px; box-shadow:0 8px 24px rgba(16,24,40,0.06) }
.layout__card-header{ padding:12px 16px; border-bottom:1px solid #e5e7eb }
.layout__card-title{ font-size:14px; font-weight:600 }
.layout__card-body{ padding:16px }
.form-grid{ display:grid; grid-template-columns: repeat(4, 1fr); gap:12px }
.status--running{ color:#2563eb }
.status--success{ color:#16a34a }
.status--failed{ color:#dc2626 }
</style>

@ -1,4 +0,0 @@
<template>
<section class="layout__section"><div class="layout__page-header"><h2 class="layout__page-title">故障中心</h2></div></section>
</template>

@ -1,23 +1,64 @@
<template>
<section class="layout__section">
<div class="layout__page-header"><h2 class="layout__page-title">日志查询</h2></div>
<form id="log-search-form" @submit.prevent="apply(true)">
<select v-model="q.level" id="log-level" class="header__search-input">
<option value="">全部级别</option>
<option value="info">INFO</option>
<option value="warn">WARN</option>
<option value="error">ERROR</option>
</select>
<input v-model.trim="q.cluster" id="source-cluster" class="header__search-input" placeholder="集群" />
<input v-model.trim="q.node" id="source-node" class="header__search-input" placeholder="节点" />
<input v-model.trim="q.op" id="op-type" class="header__search-input" placeholder="操作类型" />
<input v-model.trim="q.user" id="user-id" class="header__search-input" placeholder="用户" />
<button class="btn">搜索</button>
<button type="button" class="btn u-ml-1" @click="clear"></button>
</form>
<div id="log-filter-summary" class="u-text-sm u-text-gray-700">当前筛选</div>
<article class="layout__card">
<div class="layout__card-header"><h3 class="layout__card-title">搜索条件</h3></div>
<div class="layout__card-body">
<form id="log-search-form" @submit.prevent="apply(true)" class="layout__grid layout__grid--3">
<div>
<label class="u-text-sm u-font-medium u-text-gray-700">日志级别</label>
<select v-model="q.level" id="log-level" class="u-w-full u-p-2 u-border u-rounded u-mt-1">
<option value="">全部级别</option>
<option value="debug">DEBUG</option>
<option value="info">INFO</option>
<option value="warn">WARN</option>
<option value="error">ERROR</option>
</select>
</div>
<div>
<label class="u-text-sm u-font-medium u-text-gray-700">来源集群</label>
<select v-model="q.cluster" id="source-cluster" class="u-w-full u-p-2 u-border u-rounded u-mt-1">
<option value="">全部集群</option>
<option v-for="c in clustersOpts" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<div>
<label class="u-text-sm u-font-medium u-text-gray-700">来源节点</label>
<select v-model="q.node" id="source-node" class="u-w-full u-p-2 u-border u-rounded u-mt-1">
<option value="">全部节点</option>
<option v-for="n in nodesOpts" :key="n" :value="n">{{ n }}</option>
</select>
</div>
<div>
<label class="u-text-sm u-font-medium u-text-gray-700">操作类型</label>
<select v-model="q.op" id="op-type" class="u-w-full u-p-2 u-border u-rounded u-mt-1">
<option value="">全部类型</option>
<option v-for="o in opsOpts" :key="o" :value="o">{{ o }}</option>
</select>
</div>
<div>
<label class="u-text-sm u-font-medium u-text-gray-700">来源</label>
<input v-model.trim="q.source" id="source-id" class="u-w-full u-p-2 u-border u-rounded u-mt-1" placeholder="如alice、ops 等" />
</div>
<div>
<label class="u-text-sm u-font-medium u-text-gray-700">时间范围</label>
<select v-model="q.timeRange" id="time-range" class="u-w-full u-p-2 u-border u-rounded u-mt-1">
<option value="">全部时间</option>
<option value="1h">最近1小时</option>
<option value="6h">最近6小时</option>
<option value="24h">最近24小时</option>
<option value="7d">最近7天</option>
</select>
</div>
<div class="filter-actions" style="grid-column: 1 / -1;">
<button type="button" class="btn btn-link" @click="clear"></button>
</div>
</form>
<div id="log-filter-summary" class="u-mt-3 u-text-sm u-text-gray-700">当前筛选{{ summary }}</div>
</div>
</article>
<table class="dashboard__table">
<thead><tr><th>时间</th><th>级别</th><th>集群</th><th>节点</th><th>操作</th><th>用户</th><th>消息</th></tr></thead>
<thead><tr><th>时间</th><th>级别</th><th>集群</th><th>节点</th><th>操作</th><th>来源</th><th>消息</th></tr></thead>
<tbody id="logs-tbody">
<tr v-for="item in pageData" :key="item.id" class="dashboard__table-row">
<td><time :datetime="item.time">{{ item.time.split('T')[1] || item.time }}</time></td>
@ -25,7 +66,7 @@
<td><code>{{ item.cluster }}</code></td>
<td>{{ item.node }}</td>
<td>{{ item.op }}</td>
<td>{{ item.user }}</td>
<td>{{ item.source }}</td>
<td>{{ item.message }}</td>
</tr>
</tbody>
@ -40,22 +81,81 @@
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
const data = ref<{ id:number; time:string; level:string; cluster:string; node:string; op:string; user:string; message:string }[]>([])
import { computed, reactive, ref, watch, onMounted } from 'vue'
import api from '../lib/api'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const data = ref<{ id:number; time:string; level:string; cluster:string; node:string; op:string; source:string; message:string }[]>([])
const page = ref(1)
const size = ref(10)
const q = reactive({ level:'', cluster:'', node:'', op:'', user:'' })
function seed() {
const rows = [] as any[]
for (let i=0;i<120;i++) rows.push({ id:i, time:`2025-01-01T0${i%10}:00:00`, level: ['info','warn','error'][i%3], cluster: ['CL-1111-AAAA','CL-2222-BBBB'][i%2], node: `node-${i%7}`, op: ['query','update','security'][i%3], user: ['alice','bob','carol'][i%3], message: ` ${i}` })
data.value = rows
const total = ref(0)
const loading = ref(false)
const err = ref('')
const q = reactive({ level:'', cluster:'', node:'', op:'', source:'', timeRange:'' })
const clustersOpts = ref<string[]>([])
const nodesOpts = ref<string[]>([])
const opsOpts = ref<string[]>([])
function rangeFromNow(r:string){
const now = Date.now()
const span = r==='1h'?60*60*1000:r==='6h'?6*60*60*1000:r==='24h'?24*60*60*1000:r==='7d'?7*24*60*60*1000:0
return span? new Date(now-span).toISOString() : ''
}
async function load(){
loading.value = true
err.value = ''
try{
const params: any = { page: page.value, size: size.value }
if (q.level) params.level = q.level
if (q.cluster) params.cluster = q.cluster
if (q.node) params.node = q.node
if (q.op) params.op = q.op
if (q.source) params.source = q.source
if (q.timeRange) params.time_from = rangeFromNow(q.timeRange)
const r = await api.get('/v1/logs', { params, headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
const items = Array.isArray(r.data?.items) ? r.data.items : (Array.isArray(r.data?.logs)? r.data.logs : [])
const normalized = items.map((d:any)=>({ ...d, source: String((d?.source ?? d?.user ?? '') || '') }))
data.value = normalized
total.value = Number(r.data?.total ?? items.length)
if (!clustersOpts.value.length) clustersOpts.value = Array.from(new Set(items.map((d:any)=>d.cluster).filter(Boolean)))
if (!nodesOpts.value.length) nodesOpts.value = Array.from(new Set(items.map((d:any)=>d.node).filter(Boolean)))
if (!opsOpts.value.length) opsOpts.value = Array.from(new Set(items.map((d:any)=>d.op).filter(Boolean)))
}catch(e:any){ err.value = e?.response?.data?.detail || '加载失败' }
finally{ loading.value = false }
}
seed()
const filtered = computed(() => data.value.filter(item => (!q.level || item.level===q.level) && (!q.cluster||item.cluster===q.cluster) && (!q.node||item.node===q.node) && (!q.op||item.op===q.op) && (!q.user||item.user.toLowerCase().includes(q.user.toLowerCase()))))
const pageData = computed(() => filtered.value.slice((page.value-1)*size.value, (page.value)*size.value))
function apply(manual=false) { page.value = 1 }
function clear() { q.level=''; q.cluster=''; q.node=''; q.op=''; q.user=''; page.value=1 }
function prev() { if (page.value>1) page.value-=1 }
function next() { const max = Math.max(1, Math.ceil(filtered.value.length/size.value)); if (page.value<max) page.value+=1 }
function clear() { q.level=''; q.cluster=''; q.node=''; q.op=''; q.source=''; q.timeRange=''; page.value=1 }
function prev() { if (page.value>1) { page.value-=1; load() } }
function next() { const max = Math.max(1, Math.ceil(total.value/size.value)); if (page.value<max) { page.value+=1; load() } }
const pageData = computed(() => {
const s = q.source.trim().toLowerCase()
let list = data.value
if (s) list = list.filter(d => String(d.source || '').toLowerCase().includes(s))
return list
})
watch(() => ({...q}), () => { apply(); load() }, { deep: true })
watch(size, () => { page.value = 1; load() })
onMounted(()=>{ load() })
const summary = computed(() => {
const parts = [] as string[]
if (q.level) parts.push(`级别=${q.level}`)
if (q.cluster) parts.push(`集群=${q.cluster}`)
if (q.node) parts.push(`节点=${q.node}`)
if (q.op) parts.push(`类型=${q.op}`)
if (q.source) parts.push(`来源=${q.source}`)
if (q.timeRange) parts.push(`时间=${q.timeRange}`)
return parts.length? parts.join('') : '无'
})
</script>
<style scoped>
.layout__card { background: #ffffff; border: 1px solid #e5e7eb; border-radius: 12px; box-shadow: 0 8px 24px rgba(16,24,40,0.06) }
.layout__card-header { padding: 12px 16px; border-bottom: 1px solid #e5e7eb }
.layout__card-title { font-size: 14px; font-weight: 600 }
.layout__card-body { padding: 16px }
.layout__grid { display: grid; gap: 16px }
.layout__grid--3 { grid-template-columns: 1fr 1fr 1fr }
.btn--primary { background: #2563eb; color: #fff; border-color: #2563eb }
.btn--primary:disabled { opacity: 0.6; cursor: not-allowed }
.btn-link { background: transparent; border-color: transparent; color: #2563eb }
.filter-actions { display: flex; justify-content: flex-end; align-items: center; }
</style>

@ -1,4 +1,70 @@
<template>
<section class="layout__section"><div class="layout__page-header"><h2 class="layout__page-title">个人主页</h2></div></section>
<section class="layout__section">
<div class="layout__page-header exec-header">
<div>
<h2 class="layout__page-title">个人主页原型</h2>
<div class="layout__page-subtitle">查看与管理个人基础信息</div>
</div>
<div class="layout__page-actions"><button class="btn" disabled>编辑资料</button></div>
</div>
<article class="layout__card u-mt-2">
<div class="layout__card-header"><h3 class="layout__card-title">个人信息</h3></div>
<div class="layout__card-body">
<div class="layout__grid layout__grid--3">
<div>
<span class="u-text-sm u-text-gray-700">用户名</span>
<div class="u-font-medium u-mt-1">{{ username }}</div>
</div>
<div>
<span class="u-text-sm u-text-gray-700">邮箱</span>
<div class="u-font-medium u-mt-1">{{ email }}</div>
</div>
<div>
<span class="u-text-sm u-text-gray-700">角色</span>
<div class="u-font-medium u-mt-1">{{ roleName }}</div>
</div>
</div>
</div>
</article>
</section>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useAuthStore } from '../stores/auth'
import api from '../lib/api'
const auth = useAuthStore()
const { user, token } = storeToRefs(auth)
const username = ref('admin')
const email = ref('admin@example.com')
const roleName = ref('管理员')
onMounted(async () => {
try{
const r = await api.get('/v1/user/me', { headers: token.value ? { Authorization: `Bearer ${token.value}` } : undefined })
const u = r?.data?.user || r?.data || {}
const name = u?.username || user.value?.username || 'admin'
username.value = name
email.value = u?.email || `${name}@example.com`
const roleKey = u?.role || user.value?.role || 'admin'
roleName.value = roleKey==='admin'?'管理员': roleKey==='operator'?'操作员':'观察员'
}catch(e:any){
const name = user.value?.username || 'admin'
username.value = name
email.value = `${name}@example.com`
const roleKey = user.value?.role || 'admin'
roleName.value = roleKey==='admin'?'管理员': roleKey==='operator'?'操作员':'观察员'
}
})
</script>
<style scoped>
.exec-header{ display:flex; justify-content:space-between; align-items:center }
.layout__page-subtitle{ color:#6b7280; font-size:13px }
.layout__card{ background:#ffffff; border:1px solid #e5e7eb; border-radius:12px; box-shadow:0 8px 24px rgba(16,24,40,0.06) }
.layout__card-header{ padding:12px 16px; border-bottom:1px solid #e5e7eb }
.layout__card-title{ font-size:14px; font-weight:600 }
.layout__card-body{ padding:16px }
.layout__grid{ display:grid; gap:16px }
.layout__grid--3{ grid-template-columns: 1fr 1fr 1fr }
</style>

@ -7,7 +7,6 @@
<input v-model.trim="confirm" type="password" placeholder="确认密码" class="header__search-input" />
<input v-model.trim="email" placeholder="邮箱" class="header__search-input" />
<input v-model.trim="fullName" placeholder="姓名" class="header__search-input" />
<select v-model="role" class="header__search-input"><option value="operator">操作员</option><option value="observer">观察员</option><option value="admin"></option></select>
<button class="btn">提交</button>
</form>
<div class="u-text-sm u-text-gray-700">{{ msg }}</div>

@ -2,38 +2,107 @@
<section class="layout__section" aria-labelledby="role-assign-title">
<header class="layout__page-header">
<div>
<h2 id="role-assign-title" class="layout__page-title">角色分配原型</h2>
<p class="layout__page-subtitle">为用户分配系统角色与权限范围</p>
<h2 id="role-assign-title" class="layout__page-title"><i class="fas fa-user-tag"></i> 角色分配</h2>
<p class="layout__page-subtitle">选择用户并指定其在系统中的角色</p>
</div>
<div class="layout__page-actions">
<button class="btn btn--primary" disabled title="功能待实现">保存分配</button>
<button class="btn btn--primary" @click="onSave" :disabled="!user || !role || loading">保存分配</button>
<span class="u-text-sm u-ml-2" v-if="loading">...</span>
</div>
</header>
<article class="layout__card">
<div class="layout__card-header">
<h3 class="layout__card-title">用户角色占位数据</h3>
<h3 class="layout__card-title">选择与设置</h3>
</div>
<div class="layout__card-body">
<div class="layout__grid layout__grid--2">
<div>
<label class="u-text-sm u-font-medium u-text-gray-700">选择用户</label>
<select class="u-w-full u-p-2 u-border u-rounded u-mt-1">
<option>alice</option>
<option>bob</option>
<option>charlie</option>
<label class="u-text-sm u-font-medium u-text-gray-700"><i class="fas fa-user"></i> 选择用户</label>
<select class="u-w-full u-p-2 u-border u-rounded u-mt-1" v-model="user">
<option v-for="u in users" :key="u" :value="u">{{ u }}</option>
</select>
<div class="u-text-sm u-text-gray-700 u-mt-1" v-if="!users.length"></div>
</div>
<div>
<label class="u-text-sm u-font-medium u-text-gray-700">选择角色</label>
<select class="u-w-full u-p-2 u-border u-rounded u-mt-1">
<option>管理员</option>
<option>操作员</option>
<option>观察员</option>
<label class="u-text-sm u-font-medium u-text-gray-700"><i class="fas fa-id-badge"></i> 选择角色</label>
<select class="u-w-full u-p-2 u-border u-rounded u-mt-1" v-model="role">
<option value="admin">管理员</option>
<option value="operator">操作员</option>
<option value="observer">观察员</option>
</select>
</div>
</div>
<div class="u-text-sm u-mt-2" :class="msgClass">{{ msg }}</div>
</div>
</article>
<article class="layout__card u-mt-4">
<div class="layout__card-header">
<h3 class="layout__card-title">当前选择</h3>
</div>
<div class="layout__card-body">
<div class="u-text-sm u-text-gray-700">用户<span class="u-font-medium">{{ user || '未选择' }}</span></div>
<div class="u-text-sm u-text-gray-700 u-mt-1">角色<span class="u-font-medium">{{ roleLabel }}</span></div>
</div>
</article>
</section>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import api from '../lib/api'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const users = ref<string[]>([])
const user = ref('')
const role = ref('operator')
const msg = ref('')
const loading = ref(false)
const msgClass = computed(() => (msg.value ? (msg.value.startsWith('成功') ? 'u-text-green-600' : 'u-text-error') : 'u-text-gray-600'))
const roleLabel = computed(() => (role.value === 'admin' ? '管理员' : role.value === 'operator' ? '操作员' : role.value === 'observer' ? '观察员' : '未选择'))
onMounted(async () => {
try {
const r = await api.get('/v1/users', { headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
const list = Array.isArray(r.data?.users) ? r.data.users : []
users.value = list.map((x: any) => x?.username).filter((s: any) => typeof s === 'string')
} catch (e: any) {
msg.value = e?.response?.data?.detail || '用户列表加载失败'
}
})
async function onSave() {
msg.value = ''
if (!user.value || !role.value) { msg.value = '请选择用户与角色'; return }
loading.value = true
try {
await api.patch(`/v1/users/${encodeURIComponent(user.value)}`, { role: role.value }, { headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
msg.value = '成功:已更新用户角色'
} catch (e: any) {
const d = e?.response?.data
const errs = d?.detail?.errors
if (Array.isArray(errs) && errs.length) msg.value = errs.map((x: any) => x?.message || '').filter(Boolean).join('')
else msg.value = d?.detail || '角色分配失败'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.layout__section { background: #f8fafc; padding: 8px; border-radius: 8px }
.layout__page-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid #e5e7eb }
.layout__page-title { font-size: 18px; font-weight: 700; display: flex; align-items: center; gap: 8px }
.layout__page-subtitle { margin-top: 4px; color: #6b7280; font-size: 13px }
.layout__page-actions { display: flex; align-items: center }
.layout__card { background: #ffffff; border: 1px solid #e5e7eb; border-radius: 12px; box-shadow: 0 8px 24px rgba(16,24,40,0.06); }
.layout__card-header { padding: 12px 16px; border-bottom: 1px solid #e5e7eb }
.layout__card-title { font-size: 14px; font-weight: 600 }
.layout__card-body { padding: 16px }
.layout__grid { display: grid; gap: 16px }
.layout__grid--2 { grid-template-columns: 1fr 1fr }
.btn--primary { background: #2563eb; color: #fff; border-color: #2563eb }
.btn--primary:disabled { opacity: 0.6; cursor: not-allowed }
.u-text-error { color: #dc2626 }
.u-text-green-600 { color: #16a34a }
.u-text-gray-600 { color: #4b5563 }
</style>

Loading…
Cancel
Save