feat(profile): use /api/v1/users; normalize roles; fix charts sizing and status colors; docs: backend integration; ui(login): center register button

pull/48/head
hnu202326010131 2 months ago
parent 36b9f9a844
commit 90eaf5395b

@ -0,0 +1,81 @@
# 后端联调指南
## 环境与代理
- 前端目录:`frontend-vue`
- 开发代理:`frontend-vue/vite.config.ts:25-30` 将前端的 `/api` 代理到后端 `VITE_API_TARGET`
- 环境变量:
- `frontend-vue/.env.development` 示例:`VITE_API_TARGET=https://your-backend.example.com`
- 可选:`VITE_DEV_HOST`、`VITE_ALLOWED_HOSTS` 控制本地开发主机与允许的外网访问
- 启动与构建:
- 开发:`cd frontend-vue && npm i && npm run dev`
- 预览:`npm run preview`
## 鉴权与凭证
- 认证头:`Authorization: Bearer <token>`
- 前端存储:
- `localStorage` 键名:`cm_user`、`cm_token`
- 应用启动恢复:`frontend-vue/src/app/main.ts:10-12`
- 路由守卫:`frontend-vue/src/app/router/index.ts:23-30` 未登录重定向到登录页;基于 `meta.roles` 进行角色校验
## 关键接口规范
- 健康检查:`GET /v1/health`
- 登录:`POST /v1/user/login`
- 请求体:`{ username, password }`
- 响应体:`{ token, user: { username, role, email? } }`
- 注册:`POST /v1/user/register`
- 请求体:`{ username, email, password, fullName }`
- 响应体:`{ token?, user: { username, role, email? } }`
- 用户列表:`GET /api/v1/users`
- 响应体:`{ users: Array<{ username, role, email }> }` 或 `Array<{ username, role, email }>`
- 前端选择逻辑:仅匹配当前登录用户名;未匹配则显示错误提示
- 权限不足(例如操作员 403页面显示权限提示
- 角色值规范化:后端返回的 `role` 会统一转为小写并支持常见别名(如 `administrator`→`admin`、`ops`→`operator`
- 集群与节点:
- `GET /v1/clusters``[{ uuid, host, ip, count, health }]`
- `POST /v1/clusters`、`DELETE /v1/clusters/:id`、`POST /v1/clusters/:id/start|stop`
- `GET /v1/nodes?cluster=<uuid>``[{ name, ip, status, cpu, mem, updated }]`
- `POST /v1/nodes/:name/start|stop`、`DELETE /v1/nodes/:name`
- 指标:
- CPU 趋势:`GET /v1/metrics/cpu_trend?cluster=<uuid>` → `{ times: string[], values: number[] }`
- 内存使用:`GET /v1/metrics/memory_usage?cluster=<uuid>` → `{ used: number, free: number }`
- 诊断:
- 故障摘要:`GET /v1/faults/summary?node=<name>|cluster=<host>` → `{ code, time, scope }`
- AI 对话历史:`GET /v1/ai/history?sessionId=<id>` → `{ messages: Array<{ role, content, reasoning? }> }`
- AI 对话:`POST /v1/ai/chat` → `{ reply, reasoning? }`
## 前端数据绑定要点
- 个人主页:`frontend-vue/src/app/views/Profile.vue`
- 仅使用后端数据;加载中与错误态可视化
- 角色标签映射:`frontend-vue/src/app/constants/roles.ts`
- 角色来源:
- 登录/注册优先采用后端返回的 `user.role``role``frontend-vue/src/app/stores/auth.ts:55-72,74-91`
- 诊断页与导航:
- 路由授权:`diagnosis` 允许 `admin/operator``frontend-vue/src/app/router/index.ts:12`
- 侧边栏入口基于角色显示(`frontend-vue/src/app/components/Sidebar.vue:7`
## 联调步骤
- 设置 `VITE_API_TARGET` 指向后端地址,确保后端允许来自前端的 CORS 与 Host
- 启动前端后检查 `GET /v1/health` 返回正常
- 使用真实账号登录,确认:
- `GET /v1/user/me` 返回用户信息,个人主页字段显示为后端数据
- 侧边栏与页面访问受角色控制
- 仪表板与诊断页数据来自后端接口
- 常见问题:
- Token 无效或过期 → 返回 `401`,需要重新登录
- 代理失败 → 检查 `VITE_API_TARGET` 与后端协议/端口
- 外网访问本地开发 → 配置 `VITE_ALLOWED_HOSTS`,参考 `Cloudflare-Tunnel-Guide.md`
## 参考文件
- `frontend-vue/vite.config.ts`
- `frontend-vue/src/app/main.ts`
- `frontend-vue/src/app/router/index.ts`
- `frontend-vue/src/app/stores/auth.ts`
- `frontend-vue/src/app/views/Profile.vue`
- `frontend-vue/src/app/components/Sidebar.vue`
- `frontend-vue/src/app/constants/roles.ts`

@ -1,37 +1,37 @@
{
"hash": "4ef6bb89",
"hash": "6cbacb0f",
"configHash": "8580b9f5",
"lockfileHash": "82745bba",
"browserHash": "63ea4e11",
"lockfileHash": "ef5c83d3",
"browserHash": "a05a1ddb",
"optimized": {
"axios": {
"src": "../../node_modules/axios/index.js",
"file": "axios.js",
"fileHash": "00fa363e",
"fileHash": "633af732",
"needsInterop": false
},
"echarts": {
"src": "../../node_modules/echarts/index.js",
"file": "echarts.js",
"fileHash": "411d0844",
"fileHash": "4b0e29e6",
"needsInterop": false
},
"pinia": {
"src": "../../node_modules/pinia/dist/pinia.mjs",
"file": "pinia.js",
"fileHash": "547a4649",
"fileHash": "99c547e7",
"needsInterop": false
},
"vue": {
"src": "../../node_modules/vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js",
"fileHash": "cdee61b0",
"fileHash": "858c012f",
"needsInterop": false
},
"vue-router": {
"src": "../../node_modules/vue-router/dist/vue-router.mjs",
"file": "vue-router.js",
"fileHash": "6b0fa1a5",
"fileHash": "8f1ed449",
"needsInterop": false
}
},

@ -1,9 +1,9 @@
<template>
<div ref="root" style="height:260px"></div>
<div ref="root" style="width:100%;height:260px"></div>
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { onMounted, onBeforeUnmount, ref, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
import api from '../lib/api'
import { useAuthStore } from '../stores/auth'
@ -11,6 +11,7 @@ const props = defineProps<{ cluster: string }>()
const auth = useAuthStore()
const root = ref<HTMLElement|null>(null)
let chart: echarts.ECharts | null = null
let ro: ResizeObserver | 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 }] })
}
@ -31,7 +32,11 @@ onMounted(() => {
load()
const onResize = () => chart && chart.resize()
window.addEventListener('resize', onResize)
ro = new ResizeObserver(() => { chart && chart.resize() })
ro.observe(root.value)
nextTick(() => { chart && chart.resize() })
setTimeout(() => { chart && chart.resize() }, 300)
})
watch(() => props.cluster, () => load())
onBeforeUnmount(() => { chart?.dispose(); chart = null })
onBeforeUnmount(() => { ro?.disconnect(); ro = null; chart?.dispose(); chart = null })
</script>

