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

54
pnpm-lock.yaml generated
View File

@@ -71,6 +71,9 @@ importers:
vue-router:
specifier: '5'
version: 5.0.3(@vue/compiler-sfc@3.5.29)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3))
vue-web-terminal:
specifier: ^3.4.1
version: 3.4.1(typescript@5.9.3)
devDependencies:
'@arco-plugins/vite-vue':
specifier: ^1.4.6
@@ -1551,6 +1554,9 @@ packages:
resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==}
engines: {node: '>=20'}
clipboard@2.0.11:
resolution: {integrity: sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -1865,6 +1871,9 @@ packages:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
delegate@3.2.0:
resolution: {integrity: sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==}
dir-glob@2.2.2:
resolution: {integrity: sha512-f9LBi5QWzIW3I6e//uxZoLBlUt9kcp66qo0sSCxL6YZKc75R1c4MFCoe/LaZiBGmgujvQdxc5Bn3QhfyvK5Hsw==}
engines: {node: '>=4'}
@@ -2411,6 +2420,9 @@ packages:
engines: {node: '>=0.6.0'}
hasBin: true
good-listener@1.2.2:
resolution: {integrity: sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
@@ -3707,6 +3719,9 @@ packages:
scule@1.3.0:
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
select@1.1.2:
resolution: {integrity: sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==}
semver@5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true
@@ -4056,6 +4071,9 @@ packages:
text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
tiny-emitter@2.1.0:
resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==}
tinyexec@1.0.2:
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
engines: {node: '>=18'}
@@ -4344,6 +4362,11 @@ packages:
peerDependencies:
vue: ^3.0.0
vue-json-viewer@3.0.4:
resolution: {integrity: sha512-pnC080rTub6YjccthVSNQod2z9Sl5IUUq46srXtn6rxwhW8QM4rlYn+CTSLFKXWfw+N3xv77Cioxw7B4XUKIbQ==}
peerDependencies:
vue: ^3.2.2
vue-router@5.0.3:
resolution: {integrity: sha512-nG1c7aAFac7NYj8Hluo68WyWfc41xkEjaR0ViLHCa3oDvTQ/nIuLJlXJX1NUPw/DXzx/8+OKMng045HHQKQKWw==}
peerDependencies:
@@ -4365,6 +4388,9 @@ packages:
peerDependencies:
typescript: '>=5.0.0'
vue-web-terminal@3.4.1:
resolution: {integrity: sha512-+gU28qClqvIZQlzokcvDS2tbFpGfIJKIPc6dvLm2VYX110c6NOh7mV1YrcUESnaE5VQ9DgxqtIbr1YraEA/GRQ==}
vue@3.5.29:
resolution: {integrity: sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==}
peerDependencies:
@@ -5884,6 +5910,12 @@ snapshots:
slice-ansi: 8.0.0
string-width: 8.2.0
clipboard@2.0.11:
dependencies:
good-listener: 1.2.2
select: 1.1.2
tiny-emitter: 2.1.0
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -6178,6 +6210,8 @@ snapshots:
delayed-stream@1.0.0: {}
delegate@3.2.0: {}
dir-glob@2.2.2:
dependencies:
path-type: 3.0.0
@@ -6882,6 +6916,10 @@ snapshots:
dependencies:
minimist: 1.2.8
good-listener@1.2.2:
dependencies:
delegate: 3.2.0
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
@@ -8164,6 +8202,8 @@ snapshots:
scule@1.3.0: {}
select@1.1.2: {}
semver@5.7.2: {}
semver@6.3.1: {}
@@ -8630,6 +8670,8 @@ snapshots:
text-table@0.2.0: {}
tiny-emitter@2.1.0: {}
tinyexec@1.0.2: {}
tinyglobby@0.2.15:
@@ -8953,6 +8995,11 @@ snapshots:
'@vue/devtools-api': 6.6.4
vue: 3.5.29(typescript@5.9.3)
vue-json-viewer@3.0.4(vue@3.5.29(typescript@5.9.3)):
dependencies:
clipboard: 2.0.11
vue: 3.5.29(typescript@5.9.3)
vue-router@5.0.3(@vue/compiler-sfc@3.5.29)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3)):
dependencies:
'@babel/generator': 7.29.1
@@ -8983,6 +9030,13 @@ snapshots:
'@vue/language-core': 3.2.5
typescript: 5.9.3
vue-web-terminal@3.4.1(typescript@5.9.3):
dependencies:
vue: 3.5.29(typescript@5.9.3)
vue-json-viewer: 3.0.4(vue@3.5.29(typescript@5.9.3))
transitivePeerDependencies:
- typescript
vue@3.5.29(typescript@5.9.3):
dependencies:
'@vue/compiler-dom': 3.5.29

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>