init
This commit is contained in:
160
src/views/screen/README.md
Normal file
160
src/views/screen/README.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# 大屏页面重构说明
|
||||
|
||||
## 重构目标
|
||||
|
||||
将原本混乱的单文件组件重构为清晰、可维护的模块化结构,提高代码的可读性和可维护性。
|
||||
|
||||
## 重构后的项目结构
|
||||
|
||||
```
|
||||
src/views/screen/
|
||||
├── mainScreenV1.vue # 主组件(已重构)
|
||||
├── components/ # 子组件目录
|
||||
│ ├── DashboardHeader.vue # 顶部标题栏组件
|
||||
│ ├── OverviewPanel.vue # 总体概览面板组件
|
||||
│ ├── RiskStatisticsPanel.vue # 风险统计面板组件
|
||||
│ ├── AlertPanel.vue # 告警面板组件
|
||||
│ ├── HiddenDangerPanel.vue # 隐患排查治理面板组件
|
||||
│ ├── RegionSelector.vue # 区域选择弹窗组件
|
||||
│ └── WeatherWarning.vue # 天气预报组件
|
||||
├── composables/ # 组合式函数目录
|
||||
│ ├── useDashboardData.ts # 大屏数据管理
|
||||
│ ├── useTimeManager.ts # 时间管理
|
||||
│ ├── useRegionManager.ts # 区域管理
|
||||
│ ├── useTabManager.ts # 标签页管理
|
||||
│ └── useAlertManager.ts # 告警管理
|
||||
└── README.md # 说明文档
|
||||
```
|
||||
|
||||
## 主要改进
|
||||
|
||||
### 1. 组件化拆分
|
||||
- 将原本的单文件组件拆分为多个功能明确的子组件
|
||||
- 每个组件负责特定的功能区域,职责单一
|
||||
- 提高组件的复用性和可测试性
|
||||
|
||||
### 2. 逻辑分离
|
||||
- 使用组合式函数(Composables)将业务逻辑从组件中分离
|
||||
- 每个组合式函数负责特定的功能领域
|
||||
- 逻辑更清晰,易于维护和测试
|
||||
|
||||
### 3. 数据流优化
|
||||
- 统一的数据管理,通过props向下传递,事件向上传递
|
||||
- 清晰的数据流向,避免数据混乱
|
||||
- 更好的状态管理和响应式更新
|
||||
|
||||
### 4. 代码结构优化
|
||||
- 主组件只负责组合和协调子组件
|
||||
- 子组件专注于自己的渲染逻辑
|
||||
- 组合式函数专注于业务逻辑处理
|
||||
|
||||
## 组合式函数说明
|
||||
|
||||
### useDashboardData
|
||||
- 管理大屏的核心数据
|
||||
- 处理数字动画效果
|
||||
- 提供数据初始化和更新方法
|
||||
|
||||
### useTimeManager
|
||||
- 管理时间显示和更新
|
||||
- 自动清理定时器,避免内存泄漏
|
||||
|
||||
### useRegionManager
|
||||
- 管理区域选择相关状态
|
||||
- 处理区域选择弹窗的显示/隐藏
|
||||
|
||||
### useTabManager
|
||||
- 管理标签页切换状态
|
||||
- 提供标签页变更处理方法
|
||||
|
||||
### useAlertManager
|
||||
- 管理告警数据的轮播显示
|
||||
- 自动获取和更新告警详情
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 1. 主组件
|
||||
```vue
|
||||
<template>
|
||||
<div class="dashboard-container">
|
||||
<DashboardHeader
|
||||
:current-time="currentTime"
|
||||
:selected-region="selectedRegion"
|
||||
@open-region-selector="openRegionSelector"
|
||||
/>
|
||||
<!-- 其他组件... -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDashboardData, useTimeManager } from './composables'
|
||||
|
||||
const { dashboardData, initDashboardData } = useDashboardData()
|
||||
const { currentTime, startTimeUpdate } = useTimeManager()
|
||||
|
||||
onMounted(async () => {
|
||||
startTimeUpdate()
|
||||
await initDashboardData()
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### 2. 子组件
|
||||
```vue
|
||||
<template>
|
||||
<div class="panel">
|
||||
<h3>{{ title }}</h3>
|
||||
<div class="content">
|
||||
<!-- 组件内容 -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
data?: any
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
```
|
||||
|
||||
### 3. 组合式函数
|
||||
```typescript
|
||||
export function useCustomHook() {
|
||||
const state = ref()
|
||||
|
||||
const method = () => {
|
||||
// 业务逻辑
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
method
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 响应式设计
|
||||
|
||||
所有组件都保持了原有的响应式设计,支持不同屏幕尺寸的适配:
|
||||
- 1200px 以下
|
||||
- 1024px 以下
|
||||
- 768px 以下
|
||||
- 480px 以下
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 确保所有依赖的图片资源路径正确
|
||||
2. 检查API接口的兼容性
|
||||
3. 测试所有功能模块的正常工作
|
||||
4. 验证响应式设计的正确性
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
1. 添加错误边界处理
|
||||
2. 实现组件的懒加载
|
||||
3. 添加单元测试
|
||||
4. 优化性能,减少不必要的重渲染
|
||||
5. 添加TypeScript类型检查的严格模式
|
||||
61
src/views/screen/axios/index.ts
Normal file
61
src/views/screen/axios/index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { service } from './service'
|
||||
|
||||
import { config } from '../../../config/axios/config'
|
||||
|
||||
import { decryptAES } from '@/components/LowDesign/src/utils/aes'
|
||||
import { useLocaleStore } from '@/store/modules/locale';
|
||||
const { default_headers } = config
|
||||
|
||||
const request = (option: any) => {
|
||||
const { url, method, params, data, headersType, responseType, headers, moerData, ...config } = option
|
||||
return service({
|
||||
url: url,
|
||||
method,
|
||||
params,
|
||||
data,
|
||||
...config,
|
||||
responseType: responseType,
|
||||
headers: {
|
||||
'Content-Type': headersType || default_headers,
|
||||
'Content-Lang': useLocaleStore().getCurrentLocale.lang,
|
||||
...(headers || {})
|
||||
},
|
||||
...(moerData || {}),
|
||||
})
|
||||
}
|
||||
export default {
|
||||
get: async <T = any>(option: any) => {
|
||||
let res = await request({ method: 'GET', ...option })
|
||||
if (typeof res == 'string') {
|
||||
try {
|
||||
res = JSON.parse(decryptAES(res))
|
||||
} catch (error) { }
|
||||
}
|
||||
return res.data as unknown as T
|
||||
},
|
||||
post: async <T = any>(option: any) => {
|
||||
const res = await request({ method: 'POST', ...option })
|
||||
return res.data as unknown as T
|
||||
},
|
||||
postOriginal: async (option: any) => {
|
||||
const res = await request({ method: 'POST', ...option })
|
||||
return res
|
||||
},
|
||||
delete: async <T = any>(option: any) => {
|
||||
const res = await request({ method: 'DELETE', ...option })
|
||||
return res.data as unknown as T
|
||||
},
|
||||
put: async <T = any>(option: any) => {
|
||||
const res = await request({ method: 'PUT', ...option })
|
||||
return res.data as unknown as T
|
||||
},
|
||||
download: async <T = any>(option: any) => {
|
||||
const res = await request({ method: 'GET', responseType: 'blob', ...option })
|
||||
return res as unknown as Promise<T>
|
||||
},
|
||||
upload: async <T = any>(option: any) => {
|
||||
option.headersType = 'multipart/form-data'
|
||||
const res = await request({ method: 'POST', ...option })
|
||||
return res as unknown as Promise<T>
|
||||
}
|
||||
}
|
||||
249
src/views/screen/axios/service.ts
Normal file
249
src/views/screen/axios/service.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import axios, {
|
||||
AxiosError,
|
||||
AxiosInstance,
|
||||
AxiosRequestHeaders,
|
||||
AxiosResponse,
|
||||
InternalAxiosRequestConfig
|
||||
} from 'axios'
|
||||
|
||||
import { ElMessage, ElMessageBox, ElNotification, ElButton } from 'element-plus'
|
||||
import qs from 'qs'
|
||||
import { config, specificApiTimeoutObj } from '@/config/axios/config'
|
||||
import { getAccessToken, getRefreshToken, getTenantId, removeToken, setToken } from '@/utils/auth'
|
||||
import errorCode from '../../../config/axios/errorCode'
|
||||
|
||||
import { resetRouter } from '@/router'
|
||||
import { useCache } from '@/hooks/web/useCache'
|
||||
|
||||
const tenantEnable = import.meta.env.VITE_APP_TENANT_ENABLE
|
||||
const { result_code, base_url, request_timeout } = config
|
||||
|
||||
// 需要忽略的提示。忽略后,自动 Promise.reject('error')
|
||||
const ignoreMsgs = [
|
||||
'无效的刷新令牌', // 刷新令牌被删除时,不用提示
|
||||
'刷新令牌已过期' // 使用刷新令牌,刷新获取新的访问令牌时,结果因为过期失败,此时需要忽略。否则,会导致继续 401,无法跳转到登出界面
|
||||
]
|
||||
// 是否显示重新登录
|
||||
export const isRelogin = { show: false }
|
||||
// Axios 无感知刷新令牌,参考 https://www.dashingdog.cn/article/11 与 https://segmentfault.com/a/1190000020210980 实现
|
||||
// 请求队列
|
||||
let requestList: any[] = []
|
||||
// 是否正在刷新中
|
||||
let isRefreshToken = false
|
||||
// 请求白名单,无须token的接口
|
||||
const whiteList: string[] = ['/login', '/refresh-token']
|
||||
|
||||
// 创建axios实例
|
||||
const service: AxiosInstance = axios.create({
|
||||
baseURL: base_url, // api 的 base_url
|
||||
timeout: request_timeout, // 请求超时时间
|
||||
withCredentials: false // 禁用 Cookie 等信息
|
||||
})
|
||||
|
||||
// request拦截器
|
||||
service.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// 是否需要设置 token
|
||||
let isToken = (config!.headers || {}).isToken === false
|
||||
whiteList.some((v) => {
|
||||
if (config.url && config.url.indexOf(v) > -1) {
|
||||
return (isToken = false)
|
||||
}
|
||||
})
|
||||
if (getAccessToken() && !isToken) {
|
||||
; (config as Recordable).headers.Authorization = 'Bearer ' + getAccessToken() // 让每个请求携带自定义token
|
||||
}
|
||||
// 设置租户
|
||||
if (tenantEnable && tenantEnable === 'true') {
|
||||
const tenantId = getTenantId()
|
||||
if (tenantId) (config as Recordable).headers['tenant-id'] = tenantId
|
||||
}
|
||||
//设置特定接口超时时间
|
||||
if (config.url && specificApiTimeoutObj[config.url]) {
|
||||
config.timeout = specificApiTimeoutObj[config.url]
|
||||
}
|
||||
const params = config.params || {}
|
||||
const data = config.data || false
|
||||
if (
|
||||
config.method?.toUpperCase() === 'POST' &&
|
||||
(config.headers as AxiosRequestHeaders)['Content-Type'] ===
|
||||
'application/x-www-form-urlencoded'
|
||||
) {
|
||||
config.data = qs.stringify(data)
|
||||
}
|
||||
// get参数编码
|
||||
if (config.method?.toUpperCase() === 'GET' && params) {
|
||||
config.params = {}
|
||||
const paramsStr = qs.stringify(params, { allowDots: true })
|
||||
if (paramsStr) {
|
||||
config.url = config.url + '?' + paramsStr
|
||||
}
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
// Do something with request error
|
||||
console.log(error) // for debug
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// response 拦截器
|
||||
service.interceptors.response.use(
|
||||
async (response: AxiosResponse<any>) => {
|
||||
let { data } = response
|
||||
const config = response.config
|
||||
if (!data) {
|
||||
// 返回“[HTTP]请求没有返回值”;
|
||||
// throw new Error()
|
||||
return
|
||||
}
|
||||
const { t } = useI18n()
|
||||
// 未设置状态码则默认成功状态
|
||||
// 二进制数据则直接返回,例如说 Excel 导出
|
||||
if (
|
||||
response.request.responseType === 'blob' ||
|
||||
response.request.responseType === 'arraybuffer'
|
||||
) {
|
||||
// 注意:如果导出的响应为 json,说明可能失败了,不直接返回进行下载
|
||||
if (response.data.type !== 'application/json') {
|
||||
return response.data
|
||||
}
|
||||
data = await new Response(response.data).json()
|
||||
}
|
||||
const code = data.code || result_code
|
||||
// 获取错误信息
|
||||
const msg = data.msg || errorCode[code] || errorCode['default']
|
||||
if (ignoreMsgs.indexOf(msg) !== -1) {
|
||||
// 如果是忽略的错误码,直接返回 msg 异常
|
||||
return Promise.reject(msg)
|
||||
} else if (code === 401) {
|
||||
// 如果未认证,并且未进行刷新令牌,说明可能是访问令牌过期了
|
||||
if (!isRefreshToken) {
|
||||
isRefreshToken = true
|
||||
// 1. 如果获取不到刷新令牌,则只能执行登出操作
|
||||
if (!getRefreshToken()) {
|
||||
return handleAuthorized()
|
||||
}
|
||||
// 2. 进行刷新访问令牌
|
||||
try {
|
||||
const refreshTokenRes = await refreshToken()
|
||||
// 2.1 刷新成功,则回放队列的请求 + 当前请求
|
||||
setToken((await refreshTokenRes).data.data)
|
||||
config.headers!.Authorization = 'Bearer ' + getAccessToken()
|
||||
requestList.forEach((cb: any) => {
|
||||
cb()
|
||||
})
|
||||
requestList = []
|
||||
return service(config)
|
||||
} catch (e) {
|
||||
// 为什么需要 catch 异常呢?刷新失败时,请求因为 Promise.reject 触发异常。
|
||||
// 2.2 刷新失败,只回放队列的请求
|
||||
requestList.forEach((cb: any) => {
|
||||
cb()
|
||||
})
|
||||
// 提示是否要登出。即不回放当前请求!不然会形成递归
|
||||
return handleAuthorized()
|
||||
} finally {
|
||||
requestList = []
|
||||
isRefreshToken = false
|
||||
}
|
||||
} else {
|
||||
// 添加到队列,等待刷新获取到新的令牌
|
||||
return new Promise((resolve) => {
|
||||
requestList.push(() => {
|
||||
config.headers!.Authorization = 'Bearer ' + getAccessToken() // 让每个请求携带自定义token 请根据实际情况自行修改
|
||||
resolve(service(config))
|
||||
})
|
||||
})
|
||||
}
|
||||
} else if (code === 500) {
|
||||
// ElMessage.error(t('sys.api.errMsg500'))
|
||||
return Promise.reject(new Error(msg))
|
||||
} else if (code === 901) {
|
||||
// ElMessage.error({
|
||||
// offset: 300,
|
||||
// dangerouslyUseHTMLString: true,
|
||||
// message:
|
||||
// '<div>' +
|
||||
// t('sys.api.errMsg901') +
|
||||
// '</div>'
|
||||
// })
|
||||
return Promise.reject(new Error(msg))
|
||||
} else if (code === 1600001003) {
|
||||
// ElNotification({
|
||||
// title: msg,
|
||||
// dangerouslyUseHTMLString: true,
|
||||
// message: h(ElButton, {
|
||||
// link: true,
|
||||
// type: 'primary',
|
||||
// onClick: () => handleMoreError(msg, data.data)
|
||||
// },
|
||||
// '点击查看具体原因'
|
||||
// )
|
||||
// })
|
||||
} else if (code !== 200) {
|
||||
if (msg === '无效的刷新令牌') {
|
||||
// hard coding:忽略这个提示,直接登出
|
||||
console.log(msg)
|
||||
} else {
|
||||
// ElNotification.error({ title: msg })
|
||||
}
|
||||
return Promise.reject('error')
|
||||
} else {
|
||||
return data
|
||||
}
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
console.log('err' + error) // for debug
|
||||
let { message } = error
|
||||
const { t } = useI18n()
|
||||
if (message === 'Network Error') {
|
||||
message = t('sys.api.errorMessage')
|
||||
} else if (message.includes('timeout')) {
|
||||
message = t('sys.api.apiTimeoutMessage')
|
||||
} else if (message.includes('Request failed with status code')) {
|
||||
message = t('sys.api.apiRequestFailed') + message.substr(message.length - 3)
|
||||
}
|
||||
// ElMessage.error(message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
const refreshToken = async () => {
|
||||
axios.defaults.headers.common['tenant-id'] = getTenantId()
|
||||
return await axios.post('/system/auth/refresh-token?refreshToken=' + getRefreshToken())
|
||||
}
|
||||
const handleAuthorized = () => {
|
||||
const { t } = useI18n()
|
||||
if (!isRelogin.show) {
|
||||
// 如果已经到重新登录页面则不进行弹窗提示
|
||||
if (window.location.href.includes('login?redirect=')) {
|
||||
return
|
||||
}
|
||||
isRelogin.show = true
|
||||
ElMessageBox.confirm(t('sys.api.timeoutMessage'), t('common.confirmTitle'), {
|
||||
showCancelButton: false,
|
||||
closeOnClickModal: false,
|
||||
showClose: false,
|
||||
confirmButtonText: t('login.relogin'),
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
const { wsCache } = useCache()
|
||||
resetRouter() // 重置静态路由表
|
||||
wsCache.clear()
|
||||
removeToken()
|
||||
isRelogin.show = false
|
||||
// 干掉token后再走一次路由让它过router.beforeEach的校验
|
||||
window.location.href = window.location.href
|
||||
})
|
||||
}
|
||||
return Promise.reject(t('sys.api.timeoutMessage'))
|
||||
}
|
||||
const handleMoreError = (title, message) => {
|
||||
ElMessageBox.alert(`<div style="white-space: break-spaces;">${message}</div>`, title, {
|
||||
dangerouslyUseHTMLString: true,
|
||||
customStyle: { maxWidth: '40%' }
|
||||
})
|
||||
}
|
||||
export { service }
|
||||
1872
src/views/screen/companyScreen.vue
Normal file
1872
src/views/screen/companyScreen.vue
Normal file
File diff suppressed because it is too large
Load Diff
288
src/views/screen/components/AlertList.vue
Normal file
288
src/views/screen/components/AlertList.vue
Normal file
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<div class="list-content">
|
||||
<div v-if="title" class="list-title">
|
||||
<span>{{ title }}</span>
|
||||
<img width="50%" src="@/assets/images/line_1.png" />
|
||||
</div>
|
||||
<div class="list" :style="{ maxHeight: maxHeight }">
|
||||
<!-- 表格头部 -->
|
||||
<div v-if="tableTitle && tableTitle.length > 0" class="table-header">
|
||||
<div class="header-item" v-for="(title, index) in tableTitle" :key="index">
|
||||
{{ title.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-wrapper" ref="listWrapperRef" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave">
|
||||
<!-- 表格模式 -->
|
||||
<template v-if="tableTitle && tableTitle.length > 0">
|
||||
<div class="table-row" v-for="(item, index) in listData" :key="`table-${index}`" @mouseenter="handleMouseEnter">
|
||||
<div class="table-cell" v-for="(title, cellIndex) in tableTitle" :key="`cell-${index}-${cellIndex}`">
|
||||
{{ item[title.key] || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 列表模式 -->
|
||||
<template v-else>
|
||||
<div class="list-item" v-for="(item, index) in listData" :key="`list-${index}`" @mouseenter="handleMouseEnter">
|
||||
<span class="alert-text" :class="[{ error: item.alarm_level_code == 'severity' }, { warn: item.alarm_level_code == 'major' }]">
|
||||
{{ (index + 1) }} {{ item.description }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
|
||||
interface AlertItem {
|
||||
description: string
|
||||
alarm_level_code: string
|
||||
alarm_status: string
|
||||
alarm_biz_id: string
|
||||
}
|
||||
|
||||
interface TableTitle {
|
||||
name: string
|
||||
key: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
listData: AlertItem[]
|
||||
maxHeight?: string
|
||||
autoScroll?: boolean
|
||||
scrollSpeed?: number
|
||||
scrollInterval?: number,
|
||||
tableTitle?: TableTitle[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '', // 标题
|
||||
maxHeight: '23vh', // 最大高度
|
||||
autoScroll: true, // 是否自动滚动
|
||||
scrollSpeed: 1, // 每次滚动的像素数
|
||||
scrollInterval: 3000 // 每次滚动的间隔时间,单位毫秒
|
||||
})
|
||||
|
||||
const listWrapperRef = ref<HTMLElement | null>(null)
|
||||
let scrollTimer: NodeJS.Timeout | null = null
|
||||
let isScrolling = false
|
||||
|
||||
// 自动滚动功能
|
||||
const startAutoScroll = () => {
|
||||
if (!props.autoScroll || !listWrapperRef.value) return
|
||||
|
||||
const wrapper = listWrapperRef.value
|
||||
const scrollHeight = wrapper.scrollHeight
|
||||
const clientHeight = wrapper.clientHeight
|
||||
|
||||
// 只有当内容高度超过容器高度时才启动滚动
|
||||
if (scrollHeight <= clientHeight) return
|
||||
|
||||
isScrolling = true
|
||||
let currentScrollTop = listWrapperRef.value.scrollTop
|
||||
|
||||
const scroll = () => {
|
||||
if (!isScrolling || !wrapper) return
|
||||
|
||||
currentScrollTop += props.scrollSpeed
|
||||
|
||||
// 如果滚动到底部,重置到顶部
|
||||
if (currentScrollTop >= scrollHeight - clientHeight) {
|
||||
currentScrollTop = 0
|
||||
wrapper.scrollTop = 0
|
||||
} else {
|
||||
wrapper.scrollTop = currentScrollTop
|
||||
}
|
||||
|
||||
scrollTimer = setTimeout(scroll, 50) // 每50ms滚动一次,实现平滑效果
|
||||
}
|
||||
|
||||
scroll()
|
||||
}
|
||||
|
||||
// 停止自动滚动
|
||||
const stopAutoScroll = () => {
|
||||
isScrolling = false
|
||||
scrollTimer && clearTimeout(scrollTimer)
|
||||
scrollTimer = null
|
||||
}
|
||||
|
||||
// 鼠标悬停时暂停滚动
|
||||
const handleMouseEnter = () => {
|
||||
stopAutoScroll()
|
||||
}
|
||||
|
||||
// 鼠标离开时恢复滚动
|
||||
const handleMouseLeave = () => {
|
||||
if (props.autoScroll) {
|
||||
// 延迟启动滚动,避免鼠标快速进出
|
||||
setTimeout(() => {
|
||||
startAutoScroll()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听数据变化,重新启动滚动
|
||||
watch(() => props.listData, () => {
|
||||
nextTick(() => {
|
||||
if (props.autoScroll) {
|
||||
stopAutoScroll()
|
||||
setTimeout(() => {
|
||||
startAutoScroll()
|
||||
}, 1000) // 数据更新后1秒开始滚动
|
||||
}
|
||||
})
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autoScroll) {
|
||||
// 组件挂载后延迟启动滚动
|
||||
setTimeout(() => {
|
||||
startAutoScroll()
|
||||
}, 2000)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoScroll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.list-content {
|
||||
display: flex;
|
||||
width: 68%;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
|
||||
.list-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
background: linear-gradient(135deg, #1e40af 0%, #3b82f6 100%);
|
||||
border-radius: 0.37vh 0.37vh 0 0;
|
||||
margin-bottom: 2px;
|
||||
|
||||
.header-item {
|
||||
flex: 1;
|
||||
padding: 0.5vh 0.4vw;
|
||||
font-size: 0.75rem;
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.2);
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-wrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin-top: 10px;
|
||||
overflow-y: auto;
|
||||
flex-direction: column;
|
||||
row-gap: 4px;
|
||||
scroll-behavior: smooth; // 平滑滚动效果
|
||||
overflow-x: hidden;
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: rgb(51 65 85 / 30%);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgb(59 130 246 / 60%);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: rgb(59 130 246 / 80%);
|
||||
}
|
||||
}
|
||||
|
||||
.table-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
background: rgb(51 65 85 / 30%);
|
||||
border: 1px solid #1e40af;
|
||||
border-radius: 0.37vh;
|
||||
margin-bottom: 4px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgb(51 65 85 / 50%);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.table-cell {
|
||||
flex: 1;
|
||||
padding: 0.5vh 0.4vw;
|
||||
font-size: 0.75rem;
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: inline-flex;
|
||||
padding: 0.5vh 0.4vw;
|
||||
font-size: 0.75rem;
|
||||
background: rgb(51 65 85 / 30%);
|
||||
border: 1px solid #1e40af;
|
||||
border-radius: 0.37vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease; // 添加过渡效果
|
||||
|
||||
&:hover {
|
||||
background: rgb(51 65 85 / 50%);
|
||||
transform: translateX(2px); // 悬停时轻微右移
|
||||
}
|
||||
|
||||
.alert-text.error {
|
||||
color: #f00;
|
||||
}
|
||||
|
||||
.alert-text.warn {
|
||||
color: #ff0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
241
src/views/screen/components/AlertListDemo.vue
Normal file
241
src/views/screen/components/AlertListDemo.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div class="demo-container">
|
||||
<h1>AlertList 组件演示</h1>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2>基础用法(启用自动滚动)</h2>
|
||||
<div class="demo-item">
|
||||
<AlertList
|
||||
title="告警详情"
|
||||
:list-data="alertData"
|
||||
max-height="200px"
|
||||
:auto-scroll="true"
|
||||
:scroll-speed="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2>快速滚动(速度:2px/次)</h2>
|
||||
<div class="demo-item">
|
||||
<AlertList
|
||||
title="快速滚动演示"
|
||||
:list-data="alertData"
|
||||
max-height="200px"
|
||||
:auto-scroll="true"
|
||||
:scroll-speed="2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2>禁用自动滚动</h2>
|
||||
<div class="demo-item">
|
||||
<AlertList
|
||||
title="手动滚动"
|
||||
:list-data="alertData"
|
||||
max-height="200px"
|
||||
:auto-scroll="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2>控制面板</h2>
|
||||
<div class="control-panel">
|
||||
<label>
|
||||
<input type="checkbox" v-model="enableAutoScroll" />
|
||||
启用自动滚动
|
||||
</label>
|
||||
<label>
|
||||
滚动速度:
|
||||
<input
|
||||
type="range"
|
||||
v-model="scrollSpeed"
|
||||
min="0.5"
|
||||
max="3"
|
||||
step="0.5"
|
||||
/>
|
||||
{{ scrollSpeed }}px/次
|
||||
</label>
|
||||
<button @click="addMoreData">添加更多数据</button>
|
||||
<button @click="clearData">清空数据</button>
|
||||
<span>当前数据条数:{{ alertData.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h2>动态数据演示</h2>
|
||||
<div class="demo-item">
|
||||
<AlertList
|
||||
title="动态数据"
|
||||
:list-data="alertData"
|
||||
max-height="200px"
|
||||
:auto-scroll="enableAutoScroll"
|
||||
:scroll-speed="scrollSpeed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import AlertList from './AlertList.vue'
|
||||
|
||||
const enableAutoScroll = ref(true)
|
||||
const scrollSpeed = ref(1)
|
||||
|
||||
// 模拟告警数据
|
||||
const alertData = ref([
|
||||
{
|
||||
text: '2024-07-10 上午8:00 雄安园区隐患内容管理 状态 处理',
|
||||
error: true
|
||||
},
|
||||
{
|
||||
text: '2025-07-09 上海XXX区域A1门禁告警 处理中 紧急',
|
||||
error: true
|
||||
},
|
||||
{
|
||||
text: '2025-07-09 上海XXX区域A1门禁告警 处理中 紧急',
|
||||
error: true
|
||||
},
|
||||
{
|
||||
text: '2020-06-18 编辑内容编辑内容编辑内容编辑内容编辑内容 状态 结果',
|
||||
warn: true
|
||||
},
|
||||
{
|
||||
text: '2020-06-18 编辑内容编辑内容编辑内容编辑内容编辑内容 状态 结果',
|
||||
warn: true
|
||||
},
|
||||
{
|
||||
text: '2020-06-18 编辑内容编辑内容编辑内容编辑内容编辑内容 状态 结果'
|
||||
},
|
||||
{
|
||||
text: '2020-06-18 编辑内容编辑内容编辑内容编辑内容编辑内容 状态 结果'
|
||||
},
|
||||
{
|
||||
text: '2020-06-18 编辑内容编辑内容编辑内容编辑内容编辑内容 状态 结果'
|
||||
},
|
||||
{
|
||||
text: '2020-06-18 编辑内容编辑内容编辑内容编辑内容编辑内容 状态 结果'
|
||||
},
|
||||
{
|
||||
text: '2024-07-11 上午9:00 北京园区设备故障 状态 待处理',
|
||||
error: true
|
||||
},
|
||||
{
|
||||
text: '2024-07-11 上午9:30 深圳园区网络异常 状态 处理中',
|
||||
warn: true
|
||||
},
|
||||
{
|
||||
text: '2024-07-11 上午10:00 杭州园区安全检查 状态 已完成'
|
||||
},
|
||||
{
|
||||
text: '2024-07-11 上午10:30 成都园区消防演练 状态 进行中'
|
||||
},
|
||||
{
|
||||
text: '2024-07-11 上午11:00 武汉园区应急预案 状态 待审核'
|
||||
},
|
||||
{
|
||||
text: '2024-07-11 上午11:30 西安园区安全培训 状态 已完成'
|
||||
}
|
||||
])
|
||||
|
||||
// 添加更多数据
|
||||
const addMoreData = () => {
|
||||
const newData = {
|
||||
text: `2024-07-11 ${Math.floor(Math.random() * 24)}:${Math.floor(Math.random() * 60)} 新增告警信息 ${Date.now()}`,
|
||||
error: Math.random() > 0.7,
|
||||
warn: Math.random() > 0.8
|
||||
}
|
||||
alertData.value.push(newData)
|
||||
}
|
||||
|
||||
// 清空数据
|
||||
const clearData = () => {
|
||||
alertData.value = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.demo-container {
|
||||
max-width: 1200px;
|
||||
padding: 20px;
|
||||
margin: 0 auto;
|
||||
font-family: Arial, sans-serif;
|
||||
|
||||
h1 {
|
||||
margin-bottom: 30px;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.demo-section {
|
||||
padding: 20px;
|
||||
margin-bottom: 40px;
|
||||
background: #f9f9f9;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
|
||||
h2 {
|
||||
margin-bottom: 15px;
|
||||
font-size: 18px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.demo-item {
|
||||
min-height: 250px;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
background: #007bff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
222
src/views/screen/components/AlertPanel.vue
Normal file
222
src/views/screen/components/AlertPanel.vue
Normal file
@@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<div class="alert-panel" style="text-align: right">
|
||||
<div class="panel-title">{{ title }}</div>
|
||||
<div>
|
||||
<img style="margin: 8px 0" src="@/assets/images/title_border_line_1.png" />
|
||||
</div>
|
||||
|
||||
<div class="tip-container">
|
||||
<div class="tip-image">
|
||||
<img src="@/assets/images/screen/circle_image.png" width="80" height="80" />
|
||||
<span class="number">{{ alertData?.total || 0 }}</span>
|
||||
</div>
|
||||
<img src="@/assets/images/screen/tip_bg_image.png" width="100%" height="70" />
|
||||
<div class="tip-content">
|
||||
<div class="col-item">
|
||||
<img src="@/assets/images/screen/warning_img.png" width="23" />
|
||||
<span>告警总数</span>
|
||||
<span
|
||||
style="font-size: 1.2rem; margin-left: 2vw; color: title === '高风险告警' ? 'yellow' : 'red'"
|
||||
>
|
||||
{{ alertData?.total || 0 }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="title === '高风险告警'" class="col-item">
|
||||
<span>已处理</span>
|
||||
<span style="font-size: 1.2rem; margin-left: 2vw; color: greenyellow;">
|
||||
{{ alertData?.processed || 0 }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="title === '高风险告警'" class="col-item" style="display: flex; margin-left: 2vw; align-items: center;">
|
||||
<span>待处理/处理中</span>
|
||||
<span style="font-size: 1.2rem; margin-left: 2vw; color: yellow;">
|
||||
{{ alertData?.pending || 0 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-content">
|
||||
<div class="list-title">
|
||||
<span>告警详情</span>
|
||||
<img width="50%" src="@/assets/images/line_1.png" />
|
||||
</div>
|
||||
<div class="list">
|
||||
<div class="list-wrapper">
|
||||
<div
|
||||
class="list-item"
|
||||
v-for="(item, index) in alertDetails"
|
||||
:key="index + item.text"
|
||||
>
|
||||
<span
|
||||
class="alert-text"
|
||||
:class="[{ error: item.error }, { warn: item.warn }]"
|
||||
>
|
||||
{{ (index + 1) }} {{ item.text }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AlertItem } from '../composables/useAlertManager'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
alertData?: {
|
||||
total: number
|
||||
processed?: number
|
||||
pending?: number
|
||||
}
|
||||
alertDetails: AlertItem[]
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.alert-panel {
|
||||
display: flex;
|
||||
background-image: url('@/assets/images/screen/right_top_img.png'), url('@/assets/images/screen/right_center_img.png'), url('@/assets/images/screen/right_bottom_img.png');
|
||||
background-position: top center, right center, bottom center;
|
||||
background-repeat: no-repeat, no-repeat, no-repeat;
|
||||
background-size: 100% 90px, cover, 100% 68px;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
|
||||
.panel-title {
|
||||
margin: 4px 20px 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tip-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 70%;
|
||||
height: 80px;
|
||||
padding-right: 20px;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
|
||||
.tip-image {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
transform: translateY(-50%) translateX(-50%);
|
||||
|
||||
.number {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
font-size: 1rem;
|
||||
color: #fff;
|
||||
transform: translate(-80%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.tip-content {
|
||||
position: absolute;
|
||||
inset: 0% 0 0 6%;
|
||||
display: flex;
|
||||
padding-left: 20px;
|
||||
align-items: center;
|
||||
|
||||
.col-item {
|
||||
display: flex;
|
||||
margin-left: 1vw;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-content {
|
||||
display: flex;
|
||||
width: 68%;
|
||||
height: calc(100% - 100px);
|
||||
margin-top: 20px;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
|
||||
.list-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-height: 22vh;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.list-wrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin-top: 10px;
|
||||
overflow: hidden scroll;
|
||||
flex-direction: column;
|
||||
row-gap: 4px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: inline-flex;
|
||||
padding: 0.5vh 0.4vw;
|
||||
font-size: 0.75rem;
|
||||
background: rgb(51 65 85 / 30%);
|
||||
border: 1px solid #1e40af;
|
||||
border-radius: 0.37vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.alert-text.error {
|
||||
color: #f00;
|
||||
}
|
||||
|
||||
.alert-text.warn {
|
||||
color: #ff0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.alert-panel {
|
||||
.tip-container {
|
||||
width: 80%;
|
||||
height: 70px;
|
||||
|
||||
.tip-image img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.tip-content .col-item {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
}
|
||||
|
||||
.list-content {
|
||||
width: 75%;
|
||||
|
||||
.list .list-item {
|
||||
padding: 0.4vh 0.3vw;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
156
src/views/screen/components/DashboardHeader.vue
Normal file
156
src/views/screen/components/DashboardHeader.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<div class="header-container">
|
||||
<div class="header-left">
|
||||
<div class="back-button" @click="$emit('openRegionSelector')">
|
||||
{{ selectedRegion || '总部' }}
|
||||
<span>···</span>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="header-title">总部安全管理大屏</h1>
|
||||
<div class="header-right">
|
||||
{{ currentTime }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
currentTime: string
|
||||
selectedRegion?: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'openRegionSelector'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (width <= 1024px) {
|
||||
.header-container {
|
||||
.header-title {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.header-left .back-button {
|
||||
min-width: 8vw;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.header-container {
|
||||
height: 70px;
|
||||
|
||||
.header-left {
|
||||
line-height: 70px;
|
||||
|
||||
.back-button {
|
||||
min-width: 12vw;
|
||||
padding: 3px 12px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 1rem;
|
||||
line-height: 35px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
margin-right: 15px;
|
||||
font-size: 0.7rem;
|
||||
line-height: 70px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 480px) {
|
||||
.header-container {
|
||||
height: 60px;
|
||||
|
||||
.header-left {
|
||||
line-height: 60px;
|
||||
|
||||
.back-button {
|
||||
min-width: 15vw;
|
||||
padding: 2px 10px;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 0.9rem;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
margin-right: 10px;
|
||||
font-size: 0.65rem;
|
||||
line-height: 60px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-container {
|
||||
display: flex;
|
||||
height: 80px;
|
||||
background: url('@/assets/images/top_bg.png') no-repeat;
|
||||
background-size: 100%;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
padding-left: 1vw;
|
||||
line-height: 80px;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
|
||||
.back-button {
|
||||
display: inline-flex;
|
||||
height: 2vh;
|
||||
min-width: 6vw;
|
||||
padding: 4px 16px;
|
||||
margin-left: 0.5vw;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
background: rgb(13 24 84 / 80%);
|
||||
border: 1px solid rgb(59 130 246 / 40%);
|
||||
transition: all 0.3s ease;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: rgb(59 130 246 / 30%);
|
||||
border-color: rgb(59 130 246 / 60%);
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
margin-right: 20px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 80px;
|
||||
text-align: right;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: bold;
|
||||
line-height: 40px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
106
src/views/screen/components/HeaderSection.vue
Normal file
106
src/views/screen/components/HeaderSection.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div class="header-container">
|
||||
<div class="header-left">
|
||||
<div class="back-button" @click="$emit('go-back')"> 总部 ··· </div>
|
||||
</div>
|
||||
<h1 class="header-title">总部安全管理大屏</h1>
|
||||
<div class="header-right">
|
||||
{{ currentTime }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
currentTime: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'go-back'): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.header-container {
|
||||
height: 80px;
|
||||
background: url('@/assets/images/top_bg.png') no-repeat;
|
||||
background-size: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
.header-left {
|
||||
flex: 1;
|
||||
margin-left: 20px;
|
||||
margin-top: 10px;
|
||||
|
||||
.back-button {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
border: 1px solid rgba(59, 130, 246, 0.4);
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.9rem;
|
||||
color: #ffffff;
|
||||
|
||||
.back-icon {
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(59, 130, 246, 0.3);
|
||||
border-color: rgba(59, 130, 246, 0.6);
|
||||
transform: translateX(-2px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
flex: 1;
|
||||
margin-right: 20px;
|
||||
text-align: right;
|
||||
font-size: 0.9rem;
|
||||
color: #ffffff;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
flex: 2;
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
text-shadow: 0 0 20px rgba(59, 130, 246, 0.5);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 768px) {
|
||||
.header-container {
|
||||
.header-title {
|
||||
font-size: 1.2rem;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
583
src/views/screen/components/HiddenDangerPanel.vue
Normal file
583
src/views/screen/components/HiddenDangerPanel.vue
Normal file
@@ -0,0 +1,583 @@
|
||||
<template>
|
||||
<div class="center-container">
|
||||
<div class="center-content">
|
||||
<span class="title">隐患排查治理</span>
|
||||
<img class="bottom-border-line" src="@/assets/images/title_border_line_1.png" />
|
||||
<span class="sub-title">分类风险</span>
|
||||
<img width="50%" src="@/assets/images/line_1.png" />
|
||||
|
||||
<div class="type-wrapper">
|
||||
<div class="type-item">
|
||||
<span class="type-btn">重大</span>
|
||||
<span class="type-num">{{ hiddenDangerData?.major || 0 }}</span>
|
||||
</div>
|
||||
<div class="type-item">
|
||||
<span class="type-btn active">一般</span>
|
||||
<span class="type-num">{{ hiddenDangerData?.general || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="clasic-wrapper">
|
||||
<div class="clasic-item">
|
||||
<span>处理进度</span>
|
||||
<img width="100%" src="@/assets/images/line_1.png" />
|
||||
</div>
|
||||
<div class="clasic-item">
|
||||
<span>Top3隐患类</span>
|
||||
<img width="100%" src="@/assets/images/line_1.png" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="echart-wrapper">
|
||||
<div class="lf-rt">
|
||||
<Echart :options="progressChartOption" class="progress-chart" height="80%" />
|
||||
<div class="progress-legend">
|
||||
<div class="legend-item"><span class="dot red"></span>已逾期</div>
|
||||
<div class="legend-item"><span class="dot green"></span>已处理</div>
|
||||
</div>
|
||||
<div class="progress-legend">
|
||||
<!-- <div class="legend-item"><span class="dot yellow"></span>待排查</div>-->
|
||||
<div class="legend-item"><span class="dot blue"></span>处理中</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lf-rt">
|
||||
<Echart :options="top3TypesChartOption" class="progress-chart" height="80%" />
|
||||
<div class="progress-legend-column">
|
||||
<div class="legend-item">
|
||||
<span class="dot blue"></span>
|
||||
<span class="legend-text">{{ props.hiddenDangerData?.top3Types?.[0]?.order_type_path_name || "--" }}</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="dot green"></span>
|
||||
<span class="legend-text">{{ props.hiddenDangerData?.top3Types?.[1]?.order_type_path_name || "--" }}</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="dot yellow"></span>
|
||||
<span class="legend-text">{{ props.hiddenDangerData?.top3Types?.[2]?.order_type_path_name || "--" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="safe-wrapper">
|
||||
<span class="safe-title">
|
||||
<img width="22" style="margin: 3px 5px 0 0" src="@/assets/images/ybp_icon.png" />
|
||||
安全指数:
|
||||
</span>
|
||||
<span class="pending-count">{{ hiddenDangerData?.safetyIndex || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
|
||||
|
||||
interface Props {
|
||||
hiddenDangerData?: {
|
||||
general: number
|
||||
major: number
|
||||
safetyIndex: number
|
||||
progress: {
|
||||
overdue: number
|
||||
processed: number
|
||||
pending: number
|
||||
processing: number
|
||||
}
|
||||
top3Types: Array<{
|
||||
num: string
|
||||
order_type_path_name: string
|
||||
row_id: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// 图表引用
|
||||
const progressChartOption = ref<any>({
|
||||
series: [
|
||||
{
|
||||
type: 'pie' as const,
|
||||
radius: '55%',
|
||||
center: ['50%', '50%'],
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
data: [],
|
||||
label: {
|
||||
alignTo: 'edge' as const,
|
||||
formatter: '{time|{c} %}\n',
|
||||
minMargin: 5,
|
||||
edgeDistance: 10,
|
||||
lineHeight: 15,
|
||||
rich: {
|
||||
time: {
|
||||
fontSize: 10,
|
||||
color: '#fff'
|
||||
}
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
length: 5,
|
||||
length2: 0,
|
||||
maxSurfaceAngle: 10
|
||||
},
|
||||
labelLayout: function (params: any) {
|
||||
const isLeft = params.labelRect.x < (params.labelRect.width ? params.labelRect.width : 200) / 2;
|
||||
const points = params.labelLinePoints;
|
||||
|
||||
// 添加安全检查
|
||||
if (points && points.length >= 3 && points[2]) {
|
||||
points[2][0] = isLeft
|
||||
? params.labelRect.x
|
||||
: params.labelRect.x + params.labelRect.width;
|
||||
}
|
||||
|
||||
return {
|
||||
labelLinePoints: points
|
||||
};
|
||||
},
|
||||
}
|
||||
]
|
||||
})
|
||||
const top3TypesChartOption = ref<any>({
|
||||
series: [
|
||||
{
|
||||
type: 'pie' as const,
|
||||
roseType: 'radius' as const,
|
||||
radius: [30, 50],
|
||||
center: ['50%', '50%'],
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
data: [],
|
||||
label: {
|
||||
alignTo: 'edge' as const,
|
||||
formatter: '{time|{c}}\n',
|
||||
minMargin: 5,
|
||||
edgeDistance: 10,
|
||||
lineHeight: 15,
|
||||
rich: {
|
||||
time: {
|
||||
fontSize: 10,
|
||||
color: '#fff'
|
||||
}
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
length: 5,
|
||||
length2: 0,
|
||||
maxSurfaceAngle: 10
|
||||
},
|
||||
labelLayout: function (params: any) {
|
||||
const isLeft = params.labelRect.x < (params.labelRect.width ? params.labelRect.width : 200) / 2;
|
||||
const points = params.labelLinePoints;
|
||||
|
||||
// 添加安全检查
|
||||
if (points && points.length >= 3 && points[2]) {
|
||||
points[2][0] = isLeft
|
||||
? params.labelRect.x
|
||||
: params.labelRect.x + params.labelRect.width;
|
||||
}
|
||||
|
||||
return {
|
||||
labelLinePoints: points
|
||||
};
|
||||
},
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
watch(() => props.hiddenDangerData?.progress, (newVal) => {
|
||||
refreshProcessCharts(newVal)
|
||||
}, { deep: true })
|
||||
|
||||
// 更新图表数据
|
||||
const refreshProcessCharts = (process): void => {
|
||||
if (!props.hiddenDangerData?.progress) {
|
||||
console.warn('process is undefined or null')
|
||||
return
|
||||
}
|
||||
const option = { ...progressChartOption.value }
|
||||
option.series[0].data = [
|
||||
{ value: process.overdue || 0, name: '已逾期', itemStyle: { color: '#ef4444' } },
|
||||
{ value: process.processed || 0, name: '已处理', itemStyle: { color: '#10b981' } },
|
||||
// { value: process.pending || 0, name: '待排查', itemStyle: { color: '#eab308' } },
|
||||
{ value: process.processing || 0, name: '处理中', itemStyle: { color: '#3b82f6' } }
|
||||
]
|
||||
progressChartOption.value = option
|
||||
}
|
||||
|
||||
|
||||
watch(() => props.hiddenDangerData?.top3Types, (newVal) => {
|
||||
refreshTop3TypesCharts(newVal)
|
||||
}, { deep: true })
|
||||
|
||||
// 更新图表数据
|
||||
const refreshTop3TypesCharts = (top3Types): void => {
|
||||
if (!top3Types || !Array.isArray(top3Types) || top3Types.length === 0) {
|
||||
console.warn('top3Types is undefined, null, or empty array')
|
||||
return
|
||||
}
|
||||
const option = { ...top3TypesChartOption.value }
|
||||
|
||||
// 定义颜色数组
|
||||
const colors = ['#5470c6', '#9edf7f', '#fac858']
|
||||
|
||||
// 将数组数据转换为图表数据格式
|
||||
option.series[0].data = top3Types.slice(0, 3).map((item, index) => ({
|
||||
value: Number(item.num) || 0,
|
||||
name: item.order_type_path_name || `类型${index + 1}`,
|
||||
itemStyle: { color: colors[index] || '#999' }
|
||||
}))
|
||||
|
||||
top3TypesChartOption.value = option
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 响应式设计 */
|
||||
@media (width <=1200px) {
|
||||
.center-container {
|
||||
width: 60vh;
|
||||
height: 60vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <=1024px) {
|
||||
.center-container {
|
||||
width: 55vh;
|
||||
height: 55vh;
|
||||
|
||||
.center-content {
|
||||
.title {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.type-wrapper {
|
||||
width: 85%;
|
||||
|
||||
.type-item .type-btn {
|
||||
padding: 2px 25px;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <=768px) {
|
||||
.center-container {
|
||||
top: 60%;
|
||||
width: 50vh;
|
||||
height: 50vh;
|
||||
|
||||
.center-content {
|
||||
.title {
|
||||
margin-top: 1.5vh;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.type-wrapper {
|
||||
width: 90%;
|
||||
margin-top: 0.3vh;
|
||||
|
||||
.type-item {
|
||||
.type-btn {
|
||||
padding: 2px 20px;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.type-num {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.clasic-wrapper {
|
||||
width: 90%;
|
||||
margin-top: 1vh;
|
||||
|
||||
.clasic-item {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.echart-wrapper {
|
||||
width: 90%;
|
||||
|
||||
.lf-rt .progress-legend .legend-item {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.safe-wrapper {
|
||||
width: 50%;
|
||||
|
||||
.safe-title {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.pending-count {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <=480px) {
|
||||
.center-container {
|
||||
top: 65%;
|
||||
width: 45vh;
|
||||
height: 45vh;
|
||||
|
||||
.center-content {
|
||||
.title {
|
||||
margin-top: 1vh;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.type-wrapper {
|
||||
width: 95%;
|
||||
|
||||
.type-item .type-btn {
|
||||
padding: 1px 15px;
|
||||
font-size: 0.55rem;
|
||||
}
|
||||
}
|
||||
|
||||
.echart-wrapper {
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
.safe-wrapper {
|
||||
width: 60%;
|
||||
|
||||
.safe-title {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.pending-count {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.center-container {
|
||||
position: fixed;
|
||||
top: 55%;
|
||||
left: 50%;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
width: 65vh;
|
||||
height: 65vh;
|
||||
color: #fff;
|
||||
background-image: url('@/assets/images/circle_bg.png');
|
||||
background-size: cover;
|
||||
transform: translate(-50%, -50%);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.center-content {
|
||||
display: flex;
|
||||
width: 77%;
|
||||
height: 77%;
|
||||
overflow: hidden;
|
||||
border-radius: 50%;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
|
||||
.title {
|
||||
margin-top: 2vh;
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.bottom-border-line {
|
||||
width: 20%;
|
||||
margin: 1vh 0 1.2vh;
|
||||
}
|
||||
|
||||
.sub-title {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.type-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
width: 80%;
|
||||
font-size: 0.8rem;
|
||||
color: #fff;
|
||||
|
||||
.type-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: 0.5vh;
|
||||
|
||||
.type-btn {
|
||||
padding: 2px 30px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 0.7rem;
|
||||
background-color: #d97706;
|
||||
border-radius: 15px;
|
||||
|
||||
&.active {
|
||||
background-color: #059669;
|
||||
}
|
||||
}
|
||||
|
||||
.type-num {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.clasic-wrapper {
|
||||
display: flex;
|
||||
width: 80%;
|
||||
margin-top: 1.2vh;
|
||||
font-size: 0.8rem;
|
||||
color: #fff;
|
||||
justify-content: space-around;
|
||||
|
||||
.clasic-item {
|
||||
display: flex;
|
||||
width: 45%;
|
||||
margin-top: 0.6vh;
|
||||
margin-bottom: -1vh;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.echart-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 85%;
|
||||
height: 75%;
|
||||
|
||||
.lf-rt {
|
||||
display: flex;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
|
||||
.progress-chart {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 80%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.progress-legend {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-legend-column {
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
width: 60%;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.legend-text {
|
||||
overflow: hidden;
|
||||
font-size: 0.7rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.safe-wrapper {
|
||||
display: flex;
|
||||
width: 40%;
|
||||
height: 20%;
|
||||
margin-bottom: 5%;
|
||||
align-items: center;
|
||||
column-gap: 1vw;
|
||||
|
||||
.safe-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 1vw;
|
||||
}
|
||||
|
||||
.pending-count {
|
||||
margin-bottom: 0.7vh;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 500;
|
||||
color: yellow;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot.red {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
|
||||
.dot.green {
|
||||
background-color: #9edf7f;
|
||||
}
|
||||
|
||||
.dot.yellow {
|
||||
background-color: #eab308;
|
||||
}
|
||||
|
||||
.dot.blue {
|
||||
background-color: #3b82f6;
|
||||
}
|
||||
</style>
|
||||
268
src/views/screen/components/HighRiskAlertPanel.vue
Normal file
268
src/views/screen/components/HighRiskAlertPanel.vue
Normal file
@@ -0,0 +1,268 @@
|
||||
<template>
|
||||
<div class="right-top" style="text-align: right">
|
||||
<div class="panel-title">高风险告警</div>
|
||||
<div>
|
||||
<img style="margin: 8px 0" src="@/assets/images/title_border_line_1.png" />
|
||||
</div>
|
||||
<div class="tip-container">
|
||||
<div class="tip-image">
|
||||
<img src="@/assets/images/screen/circle_image.png" width="80" height="80" />
|
||||
<span class="number">{{ alertData?.total || 0 }}</span>
|
||||
</div>
|
||||
<img src="@/assets/images/screen/tip_bg_image.png" width="100%" height="70" />
|
||||
<div class="tip-content">
|
||||
<div class="col-item">
|
||||
<img src="@/assets/images/screen/warning_img.png" width="23" />
|
||||
<span>告警总数</span>
|
||||
<span style="font-size: 1.2rem; marker-start: 2vw; color: yellow;">{{ alertData?.total || 0 }}</span>
|
||||
</div>
|
||||
<div class="col-item">
|
||||
<span>已处理</span>
|
||||
<span style="font-size: 1.2rem; marker-start: 2vw; color: greenyellow;">{{ alertData?.processed || 0 }}</span>
|
||||
</div>
|
||||
<div class="col-item" style="display: flex; margin-left: 1vw; align-items: center;">
|
||||
<span>待处理</span>
|
||||
<span style="font-size: 1.2rem; marker-start: 2vw; color: yellow;">{{ alertData?.pending || 0 }}</span>
|
||||
</div>
|
||||
<div class="col-item" style="display: flex; margin-left: 1vw; align-items: center;">
|
||||
<span>处理中</span>
|
||||
<span style="font-size: 1.2rem; marker-start: 2vw; color: yellow;">{{ alertData?.processing }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="list-content">
|
||||
<div class="list-title">
|
||||
<span>告警详情</span>
|
||||
<img width="50%" src="@/assets/images/line_1.png" />
|
||||
</div>
|
||||
<div class="list">
|
||||
<div class="list-wrapper">
|
||||
<div class="list-item" v-for="(item, index) in alertDetails" :key="index">
|
||||
<span class="alert-text" :class="[{ error: item.status == 2 }, { warn: item.status == 1 }]">
|
||||
{{ (index + 1) }} {{ item.describe }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
<AlertList style="margin-right: 1vw;" title="告警详情" :list-data="alertDetails" ></AlertList>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AlertList from './AlertList.vue'
|
||||
|
||||
// 类型定义
|
||||
interface AlertItem {
|
||||
description: string
|
||||
alarm_level_code: string
|
||||
alarm_status: string
|
||||
alarm_biz_id: string
|
||||
}
|
||||
|
||||
interface AlertData {
|
||||
total: number
|
||||
processed: number
|
||||
pending: number
|
||||
processing: number
|
||||
}
|
||||
|
||||
// Props定义
|
||||
interface Props {
|
||||
alertData?: AlertData
|
||||
alertDetails?: AlertItem[]
|
||||
sourceIndex?: number
|
||||
}
|
||||
|
||||
// 默认值
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
alertData: () => ({
|
||||
total: 0,
|
||||
processed: 0,
|
||||
pending: 0,
|
||||
processing: 0
|
||||
}),
|
||||
alertDetails: () => [],
|
||||
sourceIndex: 1
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 响应式设计 */
|
||||
@media (width <=768px) {
|
||||
.right-top {
|
||||
.tip-container {
|
||||
width: 80%;
|
||||
height: 70px;
|
||||
|
||||
.tip-image img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.tip-content .col-item {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
}
|
||||
|
||||
.list-content {
|
||||
width: 75%;
|
||||
|
||||
.list .list-item {
|
||||
padding: 0.4vh 0.3vw;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <=480px) {
|
||||
.right-top {
|
||||
.tip-container {
|
||||
width: 85%;
|
||||
height: 60px;
|
||||
|
||||
.tip-image img {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.tip-content .col-item {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.list-content {
|
||||
width: 80%;
|
||||
|
||||
.list .list-item {
|
||||
padding: 0.3vh 0.2vw;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right-top {
|
||||
display: flex;
|
||||
background-image:
|
||||
url('@/assets/images/screen/right_top_img.png'),
|
||||
url('@/assets/images/screen/right_center_img.png'),
|
||||
url('@/assets/images/screen/right_bottom_img.png');
|
||||
background-position:
|
||||
top center,
|
||||
right center,
|
||||
bottom center;
|
||||
background-repeat: no-repeat, no-repeat, no-repeat;
|
||||
background-size:
|
||||
100% 90px,
|
||||
cover,
|
||||
100% 68px;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
|
||||
.panel-title {
|
||||
margin: 3px 15px 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tip-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 70%;
|
||||
height: 80px;
|
||||
padding-right: 20px;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
|
||||
.tip-image {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
transform: translateY(-50%) translateX(-50%);
|
||||
|
||||
.number {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
font-size: 1rem;
|
||||
color: #fff;
|
||||
transform: translate(-80%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.tip-content {
|
||||
position: absolute;
|
||||
inset: 0% 0 0 6%;
|
||||
display: flex;
|
||||
padding-left: 20px;
|
||||
align-items: center;
|
||||
|
||||
.col-item {
|
||||
display: flex;
|
||||
margin-left: 1vw;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-content {
|
||||
display: flex;
|
||||
width: 68%;
|
||||
height: calc(100% - 100px);
|
||||
margin-top: 20px;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
|
||||
.list-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-height: 22vh;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.list-wrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin-top: 10px;
|
||||
overflow: hidden scroll;
|
||||
flex-direction: column;
|
||||
row-gap: 4px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: inline-flex;
|
||||
padding: 0.5vh 0.4vw;
|
||||
font-size: 0.75rem;
|
||||
background: rgb(51 65 85 / 30%);
|
||||
border: 1px solid #1e40af;
|
||||
border-radius: 0.37vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.alert-text.error {
|
||||
color: #f00;
|
||||
}
|
||||
|
||||
.alert-text.warn {
|
||||
color: #ff0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
343
src/views/screen/components/LeftPanel.vue
Normal file
343
src/views/screen/components/LeftPanel.vue
Normal file
@@ -0,0 +1,343 @@
|
||||
<template>
|
||||
<div class="left-wrapper">
|
||||
<div class="left-top panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">人员统计</div>
|
||||
<img class="title-border" src="@/assets/images/title_border_line_1.png" alt="border" />
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div class="chart-container" ref="barChartRef"></div>
|
||||
<div class="stats-summary">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ totalStaff }}</div>
|
||||
<div class="stat-label">总人数</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ formalStaff }}</div>
|
||||
<div class="stat-label">正式员工</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ contractStaff }}</div>
|
||||
<div class="stat-label">外协人员</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="left-bottom panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">告警处理</div>
|
||||
<img class="title-border" src="@/assets/images/title_border_line_1.png" alt="border" />
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div class="chart-container" ref="donutChartRef"></div>
|
||||
<div class="alert-list">
|
||||
<div
|
||||
v-for="alert in currentAlerts"
|
||||
:key="alert.id"
|
||||
class="alert-item"
|
||||
:class="{
|
||||
'error': alert.priority === '紧急',
|
||||
'warning': alert.priority === '一般'
|
||||
}"
|
||||
>
|
||||
<div class="alert-time">{{ alert.timestamp }}</div>
|
||||
<div class="alert-content">{{ alert.content }}</div>
|
||||
<div class="alert-status">{{ alert.status }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { rgbToHex } from '@/utils/color'
|
||||
|
||||
interface AlertItem {
|
||||
id: number
|
||||
timestamp: string
|
||||
content: string
|
||||
status: string
|
||||
priority: string
|
||||
}
|
||||
|
||||
// 响应式数据
|
||||
const barChartRef = ref<HTMLElement>()
|
||||
const donutChartRef = ref<HTMLElement>()
|
||||
const totalStaff = ref(2156)
|
||||
const formalStaff = ref(1897)
|
||||
const contractStaff = ref(259)
|
||||
|
||||
// 图表实例
|
||||
let barChart: echarts.ECharts | null = null
|
||||
let donutChart: echarts.ECharts | null = null
|
||||
|
||||
// 告警数据
|
||||
const currentAlerts = ref<AlertItem[]>([
|
||||
{ id: 1, timestamp: '08:30', content: '雄安园区门禁异常', status: '处理中', priority: '紧急' },
|
||||
{ id: 2, timestamp: '08:15', content: '北京丰台区域监控离线', status: '待处理', priority: '一般' },
|
||||
{ id: 3, timestamp: '08:00', content: '上海园区网络故障', status: '已处理', priority: '一般' }
|
||||
])
|
||||
|
||||
// 初始化柱状图
|
||||
const initBarChart = () => {
|
||||
if (!barChartRef.value) return
|
||||
|
||||
barChart = echarts.init(barChartRef.value)
|
||||
|
||||
const option = {
|
||||
grid: {
|
||||
left: '5%',
|
||||
right: '5%',
|
||||
top: '15%',
|
||||
bottom: '20%'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['雄安', '北京丰台', '北京恒毅', '上海', '重庆', '成都'],
|
||||
axisLabel: {
|
||||
color: '#ffffff',
|
||||
fontSize: 10
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: { color: '#334155' }
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
color: '#ffffff',
|
||||
fontSize: 10
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: { color: '#334155' }
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: { color: '#334155' }
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '正式员工',
|
||||
type: 'bar',
|
||||
data: [320, 302, 301, 334, 390, 330],
|
||||
itemStyle: { color: rgbToHex(99, 196, 251) },
|
||||
barWidth: '15%'
|
||||
},
|
||||
{
|
||||
name: '外协人员',
|
||||
type: 'bar',
|
||||
data: [120, 132, 101, 134, 90, 230],
|
||||
itemStyle: { color: rgbToHex(251, 246, 85) },
|
||||
barWidth: '15%'
|
||||
},
|
||||
{
|
||||
name: '访客',
|
||||
type: 'bar',
|
||||
data: [220, 182, 191, 234, 290, 330],
|
||||
itemStyle: { color: rgbToHex(200, 69, 237) },
|
||||
barWidth: '15%'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
barChart.setOption(option)
|
||||
}
|
||||
|
||||
// 初始化环形图
|
||||
const initDonutChart = () => {
|
||||
if (!donutChartRef.value) return
|
||||
|
||||
donutChart = echarts.init(donutChartRef.value)
|
||||
|
||||
const option = {
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['25%', '55%'],
|
||||
center: ['50%', '50%'],
|
||||
data: [
|
||||
{ value: 12, name: '已逾期', itemStyle: { color: '#ef4444' } },
|
||||
{ value: 234, name: '已处理', itemStyle: { color: '#10b981' } },
|
||||
{ value: 34, name: '待排查', itemStyle: { color: '#eab308' } },
|
||||
{ value: 134, name: '处理中', itemStyle: { color: '#3b82f6' } }
|
||||
],
|
||||
label: {
|
||||
show: true,
|
||||
position: 'outside',
|
||||
formatter: '{b}: {c}',
|
||||
fontSize: 10,
|
||||
color: '#ffffff'
|
||||
},
|
||||
labelLine: {
|
||||
show: true,
|
||||
length: 10,
|
||||
length2: 5,
|
||||
lineStyle: {
|
||||
color: '#64748b',
|
||||
width: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
donutChart.setOption(option)
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
initBarChart()
|
||||
initDonutChart()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (barChart) barChart.dispose()
|
||||
if (donutChart) donutChart.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.left-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
.panel {
|
||||
flex: 1;
|
||||
background: url('@/assets/images/left_panel_bg.png') no-repeat;
|
||||
background-size: contain;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
.panel-header {
|
||||
padding: 15px 20px 10px;
|
||||
|
||||
.panel-title {
|
||||
font-size: 1.2rem;
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.title-border {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
padding: 0 20px 20px;
|
||||
height: calc(100% - 80px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.chart-container {
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.stats-summary {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
gap: 10px;
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
|
||||
.stat-number {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: #3b82f6;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.alert-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
.alert-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
background: rgba(51, 65, 85, 0.3);
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid #64748b;
|
||||
|
||||
&.error {
|
||||
border-left-color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
&.warning {
|
||||
border-left-color: #eab308;
|
||||
background: rgba(234, 179, 8, 0.1);
|
||||
}
|
||||
|
||||
.alert-time {
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.alert-content {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.alert-status {
|
||||
font-size: 0.8rem;
|
||||
color: #3b82f6;
|
||||
padding: 2px 8px;
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.left-top {
|
||||
background: url('@/assets/images/left_panel_bg_2.png') no-repeat !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 1200px) {
|
||||
.left-wrapper {
|
||||
.panel {
|
||||
.panel-header {
|
||||
.panel-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
.stats-summary {
|
||||
.stat-item {
|
||||
.stat-number {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
294
src/views/screen/components/OverviewPanel.vue
Normal file
294
src/views/screen/components/OverviewPanel.vue
Normal file
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<div class="left-top">
|
||||
<div class="panel-title">人员管理</div>
|
||||
<img style="margin: 8px 0" src="@/assets/images/title_border_line_1.png" />
|
||||
|
||||
<div class="top-card">
|
||||
<div class="top-card-left">
|
||||
<div>
|
||||
<img width="33px" src="@/assets/images/1_224520_821.png" />
|
||||
</div>
|
||||
<span>总计</span>
|
||||
<div class="number-wrapper">
|
||||
<span class="total-number" v-for="(digit, index) in totalCountDigits" :key="index">
|
||||
{{ digit }}
|
||||
</span>
|
||||
</div>
|
||||
<span>人</span>
|
||||
</div>
|
||||
|
||||
<div class="top-card-right">
|
||||
<div class="top-card-right-item">
|
||||
<img width="18px" src="@/assets/images/v2_rel0n8.png" />
|
||||
<span>正式员工</span>
|
||||
<div class="type-number-wrapper" style="margin-left: 2vw">
|
||||
<span class="type-number" v-for="(digit, index) in formalEmployeeDigits" :key="index">
|
||||
{{ digit }}
|
||||
</span>
|
||||
</div>
|
||||
<span>人</span>
|
||||
</div>
|
||||
|
||||
<div class="top-card-right-item">
|
||||
<img width="18px" src="@/assets/images/v2_rel0n23.png" />
|
||||
<span>外协人员</span>
|
||||
<div class="type-number-wrapper" style="margin-left: 1vw">
|
||||
<span class="type-number" v-for="(digit, index) in externalStaffDigits" :key="index">
|
||||
{{ digit }}
|
||||
</span>
|
||||
</div>
|
||||
<span>人</span>
|
||||
</div>
|
||||
|
||||
<div class="top-card-right-item">
|
||||
<img width="18px" src="@/assets/images/24508_654.png" />
|
||||
<span>访客</span>
|
||||
<div class="type-number-wrapper">
|
||||
<span class="type-number" v-for="(digit, index) in visitorDigits" :key="index">
|
||||
{{ digit }}
|
||||
</span>
|
||||
</div>
|
||||
<span>人</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom-card-overview">
|
||||
<div class="bottom-card-title">
|
||||
<span>各园区统计</span>
|
||||
<img width="50%" style="margin: 8px 0" src="@/assets/images/line_1.png" />
|
||||
</div>
|
||||
<Echart :options="barChartOption" class="bar-chart" height="17.5vh" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { rgbToHex } from '@/utils/color'
|
||||
|
||||
interface Props {
|
||||
totalCount: number
|
||||
formalEmployeeCount: number
|
||||
externalStaffCount: number
|
||||
visitorCount: number
|
||||
parkStatistics?: Array<{
|
||||
name: string
|
||||
formal: number
|
||||
external: number
|
||||
visitor: number
|
||||
}>
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const totalCountDigits = computed(() => String(props.totalCount).split('').map(Number))
|
||||
const formalEmployeeDigits = computed(() => String(props.formalEmployeeCount).split('').map(Number))
|
||||
const externalStaffDigits = computed(() => String(props.externalStaffCount).split('').map(Number))
|
||||
const visitorDigits = computed(() => String(props.visitorCount).split('').map(Number))
|
||||
|
||||
// 图表引用
|
||||
const barChartOption = ref({
|
||||
legend: {
|
||||
top: '10%',
|
||||
right: '15%',
|
||||
orient: 'vertical' as const,
|
||||
textStyle: {
|
||||
color: '#ffffff',
|
||||
fontSize: '11px'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '5%',
|
||||
right: '30%',
|
||||
top: '10%',
|
||||
bottom: '15%'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category' as const,
|
||||
data: [],
|
||||
axisLabel: {
|
||||
color: '#ffffff',
|
||||
fontSize: 10
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: { color: '#334155' }
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value' as const,
|
||||
axisLabel: {
|
||||
color: '#ffffff',
|
||||
fontSize: 10
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: { color: '#334155' }
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: { color: '#334155' }
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '正式员工',
|
||||
type: 'bar' as const,
|
||||
data: [],
|
||||
itemStyle: { color: rgbToHex(99, 196, 251) },
|
||||
barWidth: '15%'
|
||||
},
|
||||
{
|
||||
name: '外协人员',
|
||||
type: 'bar' as const,
|
||||
data: [],
|
||||
itemStyle: { color: rgbToHex(251, 246, 85) },
|
||||
barWidth: '15%'
|
||||
},
|
||||
{
|
||||
name: '访客',
|
||||
type: 'bar' as const,
|
||||
data: [],
|
||||
itemStyle: { color: rgbToHex(200, 69, 237) },
|
||||
barWidth: '15%'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// 监听数据变化,更新图表
|
||||
watch(() => props.parkStatistics, (newVal) => {
|
||||
console.log('parkStatistics changed:', { newVal })
|
||||
refreshCharts(newVal)
|
||||
}, { deep: true })
|
||||
|
||||
// 更新图表数据
|
||||
const refreshCharts = (parkStatistics): void => {
|
||||
const option = { ...barChartOption.value }
|
||||
option.xAxis.data = parkStatistics.map(park => park.name)
|
||||
option.series[0].data = parkStatistics.map(park => park.formal)
|
||||
option.series[1].data = parkStatistics.map(park => park.external)
|
||||
option.series[2].data = parkStatistics.map(park => park.visitor)
|
||||
barChartOption.value = option
|
||||
}
|
||||
|
||||
// 组件挂载后初始化图表
|
||||
onMounted(() => {
|
||||
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.left-top {
|
||||
padding: 0 5px;
|
||||
background-image: url('@/assets/images/screen/left_top_img.png'), url('@/assets/images/screen/left_center_img.png'), url('@/assets/images/screen/left_bottom_img.png');
|
||||
background-position: top center, left center, bottom center;
|
||||
background-repeat: no-repeat, no-repeat, no-repeat;
|
||||
background-size: 100% 90px, cover, 100% 68px;
|
||||
flex: 1;
|
||||
|
||||
.panel-title {
|
||||
margin: 4px 20px 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.top-card {
|
||||
display: flex;
|
||||
padding: 0 20px;
|
||||
column-gap: 15px;
|
||||
font-size: 0.8rem;
|
||||
|
||||
.top-card-left {
|
||||
display: flex;
|
||||
height: 12vh;
|
||||
min-width: 15vw;
|
||||
padding: 0 10px;
|
||||
background-image: url('@/assets/imgs/total_count_card_bg.png');
|
||||
background-size: cover;
|
||||
column-gap: 6px;
|
||||
align-items: center;
|
||||
|
||||
.number-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 0.8rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.total-number {
|
||||
display: inline-block;
|
||||
width: 26px;
|
||||
height: 50px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 50px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
background-color: rgb(177 74 201 / 100%);
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.top-card-right {
|
||||
display: flex;
|
||||
height: 12vh;
|
||||
min-width: 20vw;
|
||||
background-image: url('@/assets/imgs/staff_types_bg.png');
|
||||
background-position: top center;
|
||||
background-size: cover;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
row-gap: 4px;
|
||||
|
||||
.top-card-right-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 5px;
|
||||
padding: 0 10px;
|
||||
font-size: 0.7rem;
|
||||
color: #fff;
|
||||
|
||||
.type-number-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 0.8rem;
|
||||
color: #fff;
|
||||
|
||||
.type-number {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 25px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 25px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
background-color: #1afb8f;
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-card-overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.bottom-card-title {
|
||||
display: flex;
|
||||
margin-top: 5px;
|
||||
margin-left: -15%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bar-chart {
|
||||
flex: 1;
|
||||
width: 80%;
|
||||
min-height: 17.5vh;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
226
src/views/screen/components/ParkCenter.vue
Normal file
226
src/views/screen/components/ParkCenter.vue
Normal file
@@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<div
|
||||
class="center-content"
|
||||
:style="backgroundStyle"
|
||||
ref="centerContentRef"
|
||||
>
|
||||
<span class="park-name">{{ parkName }}</span>
|
||||
<ParkPoint class="park-point-a" point-label="A" @point-hover="handlePointHover" @point-leave="handlePointLeave" />
|
||||
<ParkPoint class="park-point-b" point-label="B" @point-hover="handlePointHover" @point-leave="handlePointLeave" />
|
||||
<ParkPoint class="park-point-c" point-label="C" @point-hover="handlePointHover" @point-leave="handlePointLeave" />
|
||||
<ParkPoint class="park-point-d" point-label="D" @point-hover="handlePointHover" @point-leave="handlePointLeave" />
|
||||
<ParkPoint class="park-point-e" point-label="E" @point-hover="handlePointHover" @point-leave="handlePointLeave" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import ParkPoint from './ParkPoint.vue'
|
||||
import defaultBgImage from '@/assets/images/screen/park/park_xiong_an_img.png'
|
||||
|
||||
interface Props {
|
||||
/** 园区名称,默认为 '雄安园区' */
|
||||
parkName?: string
|
||||
backgroundImage?: string
|
||||
}
|
||||
|
||||
interface PointPosition {
|
||||
label: string
|
||||
x: number
|
||||
y: number
|
||||
relativeX: number
|
||||
relativeY: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'pointHover', position: PointPosition): void
|
||||
(e: 'pointLeave', String): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
parkName: '雄安园区',
|
||||
backgroundImage: defaultBgImage
|
||||
})
|
||||
|
||||
const centerContentRef = ref<HTMLElement>()
|
||||
const showDebug = ref(true) // 调试模式开关
|
||||
const imageLoadStatus = ref('未加载')
|
||||
|
||||
const handlePointLeave = (label: string) => {
|
||||
emit('pointLeave', label)
|
||||
}
|
||||
|
||||
// 测试图片加载
|
||||
const testImageLoad = (url: string) => {
|
||||
if (!url || !url.startsWith('http')) {
|
||||
imageLoadStatus.value = '本地图片或无效URL'
|
||||
return
|
||||
}
|
||||
|
||||
imageLoadStatus.value = '加载中...'
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
imageLoadStatus.value = '加载成功'
|
||||
console.log('图片加载成功:', url)
|
||||
}
|
||||
img.onerror = () => {
|
||||
imageLoadStatus.value = '加载失败'
|
||||
console.error('图片加载失败:', url)
|
||||
}
|
||||
img.src = url
|
||||
}
|
||||
|
||||
// 监听backgroundImage变化
|
||||
watch(() => props.backgroundImage, (newUrl) => {
|
||||
if (newUrl) {
|
||||
testImageLoad(newUrl)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 计算背景样式
|
||||
const backgroundStyle = computed(() => {
|
||||
console.log('backgroundStyle computed, props.backgroundImage:', props.backgroundImage)
|
||||
|
||||
// 检查是否是有效的URL
|
||||
if (!props.backgroundImage) {
|
||||
return {
|
||||
background: `url('${defaultBgImage}') no-repeat`,
|
||||
backgroundSize: 'cover'
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是网络图片,添加错误处理
|
||||
if (props.backgroundImage.startsWith('http')) {
|
||||
return {
|
||||
background: `url('${props.backgroundImage}') no-repeat`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center'
|
||||
}
|
||||
}
|
||||
|
||||
// 本地图片
|
||||
return {
|
||||
background: `url('${props.backgroundImage}') no-repeat`,
|
||||
backgroundSize: 'cover'
|
||||
}
|
||||
})
|
||||
|
||||
const handlePointHover = (label: string) => {
|
||||
// console.log('ParkCenter: 收到点位点击事件,标签:', label)
|
||||
|
||||
if (!centerContentRef.value) {
|
||||
console.log('ParkCenter: centerContentRef 未找到')
|
||||
return
|
||||
}
|
||||
|
||||
// 获取center-content元素的位置信息
|
||||
const centerRect = centerContentRef.value.getBoundingClientRect()
|
||||
// console.log('ParkCenter: center-content 位置信息:', centerRect)
|
||||
|
||||
// 根据点位标签获取对应的DOM元素
|
||||
const pointElement = centerContentRef.value.querySelector(`.park-point-${label.toLowerCase()}`) as HTMLElement
|
||||
if (!pointElement) {
|
||||
console.log('ParkCenter: 点位元素未找到:', `.park-point-${label.toLowerCase()}`)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取点位元素的位置信息
|
||||
const pointRect = pointElement.getBoundingClientRect()
|
||||
// console.log('ParkCenter: 点位元素位置信息:', pointRect)
|
||||
|
||||
// 计算点位相对于center-content的位置
|
||||
const relativeX = pointRect.left - centerRect.left + pointRect.width / 2
|
||||
const relativeY = pointRect.top - centerRect.top + pointRect.height / 2
|
||||
|
||||
// 计算点位相对于center-content中心的位置(百分比)
|
||||
const centerX = centerRect.width / 2
|
||||
const centerY = centerRect.height / 2
|
||||
const percentX = ((relativeX - centerX) / centerX * 100).toFixed(2)
|
||||
const percentY = ((relativeY - centerY) / centerY * 100).toFixed(2)
|
||||
|
||||
const position: PointPosition = {
|
||||
label,
|
||||
x: Math.round(relativeX),
|
||||
y: Math.round(relativeY),
|
||||
relativeX: parseFloat(percentX),
|
||||
relativeY: parseFloat(percentY)
|
||||
}
|
||||
|
||||
// console.log(`ParkCenter: 点位 ${label} 的位置信息:`, position)
|
||||
|
||||
// 触发事件,将位置信息传递给父组件
|
||||
// console.log('ParkCenter: 准备发射 pointClick 事件')
|
||||
emit('pointHover', position)
|
||||
// console.log('ParkCenter: pointClick 事件已发射')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.center-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 77%;
|
||||
height: 77%;
|
||||
overflow: hidden;
|
||||
border-radius: 50%;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
|
||||
.park-name {
|
||||
margin-top: 2vh;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 点位标注样式 */
|
||||
.park-point-a {
|
||||
position: absolute;
|
||||
bottom: 27%;
|
||||
left: 40%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.park-point-b {
|
||||
position: absolute;
|
||||
bottom: 40%;
|
||||
left: 12%;
|
||||
}
|
||||
|
||||
.park-point-c {
|
||||
position: absolute;
|
||||
top: 20%;
|
||||
right: 15%;
|
||||
}
|
||||
|
||||
.park-point-d {
|
||||
position: absolute;
|
||||
top: 20%;
|
||||
left: 55%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.park-point-e {
|
||||
position: absolute;
|
||||
top: 25%;
|
||||
left: 20%;
|
||||
}
|
||||
|
||||
/* 调试信息样式 */
|
||||
.debug-info {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 1000;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
background: rgb(0 0 0 / 70%);
|
||||
border-radius: 5px;
|
||||
|
||||
p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
75
src/views/screen/components/ParkPoint.vue
Normal file
75
src/views/screen/components/ParkPoint.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="point-container" @click="handleClick" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave">
|
||||
<img src="@/assets/images/screen/park/point_img.png" />
|
||||
<div class="point-text">
|
||||
<span>{{ pointLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
/** 点位标签,默认为 'A' */
|
||||
pointLabel?: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'pointClick', label: string): void
|
||||
(e: 'pointHover', label: string): void
|
||||
(e: 'pointLeave', label: string): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
pointLabel: 'A'
|
||||
})
|
||||
|
||||
const handleClick = () => {
|
||||
emit('pointClick', props.pointLabel!)
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
// console.log('hover', props.pointLabel);
|
||||
emit('pointHover', props.pointLabel!)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
// console.log('leave', props.pointLabel);
|
||||
emit('pointLeave', props.pointLabel)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.point-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 31px;
|
||||
cursor: pointer;
|
||||
|
||||
.point-text {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
text-shadow: 0 0 2px rgb(188 188 193);
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
155
src/views/screen/components/ParkSelector.vue
Normal file
155
src/views/screen/components/ParkSelector.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div v-if="modelValue" class="region-mask" @click.self="close">
|
||||
<div class="region-dialog">
|
||||
<div class="region-header">
|
||||
<span>选择园区</span>
|
||||
<span class="close" @click="close">×</span>
|
||||
</div>
|
||||
<div class="region-body">
|
||||
<div v-for="item in parks" :key="item" class="region-item" :class="{ active: item === modelSelected }"
|
||||
@click="select(item)">
|
||||
{{ item }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ParkSelector',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
modelSelected: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
selectedRegion: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'update:modelSelected', 'change'],
|
||||
computed: {
|
||||
parks () {
|
||||
// 根据选择的区域返回对应的园区列表
|
||||
const parkMap = {
|
||||
'北京': ['北京丰台园区', '北京恒毅园区', '北京朝阳园区', '北京海淀园区', '北京通州园区', '北京顺义园区', '北京昌平园区', '北京大兴园区'],
|
||||
'上海': ['上海浦东园区', '上海松江园区', '上海嘉定园区', '上海青浦园区', '上海奉贤园区', '上海金山园区', '上海崇明园区', '上海闵行园区'],
|
||||
'雄安': ['雄安新区园区', '雄安容城园区', '雄安安新园区', '雄安雄县园区', '雄安新区起步区', '雄安新区核心区'],
|
||||
'南京': ['南京江宁园区', '南京浦口园区', '南京六合园区', '南京溧水园区', '南京高淳园区', '南京江北新区'],
|
||||
'江苏': ['苏州工业园区', '无锡新区', '常州高新区', '南通开发区', '扬州高新区', '镇江新区', '泰州医药城', '徐州高新区'],
|
||||
'西安': ['西安高新区', '西安经开区', '西安曲江新区', '西安浐灞生态区', '西安国际港务区', '西安航空基地', '西安航天基地', '西安软件园'],
|
||||
'成都': ['成都高新区', '成都天府新区', '成都经开区', '成都双流区', '成都温江区', '成都新都区', '成都郫都区', '成都青白江区'],
|
||||
'重庆': ['重庆两江新区', '重庆高新区', '重庆经开区', '重庆渝北区', '重庆江北区', '重庆南岸区', '重庆沙坪坝区', '重庆九龙坡区'],
|
||||
'广州': ['广州开发区', '广州高新区', '广州南沙新区', '广州黄埔区', '广州番禺区', '广州白云区', '广州花都区', '广州增城区'],
|
||||
'深圳': ['深圳南山园区', '深圳福田园区', '深圳宝安园区', '深圳龙岗区', '深圳龙华区', '深圳坪山区', '深圳光明区', '深圳大鹏新区'],
|
||||
'武汉': ['武汉光谷', '武汉经开区', '武汉临空港', '武汉东湖高新区', '武汉东西湖区', '武汉江夏区', '武汉蔡甸区', '武汉黄陂区'],
|
||||
'杭州': ['杭州高新区', '杭州经开区', '杭州钱塘新区', '杭州余杭区', '杭州萧山区', '杭州富阳区', '杭州临安区', '杭州桐庐县'],
|
||||
'苏州': ['苏州工业园区', '苏州高新区', '苏州相城园区', '苏州吴中区', '苏州吴江区', '苏州常熟市', '苏州张家港市', '苏州昆山市'],
|
||||
'天津': ['天津滨海新区', '天津高新区', '天津经开区', '天津东丽区', '天津西青区', '天津津南区', '天津北辰区', '天津武清区'],
|
||||
'郑州': ['郑州高新区', '郑州经开区', '郑州航空港', '郑州金水区', '郑州二七区', '郑州管城区', '郑州惠济区', '郑州中原区'],
|
||||
'合肥': ['合肥高新区', '合肥经开区', '合肥新站区', '合肥蜀山区', '合肥包河区', '合肥瑶海区', '合肥庐阳区', '合肥肥西县'],
|
||||
'青岛': ['青岛高新区', '青岛经开区', '青岛西海岸新区', '青岛市南区', '青岛市北区', '青岛李沧区', '青岛崂山区', '青岛城阳区'],
|
||||
'厦门': ['厦门软件园', '厦门火炬高新区', '厦门自贸区', '厦门思明区', '厦门湖里区', '厦门集美区', '厦门海沧区', '厦门同安区']
|
||||
}
|
||||
return parkMap[this.selectedRegion] || ['暂无园区数据']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
close () {
|
||||
this.$emit('update:modelValue', false)
|
||||
},
|
||||
select (name) {
|
||||
this.$emit('update:modelSelected', name)
|
||||
this.$emit('change', name)
|
||||
this.$emit('update:modelValue', false)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.region-mask {
|
||||
position: fixed;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
background: rgb(0 0 0 / 35%);
|
||||
inset: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.region-dialog {
|
||||
width: 800px;
|
||||
color: #fff;
|
||||
background: #0d2253;
|
||||
border: 1px solid rgb(59 130 246 / 35%);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 10px 30px rgb(0 0 0 / 35%);
|
||||
}
|
||||
|
||||
.region-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 48px;
|
||||
font-weight: 600;
|
||||
background: #0f2a65;
|
||||
border-bottom: 1px solid rgb(59 130 246 / 25%);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.region-header .close {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 16px;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
opacity: 0.9;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.region-body {
|
||||
display: grid;
|
||||
max-height: 400px;
|
||||
padding: 20px 24px 28px;
|
||||
overflow-y: auto;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 12px 12px;
|
||||
}
|
||||
|
||||
.region-item {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
min-width: 0;
|
||||
padding: 0 8px;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
background: rgb(30 58 138 / 35%);
|
||||
border: 1px solid rgb(59 130 246 / 35%);
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.region-item:hover {
|
||||
background: rgb(30 58 138 / 50%);
|
||||
border-color: rgb(59 130 246 / 60%);
|
||||
}
|
||||
|
||||
.region-item.active {
|
||||
font-weight: 600;
|
||||
color: #0a0a0a;
|
||||
background: #38bdf8;
|
||||
border-color: #38bdf8;
|
||||
}
|
||||
</style>
|
||||
161
src/views/screen/components/PointInfoPopup.vue
Normal file
161
src/views/screen/components/PointInfoPopup.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div v-if="visible" class="point-popup" :style="popupStyle">
|
||||
<div class="popup-header">
|
||||
<span class="popup-title">楼栋 {{ pointInfo?.label }} 信息</span>
|
||||
<button class="popup-close" @click="handleClose">×</button>
|
||||
</div>
|
||||
<div class="popup-content">
|
||||
<div class="info-item">
|
||||
<span class="info-label">楼栋:</span>
|
||||
<span class="info-value">{{ pointInfo?.label }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">告警总数:</span>
|
||||
<span class="info-value">123</span>
|
||||
<!-- <span class="info-value">X: {{ pointInfo?.x }}px, Y: {{ pointInfo?.y }}px</span> -->
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">高风险数量:</span>
|
||||
<span class="info-value">13</span>
|
||||
<!-- <span class="info-value">X: {{ pointInfo?.relativeX }}%, Y: {{ pointInfo?.relativeY }}%</span> -->
|
||||
</div>
|
||||
<!-- <div class="info-item">
|
||||
<span class="info-label">园区名称:</span>
|
||||
<span class="info-value">{{ parkName || '雄安园区' }}</span>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface PointPosition {
|
||||
label: string
|
||||
x: number
|
||||
y: number
|
||||
relativeX: number
|
||||
relativeY: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** 弹窗是否显示 */
|
||||
visible: boolean
|
||||
/** 点位信息 */
|
||||
pointInfo: PointPosition | null
|
||||
/** 园区名称 */
|
||||
parkName?: string
|
||||
/** 弹窗位置偏移量 */
|
||||
offsetX?: number
|
||||
/** 弹窗位置偏移量 */
|
||||
offsetY?: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
parkName: '雄安园区',
|
||||
offsetX: 80,
|
||||
offsetY: -150
|
||||
})
|
||||
|
||||
// 弹窗样式计算
|
||||
const popupStyle = computed(() => {
|
||||
if (!props.pointInfo) return {}
|
||||
|
||||
// 计算弹窗在屏幕上的绝对位置
|
||||
const centerContainer = document.querySelector('.center-container')
|
||||
if (!centerContainer) return {}
|
||||
|
||||
const centerRect = centerContainer.getBoundingClientRect()
|
||||
const popupX = centerRect.left + props.pointInfo.x + props.offsetX
|
||||
const popupY = centerRect.top + props.pointInfo.y + props.offsetY
|
||||
|
||||
return {
|
||||
left: `${popupX}px`,
|
||||
top: `${popupY}px`
|
||||
}
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.point-popup {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
width: 280px;
|
||||
color: white;
|
||||
background: #0D1960;
|
||||
border: 1px solid #3F86C1;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 8px 32px rgb(190 190 190 / 30%);
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgb(255 255 255 / 10%);
|
||||
|
||||
.popup-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.popup-close {
|
||||
display: flex;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
font-size: 20px;
|
||||
color: rgb(255 255 255 / 60%);
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
background: rgb(255 255 255 / 10%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
padding: 16px;
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 14px;
|
||||
color: rgb(255 255 255 / 70%);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
119
src/views/screen/components/README.md
Normal file
119
src/views/screen/components/README.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# 大屏组件库
|
||||
|
||||
本目录包含大屏相关的可复用组件。
|
||||
|
||||
## 组件列表
|
||||
|
||||
### HighRiskAlertPanel.vue
|
||||
高风险告警面板组件,用于显示高风险告警信息和详情列表。
|
||||
|
||||
**Props:**
|
||||
- `alertData`: 告警数据对象
|
||||
- `total`: 告警总数
|
||||
- `processed`: 已处理数量
|
||||
- `pending`: 待处理/处理中数量
|
||||
- `alertDetails`: 告警详情列表数组
|
||||
- `sourceIndex`: 源索引值,默认为1
|
||||
|
||||
**使用示例:**
|
||||
```vue
|
||||
<HighRiskAlertPanel
|
||||
:alertData="dashboardData?.alertData"
|
||||
:alertDetails="sourceAcitve"
|
||||
:sourceIndex="sourceIndex"
|
||||
/>
|
||||
```
|
||||
|
||||
**特性:**
|
||||
- 响应式设计,支持不同屏幕尺寸
|
||||
- 包含告警统计信息和详情列表
|
||||
- 支持错误和警告状态的样式区分
|
||||
- 背景图片和样式完全独立
|
||||
|
||||
### TimeoutWorkOrderPanel.vue
|
||||
超时工单面板组件,用于显示超时工单信息和详情列表。
|
||||
|
||||
**Props:**
|
||||
- `timeoutWorkOrders`: 超时工单数据对象
|
||||
- `total`: 超时工单总数
|
||||
- `alertDetails`: 告警详情列表数组
|
||||
- `sourceIndex`: 源索引值,默认为1
|
||||
|
||||
**使用示例:**
|
||||
```vue
|
||||
<TimeoutWorkOrderPanel
|
||||
:timeoutWorkOrders="dashboardData?.timeoutWorkOrders"
|
||||
:alertDetails="sourceAcitve"
|
||||
:sourceIndex="sourceIndex"
|
||||
/>
|
||||
```
|
||||
|
||||
**特性:**
|
||||
- 响应式设计,支持不同屏幕尺寸
|
||||
- 包含超时工单统计信息和详情列表
|
||||
- 支持错误和警告状态的样式区分
|
||||
- 背景图片和样式完全独立
|
||||
|
||||
### OverviewPanel.vue
|
||||
总体概览面板组件,显示人员统计信息。
|
||||
|
||||
### RiskStatisticsPanel.vue
|
||||
风险统计面板组件,显示风险相关的统计数据。
|
||||
|
||||
### WeatherWarning.vue
|
||||
天气预警组件,显示天气相关信息。
|
||||
|
||||
### RegionSelector.vue
|
||||
区域选择器组件,用于选择不同的区域。
|
||||
|
||||
### AlertPanel.vue
|
||||
告警面板组件。
|
||||
|
||||
### AlertList.vue
|
||||
告警列表组件。
|
||||
|
||||
### HiddenDangerPanel.vue
|
||||
隐患面板组件。
|
||||
|
||||
### DashboardHeader.vue
|
||||
仪表板头部组件。
|
||||
|
||||
### ScreenFrame.vue
|
||||
大屏框架组件。
|
||||
|
||||
### ParkCenter.vue
|
||||
园区中心组件。
|
||||
|
||||
### ParkPoint.vue
|
||||
园区点位组件。
|
||||
|
||||
### PointInfoPopup.vue
|
||||
点位信息弹窗组件。
|
||||
|
||||
### ParkSelector.vue
|
||||
园区选择器组件。
|
||||
|
||||
### RightPanel.vue
|
||||
右侧面板组件。
|
||||
|
||||
### LeftPanel.vue
|
||||
左侧面板组件。
|
||||
|
||||
### HeaderSection.vue
|
||||
头部区域组件。
|
||||
|
||||
## 使用说明
|
||||
|
||||
1. 所有组件都支持响应式设计
|
||||
2. 组件样式使用 scoped 作用域,避免样式冲突
|
||||
3. 组件间通过 props 传递数据
|
||||
4. 支持 TypeScript 类型定义
|
||||
5. 遵循 Vue 3 Composition API 规范
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 确保图片资源路径正确
|
||||
- 组件依赖特定的数据结构,请按照接口定义传递数据
|
||||
- 样式使用 SCSS 预处理器
|
||||
- 响应式断点:768px, 480px
|
||||
|
||||
148
src/views/screen/components/RegionSelector.vue
Normal file
148
src/views/screen/components/RegionSelector.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div v-if="modelValue" class="region-mask" @click.self="close">
|
||||
<div class="region-dialog">
|
||||
<div class="region-header">
|
||||
<span>选择区域</span>
|
||||
<span class="close" @click="close">×</span>
|
||||
</div>
|
||||
<div class="region-body">
|
||||
<div
|
||||
v-for="item in regions" :key="item.code" class="region-item" :class="{ active: item.name === modelSelected }"
|
||||
@click="select(item)">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'RegionSelector',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
modelSelected: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
regions: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{ name: '北京', code: '2345' },
|
||||
{ name: '上海', code: '2346' },
|
||||
{ name: '雄安', code: '2347' },
|
||||
{ name: '南京', code: '2348' },
|
||||
{ name: '江苏', code: '2349' },
|
||||
{ name: '西安', code: '2350' },
|
||||
{ name: '成都', code: '2351' },
|
||||
{ name: '重庆', code: '2352' },
|
||||
{ name: '广州', code: '2353' },
|
||||
{ name: '深圳', code: '2354' },
|
||||
{ name: '武汉', code: '2355' },
|
||||
{ name: '杭州', code: '2356' },
|
||||
{ name: '苏州', code: '2357' },
|
||||
{ name: '天津', code: '2358' },
|
||||
{ name: '郑州', code: '2359' },
|
||||
{ name: '合肥', code: '2360' },
|
||||
{ name: '青岛', code: '2361' },
|
||||
{ name: '厦门', code: '2362' }
|
||||
]
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'update:modelSelected', 'change'],
|
||||
methods: {
|
||||
close () {
|
||||
this.$emit('update:modelValue', false)
|
||||
},
|
||||
select (item) {
|
||||
this.$emit('update:modelSelected', item.name)
|
||||
this.$emit('change', item)
|
||||
this.$emit('update:modelValue', false)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.region-mask {
|
||||
position: fixed;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
background: rgb(0 0 0 / 35%);
|
||||
inset: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.region-dialog {
|
||||
width: 700px;
|
||||
color: #fff;
|
||||
background: #0d2253;
|
||||
border: 1px solid rgb(59 130 246 / 35%);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 10px 30px rgb(0 0 0 / 35%);
|
||||
}
|
||||
|
||||
.region-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: 48px;
|
||||
font-weight: 600;
|
||||
background: #0f2a65;
|
||||
border-bottom: 1px solid rgb(59 130 246 / 25%);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.region-header .close {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 16px;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
opacity: 0.9;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.region-body {
|
||||
display: grid;
|
||||
padding: 20px 24px 28px;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 14px 14px;
|
||||
}
|
||||
|
||||
|
||||
.region-item {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
min-width: 0;
|
||||
padding: 0 8px;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
background: rgb(30 58 138 / 35%);
|
||||
border: 1px solid rgb(59 130 246 / 35%);
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.region-item:hover {
|
||||
background: rgb(30 58 138 / 50%);
|
||||
border-color: rgb(59 130 246 / 60%);
|
||||
}
|
||||
|
||||
.region-item.active {
|
||||
font-weight: 600;
|
||||
color: #0a0a0a;
|
||||
background: #38bdf8;
|
||||
border-color: #38bdf8;
|
||||
}
|
||||
</style>
|
||||
360
src/views/screen/components/RightPanel.vue
Normal file
360
src/views/screen/components/RightPanel.vue
Normal file
@@ -0,0 +1,360 @@
|
||||
<template>
|
||||
<div class="right-wrapper">
|
||||
<div class="right-top panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">设备状态</div>
|
||||
<img class="title-border" src="@/assets/images/title_border_line_1.png" alt="border" />
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div class="chart-container" ref="bottomBarChartRef"></div>
|
||||
<div class="device-status">
|
||||
<div class="status-item online">
|
||||
<div class="status-dot"></div>
|
||||
<span>在线设备: {{ onlineDevices }}</span>
|
||||
</div>
|
||||
<div class="status-item offline">
|
||||
<div class="status-dot"></div>
|
||||
<span>离线设备: {{ offlineDevices }}</span>
|
||||
</div>
|
||||
<div class="status-item warning">
|
||||
<div class="status-dot"></div>
|
||||
<span>异常设备: {{ warningDevices }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="right-bottom panel">
|
||||
<div class="panel-header">
|
||||
<div class="panel-title">区域分布</div>
|
||||
<img class="title-border" src="@/assets/images/title_border_line_1.png" alt="border" />
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div class="chart-container" ref="bottomPieChartRef"></div>
|
||||
<div class="region-list">
|
||||
<div
|
||||
v-for="region in regionData"
|
||||
:key="region.name"
|
||||
class="region-item"
|
||||
>
|
||||
<div class="region-name">{{ region.name }}</div>
|
||||
<div class="region-count">{{ region.count }}</div>
|
||||
<div class="region-percentage">{{ region.percentage }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
// 响应式数据
|
||||
const bottomBarChartRef = ref<HTMLElement>()
|
||||
const bottomPieChartRef = ref<HTMLElement>()
|
||||
const onlineDevices = ref(1247)
|
||||
const offlineDevices = ref(23)
|
||||
const warningDevices = ref(15)
|
||||
|
||||
// 图表实例
|
||||
let bottomBarChart: echarts.ECharts | null = null
|
||||
let bottomPieChart: echarts.ECharts | null = null
|
||||
|
||||
// 区域数据
|
||||
const regionData = ref([
|
||||
{ name: '雄安园区', count: 456, percentage: 35 },
|
||||
{ name: '北京丰台', count: 389, percentage: 30 },
|
||||
{ name: '上海园区', count: 234, percentage: 18 },
|
||||
{ name: '重庆园区', count: 156, percentage: 12 },
|
||||
{ name: '成都园区', count: 78, percentage: 5 }
|
||||
])
|
||||
|
||||
// 初始化底部柱状图
|
||||
const initBottomBarChart = () => {
|
||||
if (!bottomBarChartRef.value) return
|
||||
|
||||
bottomBarChart = echarts.init(bottomBarChartRef.value)
|
||||
|
||||
const option = {
|
||||
grid: {
|
||||
left: '5%',
|
||||
right: '5%',
|
||||
top: '15%',
|
||||
bottom: '15%'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['雄安', '北京丰台', '北京恒毅', '上海', '重庆', '成都'],
|
||||
axisLabel: {
|
||||
color: '#94a3b8',
|
||||
fontSize: 10
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: { color: '#334155' }
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: {
|
||||
color: '#94a3b8',
|
||||
fontSize: 10
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: { color: '#334155' }
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: { color: '#334155' }
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '监控设备',
|
||||
type: 'bar',
|
||||
data: [230, 200, 210, 220, 190, 240],
|
||||
itemStyle: { color: '#3b82f6' },
|
||||
barWidth: '20%'
|
||||
},
|
||||
{
|
||||
name: '门禁设备',
|
||||
type: 'bar',
|
||||
data: [200, 180, 190, 210, 170, 220],
|
||||
itemStyle: { color: '#eab308' },
|
||||
barWidth: '20%'
|
||||
},
|
||||
{
|
||||
name: '消防设备',
|
||||
type: 'bar',
|
||||
data: [220, 210, 200, 230, 180, 250],
|
||||
itemStyle: { color: '#8b5cf6' },
|
||||
barWidth: '20%'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
bottomBarChart.setOption(option)
|
||||
}
|
||||
|
||||
// 初始化底部饼图
|
||||
const initBottomPieChart = () => {
|
||||
if (!bottomPieChartRef.value) return
|
||||
|
||||
bottomPieChart = echarts.init(bottomPieChartRef.value)
|
||||
|
||||
const option = {
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
center: ['50%', '50%'],
|
||||
data: [
|
||||
{ value: 456, name: '雄安园区', itemStyle: { color: '#3b82f6' } },
|
||||
{ value: 389, name: '北京丰台', itemStyle: { color: '#eab308' } },
|
||||
{ value: 234, name: '上海园区', itemStyle: { color: '#8b5cf6' } },
|
||||
{ value: 156, name: '重庆园区', itemStyle: { color: '#10b981' } },
|
||||
{ value: 78, name: '成都园区', itemStyle: { color: '#f59e0b' } }
|
||||
],
|
||||
label: {
|
||||
show: true,
|
||||
position: 'outside',
|
||||
formatter: '{b}\n{c}',
|
||||
fontSize: 10,
|
||||
color: '#ffffff',
|
||||
fontWeight: 'bold'
|
||||
},
|
||||
labelLine: {
|
||||
show: true,
|
||||
length: 10,
|
||||
length2: 5,
|
||||
lineStyle: {
|
||||
color: '#64748b',
|
||||
width: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
bottomPieChart.setOption(option)
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
initBottomBarChart()
|
||||
initBottomPieChart()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (bottomBarChart) bottomBarChart.dispose()
|
||||
if (bottomPieChart) bottomPieChart.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.right-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
.panel {
|
||||
flex: 1;
|
||||
background: url('@/assets/images/right-bg2.png') no-repeat;
|
||||
background-size: contain;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
.panel-header {
|
||||
padding: 15px 20px 10px;
|
||||
text-align: right;
|
||||
|
||||
.panel-title {
|
||||
font-size: 1.2rem;
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.title-border {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
padding: 0 20px 20px;
|
||||
height: calc(100% - 80px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.chart-container {
|
||||
flex: 1;
|
||||
min-height: 200px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.device-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&.online {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #10b981;
|
||||
|
||||
.status-dot {
|
||||
background: #10b981;
|
||||
}
|
||||
}
|
||||
|
||||
&.offline {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
|
||||
.status-dot {
|
||||
background: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
&.warning {
|
||||
background: rgba(234, 179, 8, 0.1);
|
||||
color: #eab308;
|
||||
|
||||
.status-dot {
|
||||
background: #eab308;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.region-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
.region-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 6px;
|
||||
background: rgba(51, 65, 85, 0.3);
|
||||
border-radius: 6px;
|
||||
|
||||
.region-name {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.region-count {
|
||||
font-size: 0.9rem;
|
||||
color: #3b82f6;
|
||||
font-weight: bold;
|
||||
margin: 0 15px;
|
||||
}
|
||||
|
||||
.region-percentage {
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式设计
|
||||
@media (max-width: 1200px) {
|
||||
.right-wrapper {
|
||||
.panel {
|
||||
.panel-header {
|
||||
.panel-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
.device-status {
|
||||
.status-item {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.region-list {
|
||||
.region-item {
|
||||
.region-name {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.region-count {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.region-percentage {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
591
src/views/screen/components/RiskStatisticsPanel.vue
Normal file
591
src/views/screen/components/RiskStatisticsPanel.vue
Normal file
@@ -0,0 +1,591 @@
|
||||
<template>
|
||||
<div class="left-bottom">
|
||||
<div class="panel-title">
|
||||
<div class="tabs">
|
||||
<span class="tab" :class="{ active: activeTab === '高危作业' }" @click="handleTabClick('高危作业')">高危作业</span>
|
||||
<span class="divider">|</span>
|
||||
<span class="tab" :class="{ active: activeTab === '安全培训考试' }" @click="handleTabClick('安全培训考试')">安全培训考试</span>
|
||||
<span class="divider">|</span>
|
||||
<span class="tab" :class="{ active: activeTab === '应急预案及演练' }" @click="handleTabClick('应急预案及演练')">应急预案及演练</span>
|
||||
</div>
|
||||
</div>
|
||||
<img style="margin: 8px 0" src="@/assets/images/title_border_line.png" />
|
||||
<div class="bottom-card-risk">
|
||||
<div class="bottom-card-title">
|
||||
<span>{{ activeTab === '高危作业' ? '各园区统计' : activeTab === '安全培训考试' ? '安全培训考试' : '园区演练完成率' }}</span>
|
||||
<img width="50%" style="margin: 8px 0" src="@/assets/images/line_1.png" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- <Echart v-if="activeTab !== '高危作业'" :options="riskChartOption" class="donut-chart-with-labels" height="30vh" /> -->
|
||||
|
||||
<AlertList maxHeight="40vh" v-if="activeTab === '安全培训考试'" :table-title="tableTitle" style="margin-left: 1vw;"
|
||||
:list-data="dataList" />
|
||||
|
||||
<div style="width: 80%; padding-left: 1vw;">
|
||||
<Echart v-if="activeTab === '高危作业'" style="height: 30vh" :options="barChartOption" class="bar-chart" />
|
||||
</div>
|
||||
|
||||
<div ref="riskChart" class="risk-chart" v-if="activeTab === '应急预案及演练'"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||
import AlertList from './AlertList.vue'
|
||||
import { rgbToHex } from '@/utils/color'
|
||||
import * as echarts from 'echarts'
|
||||
import { getDrillSum, getExamSum, getZBDangerSum } from '../report'
|
||||
type TabType = '高危作业' | '安全培训考试' | '应急预案及演练'
|
||||
const activeTab = ref<TabType>('高危作业')
|
||||
const emit = defineEmits<{
|
||||
tabChange: [tab: TabType]
|
||||
}>()
|
||||
interface AlertItem {
|
||||
description: string
|
||||
alarm_level_code: string
|
||||
alarm_status: string
|
||||
alarm_biz_id: string
|
||||
}
|
||||
interface Props {
|
||||
riskStatistics?: any
|
||||
dangerDetail?: AlertItem[]
|
||||
park?: string
|
||||
campus_id?: string
|
||||
}
|
||||
|
||||
const tableTitle = [
|
||||
{
|
||||
name: '园区名称',
|
||||
key: 'campus_name'
|
||||
},
|
||||
{
|
||||
name: '累计培训次数',
|
||||
key: 'examtimes'
|
||||
},
|
||||
{
|
||||
name: '参与总人次',
|
||||
key: 'exampeoplenum'
|
||||
},
|
||||
{
|
||||
name: '累计培训时长',
|
||||
key: 'examduration'
|
||||
},
|
||||
{
|
||||
name: '平均通过率',
|
||||
key: 'exampassrate'
|
||||
}
|
||||
]
|
||||
|
||||
// 图表引用
|
||||
const barChartOption = ref({
|
||||
legend: {
|
||||
top: '10%',
|
||||
right: '12%',
|
||||
orient: 'vertical' as const,
|
||||
textStyle: {
|
||||
color: '#ffffff',
|
||||
fontSize: '11px'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '5%',
|
||||
right: '30%',
|
||||
top: '10%',
|
||||
bottom: '15%'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category' as const,
|
||||
data: [],
|
||||
axisLabel: {
|
||||
color: '#ffffff',
|
||||
fontSize: 10
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: { color: '#334155' }
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value' as const,
|
||||
axisLabel: {
|
||||
color: '#ffffff',
|
||||
fontSize: 10
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: { color: '#334155' }
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: { color: '#334155' }
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '未开始数量',
|
||||
type: 'bar' as const,
|
||||
data: [],
|
||||
itemStyle: { color: rgbToHex(99, 196, 251) },
|
||||
barWidth: '8%',
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top' as const,
|
||||
color: '#ffffff',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold' as const,
|
||||
formatter: '{c}'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '进行中数量',
|
||||
type: 'bar' as const,
|
||||
data: [],
|
||||
itemStyle: { color: rgbToHex(251, 246, 85) },
|
||||
barWidth: '8%',
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top' as const,
|
||||
color: '#ffffff',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold' as const,
|
||||
formatter: '{c}'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '已完成数量',
|
||||
type: 'bar' as const,
|
||||
data: [],
|
||||
itemStyle: { color: rgbToHex(200, 69, 237) },
|
||||
barWidth: '8%',
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top' as const,
|
||||
color: '#ffffff',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold' as const,
|
||||
formatter: '{c}'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const dataList = ref<AlertItem[]>([
|
||||
])
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// 图表引用
|
||||
const riskChart = ref<HTMLElement | null>(null)
|
||||
let chartInstance: echarts.ECharts | null = null
|
||||
|
||||
|
||||
|
||||
// 初始化饼图
|
||||
const initPieChart = async () => {
|
||||
if (!riskChart.value) return
|
||||
|
||||
chartInstance = echarts.init(riskChart.value)
|
||||
|
||||
const colors = [
|
||||
[
|
||||
{ offset: 0, color: '#ffb74d' },
|
||||
{ offset: 0.3, color: '#ff9800' },
|
||||
{ offset: 0.7, color: '#f57c00' },
|
||||
{ offset: 1, color: '#e65100' }
|
||||
],
|
||||
[
|
||||
{ offset: 0, color: '#64b5f6' },
|
||||
{ offset: 0.3, color: '#42a5f5' },
|
||||
{ offset: 0.7, color: '#2196f3' },
|
||||
{ offset: 1, color: '#1976d2' }
|
||||
],
|
||||
[
|
||||
{ offset: 0, color: '#81c784' },
|
||||
{ offset: 0.3, color: '#66bb6a' },
|
||||
{ offset: 0.7, color: '#4caf50' },
|
||||
{ offset: 1, color: '#388e3c' }
|
||||
]
|
||||
|
||||
]
|
||||
|
||||
const res = await getDrillSum(props.campus_id || '')
|
||||
|
||||
const option = {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
borderColor: '#4a9eff',
|
||||
borderWidth: 1,
|
||||
textStyle: {
|
||||
color: '#ffffff'
|
||||
},
|
||||
formatter: function (params: any) {
|
||||
return `${params.data.name}<br/>完成率: ${params.data.value}%`
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '园区演练完成率',
|
||||
type: 'pie',
|
||||
radius: ['30%', '70%'],
|
||||
center: ['50%', '50%'],
|
||||
data: res.records.map((item, index) => (
|
||||
{
|
||||
value: item.rate,
|
||||
name: item.campus_name,
|
||||
itemStyle: {
|
||||
color: {
|
||||
type: 'radial',
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
r: 0.8,
|
||||
colorStops: colors[index % 3]
|
||||
}
|
||||
}
|
||||
}
|
||||
))
|
||||
// [
|
||||
// {
|
||||
// value: 100,
|
||||
// name: '西安创新院',
|
||||
// itemStyle: {
|
||||
// color: {
|
||||
// type: 'radial',
|
||||
// x: 0.5,
|
||||
// y: 0.5,
|
||||
// r: 0.8,
|
||||
// colorStops: [
|
||||
// { offset: 0, color: '#ffb74d' },
|
||||
// { offset: 0.3, color: '#ff9800' },
|
||||
// { offset: 0.7, color: '#f57c00' },
|
||||
// { offset: 1, color: '#e65100' }
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// value: 63,
|
||||
// name: '北京横毅大厦',
|
||||
// itemStyle: {
|
||||
// color: {
|
||||
// type: 'radial',
|
||||
// x: 0.5,
|
||||
// y: 0.5,
|
||||
// r: 0.8,
|
||||
// colorStops: [
|
||||
// { offset: 0, color: '#64b5f6' },
|
||||
// { offset: 0.3, color: '#42a5f5' },
|
||||
// { offset: 0.7, color: '#2196f3' },
|
||||
// { offset: 1, color: '#1976d2' }
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// value: 60,
|
||||
// name: '重庆产业大厦',
|
||||
// itemStyle: {
|
||||
// color: {
|
||||
// type: 'radial',
|
||||
// x: 0.5,
|
||||
// y: 0.5,
|
||||
// r: 0.8,
|
||||
// colorStops: [
|
||||
// { offset: 0, color: '#81c784' },
|
||||
// { offset: 0.3, color: '#66bb6a' },
|
||||
// { offset: 0.7, color: '#4caf50' },
|
||||
// { offset: 1, color: '#388e3c' }
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// ]
|
||||
,
|
||||
label: {
|
||||
show: true,
|
||||
position: 'inside',
|
||||
formatter: function (params: any) {
|
||||
return `${params.data.name}\n${params.data.value}%`
|
||||
},
|
||||
fontSize: 12,
|
||||
color: '#ffffff',
|
||||
fontWeight: 'bold',
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.8)',
|
||||
textShadowBlur: 2
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
chartInstance.setOption(option)
|
||||
}
|
||||
|
||||
// 销毁图表
|
||||
const destroyChart = () => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
chartInstance = null
|
||||
}
|
||||
}
|
||||
|
||||
const initBarChart = async () => {
|
||||
try {
|
||||
const res = await getZBDangerSum(props.campus_id || '')
|
||||
|
||||
// 更新图表数据
|
||||
const newOption = {
|
||||
...barChartOption.value,
|
||||
xAxis: {
|
||||
...barChartOption.value.xAxis,
|
||||
data: res.records.map((item: any) => item.campus_name)
|
||||
},
|
||||
series: [
|
||||
{
|
||||
...barChartOption.value.series[0],
|
||||
data: res.records.map((item: any) => item.ywc)
|
||||
},
|
||||
{
|
||||
...barChartOption.value.series[1],
|
||||
data: res.records.map((item: any) => item.jxz)
|
||||
},
|
||||
{
|
||||
...barChartOption.value.series[2],
|
||||
data: res.records.map((item: any) => item.wks)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
barChartOption.value = newOption
|
||||
|
||||
console.log('Bar chart data updated:', {
|
||||
xAxis: barChartOption.value.xAxis.data,
|
||||
series: barChartOption.value.series.map(s => ({ name: s.name, data: s.data }))
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load bar chart data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听标签页切换
|
||||
watchEffect(async () => {
|
||||
if (activeTab.value === '应急预案及演练') {
|
||||
initPieChart()
|
||||
} else {
|
||||
destroyChart()
|
||||
}
|
||||
|
||||
if (activeTab.value === '高危作业') {
|
||||
initBarChart()
|
||||
}
|
||||
|
||||
if (activeTab.value === '安全培训考试') {
|
||||
const res = await getExamSum(props.campus_id || '')
|
||||
|
||||
dataList.value = res.records
|
||||
}
|
||||
})
|
||||
|
||||
// 监听数据变化,更新图表
|
||||
watch(() => props.riskStatistics, (newVal) => {
|
||||
console.log('riskStatistics changed:', { newVal })
|
||||
if (newVal) {
|
||||
refreshCharts(newVal)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// 监听数据变化,更新图表
|
||||
watch(() => props.dangerDetail, (newVal) => {
|
||||
console.log('dangerDetail changed:', { newVal })
|
||||
|
||||
}, { deep: true })
|
||||
|
||||
// 监听数据变化,更新图表
|
||||
watch(() => props.dangerDetail, (newVal) => {
|
||||
console.log('dangerDetail changed:', { newVal })
|
||||
if (newVal) {
|
||||
dataList.value = newVal
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// 更新图表数据
|
||||
const refreshCharts = (riskStatistics: any): void => {
|
||||
if (!riskStatistics || !Array.isArray(riskStatistics)) {
|
||||
console.warn('riskStatistics is undefined, null, or not an array')
|
||||
return
|
||||
}
|
||||
|
||||
// 计算各园区的完成率
|
||||
const chartData = riskStatistics.map((item: any, index: number) => {
|
||||
const finishCount = Number(item.finishCount) || 0
|
||||
const participateCount = Number(item.participateCount) || 0
|
||||
const completionRate = participateCount > 0 ? Math.round((finishCount / participateCount) * 100) : 0
|
||||
|
||||
// 为每个园区分配不同的颜色
|
||||
const colors = ['#ef4444', '#10b981', '#eab308', '#3b82f6', '#8b5cf6', '#f59e0b', '#06b6d4', '#84cc16']
|
||||
const color = colors[index % colors.length]
|
||||
|
||||
return {
|
||||
value: completionRate,
|
||||
name: item.csmpus_name || `园区${index + 1}`,
|
||||
finishCount: finishCount,
|
||||
participateCount: participateCount,
|
||||
itemStyle: { color: color }
|
||||
}
|
||||
})
|
||||
|
||||
console.log("Updated chart data:", chartData)
|
||||
}
|
||||
|
||||
const handleTabClick = async (tab: TabType) => {
|
||||
activeTab.value = tab
|
||||
emit('tabChange', tab)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 如果没有传入数据,设置默认数据
|
||||
if (!props.riskStatistics) {
|
||||
const defaultData = [
|
||||
{
|
||||
csmpus_name: "雄安新区总部",
|
||||
finishCount: "234",
|
||||
participateCount: "300"
|
||||
},
|
||||
{
|
||||
csmpus_name: "雄安二区总部",
|
||||
finishCount: "180",
|
||||
participateCount: "250"
|
||||
},
|
||||
{
|
||||
csmpus_name: "雄安三区总部",
|
||||
finishCount: "156",
|
||||
participateCount: "200"
|
||||
}
|
||||
]
|
||||
refreshCharts(defaultData)
|
||||
}
|
||||
|
||||
// 如果当前是高危作业标签页,初始化柱状图
|
||||
if (activeTab.value === '高危作业') {
|
||||
nextTick(() => {
|
||||
initBarChart()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 组件卸载时销毁图表
|
||||
onUnmounted(() => {
|
||||
destroyChart()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.left-bottom {
|
||||
background-image: url('@/assets/images/screen/left_top_2_img.png'), url('@/assets/images/screen/left_center_img.png'), url('@/assets/images/screen/left_bottom_img.png');
|
||||
background-position: top center, left center, bottom center;
|
||||
background-repeat: no-repeat, no-repeat, no-repeat;
|
||||
background-size: 100% 90px, cover, 100% 68px;
|
||||
flex: 1;
|
||||
|
||||
.panel-title {
|
||||
margin: 4px 20px 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
column-gap: 8px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 2px 10px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: #1afb8f;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #1afb8f;
|
||||
background: rgb(26 251 143 / 12%);
|
||||
border: 1px solid rgb(26 251 143 / 35%);
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 0 2px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.bottom-card-risk {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.bottom-card-title {
|
||||
display: flex;
|
||||
margin-top: 5px;
|
||||
margin-left: -15%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.donut-chart-with-labels {
|
||||
width: 30vw;
|
||||
height: 30vh;
|
||||
margin-left: 2vw;
|
||||
}
|
||||
|
||||
.risk-chart {
|
||||
width: 30vw;
|
||||
height: 30vh;
|
||||
margin-left: 2vw;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <=1024px) {
|
||||
|
||||
.left-bottom .donut-chart-with-labels,
|
||||
.left-bottom .risk-chart {
|
||||
width: 35vw;
|
||||
height: 35vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <=768px) {
|
||||
.left-bottom {
|
||||
.tabs .tab {
|
||||
padding: 1px 8px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.donut-chart-with-labels,
|
||||
.risk-chart {
|
||||
width: 40vw;
|
||||
height: 40vh;
|
||||
margin-left: 1vw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <=480px) {
|
||||
|
||||
.left-bottom .donut-chart-with-labels,
|
||||
.left-bottom .risk-chart {
|
||||
width: 45vw;
|
||||
height: 45vh;
|
||||
margin-left: 0.5vw;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1900
src/views/screen/components/ScreenFrame.vue
Normal file
1900
src/views/screen/components/ScreenFrame.vue
Normal file
File diff suppressed because it is too large
Load Diff
250
src/views/screen/components/TimeoutWorkOrderPanel.vue
Normal file
250
src/views/screen/components/TimeoutWorkOrderPanel.vue
Normal file
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<div class="right-bottom" style="text-align: right">
|
||||
<div class="panel-title">超时工单</div>
|
||||
<img style="margin: 8px 0" src="@/assets/images/title_border_line_1.png" />
|
||||
|
||||
<div class="tip-container">
|
||||
<div class="tip-image">
|
||||
<img src="@/assets/images/screen/circle_image.png" width="80" height="80" />
|
||||
<span class="number">{{ timeoutWorkOrders?.total || 0 }}</span>
|
||||
</div>
|
||||
<img src="@/assets/images/screen/tip_bg_image.png" width="100%" height="70" />
|
||||
<div class="tip-content">
|
||||
<div class="col-item">
|
||||
<img src="@/assets/images/screen/warning_img.png" width="23" />
|
||||
<span>超时工单数</span>
|
||||
<span style="font-size: 1.2rem; marker-start: 2vw; color: red;">{{ timeoutWorkOrders?.total || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="list-content">
|
||||
<div class="list-title">
|
||||
<span>告警详情</span>
|
||||
<img width="50%" src="@/assets/images/line_1.png" />
|
||||
</div>
|
||||
<div class="list">
|
||||
<div class="list-wrapper">
|
||||
<div class="list-item" v-for="(item, index) in alertDetails" :key="index">
|
||||
<span class="alert-text" :class="[{ error: item.status == 2 }, { warn: item.status == 1 }]">
|
||||
{{ (index + 1) }} {{ item.describe }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
<AlertList style="margin-right: 1vw;" title="工单详情" :list-data="alertDetails" ></AlertList>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AlertList from './AlertList.vue'
|
||||
// 类型定义
|
||||
interface AlertItem {
|
||||
description: string
|
||||
alarm_level_code: string
|
||||
alarm_status: string
|
||||
alarm_biz_id: string
|
||||
}
|
||||
|
||||
interface TimeoutWorkOrders {
|
||||
total: number
|
||||
}
|
||||
|
||||
// Props定义
|
||||
interface Props {
|
||||
timeoutWorkOrders?: TimeoutWorkOrders
|
||||
alertDetails?: AlertItem[]
|
||||
sourceIndex?: number
|
||||
}
|
||||
|
||||
// 默认值
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
timeoutWorkOrders: () => ({
|
||||
total: 0
|
||||
}),
|
||||
alertDetails: () => [],
|
||||
sourceIndex: 1
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (width <= 768px) {
|
||||
.right-bottom {
|
||||
.tip-container {
|
||||
width: 80%;
|
||||
height: 70px;
|
||||
|
||||
.tip-image img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.tip-content .col-item {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
}
|
||||
|
||||
.list-content {
|
||||
width: 75%;
|
||||
|
||||
.list .list-item {
|
||||
padding: 0.4vh 0.3vw;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 480px) {
|
||||
.right-bottom {
|
||||
.tip-container {
|
||||
width: 85%;
|
||||
height: 60px;
|
||||
|
||||
.tip-image img {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.tip-content .col-item {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.list-content {
|
||||
width: 80%;
|
||||
|
||||
.list .list-item {
|
||||
padding: 0.3vh 0.2vw;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right-bottom {
|
||||
display: flex;
|
||||
background-image:
|
||||
url('@/assets/images/screen/right_top_img.png'),
|
||||
url('@/assets/images/screen/right_center_img.png'),
|
||||
url('@/assets/images/screen/right_bottom_img.png');
|
||||
background-position:
|
||||
top center,
|
||||
right center,
|
||||
bottom center;
|
||||
background-repeat: no-repeat, no-repeat, no-repeat;
|
||||
background-size:
|
||||
100% 90px,
|
||||
cover,
|
||||
100% 68px;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
|
||||
.panel-title {
|
||||
margin: 3px 15px 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tip-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 50%;
|
||||
height: 80px;
|
||||
padding-right: 20px;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
|
||||
.tip-image {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
transform: translateY(-50%) translateX(-50%);
|
||||
|
||||
.number {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
font-size: 1rem;
|
||||
color: #fff;
|
||||
transform: translate(-80%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.tip-content {
|
||||
position: absolute;
|
||||
inset: 0% 0 0 6%;
|
||||
display: flex;
|
||||
padding-left: 20px;
|
||||
align-items: center;
|
||||
|
||||
.col-item {
|
||||
display: flex;
|
||||
margin-left: 1vw;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-content {
|
||||
display: flex;
|
||||
width: 68%;
|
||||
height: calc(100% - 100px);
|
||||
margin-top: 20px;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
|
||||
.list-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-height: 22vh;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.list-wrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin-top: 10px;
|
||||
overflow: hidden scroll;
|
||||
flex-direction: column;
|
||||
row-gap: 4px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: inline-flex;
|
||||
padding: 0.5vh 0.4vw;
|
||||
font-size: 0.75rem;
|
||||
background: rgb(51 65 85 / 30%);
|
||||
border: 1px solid #1e40af;
|
||||
border-radius: 0.37vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.alert-text.error {
|
||||
color: #f00;
|
||||
}
|
||||
|
||||
.alert-text.warn {
|
||||
color: #ff0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
229
src/views/screen/components/WeatherWarning.vue
Normal file
229
src/views/screen/components/WeatherWarning.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<div class="weather-warning">
|
||||
<span>天气预警:</span>
|
||||
<!-- 预报内容 -->
|
||||
<div class="weather-scroll-container" @mouseenter="stopWeatherScroll" @mouseleave="startWeatherScroll">
|
||||
<div class="weather-scroll-content" :style="{ transform: `translateX(${scrollPosition}px)` }">
|
||||
<span v-for="(item, index) in weatherData" :key="index" class="weather-item"
|
||||
:style="{ color: getLevelColor(item.level_code) }">
|
||||
{{ item.content }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { getTableList } from '../report'
|
||||
interface WeatherWarning {
|
||||
content: string
|
||||
level_code: 'severity' | 'major' | 'general'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
// 可以传入自定义的天气数据
|
||||
customData?: WeatherWarning[]
|
||||
// 滚动速度
|
||||
scrollSpeed?: number
|
||||
// 是否自动获取数据
|
||||
autoFetch?: boolean
|
||||
// 数据更新间隔(分钟)
|
||||
updateInterval?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
customData: undefined,
|
||||
scrollSpeed: 1,
|
||||
autoFetch: true,
|
||||
updateInterval: 5
|
||||
})
|
||||
|
||||
// 天气预报数据
|
||||
const weatherData = ref<WeatherWarning[]>([
|
||||
{
|
||||
content: '2025年08月19日13:25分中央气象台发布雄安地区于17时至夜间将有200毫米强降雨,并伴有10级大风......',
|
||||
level_code: 'severity'
|
||||
},
|
||||
{
|
||||
content: '2025年08月19日10:30分河北省气象台发布石家庄地区今日下午有雷阵雨,请注意防范......',
|
||||
level_code: 'major'
|
||||
},
|
||||
{
|
||||
content: '2025年08月19日09:15分北京市气象台发布今日天气晴朗,气温25-32度,空气质量良好......',
|
||||
level_code: 'general'
|
||||
},
|
||||
{
|
||||
content: '2025年08月19日08:00分天津市气象台发布今日多云转晴,风力3-4级,适合户外活动......',
|
||||
level_code: 'general'
|
||||
}
|
||||
])
|
||||
|
||||
// 滚动位置
|
||||
const scrollPosition = ref(0)
|
||||
const scrollInterval = ref<number | null>(null)
|
||||
const updateTimerId = ref<number | null>(null)
|
||||
|
||||
// 获取预警级别对应的颜色
|
||||
const getLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'severity':
|
||||
return 'red'
|
||||
case 'major':
|
||||
return 'orange'
|
||||
case 'general':
|
||||
return 'white'
|
||||
default:
|
||||
return 'white'
|
||||
}
|
||||
}
|
||||
|
||||
// 开始天气预报滚动
|
||||
const startWeatherScroll = () => {
|
||||
if (scrollInterval.value) return
|
||||
|
||||
console.log('开始滚动,天气数据条数:', weatherData.value.length)
|
||||
|
||||
scrollInterval.value = setInterval(() => {
|
||||
scrollPosition.value -= props.scrollSpeed
|
||||
|
||||
// 动态计算需要滚动的总距离
|
||||
// 每条预警的宽度 = 内容长度 + 右边距,估算更准确
|
||||
const itemWidth = 820 // 每条预警的估算宽度:800px内容 + 20px右边距
|
||||
const totalWidth = weatherData.value.length * itemWidth
|
||||
const resetThreshold = -totalWidth
|
||||
|
||||
// 当滚动到一定位置时,重置位置实现循环效果
|
||||
if (scrollPosition.value <= resetThreshold) {
|
||||
console.log('重置滚动位置,当前:', scrollPosition.value, '阈值:', resetThreshold)
|
||||
scrollPosition.value = 0
|
||||
}
|
||||
|
||||
// 每100次更新输出一次调试信息
|
||||
if (Math.abs(scrollPosition.value) % 100 === 0) {
|
||||
console.log('滚动位置:', scrollPosition.value, '阈值:', resetThreshold)
|
||||
}
|
||||
}, 50) // 每50ms更新一次,控制滚动速度
|
||||
}
|
||||
|
||||
// 停止天气预报滚动
|
||||
const stopWeatherScroll = () => {
|
||||
if (scrollInterval.value) {
|
||||
clearInterval(scrollInterval.value)
|
||||
scrollInterval.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 获取天气数据(模拟接口调用)
|
||||
const fetchWeatherData = async () => {
|
||||
const query = {
|
||||
pageNo: 1,
|
||||
pageSize: 10000,
|
||||
parkCode: "",
|
||||
regionCode: ""
|
||||
}
|
||||
//
|
||||
try {
|
||||
let weather_warning = await getTableList(
|
||||
'weather_warning', query
|
||||
)
|
||||
|
||||
if (weather_warning.records && weather_warning.records.length > 0) {
|
||||
// 更新为新的数据格式
|
||||
weatherData.value = weather_warning.records
|
||||
} else {
|
||||
weatherData.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取天气预警数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 定时更新天气数据
|
||||
const startWeatherDataUpdate = () => {
|
||||
if (!props.autoFetch) return
|
||||
|
||||
updateTimerId.value = setInterval(async () => {
|
||||
await fetchWeatherData()
|
||||
}, props.updateInterval * 60 * 1000)
|
||||
}
|
||||
|
||||
// 停止定时更新
|
||||
const stopWeatherDataUpdate = () => {
|
||||
if (updateTimerId.value) {
|
||||
clearInterval(updateTimerId.value)
|
||||
updateTimerId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 更新天气数据(供父组件调用)
|
||||
const updateWeatherData = (data: WeatherWarning[]) => {
|
||||
weatherData.value = data
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
updateWeatherData,
|
||||
startWeatherScroll,
|
||||
stopWeatherScroll,
|
||||
fetchWeatherData
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 如果传入了自定义数据,使用自定义数据
|
||||
if (props.customData) {
|
||||
weatherData.value = props.customData
|
||||
} else if (props.autoFetch) {
|
||||
// 否则获取天气数据
|
||||
fetchWeatherData().then(() => {
|
||||
startWeatherScroll()
|
||||
})
|
||||
} else {
|
||||
// 直接启动滚动
|
||||
startWeatherScroll()
|
||||
}
|
||||
|
||||
// 启动天气数据定时更新
|
||||
startWeatherDataUpdate()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopWeatherScroll()
|
||||
stopWeatherDataUpdate()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.weather-warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-left: 10%;
|
||||
line-height: 45px;
|
||||
|
||||
.weather-scroll-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.weather-scroll-content {
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
|
||||
/* 移除过渡动画,确保滚动流畅 */
|
||||
|
||||
/* transition: transform 0.05s linear; */
|
||||
|
||||
.weather-item {
|
||||
min-width: 800px;
|
||||
margin-right: 20px;
|
||||
|
||||
/* 每条预警之间的间距,从50px调整为20px */
|
||||
white-space: nowrap;
|
||||
|
||||
/* 确保文本不会被截断 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
211
src/views/screen/components/WeatherWarningExample.vue
Normal file
211
src/views/screen/components/WeatherWarningExample.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div class="weather-example">
|
||||
<h2>WeatherWarning 组件使用示例</h2>
|
||||
|
||||
<!-- 基础用法 -->
|
||||
<div class="example-section">
|
||||
<h3>1. 基础用法</h3>
|
||||
<WeatherWarning />
|
||||
</div>
|
||||
|
||||
<!-- 自定义配置 -->
|
||||
<div class="example-section">
|
||||
<h3>2. 自定义配置</h3>
|
||||
<WeatherWarning :scroll-speed="2" :auto-fetch="false" :update-interval="10" :custom-data="customWeatherData" />
|
||||
</div>
|
||||
|
||||
<!-- 通过ref调用方法 -->
|
||||
<div class="example-section">
|
||||
<h3>3. 通过ref调用方法</h3>
|
||||
<WeatherWarning ref="weatherRef" />
|
||||
<div class="button-group">
|
||||
<button @click="updateData">更新数据</button>
|
||||
<button @click="startScroll">开始滚动</button>
|
||||
<button @click="stopScroll">停止滚动</button>
|
||||
<button @click="fetchData">获取数据</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 控制面板 -->
|
||||
<div class="example-section">
|
||||
<h3>4. 控制面板</h3>
|
||||
<div class="control-panel">
|
||||
<label>
|
||||
滚动速度:
|
||||
<input v-model.number="scrollSpeed" type="range" min="0.5" max="3" step="0.1" />
|
||||
{{ scrollSpeed }}
|
||||
</label>
|
||||
<label>
|
||||
更新间隔(分钟):
|
||||
<input v-model.number="updateInterval" type="range" min="1" max="30" step="1" />
|
||||
{{ updateInterval }}
|
||||
</label>
|
||||
<label>
|
||||
<input v-model="autoFetch" type="checkbox" />
|
||||
自动获取数据
|
||||
</label>
|
||||
</div>
|
||||
<WeatherWarning :scroll-speed="scrollSpeed" :auto-fetch="autoFetch" :update-interval="updateInterval" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import WeatherWarning from './WeatherWarning.vue'
|
||||
|
||||
// 自定义天气数据
|
||||
const customWeatherData = [
|
||||
{
|
||||
content: '自定义预警1: 今日天气晴朗,适合户外活动......',
|
||||
level: 'low'
|
||||
},
|
||||
{
|
||||
content: '自定义预警2: 明日可能有小雨,请携带雨具......',
|
||||
level: 'medium'
|
||||
},
|
||||
{
|
||||
content: '自定义预警3: 后天将有强降雨,请注意防范......',
|
||||
level: 'high'
|
||||
}
|
||||
]
|
||||
|
||||
// 控制面板数据
|
||||
const scrollSpeed = ref(1)
|
||||
const updateInterval = ref(5)
|
||||
const autoFetch = ref(true)
|
||||
|
||||
// ref引用
|
||||
const weatherRef = ref()
|
||||
|
||||
// 更新数据
|
||||
const updateData = () => {
|
||||
const newData = [
|
||||
{
|
||||
content: '动态更新的天气预警信息1......',
|
||||
level: 'high'
|
||||
},
|
||||
{
|
||||
content: '动态更新的天气预警信息2......',
|
||||
level: 'medium'
|
||||
}
|
||||
]
|
||||
weatherRef.value?.updateWeatherData(newData)
|
||||
}
|
||||
|
||||
// 开始滚动
|
||||
const startScroll = () => {
|
||||
weatherRef.value?.startWeatherScroll()
|
||||
}
|
||||
|
||||
// 停止滚动
|
||||
const stopScroll = () => {
|
||||
weatherRef.value?.stopWeatherScroll()
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const fetchData = () => {
|
||||
weatherRef.value?.fetchWeatherData()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// 响应式设计
|
||||
@media (width <=768px) {
|
||||
.weather-example {
|
||||
padding: 10px;
|
||||
|
||||
.example-section {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
flex-direction: column;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
label {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.weather-example {
|
||||
max-width: 1200px;
|
||||
padding: 20px;
|
||||
margin: 0 auto;
|
||||
|
||||
h2 {
|
||||
margin-bottom: 30px;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.example-section {
|
||||
padding: 20px;
|
||||
margin-bottom: 40px;
|
||||
background: #f9f9f9;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
|
||||
h3 {
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 15px;
|
||||
color: #555;
|
||||
border-bottom: 2px solid #007bff;
|
||||
}
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
margin-top: 15px;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
button {
|
||||
padding: 8px 16px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
background: #007bff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
transition: background 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
background: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 500;
|
||||
|
||||
input[type="range"] {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
59
src/views/screen/composables/useAlertManager.ts
Normal file
59
src/views/screen/composables/useAlertManager.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { ref } from 'vue'
|
||||
import { getAlertDetails } from '@/api/dashboard'
|
||||
|
||||
export interface AlertItem {
|
||||
text: string
|
||||
error?: boolean
|
||||
warn?: boolean
|
||||
}
|
||||
|
||||
export function useAlertManager() {
|
||||
const alertDetails = ref<AlertItem[]>([])
|
||||
const timeoutDetails = ref<AlertItem[]>([])
|
||||
let alertRotationInterval: NodeJS.Timeout | null = null
|
||||
let currentAlertType = 1
|
||||
|
||||
const fetchAlertDetails = async (type: 'risk' | 'timeout'): Promise<AlertItem[]> => {
|
||||
try {
|
||||
return await getAlertDetails(type)
|
||||
} catch (error) {
|
||||
console.error('获取告警详情失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const startAlertRotation = async (): Promise<void> => {
|
||||
// 初始化数据
|
||||
alertDetails.value = await fetchAlertDetails('risk')
|
||||
timeoutDetails.value = await fetchAlertDetails('timeout')
|
||||
|
||||
// 启动轮播
|
||||
alertRotationInterval = setInterval(async () => {
|
||||
if (currentAlertType === 1) {
|
||||
currentAlertType = 2
|
||||
alertDetails.value = await fetchAlertDetails('risk')
|
||||
} else {
|
||||
currentAlertType = 1
|
||||
timeoutDetails.value = await fetchAlertDetails('timeout')
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const stopAlertRotation = (): void => {
|
||||
if (alertRotationInterval) {
|
||||
clearInterval(alertRotationInterval)
|
||||
alertRotationInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
alertDetails,
|
||||
timeoutDetails,
|
||||
startAlertRotation,
|
||||
stopAlertRotation,
|
||||
// 返回清理函数,让调用方在组件卸载时调用
|
||||
cleanup: () => {
|
||||
stopAlertRotation()
|
||||
}
|
||||
}
|
||||
}
|
||||
258
src/views/screen/composables/useDashboardData.ts
Normal file
258
src/views/screen/composables/useDashboardData.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { getDashboardData, updateDashboardData, type DashboardData } from '@/api/dashboard'
|
||||
|
||||
export function useDashboardData() {
|
||||
// 大屏数据
|
||||
const dashboardData = ref<DashboardData | null>(null)
|
||||
|
||||
// 总计数量数据
|
||||
const totalCount = ref<number>(0)
|
||||
const formalEmployeeCount = ref<number>(0)
|
||||
const externalStaffCount = ref<number>(0)
|
||||
const visitorCount = ref<number>(0)
|
||||
|
||||
// 动画相关的状态
|
||||
const isAnimating = ref<boolean>(false)
|
||||
const animationDuration = 2000 // 动画持续时间(毫秒)
|
||||
|
||||
// 计算属性:将各种数量的数字分割成数组
|
||||
const totalCountDigits = computed(() => {
|
||||
return String(totalCount.value).split('').map(Number)
|
||||
})
|
||||
|
||||
const formalEmployeeDigits = computed(() => {
|
||||
return String(formalEmployeeCount.value).split('').map(Number)
|
||||
})
|
||||
|
||||
const externalStaffDigits = computed(() => {
|
||||
return String(externalStaffCount.value).split('').map(Number)
|
||||
})
|
||||
|
||||
const visitorDigits = computed(() => {
|
||||
return String(visitorCount.value).split('').map(Number)
|
||||
})
|
||||
|
||||
// 数字滚动动画方法
|
||||
const animateNumber = (startValue: number, endValue: number, duration: number, callback: (value: number) => void) => {
|
||||
const startTime = Date.now()
|
||||
const difference = endValue - startValue
|
||||
|
||||
const animate = () => {
|
||||
const currentTime = Date.now()
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
|
||||
// 使用缓动函数让动画更自然
|
||||
const easeOutQuart = 1 - Math.pow(1 - progress, 4)
|
||||
const currentValue = Math.round(startValue + difference * easeOutQuart)
|
||||
|
||||
callback(currentValue)
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
}
|
||||
|
||||
animate()
|
||||
}
|
||||
|
||||
// 数字变化时的闪烁效果
|
||||
const flashNumbers = () => {
|
||||
const numberElements = document.querySelectorAll('.total-number, .type-number')
|
||||
numberElements.forEach((el) => {
|
||||
el.classList.add('flash')
|
||||
setTimeout(() => {
|
||||
el.classList.remove('flash')
|
||||
}, 300)
|
||||
})
|
||||
}
|
||||
|
||||
// 更新各种数量的方法(带动画效果)
|
||||
const updateTotalCount = (newCount: number) => {
|
||||
if (isAnimating.value) return
|
||||
|
||||
isAnimating.value = true
|
||||
const startValue = totalCount.value
|
||||
|
||||
animateNumber(startValue, newCount, animationDuration, (value) => {
|
||||
totalCount.value = value
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
isAnimating.value = false
|
||||
}, animationDuration)
|
||||
}
|
||||
|
||||
const updateFormalEmployeeCount = (newCount: number) => {
|
||||
if (isAnimating.value) return
|
||||
|
||||
isAnimating.value = true
|
||||
const startValue = formalEmployeeCount.value
|
||||
|
||||
animateNumber(startValue, newCount, animationDuration, (value) => {
|
||||
formalEmployeeCount.value = value
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
isAnimating.value = false
|
||||
}, animationDuration)
|
||||
}
|
||||
|
||||
const updateExternalStaffCount = (newCount: number) => {
|
||||
if (isAnimating.value) return
|
||||
|
||||
isAnimating.value = true
|
||||
const startValue = externalStaffCount.value
|
||||
|
||||
animateNumber(startValue, newCount, animationDuration, (value) => {
|
||||
externalStaffCount.value = value
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
isAnimating.value = false
|
||||
}, animationDuration)
|
||||
}
|
||||
|
||||
const updateVisitorCount = (newCount: number) => {
|
||||
if (isAnimating.value) return
|
||||
|
||||
isAnimating.value = true
|
||||
const startValue = visitorCount.value
|
||||
|
||||
animateNumber(startValue, newCount, animationDuration, (value) => {
|
||||
visitorCount.value = value
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
isAnimating.value = false
|
||||
}, animationDuration)
|
||||
}
|
||||
|
||||
// 批量更新所有数字的方法
|
||||
const updateAllCounts = (counts: {
|
||||
total?: number
|
||||
formal?: number
|
||||
external?: number
|
||||
visitor?: number
|
||||
}) => {
|
||||
if (isAnimating.value) return
|
||||
|
||||
isAnimating.value = true
|
||||
const promises: Promise<void>[] = []
|
||||
|
||||
if (counts.total !== undefined) {
|
||||
promises.push(new Promise<void>((resolve) => {
|
||||
const startValue = totalCount.value
|
||||
animateNumber(startValue, counts.total!, animationDuration, (value) => {
|
||||
totalCount.value = value
|
||||
})
|
||||
setTimeout(resolve, animationDuration)
|
||||
}))
|
||||
}
|
||||
|
||||
if (counts.formal !== undefined) {
|
||||
promises.push(new Promise<void>((resolve) => {
|
||||
const startValue = formalEmployeeCount.value
|
||||
animateNumber(startValue, counts.formal!, animationDuration, (value) => {
|
||||
formalEmployeeCount.value = value
|
||||
})
|
||||
setTimeout(resolve, animationDuration)
|
||||
}))
|
||||
}
|
||||
|
||||
if (counts.external !== undefined) {
|
||||
promises.push(new Promise<void>((resolve) => {
|
||||
const startValue = externalStaffCount.value
|
||||
animateNumber(startValue, counts.external!, animationDuration, (value) => {
|
||||
externalStaffCount.value = value
|
||||
})
|
||||
setTimeout(resolve, animationDuration)
|
||||
}))
|
||||
}
|
||||
|
||||
if (counts.visitor !== undefined) {
|
||||
promises.push(new Promise<void>((resolve) => {
|
||||
const startValue = visitorCount.value
|
||||
animateNumber(startValue, counts.visitor!, animationDuration, (value) => {
|
||||
visitorCount.value = value
|
||||
})
|
||||
setTimeout(resolve, animationDuration)
|
||||
}))
|
||||
}
|
||||
|
||||
Promise.all(promises).then(() => {
|
||||
isAnimating.value = false
|
||||
flashNumbers() // 更新完成后闪烁效果
|
||||
})
|
||||
}
|
||||
|
||||
// 数据初始化方法
|
||||
const initDashboardData = async (): Promise<void> => {
|
||||
try {
|
||||
console.log('Fetching dashboard data...')
|
||||
const data = await getDashboardData()
|
||||
console.log('Dashboard data received:', data)
|
||||
|
||||
dashboardData.value = data
|
||||
console.log('Dashboard data set to reactive ref:', dashboardData.value)
|
||||
|
||||
// 设置初始数据
|
||||
totalCount.value = data.totalCount
|
||||
formalEmployeeCount.value = data.formalEmployeeCount
|
||||
externalStaffCount.value = data.externalStaffCount
|
||||
visitorCount.value = data.visitorCount
|
||||
|
||||
console.log('Count values set:', {
|
||||
total: totalCount.value,
|
||||
formal: formalEmployeeCount.value,
|
||||
external: externalStaffCount.value,
|
||||
visitor: visitorCount.value
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('初始化大屏数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 从API更新数据
|
||||
const updateDashboardDataFromAPI = async (): Promise<void> => {
|
||||
try {
|
||||
const newData = await updateDashboardData()
|
||||
dashboardData.value = newData
|
||||
|
||||
// 使用动画更新数字,确保数据存在
|
||||
if (newData) {
|
||||
updateAllCounts({
|
||||
total: newData.totalCount || 0,
|
||||
formal: newData.formalEmployeeCount || 0,
|
||||
external: newData.externalStaffCount || 0,
|
||||
visitor: newData.visitorCount || 0
|
||||
})
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('更新大屏数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 数据
|
||||
dashboardData,
|
||||
totalCount,
|
||||
formalEmployeeCount,
|
||||
externalStaffCount,
|
||||
visitorCount,
|
||||
|
||||
// 计算属性
|
||||
totalCountDigits,
|
||||
formalEmployeeDigits,
|
||||
externalStaffDigits,
|
||||
visitorDigits,
|
||||
|
||||
// 方法
|
||||
initDashboardData,
|
||||
updateDashboardData: updateDashboardDataFromAPI,
|
||||
updateAllCounts,
|
||||
flashNumbers
|
||||
}
|
||||
}
|
||||
22
src/views/screen/composables/useRegionManager.ts
Normal file
22
src/views/screen/composables/useRegionManager.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export function useRegionManager() {
|
||||
const selectedRegion = ref<string>('')
|
||||
const regionSelectorVisible = ref<boolean>(false)
|
||||
|
||||
const openRegionSelector = (): void => {
|
||||
regionSelectorVisible.value = true
|
||||
}
|
||||
|
||||
const onRegionChange = (name: string): void => {
|
||||
selectedRegion.value = name
|
||||
regionSelectorVisible.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
selectedRegion,
|
||||
regionSelectorVisible,
|
||||
openRegionSelector,
|
||||
onRegionChange
|
||||
}
|
||||
}
|
||||
16
src/views/screen/composables/useTabManager.ts
Normal file
16
src/views/screen/composables/useTabManager.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export type TabType = '高危作业' | '安全培训考试' | '安全培训考试'
|
||||
|
||||
export function useTabManager() {
|
||||
const activeTab = ref<TabType>('高危作业')
|
||||
|
||||
const handleTabChange = (tab: TabType): void => {
|
||||
activeTab.value = tab
|
||||
}
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
handleTabChange
|
||||
}
|
||||
}
|
||||
42
src/views/screen/composables/useTimeManager.ts
Normal file
42
src/views/screen/composables/useTimeManager.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
|
||||
export function useTimeManager() {
|
||||
const currentTime = ref<string>('')
|
||||
let timeInterval: NodeJS.Timeout | null = null
|
||||
|
||||
const updateTime = (): void => {
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(now.getDate()).padStart(2, '0')
|
||||
const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
|
||||
const weekday = weekdays[now.getDay()]
|
||||
const hours = String(now.getHours()).padStart(2, '0')
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(now.getSeconds()).padStart(2, '0')
|
||||
|
||||
currentTime.value = `${year}年${month}月${day}日 ${weekday} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
const startTimeUpdate = (): void => {
|
||||
updateTime()
|
||||
timeInterval = setInterval(updateTime, 1000)
|
||||
}
|
||||
|
||||
const stopTimeUpdate = (): void => {
|
||||
if (timeInterval) {
|
||||
clearInterval(timeInterval)
|
||||
timeInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentTime,
|
||||
startTimeUpdate,
|
||||
stopTimeUpdate,
|
||||
// 返回清理函数,让调用方在组件卸载时调用
|
||||
cleanup: () => {
|
||||
stopTimeUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
1618
src/views/screen/mainScreen.vue
Normal file
1618
src/views/screen/mainScreen.vue
Normal file
File diff suppressed because it is too large
Load Diff
1535
src/views/screen/mainScreenV1.vue
Normal file
1535
src/views/screen/mainScreenV1.vue
Normal file
File diff suppressed because it is too large
Load Diff
1089
src/views/screen/parkScreen.vue
Normal file
1089
src/views/screen/parkScreen.vue
Normal file
File diff suppressed because it is too large
Load Diff
1625
src/views/screen/regionScreen.vue
Normal file
1625
src/views/screen/regionScreen.vue
Normal file
File diff suppressed because it is too large
Load Diff
125
src/views/screen/report/index.ts
Normal file
125
src/views/screen/report/index.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// import request from '@/config/axios'
|
||||
import request from '../axios'
|
||||
|
||||
//获取报表列表
|
||||
export const getDbList = (data) => {
|
||||
let url = `/jeelowcode/report/page`
|
||||
if (data.pageSize !== undefined) {
|
||||
url = url + `?pageNo=${data.pageNo}&pageSize=${data.pageSize}`
|
||||
delete data.pageNo
|
||||
delete data.pageSize
|
||||
}
|
||||
return request.post({ url, data })
|
||||
}
|
||||
|
||||
//新增报表配置
|
||||
export const saveDbData = (data) => {
|
||||
return request.post({ url: '/jeelowcode/report/save', data })
|
||||
}
|
||||
|
||||
//修改报表配置
|
||||
export const updateDbData = (data) => {
|
||||
return request.put({ url: '/jeelowcode/report/update', data })
|
||||
}
|
||||
|
||||
//删除报表配置
|
||||
export const deleteDbData = (ids) => {
|
||||
return request.delete({ url: '/jeelowcode/report/delete', data: ids })
|
||||
}
|
||||
|
||||
//获取报表详情数据
|
||||
export const getDbDetail = (id) => {
|
||||
return request.post({ url: `/jeelowcode/report/detail?reportId=${id}`, data: ['all'] })
|
||||
}
|
||||
|
||||
|
||||
//获取报表分组数据
|
||||
export const getGroupData = (params) => {
|
||||
return request.get({ url: `/jeelowcode/group/report/list`, params })
|
||||
}
|
||||
//新增报表分组
|
||||
export const saveGroupData = (data) => {
|
||||
return request.post({ url: `/jeelowcode/group/report/save`, data })
|
||||
}
|
||||
//修改报表分组
|
||||
export const updateGroupData = (data) => {
|
||||
return request.put({ url: `/jeelowcode/group/report/update`, data })
|
||||
}
|
||||
//删除报表分组
|
||||
export const deleteGroupData = (ids) => {
|
||||
return request.delete({ url: '/jeelowcode/group/report/delete', data: ids })
|
||||
}
|
||||
|
||||
|
||||
//校验报表编码是否存在
|
||||
export const verifyReportCode = (code) => {
|
||||
return request.get({ url: '/jeelowcode/report/check/report-code?reportCode=' + code })
|
||||
}
|
||||
|
||||
|
||||
//复制报表
|
||||
export const copyReportData = (reportCode, newReportCode) => {
|
||||
return request.get({ url: `/jeelowcode/report/copy/${reportCode}?reportCode=${newReportCode}` })
|
||||
}
|
||||
|
||||
//获取报表Web配置数据
|
||||
export const getWebConfig = (reportCode) => {
|
||||
return request.get({ url: '/jeelowcode/report/get/web-config?reportCode=' + reportCode })
|
||||
}
|
||||
|
||||
//导出报表数据
|
||||
export const exportExcelData = (reportCode, data?) => {
|
||||
return request.download({ url: `/jeelowcode/excel/exportReport/${reportCode}`, method: 'POST', data })
|
||||
}
|
||||
|
||||
//获取报表数据
|
||||
export const getTableList = (reportCode, data?, isOpen?) => {
|
||||
return request.post({ url: `/jeelowcode/${isOpen ? 'open/report' : 'report-data'}/list/${reportCode}`, data })
|
||||
}
|
||||
|
||||
export const getDangerDetail = (campus_id: string) => {
|
||||
return request.post({ url: '/jeelowcode/report-data/list/dp_yq_danger_detail', data: {campus_id} })
|
||||
}
|
||||
|
||||
export const getZBDangerSum = (campus_id: string) => {
|
||||
return request.post({ url: '/jeelowcode/report-data/list/dp_zb_danger_sum', data: {campus_id} })
|
||||
}
|
||||
|
||||
export const getDangerCount = (campus_id: string) => {
|
||||
return request.post({ url: '/jeelowcode/report-data/list/dp_yq_danger_sum', data: {campus_id} })
|
||||
}
|
||||
|
||||
export const getExamDetail = (campus_id: string) => {
|
||||
return request.post({ url: '/jeelowcode/report-data/list/dp_yq_exam_detail', data: {campus_id} })
|
||||
}
|
||||
|
||||
export const getExamSum = (campus_id: string) => {
|
||||
return request.post({ url: '/jeelowcode/report-data/list/dp_zb_exam_sum', data: {campus_id} })
|
||||
}
|
||||
|
||||
export const getDrillSum = (campus_id: string) => {
|
||||
return request.post({ url: '/jeelowcode/report-data/list/dp_zb_drill_sum', data: {campus_id} })
|
||||
}
|
||||
|
||||
export const getDrillDetail = (campus_id: string) => {
|
||||
return request.post({ url: '/jeelowcode/report-data/list/dp_yq_drill_detail', data: {campus_id} })
|
||||
}
|
||||
|
||||
//获取报表数据
|
||||
export const getTableData = (tableId, data?) => {
|
||||
return request.post({ url: `/jeelowcode/dbform-data/list/${tableId}`, data })
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取报表数据
|
||||
* reportCodes:报表编码 多个用逗号隔开 xxx,xxx
|
||||
* data:报表对应的搜索值
|
||||
* 格式 {
|
||||
* 报表编码:{搜索配置}
|
||||
* }
|
||||
* */
|
||||
export const batchGetTableList = (reportCodes: string, data?) => {
|
||||
return request.post({ url: `/jeelowcode/report-data/batch/list/${reportCodes}`, data })
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user