This commit is contained in:
2025-10-17 10:31:13 +08:00
commit e6e86f2ce0
1043 changed files with 1031839 additions and 0 deletions

160
src/views/screen/README.md Normal file
View 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类型检查的严格模式

View 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>
}
}

View 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 }

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View 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()
}
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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()
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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 })
}