!226 feat:【mall 商城】分销提现的迁移(ele 100%)

Merge pull request !226 from 芋道源码/dev
This commit is contained in:
xingyu
2025-10-13 02:56:44 +00:00
committed by Gitee
331 changed files with 15304 additions and 14430 deletions

View File

@@ -2,7 +2,7 @@ import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
namespace ErpFinancePaymentApi {
export namespace ErpFinancePaymentApi {
/** 付款单项 */
export interface FinancePaymentItem {
id?: number;

View File

@@ -43,6 +43,7 @@ export namespace ErpStockMoveApi {
status?: number;
}
}
/**
* 查询库存调拨单分页
*/
@@ -102,7 +103,5 @@ export function deleteStockMove(ids: number[]) {
* 导出库存调拨单 Excel
*/
export function exportStockMove(params: ErpStockMoveApi.StockMovePageParams) {
return requestClient.download('/erp/stock-move/export-excel', {
params,
});
return requestClient.download('/erp/stock-move/export-excel', { params });
}

View File

@@ -51,7 +51,9 @@ export function getAlertConfig(id: number) {
/** 查询所有告警配置列表 */
export function getAlertConfigList() {
return requestClient.get<AlertConfigApi.AlertConfig[]>('/iot/alert-config/list');
return requestClient.get<AlertConfigApi.AlertConfig[]>(
'/iot/alert-config/list',
);
}
/** 新增告警配置 */
@@ -90,6 +92,3 @@ export function getSimpleAlertConfigList() {
'/iot/alert-config/simple-list',
);
}
export { AlertConfigApi };

View File

@@ -80,6 +80,3 @@ export function deleteAlertRecordList(ids: number[]) {
params: { ids: ids.join(',') },
});
}
export { AlertRecordApi };

View File

@@ -79,8 +79,8 @@ export namespace IotDeviceApi {
/** IoT 设备状态枚举 */
export enum DeviceStateEnum {
INACTIVE = 0, // 未激活
ONLINE = 1, // 在线
OFFLINE = 2, // 离线
ONLINE = 1, // 在线
}
/** 查询设备分页 */
@@ -194,31 +194,3 @@ export function getDeviceMessagePairPage(params: PageParam) {
export function sendDeviceMessage(params: IotDeviceApi.DeviceMessageSendReq) {
return requestClient.post('/iot/device/message/send', params);
}
// Export aliases for compatibility
export const DeviceApi = {
getDevicePage,
getDevice,
createDevice,
updateDevice,
updateDeviceGroup,
deleteDevice,
deleteDeviceList,
exportDeviceExcel,
getDeviceCount,
getSimpleDeviceList,
getDeviceListByProductId,
importDeviceTemplate,
getLatestDeviceProperties,
getHistoryDevicePropertyList,
getDeviceAuthInfo,
getDeviceMessagePage,
getDeviceMessagePairPage,
sendDeviceMessage,
};
export type DeviceVO = IotDeviceApi.Device;
export type IotDeviceAuthInfoVO = IotDeviceApi.DeviceAuthInfo;
export type IotDevicePropertyDetailRespVO = IotDeviceApi.DevicePropertyDetail;
export type IotDevicePropertyRespVO = IotDeviceApi.DeviceProperty;

View File

@@ -49,4 +49,3 @@ export function getSimpleDeviceGroupList() {
'/iot/device-group/simple-list',
);
}

View File

@@ -96,6 +96,3 @@ export function pauseOtaTask(id: number) {
export function resumeOtaTask(id: number) {
return requestClient.put(`/iot/ota/task/resume?id=${id}`);
}
export { IoTOtaTaskApi };

View File

@@ -99,6 +99,3 @@ export function getOtaTaskRecordStatusStatistics(
{ params: { firmwareId, taskId } },
);
}
export { IoTOtaTaskRecordApi };

View File

@@ -55,4 +55,3 @@ export function getSimpleProductCategoryList() {
'/iot/product-category/simple-list',
);
}

View File

@@ -30,15 +30,15 @@ export namespace IotProductApi {
/** IOT 产品设备类型枚举类 */
export enum DeviceTypeEnum {
DEVICE = 0, // 直连设备
GATEWAY_SUB = 1, // 网关子设备
GATEWAY = 2, // 网关设备
GATEWAY_SUB = 1, // 网关子设备
}
/** IOT 产品定位类型枚举类 */
export enum LocationTypeEnum {
IP = 1, // IP 定位
MODULE = 2, // 设备定位
MANUAL = 3, // 手动定位
MODULE = 2, // 设备定位
}
/** IOT 数据格式(编解码器类型)枚举类 */
@@ -97,18 +97,3 @@ export function getProductByKey(productKey: string) {
params: { productKey },
});
}
// Export aliases for compatibility
export const ProductApi = {
getProductPage,
getProduct,
createProduct,
updateProduct,
deleteProduct,
exportProduct,
updateProductStatus,
getSimpleProductList,
getProductByKey,
};
export type ProductVO = IotProductApi.Product;

View File

@@ -81,4 +81,3 @@ export function updateDataRuleStatus(id: number, status: number) {
status,
});
}

View File

@@ -29,11 +29,11 @@ export interface DataSinkVO {
/** IoT 数据目的类型枚举 */
export enum IotDataSinkTypeEnum {
HTTP = 'HTTP',
MQTT = 'MQTT',
KAFKA = 'KAFKA',
MQTT = 'MQTT',
RABBITMQ = 'RABBITMQ',
ROCKETMQ = 'ROCKETMQ',
REDIS_STREAM = 'REDIS_STREAM',
ROCKETMQ = 'ROCKETMQ',
}
/** HTTP 配置 */
@@ -146,6 +146,3 @@ export function updateDataSinkStatus(id: number, status: number) {
status,
});
}
export { DataSinkApi };

View File

@@ -90,6 +90,7 @@ export interface TriggerCondition {
operator?: string;
value?: any;
type?: string;
param?: string;
}
/** IoT 场景联动规则动作 */
@@ -153,11 +154,3 @@ export function getSimpleRuleSceneList() {
'/iot/scene-rule/simple-list',
);
}
// 别名导出(兼容旧代码)
export {
getSceneRulePage as getRuleScenePage,
deleteSceneRule as deleteRuleScene,
updateSceneRuleStatus as updateRuleSceneStatus,
};

View File

@@ -67,18 +67,3 @@ export function getDeviceMessageSummary(statType: number) {
{ params: { statType } },
);
}
// 导出 API 对象(兼容旧代码)
export const StatisticsApi = {
getStatisticsSummary,
getDeviceMessageSummaryByDate,
getDeviceMessageSummary,
};
// 导出类型别名(兼容旧代码)
export type IotStatisticsSummaryRespVO = IotStatisticsApi.StatisticsSummary;
export type IotStatisticsDeviceMessageSummaryRespVO =
IotStatisticsApi.DeviceMessageSummary;
export type IotStatisticsDeviceMessageSummaryByDateRespVO =
IotStatisticsApi.DeviceMessageSummaryByDate;
export type IotStatisticsDeviceMessageReqVO = IotStatisticsApi.DeviceMessageReq;

View File

@@ -114,13 +114,13 @@ export interface ThingModelFormRules {
}
/** 验证布尔型名称 */
export const validateBoolName = (_rule: any, value: any, callback: any) => {
if (!value) {
callback(new Error('枚举描述不能为空'));
} else {
export function validateBoolName(_rule: any, value: any, callback: any) {
if (value) {
callback();
} else {
callback(new Error('枚举描述不能为空'));
}
};
}
/** 查询产品物模型分页 */
export function getThingModelPage(params: PageParam) {
@@ -189,18 +189,3 @@ export function exportThingModelTSL(productId: number) {
params: { productId },
});
}
// Add a consolidated API object and getThingModelList alias
export const ThingModelApi = {
getThingModelPage,
getThingModel,
getThingModelList: getThingModelListByProductId, // alias for compatibility
getThingModelListByProductId,
getThingModelListByProductKey,
createThingModel,
updateThingModel,
deleteThingModel,
deleteThingModelList,
importThingModelTSL,
exportThingModelTSL,
};

View File

