This commit is contained in:
ygx
2026-03-21 19:33:01 +08:00
18 changed files with 910 additions and 358 deletions

54
pnpm-lock.yaml generated
View File

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

View File

@@ -1,5 +1,24 @@
import { request } from "@/api/request"; 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 }); 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 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

@@ -60,7 +60,12 @@
:data="data" :data="data"
:bordered="bordered" :bordered="bordered"
:size="size" :size="size"
:row-selection="rowSelection"
:scroll="scroll"
@page-change="onPageChange" @page-change="onPageChange"
@page-size-change="onPageSizeChange"
@selection-change="onSelectionChange"
@row-click="onRowClick"
> >
<!-- 动态插槽根据 columns slotName 动态渲染 --> <!-- 动态插槽根据 columns slotName 动态渲染 -->
<template v-for="col in slotColumns" :key="col.dataIndex" #[String(col.slotName)]="slotProps"> <template v-for="col in slotColumns" :key="col.dataIndex" #[String(col.slotName)]="slotProps">
@@ -72,7 +77,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref, watch, nextTick, onUnmounted, PropType } from 'vue' import { computed, ref, watch, nextTick, onUnmounted, PropType } from 'vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface' import type { TableColumnData, TableRowSelection } from '@arco-design/web-vue/es/table/interface'
import cloneDeep from 'lodash/cloneDeep' import cloneDeep from 'lodash/cloneDeep'
import Sortable from 'sortablejs' import Sortable from 'sortablejs'
@@ -107,6 +112,14 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
rowSelection: {
type: Object as PropType<TableRowSelection | undefined>,
default: undefined,
},
scroll: {
type: Object as PropType<{ x?: number | string; y?: number | string } | undefined>,
default: undefined,
},
showToolbar: { showToolbar: {
type: Boolean, type: Boolean,
default: true, default: true,
@@ -147,6 +160,9 @@ const props = defineProps({
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'page-change', current: number): void (e: 'page-change', current: number): void
(e: 'page-size-change', pageSize: number): void
(e: 'selection-change', rowKeys: (string | number)[]): void
(e: 'row-click', record: any, ev: Event): void
(e: 'refresh'): void (e: 'refresh'): void
(e: 'download'): void (e: 'download'): void
(e: 'density-change', size: SizeProps): void (e: 'density-change', size: SizeProps): void
@@ -178,6 +194,18 @@ const onPageChange = (current: number) => {
emit('page-change', current) emit('page-change', current)
} }
const onPageSizeChange = (pageSize: number) => {
emit('page-size-change', pageSize)
}
const onSelectionChange = (rowKeys: (string | number)[]) => {
emit('selection-change', rowKeys)
}
const onRowClick = (record: any, ev: Event) => {
emit('row-click', record, ev)
}
const handleRefresh = () => { const handleRefresh = () => {
emit('refresh') emit('refresh')
} }

View File

@@ -6,7 +6,13 @@ import { listenerRouteChange } from '@/utils/route-listener'
import { compile, computed, defineComponent, h, ref } from 'vue' import { compile, computed, defineComponent, h, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { RouteMeta } from 'vue-router' import type { RouteMeta } from 'vue-router'
import { RouteRecordRaw, useRoute, useRouter } from 'vue-router' import {
isNavigationFailure,
NavigationFailureType,
RouteRecordRaw,
useRoute,
useRouter,
} from 'vue-router'
import useMenuTree from './use-menu-tree' import useMenuTree from './use-menu-tree'
import { COMMON_ICONS } from '@/views/ops/pages/system-settings/menu-management/menuIcons' import { COMMON_ICONS } from '@/views/ops/pages/system-settings/menu-management/menuIcons'
@@ -45,10 +51,20 @@ export default defineComponent({
selectedKey.value = [item.name as string] selectedKey.value = [item.name as string]
return return
} }
console.log('item', item) const name = item.name
// Trigger router change if (name == null || name === '') {
router.push({ console.warn('[Menu] 无法跳转:路由缺少 name', item.path, item.meta?.locale)
name: item.name, return
}
if (!router.hasRoute(name as string)) {
console.warn('[Menu] 无法跳转:未注册的路由 name', name, item.meta?.locale)
return
}
router.push({ name }).catch((err) => {
if (isNavigationFailure(err, NavigationFailureType.duplicated)) {
return
}
console.error('[Menu] 路由跳转失败', name, err)
}) })
} }
const findMenuOpenKeys = (target: string) => { const findMenuOpenKeys = (target: string) => {
@@ -80,7 +96,8 @@ export default defineComponent({
const keySet = new Set([...menuOpenKeys, ...openKeys.value]) const keySet = new Set([...menuOpenKeys, ...openKeys.value])
openKeys.value = [...keySet] openKeys.value = [...keySet]
selectedKey.value = [activeMenu || menuOpenKeys[menuOpenKeys.length - 1]] const leafKey = (activeMenu || menuOpenKeys[menuOpenKeys.length - 1]) as string | undefined
selectedKey.value = leafKey ? [leafKey] : []
} }
}, true) }, true)
const setCollapse = (val: boolean) => { const setCollapse = (val: boolean) => {

View File

@@ -15,12 +15,12 @@ export default function useMenuTree() {
return appClientMenus return appClientMenus
}) })
const menuTree = computed(() => { const menuTree = computed(() => {
const copyRouter = cloneDeep(appRoute.value) as RouteRecordNormalized[] const copyRouter = cloneDeep(appRoute.value || []) as RouteRecordNormalized[]
copyRouter.sort((a: RouteRecordNormalized, b: RouteRecordNormalized) => { copyRouter.sort((a: RouteRecordNormalized, b: RouteRecordNormalized) => {
return (a.meta.order || 0) - (b.meta.order || 0) return (a.meta.order || 0) - (b.meta.order || 0)
}) })
function travel(_routes: RouteRecordRaw[], layer: number) { function travel(_routes: RouteRecordRaw[], layer: number): RouteRecordRaw[] {
if (!_routes) return null if (!_routes?.length) return []
const collector: any = _routes.map((element) => { const collector: any = _routes.map((element) => {
// no access // no access
@@ -44,16 +44,8 @@ export default function useMenuTree() {
element.children = subItem element.children = subItem
return element return element
} }
// the else logic
if (layer > 1) {
element.children = subItem
return element
}
if (element.meta?.hideInMenu === false) {
return element
}
// 子级全部被权限/隐藏规则过滤时,不再把父级当成可点击叶子(避免 push 父级 name 无对应页面)
return null return null
}) })
return collector.filter(Boolean) return collector.filter(Boolean)

View File

@@ -26,6 +26,8 @@
:loading="loading" :loading="loading"
:pagination="pagination" :pagination="pagination"
:bordered="bordered" :bordered="bordered"
:row-selection="rowSelection"
:scroll="scroll"
:show-toolbar="showToolbar" :show-toolbar="showToolbar"
:show-download="showDownload" :show-download="showDownload"
:show-refresh="showRefresh" :show-refresh="showRefresh"
@@ -36,6 +38,9 @@
:density-tooltip-text="densityTooltipText" :density-tooltip-text="densityTooltipText"
:column-setting-tooltip-text="columnSettingTooltipText" :column-setting-tooltip-text="columnSettingTooltipText"
@page-change="handlePageChange" @page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
@selection-change="handleSelectionChange"
@row-click="handleRowClick"
@refresh="handleRefresh" @refresh="handleRefresh"
@download="handleDownload" @download="handleDownload"
@density-change="handleDensityChange" @density-change="handleDensityChange"
@@ -61,7 +66,7 @@ import { computed, PropType } from 'vue'
import SearchForm from '../search-form/index.vue' import SearchForm from '../search-form/index.vue'
import type { FormItem } from '../search-form/types' import type { FormItem } from '../search-form/types'
import DataTable from '../data-table/index.vue' import DataTable from '../data-table/index.vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface' import type { TableColumnData, TableRowSelection } from '@arco-design/web-vue/es/table/interface'
type SizeProps = 'mini' | 'small' | 'medium' | 'large' type SizeProps = 'mini' | 'small' | 'medium' | 'large'
@@ -115,6 +120,14 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
rowSelection: {
type: Object as PropType<TableRowSelection | undefined>,
default: undefined,
},
scroll: {
type: Object as PropType<{ x?: number | string; y?: number | string } | undefined>,
default: undefined,
},
// 工具栏相关 // 工具栏相关
showToolbar: { showToolbar: {
type: Boolean, type: Boolean,
@@ -164,6 +177,9 @@ const emit = defineEmits<{
(e: 'search'): void (e: 'search'): void
(e: 'reset'): void (e: 'reset'): void
(e: 'page-change', current: number): void (e: 'page-change', current: number): void
(e: 'page-size-change', pageSize: number): void
(e: 'selection-change', rowKeys: (string | number)[]): void
(e: 'row-click', record: any, ev: Event): void
(e: 'refresh'): void (e: 'refresh'): void
(e: 'download'): void (e: 'download'): void
(e: 'density-change', size: SizeProps): void (e: 'density-change', size: SizeProps): void
@@ -192,6 +208,18 @@ const handlePageChange = (current: number) => {
emit('page-change', current) emit('page-change', current)
} }
const handlePageSizeChange = (pageSize: number) => {
emit('page-size-change', pageSize)
}
const handleSelectionChange = (rowKeys: (string | number)[]) => {
emit('selection-change', rowKeys)
}
const handleRowClick = (record: any, ev: Event) => {
emit('row-click', record, ev)
}
const handleRefresh = () => { const handleRefresh = () => {
emit('refresh') emit('refresh')
} }

View File

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

View File

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

View File

@@ -181,6 +181,17 @@ function extractRelativePath(childPath: string, parentPath: string): string {
* @param parentIsFull 父级菜单的 is_full 字段 * @param parentIsFull 父级菜单的 is_full 字段
* @returns 子路由配置数组 * @returns 子路由配置数组
*/ */
/** 许可授权中心菜单路径(严格匹配末段,避免 menu_path 中含 query 等导致误伤其它菜单如用户管理) */
const LICENSE_CENTER_VIEW = 'ops/pages/system-settings/license-center'
function isLicenseCenterMenuPath(fullPath: string): boolean {
const path = String(fullPath ?? '')
.trim()
.split('?')[0]
.replace(/\/+$/, '')
return path.endsWith('/license-center') || path === 'license-center'
}
function transformChildRoutes( function transformChildRoutes(
children: ServerMenuItem[], children: ServerMenuItem[],
parentComponent?: string, parentComponent?: string,
@@ -188,10 +199,17 @@ function transformChildRoutes(
parentIsFull?: boolean parentIsFull?: boolean
): AppRouteRecordRaw[] { ): AppRouteRecordRaw[] {
return children.map((child) => { return children.map((child) => {
// 优先使用子菜单自己的 component否则继承父级的 component const childFullPath = String(child.menu_path ?? child.path ?? '').trim()
const componentPath = child.component || parentComponent
// 计算子路由的相对路径 // 已配置 component 的菜单绝不覆盖;仅对许可页做路径/code 兜底,避免 includes 误匹配
const childFullPath = child.menu_path || child.path || '' let componentPath = child.component || parentComponent
if (
!child.component &&
(isLicenseCenterMenuPath(childFullPath) || child.code === 'LicenseCenter')
) {
componentPath = LICENSE_CENTER_VIEW
}
const relativePath = extractRelativePath(childFullPath, parentPath || '') const relativePath = extractRelativePath(childFullPath, parentPath || '')
const route: AppRouteRecordRaw = { const route: AppRouteRecordRaw = {

View File

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

View File

@@ -1,43 +1,4 @@
import { DEFAULT_LAYOUT } from '../base' import type { AppRouteRecordRaw } from '../types'
import { AppRouteRecordRaw } from '../types'
const REMOTE: AppRouteRecordRaw = { /** 占位:勿导出空对象 `{}`,否则会被当作一条无效路由加入 router导致部分菜单匹配异常 */
// path: '/dc', export default [] as AppRouteRecordRaw[]
// name: 'DC',
// component: DEFAULT_LAYOUT,
// meta: {
// locale: 'menu.dc',
// requiresAuth: true,
// icon: 'icon-desktop',
// order: 99,
// hideInMenu: true,
// },
// children: [
// {
// path: 'detail',
// name: 'DCDetail',
// component: () => import('@/views/ops/pages/dc/detail/index.vue'),
// meta: {
// locale: 'menu.dc.detail',
// requiresAuth: true,
// roles: ['*'],
// // is_full: true,
// isNewTab: true,
// },
// },
// {
// path: 'remote',
// name: 'DCRemote',
// component: () => import('@/views/ops/pages/dc/remote/index.vue'),
// meta: {
// locale: 'menu.dc.remote',
// requiresAuth: true,
// roles: ['*'],
// // is_full: true,
// isNewTab: true,
// },
// },
// ],
}
export default REMOTE

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="notice-channel-container"> <div class="container">
<SearchTable <search-table
v-model:form-model="formModel" :form-model="formModel"
:form-items="formItems" :form-items="formItems"
:data="tableData" :data="tableData"
:columns="columns" :columns="columns"
@@ -10,6 +10,10 @@
title="通知渠道管理" title="通知渠道管理"
search-button-text="查询" search-button-text="查询"
reset-button-text="重置" reset-button-text="重置"
refresh-tooltip-text="刷新数据"
density-tooltip-text="表格密度"
column-setting-tooltip-text="列设置"
@update:form-model="handleFormModelUpdate"
@search="handleSearch" @search="handleSearch"
@reset="handleReset" @reset="handleReset"
@page-change="handlePageChange" @page-change="handlePageChange"
@@ -56,7 +60,7 @@
</a-popconfirm> </a-popconfirm>
</a-space> </a-space>
</template> </template>
</SearchTable> </search-table>
<!-- 表单对话框 --> <!-- 表单对话框 -->
<channel-form-dialog <channel-form-dialog
@@ -95,12 +99,15 @@ const pagination = reactive({
total: 0, total: 0,
}) })
// 搜索表单数据
const formModel = ref<Record<string, any>>({ const formModel = ref<Record<string, any>>({
name: '', name: '',
type: '', type: '',
}) })
const handleFormModelUpdate = (value: Record<string, any>) => {
formModel.value = value
}
// 表单项配置 // 表单项配置
const formItems = computed<FormItem[]>(() => [ const formItems = computed<FormItem[]>(() => [
{ {
@@ -240,9 +247,9 @@ const handlePageChange = (current: number) => {
fetchList() fetchList()
} }
// 刷新
const handleRefresh = () => { const handleRefresh = () => {
fetchList() fetchList()
Message.success('数据已刷新')
} }
// 新建 // 新建
@@ -292,7 +299,7 @@ export default {
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.notice-channel-container { .container {
padding: 0 20px 20px 20px; margin-top: 20px;
} }
</style> </style>

View File

@@ -138,7 +138,7 @@
:data="dispatchRuleData" :data="dispatchRuleData"
:pagination="false" :pagination="false"
:bordered="true" :bordered="true"
size="small" size="medium"
> >
<template #columns> <template #columns>
<a-table-column title="告警级别" data-index="severity" :width="150"> <a-table-column title="告警级别" data-index="severity" :width="150">

View File

@@ -31,7 +31,7 @@
:loading="ruleLoading" :loading="ruleLoading"
:pagination="rulePagination" :pagination="rulePagination"
:bordered="true" :bordered="true"
size="small" size="medium"
@page-change="handlePageChange" @page-change="handlePageChange"
> >
<template #columns> <template #columns>

View File

@@ -118,7 +118,7 @@
:data="processRecords" :data="processRecords"
:columns="processColumns" :columns="processColumns"
:pagination="false" :pagination="false"
size="small" size="medium"
> >
<template #action="{ record }"> <template #action="{ record }">
<a-tag :color="getActionColor(record.action)"> <a-tag :color="getActionColor(record.action)">

View File

@@ -1,65 +1,46 @@
<template> <template>
<div class="alert-tackle-container"> <div class="container">
<a-card :bordered="false" class="general-card"> <search-table
<search-form :form-model="formModel"
v-model="searchParams" :form-items="formItems"
:form-items="searchFormConfig" :data="tableData"
:show-buttons="false" :columns="columns"
:loading="loading"
:pagination="pagination"
title="告警受理处理"
search-button-text="查询"
reset-button-text="重置"
refresh-tooltip-text="刷新数据"
density-tooltip-text="表格密度"
column-setting-tooltip-text="列设置"
@update:form-model="handleFormModelUpdate"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
@row-click="handleRowClick"
@refresh="handleRefresh"
>
<template #form-items>
<a-col :span="16">
<a-form-item
label="时间范围"
:label-col-props="{ span: 6 }"
:wrapper-col-props="{ span: 18 }"
> >
<template #extra>
<a-range-picker <a-range-picker
v-model="timeRange" v-model="timeRange"
:time-picker-props="{ defaultValue: '00:00:00' }" :time-picker-props="{ defaultValue: '00:00:00' }"
format="YYYY-MM-DD HH:mm:ss" format="YYYY-MM-DD HH:mm:ss"
show-time show-time
style="width: 380px; margin-right: 12px" style="width: 100%; max-width: 420px"
/> />
<a-button type="primary" @click="handleSearch"> </a-form-item>
<template #icon>
<icon-search />
</template>
查询
</a-button>
<a-button @click="handleReset">
<template #icon>
<icon-refresh />
</template>
重置
</a-button>
</template>
</search-form>
<a-divider style="margin: 0" />
<a-row class="toolbar">
<a-col :span="12">
<a-space>
<a-button
v-if="selectedRowKeys.length > 0"
type="primary"
status="danger"
@click="handleBatchAck"
>
批量确认 ({{ selectedRowKeys.length }})
</a-button>
</a-space>
</a-col> </a-col>
</a-row> </template>
<a-table
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
:row-selection="rowSelection"
:scroll="{ x: 2000 }"
@page-change="onPageChange"
@page-size-change="onPageSizeChange"
@selection-change="handleSelectionChange"
@row-click="handleRowClick"
>
<template #index="{ rowIndex }"> <template #index="{ rowIndex }">
{{ rowIndex + 1 }} {{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
</template> </template>
<template #status="{ record }"> <template #status="{ record }">
@@ -101,48 +82,29 @@
</template> </template>
<template #labels="{ record }"> <template #labels="{ record }">
<a-space v-if="parsedLabels(record.labels)" wrap> <a-tooltip v-if="formatLabelsLine(record.labels)" :content="formatLabelsLine(record.labels)">
<a-tag <span class="cell-ellipsis">{{ formatLabelsLine(record.labels) }}</span>
v-for="(value, key) in parsedLabels(record.labels)" </a-tooltip>
:key="key"
size="small"
>
{{ key }}: {{ value }}
</a-tag>
</a-space>
<span v-else>-</span> <span v-else>-</span>
</template> </template>
<template #actions="{ record }"> <template #actions="{ record }">
<a-space> <a-space size="small" :wrap="false">
<a-button type="text" size="small" @click="handleAck(record)"> <a-button type="text" size="small" @click.stop="handleAck(record)">
<template #icon>
<icon-check />
</template>
确认 确认
</a-button> </a-button>
<a-button type="text" size="small" @click="handleResolve(record)"> <a-button type="text" size="small" @click.stop="handleResolve(record)">
<template #icon>
<icon-check-circle />
</template>
解决 解决
</a-button> </a-button>
<a-button type="text" size="small" @click="handleSilence(record)"> <a-button type="text" size="small" @click.stop="handleSilence(record)">
<template #icon>
<icon-eye-invisible />
</template>
屏蔽 屏蔽
</a-button> </a-button>
<a-button type="text" size="small" @click="handleComment(record)"> <a-button type="text" size="small" @click.stop="handleComment(record)">
<template #icon>
<icon-message />
</template>
评论 评论
</a-button> </a-button>
<a-dropdown @select="handleMoreAction($event, record)"> <a-dropdown @select="(v) => handleMoreSelect(v, record)">
<a-button type="text" size="small"> <a-button type="text" size="small" @click.stop>
更多 更多
<icon-down />
</a-button> </a-button>
<template #content> <template #content>
<a-doption value="detail">详情</a-doption> <a-doption value="detail">详情</a-doption>
@@ -151,38 +113,32 @@
</a-dropdown> </a-dropdown>
</a-space> </a-space>
</template> </template>
</a-table> </search-table>
</a-card>
<!-- 确认对话框 -->
<ack-dialog <ack-dialog
v-model:visible="ackDialogVisible" v-model:visible="ackDialogVisible"
:alert-record-id="currentRecord.id" :alert-record-id="currentRecord.id"
@success="handleSuccess" @success="handleSuccess"
/> />
<!-- 解决对话框 -->
<resolve-dialog <resolve-dialog
v-model:visible="resolveDialogVisible" v-model:visible="resolveDialogVisible"
:alert-record-id="currentRecord.id" :alert-record-id="currentRecord.id"
@success="handleSuccess" @success="handleSuccess"
/> />
<!-- 屏蔽对话框 -->
<silence-dialog <silence-dialog
v-model:visible="silenceDialogVisible" v-model:visible="silenceDialogVisible"
:alert-record-id="currentRecord.id" :alert-record-id="currentRecord.id"
@success="handleSuccess" @success="handleSuccess"
/> />
<!-- 评论对话框 -->
<comment-dialog <comment-dialog
v-model:visible="commentDialogVisible" v-model:visible="commentDialogVisible"
:alert-record-id="currentRecord.id" :alert-record-id="currentRecord.id"
@success="handleSuccess" @success="handleSuccess"
/> />
<!-- 详情对话框 -->
<detail-dialog <detail-dialog
v-model:visible="detailDialogVisible" v-model:visible="detailDialogVisible"
:alert-record-id="currentRecord.id" :alert-record-id="currentRecord.id"
@@ -193,21 +149,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue' import { Message } from '@arco-design/web-vue'
import {
IconSearch,
IconRefresh,
IconCheck,
IconCheckCircle,
IconEyeInvisible,
IconMessage,
IconDown,
} from '@arco-design/web-vue/es/icon'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import type { FormItem } from '@/components/search-form/types'
import SearchForm from '@/components/search-form/index.vue' import type { SelectOptionData } from '@arco-design/web-vue/es/select/interface'
import SearchTable from '@/components/search-table/index.vue'
import { searchFormConfig } from './config/search-form' import { searchFormConfig } from './config/search-form'
import { columns } from './config/columns' import { columns } from './config/columns'
import { fetchAlertRecords, createAlertProcess } from '@/api/ops/alertRecord' import { fetchAlertRecords } from '@/api/ops/alertRecord'
import { fetchAlertLevelList } from '@/api/ops/alertLevel' import { fetchAlertLevelList } from '@/api/ops/alertLevel'
import AckDialog from './components/AckDialog.vue' import AckDialog from './components/AckDialog.vue'
@@ -218,21 +166,17 @@ import DetailDialog from './components/DetailDialog.vue'
const router = useRouter() const router = useRouter()
// 状态管理
const loading = ref(false) const loading = ref(false)
const tableData = ref<any[]>([]) const tableData = ref<any[]>([])
const timeRange = ref<any[]>([]) const timeRange = ref<any[]>([])
const selectedRowKeys = ref<number[]>([])
const currentRecord = ref<any>({}) const currentRecord = ref<any>({})
// 对话框状态
const ackDialogVisible = ref(false) const ackDialogVisible = ref(false)
const resolveDialogVisible = ref(false) const resolveDialogVisible = ref(false)
const silenceDialogVisible = ref(false) const silenceDialogVisible = ref(false)
const commentDialogVisible = ref(false) const commentDialogVisible = ref(false)
const detailDialogVisible = ref(false) const detailDialogVisible = ref(false)
// 分页
const pagination = reactive({ const pagination = reactive({
current: 1, current: 1,
pageSize: 20, pageSize: 20,
@@ -242,16 +186,23 @@ const pagination = reactive({
pageSizeOptions: ['10', '20', '50', '100'], pageSizeOptions: ['10', '20', '50', '100'],
}) })
// 行选择 const severityOptions = ref<SelectOptionData[]>([])
const rowSelection = computed(() => ({
type: 'checkbox',
showCheckedAll: true,
}))
// 搜索参数 const formModel = ref<Record<string, any>>({
const searchParams = ref<any>({}) keyword: '',
status: '',
severity_id: '',
})
const formItems = computed<FormItem[]>(() =>
searchFormConfig.map((item) => {
if (item.field === 'severity_id') {
return { ...item, options: severityOptions.value }
}
return item
}),
)
// 加载告警级别列表
onMounted(async () => { onMounted(async () => {
await loadSeverityOptions() await loadSeverityOptions()
handleSearch() handleSearch()
@@ -259,51 +210,58 @@ onMounted(async () => {
const loadSeverityOptions = async () => { const loadSeverityOptions = async () => {
try { try {
const result = await fetchAlertLevelList({ page: 1, page_size: 100 }) const res = await fetchAlertLevelList({ page: 1, page_size: 100 })
const severityConfig = searchFormConfig.find((item) => item.field === 'severity_id') const list = res.details?.data ?? (res as any).data ?? []
if (severityConfig && result.details) { severityOptions.value = list.map((item: any) => ({
severityConfig.options = result.data.map((item: any) => ({
label: item.name || item.code, label: item.name || item.code,
value: item.id, value: item.id,
})) }))
}
} catch (error) { } catch (error) {
console.error('加载告警级别失败:', error) console.error('加载告警级别失败:', error)
} }
} }
// 搜索 const handleFormModelUpdate = (value: Record<string, any>) => {
formModel.value = value
}
const handleSearch = () => { const handleSearch = () => {
pagination.current = 1 pagination.current = 1
loadData() loadData()
} }
const handleReset = () => { const handleReset = () => {
formModel.value = {
keyword: '',
status: '',
severity_id: '',
}
timeRange.value = [] timeRange.value = []
searchParams.value = {}
pagination.current = 1 pagination.current = 1
loadData() loadData()
} }
// 加载数据
const loadData = async () => { const loadData = async () => {
loading.value = true loading.value = true
try { try {
const params: any = { const params: any = {
page: pagination.current, page: pagination.current,
page_size: pagination.pageSize, page_size: pagination.pageSize,
...searchParams.value, keyword: formModel.value.keyword || undefined,
status: formModel.value.status || undefined,
}
if (formModel.value.severity_id !== '' && formModel.value.severity_id != null) {
params.severity_id = formModel.value.severity_id
} }
// 处理时间范围
if (timeRange.value && timeRange.value.length === 2) { if (timeRange.value && timeRange.value.length === 2) {
params.start_time = new Date(timeRange.value[0]).toISOString() params.start_time = new Date(timeRange.value[0]).toISOString()
params.end_time = new Date(timeRange.value[1]).toISOString() params.end_time = new Date(timeRange.value[1]).toISOString()
} }
const result = await fetchAlertRecords(params) const result = await fetchAlertRecords(params)
tableData.value = result.details.data || [] tableData.value = result.details?.data || []
pagination.total = result.details.total || 0 pagination.total = result.details?.total || 0
} catch (error) { } catch (error) {
console.error('加载数据失败:', error) console.error('加载数据失败:', error)
Message.error('加载数据失败') Message.error('加载数据失败')
@@ -312,30 +270,22 @@ const loadData = async () => {
} }
} }
// 分页 const handlePageChange = (page: number) => {
const onPageChange = (page: number) => {
pagination.current = page pagination.current = page
loadData() loadData()
} }
const onPageSizeChange = (pageSize: number) => { const handlePageSizeChange = (pageSize: number) => {
pagination.pageSize = pageSize pagination.pageSize = pageSize
pagination.current = 1 pagination.current = 1
loadData() loadData()
} }
// 行选择 const handleRowClick = (record: any, _ev: Event) => {
const handleSelectionChange = (rowKeys: number[]) => {
selectedRowKeys.value = rowKeys
}
// 行点击
const handleRowClick = (record: any) => {
currentRecord.value = record currentRecord.value = record
detailDialogVisible.value = true detailDialogVisible.value = true
} }
// 处理操作
const handleAck = (record: any) => { const handleAck = (record: any) => {
currentRecord.value = record currentRecord.value = record
ackDialogVisible.value = true ackDialogVisible.value = true
@@ -356,28 +306,6 @@ const handleComment = (record: any) => {
commentDialogVisible.value = true commentDialogVisible.value = true
} }
// 批量确认
const handleBatchAck = async () => {
try {
const promises = selectedRowKeys.value.map((id) =>
createAlertProcess({
alert_record_id: id,
action: 'ack',
operator: getCurrentUser(),
comment: '批量确认',
})
)
await Promise.all(promises)
Message.success(`成功确认 ${selectedRowKeys.value.length} 条告警`)
selectedRowKeys.value = []
loadData()
} catch (error) {
console.error('批量确认失败:', error)
Message.error('批量确认失败')
}
}
// 更多操作
const handleMoreAction = (action: string, record: any) => { const handleMoreAction = (action: string, record: any) => {
currentRecord.value = record currentRecord.value = record
switch (action) { switch (action) {
@@ -393,12 +321,22 @@ const handleMoreAction = (action: string, record: any) => {
} }
} }
// 操作成功回调 const handleMoreSelect = (
value: string | number | Record<string, unknown> | undefined,
record: any,
) => {
handleMoreAction(String(value), record)
}
const handleSuccess = () => { const handleSuccess = () => {
loadData() loadData()
} }
// 格式化函数 const handleRefresh = () => {
loadData()
Message.success('数据已刷新')
}
const formatDateTime = (datetime: string) => { const formatDateTime = (datetime: string) => {
if (!datetime) return '-' if (!datetime) return '-'
return new Date(datetime).toLocaleString('zh-CN') return new Date(datetime).toLocaleString('zh-CN')
@@ -424,7 +362,15 @@ const parsedLabels = (labels: string) => {
} }
} }
// 状态相关 /** 单行展示,避免 a-space wrap + 多 tag 撑高整行 td与其它告警列表页行高一致 */
const formatLabelsLine = (labels: string) => {
const obj = parsedLabels(labels)
if (!obj || typeof obj !== 'object') return ''
return Object.entries(obj as Record<string, unknown>)
.map(([k, v]) => `${k}: ${v == null ? '' : String(v)}`)
.join(', ')
}
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {
firing: 'red', firing: 'red',
@@ -466,23 +412,35 @@ const getNotifyStatusText = (status: string) => {
} }
return textMap[status] || status return textMap[status] || status
} }
</script>
function getCurrentUser() { <script lang="ts">
// TODO: 从全局状态获取当前用户 export default {
return 'admin' name: 'AlertTackle',
} }
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.alert-tackle-container { .container {
padding: 20px; margin-top: 20px;
.general-card { /* 与未设置 table scroll 的告警列表页一致行高;宽表在表格外层横向滚动,避免 Arco scroll 模式改变单元格高度 */
padding: 20px; :deep(.search-table-container .data-table) {
overflow-x: auto;
} }
.toolbar { /* 标签列单行省略,与其它页纯文本单元格行高一致 */
padding: 16px 0; .cell-ellipsis {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
/* 与其它告警 SearchTable 页一致:正文单元格垂直居中 */
:deep(.arco-table-size-medium .arco-table-td) {
vertical-align: middle;
} }
} }
</style> </style>

View File

@@ -0,0 +1,378 @@
<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>
</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>

View File

@@ -2,7 +2,6 @@
<div class="container"> <div class="container">
<Breadcrumb :items="['menu.ops.systemSettings', 'menu.ops.systemSettings.systemLogs']" /> <Breadcrumb :items="['menu.ops.systemSettings', 'menu.ops.systemSettings.systemLogs']" />
<!-- 使用 SearchTable 公共组件 -->
<SearchTable <SearchTable
:form-model="formModel" :form-model="formModel"
:form-items="formItems" :form-items="formItems"
@@ -23,24 +22,57 @@
@refresh="handleRefresh" @refresh="handleRefresh"
@download="handleDownload" @download="handleDownload"
> >
<!-- 表格自定义列日志级别 -->
<template #level="{ record }"> <template #level="{ record }">
<a-tag :color="getLevelColor(record.level)"> <a-tag :color="getLevelColor(record.level)">
{{ record.level }} {{ record.level }}
</a-tag> </a-tag>
</template> </template>
<!-- 表格自定义列序号 -->
<template #index="{ rowIndex }"> <template #index="{ rowIndex }">
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }} {{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
</template> </template>
<!-- 表格自定义列操作 -->
<template #operations="{ record }"> <template #operations="{ record }">
<a-button type="text" size="small" @click="handleView(record)"> <a-button type="text" size="small" @click="handleView(record)">
查看 查看
</a-button> </a-button>
</template> </template>
</SearchTable> </SearchTable>
<a-drawer
v-model:visible="detailVisible"
:width="480"
placement="right"
:title="detailRecord ? `日志详情 #${detailRecord.id}` : '日志详情'"
:footer="false"
unmount-on-close
>
<template v-if="detailRecord">
<a-descriptions :column="1" size="large" bordered>
<a-descriptions-item label="日志级别">
<a-tag :color="getLevelColor(detailRecord.level)">
{{ detailRecord.level }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="模块">
{{ detailRecord.module }}
</a-descriptions-item>
<a-descriptions-item label="操作人">
{{ detailRecord.operator }}
</a-descriptions-item>
<a-descriptions-item label="IP 地址">
{{ detailRecord.ip }}
</a-descriptions-item>
<a-descriptions-item label="操作时间">
{{ detailRecord.createdAt }}
</a-descriptions-item>
<a-descriptions-item label="请求 ID">
{{ detailRecord.requestId }}
</a-descriptions-item>
<a-descriptions-item label="日志内容">
<div class="detail-content">{{ detailRecord.content }}</div>
</a-descriptions-item>
</a-descriptions>
</template>
</a-drawer>
</div> </div>
</template> </template>
@@ -50,7 +82,6 @@ import { Message } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface' import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import type { FormItem } from '@/components/search-form/types' import type { FormItem } from '@/components/search-form/types'
// 定义表格数据类型
interface LogRecord { interface LogRecord {
id: number id: number
level: string level: string
@@ -59,32 +90,42 @@ interface LogRecord {
operator: string operator: string
ip: string ip: string
createdAt: string createdAt: string
/** 用于时间范围筛选(毫秒时间戳) */
timestamp: number
requestId: string
} }
// 模拟数据生成
const generateMockData = (count: number): LogRecord[] => { const generateMockData = (count: number): LogRecord[] => {
const levels = ['INFO', 'WARN', 'ERROR', 'DEBUG'] const levels = ['INFO', 'WARN', 'ERROR', 'DEBUG']
const modules = ['用户管理', '权限管理', '系统配置', '数据备份', '登录认证'] const modules = ['用户管理', '权限管理', '系统配置', '数据备份', '登录认证']
const operators = ['管理员', '张三', '李四', '系统', '定时任务'] const operators = ['管理员', '张三', '李四', '系统', '定时任务']
return Array.from({ length: count }, (_, i) => ({ return Array.from({ length: count }, (_, i) => {
const timestamp = Date.now() - i * 3600000
return {
id: i + 1, id: i + 1,
level: levels[i % levels.length], level: levels[i % levels.length],
module: modules[i % modules.length], module: modules[i % modules.length],
content: `日志内容描述 ${i + 1}`, content: `日志内容描述 ${i + 1}:系统执行例行检查与状态同步。`,
operator: operators[i % operators.length], operator: operators[i % operators.length],
ip: `192.168.${Math.floor(i / 255)}.${i % 255}`, ip: `192.168.${Math.floor(i / 255) % 256}.${i % 256}`,
createdAt: new Date(Date.now() - i * 3600000).toLocaleString('zh-CN'), createdAt: new Date(timestamp).toLocaleString('zh-CN'),
})) timestamp,
requestId: `req-${10000 + i}`,
}
})
} }
// 状态管理
const loading = ref(false) const loading = ref(false)
const tableData = ref<LogRecord[]>([]) const tableData = ref<LogRecord[]>([])
const allFilteredData = ref<LogRecord[]>([])
const formModel = ref({ const formModel = ref({
level: '', level: '',
module: '', module: '',
operator: '', operator: '',
keyword: '',
dateRange: [] as unknown[],
}) })
const pagination = reactive({ const pagination = reactive({
@@ -93,7 +134,9 @@ const pagination = reactive({
total: 0, total: 0,
}) })
// 表单项配置 const detailVisible = ref(false)
const detailRecord = ref<LogRecord | null>(null)
const formItems = computed<FormItem[]>(() => [ const formItems = computed<FormItem[]>(() => [
{ {
field: 'level', field: 'level',
@@ -126,9 +169,21 @@ const formItems = computed<FormItem[]>(() => [
type: 'input', type: 'input',
placeholder: '请输入操作人', placeholder: '请输入操作人',
}, },
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '搜索日志内容',
},
{
field: 'dateRange',
label: '时间范围',
type: 'dateRange',
placeholder: '选择时间范围',
span: 16,
},
]) ])
// 表格列配置
const columns = computed<TableColumnData[]>(() => [ const columns = computed<TableColumnData[]>(() => [
{ {
title: '序号', title: '序号',
@@ -177,7 +232,6 @@ const columns = computed<TableColumnData[]>(() => [
}, },
]) ])
// 获取日志级别颜色
const getLevelColor = (level: string) => { const getLevelColor = (level: string) => {
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {
INFO: 'arcoblue', INFO: 'arcoblue',
@@ -188,38 +242,54 @@ const getLevelColor = (level: string) => {
return colorMap[level] || 'gray' return colorMap[level] || 'gray'
} }
// 模拟异步获取数据 function applyFilters(source: LogRecord[]): LogRecord[] {
const fetchData = async () => { let data = source
loading.value = true const f = formModel.value
// 模拟网络延迟 if (f.level) {
await new Promise(resolve => setTimeout(resolve, 500)) data = data.filter(item => item.level === f.level)
let data = generateMockData(100)
// 根据搜索条件过滤
if (formModel.value.level) {
data = data.filter(item => item.level === formModel.value.level)
} }
if (formModel.value.module) { if (f.module) {
data = data.filter(item => item.module === formModel.value.module) data = data.filter(item => item.module === f.module)
}
if (f.operator) {
data = data.filter(item => item.operator.includes(f.operator))
}
if (f.keyword?.trim()) {
const kw = f.keyword.trim()
data = data.filter(item => item.content.includes(kw))
}
if (f.dateRange && f.dateRange.length === 2) {
const [start, end] = f.dateRange
const startMs = new Date(start as string | Date).getTime()
const endMs = new Date(end as string | Date).getTime()
if (!Number.isNaN(startMs) && !Number.isNaN(endMs)) {
data = data.filter(item => item.timestamp >= startMs && item.timestamp <= endMs)
} }
if (formModel.value.operator) {
data = data.filter(item => item.operator.includes(formModel.value.operator))
} }
// 更新分页 return data
pagination.total = data.length }
// 分页截取 function slicePage(data: LogRecord[]) {
const start = (pagination.current - 1) * pagination.pageSize const start = (pagination.current - 1) * pagination.pageSize
const end = start + pagination.pageSize const end = start + pagination.pageSize
tableData.value = data.slice(start, end) tableData.value = data.slice(start, end)
}
const fetchData = async () => {
loading.value = true
await new Promise(resolve => setTimeout(resolve, 400))
const base = generateMockData(100)
const filtered = applyFilters(base)
allFilteredData.value = filtered
pagination.total = filtered.length
slicePage(filtered)
loading.value = false loading.value = false
} }
// 事件处理
const handleSearch = () => { const handleSearch = () => {
pagination.current = 1 pagination.current = 1
fetchData() fetchData()
@@ -230,6 +300,8 @@ const handleReset = () => {
level: '', level: '',
module: '', module: '',
operator: '', operator: '',
keyword: '',
dateRange: [],
} }
pagination.current = 1 pagination.current = 1
fetchData() fetchData()
@@ -237,7 +309,7 @@ const handleReset = () => {
const handlePageChange = (current: number) => { const handlePageChange = (current: number) => {
pagination.current = current pagination.current = current
fetchData() slicePage(allFilteredData.value)
} }
const handleRefresh = () => { const handleRefresh = () => {
@@ -250,10 +322,10 @@ const handleDownload = () => {
} }
const handleView = (record: LogRecord) => { const handleView = (record: LogRecord) => {
Message.info(`查看日志详情:${record.id}`) detailRecord.value = record
detailVisible.value = true
} }
// 初始化加载数据
fetchData() fetchData()
</script> </script>
@@ -267,4 +339,11 @@ export default {
.container { .container {
padding: 0 20px 20px 20px; padding: 0 20px 20px 20px;
} }
.detail-content {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
color: var(--color-text-1);
}
</style> </style>