feat(system): GERP系统运维控制台(状态/时序/secrets.ini/运行时调整)+ 路由

This commit is contained in:
TermiNexus committed 2026-08-13 04:20:29 +08:00
1 parent f4aeaf94c4
commit 797bada4e7
3 files changed
+619

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
import { get, post } from './request'
/** 系统状态 */
export interface GerpSystemStatus {
Host: string
PID: number
StartTime: string
UptimeSeconds: number
BootTime: string
Python: string
Django: string
Gvsdsdk: string
MemoryMB: number
CPUPercent: number
Redis: string
Database: string
Celery: string
LogLevel: string
}
/** 系统时序:beat 调度项 */
export interface ScheduleItem {
Name: string
Task: string
Schedule: string
Enabled: boolean
}
/** 可手动触发任务 */
export interface TriggerableItem {
Name: string
Task: string
}
/** secrets.ini 键值(脱敏信息) */
export interface SecretEntry {
Value: string
Masked: boolean
}
/** secrets.ini 结构:{section: {key: SecretEntry}} */
export type SecretSections = Record<string, Record<string, SecretEntry>>
/** 运行时调整信息 */
export interface RuntimeInfo {
Current: string
Persisted: string | null
Available: string[]
}
/** GERP系统 运维 API 客户端(全部接口仅 admin:super 可访问) */
export const gerpSystemApi = {
/** 系统状态总览 */
status: (): Promise<GerpSystemStatus> => get<GerpSystemStatus>('/API/GERPSystem/Status'),
/** 系统时序(beat 调度 + 可触发任务) */
schedules: (): Promise<{ Schedules: ScheduleItem[]; Triggerable: TriggerableItem[] }> =>
get<{ Schedules: ScheduleItem[]; Triggerable: TriggerableItem[] }>('/API/GERPSystem/Schedules'),
/** 手动触发定时任务 */
trigger: (Name: string): Promise<{ Async: boolean; Result?: unknown }> =>
post<{ Async: boolean; Result?: unknown }>('/API/GERPSystem/Schedules/Trigger', { Name }),
/** secrets.ini 读取(脱敏) */
secrets: (): Promise<{ FilePath: string; Sections: SecretSections }> =>
get<{ FilePath: string; Sections: SecretSections }>('/API/GERPSystem/Secrets'),
/** secrets.ini 保存(值 '******' 视为未修改) */
saveSecrets: (Sections: Record<string, Record<string, string>>): Promise<{ Updated: string[] }> =>
post<{ Updated: string[] }>('/API/GERPSystem/Secrets', { Sections }),
/** 运行时信息(日志级别) */
runtime: (): Promise<RuntimeInfo> => get<RuntimeInfo>('/API/GERPSystem/Runtime'),
/** 切换日志级别 */
setLogLevel: (LogLevel: string): Promise<{ LogLevel: string }> =>
post<{ LogLevel: string }>('/API/GERPSystem/Runtime', { LogLevel }),
}
+7
View File
@@ -285,6 +285,13 @@ export const staticRoutes: RouteRecordRaw[] = [
component: () => import('@/views/system/DbTable.vue'),
meta: { title: '表结构管理', requiresAuth: true },
},
// === GERP系统(系统管理员 admin:super 专属运维控制台) ===
{
path: 'system/gerp-system',
name: 'SystemGerpSystem',
component: () => import('@/views/system/GerpSystem.vue'),
meta: { title: 'GERP系统', icon: 'Setting', requiresAuth: true, menuGuarded: true },
},
// === 枢机协律(CreateE):旧页面 iframe 直载(LegacyPage) ===
{
path: 'private/CreateE/shop',
+534
View File
@@ -0,0 +1,534 @@
<script setup lang="ts">
/**
* GERP系统(GerpSystem)——系统管理员(admin:super)专属运维控制台
*
* 只放"所有人现在都管不了的东西":
* 1. 系统状态:运行时间、进程、版本、Celery / Redis / 数据库健康
* 2. 系统时序:Celery beat 调度列表 + 手动触发
* 3. secrets.ini:配置查看(脱敏)与键值编辑
* 4. 运行时调整:日志级别热切换
*
* 权限:仅 admin:super 可访问(后端 IsSuperAdmin 强制校验,前端预拦截)
* 后端接口见 gvsds/apps/gerp/views/system_ops.py
*/
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { setPageTitle } from '@/utils/serverScope'
import { useUserStore } from '@/stores/user'
import AdminBadge from '@/components/AdminBadge.vue'
import {
gerpSystemApi,
type GerpSystemStatus,
type ScheduleItem,
type TriggerableItem,
type SecretSections,
type RuntimeInfo,
} from '@/api/systemOps'
setPageTitle('GERP系统')
const userStore = useUserStore()
/** 是否系统管理员(前端预拦截,后端 IsSuperAdmin 强制校验) */
const isSuperAdmin = computed(() => userStore.adminCheck?.is_super_admin === true)
const denied = computed(() => userStore.adminChecked && !isSuperAdmin.value)
/** 当前激活页签 */
const activeTab = ref('status')
// ==================== 页签 1:系统状态 ====================
const statusData = ref<GerpSystemStatus | null>(null)
const statusLoading = ref(false)
/** 格式化运行时长:秒 → "X天X小时X分X秒" */
function formatUptime(seconds: number): string {
if (!seconds || seconds < 0) return '-'
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
const parts: string[] = []
if (d) parts.push(`${d}天`)
if (h) parts.push(`${h}小时`)
if (m) parts.push(`${m}分钟`)
parts.push(`${s}秒`)
return parts.join(' ')
}
/** 服务健康状态标签 */
function healthLabel(v: string | undefined): string {
if (v === 'ok') return '正常'
if (v === 'error') return '异常'
if (v === 'enabled') return '已启用'
if (v === 'disabled') return '未启用'
return v || '-'
}
function healthType(v: string | undefined): 'success' | 'danger' | 'warning' | 'info' {
if (v === 'ok' || v === 'enabled') return 'success'
if (v === 'error') return 'danger'
return 'info'
}
async function loadStatus(): Promise<void> {
statusLoading.value = true
try {
statusData.value = await gerpSystemApi.status()
} finally {
statusLoading.value = false
}
}
// ==================== 页签 2:系统时序 ====================
const schedules = ref<ScheduleItem[]>([])
const triggerable = ref<TriggerableItem[]>([])
const schedulesLoading = ref(false)
const triggeringName = ref('')
async function loadSchedules(): Promise<void> {
schedulesLoading.value = true
try {
const data = await gerpSystemApi.schedules()
schedules.value = data.Schedules || []
triggerable.value = data.Triggerable || []
} finally {
schedulesLoading.value = false
}
}
/** 手动触发任务 */
async function onTrigger(item: TriggerableItem): Promise<void> {
try {
await ElMessageBox.confirm(
`确定手动触发任务「${item.Name}」吗?\n任务路径:${item.Task}`,
'手动触发',
{ type: 'warning', confirmButtonText: '触发', cancelButtonText: '取消' },
)
} catch {
return
}
triggeringName.value = item.Name
try {
const res = await gerpSystemApi.trigger(item.Name)
const detail = res.Async ? '(已异步投递)' : '(同步执行)'
ElMessage.success(`任务「${item.Name}」执行完成 ${detail}`)
} finally {
triggeringName.value = ''
}
}
// ==================== 页签 3:secrets.ini 配置 ====================
const secretsPath = ref('')
const secrets = reactive<SecretSections>({})
const secretsLoading = ref(false)
const secretsSaving = ref(false)
/** 敏感值占位符(与后端约定一致:提交该值视为未修改) */
const MASK = '******'
/** 保存前编辑副本:{section: {key: 用户输入值}},仅保存已改动项 */
const editedSecrets = reactive<Record<string, Record<string, string>>>({})
async function loadSecrets(): Promise<void> {
secretsLoading.value = true
try {
const data = await gerpSystemApi.secrets()
secretsPath.value = data.FilePath || ''
// 重建响应式对象(整体替换)
Object.keys(secrets).forEach((k) => delete secrets[k])
Object.assign(secrets, data.Sections || {})
// 重置编辑副本:默认全部为原值(敏感键为占位符 → 不改动则跳过)
Object.keys(editedSecrets).forEach((k) => delete editedSecrets[k])
for (const [section, kv] of Object.entries(data.Sections || {})) {
editedSecrets[section] = {}
for (const [key, entry] of Object.entries(kv)) {
editedSecrets[section][key] = entry.Value
}
}
} finally {
secretsLoading.value = false
}
}
/** 是否存在已改动(非占位符默认)的键 */
function hasSecretEdits(): boolean {
for (const [section, kv] of Object.entries(editedSecrets)) {
for (const [key, value] of Object.entries(kv)) {
if (value !== (secrets[section]?.[key]?.Value ?? '')) return true
}
}
return false
}
/** 保存 secrets.ini:仅提交值发生变化的键(敏感键未改则保留占位符被后端跳过) */
async function onSaveSecrets(): Promise<void> {
if (!hasSecretEdits()) {
ElMessage.info('没有需要保存的修改')
return
}
try {
await ElMessageBox.confirm(
'保存后将立即写入 secrets.ini 并热重载配置。\n请谨慎操作,错误的配置可能导致服务不可用。',
'确认保存配置',
{ type: 'warning', confirmButtonText: '保存', cancelButtonText: '取消' },
)
} catch {
return
}
secretsSaving.value = true
try {
const payload: Record<string, Record<string, string>> = {}
for (const [section, kv] of Object.entries(editedSecrets)) {
const changed: Record<string, string> = {}
for (const [key, value] of Object.entries(kv)) {
if (value !== (secrets[section]?.[key]?.Value ?? '')) {
changed[key] = value
}
}
if (Object.keys(changed).length) payload[section] = changed
}
const res = await gerpSystemApi.saveSecrets(payload)
ElMessage.success(`配置已保存(${(res.Updated || []).length} 项)`)
await loadSecrets()
} finally {
secretsSaving.value = false
}
}
// ==================== 页签 4:运行时调整 ====================
const runtimeInfo = ref<RuntimeInfo | null>(null)
const runtimeLoading = ref(false)
const runtimeSaving = ref(false)
const selectedLevel = ref('INFO')
async function loadRuntime(): Promise<void> {
runtimeLoading.value = true
try {
runtimeInfo.value = await gerpSystemApi.runtime()
selectedLevel.value = runtimeInfo.value.Current || 'INFO'
} finally {
runtimeLoading.value = false
}
}
async function onSetLogLevel(): Promise<void> {
try {
await ElMessageBox.confirm(
`确定将日志级别切换为「${selectedLevel.value}」吗?\n将立即生效并持久化到 app.ini [logging] LEVEL。`,
'切换日志级别',
{ type: 'warning', confirmButtonText: '切换', cancelButtonText: '取消' },
)
} catch {
return
}
runtimeSaving.value = true
try {
await gerpSystemApi.setLogLevel(selectedLevel.value)
ElMessage.success(`日志级别已切换为 ${selectedLevel.value}`)
await loadRuntime()
} finally {
runtimeSaving.value = false
}
}
// ==================== 初始化 ====================
onMounted(() => {
if (isSuperAdmin.value) {
loadStatus()
loadSchedules()
loadSecrets()
loadRuntime()
}
})
/** 页签切换时按需加载(已加载不重复请求) */
function onTabChange(name: string | number): void {
if (name === 'status' && !statusData.value) void loadStatus()
if (name === 'schedules' && !schedules.value.length) void loadSchedules()
if (name === 'secrets' && Object.keys(secrets).length === 0) void loadSecrets()
if (name === 'runtime' && !runtimeInfo.value) void loadRuntime()
}
</script>
<template>
<div class="gerp-system-page">
<!-- 页头 -->
<div class="gs-header">
<h1 class="gs-title">
<i class="fas fa-server icon-primary" />
<span>GERP系统</span>
<AdminBadge v-if="isSuperAdmin" />
</h1>
<p class="gs-desc">系统管理员专属运维控制台——管理所有"管不了"的系统级事务</p>
</div>
<!-- 无权访问 -->
<el-empty v-if="denied" description="仅系统管理员(admin:super)可访问本页面" />
<template v-else>
<el-tabs v-model="activeTab" @tab-change="onTabChange">
<!-- 页签 1:系统状态 -->
<el-tab-pane label="系统状态" name="status">
<div v-loading="statusLoading" class="gs-pane">
<el-descriptions v-if="statusData" :column="2" border class="gs-descriptions">
<el-descriptions-item label="服务器主机">{{ statusData.Host }}</el-descriptions-item>
<el-descriptions-item label="进程 PID">{{ statusData.PID }}</el-descriptions-item>
<el-descriptions-item label="进程启动时间">{{ statusData.StartTime }}</el-descriptions-item>
<el-descriptions-item label="系统启动时间">{{ statusData.BootTime }}</el-descriptions-item>
<el-descriptions-item label="运行时长">
<el-tag type="success" size="small">{{ formatUptime(statusData.UptimeSeconds) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="内存占用">{{ statusData.MemoryMB }} MB</el-descriptions-item>
<el-descriptions-item label="CPU 占用">{{ statusData.CPUPercent }}%</el-descriptions-item>
<el-descriptions-item label="日志级别">{{ statusData.LogLevel }}</el-descriptions-item>
<el-descriptions-item label="Python">{{ statusData.Python }}</el-descriptions-item>
<el-descriptions-item label="Django">{{ statusData.Django }}</el-descriptions-item>
<el-descriptions-item label="GVSDSDK">{{ statusData.Gvsdsdk }}</el-descriptions-item>
<el-descriptions-item label="Redis">
<el-tag :type="healthType(statusData.Redis)" size="small">{{ healthLabel(statusData.Redis) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="数据库">
<el-tag :type="healthType(statusData.Database)" size="small">{{ healthLabel(statusData.Database) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="Celery">
<el-tag :type="healthType(statusData.Celery)" size="small">{{ healthLabel(statusData.Celery) }}</el-tag>
</el-descriptions-item>
</el-descriptions>
<div class="gs-actions">
<el-button :loading="statusLoading" @click="loadStatus">刷新</el-button>
</div>
</div>
</el-tab-pane>
<!-- 页签 2:系统时序 -->
<el-tab-pane label="系统时序" name="schedules">
<div v-loading="schedulesLoading" class="gs-pane">
<h3 class="gs-section-title">Celery Beat 调度计划</h3>
<el-table v-if="schedules.length" :data="schedules" border stripe class="gs-table">
<el-table-column prop="Name" label="任务名" min-width="180" />
<el-table-column prop="Task" label="任务路径" min-width="260" show-overflow-tooltip />
<el-table-column prop="Schedule" label="调度表达式" min-width="160" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.Enabled ? 'success' : 'info'" size="small">
{{ row.Enabled ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="未配置 Celery beat 调度(或 Celery 未启用)" :image-size="80" />
<h3 class="gs-section-title">手动触发</h3>
<p class="gs-tip">系统定时任务可在此手动触发一次(无 Celery 时同步执行,可能较慢)。</p>
<el-table v-if="triggerable.length" :data="triggerable" border stripe class="gs-table">
<el-table-column prop="Name" label="任务名" min-width="180" />
<el-table-column prop="Task" label="任务路径" min-width="260" show-overflow-tooltip />
<el-table-column label="操作" width="140">
<template #default="{ row }">
<el-button
type="primary"
size="small"
:loading="triggeringName === (row as TriggerableItem).Name"
@click="onTrigger(row as TriggerableItem)"
>
手动触发
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无可用触发任务" :image-size="80" />
</div>
</el-tab-pane>
<!-- 页签 3:secrets.ini 配置 -->
<el-tab-pane label="配置文件" name="secrets">
<div v-loading="secretsLoading" class="gs-pane">
<div class="gs-config-header">
<div>
<h3 class="gs-section-title">secrets.ini(敏感配置)</h3>
<p class="gs-tip">
文件路径:<code>{{ secretsPath }}</code><br />
敏感键(密码/密钥/Token 等)默认脱敏显示,输入新值并保存即覆盖;不改动请保持原值。
</p>
</div>
<div class="gs-config-actions">
<el-button :loading="secretsSaving" type="primary" @click="onSaveSecrets">保存配置</el-button>
<el-button :disabled="secretsSaving" @click="loadSecrets">重新加载</el-button>
</div>
</div>
<div v-for="(kv, section) in editedSecrets" :key="section" class="gs-secrets-section">
<h4 class="gs-secrets-section-title">[{{ section }}]</h4>
<div v-for="(_, key) in kv" :key="key" class="gs-secrets-row">
<div class="gs-secrets-key" :title="key">{{ key }}</div>
<div class="gs-secrets-input">
<el-input
v-model="editedSecrets[section][key]"
:type="secrets[section]?.[key]?.Masked ? 'password' : 'text'"
:placeholder="secrets[section]?.[key]?.Masked ? MASK : ''"
clearable
/>
</div>
<el-tag v-if="secrets[section]?.[key]?.Masked" type="warning" size="small">敏感</el-tag>
</div>
</div>
<el-empty v-if="!Object.keys(editedSecrets).length" description="secrets.ini 为空或不存在" :image-size="80" />
</div>
</el-tab-pane>
<!-- 页签 4:运行时调整 -->
<el-tab-pane label="运行时调整" name="runtime">
<div v-loading="runtimeLoading" class="gs-pane gs-runtime">
<h3 class="gs-section-title">日志级别</h3>
<p class="gs-tip">
当前运行级别:<el-tag size="small">{{ runtimeInfo?.Current || '-' }}</el-tag>
<template v-if="runtimeInfo?.Persisted">
(app.ini 持久化值:{{ runtimeInfo.Persisted }})
</template>
</p>
<div class="gs-runtime-row">
<span class="gs-runtime-label">切换日志级别</span>
<el-select v-model="selectedLevel" style="width: 180px">
<el-option
v-for="lv in runtimeInfo?.Available || []"
:key="lv"
:label="lv"
:value="lv"
/>
</el-select>
<el-button type="primary" :loading="runtimeSaving" @click="onSetLogLevel">应用</el-button>
</div>
<p class="gs-tip">切换立即生效(无需重启服务),并持久化到 app.ini [logging] LEVEL。</p>
</div>
</el-tab-pane>
</el-tabs>
</template>
</div>
</template>
<style scoped lang="scss">
.gerp-system-page {
padding: 24px;
max-width: 1100px;
margin: 0 auto;
}
.gs-header {
margin-bottom: 24px;
.gs-title {
font-size: 24px;
font-weight: 700;
margin: 0 0 8px;
display: flex;
align-items: center;
gap: 8px;
.icon-primary {
color: var(--el-color-primary);
}
}
.gs-desc {
font-size: 14px;
color: var(--el-text-color-secondary);
margin: 0;
}
}
.gs-pane {
min-height: 200px;
}
.gs-descriptions {
margin-bottom: 16px;
}
.gs-actions {
margin-top: 8px;
}
.gs-section-title {
font-size: 16px;
font-weight: 600;
margin: 16px 0 12px;
}
.gs-tip {
font-size: 13px;
color: var(--el-text-color-secondary);
margin: 4px 0 12px;
line-height: 1.7;
}
.gs-table {
margin-bottom: 8px;
}
/* secrets.ini 配置区 */
.gs-config-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 8px;
}
.gs-config-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.gs-secrets-section {
background: var(--el-bg-color);
border-radius: 10px;
padding: 16px;
margin-bottom: 12px;
box-shadow: var(--el-box-shadow-light);
}
.gs-secrets-section-title {
font-size: 14px;
font-weight: 600;
margin: 0 0 12px;
color: var(--el-color-primary);
}
.gs-secrets-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
}
.gs-secrets-key {
width: 220px;
flex-shrink: 0;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gs-secrets-input {
flex: 1;
min-width: 0;
}
/* 运行时调整 */
.gs-runtime-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.gs-runtime-label {
font-size: 14px;
}
</style>