@ -1,9 +1,9 @@
<template>
<div ref="root" style="height:260px"></div>
<div ref="root" style="width:100%;height:260px"></div>
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { onMounted, onBeforeUnmount, ref, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
import api from '../lib/api'
import { useAuthStore } from '../stores/auth'
@ -11,6 +11,7 @@ const props = defineProps<{ cluster: string }>()
const auth = useAuthStore()
const root = ref<HTMLElement|null>(null)
let chart: echarts.ECharts | null = null
let ro: ResizeObserver | null = null
function render(used: number, free: number) {
chart?.setOption({ series: [{ type: 'pie', radius: ['40%','70%'], data: [{ value: used, name: '已使用' }, { value: free, name: '可用' }] }] })
}
@ -31,7 +32,11 @@ onMounted(() => {
load()
const onResize = () => chart && chart.resize()
window.addEventListener('resize', onResize)
ro = new ResizeObserver(() => { chart && chart.resize() })
ro.observe(root.value)
nextTick(() => { chart && chart.resize() })
setTimeout(() => { chart && chart.resize() }, 300)
})
watch(() => props.cluster, () => load())
onBeforeUnmount(() => { chart?.dispose(); chart = null })
onBeforeUnmount(() => { ro?.disconnect(); ro = null; chart?.dispose(); chart = null })
</script>

@ -41,8 +41,8 @@ function isActive(p: string) { return route.path === p }
function can(roles: string[]) { return roles.includes(role.value || '') }
const configOpen = ref(false)
const permOpen = ref(false)
function toggleConfig(){ closeAll(); configOpen.value = !configOpen.value }
function togglePerm(){ closeAll(); permOpen.value = !permOpen.value }
function toggleConfig(){ const next = !configOpen.value; closeAll(); configOpen.value = next }
function togglePerm(){ const next = !permOpen.value; closeAll(); permOpen.value = next }
function closeAll(){ configOpen.value = false; permOpen.value = false }
function onDocClick(){ closeAll() }
onMounted(()=>{ document.addEventListener('click', onDocClick) })

@ -9,6 +9,15 @@ function makeDemoToken() {
return `demo.${body}.${Math.random().toString(36).slice(2)}`
}
function normalizeRole(r: string): 'admin'|'operator'|'observer'|'' {
const v = String(r || '').trim().toLowerCase()
if (!v) return ''
if (v === 'admin' || v === 'administrator') return 'admin'
if (v === 'operator' || v === 'ops' || v === 'op') return 'operator'
if (v === 'observer' || v === 'obs' || v === 'view') return 'observer'
return ''
}
export const useAuthStore = defineStore('auth', {
state: () => ({ user: null as User|null, token: null as string|null }),
getters: {
@ -53,8 +62,10 @@ export const useAuthStore = defineStore('auth', {
}
try {
const r = await api.post('/v1/user/login', { username, password })
const role = username === 'admin' ? 'admin' : username === 'ops' ? 'operator' : username === 'obs' ? 'observer' : 'observer'
const token = r?.data?.token
const backendRoleRaw = (r?.data?.user?.role || r?.data?.role || '') as string
const backendRole = normalizeRole(backendRoleRaw)
const role: 'admin'|'operator'|'observer' = backendRole || (username === 'admin' ? 'admin' : username === 'ops' ? 'operator' : username === 'obs' ? 'observer' : 'observer')
if (!token) {
return { ok: false, message: '登录失败' }
}
@ -74,7 +85,9 @@ export const useAuthStore = defineStore('auth', {
async register(username: string, email: string, password: string, fullName: string) {
try {
const r = await api.post('/v1/user/register', { username, email, password, fullName })
const role = username === 'admin' ? 'admin' : username === 'ops' ? 'operator' : username === 'obs' ? 'observer' : 'observer'
const backendRoleRaw = (r?.data?.user?.role || r?.data?.role || '') as string
const backendRole = normalizeRole(backendRoleRaw)
const role: 'admin'|'operator'|'observer' = backendRole || (username === 'admin' ? 'admin' : username === 'ops' ? 'operator' : username === 'obs' ? 'observer' : 'observer')
this.user = { username, role }
this.token = r?.data?.token || null
this.persist()

@ -17,10 +17,10 @@
<div class="u-text-sm u-text-gray-700">{{ err }}</div>
</div>
<table id="cluster-list-table" class="dashboard__table">
<thead><tr><th>主机名</th><th>IP</th><th>节点数</th><th>健康</th><th>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;操作</th></tr></thead>
<thead><tr><th>集群名</th><th>节点数</th><th>健康</th><th>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;操作</th></tr></thead>
<tbody>
<tr v-for="c in clusters" :key="c.uuid" class="dashboard__table-row" @click="toDashboard(c)">
<td>{{ c.host }}</td><td>{{ c.ip }}</td><td>{{ c.count }}</td>
<td>{{ c.host }}</td><td>{{ c.count }}</td>
<td><span>{{ c.healthText }}</span></td>
<td>
<button class="btn u-text-sm" @click.stop="toDashboard(c)">进入详情</button>

@ -4,7 +4,7 @@
<h2 class="layout__page-title">仪表板 · 集群概览</h2>
<div class="top-meta">
<span>更新时间{{ updateTime }}</span>
<span>当前集群{{ meta.uuid }} | 主机{{ meta.host }} | 主IP{{ meta.ip }}</span>
<span>当前集群{{ meta.uuid }} | 集群{{ meta.host }}</span>
</div>
</div>
<div class="stats-grid">
@ -128,7 +128,7 @@ async function detail(name:string){ try{ await api.get(`/v1/nodes/${encodeURICom
.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-dot--warning{ background:#16a34a }
.status-dot--error{ background:#16a34a }
.status-text{ font-size:12px }
</style>

@ -50,12 +50,12 @@
<div class="u-text-sm u-text-gray-700 u-mt-2">{{ filterSummary }}</div>
</div>
<div class="diag-group" v-for="g in filteredGroups" :key="g.id">
<button class="diag-group-toggle" type="button" @click="g.open=!g.open">
<button class="diag-group-toggle" type="button" @click="toggleGroup(g)">
<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)">
<li v-for="n in nodesForGroup(g)" :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>
@ -69,14 +69,15 @@
<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 class="fault-row"><span class="fault-key">故障代码</span><span class="fault-val">{{ fault?.code || '—' }}</span></div>
<div class="fault-row"><span class="fault-key">发生时间</span><span class="fault-val">{{ fault?.time || '—' }}</span></div>
<div class="fault-row"><span class="fault-key">影响范围</span><span class="fault-val">{{ fault?.scope || '—' }}</span></div>
<div class="u-text-sm u-text-error u-mt-1" v-if="faultErr">{{ faultErr }}</div>
</div>
</article>
</aside>
<main class="diag-preview">
<aside class="diag-preview">
<article class="layout__card">
<div class="layout__card-header"><h3 class="layout__card-title">日志预览</h3></div>
<div class="layout__card-body">
@ -99,12 +100,12 @@
</div>
</div>
</article>
</main>
</aside>
<aside class="diag-assistant">
<article class="layout__card">
<div class="layout__card-body">
<div class="assist-row">
<div class="assist-row u-mb-2">
<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">
@ -119,8 +120,6 @@
</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" ref="chatHistory">
@ -161,11 +160,12 @@ const tab = ref<'live'|'auto'>('live')
const agent = ref('诊断智能体')
const model = ref('deepseek')
const filters = reactive<{ level:string; cluster:string; node:string; opType:string; sourceId:string; timeRange:string }>({ level:'', cluster:'', node:'', opType:'', sourceId:'', timeRange:'' })
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'] },
])
type Group = { id:string; name:string; open:boolean; nodes:string[]; count?:number }
const groups = reactive<Group[]>([])
const loadingSidebar = ref(false)
type FaultInfo = { code:string; time:string; scope:string }
const fault = ref<FaultInfo|null>(null)
const faultErr = ref('')
const selectedNode = ref('')
const clusterOptions = computed(()=> groups.map(g=>g.name))
const nodesOptions = computed(()=>{
@ -176,16 +176,76 @@ const nodesOptions = computed(()=>{
return groups.flatMap(g=>g.nodes)
})
const filteredGroups = computed(()=>{
const k = kw.value.trim().toLowerCase()
let base = groups
if (filters.cluster) base = base.filter(g => g.name === filters.cluster)
return base.map(g=>{
let nodes = g.nodes
if (k) nodes = nodes.filter(n => n.toLowerCase().includes(k) || g.name.toLowerCase().includes(k))
if (filters.node) nodes = nodes.filter(n => n === filters.node)
return { ...g, nodes }
})
const kraw = kw.value.trim().toLowerCase()
let base = groups.filter(g => !filters.cluster || g.name === filters.cluster)
if (kraw) {
base = base.filter(g => g.name.toLowerCase().includes(kraw) || g.nodes.some(n => n.toLowerCase().includes(kraw)))
}
if (filters.node) {
base = base.filter(g => g.nodes.includes(filters.node))
}
return base
})
function nodesForGroup(g:{ id:string; name:string; open:boolean; nodes:string[] }){
const k = kw.value.trim().toLowerCase()
let nodes = g.nodes
if (k) nodes = nodes.filter(n => n.toLowerCase().includes(k) || g.name.toLowerCase().includes(k))
if (filters.node) nodes = nodes.filter(n => n === filters.node)
return nodes
}
function pad3(n:number){ return String(n).padStart(3,'0') }
async function loadClusters(){
loadingSidebar.value = true
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 : []
const mapped: Group[] = list.map((x:any)=>({
id: String(x.uuid || x.id || x.host || x.name || ''),
name: String(x.host || x.name || x.uuid || ''),
open: false,
nodes: [],
count: Number(x.count)||0
})).filter(g=>g.id && g.name)
groups.splice(0, groups.length, ...mapped)
}catch(e:any){
//
err.value = formatError(e, '集群列表加载失败')
}finally{
loadingSidebar.value = false
}
}
async function loadNodesFor(clusterName:string){
const g = groups.find(x=>x.name === clusterName)
if (!g) return
try{
const r = await api.get('/v1/nodes', { params: { cluster: clusterName }, headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
const nodes = Array.isArray(r.data?.nodes) ? r.data.nodes.map((x:any)=>String(x?.name||x)).filter(Boolean) : []
if (nodes.length) g.nodes = nodes
else if ((g.count||0) > 0) g.nodes = Array.from({length: g.count as number}, (_,i)=>`${clusterName}-${pad3(i+1)}`)
}catch(e:any){
if ((g.count||0) > 0 && g.nodes.length===0) g.nodes = Array.from({length: g.count as number}, (_,i)=>`${clusterName}-${pad3(i+1)}`)
//
err.value = formatError(e, '节点列表加载失败')
}
}
async function toggleGroup(g:Group){
g.open = !g.open
if (g.open && g.nodes.length===0) await loadNodesFor(g.name)
}
async function loadFaultInfo(){
faultErr.value = ''
fault.value = null
const params:any = {}
if (selectedNode.value) params.node = selectedNode.value
else if (filters.cluster) params.cluster = filters.cluster
try{
const r = await api.get('/v1/faults/summary', { params, headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
const d = r?.data?.fault || r?.data?.data || null
if (d) fault.value = { code: String(d.code||''), time: String(d.time||''), scope: String(d.scope||'') }
}catch(e:any){
faultErr.value = formatError(e, '故障信息加载失败')
}
}
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(() => {
@ -255,8 +315,10 @@ async function generateReport(){
inputMsg.value = inputMsg.value || `请根据当前节点${selectedNode.value || '(未选定)'}最近关键日志生成一份状态报告(包含症状、影响范围、根因假设与建议)。`
await send()
}
onMounted(()=>{ loadHistory() })
onMounted(async ()=>{ await loadClusters(); await loadHistory(); await loadFaultInfo() })
watch(selectedNode, () => { loadHistory() })
watch(selectedNode, () => { loadFaultInfo() })
watch(() => filters.cluster, () => { loadFaultInfo() })
function formatError(e:any, def:string){
const r = e?.response
const s = r?.status
@ -278,12 +340,13 @@ function formatError(e:any, def:string){
.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: var(--diag-sidebar-width, 30%) 1fr var(--diag-assistant-width, 45%); gap:16px }
.diag-sidebar{ background:#ffffff; border:1px solid #e5e7eb; border-radius:12px; padding:12px; display:flex; flex-direction:column }
.diag-layout{ display:grid; grid-template-columns: var(--diag-sidebar-width, 30%) 1fr var(--diag-assistant-width, 30%); gap:16px; align-items: stretch }
.diag-sidebar{ background:#ffffff; border:1px solid #e5e7eb; border-radius:12px; padding:12px; display:flex; flex-direction:column; overflow-x:hidden }
.diag-filter{ padding-bottom:12px; border-bottom:1px solid #e5e7eb }
.diag-filter-grid{ display:grid; grid-template-columns: 1fr 1fr; gap:12px; margin-top:8px }
.diag-filter-grid > div{ display:flex; flex-direction:column }
.diag-filter-grid > div{ display:flex; flex-direction:column; min-width:0 }
.diag-filter-grid label{ margin-bottom:4px }
.diag-sidebar select{ width:100%; max-width:100%; box-sizing:border-box }
.filter-actions{ display:flex; justify-content:flex-end; margin-top:8px }
.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 }
@ -296,8 +359,8 @@ function formatError(e:any, def:string){
.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 }
.status-dot--warning{ background:#16a34a }
.status-dot--error{ background:#16a34a }
.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 }
@ -305,18 +368,26 @@ function formatError(e:any, def:string){
.fault-key{ color:#6b7280; font-size:12px }
.fault-val{ font-weight:600 }
.diag-preview{ display:flex }
.diag-preview{ display:flex; flex-direction:column; height:100% }
.diag-preview .layout__card{ display:flex; flex-direction:column; height:100%; width:100% }
.diag-preview .layout__card-body{ flex:1; overflow:auto }
.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 }
.preview-body{ background:#f9fafb; border:1px solid #e5e7eb; border-radius:8px; margin-top:8px; flex:1 }
.diag-assistant{ display:flex; flex-direction:column; margin-right: -16px }
.diag-assistant{ display:flex; flex-direction:column; margin-right: 16px }
.diag-assistant .layout__card{ display:flex; flex-direction:column; height:100% }
.diag-assistant .layout__card-body{ flex:1; overflow:auto }
.diag-assistant .layout__card-body:first-of-type{ flex:0; overflow:visible; padding-bottom:8px }
.diag-assistant .layout__card-body:last-of-type{ flex:1; overflow:auto }
.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; max-height: 360px; overflow-y: auto; overscroll-behavior: contain; padding-right: 4px }
.chat-item{ display:flex; gap:8px }
.chat-role{ width:72px; color:#6b7280 }
.chat-text{ flex:1; background:#f9fafb; border:1px solid #e5e7eb; border-radius:8px; padding:8px 10px }
.chat-text{ flex:1; background:#f9fafb; border:1px solid #e5e7eb; border-radius:8px; padding:8px 10px; max-width:100%; overflow-x:hidden; word-break:break-word }
.chat-text *{ word-break:break-word; overflow-wrap:anywhere }
.chat-history{ overflow-x:hidden }
.chat-item--assistant .chat-text{ background:#f9fafb }
.chat-item--user .chat-text{ background:#eef2ff; border-color:#c7d2fe }
.chat-input{ width:100%; min-height:80px; margin-top:8px; padding:8px; border:1px solid #e5e7eb; border-radius:8px }

@ -52,7 +52,7 @@ async function onSubmit() {
.login__subtitle{ color:#6b7280; margin-top:4px }
.login__form{ display:flex; flex-direction:column; gap:10px; margin-top:8px }
.login__input{ width:100% }
.login__btn{ width:100% }
.login__btn{ width:100%; display:flex; justify-content:center }
.login__hint{ font-size:12px; color:#a16207; background:#fef3c7; border:1px solid #fde68a; border-radius:8px; padding:8px; margin-top:10px }
.login__msg{ font-size:12px; color:#dc2626; margin-top:8px }
.login__health{ font-size:12px; margin-top:8px; color:#6b7280 }

@ -2,7 +2,7 @@
<section class="layout__section">
<div class="layout__page-header exec-header">
<div>
<h2 class="layout__page-title">个人主页原型</h2>
<h2 class="layout__page-title">个人主页</h2>
<div class="layout__page-subtitle">查看与管理个人基础信息</div>
</div>
<div class="layout__page-actions"><button class="btn" disabled>编辑资料</button></div>
@ -10,7 +10,9 @@
<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 v-if="loading" class="u-text-sm u-text-gray-700">...</div>
<div v-else-if="err" class="u-text-sm" style="color:#dc2626">{{ err }}</div>
<div v-else 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>
@ -37,24 +39,39 @@ import api from '../lib/api'
import { RoleLabel } from '../constants/roles'
const auth = useAuthStore()
const { user, token } = storeToRefs(auth)
const username = ref('admin')
const email = ref('admin@example.com')
const roleName = ref('管理员')
const username = ref('')
const email = ref('')
const roleName = ref('')
const loading = ref(true)
const err = ref('')
function normalizeRole(r: string): 'admin'|'operator'|'observer' {
const v = String(r || '').trim().toLowerCase()
if (v === 'admin' || v === 'administrator') return 'admin'
if (v === 'operator' || v === 'ops' || v === 'op') return 'operator'
return 'observer'
}
onMounted(async () => {
loading.value = true
err.value = ''
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'
const r = await api.get('/v1/users', { headers: token.value ? { Authorization: `Bearer ${token.value}` } : undefined })
const list = Array.isArray(r?.data?.users) ? r.data.users : (Array.isArray(r?.data) ? r.data : [])
const currentName = String(user.value?.username || '')
const picked = (list || []).find((x:any) => String(x?.username || '') === currentName)
if (!picked) { err.value = '未找到当前用户'; return }
const name = String(picked?.username || '')
const emailVal = String(picked?.email || '')
const roleRaw = String(picked?.role || '')
const roleKey = normalizeRole(roleRaw)
username.value = name
email.value = u?.email || `${name}@example.com`
const roleKey = u?.role || user.value?.role || 'admin'
roleName.value = RoleLabel[roleKey as keyof typeof RoleLabel] || '观察员'
email.value = emailVal
roleName.value = RoleLabel[roleKey as keyof typeof RoleLabel] || roleRaw || '观察员'
if (name && name === currentName) { auth.user = { username: name, role: roleKey as any }; auth.persist() }
}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 = RoleLabel[roleKey as keyof typeof RoleLabel] || '观察员'
const s = e?.response?.status
err.value = s === 403 ? '权限不足,无法读取用户列表' : '个人信息加载失败'
}finally{
loading.value = false
}
})
</script>

Loading…
Cancel
Save