Merge pull request 'master' (#5) from master into prod
All checks were successful
/ Explore-Gitea-Actions (push) Successful in 15m33s

Reviewed-on: #5
This commit is contained in:
2025-09-06 20:34:20 +08:00
57 changed files with 2522 additions and 1306 deletions

View File

@@ -20,6 +20,8 @@ VITE_GLOB_RSA_PUBLIC_KEY=MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj6
VITE_GLOB_RSA_PRIVATE_KEY=MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAmc3CuPiGL/LcIIm7zryCEIbl1SPzBkr75E2VMtxegyZ1lYRD+7TZGAPkvIsBcaMs6Nsy0L78n2qh+lIZMpLH8wIDAQABAkEAk82Mhz0tlv6IVCyIcw/s3f0E+WLmtPFyR9/WtV3Y5aaejUkU60JpX4m5xNR2VaqOLTZAYjW8Wy0aXr3zYIhhQQIhAMfqR9oFdYw1J9SsNc+CrhugAvKTi0+BF6VoL6psWhvbAiEAxPPNTmrkmrXwdm/pQQu3UOQmc2vCZ5tiKpW10CgJi8kCIFGkL6utxw93Ncj4exE/gPLvKcT+1Emnoox+O9kRXss5AiAMtYLJDaLEzPrAWcZeeSgSIzbL+ecokmFKSDDcRske6QIgSMkHedwND1olF8vlKsJUGK3BcdtM8w4Xq7BpSBwsloE=
# 客户端id
VITE_GLOB_APP_CLIENT_ID=e5cd7e4891bf95d1d19206ce24a7b32e
# 开启WEBSOCKET
VITE_APP_WEBSOCKET=true
# 开启SSE
VITE_GLOB_SSE_ENABLE=true

View File

@@ -26,6 +26,8 @@ VITE_GLOB_RSA_PUBLIC_KEY=MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj6
VITE_GLOB_RSA_PRIVATE_KEY=MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAmc3CuPiGL/LcIIm7zryCEIbl1SPzBkr75E2VMtxegyZ1lYRD+7TZGAPkvIsBcaMs6Nsy0L78n2qh+lIZMpLH8wIDAQABAkEAk82Mhz0tlv6IVCyIcw/s3f0E+WLmtPFyR9/WtV3Y5aaejUkU60JpX4m5xNR2VaqOLTZAYjW8Wy0aXr3zYIhhQQIhAMfqR9oFdYw1J9SsNc+CrhugAvKTi0+BF6VoL6psWhvbAiEAxPPNTmrkmrXwdm/pQQu3UOQmc2vCZ5tiKpW10CgJi8kCIFGkL6utxw93Ncj4exE/gPLvKcT+1Emnoox+O9kRXss5AiAMtYLJDaLEzPrAWcZeeSgSIzbL+ecokmFKSDDcRske6QIgSMkHedwND1olF8vlKsJUGK3BcdtM8w4Xq7BpSBwsloE=
# 客户端id
VITE_GLOB_APP_CLIENT_ID=e5cd7e4891bf95d1d19206ce24a7b32e
# 开启WEBSOCKET
VITE_APP_WEBSOCKET=true
# 开启SSE
VITE_GLOB_SSE_ENABLE=true

View File

@@ -0,0 +1,63 @@
import { requestClient } from '#/api/request';
/**
* 查询工单数量
* @param params
* @returns 工单数量
*/
export function getIndexCount() {
return requestClient.get<any>('/property/index/indexCount');
}
// 今日预警分类统计
export function getStatisticsCurrDay() {
return requestClient.get<any>('/sis/alarmEvents/query/statistics/currDay');
}
// 所有预警信息分类统计
export function getStatistics() {
return requestClient.get<any>('/sis/alarmEvents/query/statistics');
}
// /**
// * 导出资产管理列表
// * @param params
// * @returns 资产管理列表
// */
// export function assetExport(params?: AssetQuery) {
// return commonExport('/property/asset/export', params ?? {});
// }
// /**
// * 查询资产管理详情
// * @param id id
// * @returns 资产管理详情
// */
// export function assetInfo(id: ID) {
// return requestClient.get<AssetVO>(`/property/asset/${id}`);
// }
// /**
// * 新增资产管理
// * @param data
// * @returns void
// */
// export function assetAdd(data: AssetForm) {
// return requestClient.postWithMsg<void>('/property/asset', data);
// }
// /**
// * 更新资产管理
// * @param data
// * @returns void
// */
// export function assetUpdate(data: AssetForm) {
// return requestClient.putWithMsg<void>('/property/asset', data);
// }
// /**
// * 删除资产管理
// * @param id id
// * @returns void
// */
// export function assetRemove(id: ID | IDS) {
// return requestClient.deleteWithMsg<void>(`/property/asset/${id}`);
// }

View File

@@ -1,6 +1,6 @@
import type { MeterInfoVO, MeterInfoForm, MeterInfoQuery } from './model'
import type { ID, IDS, PageResult, TreeNode } from '#/api/common';
import type { ID, IDS, PageResult, TreeNode } from '#/api/common'
import { commonExport } from '#/api/helper'
import { requestClient } from '#/api/request'
@@ -64,6 +64,13 @@ export function meterInfoRemove(id: ID | IDS) {
* @param level
* @returns 水电气树
*/
export function queryTree(meterType: number | string) {
return requestClient.get<TreeNode<Number>[]>(`/property/meterInfo/tree/${meterType}`)
export function queryTree(params?: any) {
return requestClient.get<TreeNode<Number>[]>(`/property/meterInfo/tree`, { params })
}
/**
* 获取水/电/气表当前读数/状态
*/
export function currentReading(params?: any) {
return requestClient.get<void>(`/property/meterInfo/currentReading`, { params })
}

View File

@@ -1,4 +1,9 @@
import type { MeterRecordVO, MeterRecordForm, MeterRecordQuery, MeterRecordTrend } from './model';
import type {
MeterRecordVO,
MeterRecordForm,
MeterRecordQuery,
MeterRecordTrend,
} from './model';
import type { ID, IDS } from '#/api/common';
import type { PageResult } from '#/api/common';
@@ -7,12 +12,15 @@ import { commonExport } from '#/api/helper';
import { requestClient } from '#/api/request';
/**
* 查询抄表记录列表
* @param params
* @returns 抄表记录列表
*/
* 查询抄表记录列表
* @param params
* @returns 抄表记录列表
*/
export function meterRecordList(params?: MeterRecordQuery) {
return requestClient.get<PageResult<MeterRecordVO>>('/property/meterRecord/list', { params });
return requestClient.get<PageResult<MeterRecordVO>>(
'/property/meterRecord/list',
{ params },
);
}
/**
@@ -62,10 +70,10 @@ export function meterRecordRemove(id: ID | IDS) {
/**
* 获取用电/气/水趋势分析数据
*
*
* @param params
* @returns 用电/气/水趋势分析数据
*/
export function meterRecordTrend(params: MeterRecordTrend) {
return requestClient.get<void>('/property/meterRecord/trend', { params });
return requestClient.get<any>('/property/meterRecord/trend', { params });
}

View File

@@ -1,182 +1,179 @@
import type { PageQuery, BaseEntity } from '#/api/common'
import type { PageQuery, BaseEntity } from '#/api/common';
export interface MeterRecordVO {
/**
* 记录ID
*/
id: string | number
id: string | number;
/**
* 仪表编号
*/
meterId: string | number
meterId: string | number;
/**
* 仪表类型
*/
meterType: string | number
* 仪表类型
*/
meterType: string | number;
/**
* 抄表员ID
*/
readerId: string | number
readerId: string | number;
/**
* 抄表时间
*/
readingTime: string
readingTime: string;
/**
* 当前读数
*/
currentReading: number
currentReading: number;
/**
* 上次读数
*/
previousReading: number
previousReading: number;
/**
* 用量
*/
consumption: number
consumption: number;
/**
* 抄表方式(1手动 2自动 3用户上报)
*/
readingMethod: number
readingMethod: number;
/**
* 抄表照片
*/
imgOssid: string | number
imgOssid: string | number;
}
export interface MeterRecordForm extends BaseEntity {
/**
* 记录ID
*/
id?: string | number
id?: string | number;
/**
* 仪表编号
*/
meterId?: string | number
meterId?: string | number;
/**
* 抄表员ID
*/
readerId?: string | number
readerId?: string | number;
/**
* 抄表时间
*/
readingTime?: string
readingTime?: string;
/**
* 当前读数
*/
currentReading?: number
currentReading?: number;
/**
* 上次读数
*/
previousReading?: number
previousReading?: number;
/**
* 用量
*/
consumption?: number
consumption?: number;
/**
* 抄表方式(1手动 2自动 3用户上报)
*/
readingMethod?: number
readingMethod?: number;
/**
* 抄表照片
*/
imgOssid?: string | number
imgOssid?: string | number;
}
export interface MeterRecordQuery extends PageQuery {
/**
* 仪表编号
*/
meterId?: string | number
meterId?: string | number;
/**
* 抄表员ID
*/
readerId?: string | number
readerId?: string | number;
/**
* 抄表时间
*/
readingTime?: string
readingTime?: string;
/**
* 当前读数
*/
currentReading?: number
currentReading?: number;
/**
* 上次读数
*/
previousReading?: number
previousReading?: number;
/**
* 用量
*/
consumption?: number
consumption?: number;
/**
* 抄表方式(1手动 2自动 3用户上报)
*/
readingMethod?: number
readingMethod?: number;
/**
* 抄表照片
*/
imgOssid?: string | number
imgOssid?: string | number;
/**
* 日期范围参数
*/
params?: any
* 日期范围参数
*/
params?: any;
}
export interface MeterRecordTrend {
/**
* 仪表类型
*/
meterType?: string | number
meterType?: string | number;
/**
* 仪表ID
*/
meterId: string | number
meterId: any;
/**
* 楼层ID
*/
floorId: string | number
floorId: any;
/**
* 日期
*/
day?: string
day?: string;
/**
* 月份
*/
month?: string
month?: string;
/**
* 年份
*/
year?: string
year?: string;
}

View File

@@ -20,6 +20,7 @@ export interface RoomVO {
* 房间类型('住宅','商铺','办公室','设备间','公共区域')
*/
roomType: string;
roomTypeName: string;
/**
* 建筑面积(平方米)
@@ -50,6 +51,40 @@ export interface RoomVO {
* 状态('空置','已售','已租','自用'
*/
status: string;
/**
* 房间图片
*/
imgUrl: string;
imgPath: string;
/**
* 是否重要
*/
isMatter: string;
/**
* 状态
*/
statusName: string;
/**
* 小区
*/
communityText: string;
/**
* 建筑
*/
buildingText: string;
/**
* 楼层
*/
floorText: string;
/**
* 所属单位
*/
residentUnitText: string;
}

View File

@@ -1,4 +1,4 @@
import type { PageQuery, BaseEntity } from '#/api/common';
import type {PageQuery, BaseEntity} from '#/api/common';
export interface MeetVO {
/**
@@ -90,6 +90,10 @@ export interface MeetVO {
* 负责人
*/
principalsName: string;
/**
* 保密等级
*/
secrecyGrade: string;
}
export interface MeetForm extends BaseEntity {
@@ -236,8 +240,8 @@ export interface MeetQuery extends PageQuery {
searchValue?: string;
/**
* 日期范围参数
*/
* 日期范围参数
*/
params?: any;
/**
@@ -274,7 +278,7 @@ export interface MeetQuery extends PageQuery {
picture: string;
}
export interface ConferenceSettingsDetail{
export interface ConferenceSettingsDetail {
/**
* 主键
*/
@@ -375,7 +379,7 @@ export interface ConferenceSettingsDetail{
}
export interface MeetBo{
export interface MeetBo {
/**
* 会议室名称
*/

View File

@@ -0,0 +1,59 @@
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,27 @@
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'
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 +33,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 +64,48 @@ 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()
} 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 +116,20 @@ export const useAuthStore = defineStore('auth', () => {
roles,
userId: user.userId,
username: user.userName,
};
userStore.setUserInfo(userInfo);
}
userStore.setUserInfo(userInfo)
/**
* 需要重新加载字典
* 比如退出登录切换到其他租户
*/
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 +138,5 @@ export const useAuthStore = defineStore('auth', () => {
fetchUserInfo,
loginLoading,
logout,
};
});
}
})

View File

@@ -1,21 +1,21 @@
import type { NotificationItem } from '@vben/layouts';
import type { NotificationItem } from '@vben/layouts'
import { computed, ref, watch } from 'vue';
import { computed, ref, watch } from 'vue'
import { useAppConfig } from '@vben/hooks';
import { SvgMessageUrl } from '@vben/icons';
import { $t } from '@vben/locales';
import { useAccessStore, useUserStore } from '@vben/stores';
import { useAppConfig } from '@vben/hooks'
import { SvgMessageUrl } from '@vben/icons'
import { $t } from '@vben/locales'
import { useAccessStore, useUserStore } from '@vben/stores'
import { useEventSource } from '@vueuse/core';
import { notification } from 'ant-design-vue';
import dayjs from 'dayjs';
import { defineStore } from 'pinia';
import { useEventSource } from '@vueuse/core'
import { notification } from 'ant-design-vue'
import dayjs from 'dayjs'
import { defineStore } from 'pinia'
const { apiURL, clientId, sseEnable } = useAppConfig(
import.meta.env,
import.meta.env.PROD,
);
)
export const useNotifyStore = defineStore(
'app-notify',
@@ -23,18 +23,18 @@ export const useNotifyStore = defineStore(
/**
* return才会被持久化 存储全部消息
*/
const notificationList = ref<NotificationItem[]>([]);
const sseList = ref<string[]>(["111"]);
const userStore = useUserStore();
const notificationList = ref<NotificationItem[]>([])
const sseList = ref<string[]>(["111"])
const userStore = useUserStore()
const userId = computed(() => {
return userStore.userInfo?.userId || '0';
});
return userStore.userInfo?.userId || '0'
})
const notifications = computed(() => {
return notificationList.value.filter(
(item) => item.userId === userId.value,
);
});
)
})
/**
* 开始监听sse消息
@@ -44,40 +44,45 @@ export const useNotifyStore = defineStore(
* 未开启 不监听
*/
if (!sseEnable) {
return;
return
}
const accessStore = useAccessStore();
const token = accessStore.accessToken;
const accessStore = useAccessStore()
const token = accessStore.accessToken
const sseAddr = `${apiURL}/resource/sse?clientid=${clientId}&Authorization=Bearer ${token}`;
const sseAddr = `${apiURL}/resource/sse?clientid=${clientId}&Authorization=Bearer ${token}`
const { data } = useEventSource(sseAddr, [], {
autoReconnect: {
delay: 1000,
onFailed() {
console.error('sse重连失败.');
console.error('sse重连失败.')
},
retries: 3,
},
});
})
watch(data, (message) => {
if (!message) return;
console.log(`接收到消息: ${message}`);
if (!message) return
console.log(`接收到消息: ${message}`)
try {
// 尝试解析JSON
const obj = JSON.parse(message);
const obj = JSON.parse(message)
// 检查解析结果是否为对象且不为null
if (obj.getType() ==="yvjin"){
sseList.value.join(message)
if (obj.type === "yvjin") {
sseList.value.push(message)
}
// 仪表数据
if (obj.type === "meter") {
sseList.value.push(message)
}
} catch (e) {
notification.success({
description: message,
duration: 3,
message: $t('component.notice.received'),
});
})
notificationList.value.unshift({
// avatar: `https://api.multiavatar.com/${random(0, 10_000)}.png`, 随机头像
@@ -87,12 +92,12 @@ export const useNotifyStore = defineStore(
message,
title: $t('component.notice.title'),
userId: userId.value,
});
})
// 需要手动置空 vue3在值相同时不会触发watch
data.value = null;
data.value = null
}
});
})
}
/**
@@ -102,10 +107,10 @@ export const useNotifyStore = defineStore(
notificationList.value
.filter((item) => item.userId === userId.value)
.forEach((item) => {
item.isRead = true;
});
item.isRead = true
})
}
function getsseList(){
function getsseList() {
console.log(sseList.value)
return sseList.value
}
@@ -115,7 +120,7 @@ export const useNotifyStore = defineStore(
* @param item 通知
*/
function setRead(item: NotificationItem) {
!item.isRead && (item.isRead = true);
!item.isRead && (item.isRead = true)
}
/**
@@ -124,7 +129,7 @@ export const useNotifyStore = defineStore(
function clearAllMessage() {
notificationList.value = notificationList.value.filter(
(item) => item.userId !== userId.value,
);
)
}
/**
@@ -141,7 +146,7 @@ export const useNotifyStore = defineStore(
notificationList.value
.filter((item) => item.userId === userId.value)
.some((item) => !item.isRead),
);
)
return {
$reset,
@@ -154,11 +159,11 @@ export const useNotifyStore = defineStore(
setRead,
showDot,
startListeningMessage,
};
}
},
{
persist: {
pick: ['notificationList'],
},
},
);
)

View File

@@ -2,12 +2,46 @@
import type { EchartsUIType } from '@vben/plugins/echarts';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import { onMounted, ref, defineExpose } from 'vue';
import { meterRecordTrend } from '#/api/property/energyManagement/meterRecord';
import dayjs from 'dayjs';
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
// 添加日期选择相关逻辑
const selectedDate = ref<string>(dayjs().format('YYYY-MM'));
// 获取当月天数
const getDaysInMonth = (date: any) => {
const year = parseInt(date.split('-')[0]);
const month = parseInt(date.split('-')[1]);
const daysInMonth = new Date(year, month, 0).getDate();
return Array.from({ length: daysInMonth }, (_, i) => i + 1);
};
const getMeterRecordTrend = async (selectedDate: any) => {
const res = await meterRecordTrend({
day: dayjs().format('YYYY-MM-DD'),
month: selectedDate.value,
year: dayjs().format('YYYY'),
meterType: 1,
meterId: null,
floorId: null,
});
onMounted(() => {
// 处理返回的数据
const chartData = res.day.nowMonth.data || [];
// 创建一个映射,将日期和数值对应起来
const dataMap: Record<string, string> = {};
chartData.forEach(([day, value]: [string, string]) => {
dataMap[day] = value;
});
// 获取当月所有日期
const days = getDaysInMonth(selectedDate.value);
// 为没有数据的日期填充0构建完整的数据数组
const seriesData = days.map((day) => {
return parseFloat(dataMap[day.toString()] || '0');
});
renderEcharts({
grid: {
bottom: 0,
@@ -19,29 +53,13 @@ onMounted(() => {
series: [
{
areaStyle: {},
data: [
111, 2000, 6000, 16_000, 33_333, 55_555, 64_000, 33_333, 18_000,
36_000, 70_000, 42_444, 23_222, 13_000, 8000, 4000, 1200, 333, 222,
111,
],
data: seriesData,
itemStyle: {
color: '#5ab1ef',
},
smooth: true,
type: 'line',
},
{
areaStyle: {},
data: [
33, 66, 88, 333, 3333, 6200, 20_000, 3000, 1200, 13_000, 22_000,
11_000, 2221, 1201, 390, 198, 60, 30, 22, 11,
],
itemStyle: {
color: '#019680',
},
smooth: true,
type: 'line',
},
],
tooltip: {
axisPointer: {
@@ -65,7 +83,7 @@ onMounted(() => {
show: false,
},
boundaryGap: false,
data: Array.from({ length: 18 }).map((_item, index) => `${index + 6}:00`),
data: days,
splitLine: {
lineStyle: {
type: 'solid',
@@ -80,7 +98,7 @@ onMounted(() => {
axisTick: {
show: false,
},
max: 80_000,
// max: 800,
splitArea: {
show: true,
},
@@ -89,9 +107,18 @@ onMounted(() => {
},
],
});
};
onMounted(async () => {
getMeterRecordTrend(selectedDate);
});
// 暴露方法给父组件调用
defineExpose({
getMeterRecordTrend,
});
</script>
<template>
<EchartsUI ref="chartRef" />
<div>
<EchartsUI ref="chartRef" />
</div>
</template>

View File

@@ -2,77 +2,79 @@
import type { EchartsUIType } from '@vben/plugins/echarts';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import { watch, onMounted, ref, defineProps, computed } from 'vue';
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const props = defineProps<{
statisticsData?: any[]; // 根据实际数据结构调整类型
}>();
// 在组件顶层定义 computed 属性
const chartData = computed(() => {
if (!props.statisticsData) return [];
return props.statisticsData.map((item) => ({
name: item.typeName,
value: item.total,
}));
});
onMounted(() => {
// 监听数据变化,重新渲染图表
watch(
() => props.statisticsData,
(newData) => {
if (newData) {
updateChart();
}
},
{ immediate: true },
);
// 封装图表更新逻辑
const updateChart = () => {
renderEcharts({
legend: {
bottom: 0,
data: ['访问', '趋势'],
},
radar: {
indicator: [
{
name: '网页',
},
{
name: '移动端',
},
{
name: 'Ipad',
},
{
name: '客户端',
},
{
name: '第三方',
},
{
name: '其它',
},
],
radius: '60%',
splitNumber: 8,
bottom: '2%',
left: 'center',
},
series: [
{
areaStyle: {
opacity: 1,
shadowBlur: 0,
shadowColor: 'rgba(0,0,0,.2)',
shadowOffsetX: 0,
shadowOffsetY: 10,
animationDelay() {
return Math.random() * 100;
},
data: [
{
itemStyle: {
color: '#b6a2de',
},
name: '访问',
value: [90, 50, 86, 40, 50, 20],
animationEasing: 'exponentialInOut',
animationType: 'scale',
avoidLabelOverlap: false,
color: ['#5ab1ef', '#b6a2de', '#67e0e3', '#2ec7c9'],
data: chartData.value,
emphasis: {
label: {
fontSize: '12',
fontWeight: 'bold',
show: true,
},
{
itemStyle: {
color: '#5ab1ef',
},
name: '趋势',
value: [70, 75, 70, 76, 20, 85],
},
],
itemStyle: {
// borderColor: '#fff',
borderRadius: 10,
borderWidth: 2,
},
symbolSize: 0,
type: 'radar',
label: {
position: 'center',
show: false,
},
labelLine: {
show: false,
},
name: '预警类型',
type: 'pie',
},
],
tooltip: {},
tooltip: {
trigger: 'item',
},
});
};
onMounted(() => {
// 组件挂载时尝试更新图表
if (props.statisticsData) {
updateChart();
}
});
</script>

View File

@@ -2,12 +2,34 @@
import type { EchartsUIType } from '@vben/plugins/echarts';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import { watch, onMounted, ref, defineProps, computed } from 'vue';
const props = defineProps<{
workData?: any[]; // 根据实际数据结构调整类型
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const chartData = computed(() => {
if (!props.workData) return [];
return props.workData.map((item) => ({
name: item.type,
value: item.quantity,
}));
});
onMounted(() => {
// 监听数据变化,重新渲染图表
watch(
() => props.workData,
(newData) => {
if (newData) {
updateChart();
}
},
{ immediate: true },
);
// 封装图表更新逻辑
const updateChart = () => {
renderEcharts({
series: [
{
@@ -18,15 +40,8 @@ onMounted(() => {
animationType: 'scale',
center: ['50%', '50%'],
color: ['#5ab1ef', '#b6a2de', '#67e0e3', '#2ec7c9'],
data: [
{ name: '外包', value: 500 },
{ name: '定制', value: 310 },
{ name: '技术支持', value: 274 },
{ name: '远程', value: 400 },
].sort((a, b) => {
return a.value - b.value;
}),
name: '商业占比',
data: chartData.value,
name: '工单类型',
radius: '80%',
roseType: 'radius',
type: 'pie',
@@ -37,6 +52,13 @@ onMounted(() => {
trigger: 'item',
},
});
};
onMounted(() => {
// 组件挂载时尝试更新图表
if (props.workData) {
updateChart();
}
});
</script>

View File

@@ -2,12 +2,41 @@
import type { EchartsUIType } from '@vben/plugins/echarts';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import { watch, onMounted, ref, defineProps, computed } from 'vue';
const props = defineProps<{
statusData?: any[]; // 根据实际数据结构调整类型
}>();
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const chartData = computed(() => {
if (!props.statusData) return [];
return props.statusData.map((item) => ({
name:
item.state == 0
? '使用中'
: item.state == 1
? '停用中'
: item.state == 2
? '已报废'
: '闲置中',
value: item.quantity,
}));
});
onMounted(() => {
// 监听数据变化,重新渲染图表
watch(
() => props.statusData,
(newData) => {
if (newData) {
updateChart();
}
},
{ immediate: true },
);
// 封装图表更新逻辑
const updateChart = () => {
renderEcharts({
legend: {
bottom: '2%',
@@ -22,12 +51,7 @@ onMounted(() => {
animationType: 'scale',
avoidLabelOverlap: false,
color: ['#5ab1ef', '#b6a2de', '#67e0e3', '#2ec7c9'],
data: [
{ name: '搜索引擎', value: 1048 },
{ name: '直接访问', value: 735 },
{ name: '邮件营销', value: 580 },
// { name: '联盟广告', value: 484 },
],
data: chartData.value,
emphasis: {
label: {
fontSize: '12',
@@ -47,7 +71,7 @@ onMounted(() => {
labelLine: {
show: false,
},
name: '访问来源',
name: '设备状态',
radius: ['40%', '65%'],
type: 'pie',
},
@@ -56,6 +80,14 @@ onMounted(() => {
trigger: 'item',
},
});
};
onMounted(() => {
console.log('Child component mounted, props:', props.statusData);
// 组件挂载时尝试更新图表
if (props.statusData) {
updateChart();
}
});
</script>

View File

@@ -1,12 +1,5 @@
<script lang="ts" setup>
import type { AnalysisOverviewItem } from '@vben/common-ui';
import type { TabOption } from '@vben/types';
import {
AnalysisChartCard,
AnalysisChartsTabs,
AnalysisOverview,
} from '@vben/common-ui';
import { AnalysisChartCard, AnalysisOverview } from '@vben/common-ui';
import {
SvgBellIcon,
SvgCakeIcon,
@@ -18,72 +11,192 @@ import AnalyticsTrends from './analytics-trends.vue';
import AnalyticsVisitsData from './analytics-visits-data.vue';
import AnalyticsVisitsSales from './analytics-visits-sales.vue';
import AnalyticsVisitsSource from './analytics-visits-source.vue';
import AnalyticsVisits from './analytics-visits.vue';
const overviewItems: AnalysisOverviewItem[] = [
import { ref, onMounted } from 'vue';
import { DatePicker } from 'ant-design-vue';
import { Radio } from 'ant-design-vue';
import {
getIndexCount,
getStatisticsCurrDay,
getStatistics,
} from '#/api/analytics/index';
const overviewItems = ref<any[]>([
{
icon: SvgCardIcon,
title: '用户量',
totalTitle: '总用户量',
totalValue: 78,
value: 15,
title: '今日访客量',
totalTitle: '总访客量',
totalValue: 0,
value: 0,
},
{
icon: SvgCakeIcon,
title: '访问量',
totalTitle: '总访问量',
totalValue: 2_278,
value: 461,
title: '今日车流量',
totalTitle: '总车流量',
totalValue: 0,
value: 0,
},
{
icon: SvgDownloadIcon,
title: '下载量',
totalTitle: '总下载量',
totalValue: 17,
value: 2,
title: '今日预警数量',
totalTitle: '总预警量',
totalValue: 0,
value: 0,
},
{
icon: SvgBellIcon,
title: '使用量',
totalTitle: '总使用量',
totalValue: 6_652,
value: 3_739,
title: '今日工单数量',
totalTitle: '总工单量',
totalValue: 0,
value: 0,
},
];
]);
const chartTabs: TabOption[] = [
{
label: '流量趋势',
value: 'trends',
},
{
label: '月访问量',
value: 'visits',
},
];
//tab选择
const timeUnit = ref(1);
// 月份选择
const selectedDate = ref<any>(null);
const paramDate = ref(null);
const statisticsList = ref<any>();
const workList = ref<any>();
const statusDataList = ref<any>();
const analyticsTrendsRef = ref<InstanceType<typeof AnalyticsTrends> | null>(
null,
);
const handleDateChange = (date: any) => {
paramDate.value = date.format('YYYY-MM');
// 调用子组件的方法
if (
analyticsTrendsRef.value &&
analyticsTrendsRef.value.getMeterRecordTrend
) {
analyticsTrendsRef.value.getMeterRecordTrend(paramDate);
}
};
// 工单数量
async function getIndex() {
const res = await getIndexCount();
overviewItems.value[3].value = res.workOrdersToday;
overviewItems.value[3].totalValue = res.workOrdersTotal;
overviewItems.value[0].value = res.visitorsToday;
overviewItems.value[0].totalValue = res.visitorsTotal;
workList.value = res.satisfactionChartList;
statusDataList.value = res.statusChartVoChartList;
}
// 车流量
async function getCarCount() {
const startDate = new Date();
startDate.setHours(0, 0, 0, 0);
const endDate = new Date();
endDate.setHours(23, 59, 59, 999);
// 转换为正确的 ISO 格式,考虑时区偏移
const toLocalISOString = (date: Date) => {
const timezoneOffset = date.getTimezoneOffset() * 60000; // 转换为毫秒
const localTime = new Date(date.getTime() - timezoneOffset);
return localTime.toISOString().slice(0, -1) + 'Z';
};
const response = await fetch(
'https://server.cqnctc.com:6081/web/thirdParty/queryInParkData',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/plain, */*',
},
body: JSON.stringify({
pageReq: {
pageNum: 1,
pageSize: 10,
},
plNos: ['PFN000000012', 'PFN000000022', 'PFN000000025'],
parkInBeginTime: toLocalISOString(startDate),
parkInEndTime: toLocalISOString(endDate),
orgId: 10012,
parkOrderTypes: [100, 200, 201, 300, 500],
terminalSource: 50,
remark: '',
}),
},
);
const result = await response.json();
overviewItems.value[1].value = result.data.todayCount;
overviewItems.value[1].totalValue = result.data.allCount;
}
// 预警
async function getStatisticsCurrDayCount() {
const res = await getStatisticsCurrDay();
let total = 0;
for (const item of res) {
total += item.total;
}
overviewItems.value[2].value = total;
}
async function getStatisticsCount() {
const res = await getStatistics();
let total = 0;
for (const item of res) {
total += item.total;
}
overviewItems.value[2].totalValue = total;
statisticsList.value = res;
}
// 电量
onMounted(() => {
getIndex();
getCarCount();
getStatisticsCurrDayCount();
getStatisticsCount();
});
</script>
<template>
<div class="p-5">
<AnalysisOverview :items="overviewItems" />
<AnalysisChartsTabs :tabs="chartTabs" class="mt-5">
<!-- <AnalysisChartsTabs :tabs="chartTabs" class="mt-5">
<template #trends>
<AnalyticsTrends />
</template>
<template #visits>
<AnalyticsVisits />
</template>
</AnalysisChartsTabs>
</AnalysisChartsTabs> -->
<div class="mt-5 rounded-lg bg-white">
<div
class="flex items-center justify-between p-5"
style="width: 100%; height: 100px"
>
<Radio.Group v-model:value="timeUnit" size="large">
<Radio.Button value="1">电量</Radio.Button>
</Radio.Group>
<DatePicker
v-model:value="selectedDate"
picker="month"
placeholder="请选择月份"
style="width: 150px"
@change="handleDateChange"
/>
</div>
<div v-if="timeUnit == 1">
<AnalyticsTrends
ref="analyticsTrendsRef"
:selected-date="selectedDate"
/>
</div>
</div>
<div class="mt-5 w-full md:flex">
<AnalysisChartCard class="mt-5 md:mr-4 md:mt-0 md:w-1/3" title="访问数量">
<AnalyticsVisitsData />
<AnalysisChartCard class="mt-5 md:mr-4 md:mt-0 md:w-1/3" title="预警类型">
<AnalyticsVisitsData :statisticsData="statisticsList" />
</AnalysisChartCard>
<AnalysisChartCard class="mt-5 md:mr-4 md:mt-0 md:w-1/3" title="访问来源">
<AnalyticsVisitsSource />
<AnalysisChartCard
class="mt-5 md:mr-4 md:mt-0 md:w-1/3"
title="设备使用状态"
>
<AnalyticsVisitsSource :statusData="statusDataList" />
</AnalysisChartCard>
<AnalysisChartCard class="mt-5 md:mt-0 md:w-1/3" title="访问来源">
<AnalyticsVisitsSales />
<AnalysisChartCard class="mt-5 md:mt-0 md:w-1/3" title="工单类型">
<AnalyticsVisitsSales :workData="workList" />
</AnalysisChartCard>
</div>
</div>

