Compare commits

1 Commits

Author SHA1 Message Date
zxr
0066efd7c9 fix: 初验针对修改 2026-09-09 16:37:38 +08:00
21 changed files with 574 additions and 49 deletions

2
.env
View File

@@ -5,4 +5,4 @@ VITE_DEV_PORT = '8080'
VITE_DEV_PATH = 'https://ops-api.apinb.com'
# production path
VITE_PRO_PATH = 'https://ops-api.apinb.com'
VITE_PRO_PATH = ''

View File

@@ -70,12 +70,7 @@ axiosInstance.interceptors.response.use(
// 登录过期
if (code === ResultEnum.TOKEN_OVERDUE) {
window['$message'].error(window['$t']('http.token_overdue_message'))
// token 过期,跳转到源站首页
console.error('[GoView] token 已过期,即将跳转到源站首页')
setTimeout(() => {
window.location.href = window.location.origin
}, 1500)
return Promise.resolve(responseData)
return Promise.reject(new Error(message || '登录凭证已失效'))
}
// 固定错误码重定向
@@ -92,14 +87,11 @@ axiosInstance.interceptors.response.use(
const status = err.response?.status
switch (status) {
case 401:
// token 失效,跳转到源站首页
window.location.href = window.location.origin
Promise.reject(err)
break
window['$message'].error('登录凭证已失效')
return Promise.reject(err)
default:
Promise.reject(err)
break
return Promise.reject(err)
}
}
)

View File

