This commit is contained in:
FLL
2025-09-04 09:32:59 +08:00
20 changed files with 1088 additions and 290 deletions

View File

@@ -72,5 +72,5 @@ export function queryTree(params?: any) {
* 获取水/电/气表当前读数/状态
*/
export function currentReading(params?: any) {
return requestClient.get<MeterInfoVO[]>(`/property/meterInfo/currentReading`, { params })
return requestClient.get<void>(`/property/meterInfo/currentReading`, { params })
}

View File

@@ -0,0 +1,61 @@
import type { ParticipantsVO, ParticipantsForm, ParticipantsQuery } from './model';
import type { ID, IDS } from '#/api/common';
import type { PageResult } from '#/api/common';
import { commonExport } from '#/api/helper';
import { requestClient } from '#/api/request';
/**
* 查询会议室参会记录列表
* @param params
* @returns 会议室参会记录列表
*/
export function participantsList(params?: ParticipantsQuery) {
return requestClient.get<PageResult<ParticipantsVO>>('/property/participants/list', { params });
}
/**
* 导出会议室参会记录列表
* @param params
* @returns 会议室参会记录列表
*/
export function participantsExport(params?: ParticipantsQuery) {
return commonExport('/property/participants/export', params ?? {});
}
/**
* 查询会议室参会记录详情
* @param id id
* @returns 会议室参会记录详情
*/
export function participantsInfo(id: ID) {
return requestClient.get<ParticipantsVO>(`/property/participants/${id}`);
}
/**
* 新增会议室参会记录
* @param data
* @returns void
*/
export function participantsAdd(data: ParticipantsForm) {
return requestClient.postWithMsg<void>('/property/participants', data);
}
/**
* 更新会议室参会记录
* @param data
* @returns void
*/
export function participantsUpdate(data: ParticipantsForm) {
return requestClient.putWithMsg<void>('/property/participants', data);
}
/**
* 删除会议室参会记录
* @param id id
* @returns void
*/
export function participantsRemove(id: ID | IDS) {
return requestClient.deleteWithMsg<void>(`/property/participants/${id}`);
}

View File

@@ -0,0 +1,54 @@
import type { PageQuery, BaseEntity } from '#/api/common';
export interface ParticipantsVO {
/**
* 主键id
*/
id: string | number;
/**
* 会议室id
*/
meetId: string | number;
/**
* 入驻人员id
*/
residentPersonId: string | number;
}
export interface ParticipantsForm extends BaseEntity {
/**
* 主键id
*/
id?: string | number;
/**
* 会议室id
*/
meetId?: string | number;
/**
* 入驻人员id
*/
residentPersonId?: string | number;
}
export interface ParticipantsQuery extends PageQuery {
/**
* 会议室id
*/
meetId?: string | number;
/**
* 入驻人员id
*/
residentPersonId?: string | number;
/**
* 日期范围参数
*/
params?: any;
}

View File

@@ -0,0 +1,162 @@
import { useAccessStore } from '@vben/stores';
interface WebSocketCallbacks {
onOpen?: (event: Event) => void;
onMessage?: (event: MessageEvent) => void;
onError?: (error: Event) => void;
onClose?: (event: CloseEvent) => void;
}
export default class WebSocketService {
private webSocket: WebSocket | null = null;
private webSocketURL: string = '';
// 默认回调函数
private onOpenCallback: (event: Event) => void;
private onMessageCallback: (event: MessageEvent) => void;
private onErrorCallback: (error: Event) => void;
private onCloseCallback: (event: CloseEvent) => void;
constructor(callbacks?: WebSocketCallbacks) {
// 设置回调函数,使用自定义回调或默认回调
this.onOpenCallback = callbacks?.onOpen || this.defaultOnOpen;
this.onMessageCallback = callbacks?.onMessage || this.defaultOnMessage;
this.onErrorCallback = callbacks?.onError || this.defaultOnError;
this.onCloseCallback = callbacks?.onClose || this.defaultOnClose;
}
// 初始化WebSocket连接
initWebSocket(webSocketURL: string): void {
this.webSocketURL = webSocketURL;
try {
this.webSocket = new WebSocket(webSocketURL);
this.webSocket.onopen = this.onOpenCallback;
this.webSocket.onmessage = this.onMessageCallback;
this.webSocket.onerror = this.onErrorCallback;
this.webSocket.onclose = this.onCloseCallback;
} catch (error) {
console.error('Failed to initialize WebSocket:', error);
}
}
// 设置onOpen回调并更新WebSocket事件处理器
setOnOpenCallback(callback: (event: Event) => void): void {
this.onOpenCallback = callback;
if (this.webSocket) {
this.webSocket.onopen = this.onOpenCallback;
}
}
// 设置onMessage回调并更新WebSocket事件处理器
setOnMessageCallback(callback: (event: MessageEvent) => void): void {
this.onMessageCallback = callback;
if (this.webSocket) {
this.webSocket.onmessage = this.onMessageCallback;
}
}
// 设置onError回调并更新WebSocket事件处理器
setOnErrorCallback(callback: (error: Event) => void): void {
this.onErrorCallback = callback;
if (this.webSocket) {
this.webSocket.onerror = this.onErrorCallback;
}
}
// 设置onClose回调并更新WebSocket事件处理器
setOnCloseCallback(callback: (event: CloseEvent) => void): void {
this.onCloseCallback = callback;
if (this.webSocket) {
this.webSocket.onclose = this.onCloseCallback;
}
}
// 默认连接建立成功的回调
private defaultOnOpen(event: Event): void {
console.log('WebSocket连接建立成功', event);
}
// 默认收到服务器消息的回调
private defaultOnMessage(event: MessageEvent): void {
console.log('收到服务器消息', event.data);
// 通常这里会解析数据并更新页面
// const data = JSON.parse(event.data);
// 根据消息类型处理不同业务...
}
// 默认发生错误的回调
private defaultOnError(error: Event): void {
console.error('WebSocket连接错误', error);
}
// 默认连接关闭的回调
private defaultOnClose(event: CloseEvent): void {
console.log('WebSocket连接关闭', event);
}
// 关闭连接
close(): void {
if (this.webSocket) {
this.webSocket.close();
this.webSocket = null;
}
}
// 发送消息
sendMessage(message: string | object): void {
if (this.webSocket && this.webSocket.readyState === WebSocket.OPEN) {
const dataToSend = typeof message === 'string' ? message : JSON.stringify(message);
this.webSocket.send(dataToSend);
} else {
console.error('WebSocket连接未就绪');
}
}
// 获取当前WebSocket状态
getReadyState(): number | null {
return this.webSocket ? this.webSocket.readyState : null;
}
// 获取WebSocket URL
getWebSocketURL(): string {
return this.webSocketURL;
}
// 重新连接
reconnect(): void {
if (this.webSocketURL) {
this.close();
this.initWebSocket(this.webSocketURL);
}
}
}
// 创建一个可导出的WebSocket实例
let globalWebSocketService: WebSocketService | null = null;
/**
* 初始化WebSocket连接的可导出方法
* @param url WebSocket服务器地址
* @param callbacks 回调函数对象(可选)
* @returns WebSocketService实例
*/
export function initWebSocket(callbacks?: WebSocketCallbacks): void {
if (!globalWebSocketService) {
globalWebSocketService = new WebSocketService(callbacks);
}
const accessStore = useAccessStore();
const clinetId = import.meta.env.VITE_GLOB_APP_CLIENT_ID;
const api = import.meta.env.VITE_GLOB_API_URL;
const host = window.location.host;
const url = `ws://${host}${api}/resource/websocket?clientid=${clinetId}&Authorization=Bearer ${accessStore.accessToken}`;
globalWebSocketService.initWebSocket(url);
}
/**
* 获取全局WebSocket服务实例
* @returns WebSocketService实例或null
*/
export function getWebSocketService(): WebSocketService | null {
return globalWebSocketService;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,30 +1,30 @@
import { createApp, watchEffect } from 'vue';
import { createApp, watchEffect } from 'vue'
import { registerAccessDirective } from '@vben/access';
import { registerLoadingDirective } from '@vben/common-ui/es/loading';
import { preferences } from '@vben/preferences';
import { initStores } from '@vben/stores';
import '@vben/styles';
import '@vben/styles/antd';
import { registerAccessDirective } from '@vben/access'
import { registerLoadingDirective } from '@vben/common-ui/es/loading'
import { preferences } from '@vben/preferences'
import { initStores } from '@vben/stores'
import '@vben/styles'
import '@vben/styles/antd'
import { useTitle } from '@vueuse/core';
import { useTitle } from '@vueuse/core'
import { setupGlobalComponent } from '#/components/global';
import { $t, setupI18n } from '#/locales';
import { setupGlobalComponent } from '#/components/global'
import { $t, setupI18n } from '#/locales'
import { initComponentAdapter } from './adapter/component';
import { initSetupVbenForm } from './adapter/form';
import App from './app.vue';
import { router } from './router';
import { initComponentAdapter } from './adapter/component'
import { initSetupVbenForm } from './adapter/form'
import App from './app.vue'
import { router } from './router'
async function bootstrap(namespace: string) {
// 初始化组件适配器
await initComponentAdapter();
await initComponentAdapter()
// 初始化表单组件
await initSetupVbenForm();
await initSetupVbenForm()
// // 设置弹窗的默认配置
// setDefaultModalProps({
@@ -35,49 +35,50 @@ async function bootstrap(namespace: string) {
// zIndex: 1020,
// });
const app = createApp(App);
const app = createApp(App)
// 全局组件
setupGlobalComponent(app);
setupGlobalComponent(app)
// 注册v-loading指令
registerLoadingDirective(app, {
loading: 'loading', // 在这里可以自定义指令名称也可以明确提供false表示不注册这个指令
spinning: 'spinning',
});
})
// 国际化 i18n 配置
await setupI18n(app);
await setupI18n(app)
// 配置 pinia-tore
await initStores(app, { namespace });
await initStores(app, { namespace })
// 安装权限指令
registerAccessDirective(app);
registerAccessDirective(app)
// 初始化 tippy
const { initTippy } = await import('@vben/common-ui/es/tippy');
initTippy(app);
const { initTippy } = await import('@vben/common-ui/es/tippy')
initTippy(app)
// 配置路由及路由守卫
app.use(router);
app.use(router)
// 配置Motion插件
const { MotionPlugin } = await import('@vben/plugins/motion');
app.use(MotionPlugin);
const { MotionPlugin } = await import('@vben/plugins/motion')
app.use(MotionPlugin)
// 动态更新标题
watchEffect(() => {
if (preferences.app.dynamicTitle) {
const routeTitle = router.currentRoute.value.meta?.title;
const routeTitle = router.currentRoute.value.meta?.title
const pageTitle =
(routeTitle ? `${$t(routeTitle)} - ` : '') + preferences.app.name;
useTitle(pageTitle);
(routeTitle ? `${$t(routeTitle)} - ` : '') + preferences.app.name
useTitle(pageTitle)
}
});
})
app.mount('#app');
app.mount('#app')
}
export { bootstrap };
export { bootstrap }