@@ -5,18 +5,18 @@ import { requestClient } from '#/api/request';
export namespace MallBannerApi {
/** Banner 信息 */
export interface Banner {
id: number;
title: string;
picUrl: string;
status: number;
url: string;
position: number;
sort: number;
memo: string;
id: number; // Banner 编号
title: string; // Banner 标题
picUrl: string; // Banner 图片
status: number; // 状态
url: string; // 链接地址
position: number; // Banner 位置
sort: number; // 排序
memo: string; // 备注
}
}
/** 查询Banner管理列表 */
/** 查询 Banner 管理列表 */
export function getBannerPage(params: PageParam) {
return requestClient.get<PageResult<MallBannerApi.Banner>>(
'/promotion/banner/page',
@@ -24,24 +24,24 @@ export function getBannerPage(params: PageParam) {
);
}
/** 查询Banner管理详情 */
/** 查询 Banner 管理详情 */
export function getBanner(id: number) {
return requestClient.get<MallBannerApi.Banner>(
`/promotion/banner/get?id=${id}`,
);
}
/** 新增Banner管理 */
/** 新增 Banner 管理 */
export function createBanner(data: MallBannerApi.Banner) {
return requestClient.post('/promotion/banner/create', data);
}
/** 修改Banner管理 */
/** 修改 Banner 管理 */
export function updateBanner(data: MallBannerApi.Banner) {
return requestClient.put('/promotion/banner/update', data);
}
/** 删除Banner管理 */
/** 删除 Banner 管理 */
export function deleteBanner(id: number) {
return requestClient.delete(`/promotion/banner/delete?id=${id}`);
}

View File

@@ -5,18 +5,12 @@ import { requestClient } from '#/api/request';
export namespace MallBrandApi {
/** 商品品牌 */
export interface Brand {
/** 品牌编号 */
id?: number;
/** 品牌名称 */
name: string;
/** 品牌图片 */
picUrl: string;
/** 品牌排序 */
sort?: number;
/** 品牌描述 */
description?: string;
/** 开启状态 */
status: number;
id?: number; // 品牌编号
name: string; // 品牌名称
picUrl: string; // 品牌图片
sort?: number; // 品牌排序
description?: string; // 品牌描述
status: number; // 开启状态
}
}

View File

@@ -3,18 +3,12 @@ import { requestClient } from '#/api/request';
export namespace MallCategoryApi {
/** 产品分类 */
export interface Category {
/** 分类编号 */
id?: number;
/** 父分类编号 */
parentId?: number;
/** 分类名称 */
name: string;
/** 移动端分类图 */
picUrl: string;
/** 分类排序 */
sort: number;
/** 开启状态 */
status: number;
id?: number; // 分类编号
parentId?: number; // 父分类编号
name: string; // 分类名称
picUrl: string; // 移动端分类图
sort: number; // 分类排序
status: number; // 开启状态
}
}

View File

@@ -4,47 +4,47 @@ import { requestClient } from '#/api/request';
export namespace MallCommentApi {
export interface Property {
propertyId: number;
propertyName: string;
valueId: number;
valueName: string;
propertyId: number; // 属性 ID
propertyName: string; // 属性名称
valueId: number; // 属性值 ID
valueName: string; // 属性值名称
}
/** 商品评论 */
export interface Comment {
id: number;
userId: number;
userNickname: string;
userAvatar: string;
anonymous: boolean;
orderId: number;
orderItemId: number;
spuId: number;
spuName: string;
skuId: number;
visible: boolean;
scores: number;
descriptionScores: number;
benefitScores: number;
content: string;
picUrls: string[];
replyStatus: boolean;
replyUserId: number;
replyContent: string;
replyTime: Date;
createTime: Date;
skuProperties: Property[];
id: number; // 评论编号
userId: number; // 用户编号
userNickname: string; // 用户昵称
userAvatar: string; // 用户头像
anonymous: boolean; // 是否匿名
orderId: number; // 订单编号
orderItemId: number; // 订单项编号
spuId: number; // 商品SPU编号
spuName: string; // 商品名称
skuId: number; // 商品SKU编号
visible: boolean; // 是否可见
scores: number; // 总评分
descriptionScores: number; // 描述评分
benefitScores: number; // 服务评分
content: string; // 评论内容
picUrls: string[]; // 评论图片
replyStatus: boolean; // 是否回复
replyUserId: number; // 回复人编号
replyContent: string; // 回复内容
replyTime: Date; // 回复时间
createTime: Date; // 创建时间
skuProperties: Property[]; // SKU 属性数组
}
/** 评论可见性更新 */
export interface CommentVisibleUpdate {
id: number;
visible: boolean;
id: number; // 评论编号
visible: boolean; // 是否可见
}
/** 评论回复 */
export interface CommentReply {
id: number;
replyContent: string;
id: number; // 评论编号
replyContent: string; // 回复内容
}
}

View File

@@ -5,12 +5,9 @@ import { requestClient } from '#/api/request';
export namespace MallFavoriteApi {
/** 商品收藏 */
export interface Favorite {
/** 收藏编号 */
id?: number;
/** 用户编号 */
userId?: string;
/** 商品 SPU 编号 */
spuId?: null | number;
id?: number; // 收藏编号
userId?: string; // 用户编号
spuId?: number; // 商品 SPU 编号
}
}

View File

@@ -5,21 +5,15 @@ import { requestClient } from '#/api/request';
export namespace MallHistoryApi {
/** 商品浏览记录 */
export interface BrowseHistory {
/** 记录编号 */
id?: number;
/** 用户编号 */
userId?: number;
/** 商品 SPU 编号 */
spuId?: number;
/** 浏览时间 */
createTime?: Date;
id?: number; // 记录编号
userId?: number; // 用户编号
spuId?: number; // 商品 SPU 编号
createTime?: Date; // 浏览时间
}
}
/**
* 获得商品浏览记录分页
*
* @param params 请求参数
*/
export function getBrowseHistoryPage(params: PageParam) {
return requestClient.get<PageResult<MallHistoryApi.BrowseHistory>>(

View File

@@ -5,29 +5,22 @@ import { requestClient } from '#/api/request';
export namespace MallPropertyApi {
/** 商品属性 */
export interface Property {
/** 属性编号 */
id?: number;
/** 名称 */
name: string;
/** 备注 */
remark?: string;
id?: number; // 属性编号
name: string; // 名称
remark?: string; // 备注
}
/** 属性值 */
export interface PropertyValue {
/** 属性值编号 */
id?: number;
/** 属性项的编号 */
propertyId?: number;
/** 名称 */
name: string;
/** 备注 */
remark?: string;
id?: number; // 属性值编号
propertyId?: number; // 属性项的编号
name: string; // 名称
remark?: string; // 备注
}
/** 属性值查询参数 */
export interface PropertyValueQuery extends PageParam {
propertyId?: number;
propertyId?: number; // 属性编号
}
}

View File

@@ -5,124 +5,73 @@ import { requestClient } from '#/api/request';
export namespace MallSpuApi {
/** 商品属性 */
export interface Property {
/** 属性编号 */
propertyId?: number;
/** 属性名称 */
propertyName?: string;
/** 属性值编号 */
valueId?: number;
/** 属性值名称 */
valueName?: string;
propertyId?: number; // 属性编号
propertyName?: string; // 属性名称
valueId?: number; // 属性值编号
valueName?: string; // 属性值名称
}
/** 商品 SKU */
export interface Sku {
/** 商品 SKU 编号 */
id?: number;
/** 商品 SKU 名称 */
name?: string;
/** SPU 编号 */
spuId?: number;
/** 属性数组 */
properties?: Property[];
/** 商品价格 */
price?: number | string;
/** 市场价 */
marketPrice?: number | string;
/** 成本价 */
costPrice?: number | string;
/** 商品条码 */
barCode?: string;
/** 图片地址 */
picUrl?: string;
/** 库存 */
stock?: number;
/** 商品重量单位kg 千克 */
weight?: number;
/** 商品体积单位m^3 平米 */
volume?: number;
/** 一级分销的佣金 */
firstBrokeragePrice?: number | string;
/** 二级分销的佣金 */
secondBrokeragePrice?: number | string;
/** 商品销量 */
salesCount?: number;
id?: number; // 商品 SKU 编号
name?: string; // 商品 SKU 名称
spuId?: number; // SPU 编号
properties?: Property[]; // 属性数组
price?: number | string; // 商品价格
marketPrice?: number | string; // 市场价
costPrice?: number | string; // 成本价
barCode?: string; // 商品条码
picUrl?: string; // 图片地址
stock?: number; // 库存
weight?: number; // 商品重量单位kg 千克
volume?: number; // 商品体积单位m^3 平米
firstBrokeragePrice?: number | string; // 一级分销的佣金
secondBrokeragePrice?: number | string; // 二级分销的佣金
salesCount?: number; // 商品销量
}
/** 优惠券模板 */
export interface GiveCouponTemplate {
/** 优惠券编号 */
id?: number;
/** 优惠券名称 */
name?: string;
id?: number; // 优惠券编号
name?: string; // 优惠券名称
}
/** 商品 SPU */
export interface Spu {
/** 商品编号 */
id?: number;
/** 商品名称 */
name?: string;
/** 商品分类 */
categoryId?: number;
/** 关键字 */
keyword?: string;
/** 单位 */
unit?: number | undefined;
/** 商品封面图 */
picUrl?: string;
/** 商品轮播图 */
sliderPicUrls?: string[];
/** 商品简介 */
introduction?: string;
/** 配送方式 */
deliveryTypes?: number[];
/** 运费模版 */
deliveryTemplateId?: number | undefined;
/** 商品品牌编号 */
brandId?: number;
/** 商品规格 */
specType?: boolean;
/** 分销类型 */
subCommissionType?: boolean;
/** sku数组 */
skus?: Sku[];
/** 商品详情 */
description?: string;
/** 商品排序 */
sort?: number;
/** 赠送积分 */
giveIntegral?: number;
/** 虚拟销量 */
virtualSalesCount?: number;
/** 商品价格 */
price?: number;
/** 商品拼团价格 */
combinationPrice?: number;
/** 商品秒杀价格 */
seckillPrice?: number;
/** 商品销量 */
salesCount?: number;
/** 市场价 */
marketPrice?: number;
/** 成本价 */
costPrice?: number;
/** 商品库存 */
stock?: number;
/** 商品创建时间 */
createTime?: Date;
/** 商品状态 */
status?: number;
/** 浏览量 */
browseCount?: number;
id?: number; // 商品编号
name?: string; // 商品名称
categoryId?: number; // 商品分类
keyword?: string; // 关键字
unit?: number; // 单位
picUrl?: string; // 商品封面图
sliderPicUrls?: string[]; // 商品轮播图
introduction?: string; // 商品简介
deliveryTypes?: number[]; // 配送方式
deliveryTemplateId?: number; // 运费模版
brandId?: number; // 商品品牌编号
specType?: boolean; // 商品规格
subCommissionType?: boolean; // 分销类型
skus?: Sku[]; // sku数组
description?: string; // 商品详情
sort?: number; // 商品排序
giveIntegral?: number; // 赠送积分
virtualSalesCount?: number; // 虚拟销量
price?: number; // 商品价格
combinationPrice?: number; // 商品拼团价格
seckillPrice?: number; // 商品秒杀价格
salesCount?: number; // 商品销量
marketPrice?: number; // 市场价
costPrice?: number; // 成本价
stock?: number; // 商品库存
createTime?: Date; // 商品创建时间
status?: number; // 商品状态
browseCount?: number; // 浏览量
}
/** 商品状态更新 */
export interface StatusUpdate {
/** 商品编号 */
id: number;
/** 商品状态 */
status: number;
id: number; // 商品编号
status: number; // 商品状态
}
}

View File

@@ -5,32 +5,19 @@ import { requestClient } from '#/api/request';
export namespace MallArticleApi {
/** 文章管理 */
export interface Article {
/** 文章编号 */
id: number;
/** 分类编号 */
categoryId: number;
/** 文章标题 */
title: string;
/** 作者 */
author: string;
/** 封面图 */
picUrl: string;
/** 文章简介 */
introduction: string;
/** 浏览数量 */
browseCount: string;
/** 排序 */
sort: number;
/** 状态 */
status: number;
/** 商品编号 */
spuId: number;
/** 是否热门 */
recommendHot: boolean;
/** 是否轮播图 */
recommendBanner: boolean;
/** 文章内容 */
content: string;
id: number; // 文章编号
categoryId: number; // 分类编号
title: string; // 文章标题
author: string; // 作者
picUrl: string; // 封面图
introduction: string; // 文章简介
browseCount: string; // 浏览数量
sort: number; // 排序
status: number; // 状态
spuId: number; // 商品编号
recommendHot: boolean; // 是否热门
recommendBanner: boolean; // 是否轮播图
content: string; // 文章内容
}
}

View File

@@ -5,16 +5,11 @@ import { requestClient } from '#/api/request';
export namespace MallArticleCategoryApi {
/** 文章分类 */
export interface ArticleCategory {
/** 分类编号 */
id: number;
/** 分类名称 */
name: string;
/** 分类图片 */
picUrl: string;
/** 状态 */
status: number;
/** 排序 */
sort: number;
id: number; // 分类编号
name: string; // 分类名称
picUrl: string; // 分类图片
status: number; // 状态
sort: number; // 排序
}
}

View File

@@ -7,62 +7,40 @@ import { requestClient } from '#/api/request';
export namespace MallBargainActivityApi {
/** 砍价活动 */
export interface BargainActivity {
/** 活动编号 */
id?: number;
/** 活动名称 */
name?: string;
/** 开始时间 */
startTime?: Date;
/** 结束时间 */
endTime?: Date;
/** 状态 */
status?: number;
/** 达到该人数,才能砍到低价 */
helpMaxCount?: number;
/** 最大帮砍次数 */
bargainCount?: number;
/** 最大购买次数 */
totalLimitCount?: number;
/** 商品 SPU 编号 */
spuId: number;
/** 商品 SKU 编号 */
skuId: number;
/** 砍价起始价格,单位分 */
bargainFirstPrice: number;
/** 砍价底价 */
bargainMinPrice: number;
/** 活动库存 */
stock: number;
/** 用户每次砍价的最小金额,单位:分 */
randomMinPrice?: number;
/** 用户每次砍价的最大金额,单位:分 */
randomMaxPrice?: number;
id?: number; // 活动编号
name?: string; // 活动名称
startTime?: Date; // 开始时间
endTime?: Date; // 结束时间
status?: number; // 状态
helpMaxCount?: number; // 达到该人数,才能砍到低价
bargainCount?: number; // 最大帮砍次数
totalLimitCount?: number; // 最大购买次数
spuId: number; // 商品 SPU 编号
skuId: number; // 商品 SKU 编号
bargainFirstPrice: number; // 砍价起始价格,单位分
bargainMinPrice: number; // 砍价底价
stock: number; // 活动库存
randomMinPrice?: number; // 用户每次砍价的最小金额,单位:分
randomMaxPrice?: number; // 用户每次砍价的最大金额,单位:分
}
/** 砍价活动所需属性。选择的商品和属性的时候使用方便使用活动的通用封装 */
export interface BargainProduct {
/** 商品 SPU 编号 */
spuId: number;
/** 商品 SKU 编号 */
skuId: number;
/** 砍价起始价格,单位分 */
bargainFirstPrice: number;
/** 砍价底价 */
bargainMinPrice: number;
/** 活动库存 */
stock: number;
spuId: number; // 商品 SPU 编号
skuId: number; // 商品 SKU 编号
bargainFirstPrice: number; // 砍价起始价格,单位分
bargainMinPrice: number; // 砍价底价
stock: number; // 活动库存
}
/** 扩展 SKU 配置 */
export type SkuExtension = {
/** 砍价活动配置 */
productConfig: BargainProduct;
productConfig: BargainProduct; // 砍价活动配置
} & MallSpuApi.Sku;
/** 扩展 SPU 配置 */
export interface SpuExtension extends MallSpuApi.Spu {
/** SKU 列表 */
skus: SkuExtension[];
skus: SkuExtension[]; // SKU 列表
}
}

View File

@@ -5,16 +5,11 @@ import { requestClient } from '#/api/request';
export namespace MallBargainHelpApi {
/** 砍价记录 */
export interface BargainHelp {
/** 记录编号 */
id: number;
/** 砍价记录编号 */
record: number;
/** 用户编号 */
userId: number;
/** 砍掉金额 */
reducePrice: number;
/** 结束时间 */
endTime: Date;
id: number; // 记录编号
record: number; // 砍价记录编号
userId: number; // 用户编号
reducePrice: number; // 砍掉金额
endTime: Date; // 结束时间
}
}

View File

@@ -5,26 +5,16 @@ import { requestClient } from '#/api/request';
export namespace MallBargainRecordApi {
/** 砍价记录 */
export interface BargainRecord {
/** 记录编号 */
id: number;
/** 活动编号 */
activityId: number;
/** 用户编号 */
userId: number;
/** 商品 SPU 编号 */
spuId: number;
/** 商品 SKU 编号 */
skuId: number;
/** 砍价起始价格 */
bargainFirstPrice: number;
/** 砍价价格 */
bargainPrice: number;
/** 状态 */
status: number;
/** 订单编号 */
orderId: number;
/** 结束时间 */
endTime: Date;
id: number; // 记录编号
activityId: number; // 活动编号
userId: number; // 用户编号
spuId: number; // 商品 SPU 编号
skuId: number; // 商品 SKU 编号
bargainFirstPrice: number; // 砍价起始价格
bargainPrice: number; // 砍价价格
status: number; // 状态
orderId: number; // 订单编号
endTime: Date; // 结束时间
}
}

View File

@@ -7,59 +7,38 @@ import { requestClient } from '#/api/request';
export namespace MallCombinationActivityApi {
/** 拼团活动所需属性 */
export interface CombinationProduct {
/** 商品 SPU 编号 */
spuId: number;
/** 商品 SKU 编号 */
skuId: number;
/** 拼团价格 */
combinationPrice: number;
spuId: number; // 商品 SPU 编号
skuId: number; // 商品 SKU 编号
combinationPrice: number; // 拼团价格
}
/** 拼团活动 */
export interface CombinationActivity {
/** 活动编号 */
id?: number;
/** 活动名称 */
name?: string;
/** 商品 SPU 编号 */
spuId?: number;
/** 总限购数量 */
totalLimitCount?: number;
/** 单次限购数量 */
singleLimitCount?: number;
/** 开始时间 */
startTime?: Date;
/** 结束时间 */
endTime?: Date;
/** 用户数量 */
userSize?: number;
/** 总数量 */
totalCount?: number;
/** 成功数量 */
successCount?: number;
/** 订单用户数量 */
orderUserCount?: number;
/** 虚拟成团 */
virtualGroup?: number;
/** 状态 */
status?: number;
/** 限制时长 */
limitDuration?: number;
/** 拼团价格 */
combinationPrice?: number;
/** 商品列表 */
products: CombinationProduct[];
id?: number; // 活动编号
name?: string; // 活动名称
spuId?: number; // 商品 SPU 编号
totalLimitCount?: number; // 总限购数量
singleLimitCount?: number; // 单次限购数量
startTime?: Date; // 开始时间
endTime?: Date; // 结束时间
userSize?: number; // 用户数量
totalCount?: number; // 总数量
successCount?: number; // 成功数量
orderUserCount?: number; // 订单用户数量
virtualGroup?: number; // 虚拟成团
status?: number; // 状态
limitDuration?: number; // 限制时长
combinationPrice?: number; // 拼团价格
products: CombinationProduct[]; // 商品列表
}
/** 扩展 SKU 配置 */
export type SkuExtension = {
/** 拼团活动配置 */
productConfig: CombinationProduct;
productConfig: CombinationProduct; // 拼团活动配置
} & MallSpuApi.Sku;
/** 扩展 SPU 配置 */
export interface SpuExtension extends MallSpuApi.Spu {
/** SKU 列表 */
skus: SkuExtension[];
skus: SkuExtension[]; // SKU 列表
}
}

View File

@@ -5,44 +5,27 @@ import { requestClient } from '#/api/request';
export namespace MallCombinationRecordApi {
/** 拼团记录 */
export interface CombinationRecord {
/** 拼团记录编号 */
id: number;
/** 拼团活动编号 */
activityId: number;
/** 用户昵称 */
nickname: string;
/** 用户头像 */
avatar: string;
/** 团长编号 */
headId: number;
/** 过期时间 */
expireTime: string;
/** 可参团人数 */
userSize: number;
/** 已参团人数 */
userCount: number;
/** 拼团状态 */
status: number;
/** 商品名字 */
spuName: string;
/** 商品图片 */
picUrl: string;
/** 是否虚拟成团 */
virtualGroup: boolean;
/** 开始时间 (订单付款后开始的时间) */
startTime: string;
/** 结束时间(成团时间/失败时间) */
endTime: string;
id: number; // 拼团记录编号
activityId: number; // 拼团活动编号
nickname: string; // 用户昵称
avatar: string; // 用户头像
headId: number; // 团长编号
expireTime: string; // 过期时间
userSize: number; // 可参团人数
userCount: number; // 已参团人数
status: number; // 拼团状态
spuName: string; // 商品名字
picUrl: string; // 商品图片
virtualGroup: boolean; // 是否虚拟成团
startTime: string; // 开始时间 (订单付款后开始的时间)
endTime: string; // 结束时间(成团时间/失败时间)
}
/** 拼团记录概要信息 */
export interface RecordSummary {
/** 待成团数量 */
pendingCount: number;
/** 已成团数量 */
successCount: number;
/** 已失败数量 */
failCount: number;
pendingCount: number; // 待成团数量
successCount: number; // 已成团数量
failCount: number; // 已失败数量
}
}

View File

@@ -5,46 +5,28 @@ import { requestClient } from '#/api/request';
export namespace MallCouponApi {
/** 优惠券 */
export interface Coupon {
/** 优惠券编号 */
id: number;
/** 优惠券名称 */
name: string;
/** 优惠券状态 */
status: number;
/** 优惠券类型 */
type: number;
/** 优惠券金额 */
price: number;
/** 使用门槛 */
usePrice: number;
/** 商品范围 */
productScope: number;
/** 商品编号数组 */
productSpuIds: number[];
/** 有效期类型 */
validityType: number;
/** 固定日期-生效开始时间 */
validStartTime: Date;
/** 固定日期-生效结束时间 */
validEndTime: Date;
/** 领取日期-开始天数 */
fixedStartTerm: number;
/** 领取日期-结束天数 */
fixedEndTerm: number;
/** 每人限领个数 */
takeLimitCount: number;
/** 是否设置满多少金额可用 */
usePriceEnabled: boolean;
/** 商品分类编号数组 */
productCategoryIds: number[];
id: number; // 优惠券编号
name: string; // 优惠券名称
status: number; // 优惠券状态
type: number; // 优惠券类型
price: number; // 优惠券金额
usePrice: number; // 使用门槛
productScope: number; // 商品范围
productSpuIds: number[]; // 商品编号数组
validityType: number; // 有效期类型
validStartTime: Date; // 固定日期-生效开始时间
validEndTime: Date; // 固定日期-生效结束时间
fixedStartTerm: number; // 领取日期-开始天数
fixedEndTerm: number; // 领取日期-结束天数
takeLimitCount: number; // 每人限领个数
usePriceEnabled: boolean; // 是否设置满多少金额可用
productCategoryIds: number[]; // 商品分类编号数组
}
/** 发送优惠券 */
export interface CouponSendReqVO {
/** 优惠券编号 */
templateId: number;
/** 用户编号数组 */
userIds: number[];
templateId: number; // 优惠券编号
userIds: number[]; // 用户编号数组
}
}

View File

@@ -5,54 +5,32 @@ import { requestClient } from '#/api/request';
export namespace MallCouponTemplateApi {
/** 优惠券模板 */
export interface CouponTemplate {
/** 模板编号 */
id: number;
/** 模板名称 */
name: string;
/** 状态 */
status: number;
/** 发放数量 */
totalCount: number;
/** 每人限领个数 */
takeLimitCount: number;
/** 领取方式 */
takeType: number;
/** 使用门槛 */
usePrice: number;
/** 商品范围 */
productScope: number;
/** 商品范围值 */
productScopeValues: number[];
/** 有效期类型 */
validityType: number;
/** 固定日期-生效开始时间 */
validStartTime: Date;
/** 固定日期-生效结束时间 */
validEndTime: Date;
/** 领取日期-开始天数 */
fixedStartTerm: number;
/** 领取日期-结束天数 */
fixedEndTerm: number;
/** 优惠类型 */
discountType: number;
/** 折扣百分比 */
discountPercent: number;
/** 优惠金额 */
discountPrice: number;
/** 折扣上限 */
discountLimitPrice: number;
/** 已领取数量 */
takeCount: number;
/** 已使用数量 */
useCount: number;
id: number; // 模板编号
name: string; // 模板名称
status: number; // 状态
totalCount: number; // 发放数量
takeLimitCount: number; // 每人限领个数
takeType: number; // 领取方式
usePrice: number; // 使用门槛
productScope: number; // 商品范围
productScopeValues: number[]; // 商品范围值
validityType: number; // 有效期类型
validStartTime: Date; // 固定日期-生效开始时间
validEndTime: Date; // 固定日期-生效结束时间
fixedStartTerm: number; // 领取日期-开始天数
fixedEndTerm: number; // 领取日期-结束天数
discountType: number; // 优惠类型
discountPercent: number; // 折扣百分比
discountPrice: number; // 优惠金额
discountLimitPrice: number; // 折扣上限
takeCount: number; // 已领取数量
useCount: number; // 已使用数量
}
/** 优惠券模板状态更新 */
export interface StatusUpdate {
/** 模板编号 */
id: number;
/** 状态 */
status: 0 | 1;
id: number; // 模板编号
status: 0 | 1; // 状态
}
}

View File

@@ -7,48 +7,33 @@ import { requestClient } from '#/api/request';
export namespace MallDiscountActivityApi {
/** 限时折扣相关属性 */
export interface DiscountProduct {
/** 商品 SPU 编号 */
spuId: number;
/** 商品 SKU 编号 */
skuId: number;
/** 折扣类型 */
discountType: number;
/** 折扣百分比 */
discountPercent: number;
/** 折扣价格 */
discountPrice: number;
spuId: number; // 商品 SPU 编号
skuId: number; // 商品 SKU 编号
discountType: number; // 折扣类型
discountPercent: number; // 折扣百分比
discountPrice: number; // 折扣价格
}
/** 限时折扣活动 */
export interface DiscountActivity {
/** 活动编号 */
id?: number;
/** 商品 SPU 编号 */
spuId?: number;
/** 活动名称 */
name?: string;
/** 状态 */
status?: number;
/** 备注 */
remark?: string;
/** 开始时间 */
startTime?: Date;
/** 结束时间 */
endTime?: Date;
/** 商品列表 */
products?: DiscountProduct[];
id?: number; // 活动编号
spuId?: number; // 商品 SPU 编号
name?: string; // 活动名称
status?: number; // 状态
remark?: string; // 备注
startTime?: Date; // 开始时间
endTime?: Date; // 结束时间
products?: DiscountProduct[]; // 商品列表
}
/** 扩展 SKU 配置 */
export type SkuExtension = {
/** 限时折扣配置 */
productConfig: DiscountProduct;
productConfig: DiscountProduct; // 限时折扣配置
} & MallSpuApi.Sku;
/** 扩展 SPU 配置 */
export interface SpuExtension extends MallSpuApi.Spu {
/** SKU 列表 */
skus: SkuExtension[];
skus: SkuExtension[]; // SKU 列表
}
}

View File

@@ -5,18 +5,12 @@ import { requestClient } from '#/api/request';
export namespace MallDiyPageApi {
/** 装修页面 */
export interface DiyPage {
/** 页面编号 */
id?: number;
/** 模板编号 */
templateId?: number;
/** 页面名称 */
name: string;
/** 备注 */
remark: string;
/** 预览图片地址数组 */
previewPicUrls: string[];
/** 页面属性 */
property: string;
id?: number; // 页面编号
templateId?: number; // 模板编号
name: string; // 页面名称
remark: string; // 备注
previewPicUrls: string[]; // 预览图片地址数组
property: string; // 页面属性
}
}

View File

@@ -7,26 +7,18 @@ import { requestClient } from '#/api/request';
export namespace MallDiyTemplateApi {
/** 装修模板 */
export interface DiyTemplate {
/** 模板编号 */
id?: number;
/** 模板名称 */
name: string;
/** 是否使用 */
used: boolean;
/** 使用时间 */
usedTime?: Date;
/** 备注 */
remark: string;
/** 预览图片地址数组 */
previewPicUrls: string[];
/** 模板属性 */
property: string;
id?: number; // 模板编号
name: string; // 模板名称
used: boolean; // 是否使用
usedTime?: Date; // 使用时间
remark: string; // 备注
previewPicUrls: string[]; // 预览图片地址数组
property: string; // 模板属性
}
/** 装修模板属性(包含页面列表) */
export interface DiyTemplateProperty extends DiyTemplate {
/** 页面列表 */
pages: MallDiyPageApi.DiyPage[];
pages: MallDiyPageApi.DiyPage[]; // 页面列表
}
}

View File

@@ -5,38 +5,24 @@ import { requestClient } from '#/api/request';
export namespace MallKefuConversationApi {
/** 客服会话 */
export interface Conversation {
/** 编号 */
id: number;
/** 会话所属用户 */
userId: number;
/** 会话所属用户头像 */
userAvatar: string;
/** 会话所属用户昵称 */
userNickname: string;
/** 最后聊天时间 */
lastMessageTime: Date;
/** 最后聊天内容 */
lastMessageContent: string;
/** 最后发送的消息类型 */
lastMessageContentType: number;
/** 管理端置顶 */
adminPinned: boolean;
/** 用户是否可见 */
userDeleted: boolean;
/** 管理员是否可见 */
adminDeleted: boolean;
/** 管理员未读消息数 */
adminUnreadMessageCount: number;
/** 创建时间 */
createTime?: string;
id: number; // 编号
userId: number; // 会话所属用户
userAvatar: string; // 会话所属用户头像
userNickname: string; // 会话所属用户昵称
lastMessageTime: Date; // 最后聊天时间
lastMessageContent: string; // 最后聊天内容
lastMessageContentType: number; // 最后发送的消息类型
adminPinned: boolean; // 管理端置顶
userDeleted: boolean; // 用户是否可见
adminDeleted: boolean; // 管理员是否可见
adminUnreadMessageCount: number; // 管理员未读消息数
createTime?: string; // 创建时间
}
/** 会话置顶请求 */
export interface ConversationPinnedUpdate {
/** 会话编号 */
id: number;
/** 是否置顶 */
pinned: boolean;
id: number; // 会话编号
pinned: boolean; // 是否置顶
}
}

View File

@@ -5,44 +5,29 @@ import { requestClient } from '#/api/request';
export namespace MallKefuMessageApi {
/** 客服消息 */
export interface Message {
/** 编号 */
id: number;
/** 会话编号 */
conversationId: number;
/** 发送人编号 */
senderId: number;
/** 发送人头像 */
senderAvatar: string;
/** 发送人类型 */
senderType: number;
/** 接收人编号 */
receiverId: number;
/** 接收人类型 */
receiverType: number;
/** 消息类型 */
contentType: number;
/** 消息内容 */
content: string;
/** 是否已读 */
readStatus: boolean;
/** 创建时间 */
createTime: Date;
id: number; // 编号
conversationId: number; // 会话编号
senderId: number; // 发送人编号
senderAvatar: string; // 发送人头像
senderType: number; // 发送人类型
receiverId: number; // 接收人编号
receiverType: number; // 接收人类型
contentType: number; // 消息类型
content: string; // 消息内容
readStatus: boolean; // 是否已读
createTime: Date; // 创建时间
}
/** 发送消息请求 */
export interface MessageSend {
/** 会话编号 */
conversationId: number;
/** 消息类型 */
contentType: number;
/** 消息内容 */
content: string;
conversationId: number; // 会话编号
contentType: number; // 消息类型
content: string; // 消息内容
}
/** 消息列表查询参数 */
export interface MessageQuery extends PageParam {
/** 会话编号 */
conversationId: number;
conversationId: number; // 会话编号
}
}

View File

@@ -7,80 +7,51 @@ import { requestClient } from '#/api/request';
export namespace MallPointActivityApi {
/** 积分商城商品 */
export interface PointProduct {
/** 积分商城商品编号 */
id?: number;
/** 积分商城活动 id */
activityId?: number;
/** 商品 SPU 编号 */
spuId?: number;
/** 商品 SKU 编号 */
skuId: number;
/** 可兑换数量 */
count: number;
/** 兑换积分 */
point: number;
/** 兑换金额,单位:分 */
price: number;
/** 积分商城商品库存 */
stock: number;
/** 积分商城商品状态 */
activityStatus?: number;
id?: number; // 积分商城商品编号
activityId?: number; // 积分商城活动 id
spuId?: number; // 商品 SPU 编号
skuId: number; // 商品 SKU 编号
count: number; // 可兑换数量
point: number; // 兑换积分
price: number; // 兑换金额,单位:分
stock: number; // 积分商城商品库存
activityStatus?: number; // 积分商城商品状态
}
/** 积分商城活动 */
export interface PointActivity {
/** 积分商城活动编号 */
id: number;
/** 积分商城活动商品 */
spuId: number;
/** 活动状态 */
status: number;
/** 积分商城活动库存 */
stock: number;
/** 积分商城活动总库存 */
totalStock: number;
/** 备注 */
remark?: string;
/** 排序 */
sort: number;
/** 创建时间 */
createTime: string;
/** 积分商城商品 */
products: PointProduct[];
/** 商品名称 */
spuName: string;
/** 商品主图 */
picUrl: string;
/** 商品市场价,单位:分 */
marketPrice: number;
/** 兑换积分 */
point: number;
/** 兑换金额,单位:分 */
price: number;
id: number; // 积分商城活动编号
spuId: number; // 积分商城活动商品
status: number; // 活动状态
stock: number; // 积分商城活动库存
totalStock: number; // 积分商城活动总库存
remark?: string; // 备注
sort: number; // 排序
createTime: string; // 创建时间
products: PointProduct[]; // 积分商城商品
spuName: string; // 商品名称
picUrl: string; // 商品主图
marketPrice: number; // 商品市场价,单位:分
point: number; // 兑换积分
price: number; // 兑换金额,单位:分
}
/** 扩展 SKU 配置 */
export type SkuExtension = {
/** 积分商城商品配置 */
productConfig: PointProduct;
productConfig: PointProduct; // 积分商城商品配置
} & MallSpuApi.Sku;
/** 扩展 SPU 配置 */
export interface SpuExtension extends MallSpuApi.Spu {
/** SKU 列表 */
skus: SkuExtension[];
skus: SkuExtension[]; // SKU 列表
}
/** 扩展 SPU 配置(带积分信息) */
export interface SpuExtensionWithPoint extends MallSpuApi.Spu {
/** 积分商城活动库存 */
pointStock: number;
/** 积分商城活动总库存 */
pointTotalStock: number;
/** 兑换积分 */
point: number;
/** 兑换金额,单位:分 */
pointPrice: number;
pointStock: number; // 积分商城活动库存
pointTotalStock: number; // 积分商城活动总库存
point: number; // 兑换积分
pointPrice: number; // 兑换金额,单位:分
}
}

View File

@@ -5,46 +5,29 @@ import { requestClient } from '#/api/request';
export namespace MallRewardActivityApi {
/** 优惠规则 */
export interface RewardRule {
/** 满足金额 */
limit?: number;
/** 优惠金额 */
discountPrice?: number;
/** 是否包邮 */
freeDelivery?: boolean;
/** 赠送积分 */
point: number;
/** 赠送优惠券数量 */
limit?: number; // 满足金额
discountPrice?: number; // 优惠金额
freeDelivery?: boolean; // 是否包邮
point: number; // 赠送积分
giveCouponTemplateCounts?: {
[key: number]: number;
};
}; // 赠送优惠券数量
}
/** 满减送活动 */
export interface RewardActivity {
/** 活动编号 */
id?: number;
/** 活动名称 */
name?: string;
/** 开始时间 */
startTime?: Date;
/** 结束时间 */
endTime?: Date;
/** 开始和结束时间(仅前端使用) */
startAndEndTime?: Date[];
/** 备注 */
remark?: string;
/** 条件类型 */
conditionType?: number;
/** 商品范围 */
productScope?: number;
/** 优惠规则列表 */
rules: RewardRule[];
/** 商品范围值(仅表单使用):值为品类编号列表、商品编号列表 */
productScopeValues?: number[];
/** 商品分类编号列表(仅表单使用) */
productCategoryIds?: number[];
/** 商品 SPU 编号列表(仅表单使用) */
productSpuIds?: number[];
id?: number; // 活动编号
name?: string; // 活动名称
startTime?: Date; // 开始时间
endTime?: Date; // 结束时间
startAndEndTime?: Date[]; // 开始和结束时间(仅前端使用)
remark?: string; // 备注
conditionType?: number; // 条件类型
productScope?: number; // 商品范围
rules: RewardRule[]; // 优惠规则列表
productScopeValues?: number[]; // 商品范围值(仅表单使用):值为品类编号列表、商品编号列表
productCategoryIds?: number[]; // 商品分类编号列表(仅表单使用)
productSpuIds?: number[]; // 商品 SPU 编号列表(仅表单使用)
}
}

View File

@@ -7,66 +7,42 @@ import { requestClient } from '#/api/request';
export namespace MallSeckillActivityApi {
/** 秒杀商品 */
export interface SeckillProduct {
/** 商品 SKU 编号 */
skuId: number;
/** 商品 SPU 编号 */
spuId: number;
/** 秒杀价格 */
seckillPrice: number;
/** 秒杀库存 */
stock: number;
skuId: number; // 商品 SKU 编号
spuId: number; // 商品 SPU 编号
seckillPrice: number; // 秒杀价格
stock: number; // 秒杀库存
}
/** 秒杀活动 */
export interface SeckillActivity {
/** 活动编号 */
id?: number;
/** 商品 SPU 编号 */
spuId?: number;
/** 活动名称 */
name?: string;
/** 活动状态 */
status?: number;
/** 备注 */
remark?: string;
/** 开始时间 */
startTime?: Date;
/** 结束时间 */
endTime?: Date;
/** 排序 */
sort?: number;
/** 配置编号 */
configIds?: string;
/** 订单数量 */
orderCount?: number;
/** 用户数量 */
userCount?: number;
/** 总金额 */
totalPrice?: number;
/** 总限购数量 */
totalLimitCount?: number;
/** 单次限购数量 */
singleLimitCount?: number;
/** 秒杀库存 */
stock?: number;
/** 秒杀总库存 */
totalStock?: number;
/** 秒杀价格 */
seckillPrice?: number;
/** 秒杀商品列表 */
products?: SeckillProduct[];
id?: number; // 活动编号
spuId?: number; // 商品 SPU 编号
name?: string; // 活动名称
status?: number; // 活动状态
remark?: string; // 备注
startTime?: Date; // 开始时间
endTime?: Date; // 结束时间
sort?: number; // 排序
configIds?: string; // 配置编号
orderCount?: number; // 订单数量
userCount?: number; // 用户数量
totalPrice?: number; // 总金额
totalLimitCount?: number; // 总限购数量
singleLimitCount?: number; // 单次限购数量
stock?: number; // 秒杀库存
totalStock?: number; // 秒杀总库存
seckillPrice?: number; // 秒杀价格
products?: SeckillProduct[]; // 秒杀商品列表
}
/** 扩展 SKU 配置 */
export type SkuExtension = {
/** 秒杀商品配置 */
productConfig: SeckillProduct;
productConfig: SeckillProduct; // 秒杀商品配置
} & MallSpuApi.Sku;
/** 扩展 SPU 配置 */
export interface SpuExtension extends MallSpuApi.Spu {
/** SKU 列表 */
skus: SkuExtension[];
skus: SkuExtension[]; // SKU 列表
}
}

View File

@@ -5,26 +5,18 @@ import { requestClient } from '#/api/request';
export namespace MallSeckillConfigApi {
/** 秒杀时段 */
export interface SeckillConfig {
/** 编号 */
id: number;
/** 秒杀时段名称 */
name: string;
/** 开始时间点 */
startTime: string;
/** 结束时间点 */
endTime: string;
/** 秒杀轮播图 */
sliderPicUrls: string[];
/** 活动状态 */
status: number;
id: number; // 编号
name: string; // 秒杀时段名称
startTime: string; // 开始时间点
endTime: string; // 结束时间点
sliderPicUrls: string[]; // 秒杀轮播图
status: number; // 活动状态
}
/** 时段配置状态更新 */
export interface StatusUpdate {
/** 编号 */
id: number;
/** 状态 */
status: number;
id: number; // 编号
status: number; // 状态
}
}

View File

@@ -7,67 +7,65 @@ import { requestClient } from '#/api/request';
export namespace MallMemberStatisticsApi {
/** 会员分析 Request */
export interface AnalyseReq {
times: Date[];
times: Date[]; // 时间范围
}
/** 会员分析对照数据 Response */
export interface AnalyseComparison {
registerUserCount: number;
visitUserCount: number;
rechargeUserCount: number;
registerUserCount: number; // 注册用户数
visitUserCount: number; // 访问用户数
rechargeUserCount: number; // 充值用户数
}
/** 会员分析 Response */
export interface Analyse {
visitUserCount: number;
orderUserCount: number;
payUserCount: number;
atv: number;
comparison: MallDataComparisonResp<AnalyseComparison>;
visitUserCount: number; // 访问用户数
orderUserCount: number; // 下单用户数
payUserCount: number; // 支付用户数
atv: number; // 平均客单价
comparison: MallDataComparisonResp<AnalyseComparison>; // 对照数据
}
/** 会员地区统计 Response */
export interface AreaStatistics {
areaId: number;
areaName: string;
userCount: number;
orderCreateUserCount: number;
orderPayUserCount: number;
orderPayPrice: number;
areaId: number; // 地区ID
areaName: string; // 地区名称
userCount: number; // 用户数
orderCreateUserCount: number; // 下单用户数
orderPayUserCount: number; // 支付用户数
orderPayPrice: number; // 支付金额
}
/** 会员性别统计 Response */
export interface SexStatistics {
sex: number;
userCount: number;
sex: number; // 性别
userCount: number; // 用户数
}
/** 会员统计 Response */
export interface Summary {
userCount: number;
rechargeUserCount: number;
rechargePrice: number;
expensePrice: number;
userCount: number; // 用户数
rechargeUserCount: number; // 充值用户数
rechargePrice: number; // 充值金额
expensePrice: number; // 消费金额
}
/** 会员终端统计 Response */
export interface TerminalStatistics {
terminal: number;
userCount: number;
terminal: number; // 终端
userCount: number; // 用户数
}
/** 会员数量统计 Response */
export interface Count {
/** 用户访问量 */
visitUserCount: string;
/** 注册用户数量 */
registerUserCount: number;
visitUserCount: string; // 用户访问量
registerUserCount: number; // 注册用户数量
}
/** 会员注册数量 Response */
export interface RegisterCount {
date: string;
count: number;
date: string; // 日期
count: number; // 数量
}
}

View File

@@ -5,28 +5,17 @@ import { requestClient } from '#/api/request';
export namespace MallBrokerageRecordApi {
/** 佣金记录 */
export interface BrokerageRecord {
/** 编号 */
id: number;
/** 用户编号 */
userId: number;
/** 用户昵称 */
userNickname: string;
/** 用户头像 */
userAvatar: string;
/** 佣金金额,单位:分 */
price: number;
/** 佣金类型 */
type: number;
/** 关联订单编号 */
orderId: number;
/** 关联订单号 */
orderNo: string;
/** 创建时间 */
createTime: Date;
/** 状态 */
status: number;
/** 结算时间 */
settlementTime: Date;
id: number; // 编号
userId: number; // 用户编号
userNickname: string; // 用户昵称
userAvatar: string; // 用户头像
price: number; // 佣金金额,单位:分
type: number; // 佣金类型
orderId: number; // 关联订单编号
orderNo: string; // 关联订单号
createTime: Date; // 创建时间
status: number; // 状态
settlementTime: Date; // 结算时间
}
}

View File

@@ -5,59 +5,45 @@ import { requestClient } from '#/api/request';
export namespace MallBrokerageUserApi {
/** 分销用户 */
export interface BrokerageUser {
/** 编号 */
id: number;
/** 推广员编号 */
bindUserId: number;
/** 推广员绑定时间 */
bindUserTime: Date;
/** 是否启用分销 */
brokerageEnabled: boolean;
/** 分销资格时间 */
brokerageTime: Date;
/** 可提现金额,单位:分 */
price: number;
/** 冻结金额,单位:分 */
frozenPrice: number;
/** 用户昵称 */
nickname: string;
/** 用户头像 */
avatar: string;
id: number; // 编号
bindUserId: number; // 推广员编号
bindUserTime: Date; // 推广员绑定时间
brokerageEnabled: boolean; // 是否启用分销
brokerageTime: Date; // 分销资格时间
price: number; // 可提现金额,单位:分
frozenPrice: number; // 冻结金额,单位:分
nickname: string; // 用户昵称
avatar: string; // 用户头像
}
/** 创建分销用户请求 */
export interface CreateRequest {
/** 用户编号 */
userId: number;
/** 推广员编号 */
bindUserId: number;
export interface BrokerageUserCreateReqVO {
userId: number; // 用户编号
bindUserId: number; // 推广员编号
}
/** 修改推广员请求 */
export interface UpdateBindUserRequest {
/** 用户编号 */
id: number;
/** 推广员编号 */
bindUserId: number;
export interface BrokerageUserUpdateReqVO {
id: number; // 用户编号
bindUserId: number; // 推广员编号
}
/** 清除推广员请求 */
export interface ClearBindUserRequest {
/** 用户编号 */
id: number;
export interface BrokerageUserClearBrokerageUserReqVO {
id: number; // 用户编号
}
/** 修改推广资格请求 */
export interface UpdateBrokerageEnabledRequest {
/** 用户编号 */
id: number;
/** 是否启用分销 */
enabled: boolean;
export interface BrokerageUserUpdateBrokerageEnabledReqVO {
id: number; // 用户编号
enabled: boolean; // 是否启用分销
}
}
/** 创建分销用户 */
export function createBrokerageUser(data: MallBrokerageUserApi.CreateRequest) {
export function createBrokerageUser(
data: MallBrokerageUserApi.BrokerageUserCreateReqVO,
) {
return requestClient.post('/trade/brokerage-user/create', data);
}
@@ -78,19 +64,21 @@ export function getBrokerageUser(id: number) {
/** 修改推广员 */
export function updateBindUser(
data: MallBrokerageUserApi.UpdateBindUserRequest,
data: MallBrokerageUserApi.BrokerageUserUpdateReqVO,
) {
return requestClient.put('/trade/brokerage-user/update-bind-user', data);
}
/** 清除推广员 */
export function clearBindUser(data: MallBrokerageUserApi.ClearBindUserRequest) {
export function clearBindUser(
data: MallBrokerageUserApi.BrokerageUserClearBrokerageUserReqVO,
) {
return requestClient.put('/trade/brokerage-user/clear-bind-user', data);
}
/** 修改推广资格 */
export function updateBrokerageEnabled(
data: MallBrokerageUserApi.UpdateBrokerageEnabledRequest,
data: MallBrokerageUserApi.BrokerageUserUpdateBrokerageEnabledReqVO,
) {
return requestClient.put(
'/trade/brokerage-user/update-brokerage-enable',

View File

@@ -5,52 +5,31 @@ import { requestClient } from '#/api/request';
export namespace MallBrokerageWithdrawApi {
/** 佣金提现 */
export interface BrokerageWithdraw {
/** 编号 */
id: number;
/** 用户编号 */
userId: number;
/** 提现金额,单位:分 */
price: number;
/** 手续费,单位:分 */
feePrice: number;
/** 总金额,单位:分 */
totalPrice: number;
/** 提现类型 */
type: number;
/** 用户名称 */
userName: string;
/** 用户账号 */
userAccount: string;
/** 银行名称 */
bankName: string;
/** 银行地址 */
bankAddress: string;
/** 收款码地址 */
qrCodeUrl: string;
/** 状态 */
status: number;
/** 审核备注 */
auditReason: string;
/** 审核时间 */
auditTime: Date;
/** 备注 */
remark: string;
/** 支付转账编号 */
payTransferId?: number;
/** 转账渠道编码 */
transferChannelCode?: string;
/** 转账时间 */
transferTime?: Date;
/** 转账错误信息 */
transferErrorMsg?: string;
id: number; // 编号
userId: number; // 用户编号
price: number; // 提现金额,单位:分
feePrice: number; // 手续费,单位:分
totalPrice: number; // 总金额,单位:分
type: number; // 提现类型
userName: string; // 用户名称
userAccount: string; // 用户账号
bankName: string; // 银行名称
bankAddress: string; // 银行地址
qrCodeUrl: string; // 收款码地址
status: number; // 状态
auditReason: string; // 审核备注
auditTime: Date; // 审核时间
remark: string; // 备注
payTransferId?: number; // 支付转账编号
transferChannelCode?: string; // 转账渠道编码
transferTime?: Date; // 转账时间
transferErrorMsg?: string; // 转账错误信息
}
/** 驳回申请请求 */
export interface RejectRequest {
/** 编号 */
id: number;
/** 驳回原因 */
auditReason: string;
id: number; // 编号
auditReason: string; // 驳回原因
}
}

View File

@@ -38,9 +38,9 @@ export namespace MallDeliveryExpressTemplateApi {
/** 排序 */
sort: number;
/** 计费区域列表 */
templateCharge: TemplateCharge[];
charges: TemplateCharge[];
/** 包邮区域列表 */
templateFree: TemplateFree[];
frees: TemplateFree[];
}
/** 运费模板精简信息 */

View File

@@ -29,21 +29,18 @@ export namespace MallDeliveryPickUpStoreApi {
longitude: number;
/** 状态 */
status: number;
/** 绑定用户编号组数 */
verifyUserIds: number[];
/** 营业时间 用于fieldMappingTime */
rangeTime: any[];
/** 绑定用户编号组数 */
verifyUserIds?: number[];
verifyUsers?: any[];
}
/** 绑定自提店员请求 */
export interface BindStaffRequest {
export interface DeliveryPickUpBindReqVO {
id?: number;
/** 门店名称 */
name: string;
/** 门店编号 */
storeId: number;
/** 用户编号列表 */
userIds: number[];
verifyUserIds: number[];
}
}
@@ -89,8 +86,8 @@ export function deleteDeliveryPickUpStore(id: number) {
}
/** 绑定自提店员 */
export function bindStoreStaffId(
data: MallDeliveryPickUpStoreApi.BindStaffRequest,
export function bindDeliveryPickUpStore(
data: MallDeliveryPickUpStoreApi.DeliveryPickUpBindReqVO,
) {
return requestClient.post('/trade/delivery/pick-up-store/bind', data);
}

View File

@@ -1,4 +1,4 @@
import type { PageParam } from '@vben/request';
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
@@ -20,17 +20,24 @@ export namespace SystemDictDataApi {
// 查询字典数据(精简)列表
export function getSimpleDictDataList() {
return requestClient.get('/system/dict-data/simple-list');
return requestClient.get<SystemDictDataApi.DictData[]>(
'/system/dict-data/simple-list',
);
}
// 查询字典数据列表
export function getDictDataPage(params: PageParam) {
return requestClient.get('/system/dict-data/page', { params });
return requestClient.get<PageResult<SystemDictDataApi.DictData>>(
'/system/dict-data/page',
{ params },
);
}
// 查询字典数据详情
export function getDictData(id: number) {
return requestClient.get(`/system/dict-data/get?id=${id}`);
return requestClient.get<SystemDictDataApi.DictData>(
`/system/dict-data/get?id=${id}`,
);
}
// 新增字典数据

View File

@@ -1,3 +1,5 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export namespace SystemDictTypeApi {
@@ -14,17 +16,24 @@ export namespace SystemDictTypeApi {
// 查询字典(精简)列表
export function getSimpleDictTypeList() {
return requestClient.get('/system/dict-type/list-all-simple');
return requestClient.get<SystemDictTypeApi.DictType[]>(
'/system/dict-type/list-all-simple',
);
}
// 查询字典列表
export function getDictTypePage(params: any) {
return requestClient.get('/system/dict-type/page', { params });
export function getDictTypePage(params: PageParam) {
return requestClient.get<PageResult<SystemDictTypeApi.DictType>>(
'/system/dict-type/page',
{ params },
);
}
// 查询字典详情
export function getDictType(id: number) {
return requestClient.get(`/system/dict-type/get?id=${id}`);
return requestClient.get<SystemDictTypeApi.DictType>(
`/system/dict-type/get?id=${id}`,
);
}
// 新增字典

View File

@@ -3,7 +3,7 @@ import { h, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { BpmProcessInstanceStatus, DICT_TYPE } from '@vben/constants';
import { UndoOutlined, ZoomInOutlined, ZoomOutOutlined } from '@vben/icons';
import { dateFormatter, formatPast2 } from '@vben/utils';
import { formatDate, formatPast2 } from '@vben/utils';
import { Button, ButtonGroup, Modal, Row, Table } from 'ant-design-vue';
import BpmnViewer from 'bpmn-js/lib/Viewer';
@@ -345,14 +345,14 @@ onBeforeUnmount(() => {
</template>
</Table.Column>
<Table.Column
:custom-render="({ text }) => dateFormatter(text)"
:custom-render="({ text }) => formatDate(text)"
align="center"
title="开始时间"
data-index="createTime"
width="140"
/>
<Table.Column
:custom-render="({ text }) => dateFormatter(text)"
:custom-render="({ text }) => formatDate(text)"
align="center"
title="结束时间"
data-index="endTime"

View File

@@ -18,23 +18,23 @@
width: 100%;
min-height: 36px;
.el-button {
.ant-button {
text-align: center;
}
.el-button-group {
.ant-button-group {
margin: 4px;
}
.el-tooltip__popper {
.el-button {
.ant-tooltip__popper {
.ant-button {
width: 100%;
padding-right: 8px;
padding-left: 8px;
text-align: left;
}
.el-button:hover {
.ant-button:hover {
color: #fff;
background: rgb(64 158 255 / 80%);
}
@@ -175,7 +175,6 @@ pre {
}
.hljs {
word-break: break-word;
white-space: pre-wrap;
}

View File

@@ -59,7 +59,7 @@
line-height: 32px;
}
.el-form-item {
.ant-form-item {
width: 100%;
padding-bottom: 18px;
margin-bottom: 0;
@@ -106,22 +106,22 @@
margin-top: 8px;
}
.element-drawer__button > .el-button {
.element-drawer__button > .ant-button {
width: 100%;
}
.el-collapse-item__content {
.ant-collapse-item__content {
padding-bottom: 0;
}
.el-input.is-disabled .el-input__inner {
.ant-input.is-disabled .ant-input__inner {
color: #999;
}
.el-form-item.el-form-item--mini {
.ant-form-item.ant-form-item--mini {
margin-bottom: 0;
& + .el-form-item {
& + .ant-form-item {
margin-top: 16px;
}
}

View File

@@ -314,11 +314,11 @@ function getValue() {
<style scoped>
.upload-drag-area {
border: 2px dashed #d9d9d9;
border-radius: 8px;
padding: 20px;
text-align: center;
background-color: #fafafa;
border: 2px dashed #d9d9d9;
border-radius: 8px;
transition: border-color 0.3s;
}
@@ -327,15 +327,15 @@ function getValue() {
}
.ant-upload-drag-icon {
margin-bottom: 16px;
font-size: 48px;
color: #d9d9d9;
margin-bottom: 16px;
}
.ant-upload-text {
margin-bottom: 8px;
font-size: 16px;
color: #666;
margin-bottom: 8px;
}
.ant-upload-hint {

View File

@@ -13,7 +13,7 @@ import {
AntdProfileOutlined,
BookOpenText,
CircleHelp,
MdiGithub,
SvgGithubIcon,
} from '@vben/icons';
import {
BasicLayout,
@@ -79,7 +79,7 @@ const menus = computed(() => [
target: '_blank',
});
},
icon: MdiGithub,
icon: SvgGithubIcon,
text: 'GitHub',
},
{
@@ -202,11 +202,16 @@ onMounted(() => {
});
watch(
() => preferences.app.watermark,
async (enable) => {
() => ({
enable: preferences.app.watermark,
content: preferences.app.watermarkContent,
}),
async ({ enable, content }) => {
if (enable) {
await updateWatermark({
content: `${userStore.userInfo?.id} - ${userStore.userInfo?.nickname}`,
content:
content ||
`${userStore.userInfo?.id} - ${userStore.userInfo?.nickname}`,
});
} else {
destroyWatermark();

View File

@@ -1,5 +1,6 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ErpAccountApi } from '#/api/erp/finance/account';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
@@ -123,10 +124,10 @@ export function useGridFormSchema(): VbenFormSchema[] {
}
/** 列表的字段 */
export function useGridColumns<T = ErpAccountApi.Account>(
export function useGridColumns(
onDefaultStatusChange?: (
newStatus: boolean,
row: T,
row: ErpAccountApi.Account,
) => PromiseLike<boolean | undefined>,
): VxeTableGridOptions['columns'] {
return [

View File

@@ -121,10 +121,10 @@ export function useGridFormSchema(): VbenFormSchema[] {
}
/** 列表的字段 */
export function useGridColumns<T = ErpWarehouseApi.Warehouse>(
export function useGridColumns(
onDefaultStatusChange?: (
newStatus: boolean,
row: T,
row: ErpWarehouseApi.Warehouse,
) => PromiseLike<boolean | undefined>,
): VxeTableGridOptions['columns'] {
return [

View File

@@ -37,7 +37,7 @@ export function useFormSchema(): VbenFormSchema[] {
rules: 'required',
dependencies: {
triggerFields: ['id'],
show: (formValues) => !formValues.id,
disabled: (formValues) => formValues.id,
},
},
{

View File

@@ -4,14 +4,14 @@ import type { AlertConfigApi } from '#/api/iot/alert/config';
import { Page, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { message, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteAlertConfig, getAlertConfigPage } from '#/api/iot/alert/config';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import AlertConfigForm from '../modules/AlertConfigForm.vue';
import { useGridColumns, useGridFormSchema } from './data';
defineOptions({ name: 'IoTAlertConfig' });
@@ -26,7 +26,7 @@ function onRefresh() {
}
// 获取告警级别文本
const getLevelText = (level?: number) => {
function getLevelText(level?: number) {
const levelMap: Record<number, string> = {
1: '提示',
2: '一般',
@@ -35,10 +35,10 @@ const getLevelText = (level?: number) => {
5: '紧急',
};
return level ? levelMap[level] || `级别${level}` : '-';
};
}
// 获取告警级别颜色
const getLevelColor = (level?: number) => {
function getLevelColor(level?: number) {
const colorMap: Record<number, string> = {
1: 'blue',
2: 'green',
@@ -47,10 +47,10 @@ const getLevelColor = (level?: number) => {
5: 'purple',
};
return level ? colorMap[level] || 'default' : 'default';
};
}
// 获取接收类型文本
const getReceiveTypeText = (type?: number) => {
function getReceiveTypeText(type?: number) {
const typeMap: Record<number, string> = {
1: '站内信',
2: '邮箱',
@@ -59,7 +59,7 @@ const getReceiveTypeText = (type?: number) => {
5: '钉钉',
};
return type ? typeMap[type] || `类型${type}` : '-';
};
}
/** 创建告警配置 */
function handleCreate() {
@@ -138,9 +138,9 @@ const [Grid, gridApi] = useVbenVxeGrid({
<!-- 告警级别列 -->
<template #level="{ row }">
<a-tag :color="getLevelColor(row.level)">
<Tag :color="getLevelColor(row.level)">
{{ getLevelText(row.level) }}
</a-tag>
</Tag>
</template>
<!-- 关联场景联动规则列 -->
@@ -150,13 +150,13 @@ const [Grid, gridApi] = useVbenVxeGrid({
<!-- 接收类型列 -->
<template #receiveTypes="{ row }">
<a-tag
<Tag
v-for="(type, index) in row.receiveTypes"
:key="index"
class="mr-1"
>
{{ getReceiveTypeText(type) }}
</a-tag>
</Tag>
</template>
<!-- 操作列 -->

View File

@@ -49,7 +49,9 @@ const [Modal, modalApi] = useVbenModal({
// 提交表单
const data = (await formApi.getValues()) as AlertConfigApi.AlertConfig;
try {
await (formData.value?.id ? updateAlertConfig(data) : createAlertConfig(data));
await (formData.value?.id
? updateAlertConfig(data)
: createAlertConfig(data));
// 关闭并提示
await modalApi.close();
emit('success');

View File

@@ -1,14 +1,15 @@
<script setup lang="ts">
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AlertRecord } from '#/api/iot/alert/record';
import { h, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Modal, message } from 'ant-design-vue';
import { Button, message, Modal, Popover, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import type { AlertRecord } from '#/api/iot/alert/record';
import { getAlertRecordPage, processAlertRecord } from '#/api/iot/alert/record';
import { getSimpleDeviceList } from '#/api/iot/device/device';
import { getSimpleProductList } from '#/api/iot/product/product';
@@ -26,13 +27,13 @@ function onRefresh() {
}
// 加载产品和设备列表
const loadData = async () => {
async function loadData() {
productList.value = await getSimpleProductList();
deviceList.value = await getSimpleDeviceList();
};
}
// 获取告警级别文本
const getLevelText = (level?: number) => {
function getLevelText(level?: number) {
const levelMap: Record<number, string> = {
1: '提示',
2: '一般',
@@ -41,10 +42,10 @@ const getLevelText = (level?: number) => {
5: '紧急',
};
return level ? levelMap[level] || `级别${level}` : '-';
};
}
// 获取告警级别颜色
const getLevelColor = (level?: number) => {
function getLevelColor(level?: number) {
const colorMap: Record<number, string> = {
1: 'blue',
2: 'green',
@@ -53,24 +54,24 @@ const getLevelColor = (level?: number) => {
5: 'purple',
};
return level ? colorMap[level] || 'default' : 'default';
};
}
// 获取产品名称
const getProductName = (productId?: number) => {
function getProductName(productId?: number) {
if (!productId) return '-';
const product = productList.value.find((p: any) => p.id === productId);
return product?.name || '加载中...';
};
}
// 获取设备名称
const getDeviceName = (deviceId?: number) => {
function getDeviceName(deviceId?: number) {
if (!deviceId) return '-';
const device = deviceList.value.find((d: any) => d.id === deviceId);
return device?.deviceName || '加载中...';
};
}
// 处理告警记录
const handleProcess = async (row: AlertRecord) => {
async function handleProcess(row: AlertRecord) {
Modal.confirm({
title: '处理告警记录',
content: h('div', [
@@ -83,14 +84,16 @@ const handleProcess = async (row: AlertRecord) => {
}),
]),
async onOk() {
const textarea = document.getElementById('processRemark') as HTMLTextAreaElement;
const textarea = document.querySelector(
'#processRemark',
) as HTMLTextAreaElement;
const processRemark = textarea?.value || '';
if (!processRemark) {
message.warning('请输入处理原因');
return Promise.reject();
throw new Error('请输入处理原因');
}
const hideLoading = message.loading({
content: '正在处理...',
duration: 0,
@@ -101,16 +104,16 @@ const handleProcess = async (row: AlertRecord) => {
onRefresh();
} catch (error) {
console.error('处理失败:', error);
return Promise.reject();
throw error;
} finally {
hideLoading();
}
},
});
};
}
// 查看告警记录详情
const handleView = (row: AlertRecord) => {
function handleView(row: AlertRecord) {
Modal.info({
title: '告警记录详情',
width: 600,
@@ -125,7 +128,11 @@ const handleView = (row: AlertRecord) => {
]),
h('div', [
h('span', { class: 'font-semibold' }, '设备消息:'),
h('pre', { class: 'mt-1 text-xs bg-gray-50 p-2 rounded' }, row.deviceMessage || '-'),
h(
'pre',
{ class: 'mt-1 text-xs bg-gray-50 p-2 rounded' },
row.deviceMessage || '-',
),
]),
h('div', [
h('span', { class: 'font-semibold' }, '处理结果:'),
@@ -133,11 +140,16 @@ const handleView = (row: AlertRecord) => {
]),
h('div', [
h('span', { class: 'font-semibold' }, '处理时间:'),
h('span', row.processTime ? new Date(row.processTime).toLocaleString('zh-CN') : '-'),
h(
'span',
row.processTime
? new Date(row.processTime).toLocaleString('zh-CN')
: '-',
),
]),
]),
});
};
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
@@ -179,9 +191,9 @@ onMounted(() => {
<Grid table-title="告警记录列表">
<!-- 告警级别列 -->
<template #configLevel="{ row }">
<a-tag :color="getLevelColor(row.configLevel)">
<Tag :color="getLevelColor(row.configLevel)">
{{ getLevelText(row.configLevel) }}
</a-tag>
</Tag>
</template>
<!-- 产品名称列 -->
@@ -196,20 +208,20 @@ onMounted(() => {
<!-- 设备消息列 -->
<template #deviceMessage="{ row }">
<a-popover
<Popover
v-if="row.deviceMessage"
placement="topLeft"
trigger="hover"
:overlayStyle="{ maxWidth: '600px' }"
:overlay-style="{ maxWidth: '600px' }"
>
<template #content>
<pre class="text-xs">{{ row.deviceMessage }}</pre>
</template>
<VbenButton size="small" type="link">
<Icon icon="ant-design:eye-outlined" class="mr-1" />
<Button size="small" type="link">
<IconifyIcon icon="ant-design:eye-outlined" class="mr-1" />
查看消息
</VbenButton>
</a-popover>
</Button>
</Popover>
<span v-else class="text-gray-400">-</span>
</template>

View File

@@ -6,7 +6,10 @@ import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
import { DeviceTypeEnum, getSimpleProductList } from '#/api/iot/product/product';
import {
DeviceTypeEnum,
getSimpleProductList,
} from '#/api/iot/product/product';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
@@ -43,7 +46,7 @@ export function useFormSchema(): VbenFormSchema[] {
.min(4, 'DeviceName 长度不能少于 4 个字符')
.max(32, 'DeviceName 长度不能超过 32 个字符')
.regex(
/^[a-zA-Z0-9_.\-:@]{4,32}$/,
/^[\w.\-:@]{4,32}$/,
'支持英文字母、数字、下划线_、中划线-)、点号(.)、半角冒号(:)和特殊字符@',
),
},
@@ -79,7 +82,7 @@ export function useFormSchema(): VbenFormSchema[] {
.min(4, '备注名称长度限制为 4~64 个字符')
.max(64, '备注名称长度限制为 4~64 个字符')
.regex(
/^[\u4e00-\u9fa5\u3040-\u30ff_a-zA-Z0-9]+$/,
/^[\u4E00-\u9FA5\u3040-\u30FF\w]+$/,
'备注名称只能包含中文、英文字母、日文、数字和下划线_',
)
.optional()
@@ -106,7 +109,7 @@ export function useFormSchema(): VbenFormSchema[] {
},
rules: z
.string()
.regex(/^[a-zA-Z0-9-_]+$/, '序列号只能包含字母、数字、中划线和下划线')
.regex(/^[\w-]+$/, '序列号只能包含字母、数字、中划线和下划线')
.optional()
.or(z.literal('')),
},
@@ -318,4 +321,3 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
},
];
}

View File

@@ -4,30 +4,39 @@ import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Button, Card, Input, message, Select, Space, Tag } from 'ant-design-vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { downloadFileFromBlobPart } from '@vben/utils';
import { IconifyIcon } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import {
Button,
Card,
Input,
message,
Select,
Space,
Tag,
} from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
import {
deleteDevice,
deleteDeviceList,
exportDeviceExcel,
getDevicePage
getDevicePage,
} from '#/api/iot/device/device';
import { getSimpleProductList } from '#/api/iot/product/product';
import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
import { getSimpleProductList } from '#/api/iot/product/product';
import { $t } from '#/locales';
import { useGridColumns } from './data';
// @ts-ignore
import DeviceCardView from './modules/DeviceCardView.vue';
import DeviceForm from './modules/DeviceForm.vue';
import DeviceGroupForm from './modules/DeviceGroupForm.vue';
import DeviceImportForm from './modules/DeviceImportForm.vue';
// @ts-ignore
import DeviceCardView from './modules/DeviceCardView.vue';
import { useGridColumns } from './data';
/** IoT 设备列表 */
defineOptions({ name: 'IoTDevice' });
@@ -36,7 +45,7 @@ const route = useRoute();
const router = useRouter();
const products = ref<any[]>([]);
const deviceGroups = ref<any[]>([]);
const viewMode = ref<'list' | 'card'>('card');
const viewMode = ref<'card' | 'list'>('card');
const cardViewRef = ref();
// Modal instances
@@ -120,7 +129,11 @@ function openProductDetail(productId: number) {
/** 打开物模型数据 */
function openModel(id: number) {
router.push({ name: 'IoTDeviceDetail', params: { id }, query: { tab: 'model' } });
router.push({
name: 'IoTDeviceDetail',
params: { id },
query: { tab: 'model' },
});
}
/** 新增设备 */
@@ -141,7 +154,7 @@ async function handleDelete(row: any) {
});
try {
await deleteDevice(row.id);
message.success($t('common.delSuccess'));
message.success($t('ui.actionMessage.deleteSuccess'));
handleRefresh();
} finally {
hideLoading();
@@ -162,7 +175,7 @@ async function handleDeleteBatch() {
try {
const ids = checkedRows.map((row: any) => row.id);
await deleteDeviceList(ids);
message.success($t('common.delSuccess'));
message.success($t('ui.actionMessage.deleteSuccess'));
handleRefresh();
} finally {
hideLoading();
@@ -219,7 +232,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
} as VxeTableGridOptions,
});
/** 初始化 **/
/** 初始化 */
onMounted(async () => {
// 获取产品列表
products.value = await getSimpleProductList();
@@ -241,11 +254,11 @@ onMounted(async () => {
<DeviceFormModal @success="handleRefresh" />
<DeviceGroupFormModal @success="handleRefresh" />
<DeviceImportFormModal @success="handleRefresh" />
<!-- 统一搜索工具栏 -->
<Card :body-style="{ padding: '16px' }" class="mb-4">
<!-- 搜索表单 -->
<div class="flex flex-wrap items-center gap-3 mb-3">
<div class="mb-3 flex flex-wrap items-center gap-3">
<Select
v-model:value="searchParams.productId"
placeholder="请选择产品"
@@ -265,14 +278,14 @@ onMounted(async () => {
placeholder="请输入 DeviceName"
allow-clear
style="width: 200px"
@pressEnter="handleSearch"
@press-enter="handleSearch"
/>
<Input
v-model:value="searchParams.nickname"
placeholder="请输入备注名称"
allow-clear
style="width: 200px"
@pressEnter="handleSearch"
@press-enter="handleSearch"
/>
<Select
v-model:value="searchParams.deviceType"
@@ -329,22 +342,30 @@ onMounted(async () => {
<!-- 操作按钮 -->
<div class="flex items-center justify-between">
<Space :size="12">
<Button type="primary" @click="handleCreate" v-hasPermi="['iot:device:create']">
<Button
type="primary"
@click="handleCreate"
v-access:code="['iot:device:create']"
>
<IconifyIcon icon="ant-design:plus-outlined" class="mr-1" />
新增
</Button>
<Button type="primary" @click="handleExport" v-hasPermi="['iot:device:export']">
<Button
type="primary"
@click="handleExport"
v-access:code="['iot:device:export']"
>
<IconifyIcon icon="ant-design:download-outlined" class="mr-1" />
导出
</Button>
<Button @click="handleImport" v-hasPermi="['iot:device:import']">
<Button @click="handleImport" v-access:code="['iot:device:import']">
<IconifyIcon icon="ant-design:upload-outlined" class="mr-1" />
导入
</Button>
<Button
v-show="viewMode === 'list'"
@click="handleAddToGroup"
v-hasPermi="['iot:device:update']"
v-access:code="['iot:device:update']"
>
<IconifyIcon icon="ant-design:folder-add-outlined" class="mr-1" />
添加到分组
@@ -353,13 +374,13 @@ onMounted(async () => {
v-show="viewMode === 'list'"
danger
@click="handleDeleteBatch"
v-hasPermi="['iot:device:delete']"
v-access:code="['iot:device:delete']"
>
<IconifyIcon icon="ant-design:delete-outlined" class="mr-1" />
批量删除
</Button>
</Space>
<!-- 视图切换 -->
<Space :size="4">
<Button
@@ -385,7 +406,10 @@ onMounted(async () => {
<!-- 所属产品列 -->
<template #product="{ row }">
<a class="cursor-pointer text-primary" @click="openProductDetail(row.productId)">
<a
class="text-primary cursor-pointer"
@click="openProductDetail(row.productId)"
>
{{ products.find((p: any) => p.id === row.productId)?.name || '-' }}
</a>
</template>

View File

@@ -1,6 +1,10 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import {
Button,
Card,
@@ -11,38 +15,35 @@ import {
Row,
Tag,
} from 'ant-design-vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import { DeviceStateEnum, getDevicePage } from '#/api/iot/device/device';
defineOptions({ name: 'DeviceCardView' });
const props = defineProps<Props>();
const emit = defineEmits<{
create: [];
delete: [row: any];
detail: [id: number];
edit: [row: any];
model: [id: number];
productDetail: [productId: number];
}>();
interface Props {
products: any[];
deviceGroups: any[];
searchParams?: {
deviceName: string;
deviceType?: number;
groupId?: number;
nickname: string;
productId?: number;
deviceType?: number;
status?: number;
groupId?: number;
};
}
const props = defineProps<Props>();
const emit = defineEmits<{
create: [];
edit: [row: any];
delete: [row: any];
detail: [id: number];
model: [id: number];
productDetail: [productId: number];
}>();
const loading = ref(false);
const list = ref<any[]>([]);
const total = ref(0);
@@ -52,13 +53,13 @@ const queryParams = ref({
});
// 获取产品名称
const getProductName = (productId: number) => {
function getProductName(productId: number) {
const product = props.products.find((p: any) => p.id === productId);
return product?.name || '-';
};
}
// 获取设备列表
const getList = async () => {
async function getList() {
loading.value = true;
try {
const data = await getDevicePage({
@@ -70,26 +71,26 @@ const getList = async () => {
} finally {
loading.value = false;
}
};
}
// 处理页码变化
const handlePageChange = (page: number, pageSize: number) => {
function handlePageChange(page: number, pageSize: number) {
queryParams.value.pageNo = page;
queryParams.value.pageSize = pageSize;
getList();
};
}
// 获取设备类型颜色
const getDeviceTypeColor = (deviceType: number) => {
function getDeviceTypeColor(deviceType: number) {
const colors: Record<number, string> = {
0: 'blue',
1: 'cyan',
};
return colors[deviceType] || 'default';
};
}
// 获取设备状态信息
const getStatusInfo = (state: number) => {
function getStatusInfo(state: number) {
if (state === DeviceStateEnum.ONLINE) {
return {
text: '在线',
@@ -104,14 +105,14 @@ const getStatusInfo = (state: number) => {
bgColor: '#fff1f0',
borderColor: '#ffccc7',
};
};
}
onMounted(() => {
getList();
});
// 暴露方法供父组件调用
defineExpose({
defineExpose({
reload: getList,
search: () => {
queryParams.value.pageNo = 1;
@@ -145,7 +146,7 @@ defineExpose({
<div class="device-icon">
<IconifyIcon icon="mdi:chip" />
</div>
<div
<div
class="status-badge"
:style="{
color: getStatusInfo(item.state).color,
@@ -167,17 +168,30 @@ defineExpose({
<div class="info-section">
<div class="info-item">
<span class="label">所属产品</span>
<a
<a
class="value link"
@click="(e: MouseEvent) => { e.stopPropagation(); emit('productDetail', item.productId); }"
@click="
(e) => {
e.stopPropagation();
emit('productDetail', item.productId);
}
"
>
{{ getProductName(item.productId) }}
</a>
</div>
<div class="info-item">
<span class="label">设备类型</span>
<Tag :color="getDeviceTypeColor(item.deviceType)" size="small">
{{ getDictLabel(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE, item.deviceType) }}
<Tag
:color="getDeviceTypeColor(item.deviceType)"
size="small"
>
{{
getDictLabel(
DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE,
item.deviceType,
)
}}
</Tag>
</div>
<div class="info-item">
@@ -190,29 +204,44 @@ defineExpose({
<!-- 操作按钮 -->
<div class="action-bar">
<Button
<Button
type="default"
size="small"
class="action-btn btn-edit"
@click="(e: MouseEvent) => { e.stopPropagation(); emit('edit', item); }"
@click="
(e) => {
e.stopPropagation();
emit('edit', item);
}
"
>
<IconifyIcon icon="ph:note-pencil" />
编辑
</Button>
<Button
<Button
type="default"
size="small"
class="action-btn btn-view"
@click="(e: MouseEvent) => { e.stopPropagation(); emit('detail', item.id); }"
@click="
(e: MouseEvent) => {
e.stopPropagation();
emit('detail', item.id);
}
"
>
<IconifyIcon icon="ph:eye" />
详情
</Button>
<Button
<Button
type="default"
size="small"
class="action-btn btn-data"
@click="(e: MouseEvent) => { e.stopPropagation(); emit('model', item.id); }"
@click="
(e: MouseEvent) => {
e.stopPropagation();
emit('model', item.id);
}
"
>
<IconifyIcon icon="ph:database" />
数据
@@ -221,7 +250,7 @@ defineExpose({
title="确认删除该设备吗?"
@confirm="() => emit('delete', item)"
>
<Button
<Button
type="default"
size="small"
class="action-btn btn-delete"
@@ -260,17 +289,23 @@ defineExpose({
.device-card-view {
.device-card {
height: 100%;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02);
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
border: 1px solid #f0f0f0;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
box-shadow:
0 1px 2px 0 rgb(0 0 0 / 3%),
0 1px 6px -1px rgb(0 0 0 / 2%),
0 2px 4px 0 rgb(0 0 0 / 2%);
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
&:hover {
box-shadow: 0 1px 2px -2px rgba(0, 0, 0, 0.16), 0 3px 6px 0 rgba(0, 0, 0, 0.12), 0 5px 12px 4px rgba(0, 0, 0, 0.09);
transform: translateY(-4px);
border-color: #e6e6e6;
box-shadow:
0 1px 2px -2px rgb(0 0 0 / 16%),
0 3px 6px 0 rgb(0 0 0 / 12%),
0 5px 12px 4px rgb(0 0 0 / 9%);
transform: translateY(-4px);
}
:deep(.ant-card-body) {
@@ -278,10 +313,10 @@ defineExpose({
}
.card-content {
padding: 16px;
display: flex;
flex-direction: column;
height: 100%;
padding: 16px;
}
// 头部区域
@@ -292,48 +327,48 @@ defineExpose({
margin-bottom: 16px;
.device-icon {
width: 32px;
height: 32px;
border-radius: 6px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
width: 32px;
height: 32px;
font-size: 18px;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.25);
color: #fff;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 6px;
box-shadow: 0 2px 8px rgb(102 126 234 / 25%);
}
.status-badge {
display: flex;
gap: 4px;
align-items: center;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
display: flex;
align-items: center;
gap: 4px;
border: 1px solid;
line-height: 18px;
border: 1px solid;
border-radius: 12px;
.status-dot {
width: 6px;
height: 6px;
background: currentcolor;
border-radius: 50%;
background: currentColor;
}
}
}
// 设备名称
.device-name {
font-size: 16px;
font-weight: 600;
color: #262626;
margin-bottom: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 16px;
font-weight: 600;
line-height: 24px;
color: #262626;
white-space: nowrap;
}
// 信息区域
@@ -343,30 +378,30 @@ defineExpose({
.info-item {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
gap: 8px;
&:last-child {
margin-bottom: 0;
}
.label {
flex-shrink: 0;
font-size: 13px;
color: #8c8c8c;
flex-shrink: 0;
}
.value {
font-size: 13px;
color: #262626;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
color: #262626;
text-align: right;
white-space: nowrap;
&.link {
color: #1890ff;
@@ -379,10 +414,11 @@ defineExpose({
}
&.code {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Consolas', monospace;
font-family:
'SF Mono', Monaco, Inconsolata, 'Fira Code', Consolas, monospace;
font-size: 12px;
color: #595959;
font-weight: 500;
color: #595959;
}
}
}
@@ -390,28 +426,28 @@ defineExpose({
// 操作按钮栏
.action-bar {
position: relative;
z-index: 1;
display: flex;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #f5f5f5;
position: relative;
z-index: 1;
.action-btn {
flex: 1;
height: 32px;
padding: 4px 8px;
border-radius: 6px;
font-size: 13px;
display: flex;
flex: 1;
gap: 4px;
align-items: center;
justify-content: center;
gap: 4px;
transition: all 0.2s;
height: 32px;
padding: 4px 8px;
font-size: 13px;
font-weight: 400;
border: 1px solid;
cursor: pointer;
pointer-events: auto;
cursor: pointer;
border: 1px solid;
border-radius: 6px;
transition: all 0.2s;
:deep(.anticon) {
font-size: 16px;

View File

@@ -1,15 +1,13 @@
<script setup lang="ts">
import type { IotDeviceApi } from '#/api/iot/device/device';
import { computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import {
createDevice,
getDevice,
updateDevice,
type IotDeviceApi
} from '#/api/iot/device/device';
import { message } from 'ant-design-vue';
import { createDevice, getDevice, updateDevice } from '#/api/iot/device/device';
import { $t } from '#/locales';
import { useFormSchema } from '../data';

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { message } from 'ant-design-vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { updateDeviceGroup } from '#/api/iot/device/device';
import { $t } from '#/locales';

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue';
import { message } from 'ant-design-vue';
import { useVbenForm, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { message } from 'ant-design-vue';
import { importDeviceTemplate } from '#/api/iot/device/device';
import { downloadFileFromBlobPart } from '@vben/utils';
import { useImportFormSchema } from '../data';
@@ -31,22 +32,22 @@ const [Modal, modalApi] = useVbenModal({
if (!valid) {
return;
}
const values = await formApi.getValues();
const file = values.file;
if (!file || !file.length) {
if (!file || file.length === 0) {
message.error('请上传文件');
return;
}
modalApi.lock();
try {
// 构建表单数据
const formData = new FormData();
formData.append('file', file[0].originFileObj);
formData.append('updateSupport', values.updateSupport ? 'true' : 'false');
// 使用 fetch 上传文件
const accessToken = localStorage.getItem('accessToken') || '';
const response = await fetch(
@@ -57,21 +58,21 @@ const [Modal, modalApi] = useVbenModal({
Authorization: `Bearer ${accessToken}`,
},
body: formData,
}
},
);
const result = await response.json();
if (result.code !== 0) {
message.error(result.msg || '导入失败');
return;
}
// 拼接提示语
const data = result.data;
let text = `上传成功数量:${data.createDeviceNames?.length || 0};`;
if (data.createDeviceNames) {
for (let deviceName of data.createDeviceNames) {
for (const deviceName of data.createDeviceNames) {
text += `< ${deviceName} >`;
}
}
@@ -88,7 +89,7 @@ const [Modal, modalApi] = useVbenModal({
}
}
message.info(text);
// 关闭并提示
await modalApi.close();
emit('success');

View File

@@ -1,154 +1,387 @@
<!-- IoT 设备选择使用弹窗展示 -->
<script setup lang="ts">
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotDeviceGroupApi } from '#/api/iot/device/group';
import type { IotProductApi } from '#/api/iot/product/product';
import { computed, onMounted, reactive, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import { formatDate } from '@vben/utils';
import {
Button,
Form,
Input,
message,
Modal,
Pagination,
Radio,
Select,
Table,
Tag,
} from 'ant-design-vue';
import { getDevicePage } from '#/api/iot/device/device';
import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
import { getSimpleProductList } from '#/api/iot/product/product';
import { DictTag } from '#/components/dict-tag';
defineOptions({ name: 'IoTDeviceTableSelect' });
const props = defineProps({
multiple: {
type: Boolean,
default: false,
},
productId: {
type: Number,
default: null,
},
});
/** 提交表单 */
const emit = defineEmits(['success']);
// 获取字典选项
function getIntDictOptions(dictType: string) {
return getDictOptions(dictType, 'number');
}
// 日期格式化
function dateFormatter(_row: any, _column: any, cellValue: any) {
return cellValue ? formatDate(cellValue, 'YYYY-MM-DD HH:mm:ss') : '';
}
const dialogVisible = ref(false);
const dialogTitle = ref('设备选择器');
const formLoading = ref(false);
const loading = ref(true); // 列表的加载中
const list = ref<IotDeviceApi.Device[]>([]); // 列表的数据
const total = ref(0); // 列表的总页数
const selectedDevices = ref<IotDeviceApi.Device[]>([]); // 选中的设备列表
const selectedId = ref<number>(); // 单选模式下选中的ID
const products = ref<IotProductApi.Product[]>([]); // 产品列表
const deviceGroups = ref<IotDeviceGroupApi.DeviceGroup[]>([]); // 设备分组列表
const selectedRowKeys = ref<number[]>([]); // 多选模式下选中的keys
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
deviceName: undefined as string | undefined,
productId: undefined as number | undefined,
deviceType: undefined as number | undefined,
nickname: undefined as string | undefined,
status: undefined as number | undefined,
groupId: undefined as number | undefined,
});
const queryFormRef = ref(); // 搜索的表单
// 表格列定义
const columns = computed(() => {
const baseColumns = [
{
title: 'DeviceName',
dataIndex: 'deviceName',
key: 'deviceName',
},
{
title: '备注名称',
dataIndex: 'nickname',
key: 'nickname',
},
{
title: '所属产品',
key: 'productId',
},
{
title: '设备类型',
key: 'deviceType',
},
{
title: '所属分组',
key: 'groupIds',
},
{
title: '设备状态',
key: 'status',
},
{
title: '最后上线时间',
key: 'onlineTime',
width: 180,
},
];
// 单选模式添加单选列
if (!props.multiple) {
baseColumns.unshift({
title: '',
key: 'radio',
width: 55,
align: 'center',
} as any);
}
return baseColumns;
});
// 多选配置
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys: any[], rows: IotDeviceApi.Device[]) => {
selectedRowKeys.value = keys;
selectedDevices.value = rows;
},
}));
/** 查询列表 */
const getList = async () => {
loading.value = true;
try {
if (props.productId) {
queryParams.productId = props.productId;
}
const data = await getDevicePage(queryParams);
list.value = data.list;
total.value = data.total;
} finally {
loading.value = false;
}
};
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1;
getList();
};
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields();
handleQuery();
};
/** 打开弹窗 */
const open = async () => {
dialogVisible.value = true;
// 重置选择状态
selectedDevices.value = [];
selectedId.value = undefined;
selectedRowKeys.value = [];
if (!props.productId) {
// 获取产品列表
products.value = await getSimpleProductList();
}
// 获取设备列表
await getList();
};
defineExpose({ open });
/** 处理行点击事件 */
const tableRef = ref();
function handleRowClick(row: IotDeviceApi.Device) {
if (!props.multiple) {
selectedId.value = row.id;
selectedDevices.value = [row];
}
}
/** 处理单选变更事件 */
function handleRadioChange(row: IotDeviceApi.Device) {
selectedId.value = row.id;
selectedDevices.value = [row];
}
async function submitForm() {
if (selectedDevices.value.length === 0) {
message.warning({
content: props.multiple ? '请至少选择一个设备' : '请选择一个设备',
});
return;
}
emit(
'success',
props.multiple ? selectedDevices.value : selectedDevices.value[0],
);
dialogVisible.value = false;
}
/** 初始化 */
onMounted(async () => {
// 获取产品列表
products.value = await getSimpleProductList();
// 获取分组列表
deviceGroups.value = await getSimpleDeviceGroupList();
});
</script>
<template>
<a-modal
:title="dialogTitle"
v-model:open="dialogVisible"
width="60%"
<Modal
:title="dialogTitle"
v-model:open="dialogVisible"
width="60%"
:footer="null"
>
<ContentWrap>
<!-- 搜索工作栏 -->
<a-form
<Form
ref="queryFormRef"
layout="inline"
:model="queryParams"
class="-mb-15px"
>
<a-form-item v-if="!props.productId" label="产品" name="productId">
<a-select
<Form.Item v-if="!props.productId" label="产品" name="productId">
<Select
v-model:value="queryParams.productId"
placeholder="请选择产品"
allow-clear
style="width: 240px"
>
<a-select-option
<Select.Option
v-for="product in products"
:key="product.id"
:value="product.id"
>
{{ product.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="DeviceName" name="deviceName">
<a-input
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="DeviceName" name="deviceName">
<Input
v-model:value="queryParams.deviceName"
placeholder="请输入 DeviceName"
allow-clear
@pressEnter="handleQuery"
@press-enter="handleQuery"
style="width: 240px"
/>
</a-form-item>
<a-form-item label="备注名称" name="nickname">
<a-input
</Form.Item>
<Form.Item label="备注名称" name="nickname">
<Input
v-model:value="queryParams.nickname"
placeholder="请输入备注名称"
allow-clear
@pressEnter="handleQuery"
@press-enter="handleQuery"
style="width: 240px"
/>
</a-form-item>
<a-form-item label="设备类型" name="deviceType">
<a-select
</Form.Item>
<Form.Item label="设备类型" name="deviceType">
<Select
v-model:value="queryParams.deviceType"
placeholder="请选择设备类型"
allow-clear
style="width: 240px"
>
<a-select-option
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE)"
<Select.Option
v-for="dict in getIntDictOptions(
DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE,
)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="设备状态" name="status">
<a-select
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="设备状态" name="status">
<Select
v-model:value="queryParams.status"
placeholder="请选择设备状态"
allow-clear
style="width: 240px"
>
<a-select-option
<Select.Option
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DEVICE_STATUS)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="设备分组" name="groupId">
<a-select
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="设备分组" name="groupId">
<Select
v-model:value="queryParams.groupId"
placeholder="请选择设备分组"
allow-clear
style="width: 240px"
>
<a-select-option
<Select.Option
v-for="group in deviceGroups"
:key="group.id"
:value="group.id"
>
{{ group.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button @click="handleQuery">
<Icon class="mr-5px" icon="ep:search" />
</Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Button @click="handleQuery">
<IconifyIcon class="mr-5px" icon="ep:search" />
搜索
</a-button>
<a-button @click="resetQuery">
<Icon class="mr-5px" icon="ep:refresh" />
</Button>
<Button @click="resetQuery">
<IconifyIcon class="mr-5px" icon="ep:refresh" />
重置
</a-button>
</a-form-item>
</a-form>
</Button>
</Form.Item>
</Form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<a-table
<Table
ref="tableRef"
:loading="loading"
:dataSource="list"
:data-source="list"
:columns="columns"
:pagination="false"
:row-selection="multiple ? rowSelection : undefined"
@row-click="handleRowClick"
:row-key="(record: IotDeviceApi.Device) => record.id"
:row-key="(record: IotDeviceApi.Device) => record.id?.toString() ?? ''"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'radio'">
<a-radio
<Radio
:checked="selectedId === record.id"
@click="() => handleRadioChange(record)"
@click="() => handleRadioChange(record as IotDeviceApi.Device)"
/>
</template>
<template v-else-if="column.key === 'productId'">
{{ products.find((p) => p.id === record.productId)?.name || '-' }}
</template>
<template v-else-if="column.key === 'deviceType'">
<dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="record.deviceType" />
<DictTag
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
:value="record.deviceType"
/>
</template>
<template v-else-if="column.key === 'groupIds'">
<template v-if="record.groupIds?.length">
<a-tag v-for="id in record.groupIds" :key="id" class="ml-5px" size="small">
<Tag
v-for="id in record.groupIds"
:key="id"
class="ml-5px"
size="small"
>
{{ deviceGroups.find((g) => g.id === id)?.name }}
</a-tag>
</Tag>
</template>
</template>
<template v-else-if="column.key === 'status'">
<dict-tag :type="DICT_TYPE.IOT_DEVICE_STATUS" :value="record.status" />
<DictTag
:type="DICT_TYPE.IOT_DEVICE_STATUS"
:value="record.status"
/>
</template>
<template v-else-if="column.key === 'onlineTime'">
{{ dateFormatter(null, null, record.onlineTime) }}
</template>
</template>
</a-table>
</Table>
<!-- 分页 -->
<Pagination
@@ -160,211 +393,10 @@
</ContentWrap>
<template #footer>
<a-button @click="submitForm" type="primary" :disabled="formLoading">确 定</a-button>
<a-button @click="dialogVisible = false">取 消</a-button>
<Button @click="submitForm" type="primary" :disabled="formLoading">
确 定
</Button>
<Button @click="dialogVisible = false"> </Button>
</template>
</a-modal>
</Modal>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { DICT_TYPE } from '@vben/constants'
import { getDictOptions } from '@vben/hooks'
import { formatDate } from '@vben/utils'
import type { IotDeviceApi } from '#/api/iot/device/device'
import { getDevicePage } from '#/api/iot/device/device'
import type { IotProductApi } from '#/api/iot/product/product'
import { getSimpleProductList } from '#/api/iot/product/product'
import type { IotDeviceGroupApi } from '#/api/iot/device/group'
import { getSimpleDeviceGroupList } from '#/api/iot/device/group'
import { message } from 'ant-design-vue'
defineOptions({ name: 'IoTDeviceTableSelect' })
const props = defineProps({
multiple: {
type: Boolean,
default: false
},
productId: {
type: Number,
default: null
}
})
// 获取字典选项
const getIntDictOptions = (dictType: string) => {
return getDictOptions(dictType, 'number')
}
// 日期格式化
const dateFormatter = (_row: any, _column: any, cellValue: any) => {
return cellValue ? formatDate(cellValue, 'YYYY-MM-DD HH:mm:ss') : ''
}
const dialogVisible = ref(false)
const dialogTitle = ref('设备选择器')
const formLoading = ref(false)
const loading = ref(true) // 列表的加载中
const list = ref<IotDeviceApi.Device[]>([]) // 列表的数据
const total = ref(0) // 列表的总页数
const selectedDevices = ref<IotDeviceApi.Device[]>([]) // 选中的设备列表
const selectedId = ref<number>() // 单选模式下选中的ID
const products = ref<IotProductApi.Product[]>([]) // 产品列表
const deviceGroups = ref<IotDeviceGroupApi.DeviceGroup[]>([]) // 设备分组列表
const selectedRowKeys = ref<number[]>([]) // 多选模式下选中的keys
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
deviceName: undefined as string | undefined,
productId: undefined as number | undefined,
deviceType: undefined as number | undefined,
nickname: undefined as string | undefined,
status: undefined as number | undefined,
groupId: undefined as number | undefined
})
const queryFormRef = ref() // 搜索的表单
// 表格列定义
const columns = computed(() => {
const baseColumns = [
{
title: 'DeviceName',
dataIndex: 'deviceName',
key: 'deviceName',
align: 'center'
},
{
title: '备注名称',
dataIndex: 'nickname',
key: 'nickname',
align: 'center'
},
{
title: '所属产品',
key: 'productId',
align: 'center'
},
{
title: '设备类型',
key: 'deviceType',
align: 'center'
},
{
title: '所属分组',
key: 'groupIds',
align: 'center'
},
{
title: '设备状态',
key: 'status',
align: 'center'
},
{
title: '最后上线时间',
key: 'onlineTime',
align: 'center',
width: 180
}
]
// 单选模式添加单选列
if (!props.multiple) {
baseColumns.unshift({
title: '',
key: 'radio',
width: 55,
align: 'center'
} as any)
}
return baseColumns
})
// 多选配置
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys: number[], rows: IotDeviceApi.Device[]) => {
selectedRowKeys.value = keys
selectedDevices.value = rows
}
}))
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
if (props.productId) {
queryParams.productId = props.productId
}
const data = await getDevicePage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/** 打开弹窗 */
const open = async () => {
dialogVisible.value = true
// 重置选择状态
selectedDevices.value = []
selectedId.value = undefined
selectedRowKeys.value = []
if (!props.productId) {
// 获取产品列表
products.value = await getSimpleProductList()
}
// 获取设备列表
await getList()
}
defineExpose({ open })
/** 处理行点击事件 */
const tableRef = ref()
const handleRowClick = (row: IotDeviceApi.Device) => {
if (!props.multiple) {
selectedId.value = row.id
selectedDevices.value = [row]
}
}
/** 处理单选变更事件 */
const handleRadioChange = (row: IotDeviceApi.Device) => {
selectedId.value = row.id
selectedDevices.value = [row]
}
/** 提交表单 */
const emit = defineEmits(['success'])
const submitForm = async () => {
if (selectedDevices.value.length === 0) {
message.warning({ content: props.multiple ? '请至少选择一个设备' : '请选择一个设备' })
return
}
emit('success', props.multiple ? selectedDevices.value : selectedDevices.value[0])
dialogVisible.value = false
}
/** 初始化 **/
onMounted(async () => {
// 获取产品列表
products.value = await getSimpleProductList()
// 获取分组列表
deviceGroups.value = await getSimpleDeviceGroupList()
})
</script>

View File

@@ -1,7 +1,121 @@
<!-- 设备配置 -->
<script lang="ts" setup>
import type { IotDeviceApi } from '#/api/iot/device/device';
import { ref, watchEffect } from 'vue';
import { Alert, Button, message } from 'ant-design-vue';
import { sendDeviceMessage, updateDevice } from '#/api/iot/device/device';
import { IotDeviceMessageMethodEnum } from '#/views/iot/utils/constants';
defineOptions({ name: 'DeviceDetailConfig' });
const props = defineProps<{
device: IotDeviceApi.Device;
}>();
const emit = defineEmits<{
(e: 'success'): void; // 定义 success 事件,不需要参数
}>();
const loading = ref(false); // 加载中
const pushLoading = ref(false); // 推送加载中
const config = ref<any>({}); // 只存储 config 字段
const hasJsonError = ref(false); // 是否有 JSON 格式错误
/** 监听 props.device 的变化,只更新 config 字段 */
watchEffect(() => {
try {
config.value = props.device.config ? JSON.parse(props.device.config) : {};
} catch {
config.value = {};
}
});
const isEditing = ref(false); // 编辑状态
/** 启用编辑模式的函数 */
function enableEdit() {
isEditing.value = true;
hasJsonError.value = false; // 重置错误状态
}
/** 取消编辑的函数 */
function cancelEdit() {
try {
config.value = props.device.config ? JSON.parse(props.device.config) : {};
} catch {
config.value = {};
}
isEditing.value = false;
hasJsonError.value = false; // 重置错误状态
}
/** 保存配置的函数 */
async function saveConfig() {
if (hasJsonError.value) {
message.error({ content: 'JSON格式错误请修正后再提交' });
return;
}
await updateDeviceConfig();
isEditing.value = false;
}
/** 配置推送处理函数 */
async function handleConfigPush() {
try {
pushLoading.value = true;
// 调用配置推送接口
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.CONFIG_PUSH.method,
params: config.value,
});
message.success({ content: '配置推送成功!' });
} catch (error) {
if (error !== 'cancel') {
message.error({ content: '配置推送失败!' });
console.error('配置推送错误:', error);
}
} finally {
pushLoading.value = false;
}
}
/** 更新设备配置 */
async function updateDeviceConfig() {
try {
// 提交请求
loading.value = true;
await updateDevice({
id: props.device.id,
config: JSON.stringify(config.value),
} as IotDeviceApi.Device);
message.success({ content: '更新成功!' });
// 触发 success 事件
emit('success');
} catch (error) {
console.error(error);
} finally {
loading.value = false;
}
}
/** 处理 JSON 编辑器错误的函数 */
function onError(errors: any) {
if (!errors || (Array.isArray(errors) && errors.length === 0)) {
hasJsonError.value = false;
return;
}
hasJsonError.value = true;
}
</script>
<template>
<div>
<a-alert
<Alert
message="支持远程更新设备的配置文件(JSON 格式),可以在下方编辑配置模板,对设备的系统参数、网络参数等进行远程配置。配置完成后,需点击「下发」按钮,设备即可进行远程配置。"
type="info"
show-icon
@@ -15,126 +129,24 @@
@error="onError"
/>
<div class="mt-5 text-center">
<a-button v-if="isEditing" @click="cancelEdit">取消</a-button>
<a-button v-if="isEditing" type="primary" @click="saveConfig" :disabled="hasJsonError">
<Button v-if="isEditing" @click="cancelEdit">取消</Button>
<Button
v-if="isEditing"
type="primary"
@click="saveConfig"
:disabled="hasJsonError"
>
保存
</a-button>
<a-button v-else @click="enableEdit">编辑</a-button>
<a-button v-if="!isEditing" type="primary" @click="handleConfigPush" :loading="pushLoading">
</Button>
<Button v-else @click="enableEdit">编辑</Button>
<Button
v-if="!isEditing"
type="primary"
@click="handleConfigPush"
:loading="pushLoading"
>
配置推送
</a-button>
</Button>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, watchEffect } from 'vue'
import { message } from 'ant-design-vue'
import { DeviceApi } from '#/api/iot/device/device'
import type { DeviceVO } from '#/api/iot/device/device'
import { IotDeviceMessageMethodEnum } from '#/views/iot/utils/constants'
defineOptions({ name: 'DeviceDetailConfig' })
const props = defineProps<{
device: DeviceVO
}>()
const emit = defineEmits<{
(e: 'success'): void // 定义 success 事件,不需要参数
}>()
const loading = ref(false) // 加载中
const pushLoading = ref(false) // 推送加载中
const config = ref<any>({}) // 只存储 config 字段
const hasJsonError = ref(false) // 是否有 JSON 格式错误
/** 监听 props.device 的变化,只更新 config 字段 */
watchEffect(() => {
try {
config.value = props.device.config ? JSON.parse(props.device.config) : {}
} catch (e) {
config.value = {}
}
})
const isEditing = ref(false) // 编辑状态
/** 启用编辑模式的函数 */
const enableEdit = () => {
isEditing.value = true
hasJsonError.value = false // 重置错误状态
}
/** 取消编辑的函数 */
const cancelEdit = () => {
try {
config.value = props.device.config ? JSON.parse(props.device.config) : {}
} catch (e) {
config.value = {}
}
isEditing.value = false
hasJsonError.value = false // 重置错误状态
}
/** 保存配置的函数 */
const saveConfig = async () => {
if (hasJsonError.value) {
message.error({ content: 'JSON格式错误请修正后再提交' })
return
}
await updateDeviceConfig()
isEditing.value = false
}
/** 配置推送处理函数 */
const handleConfigPush = async () => {
try {
pushLoading.value = true
// 调用配置推送接口
await DeviceApi.sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.CONFIG_PUSH.method,
params: config.value
})
message.success({ content: '配置推送成功!' })
} catch (error) {
if (error !== 'cancel') {
message.error({ content: '配置推送失败!' })
console.error('配置推送错误:', error)
}
} finally {
pushLoading.value = false
}
}
/** 更新设备配置 */
const updateDeviceConfig = async () => {
try {
// 提交请求
loading.value = true
await DeviceApi.updateDevice({
id: props.device.id,
config: JSON.stringify(config.value)
} as DeviceVO)
message.success({ content: '更新成功!' })
// 触发 success 事件
emit('success')
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
/** 处理 JSON 编辑器错误的函数 */
const onError = (errors: any) => {
if (!errors || (Array.isArray(errors) && errors.length === 0)) {
hasJsonError.value = false
return
}
hasJsonError.value = true
}
</script>

View File

@@ -1,4 +1,55 @@
<!-- 设备信息头部 -->
<script setup lang="ts">
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotProductApi } from '#/api/iot/product/product';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { Button, Card, Descriptions, message } from 'ant-design-vue';
import DeviceForm from '../DeviceForm.vue';
interface Props {
product: IotProductApi.Product;
device: IotDeviceApi.Device;
loading?: boolean;
}
withDefaults(defineProps<Props>(), {
loading: false,
});
const emit = defineEmits<{
refresh: [];
}>();
const router = useRouter();
/** 操作修改 */
const formRef = ref();
function openForm(type: string, id?: number) {
formRef.value.open(type, id);
}
/** 复制到剪贴板方法 */
async function copyToClipboard(text: string | undefined) {
if (!text) return;
try {
await navigator.clipboard.writeText(text);
message.success({ content: '复制成功' });
} catch {
message.error({ content: '复制失败' });
}
}
/** 跳转到产品详情页面 */
function goToProductDetail(productId: number | undefined) {
if (productId) {
router.push({ name: 'IoTProductDetail', params: { id: productId } });
}
}
</script>
<template>
<div class="mb-4">
<div class="flex items-start justify-between">
@@ -7,81 +58,40 @@
</div>
<div class="space-x-2">
<!-- 右上按钮 -->
<a-button
<Button
v-if="product.status === 0"
v-hasPermi="['iot:device:update']"
v-access:code="['iot:device:update']"
@click="openForm('update', device.id)"
>
编辑
</a-button>
</Button>
</div>
</div>
<a-card class="mt-4">
<a-descriptions :column="1">
<a-descriptions-item label="产品">
<a @click="goToProductDetail(product.id)" class="cursor-pointer text-blue-600">
<Card class="mt-4">
<Descriptions :column="1">
<Descriptions.Item label="产品">
<a
@click="goToProductDetail(product.id)"
class="cursor-pointer text-blue-600"
>
{{ product.name }}
</a>
</a-descriptions-item>
<a-descriptions-item label="ProductKey">
</Descriptions.Item>
<Descriptions.Item label="ProductKey">
{{ product.productKey }}
<a-button size="small" class="ml-2" @click="copyToClipboard(product.productKey)">
<Button
size="small"
class="ml-2"
@click="copyToClipboard(product.productKey)"
>
复制
</a-button>
</a-descriptions-item>
</a-descriptions>
</a-card>
</Button>
</Descriptions.Item>
</Descriptions>
</Card>
<!-- 表单弹窗添加/修改 -->
<DeviceForm ref="formRef" @success="emit('refresh')" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import DeviceForm from '../DeviceForm.vue'
import type { ProductVO } from '#/api/iot/product/product'
import type { DeviceVO } from '#/api/iot/device/device'
interface Props {
product: ProductVO
device: DeviceVO
loading?: boolean
}
withDefaults(defineProps<Props>(), {
loading: false,
})
const emit = defineEmits<{
refresh: []
}>()
const router = useRouter()
/** 操作修改 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 复制到剪贴板方法 */
const copyToClipboard = async (text: string | undefined) => {
if (!text) return
try {
await navigator.clipboard.writeText(text)
message.success({ content: '复制成功' })
} catch (error) {
message.error({ content: '复制失败' })
}
}
/** 跳转到产品详情页面 */
const goToProductDetail = (productId: number | undefined) => {
if (productId) {
router.push({ name: 'IoTProductDetail', params: { id: productId } })
}
}
</script>

View File

@@ -1,179 +1,227 @@
<!-- 设备信息 -->
<script setup lang="ts">
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotProductApi } from '#/api/iot/product/product';
import { computed, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { formatDate } from '@vben/utils';
import {
Button,
Card,
Col,
Descriptions,
Form,
Input,
message,
Modal,
Row,
} from 'ant-design-vue';
import { getDeviceAuthInfo } from '#/api/iot/device/device';
import { DictTag } from '#/components/dict-tag';
// 消息提示
const { product, device } = defineProps<{
device: IotDeviceApi.Device;
product: IotProductApi.Product;
}>(); // 定义 Props
// const emit = defineEmits(['refresh']); // 定义 Emits
const authDialogVisible = ref(false); // 定义设备认证信息弹框的可见性
const authPasswordVisible = ref(false); // 定义密码可见性状态
const authInfo = ref<IotDeviceApi.DeviceAuthInfo>(
{} as IotDeviceApi.DeviceAuthInfo,
); // 定义设备认证信息对象
/** 控制地图显示的标志 */
const showMap = computed(() => {
return !!(device.longitude && device.latitude);
});
/** 复制到剪贴板方法 */
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
message.success({ content: '复制成功' });
} catch {
message.error({ content: '复制失败' });
}
}
/** 打开设备认证信息弹框的方法 */
async function handleAuthInfoDialogOpen() {
if (!device.id) return;
try {
authInfo.value = await getDeviceAuthInfo(device.id);
// 显示设备认证信息弹框
authDialogVisible.value = true;
} catch (error) {
console.error('获取设备认证信息出错:', error);
message.error({
content: '获取设备认证信息失败,请检查网络连接或联系管理员',
});
}
}
/** 关闭设备认证信息弹框的方法 */
function handleAuthInfoDialogClose() {
authDialogVisible.value = false;
}
</script>
<template>
<div>
<a-row :gutter="16">
<Row :gutter="16">
<!-- 左侧设备信息 -->
<a-col :span="12">
<a-card class="h-full">
<Col :span="12">
<Card class="h-full">
<template #title>
<div class="flex items-center">
<Icon icon="ep:info-filled" class="mr-2 text-primary" />
<IconifyIcon icon="ep:info-filled" class="text-primary mr-2" />
<span>设备信息</span>
</div>
</template>
<a-descriptions :column="1" bordered>
<a-descriptions-item label="产品名称">
<Descriptions :column="1" bordered>
<Descriptions.Item label="产品名称">
{{ product.name }}
</a-descriptions-item>
<a-descriptions-item label="ProductKey">
</Descriptions.Item>
<Descriptions.Item label="ProductKey">
{{ product.productKey }}
</a-descriptions-item>
<a-descriptions-item label="设备类型">
<dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="product.deviceType" />
</a-descriptions-item>
<a-descriptions-item label="DeviceName">
</Descriptions.Item>
<Descriptions.Item label="设备类型">
<DictTag
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
:value="product.deviceType"
/>
</Descriptions.Item>
<Descriptions.Item label="DeviceName">
{{ device.deviceName }}
</a-descriptions-item>
<a-descriptions-item label="备注名称">
</Descriptions.Item>
<Descriptions.Item label="备注名称">
{{ device.nickname || '--' }}
</a-descriptions-item>
<a-descriptions-item label="当前状态">
<dict-tag :type="DICT_TYPE.IOT_DEVICE_STATUS" :value="device.state" />
</a-descriptions-item>
<a-descriptions-item label="创建时间">
</Descriptions.Item>
<Descriptions.Item label="当前状态">
<DictTag
:type="DICT_TYPE.IOT_DEVICE_STATUS"
:value="device.state"
/>
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{{ formatDate(device.createTime) }}
</a-descriptions-item>
<a-descriptions-item label="激活时间">
</Descriptions.Item>
<Descriptions.Item label="激活时间">
{{ formatDate(device.activeTime) }}
</a-descriptions-item>
<a-descriptions-item label="最后上线时间">
</Descriptions.Item>
<Descriptions.Item label="最后上线时间">
{{ formatDate(device.onlineTime) }}
</a-descriptions-item>
<a-descriptions-item label="最后离线时间">
</Descriptions.Item>
<Descriptions.Item label="最后离线时间">
{{ formatDate(device.offlineTime) }}
</a-descriptions-item>
<a-descriptions-item label="MQTT 连接参数">
<a-button type="link" @click="handleAuthInfoDialogOpen" size="small">
</Descriptions.Item>
<Descriptions.Item label="MQTT 连接参数">
<Button
type="link"
@click="handleAuthInfoDialogOpen"
size="small"
>
查看
</a-button>
</a-descriptions-item>
</a-descriptions>
</a-card>
</a-col>
</Button>
</Descriptions.Item>
</Descriptions>
</Card>
</Col>
<!-- 右侧地图 -->
<a-col :span="12">
<a-card class="h-full">
<Col :span="12">
<Card class="h-full">
<template #title>
<div class="flex items-center justify-between">
<div class="flex items-center">
<Icon icon="ep:location" class="mr-2 text-primary" />
<IconifyIcon icon="ep:location" class="text-primary mr-2" />
<span>设备位置</span>
</div>
</div>
</template>
<div class="h-[500px] w-full">
<div v-if="showMap" class="h-full w-full bg-gray-100 flex items-center justify-center rounded">
<div
v-if="showMap"
class="flex h-full w-full items-center justify-center rounded bg-gray-100"
>
<span class="text-gray-400">地图组件</span>
</div>
<div
v-else
class="flex items-center justify-center h-full w-full bg-gray-50 text-gray-400 rounded"
class="flex h-full w-full items-center justify-center rounded bg-gray-50 text-gray-400"
>
<Icon icon="ep:warning" class="mr-2" />
<IconifyIcon icon="ep:warning" class="mr-2" />
<span>暂无位置信息</span>
</div>
</div>
</a-card>
</a-col>
</a-row>
</Card>
</Col>
</Row>
<!-- 认证信息弹框 -->
<a-modal
<Modal
v-model:open="authDialogVisible"
title="MQTT 连接参数"
width="640px"
:footer="null"
>
<a-form :label-col="{ span: 6 }">
<a-form-item label="clientId">
<a-input-group compact>
<a-input v-model:value="authInfo.clientId" readonly style="width: calc(100% - 80px)" />
<a-button @click="copyToClipboard(authInfo.clientId)" type="primary">
<Icon icon="ph:copy" />
</a-button>
</a-input-group>
</a-form-item>
<a-form-item label="username">
<a-input-group compact>
<a-input v-model:value="authInfo.username" readonly style="width: calc(100% - 80px)" />
<a-button @click="copyToClipboard(authInfo.username)" type="primary">
<Icon icon="ph:copy" />
</a-button>
</a-input-group>
</a-form-item>
<a-form-item label="password">
<a-input-group compact>
<a-input
<Form :label-col="{ span: 6 }">
<Form.Item label="clientId">
<Input.Group compact>
<Input
v-model:value="authInfo.clientId"
readonly
style="width: calc(100% - 80px)"
/>
<Button @click="copyToClipboard(authInfo.clientId)" type="primary">
<IconifyIcon icon="ph:copy" />
</Button>
</Input.Group>
</Form.Item>
<Form.Item label="username">
<Input.Group compact>
<Input
v-model:value="authInfo.username"
readonly
style="width: calc(100% - 80px)"
/>
<Button @click="copyToClipboard(authInfo.username)" type="primary">
<IconifyIcon icon="ph:copy" />
</Button>
</Input.Group>
</Form.Item>
<Form.Item label="password">
<Input.Group compact>
<Input
v-model:value="authInfo.password"
readonly
:type="authPasswordVisible ? 'text' : 'password'"
style="width: calc(100% - 160px)"
/>
<a-button @click="authPasswordVisible = !authPasswordVisible" type="primary">
<Icon :icon="authPasswordVisible ? 'ph:eye-slash' : 'ph:eye'" />
</a-button>
<a-button @click="copyToClipboard(authInfo.password)" type="primary">
<Icon icon="ph:copy" />
</a-button>
</a-input-group>
</a-form-item>
</a-form>
<div class="text-right mt-4">
<a-button @click="handleAuthInfoDialogClose">关闭</a-button>
<Button
@click="authPasswordVisible = !authPasswordVisible"
type="primary"
>
<IconifyIcon
:icon="authPasswordVisible ? 'ph:eye-slash' : 'ph:eye'"
/>
</Button>
<Button @click="copyToClipboard(authInfo.password)" type="primary">
<IconifyIcon icon="ph:copy" />
</Button>
</Input.Group>
</Form.Item>
</Form>
<div class="mt-4 text-right">
<Button @click="handleAuthInfoDialogClose">关闭</Button>
</div>
</a-modal>
</Modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import { DICT_TYPE } from '@vben/constants'
import type { ProductVO } from '#/api/iot/product/product'
import { formatDate } from '@vben/utils'
import type { DeviceVO } from '#/api/iot/device/device'
import { DeviceApi } from '#/api/iot/device/device'
import type { IotDeviceAuthInfoVO } from '#/api/iot/device/device'
// 消息提示
const { product, device } = defineProps<{ product: ProductVO; device: DeviceVO }>() // 定义 Props
const emit = defineEmits(['refresh']) // 定义 Emits
const authDialogVisible = ref(false) // 定义设备认证信息弹框的可见性
const authPasswordVisible = ref(false) // 定义密码可见性状态
const authInfo = ref<IotDeviceAuthInfoVO>({} as IotDeviceAuthInfoVO) // 定义设备认证信息对象
/** 控制地图显示的标志 */
const showMap = computed(() => {
return !!(device.longitude && device.latitude)
})
/** 复制到剪贴板方法 */
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
message.success({ content: '复制成功' })
} catch (error) {
message.error({ content: '复制失败' })
}
}
/** 打开设备认证信息弹框的方法 */
const handleAuthInfoDialogOpen = async () => {
if (!device.id) return
try {
authInfo.value = await DeviceApi.getDeviceAuthInfo(device.id)
// 显示设备认证信息弹框
authDialogVisible.value = true
} catch (error) {
console.error('获取设备认证信息出错:', error)
message.error({ content: '获取设备认证信息失败,请检查网络连接或联系管理员' })
}
}
/** 关闭设备认证信息弹框的方法 */
const handleAuthInfoDialogClose = () => {
authDialogVisible.value = false
}
</script>

View File

@@ -1,69 +1,258 @@
<!-- 设备消息列表 -->
<script setup lang="ts">
import {
computed,
onBeforeUnmount,
onMounted,
reactive,
ref,
watch,
} from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { formatDate } from '@vben/utils';
import {
Button,
Form,
Pagination,
Select,
Switch,
Table,
Tag,
} from 'ant-design-vue';
import { getDeviceMessagePage } from '#/api/iot/device/device';
import { DictTag } from '#/components/dict-tag';
import { IotDeviceMessageMethodEnum } from '#/views/iot/utils/constants';
const props = defineProps<{
deviceId: number;
}>();
// 查询参数
const queryParams = reactive({
deviceId: props.deviceId,
method: undefined,
upstream: undefined,
pageNo: 1,
pageSize: 10,
});
// 列表数据
const loading = ref(false);
const total = ref(0);
const list = ref<any[]>([]);
const autoRefresh = ref(false); // 自动刷新开关
let autoRefreshTimer: any = null; // 自动刷新定时器
// 消息方法选项
const methodOptions = computed(() => {
return Object.values(IotDeviceMessageMethodEnum).map((item) => ({
label: item.name,
value: item.method,
}));
});
// 表格列定义
const columns = [
{
title: '时间',
dataIndex: 'ts',
key: 'ts',
width: 180,
},
{
title: '上行/下行',
dataIndex: 'upstream',
key: 'upstream',
width: 140,
},
{
title: '是否回复',
dataIndex: 'reply',
key: 'reply',
width: 140,
},
{
title: '请求编号',
dataIndex: 'requestId',
key: 'requestId',
width: 300,
},
{
title: '请求方法',
dataIndex: 'method',
key: 'method',
width: 140,
},
{
title: '请求/响应数据',
dataIndex: 'params',
key: 'params',
ellipsis: true,
},
];
/** 查询消息列表 */
async function getMessageList() {
if (!props.deviceId) return;
loading.value = true;
try {
const data = await getDeviceMessagePage(queryParams);
total.value = data.total;
list.value = data.list;
} finally {
loading.value = false;
}
}
/** 搜索操作 */
function handleQuery() {
queryParams.pageNo = 1;
getMessageList();
}
/** 监听自动刷新 */
watch(autoRefresh, (newValue) => {
if (newValue) {
autoRefreshTimer = setInterval(() => {
getMessageList();
}, 5000);
} else {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
});
/** 监听设备标识变化 */
watch(
() => props.deviceId,
(newValue) => {
if (newValue) {
handleQuery();
}
},
);
/** 组件卸载时清除定时器 */
onBeforeUnmount(() => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
});
/** 初始化 */
onMounted(() => {
if (props.deviceId) {
getMessageList();
}
});
/** 刷新消息列表 */
function refresh(delay = 0) {
if (delay > 0) {
setTimeout(() => {
handleQuery();
}, delay);
} else {
handleQuery();
}
}
/** 暴露方法给父组件 */
defineExpose({
refresh,
});
</script>
<template>
<ContentWrap>
<!-- 搜索区域 -->
<a-form :model="queryParams" layout="inline">
<a-form-item>
<a-select v-model:value="queryParams.method" placeholder="所有方法" style="width: 160px" allow-clear>
<a-select-option
<Form :model="queryParams" layout="inline">
<Form.Item>
<Select
v-model:value="queryParams.method"
placeholder="所有方法"
style="width: 160px"
allow-clear
>
<Select.Option
v-for="item in methodOptions"
:key="item.value"
:label="item.label"
:value="item.value"
>
{{ item.label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-select
</Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Select
v-model:value="queryParams.upstream"
placeholder="上行/下行"
style="width: 160px"
allow-clear
>
<a-select-option label="上行" value="true">上行</a-select-option>
<a-select-option label="下行" value="false">下行</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleQuery">
<Icon icon="ep:search" class="mr-5px" /> 搜索
</a-button>
<a-switch
<Select.Option label="上行" value="true">上行</Select.Option>
<Select.Option label="下行" value="false">下行</Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Button type="primary" @click="handleQuery">
<IconifyIcon icon="ep:search" class="mr-5px" /> 搜索
</Button>
<Switch
v-model:checked="autoRefresh"
class="ml-20px"
checked-children="定时刷新"
un-checked-children="定时刷新"
/>
</a-form-item>
</a-form>
</Form.Item>
</Form>
<!-- 消息列表 -->
<a-table :loading="loading" :dataSource="list" :columns="columns" :pagination="false" class="whitespace-nowrap">
<Table
:loading="loading"
:data-source="list"
:columns="columns"
:pagination="false"
align="center"
class="whitespace-nowrap"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'ts'">
{{ formatDate(record.ts) }}
</template>
<template v-else-if="column.key === 'upstream'">
<a-tag :color="record.upstream ? 'blue' : 'green'">
<Tag :color="record.upstream ? 'blue' : 'green'">
{{ record.upstream ? '上行' : '下行' }}
</a-tag>
</Tag>
</template>
<template v-else-if="column.key === 'reply'">
<dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="record.reply" />
<DictTag
:type="DICT_TYPE.INFRA_BOOLEAN_STRING"
:value="record.reply"
/>
</template>
<template v-else-if="column.key === 'method'">
{{ methodOptions.find((item) => item.value === record.method)?.label }}
{{
methodOptions.find((item) => item.value === record.method)?.label
}}
</template>
<template v-else-if="column.key === 'params'">
<span v-if="record.reply">
{{ `{"code":${record.code},"msg":"${record.msg}","data":${record.data}\}` }}
{{
`{"code":${record.code},"msg":"${record.msg}","data":${record.data}\}`
}}
</span>
<span v-else>{{ record.params }}</span>
</template>
</template>
</a-table>
</Table>
<!-- 分页 -->
<div class="mt-10px flex justify-end">
@@ -76,157 +265,3 @@
</div>
</ContentWrap>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { DICT_TYPE } from '@vben/constants'
import { DeviceApi } from '#/api/iot/device/device'
import { formatDate } from '@vben/utils'
import { IotDeviceMessageMethodEnum } from '#/views/iot/utils/constants'
const props = defineProps<{
deviceId: number
}>()
// 查询参数
const queryParams = reactive({
deviceId: props.deviceId,
method: undefined,
upstream: undefined,
pageNo: 1,
pageSize: 10
})
// 列表数据
const loading = ref(false)
const total = ref(0)
const list = ref<any[]>([])
const autoRefresh = ref(false) // 自动刷新开关
let autoRefreshTimer: any = null // 自动刷新定时器
// 消息方法选项
const methodOptions = computed(() => {
return Object.values(IotDeviceMessageMethodEnum).map((item) => ({
label: item.name,
value: item.method
}))
})
// 表格列定义
const columns = [
{
title: '时间',
dataIndex: 'ts',
key: 'ts',
align: 'center',
width: 180
},
{
title: '上行/下行',
dataIndex: 'upstream',
key: 'upstream',
align: 'center',
width: 140
},
{
title: '是否回复',
dataIndex: 'reply',
key: 'reply',
align: 'center',
width: 140
},
{
title: '请求编号',
dataIndex: 'requestId',
key: 'requestId',
align: 'center',
width: 300
},
{
title: '请求方法',
dataIndex: 'method',
key: 'method',
align: 'center',
width: 140
},
{
title: '请求/响应数据',
dataIndex: 'params',
key: 'params',
align: 'center',
ellipsis: true
}
]
/** 查询消息列表 */
const getMessageList = async () => {
if (!props.deviceId) return
loading.value = true
try {
const data = await DeviceApi.getDeviceMessagePage(queryParams)
total.value = data.total
list.value = data.list
} finally {
loading.value = false
}
}
/** 搜索操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getMessageList()
}
/** 监听自动刷新 */
watch(autoRefresh, (newValue) => {
if (newValue) {
autoRefreshTimer = setInterval(() => {
getMessageList()
}, 5000)
} else {
clearInterval(autoRefreshTimer)
autoRefreshTimer = null
}
})
/** 监听设备标识变化 */
watch(
() => props.deviceId,
(newValue) => {
if (newValue) {
handleQuery()
}
}
)
/** 组件卸载时清除定时器 */
onBeforeUnmount(() => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer)
autoRefreshTimer = null
}
})
/** 初始化 */
onMounted(() => {
if (props.deviceId) {
getMessageList()
}
})
/** 刷新消息列表 */
const refresh = (delay = 0) => {
if (delay > 0) {
setTimeout(() => {
handleQuery()
}, delay)
} else {
handleQuery()
}
}
/** 暴露方法给父组件 */
defineExpose({
refresh
})
</script>

View File

@@ -1,247 +1,104 @@
<!-- 模拟设备 -->
<template>
<ContentWrap>
<a-row :gutter="20">
<!-- 左侧指令调试区域 -->
<a-col :span="12">
<a-card>
<a-tabs v-model:active-key="activeTab">
<!-- 上行指令调试 -->
<a-tab-pane key="upstream" tab="上行指令调试">
<a-tabs v-if="activeTab === 'upstream'" v-model:active-key="upstreamTab">
<!-- 属性上报 -->
<a-tab-pane :key="IotDeviceMessageMethodEnum.PROPERTY_POST.method" tab="属性上报">
<ContentWrap>
<a-table :dataSource="propertyList" :columns="propertyColumns" :pagination="false">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataType'">
{{ record.property?.dataType ?? '-' }}
</template>
<template v-else-if="column.key === 'dataDefinition'">
<DataDefinition :data="record" />
</template>
<template v-else-if="column.key === 'value'">
<a-input
:value="getFormValue(record.identifier)"
@update:value="setFormValue(record.identifier, $event)"
placeholder="输入值"
size="small"
/>
</template>
</template>
</a-table>
<div class="flex justify-between items-center mt-4">
<span class="text-sm text-gray-600">
设置属性值后点击发送属性上报按钮
</span>
<a-button type="primary" @click="handlePropertyPost">发送属性上报</a-button>
</div>
</ContentWrap>
</a-tab-pane>
<!-- 事件上报 -->
<a-tab-pane :key="IotDeviceMessageMethodEnum.EVENT_POST.method" tab="事件上报">
<ContentWrap>
<a-table :dataSource="eventList" :columns="eventColumns" :pagination="false">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataType'">
{{ record.event?.dataType ?? '-' }}
</template>
<template v-else-if="column.key === 'dataDefinition'">
<DataDefinition :data="record" />
</template>
<template v-else-if="column.key === 'value'">
<a-textarea
:value="getFormValue(record.identifier)"
@update:value="setFormValue(record.identifier, $event)"
:rows="3"
placeholder="输入事件参数JSON格式"
size="small"
/>
</template>
<template v-else-if="column.key === 'action'">
<a-button type="primary" size="small" @click="handleEventPost(record)">
上报事件
</a-button>
</template>
</template>
</a-table>
</ContentWrap>
</a-tab-pane>
<!-- 状态变更 -->
<a-tab-pane :key="IotDeviceMessageMethodEnum.STATE_UPDATE.method" tab="状态变更">
<ContentWrap>
<div class="flex gap-4">
<a-button type="primary" @click="handleDeviceState(DeviceStateEnum.ONLINE)">
设备上线
</a-button>
<a-button danger @click="handleDeviceState(DeviceStateEnum.OFFLINE)">
设备下线
</a-button>
</div>
</ContentWrap>
</a-tab-pane>
</a-tabs>
</a-tab-pane>
<!-- 下行指令调试 -->
<a-tab-pane key="downstream" tab="下行指令调试">
<a-tabs v-if="activeTab === 'downstream'" v-model:active-key="downstreamTab">
<!-- 属性调试 -->
<a-tab-pane :key="IotDeviceMessageMethodEnum.PROPERTY_SET.method" tab="属性设置">
<ContentWrap>
<a-table :dataSource="propertyList" :columns="propertyColumns" :pagination="false">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataType'">
{{ record.property?.dataType ?? '-' }}
</template>
<template v-else-if="column.key === 'dataDefinition'">
<DataDefinition :data="record" />
</template>
<template v-else-if="column.key === 'value'">
<a-input
:value="getFormValue(record.identifier)"
@update:value="setFormValue(record.identifier, $event)"
placeholder="输入值"
size="small"
/>
</template>
</template>
</a-table>
<div class="flex justify-between items-center mt-4">
<span class="text-sm text-gray-600">
设置属性值后点击发送属性设置按钮
</span>
<a-button type="primary" @click="handlePropertySet">发送属性设置</a-button>
</div>
</ContentWrap>
</a-tab-pane>
<!-- 服务调用 -->
<a-tab-pane :key="IotDeviceMessageMethodEnum.SERVICE_INVOKE.method" tab="设备服务调用">
<ContentWrap>
<a-table :dataSource="serviceList" :columns="serviceColumns" :pagination="false">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataDefinition'">
<DataDefinition :data="record" />
</template>
<template v-else-if="column.key === 'value'">
<a-textarea
:value="getFormValue(record.identifier)"
@update:value="setFormValue(record.identifier, $event)"
:rows="3"
placeholder="输入服务参数JSON格式"
size="small"
/>
</template>
<template v-else-if="column.key === 'action'">
<a-button
type="primary"
size="small"
@click="handleServiceInvoke(record)"
>
服务调用
</a-button>
</template>
</template>
</a-table>
</ContentWrap>
</a-tab-pane>
</a-tabs>
</a-tab-pane>
</a-tabs>
</a-card>
</a-col>
<!-- 右侧设备日志区域 -->
<a-col :span="12">
<ContentWrap title="设备消息">
<DeviceDetailsMessage v-if="device.id" ref="deviceMessageRef" :device-id="device.id" />
</ContentWrap>
</a-col>
</a-row>
</ContentWrap>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import type { ProductVO } from '#/api/iot/product/product'
import type { ThingModelData } from '#/api/iot/thingmodel'
import { DeviceApi, DeviceStateEnum } from '#/api/iot/device/device'
import type { DeviceVO } from '#/api/iot/device/device'
import DeviceDetailsMessage from './DeviceDetailsMessage.vue'
import { IotDeviceMessageMethodEnum, IoTThingModelTypeEnum } from '#/views/iot/utils/constants'
import type { TableColumnType } from 'ant-design-vue';
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotProductApi } from '#/api/iot/product/product';
import type { ThingModelData } from '#/api/iot/thingmodel';
import { computed, ref } from 'vue';
import {
Button,
Card,
Col,
Input,
message,
Row,
Table,
Tabs,
Textarea,
} from 'ant-design-vue';
import { DeviceStateEnum, sendDeviceMessage } from '#/api/iot/device/device';
import {
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
import DeviceDetailsMessage from './DeviceDetailsMessage.vue';
const props = defineProps<{
product: ProductVO
device: DeviceVO
thingModelList: ThingModelData[]
}>()
device: IotDeviceApi.Device;
product: IotProductApi.Product;
thingModelList: ThingModelData[];
}>();
// 消息弹窗
const activeTab = ref('upstream') // 上行upstream、下行downstream
const upstreamTab = ref(IotDeviceMessageMethodEnum.PROPERTY_POST.method) // 上行子标签
const downstreamTab = ref(IotDeviceMessageMethodEnum.PROPERTY_SET.method) // 下行子标签
const deviceMessageRef = ref() // 设备消息组件引用
const deviceMessageRefreshDelay = 2000 // 延迟 N 秒,保证模拟上行的消息被处理
// 消息弹窗
const activeTab = ref('upstream'); // 上行upstream、下行downstream
const upstreamTab = ref(IotDeviceMessageMethodEnum.PROPERTY_POST.method); // 上行子标签
const downstreamTab = ref(IotDeviceMessageMethodEnum.PROPERTY_SET.method); // 下行子标签
const deviceMessageRef = ref(); // 设备消息组件引用
const deviceMessageRefreshDelay = 2000; // 延迟 N 秒,保证模拟上行的消息被处理
// 表单数据:存储用户输入的模拟值
const formData = ref<Record<string, string>>({})
const formData = ref<Record<string, string>>({});
// 根据类型过滤物模型数据
const getFilteredThingModelList = (type: number) => {
return props.thingModelList.filter((item) => String(item.type) === String(type))
}
return props.thingModelList.filter(
(item) => String(item.type) === String(type),
);
};
// 计算属性:属性列表
const propertyList = computed(() => getFilteredThingModelList(IoTThingModelTypeEnum.PROPERTY))
const propertyList = computed(() =>
getFilteredThingModelList(IoTThingModelTypeEnum.PROPERTY),
);
// 计算属性:事件列表
const eventList = computed(() => getFilteredThingModelList(IoTThingModelTypeEnum.EVENT))
const eventList = computed(() =>
getFilteredThingModelList(IoTThingModelTypeEnum.EVENT),
);
// 计算属性:服务列表
const serviceList = computed(() => getFilteredThingModelList(IoTThingModelTypeEnum.SERVICE))
const serviceList = computed(() =>
getFilteredThingModelList(IoTThingModelTypeEnum.SERVICE),
);
// 属性表格列定义
const propertyColumns = [
const propertyColumns: TableColumnType[] = [
{
title: '功能名称',
dataIndex: 'name',
key: 'name',
width: 120,
align: 'center',
fixed: 'left'
fixed: 'left' as any,
},
{
title: '标识符',
dataIndex: 'identifier',
key: 'identifier',
width: 120,
align: 'center',
fixed: 'left'
fixed: 'left' as any,
},
{
title: '数据类型',
key: 'dataType',
width: 100,
align: 'center'
},
{
title: '数据定义',
key: 'dataDefinition',
minWidth: 200,
align: 'left'
},
{
title: '值',
key: 'value',
width: 150,
align: 'center',
fixed: 'right'
}
]
fixed: 'right' as any,
},
];
// 事件表格列定义
const eventColumns = [
@@ -250,43 +107,37 @@ const eventColumns = [
dataIndex: 'name',
key: 'name',
width: 120,
align: 'center',
fixed: 'left'
fixed: 'left' as any,
},
{
title: '标识符',
dataIndex: 'identifier',
key: 'identifier',
width: 120,
align: 'center',
fixed: 'left'
fixed: 'left' as any,
},
{
title: '数据类型',
key: 'dataType',
width: 100,
align: 'center'
},
{
title: '数据定义',
key: 'dataDefinition',
minWidth: 200,
align: 'left'
},
{
title: '值',
key: 'value',
width: 200,
align: 'center'
},
{
title: '操作',
key: 'action',
width: 100,
align: 'center',
fixed: 'right'
}
]
fixed: 'right' as any,
},
];
// 服务表格列定义
const serviceColumns = [
@@ -295,191 +146,418 @@ const serviceColumns = [
dataIndex: 'name',
key: 'name',
width: 120,
align: 'center',
fixed: 'left'
fixed: 'left' as any,
},
{
title: '标识符',
dataIndex: 'identifier',
key: 'identifier',
width: 120,
align: 'center',
fixed: 'left'
fixed: 'left' as any,
},
{
title: '输入参数',
key: 'dataDefinition',
minWidth: 200,
align: 'left'
},
{
title: '参数值',
key: 'value',
width: 200,
align: 'center'
},
{
title: '操作',
key: 'action',
width: 100,
align: 'center',
fixed: 'right'
}
]
fixed: 'right' as any,
},
];
// 获取表单值
const getFormValue = (identifier: string) => {
return formData.value[identifier] || ''
function getFormValue(identifier: string) {
return formData.value[identifier] || '';
}
// 设置表单值
const setFormValue = (identifier: string, value: string) => {
formData.value[identifier] = value
function setFormValue(identifier: string, value: string) {
formData.value[identifier] = value;
}
// 属性上报
const handlePropertyPost = async () => {
async function handlePropertyPost() {
try {
const params: Record<string, any> = {}
const params: Record<string, any> = {};
propertyList.value.forEach((item) => {
const value = formData.value[item.identifier!]
const value = formData.value[item.identifier!];
if (value) {
params[item.identifier!] = value
params[item.identifier!] = value;
}
})
});
if (Object.keys(params).length === 0) {
message.warning({ content: '请至少输入一个属性值' })
return
message.warning({ content: '请至少输入一个属性值' });
return;
}
await DeviceApi.sendDeviceMessage({
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.PROPERTY_POST.method,
params
})
params,
});
message.success({ content: '属性上报成功' })
message.success({ content: '属性上报成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay)
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '属性上报失败' })
console.error(error)
message.error({ content: '属性上报失败' });
console.error(error);
}
}
// 事件上报
const handleEventPost = async (row: ThingModelData) => {
async function handleEventPost(row: ThingModelData) {
try {
const valueStr = formData.value[row.identifier!]
let params: any = {}
const valueStr = formData.value[row.identifier!];
let params: any = {};
if (valueStr) {
try {
params = JSON.parse(valueStr)
} catch (e) {
message.error({ content: '事件参数格式错误请输入有效的JSON格式' })
return
params = JSON.parse(valueStr);
} catch {
message.error({ content: '事件参数格式错误请输入有效的JSON格式' });
return;
}
}
await DeviceApi.sendDeviceMessage({
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.EVENT_POST.method,
params: {
identifier: row.identifier,
params
}
})
params,
},
});
message.success({ content: '事件上报成功' })
message.success({ content: '事件上报成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay)
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '事件上报失败' })
console.error(error)
message.error({ content: '事件上报失败' });
console.error(error);
}
}
// 状态变更
const handleDeviceState = async (state: number) => {
async function handleDeviceState(state: number) {
try {
await DeviceApi.sendDeviceMessage({
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.STATE_UPDATE.method,
params: { state }
})
params: { state },
});
message.success({ content: '状态变更成功' })
message.success({ content: '状态变更成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay)
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '状态变更失败' })
console.error(error)
message.error({ content: '状态变更失败' });
console.error(error);
}
}
// 属性设置
const handlePropertySet = async () => {
async function handlePropertySet() {
try {
const params: Record<string, any> = {}
const params: Record<string, any> = {};
propertyList.value.forEach((item) => {
const value = formData.value[item.identifier!]
const value = formData.value[item.identifier!];
if (value) {
params[item.identifier!] = value
params[item.identifier!] = value;
}
})
});
if (Object.keys(params).length === 0) {
message.warning({ content: '请至少输入一个属性值' })
return
message.warning({ content: '请至少输入一个属性值' });
return;
}
await DeviceApi.sendDeviceMessage({
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.PROPERTY_SET.method,
params
})
params,
});
message.success({ content: '属性设置成功' })
message.success({ content: '属性设置成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay)
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '属性设置失败' })
console.error(error)
message.error({ content: '属性设置失败' });
console.error(error);
}
}
// 服务调用
const handleServiceInvoke = async (row: ThingModelData) => {
async function handleServiceInvoke(row: ThingModelData) {
try {
const valueStr = formData.value[row.identifier!]
let params: any = {}
const valueStr = formData.value[row.identifier!];
let params: any = {};
if (valueStr) {
try {
params = JSON.parse(valueStr)
} catch (e) {
message.error({ content: '服务参数格式错误请输入有效的JSON格式' })
return
params = JSON.parse(valueStr);
} catch {
message.error({ content: '服务参数格式错误请输入有效的JSON格式' });
return;
}
}
await DeviceApi.sendDeviceMessage({
await sendDeviceMessage({
deviceId: props.device.id!,
method: IotDeviceMessageMethodEnum.SERVICE_INVOKE.method,
params: {
identifier: row.identifier,
params
}
})
params,
},
});
message.success({ content: '服务调用成功' })
message.success({ content: '服务调用成功' });
// 延迟刷新设备消息列表
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay)
deviceMessageRef.value?.refresh(deviceMessageRefreshDelay);
} catch (error) {
message.error({ content: '服务调用失败' })
console.error(error)
message.error({ content: '服务调用失败' });
console.error(error);
}
}
</script>
<template>
<ContentWrap>
<Row :gutter="20">
<!-- 左侧指令调试区域 -->
<Col :span="12">
<Card>
<Tabs v-model:active-key="activeTab">
<!-- 上行指令调试 -->
<Tabs.Pane key="upstream" tab="上行指令调试">
<Tabs
v-if="activeTab === 'upstream'"
v-model:active-key="upstreamTab"
>
<!-- 属性上报 -->
<Tabs.Pane
:key="IotDeviceMessageMethodEnum.PROPERTY_POST.method"
tab="属性上报"
>
<ContentWrap>
<Table
:data-source="propertyList"
align="center"
:columns="propertyColumns"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataType'">
{{ record.property?.dataType ?? '-' }}
</template>
<template v-else-if="column.key === 'dataDefinition'">
<DataDefinition :data="record" />
</template>
<template v-else-if="column.key === 'value'">
<Input
:value="getFormValue(record.identifier)"
@update:value="
setFormValue(record.identifier, $event)
"
placeholder="输入值"
size="small"
/>
</template>
</template>
</Table>
<div class="mt-4 flex items-center justify-between">
<span class="text-sm text-gray-600">
设置属性值后点击发送属性上报按钮
</span>
<Button type="primary" @click="handlePropertyPost">
发送属性上报
</Button>
</div>
</ContentWrap>
</Tabs.Pane>
<!-- 事件上报 -->
<Tabs.Pane
:key="IotDeviceMessageMethodEnum.EVENT_POST.method"
tab="事件上报"
>
<ContentWrap>
<Table
:data-source="eventList"
align="center"
:columns="eventColumns"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataType'">
{{ record.event?.dataType ?? '-' }}
</template>
<template v-else-if="column.key === 'dataDefinition'">
<DataDefinition :data="record" />
</template>
<template v-else-if="column.key === 'value'">
<Textarea
:value="getFormValue(record.identifier)"
@update:value="
setFormValue(record.identifier, $event)
"
:rows="3"
placeholder="输入事件参数JSON格式"
size="small"
/>
</template>
<template v-else-if="column.key === 'action'">
<Button
type="primary"
size="small"
@click="handleEventPost(record)"
>
上报事件
</Button>
</template>
</template>
</Table>
</ContentWrap>
</Tabs.Pane>
<!-- 状态变更 -->
<Tabs.Pane
:key="IotDeviceMessageMethodEnum.STATE_UPDATE.method"
tab="状态变更"
>
<ContentWrap>
<div class="flex gap-4">
<Button
type="primary"
@click="handleDeviceState(DeviceStateEnum.ONLINE)"
>
设备上线
</Button>
<Button
danger
@click="handleDeviceState(DeviceStateEnum.OFFLINE)"
>
设备下线
</Button>
</div>
</ContentWrap>
</Tabs.Pane>
</Tabs>
</Tabs.Pane>
<!-- 下行指令调试 -->
<Tabs.Pane key="downstream" tab="下行指令调试">
<Tabs
v-if="activeTab === 'downstream'"
v-model:active-key="downstreamTab"
>
<!-- 属性调试 -->
<Tabs.Pane
:key="IotDeviceMessageMethodEnum.PROPERTY_SET.method"
tab="属性设置"
>
<ContentWrap>
<Table
:data-source="propertyList"
align="center"
:columns="propertyColumns"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataType'">
{{ record.property?.dataType ?? '-' }}
</template>
<template v-else-if="column.key === 'dataDefinition'">
<DataDefinition :data="record" />
</template>
<template v-else-if="column.key === 'value'">
<Input
:value="getFormValue(record.identifier)"
@update:value="
setFormValue(record.identifier, $event)
"
placeholder="输入值"
size="small"
/>
</template>
</template>
</Table>
<div class="mt-4 flex items-center justify-between">
<span class="text-sm text-gray-600">
设置属性值后点击发送属性设置按钮
</span>
<Button type="primary" @click="handlePropertySet">
发送属性设置
</Button>
</div>
</ContentWrap>
</Tabs.Pane>
<!-- 服务调用 -->
<Tabs.Pane
:key="IotDeviceMessageMethodEnum.SERVICE_INVOKE.method"
tab="设备服务调用"
>
<ContentWrap>
<Table
:data-source="serviceList"
align="center"
:columns="serviceColumns"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'dataDefinition'">
<DataDefinition :data="record" />
</template>
<template v-else-if="column.key === 'value'">
<Textarea
:value="getFormValue(record.identifier)"
@update:value="
setFormValue(record.identifier, $event)
"
:rows="3"
placeholder="输入服务参数JSON格式"
size="small"
/>
</template>
<template v-else-if="column.key === 'action'">
<Button
type="primary"
size="small"
@click="handleServiceInvoke(record)"
>
服务调用
</Button>
</template>
</template>
</Table>
</ContentWrap>
</Tabs.Pane>
</Tabs>
</Tabs.Pane>
</Tabs>
</Card>
</Col>
<!-- 右侧设备日志区域 -->
<Col :span="12">
<ContentWrap title="设备消息">
<DeviceDetailsMessage
v-if="device.id"
ref="deviceMessageRef"
:device-id="device.id"
/>
</ContentWrap>
</Col>
</Row>
</ContentWrap>
</template>

View File

@@ -1,47 +1,42 @@
<!-- 设备物模型设备属性事件管理服务调用 -->
<script setup lang="ts">
import type { ThingModelData } from '#/api/iot/thingmodel';
import { ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { Tabs } from 'ant-design-vue';
import DeviceDetailsThingModelEvent from './DeviceDetailsThingModelEvent.vue';
import DeviceDetailsThingModelProperty from './DeviceDetailsThingModelProperty.vue';
import DeviceDetailsThingModelService from './DeviceDetailsThingModelService.vue';
const props = defineProps<{
deviceId: number;
thingModelList: ThingModelData[];
}>();
const activeTab = ref('property'); // 默认选中设备属性
</script>
<template>
<ContentWrap>
<a-tabs v-model:active-key="activeTab" class="thing-model-tabs">
<a-tab-pane key="property" tab="设备属性(运行状态)">
<Tabs v-model:active-key="activeTab" class="!h-auto !p-0">
<Tabs.Pane key="property" tab="设备属性(运行状态)">
<DeviceDetailsThingModelProperty :device-id="deviceId" />
</a-tab-pane>
<a-tab-pane key="event" tab="设备事件上报">
</Tabs.Pane>
<Tabs.Pane key="event" tab="设备事件上报">
<DeviceDetailsThingModelEvent
:device-id="props.deviceId"
:thing-model-list="props.thingModelList"
/>
</a-tab-pane>
<a-tab-pane key="service" tab="设备服务调用">
</Tabs.Pane>
<Tabs.Pane key="service" tab="设备服务调用">
<DeviceDetailsThingModelService
:device-id="deviceId"
:thing-model-list="props.thingModelList"
/>
</a-tab-pane>
</a-tabs>
</Tabs.Pane>
</Tabs>
</ContentWrap>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ContentWrap } from '@vben/common-ui'
import type { ThingModelData } from '#/api/iot/thingmodel'
import DeviceDetailsThingModelProperty from './DeviceDetailsThingModelProperty.vue'
import DeviceDetailsThingModelEvent from './DeviceDetailsThingModelEvent.vue'
import DeviceDetailsThingModelService from './DeviceDetailsThingModelService.vue'
const props = defineProps<{
deviceId: number
thingModelList: ThingModelData[]
}>()
const activeTab = ref('property') // 默认选中设备属性
</script>
<style scoped>
.thing-model-tabs :deep(.ant-tabs-content) {
height: auto !important;
}
.thing-model-tabs :deep(.ant-tabs-tabpane) {
padding: 0 !important;
}
</style>

View File

@@ -1,84 +1,228 @@
<!-- 设备事件管理 -->
<script setup lang="ts">
import type { ThingModelData } from '#/api/iot/thingmodel';
import { computed, onMounted, reactive, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { formatDate } from '@vben/utils';
import {
Button,
Divider,
Form,
Pagination,
RangePicker,
Select,
Table,
Tag,
} from 'ant-design-vue';
import { getDeviceMessagePairPage } from '#/api/iot/device/device';
import {
getEventTypeLabel,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
const props = defineProps<{
deviceId: number;
thingModelList: ThingModelData[];
}>();
const loading = ref(false); // 列表的加载中
const total = ref(0); // 列表的总页数
const list = ref([] as any[]); // 列表的数据
const queryParams = reactive({
deviceId: props.deviceId,
method: IotDeviceMessageMethodEnum.EVENT_POST.method, // 固定筛选事件消息
identifier: '',
times: undefined,
pageNo: 1,
pageSize: 10,
});
const queryFormRef = ref(); // 搜索的表单
/** 事件类型的物模型数据 */
const eventThingModels = computed(() => {
return props.thingModelList.filter(
(item: ThingModelData) =>
String(item.type) === String(IoTThingModelTypeEnum.EVENT),
);
});
/** 查询列表 */
async function getList() {
if (!props.deviceId) return;
loading.value = true;
try {
const data = await getDeviceMessagePairPage(queryParams);
list.value = data.list || [];
total.value = data.total || 0;
} finally {
loading.value = false;
}
}
/** 搜索按钮操作 */
function handleQuery() {
queryParams.pageNo = 1;
getList();
}
/** 重置按钮操作 */
function resetQuery() {
queryFormRef.value?.resetFields();
queryParams.identifier = '';
queryParams.times = undefined;
handleQuery();
}
/** 获取事件名称 */
function getEventName(identifier: string | undefined) {
if (!identifier) return '-';
const event = eventThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier,
);
return event?.name || identifier;
}
/** 获取事件类型 */
function getEventType(identifier: string | undefined) {
if (!identifier) return '-';
const event = eventThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier,
);
if (!event?.event?.type) return '-';
return getEventTypeLabel(event.event.type) || '-';
}
/** 解析参数 */
function parseParams(params: string) {
try {
const parsed = JSON.parse(params);
if (parsed.params) {
return parsed.params;
}
return parsed;
} catch {
return {};
}
}
/** 初始化 */
onMounted(() => {
getList();
});
</script>
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<a-form
<Form
:model="queryParams"
ref="queryFormRef"
layout="inline"
@submit.prevent
style="margin-bottom: 16px;"
style="margin-bottom: 16px"
>
<a-form-item label="标识符" name="identifier">
<a-select
<Form.Item label="标识符" name="identifier">
<Select
v-model:value="queryParams.identifier"
placeholder="请选择事件标识符"
allow-clear
style="width: 240px;"
style="width: 240px"
>
<a-select-option
<Select.Option
v-for="event in eventThingModels"
:key="event.identifier"
:value="event.identifier!"
>
{{ event.name }}({{ event.identifier }})
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="时间范围" name="times">
<a-range-picker
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="时间范围" name="times">
<RangePicker
v-model:value="queryParams.times"
show-time
format="YYYY-MM-DD HH:mm:ss"
style="width: 360px;"
style="width: 360px"
/>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleQuery">
</Form.Item>
<Form.Item>
<Button type="primary" @click="handleQuery">
<template #icon>
<IconifyIcon icon="ep:search" />
</template>
搜索
</a-button>
<a-button @click="resetQuery" style="margin-left: 8px;">
</Button>
<Button @click="resetQuery" style="margin-left: 8px">
<template #icon>
<IconifyIcon icon="ep:refresh" />
</template>
重置
</a-button>
</a-form-item>
</a-form>
</Button>
</Form.Item>
</Form>
<Divider style="margin: 16px 0" />
<a-divider style="margin: 16px 0;" />
<!-- 事件列表 -->
<a-table v-loading="loading" :data-source="list" :pagination="false">
<a-table-column title="上报时间" align="center" data-index="reportTime" :width="180">
<Table v-loading="loading" :data-source="list" :pagination="false">
<Table.Column
title="上报时间"
align="center"
data-index="reportTime"
:width="180"
>
<template #default="{ record }">
{{ record.request?.reportTime ? formatDate(record.request.reportTime) : '-' }}
{{
record.request?.reportTime
? formatDate(record.request.reportTime)
: '-'
}}
</template>
</a-table-column>
<a-table-column title="标识符" align="center" data-index="identifier" :width="160">
</Table.Column>
<Table.Column
title="标识符"
align="center"
data-index="identifier"
:width="160"
>
<template #default="{ record }">
<a-tag color="blue" size="small">
<Tag color="blue" size="small">
{{ record.request?.identifier }}
</a-tag>
</Tag>
</template>
</a-table-column>
<a-table-column title="事件名称" align="center" data-index="eventName" :width="160">
</Table.Column>
<Table.Column
title="事件名称"
align="center"
data-index="eventName"
:width="160"
>
<template #default="{ record }">
{{ getEventName(record.request?.identifier) }}
</template>
</a-table-column>
<a-table-column title="事件类型" align="center" data-index="eventType" :width="100">
</Table.Column>
<Table.Column
title="事件类型"
align="center"
data-index="eventType"
:width="100"
>
<template #default="{ record }">
{{ getEventType(record.request?.identifier) }}
</template>
</a-table-column>
<a-table-column title="输入参数" align="center" data-index="params">
<template #default="{ record }"> {{ parseParams(record.request.params) }} </template>
</a-table-column>
</a-table>
</Table.Column>
<Table.Column title="输入参数" align="center" data-index="params">
<template #default="{ record }">
{{ parseParams(record.request.params) }}
</template>
</Table.Column>
</Table>
<!-- 分页 -->
<Pagination
@@ -89,108 +233,3 @@
/>
</ContentWrap>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { Pagination } from 'ant-design-vue'
import { ContentWrap } from '@vben/common-ui'
import { IconifyIcon } from '@vben/icons'
import { DeviceApi } from '#/api/iot/device/device'
import type { ThingModelData } from '#/api/iot/thingmodel'
import { formatDate } from '@vben/utils'
import {
getEventTypeLabel,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum
} from '#/views/iot/utils/constants'
const props = defineProps<{
deviceId: number
thingModelList: ThingModelData[]
}>()
const loading = ref(false) // 列表的加载中
const total = ref(0) // 列表的总页数
const list = ref([] as any[]) // 列表的数据
const queryParams = reactive({
deviceId: props.deviceId,
method: IotDeviceMessageMethodEnum.EVENT_POST.method, // 固定筛选事件消息
identifier: '',
times: [] as any[],
pageNo: 1,
pageSize: 10
})
const queryFormRef = ref() // 搜索的表单
/** 事件类型的物模型数据 */
const eventThingModels = computed(() => {
return props.thingModelList.filter(
(item: ThingModelData) => String(item.type) === String(IoTThingModelTypeEnum.EVENT)
)
})
/** 查询列表 */
const getList = async () => {
if (!props.deviceId) return
loading.value = true
try {
const data = await DeviceApi.getDeviceMessagePairPage(queryParams)
list.value = data.list || []
total.value = data.total || 0
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value?.resetFields()
queryParams.identifier = ''
queryParams.times = []
handleQuery()
}
/** 获取事件名称 */
const getEventName = (identifier: string | undefined) => {
if (!identifier) return '-'
const event = eventThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier
)
return event?.name || identifier
}
/** 获取事件类型 */
const getEventType = (identifier: string | undefined) => {
if (!identifier) return '-'
const event = eventThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier
)
if (!event?.event?.type) return '-'
return getEventTypeLabel(event.event.type) || '-'
}
/** 解析参数 */
const parseParams = (params: string) => {
try {
const parsed = JSON.parse(params)
if (parsed.params) {
return parsed.params
}
return parsed
} catch (error) {
return {}
}
}
/** 初始化 */
onMounted(() => {
getList()
})
</script>

View File

@@ -1,41 +1,156 @@
<!-- 设备属性管理 -->
<script setup lang="ts">
import type { IotDeviceApi } from '#/api/iot/device/device';
import { onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { formatDate } from '@vben/utils';
import {
Button,
Card,
Col,
Divider,
Input,
Row,
Switch,
Table,
Tag,
} from 'ant-design-vue';
import { getLatestDeviceProperties } from '#/api/iot/device/device';
import DeviceDetailsThingModelPropertyHistory from './DeviceDetailsThingModelPropertyHistory.vue';
const props = defineProps<{ deviceId: number }>();
const loading = ref(true); // 列表的加载中
const list = ref<IotDeviceApi.DevicePropertyDetail[]>([]); // 显示的列表数据
const filterList = ref<IotDeviceApi.DevicePropertyDetail[]>([]); // 完整的数据列表
const queryParams = reactive({
keyword: '' as string,
});
const autoRefresh = ref(false); // 自动刷新开关
let autoRefreshTimer: any = null; // 定时器
const viewMode = ref<'card' | 'list'>('card'); // 视图模式状态
/** 查询列表 */
async function getList() {
loading.value = true;
try {
const params = {
deviceId: props.deviceId,
identifier: undefined as string | undefined,
name: undefined as string | undefined,
};
filterList.value = await getLatestDeviceProperties(params);
handleFilter();
} finally {
loading.value = false;
}
}
/** 前端筛选数据 */
function handleFilter() {
if (queryParams.keyword.trim()) {
const keyword = queryParams.keyword.toLowerCase();
list.value = filterList.value.filter(
(item: IotDeviceApi.DevicePropertyDetail) =>
item.identifier?.toLowerCase().includes(keyword) ||
item.name?.toLowerCase().includes(keyword),
);
} else {
list.value = filterList.value;
}
}
/** 搜索按钮操作 */
function handleQuery() {
handleFilter();
}
/** 历史操作 */
const historyRef = ref();
function openHistory(deviceId: number, identifier: string, dataType: string) {
historyRef.value.open(deviceId, identifier, dataType);
}
/** 格式化属性值和单位 */
function formatValueWithUnit(item: IotDeviceApi.DevicePropertyDetail) {
if (item.value === null || item.value === undefined || item.value === '') {
return '-';
}
const unitName = item.dataSpecs?.unitName;
return unitName ? `${item.value} ${unitName}` : item.value;
}
/** 监听自动刷新 */
watch(autoRefresh, (newValue) => {
if (newValue) {
autoRefreshTimer = setInterval(() => {
getList();
}, 5000); // 每 5 秒刷新一次
} else {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
});
/** 组件卸载时清除定时器 */
onBeforeUnmount(() => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer);
}
});
/** 初始化 */
onMounted(() => {
getList();
});
</script>
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<div class="flex items-center justify-between" style="margin-bottom: 16px;">
<div class="flex items-center" style="gap: 16px;">
<a-input
<div class="flex items-center justify-between" style="margin-bottom: 16px">
<div class="flex items-center" style="gap: 16px">
<Input
v-model:value="queryParams.keyword"
placeholder="请输入属性名称、标识符"
allow-clear
style="width: 240px;"
@pressEnter="handleQuery"
style="width: 240px"
@press-enter="handleQuery"
/>
<div class="flex items-center" style="gap: 8px;">
<span style="font-size: 14px; color: #666;">自动刷新</span>
<a-switch
v-model:checked="autoRefresh"
size="small"
/>
<div class="flex items-center" style="gap: 8px">
<span style="font-size: 14px; color: #666">自动刷新</span>
<Switch v-model:checked="autoRefresh" size="small" />
</div>
</div>
<a-button-group>
<a-button :type="viewMode === 'card' ? 'primary' : 'default'" @click="viewMode = 'card'">
<Button.Group>
<Button
:type="viewMode === 'card' ? 'primary' : 'default'"
@click="viewMode = 'card'"
>
<IconifyIcon icon="ep:grid" />
</a-button>
<a-button :type="viewMode === 'list' ? 'primary' : 'default'" @click="viewMode = 'list'">
</Button>
<Button
:type="viewMode === 'list' ? 'primary' : 'default'"
@click="viewMode = 'list'"
>
<IconifyIcon icon="ep:list" />
</a-button>
</a-button-group>
</Button>
</Button.Group>
</div>
<!-- 分隔线 -->
<a-divider style="margin: 16px 0;" />
<Divider style="margin: 16px 0" />
<!-- 卡片视图 -->
<template v-if="viewMode === 'card'">
<a-row :gutter="16" v-loading="loading">
<a-col
<Row :gutter="16" v-loading="loading">
<Col
v-for="item in list"
:key="item.identifier"
:xs="24"
@@ -44,75 +159,82 @@
:lg="6"
class="mb-4"
>
<a-card
class="h-full transition-colors relative overflow-hidden"
<Card
class="relative h-full overflow-hidden transition-colors"
:body-style="{ padding: '0' }"
>
<!-- 添加渐变背景层 -->
<div
class="absolute top-0 left-0 right-0 h-[50px] pointer-events-none bg-gradient-to-b from-[#eefaff] to-transparent"
>
</div>
<div class="p-4 relative">
class="pointer-events-none absolute left-0 right-0 top-0 h-[50px] bg-gradient-to-b from-[#eefaff] to-transparent"
></div>
<div class="relative p-4">
<!-- 标题区域 -->
<div class="flex items-center mb-3">
<div class="mb-3 flex items-center">
<div class="mr-2.5 flex items-center">
<IconifyIcon icon="ep:cpu" class="text-[18px] text-[#0070ff]" />
<IconifyIcon
icon="ep:cpu"
class="text-[18px] text-[#0070ff]"
/>
</div>
<div class="text-[16px] font-600 flex-1">{{ item.name }}</div>
<div class="font-600 flex-1 text-[16px]">{{ item.name }}</div>
<!-- 标识符 -->
<div class="inline-flex items-center mr-2">
<a-tag size="small" color="blue">
<div class="mr-2 inline-flex items-center">
<Tag size="small" color="blue">
{{ item.identifier }}
</a-tag>
</Tag>
</div>
<!-- 数据类型标签 -->
<div class="inline-flex items-center mr-2">
<a-tag size="small">
<div class="mr-2 inline-flex items-center">
<Tag size="small">
{{ item.dataType }}
</a-tag>
</Tag>
</div>
<!-- 数据图标 - 可点击 -->
<div
class="cursor-pointer flex items-center justify-center w-8 h-8 rounded-full hover:bg-blue-50 transition-colors"
@click="openHistory(props.deviceId, item.identifier, item.dataType)"
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-blue-50"
@click="
openHistory(props.deviceId, item.identifier, item.dataType)
"
>
<IconifyIcon icon="ep:data-line" class="text-[18px] text-[#0070ff]" />
<IconifyIcon
icon="ep:data-line"
class="text-[18px] text-[#0070ff]"
/>
</div>
</div>
<!-- 信息区域 -->
<div class="text-[14px]">
<div class="mb-2.5 last:mb-0">
<span class="text-[#717c8e] mr-2.5">属性值</span>
<span class="text-[#0b1d30] font-600">
<span class="mr-2.5 text-[#717c8e]">属性值</span>
<span class="font-600 text-[#0b1d30]">
{{ formatValueWithUnit(item) }}
</span>
</div>
<div class="mb-2.5 last:mb-0">
<span class="text-[#717c8e] mr-2.5">更新时间</span>
<span class="text-[#0b1d30] text-[12px]">
<span class="mr-2.5 text-[#717c8e]">更新时间</span>
<span class="text-[12px] text-[#0b1d30]">
{{ item.updateTime ? formatDate(item.updateTime) : '-' }}
</span>
</div>
</div>
</div>
</a-card>
</a-col>
</a-row>
</Card>
</Col>
</Row>
</template>
<!-- 列表视图 -->
<a-table v-else v-loading="loading" :data-source="list" :pagination="false">
<a-table-column title="属性标识符" align="center" data-index="identifier" />
<a-table-column title="属性名称" align="center" data-index="name" />
<a-table-column title="数据类型" align="center" data-index="dataType" />
<a-table-column title="属性值" align="center" data-index="value">
<Table v-else v-loading="loading" :data-source="list" :pagination="false">
<Table.Column title="属性标识符" align="center" data-index="identifier" />
<Table.Column title="属性名称" align="center" data-index="name" />
<Table.Column title="数据类型" align="center" data-index="dataType" />
<Table.Column title="属性值" align="center" data-index="value">
<template #default="{ record }">
{{ formatValueWithUnit(record) }}
</template>
</a-table-column>
<a-table-column
</Table.Column>
<Table.Column
title="更新时间"
align="center"
data-index="updateTime"
@@ -121,123 +243,32 @@
<template #default="{ record }">
{{ record.updateTime ? formatDate(record.updateTime) : '-' }}
</template>
</a-table-column>
<a-table-column title="操作" align="center">
</Table.Column>
<Table.Column title="操作" align="center">
<template #default="{ record }">
<a-button
<Button
type="link"
@click="openHistory(props.deviceId, record.identifier, record.dataType)"
@click="
openHistory(props.deviceId, record.identifier, record.dataType)
"
>
查看数据
</a-button>
</Button>
</template>
</a-table-column>
</a-table>
</Table.Column>
</Table>
<!-- 表单弹窗添加/修改 -->
<DeviceDetailsThingModelPropertyHistory ref="historyRef" :deviceId="props.deviceId" />
<DeviceDetailsThingModelPropertyHistory
ref="historyRef"
:device-id="props.deviceId"
/>
</ContentWrap>
</template>
<style scoped>
/* 移除 a-row 的额外边距 */
:deep(.ant-row) {
margin-left: -8px !important;
margin-right: -8px !important;
margin-left: -8px !important;
}
</style>
<script setup lang="ts">
import { ref, reactive, watch, onMounted, onBeforeUnmount } from 'vue'
import { ContentWrap } from '@vben/common-ui'
import { IconifyIcon } from '@vben/icons'
import { DeviceApi, type IotDevicePropertyDetailRespVO } from '#/api/iot/device/device'
import { formatDate } from '@vben/utils'
import DeviceDetailsThingModelPropertyHistory from './DeviceDetailsThingModelPropertyHistory.vue'
const props = defineProps<{ deviceId: number }>()
const loading = ref(true) // 列表的加载中
const list = ref<IotDevicePropertyDetailRespVO[]>([]) // 显示的列表数据
const filterList = ref<IotDevicePropertyDetailRespVO[]>([]) // 完整的数据列表
const queryParams = reactive({
keyword: '' as string
})
const autoRefresh = ref(false) // 自动刷新开关
let autoRefreshTimer: any = null // 定时器
const viewMode = ref<'card' | 'list'>('card') // 视图模式状态
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const params = {
deviceId: props.deviceId,
identifier: undefined as string | undefined,
name: undefined as string | undefined
}
filterList.value = await DeviceApi.getLatestDeviceProperties(params)
handleFilter()
} finally {
loading.value = false
}
}
/** 前端筛选数据 */
const handleFilter = () => {
if (!queryParams.keyword.trim()) {
list.value = filterList.value
} else {
const keyword = queryParams.keyword.toLowerCase()
list.value = filterList.value.filter(
(item: IotDevicePropertyDetailRespVO) =>
item.identifier?.toLowerCase().includes(keyword) ||
item.name?.toLowerCase().includes(keyword)
)
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
handleFilter()
}
/** 历史操作 */
const historyRef = ref()
const openHistory = (deviceId: number, identifier: string, dataType: string) => {
historyRef.value.open(deviceId, identifier, dataType)
}
/** 格式化属性值和单位 */
const formatValueWithUnit = (item: IotDevicePropertyDetailRespVO) => {
if (item.value === null || item.value === undefined || item.value === '') {
return '-'
}
const unitName = item.dataSpecs?.unitName
return unitName ? `${item.value} ${unitName}` : item.value
}
/** 监听自动刷新 */
watch(autoRefresh, (newValue) => {
if (newValue) {
autoRefreshTimer = setInterval(() => {
getList()
}, 5000) // 每 5 秒刷新一次
} else {
clearInterval(autoRefreshTimer)
autoRefreshTimer = null
}
})
/** 组件卸载时清除定时器 */
onBeforeUnmount(() => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer)
}
})
/** 初始化 **/
onMounted(() => {
getList()
})
</script>

View File

@@ -1,192 +1,98 @@
<!-- 设备物模型 -> 运行状态 -> 查看数据设备的属性值历史-->
<template>
<Modal
v-model:open="dialogVisible"
title="查看数据"
width="1200px"
:destroy-on-close="true"
@cancel="handleClose"
>
<div class="property-history-container">
<!-- 工具栏 -->
<div class="toolbar-wrapper mb-4">
<a-space :size="12" class="w-full" wrap>
<!-- 时间选择 -->
<a-range-picker
v-model:value="dateRange"
:show-time="{ format: 'HH:mm:ss' }"
format="YYYY-MM-DD HH:mm:ss"
:placeholder="['开始时间', '结束时间']"
class="!w-[400px]"
@change="handleTimeChange"
/>
<!-- 刷新按钮 -->
<a-button @click="handleRefresh" :loading="loading">
<template #icon>
<Icon icon="ant-design:reload-outlined" />
</template>
刷新
</a-button>
<!-- 导出按钮 -->
<a-button @click="handleExport" :loading="exporting" :disabled="list.length === 0">
<template #icon>
<Icon icon="ant-design:export-outlined" />
</template>
导出
</a-button>
<!-- 视图切换 -->
<a-button-group class="ml-auto">
<a-button
:type="viewMode === 'chart' ? 'primary' : 'default'"
@click="viewMode = 'chart'"
:disabled="isComplexDataType"
>
<template #icon>
<Icon icon="ant-design:line-chart-outlined" />
</template>
图表
</a-button>
<a-button :type="viewMode === 'list' ? 'primary' : 'default'" @click="viewMode = 'list'">
<template #icon>
<Icon icon="ant-design:table-outlined" />
</template>
列表
</a-button>
</a-button-group>
</a-space>
<!-- 数据统计信息 -->
<div v-if="list.length > 0" class="mt-3 text-sm text-gray-600">
<a-space :size="16">
<span> {{ total }} 条数据</span>
<span v-if="viewMode === 'chart' && !isComplexDataType">
最大值: {{ maxValue }} | 最小值: {{ minValue }} | 平均值: {{ avgValue }}
</span>
</a-space>
</div>
</div>
<!-- 数据展示区域 -->
<a-spin :spinning="loading" :delay="200">
<!-- 图表模式 -->
<div v-if="viewMode === 'chart'" class="chart-container">
<a-empty
v-if="list.length === 0"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="暂无数据"
class="py-20"
/>
<EchartsUI v-else ref="chartRef" height="500px" />
</div>
<!-- 表格模式 -->
<div v-else class="table-container">
<a-table
:dataSource="list"
:columns="tableColumns"
:pagination="paginationConfig"
:scroll="{ y: 500 }"
row-key="updateTime"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'updateTime'">
{{ formatDate(new Date(record.updateTime)) }}
</template>
<template v-else-if="column.key === 'value'">
<a-tag v-if="isComplexDataType" color="processing">
{{ formatComplexValue(record.value) }}
</a-tag>
<span v-else class="font-medium">{{ record.value }}</span>
</template>
</template>
</a-table>
</div>
</a-spin>
</div>
<template #footer>
<a-button @click="handleClose">关闭</a-button>
</template>
</Modal>
</template>
<script setup lang="ts">
import type { EchartsUIType } from '@vben/plugins/echarts'
import type { IotDevicePropertyRespVO } from '#/api/iot/device/device'
import type { Dayjs } from 'dayjs';
import { computed, nextTick, reactive, ref, watch } from 'vue'
import type { EchartsUIType } from '@vben/plugins/echarts';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts'
import { beginOfDay, endOfDay, formatDate } from '@vben/utils'
import type { IotDeviceApi } from '#/api/iot/device/device';
import { Empty, message, Modal } from 'ant-design-vue'
import dayjs, { type Dayjs } from 'dayjs'
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { DeviceApi } from '#/api/iot/device/device'
import { IoTDataSpecsDataTypeEnum } from '#/views/iot/utils/constants'
import { IconifyIcon } from '@vben/icons';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { beginOfDay, endOfDay, formatDate } from '@vben/utils';
defineProps<{ deviceId: number }>()
import {
Button,
Empty,
message,
Modal,
RangePicker,
Space,
Spin,
Tag,
} from 'ant-design-vue';
import dayjs from 'dayjs';
import { getHistoryDevicePropertyList } from '#/api/iot/device/device';
import { IoTDataSpecsDataTypeEnum } from '#/views/iot/utils/constants';
/** IoT 设备属性历史数据详情 */
defineOptions({ name: 'DeviceDetailsThingModelPropertyHistory' })
defineOptions({ name: 'DeviceDetailsThingModelPropertyHistory' });
const dialogVisible = ref(false) // 弹窗的是否展示
const loading = ref(false)
const exporting = ref(false)
const viewMode = ref<'chart' | 'list'>('chart') // 视图模式状态
const list = ref<IotDevicePropertyRespVO[]>([]) // 列表的数据
const total = ref(0) // 总数据量
const thingModelDataType = ref<string>('') // 物模型数据类型
const propertyIdentifier = ref<string>('') // 属性标识符
defineProps<{ deviceId: number }>();
const dialogVisible = ref(false); // 弹窗的是否展示
const loading = ref(false);
const exporting = ref(false);
const viewMode = ref<'chart' | 'list'>('chart'); // 视图模式状态
const list = ref<IotDeviceApi.DevicePropertyDetail[]>([]); // 列表的数据
const total = ref(0); // 总数据量
const thingModelDataType = ref<string>(''); // 物模型数据类型
const propertyIdentifier = ref<string>(''); // 属性标识符
const dateRange = ref<[Dayjs, Dayjs]>([
dayjs().subtract(7, 'day').startOf('day'),
dayjs().endOf('day')
])
dayjs().endOf('day'),
]);
const queryParams = reactive({
deviceId: -1,
identifier: '',
times: [
formatDate(beginOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24 * 7))),
formatDate(endOfDay(new Date()))
]
})
formatDate(beginOfDay(new Date(Date.now() - 3600 * 1000 * 24 * 7))),
formatDate(endOfDay(new Date())),
],
});
// Echarts 相关
const chartRef = ref<EchartsUIType>()
const { renderEcharts } = useEcharts(chartRef)
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
// 判断是否为复杂数据类型struct 或 array
const isComplexDataType = computed(() => {
if (!thingModelDataType.value) return false
return [IoTDataSpecsDataTypeEnum.STRUCT, IoTDataSpecsDataTypeEnum.ARRAY].includes(
thingModelDataType.value as any
)
})
if (!thingModelDataType.value) return false;
return [
IoTDataSpecsDataTypeEnum.ARRAY,
IoTDataSpecsDataTypeEnum.STRUCT,
].includes(thingModelDataType.value as any);
});
// 统计数据
const maxValue = computed(() => {
if (isComplexDataType.value || list.value.length === 0) return '-'
const values = list.value.map((item) => Number(item.value)).filter((v) => !isNaN(v))
return values.length > 0 ? Math.max(...values).toFixed(2) : '-'
})
if (isComplexDataType.value || list.value.length === 0) return '-';
const values = list.value
.map((item) => Number(item.value))
.filter((v) => !Number.isNaN(v));
return values.length > 0 ? Math.max(...values).toFixed(2) : '-';
});
const minValue = computed(() => {
if (isComplexDataType.value || list.value.length === 0) return '-'
const values = list.value.map((item) => Number(item.value)).filter((v) => !isNaN(v))
return values.length > 0 ? Math.min(...values).toFixed(2) : '-'
})
if (isComplexDataType.value || list.value.length === 0) return '-';
const values = list.value
.map((item) => Number(item.value))
.filter((v) => !Number.isNaN(v));
return values.length > 0 ? Math.min(...values).toFixed(2) : '-';
});
const avgValue = computed(() => {
if (isComplexDataType.value || list.value.length === 0) return '-'
const values = list.value.map((item) => Number(item.value)).filter((v) => !isNaN(v))
if (values.length === 0) return '-'
const sum = values.reduce((acc, val) => acc + val, 0)
return (sum / values.length).toFixed(2)
})
if (isComplexDataType.value || list.value.length === 0) return '-';
const values = list.value
.map((item) => Number(item.value))
.filter((v) => !Number.isNaN(v));
if (values.length === 0) return '-';
const sum = values.reduce((acc, val) => acc + val, 0);
return (sum / values.length).toFixed(2);
});
// 表格列配置
const tableColumns = computed(() => [
@@ -195,22 +101,22 @@ const tableColumns = computed(() => [
key: 'index',
width: 80,
align: 'center',
customRender: ({ index }: { index: number }) => index + 1
customRender: ({ index }: { index: number }) => index + 1,
},
{
title: '时间',
key: 'updateTime',
dataIndex: 'updateTime',
width: 200,
align: 'center'
align: 'center',
},
{
title: '属性值',
key: 'value',
dataIndex: 'value',
align: 'center'
}
])
align: 'center',
},
]);
// 分页配置
const paginationConfig = computed(() => ({
@@ -220,36 +126,40 @@ const paginationConfig = computed(() => ({
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
showTotal: (total: number) => `${total} 条数据`
}))
showTotal: (total: number) => `${total} 条数据`,
}));
/** 获得设备历史数据 */
const getList = async () => {
loading.value = true
async function getList() {
loading.value = true;
try {
const data = await DeviceApi.getHistoryDevicePropertyList(queryParams)
list.value = data?.list || []
total.value = list.value.length
const data = await getHistoryDevicePropertyList(queryParams);
list.value = (data?.list as IotDeviceApi.DevicePropertyDetail[]) || [];
total.value = list.value.length;
// 如果是图表模式且不是复杂数据类型,渲染图表
if (viewMode.value === 'chart' && !isComplexDataType.value && list.value.length > 0) {
await nextTick()
renderChart()
if (
viewMode.value === 'chart' &&
!isComplexDataType.value &&
list.value.length > 0
) {
await nextTick();
renderChart();
}
} catch (error) {
message.error('获取数据失败')
list.value = []
total.value = 0
} catch {
message.error('获取数据失败');
list.value = [];
total.value = 0;
} finally {
loading.value = false
loading.value = false;
}
}
/** 渲染图表 */
const renderChart = () => {
if (!list.value || list.value.length === 0) return
function renderChart() {
if (!list.value || list.value.length === 0) return;
const chartData = list.value.map((item) => [item.updateTime, item.value])
const chartData = list.value.map((item) => [item.updateTime, item.value]);
renderEcharts({
title: {
@@ -257,23 +167,23 @@ const renderChart = () => {
left: 'center',
textStyle: {
fontSize: 16,
fontWeight: 'normal'
}
fontWeight: 'normal',
},
},
grid: {
left: 60,
right: 60,
bottom: 100,
top: 80,
containLabel: true
containLabel: true,
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
type: 'cross',
},
formatter: (params: any) => {
const param = params[0]
const param = params[0];
return `
<div style="padding: 8px;">
<div style="margin-bottom: 4px; font-weight: bold;">
@@ -284,27 +194,27 @@ const renderChart = () => {
<span>属性值: <strong>${param.value[1]}</strong></span>
</div>
</div>
`
}
`;
},
},
xAxis: {
type: 'time',
name: '时间',
nameTextStyle: {
padding: [10, 0, 0, 0]
padding: [10, 0, 0, 0],
},
axisLabel: {
formatter: (value: number) => {
return String(formatDate(new Date(value), 'MM-DD HH:mm') || '')
}
}
return String(formatDate(new Date(value), 'MM-DD HH:mm') || '');
},
},
},
yAxis: {
type: 'value',
name: '属性值',
nameTextStyle: {
padding: [0, 0, 10, 0]
}
padding: [0, 0, 10, 0],
},
},
series: [
{
@@ -315,10 +225,10 @@ const renderChart = () => {
symbolSize: 6,
lineStyle: {
width: 2,
color: '#1890FF'
color: '#1890FF',
},
itemStyle: {
color: '#1890FF'
color: '#1890FF',
},
areaStyle: {
color: {
@@ -330,141 +240,267 @@ const renderChart = () => {
colorStops: [
{
offset: 0,
color: 'rgba(24, 144, 255, 0.3)'
color: 'rgba(24, 144, 255, 0.3)',
},
{
offset: 1,
color: 'rgba(24, 144, 255, 0.05)'
}
]
}
color: 'rgba(24, 144, 255, 0.05)',
},
],
},
},
data: chartData
}
data: chartData,
},
],
dataZoom: [
{
type: 'inside',
start: 0,
end: 100
end: 100,
},
{
type: 'slider',
height: 30,
bottom: 20
}
]
})
bottom: 20,
},
],
});
}
/** 打开弹窗 */
const open = async (deviceId: number, identifier: string, dataType: string) => {
dialogVisible.value = true
queryParams.deviceId = deviceId
queryParams.identifier = identifier
propertyIdentifier.value = identifier
thingModelDataType.value = dataType
async function open(deviceId: number, identifier: string, dataType: string) {
dialogVisible.value = true;
queryParams.deviceId = deviceId;
queryParams.identifier = identifier;
propertyIdentifier.value = identifier;
thingModelDataType.value = dataType;
// 如果物模型是 struct、array需要默认使用 list 模式
if (isComplexDataType.value) {
viewMode.value = 'list'
} else {
viewMode.value = 'chart'
}
viewMode.value = isComplexDataType.value ? 'list' : 'chart';
// 等待弹窗完全渲染后再获取数据
await nextTick()
await getList()
await nextTick();
await getList();
}
/** 时间变化处理 */
const handleTimeChange = () => {
function handleTimeChange() {
if (!dateRange.value || dateRange.value.length !== 2) {
return
return;
}
queryParams.times = [
formatDate(dateRange.value[0].toDate()),
formatDate(dateRange.value[1].toDate())
]
getList()
formatDate(dateRange.value[1].toDate()),
];
getList();
}
/** 刷新数据 */
const handleRefresh = () => {
getList()
function handleRefresh() {
getList();
}
/** 导出数据 */
const handleExport = async () => {
async function handleExport() {
if (list.value.length === 0) {
message.warning('暂无数据可导出')
return
message.warning('暂无数据可导出');
return;
}
exporting.value = true
exporting.value = true;
try {
// 构建CSV内容
const headers = ['序号', '时间', '属性值']
const headers = ['序号', '时间', '属性值'];
const csvContent = [
headers.join(','),
...list.value.map((item, index) => {
return [
index + 1,
formatDate(new Date(item.updateTime)),
isComplexDataType.value ? `"${JSON.stringify(item.value)}"` : item.value
].join(',')
})
].join('\n')
isComplexDataType.value
? `"${JSON.stringify(item.value)}"`
: item.value,
].join(',');
}),
].join('\n');
// 创建 BOM 头,解决中文乱码
const BOM = '\uFEFF'
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8' })
// 下载文件
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `设备属性历史_${propertyIdentifier.value}_${formatDate(new Date(), 'YYYYMMDDHHmmss')}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
const BOM = '\uFEFF';
const blob = new Blob([BOM + csvContent], {
type: 'text/csv;charset=utf-8',
});
message.success('导出成功')
} catch (error) {
message.error('导出失败')
// 下载文件
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `设备属性历史_${propertyIdentifier.value}_${formatDate(new Date(), 'YYYYMMDDHHmmss')}.csv`;
document.body.append(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
message.success('导出成功');
} catch {
message.error('导出失败');
} finally {
exporting.value = false
exporting.value = false;
}
}
/** 关闭弹窗 */
const handleClose = () => {
dialogVisible.value = false
list.value = []
total.value = 0
function handleClose() {
dialogVisible.value = false;
list.value = [];
total.value = 0;
}
/** 格式化复杂数据类型 */
const formatComplexValue = (value: any) => {
function formatComplexValue(value: any) {
if (typeof value === 'object') {
return JSON.stringify(value)
return JSON.stringify(value);
}
return String(value)
return String(value);
}
/** 监听视图模式变化,重新渲染图表 */
watch(viewMode, async (newMode) => {
if (newMode === 'chart' && !isComplexDataType.value && list.value.length > 0) {
await nextTick()
renderChart()
if (
newMode === 'chart' &&
!isComplexDataType.value &&
list.value.length > 0
) {
await nextTick();
renderChart();
}
})
});
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
defineExpose({ open }); // 提供 open 方法,用于打开弹窗
</script>
<template>
<Modal
v-model:open="dialogVisible"
title="查看数据"
width="1200px"
:destroy-on-close="true"
@cancel="handleClose"
>
<div class="property-history-container">
<!-- 工具栏 -->
<div class="toolbar-wrapper mb-4">
<Space :size="12" class="w-full" wrap>
<!-- 时间选择 -->
<RangePicker
v-model:value="dateRange"
:show-time="{ format: 'HH:mm:ss' }"
format="YYYY-MM-DD HH:mm:ss"
:placeholder="['开始时间', '结束时间']"
class="!w-[400px]"
@press-enter="handleTimeChange"
/>
<!-- 刷新按钮 -->
<Button @click="handleRefresh" :loading="loading">
<template #icon>
<IconifyIcon icon="ant-design:reload-outlined" />
</template>
刷新
</Button>
<!-- 导出按钮 -->
<Button
@click="handleExport"
:loading="exporting"
:disabled="list.length === 0"
>
<template #icon>
<IconifyIcon icon="ant-design:export-outlined" />
</template>
导出
</Button>
<!-- 视图切换 -->
<Button.Group class="ml-auto">
<Button
:type="viewMode === 'chart' ? 'primary' : 'default'"
@click="viewMode = 'chart'"
:disabled="isComplexDataType"
>
<template #icon>
<IconifyIcon icon="ant-design:line-chart-outlined" />
</template>
图表
</Button>
<Button
:type="viewMode === 'list' ? 'primary' : 'default'"
@click="viewMode = 'list'"
>
<template #icon>
<IconifyIcon icon="ant-design:table-outlined" />
</template>
列表
</Button>
</Button.Group>
</Space>
<!-- 数据统计信息 -->
<div v-if="list.length > 0" class="mt-3 text-sm text-gray-600">
<Space :size="16">
<span> {{ total }} 条数据</span>
<span v-if="viewMode === 'chart' && !isComplexDataType">
最大值: {{ maxValue }} | 最小值: {{ minValue }} | 平均值:
{{ avgValue }}
</span>
</Space>
</div>
</div>
<!-- 数据展示区域 -->
<Spin :spinning="loading" :delay="200">
<!-- 图表模式 -->
<div v-if="viewMode === 'chart'" class="chart-container">
<Empty
v-if="list.length === 0"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="暂无数据"
class="py-20"
/>
<EchartsUI v-else ref="chartRef" height="500px" />
</div>
<!-- 表格模式 -->
<div v-else class="table-container">
<Table
:data-source="list"
:columns="tableColumns"
:pagination="paginationConfig"
:scroll="{ y: 500 }"
row-key="updateTime"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'updateTime'">
{{ formatDate(new Date(record.updateTime)) }}
</template>
<template v-else-if="column.key === 'value'">
<Tag v-if="isComplexDataType" color="processing">
{{ formatComplexValue(record.value) }}
</Tag>
<span v-else class="font-medium">{{ record.value }}</span>
</template>
</template>
</Table>
</div>
</Spin>
</div>
<template #footer>
<Button @click="handleClose">关闭</Button>
</template>
</Modal>
</template>
<style scoped lang="scss">
.property-history-container {
@@ -485,4 +521,3 @@ defineExpose({ open }) // 提供 open 方法,用于打开弹窗
}
}
</style>

View File

@@ -1,89 +1,240 @@
<!-- 设备服务调用 -->
<script setup lang="ts">
import type { ThingModelData } from '#/api/iot/thingmodel';
import { computed, onMounted, reactive, ref } from 'vue';
import { ContentWrap } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { formatDate } from '@vben/utils';
import {
Button,
Divider,
Form,
Pagination,
Select,
Table,
Tag,
} from 'ant-design-vue';
import { getDeviceMessagePairPage } from '#/api/iot/device/device';
import {
getThingModelServiceCallTypeLabel,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum,
} from '#/views/iot/utils/constants';
const props = defineProps<{
deviceId: number;
thingModelList: ThingModelData[];
}>();
const loading = ref(false); // 列表的加载中
const total = ref(0); // 列表的总页数
const list = ref([] as any[]); // 列表的数据
const queryParams = reactive({
deviceId: props.deviceId,
method: IotDeviceMessageMethodEnum.SERVICE_INVOKE.method, // 固定筛选服务调用消息
identifier: '',
times: [] as any[],
pageNo: 1,
pageSize: 10,
});
const queryFormRef = ref(); // 搜索的表单
/** 服务类型的物模型数据 */
const serviceThingModels = computed(() => {
return props.thingModelList.filter(
(item: ThingModelData) =>
String(item.type) === String(IoTThingModelTypeEnum.SERVICE),
);
});
/** 查询列表 */
const getList = async () => {
if (!props.deviceId) return;
loading.value = true;
try {
const data = await getDeviceMessagePairPage(queryParams);
list.value = data.list || [];
total.value = data.total;
} finally {
loading.value = false;
}
};
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1;
getList();
};
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value?.resetFields();
queryParams.identifier = '';
queryParams.times = [];
handleQuery();
};
/** 获取服务名称 */
const getServiceName = (identifier: string | undefined) => {
if (!identifier) return '-';
const service = serviceThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier,
);
return service?.name || identifier;
};
/** 获取调用方式 */
const getCallType = (identifier: string | undefined) => {
if (!identifier) return '-';
const service = serviceThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier,
);
if (!service?.service?.callType) return '-';
return getThingModelServiceCallTypeLabel(service.service.callType) || '-';
};
/** 解析参数 */
const parseParams = (params: string) => {
if (!params) return '-';
try {
const parsed = JSON.parse(params);
if (parsed.params) {
return JSON.stringify(parsed.params, null, 2);
}
return JSON.stringify(parsed, null, 2);
} catch {
return params;
}
};
/** 初始化 */
onMounted(() => {
getList();
});
</script>
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<a-form
<Form
:model="queryParams"
ref="queryFormRef"
layout="inline"
@submit.prevent
style="margin-bottom: 16px;"
style="margin-bottom: 16px"
>
<a-form-item label="标识符" name="identifier">
<a-select
<Form.Item label="标识符" name="identifier">
<Select
v-model:value="queryParams.identifier"
placeholder="请选择服务标识符"
allow-clear
style="width: 240px;"
style="width: 240px"
>
<a-select-option
<Select.Option
v-for="service in serviceThingModels"
:key="service.identifier"
:value="service.identifier!"
>
{{ service.name }}({{ service.identifier }})
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="时间范围" name="times">
<a-range-picker
</Select.Option>
</Select>
</Form.Item>
<Form.Item label="时间范围" name="times">
<RangePicker
v-model:value="queryParams.times"
show-time
format="YYYY-MM-DD HH:mm:ss"
style="width: 360px;"
style="width: 360px"
/>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleQuery">
</Form.Item>
<Form.Item>
<Button type="primary" @click="handleQuery">
<template #icon>
<IconifyIcon icon="ep:search" />
</template>
搜索
</a-button>
<a-button @click="resetQuery" style="margin-left: 8px;">
</Button>
<Button @click="resetQuery" style="margin-left: 8px">
<template #icon>
<IconifyIcon icon="ep:refresh" />
</template>
重置
</a-button>
</a-form-item>
</a-form>
</Button>
</Form.Item>
</Form>
<Divider style="margin: 16px 0" />
<a-divider style="margin: 16px 0;" />
<!-- 服务调用列表 -->
<a-table v-loading="loading" :data-source="list" :pagination="false">
<a-table-column title="调用时间" align="center" data-index="requestTime" :width="180">
<Table v-loading="loading" :data-source="list" :pagination="false">
<Table.Column
title="调用时间"
align="center"
data-index="requestTime"
:width="180"
>
<template #default="{ record }">
{{ record.request?.reportTime ? formatDate(record.request.reportTime) : '-' }}
{{
record.request?.reportTime
? formatDate(record.request.reportTime)
: '-'
}}
</template>
</a-table-column>
<a-table-column title="响应时间" align="center" data-index="responseTime" :width="180">
</Table.Column>
<Table.Column
title="响应时间"
align="center"
data-index="responseTime"
:width="180"
>
<template #default="{ record }">
{{ record.reply?.reportTime ? formatDate(record.reply.reportTime) : '-' }}
{{
record.reply?.reportTime ? formatDate(record.reply.reportTime) : '-'
}}
</template>
</a-table-column>
<a-table-column title="标识符" align="center" data-index="identifier" :width="160">
</Table.Column>
<Table.Column
title="标识符"
align="center"
data-index="identifier"
:width="160"
>
<template #default="{ record }">
<a-tag color="blue" size="small">
<Tag color="blue" size="small">
{{ record.request?.identifier }}
</a-tag>
</Tag>
</template>
</a-table-column>
<a-table-column title="服务名称" align="center" data-index="serviceName" :width="160">
</Table.Column>
<Table.Column
title="服务名称"
align="center"
data-index="serviceName"
:width="160"
>
<template #default="{ record }">
{{ getServiceName(record.request?.identifier) }}
</template>
</a-table-column>
<a-table-column title="调用方式" align="center" data-index="callType" :width="100">
</Table.Column>
<Table.Column
title="调用方式"
align="center"
data-index="callType"
:width="100"
>
<template #default="{ record }">
{{ getCallType(record.request?.identifier) }}
</template>
</a-table-column>
<a-table-column title="输入参数" align="center" data-index="inputParams">
<template #default="{ record }"> {{ parseParams(record.request?.params) }} </template>
</a-table-column>
<a-table-column title="输出参数" align="center" data-index="outputParams">
</Table.Column>
<Table.Column title="输入参数" align="center" data-index="inputParams">
<template #default="{ record }">
{{ parseParams(record.request?.params) }}
</template>
</Table.Column>
<Table.Column title="输出参数" align="center" data-index="outputParams">
<template #default="{ record }">
<span v-if="record.reply">
{{
@@ -92,8 +243,8 @@
</span>
<span v-else>-</span>
</template>
</a-table-column>
</a-table>
</Table.Column>
</Table>
<!-- 分页 -->
<Pagination
@@ -104,109 +255,3 @@
/>
</ContentWrap>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { Pagination } from 'ant-design-vue'
import { ContentWrap } from '@vben/common-ui'
import { IconifyIcon } from '@vben/icons'
import { DeviceApi } from '#/api/iot/device/device'
import type { ThingModelData } from '#/api/iot/thingmodel'
import { formatDate } from '@vben/utils'
import {
getThingModelServiceCallTypeLabel,
IotDeviceMessageMethodEnum,
IoTThingModelTypeEnum
} from '#/views/iot/utils/constants'
const props = defineProps<{
deviceId: number
thingModelList: ThingModelData[]
}>()
const loading = ref(false) // 列表的加载中
const total = ref(0) // 列表的总页数
const list = ref([] as any[]) // 列表的数据
const queryParams = reactive({
deviceId: props.deviceId,
method: IotDeviceMessageMethodEnum.SERVICE_INVOKE.method, // 固定筛选服务调用消息
identifier: '',
times: [] as any[],
pageNo: 1,
pageSize: 10
})
const queryFormRef = ref() // 搜索的表单
/** 服务类型的物模型数据 */
const serviceThingModels = computed(() => {
return props.thingModelList.filter(
(item: ThingModelData) => String(item.type) === String(IoTThingModelTypeEnum.SERVICE)
)
})
/** 查询列表 */
const getList = async () => {
if (!props.deviceId) return
loading.value = true
try {
const data = await DeviceApi.getDeviceMessagePairPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value?.resetFields()
queryParams.identifier = ''
queryParams.times = []
handleQuery()
}
/** 获取服务名称 */
const getServiceName = (identifier: string | undefined) => {
if (!identifier) return '-'
const service = serviceThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier
)
return service?.name || identifier
}
/** 获取调用方式 */
const getCallType = (identifier: string | undefined) => {
if (!identifier) return '-'
const service = serviceThingModels.value.find(
(item: ThingModelData) => item.identifier === identifier
)
if (!service?.service?.callType) return '-'
return getThingModelServiceCallTypeLabel(service.service.callType) || '-'
}
/** 解析参数 */
const parseParams = (params: string) => {
if (!params) return '-'
try {
const parsed = JSON.parse(params)
if (parsed.params) {
return JSON.stringify(parsed.params, null, 2)
}
return JSON.stringify(parsed, null, 2)
} catch (error) {
return params
}
}
/** 初始化 */
onMounted(() => {
getList()
})
</script>

View File

@@ -1,3 +1,79 @@
<script lang="ts" setup>
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotProductApi } from '#/api/iot/product/product';
import type { ThingModelData } from '#/api/iot/thingmodel';
import { onMounted, ref, unref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { useTabbarStore } from '@vben/stores';
import { message, Tabs } from 'ant-design-vue';
import { getDevice } from '#/api/iot/device/device';
import { DeviceTypeEnum, getProduct } from '#/api/iot/product/product';
import { getThingModelListByProductId } from '#/api/iot/thingmodel';
import DeviceDetailConfig from './DeviceDetailConfig.vue';
import DeviceDetailsHeader from './DeviceDetailsHeader.vue';
import DeviceDetailsInfo from './DeviceDetailsInfo.vue';
import DeviceDetailsMessage from './DeviceDetailsMessage.vue';
import DeviceDetailsSimulator from './DeviceDetailsSimulator.vue';
import DeviceDetailsThingModel from './DeviceDetailsThingModel.vue';
defineOptions({ name: 'IoTDeviceDetail' });
const route = useRoute();
const id = Number(route.params.id); // 将字符串转换为数字
const loading = ref(true); // 加载中
const product = ref<IotProductApi.Product>({} as IotProductApi.Product); // 产品详情
const device = ref<IotDeviceApi.Device>({} as IotDeviceApi.Device); // 设备详情
const activeTab = ref('info'); // 默认激活的标签页
const thingModelList = ref<ThingModelData[]>([]); // 物模型列表数据
/** 获取设备详情 */
async function getDeviceData() {
loading.value = true;
try {
device.value = await getDevice(id);
await getProductData(device.value.productId);
await getThingModelList(device.value.productId);
} finally {
loading.value = false;
}
}
/** 获取产品详情 */
async function getProductData(id: number) {
product.value = await getProduct(id);
}
/** 获取物模型列表 */
async function getThingModelList(productId: number) {
try {
const data = await getThingModelListByProductId(productId);
thingModelList.value = data || [];
} catch (error) {
console.error('获取物模型列表失败:', error);
thingModelList.value = [];
}
}
/** 初始化 */
const tabbarStore = useTabbarStore(); // 视图操作
const router = useRouter(); // 路由
const { currentRoute } = router;
onMounted(async () => {
if (!id) {
message.warning({ content: '参数错误,产品不能为空!' });
await tabbarStore.closeTab(unref(currentRoute), router);
return;
}
await getDeviceData();
activeTab.value = (route.query.tab as string) || 'info';
});
</script>
<template>
<Page>
<DeviceDetailsHeader
@@ -6,109 +82,48 @@
:device="device"
@refresh="getDeviceData"
/>
<a-tabs v-model:active-key="activeTab" class="device-detail-tabs mt-4">
<a-tab-pane key="info" tab="设备信息">
<DeviceDetailsInfo v-if="activeTab === 'info'" :product="product" :device="device" />
</a-tab-pane>
<a-tab-pane key="model" tab="物模型数据">
<Tabs v-model:active-key="activeTab" class="device-detail-tabs mt-4">
<Tabs.Pane key="info" tab="设备信息">
<DeviceDetailsInfo
v-if="activeTab === 'info'"
:product="product"
:device="device"
/>
</Tabs.Pane>
<Tabs.Pane key="model" tab="物模型数据">
<DeviceDetailsThingModel
v-if="activeTab === 'model' && device.id"
:device-id="device.id"
:thing-model-list="thingModelList"
/>
</a-tab-pane>
<a-tab-pane v-if="product.deviceType === DeviceTypeEnum.GATEWAY" key="sub-device" tab="子设备管理" />
<a-tab-pane key="log" tab="设备消息">
<DeviceDetailsMessage v-if="activeTab === 'log' && device.id" :device-id="device.id" />
</a-tab-pane>
<a-tab-pane key="simulator" tab="模拟设备">
</Tabs.Pane>
<Tabs.Pane
v-if="product.deviceType === DeviceTypeEnum.GATEWAY"
key="sub-device"
tab="子设备管理"
/>
<Tabs.Pane key="log" tab="设备消息">
<DeviceDetailsMessage
v-if="activeTab === 'log' && device.id"
:device-id="device.id"
/>
</Tabs.Pane>
<Tabs.Pane key="simulator" tab="模拟设备">
<DeviceDetailsSimulator
v-if="activeTab === 'simulator'"
:product="product"
:device="device"
:thing-model-list="thingModelList"
/>
</a-tab-pane>
<a-tab-pane key="config" tab="设备配置">
</Tabs.Pane>
<Tabs.Pane key="config" tab="设备配置">
<DeviceDetailConfig
v-if="activeTab === 'config'"
:device="device"
@success="getDeviceData"
/>
</a-tab-pane>
</a-tabs>
</Tabs.Pane>
</Tabs>
</Page>
</template>
<script lang="ts" setup>
import { ref, unref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { useTabbarStore } from '@vben/stores'
import { Page } from '@vben/common-ui'
import { DeviceApi } from '#/api/iot/device/device'
import type { DeviceVO } from '#/api/iot/device/device'
import { DeviceTypeEnum, ProductApi } from '#/api/iot/product/product'
import type { ProductVO } from '#/api/iot/product/product'
import { ThingModelApi } from '#/api/iot/thingmodel'
import type { ThingModelData } from '#/api/iot/thingmodel'
import DeviceDetailsHeader from './DeviceDetailsHeader.vue'
import DeviceDetailsInfo from './DeviceDetailsInfo.vue'
import DeviceDetailsThingModel from './DeviceDetailsThingModel.vue'
import DeviceDetailsMessage from './DeviceDetailsMessage.vue'
import DeviceDetailsSimulator from './DeviceDetailsSimulator.vue'
import DeviceDetailConfig from './DeviceDetailConfig.vue'
defineOptions({ name: 'IoTDeviceDetail' })
const route = useRoute()
const id = Number(route.params.id) // 将字符串转换为数字
const loading = ref(true) // 加载中
const product = ref<ProductVO>({} as ProductVO) // 产品详情
const device = ref<DeviceVO>({} as DeviceVO) // 设备详情
const activeTab = ref('info') // 默认激活的标签页
const thingModelList = ref<ThingModelData[]>([]) // 物模型列表数据
/** 获取设备详情 */
const getDeviceData = async () => {
loading.value = true
try {
device.value = await DeviceApi.getDevice(id)
await getProductData(device.value.productId)
await getThingModelList(device.value.productId)
} finally {
loading.value = false
}
}
/** 获取产品详情 */
const getProductData = async (id: number) => {
product.value = await ProductApi.getProduct(id)
}
/** 获取物模型列表 */
const getThingModelList = async (productId: number) => {
try {
const data = await ThingModelApi.getThingModelList(productId)
thingModelList.value = data || []
} catch (error) {
console.error('获取物模型列表失败:', error)
thingModelList.value = []
}
}
/** 初始化 */
const tabbarStore = useTabbarStore() // 视图操作
const router = useRouter() // 路由
const { currentRoute } = router
onMounted(async () => {
if (!id) {
message.warning({ content: '参数错误,产品不能为空!' })
await tabbarStore.closeTab(unref(currentRoute), router)
return
}
await getDeviceData()
activeTab.value = (route.query.tab as string) || 'info'
})
</script>

View File

@@ -48,19 +48,17 @@ const [Modal, modalApi] = useVbenModal({
return;
}
modalApi.lock();
try {
const values = await formApi.getValues();
if (formData.value?.id) {
await updateDeviceGroup({
...values,
id: formData.value.id,
} as IotDeviceGroupApi.DeviceGroup);
} else {
await createDeviceGroup(values as IotDeviceGroupApi.DeviceGroup);
}
await (formData.value?.id
? updateDeviceGroup({
...values,
id: formData.value.id,
} as IotDeviceGroupApi.DeviceGroup)
: createDeviceGroup(values as IotDeviceGroupApi.DeviceGroup));
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
@@ -68,20 +66,20 @@ const [Modal, modalApi] = useVbenModal({
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
return;
}
const data = modalApi.getData<IotDeviceGroupApi.DeviceGroup>();
// 如果没有数据或没有 id表示是新增
if (!data || !data.id) {
formData.value = undefined;
return;
}
// 编辑模式:加载数据
modalApi.lock();
try {

View File

@@ -1,9 +1,11 @@
/**
* 设备数量饼图配置
*/
export function getDeviceCountChartOptions(productCategoryDeviceCounts: Record<string, number>): any {
export function getDeviceCountChartOptions(
productCategoryDeviceCounts: Record<string, number>,
): any {
const data = Object.entries(productCategoryDeviceCounts).map(
([name, value]) => ({ name, value })
([name, value]) => ({ name, value }),
);
return {
@@ -22,7 +24,7 @@ export function getDeviceCountChartOptions(productCategoryDeviceCounts: Record<s
type: 'pie',
radius: ['50%', '80%'],
center: ['30%', '50%'],
data: data,
data,
emphasis: {
itemStyle: {
shadowBlur: 10,
@@ -42,7 +44,12 @@ export function getDeviceCountChartOptions(productCategoryDeviceCounts: Record<s
/**
* 仪表盘图表配置
*/
export function getGaugeChartOptions(value: number, max: number, color: string, title: string): any {
export function getGaugeChartOptions(
value: number,
max: number,
color: string,
title: string,
): any {
return {
series: [
{
@@ -50,14 +57,14 @@ export function getGaugeChartOptions(value: number, max: number, color: string,
startAngle: 180,
endAngle: 0,
min: 0,
max: max,
max,
center: ['50%', '70%'],
radius: '120%',
progress: {
show: true,
width: 12,
itemStyle: {
color: color,
color,
},
},
axisLine: {
@@ -74,7 +81,7 @@ export function getGaugeChartOptions(value: number, max: number, color: string,
valueAnimation: true,
fontSize: 24,
fontWeight: 'bold',
color: color,
color,
offsetCenter: [0, '-20%'],
formatter: '{value}',
},
@@ -84,10 +91,8 @@ export function getGaugeChartOptions(value: number, max: number, color: string,
fontSize: 14,
color: '#666',
},
data: [{ value: value, name: title }],
data: [{ value, name: title }],
},
],
};
}

View File

@@ -1,16 +1,16 @@
/**
* IoT 首页数据配置文件
*
*
* 该文件封装了 IoT 首页所需的:
* - 统计数据接口定义
* - 业务逻辑函数
* - 工具函数
*/
import { ref, onMounted } from 'vue';
import type { IotStatisticsApi } from '#/api/iot/statistics';
import { onMounted, ref } from 'vue';
import { getStatisticsSummary } from '#/api/iot/statistics';
/** 统计数据接口 - 使用 API 定义的类型 */
@@ -43,13 +43,13 @@ export async function loadStatisticsData(): Promise<StatsData> {
} catch (error) {
console.error('获取统计数据出错:', error);
console.warn('使用 Mock 数据,请检查后端接口是否已实现');
// 返回 Mock 数据用于开发调试
return {
productCategoryCount: 12,
productCount: 45,
deviceCount: 328,
deviceMessageCount: 15678,
deviceMessageCount: 15_678,
productCategoryTodayCount: 2,
productTodayCount: 5,
deviceTodayCount: 23,
@@ -58,10 +58,10 @@ export async function loadStatisticsData(): Promise<StatsData> {
deviceOfflineCount: 48,
deviceInactiveCount: 24,
productCategoryDeviceCounts: {
'智能家居': 120,
'工业设备': 98,
'环境监测': 65,
'智能穿戴': 45,
智能家居: 120,
工业设备: 98,
环境监测: 65,
智能穿戴: 45,
},
};
}
@@ -102,13 +102,12 @@ export function useIotHome() {
}
/** 格式化数字 - 大数字显示为 K/M */
export const formatNumber = (num: number): string => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
export function formatNumber(num: number): string {
if (num >= 1_000_000) {
return `${(num / 1_000_000).toFixed(1)}M`;
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
return `${(num / 1000).toFixed(1)}K`;
}
return num.toString();
};
}

View File

@@ -1,16 +1,16 @@
<script setup lang="ts">
import { Row, Col } from 'ant-design-vue';
import { Page } from '@vben/common-ui';
import { Col, Row } from 'ant-design-vue';
// 导入业务逻辑
import { useIotHome } from './data';
// 导入组件
import ComparisonCard from './modules/ComparisonCard.vue';
import DeviceCountCard from './modules/DeviceCountCard.vue';
import DeviceStateCountCard from './modules/DeviceStateCountCard.vue';
import MessageTrendCard from './modules/MessageTrendCard.vue';
// 导入业务逻辑
import { useIotHome } from './data';
defineOptions({ name: 'IoTHome' });
// 使用业务逻辑 Hook
@@ -25,9 +25,9 @@ const { loading, statsData } = useIotHome();
<ComparisonCard
title="分类数量"
:value="statsData.productCategoryCount"
:todayCount="statsData.productCategoryTodayCount"
:today-count="statsData.productCategoryTodayCount"
icon="menu"
iconColor="text-blue-500"
icon-color="text-blue-500"
:loading="loading"
/>
</Col>
@@ -35,9 +35,9 @@ const { loading, statsData } = useIotHome();
<ComparisonCard
title="产品数量"
:value="statsData.productCount"
:todayCount="statsData.productTodayCount"
:today-count="statsData.productTodayCount"
icon="box"
iconColor="text-orange-500"
icon-color="text-orange-500"
:loading="loading"
/>
</Col>
@@ -45,9 +45,9 @@ const { loading, statsData } = useIotHome();
<ComparisonCard
title="设备数量"
:value="statsData.deviceCount"
:todayCount="statsData.deviceTodayCount"
:today-count="statsData.deviceTodayCount"
icon="cpu"
iconColor="text-purple-500"
icon-color="text-purple-500"
:loading="loading"
/>
</Col>
@@ -55,9 +55,9 @@ const { loading, statsData } = useIotHome();
<ComparisonCard
title="设备消息数"
:value="statsData.deviceMessageCount"
:todayCount="statsData.deviceMessageTodayCount"
:today-count="statsData.deviceMessageTodayCount"
icon="message"
iconColor="text-teal-500"
icon-color="text-teal-500"
:loading="loading"
/>
</Col>
@@ -66,10 +66,10 @@ const { loading, statsData } = useIotHome();
<!-- 第二行图表 -->
<Row :gutter="16" class="mb-4">
<Col :span="12">
<DeviceCountCard :statsData="statsData" :loading="loading" />
<DeviceCountCard :stats-data="statsData" :loading="loading" />
</Col>
<Col :span="12">
<DeviceStateCountCard :statsData="statsData" :loading="loading" />
<DeviceStateCountCard :stats-data="statsData" :loading="loading" />
</Col>
</Row>

View File

@@ -1,9 +1,40 @@
<script setup lang="ts">
import { computed } from 'vue';
import { CountTo } from '@vben/common-ui';
import { createIconifyIcon } from '@vben/icons';
import { Card } from 'ant-design-vue';
defineOptions({ name: 'ComparisonCard' });
const props = defineProps<{
icon: string;
iconColor?: string;
loading?: boolean;
title: string;
todayCount: number;
value: number;
}>();
const iconMap: Record<string, any> = {
menu: createIconifyIcon('ant-design:appstore-outlined'),
box: createIconifyIcon('ant-design:box-plot-outlined'),
cpu: createIconifyIcon('ant-design:cluster-outlined'),
message: createIconifyIcon('ant-design:message-outlined'),
};
const IconComponent = computed(() => iconMap[props.icon] || iconMap.menu);
</script>
<template>
<Card class="stat-card" :loading="loading">
<div class="flex flex-col h-full">
<div class="flex justify-between items-start mb-4">
<div class="flex flex-col flex-1">
<span class="text-gray-500 text-sm font-medium mb-2">{{ title }}</span>
<div class="flex h-full flex-col">
<div class="mb-4 flex items-start justify-between">
<div class="flex flex-1 flex-col">
<span class="mb-2 text-sm font-medium text-gray-500">
{{ title }}
</span>
<span class="text-3xl font-bold text-gray-800">
<span v-if="value === -1">--</span>
<CountTo v-else :end-val="value" :duration="1000" />
@@ -13,60 +44,35 @@
<IconComponent />
</div>
</div>
<div class="mt-auto pt-3 border-t border-gray-100">
<div class="flex justify-between items-center text-sm">
<div class="mt-auto border-t border-gray-100 pt-3">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-400">今日新增</span>
<span v-if="todayCount === -1" class="text-gray-400">--</span>
<span v-else class="text-green-500 font-medium">+{{ todayCount }}</span>
<span v-else class="font-medium text-green-500">
+{{ todayCount }}
</span>
</div>
</div>
</div>
</Card>
</template>
<script setup lang="ts">
import { Card } from 'ant-design-vue';
import { CountTo } from '@vben/common-ui';
import { createIconifyIcon } from '@vben/icons';
import { computed } from 'vue';
defineOptions({ name: 'ComparisonCard' });
const props = defineProps<{
title: string;
value: number;
todayCount: number;
icon: string;
iconColor?: string;
loading?: boolean;
}>();
const iconMap: Record<string, any> = {
'menu': createIconifyIcon('ant-design:appstore-outlined'),
'box': createIconifyIcon('ant-design:box-plot-outlined'),
'cpu': createIconifyIcon('ant-design:cluster-outlined'),
'message': createIconifyIcon('ant-design:message-outlined'),
};
const IconComponent = computed(() => iconMap[props.icon] || iconMap.menu);
</script>
<style scoped>
.stat-card {
height: 160px;
transition: all 0.3s ease;
cursor: pointer;
transition: all 0.3s ease;
}
.stat-card:hover {
box-shadow: 0 6px 20px rgb(0 0 0 / 8%);
transform: translateY(-4px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
}
.stat-card :deep(.ant-card-body) {
height: 100%;
display: flex;
flex-direction: column;
height: 100%;
}
</style>

View File

@@ -1,28 +1,17 @@
<template>
<Card title="设备数量统计" :loading="loading" class="chart-card">
<div v-if="loading && !hasData" class="h-[300px] flex justify-center items-center">
<Empty description="加载中..." />
</div>
<div v-else-if="!hasData" class="h-[300px] flex justify-center items-center">
<Empty description="暂无数据" />
</div>
<div v-else>
<EchartsUI ref="deviceCountChartRef" class="h-[400px] w-full" />
</div>
</Card>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, nextTick } from 'vue';
import { Card, Empty } from 'ant-design-vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import type { IotStatisticsApi } from '#/api/iot/statistics';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Card, Empty } from 'ant-design-vue';
defineOptions({ name: 'DeviceCountCard' });
const props = defineProps<{
statsData: IotStatisticsApi.StatisticsSummary;
loading?: boolean;
statsData: IotStatisticsApi.StatisticsSummary;
}>();
const deviceCountChartRef = ref();
@@ -31,18 +20,20 @@ const { renderEcharts } = useEcharts(deviceCountChartRef);
/** 是否有数据 */
const hasData = computed(() => {
if (!props.statsData) return false;
const categories = Object.entries(props.statsData.productCategoryDeviceCounts || {});
const categories = Object.entries(
props.statsData.productCategoryDeviceCounts || {},
);
return categories.length > 0 && props.statsData.deviceCount !== 0;
});
/** 初始化图表 */
const initChart = () => {
function initChart() {
if (!hasData.value) return;
nextTick(() => {
const data = Object.entries(props.statsData.productCategoryDeviceCounts).map(
([name, value]) => ({ name, value })
);
const data = Object.entries(
props.statsData.productCategoryDeviceCounts,
).map(([name, value]) => ({ name, value }));
renderEcharts({
tooltip: {
@@ -98,12 +89,12 @@ const initChart = () => {
labelLine: {
show: false,
},
data: data,
data,
},
],
});
});
};
}
/** 监听数据变化 */
watch(
@@ -111,7 +102,7 @@ watch(
() => {
initChart();
},
{ deep: true }
{ deep: true },
);
/** 组件挂载时初始化图表 */
@@ -120,6 +111,26 @@ onMounted(() => {
});
</script>
<template>
<Card title="设备数量统计" :loading="loading" class="chart-card">
<div
v-if="loading && !hasData"
class="flex h-[300px] items-center justify-center"
>
<Empty description="加载中..." />
</div>
<div
v-else-if="!hasData"
class="flex h-[300px] items-center justify-center"
>
<Empty description="暂无数据" />
</div>
<div v-else>
<EchartsUI ref="deviceCountChartRef" class="h-[400px] w-full" />
</div>
</Card>
</template>
<style scoped>
.chart-card {
height: 100%;

View File

@@ -1,36 +1,17 @@
<template>
<Card title="设备状态统计" :loading="loading" class="chart-card">
<div v-if="loading && !hasData" class="h-[300px] flex justify-center items-center">
<Empty description="加载中..." />
</div>
<div v-else-if="!hasData" class="h-[300px] flex justify-center items-center">
<Empty description="暂无数据" />
</div>
<Row v-else class="h-[280px]">
<Col :span="8" class="flex items-center justify-center">
<EchartsUI ref="deviceOnlineChartRef" class="h-[250px] w-full" />
</Col>
<Col :span="8" class="flex items-center justify-center">
<EchartsUI ref="deviceOfflineChartRef" class="h-[250px] w-full" />
</Col>
<Col :span="8" class="flex items-center justify-center">
<EchartsUI ref="deviceInactiveChartRef" class="h-[250px] w-full" />
</Col>
</Row>
</Card>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, nextTick } from 'vue';
import { Card, Empty, Row, Col } from 'ant-design-vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import type { IotStatisticsApi } from '#/api/iot/statistics';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Card, Col, Empty, Row } from 'ant-design-vue';
defineOptions({ name: 'DeviceStateCountCard' });
const props = defineProps<{
statsData: IotStatisticsApi.StatisticsSummary;
loading?: boolean;
statsData: IotStatisticsApi.StatisticsSummary;
}>();
const deviceOnlineChartRef = ref();
@@ -39,7 +20,9 @@ const deviceInactiveChartRef = ref();
const { renderEcharts: renderOnlineChart } = useEcharts(deviceOnlineChartRef);
const { renderEcharts: renderOfflineChart } = useEcharts(deviceOfflineChartRef);
const { renderEcharts: renderInactiveChart } = useEcharts(deviceInactiveChartRef);
const { renderEcharts: renderInactiveChart } = useEcharts(
deviceInactiveChartRef,
);
/** 是否有数据 */
const hasData = computed(() => {
@@ -63,7 +46,7 @@ const getGaugeOption = (value: number, color: string, title: string): any => {
show: true,
width: 12,
itemStyle: {
color: color,
color,
},
},
axisLine: {
@@ -86,35 +69,39 @@ const getGaugeOption = (value: number, color: string, title: string): any => {
valueAnimation: true,
fontSize: 32,
fontWeight: 'bold',
color: color,
color,
offsetCenter: [0, '10%'],
formatter: (val: number) => `${val}`,
},
data: [{ value: value, name: title }],
data: [{ value, name: title }],
},
],
};
};
/** 初始化图表 */
const initCharts = () => {
function initCharts() {
if (!hasData.value) return;
nextTick(() => {
// 在线设备
renderOnlineChart(
getGaugeOption(props.statsData.deviceOnlineCount, '#52c41a', '在线设备')
getGaugeOption(props.statsData.deviceOnlineCount, '#52c41a', '在线设备'),
);
// 离线设备
renderOfflineChart(
getGaugeOption(props.statsData.deviceOfflineCount, '#ff4d4f', '离线设备')
getGaugeOption(props.statsData.deviceOfflineCount, '#ff4d4f', '离线设备'),
);
// 待激活设备
renderInactiveChart(
getGaugeOption(props.statsData.deviceInactiveCount, '#1890ff', '待激活设备')
getGaugeOption(
props.statsData.deviceInactiveCount,
'#1890ff',
'待激活设备',
),
);
});
};
}
/** 监听数据变化 */
watch(
@@ -122,7 +109,7 @@ watch(
() => {
initCharts();
},
{ deep: true }
{ deep: true },
);
/** 组件挂载时初始化图表 */
@@ -131,6 +118,34 @@ onMounted(() => {
});
</script>
<template>
<Card title="设备状态统计" :loading="loading" class="chart-card">
<div
v-if="loading && !hasData"
class="flex h-[300px] items-center justify-center"
>
<Empty description="加载中..." />
</div>
<div
v-else-if="!hasData"
class="flex h-[300px] items-center justify-center"
>
<Empty description="暂无数据" />
</div>
<Row v-else class="h-[280px]">
<Col :span="8" class="flex items-center justify-center">
<EchartsUI ref="deviceOnlineChartRef" class="h-[250px] w-full" />
</Col>
<Col :span="8" class="flex items-center justify-center">
<EchartsUI ref="deviceOfflineChartRef" class="h-[250px] w-full" />
</Col>
<Col :span="8" class="flex items-center justify-center">
<EchartsUI ref="deviceInactiveChartRef" class="h-[250px] w-full" />
</Col>
</Row>
</Card>
</template>
<style scoped>
.chart-card {
height: 100%;

View File

@@ -1,79 +1,30 @@
<template>
<Card class="chart-card" :loading="loading">
<template #title>
<div class="flex items-center justify-between flex-wrap gap-2">
<span class="text-base font-medium">上下行消息量统计</span>
<Space :size="8">
<Button
:type="activeTimeRange === '1h' ? 'primary' : 'default'"
size="small"
@click="setTimeRange('1h')"
>
最近1小时
</Button>
<Button
:type="activeTimeRange === '24h' ? 'primary' : 'default'"
size="small"
@click="setTimeRange('24h')"
>
最近24小时
</Button>
<Button
:type="activeTimeRange === '7d' ? 'primary' : 'default'"
size="small"
@click="setTimeRange('7d')"
>
近一周
</Button>
<RangePicker
v-model:value="dateRange"
format="YYYY-MM-DD"
:placeholder="['开始时间', '结束时间']"
@change="handleDateChange"
size="small"
style="width: 240px"
/>
</Space>
</div>
</template>
<div v-if="loading" class="h-[350px] flex justify-center items-center">
<Empty description="加载中..." />
</div>
<div v-else-if="!hasData" class="h-[350px] flex justify-center items-center">
<Empty description="暂无数据" />
</div>
<div v-else>
<EchartsUI ref="messageChartRef" class="h-[350px] w-full" />
</div>
</Card>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick } from 'vue';
import { Card, Empty, Space, DatePicker, Button } from 'ant-design-vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import {
StatisticsApi,
type IotStatisticsDeviceMessageSummaryByDateRespVO,
type IotStatisticsDeviceMessageReqVO,
} from '#/api/iot/statistics';
const { RangePicker } = DatePicker;
import type { IotStatisticsApi } from '#/api/iot/statistics';
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Button, Card, DatePicker, Empty, Space } from 'ant-design-vue';
import dayjs from 'dayjs';
import { getDeviceMessageSummaryByDate } from '#/api/iot/statistics';
defineOptions({ name: 'MessageTrendCard' });
const { RangePicker } = DatePicker;
const messageChartRef = ref();
const { renderEcharts } = useEcharts(messageChartRef);
const loading = ref(false);
const messageData = ref<IotStatisticsDeviceMessageSummaryByDateRespVO[]>([]);
const messageData = ref<IotStatisticsApi.DeviceMessageSummaryByDate[]>([]);
const activeTimeRange = ref('7d'); // 当前选中的时间范围
const dateRange = ref<[Dayjs, Dayjs] | undefined>(undefined);
const queryParams = reactive<IotStatisticsDeviceMessageReqVO>({
const queryParams = reactive<IotStatisticsApi.DeviceMessageReq>({
interval: 1, // 按天
times: [],
});
@@ -84,41 +35,45 @@ const hasData = computed(() => {
});
// 设置时间范围
const setTimeRange = (range: string) => {
function setTimeRange(range: string) {
activeTimeRange.value = range;
dateRange.value = undefined; // 清空自定义时间选择
let start: Dayjs;
let end = dayjs();
const end = dayjs();
switch (range) {
case '1h':
case '1h': {
start = dayjs().subtract(1, 'hour');
queryParams.interval = 1; // 按分钟
break;
case '24h':
start = dayjs().subtract(24, 'hour');
queryParams.interval = 1; // 按小时
break;
case '7d':
}
case '7d': {
start = dayjs().subtract(7, 'day');
queryParams.interval = 1; // 按天
break;
default:
}
case '24h': {
start = dayjs().subtract(24, 'hour');
queryParams.interval = 1; // 按小时
break;
}
default: {
start = dayjs().subtract(7, 'day');
queryParams.interval = 1;
}
}
queryParams.times = [
start.format('YYYY-MM-DD HH:mm:ss'),
end.format('YYYY-MM-DD HH:mm:ss'),
];
fetchMessageData();
};
}
// 处理自定义日期选择
const handleDateChange = () => {
function handleDateChange() {
if (dateRange.value && dateRange.value.length === 2) {
activeTimeRange.value = ''; // 清空快捷选择
queryParams.interval = 1; // 按天
@@ -128,15 +83,15 @@ const handleDateChange = () => {
];
fetchMessageData();
}
};
}
// 获取消息统计数据
const fetchMessageData = async () => {
async function fetchMessageData() {
if (!queryParams.times || queryParams.times.length !== 2) return;
loading.value = true;
try {
messageData.value = await StatisticsApi.getDeviceMessageSummaryByDate(queryParams);
messageData.value = await getDeviceMessageSummaryByDate(queryParams);
await nextTick();
initChart();
} catch (error) {
@@ -145,10 +100,10 @@ const fetchMessageData = async () => {
} finally {
loading.value = false;
}
};
}
// 初始化图表
const initChart = () => {
function initChart() {
if (!hasData.value) return;
const times = messageData.value.map((item) => item.time);
@@ -222,7 +177,7 @@ const initChart = () => {
},
],
});
};
}
// 组件挂载时查询数据
onMounted(() => {
@@ -230,6 +185,60 @@ onMounted(() => {
});
</script>
<template>
<Card class="chart-card" :loading="loading">
<template #title>
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-base font-medium">上下行消息量统计</span>
<Space :size="8">
<Button
:type="activeTimeRange === '1h' ? 'primary' : 'default'"
size="small"
@click="setTimeRange('1h')"
>
最近1小时
</Button>
<Button
:type="activeTimeRange === '24h' ? 'primary' : 'default'"
size="small"
@click="setTimeRange('24h')"
>
最近24小时
</Button>
<Button
:type="activeTimeRange === '7d' ? 'primary' : 'default'"
size="small"
@click="setTimeRange('7d')"
>
近一周
</Button>
<RangePicker
v-model:value="dateRange"
format="YYYY-MM-DD"
:placeholder="['开始时间', '结束时间']"
@change="handleDateChange"
size="small"
style="width: 240px"
/>
</Space>
</div>
</template>
<div v-if="loading" class="flex h-[350px] items-center justify-center">
<Empty description="加载中..." />
</div>
<div
v-else-if="!hasData"
class="flex h-[350px] items-center justify-center"
>
<Empty description="暂无数据" />
</div>
<div v-else>
<EchartsUI ref="messageChartRef" class="h-[350px] w-full" />
</div>
</Card>
</template>
<style scoped>
.chart-card {
height: 100%;

View File

@@ -1,12 +1,7 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IoTOtaFirmwareApi } from '#/api/iot/ota/firmware';
import { message } from 'ant-design-vue';
import { deleteOtaFirmware, getOtaFirmwarePage } from '#/api/iot/ota/firmware';
import { getSimpleProductList } from '#/api/iot/product/product';
import { $t } from '#/locales';
import { getRangePickerDefaultProps } from '#/utils';
/** 新增/修改固件的表单 */
@@ -157,51 +152,3 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
},
];
}
/** Grid 配置项 */
export function useGridOptions(): VxeTableGridOptions<IoTOtaFirmwareApi.Firmware> {
return {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getOtaFirmwarePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
};
}
/** 删除固件 */
export async function handleDeleteFirmware(
row: IoTOtaFirmwareApi.Firmware,
onSuccess: () => void,
) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
});
try {
await deleteOtaFirmware(row.id as number);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
});
onSuccess();
} finally {
hideLoading();
}
}

View File

@@ -1,17 +1,18 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IoTOtaFirmwareApi } from '#/api/iot/ota/firmware';
import { Page, useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteOtaFirmware, getOtaFirmwarePage } from '#/api/iot/ota/firmware';
import { $t } from '#/locales';
import {
handleDeleteFirmware,
useGridFormSchema,
useGridOptions,
} from './data';
import Form from '../modules/OtaFirmwareForm.vue';
import { useGridColumns, useGridFormSchema } from './data';
defineOptions({ name: 'IoTOtaFirmware' });
@@ -35,21 +36,56 @@ function handleEdit(row: IoTOtaFirmwareApi.Firmware) {
formModalApi.setData({ type: 'update', id: row.id }).open();
}
/** 删除固件 */
async function handleDelete(row: IoTOtaFirmwareApi.Firmware) {
await handleDeleteFirmware(row, onRefresh);
}
/** 查看固件详情 */
function handleDetail(row: IoTOtaFirmwareApi.Firmware) {
formModalApi.setData({ type: 'view', id: row.id }).open();
}
/** 删除固件 */
async function handleDelete(row: IoTOtaFirmwareApi.Firmware) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
});
try {
await deleteOtaFirmware(row.id as number);
message.success({
content: $t('ui.actionMessage.deleteSuccess', [row.name]),
});
onRefresh();
} finally {
hideLoading();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: useGridOptions(),
gridOptions: {
columns: useGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getOtaFirmwarePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<IoTOtaFirmwareApi.Firmware>,
});
</script>
@@ -84,7 +120,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
download
class="text-primary cursor-pointer hover:underline"
>
<Icon icon="ant-design:download-outlined" class="mr-1" />
<IconifyIcon icon="ant-design:download-outlined" class="mr-1" />
下载固件
</a>
<span v-else class="text-gray-400">无文件</span>

View File

@@ -52,7 +52,9 @@ const [Modal, modalApi] = useVbenModal({
// 提交表单
const data = (await formApi.getValues()) as IoTOtaFirmwareApi.Firmware;
try {
await (formData.value?.id ? updateOtaFirmware(data) : createOtaFirmware(data));
await (formData.value?.id
? updateOtaFirmware(data)
: createOtaFirmware(data));
// 关闭并提示
await modalApi.close();
emit('success');

View File

@@ -1,12 +1,17 @@
<script setup lang="ts">
import type { IoTOtaFirmware } from '#/api/iot/ota/firmware';
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { Card, Col, Descriptions, Row } from 'ant-design-vue';
import { formatDate } from '@vben/utils';
import type { IoTOtaFirmware } from '#/api/iot/ota/firmware';
import { Card, Col, Descriptions, Row } from 'ant-design-vue';
import * as IoTOtaFirmwareApi from '#/api/iot/ota/firmware';
import * as IoTOtaTaskRecordApi from '#/api/iot/ota/task/record';
import { IoTOtaTaskRecordStatusEnum } from '#/views/iot/utils/constants';
import OtaTaskList from '../task/OtaTaskList.vue';
/** IoT OTA 固件详情 */
@@ -22,26 +27,27 @@ const firmwareStatisticsLoading = ref(false);
const firmwareStatistics = ref<Record<string, number>>({});
/** 获取固件信息 */
const getFirmwareInfo = async () => {
async function getFirmwareInfo() {
firmwareLoading.value = true;
try {
firmware.value = await IoTOtaFirmwareApi.getOtaFirmware(firmwareId.value);
} finally {
firmwareLoading.value = false;
}
};
}
/** 获取升级统计 */
const getStatistics = async () => {
async function getStatistics() {
firmwareStatisticsLoading.value = true;
try {
firmwareStatistics.value = await IoTOtaTaskRecordApi.getOtaTaskRecordStatusStatistics(
firmwareId.value,
);
firmwareStatistics.value =
await IoTOtaTaskRecordApi.getOtaTaskRecordStatusStatistics(
firmwareId.value,
);
} finally {
firmwareStatisticsLoading.value = false;
}
};
}
/** 初始化 */
onMounted(() => {
@@ -65,7 +71,11 @@ onMounted(() => {
{{ firmware?.version }}
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{{ firmware?.createTime ? formatDate(firmware.createTime, 'YYYY-MM-DD HH:mm:ss') : '-' }}
{{
firmware?.createTime
? formatDate(firmware.createTime, 'YYYY-MM-DD HH:mm:ss')
: '-'
}}
</Descriptions.Item>
<Descriptions.Item label="固件描述" :span="2">
{{ firmware?.description }}
@@ -74,63 +84,101 @@ onMounted(() => {
</Card>
<!-- 升级设备统计 -->
<Card title="升级设备统计" class="mb-5" :loading="firmwareStatisticsLoading">
<Card
title="升级设备统计"
class="mb-5"
:loading="firmwareStatisticsLoading"
>
<Row :gutter="20" class="py-5">
<Col :span="6">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-blue-500">
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
>
<div class="mb-2 text-3xl font-bold text-blue-500">
{{
Object.values(firmwareStatistics).reduce((sum: number, count) => sum + (count || 0), 0) ||
0
Object.values(firmwareStatistics).reduce(
(sum: number, count) => sum + (count || 0),
0,
) || 0
}}
</div>
<div class="text-sm text-gray-600">升级设备总数</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-gray-400">
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.PENDING.value] || 0 }}
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
>
<div class="mb-2 text-3xl font-bold text-gray-400">
{{
firmwareStatistics[IoTOtaTaskRecordStatusEnum.PENDING.value] ||
0
}}
</div>
<div class="text-sm text-gray-600">待推送</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-blue-400">
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.PUSHED.value] || 0 }}
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
>
<div class="mb-2 text-3xl font-bold text-blue-400">
{{
firmwareStatistics[IoTOtaTaskRecordStatusEnum.PUSHED.value] || 0
}}
</div>
<div class="text-sm text-gray-600">已推送</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-yellow-500">
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.UPGRADING.value] || 0 }}
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
>
<div class="mb-2 text-3xl font-bold text-yellow-500">
{{
firmwareStatistics[
IoTOtaTaskRecordStatusEnum.UPGRADING.value
] || 0
}}
</div>
<div class="text-sm text-gray-600">正在升级</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-green-500">
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.SUCCESS.value] || 0 }}
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
>
<div class="mb-2 text-3xl font-bold text-green-500">
{{
firmwareStatistics[IoTOtaTaskRecordStatusEnum.SUCCESS.value] ||
0
}}
</div>
<div class="text-sm text-gray-600">升级成功</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-red-500">
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.FAILURE.value] || 0 }}
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
>
<div class="mb-2 text-3xl font-bold text-red-500">
{{
firmwareStatistics[IoTOtaTaskRecordStatusEnum.FAILURE.value] ||
0
}}
</div>
<div class="text-sm text-gray-600">升级失败</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-gray-400">
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.CANCELED.value] || 0 }}
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
>
<div class="mb-2 text-3xl font-bold text-gray-400">
{{
firmwareStatistics[IoTOtaTaskRecordStatusEnum.CANCELED.value] ||
0
}}
</div>
<div class="text-sm text-gray-600">升级取消</div>
</div>

View File

@@ -1,12 +1,17 @@
<script setup lang="ts">
import type { IoTOtaFirmware } from '#/api/iot/ota/firmware';
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { Card, Col, Descriptions, Row } from 'ant-design-vue';
import { formatDate } from '@vben/utils';
import type { IoTOtaFirmware } from '#/api/iot/ota/firmware';
import { Card, Col, Descriptions, Row } from 'ant-design-vue';
import * as IoTOtaFirmwareApi from '#/api/iot/ota/firmware';
import * as IoTOtaTaskRecordApi from '#/api/iot/ota/task/record';
import { IoTOtaTaskRecordStatusEnum } from '#/views/iot/utils/constants';
import OtaTaskList from '../task/OtaTaskList.vue';
/** IoT OTA 固件详情 */
@@ -22,26 +27,27 @@ const firmwareStatisticsLoading = ref(false);
const firmwareStatistics = ref<Record<string, number>>({});
/** 获取固件信息 */
const getFirmwareInfo = async () => {
async function getFirmwareInfo() {
firmwareLoading.value = true;
try {
firmware.value = await IoTOtaFirmwareApi.getOtaFirmware(firmwareId.value);
} finally {
firmwareLoading.value = false;
}
};
}
/** 获取升级统计 */
const getStatistics = async () => {
async function getStatistics() {
firmwareStatisticsLoading.value = true;
try {
firmwareStatistics.value = await IoTOtaTaskRecordApi.getOtaTaskRecordStatusStatistics(
firmwareId.value,
);
firmwareStatistics.value =
await IoTOtaTaskRecordApi.getOtaTaskRecordStatusStatistics(
firmwareId.value,
);
} finally {
firmwareStatisticsLoading.value = false;
}
};
}
/** 初始化 */
onMounted(() => {
@@ -65,7 +71,11 @@ onMounted(() => {
{{ firmware?.version }}
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{{ firmware?.createTime ? formatDate(firmware.createTime, 'YYYY-MM-DD HH:mm:ss') : '-' }}
{{
firmware?.createTime
? formatDate(firmware.createTime, 'YYYY-MM-DD HH:mm:ss')
: '-'
}}
</Descriptions.Item>
<Descriptions.Item label="固件描述" :span="2">
{{ firmware?.description }}
@@ -74,63 +84,101 @@ onMounted(() => {
</Card>
<!-- 升级设备统计 -->
<Card title="升级设备统计" class="mb-5" :loading="firmwareStatisticsLoading">
<Card
title="升级设备统计"
class="mb-5"
:loading="firmwareStatisticsLoading"
>
<Row :gutter="20" class="py-5">
<Col :span="6">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-blue-500">
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
>
<div class="mb-2 text-3xl font-bold text-blue-500">
{{
Object.values(firmwareStatistics).reduce((sum: number, count) => sum + (count || 0), 0) ||
0
Object.values(firmwareStatistics).reduce(
(sum: number, count) => sum + (count || 0),
0,
) || 0
}}
</div>
<div class="text-sm text-gray-600">升级设备总数</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-gray-400">
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.PENDING.value] || 0 }}
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
>
<div class="mb-2 text-3xl font-bold text-gray-400">
{{
firmwareStatistics[IoTOtaTaskRecordStatusEnum.PENDING.value] ||
0
}}
</div>
<div class="text-sm text-gray-600">待推送</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-blue-400">
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.PUSHED.value] || 0 }}
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
>
<div class="mb-2 text-3xl font-bold text-blue-400">
{{
firmwareStatistics[IoTOtaTaskRecordStatusEnum.PUSHED.value] || 0
}}
</div>
<div class="text-sm text-gray-600">已推送</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-yellow-500">
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.UPGRADING.value] || 0 }}
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
>
<div class="mb-2 text-3xl font-bold text-yellow-500">
{{
firmwareStatistics[
IoTOtaTaskRecordStatusEnum.UPGRADING.value
] || 0
}}
</div>
<div class="text-sm text-gray-600">正在升级</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-green-500">
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.SUCCESS.value] || 0 }}
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
>
<div class="mb-2 text-3xl font-bold text-green-500">
{{
firmwareStatistics[IoTOtaTaskRecordStatusEnum.SUCCESS.value] ||
0
}}
</div>
<div class="text-sm text-gray-600">升级成功</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-red-500">
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.FAILURE.value] || 0 }}
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
>
<div class="mb-2 text-3xl font-bold text-red-500">
{{
firmwareStatistics[IoTOtaTaskRecordStatusEnum.FAILURE.value] ||
0
}}
</div>
<div class="text-sm text-gray-600">升级失败</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-gray-400">
{{ firmwareStatistics[IoTOtaTaskRecordStatusEnum.CANCELED.value] || 0 }}
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
>
<div class="mb-2 text-3xl font-bold text-gray-400">
{{
firmwareStatistics[IoTOtaTaskRecordStatusEnum.CANCELED.value] ||
0
}}
</div>
<div class="text-sm text-gray-600">升级取消</div>
</div>

View File

@@ -1,14 +1,29 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Card, Col, Descriptions, Modal, Row, Table, Tabs, Tag, message } from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import type { OtaTask } from '#/api/iot/ota/task';
import * as IoTOtaTaskApi from '#/api/iot/ota/task';
import type { OtaTaskRecord } from '#/api/iot/ota/task/record';
import { computed, reactive, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { formatDate } from '@vben/utils';
import {
Card,
Col,
Descriptions,
message,
Modal,
Row,
Table,
Tabs,
Tag,
} from 'ant-design-vue';
import * as IoTOtaTaskApi from '#/api/iot/ota/task';
import * as IoTOtaTaskRecordApi from '#/api/iot/ota/task/record';
import { IoTOtaTaskRecordStatusEnum } from '#/views/iot/utils/constants';
import { formatDate } from '@vben/utils';
/** OTA 任务详情组件 */
defineOptions({ name: 'OtaTaskDetail' });
@@ -98,7 +113,7 @@ const columns: TableColumnsType = [
const [ModalComponent, modalApi] = useVbenModal();
/** 获取任务详情 */
const getTaskInfo = async () => {
async function getTaskInfo() {
if (!taskId.value) {
return;
}
@@ -108,26 +123,27 @@ const getTaskInfo = async () => {
} finally {
taskLoading.value = false;
}
};
}
/** 获取统计数据 */
const getStatistics = async () => {
async function getStatistics() {
if (!taskId.value) {
return;
}
taskStatisticsLoading.value = true;
try {
taskStatistics.value = await IoTOtaTaskRecordApi.getOtaTaskRecordStatusStatistics(
undefined,
taskId.value,
);
taskStatistics.value =
await IoTOtaTaskRecordApi.getOtaTaskRecordStatusStatistics(
undefined,
taskId.value,
);
} finally {
taskStatisticsLoading.value = false;
}
};
}
/** 获取升级记录列表 */
const getRecordList = async () => {
async function getRecordList() {
if (!taskId.value) {
return;
}
@@ -140,25 +156,26 @@ const getRecordList = async () => {
} finally {
recordLoading.value = false;
}
};
}
/** 切换标签 */
const handleTabChange = (tabKey: string | number) => {
function handleTabChange(tabKey: number | string) {
activeTab.value = String(tabKey);
queryParams.pageNo = 1;
queryParams.status = activeTab.value === '' ? undefined : parseInt(String(tabKey));
queryParams.status =
activeTab.value === '' ? undefined : Number.parseInt(String(tabKey));
getRecordList();
};
}
/** 分页变化 */
const handleTableChange = (pagination: any) => {
function handleTableChange(pagination: any) {
queryParams.pageNo = pagination.current;
queryParams.pageSize = pagination.pageSize;
getRecordList();
};
}
/** 取消升级 */
const handleCancelUpgrade = async (record: OtaTaskRecord) => {
async function handleCancelUpgrade(record: OtaTaskRecord) {
Modal.confirm({
title: '确认取消',
content: '确认要取消该设备的升级任务吗?',
@@ -175,10 +192,10 @@ const handleCancelUpgrade = async (record: OtaTaskRecord) => {
}
},
});
};
}
/** 打开弹窗 */
const open = (id: number) => {
function open(id: number) {
modalApi.open();
taskId.value = id;
activeTab.value = '';
@@ -189,7 +206,7 @@ const open = (id: number) => {
getTaskInfo();
getStatistics();
getRecordList();
};
}
/** 暴露方法 */
defineExpose({ open });
@@ -202,7 +219,9 @@ defineExpose({ open });
<Card title="任务信息" class="mb-5" :loading="taskLoading">
<Descriptions :column="3" bordered>
<Descriptions.Item label="任务编号">{{ task.id }}</Descriptions.Item>
<Descriptions.Item label="任务名称">{{ task.name }}</Descriptions.Item>
<Descriptions.Item label="任务名称">
{{ task.name }}
</Descriptions.Item>
<Descriptions.Item label="升级范围">
<Tag v-if="task.deviceScope === 1" color="blue">全部设备</Tag>
<Tag v-else-if="task.deviceScope === 2" color="green">指定设备</Tag>
@@ -216,7 +235,11 @@ defineExpose({ open });
<Tag v-else>{{ task.status }}</Tag>
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{{ task.createTime ? formatDate(task.createTime, 'YYYY-MM-DD HH:mm:ss') : '-' }}
{{
task.createTime
? formatDate(task.createTime, 'YYYY-MM-DD HH:mm:ss')
: '-'
}}
</Descriptions.Item>
<Descriptions.Item label="任务描述" :span="3">
{{ task.description }}
@@ -228,59 +251,89 @@ defineExpose({ open });
<Card title="升级设备统计" class="mb-5" :loading="taskStatisticsLoading">
<Row :gutter="20" class="py-5">
<Col :span="6">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-blue-500">
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
>
<div class="mb-2 text-3xl font-bold text-blue-500">
{{
Object.values(taskStatistics).reduce((sum, count) => sum + (count || 0), 0) || 0
Object.values(taskStatistics).reduce(
(sum, count) => sum + (count || 0),
0,
) || 0
}}
</div>
<div class="text-sm text-gray-600">升级设备总数</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-gray-400">
{{ taskStatistics[IoTOtaTaskRecordStatusEnum.PENDING.value] || 0 }}
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
>
<div class="mb-2 text-3xl font-bold text-gray-400">
{{
taskStatistics[IoTOtaTaskRecordStatusEnum.PENDING.value] || 0
}}
</div>
<div class="text-sm text-gray-600">待推送</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-blue-400">
{{ taskStatistics[IoTOtaTaskRecordStatusEnum.PUSHED.value] || 0 }}
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
>
<div class="mb-2 text-3xl font-bold text-blue-400">
{{
taskStatistics[IoTOtaTaskRecordStatusEnum.PUSHED.value] || 0
}}
</div>
<div class="text-sm text-gray-600">已推送</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-yellow-500">
{{ taskStatistics[IoTOtaTaskRecordStatusEnum.UPGRADING.value] || 0 }}
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
>
<div class="mb-2 text-3xl font-bold text-yellow-500">
{{
taskStatistics[IoTOtaTaskRecordStatusEnum.UPGRADING.value] ||
0
}}
</div>
<div class="text-sm text-gray-600">正在升级</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-green-500">
{{ taskStatistics[IoTOtaTaskRecordStatusEnum.SUCCESS.value] || 0 }}
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
>
<div class="mb-2 text-3xl font-bold text-green-500">
{{
taskStatistics[IoTOtaTaskRecordStatusEnum.SUCCESS.value] || 0
}}
</div>
<div class="text-sm text-gray-600">升级成功</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-red-500">
{{ taskStatistics[IoTOtaTaskRecordStatusEnum.FAILURE.value] || 0 }}
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
>
<div class="mb-2 text-3xl font-bold text-red-500">
{{
taskStatistics[IoTOtaTaskRecordStatusEnum.FAILURE.value] || 0
}}
</div>
<div class="text-sm text-gray-600">升级失败</div>
</div>
</Col>
<Col :span="3">
<div class="text-center p-5 border border-solid border-gray-200 rounded bg-gray-50">
<div class="text-3xl font-bold mb-2 text-gray-400">
{{ taskStatistics[IoTOtaTaskRecordStatusEnum.CANCELED.value] || 0 }}
<div
class="rounded border border-solid border-gray-200 bg-gray-50 p-5 text-center"
>
<div class="mb-2 text-3xl font-bold text-gray-400">
{{
taskStatistics[IoTOtaTaskRecordStatusEnum.CANCELED.value] || 0
}}
</div>
<div class="text-sm text-gray-600">升级取消</div>
</div>
@@ -290,8 +343,16 @@ defineExpose({ open });
<!-- 设备管理 -->
<Card title="升级设备记录">
<Tabs v-model:activeKey="activeTab" @change="handleTabChange" class="mb-4">
<Tabs.TabPane v-for="tab in statusTabs" :key="tab.key" :tab="tab.label" />
<Tabs
v-model:active-key="activeTab"
@change="handleTabChange"
class="mb-4"
>
<Tabs.TabPane
v-for="tab in statusTabs"
:key="tab.key"
:tab="tab.label"
/>
</Tabs>
<Table
@@ -313,7 +374,9 @@ defineExpose({ open });
<template v-if="column.key === 'status'">
<Tag v-if="record.status === 0" color="default">待推送</Tag>
<Tag v-else-if="record.status === 1" color="blue">已推送</Tag>
<Tag v-else-if="record.status === 2" color="processing">升级中</Tag>
<Tag v-else-if="record.status === 2" color="processing">
升级中
</Tag>
<Tag v-else-if="record.status === 3" color="success">成功</Tag>
<Tag v-else-if="record.status === 4" color="error">失败</Tag>
<Tag v-else-if="record.status === 5" color="warning">已取消</Tag>

View File

@@ -1,12 +1,16 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message, Form, Input, Select, Spin } from 'ant-design-vue';
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { OtaTask } from '#/api/iot/ota/task';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Form, Input, message, Select, Spin } from 'ant-design-vue';
import * as DeviceApi from '#/api/iot/device/device';
import * as IoTOtaTaskApi from '#/api/iot/ota/task';
import { IoTOtaTaskDeviceScopeEnum } from '#/views/iot/utils/constants';
import type { DeviceVO } from '#/api/iot/device/device';
import * as DeviceApi from '#/api/iot/device/device';
/** IoT OTA 升级任务表单 */
defineOptions({ name: 'OtaTaskForm' });
@@ -28,11 +32,32 @@ const formData = ref<OtaTask>({
});
const formRef = ref();
const formRules = {
name: [{ required: true, message: '请输入任务名称', trigger: 'blur' as const, type: 'string' as const }],
deviceScope: [{ required: true, message: '请选择升级范围', trigger: 'change' as const, type: 'number' as const }],
deviceIds: [{ required: true, message: '请至少选择一个设备', trigger: 'change' as const, type: 'array' as const }],
name: [
{
required: true,
message: '请输入任务名称',
trigger: 'blur' as const,
type: 'string' as const,
},
],
deviceScope: [
{
required: true,
message: '请选择升级范围',
trigger: 'change' as const,
type: 'number' as const,
},
],
deviceIds: [
{
required: true,
message: '请至少选择一个设备',
trigger: 'change' as const,
type: 'array' as const,
},
],
};
const devices = ref<DeviceVO[]>([]);
const devices = ref<IotDeviceApi.Device[]>([]);
/** 设备选项 */
const deviceOptions = computed(() => {
@@ -73,7 +98,8 @@ const [Modal, modalApi] = useVbenModal({
// 加载设备列表
formLoading.value = true;
try {
devices.value = (await DeviceApi.getDeviceListByProductId(props.productId)) || [];
devices.value =
(await DeviceApi.getDeviceListByProductId(props.productId)) || [];
} finally {
formLoading.value = false;
}
@@ -81,7 +107,7 @@ const [Modal, modalApi] = useVbenModal({
});
/** 重置表单 */
const resetForm = () => {
function resetForm() {
formData.value = {
name: '',
deviceScope: IoTOtaTaskDeviceScopeEnum.ALL.value,
@@ -90,12 +116,12 @@ const resetForm = () => {
deviceIds: [],
};
formRef.value?.resetFields();
};
}
/** 打开弹窗 */
const open = async () => {
async function open() {
await modalApi.open();
};
}
defineExpose({ open });
</script>

View File

@@ -1,14 +1,29 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { formatDate } from '@vben/utils';
import type { TableColumnsType } from 'ant-design-vue';
import type { OtaTask } from '#/api/iot/ota/task';
import { onMounted, reactive, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { formatDate } from '@vben/utils';
import {
Button,
Card,
Input,
message,
Modal,
Space,
Table,
Tag,
} from 'ant-design-vue';
import * as IoTOtaTaskApi from '#/api/iot/ota/task';
import { IoTOtaTaskStatusEnum } from '#/views/iot/utils/constants';
import OtaTaskForm from './OtaTaskForm.vue';
import OtaTaskDetail from './OtaTaskDetail.vue';
import { Card, Input, Table, Space, Modal, message, Tag } from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import { VbenButton } from '@vben/common-ui';
import OtaTaskForm from './OtaTaskForm.vue';
/** IoT OTA 任务列表 */
defineOptions({ name: 'OtaTaskList' });
@@ -34,7 +49,7 @@ const taskFormRef = ref(); // 任务表单引用
const taskDetailRef = ref(); // 任务详情引用
/** 获取任务列表 */
const getTaskList = async () => {
async function getTaskList() {
taskLoading.value = true;
try {
const data = await IoTOtaTaskApi.getOtaTaskPage(queryParams);
@@ -43,32 +58,32 @@ const getTaskList = async () => {
} finally {
taskLoading.value = false;
}
};
}
/** 搜索 */
const handleQuery = () => {
function handleQuery() {
queryParams.pageNo = 1;
getTaskList();
};
}
/** 打开任务表单 */
const openTaskForm = () => {
function openTaskForm() {
taskFormRef.value?.open();
};
}
/** 处理任务创建成功 */
const handleTaskCreateSuccess = () => {
function handleTaskCreateSuccess() {
getTaskList();
emit('success');
};
}
/** 查看任务详情 */
const handleTaskDetail = (id: number) => {
function handleTaskDetail(id: number) {
taskDetailRef.value?.open(id);
};
}
/** 取消任务 */
const handleCancelTask = async (id: number) => {
async function handleCancelTask(id: number) {
Modal.confirm({
title: '确认取消',
content: '确认要取消该升级任务吗?',
@@ -82,20 +97,20 @@ const handleCancelTask = async (id: number) => {
}
},
});
};
}
/** 刷新数据 */
const refresh = async () => {
async function refresh() {
await getTaskList();
emit('success');
};
}
/** 分页变化 */
const handleTableChange = (pagination: any) => {
function handleTableChange(pagination: any) {
queryParams.pageNo = pagination.current;
queryParams.pageSize = pagination.pageSize;
getTaskList();
};
}
/** 表格列配置 */
const columns: TableColumnsType = [
@@ -160,11 +175,11 @@ onMounted(() => {
<template>
<Card title="升级任务管理" class="mb-5">
<!-- 搜索栏 -->
<div class="mb-4 flex justify-between items-center">
<VbenButton type="primary" @click="openTaskForm">
<Icon icon="ant-design:plus-outlined" class="mr-1" />
<div class="mb-4 flex items-center justify-between">
<Button type="primary" @click="openTaskForm">
<IconifyIcon icon="ant-design:plus-outlined" class="mr-1" />
新增
</VbenButton>
</Button>
<Input
v-model:value="queryParams.name"
placeholder="请输入任务名称"

View File

@@ -1,15 +1,13 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
defineOptions({ name: 'IotPlugin' });
</script>
<template>
<Page
description="物聯網插件管理"
title="插件管理"
>
<Page description="物聯網插件管理" title="插件管理">
<div class="p-4">
<Button
danger

View File

@@ -1,19 +1,10 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IotProductCategoryApi } from '#/api/iot/product/category';
import { DICT_TYPE } from '@vben/constants';
import { handleTree } from '@vben/utils';
import { message } from 'ant-design-vue';
import { z } from '#/adapter/form';
import {
deleteProductCategory,
getProductCategoryPage,
getSimpleProductCategoryList
} from '#/api/iot/product/category';
import { $t } from '#/locales';
import { getSimpleProductCategoryList } from '#/api/iot/product/category';
/** 新增/修改产品分类的表单 */
export function useFormSchema(): VbenFormSchema[] {
@@ -160,32 +151,3 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
},
];
}
/** 删除分类 */
export async function handleDeleteCategory(row: IotProductCategoryApi.ProductCategory, onSuccess?: () => void) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
});
try {
await deleteProductCategory(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
onSuccess?.();
} finally {
hideLoading();
}
}
/** 查询分类列表 */
export async function queryProductCategoryList({ page }: any, formValues: any) {
const data = await getProductCategoryPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
// 转换为树形结构
return {
...data,
list: handleTree(data.list, 'id', 'parentId'),
};
}

View File

@@ -3,16 +3,18 @@ import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IotProductCategoryApi } from '#/api/iot/product/category';
import { Page, useVbenModal } from '@vben/common-ui';
import { handleTree } from '@vben/utils';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteProductCategory,
getProductCategoryPage,
} from '#/api/iot/product/category';
import { $t } from '#/locales';
import {
handleDeleteCategory,
queryProductCategoryList,
useGridColumns,
useGridFormSchema
} from './data';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/ProductCategoryForm.vue';
defineOptions({ name: 'IoTProductCategory' });
@@ -39,7 +41,17 @@ function handleEdit(row: IotProductCategoryApi.ProductCategory) {
/** 删除分类 */
async function handleDelete(row: IotProductCategoryApi.ProductCategory) {
await handleDeleteCategory(row, handleRefresh);
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
});
try {
await deleteProductCategory(row.id!);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
hideLoading();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
@@ -57,7 +69,18 @@ const [Grid, gridApi] = useVbenVxeGrid({
},
proxyConfig: {
ajax: {
query: queryProductCategoryList,
query: async ({ page }, formValues) => {
const data = await getProductCategoryPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
// 转换为树形结构
return {
...data,
list: handleTree(data.list, 'id', 'parentId'),
};
},
},
},
rowConfig: {
@@ -66,7 +89,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
},
toolbarConfig: {
refresh: true,
search:true,
search: true,
},
treeConfig: {
parentField: 'parentId',
@@ -95,7 +118,6 @@ const [Grid, gridApi] = useVbenVxeGrid({
icon: ACTION_ICON.ADD,
onClick: handleCreate,
},
]"
/>
</template>

View File

@@ -5,17 +5,10 @@ import { ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { downloadFileFromBlobPart } from '@vben/utils';
import { message } from 'ant-design-vue';
import { z } from '#/adapter/form';
import { getSimpleProductCategoryList } from '#/api/iot/product/category';
import {
deleteProduct,
exportProduct,
getProductPage
} from '#/api/iot/product/product';
import { getProductPage } from '#/api/iot/product/product';
/** 新增/修改产品的表单 */
export function useFormSchema(): VbenFormSchema[] {
@@ -208,38 +201,6 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
];
}
/** 加载产品分类列表 */
export async function loadCategoryList() {
return await getSimpleProductCategoryList();
}
/** 获取分类名称 */
export function getCategoryName(categoryList: any[], categoryId: number) {
const category = categoryList.find((c: any) => c.id === categoryId);
return category?.name || '未分类';
}
/** 删除产品 */
export async function handleDeleteProduct(row: any, onSuccess?: () => void) {
const hideLoading = message.loading({
content: `正在删除 ${row.name}...`,
duration: 0,
});
try {
await deleteProduct(row.id);
message.success(`删除 ${row.name} 成功`);
onSuccess?.();
} finally {
hideLoading();
}
}
/** 导出产品 */
export async function handleExportProduct(searchParams: any) {
const data = await exportProduct(searchParams);
downloadFileFromBlobPart({ fileName: '产品列表.xls', source: data });
}
/** 查询产品列表 */
export async function queryProductList({ page }: any, searchParams: any) {
return await getProductPage({

View File

@@ -4,31 +4,31 @@ import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Button, Card, Image, Input, Space } from 'ant-design-vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, Card, Image, Input, message, Space } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteProduct,
exportProduct,
getProductPage,
} from '#/api/crm/product';
import { getSimpleProductCategoryList } from '#/api/iot/product/category';
import { $t } from '#/locales';
import ProductForm from './modules/ProductForm.vue';
import { useGridColumns, useImagePreview } from './data';
// @ts-ignore
import ProductCardView from './modules/ProductCardView.vue';
import {
getCategoryName,
handleDeleteProduct,
handleExportProduct,
loadCategoryList,
queryProductList,
useGridColumns,
useImagePreview,
} from './data';
import ProductForm from './modules/ProductForm.vue';
defineOptions({ name: 'IoTProduct' });
const router = useRouter();
const categoryList = ref<any[]>([]);
const viewMode = ref<'list' | 'card'>('card');
const viewMode = ref<'card' | 'list'>('card');
const cardViewRef = ref();
// 搜索参数
@@ -46,14 +46,15 @@ const [FormModal, formModalApi] = useVbenModal({
});
// 加载产品分类列表
const loadCategories = async () => {
categoryList.value = await loadCategoryList();
};
async function loadCategories() {
categoryList.value = await getSimpleProductCategoryList();
}
// 获取分类名称
const getCategoryNameByValue = (categoryId: number) => {
return getCategoryName(categoryList.value, categoryId);
};
function getCategoryNameByValue(categoryId: number) {
const category = categoryList.value.find((c: any) => c.id === categoryId);
return category?.name || '未分类';
}
/** 搜索 */
function handleSearch() {
@@ -83,7 +84,8 @@ function handleRefresh() {
/** 导出表格 */
async function handleExport() {
await handleExportProduct(searchParams.value);
const data = await exportProduct(searchParams.value);
await downloadFileFromBlobPart({ fileName: '产品列表.xls', source: data });
}
/** 打开产品详情 */
@@ -115,7 +117,17 @@ function handleEdit(row: any) {
/** 删除产品 */
async function handleDelete(row: any) {
await handleDeleteProduct(row, handleRefresh);
const hideLoading = message.loading({
content: `正在删除 ${row.name}...`,
duration: 0,
});
try {
await deleteProduct(row.id);
message.success(`删除 ${row.name} 成功`);
handleRefresh();
} finally {
hideLoading();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
@@ -128,7 +140,13 @@ const [Grid, gridApi] = useVbenVxeGrid({
keepSource: true,
proxyConfig: {
ajax: {
query: ({ page }) => queryProductList({ page }, searchParams.value),
query: async ({ page }) => {
return await getProductPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...searchParams.value,
});
},
},
},
rowConfig: {
@@ -150,17 +168,17 @@ onMounted(() => {
<template>
<Page auto-content-height>
<FormModal @success="handleRefresh" />
<!-- 统一搜索工具栏 -->
<Card :body-style="{ padding: '16px' }" class="mb-4">
<!-- 搜索表单 -->
<div class="flex items-center gap-3 mb-3">
<div class="mb-3 flex items-center gap-3">
<Input
v-model:value="searchParams.name"
placeholder="请输入产品名称"
allow-clear
style="width: 200px"
@pressEnter="handleSearch"
@press-enter="handleSearch"
>
<template #prefix>
<span class="text-gray-400">产品名称</span>
@@ -171,7 +189,7 @@ onMounted(() => {
placeholder="请输入产品标识"
allow-clear
style="width: 200px"
@pressEnter="handleSearch"
@press-enter="handleSearch"
>
<template #prefix>
<span class="text-gray-400">ProductKey</span>
@@ -199,7 +217,7 @@ onMounted(() => {
导出
</Button>
</Space>
<!-- 视图切换 -->
<Space :size="4">
<Button
@@ -335,6 +353,6 @@ onMounted(() => {
}
.ant-image-preview-operations {
background: rgba(0, 0, 0, 0.7) !important;
background: rgb(0 0 0 / 70%) !important;
}
</style>

View File

@@ -1,6 +1,10 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import {
Button,
Card,
@@ -12,14 +16,21 @@ import {
Tag,
Tooltip,
} from 'ant-design-vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import { getProductPage } from '#/api/iot/product/product';
defineOptions({ name: 'ProductCardView' });
const props = defineProps<Props>();
const emit = defineEmits<{
create: [];
delete: [row: any];
detail: [productId: number];
edit: [row: any];
thingModel: [productId: number];
}>();
interface Props {
categoryList: any[];
searchParams?: {
@@ -28,16 +39,6 @@ interface Props {
};
}
const props = defineProps<Props>();
const emit = defineEmits<{
create: [];
edit: [row: any];
delete: [row: any];
detail: [productId: number];
thingModel: [productId: number];
}>();
const loading = ref(false);
const list = ref<any[]>([]);
const total = ref(0);
@@ -47,13 +48,13 @@ const queryParams = ref({
});
// 获取分类名称
const getCategoryName = (categoryId: number) => {
function getCategoryName(categoryId: number) {
const category = props.categoryList.find((c: any) => c.id === categoryId);
return category?.name || '未分类';
};
}
// 获取产品列表
const getList = async () => {
async function getList() {
loading.value = true;
try {
const data = await getProductPage({
@@ -65,30 +66,30 @@ const getList = async () => {
} finally {
loading.value = false;
}
};
}
// 处理页码变化
const handlePageChange = (page: number, pageSize: number) => {
function handlePageChange(page: number, pageSize: number) {
queryParams.value.pageNo = page;
queryParams.value.pageSize = pageSize;
getList();
};
}
// 获取设备类型颜色
const getDeviceTypeColor = (deviceType: number) => {
function getDeviceTypeColor(deviceType: number) {
const colors: Record<number, string> = {
0: 'blue',
1: 'green',
};
return colors[deviceType] || 'default';
};
}
onMounted(() => {
getList();
});
// 暴露方法供父组件调用
defineExpose({
defineExpose({
reload: getList,
search: () => {
queryParams.value.pageNo = 1;
@@ -111,42 +112,57 @@ defineExpose({
:lg="6"
class="mb-4"
>
<Card
:body-style="{ padding: '20px' }"
class="product-card h-full"
>
<Card :body-style="{ padding: '20px' }" class="product-card h-full">
<!-- 顶部标题区域 -->
<div class="flex items-start mb-4">
<div class="mb-4 flex items-start">
<div class="product-icon">
<IconifyIcon icon="ant-design:inbox-outlined" class="text-[32px]" />
<IconifyIcon
icon="ant-design:inbox-outlined"
class="text-[32px]"
/>
</div>
<div class="ml-3 flex-1 min-w-0">
<div class="ml-3 min-w-0 flex-1">
<div class="product-title">{{ item.name }}</div>
</div>
</div>
<!-- 内容区域 -->
<div class="flex items-start mb-4">
<div class="flex-1 info-list">
<div class="mb-4 flex items-start">
<div class="info-list flex-1">
<div class="info-item">
<span class="info-label">产品分类</span>
<span class="info-value text-primary">{{ getCategoryName(item.categoryId) }}</span>
<span class="info-value text-primary">
{{ getCategoryName(item.categoryId) }}
</span>
</div>
<div class="info-item">
<span class="info-label">产品类型</span>
<Tag :color="getDeviceTypeColor(item.deviceType)" class="m-0 info-tag">
{{ getDictLabel(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE, item.deviceType) }}
<Tag
:color="getDeviceTypeColor(item.deviceType)"
class="info-tag m-0"
>
{{
getDictLabel(
DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE,
item.deviceType,
)
}}
</Tag>
</div>
<div class="info-item">
<span class="info-label">产品标识</span>
<Tooltip :title="item.productKey || item.id" placement="top">
<span class="info-value product-key">{{ item.productKey || item.id }}</span>
<span class="info-value product-key">
{{ item.productKey || item.id }}
</span>
</Tooltip>
</div>
</div>
<div class="product-3d-icon">
<IconifyIcon icon="ant-design:box-plot-outlined" class="text-[80px]" />
<IconifyIcon
icon="ant-design:box-plot-outlined"
class="text-[80px]"
/>
</div>
</div>
@@ -173,7 +189,10 @@ defineExpose({
class="action-btn action-btn-model"
@click="emit('thingModel', item.id)"
>
<IconifyIcon icon="ant-design:apartment-outlined" class="mr-1" />
<IconifyIcon
icon="ant-design:apartment-outlined"
class="mr-1"
/>
物模型
</Button>
<Popconfirm
@@ -217,44 +236,44 @@ defineExpose({
.product-card-view {
.product-card {
height: 100%;
transition: all 0.3s ease;
overflow: hidden;
border: 1px solid #e8e8e8;
border-radius: 8px;
overflow: hidden;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
border-color: #d9d9d9;
box-shadow: 0 4px 16px rgb(0 0 0 / 8%);
transform: translateY(-2px);
}
:deep(.ant-card-body) {
height: 100%;
display: flex;
flex-direction: column;
height: 100%;
}
// 产品图标
.product-icon {
width: 48px;
height: 48px;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
color: white;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
color: white;
flex-shrink: 0;
}
// 产品标题
.product-title {
font-size: 16px;
font-weight: 600;
color: #1f2937;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
font-size: 16px;
font-weight: 600;
line-height: 1.5;
color: #1f2937;
white-space: nowrap;
}
@@ -271,16 +290,16 @@ defineExpose({
}
.info-label {
color: #6b7280;
margin-right: 8px;
flex-shrink: 0;
margin-right: 8px;
color: #6b7280;
}
.info-value {
color: #1f2937;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
color: #1f2937;
white-space: nowrap;
&.text-primary {
@@ -289,15 +308,15 @@ defineExpose({
}
.product-key {
font-family: 'Courier New', monospace;
font-size: 12px;
color: #374151;
display: inline-block;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'Courier New', monospace;
font-size: 12px;
vertical-align: middle;
color: #374151;
white-space: nowrap;
cursor: help;
}
@@ -309,15 +328,15 @@ defineExpose({
// 3D 图标
.product-3d-icon {
width: 100px;
height: 100px;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 100px;
height: 100px;
color: #667eea;
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
border-radius: 8px;
flex-shrink: 0;
color: #667eea;
}
// 按钮组
@@ -325,8 +344,8 @@ defineExpose({
display: flex;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
margin-top: auto;
border-top: 1px solid #f0f0f0;
.action-btn {
flex: 1;
@@ -340,8 +359,8 @@ defineExpose({
border-color: #1890ff;
&:hover {
background: #1890ff;
color: white;
background: #1890ff;
}
}
@@ -350,8 +369,8 @@ defineExpose({
border-color: #52c41a;
&:hover {
background: #52c41a;
color: white;
background: #52c41a;
}
}
@@ -360,8 +379,8 @@ defineExpose({
border-color: #722ed1;
&:hover {
background: #722ed1;
color: white;
background: #722ed1;
}
}
@@ -374,4 +393,3 @@ defineExpose({
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More