This commit is contained in:
dzc committed 2026-08-20 19:42:53 +08:00
1 parent 7aa7addb44
commit 1c7a6035f3
38 files changed
+2110 -217

No files matched your search

+44
View File
@@ -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",
});
}
+83
View File
@@ -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,
});
}
+16
View File
@@ -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" })
+66 -8
View File
@@ -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>
+39 -3
View File
@@ -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;
}
+9 -7
View File
@@ -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;
+2 -2
View File
@@ -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>
+3 -2
View File
@@ -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;
};
};
// 初始化菜单数据
+11 -1
View File
@@ -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) {
+3 -3
View File
@@ -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 -3
View File
@@ -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>
+3 -3
View File
@@ -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>
+1 -1
View File
@@ -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;
},
{
+3
View File
@@ -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');
+1
View File
@@ -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
View File
@@ -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>
+34 -43
View File
@@ -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;
});
};
// 暴露变量
+18 -3
View File
@@ -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>
+2
View File
@@ -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;
+37 -21
View File
@@ -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>
+43
View File
@@ -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>
+147
View File
@@ -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>
+145 -26
View File
@@ -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);
}
+20 -2
View File
@@ -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>
+10 -2
View File
@@ -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)
+1 -1
View File
@@ -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>
+96
View File
@@ -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>
+90
View File
@@ -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>
+88
View File
@@ -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>
+7
View File
@@ -0,0 +1,7 @@
<template>
<device-group-play />
</template>
<script setup lang="ts" name="wlVideoStream">
import DeviceGroupPlay from "/@/views/device/deviceGroupPlay/index.vue";
</script>