chore(app): 删除已拆分的单页应用中心/单页GERP系统(由子页面取代)
This commit is contained in:
1 parent
b15d3fe89d
commit
1894786327
2 files changed
-928
No files matched your search
@@ -1,394 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* 应用中心 AppCenter
|
|
||||||
*
|
|
||||||
* 功能/应用体系:企业视角叫"功能"(租户安装后全员可用),个人视角叫"应用"(个人启用后自用)。
|
|
||||||
* 三个页签:
|
|
||||||
* 1. 应用市场 —— 全部应用,标注类型(企业功能/个人应用/混合)与安装/启用状态;
|
|
||||||
* 租户管理员可安装/停用企业功能;个人可启用个人应用、申请企业授权
|
|
||||||
* 2. 我的应用 —— 当前用户可用应用(租户已装的企业功能 + 个人已启用)
|
|
||||||
* 3. 授权审批 —— 仅租户管理员:审批个人对企业授权应用的申请
|
|
||||||
*
|
|
||||||
* 接口:GET/POST /API/App/*
|
|
||||||
*/
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import { Grid } from '@element-plus/icons-vue'
|
|
||||||
import { appApi, type AppItem, type PendingItem } from '@/api/app'
|
|
||||||
import { useUserStore } from '@/stores/user'
|
|
||||||
import { setPageTitle } from '@/utils/serverScope'
|
|
||||||
|
|
||||||
const userStore = useUserStore()
|
|
||||||
|
|
||||||
// === 状态 ===
|
|
||||||
const activeTab = ref<'market' | 'mine' | 'pending'>('market')
|
|
||||||
const market = ref<AppItem[]>([])
|
|
||||||
const myApps = ref<AppItem[]>([])
|
|
||||||
const pending = ref<PendingItem[]>([])
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
const isAdmin = computed(() => userStore.isAdmin)
|
|
||||||
|
|
||||||
// === 类型标签 ===
|
|
||||||
function typeLabel(t: string): { text: string; type: 'success' | 'info' | 'warning' } {
|
|
||||||
if (t === 'enterprise') return { text: '企业功能', type: 'success' }
|
|
||||||
if (t === 'personal') return { text: '个人应用', type: 'info' }
|
|
||||||
return { text: '混合', type: 'warning' }
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyStatusLabel(s: number | undefined): string {
|
|
||||||
if (s === 1) return '授权申请中'
|
|
||||||
if (s === 2) return '已授权'
|
|
||||||
if (s === 3) return '已拒绝'
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// === 加载 ===
|
|
||||||
async function loadMarket(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const r = await appApi.market()
|
|
||||||
market.value = r.list
|
|
||||||
} catch { /* 拦截器已提示 */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMyApps(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const r = await appApi.myApps()
|
|
||||||
myApps.value = r.list
|
|
||||||
} catch { /* 拦截器已提示 */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPending(): Promise<void> {
|
|
||||||
try {
|
|
||||||
const r = await appApi.pending()
|
|
||||||
pending.value = r.list
|
|
||||||
} catch { /* 拦截器已提示 */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAll(): Promise<void> {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
await Promise.all([loadMarket(), loadMyApps(), loadPending()])
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
setPageTitle('应用中心')
|
|
||||||
await loadAll()
|
|
||||||
})
|
|
||||||
|
|
||||||
function switchTab(tab: 'market' | 'mine' | 'pending'): void {
|
|
||||||
activeTab.value = tab
|
|
||||||
if (tab === 'market') void loadMarket()
|
|
||||||
if (tab === 'mine') void loadMyApps()
|
|
||||||
if (tab === 'pending') void loadPending()
|
|
||||||
}
|
|
||||||
|
|
||||||
// === 操作 ===
|
|
||||||
|
|
||||||
/** 租户管理员:安装/停用企业功能 */
|
|
||||||
async function toggleInstall(a: AppItem): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (a.TenantInstalled) {
|
|
||||||
await appApi.uninstall(a.AppUUID)
|
|
||||||
ElMessage.success(`已停用「${a.AppName}」`)
|
|
||||||
} else {
|
|
||||||
await appApi.install(a.AppUUID)
|
|
||||||
ElMessage.success(`已安装「${a.AppName}」,全员可用`)
|
|
||||||
}
|
|
||||||
await Promise.all([loadMarket(), loadMyApps()])
|
|
||||||
} catch { /* 拦截器已提示 */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 个人:启用/停用应用 */
|
|
||||||
async function toggleEnable(a: AppItem): Promise<void> {
|
|
||||||
try {
|
|
||||||
if (a.UserEnabled) {
|
|
||||||
await appApi.disable(a.AppUUID)
|
|
||||||
ElMessage.success('已停用')
|
|
||||||
} else {
|
|
||||||
await appApi.enable(a.AppUUID)
|
|
||||||
ElMessage.success('已启用')
|
|
||||||
}
|
|
||||||
await Promise.all([loadMarket(), loadMyApps()])
|
|
||||||
} catch { /* 拦截器已提示 */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 个人:申请企业授权 */
|
|
||||||
async function applyAuth(a: AppItem): Promise<void> {
|
|
||||||
try {
|
|
||||||
await appApi.apply(a.AppUUID)
|
|
||||||
ElMessage.success(`已提交「${a.AppName}」授权申请,等待租户管理员审批`)
|
|
||||||
await loadMarket()
|
|
||||||
} catch { /* 拦截器已提示 */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 租户管理员:审批 */
|
|
||||||
async function approve(u: PendingItem, ok: boolean): Promise<void> {
|
|
||||||
try {
|
|
||||||
await appApi.approve(u.UserAppUUID, ok)
|
|
||||||
ElMessage.success(ok ? '已通过' : '已拒绝')
|
|
||||||
await Promise.all([loadPending(), loadMarket()])
|
|
||||||
} catch { /* 拦截器已提示 */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 市场卡片操作按钮(按类型 + 管理员/个人视角返回描述文案) */
|
|
||||||
function marketActionDesc(a: AppItem): string {
|
|
||||||
if (a.AuthRequired && a.ApplyStatus === 1) return '授权申请中'
|
|
||||||
if (a.TenantInstalled) return '企业已安装'
|
|
||||||
if (a.UserEnabled) return '个人已启用'
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="app-center-page">
|
|
||||||
<el-card shadow="never" class="app-center-card">
|
|
||||||
<template #header>
|
|
||||||
<div class="app-center-header">
|
|
||||||
<el-icon class="header-icon"><Grid /></el-icon>
|
|
||||||
<span>应用中心</span>
|
|
||||||
<span class="header-sub">企业功能 · 个人应用 · 企业授权</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<el-tabs v-model="activeTab" @tab-change="switchTab(activeTab as 'market' | 'mine' | 'pending')">
|
|
||||||
<!-- 应用市场 -->
|
|
||||||
<el-tab-pane label="应用市场" name="market">
|
|
||||||
<div v-loading="loading" class="app-grid">
|
|
||||||
<div v-for="a in market" :key="a.AppUUID" class="app-card">
|
|
||||||
<div class="app-card-head">
|
|
||||||
<div class="app-icon">
|
|
||||||
<el-icon :size="24"><component :is="a.AppIcon || 'Grid'" /></el-icon>
|
|
||||||
</div>
|
|
||||||
<div class="app-titles">
|
|
||||||
<div class="app-name">{{ a.AppName }}</div>
|
|
||||||
<div class="app-code">{{ a.AppCode }}</div>
|
|
||||||
</div>
|
|
||||||
<el-tag :type="typeLabel(a.AppType).type" size="small">{{ typeLabel(a.AppType).text }}</el-tag>
|
|
||||||
</div>
|
|
||||||
<div class="app-desc">{{ a.AppDesc || '暂无描述' }}</div>
|
|
||||||
<div class="app-foot">
|
|
||||||
<span v-if="marketActionDesc(a)" class="app-status">{{ marketActionDesc(a) }}</span>
|
|
||||||
<span v-else-if="a.ApplyStatus === 2" class="app-status ok">已授权</span>
|
|
||||||
<span v-else-if="a.ApplyStatus === 3" class="app-status err">已拒绝</span>
|
|
||||||
<div class="spacer" />
|
|
||||||
<!-- 企业功能:管理员安装/停用;普通用户提示 -->
|
|
||||||
<template v-if="a.AppType === 'enterprise'">
|
|
||||||
<el-button v-if="isAdmin" size="small" :type="a.TenantInstalled ? 'danger' : 'primary'"
|
|
||||||
:plain="!!a.TenantInstalled" @click="toggleInstall(a)">
|
|
||||||
{{ a.TenantInstalled ? '停用' : '安装' }}
|
|
||||||
</el-button>
|
|
||||||
<el-tag v-else size="small" type="info" effect="plain">由企业管理员开通</el-tag>
|
|
||||||
</template>
|
|
||||||
<!-- 个人应用:启用/停用/申请 -->
|
|
||||||
<template v-else-if="a.AppType === 'personal'">
|
|
||||||
<el-button v-if="a.AuthRequired && !a.UserEnabled && !a.ApplyStatus" size="small" type="primary"
|
|
||||||
@click="applyAuth(a)">申请授权</el-button>
|
|
||||||
<el-button v-else-if="!a.AuthRequired || a.ApplyStatus === 2" size="small"
|
|
||||||
:type="a.UserEnabled ? 'danger' : 'primary'" :plain="!!a.UserEnabled"
|
|
||||||
@click="toggleEnable(a)">
|
|
||||||
{{ a.UserEnabled ? '停用' : '启用' }}
|
|
||||||
</el-button>
|
|
||||||
<el-tag v-else size="small" type="info" effect="plain">{{ applyStatusLabel(a.ApplyStatus) }}</el-tag>
|
|
||||||
</template>
|
|
||||||
<!-- 混合:管理员可安装,个人可启用 -->
|
|
||||||
<template v-else>
|
|
||||||
<template v-if="isAdmin">
|
|
||||||
<el-button size="small" :type="a.TenantInstalled ? 'danger' : 'primary'"
|
|
||||||
:plain="!!a.TenantInstalled" @click="toggleInstall(a)">
|
|
||||||
{{ a.TenantInstalled ? '停用' : '安装' }}
|
|
||||||
</el-button>
|
|
||||||
</template>
|
|
||||||
<el-button v-else size="small" :type="a.UserEnabled ? 'danger' : 'primary'"
|
|
||||||
:plain="!!a.UserEnabled" @click="toggleEnable(a)">
|
|
||||||
{{ a.UserEnabled ? '停用' : '启用' }}
|
|
||||||
</el-button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<el-empty v-if="!loading && !market.length" description="暂无可用的应用" :image-size="80" />
|
|
||||||
</div>
|
|
||||||
</el-tab-pane>
|
|
||||||
|
|
||||||
<!-- 我的应用 -->
|
|
||||||
<el-tab-pane label="我的应用" name="mine">
|
|
||||||
<div v-loading="loading" class="app-grid">
|
|
||||||
<div v-for="a in myApps" :key="a.AppUUID" class="app-card">
|
|
||||||
<div class="app-card-head">
|
|
||||||
<div class="app-icon">
|
|
||||||
<el-icon :size="24"><component :is="a.AppIcon || 'Grid'" /></el-icon>
|
|
||||||
</div>
|
|
||||||
<div class="app-titles">
|
|
||||||
<div class="app-name">{{ a.AppName }}</div>
|
|
||||||
<div class="app-code">{{ a.AppCode }}</div>
|
|
||||||
</div>
|
|
||||||
<el-tag :type="typeLabel(a.AppType).type" size="small">{{ typeLabel(a.AppType).text }}</el-tag>
|
|
||||||
</div>
|
|
||||||
<div class="app-desc">{{ a.AppDesc || '暂无描述' }}</div>
|
|
||||||
<div class="app-foot">
|
|
||||||
<span class="app-status ok">{{ a.TenantInstalled && a.AppType !== 'personal' ? '企业已开通' : '个人已启用' }}</span>
|
|
||||||
<div class="spacer" />
|
|
||||||
<el-button v-if="a.AppType !== 'enterprise'" size="small" type="danger" plain @click="toggleEnable(a)">停用</el-button>
|
|
||||||
<el-tag v-else size="small" type="success" effect="plain">企业功能</el-tag>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<el-empty v-if="!loading && !myApps.length" description="暂无可用应用,请到应用市场启用" :image-size="80" />
|
|
||||||
</div>
|
|
||||||
</el-tab-pane>
|
|
||||||
|
|
||||||
<!-- 授权审批(仅管理员) -->
|
|
||||||
<el-tab-pane v-if="isAdmin" label="授权审批" name="pending">
|
|
||||||
<div v-loading="loading" class="pending-list">
|
|
||||||
<div v-for="u in pending" :key="u.UserAppUUID" class="pending-row">
|
|
||||||
<div class="app-icon small">
|
|
||||||
<el-icon :size="18"><component :is="u.AppIcon || 'Grid'" /></el-icon>
|
|
||||||
</div>
|
|
||||||
<div class="pending-main">
|
|
||||||
<div class="pending-title">{{ u.AppName }}</div>
|
|
||||||
<div class="pending-sub">申请人 {{ u.UserUUID.slice(0, 8) }} · 申请于 {{ u.ApplyTime ? new Date(u.ApplyTime).toLocaleString('zh-CN', { hour12: false }) : '-' }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="spacer" />
|
|
||||||
<el-button size="small" type="danger" @click="approve(u, false)">拒绝</el-button>
|
|
||||||
<el-button size="small" type="primary" @click="approve(u, true)">通过</el-button>
|
|
||||||
</div>
|
|
||||||
<el-empty v-if="!loading && !pending.length" description="暂无待审批的授权申请" :image-size="80" />
|
|
||||||
</div>
|
|
||||||
</el-tab-pane>
|
|
||||||
</el-tabs>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.app-center-page {
|
|
||||||
padding: 16px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.app-center-card {
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
.app-center-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 15px;
|
|
||||||
.header-icon {
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
}
|
|
||||||
.header-sub {
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.app-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
||||||
gap: 14px;
|
|
||||||
min-height: 120px;
|
|
||||||
}
|
|
||||||
.app-card {
|
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 14px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
transition: box-shadow 0.15s;
|
|
||||||
&:hover {
|
|
||||||
box-shadow: var(--el-box-shadow-light);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.app-card-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.app-icon {
|
|
||||||
width: 42px;
|
|
||||||
height: 42px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-radius: 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: linear-gradient(135deg, var(--el-color-primary-light-8), var(--el-color-primary-light-7));
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
&.small {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.app-titles {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.app-name {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--el-text-color-primary);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.app-code {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-placeholder);
|
|
||||||
}
|
|
||||||
.app-desc {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
min-height: 36px;
|
|
||||||
}
|
|
||||||
.app-foot {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.spacer {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.app-status {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
&.ok {
|
|
||||||
color: var(--el-color-success);
|
|
||||||
}
|
|
||||||
&.err {
|
|
||||||
color: var(--el-color-danger);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pending-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.pending-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
.pending-main {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.pending-title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.pending-sub {
|
|
||||||
margin-top: 2px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--el-text-color-secondary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,534 +0,0 @@
|
|||||||
<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>
|
|
||||||
Reference in new issue
Block a user