@@ -5,7 +5,6 @@ export type ProjectImageKind = 'index' | 'background'
interface PendingUpload {
file_id: string
object_key: string
upload: {
method: string
url: string
@@ -14,6 +13,15 @@ interface PendingUpload {
}
}
/** 删除项目图片及其文件关联。 */
export async function deleteProjectImage(projectIdentity: string, kind: ProjectImageKind): Promise<void> {
const projectPath = `project/${encodeURIComponent(projectIdentity)}/images/${kind}`
const response = await http(RequestHttpEnum.DELETE)(projectPath)
if (response.code !== ResultEnum.SUCCESS) {
throw new Error(response.message || '删除项目图片失败')
}
}
interface CompletedUpload {
file_id: string
url: string

80
src/api/publicScreen.ts Normal file
View File

@@ -0,0 +1,80 @@
const apiBase = import.meta.env.PROD ? import.meta.env.VITE_PRO_PATH : import.meta.env.VITE_DEV_PATH
export interface PublicScreenManifest {
id: number
name: string
description: string
width: number
height: number
dataset_code: string
built_in_code?: string
is_builtin: boolean
goview_project_id?: string
publish_version: string
project_content?: Record<string, any>
carousel_order: number
}
export interface PublicScreenData {
screen_id: number
dataset_code: string
generated_at: string
monitoring: Record<string, any>
alerts: Record<string, any>
partial_errors: string[]
}
export interface PublicCarousel {
enabled: boolean
interval_seconds: number
screens: PublicScreenManifest[]
}
const requestPublic = async <T>(path: string): Promise<T> => {
const response = await fetch(`${apiBase}${path}`, { method: 'GET', credentials: 'omit' })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const body = await response.json()
const code = Number(body?.code)
if (code !== 0 && code !== 200) throw new Error(body?.message || '公开大屏接口返回失败')
return body?.details ?? body?.data
}
export const publicScreenId = () => {
const query = window.location.hash.split('?')[1] || ''
const value = new URLSearchParams(query).get('screenId')
const id = Number(value)
return Number.isInteger(id) && id > 0 ? id : 0
}
export const isPublicCarousel = () => {
const query = window.location.hash.split('?')[1] || ''
return new URLSearchParams(query).get('carousel') === '1'
}
export const fetchPublicScreenManifest = (screenId: number) =>
requestPublic<PublicScreenManifest>(`/Mgt/v1/public/big-screens/${screenId}`)
let cachedScreenId = 0
let cachedAt = 0
let cachedData: PublicScreenData | null = null
let pendingData: Promise<PublicScreenData> | null = null
export const fetchPublicScreenData = (screenId: number) => {
const now = Date.now()
if (cachedData && cachedScreenId === screenId && now - cachedAt < 2000) return Promise.resolve(cachedData)
if (pendingData && cachedScreenId === screenId) return pendingData
cachedScreenId = screenId
pendingData = requestPublic<PublicScreenData>(`/Mgt/v1/public/big-screens/${screenId}/data`)
.then(data => {
cachedData = data
cachedAt = Date.now()
return data
})
.finally(() => {
pendingData = null
})
return pendingData
}
export const fetchPublicCarousel = () =>
requestPublic<PublicCarousel>('/Mgt/v1/public/big-screens/carousel')

View File

@@ -339,7 +339,10 @@
import { PropType, computed, watch } from 'vue'
import { GlobalThemeJsonType } from '@/settings/chartThemes/index'
import { axisConfig, legendConfig } from '@/packages/chartConfiguration/echarts/index'
import { CollapseItem, SettingItemBox, SettingItem, GlobalSettingPosition } from '@/components/Pages/ChartItemSetting'
import CollapseItem from './CollapseItem.vue'
import SettingItemBox from './SettingItemBox.vue'
import SettingItem from './SettingItem.vue'
import GlobalSettingPosition from './GlobalSettingPosition.vue'
import { icon } from '@/plugins'
import { useChartEditStore } from '@/store/modules/chartEditStore/chartEditStore'
import EchartsRendererSetting from './EchartsRendererSetting.vue'

View File

@@ -11,7 +11,9 @@
<script setup lang="ts">
import { PropType, reactive } from 'vue'
import { CollapseItem, SettingItemBox, SettingItem } from '@/components/Pages/ChartItemSetting'
import CollapseItem from './CollapseItem.vue'
import SettingItemBox from './SettingItemBox.vue'
import SettingItem from './SettingItem.vue'
type positionType = {
top?: number | string | null

View File

@@ -17,7 +17,7 @@
<script setup lang="ts">
import { PropType } from 'vue'
import { SettingItemBox } from '@/components/Pages/ChartItemSetting'
import SettingItemBox from './SettingItemBox.vue'
import { ConfigType } from '@/packages/index.d'
const props = defineProps({

View File

@@ -39,7 +39,7 @@
<script setup lang="ts">
import { PropType } from 'vue'
import { PickCreateComponentType } from '@/packages/index.d'
import { SettingItemBox } from '@/components/Pages/ChartItemSetting'
import SettingItemBox from './SettingItemBox.vue'
import { renderIcon } from '@/utils'
import { icon } from '@/plugins/index'
import { EditCanvasConfigType } from '@/store/modules/chartEditStore/chartEditStore.d'

View File

@@ -28,7 +28,7 @@
<script setup lang="ts">
import { PropType } from 'vue'
import { PickCreateComponentType } from '@/packages/index.d'
import { SettingItemBox } from '@/components/Pages/ChartItemSetting'
import SettingItemBox from './SettingItemBox.vue'
const props = defineProps({
chartAttr: {

View File

@@ -167,7 +167,9 @@
<script setup lang="ts">
import { ref, PropType } from 'vue'
import { PickCreateComponentType, BlendModeEnumList } from '@/packages/index.d'
import { SettingItemBox, SettingItem, CollapseItem } from '@/components/Pages/ChartItemSetting'
import SettingItemBox from './SettingItemBox.vue'
import SettingItem from './SettingItem.vue'
import CollapseItem from './CollapseItem.vue'
import { icon } from '@/plugins'
import logoImg from '@/assets/logo.png'
import { useDesignStore } from '@/store/modules/designStore/designStore'

View File

@@ -6,7 +6,7 @@
<script lang="ts" setup>
import { onMounted, watch, PropType } from 'vue'
import { useMonacoEditor } from './index.hook'
import { EditorWorker } from './index'
import EditorWorker from './EditorWorker.vue'
const props = defineProps({
width: {

View File

@@ -8,6 +8,7 @@ import { RequestDataTypeEnum } from '@/enums/httpEnum'
import { isPreview, newFunctionHandle, intervalUnitHandle } from '@/utils'
import { setOption } from '@/packages/public/chart'
import { isNil } from 'lodash'
import { fetchPublicScreenData, publicScreenId } from '@/api/publicScreen'
// 获取类型
type ChartEditStoreType = typeof useChartEditStore
@@ -65,15 +66,18 @@ export const useChartDataFetch = (
try {
// 处理地址
// @ts-ignore
if (requestUrl?.value) {
const screenId = publicScreenId()
if (screenId || requestUrl?.value) {
// requestOriginUrl 允许为空
const completePath = requestOriginUrl && requestOriginUrl.value + requestUrl.value
if (!completePath) return
if (!screenId && !completePath) return
clearInterval(fetchInterval)
const fetchFn = async () => {
const res = await customizeHttp(toRaw(targetComponent.request), toRaw(chartEditStore.getRequestGlobalConfig))
const res = screenId
? { data: await fetchPublicScreenData(screenId) }
: await customizeHttp(toRaw(targetComponent.request), toRaw(chartEditStore.getRequestGlobalConfig))
if (res) {
try {
const filter = targetComponent.filter

View File

@@ -4,6 +4,7 @@ import { CreateComponentType } from '@/packages/index.d'
import { useChartEditStore } from '@/store/modules/chartEditStore/chartEditStore'
import { RequestGlobalConfigType, RequestDataPondItemType } from '@/store/modules/chartEditStore/chartEditStore.d'
import { newFunctionHandle, intervalUnitHandle } from '@/utils'
import { fetchPublicScreenData, publicScreenId } from '@/api/publicScreen'
// 获取类型
type ChartEditStoreType = typeof useChartEditStore
@@ -31,7 +32,10 @@ const newPondItemInterval = (
// 请求
const fetchFn = async () => {
try {
const res = await customizeHttp(toRaw(requestDataPondItem.value.dataPondRequestConfig), toRaw(requestGlobalConfig))
const screenId = publicScreenId()
const res = screenId
? { data: await fetchPublicScreenData(screenId) }
: await customizeHttp(toRaw(requestDataPondItem.value.dataPondRequestConfig), toRaw(requestGlobalConfig))
if (res) {
try {
// 遍历更新回调函数

View File

@@ -137,7 +137,7 @@ import { UploadCustomRequestOptions } from 'naive-ui'
import { loadAsyncComponent, fetchRouteParamsLocation } from '@/utils'
import { PreviewScaleEnum } from '@/enums/styleEnum'
import { icon } from '@/plugins'
import { uploadProjectImage } from '@/api/projectFiles'
import { deleteProjectImage, uploadProjectImage } from '@/api/projectFiles'
const { ColorPaletteIcon } = icon.ionicons5
const { ScaleIcon, FitToScreenIcon, FitToHeightIcon, FitToWidthIcon } = icon.carbon
@@ -249,9 +249,16 @@ const selectColorValueHandle = (value: number) => {
}
// 清除背景
const clearImage = () => {
chartEditStore.setEditCanvasConfig(EditCanvasConfigEnum.BACKGROUND_IMAGE, undefined)
chartEditStore.setEditCanvasConfig(EditCanvasConfigEnum.SELECT_COLOR, true)
const clearImage = async () => {
try {
await deleteProjectImage(fetchRouteParamsLocation(), 'background')
chartEditStore.setEditCanvasConfig(EditCanvasConfigEnum.BACKGROUND_IMAGE, undefined)
chartEditStore.setEditCanvasConfig(EditCanvasConfigEnum.SELECT_COLOR, true)
window['$message'].success('背景图片已清除')
} catch (error) {
console.error(error)
window['$message'].error('清除背景图片失败,请稍后重试!')
}
}
// 启用/关闭 颜色(强制更新)

View File

@@ -144,18 +144,5 @@ watch(
<style lang="scss">
@include go('request-header-table-box') {
background-color: rgba(0, 0, 0, 0);
@include deep() {
.n-data-table .n-data-table-td {
background-color: rgba(0, 0, 0, 0);
}
.add-btn-box {
width: 100%;
display: flex;
justify-content: center;
.add-btn {
width: 300px;
}
}
}
}
</style>

View File

@@ -226,10 +226,11 @@ export const useSync = () => {
identity: string,
projectName: string,
indexImage: string,
backgroundImage?: string,
remarks: string,
state: number
}) => {
const { identity, projectName, remarks, indexImage, state } = projectData
const { identity, projectName, remarks, indexImage, backgroundImage, state } = projectData
// ID
chartEditStore.setProjectInfo(ProjectInfoEnum.PROJECT_ID, identity)
// 名称
@@ -238,6 +239,8 @@ export const useSync = () => {
chartEditStore.setProjectInfo(ProjectInfoEnum.REMARKS, remarks)
// 缩略图
chartEditStore.setProjectInfo(ProjectInfoEnum.THUMBNAIL, indexImage)
// 背景图地址由 Files 动态生成,不保存在画布 JSON 中
chartEditStore.setEditCanvasConfig(EditCanvasConfigEnum.BACKGROUND_IMAGE, backgroundImage || undefined)
// 发布
chartEditStore.setProjectInfo(ProjectInfoEnum.RELEASE, state === 1)
}
@@ -252,10 +255,10 @@ export const useSync = () => {
const res = await fetchProjectApi({ projectId: fetchRouteParamsLocation() })
if (res && res.code === ResultEnum.SUCCESS) {
if (res.data) {
updateStoreInfo(res.data)
// 更新全局数据
const storageInfo = res.data.content ? JSONParse(res.data.content) : chartEditStore.getStorageInfo()
await updateComponent(storageInfo || chartEditStore.getStorageInfo())
updateStoreInfo(res.data)
return
}else {
chartEditStore.setProjectInfo(ProjectInfoEnum.PROJECT_ID, fetchRouteParamsLocation())
@@ -326,7 +329,11 @@ export const useSync = () => {
// 保存数据
let params = new FormData()
params.append('projectId', projectId)
params.append('content', JSONStringify(chartEditStore.getStorageInfo() || {}))
const storageInfo = JSONParse(JSONStringify(chartEditStore.getStorageInfo() || {}))
if (storageInfo.editCanvasConfig) {
delete storageInfo.editCanvasConfig.backgroundImage
}
params.append('content', JSONStringify(storageInfo))
const res= await saveProjectApi(params)
if (res && res.code === ResultEnum.SUCCESS) {

View File

@@ -0,0 +1,104 @@
<template>
<div class="carousel-page">
<div v-if="error" class="carousel-message">{{ error }}</div>
<div v-else-if="!screens.length" class="carousel-message">当前没有加入轮播的已发布大屏</div>
<template v-else>
<iframe v-if="currentScreen" class="screen-frame active" :src="screenUrl(currentScreen)" />
<iframe v-if="nextScreen && nextScreen.id !== currentScreen?.id" class="screen-frame preload" :src="screenUrl(nextScreen)" />
<div class="carousel-indicator">
<span>{{ currentScreen?.name }}</span>
<span>{{ currentIndex + 1 }} / {{ screens.length }}</span>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { fetchPublicCarousel, type PublicScreenManifest } from '@/api/publicScreen'
const screens = ref<PublicScreenManifest[]>([])
const currentIndex = ref(0)
const error = ref('')
let timer = 0
const currentScreen = computed(() => screens.value[currentIndex.value])
const nextScreen = computed(() => screens.value[(currentIndex.value + 1) % screens.value.length])
const screenUrl = (screen: PublicScreenManifest) => {
const identity = screen.goview_project_id || screen.built_in_code || `screen-${screen.id}`
return `${window.location.origin}${window.location.pathname}#/chart/preview/${encodeURIComponent(identity)}?screenId=${screen.id}`
}
onMounted(async () => {
try {
const carousel = await fetchPublicCarousel()
if (!carousel.enabled) {
error.value = '大屏轮播未启用'
return
}
screens.value = carousel.screens || []
if (screens.value.length > 1) {
timer = window.setInterval(() => {
currentIndex.value = (currentIndex.value + 1) % screens.value.length
}, Math.max(10, carousel.interval_seconds) * 1000)
}
} catch (reason: any) {
error.value = reason?.message || '轮播配置加载失败'
}
})
onBeforeUnmount(() => window.clearInterval(timer))
</script>
<style scoped>
.carousel-page {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
background: #07111f;
}
.screen-frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
background: #07111f;
}
.active {
z-index: 2;
}
.preload {
z-index: 1;
visibility: hidden;
}
.carousel-indicator {
position: absolute;
right: 24px;
bottom: 18px;
z-index: 3;
display: flex;
gap: 14px;
padding: 7px 12px;
border: 1px solid rgba(111, 186, 255, 0.28);
border-radius: 4px;
color: #a9d5ff;
background: rgba(5, 18, 34, 0.8);
font-size: 13px;
}
.carousel-message {
display: grid;
place-items: center;
width: 100%;
height: 100%;
color: #b7cae0;
font-size: 18px;
}
</style>

View File

@@ -0,0 +1,268 @@
<template>
<div class="ops-screen">
<header class="screen-header">
<div>
<h1>{{ manifest.name }}</h1>
<p>{{ manifest.description }}</p>
</div>
<div class="screen-time">
<strong>{{ clock }}</strong>
<span>数据时间 {{ dataTime }}</span>
</div>
</header>
<div v-if="loadError && !screenData" class="load-state">{{ loadError }}</div>
<template v-else>
<section v-if="manifest.dataset_code === 'network_topology'" class="screen-grid network-layout">
<div class="panel topology-panel">
<div class="panel-title">{{ monitoring.topology?.name || '网络拓扑' }}</div>
<svg v-if="networkNodes.length" viewBox="0 0 1000 560" class="topology-svg">
<line
v-for="link in topologyLinks"
:key="link.id"
:x1="nodePoint(link.source_node_id).x"
:y1="nodePoint(link.source_node_id).y"
:x2="nodePoint(link.target_node_id).x"
:y2="nodePoint(link.target_node_id).y"
:class="['topology-link', statusClass(link.status)]"
/>
<g v-for="node in networkNodes" :key="node.node_id" :transform="`translate(${node.x},${node.y})`">
<circle :class="['node-ring', statusClass(node.status), { alarm: alertCount(node.resource_uid) > 0 }]" r="25" />
<circle class="node-core" r="17" />
<text class="node-label" y="43" text-anchor="middle">{{ node.shortLabel }}</text>
<text v-if="alertCount(node.resource_uid)" class="node-alert" x="22" y="-18">{{ alertCount(node.resource_uid) }}</text>
</g>
</svg>
<div v-else class="empty-panel">暂无真实拓扑数据</div>
</div>
<div class="side-column">
<div class="metric-row">
<div class="metric-card"><span>节点</span><strong>{{ monitoring.nodes?.length || 0 }}</strong></div>
<div class="metric-card"><span>链路</span><strong>{{ monitoring.links?.length || 0 }}</strong></div>
<div class="metric-card danger"><span>活动告警</span><strong>{{ alerts.total || 0 }}</strong></div>
</div>
<div class="panel compact-panel">
<div class="panel-title">接口状态</div>
<div class="interface-summary">
<span>接口总数 <b>{{ monitoring.interface_summary?.total || 0 }}</b></span>
<span class="healthy">正常 <b>{{ monitoring.interface_summary?.up || 0 }}</b></span>
<span class="danger-text">异常 <b>{{ monitoring.interface_summary?.down || 0 }}</b></span>
</div>
<div class="traffic-summary">
<span>入向流量 <b>{{ formatBytes(trafficSummary.inbound) }}</b></span>
<span>出向流量 <b>{{ formatBytes(trafficSummary.outbound) }}</b></span>
</div>
</div>
<alert-list />
</div>
</section>
<section v-else-if="manifest.dataset_code === 'it_resource_monitor'" class="screen-grid resource-layout">
<div class="metric-strip">
<div class="metric-card large"><span>纳管资源</span><strong>{{ monitoring.total || 0 }}</strong></div>
<div v-for="item in statusSummary" :key="item.code" :class="['metric-card', statusClass(item.code)]">
<span>{{ statusText(item.code) }}</span><strong>{{ item.count }}</strong>
</div>
<div class="metric-card danger"><span>活动告警</span><strong>{{ alerts.total || 0 }}</strong></div>
</div>
<div class="panel distribution-panel">
<div class="panel-title">资源类型分布</div>
<div v-for="item in monitoring.by_category || []" :key="item.code" class="bar-row">
<span>{{ categoryText(item.code) }}</span>
<div><i :style="{ width: barWidth(item.count, monitoring.by_category) }" /></div>
<b>{{ item.count }}</b>
</div>
</div>
<div class="panel distribution-panel">
<div class="panel-title">厂商分布</div>
<div v-for="item in monitoring.by_manufacturer || []" :key="item.code" class="bar-row">
<span>{{ item.code }}</span>
<div><i :style="{ width: barWidth(item.count, monitoring.by_manufacturer) }" /></div>
<b>{{ item.count }}</b>
</div>
</div>
<div class="panel list-panel">
<div class="panel-title">异常资源</div>
<div v-if="!(monitoring.abnormal_resources || []).length" class="empty-panel healthy">当前无异常资源</div>
<div v-for="item in monitoring.abnormal_resources || []" :key="item.resource_uid" class="list-item">
<div><strong>{{ item.display_name }}</strong><span>{{ categoryText(item.resource_category) }} · {{ item.manufacturer_code || '未知厂商' }}</span></div>
<b :class="statusClass(item.status)">{{ statusText(item.status) }}</b>
</div>
</div>
<alert-list />
</section>
<section v-else class="screen-grid business-layout">
<div class="metric-strip">
<div class="metric-card large"><span>业务系统</span><strong>{{ monitoring.total || 0 }}</strong></div>
<div class="metric-card healthy"><span>可用</span><strong>{{ monitoring.summary?.available || 0 }}</strong></div>
<div class="metric-card warning"><span>数据不足</span><strong>{{ monitoring.summary?.degraded || 0 }}</strong></div>
<div class="metric-card danger"><span>不可用</span><strong>{{ monitoring.summary?.unavailable || 0 }}</strong></div>
<div class="metric-card danger"><span>活动告警</span><strong>{{ alerts.total || 0 }}</strong></div>
</div>
<div class="business-cards">
<div v-for="system in monitoring.systems || []" :key="system.id" :class="['business-card', statusClass(system.health)]">
<div class="business-card-header"><strong>{{ system.name }}</strong><span>{{ system.code }}</span></div>
<div class="business-health">{{ healthText(system.health) }}</div>
<div class="business-signals">
<span>资源 {{ system.resource_count }}</span>
<span>异常 {{ system.abnormal_resources }}</span>
<span>检测 {{ system.check_count }}</span>
<span>失败 {{ system.failed_checks }}</span>
</div>
</div>
</div>
<alert-list />
</section>
</template>
<div v-if="partialErrors.length" class="partial-error">部分数据暂不可用{{ partialErrors.join('') }}</div>
</div>
</template>
<script setup lang="ts">
import { computed, defineComponent, h, onBeforeUnmount, onMounted, ref } from 'vue'
import { fetchPublicScreenData, type PublicScreenData, type PublicScreenManifest } from '@/api/publicScreen'
const props = defineProps<{ manifest: PublicScreenManifest }>()
const screenData = ref<PublicScreenData | null>(null)
const loadError = ref('')
const clock = ref('')
let clockTimer = 0
let dataTimer = 0
const monitoring = computed(() => screenData.value?.monitoring?.data || {})
const alerts = computed(() => screenData.value?.alerts || {})
const partialErrors = computed(() => screenData.value?.partial_errors || [])
const dataTime = computed(() => screenData.value?.generated_at ? new Date(screenData.value.generated_at).toLocaleTimeString('zh-CN', { hour12: false }) : '-')
const statusSummary = computed(() => (monitoring.value.by_status || []).slice(0, 4))
const alertCountMap = computed(() => Object.fromEntries((alerts.value.by_resource || []).map((item: any) => [item.resource_uid, item.count])))
const rawNodes = computed<any[]>(() => monitoring.value.nodes || [])
const networkNodes = computed(() => {
const rows = rawNodes.value
if (!rows.length) return []
const hasPosition = rows.some(item => Number(item.position_x) !== 0 || Number(item.position_y) !== 0)
if (!hasPosition) {
return rows.map((item, index) => {
const angle = (Math.PI * 2 * index) / rows.length - Math.PI / 2
return { ...item, x: 500 + Math.cos(angle) * 360, y: 280 + Math.sin(angle) * 210, shortLabel: shortLabel(item.label) }
})
}
const xs = rows.map(item => Number(item.position_x) || 0)
const ys = rows.map(item => Number(item.position_y) || 0)
const minX = Math.min(...xs), maxX = Math.max(...xs), minY = Math.min(...ys), maxY = Math.max(...ys)
return rows.map(item => ({
...item,
x: 60 + ((Number(item.position_x) - minX) / Math.max(1, maxX - minX)) * 880,
y: 50 + ((Number(item.position_y) - minY) / Math.max(1, maxY - minY)) * 460,
shortLabel: shortLabel(item.label)
}))
})
const nodeMap = computed(() => Object.fromEntries(networkNodes.value.map(item => [item.node_id, item])))
const topologyLinks = computed<any[]>(() => (monitoring.value.links || []).filter((link: any) => nodeMap.value[link.source_node_id] && nodeMap.value[link.target_node_id]))
const trafficSummary = computed(() => topologyLinks.value.reduce((total, link) => ({
inbound: total.inbound + Number(link.in_traffic_bytes || 0),
outbound: total.outbound + Number(link.out_traffic_bytes || 0)
}), { inbound: 0, outbound: 0 }))
const nodePoint = (id: string) => nodeMap.value[id] || { x: 0, y: 0 }
const alertCount = (uid?: string) => uid ? Number(alertCountMap.value[uid] || 0) : 0
const shortLabel = (value?: string) => String(value || '-').slice(0, 12)
const statusClass = (value?: string) => {
const status = String(value || '').toLowerCase()
if (['online', 'up', 'healthy', 'normal', 'running', 'success', 'available', 'active'].includes(status)) return 'healthy'
if (['warning', 'degraded', 'unknown', 'pending'].includes(status)) return 'warning'
return 'danger'
}
const statusLabels: Record<string, string> = { online: '在线', offline: '离线', unknown: '未知', healthy: '健康', warning: '警告', error: '异常', active: '正常' }
const healthLabels: Record<string, string> = { available: '可用', degraded: '数据不足', unavailable: '不可用' }
const categoryLabels: Record<string, string> = { host: '服务器', network: '网络设备', security: '安全设备', storage: '存储设备', database: '数据库', middleware: '中间件', room_device: '机房设备', business: '业务系统' }
const statusText = (value?: string) => statusLabels[String(value || '').toLowerCase()] || value || '未知'
const healthText = (value?: string) => healthLabels[String(value || '')] || '未知'
const categoryText = (value?: string) => categoryLabels[String(value || '')] || value || '其他'
const barWidth = (count: number, rows: any[]) => `${Math.max(4, (Number(count) / Math.max(1, ...rows.map(item => Number(item.count)))) * 100)}%`
const formatBytes = (value: number) => {
if (value < 1024) return `${value} B`
const units = ['KB', 'MB', 'GB', 'TB']
let amount = value / 1024
let unit = 0
while (amount >= 1024 && unit < units.length - 1) {
amount /= 1024
unit++
}
return `${amount.toFixed(amount >= 100 ? 0 : 1)} ${units[unit]}`
}
const AlertList = defineComponent({
name: 'AlertList',
setup() {
return () => h('div', { class: 'panel list-panel alert-panel' }, [
h('div', { class: 'panel-title' }, '活动告警'),
...(alerts.value.recent || []).slice(0, 12).map((item: any) => h('div', { class: 'list-item', key: item.id }, [
h('div', [h('strong', item.alert_name), h('span', item.summary || item.resource_uid)]),
h('b', { style: { color: item.severity_color || '#ff6b6b' } }, item.severity_name || item.severity_code)
])),
...(alerts.value.recent || []).length ? [] : [h('div', { class: 'empty-panel healthy' }, '当前无活动告警')]
])
}
})
const loadData = async () => {
try {
const data = await fetchPublicScreenData(props.manifest.id)
screenData.value = data
loadError.value = ''
} catch (error: any) {
loadError.value = error?.message || '大屏数据加载失败'
}
}
onMounted(() => {
const updateClock = () => { clock.value = new Date().toLocaleString('zh-CN', { hour12: false }) }
updateClock()
clockTimer = window.setInterval(updateClock, 1000)
loadData()
dataTimer = window.setInterval(loadData, 30000)
})
onBeforeUnmount(() => {
window.clearInterval(clockTimer)
window.clearInterval(dataTimer)
})
</script>
<style scoped>
.ops-screen { box-sizing: border-box; width: 100vw; height: 100vh; overflow: hidden; padding: 18px 22px 22px; color: #d9edff; background: radial-gradient(circle at 50% -20%, #17385c 0, #081727 42%, #050d18 100%); font-family: "Microsoft YaHei", sans-serif; }
.screen-header { height: 78px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid rgba(91, 178, 255, .28); }
.screen-header h1 { margin: 0; color: #ecf8ff; font-size: clamp(24px, 2vw, 38px); letter-spacing: 4px; }
.screen-header p { margin: 7px 0 0; color: #78a8cf; font-size: 13px; }
.screen-time { display: flex; flex-direction: column; align-items: flex-end; gap: 7px; color: #78a8cf; }
.screen-time strong { color: #80c7ff; font-size: 18px; }
.screen-grid { height: calc(100vh - 118px); padding-top: 16px; gap: 14px; }
.network-layout { display: grid; grid-template-columns: minmax(0, 2.1fr) minmax(330px, .9fr); }
.side-column { display: grid; min-height: 0; grid-template-rows: auto auto minmax(0, 1fr); gap: 14px; }
.panel { min-height: 0; overflow: hidden; border: 1px solid rgba(73, 159, 231, .25); border-radius: 5px; background: linear-gradient(145deg, rgba(13, 38, 63, .9), rgba(7, 21, 37, .88)); box-shadow: inset 0 0 25px rgba(25, 104, 164, .08); }
.panel-title { padding: 12px 16px; border-bottom: 1px solid rgba(73, 159, 231, .18); color: #86c9ff; font-size: 15px; font-weight: 600; }
.topology-panel { display: flex; flex-direction: column; }
.topology-svg { width: 100%; height: 100%; min-height: 0; }
.topology-link { stroke: #438fbd; stroke-width: 2; opacity: .72; }
.topology-link.warning { stroke: #f6bd4d; }.topology-link.danger { stroke: #ff5f69; stroke-width: 3; }
.node-core { fill: #102e49; stroke: #72c1f4; stroke-width: 1; }.node-ring { fill: rgba(40, 168, 255, .1); stroke: #36b5ff; stroke-width: 3; }
.node-ring.warning { stroke: #f6bd4d; }.node-ring.danger,.node-ring.alarm { stroke: #ff5f69; filter: drop-shadow(0 0 8px #ff4250); }
.node-label { fill: #d8edff; font-size: 13px; }.node-alert { fill: #fff; font-size: 11px; font-weight: 700; }
.metric-row,.metric-strip { display: grid; gap: 12px; }.metric-row { grid-template-columns: repeat(3, 1fr); }.metric-strip { grid-template-columns: repeat(5, minmax(120px, 1fr)); grid-column: 1 / -1; }
.metric-card { min-width: 0; padding: 14px 16px; border: 1px solid rgba(66, 157, 223, .28); border-radius: 5px; background: rgba(10, 35, 57, .86); }
.metric-card span { display: block; color: #7ca8c8; font-size: 12px; }.metric-card strong { display: block; margin-top: 8px; color: #71c7ff; font-size: 28px; }
.metric-card.healthy strong,.healthy { color: #56d69a; }.metric-card.warning strong,.warning { color: #f6bd4d; }.metric-card.danger strong,.danger,.danger-text { color: #ff6b74; }
.interface-summary { display: grid; grid-template-columns: repeat(3, 1fr); padding: 18px; gap: 12px; }.interface-summary span { color: #8baccc; }.interface-summary b { display: block; margin-top: 7px; font-size: 22px; }
.traffic-summary { display: grid; grid-template-columns: repeat(2, 1fr); padding: 0 18px 16px; gap: 12px; color: #7ca8c8; font-size: 12px; }.traffic-summary b { display: block; margin-top: 5px; color: #71c7ff; font-size: 16px; }
.list-panel { min-height: 0; overflow: auto; }.list-item { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-bottom: 1px solid rgba(75, 145, 198, .13); }
.list-item div { min-width: 0; }.list-item strong,.list-item span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.list-item strong { color: #d7ecff; font-size: 13px; }.list-item span { max-width: 330px; margin-top: 4px; color: #718fab; font-size: 11px; }.list-item b { flex: none; font-size: 12px; }
.empty-panel,.load-state { display: grid; place-items: center; min-height: 110px; color: #7594ae; }.load-state { height: calc(100vh - 118px); font-size: 18px; }
.resource-layout { display: grid; grid-template-columns: .8fr .8fr 1.4fr 1.2fr; grid-template-rows: auto minmax(0, 1fr); }
.distribution-panel { overflow: auto; }.bar-row { display: grid; grid-template-columns: 90px 1fr 48px; align-items: center; gap: 10px; padding: 9px 14px; color: #91b4d1; font-size: 12px; }.bar-row > div { height: 7px; border-radius: 4px; background: rgba(58, 132, 187, .18); }.bar-row i { display: block; height: 100%; border-radius: 4px; background: linear-gradient(90deg, #288ed1, #54d1ff); }
.business-layout { display: grid; grid-template-columns: minmax(0, 2fr) minmax(330px, .8fr); grid-template-rows: auto minmax(0, 1fr); }.business-cards { display: grid; grid-template-columns: repeat(2, minmax(230px, 1fr)); align-content: start; gap: 14px; overflow: auto; }.business-card { padding: 18px; border: 1px solid rgba(81, 169, 228, .25); border-left: 4px solid currentColor; border-radius: 5px; background: rgba(10, 31, 51, .88); }.business-card-header { display: flex; justify-content: space-between; color: #dcefff; }.business-card-header span { color: #6689a6; }.business-health { margin: 22px 0; font-size: 27px; font-weight: 600; }.business-signals { display: grid; grid-template-columns: repeat(2, 1fr); gap: 9px; color: #83a5be; font-size: 12px; }
.partial-error { position: fixed; left: 22px; bottom: 8px; color: #f6bd4d; font-size: 11px; }
@media (max-width: 1100px) { .network-layout,.business-layout { grid-template-columns: 1.5fr 1fr; }.resource-layout { grid-template-columns: 1fr 1fr; }.metric-strip { grid-template-columns: repeat(5, 1fr); }.alert-panel { display: none; } }
</style>

View File

@@ -1,9 +1,49 @@
<template>
<suspense>
<suspense-index></suspense-index>
</suspense>
<public-carousel v-if="carouselMode" />
<public-ops-screen v-else-if="manifest?.is_builtin" :manifest="manifest" />
<div v-else-if="loadError" class="public-load-error">{{ loadError }}</div>
<suspense v-else-if="ready">
<suspense-index />
</suspense>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import suspenseIndex from './suspenseIndex.vue'
import PublicOpsScreen from './PublicOpsScreen.vue'
import PublicCarousel from './PublicCarousel.vue'
import {
fetchPublicScreenManifest,
isPublicCarousel,
publicScreenId,
type PublicScreenManifest
} from '@/api/publicScreen'
const carouselMode = isPublicCarousel()
const screenId = publicScreenId()
const manifest = ref<PublicScreenManifest | null>(null)
const ready = ref(!screenId && !carouselMode)
const loadError = ref('')
onMounted(async () => {
if (!screenId || carouselMode) return
try {
manifest.value = await fetchPublicScreenManifest(screenId)
ready.value = !manifest.value.is_builtin
} catch (error: any) {
loadError.value = error?.message || '大屏加载失败'
}
})
</script>
<style scoped>
.public-load-error {
display: grid;
place-items: center;
width: 100vw;
height: 100vh;
color: #ffb4b4;
background: #07111f;
font-size: 18px;
}
</style>

View File

@@ -4,6 +4,7 @@ import { StorageEnum } from '@/enums/storageEnum'
import { ChartEditStorage } from '@/store/modules/chartEditStore/chartEditStore.d'
import { fetchProjectApi } from '@/api/path'
import { useChartEditStore } from '@/store/modules/chartEditStore/chartEditStore'
import { fetchPublicScreenManifest, publicScreenId } from '@/api/publicScreen'
const chartEditStore = useChartEditStore()
@@ -14,6 +15,22 @@ export interface ChartEditStorageType extends ChartEditStorage {
// 根据路由 identity 获取存储数据的信息
export const getSessionStorageInfo = async () => {
const identity = fetchRouteParamsLocation()
const screenId = publicScreenId()
if (screenId) {
const manifest = await fetchPublicScreenManifest(screenId)
if (manifest.is_builtin) return { isBuiltin: true, manifest }
const content = manifest.project_content?.content
if (typeof content !== 'string' || !content.trim()) throw new Error('大屏发布快照缺少项目内容')
const parseData = { ...JSONParse(content), identity }
if (parseData.editCanvasConfig && typeof manifest.project_content?.backgroundImage === 'string') {
parseData.editCanvasConfig.backgroundImage = manifest.project_content.backgroundImage
}
const { editCanvasConfig, requestGlobalConfig, componentList } = parseData
chartEditStore.editCanvasConfig = editCanvasConfig
chartEditStore.requestGlobalConfig = requestGlobalConfig
chartEditStore.componentList = componentList
return parseData
}
const storageList: ChartEditStorageType[] = getSessionStorage(StorageEnum.GO_CHART_STORAGE_LIST)
// 是否本地预览

View File

@@ -19,7 +19,7 @@
object-fit="cover"
height="100%"
preview-disabled
:src="`${cardData.image}?time=${new Date().getTime()}`"
:src="cardData.image"
:alt="cardData.title"
:fallback-src="requireErrorImg()"
></n-image>