This commit is contained in:
zxr
2026-03-21 17:06:54 +08:00
parent 69e421834b
commit ecf78bc727
7 changed files with 480 additions and 4 deletions

View File

@@ -1,5 +1,24 @@
import { request } from "@/api/request";
/** 许可证配置(与 DC-Control `LicenceConfig` / 接口 `data` 一致;字段按实际响应可能部分缺失) */
export interface LicenceConfig {
title?: string
version?: string
company_name?: string
create_time?: string
expire_time?: string
machine_code?: string
max_database?: number
max_middleware?: number
max_pc?: number
max_server?: number
max_client?: number
max_user?: number
max_role?: number
max_permission?: number
max_menu?: number
}
/** 获取 采集器 */
export const fetchCollectors = (data: { page: number, size: number, keyword?: string }) => request.get("/DC-Control/v1/collectors", { params: data });
@@ -19,4 +38,5 @@ export const updateCollector = (data: any) => request.put(`/DC-Control/v1/collec
export const fetchCollectorStatistics = () => request.get("/DC-Control/v1/statistics");
/** 获取 许可证信息 */
export const fetchLicenseInfo = () => request.get("/DC-Control/v1/license");
export const fetchLicenseInfo = () =>
request.get<{ code?: number; data?: LicenceConfig; message?: string }>("/DC-Control/v1/license");

View File

@@ -32,6 +32,7 @@ export default {
'menu.ops.systemSettings': 'System Settings',
'menu.ops.systemSettings.menuManagement': 'Menu Management',
'menu.ops.systemSettings.systemLogs': 'System Logs',
'menu.ops.systemSettings.licenseCenter': 'License Center',
'menu.ops.webTest': 'Web Test',
'menu.ops.report': 'Report Management',
'menu.ops.report.history': 'Report History',

View File

@@ -32,6 +32,7 @@ export default {
'menu.ops.systemSettings': '系统设置',
'menu.ops.systemSettings.menuManagement': '菜单管理',
'menu.ops.systemSettings.systemLogs': '系统日志',
'menu.ops.systemSettings.licenseCenter': '许可授权中心',
'menu.ops.webTest': '网页测试',
'menu.ops.report': '报表管理',
'menu.ops.report.history': '报表历史',

View File

@@ -181,6 +181,11 @@ function extractRelativePath(childPath: string, parentPath: string): string {
* @param parentIsFull 父级菜单的 is_full 字段
* @returns 子路由配置数组
*/
/** 服务端未配置 component 时按 menu_path 绑定视图(避免误用 redirect 导致白屏) */
const MENU_PATH_COMPONENT_FALLBACK: { test: (fullPath: string) => boolean; component: string }[] = [
{ test: (p) => p.includes('license-center'), component: 'ops/pages/system-settings/license-center' },
]
function transformChildRoutes(
children: ServerMenuItem[],
parentComponent?: string,
@@ -188,10 +193,16 @@ function transformChildRoutes(
parentIsFull?: boolean
): AppRouteRecordRaw[] {
return children.map((child) => {
// 优先使用子菜单自己的 component否则继承父级的 component
const componentPath = child.component || parentComponent
// 计算子路由的相对路径
// 计算子路由的相对路径(需先于 component 解析,供 path 兜底使用)
const childFullPath = child.menu_path || child.path || ''
// 优先使用子菜单自己的 component否则继承父级的 component再按路径兜底
let componentPath = child.component || parentComponent
const matchedFallback = MENU_PATH_COMPONENT_FALLBACK.find((fb) => fb.test(childFullPath))
if (matchedFallback) {
componentPath = matchedFallback.component
}
const relativePath = extractRelativePath(childFullPath, parentPath || '')
const route: AppRouteRecordRaw = {

View File

@@ -32,6 +32,16 @@ const OPS: AppRouteRecordRaw = {
roles: ['*'],
},
},
{
path: 'license-center',
name: 'LicenseCenter',
component: () => import('@/views/ops/pages/system-settings/license-center/index.vue'),
meta: {
locale: 'menu.ops.systemSettings.licenseCenter',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'web-test',
name: 'WebTest',

View File

@@ -0,0 +1,379 @@
<template>
<div class="container">
<Breadcrumb :items="['menu.ops.systemSettings', 'menu.ops.systemSettings.licenseCenter']" />
<a-card class="license-page-card" :bordered="false">
<template #title>
<div class="card-head-title">
<div class="page-title">许可证信息</div>
<div class="page-subtitle">{{ license?.company_name || '—' }}</div>
</div>
</template>
<template #extra>
<a-button type="outline" :loading="loading" :disabled="loading" @click="loadLicense">
<template #icon><icon-refresh /></template>
刷新
</a-button>
</template>
<a-spin :loading="loading" class="page-spin">
<a-empty v-if="!loading && loadError && !license" description="加载失败,请稍后重试" />
<template v-else-if="license">
<a-card class="section-card" :bordered="false">
<template #title>
<span class="section-title">基本信息</span>
</template>
<div class="kv-list">
<div v-for="row in basicRows" :key="row.key" class="basic-row">
<span class="label">{{ row.label }}</span>
<span class="value" :class="{ 'value-code': row.key === 'machine_code' }">{{ row.display }}</span>
</div>
</div>
</a-card>
<a-card class="section-card section-card--quota" :bordered="false">
<template #title>
<span class="section-title">资源限制</span>
</template>
<p class="quota-hint">配额项为 0 时表示该维度不按许可证限制数量由业务逻辑决定</p>
<div class="kv-list">
<div v-for="row in quotaRows" :key="row.key" class="quota-row">
<span class="label">{{ row.label }}</span>
<span class="value">{{ row.display }}</span>
</div>
</div>
</a-card>
</template>
</a-spin>
</a-card>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconRefresh } from '@arco-design/web-vue/es/icon'
import { fetchLicenseInfo, type LicenceConfig } from '@/api/ops/dcControl'
const loading = ref(false)
const loadError = ref(false)
const license = ref<LicenceConfig | null>(null)
const dash = (v: string | undefined | null) => (v != null && String(v).length > 0 ? String(v) : '—')
const formatQuota = (n: number | undefined) => {
if (n == null || Number.isNaN(Number(n))) return '—'
const v = Number(n)
return v > 0 ? String(v) : '无限制'
}
function pickStr(o: Record<string, unknown>, snake: string, camel: string): string | undefined {
const v = o[snake] ?? o[camel]
return typeof v === 'string' ? v : v != null ? String(v) : undefined
}
function pickNum(o: Record<string, unknown>, snake: string, camel: string): number | undefined {
const v = o[snake] ?? o[camel]
if (v == null || v === '') return undefined
const n = Number(v)
return Number.isNaN(n) ? undefined : n
}
/** 将接口对象统一为 LicenceConfig兼容 snake_case / camelCase */
function normalizeLicensePayload(raw: Record<string, unknown>): LicenceConfig {
return {
title: pickStr(raw, 'title', 'title'),
version: pickStr(raw, 'version', 'version'),
company_name: pickStr(raw, 'company_name', 'companyName'),
create_time: pickStr(raw, 'create_time', 'createTime'),
expire_time: pickStr(raw, 'expire_time', 'expireTime'),
machine_code: pickStr(raw, 'machine_code', 'machineCode'),
max_database: pickNum(raw, 'max_database', 'maxDatabase'),
max_middleware: pickNum(raw, 'max_middleware', 'maxMiddleware'),
max_pc: pickNum(raw, 'max_pc', 'maxPc'),
max_server: pickNum(raw, 'max_server', 'maxServer'),
max_client: pickNum(raw, 'max_client', 'maxClient'),
max_user: pickNum(raw, 'max_user', 'maxUser'),
max_role: pickNum(raw, 'max_role', 'maxRole'),
max_permission: pickNum(raw, 'max_permission', 'maxPermission'),
max_menu: pickNum(raw, 'max_menu', 'maxMenu'),
}
}
function isLicensePayloadRaw(v: unknown): v is Record<string, unknown> {
if (v == null || typeof v !== 'object' || Array.isArray(v)) return false
const o = v as Record<string, unknown>
const strHit = (s: string, c: string) => {
const x = o[s] ?? o[c]
return typeof x === 'string' && x.length > 0
}
const numHit = (s: string, c: string) => {
const x = o[s] ?? o[c]
return typeof x === 'number' && !Number.isNaN(x)
}
return (
strHit('company_name', 'companyName') ||
strHit('machine_code', 'machineCode') ||
strHit('title', 'title') ||
strHit('version', 'version') ||
numHit('max_database', 'maxDatabase') ||
numHit('max_middleware', 'maxMiddleware') ||
numHit('max_pc', 'maxPc') ||
numHit('max_server', 'maxServer') ||
numHit('max_client', 'maxClient') ||
numHit('max_user', 'maxUser') ||
numHit('max_role', 'maxRole') ||
numHit('max_permission', 'maxPermission') ||
numHit('max_menu', 'maxMenu')
)
}
function parseLicenseResponse(res: unknown): {
code?: number
success?: boolean
data: unknown
message?: string
} {
if (res == null || typeof res !== 'object') {
return { data: undefined, message: undefined }
}
const r = res as Record<string, unknown>
const code = typeof r.code === 'number' ? r.code : undefined
const success = typeof r.success === 'boolean' ? r.success : undefined
const message =
typeof r.message === 'string' ? r.message : typeof r.msg === 'string' ? r.msg : undefined
let data: unknown = r.data ?? r.details ?? r.result
if (data == null && isLicensePayloadRaw(r)) {
data = r
}
return { code, success, data, message }
}
function responseIndicatesSuccess(code: number | undefined, success: boolean | undefined): boolean {
if (success === false) return false
if (success === true) return true
if (code === undefined) return true
return code === 200 || code === 0
}
const basicRows = computed(() => {
const L = license.value
if (!L) return []
return [
{ key: 'company_name', label: '公司名称', display: dash(L.company_name) },
{ key: 'title', label: '版本标题', display: dash(L.title) },
{ key: 'version', label: '版本号', display: dash(L.version) },
{ key: 'machine_code', label: '机器码', display: dash(L.machine_code) },
{ key: 'create_time', label: '创建时间', display: dash(L.create_time) },
{ key: 'expire_time', label: '过期时间', display: dash(L.expire_time) },
]
})
const quotaRows = computed(() => {
const L = license.value
if (!L) return []
return [
{ key: 'max_database', label: '数据库', display: formatQuota(L.max_database) },
{ key: 'max_middleware', label: '中间件', display: formatQuota(L.max_middleware) },
{ key: 'max_pc', label: 'PC', display: formatQuota(L.max_pc) },
{ key: 'max_server', label: '服务器', display: formatQuota(L.max_server) },
{ key: 'max_client', label: '客户端', display: formatQuota(L.max_client) },
{ key: 'max_user', label: '用户', display: formatQuota(L.max_user) },
{ key: 'max_role', label: '角色', display: formatQuota(L.max_role) },
{ key: 'max_permission', label: '权限', display: formatQuota(L.max_permission) },
{ key: 'max_menu', label: '菜单', display: formatQuota(L.max_menu) },
]
})
async function loadLicense() {
loading.value = true
loadError.value = false
try {
const res = await fetchLicenseInfo()
const { code, success, data, message } = parseLicenseResponse(res)
const ok = responseIndicatesSuccess(code, success)
if (data != null && typeof data === 'object' && !Array.isArray(data) && isLicensePayloadRaw(data) && ok) {
license.value = normalizeLicensePayload(data as Record<string, unknown>)
loadError.value = false
} else {
loadError.value = true
Message.error(message || '获取许可证失败')
}
} catch (e) {
loadError.value = true
console.error('[license-center] fetchLicenseInfo', e)
Message.error('获取许可证失败')
} finally {
loading.value = false
}
}
onMounted(() => {
loadLicense()
})
</script>
<script lang="ts">
export default {
name: 'LicenseCenter',
}
</script>
<style scoped lang="less">
/* 与 system-settings / 报表等运维页一致 */
.container {
padding: 0 20px 20px;
}
.license-page-card {
:deep(.arco-card-body) {
padding-top: 8px;
}
}
.card-head-title {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
min-width: 0;
}
.page-title {
font-size: 16px;
font-weight: 600;
color: var(--color-text-1);
line-height: 1.4;
}
.page-subtitle {
margin: 0;
font-size: 13px;
font-weight: normal;
color: var(--color-text-2);
line-height: 1.5;
word-break: break-word;
}
.page-spin {
display: block;
width: 100%;
min-height: 120px;
}
.section-card {
margin-bottom: 16px;
background: var(--color-fill-1);
border-radius: 4px;
overflow: hidden;
&:last-child {
margin-bottom: 0;
}
:deep(.arco-card-header) {
border-bottom: 1px solid var(--color-border-2);
padding-top: 12px;
padding-bottom: 12px;
}
:deep(.arco-card-body) {
padding: 0;
background: var(--color-bg-2);
}
}
.section-card--quota {
:deep(.arco-card-body) {
padding-top: 0;
}
}
.section-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text-1);
}
.quota-hint {
margin: 0;
padding: 12px 20px 4px;
font-size: 12px;
color: var(--color-text-3);
line-height: 1.5;
background: var(--color-bg-2);
}
.kv-list {
padding: 0;
background: var(--color-bg-2);
}
.basic-row,
.quota-row {
display: flex;
align-items: flex-start;
padding: 12px 20px;
border-bottom: 1px solid var(--color-border-1);
&:last-child {
border-bottom: none;
}
.label {
flex-shrink: 0;
width: 100px;
font-size: 14px;
color: var(--color-text-2);
}
.value {
flex: 1;
min-width: 0;
font-size: 14px;
color: var(--color-text-1);
text-align: left;
}
}
.quota-row {
align-items: center;
.value {
text-align: right;
font-variant-numeric: tabular-nums;
}
}
.value-code {
word-break: break-all;
}
@media (max-width: 576px) {
.container {
padding: 0 12px 16px;
}
:deep(.arco-card-header) {
flex-wrap: wrap;
gap: 8px;
}
.basic-row {
flex-direction: column;
align-items: stretch;
gap: 6px;
.label {
width: auto;
}
}
.quota-row .label {
width: auto;
min-width: 72px;
}
}
</style>