11
This commit is contained in:
1 parent
7aa7addb44
commit
1c7a6035f3
38 files changed
+2110
-217
No files matched your search
@@ -0,0 +1,44 @@
|
||||
import request from "/@/utils/request";
|
||||
|
||||
export interface DoorDashboard {
|
||||
deviceTotal: number;
|
||||
onlineTotal: number;
|
||||
offlineTotal: number;
|
||||
todayEnterTotal: number;
|
||||
todayExitTotal: number;
|
||||
unidentifiedTotal: number;
|
||||
iccConnected: boolean;
|
||||
message: string;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export function devicePage(query: any) {
|
||||
return request({
|
||||
url: "/admin/collection/door/access/device/page",
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
}
|
||||
|
||||
export function syncDevices() {
|
||||
return request({
|
||||
url: "/admin/collection/door/access/device/sync",
|
||||
method: "post",
|
||||
timeout: 120000,
|
||||
});
|
||||
}
|
||||
|
||||
export function remoteDeviceList(params?: any) {
|
||||
return request({
|
||||
url: "/admin/collection/door/display/device/list",
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export function dashboard() {
|
||||
return request({
|
||||
url: "/admin/collection/door/display/dashboard",
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
@@ -29,6 +29,54 @@ export interface PlatformSyncResult extends PlatformStatus {
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/** 平台资源类型 */
|
||||
export type PlatformResourceType = "DEVICE" | "CHANNEL";
|
||||
|
||||
/** 平台资源明细视图 */
|
||||
export type PlatformDetailViewType = "REMOTE" | "LOCAL" | "DIFFERENCE" | "ONLINE";
|
||||
|
||||
/** 平台资源明细查询参数 */
|
||||
export interface PlatformDetailQuery {
|
||||
resourceType: PlatformResourceType;
|
||||
viewType: PlatformDetailViewType;
|
||||
current: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/** ICC 或本地的设备、通道明细项 */
|
||||
export interface PlatformDetailItem {
|
||||
code: string;
|
||||
name: string;
|
||||
deviceCode?: string;
|
||||
type?: string;
|
||||
manufacturer?: string;
|
||||
ownerCode?: string;
|
||||
ip?: string;
|
||||
online?: boolean | null;
|
||||
source: "ICC" | "LOCAL";
|
||||
differenceType?: "REMOTE_ONLY" | "LOCAL_ONLY";
|
||||
}
|
||||
|
||||
/** 平台资源明细分页结果 */
|
||||
export interface PlatformDetailResult {
|
||||
resourceType: PlatformResourceType;
|
||||
viewType: PlatformDetailViewType;
|
||||
current: number;
|
||||
size: number;
|
||||
total: number;
|
||||
remoteTotal: number | null;
|
||||
localTotal: number | null;
|
||||
remoteOnlyCount: number;
|
||||
localOnlyCount: number;
|
||||
matchedCount: number;
|
||||
records: PlatformDetailItem[];
|
||||
}
|
||||
|
||||
/** 设备差异处理参数;编码为空时处理当前全部同类差异 */
|
||||
export interface PlatformDeviceDifferenceAction {
|
||||
deviceCodes?: string[];
|
||||
}
|
||||
|
||||
export function getStatus() {
|
||||
return request({
|
||||
url: "/admin/third/platform/status",
|
||||
@@ -43,3 +91,38 @@ export function syncPlatform() {
|
||||
timeout: 300000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询 ICC、本地同步、在线或双方差异的设备与通道明细
|
||||
*/
|
||||
export function getPlatformDetail(params: PlatformDetailQuery) {
|
||||
return request({
|
||||
url: "/admin/third/platform/detail",
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 ICC 存在但本地缺少的设备同步到本地
|
||||
*/
|
||||
export function syncMissingDevices(data: PlatformDeviceDifferenceAction = {}) {
|
||||
return request({
|
||||
url: "/admin/third/platform/device/sync-missing",
|
||||
method: "post",
|
||||
data,
|
||||
timeout: 300000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除经 ICC 实时复核后仍属于本地多余的同步设备
|
||||
*/
|
||||
export function deleteLocalExtraDevices(data: PlatformDeviceDifferenceAction = {}) {
|
||||
return request({
|
||||
url: "/admin/third/platform/device/local-extra",
|
||||
method: "delete",
|
||||
data,
|
||||
timeout: 300000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import request from "/@/utils/request"
|
||||
|
||||
export const fetchConfigList = (query?: any) => request({ url: "/admin/sceneLinkage/config/page", method: "get", params: query })
|
||||
export const getConfig = (id: string | number) => request({ url: `/admin/sceneLinkage/config/${id}`, method: "get" })
|
||||
export const addConfig = (data: any) => request({ url: "/admin/sceneLinkage/config", method: "post", data })
|
||||
export const updateConfig = (data: any) => request({ url: "/admin/sceneLinkage/config", method: "put", data })
|
||||
export const updateConfigStatus = (id: string | number, status: string) => request({ url: "/admin/sceneLinkage/config/status", method: "put", params: { id, status } })
|
||||
export const deleteConfigs = (ids: Array<string | number>) => request({ url: "/admin/sceneLinkage/config", method: "delete", data: ids })
|
||||
|
||||
export const fetchTriggerList = (query?: any) => {
|
||||
const params = { ...query }
|
||||
if (params.eventTimeList?.length === 2) [params.startTime, params.endTime] = params.eventTimeList
|
||||
delete params.eventTimeList
|
||||
return request({ url: "/admin/sceneLinkage/trigger/page", method: "get", params })
|
||||
}
|
||||
export const getTrigger = (id: string | number) => request({ url: `/admin/sceneLinkage/trigger/${id}`, method: "get" })
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<Splitpanes @resize="state.treeWidth = $event[0].size">
|
||||
<pane :size="state.treeWidth">
|
||||
<Splitpanes class="org-tree-splitpanes" :class="{ 'is-fixed': !resizable }" @resize="handleResize">
|
||||
<pane :size="state.treeWidth" :min-size="treePaneMinSize" :max-size="treePaneMaxSize">
|
||||
<!-- 树 -->
|
||||
<div v-loading="state.loading" class="h-full flex flex-col m-1">
|
||||
<div v-loading="state.loading" class="org-tree-panel">
|
||||
<!-- 头部区域 -->
|
||||
<div class="flex items-center pb-2 shadow-gray-400 z-10"
|
||||
<div class="org-tree-header flex items-center pb-2 shadow-gray-400 z-10"
|
||||
style="box-shadow: 0 8px 8px -8px rgba(0, 0, 0, 0.1)">
|
||||
<div class="w-4/5">
|
||||
<el-input v-model="state.searchName" suffix-icon="search" :placeholder="placeholder" clearable
|
||||
@@ -32,7 +32,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 树形结构 -->
|
||||
<div class="flex-1 overflow-hidden hover:overflow-auto">
|
||||
<div class="org-tree-content">
|
||||
<el-tree ref="treeRef" :props="props.props" :data="state.treeDataList" :expand-on-click-node="false"
|
||||
node-key="id" :highlight-current="nodeClickable ? true : false" default-expand-all show-checkbox
|
||||
@node-click="handleNodeClick" @check="handleCheck">
|
||||
@@ -49,9 +49,16 @@
|
||||
</div>
|
||||
|
||||
<!-- 底部区域 -->
|
||||
<div class="flex justify-center items-center pt-2 z-50 sticky bottom-0 overflow-hidden"
|
||||
<div class="org-tree-footer flex justify-center items-center pt-2 z-50 overflow-hidden"
|
||||
style="box-shadow: 0 -8px 8px -8px rgba(0, 0, 0, 0.1)">
|
||||
<el-switch v-model="state.switch" @change="handleSwitchChange" :inactive-text="switchInactiveText"
|
||||
<el-radio-group v-if="filterControl === 'segmented'" v-model="state.switch" size="small"
|
||||
class="org-tree-filter" aria-label="组织范围"
|
||||
:disabled="nodeClickable && state.checkedTreeNodes.length > 0" @change="handleSwitchChange">
|
||||
<el-radio-button :label="false">{{ switchInactiveText }}</el-radio-button>
|
||||
<el-radio-button :label="true">{{ switchActiveText }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-switch v-else v-model="state.switch" @change="handleSwitchChange"
|
||||
:inactive-text="switchInactiveText"
|
||||
:active-text="switchActiveText"
|
||||
:disabled="nodeClickable && state.checkedTreeNodes.length > 0" />
|
||||
</div>
|
||||
@@ -60,7 +67,8 @@
|
||||
|
||||
<pane :size="100 - state.treeWidth" class="pl-1 relative">
|
||||
<!-- 折叠按钮 -->
|
||||
<div class="w-[12px] h-[56px] bg-gray-200 absolute left-0 top-[44vh] translate-y-[-50%] transition-all cursor-pointer rounded-tr-2xl rounded-br-2xl"
|
||||
<div v-if="resizable"
|
||||
class="w-[12px] h-[56px] bg-gray-200 absolute left-0 top-[44vh] translate-y-[-50%] transition-all cursor-pointer rounded-tr-2xl rounded-br-2xl"
|
||||
@click="handleClickTreeFold()">
|
||||
<i class="text-gray-400 inline-block text-center leading-[62px] hover:opacity-50">
|
||||
<el-icon>
|
||||
@@ -93,6 +101,8 @@ const props = withDefaults(
|
||||
switchInactiveText?: string | undefined
|
||||
switchActiveText?: string | undefined
|
||||
defaultTreeWidth?: number | string
|
||||
resizable?: boolean
|
||||
filterControl?: "switch" | "segmented"
|
||||
}>(),
|
||||
{
|
||||
placeholder: "",
|
||||
@@ -110,6 +120,8 @@ const props = withDefaults(
|
||||
switchInactiveText: "当前级",
|
||||
switchActiveText: "当前级及子级",
|
||||
defaultTreeWidth: 15,
|
||||
resizable: true,
|
||||
filterControl: "switch",
|
||||
},
|
||||
)
|
||||
const emit = defineEmits(["getSelectNode"])
|
||||
@@ -128,6 +140,12 @@ const state = reactive({
|
||||
})
|
||||
// 是否折叠树组件
|
||||
const treeVisible = computed(() => state.treeWidth > 0)
|
||||
const treePaneMinSize = computed(() => props.resizable ? 0 : Number(props.defaultTreeWidth))
|
||||
const treePaneMaxSize = computed(() => props.resizable ? 100 : Number(props.defaultTreeWidth))
|
||||
|
||||
const handleResize = (panes: Array<{ size: number }>) => {
|
||||
if (props.resizable && panes[0]) state.treeWidth = panes[0].size
|
||||
}
|
||||
|
||||
// 获取部门树形结构数据
|
||||
const getTreeData = () => {
|
||||
@@ -263,4 +281,44 @@ defineExpose({
|
||||
background: var(--el-color-primary);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.org-tree-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.org-tree-header,
|
||||
.org-tree-footer {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.org-tree-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.org-tree-filter {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.org-tree-filter .el-radio-button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.org-tree-filter .el-radio-button__inner {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.org-tree-splitpanes.is-fixed>.splitpanes__splitter {
|
||||
width: 1px;
|
||||
pointer-events: none;
|
||||
background: var(--el-border-color);
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,12 @@
|
||||
<template>
|
||||
<el-tree-select class="stafftree" :props="{
|
||||
<el-tree-select class="stafftree" :class="{ 'stafftree--allow-device': props.allowDevice }" :props="{
|
||||
label: 'treeLabel',
|
||||
children: 'deviceChannelList',
|
||||
}" node-key="treeValue" v-model="state.selectId" :placeholder="props.placeholder" :clearable="props.clearable"
|
||||
:loading="state.loading" :data="state.options" :render-after-expand="true" show-checkbox multiple
|
||||
:disabled="props.disabled" :loading="state.loading" :data="state.options" :render-after-expand="true"
|
||||
:check-strictly="props.allowDevice" show-checkbox multiple
|
||||
style="width: 100%" filterable :filter-node-method="filterNode"
|
||||
@change="handleChange"
|
||||
@visible-change="(e: boolean) => e && getOptions(true)">
|
||||
<template #default="{ data, node }">
|
||||
<div v-if="node.level == 1" class="flex items-center gap-2">
|
||||
@@ -34,6 +36,8 @@ type Props = {
|
||||
clearable?: boolean
|
||||
autoFirst?: boolean
|
||||
apiParams?: any
|
||||
/** 是否允许选择设备节点,默认仅选择设备下的通道 */
|
||||
allowDevice?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -44,6 +48,7 @@ const props = withDefaults(
|
||||
clearable: true,
|
||||
autoFirst: false,
|
||||
apiParams: {},
|
||||
allowDevice: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -70,6 +75,37 @@ const hasSelectedValue = (value: unknown) => {
|
||||
|
||||
const shouldLoadOptionsOnInit = (value: unknown) => props.autoFirst || hasSelectedValue(value)
|
||||
|
||||
/**
|
||||
* 将设备通道树拍平,并补充通道所属设备,供业务表单直接转换提交数据。
|
||||
*/
|
||||
const flattenOptions = (nodes: any[], parentDevice?: any): any[] => nodes.flatMap((node) => {
|
||||
const isDevice = Array.isArray(node.deviceChannelList)
|
||||
const currentDevice = isDevice ? node : parentDevice
|
||||
const currentNode = {
|
||||
...node,
|
||||
nodeType: isDevice ? "DEVICE" : "CHANNEL",
|
||||
deviceCode: node.deviceCode || currentDevice?.deviceCode || currentDevice?.treeValue,
|
||||
deviceName: isDevice
|
||||
? (node.deviceName || node.treeLabel)
|
||||
: (node.deviceName || currentDevice?.deviceName || currentDevice?.treeLabel),
|
||||
channelCode: isDevice ? undefined : (node.channelCode || node.treeValue),
|
||||
channelName: isDevice ? undefined : (node.channelName || node.treeLabel),
|
||||
}
|
||||
return [currentNode, ...(isDevice ? flattenOptions(node.deviceChannelList, node) : [])]
|
||||
})
|
||||
|
||||
/** 根据选中值返回对应的设备或通道节点。 */
|
||||
const getSelectedNodes = (values: unknown) => {
|
||||
const selectedValues = Array.isArray(values) ? values : []
|
||||
const selectedSet = new Set(selectedValues.map((item) => String(item)))
|
||||
return flattenOptions(state.options).filter((item) => selectedSet.has(String(item.treeValue)))
|
||||
}
|
||||
|
||||
/** 同时返回选中值和节点详情,兼容原有只接收第一个参数的使用方式。 */
|
||||
const handleChange = (values: unknown) => {
|
||||
emit("change", values, getSelectedNodes(values))
|
||||
}
|
||||
|
||||
watch(
|
||||
selectId,
|
||||
(newVal, oldValue) => {
|
||||
@@ -171,7 +207,7 @@ async function getOptions(force = false) {
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.stafftree>.el-tree-node>.el-tree-node__content .el-checkbox {
|
||||
.stafftree:not(.stafftree--allow-device)>.el-tree-node>.el-tree-node__content .el-checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,15 +13,15 @@
|
||||
}
|
||||
"
|
||||
:class="{ 'layout-columns-active': state.liIndex === k, 'layout-columns-hover': state.liHoverIndex === k }"
|
||||
:title="$t(v.name)"
|
||||
:title="$t(getRouteTitle(v))"
|
||||
>
|
||||
<div :class="themeConfig.columnsAsideLayout" v-if="!v.meta.isLink || (v.meta.isLink && v.meta.isIframe)">
|
||||
<SvgIcon :name="v.meta.icon" />
|
||||
<div class="columns-vertical-title font12">
|
||||
{{
|
||||
$t(v.name) && $t(v.name).length >= 4
|
||||
? $t(v.name).substr(0, themeConfig.columnsAsideLayout === 'columns-vertical' ? 4 : 3)
|
||||
: $t(v.name)
|
||||
$t(getRouteTitle(v)) && $t(getRouteTitle(v)).length >= 4
|
||||
? $t(getRouteTitle(v)).substr(0, themeConfig.columnsAsideLayout === 'columns-vertical' ? 4 : 3)
|
||||
: $t(getRouteTitle(v))
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,9 +30,9 @@
|
||||
<SvgIcon :name="v.meta.icon" />
|
||||
<div class="columns-vertical-title font12">
|
||||
{{
|
||||
$t(v.name) && $t(v.name).length >= 4
|
||||
? $t(v.name).substr(0, themeConfig.columnsAsideLayout === 'columns-vertical' ? 4 : 3)
|
||||
: $t(v.name)
|
||||
$t(getRouteTitle(v)) && $t(getRouteTitle(v)).length >= 4
|
||||
? $t(getRouteTitle(v)).substr(0, themeConfig.columnsAsideLayout === 'columns-vertical' ? 4 : 3)
|
||||
: $t(getRouteTitle(v))
|
||||
}}
|
||||
</div>
|
||||
</a>
|
||||
@@ -70,6 +70,8 @@ const state = reactive<ColumnsAsideState>({
|
||||
routeSplit: [],
|
||||
});
|
||||
|
||||
const getRouteTitle = (item: RouteItem) => item.meta?.title || item.name;
|
||||
|
||||
// 设置菜单高亮位置移动
|
||||
const setColumnsAsideMove = (k: number) => {
|
||||
state.liIndex = k;
|
||||
|
||||
@@ -11,11 +11,11 @@
|
||||
<el-breadcrumb-item v-for="(v, k) in state.breadcrumbList" :key="!v.meta.tagsViewName ? v.name : v.meta.tagsViewName">
|
||||
<span v-if="k === state.breadcrumbList.length - 1" class="layout-navbars-breadcrumb-span">
|
||||
<SvgIcon :name="v.meta.icon" class="layout-navbars-breadcrumb-iconfont" v-if="themeConfig.isBreadcrumbIcon" />
|
||||
<div v-if="!v.meta.tagsViewName">{{ $t(v.name) }}</div>
|
||||
<div v-if="!v.meta.tagsViewName">{{ $t(v.meta.title || v.name) }}</div>
|
||||
<div v-else>{{ v.meta.tagsViewName }}</div>
|
||||
</span>
|
||||
<a v-else @click.prevent="onBreadcrumbClick(v)">
|
||||
<SvgIcon :name="v.meta.icon" class="layout-navbars-breadcrumb-iconfont" v-if="themeConfig.isBreadcrumbIcon" />{{ $t(v.name) }}
|
||||
<SvgIcon :name="v.meta.icon" class="layout-navbars-breadcrumb-iconfont" v-if="themeConfig.isBreadcrumbIcon" />{{ $t(v.meta.title || v.name) }}
|
||||
</a>
|
||||
</el-breadcrumb-item>
|
||||
</transition-group>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<template #default="{ item }">
|
||||
<div>
|
||||
<SvgIcon :name="item.meta.icon" class="mr5" />
|
||||
{{ $t(item.name) }}
|
||||
{{ $t(item.meta.title || item.name) }}
|
||||
</div>
|
||||
</template>
|
||||
</el-autocomplete>
|
||||
@@ -66,7 +66,8 @@ const menuSearch = (queryString: string, cb: Function) => {
|
||||
// 菜单搜索过滤
|
||||
const createFilter = (queryString: string) => {
|
||||
return (restaurant: RouteItem) => {
|
||||
return restaurant.path.toLowerCase().indexOf(queryString.toLowerCase()) > -1 || t(restaurant!.name!).indexOf(queryString.toLowerCase()) > -1;
|
||||
const routeTitle = restaurant.meta?.title || restaurant.name!;
|
||||
return restaurant.path.toLowerCase().indexOf(queryString.toLowerCase()) > -1 || t(routeTitle).indexOf(queryString.toLowerCase()) > -1;
|
||||
};
|
||||
};
|
||||
// 初始化菜单数据
|
||||
|
||||
@@ -129,7 +129,17 @@ const getTagsViewRoutes = async () => {
|
||||
// pinia 中获取路由信息:如果是设置了固定的(isAffix),进行初始化显示
|
||||
const initTagsView = async () => {
|
||||
if (Session.get('tagsViewList') && getThemeConfig.value.isCacheTagsView) {
|
||||
state.tagsViewList = await Session.get('tagsViewList');
|
||||
const cachedTags = (await Session.get('tagsViewList')) as RouteItem[];
|
||||
state.tagsViewList = cachedTags.map((cachedTag) => {
|
||||
const currentRoute = state.tagsViewRoutesList.find((item: RouteItem) => item.path === cachedTag.path);
|
||||
if (!currentRoute) return cachedTag;
|
||||
return {
|
||||
...cachedTag,
|
||||
name: currentRoute.name,
|
||||
meta: { ...cachedTag.meta, ...currentRoute.meta },
|
||||
};
|
||||
});
|
||||
addBrowserSetSession(state.tagsViewList);
|
||||
} else {
|
||||
await state.tagsViewRoutesList.map((v: RouteItem) => {
|
||||
if (v.meta?.isAffix && !v.meta.isHide) {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<el-sub-menu :index="val.path" v-if="val.children && val.children.length > 0" :key="val.path">
|
||||
<template #title>
|
||||
<SvgIcon :name="val.meta.icon"/>
|
||||
<span class="font-semibold">{{ $t(val.name) }}</span>
|
||||
<span class="font-semibold">{{ $t(val.meta.title || val.name) }}</span>
|
||||
</template>
|
||||
<SubItem :chil="val.children"/>
|
||||
</el-sub-menu>
|
||||
@@ -16,12 +16,12 @@
|
||||
<template #title v-if="!val.meta.isLink || (val.meta.isLink && val.meta.isIframe)">
|
||||
<!-- 此处可 指定 color='red' 等指定顶栏SVG颜色 -->
|
||||
<SvgIcon :name="val.meta.icon"/>
|
||||
<p class="font-semibold">{{ $t(val.name) }}</p>
|
||||
<p class="font-semibold">{{ $t(val.meta.title || val.name) }}</p>
|
||||
</template>
|
||||
<template #title v-else>
|
||||
<a class="w100" @click.prevent="onALinkClick(val)">
|
||||
<SvgIcon :name="val.meta.icon"/>
|
||||
{{ $t(val.name) }}
|
||||
{{ $t(val.meta.title || val.name) }}
|
||||
</a>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<el-sub-menu :index="val.path" :key="val.path" v-if="val.children && val.children.length > 0">
|
||||
<template #title>
|
||||
<SvgIcon :name="val.meta.icon" />
|
||||
<span>{{ $t(val.name) }}</span>
|
||||
<span>{{ $t(val.meta.title || val.name) }}</span>
|
||||
</template>
|
||||
<sub-item :chil="val.children" />
|
||||
</el-sub-menu>
|
||||
@@ -11,12 +11,12 @@
|
||||
<el-menu-item :index="val.path" :key="val.path">
|
||||
<template v-if="!val.meta.isLink || (val.meta.isLink && val.meta.isIframe)">
|
||||
<SvgIcon :name="val.meta.icon" />
|
||||
<span>{{ $t(val.name) }}</span>
|
||||
<span>{{ $t(val.meta.title || val.name) }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a class="w100" @click.prevent="onALinkClick(val)">
|
||||
<SvgIcon :name="val.meta.icon" />
|
||||
{{ $t(val.name) }}
|
||||
{{ $t(val.meta.title || val.name) }}
|
||||
</a>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<el-sub-menu :index="val.path" v-if="val.children && val.children.length > 0" :key="val.path">
|
||||
<template #title>
|
||||
<SvgIcon :name="val.meta.icon" />
|
||||
<span>{{ $t(val.name) }}</span>
|
||||
<span>{{ $t(val.meta.title || val.name) }}</span>
|
||||
</template>
|
||||
<SubItem :chil="val.children" />
|
||||
</el-sub-menu>
|
||||
@@ -21,10 +21,10 @@
|
||||
<el-menu-item :index="val.path" :key="val.path">
|
||||
<SvgIcon :name="val.meta.icon" />
|
||||
<template #title v-if="!val.meta.isLink || (val.meta.isLink && val.meta.isIframe)">
|
||||
<span>{{ $t(val.name) }}</span>
|
||||
<span>{{ $t(val.meta.title || val.name) }}</span>
|
||||
</template>
|
||||
<template #title v-else>
|
||||
<a class="w100" @click.prevent="onALinkClick(val)">{{ $t(val.name) }}</a>
|
||||
<a class="w100" @click.prevent="onALinkClick(val)">{{ $t(val.meta.title || val.name) }}</a>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
|
||||
@@ -32,7 +32,7 @@ const onGotoFullPage = () => {
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
state.title = <string>route.name;
|
||||
state.title = <string>(route.meta.title || route.name);
|
||||
state.isLink = <string>route.meta.isLink;
|
||||
},
|
||||
{
|
||||
|
||||
@@ -125,6 +125,9 @@ export async function setBackEndControlRefreshRoutes() {
|
||||
export function backEndComponent(routes: any) {
|
||||
if (!routes) return;
|
||||
return routes.map((item: any) => {
|
||||
const routeTitle = item.meta?.title || item.name;
|
||||
item.meta = { ...item.meta, title: routeTitle };
|
||||
item.name = `${routeTitle}${item.path}`;
|
||||
if (item.path && item.path.startsWith('http')) {
|
||||
if (item.meta.isIframe) {
|
||||
item.component = () => import('/@/layout/routerView/iframes.vue');
|
||||
|
||||
@@ -20,6 +20,7 @@ import { RouteRecordRaw } from 'vue-router';
|
||||
// 扩展 RouteMeta 接口
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
title?: string;
|
||||
isLink?: string;
|
||||
isHide?: boolean;
|
||||
isAuth?: boolean;
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ export function setTagsViewNameI18n(item: any) {
|
||||
}
|
||||
} else {
|
||||
// 非自定义 tagsView 名称
|
||||
tagsViewName = i18n.global.t(item.name);
|
||||
tagsViewName = i18n.global.t(item.meta?.title || item.name);
|
||||
}
|
||||
return tagsViewName;
|
||||
}
|
||||
|
||||
@@ -17,105 +17,65 @@
|
||||
<el-row>
|
||||
<div class="mb8" style="width: 100%">
|
||||
<template v-if="isRoleMode">
|
||||
<el-button icon="folder-add" type="primary" class="ml10" v-auth="'sys_role_add'" @click="roleDialogRef.openDialog()">
|
||||
<el-button icon="folder-add" type="primary" class="ml10" v-auth="'sys_role_add'"
|
||||
@click="roleDialogRef.openDialog()">
|
||||
{{ $t('common.addBtn') }}
|
||||
</el-button>
|
||||
<el-button plain icon="upload-filled" type="primary" class="ml10" v-auth="'sys_user_add'" @click="excelUploadRef.show()">
|
||||
<el-button plain icon="upload-filled" type="primary" class="ml10" v-auth="'sys_user_add'"
|
||||
@click="excelUploadRef.show()">
|
||||
{{ $t('common.importBtn') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
plain
|
||||
:disabled="multiple"
|
||||
icon="Delete"
|
||||
type="primary"
|
||||
class="ml10"
|
||||
v-auth="'sys_user_del'"
|
||||
@click="handleDelete(selectObjs)"
|
||||
>
|
||||
<el-button plain :disabled="multiple" icon="Delete" type="primary" class="ml10"
|
||||
v-auth="'sys_user_del'" @click="handleDelete(selectObjs)">
|
||||
{{ $t('common.delBtn') }}
|
||||
</el-button>
|
||||
</template>
|
||||
<right-toolbar
|
||||
v-model:showSearch="showSearch"
|
||||
:export="isRoleMode ? 'sys_role_export' : false"
|
||||
class="ml10"
|
||||
style="float: right; margin-right: 20px"
|
||||
@exportExcel="exportExcel"
|
||||
@queryTable="getDataList"
|
||||
></right-toolbar>
|
||||
<right-toolbar v-model:showSearch="showSearch" :export="isRoleMode ? 'sys_role_export' : false"
|
||||
class="ml10" style="float: right; margin-right: 20px" @exportExcel="exportExcel"
|
||||
@queryTable="getDataList"></right-toolbar>
|
||||
</div>
|
||||
</el-row>
|
||||
<el-table
|
||||
:data="state.dataList"
|
||||
v-loading="state.loading"
|
||||
style="width: 100%"
|
||||
row-key="roleId"
|
||||
border
|
||||
:cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column v-if="isRoleMode" type="selection" :selectable="handleSelectable" width="50" align="center" />
|
||||
<el-table :data="state.dataList" v-loading="state.loading" style="width: 100%" row-key="roleId" border
|
||||
:cell-style="tableStyle.cellStyle" :header-cell-style="tableStyle.headerCellStyle"
|
||||
@selection-change="handleSelectionChange">
|
||||
<el-table-column v-if="isRoleMode" type="selection" :selectable="handleSelectable" width="50"
|
||||
align="center" />
|
||||
<el-table-column type="index" :label="$t('sysrole.index')" width="80" />
|
||||
<el-table-column prop="roleName" :label="$t('sysrole.roleName')" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="roleCode" :label="$t('sysrole.roleCode')" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="roleDesc" :label="$t('sysrole.roleDesc')" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="roleName" :label="$t('sysrole.roleName')"
|
||||
show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="roleCode" :label="$t('sysrole.roleCode')"
|
||||
show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="roleDesc" :label="$t('sysrole.roleDesc')"
|
||||
show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="data_authority" :label="$t('sysrole.data_authority')" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<dict-tag :options="dictType" :value="scope.row.dsType"></dict-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createTime" :label="$t('sysrole.createTime')" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column :label="$t('common.action')" :width="isRoleMode ? 180 : 250">
|
||||
<el-table-column prop="createTime" :label="$t('sysrole.createTime')"
|
||||
show-overflow-tooltip></el-table-column>
|
||||
<el-table-column :label="$t('common.action')" :width="isRoleMode ? 220 : 250">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
v-if="isRoleMode"
|
||||
text
|
||||
type="primary"
|
||||
icon="edit-pen"
|
||||
v-auth="'sys_role_edit'"
|
||||
@click="roleDialogRef.openDialog(scope.row.roleId)"
|
||||
>
|
||||
<el-button v-if="isRoleMode" text type="primary" icon="edit-pen" v-auth="'sys_role_edit'"
|
||||
@click="roleDialogRef.openDialog(scope.row.roleId)">
|
||||
{{ $t('common.editBtn') }}
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
v-if="!isRoleMode"
|
||||
text
|
||||
type="primary"
|
||||
icon="turn-off"
|
||||
v-auth="'sys_role_perm'"
|
||||
@click="permessionRef.openDialog(scope.row)"
|
||||
>
|
||||
<!-- v-if="!isRoleMode" -->
|
||||
<el-button text type="primary" icon="turn-off" v-auth="'sys_role_perm'"
|
||||
@click="permessionRef.openDialog(scope.row)">
|
||||
{{ $t('sysrole.permissionTip') }}
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
v-if="!isRoleMode"
|
||||
text
|
||||
type="primary"
|
||||
icon="edit-pen"
|
||||
v-auth="'sys_role_edit'"
|
||||
@click="roleDialogRef.openDialog(scope.row.roleId)"
|
||||
>
|
||||
<el-button v-if="!isRoleMode" text type="primary" icon="edit-pen" v-auth="'sys_role_edit'"
|
||||
@click="roleDialogRef.openDialog(scope.row.roleId)">
|
||||
{{ $t('sysrole.data_authority') }}
|
||||
</el-button>
|
||||
|
||||
<el-tooltip
|
||||
v-if="isRoleMode"
|
||||
:content="$t('sysrole.deleteDisabledTip')"
|
||||
:disabled="scope.row.roleId !== '1'"
|
||||
placement="top"
|
||||
>
|
||||
<el-tooltip v-if="isRoleMode" :content="$t('sysrole.deleteDisabledTip')"
|
||||
:disabled="scope.row.roleId !== '1'" placement="top">
|
||||
<span style="margin-left: 12px">
|
||||
<el-button
|
||||
text
|
||||
type="primary"
|
||||
icon="delete"
|
||||
:disabled="scope.row.roleId === '1'"
|
||||
v-auth="'sys_role_del'"
|
||||
@click="handleDelete([scope.row.roleId])"
|
||||
>
|
||||
<el-button text type="primary" icon="delete" :disabled="scope.row.roleId === '1'"
|
||||
v-auth="'sys_role_del'" @click="handleDelete([scope.row.roleId])">
|
||||
{{ $t('common.delBtn') }}
|
||||
</el-button>
|
||||
</span>
|
||||
@@ -123,22 +83,17 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<pagination v-bind="state.pagination" @size-change="sizeChangeHandle" @current-change="currentChangeHandle" />
|
||||
<pagination v-bind="state.pagination" @size-change="sizeChangeHandle"
|
||||
@current-change="currentChangeHandle" />
|
||||
</div>
|
||||
|
||||
<!-- 角色编辑、新增 -->
|
||||
<role-dialog ref="roleDialogRef" :mode="props.mode" @refresh="getDataList()" />
|
||||
<!-- 导入角色 -->
|
||||
<upload-excel
|
||||
v-if="isRoleMode"
|
||||
ref="excelUploadRef"
|
||||
:title="$t('sysrole.importRoleTip')"
|
||||
url="/admin/role/import"
|
||||
temp-url="/admin/sys-file/local/file/role.xlsx"
|
||||
@refreshDataList="getDataList"
|
||||
/>
|
||||
<upload-excel v-if="isRoleMode" ref="excelUploadRef" :title="$t('sysrole.importRoleTip')"
|
||||
url="/admin/role/import" temp-url="/admin/sys-file/local/file/role.xlsx" @refreshDataList="getDataList" />
|
||||
<!-- 授权 -->
|
||||
<permession v-if="!isRoleMode" ref="permessionRef" />
|
||||
<permession ref="permessionRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -5,45 +5,36 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div>{{ state.dialog.title }}</div>
|
||||
<div class="flex mr-16">
|
||||
<el-checkbox :label="$t('common.expand')" @change="handleExpand"/>
|
||||
<el-checkbox :label="$t('common.selectAll')" @change="handleSelectAll"/>
|
||||
<el-checkbox :label="$t('common.expand')" @change="handleExpand" />
|
||||
<el-checkbox :label="$t('common.selectAll')" @change="handleSelectAll" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-scrollbar class="h-[400px] sm:h-[600px]">
|
||||
<el-tree
|
||||
v-loading="loading"
|
||||
ref="menuTree"
|
||||
:data="state.treeData"
|
||||
:default-checked-keys="state.checkedKeys"
|
||||
:check-strictly="!checkStrictly"
|
||||
:props="state.defaultProps"
|
||||
class="filter-tree"
|
||||
node-key="id"
|
||||
highlight-current
|
||||
show-checkbox
|
||||
/>
|
||||
<el-tree v-loading="loading" ref="menuTree" :data="state.treeData" :default-checked-keys="state.checkedKeys"
|
||||
:check-strictly="!checkStrictly" :props="state.defaultProps" class="filter-tree" node-key="id"
|
||||
highlight-current show-checkbox />
|
||||
</el-scrollbar>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="state.dialog.isShowDialog = false">取 消</el-button>
|
||||
<el-button type="primary" @click="onSubmit">{{ state.dialog.submitTxt }}</el-button>
|
||||
</span>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="state.dialog.isShowDialog = false">取 消</el-button>
|
||||
<el-button type="primary" @click="onSubmit">{{ state.dialog.submitTxt }}</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="role-permession">
|
||||
import {fetchRoleTree, permissionUpd} from '/@/api/admin/role';
|
||||
import {pageList} from '/@/api/admin/menu';
|
||||
import {useMessage} from '/@/hooks/message';
|
||||
import {Ref} from 'vue';
|
||||
import {useI18n} from 'vue-i18n';
|
||||
import { fetchRoleTree, permissionUpd } from '/@/api/admin/role';
|
||||
import { pageList } from '/@/api/admin/menu';
|
||||
import { useMessage } from '/@/hooks/message';
|
||||
import { Ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import other from '/@/utils/other';
|
||||
import {CheckboxValueType} from 'element-plus';
|
||||
import { CheckboxValueType } from 'element-plus';
|
||||
|
||||
const {t} = useI18n();
|
||||
const { t } = useI18n();
|
||||
|
||||
const menuTree = ref();
|
||||
const checkStrictly = ref(true);
|
||||
@@ -74,17 +65,17 @@ const openDialog = (row: any) => {
|
||||
state.roleId = row.roleId;
|
||||
loading.value = true;
|
||||
fetchRoleTree(row.roleId)
|
||||
.then((res) => {
|
||||
checkedKeys.value = res.data;
|
||||
return pageList();
|
||||
})
|
||||
.then((r) => {
|
||||
state.treeData = r.data;
|
||||
state.checkedKeys = other.resolveAllEunuchNodeId(state.treeData, checkedKeys.value, []);
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
.then((res) => {
|
||||
checkedKeys.value = res.data;
|
||||
return pageList();
|
||||
})
|
||||
.then((r) => {
|
||||
state.treeData = r.data;
|
||||
state.checkedKeys = other.resolveAllEunuchNodeId(state.treeData, checkedKeys.value, []);
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
state.dialog.isShowDialog = true;
|
||||
};
|
||||
|
||||
@@ -119,13 +110,13 @@ const onSubmit = () => {
|
||||
const menuIds = menuTree.value.getCheckedKeys().join(',').concat(',').concat(menuTree.value.getHalfCheckedKeys().join(','));
|
||||
loading.value = true;
|
||||
permissionUpd(state.roleId, menuIds)
|
||||
.then(() => {
|
||||
state.dialog.isShowDialog = false;
|
||||
useMessage().success(t('common.editSuccessText'));
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
.then(() => {
|
||||
state.dialog.isShowDialog = false;
|
||||
useMessage().success(t('common.editSuccessText'));
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
// 暴露变量
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="layout-padding">
|
||||
<div :class="{ 'layout-padding': !props.embedded, 'embedded-alarm-list': props.embedded }">
|
||||
<div class="layout-padding-auto layout-padding-view">
|
||||
<el-row shadow="hover" v-show="showSearch" class="ml10">
|
||||
<el-form :model="state.queryForm" ref="queryRef" :inline="true" @keyup.enter="getDataList">
|
||||
@@ -82,7 +82,7 @@
|
||||
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
|
||||
</div>
|
||||
<!-- 编辑、新增 -->
|
||||
<form-dialog ref="formDialogRef" @refresh="getDataList(false)" />
|
||||
<form-dialog v-if="!props.readOnly" ref="formDialogRef" @refresh="getDataList(false)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -94,7 +94,13 @@ import { useDict } from '/@/hooks/dict';
|
||||
import { usePanel } from "/@/stores/panel"
|
||||
import { getDefaultAlarmDateList } from "/@/utils/other"
|
||||
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
readOnly?: boolean
|
||||
embedded?: boolean
|
||||
}>(), {
|
||||
readOnly: false,
|
||||
embedded: false,
|
||||
})
|
||||
|
||||
const storesPanel = usePanel()
|
||||
|
||||
@@ -215,3 +221,12 @@ const handleDelete = async (ids: string[]) => {
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.embedded-alarm-list :deep(.layout-padding-auto) {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
@@ -285,6 +285,8 @@ const handleDelete = async (ids: string[]) => {
|
||||
useMessage().error(err.msg);
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ refresh: getDataList });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
<el-table-column prop="orgName" label="所属组织" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="实时状态" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.isOnline === 1 ? 'success' : 'danger'">{{ row.isOnline === 1 ? '在线' : '离线' }}</el-tag>
|
||||
<el-tag :type="isOnline(row.isOnline) ? 'success' : 'danger'">
|
||||
{{ isOnline(row.isOnline) ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -52,6 +54,7 @@ const loading = ref(false);
|
||||
const lastRefresh = ref("");
|
||||
const lastError = ref("");
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
const isOnline = (value: unknown) => String(value) === "1";
|
||||
|
||||
const load = async (quiet = false) => {
|
||||
if (!quiet) loading.value = true;
|
||||
|
||||
@@ -20,17 +20,9 @@
|
||||
<el-tab-pane label="实时状态" name="status">
|
||||
<gate-remote-list ref="remoteRef" :active="activeTab === 'status'" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="开关闸记录" name="operation" lazy>
|
||||
<gate-operation-list />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="车辆识别记录" name="record" lazy>
|
||||
<el-tab-pane label="道闸通行记录" name="records" lazy>
|
||||
<div class="embedded-view">
|
||||
<car-record :read-only="true" :embedded="true" :active="activeTab === 'record'" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="车辆进出记录" name="access" lazy>
|
||||
<div class="embedded-view">
|
||||
<car-access :read-only="true" :embedded="true" :active="activeTab === 'access'" />
|
||||
<car-record ref="recordRef" :read-only="true" :embedded="true" :active="activeTab === 'records'" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
@@ -43,9 +35,7 @@ import dayjs from "dayjs";
|
||||
import * as GateApi from "/@/api/collection/gate";
|
||||
import type { GateDashboard } from "/@/api/collection/gate";
|
||||
import GateRemoteList from "./components/gate-remote-list.vue";
|
||||
import GateOperationList from "./components/gate-operation-list.vue";
|
||||
import CarRecord from "/@/views/car/carRecord/index.vue";
|
||||
import CarAccess from "/@/views/car/carAccess/index.vue";
|
||||
|
||||
const emptyDashboard = (): GateDashboard => ({
|
||||
deviceTotal: 0,
|
||||
@@ -60,6 +50,7 @@ const emptyDashboard = (): GateDashboard => ({
|
||||
});
|
||||
const activeTab = ref("status");
|
||||
const remoteRef = ref();
|
||||
const recordRef = ref();
|
||||
const dashboardLoading = ref(false);
|
||||
const dashboardData = reactive<GateDashboard>(emptyDashboard());
|
||||
const summaryItems = [
|
||||
@@ -85,7 +76,11 @@ const loadDashboard = async () => {
|
||||
}
|
||||
};
|
||||
const refreshAll = async () => {
|
||||
await Promise.all([loadDashboard(), activeTab.value === "status" ? remoteRef.value?.refresh() : Promise.resolve()]);
|
||||
await Promise.all([
|
||||
loadDashboard(),
|
||||
activeTab.value === "status" ? remoteRef.value?.refresh() : Promise.resolve(),
|
||||
activeTab.value === "records" ? recordRef.value?.refresh() : Promise.resolve(),
|
||||
]);
|
||||
};
|
||||
onMounted(loadDashboard);
|
||||
</script>
|
||||
@@ -145,10 +140,21 @@ onMounted(loadDashboard);
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.summary-item strong.primary { color: var(--el-color-primary); }
|
||||
.summary-item strong.success { color: var(--el-color-success); }
|
||||
.summary-item strong.warning { color: var(--el-color-warning); }
|
||||
.summary-item strong.danger { color: var(--el-color-danger); }
|
||||
.summary-item strong.primary {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.summary-item strong.success {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.summary-item strong.warning {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.summary-item strong.danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.embedded-view :deep(> .layout-padding) {
|
||||
height: auto;
|
||||
@@ -163,12 +169,22 @@ onMounted(loadDashboard);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.summary-grid { grid-template-columns: repeat(3, minmax(120px, 1fr)); }
|
||||
.summary-grid {
|
||||
grid-template-columns: repeat(3, minmax(120px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.display-page { padding: 14px; }
|
||||
.page-header { align-items: flex-start; }
|
||||
.summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.display-page {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="layout-padding">
|
||||
<div class="layout-padding-auto layout-padding-view access-page">
|
||||
<header class="page-header">
|
||||
<h2>出入门禁接入管理</h2>
|
||||
</header>
|
||||
<el-tabs v-model="activeTab" class="access-tabs">
|
||||
<el-tab-pane label="门禁设备" name="devices" lazy>
|
||||
<door-device-list />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="同步日志" name="logs" lazy>
|
||||
<door-log-list />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="collectionDoorAccess">
|
||||
import DoorDeviceList from "./components/door-device-list.vue";
|
||||
import DoorLogList from "./components/door-log-list.vue";
|
||||
|
||||
const activeTab = ref("devices");
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.access-page {
|
||||
display: block;
|
||||
overflow-y: auto;
|
||||
padding: 18px 22px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
letter-spacing: 0;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.access-tabs {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-form ref="queryRef" :model="state.queryForm" inline @keyup.enter="getDataList">
|
||||
<el-form-item label="设备名称" prop="deviceName">
|
||||
<el-input v-model="state.queryForm.deviceName" placeholder="设备名称模糊查询" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="设备 IP" prop="deviceIp">
|
||||
<el-input v-model="state.queryForm.deviceIp" placeholder="IP 模糊查询" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="在线状态" prop="isOnline">
|
||||
<el-select v-model="state.queryForm.isOnline" clearable style="width: 140px">
|
||||
<el-option label="在线" value="1" />
|
||||
<el-option label="离线" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="同步状态" prop="isSync">
|
||||
<el-select v-model="state.queryForm.isSync" clearable style="width: 140px">
|
||||
<el-option label="已同步" value="1" />
|
||||
<el-option label="未同步" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="Search" @click="getDataList">查询</el-button>
|
||||
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="table-actions">
|
||||
<div>
|
||||
<el-button type="primary" icon="Plus" v-auth="'admin_deviceManage_add'" @click="formRef.openDialog()">
|
||||
新增
|
||||
</el-button>
|
||||
<el-button icon="Connection" v-auth="'collection_door_icc_sync'" :loading="syncing" @click="syncFromIcc">
|
||||
同步 ICC 门禁设备
|
||||
</el-button>
|
||||
</div>
|
||||
<div>
|
||||
<el-button icon="Download" v-auth="'admin_deviceManage_export'" @click="exportList">导出</el-button>
|
||||
<el-button icon="Refresh" @click="getDataList(false)">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle">
|
||||
<el-table-column type="index" label="#" width="60" />
|
||||
<el-table-column prop="deviceCode" label="设备编码" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="deviceName" label="设备名称" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="deviceSn" label="设备唯一标识码" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="deviceIp" label="IP" min-width="130" />
|
||||
<el-table-column prop="devicePort" label="端口" width="90" />
|
||||
<el-table-column prop="deviceManufacturer" label="厂商" width="110">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :options="dh_device_manufacturer" :value="row.deviceManufacturer" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="orgName" label="所属组织" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="在线状态" width="100" align="center">
|
||||
<template #default="{ row }"><tag-online :is-online="isOnline(row.isOnline)" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="同步状态" width="100" align="center">
|
||||
<template #default="{ row }"><tag-sync :is-sync="row.isSync == 1" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="offlineReason" label="离线原因" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="isOnline(row.isOnline)">-</span>
|
||||
<dict-tag v-else :options="offline_reason" :value="row.offlineReason" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" fixed="right" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" icon="EditPen" v-auth="'admin_deviceManage_edit'"
|
||||
@click="formRef.openDialog(row.id)">编辑</el-button>
|
||||
<el-button text type="danger" icon="Delete" v-auth="'admin_deviceManage_del'"
|
||||
@click="remove(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
|
||||
|
||||
<device-form ref="formRef" fixed-category="8" fixed-category-label="门禁设备" @refresh="getDataList(false)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="DoorDeviceList">
|
||||
import dayjs from "dayjs";
|
||||
import * as DoorApi from "/@/api/collection/door";
|
||||
import { delObjs } from "/@/api/device/deviceManage";
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table";
|
||||
import { useDict } from "/@/hooks/dict";
|
||||
import { useMessage, useMessageBox } from "/@/hooks/message";
|
||||
import DeviceForm from "/@/views/device/components/deviceForm.vue";
|
||||
|
||||
const { dh_device_manufacturer, offline_reason } = useDict("dh_device_manufacturer", "offline_reason");
|
||||
const queryRef = ref();
|
||||
const formRef = ref();
|
||||
const syncing = ref(false);
|
||||
const state: BasicTableProps = reactive({ queryForm: {}, pageList: DoorApi.devicePage });
|
||||
const { getDataList, sizeChangeHandle, currentChangeHandle, postDownBlobFile, tableStyle } = useTable(state);
|
||||
const isOnline = (value: unknown) => String(value) === "1";
|
||||
|
||||
const resetQuery = () => {
|
||||
queryRef.value?.resetFields();
|
||||
getDataList();
|
||||
};
|
||||
const exportList = () => {
|
||||
postDownBlobFile("/admin/deviceManage/export", {
|
||||
...state.queryForm,
|
||||
deviceCategory: undefined,
|
||||
deviceCategoryList: ["8"],
|
||||
}, `门禁设备_${dayjs().format("YYYY-MM-DD_HH-mm-ss")}.xlsx`);
|
||||
};
|
||||
const syncFromIcc = async () => {
|
||||
syncing.value = true;
|
||||
try {
|
||||
const { data } = await DoorApi.syncDevices();
|
||||
useMessage().success(data?.message || "同步完成");
|
||||
getDataList(false);
|
||||
} catch (error: any) {
|
||||
useMessage().error(error?.msg || "门禁设备同步失败");
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
};
|
||||
const remove = async (id: string) => {
|
||||
try {
|
||||
await useMessageBox().confirm("确认删除该本地门禁设备?");
|
||||
await delObjs([id]);
|
||||
useMessage().success("删除成功");
|
||||
getDataList(false);
|
||||
} catch (error: any) {
|
||||
if (error?.msg) useMessage().error(error.msg);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.table-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-form ref="queryRef" :model="state.queryForm" inline @keyup.enter="getDataList">
|
||||
<el-form-item label="日志类型" prop="type">
|
||||
<el-input v-model="state.queryForm.type" placeholder="请求类型模糊查询" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="请求方式" prop="method">
|
||||
<el-select v-model="state.queryForm.method" clearable style="width: 130px">
|
||||
<el-option label="GET" value="GET" />
|
||||
<el-option label="POST" value="POST" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="调用日期" prop="createTimeList">
|
||||
<el-date-picker v-model="state.queryForm.createTimeList" type="daterange" value-format="YYYY-MM-DD"
|
||||
start-placeholder="开始日期" end-placeholder="结束日期" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="Search" @click="getDataList">查询</el-button>
|
||||
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-table :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle">
|
||||
<el-table-column type="index" label="#" width="60" />
|
||||
<el-table-column prop="type" label="请求类型" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="method" label="方式" width="90" />
|
||||
<el-table-column prop="url" label="接口地址" min-width="300" show-overflow-tooltip />
|
||||
<el-table-column label="结果" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.errorMsg ? 'danger' : 'success'">{{ row.errorMsg ? "失败" : "成功" }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="errorMsg" label="失败原因" min-width="230" show-overflow-tooltip />
|
||||
<el-table-column prop="createTime" label="调用时间" width="180" />
|
||||
</el-table>
|
||||
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="DoorLogList">
|
||||
import * as ComDhLogApi from "/@/api/com/comDhLog";
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table";
|
||||
import { getDefaultAlarmDateList } from "/@/utils/other";
|
||||
|
||||
const logTypes = ["门禁设备同步", "门禁记录", "刷卡记录分页"];
|
||||
const queryRef = ref();
|
||||
const initialQuery = () => ({
|
||||
type: "",
|
||||
typeList: [...logTypes],
|
||||
createTimeList: getDefaultAlarmDateList("YYYY-MM-DD"),
|
||||
});
|
||||
const state: BasicTableProps = reactive({ queryForm: initialQuery(), pageList: ComDhLogApi.fetchList });
|
||||
const { getDataList, sizeChangeHandle, currentChangeHandle, tableStyle } = useTable(state);
|
||||
const resetQuery = () => {
|
||||
queryRef.value?.resetFields();
|
||||
Object.assign(state.queryForm, initialQuery());
|
||||
getDataList();
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-form ref="queryRef" :model="state.queryForm" inline @keyup.enter="getDataList">
|
||||
<el-form-item label="人员姓名" prop="personName">
|
||||
<el-input v-model="state.queryForm.personName" placeholder="人员姓名模糊查询" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="门禁设备" prop="deviceName">
|
||||
<el-input v-model="state.queryForm.deviceName" placeholder="设备名称模糊查询" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="进出方向" prop="enterOrExit">
|
||||
<dict-select v-model="state.queryForm.enterOrExit" :options="dh_enter_or_exit" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="识别状态" prop="recognitionStatus">
|
||||
<el-select v-model="state.queryForm.recognitionStatus" clearable style="width: 140px">
|
||||
<el-option label="已识别人员" value="recognized" />
|
||||
<el-option label="已识别访客" value="visitor" />
|
||||
<el-option label="未识别人员" value="unidentified" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="开门结果" prop="openResult">
|
||||
<el-select v-model="state.queryForm.openResult" clearable style="width: 130px">
|
||||
<el-option label="成功" value="1" />
|
||||
<el-option label="失败" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="开门方式" prop="openType">
|
||||
<dict-select v-model="state.queryForm.openType" :options="dh_door_open_type" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="发生时间" prop="swingTimeList">
|
||||
<el-date-picker v-model="state.queryForm.swingTimeList" value-format="YYYY-MM-DD HH:mm:ss"
|
||||
type="datetimerange" start-placeholder="开始时间" end-placeholder="结束时间" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="Search" @click="getDataList">查询</el-button>
|
||||
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
|
||||
<el-button icon="Download" @click="exportExcel">导出</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle">
|
||||
<el-table-column type="index" label="#" width="60" />
|
||||
<el-table-column prop="swingTime" label="发生时间" width="180" />
|
||||
<el-table-column label="识别状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="personStatusType(row)">{{ personStatus(row) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="personCode" label="人员编号" min-width="120">
|
||||
<template #default="{ row }">{{ row.personCode || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="personName" label="姓名" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ 'color-danger': personStatus(row) === '未识别人员' }">{{ personName(row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deptName" label="部门" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column prop="enterOrExit" label="进出方向" width="110">
|
||||
<template #default="{ row }"><dict-tag :options="dh_enter_or_exit" :value="row.enterOrExit" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deviceName" label="门禁设备" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="channelName" label="门禁通道" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="openType" label="开门方式" min-width="150">
|
||||
<template #default="{ row }"><dict-tag :options="dh_door_open_type" :value="row.openType" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="openResult" label="通行结果" width="110">
|
||||
<template #default="{ row }"><open-result-tag :value="row.openResult" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="remark" label="失败原因" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.remark || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="抓拍图片" width="100">
|
||||
<template #default="{ row }">
|
||||
<preview-image v-if="row.parseRecordImage" width="50px" height="50px" :src="row.parseRecordImage" />
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="DoorCollectionRecordList">
|
||||
import { h } from "vue";
|
||||
import { ElTag } from "element-plus";
|
||||
import * as DoorRecordApi from "/@/api/door/doorRecord";
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table";
|
||||
import { useDict } from "/@/hooks/dict";
|
||||
import { getDefaultAlarmDateList } from "/@/utils/other";
|
||||
|
||||
const props = defineProps({
|
||||
active: { type: Boolean, default: false },
|
||||
});
|
||||
const { dh_enter_or_exit, dh_door_open_type } = useDict(
|
||||
"dh_enter_or_exit", "dh_door_open_type",
|
||||
);
|
||||
const OpenResultTag = (componentProps: { value?: string }) => {
|
||||
if (String(componentProps.value) === "1") return h(ElTag, { type: "success" }, () => "成功");
|
||||
if (String(componentProps.value) === "0") return h(ElTag, { type: "danger" }, () => "失败");
|
||||
return h("span", "-");
|
||||
};
|
||||
|
||||
const queryRef = ref();
|
||||
const initialQuery = () => ({ swingTimeList: getDefaultAlarmDateList() });
|
||||
const state: BasicTableProps = reactive({ queryForm: initialQuery(), pageList: DoorRecordApi.fetchList });
|
||||
const { getDataList, sizeChangeHandle, currentChangeHandle, postDownBlobFile, tableStyle } = useTable(state);
|
||||
|
||||
const resetQuery = () => {
|
||||
queryRef.value?.resetFields();
|
||||
Object.assign(state.queryForm, initialQuery());
|
||||
getDataList();
|
||||
};
|
||||
const exportExcel = () => {
|
||||
postDownBlobFile("/admin/door/doorRecord/export", { ...state.queryForm }, "门禁通行记录.xlsx");
|
||||
};
|
||||
const personStatus = (row: any) => {
|
||||
if (String(row.imageType) === "2" && row.personName) return "已识别访客";
|
||||
if (row.personCode || row.personName) return "已识别人员";
|
||||
return "未识别人员";
|
||||
};
|
||||
const personStatusType = (row: any) => {
|
||||
const status = personStatus(row);
|
||||
if (status === "未识别人员") return "danger";
|
||||
if (status === "已识别访客") return "warning";
|
||||
return "success";
|
||||
};
|
||||
const personName = (row: any) => row.personName || personStatus(row);
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
watch(() => props.active, (active) => {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = undefined;
|
||||
if (active) {
|
||||
getDataList();
|
||||
timer = setInterval(() => getDataList(false), 10000);
|
||||
}
|
||||
}, { immediate: true });
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
defineExpose({ refresh: getDataList });
|
||||
</script>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-form ref="queryRef" :model="query" inline @keyup.enter="load">
|
||||
<el-form-item label="关键字" prop="searchKey">
|
||||
<el-input v-model="query.searchKey" placeholder="设备名称、IP、编码或组织" clearable style="width: 260px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="在线状态" prop="isOnline">
|
||||
<el-select v-model="query.isOnline" clearable style="width: 140px">
|
||||
<el-option label="在线" :value="1" />
|
||||
<el-option label="离线" :value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="Search" @click="load">查询</el-button>
|
||||
<el-button icon="Refresh" @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item class="refresh-status">
|
||||
<el-tag :type="lastError ? 'danger' : 'info'">
|
||||
{{ lastError || `每 10 秒刷新,最近更新 ${lastRefresh || "-"}` }}
|
||||
</el-tag>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-table :data="rows" v-loading="loading" border>
|
||||
<el-table-column type="index" label="#" width="60" align="center" />
|
||||
<el-table-column prop="deviceName" label="设备名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="deviceCode" label="ICC 设备编码" min-width="190" show-overflow-tooltip />
|
||||
<el-table-column prop="deviceSn" label="设备唯一标识码" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="deviceManufacturer" label="厂商" width="110">
|
||||
<template #default="{ row }">{{ row.deviceManufacturer || "大华" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deviceModel" label="型号" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="deviceIp" label="IP" min-width="130" />
|
||||
<el-table-column prop="devicePort" label="端口" width="90" />
|
||||
<el-table-column prop="orgName" label="所属组织" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="实时状态" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="isOnline(row.isOnline) ? 'success' : 'danger'">
|
||||
{{ isOnline(row.isOnline) ? "在线" : "离线" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="offlineReason" label="离线原因" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="isOnline(row.isOnline)">-</span>
|
||||
<dict-tag v-else :options="offline_reason" :value="row.offlineReason" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="DoorRemoteList">
|
||||
import dayjs from "dayjs";
|
||||
import * as DoorApi from "/@/api/collection/door";
|
||||
import { useDict } from "/@/hooks/dict";
|
||||
|
||||
const props = defineProps({ active: { type: Boolean, default: false } });
|
||||
const { offline_reason } = useDict("offline_reason");
|
||||
const queryRef = ref();
|
||||
const query = reactive({ searchKey: "", isOnline: undefined as number | undefined });
|
||||
const rows = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const lastRefresh = ref("");
|
||||
const lastError = ref("");
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
const isOnline = (value: unknown) => String(value) === "1";
|
||||
|
||||
const load = async (quiet = false) => {
|
||||
if (!quiet) loading.value = true;
|
||||
try {
|
||||
const { data } = await DoorApi.remoteDeviceList(query);
|
||||
rows.value = data || [];
|
||||
lastRefresh.value = dayjs().format("HH:mm:ss");
|
||||
lastError.value = "";
|
||||
} catch (error: any) {
|
||||
lastError.value = error?.msg || "ICC 状态读取失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
const reset = () => {
|
||||
queryRef.value?.resetFields();
|
||||
load();
|
||||
};
|
||||
const stopTimer = () => {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = undefined;
|
||||
};
|
||||
const startTimer = () => {
|
||||
stopTimer();
|
||||
if (props.active) timer = setInterval(() => load(true), 10000);
|
||||
};
|
||||
watch(() => props.active, (active) => {
|
||||
if (active) {
|
||||
load();
|
||||
startTimer();
|
||||
} else stopTimer();
|
||||
}, { immediate: true });
|
||||
onBeforeUnmount(stopTimer);
|
||||
defineExpose({ refresh: load });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.refresh-status { float: right; }
|
||||
</style>
|
||||
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div class="layout-padding">
|
||||
<div class="layout-padding-auto layout-padding-view display-page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h2>出入门禁数据展示</h2>
|
||||
<p>{{ dashboardText }}</p>
|
||||
</div>
|
||||
<el-button icon="Refresh" :loading="dashboardLoading" @click="refreshAll">刷新</el-button>
|
||||
</header>
|
||||
|
||||
<section class="summary-grid" v-loading="dashboardLoading">
|
||||
<div v-for="item in summaryItems" :key="item.key" class="summary-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong :class="item.tone">{{ dashboardData[item.key] ?? 0 }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-tabs v-model="activeTab" class="display-tabs">
|
||||
<el-tab-pane label="实时状态" name="status">
|
||||
<door-remote-list ref="remoteRef" :active="activeTab === 'status'" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="门禁通行记录" name="records" lazy>
|
||||
<door-record-list ref="recordRef" :active="activeTab === 'records'" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="collectionDoorDisplay">
|
||||
import dayjs from "dayjs";
|
||||
import * as DoorApi from "/@/api/collection/door";
|
||||
import type { DoorDashboard } from "/@/api/collection/door";
|
||||
import DoorRecordList from "./components/door-record-list.vue";
|
||||
import DoorRemoteList from "./components/door-remote-list.vue";
|
||||
|
||||
const emptyDashboard = (): DoorDashboard => ({
|
||||
deviceTotal: 0,
|
||||
onlineTotal: 0,
|
||||
offlineTotal: 0,
|
||||
todayEnterTotal: 0,
|
||||
todayExitTotal: 0,
|
||||
unidentifiedTotal: 0,
|
||||
iccConnected: false,
|
||||
message: "正在读取 ICC 状态",
|
||||
checkedAt: "",
|
||||
});
|
||||
const activeTab = ref("status");
|
||||
const remoteRef = ref();
|
||||
const recordRef = ref();
|
||||
const dashboardLoading = ref(false);
|
||||
const dashboardData = reactive<DoorDashboard>(emptyDashboard());
|
||||
const summaryItems = [
|
||||
{ key: "deviceTotal", label: "门禁设备总数", tone: "" },
|
||||
{ key: "onlineTotal", label: "在线数", tone: "success" },
|
||||
{ key: "offlineTotal", label: "离线数", tone: "danger" },
|
||||
{ key: "todayEnterTotal", label: "今日进入", tone: "primary" },
|
||||
{ key: "todayExitTotal", label: "今日离开", tone: "primary" },
|
||||
{ key: "unidentifiedTotal", label: "未识别人员", tone: "warning" },
|
||||
] as const;
|
||||
|
||||
const dashboardText = computed(() => {
|
||||
const checkedAt = dashboardData.checkedAt ? dayjs(dashboardData.checkedAt).format("YYYY-MM-DD HH:mm:ss") : "-";
|
||||
return `${dashboardData.iccConnected ? "ICC 连接正常" : "ICC 连接异常"} · ${dashboardData.message || ""} · ${checkedAt}`;
|
||||
});
|
||||
const loadDashboard = async () => {
|
||||
dashboardLoading.value = true;
|
||||
try {
|
||||
const { data } = await DoorApi.dashboard();
|
||||
Object.assign(dashboardData, emptyDashboard(), data || {});
|
||||
} finally {
|
||||
dashboardLoading.value = false;
|
||||
}
|
||||
};
|
||||
const refreshAll = async () => {
|
||||
await Promise.all([
|
||||
loadDashboard(),
|
||||
activeTab.value === "status" ? remoteRef.value?.refresh() : Promise.resolve(),
|
||||
activeTab.value === "records" ? recordRef.value?.refresh() : Promise.resolve(),
|
||||
]);
|
||||
};
|
||||
onMounted(loadDashboard);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.display-page {
|
||||
display: block;
|
||||
overflow-y: auto;
|
||||
padding: 18px 22px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
letter-spacing: 0;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
margin: 7px 0 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(120px, 1fr));
|
||||
margin: 18px 0;
|
||||
border-top: 1px solid var(--el-border-color-light);
|
||||
border-left: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
min-height: 86px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
padding: 12px 16px;
|
||||
border-right: 1px solid var(--el-border-color-light);
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.summary-item span { color: var(--el-text-color-secondary); }
|
||||
.summary-item strong { font-size: 25px; line-height: 1; color: var(--el-text-color-primary); }
|
||||
.summary-item strong.primary { color: var(--el-color-primary); }
|
||||
.summary-item strong.success { color: var(--el-color-success); }
|
||||
.summary-item strong.warning { color: var(--el-color-warning); }
|
||||
.summary-item strong.danger { color: var(--el-color-danger); }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.summary-grid { grid-template-columns: repeat(3, minmax(120px, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.display-page { padding: 14px; }
|
||||
.page-header { align-items: flex-start; }
|
||||
.summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,473 @@
|
||||
<template>
|
||||
<el-dialog v-model="state.visible" :title="state.title" class="platform-resource-dialog" append-to-body
|
||||
destroy-on-close draggable :close-on-click-modal="false" @closed="resetDialog">
|
||||
<div class="resource-dialog-body" v-loading="state.loading">
|
||||
<!-- 差异视图同时展示两端独有数,避免仅凭正负差值误判数据缺失方向。 -->
|
||||
<section v-if="isDifferenceView" class="difference-overview" aria-label="平台资源差异汇总">
|
||||
<div class="difference-summary difference-summary--remote">
|
||||
<span>仅 ICC 存在</span>
|
||||
<strong>{{ state.result.remoteOnlyCount }}</strong>
|
||||
<small>本地缺少 {{ state.result.remoteOnlyCount }} 条</small>
|
||||
</div>
|
||||
<div class="difference-summary difference-summary--local">
|
||||
<span>仅本地存在</span>
|
||||
<strong>{{ state.result.localOnlyCount }}</strong>
|
||||
<small>ICC 缺少 {{ state.result.localOnlyCount }} 条</small>
|
||||
</div>
|
||||
<div class="difference-summary difference-summary--matched">
|
||||
<span>两端一致</span>
|
||||
<strong>{{ state.result.matchedCount }}</strong>
|
||||
<small>按唯一编码匹配</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-alert v-if="isDifferenceView" class="difference-alert" :type="differenceAlertType"
|
||||
:closable="false" show-icon :title="differenceConclusion" />
|
||||
|
||||
<!-- 设备差异支持批量修复,也保留表格逐条处理入口。 -->
|
||||
<section v-if="isDeviceDifferenceView" class="difference-actions" aria-label="设备差异处理">
|
||||
<div class="difference-actions__description">
|
||||
<strong>快速处理设备差异</strong>
|
||||
<span>操作前会重新读取 ICC 并复核设备编码,已变化的数据会自动跳过。</span>
|
||||
</div>
|
||||
<div class="difference-actions__buttons">
|
||||
<el-button type="primary" icon="RefreshRight" :loading="state.actionKey === 'SYNC_ALL'"
|
||||
:disabled="actionBusy || state.result.remoteOnlyCount === 0"
|
||||
v-auth="'admin_deviceManage_edit'" @click="handleDifferenceAction('SYNC')">
|
||||
同步本地缺少设备({{ state.result.remoteOnlyCount }})
|
||||
</el-button>
|
||||
<el-button type="danger" plain icon="Delete" :loading="state.actionKey === 'DELETE_ALL'"
|
||||
:disabled="actionBusy || state.result.localOnlyCount === 0"
|
||||
v-auth="'admin_deviceManage_del'" @click="handleDifferenceAction('DELETE')">
|
||||
删除本地多余设备({{ state.result.localOnlyCount }})
|
||||
</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="!isDifferenceView" class="resource-overview" aria-label="平台资源明细汇总">
|
||||
<div>
|
||||
<span class="resource-overview__label">当前明细</span>
|
||||
<strong>{{ state.result.total }}</strong>
|
||||
<span>条</span>
|
||||
</div>
|
||||
<div class="resource-overview__meta">
|
||||
<span>ICC 共 {{ countText(state.result.remoteTotal) }} 条</span>
|
||||
<el-divider direction="vertical" />
|
||||
<span>本地已同步 {{ countText(state.result.localTotal) }} 条</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-alert v-if="state.error" class="resource-error" type="error" :closable="false" show-icon
|
||||
:title="state.error">
|
||||
<template #default>
|
||||
<el-button type="danger" link @click="loadDetail">重新加载</el-button>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<el-table v-else :data="state.result.records" border :height="isDeviceDifferenceView ? 360 : 430"
|
||||
empty-text="暂无明细数据"
|
||||
row-key="code">
|
||||
<el-table-column v-if="isDifferenceView" label="差异结论" width="190" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.differenceType === 'REMOTE_ONLY'" type="danger" effect="light">
|
||||
仅 ICC 存在 / 本地缺少
|
||||
</el-tag>
|
||||
<el-tag v-else type="warning" effect="light">
|
||||
仅本地存在 / ICC 缺少
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :prop="'code'" :label="isDevice ? '设备编码' : '通道编码'" min-width="190"
|
||||
show-overflow-tooltip />
|
||||
<el-table-column prop="name" :label="isDevice ? '设备名称' : '通道名称'" min-width="160"
|
||||
show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.name || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="!isDevice" prop="deviceCode" label="所属设备编码" min-width="180"
|
||||
show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.deviceCode || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="source" label="数据来源" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.source === 'ICC' ? 'primary' : 'info'" effect="light">
|
||||
{{ row.source === "ICC" ? "ICC" : "本地" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="type" :label="isDevice ? '设备类型' : '通道类型'" min-width="120"
|
||||
show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.type || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isDevice" prop="manufacturer" label="厂商类型" min-width="120"
|
||||
show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.manufacturer || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ownerCode" label="所属组织编码" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.ownerCode || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isDevice" prop="ip" label="设备 IP" min-width="130" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.ip || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="在线状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.online !== null && row.online !== undefined"
|
||||
:type="row.online ? 'success' : 'info'" effect="light">
|
||||
{{ row.online ? "在线" : "离线" }}
|
||||
</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isDeviceDifferenceView" label="操作" width="104" align="center"
|
||||
fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.differenceType === 'REMOTE_ONLY'" type="primary" link
|
||||
:loading="state.actionKey === `SYNC_${row.code}`" :disabled="actionBusy"
|
||||
v-auth="'admin_deviceManage_edit'" @click="handleDifferenceAction('SYNC', row.code)">
|
||||
同步
|
||||
</el-button>
|
||||
<el-button v-else type="danger" link :loading="state.actionKey === `DELETE_${row.code}`"
|
||||
:disabled="actionBusy" v-auth="'admin_deviceManage_del'"
|
||||
@click="handleDifferenceAction('DELETE', row.code)">
|
||||
删除本地
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination v-if="!state.error && detailTotal > 0" class="resource-pagination"
|
||||
v-model:current-page="state.query.current" v-model:page-size="state.query.size"
|
||||
:page-sizes="[10, 20, 50, 100]" :total="detailTotal"
|
||||
layout="total, sizes, prev, pager, next, jumper" @size-change="handleSizeChange"
|
||||
@current-change="loadDetail" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="state.visible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as VideoPlatformApi from "/@/api/collection/videoPlatform";
|
||||
import { useMessage, useMessageBox } from "/@/hooks/message";
|
||||
import type {
|
||||
PlatformDetailQuery,
|
||||
PlatformDetailResult,
|
||||
PlatformDetailViewType,
|
||||
PlatformResourceType,
|
||||
} from "/@/api/collection/videoPlatform";
|
||||
|
||||
const emptyResult = (): PlatformDetailResult => ({
|
||||
resourceType: "DEVICE",
|
||||
viewType: "REMOTE",
|
||||
current: 1,
|
||||
size: 20,
|
||||
total: 0,
|
||||
remoteTotal: null,
|
||||
localTotal: null,
|
||||
remoteOnlyCount: 0,
|
||||
localOnlyCount: 0,
|
||||
matchedCount: 0,
|
||||
records: [],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 设备差异处理完成后通知主页面刷新统计。 */
|
||||
(event: "changed"): void;
|
||||
}>();
|
||||
|
||||
const state = reactive({
|
||||
visible: false,
|
||||
loading: false,
|
||||
title: "平台资源明细",
|
||||
error: "",
|
||||
actionKey: "",
|
||||
query: {
|
||||
resourceType: "DEVICE",
|
||||
viewType: "REMOTE",
|
||||
current: 1,
|
||||
size: 20,
|
||||
} as PlatformDetailQuery,
|
||||
result: emptyResult(),
|
||||
});
|
||||
|
||||
const isDevice = computed(() => state.query.resourceType === "DEVICE");
|
||||
const isDifferenceView = computed(() => state.query.viewType === "DIFFERENCE");
|
||||
const isDeviceDifferenceView = computed(() => isDevice.value && isDifferenceView.value);
|
||||
const actionBusy = computed(() => Boolean(state.actionKey));
|
||||
|
||||
// 兼容旧服务未完整返回 total 的情况,差异页可由双方独有数量准确计算总数。
|
||||
const detailTotal = computed(() => {
|
||||
const responseTotal = Number(state.result.total);
|
||||
if (Number.isFinite(responseTotal) && responseTotal > 0) return responseTotal;
|
||||
if (isDifferenceView.value) {
|
||||
return Number(state.result.remoteOnlyCount || 0) + Number(state.result.localOnlyCount || 0);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// 同时存在两类差异时使用警告色,完全一致时使用成功状态。
|
||||
const differenceAlertType = computed(() => detailTotal.value === 0 ? "success" : "warning");
|
||||
|
||||
const differenceConclusion = computed(() => {
|
||||
const remoteOnly = state.result.remoteOnlyCount || 0;
|
||||
const localOnly = state.result.localOnlyCount || 0;
|
||||
if (!remoteOnly && !localOnly) return "ICC 与本地按唯一编码比对完全一致。";
|
||||
if (remoteOnly && localOnly) {
|
||||
return `本地缺少 ${remoteOnly} 条 ICC 数据,同时 ICC 缺少 ${localOnly} 条本地数据,请分别核对。`;
|
||||
}
|
||||
if (remoteOnly) return `ICC 比本地多出 ${remoteOnly} 条,本地缺少对应同步数据。`;
|
||||
return `本地比 ICC 多出 ${localOnly} 条,ICC 中已找不到对应数据。`;
|
||||
});
|
||||
|
||||
const countText = (value: number | null | undefined) => value == null ? "-" : value;
|
||||
|
||||
/**
|
||||
* 打开指定资源明细并从第一页加载
|
||||
*/
|
||||
const open = (resourceType: PlatformResourceType, viewType: PlatformDetailViewType, title: string) => {
|
||||
state.query.resourceType = resourceType;
|
||||
state.query.viewType = viewType;
|
||||
state.query.current = 1;
|
||||
state.title = title;
|
||||
state.result = emptyResult();
|
||||
state.visible = true;
|
||||
loadDetail();
|
||||
};
|
||||
|
||||
/**
|
||||
* 加载弹窗明细;失败时保留弹窗并提供重试入口
|
||||
*/
|
||||
const loadDetail = async () => {
|
||||
state.loading = true;
|
||||
state.error = "";
|
||||
try {
|
||||
const { data } = await VideoPlatformApi.getPlatformDetail({ ...state.query });
|
||||
state.result = data as PlatformDetailResult;
|
||||
} catch (error: any) {
|
||||
state.result = emptyResult();
|
||||
state.error = error?.msg || error?.message || "平台资源明细读取失败,请检查 ICC 连接后重试";
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 修改每页数量后回到第一页,避免页码超出新范围
|
||||
*/
|
||||
const handleSizeChange = () => {
|
||||
state.query.current = 1;
|
||||
loadDetail();
|
||||
};
|
||||
|
||||
/**
|
||||
* 同步 ICC 独有设备或删除本地独有设备,编码为空时处理当前全部同类差异
|
||||
*/
|
||||
const handleDifferenceAction = async (action: "SYNC" | "DELETE", code?: string) => {
|
||||
const isSync = action === "SYNC";
|
||||
const count = code
|
||||
? 1
|
||||
: isSync ? state.result.remoteOnlyCount : state.result.localOnlyCount;
|
||||
const actionText = isSync ? "同步" : "删除";
|
||||
const targetText = isSync ? "本地缺少设备" : "本地多余设备及其通道";
|
||||
try {
|
||||
await useMessageBox().confirm(`确认${actionText}${code ? `设备 ${code}` : `全部 ${count} 条${targetText}`}?`);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
state.actionKey = `${action}_${code || "ALL"}`;
|
||||
try {
|
||||
const actionApi = isSync
|
||||
? VideoPlatformApi.syncMissingDevices
|
||||
: VideoPlatformApi.deleteLocalExtraDevices;
|
||||
const { data } = await actionApi({ deviceCodes: code ? [code] : [] });
|
||||
useMessage().success(`${actionText}完成,共处理 ${Number(data) || 0} 条设备`);
|
||||
state.query.current = 1;
|
||||
await loadDetail();
|
||||
emit("changed");
|
||||
} catch (error: any) {
|
||||
useMessage().error(error?.msg || `${targetText}${actionText}失败`);
|
||||
} finally {
|
||||
state.actionKey = "";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭弹窗后清空错误与列表,防止下次打开闪现旧数据
|
||||
*/
|
||||
const resetDialog = () => {
|
||||
state.error = "";
|
||||
state.actionKey = "";
|
||||
state.result = emptyResult();
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.resource-dialog-body {
|
||||
min-height: 530px;
|
||||
}
|
||||
|
||||
.difference-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.difference-summary {
|
||||
min-height: 92px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 4px 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
}
|
||||
|
||||
.difference-summary strong {
|
||||
font-size: 26px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.difference-summary small {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.difference-summary--remote {
|
||||
border-left: 4px solid var(--el-color-danger);
|
||||
}
|
||||
|
||||
.difference-summary--remote strong {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.difference-summary--local {
|
||||
border-left: 4px solid var(--el-color-warning);
|
||||
}
|
||||
|
||||
.difference-summary--local strong {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.difference-summary--matched {
|
||||
border-left: 4px solid var(--el-color-success);
|
||||
}
|
||||
|
||||
.difference-summary--matched strong {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.difference-alert,
|
||||
.resource-error {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.difference-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
}
|
||||
|
||||
.difference-actions__description {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.difference-actions__description span {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.difference-actions__buttons {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.difference-actions__buttons :deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.resource-overview {
|
||||
min-height: 54px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
}
|
||||
|
||||
.resource-overview strong {
|
||||
margin: 0 4px 0 10px;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 24px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.resource-overview__label,
|
||||
.resource-overview__meta {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.resource-pagination {
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.difference-overview {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.resource-dialog-body {
|
||||
min-height: 460px;
|
||||
}
|
||||
|
||||
.resource-overview {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.difference-actions {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.difference-actions__buttons {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.resource-pagination {
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.platform-resource-dialog {
|
||||
width: min(1040px, calc(100vw - 32px)) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -33,38 +33,70 @@
|
||||
<span class="status-label">同步状态</span>
|
||||
<el-tag :type="syncTagType" effect="light">{{ syncText }}</el-tag>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<button class="status-item status-item--clickable" type="button" aria-label="查看 ICC 设备明细"
|
||||
@click="openResourceDetail('DEVICE', 'REMOTE', 'ICC 设备明细')">
|
||||
<span class="status-label">ICC 设备数</span>
|
||||
<strong>{{ countText(state.status.remoteDeviceCount) }}</strong>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-value-row">
|
||||
<strong>{{ countText(state.status.remoteDeviceCount) }}</strong>
|
||||
<el-icon><ArrowRight /></el-icon>
|
||||
</span>
|
||||
</button>
|
||||
<button class="status-item status-item--clickable" type="button" aria-label="查看本地同步设备明细"
|
||||
@click="openResourceDetail('DEVICE', 'LOCAL', '本地同步设备明细')">
|
||||
<span class="status-label">本地同步设备数</span>
|
||||
<strong>{{ countText(state.status.localDeviceCount) }}</strong>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-value-row">
|
||||
<strong>{{ countText(state.status.localDeviceCount) }}</strong>
|
||||
<el-icon><ArrowRight /></el-icon>
|
||||
</span>
|
||||
</button>
|
||||
<button class="status-item status-item--clickable status-item--difference" type="button"
|
||||
aria-label="查看设备差异明细"
|
||||
@click="openResourceDetail('DEVICE', 'DIFFERENCE', '设备数量差异明细')">
|
||||
<span class="status-label">设备数量差异</span>
|
||||
<strong :class="differenceClass(state.status.deviceDifference)">
|
||||
{{ differenceText(state.status.deviceDifference) }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-value-row">
|
||||
<strong :class="differenceClass(state.status.deviceDifference)">
|
||||
{{ differenceText(state.status.deviceDifference) }}
|
||||
</strong>
|
||||
<el-icon><ArrowRight /></el-icon>
|
||||
</span>
|
||||
<small class="difference-direction">{{ differenceDirection(state.status.deviceDifference) }}</small>
|
||||
</button>
|
||||
<button class="status-item status-item--clickable" type="button" aria-label="查看 ICC 通道明细"
|
||||
@click="openResourceDetail('CHANNEL', 'REMOTE', 'ICC 通道明细')">
|
||||
<span class="status-label">ICC 通道数</span>
|
||||
<strong>{{ countText(state.status.remoteChannelCount) }}</strong>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-value-row">
|
||||
<strong>{{ countText(state.status.remoteChannelCount) }}</strong>
|
||||
<el-icon><ArrowRight /></el-icon>
|
||||
</span>
|
||||
</button>
|
||||
<button class="status-item status-item--clickable" type="button" aria-label="查看本地同步通道明细"
|
||||
@click="openResourceDetail('CHANNEL', 'LOCAL', '本地同步通道明细')">
|
||||
<span class="status-label">本地同步通道数</span>
|
||||
<strong>{{ countText(state.status.localChannelCount) }}</strong>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-value-row">
|
||||
<strong>{{ countText(state.status.localChannelCount) }}</strong>
|
||||
<el-icon><ArrowRight /></el-icon>
|
||||
</span>
|
||||
</button>
|
||||
<button class="status-item status-item--clickable status-item--difference" type="button"
|
||||
aria-label="查看通道差异明细"
|
||||
@click="openResourceDetail('CHANNEL', 'DIFFERENCE', '通道数量差异明细')">
|
||||
<span class="status-label">通道数量差异</span>
|
||||
<strong :class="differenceClass(state.status.channelDifference)">
|
||||
{{ differenceText(state.status.channelDifference) }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-value-row">
|
||||
<strong :class="differenceClass(state.status.channelDifference)">
|
||||
{{ differenceText(state.status.channelDifference) }}
|
||||
</strong>
|
||||
<el-icon><ArrowRight /></el-icon>
|
||||
</span>
|
||||
<small class="difference-direction">{{ differenceDirection(state.status.channelDifference) }}</small>
|
||||
</button>
|
||||
<button class="status-item status-item--clickable" type="button" aria-label="查看在线设备明细"
|
||||
@click="openResourceDetail('DEVICE', 'ONLINE', '在线设备明细')">
|
||||
<span class="status-label">在线设备数</span>
|
||||
<strong>{{ countText(state.status.onlineDeviceCount) }}</strong>
|
||||
</div>
|
||||
<span class="status-value-row">
|
||||
<strong>{{ countText(state.status.onlineDeviceCount) }}</strong>
|
||||
<el-icon><ArrowRight /></el-icon>
|
||||
</span>
|
||||
</button>
|
||||
<div class="status-item status-result">
|
||||
<span class="status-label">最近同步结果</span>
|
||||
<span>{{ state.lastSyncResult }}</span>
|
||||
@@ -114,17 +146,26 @@
|
||||
</div>
|
||||
<dh-icc-setting ref="settingRef" />
|
||||
</section>
|
||||
|
||||
<platform-resource-dialog ref="resourceDialogRef" @changed="handleResourceChanged" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="collectionVideoPlatform">
|
||||
import dayjs from "dayjs";
|
||||
import { ArrowRight } from "@element-plus/icons-vue";
|
||||
import { useMessage } from "/@/hooks/message";
|
||||
import * as ComDhLogApi from "/@/api/com/comDhLog";
|
||||
import * as VideoPlatformApi from "/@/api/collection/videoPlatform";
|
||||
import type { PlatformStatus, PlatformSyncResult } from "/@/api/collection/videoPlatform";
|
||||
import type {
|
||||
PlatformDetailViewType,
|
||||
PlatformResourceType,
|
||||
PlatformStatus,
|
||||
PlatformSyncResult,
|
||||
} from "/@/api/collection/videoPlatform";
|
||||
import DhIccSetting from "/@/views/alarm/setting/dhIccSetting/dh-icc-setting.vue";
|
||||
import PlatformResourceDialog from "/@/views/collection/video/components/platform-resource-dialog.vue";
|
||||
|
||||
type ConnectionStatus = "checking" | "connected" | "disconnected";
|
||||
type SyncStatus = "checking" | "synced" | "different" | "failed";
|
||||
@@ -145,6 +186,7 @@ const emptyStatus = (): PlatformStatus => ({
|
||||
});
|
||||
|
||||
const settingRef = ref();
|
||||
const resourceDialogRef = ref<InstanceType<typeof PlatformResourceDialog>>();
|
||||
|
||||
const state = reactive({
|
||||
loading: true,
|
||||
@@ -196,6 +238,30 @@ const differenceText = (value: number | null | undefined) => {
|
||||
|
||||
const differenceClass = (value: number | null | undefined) => value ? "is-different" : "";
|
||||
|
||||
/**
|
||||
* 直接说明净差值对应的多缺方向,避免运维人员自行解释正负号
|
||||
*/
|
||||
const differenceDirection = (value: number | null | undefined) => {
|
||||
if (value == null) return "等待 ICC 数据";
|
||||
if (value > 0) return `ICC 多 ${value},本地少 ${value}`;
|
||||
if (value < 0) return `本地多 ${Math.abs(value)},ICC 少 ${Math.abs(value)}`;
|
||||
return "两端数量一致";
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开设备或通道的 ICC、本地、在线及差异明细
|
||||
*/
|
||||
const openResourceDetail = (resourceType: PlatformResourceType, viewType: PlatformDetailViewType, title: string) => {
|
||||
resourceDialogRef.value?.open(resourceType, viewType, title);
|
||||
};
|
||||
|
||||
/**
|
||||
* 差异弹窗处理设备后同步刷新顶部统计和 ICC 请求记录
|
||||
*/
|
||||
const handleResourceChanged = async () => {
|
||||
await Promise.all([loadPlatformStatus(), loadLogs()]);
|
||||
};
|
||||
|
||||
const formatTime = (value?: string) => value ? dayjs(value).format("YYYY-MM-DD HH:mm:ss") : "-";
|
||||
|
||||
const getStatusSummary = (status: PlatformStatus) => {
|
||||
@@ -393,6 +459,53 @@ h3 {
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.status-item--clickable {
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.status-item--clickable:hover {
|
||||
background: var(--el-color-primary-light-9);
|
||||
box-shadow: inset 0 0 0 1px var(--el-color-primary-light-5);
|
||||
}
|
||||
|
||||
.status-item--clickable:focus-visible {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
outline: 2px solid var(--el-color-primary);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
.status-item--difference:hover {
|
||||
background: var(--el-color-warning-light-9);
|
||||
box-shadow: inset 0 0 0 1px var(--el-color-warning-light-5);
|
||||
}
|
||||
|
||||
.status-value-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-value-row .el-icon {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 18px;
|
||||
transition: color 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.status-item--clickable:hover .status-value-row .el-icon {
|
||||
color: var(--el-color-primary);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.status-item strong {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
@@ -403,6 +516,12 @@ h3 {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.difference-direction {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<template>
|
||||
<div class="layout-padding">
|
||||
<div :class="{ 'layout-padding': !props.embedded, 'embedded-camera-list': props.embedded }">
|
||||
<div class="layout-padding-auto layout-padding-view">
|
||||
<draggable-org-Tree ref="draggableDepTreeRef" placeholder="请输入部门名称" :show-expand="true"
|
||||
@getSelectNode="handleGetSelectNode" switch-inactive-text="包含" switch-active-text="不包含"
|
||||
:defaultTreeWidth="15" :nodeClickable="false">
|
||||
:defaultTreeWidth="15" :nodeClickable="false" :resizable="!props.embedded"
|
||||
:filter-control="props.embedded ? 'segmented' : 'switch'">
|
||||
<template #default>
|
||||
<el-row shadow="hover" v-show="showSearch" class="ml10">
|
||||
<el-form :model="state.queryForm" ref="queryRef" :inline="true" @keyup.enter="getDataList">
|
||||
@@ -146,11 +147,13 @@ const props = withDefaults(defineProps<{
|
||||
enableCrud?: boolean;
|
||||
enableExport?: boolean;
|
||||
fixedQuery?: Record<string, any>;
|
||||
embedded?: boolean;
|
||||
}>(), {
|
||||
actions: () => ['detail', 'preview', 'replay', 'edit', 'delete'],
|
||||
enableCrud: true,
|
||||
enableExport: false,
|
||||
fixedQuery: () => ({}),
|
||||
embedded: false,
|
||||
});
|
||||
|
||||
const hasAction = (action: CameraAction) => props.actions.includes(action);
|
||||
@@ -256,3 +259,18 @@ const handleDelete = async (ids: string[]) => {
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.embedded-camera-list {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.embedded-camera-list :deep(.layout-padding-auto) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
@@ -19,7 +19,9 @@
|
||||
</el-col>
|
||||
<el-col :span="12" class="mb20">
|
||||
<el-form-item label="设备大类" prop="deviceCategory">
|
||||
<dict-select v-model="form.deviceCategory" :options="device_category" clearable placeholder="请选择设备大类" />
|
||||
<el-input v-if="props.fixedCategory" :model-value="props.fixedCategoryLabel || '门禁设备'" disabled />
|
||||
<dict-select v-else v-model="form.deviceCategory" :options="device_category" clearable
|
||||
placeholder="请选择设备大类" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12" class="mb20">
|
||||
@@ -75,6 +77,10 @@ import { getObj, addObj, putObj } from '/@/api/device/deviceManage'
|
||||
import { deptTree } from '/@/api/admin/dept';
|
||||
import { rule } from '/@/utils/validate';
|
||||
const emit = defineEmits(['refresh']);
|
||||
const props = defineProps({
|
||||
fixedCategory: { type: String, default: '' },
|
||||
fixedCategoryLabel: { type: String, default: '' },
|
||||
});
|
||||
const { device_category, dh_device_manufacturer } = useDict("device_category", "dh_device_manufacturer")
|
||||
|
||||
// 定义变量内容
|
||||
@@ -88,7 +94,7 @@ const initialForm = () => ({
|
||||
deviceName: '',
|
||||
deviceCode: '',
|
||||
deviceSn: '',
|
||||
deviceCategory: '',
|
||||
deviceCategory: props.fixedCategory,
|
||||
deviceType: '',
|
||||
deviceManufacturer: '',
|
||||
deviceModel: '',
|
||||
@@ -190,6 +196,7 @@ const onSubmit = async () => {
|
||||
loading.value = true;
|
||||
const submitForm = {
|
||||
...form,
|
||||
deviceCategory: props.fixedCategory || form.deviceCategory,
|
||||
devicePort: form.devicePort?.toString() ?? '',
|
||||
sortOrder: form.sortOrder?.toString() ?? '',
|
||||
}
|
||||
@@ -212,6 +219,7 @@ const getdeviceManageData = (id: string) => {
|
||||
getObj(id).then((res: any) => {
|
||||
Object.assign(form, {
|
||||
...res.data,
|
||||
deviceCategory: props.fixedCategory || res.data?.deviceCategory || '',
|
||||
devicePort: res.data?.devicePort ? Number(res.data.devicePort) : undefined,
|
||||
sortOrder: res.data?.sortOrder !== null && res.data?.sortOrder !== undefined && res.data?.sortOrder !== ''
|
||||
? Number(res.data.sortOrder)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<el-row :gutter="10" v-if="showRoutes.length > 0">
|
||||
<el-col class="shortcutCard" :span="6" :key="shortcut.id" v-for="shortcut in showRoutes">
|
||||
<SvgIcon name="ele-Close" :size="12" class="shortcutCardClose" @click="handleCloseFavorite(shortcut)" />
|
||||
<shortcutCard :icon="shortcut.meta?.icon" :label="shortcut.name" @click="handleRoute(shortcut.path)" />
|
||||
<shortcutCard :icon="shortcut.meta?.icon" :label="shortcut.meta?.title || shortcut.name" @click="handleRoute(shortcut.path)" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-empty :image-size="48" :description="props.emptyDescription" v-else />
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" :title="form.id ? '编辑场景' : '新增场景'" width="760px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||
<el-form-item label="场景名称" prop="sceneName"><el-input v-model="form.sceneName" maxlength="128" show-word-limit /></el-form-item>
|
||||
<el-form-item label="源事件" prop="sourceEventType">
|
||||
<el-select v-model="form.sourceEventType" @change="resetConditions">
|
||||
<el-option v-for="item in eventOptions" :key="item.value" v-bind="item" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="源设备/通道" prop="sourceNodes">
|
||||
<select-tree-channel
|
||||
v-model="sourceNodeValues"
|
||||
allow-device
|
||||
placeholder="请选择源设备或通道"
|
||||
@change="handleSourceNodesChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="触发条件"><div class="condition-grid"><template v-for="item in conditionFields" :key="item.key">
|
||||
<el-form-item :label="item.label"><el-select v-model="form.conditions[item.key]" multiple clearable collapse-tags><el-option v-for="option in item.options" :key="option.value" v-bind="option" /></el-select></el-form-item>
|
||||
</template></div></el-form-item>
|
||||
<el-form-item label="目标设备/通道" prop="targetNodes">
|
||||
<select-tree-channel
|
||||
v-model="targetNodeValues"
|
||||
allow-device
|
||||
placeholder="请选择目标设备或通道"
|
||||
@change="handleTargetNodesChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="报警模式" prop="alarmMode"><el-radio-group v-model="form.alarmMode"><el-radio label="IMMEDIATE">立即报警</el-radio><el-radio label="FILTERED">过滤报警</el-radio></el-radio-group></el-form-item>
|
||||
<el-form-item v-if="form.alarmMode === 'FILTERED'" label="过滤时间" prop="filterMinutes"><el-input-number v-model="form.filterMinutes" :min="1" :max="1440" /><span class="unit">分钟</span></el-form-item>
|
||||
<el-form-item label="报警等级" prop="alarmGrade"><el-radio-group v-model="form.alarmGrade"><el-radio label="1">严重</el-radio><el-radio label="2">一般</el-radio><el-radio label="3">轻微</el-radio></el-radio-group></el-form-item>
|
||||
<el-form-item label="启用状态"><el-switch v-model="form.status" active-value="1" inactive-value="0" /></el-form-item>
|
||||
<el-form-item label="说明"><el-input v-model="form.description" type="textarea" maxlength="512" show-word-limit /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="visible = false">取消</el-button><el-button type="primary" :loading="saving" @click="submit">保存</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="scene-config-form">
|
||||
import * as api from "/@/api/wl/sceneLinkage"
|
||||
import { useDict } from "/@/hooks/dict"
|
||||
import { useMessage } from "/@/hooks/message"
|
||||
|
||||
/** 场景接口保存的设备或通道节点结构。 */
|
||||
interface SceneNode {
|
||||
nodeType: "DEVICE" | "CHANNEL"
|
||||
deviceCode: string
|
||||
deviceName: string
|
||||
channelCode?: string
|
||||
channelName?: string
|
||||
}
|
||||
|
||||
const emit = defineEmits(["refresh"])
|
||||
const visible = ref(false)
|
||||
const saving = ref(false)
|
||||
const formRef = ref()
|
||||
const sourceNodeValues = ref<string[]>([])
|
||||
const targetNodeValues = ref<string[]>([])
|
||||
const { alarm_type } = useDict("alarm_type")
|
||||
const eventOptions = [
|
||||
{ value: "ICC_ALARM", label: "ICC报警" }, { value: "DOOR_ACCESS", label: "门禁识别" },
|
||||
{ value: "CAR_CAPTURE", label: "车辆识别" }, { value: "CAR_ACCESS", label: "车辆进出" },
|
||||
]
|
||||
const conditionDefinitions = computed<Record<string, any[]>>(() => ({
|
||||
ICC_ALARM: [{ key: "alarmType", label: "报警类型", options: alarm_type.value }, { key: "alarmGrade", label: "报警等级", options: [{ value: "1", label: "严重" }, { value: "2", label: "一般" }, { value: "3", label: "轻微" }] }, { key: "alarmStat", label: "报警状态", options: [{ value: "1", label: "报警产生" }, { value: "2", label: "报警消失" }] }],
|
||||
DOOR_ACCESS: [{ key: "enterOrExit", label: "进出方向", options: [{ value: "1", label: "进门" }, { value: "2", label: "出门" }, { value: "3", label: "进出门" }] }, { key: "openResult", label: "开门结果", options: [{ value: "1", label: "成功" }, { value: "0", label: "失败" }] }, { key: "recognized", label: "人员识别", options: [{ value: "1", label: "已识别" }, { value: "0", label: "未识别" }] }],
|
||||
CAR_CAPTURE: [{ key: "carDirect", label: "进出方向", options: [{ value: "8", label: "进入" }, { value: "9", label: "外出" }] }, { key: "recognized", label: "车牌识别", options: [{ value: "1", label: "已识别" }, { value: "0", label: "未识别" }] }, { key: "strobeState", label: "自动开闸", options: [{ value: "1", label: "已开闸" }, { value: "0", label: "未开闸" }] }],
|
||||
CAR_ACCESS: [{ key: "phase", label: "进出阶段", options: [{ value: "ENTER", label: "入场" }, { value: "EXIT", label: "出场" }] }, { key: "carStatus", label: "车辆状态", options: [{ value: "0", label: "进入" }, { value: "1", label: "离开" }, { value: "2", label: "只有出车" }, { value: "3", label: "强制出场" }] }, { key: "recognized", label: "车牌识别", options: [{ value: "1", label: "已识别" }, { value: "0", label: "未识别" }] }],
|
||||
}))
|
||||
const form = reactive<any>({ id: undefined, sceneName: "", sourceEventType: "ICC_ALARM", conditions: { alarmStat: ["1"] }, sourceNodes: [], targetNodes: [], alarmMode: "IMMEDIATE", filterMinutes: 5, alarmGrade: "2", status: "1", description: "" })
|
||||
const conditionFields = computed(() => conditionDefinitions.value[form.sourceEventType] || [])
|
||||
const rules = { sceneName: [{ required: true, message: "请输入场景名称", trigger: "blur" }], sourceEventType: [{ required: true, message: "请选择源事件", trigger: "change" }], sourceNodes: [{ required: true, message: "请选择源设备或通道", trigger: "change" }], targetNodes: [{ required: true, message: "请选择目标设备或通道", trigger: "change" }], alarmMode: [{ required: true, message: "请选择报警模式", trigger: "change" }], alarmGrade: [{ required: true, message: "请选择报警等级", trigger: "change" }] }
|
||||
const resetConditions = () => { form.conditions = form.sourceEventType === "ICC_ALARM" ? { alarmStat: ["1"] } : {} }
|
||||
|
||||
/** 将公共组件返回的节点裁剪为场景接口需要的字段。 */
|
||||
const normalizeSceneNodes = (nodes: SceneNode[] = []): SceneNode[] => nodes.map((node) => ({
|
||||
nodeType: node.nodeType,
|
||||
deviceCode: node.deviceCode,
|
||||
deviceName: node.deviceName,
|
||||
...(node.nodeType === "CHANNEL" ? {
|
||||
channelCode: node.channelCode,
|
||||
channelName: node.channelName,
|
||||
} : {}),
|
||||
}))
|
||||
|
||||
/** 将编辑详情转换为公共组件使用的树节点值。 */
|
||||
const getSceneNodeValues = (nodes: SceneNode[] = []) => nodes
|
||||
.map((node) => node.nodeType === "CHANNEL" ? node.channelCode : node.deviceCode)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
|
||||
const handleSourceNodesChange = (_values: string[], nodes: SceneNode[]) => {
|
||||
form.sourceNodes = normalizeSceneNodes(nodes)
|
||||
}
|
||||
const handleTargetNodesChange = (_values: string[], nodes: SceneNode[]) => {
|
||||
form.targetNodes = normalizeSceneNodes(nodes)
|
||||
}
|
||||
|
||||
const open = async (id?: string | number) => {
|
||||
Object.assign(form, { id: undefined, sceneName: "", sourceEventType: "ICC_ALARM", conditions: { alarmStat: ["1"] }, sourceNodes: [], targetNodes: [], alarmMode: "IMMEDIATE", filterMinutes: 5, alarmGrade: "2", status: "1", description: "" })
|
||||
if (id) Object.assign(form, (await api.getConfig(id)).data)
|
||||
// 编辑场景时,将接口节点对象还原成公共组件的选中值。
|
||||
sourceNodeValues.value = getSceneNodeValues(form.sourceNodes)
|
||||
targetNodeValues.value = getSceneNodeValues(form.targetNodes)
|
||||
visible.value = true
|
||||
}
|
||||
const submit = async () => {
|
||||
await formRef.value.validate()
|
||||
saving.value = true
|
||||
try { await (form.id ? api.updateConfig(form) : api.addConfig(form)); useMessage().success("保存成功"); visible.value = false; emit("refresh") } finally { saving.value = false }
|
||||
}
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.condition-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; width: 100%; }
|
||||
.condition-grid :deep(.el-form-item) { margin-bottom: 12px; }
|
||||
.condition-grid :deep(.el-select) { width: 100%; }
|
||||
.unit { margin-left: 8px; color: var(--el-text-color-secondary); }
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div class="layout-padding">
|
||||
<div class="layout-padding-auto layout-padding-view scene-page">
|
||||
<el-form ref="queryRef" :model="state.queryForm" inline @keyup.enter="getDataList">
|
||||
<el-form-item label="场景名称" prop="sceneName"><el-input v-model="state.queryForm.sceneName" placeholder="名称模糊查询"
|
||||
clearable /></el-form-item>
|
||||
<el-form-item label="源事件" prop="sourceEventType"><el-select v-model="state.queryForm.sourceEventType"
|
||||
clearable><el-option v-for="item in eventOptions" :key="item.value"
|
||||
v-bind="item" /></el-select></el-form-item>
|
||||
<el-form-item label="报警模式" prop="alarmMode"><el-select v-model="state.queryForm.alarmMode" clearable><el-option
|
||||
label="立即报警" value="IMMEDIATE" /><el-option label="过滤报警" value="FILTERED" /></el-select></el-form-item>
|
||||
<el-form-item label="状态" prop="status"><el-select v-model="state.queryForm.status" clearable><el-option
|
||||
label="启用" value="1" /><el-option label="停用" value="0" /></el-select></el-form-item>
|
||||
<el-form-item><el-button type="primary" icon="Search" @click="getDataList">查询</el-button><el-button
|
||||
icon="Refresh" @click="reset">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
<div class="table-actions"><el-button type="primary" icon="Plus" v-auth="'wl_sceneConfig_add'"
|
||||
@click="formRef.open()">新增场景</el-button></div>
|
||||
<el-table :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle">
|
||||
<el-table-column type="index" label="#" width="60" />
|
||||
<el-table-column prop="sceneName" label="场景名称" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="sourceEventType" label="源事件" width="120"><template #default="{ row }">{{
|
||||
eventLabel(row.sourceEventType) }}</template></el-table-column>
|
||||
<el-table-column prop="sourceNames" label="源设备/通道" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="targetNames" label="目标设备/通道" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="alarmMode" label="报警模式" width="110"><template #default="{ row }"><el-tag
|
||||
:type="row.alarmMode === 'FILTERED' ? 'warning' : 'danger'">{{ row.alarmMode === 'FILTERED' ? '过滤报警' :
|
||||
'立即报警' }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="filterMinutes" label="过滤时间" width="100"><template #default="{ row }">{{ row.alarmMode ===
|
||||
'FILTERED' ? `${row.filterMinutes}分钟` : '-' }}</template></el-table-column>
|
||||
<el-table-column prop="alarmGrade" label="报警等级" width="95"><template #default="{ row }"><el-tag
|
||||
:type="gradeType(row.alarmGrade)">{{ gradeLabel(row.alarmGrade) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="effectiveTime" label="生效时间" width="180" />
|
||||
<el-table-column label="状态" width="85"><template #default="{ row }"><el-switch :model-value="row.status"
|
||||
active-value="1" inactive-value="0" v-auth="'wl_sceneConfig_status'"
|
||||
@change="value => changeStatus(row, value)" /></template></el-table-column>
|
||||
<el-table-column label="操作" fixed="right" width="190"><template #default="{ row }"><el-button text
|
||||
type="primary" icon="View" @click="showDetail(row.id)">详情</el-button><el-button text type="primary"
|
||||
icon="EditPen" v-auth="'wl_sceneConfig_edit'" @click="formRef.open(row.id)">编辑</el-button><el-button text
|
||||
type="danger" icon="Delete" v-auth="'wl_sceneConfig_del'"
|
||||
@click="remove(row)">删除</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
|
||||
</div>
|
||||
<scene-config-form ref="formRef" @refresh="getDataList(false)" />
|
||||
<el-drawer v-model="detailVisible" title="场景详情" size="560px"><el-descriptions v-if="detail" :column="1"
|
||||
border><el-descriptions-item label="场景名称">{{ detail.sceneName }}</el-descriptions-item><el-descriptions-item
|
||||
label="源事件">{{ eventLabel(detail.sourceEventType) }}</el-descriptions-item><el-descriptions-item
|
||||
label="源设备/通道">{{
|
||||
detail.sourceNames }}</el-descriptions-item><el-descriptions-item label="触发条件">
|
||||
<pre>{{ JSON.stringify(detail.conditions || {}, null, 2) }}</pre>
|
||||
</el-descriptions-item><el-descriptions-item label="目标设备/通道">{{ detail.targetNames
|
||||
}}</el-descriptions-item><el-descriptions-item label="报警模式">{{ detail.alarmMode === 'FILTERED' ?
|
||||
`过滤报警(${detail.filterMinutes}分钟)` : '立即报警' }}</el-descriptions-item><el-descriptions-item label="说明">{{
|
||||
detail.description || '-' }}</el-descriptions-item></el-descriptions></el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="wlSceneConfig">
|
||||
import { ElMessageBox } from "element-plus"
|
||||
import * as api from "/@/api/wl/sceneLinkage"
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table"
|
||||
import { useMessage } from "/@/hooks/message"
|
||||
import SceneConfigForm from "./components/scene-config-form.vue"
|
||||
|
||||
const eventOptions = [{ value: "ICC_ALARM", label: "ICC报警" }, { value: "DOOR_ACCESS", label: "门禁识别" }, { value: "CAR_CAPTURE", label: "车辆识别" }, { value: "CAR_ACCESS", label: "车辆进出" }]
|
||||
const queryRef = ref(); const formRef = ref(); const detailVisible = ref(false); const detail = ref<any>()
|
||||
const state: BasicTableProps = reactive({ queryForm: {}, pageList: api.fetchConfigList })
|
||||
const { getDataList, sizeChangeHandle, currentChangeHandle, tableStyle } = useTable(state)
|
||||
const reset = () => { queryRef.value?.resetFields(); getDataList() }
|
||||
const eventLabel = (value: string) => eventOptions.find(item => item.value === value)?.label || value
|
||||
const gradeLabel = (value: string) => ({ "1": "严重", "2": "一般", "3": "轻微" }[value] || value)
|
||||
const gradeType = (value: string) => value === "1" ? "danger" : value === "2" ? "warning" : "info"
|
||||
const showDetail = async (id: string) => { detail.value = (await api.getConfig(id)).data; detailVisible.value = true }
|
||||
const changeStatus = async (row: any, status: any) => { await api.updateConfigStatus(row.id, String(status)); useMessage().success("状态已更新"); getDataList(false) }
|
||||
const remove = async (row: any) => { try { await ElMessageBox.confirm(`确认删除场景“${row.sceneName}”?历史触发记录会继续保留`, "删除场景", { type: "warning" }); await api.deleteConfigs([row.id]); useMessage().success("删除成功"); getDataList(false) } catch { } }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scene-page {
|
||||
display: block;
|
||||
overflow: auto;
|
||||
padding: 18px 22px;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="layout-padding">
|
||||
<div class="layout-padding-auto layout-padding-view record-page">
|
||||
<el-form ref="queryRef" :model="state.queryForm" inline @keyup.enter="getDataList">
|
||||
<el-form-item label="场景名称" prop="sceneName"><el-input v-model="state.queryForm.sceneName" clearable
|
||||
placeholder="名称模糊查询" /></el-form-item>
|
||||
<el-form-item label="报警设备" prop="sourceDeviceName"><el-input v-model="state.queryForm.sourceDeviceName"
|
||||
clearable placeholder="设备名称模糊查询" /></el-form-item>
|
||||
<el-form-item label="是否过滤" prop="filteredFlag"><el-select v-model="state.queryForm.filteredFlag"
|
||||
clearable><el-option label="是" value="1" /><el-option label="否" value="0" /></el-select></el-form-item>
|
||||
<el-form-item label="触发结果" prop="triggerResult"><el-select v-model="state.queryForm.triggerResult"
|
||||
clearable><el-option label="已报警" value="ALARMED" /><el-option label="已过滤" value="FILTERED" /><el-option
|
||||
label="执行失败" value="FAILED" /></el-select></el-form-item>
|
||||
<el-form-item label="报警时间" prop="eventTimeList"><el-date-picker v-model="state.queryForm.eventTimeList"
|
||||
type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss" start-placeholder="开始时间"
|
||||
end-placeholder="结束时间" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" icon="Search" @click="getDataList">查询</el-button><el-button
|
||||
icon="Refresh" @click="reset">重置</el-button><el-button icon="Download" v-auth="'wl_sceneTrigger_export'"
|
||||
@click="exportList">导出</el-button></el-form-item>
|
||||
</el-form>
|
||||
<el-table :data="state.dataList" v-loading="state.loading" border :cell-style="tableStyle.cellStyle"
|
||||
:header-cell-style="tableStyle.headerCellStyle">
|
||||
<el-table-column type="index" label="#" width="60" /><el-table-column prop="sceneName" label="场景名称"
|
||||
min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="sourceEventType" label="源事件" width="110"><template #default="{ row }">{{
|
||||
eventLabel(row.sourceEventType) }}</template></el-table-column>
|
||||
<el-table-column prop="sourceDeviceName" label="报警设备" min-width="150" show-overflow-tooltip /><el-table-column
|
||||
prop="sourceChannelName" label="报警通道" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="targetNames" label="目标设备" min-width="180" show-overflow-tooltip /><el-table-column
|
||||
prop="alarmMode" label="报警模式" width="100"><template #default="{ row }">{{ row.alarmMode === 'FILTERED' ?
|
||||
'过滤报警' : '立即报警' }}</template></el-table-column>
|
||||
<el-table-column prop="eventTime" label="事件时间" width="180" /><el-table-column prop="triggerTime" label="触发时间"
|
||||
width="180" />
|
||||
<el-table-column prop="triggerResult" label="触发结果" width="100"><template #default="{ row }"><el-tag
|
||||
:type="resultType(row.triggerResult)">{{ resultLabel(row.triggerResult)
|
||||
}}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="alarmCode" label="报警编号" min-width="220" show-overflow-tooltip><template
|
||||
#default="{ row }">{{ row.alarmCode || '-' }}</template></el-table-column>
|
||||
<el-table-column label="操作" fixed="right" width="90"><template #default="{ row }"><el-button text type="primary"
|
||||
icon="View" @click="showDetail(row.id)">详情</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
|
||||
<el-drawer v-model="detailVisible" title="触发详情" size="600px"><el-descriptions v-if="detail" :column="1"
|
||||
border><el-descriptions-item label="场景">{{ detail.sceneName }}</el-descriptions-item><el-descriptions-item
|
||||
label="事件">{{ eventLabel(detail.sourceEventType) }}</el-descriptions-item><el-descriptions-item
|
||||
label="源设备">{{
|
||||
detail.sourceDeviceName || detail.sourceDeviceCode }}</el-descriptions-item><el-descriptions-item
|
||||
label="源通道">{{
|
||||
detail.sourceChannelName || detail.sourceChannelCode || '-' }}</el-descriptions-item><el-descriptions-item
|
||||
label="目标设备">{{ detail.targetNames }}</el-descriptions-item><el-descriptions-item label="条件快照">
|
||||
<pre>{{ pretty(detail.conditionSnapshot) }}</pre>
|
||||
</el-descriptions-item><el-descriptions-item label="事件摘要">
|
||||
<pre>{{ pretty(detail.eventSummary) }}</pre>
|
||||
</el-descriptions-item><el-descriptions-item label="失败原因">{{ detail.errorMessage || '-'
|
||||
}}</el-descriptions-item></el-descriptions></el-drawer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="wlSceneTriggerRecord">
|
||||
import dayjs from "dayjs"
|
||||
import * as api from "/@/api/wl/sceneLinkage"
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table"
|
||||
|
||||
const queryRef = ref(); const detailVisible = ref(false); const detail = ref<any>()
|
||||
const state: BasicTableProps = reactive({ queryForm: {}, pageList: api.fetchTriggerList })
|
||||
const { getDataList, sizeChangeHandle, currentChangeHandle, downBlobFile, tableStyle } = useTable(state)
|
||||
const reset = () => { queryRef.value?.resetFields(); getDataList() }
|
||||
const eventLabel = (value: string) => ({ ICC_ALARM: "ICC报警", DOOR_ACCESS: "门禁识别", CAR_CAPTURE: "车辆识别", CAR_ACCESS: "车辆进出" }[value] || value)
|
||||
const resultLabel = (value: string) => ({ ALARMED: "已报警", FILTERED: "已过滤", FAILED: "执行失败" }[value] || value)
|
||||
const resultType = (value: string) => value === "ALARMED" ? "danger" : value === "FILTERED" ? "warning" : "info"
|
||||
const showDetail = async (id: string) => { detail.value = (await api.getTrigger(id)).data; detailVisible.value = true }
|
||||
const pretty = (value: string) => { try { return JSON.stringify(JSON.parse(value || "{}"), null, 2) } catch { return value || "-" } }
|
||||
const exportList = () => { const query: any = { ...state.queryForm }; if (query.eventTimeList?.length === 2) [query.startTime, query.endTime] = query.eventTimeList; delete query.eventTimeList; downBlobFile("/admin/sceneLinkage/trigger/export", query, `场景联动触发记录_${dayjs().format("YYYY-MM-DD_HH-mm-ss")}.xlsx`) }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.record-page {
|
||||
display: block;
|
||||
overflow: auto;
|
||||
padding: 18px 22px;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div class="layout-padding">
|
||||
<div class="layout-padding-auto layout-padding-view intelligence-page">
|
||||
<header class="page-header">
|
||||
<h2>智能视频管理</h2>
|
||||
</header>
|
||||
|
||||
<el-tabs v-model="activeTab" class="intelligence-tabs">
|
||||
<el-tab-pane label="摄像头管理" name="camera">
|
||||
<div class="camera-tab-content">
|
||||
<camera-list
|
||||
embedded
|
||||
:fixed-query="{ isSync: 1 }"
|
||||
:enable-crud="false"
|
||||
:actions="['detail', 'preview', 'replay']"
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="异常图像报警" name="alarm" lazy>
|
||||
<div class="alarm-tab-content">
|
||||
<alarm-record-list embedded read-only />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="wlVideoIntelligence">
|
||||
import CameraList from "/@/views/device/components/camera-list.vue";
|
||||
import AlarmRecordList from "/@/views/alarm/alarm-record/index.vue";
|
||||
|
||||
const activeTab = ref("camera");
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.intelligence-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 18px 22px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
letter-spacing: 0;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.intelligence-tabs {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.intelligence-tabs :deep(.el-tabs__header) {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.intelligence-tabs :deep(.el-tabs__content),
|
||||
.intelligence-tabs :deep(.el-tab-pane) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.camera-tab-content,
|
||||
.alarm-tab-content {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.camera-tab-content {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.alarm-tab-content {
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<device-group-play />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="wlVideoStream">
|
||||
import DeviceGroupPlay from "/@/views/device/deviceGroupPlay/index.vue";
|
||||
</script>
|
||||
Reference in new issue
Block a user