46 Commits

Author SHA1 Message Date
yangsy ee68a2032e fix(vimp): 修正 use-channels-query 中 cient 拼写错误
- import 与函数调用中两处 cientCatalogAllDeviceApi 修正为 clientCatalogAllDeviceApi
- 关联 9f127d2(重命名 VIMP API 文件)的残留拼写
2026-06-23 14:01:27 +08:00
yangsy ecfb6048ce 重构(vimp): 重命名VIMP API文件并更新调用处
将原有的catalog.channel和catalog.all-device API文件重命名为带client前缀的新文件,新增对应API实现模块,修复all-device接口函数名的拼写错误,并更新use-channels-query组合式函数中的API调用逻辑
2026-06-23 14:01:27 +08:00
yangsy a55f1d2632 fix(vimp接口): 调整API基础URL与接口请求路径
适配修改后的VIMP客户端基础API地址,同步更新各接口的请求路径以保证请求正确。
2026-06-23 14:01:27 +08:00
yangsy b6fb767928 feat(vimp): 新增ScreenPanel和screen-store支持多屏增删与重命名
- 新建 stores/screen.ts(id用 crypto.randomUUID 唯一生成、addScreen/removeScreen/renameScreen actions)
- 新建 screen-panel.vue 替换 canvas-area.vue(NTab + NTabs type=card + addable/closable)
- 添加屏幕增删的二次确认弹窗(window.$dialog.warning/info)
- 双击Tab弹出重命名输入框(沿用删除dialog的交互风格)
- 调整 onClose 入参命名为 id,语义与 store 一致
- 包裹 Tab 区域加 user-select: none,防止双击/拖动时文字被选中
- 附带:config-panel.vue 同步 Prettier 格式化(无逻辑改动)
2026-06-23 14:01:27 +08:00
yangsy c7c2fa5e22 refactor(vimp组件树): 提取默认展开逻辑为计算属性
统一告警树和摄像头树的展开行为,当搜索关键词不为空时自动展开所有节点,移除重复的内联计算代码,提升代码可维护性
2026-06-23 14:01:26 +08:00
yangsy 635d623994 refactor(vimp): 重命名右侧面板为ConfigPanel并镜像左侧面板结构
- 删除 right-panel.vue,新建 config-panel.vue + config-panel.ts store
- 接入 Pinia store 管理折叠状态,与 ResourcePanel store 结构镜像
- NTabs 改为纵向 placement=right + type=bar,Tab 标签加 lucide 图标
- 标题栏与 Tab 栏 72px 格纵向对齐,折叠后从右截断保留 Tab 标签
- 画布区域移除占位卡片,保留灰色斜纹 + drop 行为
- 删除未实现的"事件陷阱" Tab
2026-06-23 14:01:26 +08:00
yangsy 5092620008 feat(vimp): 在vimp-page中新增三栏布局占位(resource-panel + canvas-area + right-panel)
新增 components/canvas-area.vue(多屏NTabs + 灰色斜纹画布占位)。新增 components/right-panel.vue(5 NTabs 占位 + 折叠按钮)。改造 vimp-page.vue 为三栏 flex div + 内联样式。保留 ResourcePanel 与原有 drop 行为不动。
2026-06-23 14:01:26 +08:00
yangsy 2df67df74a feat(vimp): 新增资源面板组件并修正导入拼写错误
修复vimp页面资源面板组件的导入文件名拼写错误,新增的资源面板支持多标签资源分类、折叠展开切换以及对应标签页的搜索过滤功能。
2026-06-23 14:01:26 +08:00
yangsy 188e81c089 feat(vimp): 新增摄像头和告警通道的全局索引记录及构建方法
在摄像头、告警的Pinia存储中新增对应的数据记录与构建函数,并在渠道查询组合式函数中调用生成全局索引映射数据
2026-06-23 14:01:26 +08:00
yangsy 50a30ba106 refactor(vimp): 重命名设备查询为通道查询并更新相关引用
- 新增 vimp 常量文件,定义查询键 VIMP_CHANNELS_QUERY_KEY
- 重命名设备中心查询组合式函数为通道查询组合式函数
- 更新 alarm-tree.vue 和 camera-tree.vue 中的查询调用
- 优化通道数据排序与存储更新流程
2026-06-23 14:01:26 +08:00
yangsy 0d067f6e4f refactor(vimp): 拆分站点模型为 VimpRawSite / VimpSite 并归一化 online 字段
- 将 VimpStation 重命名为 VimpSite,统一"站点"语义贯穿全模块
- 新增 VimpRawSite 表示 /catalog/allDevice 接口原样数据,online 字段可选
- 抽离 normalizeVimpSite 纯函数,集中处理 online 字段缺失时的默认值
- catalogAllDeviceApi 入参改为 VimpRawSite[],出参统一归一化为 VimpSite[] | null
- 下游 store、composable、树节点类型同步从 VimpStation 切换到 VimpSite
- 内部业务模型保持 online: boolean 必填,避免缺失字段沿调用链向下传播
2026-06-23 14:01:26 +08:00
yangsy 1cb33d0406 feat(vimp客户端): 补充完整请求方法并添加统一认证与错误处理
- 新增VimpRequestOptions配置接口,支持自定义请求、响应及错误拦截器
- 补充get、put、delete请求方法并封装统一响应格式
- 添加默认请求拦截器自动携带公共认证参数
- 实现401错误拦截,超时自动跳转登录页并重置用户状态
2026-06-23 14:01:26 +08:00
yangsy 19961f6de2 style(vimp-tree): 优化线路树组件的标签页样式与文本展示
修改了alarm-tree.vue和camera-tree.vue两个组件,优化NTabs的样式配置,自定义标签尺寸与文本格式,调整内部布局细节
2026-06-23 14:01:25 +08:00
yangsy 7baabf8586 refactor(alarm-tree, camera-tree): 整合节点图标到标签并优化渲染逻辑
为树形节点标签添加弹性布局以正确对齐图标与文字,将图标渲染逻辑从单独的前缀函数整合到标签渲染函数中,为摄像机树形组件新增按类型匹配对应图标的逻辑,并移除了冗余的前缀渲染属性与函数
2026-06-23 14:01:25 +08:00
yangsy d3d1cc9587 refactor(vimp-alarm): 重构告警树数据构建逻辑并优化节点显示
- 统一区域数据处理流程,使用compiledCodeAreas替代原有的四个独立区域数组
- 使用Map索引替代线性查找,提升数据构建性能
- 将节点的后缀/前缀渲染从节点属性迁移至Tree组件的renderPrefix和renderSuffix方法
- 为区域节点添加areaLevel字段以适配不同层级的样式
- 为告警节点添加警笛图标前缀,为各层级节点添加统计信息后缀
2026-06-23 14:01:25 +08:00
yangsy adcebfa142 修复(vimp设备中心): 按编码排序站点与通道以稳定UI显示顺序
- 新增通用排序工具函数,对站点和通道数组按code字段排序,确保线路面板、站点节点和通道节点的显示顺序稳定一致,避免UI因数据源顺序波动出现跳动。
2026-06-23 14:01:25 +08:00
yangsy f6fe5867f8 style(use-device-center-query): 移除未使用的类型导入 2026-06-23 14:01:25 +08:00
yangsy e06afe36ae perf(vimp): 预编译码表区域索引优化查询性能
- 新增compileCodeAreas工具函数,将区域数据预构建为Map索引,降低区域查找的时间复杂度
- 重构camera store,移除冗余的区域遍历查找逻辑,改用预编译索引数据
新增节点areaLevel字段,适配不同层级节点的统计后缀样式间距
- 将摄像头图标和节点统计后缀的渲染逻辑迁移至camera-tree组件
- 清理调试控制台日志,简化空通道判断逻辑
2026-06-23 14:01:25 +08:00
yangsy 13fbac1771 refactor(vimp): 重命名映射变量并优化摄像头商店逻辑
1. 统一重命名站点告警、摄像头的映射变量为更具可读性的命名格式
2. 重构摄像头商店的buildLineTabPanes方法,提取重复逻辑为独立工具函数
3. 使用Map和Set优化节点索引与查找流程,避免重复添加摄像头节点
4. 统一统计后缀的渲染逻辑,简化代码结构
2026-06-23 14:01:25 +08:00
yangsy ef56f68530 perf(vimp): 优化存储响应式并重构设备查询逻辑
将 camera 和 alarm 存储的 lineTabPanes 从 ref 替换为 shallowRef,减少大型数组的响应式开销。重构设备查询组合式函数,拆分相机和告警的站点与通道数据,添加调试控制台日志。
2026-06-23 14:01:25 +08:00
yangsy b14290080f refactor(vimp): 修复重复站点问题并重构告警逻辑
- 新增站点去重逻辑避免重复条目
- 更新告警面板构建函数的参数与内部实现
- 修正摄像机和告警区域的注释表述
2026-06-23 14:01:25 +08:00
yangsy 773540b7d6 fix(device-center-query): 简化并修正设备站点与通道的处理逻辑
原先的逻辑先按API返回的站点分组通道,再通过通道编码重建站点列表,存在冗余且可能出错。现在改为单次循环直接基于通道编码生成正确的站点,并按站点分组相机和告警通道,同时完善了相关注释说明。
2026-06-23 14:01:24 +08:00
yangsy db7d282a05 style(use-device-center-query): 调整代码空行优化可读性 2026-06-23 14:01:24 +08:00
yangsy fa0fb4c3b1 refactor(vimp): 重构摄像头站点处理逻辑,修正站点匹配问题
将站点聚合逻辑从摄像头store移至设备中心查询模块,基于通道编码生成正确的站点列表,解决接口返回站点编码不匹配的问题
简化buildLineTabPanes函数的参数和内部处理流程
移除未使用的@vueuse/core的objectEntries导入
2026-06-23 14:01:24 +08:00
yangsy cc2c83baf7 refactor(vimp): 将普通对象替换为Map并优化代码逻辑 (不包含警报器树)
- 将站点摄像机和告警的映射存储从普通对象改为Map,提升查询性能
- 新增站点在线状态缓存和已访问站点集合,简化重复站点判断逻辑
- 移动buildTrainAreas和axios配置对象至顶层作用域
- 调整代码结构并临时注释告警存储的线路面板构建调用
2026-06-23 14:01:24 +08:00
yangsy 9770071b3b build(proxy): 添加内网后端代理注释配置
新增一条注释的内网后端代理配置,方便团队成员快速切换开发后端服务地址
2026-06-23 14:01:24 +08:00
yangsy 33659188b7 feat(vimp/codes): 添加新的站点、OCC及停车场代码条目 2026-06-23 14:01:24 +08:00
yangsy 0b0e15be65 fix(vimp设备中心): 修正摄像头和告警的站点映射及区域码截取逻辑
- 重命名站点摄像头和告警的映射变量以提升代码可读性,存储API返回数据时将站点代码截断为前6位。
- 新增摄像头站点列表生成逻辑,通过摄像头国标码修复原始站点列表的匹配错误问题,同时调整区域码截取长度,列车站点使用3位长度,其他站点使用2位。
2026-06-23 14:01:24 +08:00
yangsy 4e767d20fe refactor(vimp): 重构设备中心代码,修复图标渲染并整理导入
- 重新组织use-device-center-query的导入语句,合并api与类型导入
- 将接口返回的站点数据重命名为sitesFromApi以提升代码可读性
- 修复camera和alarm store中图标的渲染插槽语法
- 更新store方法调用时的参数传递
2026-06-23 14:01:24 +08:00
yangsy ec4e12ad6f style(vimp): 清理未使用的lucide图标导入 2026-06-23 14:01:23 +08:00
yangsy 82a690eb0d feat(resource-pannel): 为资源面板添加标签页图标和新标签,清理未使用导入
- 优化资源面板标签页布局,将图标置于文字上方
- 新增复合技和地图两个标签页
更新标签页数据结构以支持图标配置
移除未使用的naive-ui和vueuse依赖导入
2026-06-23 14:01:23 +08:00
yangsy 59466a2913 feat(vimp-resource): 优化资源面板,添加设备搜索与图标展示
- 新增bullet-camera、hemi-ptz-camera、ptz-camera三个自定义svg摄像头图标
- 替换告警和摄像头列表的文字前缀为对应图标展示
- 重构资源面板状态管理,简化搜索关键词的存储逻辑
- 为摄像头和告警树添加本地搜索过滤功能,搜索时自动展开所有节点
- 重构资源面板UI布局,添加折叠动画,优化搜索框显示逻辑与侧边栏样式
2026-06-23 14:01:23 +08:00
yangsy 6497c0a9e8 fix(resource-pannel): 调整资源面板的展开触发方式为点击标签页
移除顶部资源标题的点击展开事件,为各标签页添加点击触发展开的事件并优化模板条件顺序,提升用户体验
2026-06-23 14:01:23 +08:00
yangsy 6d63f1e301 refactor(vimp): 提取资源面板为独立组件并添加pinia存储
- 将原内嵌的资源标签页逻辑提取为独立组件
- 新增专用pinia存储管理资源面板的折叠和搜索状态
- 统一折叠展开与搜索交互的逻辑实现
2026-06-23 14:01:23 +08:00
yangsy 013d21d79d refactor(vimp): 重构模块结构,优化代码组织
- 将设备中心查询逻辑从API层抽取至composables目录,封装为useDeviceCenterQuery组合式函数
- 拆分camera、alarm的状态管理为独立store文件,新增资源面板搜索状态store
- 更新相关组件的依赖导入路径,清理冗余导出并调整导出列表
2026-06-23 14:01:23 +08:00
yangsy f46f7de17e refactor(vimp): 移除冗余的选中设备GB编码相关代码
删除alarm-tree、camera-tree组件中的选中状态定义、节点绑定及双击设置逻辑,同时移除vimp主页面中对应的状态声明、组件传参和状态展示代码
2026-06-23 14:01:23 +08:00
yangsy 94fe2ea407 重构(alarm-tree): 使用 alarmOnline 函数替代直接状态检查
将多处行内的告警状态检查替换为统一的 alarmOnline 辅助函数,后续若需调整在线状态校验逻辑仅需修改一处,提升代码可读性与可维护性。
2026-06-23 14:01:23 +08:00
yangsy b3e6b9867c fix(device-center-query): 移除设备及告警数据赋值的非空判断
解决空数组无法更新对应站点映射的问题,避免残留旧数据
2026-06-23 14:01:22 +08:00
yangsy 723ee59376 refactor(vimp/types): 重命名树类型模块为设备树并补充类型定义
创建device-tree.ts作为新的设备树类型模块,迁移原tree.ts中的类型定义,新增摄像机、警报器设备树的节点类型、类型守卫函数及标签页属性类型,同时更新类型入口文件的导出路径。
2026-06-23 14:01:22 +08:00
yangsy d47d0c6fa8 refactor(vimp): 抽离并重构vimp的摄像机、告警store与树形类型
- 新增camera-store.ts与alarm-store.ts,封装摄像机、告警业务逻辑为独立Pinia store
- 重构tree.ts中的树形节点类型命名与关联判断函数
- 更新stores/index.ts的导出文件路径
- 移除alarm-tree.vue中的冗余类型导入
2026-06-23 14:01:22 +08:00
yangsy d6679d9a6d refactor(vimp): 重构vimp模块的API目录与导入路径
重新梳理vimp模块的API代码结构,拆分为client、model、query、request子模块并添加统一导出入口;修正所有相关文件的导入路径,新增通用响应类型与工具函数,优化树组件的类型判断逻辑,同时新增设备查询相关API与查询hook。
2026-06-23 14:01:22 +08:00
yangsy 39e821e12a feat(vimp): 设备树原型 2026-06-23 14:01:22 +08:00
yangsy 1634ed200d build(vite config): 优化开发服务器代理与端口配置
- 将开发服务器端口提取为常量 SERVER_PORT 以简化维护
- 为 API 代理添加环境注释并注释备用后端地址
- 新增本地测试用的 vimp/api 代理配置
- 新增 CDN 代理指向本地开发服务器端口
2026-06-23 14:01:22 +08:00
yangsy 5a06667c31 feat(codes): 新增地铁基础静态配置JSON数据
本次新增public/vimp/codes/目录下的5个静态配置文件,覆盖地铁线路信息、区域分区编码、停车区域、站点内部区域细分以及全量站点列表,为地铁相关业务提供基础数据支撑。
2026-06-23 14:01:21 +08:00
yangsy 679848761f fix: 修复关闭调试模式时未自动启用订阅消息 2026-06-23 13:58:32 +08:00
yangsy 1e2ffddeae feat(settings-drawer): 为调试码输入框添加自动聚焦功能
更新了相关导入,添加输入框模板引用并在调试模态框打开时自动聚焦输入框
2026-06-09 13:19:34 +08:00
29 changed files with 1017 additions and 439 deletions
+6
View File
@@ -716,6 +716,12 @@
"018507": { "name": "康桥站", "type": "station" },
"018508": { "name": "御桥站", "type": "station" },
"021509": { "name": "申江南路", "type": "station" },
"021575": { "name": "OCC", "type": "occ" },
"021609": { "name": "申江南路", "type": "station" },
"021675": { "name": "OCC", "type": "occ" },
"021680": { "name": "六陈路车辆段", "type": "parking" },
"051501": { "name": "沈杜公路", "type": "station" },
"051502": { "name": "三鲁公路", "type": "station" },
"051503": { "name": "闵瑞路", "type": "station" },
@@ -13,9 +13,27 @@ import destr from 'destr';
import { isFunction } from 'es-toolkit';
import localforage from 'localforage';
import { DownloadIcon, Trash2Icon, UploadIcon } from 'lucide-vue-next';
import { NButton, NButtonGroup, NDivider, NDrawer, NDrawerContent, NDropdown, NFlex, NFormItem, NIcon, NInput, NInputNumber, NModal, NSwitch, NText, NTooltip, type DropdownOption } from 'naive-ui';
import {
NButton,
NButtonGroup,
NDivider,
NDrawer,
NDrawerContent,
NDropdown,
NFlex,
NFormItem,
NIcon,
NInput,
NInputNumber,
NModal,
NSwitch,
NText,
NTooltip,
type DropdownOption,
type InputInst,
} from 'naive-ui';
import { storeToRefs } from 'pinia';
import { computed, ref, watch } from 'vue';
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
@@ -149,8 +167,14 @@ useEventListener('keydown', (event) => {
});
const expectToShowDebugCodeInput = ref(false);
const debugCodeInputRef = useTemplateRef<InputInst>('debug-code-input-ref');
const onModalAfterEnter = () => {
expectToShowDebugCodeInput.value = !debugMode.value;
if (expectToShowDebugCodeInput.value) {
nextTick(() => {
debugCodeInputRef.value?.focus();
});
}
};
const onModalAfterLeave = () => {
expectToShowDebugCodeInput.value = false;
@@ -412,7 +436,7 @@ const onClickVersion = () => {
<NText v-else>确认关闭调试模式</NText>
</template>
<template #default>
<NInput v-if="expectToShowDebugCodeInput" v-model:value="debugCode" placeholder="输入调试码" @keyup.enter="enableDebugMode" />
<NInput ref="debug-code-input-ref" v-if="expectToShowDebugCodeInput" v-model:value="debugCode" placeholder="输入调试码" @keyup.enter="enableDebugMode" />
</template>
<template #action>
<NButton @click="showDebugCodeModal = false">取消</NButton>
+111 -4
View File
@@ -1,9 +1,59 @@
import type { AxiosError, AxiosRequestConfig, CreateAxiosDefaults } from 'axios';
import axios from 'axios';
import type { AxiosError, AxiosRequestConfig, AxiosResponse, CreateAxiosDefaults, InternalAxiosRequestConfig } from 'axios';
import axios, { isAxiosError } from 'axios';
import type { VimpResponse, VimpResult } from '../../types';
import { useUserStore } from '@/stores';
import { getAppEnvConfig } from '@/utils';
import router from '@/router';
export interface VimpRequestOptions extends CreateAxiosDefaults {
requestInterceptor?: (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig | Promise<InternalAxiosRequestConfig>;
responseInterceptor?: (resp: AxiosResponse) => AxiosResponse | Promise<AxiosResponse>;
responseErrorInterceptor?: (error: any) => any;
}
export const createVimpClient = (config?: VimpRequestOptions) => {
const defaultRequestInterceptor = (config: InternalAxiosRequestConfig) => config;
const defaultResponseInterceptor = (response: AxiosResponse) => response;
const defaultResponseErrorInterceptor = (error: any) => {
if (isAxiosError(error)) {
if (error.status === 401) {
// 处理 401 错误
}
if (error.status === 404) {
// 处理 404 错误
}
}
return Promise.reject(error);
};
const requestInterceptor = config?.requestInterceptor ?? defaultRequestInterceptor;
const responseInterceptor = config?.responseInterceptor ?? defaultResponseInterceptor;
const responseErrorInterceptor = config?.responseErrorInterceptor ?? defaultResponseErrorInterceptor;
export const createVimpClient = (config?: CreateAxiosDefaults) => {
const instance = axios.create(config);
instance.interceptors.request.use(requestInterceptor);
instance.interceptors.response.use(responseInterceptor, responseErrorInterceptor);
const vimpGet = <T>(url: string, options?: AxiosRequestConfig & { retRaw?: boolean }): Promise<VimpResponse<T>> => {
const { retRaw, ...reqConfig } = options ?? {};
return new Promise((resolve) => {
instance
.get(url, {
...reqConfig,
})
.then((res) => {
if (retRaw) {
resolve([null, res.data as T, null]);
} else {
const resData = res.data as VimpResult<T>;
resolve([null, resData.data, resData]);
}
})
.catch((err) => {
resolve([err as AxiosError, null, null]);
});
});
};
const vimpPost = <T>(url: string, data?: AxiosRequestConfig['data'], options?: Partial<Omit<AxiosRequestConfig, 'data'>> & { retRaw?: boolean; upload?: boolean }): Promise<VimpResponse<T>> => {
const { retRaw, upload, ...reqConfig } = options ?? {};
@@ -24,9 +74,40 @@ export const createVimpClient = (config?: CreateAxiosDefaults) => {
});
};
const httpPut = <T>(url: string, data?: AxiosRequestConfig['data'], options?: Partial<Omit<AxiosRequestConfig, 'data'>>): Promise<VimpResponse<T>> => {
const reqConfig = options ?? {};
return new Promise((resolve) => {
instance
.put<VimpResult<T>>(url, data, { ...reqConfig })
.then((res) => {
resolve([null, res.data.data, res.data]);
})
.catch((err) => {
resolve([err as AxiosError, null, null]);
});
});
};
const httpDelete = <T>(url: string, idList: string[], options?: Partial<Omit<AxiosRequestConfig, 'data'>>): Promise<VimpResponse<T>> => {
const reqConfig = options ?? {};
return new Promise((resolve) => {
instance
.delete<VimpResult<T>>(url, { ...reqConfig, data: idList })
.then((res) => {
resolve([null, res.data.data, res.data]);
})
.catch((err) => {
resolve([err as AxiosError, null, null]);
});
});
};
return {
instance,
get: vimpGet,
post: vimpPost,
put: httpPut,
delete: httpDelete,
};
};
@@ -41,5 +122,31 @@ export const unwrapVimpResponse = <T>(resp: VimpResponse<T>) => {
};
export const vimpClient = createVimpClient({
baseURL: `/vimp/api/client`,
baseURL: `/vimp/api`,
requestInterceptor: (config) => {
const userStore = useUserStore();
const { lampAuthorization, lampClientId, lampClientSecret } = getAppEnvConfig();
const newAuthorization = window.btoa(`${lampClientId}:${lampClientSecret}`);
const authorization = lampAuthorization.trim() !== '' ? lampAuthorization : newAuthorization;
config.headers.set('accept-language', 'zh-CN,zh;q=0.9');
config.headers.set('accept', 'application/json, text/plain, */*');
config.headers.set('Applicationid', '');
config.headers.set('Tenantid', '1');
config.headers.set('Authorization', authorization);
config.headers.set('token', userStore.userLoginResult?.token ?? '');
return config;
},
responseInterceptor: (response) => {
return response;
},
responseErrorInterceptor: (error) => {
const err = error as AxiosError;
if (err.response?.status === 401) {
window.$message.error('登录超时,请重新登录');
const userStore = useUserStore();
userStore.resetStore();
router.push({ path: '/login' });
}
return Promise.reject(error);
},
});
+1 -1
View File
@@ -1,2 +1,2 @@
export * from './vimp-channel';
export * from './vimp-station';
export * from './vimp-site';
+17
View File
@@ -0,0 +1,17 @@
export interface VimpRawSite {
code: string;
name: string;
online?: boolean;
}
export interface VimpSite {
code: string;
name: string;
online: boolean;
}
export const normalizeVimpSite = (site: VimpRawSite): VimpSite => ({
code: site.code,
name: site.name,
online: site.online ?? true,
});
@@ -1,5 +0,0 @@
export interface VimpStation {
code: string;
name: string;
online: boolean;
}
@@ -1,11 +0,0 @@
import { unwrapVimpResponse, vimpClient } from '../client';
import type { VimpStation } from '../model';
export const catalogAllDeviceApi = async (options?: { signal?: AbortSignal }) => {
const { signal } = options ?? {};
const client = vimpClient;
const endpoint = `/catalog/allDevice`;
const resp = await client.post<VimpStation[]>(endpoint, {}, { signal });
const data = unwrapVimpResponse(resp);
return data;
};
@@ -0,0 +1,11 @@
import { unwrapVimpResponse, vimpClient } from '../client';
import { normalizeVimpSite, type VimpRawSite } from '../model';
export const clientCatalogAllDeviceApi = async (options?: { signal?: AbortSignal }) => {
const { signal } = options ?? {};
const client = vimpClient;
const endpoint = `/client/catalog/allDevice`;
const resp = await client.post<VimpRawSite[]>(endpoint, {}, { signal });
const data = unwrapVimpResponse(resp);
return data?.map(normalizeVimpSite) ?? null;
};
@@ -1,10 +1,10 @@
import { unwrapVimpResponse, vimpClient } from '../client';
import type { VimpChannel } from '../model';
export const catalogChannelApi = async (code: string, options?: { signal?: AbortSignal }) => {
export const clientCatalogChannelApi = async (code: string, options?: { signal?: AbortSignal }) => {
const { signal } = options ?? {};
const client = vimpClient;
const endpoint = `/catalog/channel`;
const endpoint = `/client/catalog/channel`;
const resp = await client.post<VimpChannel[]>(endpoint, { code, time: '' }, { signal });
const data = unwrapVimpResponse(resp);
return data;
+2 -2
View File
@@ -1,2 +1,2 @@
export * from './catalog.channel';
export * from './catalog.all-device';
export * from './client.catalog.channel';
export * from './client.catalog.all-device';
+64 -22
View File
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { NTabPane, NTabs, NTree, type TreeOverrideNodeClickBehavior, type TreeProps } from 'naive-ui';
import { h, type CSSProperties } from 'vue';
import { NIcon, NTabPane, NTabs, NTree, type TreeOverrideNodeClickBehavior, type TreeProps } from 'naive-ui';
import { computed, h, type CSSProperties } from 'vue';
import { useAlarmStore, useResourcePanelStore } from '../stores';
import { storeToRefs } from 'pinia';
import { useDeviceCenterQuery } from '../composables';
import { useChannelsQuery } from '../composables';
import { isAlarmNode, isAlarmSiteNode, isAlarmAreaNode } from '../types';
import { SirenIcon } from 'lucide-vue-next';
const { isLoading } = useDeviceCenterQuery();
const { isLoading } = useChannelsQuery();
const alarmStore = useAlarmStore();
const { lineTabPanes } = storeToRefs(alarmStore);
@@ -51,6 +52,9 @@ const renderNodeLabel: TreeProps['renderLabel'] = ({ option }) => {
const alarmNodeStyle: CSSProperties = {
opacity: alarmOnline() ? 1 : 0.5,
cursor: alarmOnline() ? 'pointer' : 'not-allowed',
display: 'flex',
alignItems: 'center',
gap: '6px',
};
return h(
'div',
@@ -69,7 +73,7 @@ const renderNodeLabel: TreeProps['renderLabel'] = ({ option }) => {
event.dataTransfer?.setData('name', alarm.name);
},
},
alarm.name,
[h(NIcon, () => h(SirenIcon)), h('span', alarm.name)],
);
}
@@ -77,9 +81,26 @@ const renderNodeLabel: TreeProps['renderLabel'] = ({ option }) => {
return option.label;
};
const renderNodeSuffix: TreeProps['renderSuffix'] = ({ option }) => {
if (isAlarmSiteNode(option)) {
const { online, offline, total } = option.stats;
return `(${online}/${offline}/${total})`;
}
if (isAlarmAreaNode(option)) {
const { online, offline, total } = option.stats;
const suffixStyle: CSSProperties = option.areaLevel === 1 ? { marginRight: '8px', opacity: 0.6 } : { marginRight: '16px', opacity: 0.4 };
return h('div', { style: suffixStyle }, `(${online}/${offline}/${total})`);
}
return null;
};
const resourcePanelStore = useResourcePanelStore();
const { searchPattern } = storeToRefs(resourcePanelStore);
const defaultExpandAll = computed(() => searchPattern.value.trim().length > 0);
const searchFilter: TreeProps['filter'] = (pattern, node) => {
if (!isAlarmNode(node)) return false;
return node.alarm.name.includes(pattern);
@@ -98,8 +119,9 @@ const searchFilter: TreeProps['filter'] = (pattern, node) => {
virtual-scroll
style="height: 100%"
:render-label="renderNodeLabel"
:render-suffix="renderNodeSuffix"
:override-default-node-click-behavior="overrideNodeClickBehavior"
:default-expand-all="searchPattern.trim().length > 0"
:default-expand-all="defaultExpandAll"
:show-irrelevant-nodes="false"
:data="lineTabPanes.at(0)?.alarmTree"
:pattern="searchPattern"
@@ -107,22 +129,42 @@ const searchFilter: TreeProps['filter'] = (pattern, node) => {
/>
</template>
<template v-if="lineTabPanes.length > 1">
<NTabs :type="'card'" :placement="'left'" style="height: 100%">
<NTabPane v-for="{ lineCode, lineName, alarmTree } in lineTabPanes" :key="lineCode" :name="lineName" :tab="lineName">
<NTree
block-line
block-node
show-line
virtual-scroll
style="height: 100%"
:render-label="renderNodeLabel"
:override-default-node-click-behavior="overrideNodeClickBehavior"
:default-expand-all="false"
:show-irrelevant-nodes="false"
:data="alarmTree"
:pattern="searchPattern"
:filter="searchFilter"
/>
<NTabs
:type="'card'"
:placement="'left'"
style="height: 100%"
:tab-style="{
width: '64px',
height: '36px',
}"
:style="{
'--n-bar-color': '#0000',
'--n-pane-padding-top': '0',
'--n-tab-gap-vertical': '0',
// '--n-tab-padding-vertical': '14px 12px'
}"
>
<NTabPane v-for="{ lineCode, lineName, alarmTree } in lineTabPanes" :key="lineCode" :name="lineName">
<template #tab>
<span style="font-size: 12px">{{ lineName }}</span>
</template>
<template #default>
<NTree
block-line
block-node
show-line
virtual-scroll
style="height: 100%"
:render-label="renderNodeLabel"
:render-suffix="renderNodeSuffix"
:override-default-node-click-behavior="overrideNodeClickBehavior"
:default-expand-all="defaultExpandAll"
:show-irrelevant-nodes="false"
:data="alarmTree"
:pattern="searchPattern"
:filter="searchFilter"
/>
</template>
</NTabPane>
</NTabs>
</template>
+67 -22
View File
@@ -1,12 +1,15 @@
<script setup lang="ts">
import { NTabPane, NTabs, NTree, type TreeOverrideNodeClickBehavior, type TreeProps } from 'naive-ui';
import { h, type CSSProperties } from 'vue';
import { NIcon, NTabPane, NTabs, NTree, type TreeOverrideNodeClickBehavior, type TreeProps } from 'naive-ui';
import { computed, h, type CSSProperties } from 'vue';
import { useCameraStore, useResourcePanelStore } from '../stores';
import { storeToRefs } from 'pinia';
import { useDeviceCenterQuery } from '../composables';
import { useChannelsQuery } from '../composables';
import { isCameraNode, isCameraSiteNode, isCameraAreaNode } from '../types';
import PtzCamera from './icon/ptz-camera.vue';
import HemiPtzCamera from './icon/hemi-ptz-camera.vue';
import BulletCamera from './icon/bullet-camera.vue';
const { isLoading } = useDeviceCenterQuery();
const { isLoading } = useChannelsQuery();
const cameraStore = useCameraStore();
const { lineTabPanes } = storeToRefs(cameraStore);
@@ -51,7 +54,11 @@ const renderNodeLabel: TreeProps['renderLabel'] = ({ option }) => {
const cameraNodeStyle: CSSProperties = {
opacity: cameraOnline() ? 1 : 0.5,
cursor: cameraOnline() ? 'pointer' : 'not-allowed',
display: 'flex',
alignItems: 'center',
gap: '6px',
};
const cameraIcon = option.type === '004' ? h(NIcon, () => h(PtzCamera)) : option.type === '005' ? h(NIcon, () => h(HemiPtzCamera)) : option.type === '006' ? h(NIcon, () => h(BulletCamera)) : null;
return h(
'div',
{
@@ -69,7 +76,7 @@ const renderNodeLabel: TreeProps['renderLabel'] = ({ option }) => {
event.dataTransfer?.setData('name', camera.name);
},
},
camera.name,
[cameraIcon, h('span', camera.name)],
);
}
@@ -77,9 +84,26 @@ const renderNodeLabel: TreeProps['renderLabel'] = ({ option }) => {
return option.label;
};
const renderNodeSuffix: TreeProps['renderSuffix'] = ({ option }) => {
if (isCameraSiteNode(option)) {
const { online, offline, total } = option.stats;
return `(${online}/${offline}/${total})`;
}
if (isCameraAreaNode(option)) {
const { online, offline, total } = option.stats;
const suffixStyle: CSSProperties = option.areaLevel === 1 ? { marginRight: '8px', opacity: 0.6 } : { marginRight: '16px', opacity: 0.4 };
return h('div', { style: suffixStyle }, `(${online}/${offline}/${total})`);
}
return null;
};
const resourcePanelStore = useResourcePanelStore();
const { searchPattern } = storeToRefs(resourcePanelStore);
const defaultExpandAll = computed(() => searchPattern.value.trim().length > 0);
const searchFilter: TreeProps['filter'] = (pattern, node) => {
if (!isCameraNode(node)) return false;
return node.camera.name.includes(pattern);
@@ -98,8 +122,9 @@ const searchFilter: TreeProps['filter'] = (pattern, node) => {
virtual-scroll
style="height: 100%"
:render-label="renderNodeLabel"
:render-suffix="renderNodeSuffix"
:override-default-node-click-behavior="overrideNodeClickBehavior"
:default-expand-all="searchPattern.trim().length > 0"
:default-expand-all="defaultExpandAll"
:show-irrelevant-nodes="false"
:data="lineTabPanes.at(0)?.cameraTree"
:pattern="searchPattern"
@@ -107,22 +132,42 @@ const searchFilter: TreeProps['filter'] = (pattern, node) => {
/>
</template>
<template v-if="lineTabPanes.length > 1">
<NTabs :type="'card'" :placement="'left'" style="height: 100%">
<NTabPane v-for="{ lineCode, lineName, cameraTree } in lineTabPanes" :key="lineCode" :name="lineName" :tab="lineName">
<NTree
block-line
block-node
show-line
virtual-scroll
style="height: 100%"
:render-label="renderNodeLabel"
:override-default-node-click-behavior="overrideNodeClickBehavior"
:default-expand-all="false"
:show-irrelevant-nodes="false"
:data="cameraTree"
:pattern="searchPattern"
:filter="searchFilter"
/>
<NTabs
:type="'card'"
:placement="'left'"
style="height: 100%"
:tab-style="{
width: '64px',
height: '36px',
}"
:style="{
'--n-bar-color': '#0000',
'--n-pane-padding-top': '0',
'--n-tab-gap-vertical': '0',
// '--n-tab-padding-vertical': '14px 12px'
}"
>
<NTabPane v-for="{ lineCode, lineName, cameraTree } in lineTabPanes" :key="lineCode" :name="lineName">
<template #tab>
<span style="font-size: 12px">{{ lineName }}</span>
</template>
<template #default>
<NTree
block-line
block-node
show-line
virtual-scroll
style="height: 100%"
:render-label="renderNodeLabel"
:render-suffix="renderNodeSuffix"
:override-default-node-click-behavior="overrideNodeClickBehavior"
:default-expand-all="defaultExpandAll"
:show-irrelevant-nodes="false"
:data="cameraTree"
:pattern="searchPattern"
:filter="searchFilter"
/>
</template>
</NTabPane>
</NTabs>
</template>
+123
View File
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { NButton, NIcon, NTabPane, NTabs, NText } from 'naive-ui';
import { ChevronRightIcon, DatabaseIcon, LayoutGridIcon, SlidersHorizontalIcon, ZapIcon } from 'lucide-vue-next';
import { ref, type Component } from 'vue';
import { useConfigPanelStore } from '../stores';
import { storeToRefs } from 'pinia';
interface ControlTabPane {
name: string;
tab: string;
icon: Component;
}
const tabs: ControlTabPane[] = [
{ name: 'component', tab: '组件', icon: LayoutGridIcon },
{ name: 'config', tab: '属性', icon: SlidersHorizontalIcon },
{ name: 'data', tab: '数据', icon: DatabaseIcon },
{ name: 'interaction', tab: '事件', icon: ZapIcon },
];
const PANEL_WIDTH_EXPANDED = '320px';
const PANEL_WIDTH_COLLAPSED = '72px';
const TAB_WIDTH = '72px';
const activeTab = ref(tabs[0]?.name ?? '');
const configPanelStore = useConfigPanelStore();
const { collapsed } = storeToRefs(configPanelStore);
const expandConfigPanel = () => {
if (collapsed.value) {
configPanelStore.toggleCollapsed();
}
};
</script>
<template>
<div
:style="{
width: collapsed ? PANEL_WIDTH_COLLAPSED : PANEL_WIDTH_EXPANDED,
flexShrink: 0,
height: '100%',
display: 'flex',
justifyContent: 'flex-end',
overflow: 'hidden',
transition: 'width 0.2s',
}"
>
<div
:style="{
width: PANEL_WIDTH_EXPANDED,
height: '100%',
display: 'flex',
flexDirection: 'column',
}"
>
<div
:style="{
height: '42px',
flexShrink: 0,
padding: '8px 0',
display: 'flex',
alignItems: 'center',
}"
>
<div
:style="{
display: 'grid',
placeItems: 'center',
width: '32px',
marginRight: 'auto',
}"
>
<NButton text @click="configPanelStore.toggleCollapsed()">
<NIcon :component="ChevronRightIcon" />
</NButton>
</div>
<div
:style="{
display: 'grid',
placeItems: 'center',
width: TAB_WIDTH,
}"
>
<NText>控制</NText>
</div>
</div>
<div
:style="{
flex: 1,
minHeight: 0,
overflow: 'hidden',
}"
>
<NTabs
v-model:value="activeTab"
:type="'bar'"
:placement="'right'"
:size="'small'"
:tab-style="{
width: TAB_WIDTH,
height: '64px',
}"
:style="{
height: '100%',
'--n-pane-padding-top': '0',
'--n-tab-gap-vertical': '0',
}"
>
<NTabPane v-for="t in tabs" :key="t.name" :name="t.name" :tab="t.tab" :tab-props="{ onClick: () => expandConfigPanel() }">
<template #tab>
<div :style="{ width: '48px', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }">
<NIcon :size="18" :component="t.icon" />
<div :style="{ fontSize: '12px' }">{{ t.tab }}</div>
</div>
</template>
<div :style="{ padding: '20px', textAlign: 'center', fontSize: '12px' }">{{ t.tab }}面板占位</div>
</NTabPane>
</NTabs>
</div>
</div>
</div>
</template>
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { NInput, NTabs, NTab } from 'naive-ui';
import { h, ref } from 'vue';
import { useScreenStore } from '../stores';
import { storeToRefs } from 'pinia';
const screenStore = useScreenStore();
const { screens, activeScreenId } = storeToRefs(screenStore);
const onAdd = () => {
screenStore.addScreen();
};
const onClose = (id: string) => {
const screen = screens.value.find(s => s.id === id)
if (!screen) return
window.$dialog.warning({
title: '删除屏幕',
content: `确认删除屏幕"${screen.name}"吗?此操作无法撤销。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => {
screenStore.removeScreen(id)
},
})
};
const renameValue = ref('');
const onTabDblclick = (id: string) => {
const screen = screens.value.find(s => s.id === id)
if (!screen) return
renameValue.value = screen.name
window.$dialog.info({
title: '重命名屏幕',
positiveText: '确认',
negativeText: '取消',
content: () =>
h(NInput, {
value: renameValue.value,
'onUpdate:value': (v: string) => {
renameValue.value = v
},
placeholder: '请输入屏幕名称',
autofocus: true,
onKeyup: (e: KeyboardEvent) => {
if (e.key === 'Enter') {
handleRenameConfirm(id)
}
},
}),
onPositiveClick: () => {
handleRenameConfirm(id)
},
})
};
const handleRenameConfirm = (id: string) => {
screenStore.renameScreen(id, renameValue.value)
};
const onDragover = (e: DragEvent) => {
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
}
const onDrop = (e: DragEvent) => {
e.preventDefault()
// TODO: 放置占位组件(等 grid-layout-plus 接入后实现)
}
</script>
<template>
<div :style="{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }">
<div :style="{ flexShrink: 0, padding: '0 8px', userSelect: 'none' }">
<NTabs v-model:value="activeScreenId" type="card" size="small" animated :addable="true" @add="onAdd" @close="onClose">
<NTab v-for="s in screens" :key="s.id" :name="s.id" :tab="s.name" closable @dblclick="onTabDblclick(s.id)" />
</NTabs>
</div>
<div
:style="{
flex: 1,
minHeight: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#f5f5f5',
backgroundImage:
'linear-gradient(45deg, #e5e5e5 25%, transparent 25%), linear-gradient(-45deg, #e5e5e5 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e5e5e5 75%), linear-gradient(-45deg, transparent 75%, #e5e5e5 75%)',
backgroundSize: '16px 16px',
backgroundPosition: '0 0, 0 8px, 8px -8px, -8px 0',
}"
@dragover="onDragover"
@drop="onDrop"
/>
</div>
</template>
+1 -1
View File
@@ -1 +1 @@
export * from './use-device-center-query';
export * from './use-channels-query';
@@ -0,0 +1,151 @@
import { useQuery } from '@tanstack/vue-query';
import { computed } from 'vue';
import type { AxiosRequestConfig } from 'axios';
import axios from 'axios';
import { compileCodeAreas, type CodeArea, type CodeLines, type CodeSites } from '../../types';
import { useCameraStore, useAlarmStore } from '../../stores';
import { clientCatalogAllDeviceApi, clientCatalogChannelApi, type VimpChannel, type VimpSite } from '../../apis';
import { VIMP_CHANNELS_QUERY_KEY } from '../../constants';
const config: AxiosRequestConfig = {
headers: {
'Cache-Control': 'no-store',
},
};
const buildTrainAreas = () => {
const codeTrainAreas: CodeArea[] = [];
for (let i = 0; i < 999; i++) {
const codeTrain = i.toString().padStart(3, '0');
// 市域线name为车组,改造线name为车次
const area: CodeArea = { code: codeTrain, name: '车次' + codeTrain, subs: [] };
for (let j = 0; j <= 99; j++) {
const codeCarriage = j.toString().padStart(2, '0');
const subArea: CodeArea['subs'][number] = { code: codeTrain + codeCarriage, name: '车厢' + codeCarriage };
area.subs.push(subArea);
}
// const areaPreserve: CodeArea['subs'][number] = { code: codeTrain + '51', name: '预留' };
// area.subs.push(areaPreserve);
codeTrainAreas.push(area);
}
return codeTrainAreas;
};
const compareByCode = <T extends { code: string }>(a: T, b: T) => {
if (a.code < b.code) return -1;
if (a.code > b.code) return 1;
return 0;
};
const sortSitesByCode = (sites: VimpSite[]) => {
sites.sort(compareByCode);
};
const sortChannelsMapByCode = (siteCodeToChannelsMap: Map<string, VimpChannel[]>) => {
for (const channels of siteCodeToChannelsMap.values()) {
channels.sort(compareByCode);
}
};
export const useChannelsQuery = () => {
const cameraStore = useCameraStore();
const alarmStore = useAlarmStore();
return useQuery({
queryKey: computed(() => [VIMP_CHANNELS_QUERY_KEY]),
refetchInterval: 10 * 1000,
refetchOnWindowFocus: false,
queryFn: async ({ signal }) => {
// 请求所有码表
const codeLines = (await axios.get<CodeLines>('/cdn/vimp/codes/codeLines.json', config)).data;
const codeSites = (await axios.get<CodeSites>('/cdn/vimp/codes/codeStations.json', config)).data;
const codeStationAreas = (await axios.get<CodeArea[]>('/cdn/vimp/codes/codeStationAreas.json', config)).data;
const codeParkingAreas = (await axios.get<CodeArea[]>('/cdn/vimp/codes/codeParkingAreas.json', config)).data;
const codeOccAreas = (await axios.get<CodeArea[]>('/cdn/vimp/codes/codeOccAreas.json', config)).data;
const codeTrainAreas = buildTrainAreas();
// 预编译区域码表索引 (性能优化)
const compiledCodeAreas = compileCodeAreas({
codeStationAreas,
codeParkingAreas,
codeOccAreas,
codeTrainAreas,
});
const sitesFromApi = await clientCatalogAllDeviceApi({ signal });
// 从 /allDevice 接口获取的站点信息并不保证真实性和完整性,
// 例如有一个站点的编码是 010699 开头,但是其下的通道是 010199 和 010599 开头,
// 而 010699 是一个不存在的站点编码,所以需要基于通道的编码来确定所有的站点。
const cameraSites: VimpSite[] = [];
const alarmSites: VimpSite[] = [];
const cameraBuiltSitesSet = new Set<string>();
const alarmBuiltSitesSet = new Set<string>();
const siteCodeToCamerasMap = new Map<string, VimpChannel[]>();
const siteCodeToAlarmsMap = new Map<string, VimpChannel[]>();
for (const siteFromApi of sitesFromApi ?? []) {
const channels = await clientCatalogChannelApi(siteFromApi.code, { signal });
if (!channels) continue;
channels.forEach((channel) => {
const siteCode = channel.code.substring(0, 6);
const typeCode = Number(channel.code.substring(11, 14));
const isCamera = typeCode >= 4 && typeCode <= 6;
const isAlarm = (typeCode >= 101 && typeCode <= 108) || (typeCode >= 810 && typeCode <= 815);
if (isCamera) {
if (!cameraBuiltSitesSet.has(siteCode)) {
cameraSites.push({
code: siteCode,
name: codeSites[siteCode]?.name ?? '',
online: siteFromApi.online,
});
cameraBuiltSitesSet.add(siteCode);
}
if (!siteCodeToCamerasMap.has(siteCode)) {
siteCodeToCamerasMap.set(siteCode, []);
}
siteCodeToCamerasMap.get(siteCode)!.push(channel);
} else if (isAlarm) {
if (!alarmBuiltSitesSet.has(siteCode)) {
alarmSites.push({
code: siteCode,
name: codeSites[siteCode]?.name ?? '',
online: siteFromApi.online,
});
alarmBuiltSitesSet.add(siteCode);
}
if (!siteCodeToAlarmsMap.has(siteCode)) {
siteCodeToAlarmsMap.set(siteCode, []);
}
siteCodeToAlarmsMap.get(siteCode)!.push(channel);
}
});
}
// 1. 站点数组排序:稳定线路面板顺序和站点节点顺序
sortSitesByCode(cameraSites);
sortSitesByCode(alarmSites);
// 2. 每站通道数组排序:稳定区域节点顺序和通道节点顺序
sortChannelsMapByCode(siteCodeToCamerasMap);
sortChannelsMapByCode(siteCodeToAlarmsMap);
cameraStore.buildLineTabPanes({
sites: cameraSites,
siteCodeToCamerasMap: siteCodeToCamerasMap,
codeLines,
codeSites,
compiledCodeAreas,
});
cameraStore.buildCameraRecord(siteCodeToCamerasMap);
alarmStore.buildLineTabPanes({
sites: alarmSites,
siteCodeToAlarmsMap,
codeLines,
codeSites,
compiledCodeAreas,
});
alarmStore.buildAlarmRecord(siteCodeToAlarmsMap);
return null;
},
});
};
@@ -1,101 +0,0 @@
import { useQuery } from '@tanstack/vue-query';
import { computed } from 'vue';
import { catalogChannelApi, catalogAllDeviceApi } from '../../apis/request';
import type { AxiosRequestConfig } from 'axios';
import axios from 'axios';
import type { CodeArea, CodeLines, CodeSites } from '../../types';
import { useCameraStore, useAlarmStore } from '../../stores';
import type { VimpChannel } from '../../apis/model';
export const useDeviceCenterQuery = () => {
const cameraStore = useCameraStore();
const alarmStore = useAlarmStore();
return useQuery({
queryKey: computed(() => ['vimp-device']),
refetchInterval: 10 * 1000,
refetchOnWindowFocus: false,
queryFn: async ({ signal }) => {
const config: AxiosRequestConfig = {
headers: {
'Cache-Control': 'no-store',
},
};
const buildTrainAreas = () => {
const codeTrainAreas: CodeArea[] = [];
for (let i = 0; i < 999; i++) {
const codeTrain = i.toString().padStart(3, '0');
// 市域线name为车组,改造线name为车次
const area: CodeArea = { code: codeTrain, name: '车次' + codeTrain, subs: [] };
for (let j = 0; j <= 99; j++) {
const codeCarriage = j.toString().padStart(2, '0');
const subArea: CodeArea['subs'][number] = { code: codeTrain + codeCarriage, name: '车厢' + codeCarriage };
area.subs.push(subArea);
}
// const areaPreserve: CodeArea['subs'][number] = { code: codeTrain + '51', name: '预留' };
// area.subs.push(areaPreserve);
codeTrainAreas.push(area);
}
return codeTrainAreas;
};
const codeLines = (await axios.get<CodeLines>('/cdn/vimp/codes/codeLines.json', config)).data;
const codeSites = (await axios.get<CodeSites>('/cdn/vimp/codes/codeStations.json', config)).data;
const codeStationAreas = (await axios.get<CodeArea[]>('/cdn/vimp/codes/codeStationAreas.json', config)).data;
const codeParkingAreas = (await axios.get<CodeArea[]>('/cdn/vimp/codes/codeParkingAreas.json', config)).data;
const codeOccAreas = (await axios.get<CodeArea[]>('/cdn/vimp/codes/codeOccAreas.json', config)).data;
const codeTrainAreas = buildTrainAreas();
const siteCamerasMap: Record<string, VimpChannel[]> = {};
const siteAlarmsMap: Record<string, VimpChannel[]> = {};
const sites = await catalogAllDeviceApi({ signal });
if (!!sites) {
for (const site of sites) {
const channels = await catalogChannelApi(site.code, { signal });
if (!channels || channels.length === 0) continue;
const cameras: VimpChannel[] = [];
const alarms: VimpChannel[] = [];
channels.forEach((channel) => {
const typeCode = Number(channel.code.substring(11, 14));
if (typeCode >= 4 && typeCode <= 6) {
cameras.push(channel);
} else if ((typeCode >= 101 && typeCode <= 108) || (typeCode >= 810 && typeCode <= 815)) {
alarms.push(channel);
}
});
siteCamerasMap[site.code] = cameras;
siteAlarmsMap[site.code] = alarms;
}
}
cameraStore.buildLineTabPanes({
sites,
siteCamerasMap,
codeLines,
codeSites,
codeStationAreas,
codeParkingAreas,
codeOccAreas,
codeTrainAreas,
});
alarmStore.buildLineTabPanes({
sites,
siteAlarmsMap,
codeLines,
codeSites,
codeStationAreas,
codeParkingAreas,
codeOccAreas,
codeTrainAreas,
});
return null;
},
});
};
+1
View File
@@ -0,0 +1 @@
export * from './query';
+1
View File
@@ -0,0 +1 @@
export const VIMP_CHANNELS_QUERY_KEY = 'vimp-channels';
+100 -108
View File
@@ -1,47 +1,53 @@
import { defineStore } from 'pinia';
import type { VimpChannel, VimpStation } from '../apis';
import { h, ref } from 'vue';
import type { AlarmMainAreaNodeOption, AlarmNodeOption, CodeArea, CodeLines, CodeSites, AlarmLineTabPane, AlarmSiteNodeOption, AlarmSubAreaNodeOption } from '../types';
import { NIcon } from 'naive-ui';
import { SirenIcon } from 'lucide-vue-next';
import type { VimpChannel, VimpSite } from '../apis';
import { shallowRef } from 'vue';
import type { AlarmMainAreaNodeOption, AlarmNodeOption, CodeLines, CodeSites, AlarmLineTabPane, AlarmSiteNodeOption, AlarmSubAreaNodeOption, CompiledCodeAreas } from '../types';
interface BuildLineTabPanesParams {
sites: VimpStation[] | null;
siteAlarmsMap: Record<string, VimpChannel[]>;
sites: VimpSite[];
siteCodeToAlarmsMap: Map<string, VimpChannel[]>;
codeLines: CodeLines;
codeSites: CodeSites;
codeStationAreas: CodeArea[];
codeParkingAreas: CodeArea[];
codeOccAreas: CodeArea[];
codeTrainAreas: CodeArea[];
compiledCodeAreas: CompiledCodeAreas;
}
const buildMainAreaNodeKey = (siteCode: string, mainAreaCode: string) => `${siteCode}${mainAreaCode}`;
const buildSubAreaNodeKey = (siteCode: string, areaCode: string) => `${siteCode}${areaCode}`;
export const useAlarmStore = defineStore('vimp-alarm-store', () => {
const lineTabPanes = ref<AlarmLineTabPane[]>([]);
const lineTabPanes = shallowRef<AlarmLineTabPane[]>([]);
const alarmRecord = shallowRef<Record<string, VimpChannel>>({});
const buildLineTabPanes = (params: BuildLineTabPanesParams) => {
const { sites, siteAlarmsMap, codeLines, codeSites, codeStationAreas, codeParkingAreas, codeOccAreas, codeTrainAreas } = params;
if (!sites) {
lineTabPanes.value = [];
return;
}
// 构造线路TabPane
const _lineTabPanes: AlarmLineTabPane[] = [];
const lineCode = sites.at(0)?.code.substring(0, 3) ?? '';
const lineName = codeLines[lineCode]?.name ?? '';
if (!_lineTabPanes.some((lineNode) => lineNode.lineCode === lineCode)) {
_lineTabPanes.push({
lineCode,
lineName,
alarmTree: [],
});
}
const { sites, siteCodeToAlarmsMap, codeLines, codeSites, compiledCodeAreas } = params;
const result: AlarmLineTabPane[] = [];
// 1. 线路索引 lineCode -> AlarmLineTabPane
const linePaneMap = new Map<string, AlarmLineTabPane>();
// 遍历所有站点
for (const site of sites) {
const siteCode = site.code.substring(0, 6);
const siteName = codeSites[siteCode]?.name;
if (!siteName) continue;
// 2. 站点节点 siteNode 只在当前轮次中顺序创建,不需要建立索引
const lineCode = site.code.substring(0, 3);
const lineName = codeLines[lineCode]?.name ?? '';
let linePane = linePaneMap.get(lineCode);
if (!linePane) {
linePane = { lineCode, lineName, alarmTree: [] };
linePaneMap.set(lineCode, linePane);
result.push(linePane);
}
const siteCode = site.code;
const siteMeta = codeSites[siteCode];
if (!siteMeta) continue;
const siteName = siteMeta.name;
const siteType = siteMeta.type;
const compiledCodeAreaMaps = compiledCodeAreas[siteType];
const mainAreaCodeLength = siteType === 'train' ? 3 : 2;
// 构造站点节点
const siteNode: AlarmSiteNodeOption = {
@@ -51,79 +57,76 @@ export const useAlarmStore = defineStore('vimp-alarm-store', () => {
stats: { online: 0, offline: 0, total: 0 },
online: site.online,
};
_lineTabPanes.find((lineTabPane) => lineTabPane.lineCode === lineCode)?.alarmTree.push(siteNode);
linePane.alarmTree.push(siteNode);
// 获取所有警报器
const alarms = siteAlarmsMap[site.code];
if (!alarms || alarms.length === 0) continue;
const alarms = siteCodeToAlarmsMap.get(siteCode);
if (!alarms) continue;
// 3. 1级区域索引 mainAreaNodeKey -> AlarmMainAreaNodeOption
// mainAreaNodeKey = ${siteCode}${alarmMainAreaCode}
const mainAreaNodeMap = new Map<string, AlarmMainAreaNodeOption>();
// 4. 2级区域索引 subAreaNodeKey -> AlarmSubAreaNodeOption
// subAreaNodeKey = ${siteCode}${alarmAreaCode}
const subAreaNodeMap = new Map<string, AlarmSubAreaNodeOption>();
// 5. 警报器索引 subAreaNodeKey -> Set<AlarmGbCode>
const subAreaNodeKeyToAlarmGbCodeSetMap = new Map<string, Set<string>>();
// 遍历警报器
for (const alarm of alarms) {
// 计算相关编码
const { code: alarmGbCode, name: alarmName } = alarm;
const alarmSiteCode = alarmGbCode.substring(0, 6);
const alarmSiteType = codeSites[alarmSiteCode]?.type;
const alarmAreaCode = alarmGbCode.substring(6, 11);
const alarmMainAreaCode = alarmAreaCode.slice(0, 2);
const alarmMainAreaCode = alarmAreaCode.slice(0, mainAreaCodeLength);
// 构造车站/基地/OCC/车次区域
let siteArea: CodeArea | undefined = undefined;
if (alarmSiteType === 'station') {
siteArea = codeStationAreas.find((area) => area.code === alarmMainAreaCode);
} else if (alarmSiteType === 'parking') {
siteArea = codeParkingAreas.find((area) => area.code === alarmMainAreaCode);
} else if (alarmSiteType === 'occ') {
siteArea = codeOccAreas.find((area) => area.code === alarmMainAreaCode);
} else if (alarmSiteType === 'train') {
siteArea = codeTrainAreas.find((area) => area.code === alarmMainAreaCode);
} else {
continue;
}
if (!siteArea) continue; // 如果还是未找到区域,则跳过该警报器
// 查找1级区域,如果未找到则跳过该警报器
const mainArea = compiledCodeAreaMaps.mainAreaMap.get(alarmMainAreaCode);
if (!mainArea) continue;
// 构造1级区域节点
if (!siteNode.children?.find((areaNode) => areaNode.key === `${alarmSiteCode}${alarmMainAreaCode}`)) {
const mainAreaNode: AlarmMainAreaNodeOption = {
key: `${alarmSiteCode}${alarmMainAreaCode}`,
label: siteArea.name,
// 尝试从索引中获取1级区域节点,若不存在则创建
const mainAreaNodeKey = buildMainAreaNodeKey(siteCode, alarmMainAreaCode);
let mainAreaNode = mainAreaNodeMap.get(mainAreaNodeKey);
if (!mainAreaNode) {
mainAreaNode = {
key: mainAreaNodeKey,
label: mainArea.name,
children: [],
stats: { online: 0, offline: 0, total: 0 },
site: site,
areaLevel: 1,
};
mainAreaNodeMap.set(mainAreaNodeKey, mainAreaNode);
siteNode.children?.push(mainAreaNode);
}
const targetMainAreaNode = siteNode.children?.find((areaNode) => areaNode.key === `${alarmSiteCode}${alarmMainAreaCode}`);
if (!targetMainAreaNode) continue; // 如果1级区域节点不存在,则跳过该警报器
// 构造2级区域节点
if (!targetMainAreaNode.children?.find((subAreaNode) => subAreaNode.key === `${alarmSiteCode}${alarmAreaCode}`)) {
let subArea: CodeArea['subs'][number] | undefined = undefined;
if (alarmSiteType === 'station') {
subArea = codeStationAreas.find((area) => area.code === alarmMainAreaCode)?.subs.find((subArea) => subArea.code === alarmAreaCode);
} else if (alarmSiteType === 'parking') {
subArea = codeParkingAreas.find((area) => area.code === alarmMainAreaCode)?.subs.find((subArea) => subArea.code === alarmAreaCode);
} else if (alarmSiteType === 'occ') {
subArea = codeOccAreas.find((area) => area.code === alarmMainAreaCode)?.subs.find((subArea) => subArea.code === alarmAreaCode);
} else if (alarmSiteType === 'train') {
subArea = codeTrainAreas.find((area) => area.code === alarmMainAreaCode)?.subs.find((subArea) => subArea.code === alarmAreaCode);
} else {
continue;
}
if (!subArea) continue; // 如果还是未找到子区域,则跳过该警报器
// 查找2级区域,如果未找到则跳过该警报器
const subArea = compiledCodeAreaMaps.subAreaMap.get(alarmAreaCode);
if (!subArea) continue;
const subAreaNode: AlarmSubAreaNodeOption = {
key: `${alarmSiteCode}${alarmAreaCode}`,
// 尝试从索引中获取2级区域节点,若不存在则创建
const subAreaNodeKey = buildSubAreaNodeKey(siteCode, alarmAreaCode);
let subAreaNode = subAreaNodeMap.get(subAreaNodeKey);
if (!subAreaNode) {
subAreaNode = {
key: subAreaNodeKey,
label: subArea.name,
children: [],
stats: { online: 0, offline: 0, total: 0 },
site: site,
areaLevel: 2,
};
targetMainAreaNode.children?.push(subAreaNode);
subAreaNodeMap.set(subAreaNodeKey, subAreaNode);
mainAreaNode.children?.push(subAreaNode);
}
const subAreaNode = targetMainAreaNode.children?.find((subAreaNode) => subAreaNode.key === `${alarmSiteCode}${alarmAreaCode}`);
if (!subAreaNode) continue; // 如果子区域节点不存在,则跳过该警报器
// 构造警报器节点
let alarmGbCodeSet = subAreaNodeKeyToAlarmGbCodeSetMap.get(subAreaNodeKey);
if (!alarmGbCodeSet) {
alarmGbCodeSet = new Set<string>();
subAreaNodeKeyToAlarmGbCodeSetMap.set(subAreaNodeKey, alarmGbCodeSet);
}
if (alarmGbCodeSet.has(alarmGbCode)) continue;
alarmGbCodeSet.add(alarmGbCode);
const alarmType = alarm.code.substring(11, 14);
const alarmNode: AlarmNodeOption = {
key: alarmGbCode,
@@ -131,53 +134,42 @@ export const useAlarmStore = defineStore('vimp-alarm-store', () => {
type: alarmType,
alarm: alarm,
site: site,
prefix: () => {
return h(NIcon, h(SirenIcon));
},
};
// 添加警报器节点到子区域节点
if (!subAreaNode.children?.find((alarmNode) => alarmNode.key === alarmGbCode)) {
subAreaNode.children?.push(alarmNode);
}
subAreaNode.children?.push(alarmNode);
// 统计站点、区域、子区域的在线/离线/总警报器数量
siteNode.stats.total++;
targetMainAreaNode.stats.total++;
mainAreaNode.stats.total++;
subAreaNode.stats.total++;
if (alarm.status === 1) {
siteNode.stats.online++;
targetMainAreaNode.stats.online++;
mainAreaNode.stats.online++;
subAreaNode.stats.online++;
}
if (alarm.status === 0) {
} else if (alarm.status === 0) {
siteNode.stats.offline++;
targetMainAreaNode.stats.offline++;
mainAreaNode.stats.offline++;
subAreaNode.stats.offline++;
}
}
siteNode.suffix = () => {
const { online, offline, total } = siteNode.stats;
return `(${online}/${offline}/${total})`;
};
siteNode.children?.forEach((areaNode) => {
areaNode.suffix = () => {
const { online, offline, total } = areaNode.stats;
return h('div', { style: { marginRight: '8px', opacity: 0.6 } }, `(${online}/${offline}/${total})`);
};
areaNode.children?.forEach((subAreaNode) => {
subAreaNode.suffix = () => {
const { online, offline, total } = subAreaNode.stats;
return h('div', { style: { marginRight: '16px', opacity: 0.4 } }, `(${online}/${offline}/${total})`);
};
});
});
}
lineTabPanes.value = _lineTabPanes;
lineTabPanes.value = result;
};
const buildAlarmRecord = (siteCodeToAlarmsMap: Map<string, VimpChannel[]>) => {
const record: Record<string, VimpChannel> = {};
for (const [, alarms] of siteCodeToAlarmsMap) {
for (const alarm of alarms) {
record[alarm.code] = alarm;
}
}
alarmRecord.value = record;
};
return {
lineTabPanes,
alarmRecord,
buildLineTabPanes,
buildAlarmRecord,
};
});
+102 -115
View File
@@ -1,49 +1,53 @@
import { defineStore } from 'pinia';
import type { VimpChannel, VimpStation } from '../apis';
import { h, ref } from 'vue';
import type { CameraMainAreaNodeOption, CameraNodeOption, CodeArea, CodeLines, CodeSites, CameraLineTabPane, CameraSiteNodeOption, CameraSubAreaNodeOption } from '../types';
import { NIcon } from 'naive-ui';
import BulletCamera from '../components/icon/bullet-camera.vue';
import PtzCamera from '../components/icon/ptz-camera.vue';
import HemiPtzCamera from '../components/icon/hemi-ptz-camera.vue';
import type { VimpChannel, VimpSite } from '../apis';
import { ref, shallowRef } from 'vue';
import type { CameraMainAreaNodeOption, CameraNodeOption, CodeLines, CodeSites, CameraLineTabPane, CameraSiteNodeOption, CameraSubAreaNodeOption, CompiledCodeAreas } from '../types';
interface BuildLineTabPanesParams {
sites: VimpStation[] | null;
siteCamerasMap: Record<string, VimpChannel[]>;
sites: VimpSite[];
siteCodeToCamerasMap: Map<string, VimpChannel[]>;
codeLines: CodeLines;
codeSites: CodeSites;
codeStationAreas: CodeArea[];
codeParkingAreas: CodeArea[];
codeOccAreas: CodeArea[];
codeTrainAreas: CodeArea[];
compiledCodeAreas: CompiledCodeAreas;
}
const buildMainAreaNodeKey = (siteCode: string, mainAreaCode: string) => `${siteCode}${mainAreaCode}`;
const buildSubAreaNodeKey = (siteCode: string, areaCode: string) => `${siteCode}${areaCode}`;
export const useCameraStore = defineStore('vimp-camera-store', () => {
const lineTabPanes = ref<CameraLineTabPane[]>([]);
const lineTabPanes = shallowRef<CameraLineTabPane[]>([]);
const cameraRecord = shallowRef<Record<string, VimpChannel>>({});
const buildLineTabPanes = (params: BuildLineTabPanesParams) => {
const { sites, siteCamerasMap, codeLines, codeSites, codeStationAreas, codeParkingAreas, codeOccAreas, codeTrainAreas } = params;
if (!sites) {
lineTabPanes.value = [];
return;
}
// 构造线路TabPane
const _lineTabPanes: CameraLineTabPane[] = [];
const lineCode = sites.at(0)?.code.substring(0, 3) ?? '';
const lineName = codeLines[lineCode]?.name ?? '';
if (!_lineTabPanes.some((lineNode) => lineNode.lineCode === lineCode)) {
_lineTabPanes.push({
lineCode,
lineName,
cameraTree: [],
});
}
const { sites, siteCodeToCamerasMap, codeLines, codeSites, compiledCodeAreas } = params;
const result: CameraLineTabPane[] = [];
// 1. 线路索引 lineCode -> CameraLineTabPane
const linePaneMap = new Map<string, CameraLineTabPane>();
// 遍历所有站点
for (const site of sites) {
const siteCode = site.code.substring(0, 6);
const siteName = codeSites[siteCode]?.name;
if (!siteName) continue;
// 2. 站点节点 siteNode 只在当前轮次中顺序创建,不需要建立索引
const lineCode = site.code.substring(0, 3);
const lineName = codeLines[lineCode]?.name ?? '';
let linePane = linePaneMap.get(lineCode);
if (!linePane) {
linePane = { lineCode, lineName, cameraTree: [] };
linePaneMap.set(lineCode, linePane);
result.push(linePane);
}
const siteCode = site.code;
const siteMeta = codeSites[siteCode];
if (!siteMeta) continue;
const siteName = siteMeta.name;
const siteType = siteMeta.type;
const compiledCodeAreaMaps = compiledCodeAreas[siteType];
const mainAreaCodeLength = siteType === 'train' ? 3 : 2;
// 构造站点节点
const siteNode: CameraSiteNodeOption = {
@@ -53,135 +57,118 @@ export const useCameraStore = defineStore('vimp-camera-store', () => {
stats: { online: 0, offline: 0, total: 0 },
online: site.online,
};
_lineTabPanes.find((lineTabPane) => lineTabPane.lineCode === lineCode)?.cameraTree.push(siteNode);
linePane.cameraTree.push(siteNode);
// 获取所有摄像机
const cameras = siteCamerasMap[site.code];
if (!cameras || cameras.length === 0) continue;
const cameras = siteCodeToCamerasMap.get(siteCode);
if (!cameras) continue;
// 3. 1级区域索引 mainAreaNodeKey -> CameraMainAreaNodeOption
// mainAreaNodeKey = ${siteCode}${cameraMainAreaCode}
const mainAreaNodeMap = new Map<string, CameraMainAreaNodeOption>();
// 4. 2级区域索引 subAreaNodeKey -> CameraSubAreaNodeOption
// subAreaNodeKey = ${siteCode}${cameraAreaCode}
const subAreaNodeMap = new Map<string, CameraSubAreaNodeOption>();
// 5. 摄像机索引 subAreaNodeKey -> Set<CameraGbCode>
const subAreaNodeKeyToCameraGbCodeSetMap = new Map<string, Set<string>>();
// 遍历摄像机
for (const camera of cameras) {
// 计算相关编码
const { code: cameraGbCode, name: cameraName } = camera;
const cameraSiteCode = cameraGbCode.substring(0, 6);
const cameraSiteType = codeSites[cameraSiteCode]?.type;
const cameraAreaCode = cameraGbCode.substring(6, 11);
const cameraMainAreaCode = cameraAreaCode.slice(0, 2);
const cameraMainAreaCode = cameraAreaCode.slice(0, mainAreaCodeLength);
// 构造车站/基地/OCC/车次区域
let siteArea: CodeArea | undefined = undefined;
if (cameraSiteType === 'station') {
siteArea = codeStationAreas.find((area) => area.code === cameraMainAreaCode);
} else if (cameraSiteType === 'parking') {
siteArea = codeParkingAreas.find((area) => area.code === cameraMainAreaCode);
} else if (cameraSiteType === 'occ') {
siteArea = codeOccAreas.find((area) => area.code === cameraMainAreaCode);
} else if (cameraSiteType === 'train') {
siteArea = codeTrainAreas.find((area) => area.code === cameraMainAreaCode);
} else {
continue;
}
if (!siteArea) continue; // 如果还是未找到区域,则跳过该摄像机
// 构造1级区域节点
if (!siteNode.children?.find((areaNode) => areaNode.key === `${cameraSiteCode}${cameraMainAreaCode}`)) {
const mainAreaNode: CameraMainAreaNodeOption = {
key: `${cameraSiteCode}${cameraMainAreaCode}`,
label: siteArea.name,
// 查找1级区域,如果未找到则跳过该摄像机
const mainArea = compiledCodeAreaMaps.mainAreaMap.get(cameraMainAreaCode);
if (!mainArea) continue;
// 尝试从索引中获取1级区域节点,若不存在则创建
const mainAreaNodeKey = buildMainAreaNodeKey(siteCode, cameraMainAreaCode);
let mainAreaNode = mainAreaNodeMap.get(mainAreaNodeKey);
if (!mainAreaNode) {
mainAreaNode = {
key: mainAreaNodeKey,
label: mainArea.name,
children: [],
stats: { online: 0, offline: 0, total: 0 },
site: site,
areaLevel: 1,
};
mainAreaNodeMap.set(mainAreaNodeKey, mainAreaNode);
siteNode.children?.push(mainAreaNode);
}
const targetMainAreaNode = siteNode.children?.find((areaNode) => areaNode.key === `${cameraSiteCode}${cameraMainAreaCode}`);
if (!targetMainAreaNode) continue; // 如果1级区域节点不存在,则跳过该摄像机
// 构造2级区域节点
if (!targetMainAreaNode.children?.find((subAreaNode) => subAreaNode.key === `${cameraSiteCode}${cameraAreaCode}`)) {
let subArea: CodeArea['subs'][number] | undefined = undefined;
if (cameraSiteType === 'station') {
subArea = codeStationAreas.find((area) => area.code === cameraMainAreaCode)?.subs.find((subArea) => subArea.code === cameraAreaCode);
} else if (cameraSiteType === 'parking') {
subArea = codeParkingAreas.find((area) => area.code === cameraMainAreaCode)?.subs.find((subArea) => subArea.code === cameraAreaCode);
} else if (cameraSiteType === 'occ') {
subArea = codeOccAreas.find((area) => area.code === cameraMainAreaCode)?.subs.find((subArea) => subArea.code === cameraAreaCode);
} else if (cameraSiteType === 'train') {
subArea = codeTrainAreas.find((area) => area.code === cameraMainAreaCode)?.subs.find((subArea) => subArea.code === cameraAreaCode);
} else {
continue;
}
if (!subArea) continue; // 如果还是未找到2级区域,则跳过该摄像机
const subAreaNode: CameraSubAreaNodeOption = {
key: `${cameraSiteCode}${cameraAreaCode}`,
// 查找2级区域,如果未找到则跳过该摄像机
const subArea = compiledCodeAreaMaps.subAreaMap.get(cameraAreaCode);
if (!subArea) continue;
// 尝试从索引中获取2级区域节点,若不存在则创建
const subAreaNodeKey = buildSubAreaNodeKey(siteCode, cameraAreaCode);
let subAreaNode = subAreaNodeMap.get(subAreaNodeKey);
if (!subAreaNode) {
subAreaNode = {
key: subAreaNodeKey,
label: subArea.name,
children: [],
stats: { online: 0, offline: 0, total: 0 },
site: site,
areaLevel: 2,
};
targetMainAreaNode.children?.push(subAreaNode);
subAreaNodeMap.set(subAreaNodeKey, subAreaNode);
mainAreaNode.children?.push(subAreaNode);
}
const subAreaNode = targetMainAreaNode.children?.find((subAreaNode) => subAreaNode.key === `${cameraSiteCode}${cameraAreaCode}`);
if (!subAreaNode) continue; // 如果子区域节点不存在,则跳过该摄像机
// 构造摄像机节点
const cameraType = camera.code.substring(11, 14);
let cameraGbCodeSet = subAreaNodeKeyToCameraGbCodeSetMap.get(subAreaNodeKey);
if (!cameraGbCodeSet) {
cameraGbCodeSet = new Set<string>();
subAreaNodeKeyToCameraGbCodeSetMap.set(subAreaNodeKey, cameraGbCodeSet);
}
if (cameraGbCodeSet.has(cameraGbCode)) continue;
cameraGbCodeSet.add(cameraGbCode);
const cameraType = cameraGbCode.substring(11, 14);
const cameraNode: CameraNodeOption = {
key: cameraGbCode,
label: cameraName,
type: cameraType,
camera: camera,
site: site,
prefix: () => {
if (cameraType === '004') return h(NIcon, h(PtzCamera));
if (cameraType === '005') return h(NIcon, h(HemiPtzCamera));
if (cameraType === '006') return h(NIcon, h(BulletCamera));
},
};
// 添加摄像机节点到子区域节点
if (!subAreaNode.children?.find((cameraNode) => cameraNode.key === cameraGbCode)) {
subAreaNode.children?.push(cameraNode);
}
subAreaNode.children?.push(cameraNode);
// 统计站点、区域、子区域的在线/离线/总摄像机数量
siteNode.stats.total++;
targetMainAreaNode.stats.total++;
mainAreaNode.stats.total++;
subAreaNode.stats.total++;
if (camera.status === 1) {
siteNode.stats.online++;
targetMainAreaNode.stats.online++;
mainAreaNode.stats.online++;
subAreaNode.stats.online++;
}
if (camera.status === 0) {
} else if (camera.status === 0) {
siteNode.stats.offline++;
targetMainAreaNode.stats.offline++;
mainAreaNode.stats.offline++;
subAreaNode.stats.offline++;
}
}
siteNode.suffix = () => {
const { online, offline, total } = siteNode.stats;
return `(${online}/${offline}/${total})`;
};
siteNode.children?.forEach((areaNode) => {
areaNode.suffix = () => {
const { online, offline, total } = areaNode.stats;
return h('div', { style: { marginRight: '8px', opacity: 0.6 } }, `(${online}/${offline}/${total})`);
};
areaNode.children?.forEach((subAreaNode) => {
subAreaNode.suffix = () => {
const { online, offline, total } = subAreaNode.stats;
return h('div', { style: { marginRight: '16px', opacity: 0.4 } }, `(${online}/${offline}/${total})`);
};
});
});
}
lineTabPanes.value = _lineTabPanes;
lineTabPanes.value = result;
};
const buildCameraRecord = (siteCodeToCamerasMap: Map<string, VimpChannel[]>) => {
const record: Record<string, VimpChannel> = {};
for (const [, cameras] of siteCodeToCamerasMap) {
for (const camera of cameras) {
record[camera.code] = camera;
}
}
cameraRecord.value = record;
};
return {
lineTabPanes,
cameraRecord,
buildLineTabPanes,
buildCameraRecord,
};
});
+15
View File
@@ -0,0 +1,15 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useConfigPanelStore = defineStore('vimp-config-panel', () => {
const collapsed = ref<boolean>(false)
const toggleCollapsed = () => {
collapsed.value = !collapsed.value
}
return {
collapsed,
toggleCollapsed,
}
})
+2
View File
@@ -1,3 +1,5 @@
export * from './alarm';
export * from './camera';
export * from './config-panel';
export * from './resource-panel';
export * from './screen';
+58
View File
@@ -0,0 +1,58 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface Screen {
id: string
name: string
}
let counter = 4
const genId = (): string => `screen-${crypto.randomUUID()}`
const defaultName = (n: number): string => `屏幕 ${n}`
export const useScreenStore = defineStore('vimp-screen', () => {
const screens = ref<Screen[]>([
{ id: 'screen-1', name: '屏幕 1' },
{ id: 'screen-2', name: '屏幕 2' },
{ id: 'screen-3', name: '屏幕 3' },
{ id: 'screen-4', name: '屏幕 4' },
])
const activeScreenId = ref<string>(screens.value[0]?.id ?? '')
const addScreen = () => {
const id = genId()
screens.value.push({ id, name: defaultName(screens.value.length + 1) })
activeScreenId.value = id
}
const removeScreen = (id: string) => {
if (screens.value.length <= 1) return
const index = screens.value.findIndex(s => s.id === id)
if (index === -1) return
screens.value.splice(index, 1)
if (activeScreenId.value === id) {
const fallback = screens.value[index] ?? screens.value[index - 1]
if (fallback) activeScreenId.value = fallback.id
}
}
const renameScreen = (id: string, name: string) => {
const target = screens.value.find(s => s.id === id)
if (!target) return
const trimmed = name.trim()
if (trimmed.length === 0) return
if (trimmed === target.name) return
target.name = trimmed
}
return {
screens,
activeScreenId,
addScreen,
removeScreen,
renameScreen,
}
})
+50 -7
View File
@@ -1,11 +1,50 @@
import type { TabPaneProps, TreeOption } from 'naive-ui';
import type { VimpChannel, VimpStation } from '../apis/model';
import type { VimpChannel, VimpSite } from '../apis/model';
export type SiteType = 'station' | 'parking' | 'occ' | 'train';
export type CodeLines = Record<string, { name: string; color: string }>;
export type CodeSites = Record<string, { name: string; type: SiteType }>;
export type CodeArea = { code: string; name: string; subs: { code: string; name: string }[] };
export type CompiledCodeAreaMaps = {
mainAreaMap: Map<string, CodeArea>;
subAreaMap: Map<string, CodeArea['subs'][number]>;
};
export type CompiledCodeAreas = Record<SiteType, CompiledCodeAreaMaps>;
interface CompileCodeAreasParams {
codeStationAreas: CodeArea[];
codeParkingAreas: CodeArea[];
codeOccAreas: CodeArea[];
codeTrainAreas: CodeArea[];
}
const compileCodeAreaMaps = (areas: CodeArea[]): CompiledCodeAreaMaps => {
const mainAreaMap = new Map<string, CodeArea>();
const subAreaMap = new Map<string, CodeArea['subs'][number]>();
for (const area of areas) {
mainAreaMap.set(area.code, area);
for (const subArea of area.subs) {
subAreaMap.set(subArea.code, subArea);
}
}
return {
mainAreaMap,
subAreaMap,
};
};
export const compileCodeAreas = (parmas: CompileCodeAreasParams): CompiledCodeAreas => {
const { codeStationAreas, codeParkingAreas, codeOccAreas, codeTrainAreas } = parmas;
return {
station: compileCodeAreaMaps(codeStationAreas),
parking: compileCodeAreaMaps(codeParkingAreas),
occ: compileCodeAreaMaps(codeOccAreas),
train: compileCodeAreaMaps(codeTrainAreas),
};
};
export interface CountStats {
online: number;
offline: number;
@@ -18,19 +57,21 @@ export interface CountStats {
export interface CameraNodeOption extends TreeOption {
camera: VimpChannel;
type: string;
site: VimpStation;
site: VimpSite;
}
export interface CameraSubAreaNodeOption extends TreeOption {
children?: CameraNodeOption[];
stats: CountStats;
site: VimpStation;
site: VimpSite;
areaLevel: 2;
}
export interface CameraMainAreaNodeOption extends TreeOption {
children?: CameraSubAreaNodeOption[];
stats: CountStats;
site: VimpStation;
site: VimpSite;
areaLevel: 1;
}
export interface CameraSiteNodeOption extends TreeOption {
@@ -63,19 +104,21 @@ export interface CameraLineTabPane extends TabPaneProps {
export interface AlarmNodeOption extends TreeOption {
alarm: VimpChannel;
type: string;
site: VimpStation;
site: VimpSite;
}
export interface AlarmSubAreaNodeOption extends TreeOption {
children?: AlarmNodeOption[];
stats: CountStats;
site: VimpStation;
site: VimpSite;
areaLevel: 2;
}
export interface AlarmMainAreaNodeOption extends TreeOption {
children?: AlarmSubAreaNodeOption[];
stats: CountStats;
site: VimpStation;
site: VimpSite;
areaLevel: 1;
}
export interface AlarmSiteNodeOption extends TreeOption {
+6 -34
View File
@@ -1,41 +1,13 @@
<script setup lang="ts">
import ResourcePanel from './components/resource-pannel.vue';
const onDragover = (event: DragEvent) => {
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy';
}
};
const onDrop = (event: DragEvent) => {
event.preventDefault();
const type = event.dataTransfer?.getData('type');
if (!type) return;
if (type === 'camera') {
const code = event.dataTransfer?.getData('code');
if (!code) return;
const name = event.dataTransfer?.getData('name');
window.$message.info(`播放:${JSON.stringify({ code, name })}`);
} else if (type === 'alarm') {
const code = event.dataTransfer?.getData('code');
if (!code) return;
const name = event.dataTransfer?.getData('name');
window.$message.info(`查看警报器:${JSON.stringify({ code, name })}`);
} else {
}
};
import ResourcePanel from './components/resource-panel.vue'
import ConfigPanel from './components/config-panel.vue'
import ScreenPanel from './components/screen-panel.vue'
</script>
<template>
<div style="height: 100%; overflow: hidden; display: flex">
<div style="height: 100%; display: flex; overflow: hidden">
<ResourcePanel />
<div style="flex: 1">
<div style="height: 480px; background-color: #666; display: grid; place-items: center" @dragover="onDragover" @drop="onDrop">
<div>这里是播放器</div>
</div>
</div>
<ScreenPanel />
<ConfigPanel />
</div>
</template>
<style scoped></style>
+1 -1
View File
@@ -45,7 +45,7 @@ export const useSettingStore = defineStore(
showDeviceRawData.value = false;
pollingStations.value = true;
activeRequests.value = true;
subscribeMessages.value = false;
subscribeMessages.value = true;
mockUser.value = false;
useLocalDB.value = false;
}
+1
View File
@@ -215,6 +215,7 @@ const apiProxyList: ProxyItem[] = [
// { key: '/vimp/api', target: 'http://10.14.0.10:18080', rewrite: ['/vimp/api', '/api'] },
// { key: '/vimp/api', target: 'http://10.18.128.6:18080', rewrite: ['/vimp/api', '/api'] },
{ key: '/vimp/api', target: 'http://localhost:4000', rewrite: ['/vimp/api', '/api'] },
// { key: '/vimp/api', target: 'http://10.24.17.6:18080', rewrite: ['/vimp/api', '/api'] },
// { key: '/vimp/api', target: 'http://10.18.128.6:18080', rewrite: ['/vimp/api', '/api'] },
{ key: '/cdn', target: `http://localhost:${SERVER_PORT}`, rewrite: ['/cdn', ''] },
];