fix: 初验针对修改
This commit is contained in:
2
.env
2
.env
@@ -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 = ''
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
80
src/api/publicScreen.ts
Normal 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')
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
// 遍历更新回调函数
|
||||
|
||||
@@ -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('清除背景图片失败,请稍后重试!')
|
||||
}
|
||||
}
|
||||
|
||||
// 启用/关闭 颜色(强制更新)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
104
src/views/preview/PublicCarousel.vue
Normal file
104
src/views/preview/PublicCarousel.vue
Normal 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>
|
||||
268
src/views/preview/PublicOpsScreen.vue
Normal file
268
src/views/preview/PublicOpsScreen.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
// 是否本地预览
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user