View File

@@ -23,7 +23,7 @@ const [BasicForm, formApi] = useVbenForm({
// 默认占满两列
formItemClass: 'col-span-1',
// 默认label宽度 px
labelWidth: 80,
labelWidth: 90,
// 通用配置项 会影响到所有表单项
componentProps: {
class: 'w-full',

View File

@@ -13,7 +13,7 @@ let arr: CommunityVO[] = [];
const communitySelect: VbenFormSchema = {
component: 'ApiSelect',
fieldName: 'communityId',
label: '社区',
label: '园区名称',
componentProps: {
resultField: 'list', // 根据API返回结构调整
labelField: 'communityName',
@@ -45,6 +45,10 @@ export const querySchema: FormSchemaGetter = () => [
// export const columns: () => VxeGridProps['columns'] = () => [
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 },
{
title: '园区名称',
field: 'communityText',
},
{
title: '建筑名称',
field: 'buildingName',
@@ -69,9 +73,21 @@ export const columns: VxeGridProps['columns'] = [
title: '竣工日期',
field: 'completionDate',
},
// {
// title: '地址',
// field: 'addr',
// },
{
title: '地址',
field: 'addr',
title: '建筑面积(㎡)',
field: 'area',
},
{
title: '套内面积(㎡)',
field: 'insideInArea',
},
{
title: '公摊面积(㎡)',
field: 'sharedArea',
},
{
field: 'action',
@@ -107,8 +123,8 @@ export const modalSchema: FormSchemaGetter = () => [
fieldName: 'floorCount',
component: 'InputNumber',
componentProps: {
min:1,
precision:0,
min: 1,
precision: 0,
},
},
{
@@ -116,8 +132,8 @@ export const modalSchema: FormSchemaGetter = () => [
fieldName: 'unitCount',
component: 'InputNumber',
componentProps: {
min:1,
precision:0,
min: 1,
precision: 0,
},
},
{
@@ -134,8 +150,8 @@ export const modalSchema: FormSchemaGetter = () => [
fieldName: 'elevatorCount',
component: 'InputNumber',
componentProps: {
min:0,
precision:0,
min: 0,
precision: 0,
},
},
{
@@ -147,10 +163,37 @@ export const modalSchema: FormSchemaGetter = () => [
valueFormat: 'YYYY-MM-DD',
},
},
// {
// label: '地址',
// fieldName: 'addr',
// component: 'Input',
// rules: 'required',
// },
{
label: '地址',
fieldName: 'addr',
component: 'Input',
rules: 'required',
label: '建筑面积(㎡)',
fieldName: 'area',
component: 'InputNumber',
componentProps: {
min: 0,
precision: 2,
},
},
{
label: '套内面积(㎡)',
fieldName: 'insideInArea',
component: 'InputNumber',
componentProps: {
min: 0,
precision: 2,
},
},
{
label: '公摊面积(㎡)',
fieldName: 'sharedArea',
component: 'InputNumber',
componentProps: {
min: 0,
precision: 2,
},
},
];

View File

@@ -1,19 +1,24 @@
import type {FormSchemaGetter} from '#/adapter/form';
import type {VxeGridProps} from '#/adapter/vxe-table';
import {getPopupContainer} from '@vben/utils';
import {getDictOptions} from '#/utils/dict';
import {DictEnum} from '@vben/constants';
import {renderDict} from "#/utils/render";
import type { FormSchemaGetter } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { getPopupContainer } from '@vben/utils';
import { getDictOptions } from '#/utils/dict';
import { DictEnum } from '@vben/constants';
import { renderDict } from '#/utils/render';
export const querySchema: FormSchemaGetter = () => [
// {
// component: 'Select',
// componentProps: {
// getPopupContainer,
// options: getDictOptions(DictEnum.wy_sqlx),
// },
// fieldName: 'communityType',
// label: '园区类型',
// },
{
component: 'Select',
componentProps: {
getPopupContainer,
options: getDictOptions(DictEnum.wy_sqlx),
},
fieldName: 'communityType',
label: '社区类型',
component: 'Input',
fieldName: 'communityName',
label: '园区名称',
},
{
component: 'Input',
@@ -25,20 +30,20 @@ export const querySchema: FormSchemaGetter = () => [
// 需要使用i18n注意这里要改成getter形式 否则切换语言不会刷新
// export const columns: () => VxeGridProps['columns'] = () => [
export const columns: VxeGridProps['columns'] = [
{type: 'checkbox', width: 60},
{ type: 'checkbox', width: 60 },
{
title: '区名称',
title: '区名称',
field: 'communityName',
},
{
title: '社区类型',
field: 'communityType',
slots: {
default: ({row}) => {
return renderDict(row.communityType, DictEnum.wy_sqlx)
}
},
},
// {
// title: '园区类型',
// field: 'communityType',
// slots: {
// default: ({row}) => {
// return renderDict(row.communityType, DictEnum.wy_sqlx)
// }
// },
// },
{
title: '城市',
field: 'cityFullName',
@@ -72,13 +77,13 @@ export const columns: VxeGridProps['columns'] = [
field: 'contactPhone',
},
{
title: '区描述',
title: '区描述',
field: 'description',
},
{
field: 'action',
fixed: 'right',
slots: {default: 'action'},
slots: { default: 'action' },
title: '操作',
width: 180,
},
@@ -95,21 +100,21 @@ export const modalSchema: FormSchemaGetter = () => [
},
},
{
label: '区名称',
label: '区名称',
fieldName: 'communityName',
component: 'Input',
rules: 'required',
},
{
label: '社区类型',
fieldName: 'communityType',
component: 'Select',
componentProps: {
getPopupContainer,
options: getDictOptions(DictEnum.wy_sqlx),
},
rules: 'selectRequired',
},
// {
// label: '园区类型',
// fieldName: 'communityType',
// component: 'Select',
// componentProps: {
// getPopupContainer,
// options: getDictOptions(DictEnum.wy_sqlx),
// },
// rules: 'selectRequired',
// },
{
component: 'TreeSelect',
fieldName: 'cityFullCode',
@@ -177,12 +182,12 @@ export const modalSchema: FormSchemaGetter = () => [
component: 'Input',
},
{
label: '区描述',
label: '区描述',
fieldName: 'description',
component: 'Textarea',
},
{
label: '社区/园区图片',
label: '园区图片',
fieldName: 'img',
component: 'ImageUpload',
componentProps: {

View File

@@ -5,9 +5,12 @@ import {renderDict} from "#/utils/render";
export const querySchema: FormSchemaGetter = () => [
{
component: 'Input',
fieldName: 'searchValue',
label: '搜索值',
label: '费用类型',
fieldName: 'costType',
component: 'Select',
componentProps: () => ({
options: getDictOptions('wy_cbfylx'),
}),
},
];

View File

@@ -63,11 +63,6 @@ export const columns: VxeGridProps['columns'] = [
},
width:120
},
{
title: '客服电话',
field: 'serviceName',
width:120
},
{
field: 'action',
fixed: 'right',
@@ -122,10 +117,10 @@ export const modalSchema: FormSchemaGetter = () => [
},
},
{
label: '客服电话',
fieldName: 'serviceName',
component: 'Input',
rules: 'required',
label: '反馈单位',
fieldName: 'feedbackUnit',
component: 'Select',
rules: 'selectRequired',
},
{
label: '反馈内容',

View File

@@ -18,6 +18,7 @@ import type {FeedbacksVO} from "#/api/property/customerService/feedbacks/model";
import {
workOrdersTypeTree
} from "#/api/property/businessManagement/workOrdersType";
import {resident_unitList} from "#/api/property/resident/unit";
const emit = defineEmits<{ reload: [] }>();
@@ -66,6 +67,7 @@ const [BasicModal, modalApi] = useVbenModal({
const {id} = modalApi.getData() as { id?: number | string };
isUpdate.value = !!id;
await initWorkOrderTypeOption()
await initUnitOption()
if (isUpdate.value && id) {
const record = await feedbacksInfo(id);
detail.value = record
@@ -127,6 +129,25 @@ async function initWorkOrderTypeOption() {
]);
}
async function initUnitOption() {
let params = {
pageSize: 1000,
pageNum: 1
}
const res = await resident_unitList(params)
formApi.updateSchema([{
componentProps: () => ({
options: res.rows,
//开启搜索
showSearch: true,
//数据过滤字段
optionFilterProp: 'name',
//下拉显示字段
fieldNames: {label: 'name', value: 'id'},
}),
fieldName: 'feedbackUnit',
}])
}
</script>
<template>

View File

@@ -1,14 +1,19 @@
<script setup lang="ts">
import type { PropType } from "vue";
import { onMounted, ref } from "vue";
import { handleNode } from "@vben/utils";
import { Empty, Skeleton, Tree } from "ant-design-vue";
import { queryTree } from "#/api/property/energyManagement/meterInfo";
import type { TreeNode } from "#/api/common";
import type { PropType } from 'vue';
import { onMounted, ref } from 'vue';
import { handleNode } from '@vben/utils';
import { Empty, Skeleton, Tree } from 'ant-design-vue';
import { queryTree } from '#/api/property/energyManagement/meterInfo';
import type { TreeNode } from '#/api/common';
defineOptions({ inheritAttrs: false });
withDefaults(defineProps<{ showSearch?: boolean }>(), { showSearch: true });
const props = defineProps({
isMeter: {
type: Boolean,
default: true,
},
});
const emit = defineEmits<{
/**
@@ -23,13 +28,13 @@ const emit = defineEmits<{
select: [selectedKeys: string[], info: any];
}>();
const selectTreeId = defineModel("selectTreeId", {
const selectTreeId = defineModel('selectTreeId', {
type: Array as PropType<string[]>,
});
const searchValue = defineModel("searchValue", {
const searchValue = defineModel('searchValue', {
type: String,
default: "",
default: '',
});
const treeArray = ref<TreeNode[]>([]);
@@ -38,8 +43,8 @@ const showTreeSkeleton = ref<boolean>(true);
async function loadTree() {
showTreeSkeleton.value = true;
searchValue.value = "";
const ret = await queryTree(1);
searchValue.value = '';
const ret = await queryTree({ meterType: 1, isMeter: props.isMeter });
handleNode(ret, 3);
treeArray.value = ret;
showTreeSkeleton.value = false;
@@ -62,33 +67,46 @@ onMounted(loadTree);
<template>
<div :class="$attrs.class">
<Skeleton :loading="showTreeSkeleton"
:paragraph="{ rows: 8 }"
active
class="p-[8px] flex-1 min-h-0">
<div class="bg-background flex h-full flex-col overflow-y-auto rounded-lg">
<Skeleton
:loading="showTreeSkeleton"
:paragraph="{ rows: 8 }"
active
class="min-h-0 flex-1 p-[8px]"
>
<div
class="bg-background flex h-full flex-col overflow-y-auto rounded-lg"
>
<div class="h-full overflow-x-hidden px-[8px]">
<Tree v-bind="$attrs"
v-if="treeArray.length > 0"
v-model:selected-keys="selectTreeId"
:show-line="{ showLeafIcon: false }"
:tree-data="treeArray"
:virtual="false"
default-expand-all
@select="(selectedKeys, info) => $emit('select', selectedKeys, info)">
<Tree
v-bind="$attrs"
v-if="treeArray.length > 0"
v-model:selected-keys="selectTreeId"
:show-line="{ showLeafIcon: false }"
:tree-data="treeArray"
:virtual="false"
default-expand-all
@select="
(selectedKeys, info) => $emit('select', selectedKeys, info)
"
>
<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) }}
{{
label.substring(
label.indexOf(searchValue) + searchValue.length,
)
}}
</span>
<span v-else>{{ label }}</span>
</template>
</Tree>
<div v-else
class="mt-5">
<Empty :image="Empty.PRESENTED_IMAGE_SIMPLE"
description="暂无数据" />
<div v-else class="mt-5">
<Empty
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="暂无数据"
/>
</div>
</div>
</div>

View File

@@ -1,516 +1,289 @@
<script setup lang="ts">
import { RadioGroup, RadioButton, message } from 'ant-design-vue';
import { Page } from '@vben/common-ui';
import { ref, onMounted, onBeforeUnmount, reactive } from 'vue';
import * as echarts from 'echarts';
import type { ECharts, EChartsOption } from 'echarts';
import { useNotifyStore } from '#/store';
import { useAccessStore } from '@vben/stores';
import FloorTree from '../components/floor-tree.vue';
import dayjs from 'dayjs';
import { meterRecordTrend } from '#/api/property/energyManagement/meterRecord';
import { BackTop, message, Spin } from 'ant-design-vue';
import { Page, VbenCountToAnimator } from '@vben/common-ui';
import { ref, onMounted, onBeforeUnmount, reactive, watch } from 'vue';
import { currentReading } from '#/api/property/energyManagement/meterInfo';
// 左边楼层用
const selectFloorId = ref<string[]>([]);
const notifyStore = useNotifyStore();
const readingData = ref<any>({});
const readingTime = ref('');
const readingLoading = ref(false);
const chainData = reactive({
todayEnergy: '231.78',
yesterdaySamePeriodEnergy: '269.56',
dayTrendPercentage: '-14.02%',
dayTrendValue: '-37.78',
currentMonthEnergy: '18758.39',
lastMonthSamePeriodEnergy: '--',
monthTrendPercentage: '--',
monthTrendValue: '--',
currentYearEnergy: '18758.39',
lastYearSamePeriodEnergy: '--',
yearTrendPercentage: '--',
yearTrendValue: '--',
onMounted(() => {
notifyStore.startListeningMessage();
// 监听sseList的变化
watch(
() => notifyStore.sseList,
(val) => {
const latestMessage = val[val.length - 1];
try {
// 尝试解析消息内容
const parsedMessage = JSON.parse(latestMessage);
console.log('收到sse消息:', parsedMessage);
if (parsedMessage.type === 'meter') {
// 根据消息内容执行相应操作
handleSSEMessage(parsedMessage);
}
} catch (e) {
console.log('收到非JSON消息:', latestMessage);
// 处理非JSON格式消息
}
},
{ deep: true },
);
});
const peakData = reactive({
todayPeakPower: '2961.08',
todayPeakTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
yesterdayPeakPower: '2993.89',
yesterdayPeakTime: dayjs().subtract(1, 'day').format('YYYY-MM-DD HH:mm:ss'),
});
const energyTrendTime = ref('1');
// 能耗趋势图表容器
const energyTrendChart = ref<HTMLElement | null>(null);
const energyTrendInstance = ref<ECharts | null>(null);
const energyTrendOption: EChartsOption = {
tooltip: {
trigger: 'item',
axisPointer: {
type: 'shadow',
},
},
xAxis: {
type: 'category',
data: [],
name: '时间',
},
yAxis: {
type: 'value',
name: 'kW.h',
},
series: [
{
data: [],
type: 'bar',
markPoint: {
data: [
{ type: 'max', name: 'Max' },
{ type: 'min', name: 'Min' },
],
},
},
],
};
const initEnergyTrendChart = () => {
if (energyTrendChart.value) {
// 销毁旧实例
energyTrendInstance.value?.dispose();
// 创建新实例
energyTrendInstance.value = echarts.init(energyTrendChart.value);
// 设置配置项
energyTrendInstance.value.setOption(energyTrendOption);
// buildingEnergyTrendData('1');
// 可选:添加窗口大小变化监听
const resizeHandler = () => {
energyTrendInstance.value?.resize();
};
window.addEventListener('resize', resizeHandler);
// 在组件卸载前移除监听
onBeforeUnmount(() => {
window.removeEventListener('resize', resizeHandler);
});
}
};
function buildingEnergyTrendData(val: string) {
if (trendData.value.hour == null) {
message.warning('请先选择楼层或电表!');
return;
}
const now = new Date();
let timeArr = [];
let valArr = [];
let name = '时间';
if (val == '1') {
const hour = now.getHours();
for (let i = 0; i < hour; i++) {
timeArr.push(i + ':00');
}
valArr = trendData.value.hour.today.data;
} else if (val == '2') {
const day = now.getDate();
for (let i = 1; i < day; i++) {
timeArr.push(i);
}
name = '日期';
valArr = trendData.value.day.nowMonth.data;
} else {
const month = now.getMonth() + 1;
for (let i = 1; i < month + 1; i++) {
timeArr.push(i);
}
name = '月份';
valArr = trendData.value.month.nowYear.data;
}
if (energyTrendInstance.value) {
energyTrendInstance.value.setOption({
xAxis: { data: timeArr, name },
series: [{ data: valArr }],
});
function handleSSEMessage(data: any) {
if (data.data.length === 0) {
message.warn('当前楼层暂无电表!');
}
readingData.value = data.data;
readingTime.value = data.readingTime;
readingLoading.value = false;
}
//日用电率
const powerCurveChart = ref<HTMLElement | null>(null);
const powerCurveInstance = ref<ECharts | null>(null);
const powerCurveOption: EChartsOption = {
tooltip: {
trigger: 'item',
axisPointer: {
type: 'cross',
crossStyle: {
color: '#999',
},
},
},
toolbox: {
feature: {
magicType: { show: true, type: ['line', 'bar'] },
restore: { show: true },
},
},
legend: {
data: ['今日', '昨日'],
},
xAxis: [
{
type: 'category',
data: [
'0:00',
'1:00',
'2:00',
'3:00',
'4:00',
'5:00',
'6:00',
'7:00',
'8:00',
'9:00',
'10:00',
'11:00',
'12:00',
'13:00',
'14:00',
'15:00',
'16:00',
'17:00',
'18:00',
'19:00',
'20:00',
'21:00',
'22:00',
'23:00',
],
axisPointer: {
type: 'shadow',
},
name: '时',
},
],
yAxis: [
{
type: 'value',
name: 'kW',
},
],
series: [
{
name: '昨日',
type: 'line',
smooth: true,
data: [],
markPoint: {
data: [{ type: 'max' }, { type: 'min' }],
},
itemStyle: { color: '#cbb0e3' }, // 数据点颜色
lineStyle: { color: '#cbb0e3' }, // 线条颜色
},
],
};
const initPowerCurveChart = () => {
if (powerCurveChart.value) {
// 销毁旧实例
powerCurveInstance.value?.dispose();
// 创建新实例
powerCurveInstance.value = echarts.init(powerCurveChart.value);
// 设置配置项
powerCurveInstance.value.setOption(powerCurveOption);
// 可选:添加窗口大小变化监听
const resizeHandler = () => {
powerCurveInstance.value?.resize();
};
window.addEventListener('resize', resizeHandler);
// 在组件卸载前移除监听
onBeforeUnmount(() => {
window.removeEventListener('resize', resizeHandler);
});
}
};
// 组件挂载后初始化图表
onMounted(() => {
initEnergyTrendChart();
initPowerCurveChart();
});
// 组件卸载前销毁图表实例
onBeforeUnmount(() => {
energyTrendInstance.value?.dispose();
powerCurveInstance.value?.dispose();
// 关闭页面,发送请求停止心跳
currentReading({ meterType: 0, floorId: 0 });
});
const trendData = ref<any>({});
async function handleSelectFloor(selectedKeys, info) {
const now = new Date();
// 获取年、月、日
const year = now.getFullYear();
// 月份从0开始所以要+1并格式化为两位数
const month = String(now.getMonth() + 1).padStart(2, '0');
// 日期格式化为两位数
const day = String(now.getDate()).padStart(2, '0');
let data = {
day: year + '-' + month + '-' + day,
month: year + '-' + month,
year: year,
if (typeof selectedKeys[0] === 'undefined') {
return;
}
readingLoading.value = true;
await currentReading({
meterType: 1,
meterId: null,
floorId: null,
};
floorId: selectedKeys[0],
});
}
if (info.node.level == 3) {
data.floorId = selectedKeys[0];
} else {
data.meterId = selectedKeys[0];
}
const trend = await meterRecordTrend(data);
trendData.value = trend;
// 趋势曲线
let timeArr = [];
let valArr = [];
let name = '时间';
if (energyTrendTime.value == '1') {
const hour = now.getHours();
for (let i = 0; i < hour; i++) {
timeArr.push(i + ':00');
}
valArr = trend.hour.today.data;
} else if (energyTrendTime.value == '2') {
const day = now.getDate();
for (let i = 1; i < day; i++) {
timeArr.push(i);
}
name = '日期';
valArr = trend.day.nowMonth.data;
} else {
const month = now.getMonth() + 1;
for (let i = 1; i < month + 1; i++) {
timeArr.push(i);
}
name = '月份';
valArr = trend.month.nowYear.data;
}
if (energyTrendInstance.value) {
energyTrendInstance.value.setOption({
xAxis: { data: timeArr, name },
series: [{ data: valArr }],
});
}
if (powerCurveInstance.value) {
powerCurveInstance.value.setOption({
series: [
{
name: '今日',
type: 'line',
smooth: true,
data: trend.hour.today.data,
markPoint: {
data: [{ type: 'max' }, { type: 'min' }],
},
},
{
name: '昨日',
type: 'line',
smooth: true,
data: trend.hour.yesterday.data,
markPoint: {
data: [{ type: 'max' }, { type: 'min' }],
},
},
],
});
}
function targetFn() {
return document.getElementById('right-panel');
}
</script>
<template>
<Page :auto-content-height="true">
<div class="flex h-full gap-[8px]">
<FloorTree class="w-[260px]" @select="handleSelectFloor"></FloorTree>
<div class="flex-1 overflow-hidden">
<div class="row">
<div class="energy-trend-container">
<div class="energy-trend-top">
<div class="section-header">
<div class="header-title">能耗趋势</div>
<FloorTree
:isMeter="false"
class="w-[260px]"
@select="handleSelectFloor"
></FloorTree>
<div class="flex-1" id="right-panel">
<Spin :spinning="readingLoading" size="large" style="height: 100px">
<div class="cards-container">
<div v-for="item in readingData" class="meterInfo-card">
<h2>
{{ item.meterName }}
</h2>
<div class="meterInfo-reading">
<p>
<VbenCountToAnimator
:end-val="item.initReading"
:decimals="2"
prefix=""
/>
</p>
<div></div>
</div>
<div class="meterInfo-list">
<ul>
<li>
<span>采集时间</span>
<span>{{ readingTime }}</span>
</li>
<li>
<span>设备位置</span>
<span>{{ item.installLocation }}</span>
</li>
</ul>
</div>
<div class="button-get-plan">
<a
v-if="item.communicationState !== 0"
style="background: #6bb1e3"
>
<span>在线</span>
</a>
<a v-else style="background: orange">
<span>离线</span>
</a>
</div>
<RadioGroup
v-model:value="energyTrendTime"
button-style="solid"
size="small"
@change="buildingEnergyTrendData(energyTrendTime)"
>
<RadioButton value="1">当日</RadioButton>
<RadioButton value="2">当月</RadioButton>
<RadioButton value="3">当年</RadioButton>
</RadioGroup>
</div>
<div class="chart-placeholder" ref="energyTrendChart"></div>
</div>
</div>
<div class="row">
<div class="power-curve-container">
<div class="section-header">
<div class="header-title">平均电功率曲线</div>
</div>
<div class="power-chart" ref="powerCurveChart"></div>
</div>
<!-- <div class="power-peak-container">
<div class="section-header">
<div class="header-title">电功率峰值</div>
</div>
<div class="peak-item">
<p class="value">{{ peakData.todayPeakPower }}</p>
<p class="time">{{ peakData.todayPeakTime }}</p>
<div class="bottom-text">当日(kW)</div>
</div>
<div class="peak-item">
<p class="value">{{ peakData.yesterdayPeakPower }}</p>
<p class="time">{{ peakData.yesterdayPeakTime }}</p>
<div class="bottom-text">昨日(kW)</div>
</div>
</div> -->
</div>
</Spin>
</div>
<BackTop class="back-to-top" :target="targetFn"></BackTop>
</div>
</Page>
</template>
<style scoped>
.row {
display: flex;
gap: 1rem;
width: 100%;
}
.comparison-section-container {
flex: 2;
}
.energy-trend-container {
flex: 3;
}
.power-curve-container {
margin-top: 10px;
flex: 5;
}
.power-peak-container {
margin-top: 10px;
/* 右侧内容区域样式 */
.flex-1 {
flex: 1;
overflow-y: auto; /* 添加垂直滚动条 */
padding: 10px;
height: 100%; /* 确保高度占满父容器 */
box-sizing: border-box; /* 包括padding在高度计算中 */
}
.energy-trend-top {
/* 卡片容器样式 */
.cards-container {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
justify-content: flex-start;
gap: 20px; /* 使用gap替代margin控制间距 */
}
.section-header {
border-left: 4px solid #3671e8;
margin-bottom: 15px;
padding-left: 16px;
}
.header-title {
font-size: 16px;
color: #333;
}
.comparison-section-container,
.energy-trend-container,
.power-curve-container,
.power-peak-container {
.meterInfo-card {
background: #fff;
width: 15rem;
padding-left: 2rem;
padding-right: 2rem;
padding-top: 10px;
padding-bottom: 20px;
border-radius: 10px;
border-bottom: 4px solid #87ceeb;
box-shadow: 0 6px 30px rgba(173, 216, 230, 0.3);
font-family: 'Poppins', sans-serif;
height: 270px;
margin: 0 5px 20px 5px;
}
.meterInfo-card h2 {
margin-bottom: 15px;
font-size: 27px;
color: #5faee3;
font-weight: 600;
}
.meterInfo-card h2 span {
display: block;
margin-top: -4px;
color: #7eb8da;
font-size: 12px;
font-weight: 400;
}
.meterInfo-reading {
position: relative;
background: #f0f8ff;
width: 14.46rem;
margin-left: -0.65rem;
padding: 0.2rem 1.2rem;
border-radius: 5px 0 0 5px;
}
.meterInfo-reading p {
margin: 0;
padding-top: 0.4rem;
display: flex;
font-size: 1.9rem;
font-weight: 500;
color: #4a8cbb;
}
.meterInfo-reading p:before {
content: '';
margin-right: 5px;
font-size: 15px;
font-weight: 300;
}
.meterInfo-reading p:after {
content: '/ KW.H';
margin-left: 5px;
font-size: 15px;
font-weight: 300;
}
.meterInfo-reading div {
position: absolute;
bottom: -23px;
right: 0px;
width: 0;
height: 0;
border-top: 13px solid #87ceeb;
border-bottom: 10px solid transparent;
border-right: 13px solid transparent;
z-index: 1;
}
.meterInfo-list {
margin-top: 16px;
}
.meterInfo-list ul {
padding: 0;
font-size: 14px;
}
.meterInfo-list ul li {
color: #5a7d9a;
list-style: none;
margin-bottom: 0.2rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.meterInfo-list ul li svg {
width: 0.9rem;
fill: currentColor;
}
.meterInfo-list ul li span {
font-weight: 300;
white-space: nowrap;
}
.button-get-plan {
display: flex;
justify-content: center;
margin-top: 1.2rem;
}
.button-get-plan a {
display: flex;
justify-content: center;
align-items: center;
color: #fff;
padding: 10px 15px;
border-radius: 5px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 1rem;
text-decoration: none;
font-size: 0.8rem;
letter-spacing: 0.05rem;
font-weight: 500;
transition: all 0.3s ease;
}
.comparison-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 5px;
.button-get-plan a:hover {
transform: translateY(-3%);
box-shadow: 0 3px 10px rgba(123, 180, 220, 0.6); /* 浅蓝色阴影 */
background: #5fa0d0; /* 悬停时略深的蓝色 */
}
.comparison-item {
padding: 5px 10px;
border: 1px solid #e0e0e0;
text-align: center;
.button-get-plan .svg-rocket {
margin-right: 10px;
width: 0.9rem;
fill: currentColor;
}
.item-value {
font-size: 22px;
color: #333;
margin-bottom: 10px;
.back-to-top {
width: 50px;
height: 50px;
}
.item-title {
font-size: 12px;
color: #666;
}
.item-top {
font-size: 16px;
margin-bottom: 10px;
}
.item-percent {
color: #7fb926;
margin-bottom: 5px;
border-bottom: 1px solid #666;
}
.item-unit {
font-size: 12px;
color: #999;
margin-left: 3px;
}
.chart-placeholder {
height: 36vh;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.power-chart {
height: 38vh;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.peak-item {
padding: 15px 0 0 0;
border: 1px solid #3671e8;
text-align: center;
margin-bottom: 1rem;
.bottom-text {
background-color: #3671e8;
color: white;
line-height: 40px;
}
}
.peak-item .value {
font-size: 22px;
color: #333;
}
.peak-item .time {
font-size: 12px;
color: #999;
margin: 5px 0;
/* 返回顶部按钮 */
.back-to-top:hover {
background-color: #6bb1e3;
transform: translateY(-5px);
}
</style>

View File

@@ -266,6 +266,9 @@ async function handleSelectFloor(selectedKeys, info) {
{ type: 'min', name: 'Min' },
],
},
markLine: {
data: [{ type: 'average', name: 'Avg' }],
},
},
{
name: '当日',
@@ -277,6 +280,9 @@ async function handleSelectFloor(selectedKeys, info) {
{ type: 'min', name: 'Min' },
],
},
markLine: {
data: [{ type: 'average', name: 'Avg' }],
},
},
],
});
@@ -297,6 +303,9 @@ async function handleSelectFloor(selectedKeys, info) {
{ type: 'min', name: 'Min' },
],
},
markLine: {
data: [{ type: 'average', name: 'Avg' }],
},
},
{
name: '当月',
@@ -308,6 +317,9 @@ async function handleSelectFloor(selectedKeys, info) {
{ type: 'min', name: 'Min' },
],
},
markLine: {
data: [{ type: 'average', name: 'Avg' }],
},
},
],
});
@@ -328,6 +340,9 @@ async function handleSelectFloor(selectedKeys, info) {
{ type: 'min', name: 'Min' },
],
},
markLine: {
data: [{ type: 'average', name: 'Avg' }],
},
},
{
name: '当年',
@@ -339,6 +354,9 @@ async function handleSelectFloor(selectedKeys, info) {
{ type: 'min', name: 'Min' },
],
},
markLine: {
data: [{ type: 'average', name: 'Avg' }],
},
},
],
});

View File

@@ -81,7 +81,7 @@ import { Page } from '@vben/common-ui'
import { ref, onMounted, onBeforeUnmount, reactive } from 'vue'
import * as echarts from 'echarts'
import type { ECharts, EChartsOption } from 'echarts'
import FloorTree from "../../electricEnergy/components/floor-tree.vue"
import FloorTree from "../components/floor-tree.vue"
const chainData = reactive({
currentMonthEnergy: '9',

View File

@@ -13,15 +13,23 @@ export const querySchema: FormSchemaGetter = () => [
// export const columns: () => VxeGridProps['columns'] = () => [
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 },
{
title: '园区名称',
field: 'communityText',
},
{
title: '建筑名称',
field: 'buildingText',
},
{
title: '楼层名称',
field: 'floorName',
},
{
title: '楼层号',
field: 'floorNumber',
},
/* {
// {
// title: '楼层号',
// field: 'floorNumber',
// },
/* {
title: '楼层类型',
field: 'floorType',
},*/
@@ -33,6 +41,18 @@ export const columns: VxeGridProps['columns'] = [
title: '层高',
field: 'floorHeight',
},
{
title: '建筑面积(㎡)',
field: 'area',
},
{
title: '套内面积(㎡)',
field: 'insideInArea',
},
{
title: '公摊面积(㎡)',
field: 'sharedArea',
},
{
field: 'action',
fixed: 'right',
@@ -56,34 +76,23 @@ export const modalSchema: FormSchemaGetter = () => [
component: 'TreeSelect',
fieldName: 'buildingId',
defaultValue: undefined,
label: '社区建筑',
label: '建筑名称',
rules: 'selectRequired',
formItemClass: 'col-span-2',
},
{
label: '楼层名称',
label: '楼层名称',
fieldName: 'floorName',
component: 'Input',
rules: 'required',
},
{
label: '楼层号',
fieldName: 'floorNumber',
component: 'Input',
rules: 'required',
},
/*{
label: '楼层类型',
fieldName: 'floorType',
component: 'Select',
componentProps: {},
},*/
{
label: '房间数量',
fieldName: 'roomCount',
component: 'InputNumber',
componentProps: {
min:0,
precision:0,
min: 0,
precision: 0,
},
},
{
@@ -91,8 +100,35 @@ export const modalSchema: FormSchemaGetter = () => [
fieldName: 'floorHeight',
component: 'InputNumber',
componentProps: {
min:0,
precision:0,
min: 0,
precision: 0,
},
},
{
label: '建筑面积(㎡)',
fieldName: 'area',
component: 'InputNumber',
componentProps: {
min: 0,
precision: 2,
},
},
{
label: '套内面积(㎡)',
fieldName: 'insideInArea',
component: 'InputNumber',
componentProps: {
min: 0,
precision: 2,
},
},
{
label: '公摊面积(㎡)',
fieldName: 'sharedArea',
component: 'InputNumber',
componentProps: {
min: 0,
precision: 2,
},
},
];

View File

@@ -22,9 +22,9 @@ const title = computed(() => {
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
// 默认占满两列
formItemClass: 'col-span-2',
formItemClass: 'col-span-1',
// 默认label宽度 px
labelWidth: 80,
labelWidth: 90,
// 通用配置项 会影响到所有表单项
componentProps: {
class: 'w-full',
@@ -44,7 +44,7 @@ const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
const [BasicModal, modalApi] = useVbenModal({
// 在这里更改宽度
class: 'w-[550px]',
class: 'w-[60%]',
fullscreenButton: false,
onBeforeClose,
onClosed: handleClosed,

View File

@@ -162,7 +162,7 @@ export const columns: VxeGridProps['columns'] = [
fixed: 'right',
slots: { default: 'action' },
title: '操作',
width: 180,
width: 240,
},
];

View File

@@ -139,6 +139,20 @@ function handleMultiDelete() {
>
{{ $t('pages.common.edit') }}
</ghost-button>
<Popconfirm
:get-popup-container="getVxePopupContainer"
placement="left"
title="确认退订吗?"
@confirm="handleDelete(row)"
>
<ghost-button
danger
v-access:code="['property:orderCharge:remove']"
@click.stop=""
>
{{ '退订' }}
</ghost-button>
</Popconfirm>
<Popconfirm
:get-popup-container="getVxePopupContainer"
placement="left"

View File

@@ -46,6 +46,7 @@ export const columns: VxeGridProps['columns'] = [
{
title: '图片',
field: 'imgPath',
slots: { default: 'imgPath' },
},
{
title: '规格',
@@ -121,9 +122,9 @@ export const modalSchema: FormSchemaGetter = () => [
fieldName: 'rent',
component: 'InputNumber',
rules: 'required',
componentProps:{
componentProps: {
precision: 2,
}
},
},
{
label: '库存数量',

View File

@@ -1,11 +1,11 @@
<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 { Modal, Popconfirm, Space } from 'ant-design-vue';
import {
useVbenVxeGrid,
vxeCheckboxChecked,
type VxeGridProps
type VxeGridProps,
} from '#/adapter/vxe-table';
import {
plantsProductList,
@@ -15,6 +15,7 @@ import type { PropertyForm } from '#/api/property/productManagement/model';
import PlantsProductModal from './plantsProduct-modal.vue';
import PlantsProductDetail from './plantsProduct-detail.vue';
import { columns, querySchema } from './data';
import { ossInfo } from '#/api/system/oss';
const formOptions: VbenFormProps = {
commonConfig: {
labelWidth: 80,
@@ -38,18 +39,48 @@ const gridOptions: VxeGridProps = {
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
return await plantsProductList({
const result = await plantsProductList({
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
// 预处理图片路径
if (result.rows && result.rows.length > 0) {
// 收集所有图片路径
const imgPaths = result.rows
.filter((row: any) => row.imgPath)
.map((row: any) => row.imgPath);
if (imgPaths.length > 0) {
try {
// 批量获取图片URL
const imgUrls = await ossInfo(imgPaths);
const urlMap = new Map(
imgUrls.map((item) => [item.ossId, item.url]),
);
// 将URL映射回数据行
result.rows.forEach((row: any) => {
if (row.imgPath && urlMap.has(row.imgPath)) {
row._imgUrl = urlMap.get(row.imgPath);
}
});
} catch (error) {
console.error('获取图片URL失败:', error);
}
}
}
return result;
},
},
},
rowConfig: {
keyField: 'id',
},
id: 'property-property-index'
id: 'property-property-index',
};
const [BasicTable, tableApi] = useVbenVxeGrid({
@@ -98,6 +129,9 @@ function handleMultiDelete() {
},
});
}
function getImgUrl(row: any) {
return row._imgUrl || '';
}
</script>
<template>
@@ -110,7 +144,8 @@ function handleMultiDelete() {
danger
type="primary"
v-access:code="['property:plantsProduct:remove']"
@click="handleMultiDelete">
@click="handleMultiDelete"
>
{{ $t('pages.common.delete') }}
</a-button>
<a-button
@@ -152,8 +187,16 @@ function handleMultiDelete() {
</Popconfirm>
</Space>
</template>
<template #imgPath="{ row }">
<img
v-if="row.imgPath"
:src="getImgUrl(row)"
alt=""
class="h-[50px] w-[70px]"
/>
</template>
</BasicTable>
<PlantsProduct @reload="tableApi.query()" />
<PlantsProductDetailModal/>
<PlantsProductDetailModal />
</Page>
</template>

View File

@@ -112,6 +112,12 @@ export const modalSchema: FormSchemaGetter = () => [
fieldName: 'nfcCode',
component: 'Input',
},
{
label: '巡检位置',
fieldName: 'inspectionLocation',
component: 'Input',
rules: 'required',
},
{
label: '备注',
fieldName: 'remark',

View File

@@ -5,11 +5,6 @@ import { getDictOptions } from '#/utils/dict';
import { DictEnum } from '@vben/constants';
import { renderDict } from '#/utils/render';
export const querySchema: FormSchemaGetter = () => [
{
component: 'Input',
fieldName: 'communityName',
label: '社区',
},
{
component: 'Select',
componentProps: {
@@ -44,11 +39,11 @@ export const columns: VxeGridProps['columns'] = [
{
title: '是否重要',
field: 'isMatter',
slots:{
default:({row})=>{
return renderDict(row.isMatter,'wy_fjzydj')
}
}
slots: {
default: ({ row }) => {
return renderDict(row.isMatter, 'wy_fjzydj');
},
},
},
{
@@ -96,25 +91,25 @@ export const modalSchema: FormSchemaGetter = () => [
getPopupContainer,
options: getDictOptions(DictEnum.wy_room_type),
},
rules:'selectRequired'
rules: 'selectRequired',
},
{
label: '建筑面积',
fieldName: 'area',
component: 'InputNumber',
componentProps:{
min:0,
componentProps: {
min: 0,
},
rules:'required'
rules: 'required',
},
{
label: '使用面积',
fieldName: 'insideInArea',
component: 'InputNumber',
componentProps:{
min:0,
componentProps: {
min: 0,
},
rules:'required'
rules: 'required',
},
{
label: '是否重要',
@@ -123,7 +118,7 @@ export const modalSchema: FormSchemaGetter = () => [
componentProps: {
options: getDictOptions('wy_fjzydj'),
},
rules:'selectRequired'
rules: 'selectRequired',
},
{
label: '状态',
@@ -133,7 +128,7 @@ export const modalSchema: FormSchemaGetter = () => [
getPopupContainer,
options: getDictOptions(DictEnum.wy_fjzt),
},
rules:'selectRequired'
rules: 'selectRequired',
},
{
label: '房间图片',

View File

@@ -0,0 +1,125 @@
<script setup lang="ts">
import type { PropType } from 'vue';
import type { DeptTree } from '#/api/system/user/model';
import { onMounted, ref } from 'vue';
import { SyncOutlined } from '@ant-design/icons-vue';
import { Empty, InputSearch, Skeleton, Tree } from 'ant-design-vue';
import { communityTree } from '#/api/property/community';
defineOptions({ inheritAttrs: false });
withDefaults(defineProps<{ showSearch?: boolean }>(), { showSearch: true });
const emit = defineEmits<{
/**
* 点击刷新按钮的事件
*/
reload: [];
/**
* 点击节点的事件
*/
select: [];
}>();
const selectFloorId = defineModel('selectFloorId', {
default: '',
type: Array as PropType<string[]>,
});
const searchValue = defineModel('searchValue', {
type: String,
default: '',
});
/** 部门数据源 */
type DeptTreeArray = DeptTree[];
const deptTreeArray = ref<DeptTreeArray>([]);
/** 骨架屏加载 */
const showTreeSkeleton = ref<boolean>(true);
async function loadTree() {
showTreeSkeleton.value = true;
searchValue.value = '';
selectFloorId.value = [];
const ret = await communityTree(3);
deptTreeArray.value = ret;
showTreeSkeleton.value = false;
}
async function handleReload() {
await loadTree();
emit('reload');
}
function selectNode(selectedKeys, e) {
emit('select',e.node.level);
}
onMounted(loadTree);
</script>
<template>
<div :class="$attrs.class">
<Skeleton
:loading="showTreeSkeleton"
:paragraph="{ rows: 8 }"
active
class="p-[8px]"
>
<div
class="bg-background flex h-full flex-col overflow-y-auto rounded-lg"
>
<!-- 固定在顶部 必须加上bg-background背景色 否则会产生'穿透'效果 -->
<div
v-if="showSearch"
class="bg-background z-100 sticky left-0 top-0 p-[8px]"
>
<InputSearch
v-model:value="searchValue"
:placeholder="$t('pages.common.search')"
size="small"
>
<template #enterButton>
<a-button @click="handleReload">
<SyncOutlined class="text-primary" />
</a-button>
</template>
</InputSearch>
</div>
<div class="h-full overflow-x-hidden px-[8px]">
<Tree
v-bind="$attrs"
v-if="deptTreeArray.length > 0"
v-model:selected-keys="selectFloorId"
:class="$attrs.class"
:field-names="{ title: 'label', key: 'id' }"
:show-line="{ showLeafIcon: false }"
:tree-data="deptTreeArray"
:virtual="false"
default-expand-all
@select="selectNode"
>
<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>
</Tree>
<!-- 仅本人数据权限 可以考虑直接不显示 -->
<div v-else class="mt-5">
<Empty
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="无部门数据"
/>
</div>
</div>
</div>
</Skeleton>
</div>
</template>

View File

@@ -1,18 +1,14 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import {Page, useVbenModal, type VbenFormProps} from '@vben/common-ui';
import {getVxePopupContainer} from '@vben/utils';
import {Modal, Popconfirm, Space} from 'ant-design-vue';
import FloorTree from "./floor-tree.vue"
import detailModal from "./room-detail.vue";
import { ref } from 'vue';
import { Page, useVbenModal, type VbenFormProps } from '@vben/common-ui';
import { getVxePopupContainer } from '@vben/utils';
import { Modal, Popconfirm, Space } from 'ant-design-vue';
import dayjs from 'dayjs';
import {
import {
useVbenVxeGrid,
vxeCheckboxChecked,
type VxeGridProps
type VxeGridProps
} from '#/adapter/vxe-table';
import {
@@ -20,11 +16,12 @@ import {
roomList,
roomRemove,
} from '#/api/property/room';
import type { RoomForm } from '#/api/property/room/model';
import { commonDownloadExcel } from '#/utils/file/download';
import type {RoomForm} from '#/api/property/room/model';
import {commonDownloadExcel} from '#/utils/file/download';
import roomModal from './room-modal.vue';
import { columns, querySchema } from './data';
import {columns, querySchema} from './data';
import {ref} from "vue";
const formOptions: VbenFormProps = {
commonConfig: {
@@ -63,7 +60,19 @@ const gridOptions: VxeGridProps = {
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
query: async ({page}, formValues = {}) => {
Reflect.deleteProperty(formValues, 'communityId');
Reflect.deleteProperty(formValues, 'buildingId');
Reflect.deleteProperty(formValues, 'floorId');
if (selectFloorId.value.length === 1) {
if (level.value == 1) {
formValues.communityId = selectFloorId.value[0];
} else if (level.value == 2) {
formValues.buildingId = selectFloorId.value[0];
} else {
formValues.floorId = selectFloorId.value[0];
}
}
return await roomList({
pageNum: page.currentPage,
pageSize: page.pageSize,
@@ -87,6 +96,9 @@ const [BasicTable, tableApi] = useVbenVxeGrid({
const [RoomModal, modalApi] = useVbenModal({
connectedComponent: roomModal,
});
const [RoomDetail, detailApi] = useVbenModal({
connectedComponent: detailModal,
});
function handleAdd() {
modalApi.setData({});
@@ -94,10 +106,15 @@ function handleAdd() {
}
async function handleEdit(row: Required<RoomForm>) {
modalApi.setData({ id: row.id });
modalApi.setData({id: row.id});
modalApi.open();
}
async function handleInfo(row: Required<RoomForm>) {
detailApi.setData({id: row.id});
detailApi.open();
}
async function handleDelete(row: Required<RoomForm>) {
await roomRemove(row.id);
await tableApi.query();
@@ -122,61 +139,82 @@ function handleDownloadExcel() {
fieldMappingTime: formOptions.fieldMappingTime,
});
}
const selectFloorId = ref<string[]>([]);
const level = ref<number>();
function handleSelectFloor(nodeLevel: number) {
level.value = nodeLevel;
tableApi.reload()
}
</script>
<template>
<Page :auto-content-height="true">
<BasicTable table-title="房间信息列表">
<template #toolbar-tools>
<Space>
<a-button
v-access:code="['property:room:export']"
@click="handleDownloadExcel"
>
{{ $t('pages.common.export') }}
</a-button>
<a-button
:disabled="!vxeCheckboxChecked(tableApi)"
danger
type="primary"
v-access:code="['property:room:remove']"
@click="handleMultiDelete">
{{ $t('pages.common.delete') }}
</a-button>
<a-button
type="primary"
v-access:code="['property:room:add']"
@click="handleAdd"
>
{{ $t('pages.common.add') }}
</a-button>
</Space>
</template>
<template #action="{ row }">
<Space>
<ghost-button
v-access:code="['property:room: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:room:remove']"
@click.stop=""
<Page :auto-content-height="true" style="width: 100%">
<div class="flex h-full gap-[15px]">
<FloorTree v-model:select-floor-id="selectFloorId"
class="w-[260px]"
@reload="() => tableApi.reload()"
@select="handleSelectFloor"></FloorTree>
<BasicTable class="flex-1 overflow-hidden" table-title="房间信息列表">
<template #toolbar-tools>
<Space>
<a-button
v-access:code="['property:room:export']"
@click="handleDownloadExcel"
>
{{ $t('pages.common.export') }}
</a-button>
<a-button
:disabled="!vxeCheckboxChecked(tableApi)"
danger
type="primary"
v-access:code="['property:room:remove']"
@click="handleMultiDelete">
{{ $t('pages.common.delete') }}
</a-button>
<a-button
type="primary"
v-access:code="['property:room:add']"
@click="handleAdd"
>
{{ $t('pages.common.add') }}
</a-button>
</Space>
</template>
<template #action="{ row }">
<Space>
<ghost-button
v-access:code="['property:room:query']"
@click.stop="handleInfo(row)"
>
{{ $t('pages.common.info') }}
</ghost-button>
</Popconfirm>
</Space>
</template>
</BasicTable>
<RoomModal @reload="tableApi.query()" />
<ghost-button
v-access:code="['property:room: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:room:remove']"
@click.stop=""
>
{{ $t('pages.common.delete') }}
</ghost-button>
</Popconfirm>
</Space>
</template>
</BasicTable>
</div>
<RoomModal @reload="tableApi.query()"/>
<RoomDetail/>
</Page>
</template>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import {shallowRef} from 'vue';
import {useVbenModal} from '@vben/common-ui';
import {Descriptions, DescriptionsItem} from 'ant-design-vue';
import {ossInfo} from "#/api/system/oss";
import {roomInfo} from "#/api/property/room";
import type {RoomVO} from "#/api/property/room/model";
import {renderDict} from "#/utils/render";
const [BasicModal, modalApi] = useVbenModal({
onOpenChange: handleOpenChange,
onClosed() {
roomDetail.value = null;
},
});
const roomDetail = shallowRef<null | RoomVO>(null);
async function handleOpenChange(open: boolean) {
if (!open) {
return null;
}
modalApi.modalLoading(true);
const {id} = modalApi.getData() as { id: number | string };
roomDetail.value = await roomInfo(id);
try {
if (roomDetail.value.imgUrl) {
const res = await ossInfo([roomDetail.value.imgUrl]);
roomDetail.value.imgPath = res?.[0]?.url
}
} catch (e) {}
modalApi.modalLoading(false);
}
</script>
<template>
<BasicModal :footer="false" :fullscreen-button="false" title="房间详情" class="w-[70%]">
<Descriptions v-if="roomDetail" size="small" :column="2" bordered
:labelStyle="{width:'120px'}">
<DescriptionsItem label="社区">
{{ roomDetail.communityText }}
</DescriptionsItem>
<DescriptionsItem label="建筑名称">
{{ roomDetail.buildingText }}
</DescriptionsItem>
<DescriptionsItem label="楼栋号">
{{ roomDetail.floorText }}
</DescriptionsItem>
<DescriptionsItem label="房间号">
{{ roomDetail.roomNumber }}
</DescriptionsItem>
<DescriptionsItem label="房间类型">
{{ roomDetail.roomTypeName }}
</DescriptionsItem>
<DescriptionsItem label="建筑面积">
{{ roomDetail.area }}
</DescriptionsItem>
<DescriptionsItem label="使用面积">
{{ roomDetail.insideInArea }}
</DescriptionsItem>
<DescriptionsItem label="是否重要">
<component v-if="roomDetail.isMatter != null"
:is="renderDict(roomDetail.isMatter,'wy_fjzydj')"
/>
</DescriptionsItem>
<DescriptionsItem label="状态">
{{ roomDetail.statusName }}
</DescriptionsItem>
<DescriptionsItem label="所属单位">
{{ roomDetail.residentUnitText ? roomDetail.residentUnitText : '-'}}
</DescriptionsItem>
<DescriptionsItem label="房间图片" :span="2">
<img v-if="roomDetail.imgPath" :src="roomDetail.imgPath" alt="图片加载失败" class="w-[100px] h-[100px]"/>
</DescriptionsItem>
</Descriptions>
</BasicModal>
</template>

View File

@@ -54,10 +54,15 @@ async function handleOpenChange(open: boolean) {
{{ conferenceSettingsDetail.name }}
</DescriptionsItem>
<DescriptionsItem label="会议室类型">
<component
<component v-if="conferenceSettingsDetail.meetingRoomType!=null"
:is="renderDict(conferenceSettingsDetail.meetingRoomType,'meeting_room_type')"
/>
</DescriptionsItem>
<DescriptionsItem label="保密等级">
<component v-if="conferenceSettingsDetail.secrecyGrade!=null"
:is="renderDict(conferenceSettingsDetail.secrecyGrade,'wy_hysbmdj')"
/>
</DescriptionsItem>
<DescriptionsItem label="可容纳人数">
{{ conferenceSettingsDetail.personNumber }}
</DescriptionsItem>

View File

@@ -42,7 +42,16 @@ export const columns: VxeGridProps['columns'] = [
field: 'meetingRoomType',
slots: {
default: ({row}) => {
return row.meetingRoomType!=null?renderDict(row.meetingRoomType, 'meeting_room_type'):'';
return row.meetingRoomType!=null?renderDict(row.meetingRoomType, 'meeting_room_type'):'-';
},
},
minWidth:100,
},
{
title: '保密等级',
slots: {
default: ({row}) => {
return row.secrecyGrade!=null?renderDict(row.secrecyGrade, 'wy_hysbmdj'):'-';
},
},
minWidth:100,
@@ -124,6 +133,16 @@ export const modalSchema: FormSchemaGetter = () => [
},
rules: 'selectRequired',
},
{
label: '保密等级',
fieldName: 'secrecyGrade',
component: 'Select',
componentProps: {
options: getDictOptions('wy_hysbmdj'),
},
rules: 'selectRequired',
formItemClass: 'col-span-1',
},
{
label: '可容纳人数',
fieldName: 'personNumber',

View File

@@ -0,0 +1,88 @@
import type { FormSchemaGetter } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { personList } from '#/api/property/resident/person';
import {participantsList} from '#/api/property/roomBooking/participants';
export const querySchema: FormSchemaGetter = () => [
{
label: '会议室名称',
fieldName: 'meetBookId',
component: 'ApiSelect',
componentProps: {
api: async () => {
const rows = await participantsList({ pageSize: 1000000000, pageNum: 1 });
return rows;
},
resultField: 'rows',
labelField: 'meetBookingVo.name',
valueField: 'meetBookId',
},
},
{
label: '发起人',
fieldName: 'residentPersonId',
component: 'ApiSelect',
componentProps: {
api: async () => {
const rows = await personList({ pageSize: 1000000000, pageNum: 1 });
return rows;
},
resultField: 'rows',
labelField: 'userName',
valueField: 'id',
},
},
];
export const columns: VxeGridProps['columns'] = [
{
title: '会议室名称',
field: 'meetBookingVo.name',
},
{
title: '会议主题',
field: 'meetBookingVo.meetTheme',
},
{
title: '会议时间',
field: 'meetBookingVo.meetingTime',
slots: {
default: ({row}) => {
return row.meetBookingVo.scheduledStarttime+'~'+row.meetBookingVo.scheduledEndtime;
},
},
width:300
},
{
title: '发起人',
field: 'meetBookingVo.personName',
},
{
title: '发起单位',
field: 'meetBookingVo.unitName',
},
{
title: '会议室地点',
field: 'meetBookingVo.meetLocation',
},
{
title: '签到状态',
field: 'signState',
slots: {
default: ({row}) => {
return row.signState === '0' ? '未签到' : '已签到';
},
},
},
{
title: '签到时间',
field: 'signTime',
},
{
field: 'action',
fixed: 'right',
slots: { default: 'action' },
title: '操作',
width: 180,
},
];

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
import { Page, useVbenModal, type VbenFormProps } from '@vben/common-ui';
import { Space } from 'ant-design-vue';
import {
useVbenVxeGrid,
type VxeGridProps
} from '#/adapter/vxe-table';
import {
participantsExport,
participantsList,
} from '#/api/property/roomBooking/participants';
import type { ParticipantsForm } from '#/api/property/roomBooking/participants/model';
import { commonDownloadExcel } from '#/utils/file/download';
import participantsDetail from './participants-detail.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',
};
const gridOptions: VxeGridProps = {
checkboxConfig: {
highlight: true,
reserve: true,
},
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 [ParticipantsDetail, participantsDetailApi] = useVbenModal({
connectedComponent: participantsDetail,
});
async function handleInfo(row: Required<ParticipantsForm>) {
participantsDetailApi.setData({ id: row.id });
participantsDetailApi.open();
}
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>
</Space>
</template>
<template #action="{ row }">
<Space>
<ghost-button
@click.stop="handleInfo(row)"
>
{{ $t('pages.common.info') }}
</ghost-button>
</Space>
</template>
</BasicTable>
<ParticipantsDetail/>
</Page>
</template>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import type {ParticipantsVO} from '#/api/property/roomBooking/participants/model';
import {shallowRef} from 'vue';
import {useVbenModal} from '@vben/common-ui';
import {Descriptions, DescriptionsItem} from 'ant-design-vue';
import {participantsInfo} from '#/api/property/roomBooking/participants';
import {ossInfo} from "#/api/system/oss";
const [BasicModal, modalApi] = useVbenModal({
onOpenChange: handleOpenChange,
onClosed() {
ParticipantsDetail.value = null;
},
});
const ParticipantsDetail = shallowRef<null | ParticipantsVO>(null);
async function handleOpenChange(open: boolean) {
if (!open) {
return null;
}
modalApi.modalLoading(true);
const {id} = modalApi.getData() as { id: number | string };
ParticipantsDetail.value = await participantsInfo(id);
try {
if (ParticipantsDetail.value.residentPersonVo.img) {
const res = await ossInfo([ParticipantsDetail.value.residentPersonVo.img]);
ParticipantsDetail.value.residentPersonVo.img = res?.[0]?.url
}
} catch (e) {}
modalApi.modalLoading(false);
}
</script>
<template>
<BasicModal :footer="false" :fullscreen-button="false" title="会议室详情" class="w-[70%]">
<Descriptions v-if="ParticipantsDetail" size="small" :column="2" bordered
:labelStyle="{width:'120px'}">
<DescriptionsItem label="会议室名称">
{{ ParticipantsDetail.meetBookingVo.name }}
</DescriptionsItem>
<DescriptionsItem label="会议主题">
{{ ParticipantsDetail.meetBookingVo.meetTheme }}
</DescriptionsItem>
<DescriptionsItem label="会议时间">
{{ ParticipantsDetail.meetBookingVo.scheduledStarttime }}
</DescriptionsItem>
<DescriptionsItem label="发起人">
{{ ParticipantsDetail.meetBookingVo.personName }}
</DescriptionsItem>
<DescriptionsItem label="发起单位">
{{ ParticipantsDetail.meetBookingVo.unitName }}
</DescriptionsItem>
<DescriptionsItem label="会议室地点">
{{ ParticipantsDetail.meetBookingVo.meetLocation }}
</DescriptionsItem>
<DescriptionsItem label="参会姓名">
{{ ParticipantsDetail.residentPersonVo.userName }}
</DescriptionsItem>
<DescriptionsItem label="证件号">
{{ ParticipantsDetail.residentPersonVo.idCard }}
</DescriptionsItem>
<DescriptionsItem label="所属公司">
{{ ParticipantsDetail.residentPersonVo.unitName }}
</DescriptionsItem>
<DescriptionsItem label="联系电话">
{{ ParticipantsDetail.residentPersonVo.phone }}
</DescriptionsItem>
<DescriptionsItem label="人脸图片" v-if="ParticipantsDetail.residentPersonVo.img" :span="2">
<img :src="ParticipantsDetail.residentPersonVo.img" alt="图片加载失败" class="w-[100px] h-[100px]"/>
</DescriptionsItem>
<DescriptionsItem label="签到状态">
{{ ParticipantsDetail.signState === '0' ? '未签到' : '已签到'}}
</DescriptionsItem>
<DescriptionsItem label="签到时间">
{{ ParticipantsDetail.signTime }}
</DescriptionsItem>
</Descriptions>
</BasicModal>
</template>

View File

@@ -2,7 +2,7 @@ import type { FormSchemaGetter } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import {getDictOptions} from "#/utils/dict";
import {renderDict} from "#/utils/render";
import {personList} from '#/api/property/resident/person'
import {resident_unitList,} from '#/api/property/resident/unit';
export const querySchema: FormSchemaGetter = () => [
{
@@ -138,7 +138,7 @@ export const modalSchema: FormSchemaGetter = () => [
rules: 'required',
},
{
label: '所属公司',
label: '所属单位',
fieldName: 'visitorUnit',
component: 'Input',
rules: 'required',
@@ -160,10 +160,10 @@ export const modalSchema: FormSchemaGetter = () => [
fieldName: 'interviewedUnit',
component: 'ApiSelect',
componentProps: {
api: personList,
api: resident_unitList,
resultField: 'rows',
labelField: 'unitName',
valueField: 'unitName',
labelField: 'name',
valueField: 'name',
placeholder: '请选择邀约单位',
},
rules: 'required',

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

@@ -21,7 +21,7 @@ export const querySchema: FormSchemaGetter = () => [
options: getDictOptions(DictEnum.alarm_level, true),
},
fieldName: 'level',
label: '级别',
label: '预警级别',
},
/*{
component: 'Select',
@@ -45,7 +45,7 @@ export const columns: VxeGridProps['columns'] = [
field: 'reportTime',
},
{
title: '设备ip',
title: '设备IP',
field: 'deviceIp',
},
{
@@ -53,7 +53,7 @@ export const columns: VxeGridProps['columns'] = [
field: 'deviceName',
},
{
title: '级别',
title: '预警级别',
field: 'level',
slots: {
default: ({ row }: any) => {
@@ -138,7 +138,7 @@ export const modalSchema: FormSchemaGetter = () => [
disabled: true,
},
{
label: '设备名称',
label: '设备IP',
fieldName: 'deviceIp',
component: 'Input',
disabled: true,
@@ -152,7 +152,7 @@ export const modalSchema: FormSchemaGetter = () => [
rules: 'required',
},
{
label: '重要级别',
label: '预警级别',
fieldName: 'level',
component: 'Select',
disabled: true,
@@ -185,7 +185,7 @@ export const modalSchema: FormSchemaGetter = () => [
rules: 'selectRequired',
},
{
label: '描述',
label: '预警描述',
disabled: true,
fieldName: 'description',
component: 'Textarea',

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';
@@ -72,15 +72,24 @@ function loadProcessList() {
:labelStyle="{ width: '120px' }"
style="margin-bottom: 30px"
>
<DescriptionsItem label="预警编">
<DescriptionsItem label="预警编">
{{ warningDetail.id }}
</DescriptionsItem>
<DescriptionsItem label="预警时间">
{{ warningDetail.reportTime }}
<DescriptionsItem label="预警类型">
{{ warningDetail.bigTypeName + ' - ' + warningDetail.smallTypeName }}
</DescriptionsItem>
<DescriptionsItem label="级别">
<DescriptionsItem label="设备IP"
>{{ warningDetail.deviceIp }}
</DescriptionsItem>
<DescriptionsItem label="设备名称"
>{{ warningDetail.deviceName }}
</DescriptionsItem>
<DescriptionsItem label="预警位置" :span="2">
{{ warningDetail.deviceName }}
</DescriptionsItem>
<DescriptionsItem label="预警级别">
<Tag
:color="
warningDetail.level === '特大'
@@ -93,42 +102,15 @@ function loadProcessList() {
{{ warningDetail.levelName }}
</Tag>
</DescriptionsItem>
<DescriptionsItem label="预警类型">
{{ warningDetail.bigTypeName + ' - ' + warningDetail.smallTypeName }}
<DescriptionsItem label="预警时间">
{{ warningDetail.reportTime }}
</DescriptionsItem>
<DescriptionsItem label="设备ip"
>{{ warningDetail.deviceIp }}
</DescriptionsItem>
<DescriptionsItem label="设备ip"
>{{ warningDetail.deviceName }}
</DescriptionsItem>
<DescriptionsItem label="描述" :span="2">
<DescriptionsItem label="预警描述" :span="2">
{{ warningDetail.description }}
</DescriptionsItem>
<DescriptionsItem label="所在位置">
{{ 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 +122,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

@@ -21,7 +21,7 @@ export const querySchema: FormSchemaGetter = () => [
options: getDictOptions(DictEnum.alarm_level, true),
},
fieldName: 'level',
label: '级别',
label: '预警级别',
},
/*{
component: 'Select',
@@ -45,7 +45,7 @@ export const columns: VxeGridProps['columns'] = [
field: 'reportTime',
},
{
title: '设备ip',
title: '设备IP',
field: 'deviceIp',
},
{
@@ -53,7 +53,7 @@ export const columns: VxeGridProps['columns'] = [
field: 'deviceName',
},
{
title: '级别',
title: '预警级别',
field: 'level',
slots: {
default: ({ row }: any) => {
@@ -138,7 +138,7 @@ export const modalSchema: FormSchemaGetter = () => [
disabled: true,
},
{
label: '设备名称',
label: '设备IP',
fieldName: 'deviceIp',
component: 'Input',
disabled: true,
@@ -152,7 +152,7 @@ export const modalSchema: FormSchemaGetter = () => [
rules: 'required',
},
{
label: '重要级别',
label: '预警级别',
fieldName: 'level',
component: 'Select',
disabled: true,
@@ -185,7 +185,7 @@ export const modalSchema: FormSchemaGetter = () => [
rules: 'selectRequired',
},
{
label: '描述',
label: '预警描述',
disabled: true,
fieldName: 'description',
component: 'Textarea',

View File

@@ -70,15 +70,23 @@ function loadProcessList() {
:labelStyle="{ width: '120px' }"
style="margin-bottom: 30px"
>
<DescriptionsItem label="预警编">
<DescriptionsItem label="预警编">
{{ warningDetail.id }}
</DescriptionsItem>
<DescriptionsItem label="预警时间">
{{ warningDetail.reportTime }}
<DescriptionsItem label="预警类型">
{{ warningDetail.bigTypeName + ' - ' + warningDetail.smallTypeName }}
</DescriptionsItem>
<DescriptionsItem label="设备IP"
>{{ warningDetail.deviceIp }}
</DescriptionsItem>
<DescriptionsItem label="级别">
<DescriptionsItem label="设备名称"
>{{ warningDetail.deviceName }}
</DescriptionsItem>
<DescriptionsItem label="预警位置" :span="2">
{{ warningDetail.deviceName }}
</DescriptionsItem>
<DescriptionsItem label="预警级别">
<Tag
:color="
warningDetail.level === '特大'
@@ -91,28 +99,16 @@ function loadProcessList() {
{{ warningDetail.levelName }}
</Tag>
</DescriptionsItem>
<DescriptionsItem label="预警类型">
{{ warningDetail.bigTypeName + ' - ' + warningDetail.smallTypeName }}
<DescriptionsItem label="预警时间">
{{ warningDetail.reportTime }}
</DescriptionsItem>
<DescriptionsItem label="设备ip"
>{{ warningDetail.deviceIp }}
</DescriptionsItem>
<DescriptionsItem label="设备ip"
>{{ warningDetail.deviceName }}
</DescriptionsItem>
<DescriptionsItem label="描述" :span="2">
<DescriptionsItem label="预警描述" :span="2">
{{ warningDetail.description }}
</DescriptionsItem>
<DescriptionsItem label="所在位置">
{{ warningDetail.deviceName }}
</DescriptionsItem>
<DescriptionsItem label="处理状态">
<DescriptionsItem label="处理状态" :span="2">
<Tag color="processing">
{{ warningDetail.stateName }}
</Tag>
@@ -126,7 +122,7 @@ function loadProcessList() {
{{ warningDetail.processingTime || '-' }}
</DescriptionsItem>-->
<DescriptionsItem :span="1" label="附件信息">
<DescriptionsItem :span="2" label="相关图片">
<div class="file-box">
<div class="img-box" v-for="item in currFiles">
<Image
@@ -138,7 +134,7 @@ function loadProcessList() {
</div>
</DescriptionsItem>
<DescriptionsItem :span="1" label="报警视频"></DescriptionsItem>
<DescriptionsItem :span="2" label="报警视频"></DescriptionsItem>
</Descriptions>
</BasicModal>
</template>

View File

@@ -22,7 +22,7 @@ export const querySchema: FormSchemaGetter = () => [
options: getDictOptions(DictEnum.alarm_level, true),
},
fieldName: 'level',
label: '级别',
label: '预警级别',
},
/*{
component: 'Select',
@@ -46,7 +46,7 @@ export const columns: VxeGridProps['columns'] = [
field: 'reportTime',
},
{
title: '设备ip',
title: '设备IP',
field: 'deviceIp',
},
{
@@ -54,7 +54,7 @@ export const columns: VxeGridProps['columns'] = [
field: 'deviceName',
},
{
title: '级别',
title: '预警级别',
field: 'level',
slots: {
default: ({ row }: any) => {
@@ -139,7 +139,7 @@ export const modalSchema: FormSchemaGetter = () => [
disabled: true,
},
{
label: '设备名称',
label: '设备IP',
fieldName: 'deviceIp',
component: 'Input',
disabled: true,
@@ -153,7 +153,7 @@ export const modalSchema: FormSchemaGetter = () => [
rules: 'required',
},
{
label: '重要级别',
label: '预警级别',
fieldName: 'level',
component: 'Select',
disabled: true,
@@ -186,7 +186,7 @@ export const modalSchema: FormSchemaGetter = () => [
rules: 'selectRequired',
},
{
label: '描述',
label: '预警描述',
disabled: true,
fieldName: 'description',
component: 'Textarea',
@@ -196,6 +196,19 @@ export const modalSchema: FormSchemaGetter = () => [
},
},
// 插入分割线
{
component: 'Divider',
fieldName: '_divider',
formItemClass: 'col-span-2',
hideLabel: true,
renderComponentContent: () => {
return {
default: () => h('div', '处理'),
};
},
},
{
label: '处理人',
fieldName: 'solveName',
@@ -212,19 +225,6 @@ export const modalSchema: FormSchemaGetter = () => [
fieldName: 'solveEmail',
component: 'Input',
},
// 插入分割线
{
component: 'Divider',
fieldName: '_divider',
formItemClass: 'col-span-2',
hideLabel: true,
renderComponentContent: () => {
return {
default: () => h('div', '处理'),
};
},
},
{
label: '备注',
fieldName: 'remark',

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';
@@ -70,15 +70,23 @@ function loadProcessList() {
:labelStyle="{ width: '120px' }"
style="margin-bottom: 30px"
>
<DescriptionsItem label="预警编">
<DescriptionsItem label="预警编">
{{ warningDetail.id }}
</DescriptionsItem>
<DescriptionsItem label="预警时间">
{{ warningDetail.reportTime }}
<DescriptionsItem label="预警类型">
{{ warningDetail.bigTypeName + ' - ' + warningDetail.smallTypeName }}
</DescriptionsItem>
<DescriptionsItem label="设备IP"
>{{ warningDetail.deviceIp }}
</DescriptionsItem>
<DescriptionsItem label="级别">
<DescriptionsItem label="设备名称"
>{{ warningDetail.deviceName }}
</DescriptionsItem>
<DescriptionsItem label="预警位置" :span="2">
{{ warningDetail.deviceName }}
</DescriptionsItem>
<DescriptionsItem label="预警级别">
<Tag
:color="
warningDetail.level === '特大'
@@ -91,34 +99,13 @@ function loadProcessList() {
{{ warningDetail.levelName }}
</Tag>
</DescriptionsItem>
<DescriptionsItem label="预警类型">
{{ warningDetail.bigTypeName + ' - ' + warningDetail.smallTypeName }}
<DescriptionsItem label="预警时间">
{{ warningDetail.reportTime }}
</DescriptionsItem>
<DescriptionsItem label="设备ip"
>{{ warningDetail.deviceIp }}
</DescriptionsItem>
<DescriptionsItem label="设备ip"
>{{ warningDetail.deviceName }}
</DescriptionsItem>
<DescriptionsItem label="描述" :span="2">
<DescriptionsItem label="预警描述" :span="2">
{{ warningDetail.description }}
</DescriptionsItem>
<DescriptionsItem label="所在位置">
{{ 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 +117,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>