View File

@@ -1,27 +1,29 @@
import type { LoginAndRegisterParams } from '@vben/common-ui';
import type { UserInfo } from '@vben/types';
import type { LoginAndRegisterParams } from '@vben/common-ui'
import type { UserInfo } from '@vben/types'
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { LOGIN_PATH } from '@vben/constants';
import { preferences } from '@vben/preferences';
import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
import { LOGIN_PATH } from '@vben/constants'
import { preferences } from '@vben/preferences'
import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores'
import { notification } from 'ant-design-vue';
import { defineStore } from 'pinia';
import { notification } from 'ant-design-vue'
import { defineStore } from 'pinia'
import { doLogout, getUserInfoApi, loginApi, seeConnectionClose } from '#/api';
import { $t } from '#/locales';
import { doLogout, getUserInfoApi, loginApi, seeConnectionClose } from '#/api'
import { $t } from '#/locales'
import { useDictStore } from './dict';
import { useDictStore } from './dict'
import { initWebSocket, getWebSocketService } from '#/api/websocket'
export const useAuthStore = defineStore('auth', () => {
const accessStore = useAccessStore();
const userStore = useUserStore();
const router = useRouter();
const accessStore = useAccessStore()
const userStore = useUserStore()
const router = useRouter()
const loginLoading = ref(false);
const loginLoading = ref(false)
/**
* 异步处理登录操作
@@ -33,30 +35,30 @@ export const useAuthStore = defineStore('auth', () => {
onSuccess?: () => Promise<void> | void,
) {
// 异步处理用户登录操作并获取 accessToken
let userInfo: null | UserInfo = null;
let userInfo: null | UserInfo = null
try {
loginLoading.value = true;
const { access_token } = await loginApi(params);
loginLoading.value = true
const { access_token } = await loginApi(params)
// 将 accessToken 存储到 accessStore 中
accessStore.setAccessToken(access_token);
accessStore.setRefreshToken(access_token);
accessStore.setAccessToken(access_token)
accessStore.setRefreshToken(access_token)
// 获取用户信息并存储到 accessStore 中
userInfo = await fetchUserInfo();
userInfo = await fetchUserInfo()
/**
* 设置用户信息
*/
userStore.setUserInfo(userInfo);
userStore.setUserInfo(userInfo)
/**
* 在这里设置权限
*/
accessStore.setAccessCodes(userInfo.permissions);
accessStore.setAccessCodes(userInfo.permissions)
if (accessStore.loginExpired) {
accessStore.setLoginExpired(false);
accessStore.setLoginExpired(false)
} else {
onSuccess ? await onSuccess?.() : await router.push('/analytics');
onSuccess ? await onSuccess?.() : await router.push('/analytics')
}
if (userInfo?.realName) {
@@ -64,48 +66,55 @@ export const useAuthStore = defineStore('auth', () => {
description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.realName}`,
duration: 3,
message: $t('authentication.loginSuccess'),
});
})
}
} finally {
loginLoading.value = false;
loginLoading.value = false
}
return {
userInfo,
};
}
}
async function logout(redirect: boolean = true) {
try {
await seeConnectionClose();
await doLogout();
await seeConnectionClose()
await doLogout()
/**
* 断开websocket连接
*/
const ws = getWebSocketService();
if(ws) {
ws.close();
}
} catch (error) {
console.error(error);
console.error(error)
} finally {
resetAllStores();
accessStore.setLoginExpired(false);
resetAllStores()
accessStore.setLoginExpired(false)
// 回登陆页带上当前路由地址
await router.replace({
path: LOGIN_PATH,
query: redirect
? {
redirect: encodeURIComponent(router.currentRoute.value.fullPath),
}
redirect: encodeURIComponent(router.currentRoute.value.fullPath),
}
: {},
});
})
}
}
async function fetchUserInfo() {
const backUserInfo = await getUserInfoApi();
const backUserInfo = await getUserInfoApi()
/**
* 登录超时的情况
*/
if (!backUserInfo) {
throw new Error('获取用户信息失败.');
throw new Error('获取用户信息失败.')
}
const { permissions = [], roles = [], user } = backUserInfo;
const { permissions = [], roles = [], user } = backUserInfo
/**
* 从后台user -> vben user转换
*/
@@ -116,19 +125,25 @@ export const useAuthStore = defineStore('auth', () => {
roles,
userId: user.userId,
username: user.userName,
};
userStore.setUserInfo(userInfo);
}
userStore.setUserInfo(userInfo)
/**
* 初始化websocket
*/
initWebSocket()
/**
* 需要重新加载字典
* 比如退出登录切换到其他租户
*/
const dictStore = useDictStore();
dictStore.resetCache();
return userInfo;
const dictStore = useDictStore()
dictStore.resetCache()
return userInfo
}
function $reset() {
loginLoading.value = false;
loginLoading.value = false
}
return {
@@ -137,5 +152,5 @@ export const useAuthStore = defineStore('auth', () => {
fetchUserInfo,
loginLoading,
logout,
};
});
}
})

View File

@@ -1,13 +1,44 @@
<script setup lang="ts">
import { Page } from '@vben/common-ui';
import { useAccessStore } from '@vben/stores';
import { BackTop, message, Spin } from 'ant-design-vue';
import { Page, VbenCountToAnimator } from '@vben/common-ui';
import { ref, onMounted, onBeforeUnmount, reactive } from 'vue';
import FloorTree from '../components/floor-tree.vue';
import { getWebSocketService } from '#/api/websocket';
import { currentReading } from '#/api/property/energyManagement/meterInfo';
onMounted(() => {});
const ws = getWebSocketService();
onBeforeUnmount(() => {});
if (ws) {
// 使用setOnMessageCallback方法设置消息回调
ws.setOnMessageCallback((event: MessageEvent) => {
// 解析数据并更新UI
try {
const data = JSON.parse(event.data);
if (data.type === 'meter') {
if (typeof data.data === 'undefined') {
message.warn('当前楼层暂无电表!');
}
readingData.value = data.data;
readingTime.value = data.readingTime;
}
} catch (e) {
console.error('Error parsing data:');
}
readingLoading.value = false;
});
// 如果需要,也可以设置错误回调
ws.setOnErrorCallback((error: any) => {
console.log('Error in WebSocket:');
currentReading({ meterType: 0, floorId: 0 });
readingLoading.value = false;
});
}
onBeforeUnmount(() => {
currentReading({ meterType: 0, floorId: 0 });
});
const readingData = ref<any>({});
const readingTime = ref('');
@@ -16,21 +47,15 @@ async function handleSelectFloor(selectedKeys, info) {
if (typeof selectedKeys[0] === 'undefined') {
return;
}
if (ws.webSocket.readyState !== 1) {
message.warn('websocket未连接请刷新页面重试');
return;
}
readingLoading.value = true;
const reading = await currentReading({
await currentReading({
meterType: 1,
floorId: selectedKeys[0],
});
readingLoading.value = false;
if (reading === null) {
message.warn('当前楼层暂无电表数据!');
}
const nowTime =
new Date().toLocaleDateString().replace(/\//g, '-') +
' ' +
new Date().toLocaleTimeString();
readingTime.value = nowTime;
readingData.value = reading;
}
function targetFn() {
@@ -54,7 +79,13 @@ function targetFn() {
{{ item.meterName }}
</h2>
<div class="meterInfo-reading">
<p>{{ item.initReading }}</p>
<p>
<VbenCountToAnimator
:end-val="item.initReading"
:decimals="2"
prefix=""
/>
</p>
<div></div>
</div>
<div class="meterInfo-list">

View File

@@ -0,0 +1,65 @@
import type { FormSchemaGetter } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
export const querySchema: FormSchemaGetter = () => [
{
component: 'Input',
fieldName: 'meetId',
label: '会议室id',
},
{
component: 'Input',
fieldName: 'residentPersonId',
label: '入驻人员id',
},
];
// 需要使用i18n注意这里要改成getter形式 否则切换语言不会刷新
// export const columns: () => VxeGridProps['columns'] = () => [
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 },
{
title: '主键id',
field: 'id',
},
{
title: '会议室id',
field: 'meetId',
},
{
title: '入驻人员id',
field: 'residentPersonId',
},
{
field: 'action',
fixed: 'right',
slots: { default: 'action' },
title: '操作',
width: 180,
},
];
export const modalSchema: FormSchemaGetter = () => [
{
label: '主键id',
fieldName: 'id',
component: 'Input',
dependencies: {
show: () => false,
triggerFields: [''],
},
},
{
label: '会议室id',
fieldName: 'meetId',
component: 'Input',
rules: 'required',
},
{
label: '入驻人员id',
fieldName: 'residentPersonId',
component: 'Input',
rules: 'required',
},
];

View File

@@ -0,0 +1,178 @@
<script setup lang="ts">
import { Page, useVbenModal, type VbenFormProps } from '@vben/common-ui';
import { getVxePopupContainer } from '@vben/utils';
import { Modal, Popconfirm, Space } from 'ant-design-vue';
import {
useVbenVxeGrid,
vxeCheckboxChecked,
type VxeGridProps
} from '#/adapter/vxe-table';
import {
participantsExport,
participantsList,
participantsRemove,
} from '#/api/property/roomBooking/participants';
import type { ParticipantsForm } from '#/api/property/roomBooking/participants/model';
import { commonDownloadExcel } from '#/utils/file/download';
import participantsModal from './participants-modal.vue';
import { columns, querySchema } from './data';
const formOptions: VbenFormProps = {
commonConfig: {
labelWidth: 80,
componentProps: {
allowClear: true,
},
},
schema: querySchema(),
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
// 处理区间选择器RangePicker时间格式 将一个字段映射为两个字段 搜索/导出会用到
// 不需要直接删除
// fieldMappingTime: [
// [
// 'createTime',
// ['params[beginTime]', 'params[endTime]'],
// ['YYYY-MM-DD 00:00:00', 'YYYY-MM-DD 23:59:59'],
// ],
// ],
};
const gridOptions: VxeGridProps = {
checkboxConfig: {
// 高亮
highlight: true,
// 翻页时保留选中状态
reserve: true,
// 点击行选中
// trigger: 'row',
},
// 需要使用i18n注意这里要改成getter形式 否则切换语言不会刷新
// columns: columns(),
columns,
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
return await participantsList({
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
// 表格全局唯一表示 保存列配置需要用到
id: 'property-participants-index'
};
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
});
const [ParticipantsModal, modalApi] = useVbenModal({
connectedComponent: participantsModal,
});
function handleAdd() {
modalApi.setData({});
modalApi.open();
}
async function handleEdit(row: Required<ParticipantsForm>) {
modalApi.setData({ id: row.id });
modalApi.open();
}
async function handleDelete(row: Required<ParticipantsForm>) {
await participantsRemove(row.id);
await tableApi.query();
}
function handleMultiDelete() {
const rows = tableApi.grid.getCheckboxRecords();
const ids = rows.map((row: Required<ParticipantsForm>) => row.id);
Modal.confirm({
title: '提示',
okType: 'danger',
content: `确认删除选中的${ids.length}条记录吗?`,
onOk: async () => {
await participantsRemove(ids);
await tableApi.query();
},
});
}
function handleDownloadExcel() {
commonDownloadExcel(participantsExport, '会议室参会记录数据', tableApi.formApi.form.values, {
fieldMappingTime: formOptions.fieldMappingTime,
});
}
</script>
<template>
<Page :auto-content-height="true">
<BasicTable table-title="会议室参会记录列表">
<template #toolbar-tools>
<Space>
<a-button
v-access:code="['property:participants:export']"
@click="handleDownloadExcel"
>
{{ $t('pages.common.export') }}
</a-button>
<a-button
:disabled="!vxeCheckboxChecked(tableApi)"
danger
type="primary"
v-access:code="['property:participants:remove']"
@click="handleMultiDelete">
{{ $t('pages.common.delete') }}
</a-button>
<a-button
type="primary"
v-access:code="['property:participants:add']"
@click="handleAdd"
>
{{ $t('pages.common.add') }}
</a-button>
</Space>
</template>
<template #action="{ row }">
<Space>
<ghost-button
v-access:code="['property:participants:edit']"
@click.stop="handleEdit(row)"
>
{{ $t('pages.common.edit') }}
</ghost-button>
<Popconfirm
:get-popup-container="getVxePopupContainer"
placement="left"
title="确认删除?"
@confirm="handleDelete(row)"
>
<ghost-button
danger
v-access:code="['property:participants:remove']"
@click.stop=""
>
{{ $t('pages.common.delete') }}
</ghost-button>
</Popconfirm>
</Space>
</template>
</BasicTable>
<ParticipantsModal @reload="tableApi.query()" />
</Page>
</template>

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { cloneDeep } from '@vben/utils';
import { useVbenForm } from '#/adapter/form';
import { participantsAdd, participantsInfo, participantsUpdate } from '#/api/property/roomBooking/participants';
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
import { modalSchema } from './data';
const emit = defineEmits<{ reload: [] }>();
const isUpdate = ref(false);
const title = computed(() => {
return isUpdate.value ? $t('pages.common.edit') : $t('pages.common.add');
});
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
// 默认占满两列
formItemClass: 'col-span-2',
// 默认label宽度 px
labelWidth: 80,
// 通用配置项 会影响到所有表单项
componentProps: {
class: 'w-full',
}
},
schema: modalSchema(),
showDefaultActions: false,
wrapperClass: 'grid-cols-2',
});
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
{
initializedGetter: defaultFormValueGetter(formApi),
currentGetter: defaultFormValueGetter(formApi),
},
);
const [BasicModal, modalApi] = useVbenModal({
// 在这里更改宽度
class: 'w-[550px]',
fullscreenButton: false,
onBeforeClose,
onClosed: handleClosed,
onConfirm: handleConfirm,
onOpenChange: async (isOpen) => {
if (!isOpen) {
return null;
}
modalApi.modalLoading(true);
const { id } = modalApi.getData() as { id?: number | string };
isUpdate.value = !!id;
if (isUpdate.value && id) {
const record = await participantsInfo(id);
await formApi.setValues(record);
}
await markInitialized();
modalApi.modalLoading(false);
},
});
async function handleConfirm() {
try {
modalApi.lock(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
// getValues获取为一个readonly的对象 需要修改必须先深拷贝一次
const data = cloneDeep(await formApi.getValues());
await (isUpdate.value ? participantsUpdate(data) : participantsAdd(data));
resetInitialized();
emit('reload');
modalApi.close();
} catch (error) {
console.error(error);
} finally {
modalApi.lock(false);
}
}
async function handleClosed() {
await formApi.resetForm();
resetInitialized();
}
</script>
<template>
<BasicModal :title="title">
<BasicForm />
</BasicModal>
</template>

View File

@@ -7,17 +7,20 @@ import type { TreeNode } from '#/api/common';
defineOptions({ inheritAttrs: false });
withDefaults(defineProps<{ showSearch?: boolean }>(), { showSearch: true });
withDefaults(
defineProps<{
showSearch?: boolean;
currentSelectKey?: string;
selectKeys?: string[];
}>(),
{
showSearch: true,
currentSelectKey: '',
selectKeys: [],
},
);
const emit = defineEmits<{
checked: [];
/**
* 点击节点的事件
*/
reload: [];
select: [];
}>();
const emit = defineEmits(['selected', 'reload', 'checked']);
const searchValue = defineModel('searchValue', {
type: String,
@@ -37,6 +40,10 @@ async function loadChannelTree() {
showTreeSkeleton.value = false;
}
function onSelect(key: any, selectNode: any) {
emit('selected', key, selectNode);
}
async function handleReload() {
await loadChannelTree();
emit('reload');
@@ -46,21 +53,11 @@ onMounted(loadChannelTree);
</script>
<template>
<div class="h-[800px]" :class="$attrs.class">
<Skeleton
:loading="showTreeSkeleton"
:paragraph="{ rows: 8 }"
active
class="p-[8px]"
>
<div
class="flex h-full flex-col overflow-y-auto rounded-lg"
>
<div :class="$attrs.class">
<Skeleton :loading="showTreeSkeleton" :paragraph="{ rows: 8 }" active>
<div class="h-full">
<!-- 固定在顶部 必须加上bg-background背景色 否则会产生'穿透'效果 -->
<divx
v-if="showSearch"
class="z-100 sticky left-0 top-0 p-[8px]"
>
<div v-if="showSearch" class="z-100 sticky left-0 top-0 p-[8px]">
<InputSearch
v-model:value="searchValue"
:placeholder="$t('pages.common.search')"
@@ -72,31 +69,45 @@ onMounted(loadChannelTree);
</a-button>
</template>
</InputSearch>
</divx>
<div class="h-full overflow-x-hidden px-[8px]">
</div>
<div
class="h-[calc(100%-40px)] overflow-y-auto overflow-x-hidden px-[8px]"
>
<Tree
v-bind="$attrs"
v-if="channelTree.length > 0"
:class="$attrs.class"
:show-line="{ showLeafIcon: false }"
:tree-data="channelTree"
:virtual="false"
default-expand-all
checkable
@select="$emit('select')"
@check="$emit('checked')"
@select="onSelect"
>
<template #title="{ label }">
<span v-if="label.indexOf(searchValue) > -1">
{{ label.substring(0, label.indexOf(searchValue)) }}
<span style="color: #f50">{{ searchValue }}</span>
{{
label.substring(
label.indexOf(searchValue) + searchValue.length,
)
}}
</span>
<span v-else>{{ label }}</span>
<template #title="{ label, level, key }">
<div class="flex">
<div v-if="level == 2" class="tree-icon">
<div
v-if="selectKeys.indexOf(key) > -1"
class="icon playing"
></div>
<div v-else class="icon unplay"></div>
</div>
<span :style="currentSelectKey == key?'color:blue':''">
<span v-if="label.indexOf(searchValue) > -1">
{{ label.substring(0, label.indexOf(searchValue)) }}
<span style="color: #f50">{{ searchValue }}</span>
{{
label.substring(
label.indexOf(searchValue) + searchValue.length,
)
}}
</span>
<span v-else>
<span>{{ label }}</span>
</span>
</span>
</div>
</template>
</Tree>
</div>
@@ -104,3 +115,25 @@ onMounted(loadChannelTree);
</Skeleton>
</div>
</template>
<style scoped>
.tree-icon {
display: inline-block;
height: 20px;
width: 20px;
.unplay {
width: 100%;
height: 100%;
background: url('/src/assets/tree/unplayer.png') no-repeat;
background-size: 100% 100%;
}
.playing {
width: 100%;
height: 100%;
background: url('/src/assets/tree/playering.png') no-repeat;
background-size: 100% 100%;
}
}
</style>

View File

@@ -1,9 +1,15 @@
<template>
<Page class="h-full w-full">
<Page style="height: calc(100vh - 88px)" class="video-page h-full w-full">
<!-- 设备分组区域 -->
<div class="flex h-full gap-[8px]">
<div class="c-tree bg-background h-full pb-[5px]">
<ChannelTree class="w-[300px]" @check="onNodeChecked" />
<div class="c-tree bg-background h-full overflow-hidden pb-[5px]">
<ChannelTree
class="h-full w-[300px]"
:currentSelectKey="currentSelectKey"
:selectKeys="selectKeys"
@check="onNodeChecked"
@select="onTreeSelect"
/>
</div>
<!-- 设备分组区域 -->
@@ -13,29 +19,43 @@
v-for="i in playerNum"
:style="playerStyle"
class="player"
:class="`layer-${i} ${currentSelectPlayerIndex == i ? selected : ''}`"
@click="playerSelect(i)"
:class="`layer-${i - 1} ${currentSelectPlayerIndex == i - 1 ? selected : ''}`"
@click="playerSelect(i - 1)"
>
<video
style="width: 100%; height: 100%"
:ref="setItemRef"
muted
autoplay
></video>
<Loading
:spinning="playerLoading[i - 1]"
text="加载中..."
class="flex h-full w-full items-center justify-center"
>
<video
style="width: 100%; height: 100%"
:ref="(el) => setItemRef(i - 1, el)"
muted
autoplay
></video>
</Loading>
</div>
</div>
<div class="player-area flex h-[30px] gap-[5px]">
<div @click="onPlayerNumChanged(1)" class="h-[20px] w-[20px]">
<Svg1FrameIcon style="width: 100%; height: 100%" />
</div>
<div @click="onPlayerNumChanged(2)" class="h-[20px] w-[20px]">
<Svg4FrameIcon style="width: 100%; height: 100%" />
</div>
<div @click="onPlayerNumChanged(3)" class="h-[20px] w-[20px]">
<Svg9FrameIcon style="width: 100%; height: 100%" />
</div>
<div @click="onPlayerNumChanged(4)" class="h-[20px] w-[20px]">
<Svg16FrameIcon style="width: 100%; height: 100%" />
<div
v-for="key in 4"
@click="onPlayerNumChanged(key)"
:class="playerSelectItemIndex == key ? selected : ''"
class="btn-item h-[20px] w-[20px]"
>
<Svg1FrameIcon v-if="key == 1" style="width: 100%; height: 100%" />
<Svg4FrameIcon
v-else-if="key == 2"
style="width: 100%; height: 100%"
/>
<Svg9FrameIcon
v-else-if="key == 3"
style="width: 100%; height: 100%"
/>
<Svg16FrameIcon
v-else-if="key == 4"
style="width: 100%; height: 100%"
/>
</div>
</div>
</div>
@@ -45,7 +65,7 @@
<script lang="ts" setup>
import { onMounted, onUnmounted, ref, toRaw } from 'vue';
import { Page } from '@vben/common-ui';
import { Loading, Page } from '@vben/common-ui';
import ChannelTree from './channel-tree.vue';
import mpegts from 'mpegts.js';
import { message } from 'ant-design-vue';
@@ -61,32 +81,46 @@ import type { AddStreamProxyResult } from '#/api/sis/stream/model';
const selected = 'selected';
const itemRefs = ref<HTMLVideoElement[]>([]);
const setItemRef = (el: any) => {
if (el) {
itemRefs.value.push(el);
}
const itemRefs: any[] = [];
const setItemRef = (index: number, el: any) => {
itemRefs[index] = el;
};
/**
* 屏幕播放器数量
*/
const playerNum = ref(1);
/**
* 屏幕播放器样式
*/
//屏幕播放器样式
const playerStyle = ref({
width: '100%',
height: '100%',
});
const currentSelectPlayerIndex = ref(-1);
// 播放器配置
const playerConfig = {
enableErrorRecover: true, // 启用错误恢复
autoCleanupMaxBackwardDuration: 30,
autoCleanupMinBackwardDuration: 10,
};
//屏幕播放器数量
const playerNum = ref(1);
// 播放器数量控制按钮组索引
const playerSelectItemIndex = ref(1);
// 当前选择的播放器索引,默认选中第一个
const currentSelectPlayerIndex = ref(0);
// 播放器数据, 每一个位置代表页面上行的一个矩形
const playerList: any[] = [];
// 播放器loading
const playerLoading = ref<boolean[]>([]);
// 当前选择播放器的key
const currentSelectKey = ref('');
// 当前所有的播放设备key
const selectKeys = ref<string[]>([]);
function playerSelect(index: number) {
if (index === currentSelectPlayerIndex.value) {
currentSelectPlayerIndex.value = -1;
return;
}
currentSelectPlayerIndex.value = index;
const player = playerList[index];
if (player) {
currentSelectKey.value = player.key;
} else {
currentSelectKey.value = '';
}
}
/**
@@ -95,8 +129,8 @@ function playerSelect(index: number) {
*/
function onPlayerNumChanged(val: number) {
// 1个屏幕
const changeBeforeNum = playerNum.value;
let changeNum = 1;
playerSelectItemIndex.value = val;
if (val === 1) {
playerStyle.value = {
width: '100%',
@@ -128,26 +162,18 @@ function onPlayerNumChanged(val: number) {
changeNum = 16;
}
playerNum.value = changeNum;
// 缩小布局
if (changeBeforeNum > changeNum) {
const playerArr = [];
for (let i = 0; i < playerList.length; i++) {
const playerBox = playerList[i];
if (playerBox) {
playerArr.push(playerBox);
}
playerList[i] = null;
}
for (let i = 0; i < playerArr.length; i++) {
const play = playerArr[i];
if (i < changeNum) {
// 获取播放元素
changeElPlayer(play, i);
} else {
closePlayVieo(play.player);
// 缩小布局,超过当前播放器的都关闭
for (let i = 0; i < playerList.length; i++) {
const playInfo = playerList[i];
if (i >= changeNum) {
if (playInfo) {
closePlayVieo(playInfo.player);
}
}
}
// 处理树节点状态
treeSelectHandle();
}
/**
@@ -168,8 +194,43 @@ function handleParentNoe(node: any, newNode: any[] = []) {
}
}
// 播放器数据, 每一个位置代表页面上行的一个矩形
const playerList: any[] = [];
function onTreeSelect(_key: any, selectNode: any) {
const {
selected,
node: { level, data },
} = selectNode;
// 代表点击的是摄像头
if (level === 2) {
// 播放
if (selected) {
doPlayer(data, currentSelectPlayerIndex.value);
}
// 取消播放
else {
for (let i = 0; i < playerNum.value; i++) {
const player = playerList[i];
if (player && player.data.id === data.id) {
closePlayer(i);
}
}
}
} else {
message.error('请选择摄像头');
}
}
function treeSelectHandle() {
// 此处player可能已经释放所以不可能在取到只
const player = playerList[currentSelectPlayerIndex.value];
currentSelectKey.value = player ? player.key : '';
const arr: string[] = [];
playerList.forEach((item: any) => {
if (item && item.key) {
arr.push(item.key);
}
});
selectKeys.value = arr;
}
/**
* 节点选中时间处理
@@ -194,7 +255,7 @@ function onNodeChecked(
* 如果当前页面有选择播放未知,并且播放视频只有一个,则播放到制定位置
*/
if (currentSelectPlayerIndex.value !== -1 && checkNode.length == 1) {
doPlayer(checkNode[0], currentSelectPlayerIndex.value - 1);
doPlayer(checkNode[0], currentSelectPlayerIndex.value);
}
// 批量播放 currentSelectPlayerIndex 将不再生效
else {
@@ -233,40 +294,33 @@ function onNodeChecked(
}
}
function changeElPlayer(playerInfo: any, index: number) {
const playerData = playerInfo.data;
const oldPlayer = playerInfo.player;
if (oldPlayer) {
closePlayVieo(oldPlayer);
}
const videoConfig = {
type: 'flv',
url: playerData.url,
isLive: true,
hasAudio: false,
hasVideo: true,
enableWorker: true, // 启用分离的线程进行转码
enableStashBuffer: false, // 关闭IO隐藏缓冲区
stashInitialSize: 256, // 减少首帧显示等待时长
};
const playerConfig = {
enableErrorRecover: true, // 启用错误恢复
autoCleanupMaxBackwardDuration: 30,
autoCleanupMinBackwardDuration: 10,
};
const player = mpegts.createPlayer(videoConfig, playerConfig);
const videoElement = itemRefs.value[index];
if (videoElement) {
player.attachMediaElement(videoElement);
player.load();
player.play();
playerList[index] = {
player,
data: playerData,
};
} else {
console.log('视频播放元素获取异常');
}
/**
* 添加媒体播放器事件监听
* @param index 媒体索引
* @param player 播放器对象
*/
function addPlayerListener(index: number, player: any) {
// 播放结束。
player.on(mpegts.Events.LOADING_COMPLETE, (...args: any[]) => {
console.log('LOADING_COMPLETE', args);
});
// 播放媒体数据
player.on(mpegts.Events.MEDIA_INFO, (...args: any[]) => {
console.log('MEDIA_INFO', args);
loading(index, 1);
});
// 播放媒体元数据到到达
player.on(mpegts.Events.METADATA_ARRIVED, (...args: any[]) => {
console.log('METADATA_ARRIVED', args);
});
// 统计播放数据
// player.on(mpegts.Events.STATISTICS_INFO, (...args: any[]) => {
// console.log('STATISTICS_INFO', args);
// });
// 播放异常
player.on(mpegts.Events.ERROR, (...args: any[]) => {
console.log('ERROR', args);
});
}
function streamProxy(nodeData: any, cb: Function) {
@@ -309,6 +363,7 @@ function doPlayer(nodeData: any, index: number = 0) {
streamProxy(nodeData, (res: AddStreamProxyResult) => {
const host = window.location.host;
const url = `ws://${host}/${res.app}/${res.streamId}.live.flv`;
// const url = `ws://183.230.235.66:11010/${res.app}/${res.streamId}.live.flv`;
// 将url 绑定到 nodeData
nodeData.url = url;
closePlayer(index);
@@ -322,13 +377,11 @@ function doPlayer(nodeData: any, index: number = 0) {
enableStashBuffer: false, // 关闭IO隐藏缓冲区
stashInitialSize: 256, // 减少首帧显示等待时长
};
const playerConfig = {
enableErrorRecover: true, // 启用错误恢复
autoCleanupMaxBackwardDuration: 30,
autoCleanupMinBackwardDuration: 10,
};
// 开启loading
loading(index);
const player = mpegts.createPlayer(videoConfig, playerConfig);
const videoElement = itemRefs.value[index];
addPlayerListener(index, player);
const videoElement = itemRefs[index];
if (videoElement) {
player.attachMediaElement(videoElement);
player.load();
@@ -338,6 +391,8 @@ function doPlayer(nodeData: any, index: number = 0) {
key: nodeData.id,
data: nodeData,
};
// 播放完成后, 需要处理树组件的状态
treeSelectHandle();
} else {
console.log('视频播放元素获取异常');
}
@@ -352,7 +407,9 @@ function closePlayVieo(plInfo: any) {
try {
plInfo.pause(); // 暂停
plInfo.unload(); // 卸载
plInfo.detachMediaElement();
plInfo.destroy(); // 销毁
treeSelectHandle();
} catch (e) {
console.log('播放器关闭失败e=', e);
}
@@ -363,30 +420,19 @@ function closePlayer(index: number) {
// 如果播放器存在,尝试关闭
const pData = playerList[index];
if (pData) {
try {
const player = pData.player;
player.pause(); // 暂停
player.unload(); // 卸载
player.destroy(); // 销毁
playerList[index] = null;
} catch (e) {
console.log('播放器关闭失败e=', e);
}
closePlayVieo(pData.player);
}
}
function catchUp() {
playerList.forEach((playerData) => {
if (playerData) {
const { player, el } = playerData;
const end = player.buffered.end(player.buffered.length - 1);
const diff = end - el.currentTime;
if (diff > 2) {
// 如果延迟超过2秒
el.currentTime = end - 0.5; // 跳转到接近直播点
}
}
});
/**
* 开启或关闭播放器loading
* @param index 播放器索引
* @param cmd 0:开启1:关闭
*/
function loading(index: number, cmd: Number = 0) {
const loadinArr = [...playerLoading.value];
loadinArr[index] = cmd === 0;
playerLoading.value = loadinArr;
}
let isSupportH265 = false;
@@ -401,6 +447,20 @@ onUnmounted(() => {
closePlayer(i);
}
});
function catchUp() {
playerList.forEach((playerData) => {
if (playerData) {
const { player, el } = playerData;
const end = player.buffered.end(player.buffered.length - 1);
const diff = end - el.currentTime;
if (diff > 2) {
// 如果延迟超过2秒
el.currentTime = end - 0.5; // 跳转到接近直播点
}
}
});
}
</script>
<style scoped>
@@ -415,6 +475,10 @@ onUnmounted(() => {
}
}
.c-tree {
font-size: 12px;
}
.player.selected {
border: 2px solid deepskyblue;
}
@@ -423,5 +487,9 @@ onUnmounted(() => {
display: flex;
align-items: center;
cursor: pointer;
.btn-item.selected {
color: #00a8ff;
}
}
</style>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, shallowRef } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Descriptions, DescriptionsItem, Image, Tag } from 'ant-design-vue';
import { Descriptions, DescriptionsItem, Image, Tag,Divider } from 'ant-design-vue';
import { queryAlarmEventAttachmentsList } from '#/api/sis/alarmEventAttachments';
import type { AlarmEventAttachmentsVO } from '#/api/sis/alarmEventAttachments/model';
import { fallImg } from './data';
@@ -110,25 +110,10 @@ function loadProcessList() {
{{ warningDetail.description }}
</DescriptionsItem>
<DescriptionsItem label="所在位置">
<DescriptionsItem label="所在位置" :span="2">
{{ warningDetail.deviceName }}
</DescriptionsItem>
<DescriptionsItem label="处理状态">
<Tag>
{{ warningDetail.stateName }}
</Tag>
</DescriptionsItem>
<DescriptionsItem label="处理情况" :span="2">
{{ warningDetail.processingDetails || '-' }}
</DescriptionsItem>
<DescriptionsItem label="处理时间" :span="2">
{{ warningDetail.solveTime || '-' }}
</DescriptionsItem>
<DescriptionsItem :span="1" label="附件信息">
<DescriptionsItem :span="2" label="附件信息">
<div class="file-box">
<div class="img-box" v-for="item in currFiles">
<Image
@@ -140,7 +125,33 @@ function loadProcessList() {
</div>
</DescriptionsItem>
<DescriptionsItem :span="1" label="报警视频"></DescriptionsItem>
<DescriptionsItem :span="2" label="报警视频"></DescriptionsItem>
</Descriptions>
<Divider orientation="left">处理</Divider>
<Descriptions
v-if="warningDetail"
size="small"
:column="2"
bordered
:labelStyle="{ width: '120px' }"
style="margin-bottom: 30px">
<DescriptionsItem label="处理状态">
<Tag>
{{ warningDetail.stateName }}
</Tag>
</DescriptionsItem>
<DescriptionsItem label="处理人">
{{ warningDetail.solveName }}
</DescriptionsItem>
<DescriptionsItem label="处理人电话">
{{ warningDetail.solvePhone }}
</DescriptionsItem>
<DescriptionsItem label="处理时间">
{{ warningDetail.solveTime || '-' }}
</DescriptionsItem>
<DescriptionsItem label="处理情况" :span="2">
{{ warningDetail.processingDetails || '-' }}
</DescriptionsItem>
</Descriptions>
</BasicModal>
</template>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, shallowRef } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Descriptions, DescriptionsItem, Image, Tag } from 'ant-design-vue';
import {Descriptions, DescriptionsItem, Divider, Image, Tag} from 'ant-design-vue';
import { queryAlarmEventAttachmentsList } from '#/api/sis/alarmEventAttachments';
import type { AlarmEventAttachmentsVO } from '#/api/sis/alarmEventAttachments/model';
import { fallImg } from './data';
@@ -108,17 +108,11 @@ function loadProcessList() {
{{ warningDetail.description }}
</DescriptionsItem>
<DescriptionsItem label="所在位置">
<DescriptionsItem label="所在位置" :span="2">
{{ warningDetail.deviceName }}
</DescriptionsItem>
<DescriptionsItem label="处理状态">
<Tag color="success">
{{ warningDetail.stateName }}
</Tag>
</DescriptionsItem>
<DescriptionsItem :span="1" label="附件信息">
<DescriptionsItem label="附件信息" :span="2">
<div class="file-box">
<div class="img-box" v-for="item in currFiles">
<Image
@@ -130,7 +124,27 @@ function loadProcessList() {
</div>
</DescriptionsItem>
<DescriptionsItem :span="1" label="报警视频"></DescriptionsItem>
<DescriptionsItem :span="2" label="报警视频"></DescriptionsItem>
</Descriptions>
<Divider orientation="left">处理</Divider>
<Descriptions
v-if="warningDetail"
size="small"
:column="2"
bordered
:labelStyle="{ width: '120px' }"
style="margin-bottom: 30px">
<DescriptionsItem label="处理状态" :span="2">
<Tag>
{{ warningDetail.stateName }}
</Tag>
</DescriptionsItem>
<DescriptionsItem label="处理人">
{{ warningDetail.solveName }}
</DescriptionsItem>
<DescriptionsItem label="处理人电话">
{{ warningDetail.solvePhone }}
</DescriptionsItem>
</Descriptions>
</BasicModal>
</template>