modify: worker 管理

This commit is contained in:
2026-08-27 14:45:10 +08:00
parent 79d29a8d8d
commit 02b763bfb6
11 changed files with 1089 additions and 147 deletions
+124 -48
View File
@@ -1,87 +1,163 @@
<template>
<el-container class="app-container">
<el-header class="app-header">
<div class="header-left">
<el-icon :size="28" color="#409EFF"><Cpu /></el-icon>
<span class="app-title">Chongming 平台</span>
</div>
<el-menu
:default-active="$route.path"
mode="horizontal"
router
class="header-menu"
background-color="transparent"
text-color="#333"
active-text-color="#409EFF"
>
<el-menu-item index="/">
<el-icon><HomeFilled /></el-icon> 豆包测试
</el-menu-item>
<el-menu-item index="/about">
<el-icon><InfoFilled /></el-icon> 关于
</el-menu-item>
</el-menu>
</el-header>
<div class="layout">
<el-container class="app-container">
<el-header class="app-header">
<div class="header-left">
<div class="logo-badge">
<el-icon :size="22" color="#4f8cff"><Cpu /></el-icon>
</div>
<div class="brand">
<span class="app-title">Chongming</span>
<span class="app-subtitle">Zenoh 微服务网关平台</span>
</div>
</div>
<el-main class="app-main">
<router-view />
</el-main>
<el-menu
:default-active="$route.path"
mode="horizontal"
router
class="header-menu"
:ellipsis="false"
>
<el-menu-item index="/">
<el-icon><HomeFilled /></el-icon>
<span>豆包测试</span>
</el-menu-item>
<el-menu-item index="/workers">
<el-icon><Monitor /></el-icon>
<span>Worker 管理</span>
</el-menu-item>
<el-menu-item index="/about">
<el-icon><InfoFilled /></el-icon>
<span>关于</span>
</el-menu-item>
</el-menu>
</el-header>
<el-footer class="app-footer" height="50px">
© 2026 Chongming · 基于 FastAPI + Vue 3 + Element Plus
</el-footer>
</el-container>
<el-main class="app-main">
<router-view />
</el-main>
<el-footer class="app-footer" height="52px">
<span>© 2026 Chongming · FastAPI + Zenoh + Vue 3 + Element Plus</span>
</el-footer>
</el-container>
</div>
</template>
<script setup lang="ts">
import { Cpu, HomeFilled, InfoFilled } from '@element-plus/icons-vue'
import { Cpu, HomeFilled, InfoFilled, Monitor } from '@element-plus/icons-vue'
</script>
<style scoped>
.layout {
min-height: 100vh;
background: var(--cm-bg);
}
.app-container {
min-height: 100vh;
background: #f5f7fa;
}
/* ---------- 顶部导航 ---------- */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
background: #ffffff;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
padding: 0 24px;
height: 64px;
border-bottom: 1px solid #e4e7ed;
padding: 0 28px;
position: sticky;
top: 0;
z-index: 20;
background: rgba(15, 20, 28, 0.75);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-bottom: 1px solid var(--cm-border);
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
gap: 12px;
}
.logo-badge {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
background: linear-gradient(135deg, rgba(79, 140, 255, 0.22), rgba(79, 140, 255, 0.06));
border: 1px solid rgba(79, 140, 255, 0.35);
}
.brand {
display: flex;
flex-direction: column;
line-height: 1.15;
}
.app-title {
font-size: 20px;
font-weight: 600;
color: #2c3e50;
font-size: 19px;
font-weight: 700;
color: var(--cm-text);
letter-spacing: 0.4px;
}
.app-subtitle {
font-size: 11px;
color: var(--cm-text-muted);
letter-spacing: 0.3px;
}
/* ---------- 菜单 ---------- */
.header-menu {
border-bottom: none;
flex: 1;
justify-content: flex-end;
border-bottom: none;
background: transparent;
--el-menu-hover-bg-color: rgba(79, 140, 255, 0.08);
--el-menu-active-color: #4f8cff;
}
.header-menu .el-menu-item {
height: 64px;
line-height: 64px;
color: var(--cm-text-muted);
border-bottom: none;
margin: 0 2px;
border-radius: 8px;
transition: all 0.2s;
}
.header-menu .el-menu-item:hover {
color: var(--cm-text);
background: rgba(79, 140, 255, 0.08);
}
.header-menu .el-menu-item.is-active {
color: #4f8cff;
background: rgba(79, 140, 255, 0.12);
font-weight: 600;
}
.header-menu .el-menu-item i {
margin-right: 6px;
}
/* ---------- 主内容 ---------- */
.app-main {
padding: 24px;
padding: 28px;
max-width: 1200px;
margin: 0 auto;
width: 100%;
margin: 0 auto;
}
/* ---------- 底部 ---------- */
.app-footer {
text-align: center;
color: #909399;
font-size: 14px;
border-top: 1px solid #e4e7ed;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
color: var(--cm-text-muted);
font-size: 13px;
border-top: 1px solid var(--cm-border);
background: rgba(15, 20, 28, 0.6);
}
</style>
+162
View File
@@ -0,0 +1,162 @@
/**
* Worker 管理 API(手动封装,管理端点在网关 /admin/workers 下)
*
* 管理端点(重启)仅超级管理员可调用,故所有请求需携带管理员的 Bearer token。
* 未列入自动生成 SDK(openapi 之后可重新 gen 合并),此处用原生 fetch 保持轻量。
*/
const API_BASE: string = (import.meta.env.VITE_API_BASE_URL as string) || ''
export interface WorkerInstance {
worker_type: string
worker_id: string
mode: 'inline' | 'external'
alive: boolean
}
export interface AdminCmdReply {
status?: string
cmd?: string
worker_type?: string
worker_id?: string
exit_request?: string
}
export interface RestartAllResult {
worker_id: string
ok: boolean
result?: AdminCmdReply
error?: string
}
async function request<T>(path: string, token: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init.headers || {}),
},
})
if (!res.ok) {
let detail = `HTTP ${res.status}`
try {
const body = await res.json()
if (body?.detail) {
detail = typeof body.detail === 'string' ? body.detail : JSON.stringify(body.detail)
}
} catch {
/* ignore non-JSON error body */
}
throw new Error(detail)
}
return res.json() as Promise<T>
}
/** 盘点全部 Worker 实例 */
export function getWorkers(token: string) {
return request<{ success: boolean; workers: WorkerInstance[] }>('/api/v1/admin/workers', token)
}
/** 精细化重启单个 Worker 副本 */
export function restartWorker(token: string, workerType: string, workerId: string) {
const qs = new URLSearchParams({ worker_type: workerType, worker_id: workerId })
return request<{ success: boolean; result: AdminCmdReply }>(
`/api/v1/admin/workers/restart?${qs.toString()}`,
token,
{ method: 'POST' },
)
}
/** 重启某插件全部副本 */
export function restartAll(token: string, workerType: string) {
return request<{ success: boolean; total: number; failed: number; results: RestartAllResult[] }>(
`/api/v1/admin/workers/${encodeURIComponent(workerType)}/restart`,
token,
{ method: 'POST' },
)
}
export interface ConfigUpdateResult {
total: number
failed: number
ok: boolean
results: Array<{ worker_id: string; ok: boolean; reply?: AdminCmdReply; error?: string }>
errors: string[]
}
export interface AuthUser {
username: string
is_superuser: boolean
}
export interface LoginResult {
success: boolean
result: string
message: string
token: string
timestamp: number
}
/** 通过 auth 插件登录接口换取管理 Token(无需手动填 JWT,返回体只有 token,用户信息需从 JWT 解析) */
export function login(username: string, password: string) {
return request<LoginResult>('/api/v1/auth/login', '', {
method: 'POST',
body: JSON.stringify({ username, password }),
})
}
/** 从 JWT payload 解析用户名与超管标识(仅本地展示用,权限校验以后端为准) */
export function parseJwt(token: string): AuthUser | null {
try {
const part = token.split('.')[1]
if (!part) return null
const b64 = part.replace(/-/g, '+').replace(/_/g, '/')
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))
const payload = JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>
return {
username: String(payload.username ?? 'unknown'),
is_superuser: !!payload.is_superuser,
}
} catch {
return null
}
}
/** 读取某插件的现有运行时配置([workers.config]) */
export function getWorkerConfig(token: string, workerType: string) {
return request<{ success: boolean; worker_type: string; config: Record<string, unknown> }>(
`/api/v1/admin/workers/${encodeURIComponent(workerType)}/config`,
token,
)
}
/** 更新某插件的运行时配置([workers.config],路由配置除外),由 worker 写回并重启 */
export function updateWorkerConfig(token: string, workerType: string, updates: Record<string, unknown>) {
return request<ConfigUpdateResult>(
`/api/v1/admin/workers/${encodeURIComponent(workerType)}/config`,
token,
{ method: 'POST', body: JSON.stringify({ updates }) },
)
}
export interface ScaleResult {
success: boolean
result: {
worker_type: string
target: number
mode: 'inline' | 'external'
sent_to?: string
reply?: AdminCmdReply & { persist?: string }
}
}
/** 调整某类型 worker 副本数量(扩缩容,缩容走优雅排空),结果含持久化信息 */
export function scaleWorker(token: string, workerType: string, count: number) {
return request<ScaleResult>(
`/api/v1/admin/workers/${encodeURIComponent(workerType)}/scale`,
token,
{ method: 'POST', body: JSON.stringify({ count }) },
)
}
export const TOKEN_KEY = 'cm_admin_token'
+51 -60
View File
@@ -1,53 +1,39 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
/* ============================================================
Chongming 深色主题基础样式
始终使用深色配色(html.dark 已启用),并微调 Element Plus
深色变量以获得更统一的观感。
============================================================ */
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
:root,
html.dark {
/* 背景层级 */
--cm-bg: #0d1117; /* 页面最底层 */
--cm-bg-soft: #161b22; /* 卡片/面板 */
--cm-bg-elevated: #1c2129; /* 悬浮/表头 */
--cm-border: #2a303a; /* 分割线 */
--cm-text: #e6edf3; /* 主文字 */
--cm-text-muted: #8b949e; /* 次要文字 */
--vt-c-indigo: #2c3e50;
/* Element Plus 深色变量覆盖:统一背景与文字 */
--el-bg-color: #161b22;
--el-bg-color-page: #0d1117;
--el-bg-color-overlay: #1c2129;
--el-fill-color-blank: #161b22;
--el-fill-color: #21262d;
--el-fill-color-light: #262c34;
--el-fill-color-lighter: #2a303a;
--el-fill-color-lightest: #2f3640;
--el-border-color: #2a303a;
--el-border-color-light: #2a303a;
--el-border-color-lighter: #30363d;
--el-border-color-extra-light: #353b45;
--el-text-color-primary: #e6edf3;
--el-text-color-regular: #c9d1d9;
--el-text-color-secondary: #8b949e;
--el-text-color-placeholder: #6e7681;
--el-color-primary: #4f8cff;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
color-scheme: dark;
}
*,
@@ -58,29 +44,34 @@
font-weight: normal;
}
body {
html,
body,
#app {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
width: 100%;
}
body {
margin: 0;
color: var(--cm-text);
background: var(--cm-bg);
background-image:
radial-gradient(1200px 600px at 10% -10%, rgba(79, 140, 255, 0.10), transparent 60%),
radial-gradient(1000px 500px at 110% 10%, rgba(79, 140, 255, 0.06), transparent 55%);
background-attachment: fixed;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
Arial,
'PingFang SC',
'Microsoft YaHei',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
+21 -24
View File
@@ -1,35 +1,32 @@
@import './base.css';
/* 应用全局兜底:字号与滚动条 */
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
}
a,
.green {
a {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
color: var(--el-color-primary);
transition: 0.2s;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
/* 全局深色细滚动条 */
* {
scrollbar-width: thin;
scrollbar-color: var(--cm-border) transparent;
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
background: var(--cm-border);
border-radius: 6px;
}
*::-webkit-scrollbar-thumb:hover {
background: #3d4550;
}
*::-webkit-scrollbar-track {
background: transparent;
}
+1
View File
@@ -3,6 +3,7 @@ import './assets/main.css'
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import { createPinia } from 'pinia'
import App from './App.vue'
+6
View File
@@ -17,6 +17,12 @@ const router = createRouter({
// which is lazy-loaded when the route is visited.
component: () => import('../views/AboutView.vue'),
},
{
path: '/workers',
name: 'workers',
// 懒加载 Worker 管理页面(实例盘点 + 单/批量重启)
component: () => import('../views/WorkerView.vue'),
},
],
})
+42 -10
View File
@@ -1,16 +1,48 @@
<template>
<div class="about">
<h1>关于页面</h1>
<p>这里是后续扩展的示例页面。</p>
<el-card class="about-card" shadow="never">
<template #header>
<div class="card-header">
<el-icon :size="20" color="#4f8cff"><InfoFilled /></el-icon>
<span class="title">关于 Chongming</span>
</div>
</template>
<p>Chongming 是配置驱动的微服务网关平台,基于 <b>FastAPI + Zenoh + MQTT + Vue 3</b> 构建。</p>
<p>Web 请求经网关路由到对应的 Worker 插件(RPC 层走 Eclipse Zenoh),支持分布式、多语言插件部署。</p>
</el-card>
</div>
</template>
<style>
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
}
<script setup lang="ts">
import { InfoFilled } from '@element-plus/icons-vue'
</script>
<style scoped>
.about {
max-width: 760px;
margin: 0 auto;
}
</style>
.about-card {
border-radius: 12px;
border: 1px solid var(--cm-border);
background: var(--cm-bg-soft);
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
}
.title {
font-weight: 700;
font-size: 17px;
color: var(--cm-text);
}
.about-card p {
margin-bottom: 12px;
color: var(--cm-text-regular, var(--cm-text));
line-height: 1.8;
}
.about-card b {
color: var(--cm-text);
}
</style>
+5 -3
View File
@@ -239,14 +239,15 @@ const copyResult = () => {
max-width: 100%;
max-height: 400px;
border-radius: 8px;
border: 1px solid #dcdfe6;
border: 1px solid var(--cm-border);
}
.preview-actions {
position: absolute;
top: 8px;
right: 8px;
background: rgba(255, 255, 255, 0.8);
background: rgba(22, 27, 34, 0.75);
border-radius: 50%;
backdrop-filter: blur(4px);
}
.action-bar {
margin-top: 16px;
@@ -255,11 +256,12 @@ const copyResult = () => {
flex-wrap: wrap;
}
.result-content {
background: #f5f7fa;
background: var(--el-fill-color-lighter);
padding: 16px;
border-radius: 8px;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.8;
color: var(--cm-text);
}
</style>
+658
View File
@@ -0,0 +1,658 @@
<template>
<div class="worker-container">
<el-card class="toolbar-card" shadow="hover">
<template v-if="!token">
<el-form inline>
<el-form-item label="用户名">
<el-input
v-model="username"
placeholder="用户名"
style="width: 200px"
@keyup.enter="doLogin"
/>
</el-form-item>
<el-form-item label="密码">
<el-input
v-model="password"
type="password"
show-password
placeholder="密码"
style="width: 220px"
@keyup.enter="doLogin"
/>
</el-form-item>
<el-button type="primary" :loading="logging" style="margin-left: 8px" @click="doLogin">
登录
</el-button>
</el-form>
<el-alert
type="info"
:closable="false"
title="通过 auth 插件登录领取管理 Token。仅超级管理员可配置/重启 Worker,权限不足将弹出提示。"
show-icon
style="margin-top: 8px"
/>
</template>
<template v-else>
<div class="login-bar">
<el-tag effect="plain" type="primary">
{{ currentUser?.username || '已登录' }}
</el-tag>
<el-tag v-if="currentUser?.is_superuser" type="success" size="small">
超级管理员
</el-tag>
<el-tag v-else type="danger" size="small">非超管(操作将被拒绝)</el-tag>
<el-button type="primary" :loading="loading" style="margin-left: 12px" @click="fetchWorkers">
刷新列表
</el-button>
<el-button
type="warning"
:disabled="!workerType || selected.length === 0"
@click="batchRestart"
>
重启选中 ({{ selected.length }})
</el-button>
<el-button type="info" plain style="margin-left: 12px" @click="logout">退出</el-button>
</div>
</template>
</el-card>
<el-card class="table-card" shadow="hover">
<template #header>
<div class="card-header">
<el-icon :size="20"><Monitor /></el-icon>
<span style="font-weight: 600; margin-left: 8px">Worker 实例列表</span>
</div>
</template>
<el-table
:data="pagedWorkers"
v-loading="loading"
style="width: 100%"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="50" />
<el-table-column prop="worker_type" label="插件类型" min-width="120">
<template #default="{ row }">
<el-tag effect="plain">{{ row.worker_type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="worker_id" label="副本 ID" min-width="200" show-overflow-tooltip />
<el-table-column label="模式" width="110">
<template #default="{ row }">
<el-tag :type="row.mode === 'inline' ? 'success' : 'warning'" size="small">
{{ row.mode }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.alive ? 'success' : 'danger'" size="small">
{{ row.alive ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button
type="danger"
size="small"
:loading="restartingId === row.worker_id"
@click="restartOne(row)"
>
重启单副本
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pager-bar">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
/>
</div>
<div v-if="workerTypes.length" style="margin-top: 12px">
<el-divider content-position="left">插件级批量操作</el-divider>
<el-select v-model="workerType" placeholder="选择插件类型" style="width: 200px">
<el-option
v-for="t in workerTypes"
:key="t"
:label="`${t}(${countByType(t)} 副本)`"
:value="t"
/>
</el-select>
<el-button
type="warning"
style="margin-left: 12px"
:loading="restartingType === workerType"
:disabled="!workerType"
@click="restartAllForType"
>
重启该插件全部副本
</el-button>
<el-button
type="primary"
style="margin-left: 12px"
:disabled="!workerType"
@click="openConfigDialog"
>
更新插件配置
</el-button>
<el-divider style="margin: 14px 0" />
<div class="scale-bar">
<span style="color: var(--cm-text-muted)"
>副本数量:当前 <strong>{{ workerType ? countByType(workerType) : '—' }}</strong></span
>
<el-input-number
v-model="scaleTarget"
:min="0"
:max="50"
:disabled="!workerType || scalingType === workerType"
style="margin-left: 12px"
/>
<el-button
type="success"
style="margin-left: 12px"
:loading="scalingType === workerType"
:disabled="!workerType"
@click="applyScale"
>
调整数量
</el-button>
<span class="scale-tip"
>调小会<strong>优雅排空</strong>:先隔离新请求,等当前请求处理完再关闭对应副本;数量将写入配置,重启网关后保持。</span
>
</div>
</div>
</el-card>
<el-dialog
v-model="configDialogVisible"
title="更新插件运行时配置"
width="520px"
:close-on-click-modal="false"
>
<el-alert
type="info"
:closable="false"
show-icon
title="写入插件 config.toml 的 [workers.config](路由配置除外)。由 worker 写回并重启所有对应副本。"
style="margin-bottom: 14px"
/>
<el-form label-position="top">
<el-form-item label="插件类型">
<el-tag effect="plain">{{ workerType || '-' }}</el-tag>
</el-form-item>
<el-form-item label="现有配置值">
<el-table
:data="currentConfig"
v-loading="configLoading"
size="small"
max-height="220"
highlight-current-row
@current-change="(row: any) => row && selectConfigEntry(row)"
style="width: 100%"
>
<el-table-column prop="key" label="键" width="160" />
<el-table-column prop="value" label="当前值" show-overflow-tooltip />
<el-table-column prop="type" label="类型" width="90">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.type }}</el-tag>
</template>
</el-table-column>
<template #empty>
<span style="color: var(--cm-text-muted)">暂无 `[workers.config]` 配置项,可直接新增</span>
</template>
</el-table>
</el-form-item>
<el-divider style="margin: 4px 0 14px" />
<el-form-item label="配置键" required>
<el-input v-model="configKey" placeholder="如 data_dir(点击上方行可带入)" />
</el-form-item>
<el-form-item label="配置值">
<el-select
v-model="configValueType"
style="width: 120px; margin-right: 8px"
>
<el-option label="字符串" value="string" />
<el-option label="数字" value="number" />
<el-option label="布尔" value="boolean" />
</el-select>
<el-input
v-model="configValue"
:style="{ width: 'calc(100% - 128px)' }"
placeholder="配置值"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="configDialogVisible = false">取消</el-button>
<el-button
type="primary"
:loading="configUpdating"
:disabled="!configKey"
@click="submitConfig"
>
下发并重启
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Monitor } from '@element-plus/icons-vue'
import {
getWorkers,
restartWorker,
restartAll,
getWorkerConfig,
updateWorkerConfig,
scaleWorker,
login,
parseJwt,
TOKEN_KEY,
type WorkerInstance,
type AuthUser,
} from '@/api/workerAdmin'
const token = ref(localStorage.getItem(TOKEN_KEY) || '')
const currentUser = ref<AuthUser | null>(null)
const username = ref('')
const password = ref('')
const logging = ref(false)
const loading = ref(false)
const workers = ref<WorkerInstance[]>([])
const selected = ref<WorkerInstance[]>([])
const restartingId = ref('')
const restartingType = ref('')
const workerType = ref('')
// 分页
const currentPage = ref(1)
const pageSize = ref(10)
const total = computed(() => workers.value.length)
const pagedWorkers = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return workers.value.slice(start, start + pageSize.value)
})
// 数据刷新/变化时回到第一页
watch(workers, () => {
currentPage.value = 1
})
// 配置编辑
const configDialogVisible = ref(false)
const configKey = ref('')
const configValue = ref('')
const configValueType = ref<'string' | 'number' | 'boolean'>('string')
const configUpdating = ref(false)
const configLoading = ref(false)
const currentConfig = ref<Array<{ key: string; value: string; type: 'string' | 'number' | 'boolean' }>>([])
const doLogin = async () => {
const name = username.value.trim()
if (!name || !password.value) {
ElMessage.warning('请输入用户名和密码')
return
}
logging.value = true
try {
const data = await login(name, password.value)
// 后端登录失败仍返回 HTTP 200,仅 result=fail + 空 token
if (data.result !== 'success' || !data.token) {
ElMessage.error(`登录失败:${data.message || '用户名或密码错误'}`)
return
}
token.value = data.token
localStorage.setItem(TOKEN_KEY, data.token)
username.value = ''
password.value = ''
const info = parseJwt(data.token)
currentUser.value = info
ElMessage.success(`登录成功:${(info?.username || '').trim() || '未知用户'}`)
if (info?.is_superuser) {
await fetchWorkers()
startPolling()
} else {
ElMessage.error('权限不足:仅超级管理员可管理 Worker,操作将被拒绝')
workers.value = []
selected.value = []
currentPage.value = 1
}
} catch (e: any) {
ElMessage.error(`登录失败:${e.message}`)
} finally {
logging.value = false
}
}
const logout = () => {
stopPolling()
localStorage.removeItem(TOKEN_KEY)
token.value = ''
currentUser.value = null
workers.value = []
selected.value = []
currentPage.value = 1
ElMessage.success('已退出登录')
}
// 自动轮询:让扩缩容/停止/重启等操作的结果(副本关闭、模式变化)无需手动刷新即反映到列表
let pollTimer: ReturnType<typeof setInterval> | undefined
const startPolling = () => {
stopPolling()
if (!token.value.trim()) return
pollTimer = setInterval(() => {
if (!loading.value) fetchWorkers()
}, 10000)
}
const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = undefined
}
}
const fetchWorkers = async () => {
const t = token.value.trim()
if (!t) {
ElMessage.warning('请先登录')
return
}
loading.value = true
try {
const data = await getWorkers(t)
// auth 插件为核心鉴权,其配置不可更改,不在 Worker 管理列表展示
workers.value = (data.workers || []).filter((w) => w.worker_type !== 'auth')
if (!workerType.value && workers.value.length) {
workerType.value = workers.value[0]!.worker_type
}
} catch (e: any) {
ElMessage.error(`获取 Worker 列表失败:${e.message}`)
} finally {
loading.value = false
}
}
const workerTypes = computed(() => {
return Array.from(new Set(workers.value.map((w) => w.worker_type))).sort()
})
const countByType = (t: string) => workers.value.filter((w) => w.worker_type === t).length
// 副本数量调整(扩缩容)
const scaleTarget = ref(0)
const scalingType = ref('')
watch(workerType, (t) => {
scaleTarget.value = t ? countByType(t) : 0
})
const applyScale = async () => {
const t = workerType.value
if (!t) return
const target = Math.floor(scaleTarget.value)
if (Number.isNaN(target) || target < 0) return
const cur = countByType(t)
if (target === cur) {
ElMessage.info('目标数量与当前相同,未调整')
return
}
try {
await ElMessageBox.confirm(
target > cur
? `扩容插件「${t}」到 ${target} 个副本(新增 ${target - cur} 个)?将持久化到配置。`
: `缩容插件「${t}」到 ${target} 个副本(关闭 ${cur - target} 个)?将优雅排空:先隔离新请求,等待当前请求处理完毕后再关闭对应副本。`,
'调整副本数量',
{ type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' },
)
} catch {
return
}
scalingType.value = t
try {
const res = await scaleWorker(token.value.trim(), t, target)
ElMessage.success(`已调整插件「${t}」副本数至 ${target}(${res.result.mode})`)
scaleTarget.value = target
setTimeout(fetchWorkers, target > cur ? 1200 : 2600)
} catch (e: any) {
ElMessage.error(`调整副本数量失败:${e.message}`)
} finally {
scalingType.value = ''
}
}
const restartOne = async (row: WorkerInstance) => {
try {
await ElMessageBox.confirm(
`确定重启副本「${row.worker_id}」吗?重启后将生成新副本 ID,期间该副本短暂不可用。`,
'重启确认',
{ type: 'warning', confirmButtonText: '重启', cancelButtonText: '取消' },
)
} catch {
return
}
restartingId.value = row.worker_id
try {
await restartWorker(token.value.trim(), row.worker_type, row.worker_id)
ElMessage.success(`已下发重启指令:${row.worker_id}`)
// 重启后等新副本注册完成再刷新
setTimeout(fetchWorkers, 1200)
} catch (e: any) {
ElMessage.error(`下发重启指令失败:${e.message}`)
} finally {
restartingId.value = ''
}
}
const batchRestart = async () => {
if (!selected.value.length) return
try {
await ElMessageBox.confirm(
`确定重启选中的 ${selected.value.length} 个副本吗?`,
'批量重启确认',
{ type: 'warning', confirmButtonText: '重启', cancelButtonText: '取消' },
)
} catch {
return
}
let ok = 0
let fail = 0
for (const w of selected.value) {
try {
await restartWorker(token.value.trim(), w.worker_type, w.worker_id)
ok++
} catch {
fail++
}
}
ElMessage[fail ? 'warning' : 'success'](`批量重启完成:成功 ${ok},失败 ${fail}`)
setTimeout(fetchWorkers, 1200)
}
const restartAllForType = async () => {
const t = workerType.value
if (!t) return
try {
await ElMessageBox.confirm(
`确定重启插件「${t}」的全部 ${countByType(t)} 个副本吗?`,
'插件级重启确认',
{ type: 'warning', confirmButtonText: '重启', cancelButtonText: '取消' },
)
} catch {
return
}
restartingType.value = t
try {
const data = await restartAll(token.value.trim(), t)
ElMessage[data.failed ? 'warning' : 'success'](
`插件 ${t} 重启完成:成功 ${data.total - data.failed}/${data.total}`,
)
setTimeout(fetchWorkers, 1200)
} catch (e: any) {
ElMessage.error(`重启插件失败:${e.message}`)
} finally {
restartingType.value = ''
}
}
const onSelectionChange = (rows: WorkerInstance[]) => {
selected.value = rows
}
const valueToDisplay = (v: unknown): { value: string; type: 'string' | 'number' | 'boolean' } => {
if (typeof v === 'boolean') return { value: String(v), type: 'boolean' }
if (typeof v === 'number') return { value: String(v), type: 'number' }
if (typeof v === 'object' && v !== null) return { value: JSON.stringify(v), type: 'string' }
return { value: String(v ?? ''), type: 'string' }
}
const openConfigDialog = async () => {
configKey.value = ''
configValue.value = ''
configValueType.value = 'string'
currentConfig.value = []
configDialogVisible.value = true
// 展示现有值:读取 [workers.config]
const t = token.value.trim()
const type = workerType.value
if (t && type) {
configLoading.value = true
try {
const data = await getWorkerConfig(t, type)
currentConfig.value = Object.entries(data.config || {}).map(([k, v]) => ({
key: k,
...valueToDisplay(v),
}))
} catch (e: any) {
ElMessage.warning(`读取现有配置失败(不影响编辑):${e.message}`)
} finally {
configLoading.value = false
}
}
}
const selectConfigEntry = (row: { key: string; value: string; type: 'string' | 'number' | 'boolean' }) => {
configKey.value = row.key
configValue.value = row.value
configValueType.value = row.type
}
const submitConfig = async () => {
const t = token.value.trim()
const type = workerType.value
const key = configKey.value.trim()
if (!t) {
ElMessage.warning('请先登录')
return
}
if (!type || !key) {
ElMessage.warning('请选择插件类型并填写配置键')
return
}
// 按所选类型转换值
let value: string | number | boolean = configValue.value
if (configValueType.value === 'number') {
if (configValue.value === '' || Number.isNaN(Number(configValue.value))) {
ElMessage.warning('数字类型配置值不合法')
return
}
value = Number(configValue.value)
} else if (configValueType.value === 'boolean') {
value = String(configValue.value).toLowerCase() === 'true'
}
try {
await ElMessageBox.confirm(
`确定将「${type}」的配置 ${key}=${String(value)} 下发并重启所有对应副本吗?`,
'配置更新确认',
{ type: 'warning', confirmButtonText: '下发并重启', cancelButtonText: '取消' },
)
} catch {
return
}
configUpdating.value = true
try {
const result = await updateWorkerConfig(t, type, { [key]: value })
if (result.ok) {
ElMessage.success(`配置已下发并生效(成功 ${result.total}/${result.total} 副本)`)
} else {
ElMessage.error(
`配置更新部分失败(成功 ${result.total - result.failed}/${result.total}).\n${result.errors.join('\n')}`,
)
}
configDialogVisible.value = false
setTimeout(fetchWorkers, 1500)
} catch (e: any) {
ElMessage.error(`配置更新失败:${e.message}`)
} finally {
configUpdating.value = false
}
}
onMounted(() => {
if (token.value.trim()) {
// 刷新/重新进入时从持久化 token 恢复身份(登录后 currentUser 只在此处与 doLogin 设置)
currentUser.value = parseJwt(token.value.trim())
fetchWorkers()
startPolling()
}
})
onUnmounted(() => stopPolling())
</script>
<style scoped>
.worker-container {
max-width: 1100px;
margin: 0 auto;
}
.toolbar-card,
.table-card {
border-radius: 12px;
margin-bottom: 16px;
border: 1px solid var(--cm-border);
background: var(--cm-bg-soft);
}
.card-header {
display: flex;
align-items: center;
color: var(--cm-text);
}
.login-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.pager-bar {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.scale-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.scale-tip {
margin-left: 12px;
color: var(--cm-text-muted);
font-size: 12px;
}
:deep(.el-table) {
--el-table-header-bg-color: var(--cm-bg-elevated);
--el-table-tr-bg-color: var(--cm-bg-soft);
--el-table-row-hover-bg-color: rgba(79, 140, 255, 0.06);
background: transparent;
}
</style>