refactor:【antd】【iot】代码优化

This commit is contained in:
haohao
2025-12-22 17:30:59 +08:00
parent 13f81b3130
commit 6bf9acbfb2
26 changed files with 178 additions and 529 deletions

View File

@@ -1,7 +1,7 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { DeviceTypeEnum, DICT_TYPE } from '@vben/constants';
import { DeviceTypeEnum, DICT_TYPE, LocationTypeEnum } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form';
@@ -68,7 +68,7 @@ export function useFormSchema(): VbenFormSchema[] {
},
dependencies: {
triggerFields: ['deviceType'],
show: (values) => values.deviceType === 1, // GATEWAY_SUB
show: (values) => values.deviceType === DeviceTypeEnum.GATEWAY_SUB,
},
},
{
@@ -134,20 +134,20 @@ export function useFormSchema(): VbenFormSchema[] {
},
dependencies: {
triggerFields: ['locationType'],
show: (values) => values.locationType === 3, // MANUAL
show: (values) => values.locationType === LocationTypeEnum.MANUAL,
},
},
{
fieldName: 'latitude',
label: '设备度',
label: '设备度',
component: 'InputNumber',
componentProps: {
placeholder: '请输入设备度',
placeholder: '请输入设备度',
class: 'w-full',
},
dependencies: {
triggerFields: ['locationType'],
show: (values) => values.locationType === 3, // MANUAL
show: (values) => values.locationType === LocationTypeEnum.MANUAL,
},
},
];

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<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';
@@ -89,9 +89,9 @@ onMounted(async () => {
<template>
<Page>
<DeviceDetailsHeader
:device="device"
:loading="loading"
:product="product"
:device="device"
@refresh="() => getDeviceData(id)"
/>
@@ -99,8 +99,8 @@ onMounted(async () => {
<Tabs.TabPane key="info" tab="设备信息">
<DeviceDetailsInfo
v-if="activeTab === 'info'"
:product="product"
:device="device"
:product="product"
/>
</Tabs.TabPane>
<Tabs.TabPane key="model" tab="物模型数据">
@@ -129,8 +129,8 @@ onMounted(async () => {
<Tabs.TabPane key="simulator" tab="模拟设备">
<DeviceDetailsSimulator
v-if="activeTab === 'simulator'"
:product="product"
:device="device"
:product="product"
:thing-model-list="thingModelList"
/>
</Tabs.TabPane>

View File

@@ -128,11 +128,11 @@ async function updateDeviceConfig() {
<div>
<!-- 使用说明提示 -->
<Alert
message="支持远程更新设备的配置文件(JSON 格式),可以在下方编辑配置模板,对设备的系统参数、网络参数等进行远程配置。配置完成后,需点击「配置推送」按钮,设备即可进行远程配置。"
type="info"
show-icon
class="my-4"
description="如需编辑文件,请点击下方编辑按钮"
message="支持远程更新设备的配置文件(JSON 格式),可以在下方编辑配置模板,对设备的系统参数、网络参数等进行远程配置。配置完成后,需点击「配置推送」按钮,设备即可进行远程配置。"
show-icon
type="info"
/>
<!-- 代码视图 - 只读展示 -->
@@ -145,8 +145,8 @@ async function updateDeviceConfig() {
v-else
v-model:value="configString"
:rows="20"
placeholder="请输入 JSON 格式的配置信息"
class="json-editor"
placeholder="请输入 JSON 格式的配置信息"
/>
<!-- 操作按钮 -->
@@ -154,18 +154,18 @@ async function updateDeviceConfig() {
<Button v-if="isEditing" @click="handleCancelEdit">取消</Button>
<Button
v-if="isEditing"
:loading="saveLoading"
type="primary"
@click="saveConfig"
:loading="saveLoading"
>
保存
</Button>
<Button v-else @click="handleEdit">编辑</Button>
<Button
v-if="!isEditing"
:loading="pushLoading"
type="primary"
@click="handleConfigPush"
:loading="pushLoading"
>
配置推送
</Button>

View File

@@ -86,8 +86,8 @@ function openEditForm(row: IotDeviceApi.Device) {
<Descriptions.Item label="ProductKey">
{{ product.productKey }}
<Button
size="small"
class="ml-2"
size="small"
@click="copyToClipboard(product.productKey)"
>
复制

View File

@@ -75,7 +75,7 @@ function handleAuthInfoDialogClose() {
<Card class="h-full">
<template #title>
<div class="flex items-center">
<IconifyIcon icon="ep:info-filled" class="mr-2 text-primary" />
<IconifyIcon class="mr-2 text-primary" icon="ep:info-filled" />
<span>设备信息</span>
</div>
</template>
@@ -124,9 +124,9 @@ function handleAuthInfoDialogClose() {
</Descriptions.Item>
<Descriptions.Item label="MQTT 连接参数">
<Button
size="small"
type="link"
@click="handleAuthInfoDialogOpen"
size="small"
>
查看
</Button>
@@ -141,7 +141,7 @@ function handleAuthInfoDialogClose() {
<template #title>
<div class="flex items-center justify-between">
<div class="flex items-center">
<IconifyIcon icon="ep:location" class="mr-2 text-primary" />
<IconifyIcon class="mr-2 text-primary" icon="ep:location" />
<span>设备位置</span>
</div>
<div class="text-sm text-gray-500">
@@ -160,7 +160,7 @@ function handleAuthInfoDialogClose() {
v-else
class="flex h-full w-full items-center justify-center rounded bg-gray-50 text-gray-400"
>
<IconifyIcon icon="ep:warning" class="mr-2" />
<IconifyIcon class="mr-2" icon="ep:warning" />
<span>暂无位置信息</span>
</div>
</div>
@@ -171,9 +171,9 @@ function handleAuthInfoDialogClose() {
<!-- 认证信息弹框 -->
<Modal
v-model:open="authDialogVisible"
:footer="null"
title="MQTT 连接参数"
width="640px"
:footer="null"
>
<Form :label-col="{ span: 6 }">
<Form.Item label="clientId">
@@ -183,7 +183,7 @@ function handleAuthInfoDialogClose() {
readonly
style="width: calc(100% - 80px)"
/>
<Button @click="copyToClipboard(authInfo.clientId)" type="primary">
<Button type="primary" @click="copyToClipboard(authInfo.clientId)">
<IconifyIcon icon="lucide:copy" />
</Button>
</Input.Group>
@@ -195,7 +195,7 @@ function handleAuthInfoDialogClose() {
readonly
style="width: calc(100% - 80px)"
/>
<Button @click="copyToClipboard(authInfo.username)" type="primary">
<Button type="primary" @click="copyToClipboard(authInfo.username)">
<IconifyIcon icon="lucide:copy" />
</Button>
</Input.Group>
@@ -204,19 +204,19 @@ function handleAuthInfoDialogClose() {
<Input.Group compact>
<Input
v-model:value="authInfo.password"
readonly
:type="authPasswordVisible ? 'text' : 'password'"
readonly
style="width: calc(100% - 160px)"
/>
<Button
@click="authPasswordVisible = !authPasswordVisible"
type="primary"
@click="authPasswordVisible = !authPasswordVisible"
>
<IconifyIcon
:icon="authPasswordVisible ? 'lucide:eye-off' : 'lucide:eye'"
/>
</Button>
<Button @click="copyToClipboard(authInfo.password)" type="primary">
<Button type="primary" @click="copyToClipboard(authInfo.password)">
<IconifyIcon icon="lucide:copy" />
</Button>
</Input.Group>

View File

@@ -186,9 +186,9 @@ defineExpose({
<div class="mb-4 flex flex-wrap items-center gap-3">
<Select
v-model:value="queryParams.method"
allow-clear
placeholder="所有方法"
style="width: 160px"
allow-clear
>
<Select.Option
v-for="item in methodOptions"
@@ -201,9 +201,9 @@ defineExpose({
</Select>
<Select
v-model:value="queryParams.upstream"
allow-clear
placeholder="上行/下行"
style="width: 160px"
allow-clear
>
<Select.Option label="上行" value="true">上行</Select.Option>
<Select.Option label="下行" value="false">下行</Select.Option>

View File

@@ -343,18 +343,18 @@ async function handleServiceInvoke(row: ThingModelData) {
<ContentWrap>
<Row :gutter="16">
<!-- 左侧指令调试区域 -->
<Col :xs="24" :sm="24" :md="24" :lg="12" :xl="12">
<Col :lg="12" :md="24" :sm="24" :xl="12" :xs="24">
<Card class="simulator-tabs h-full">
<template #title>
<div class="flex items-center justify-between">
<span>指令调试</span>
<Button
type="text"
size="small"
type="text"
@click="debugCollapsed = !debugCollapsed"
>
<IconifyIcon icon="lucide:chevron-up" v-if="!debugCollapsed" />
<IconifyIcon icon="lucide:chevron-down" v-if="debugCollapsed" />
<IconifyIcon v-if="!debugCollapsed" icon="lucide:chevron-up" />
<IconifyIcon v-if="debugCollapsed" icon="lucide:chevron-down" />
</Button>
</div>
</template>
@@ -374,11 +374,11 @@ async function handleServiceInvoke(row: ThingModelData) {
>
<ContentWrap>
<Table
:data-source="propertyList"
align="center"
:columns="propertyColumns"
:data-source="propertyList"
:pagination="false"
:scroll="{ x: 'max-content', y: 300 }"
align="center"
size="small"
>
<template #bodyCell="{ column, record }">
@@ -391,11 +391,11 @@ async function handleServiceInvoke(row: ThingModelData) {
<template v-else-if="column.key === 'value'">
<Input
:value="getFormValue(record.identifier)"
placeholder="输入值"
size="small"
@update:value="
setFormValue(record.identifier, $event)
"
placeholder="输入值"
size="small"
/>
</template>
</template>
@@ -418,11 +418,11 @@ async function handleServiceInvoke(row: ThingModelData) {
>
<ContentWrap>
<Table
:data-source="eventList"
align="center"
:columns="eventColumns"
:data-source="eventList"
:pagination="false"
:scroll="{ x: 'max-content', y: 300 }"
align="center"
size="small"
>
<template #bodyCell="{ column, record }">
@@ -434,19 +434,19 @@ async function handleServiceInvoke(row: ThingModelData) {
</template>
<template v-else-if="column.key === 'value'">
<Textarea
:rows="3"
:value="getFormValue(record.identifier)"
placeholder="输入事件参数JSON格式"
size="small"
@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"
type="primary"
@click="handleEventPost(record)"
>
上报事件
@@ -496,11 +496,11 @@ async function handleServiceInvoke(row: ThingModelData) {
>
<ContentWrap>
<Table
:data-source="propertyList"
align="center"
:columns="propertyColumns"
:data-source="propertyList"
:pagination="false"
:scroll="{ x: 'max-content', y: 300 }"
align="center"
size="small"
>
<template #bodyCell="{ column, record }">
@@ -513,11 +513,11 @@ async function handleServiceInvoke(row: ThingModelData) {
<template v-else-if="column.key === 'value'">
<Input
:value="getFormValue(record.identifier)"
placeholder="输入值"
size="small"
@update:value="
setFormValue(record.identifier, $event)
"
placeholder="输入值"
size="small"
/>
</template>
</template>
@@ -540,11 +540,11 @@ async function handleServiceInvoke(row: ThingModelData) {
>
<ContentWrap>
<Table
:data-source="serviceList"
align="center"
:columns="serviceColumns"
:data-source="serviceList"
:pagination="false"
:scroll="{ x: 'max-content', y: 300 }"
align="center"
size="small"
>
<template #bodyCell="{ column, record }">
@@ -553,19 +553,19 @@ async function handleServiceInvoke(row: ThingModelData) {
</template>
<template v-else-if="column.key === 'value'">
<Textarea
:rows="3"
:value="getFormValue(record.identifier)"
placeholder="输入服务参数JSON格式"
size="small"
@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"
type="primary"
@click="handleServiceInvoke(record)"
>
服务调用
@@ -583,23 +583,23 @@ async function handleServiceInvoke(row: ThingModelData) {
</Col>
<!-- 右侧设备消息区域 -->
<Col :xs="24" :sm="24" :md="24" :lg="12" :xl="12">
<Col :lg="12" :md="24" :sm="24" :xl="12" :xs="24">
<Card class="h-full">
<template #title>
<div class="flex items-center justify-between">
<span>设备消息</span>
<Button
type="text"
size="small"
type="text"
@click="messageCollapsed = !messageCollapsed"
>
<IconifyIcon
icon="lucide:chevron-down"
v-if="!messageCollapsed"
icon="lucide:chevron-down"
/>
<IconifyIcon
icon="lucide:chevron-down"
v-if="messageCollapsed"
icon="lucide:chevron-down"
/>
</Button>
</div>

View File

@@ -37,8 +37,8 @@ onMounted(() => {
<template>
<Card :loading="loading" title="子设备管理">
<Empty
description="暂无子设备数据,此功能待实现"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="暂无子设备数据,此功能待实现"
/>
<!-- TODO: 实现子设备列表展示和管理功能 -->
</Card>

View File

@@ -191,8 +191,8 @@ defineExpose({
<span>标识符</span>
<Select
v-model:value="queryParams.identifier"
placeholder="请选择事件标识符"
allow-clear
placeholder="请选择事件标识符"
style="width: 240px"
>
<Select.Option
@@ -208,10 +208,10 @@ defineExpose({
<span>时间范围</span>
<RangePicker
v-model:value="queryParams.times"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
show-time
style="width: 360px"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</div>
<Space>

View File

@@ -118,7 +118,6 @@ function formatDateRangeWithTime(dates: [string, string]): [string, string] {
return [`${dates[0]} 00:00:00`, `${dates[1]} 23:59:59`];
}
// 表格列配置
const tableColumns = computed(() => [
{
title: '序号',
@@ -140,9 +139,8 @@ const tableColumns = computed(() => [
dataIndex: 'value',
align: 'center' as const,
},
]);
]); // 表格列配置
// 分页配置
const paginationConfig = computed(() => ({
current: 1,
pageSize: 10,
@@ -151,7 +149,7 @@ const paginationConfig = computed(() => ({
showQuickJumper: true,
pageSizeOptions: ['10', '20', '50', '100'],
showTotal: (total: number) => `${total} 条数据`,
}));
})); // 分页配置
/** 获得设备历史数据 */
async function getList() {
@@ -429,7 +427,7 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
</div>
<!-- 刷新按钮 -->
<Button @click="handleRefresh" :loading="loading">
<Button :loading="loading" @click="handleRefresh">
<template #icon>
<IconifyIcon icon="ant-design:reload-outlined" />
</template>
@@ -438,9 +436,9 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
<!-- 导出按钮 -->
<Button
@click="handleExport"
:loading="exporting"
:disabled="list.length === 0"
:loading="exporting"
@click="handleExport"
>
<template #icon>
<IconifyIcon icon="ant-design:export-outlined" />
@@ -451,9 +449,9 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
<!-- 视图切换 -->
<Button.Group class="ml-auto">
<Button
:disabled="!canShowChart"
:type="viewMode === 'chart' ? 'primary' : 'default'"
@click="viewMode = 'chart'"
:disabled="!canShowChart"
>
<template #icon>
<IconifyIcon icon="ant-design:line-chart-outlined" />
@@ -485,14 +483,14 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
</div>
<!-- 数据展示区域 -->
<Spin :spinning="loading" :delay="200">
<Spin :delay="200" :spinning="loading">
<!-- 图表模式 - 使用 v-show 确保图表组件始终挂载 -->
<div v-show="viewMode === 'chart'" class="chart-container">
<Empty
v-if="list.length === 0"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="暂无数据"
class="py-20"
:description="$t('common.noData')"
/>
<div v-show="list.length > 0">
<EchartsUI ref="chartRef" height="500px" />
@@ -502,8 +500,8 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
<!-- 表格模式 -->
<div v-show="viewMode === 'list'" class="table-container">
<Table
:data-source="list"
:columns="tableColumns"
:data-source="list"
:pagination="paginationConfig"
:scroll="{ y: 500 }"
row-key="updateTime"
@@ -531,7 +529,7 @@ defineExpose({ open }); // 提供 open 方法,用于打开弹窗
</Modal>
</template>
<style scoped lang="scss">
<style lang="scss" scoped>
.property-history-container {
max-height: 70vh;
overflow: auto;

View File

@@ -34,23 +34,15 @@ import DeviceDetailsThingModelPropertyHistory from './thing-model-property-histo
const props = defineProps<{ deviceId: number }>();
// TODO @haohao变量写在 // 列表的加载中 这种注释哈,变量后面;
/** 列表的加载中 */
const loading = ref(true);
/** 显示的列表数据 */
const list = ref<IotDeviceApi.DevicePropertyDetail[]>([]);
/** 完整的数据列表 */
const filterList = ref<IotDeviceApi.DevicePropertyDetail[]>([]);
/** 查询参数 */
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');
}); // 查询参数
const autoRefresh = ref(false); // 自动刷新开关
let autoRefreshTimer: any = null; // 定时器
const viewMode = ref<'card' | 'list'>('card'); // 视图模式状态
/** Grid 列定义 */
function useGridColumns(): VxeTableGridOptions['columns'] {
@@ -286,15 +278,15 @@ onBeforeUnmount(() => {
<div class="flex items-center" style="gap: 16px">
<Input
v-model:value="queryParams.keyword"
placeholder="请输入属性名称、标识符"
allow-clear
placeholder="请输入属性名称、标识符"
style="width: 240px"
@press-enter="handleQuery"
/>
<Switch
v-model:checked="autoRefresh"
class="ml-20px"
checked-children="定时刷新"
class="ml-20px"
un-checked-children="定时刷新"
/>
</div>
@@ -319,19 +311,19 @@ onBeforeUnmount(() => {
<!-- 卡片视图 -->
<template v-if="viewMode === 'card'">
<Row :gutter="16" v-loading="loading">
<Row v-loading="loading" :gutter="16">
<Col
v-for="item in list"
:key="item.identifier"
:xs="24"
:sm="12"
:md="12"
:lg="6"
:md="12"
:sm="12"
:xs="24"
class="mb-4"
>
<Card
class="relative h-full overflow-hidden transition-colors"
:body-style="{ padding: '0' }"
class="relative h-full overflow-hidden transition-colors"
>
<!-- 添加渐变背景层 -->
<div
@@ -341,12 +333,12 @@ onBeforeUnmount(() => {
<!-- 标题区域 -->
<div class="mb-3 flex items-center">
<div class="mr-2.5 flex items-center">
<IconifyIcon icon="ep:cpu" class="text-lg text-primary" />
<IconifyIcon class="text-lg text-primary" icon="ep:cpu" />
</div>
<div class="flex-1 text-base font-bold">{{ item.name }}</div>
<!-- 标识符 -->
<div class="mr-2 inline-flex items-center">
<Tag size="small" color="blue">
<Tag color="blue" size="small">
{{ item.identifier }}
</Tag>
</div>
@@ -364,8 +356,8 @@ onBeforeUnmount(() => {
"
>
<IconifyIcon
icon="ep:data-line"
class="text-lg text-primary"
icon="ep:data-line"
/>
</div>
</div>

View File

@@ -205,8 +205,8 @@ defineExpose({
<span>标识符</span>
<Select
v-model:value="queryParams.identifier"
placeholder="请选择服务标识符"
allow-clear
placeholder="请选择服务标识符"
style="width: 240px"
>
<Select.Option
@@ -222,10 +222,10 @@ defineExpose({
<span>时间范围</span>
<RangePicker
v-model:value="queryParams.times"
show-time
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
show-time
style="width: 360px"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</div>
<Space>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
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 { nextTick, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
@@ -43,10 +45,11 @@ defineOptions({ name: 'IoTDevice' });
const route = useRoute();
const router = useRouter();
const products = ref<any[]>([]);
const deviceGroups = ref<any[]>([]);
const products = ref<IotProductApi.Product[]>([]);
const deviceGroups = ref<IotDeviceGroupApi.DeviceGroup[]>([]);
const viewMode = ref<'card' | 'list'>('card');
const cardViewRef = ref();
const checkedIds = ref<number[]>([]);
const [DeviceFormModal, deviceFormModalApi] = useVbenModal({
connectedComponent: DeviceForm,
@@ -150,7 +153,7 @@ async function handleDelete(row: IotDeviceApi.Device) {
});
try {
await deleteDevice(row.id!);
message.success($t('ui.actionMessage.deleteSuccess'));
message.success($t('ui.actionMessage.deleteSuccess', [row.deviceName]));
handleRefresh();
} finally {
hideLoading();
@@ -164,7 +167,7 @@ async function handleDeleteBatch() {
return;
}
const hideLoading = message.loading({
content: '正在批量删除...',
content: $t('ui.actionMessage.deletingBatch'),
duration: 0,
});
try {
@@ -191,7 +194,6 @@ function handleImport() {
deviceImportFormModalApi.open();
}
const checkedIds = ref<number[]>([]);
function handleRowCheckboxChange({
records,
}: {
@@ -235,7 +237,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
},
});
// 包装 gridApi.query() 方法,统一列表视图和卡片视图的查询接口
/** 包装 gridApi.query() 方法,统一列表视图和卡片视图的查询接口 */
const originalQuery = gridApi.query.bind(gridApi);
gridApi.query = async (params?: Record<string, any>) => {
if (viewMode.value === 'list') {
@@ -348,11 +350,11 @@ onMounted(async () => {
</Select>
<Button type="primary" @click="handleSearch">
<IconifyIcon icon="ant-design:search-outlined" class="mr-1" />
搜索
{{ $t('common.search') }}
</Button>
<Button @click="handleReset">
<IconifyIcon icon="ant-design:reload-outlined" class="mr-1" />
重置
{{ $t('common.reset') }}
</Button>
</div>
@@ -361,21 +363,21 @@ onMounted(async () => {
<TableAction
:actions="[
{
label: '新增',
label: $t('ui.actionTitle.create', ['设备']),
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['iot:device:create'],
onClick: handleCreate,
},
{
label: '导出',
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
auth: ['iot:device:export'],
onClick: handleExport,
},
{
label: '导入',
label: $t('ui.actionTitle.import'),
type: 'primary',
icon: ACTION_ICON.UPLOAD,
auth: ['iot:device:import'],
@@ -391,7 +393,7 @@ onMounted(async () => {
onClick: handleAddToGroup,
},
{
label: '批量删除',
label: $t('ui.actionTitle.deleteBatch'),
type: 'primary',
danger: true,
icon: ACTION_ICON.DELETE,
@@ -428,7 +430,7 @@ onMounted(async () => {
class="cursor-pointer text-primary"
@click="openProductDetail(row.productId)"
>
{{ products.find((p: any) => p.id === row.productId)?.name || '-' }}
{{ products.find((p) => p.id === row.productId)?.name || '-' }}
</a>
</template>
@@ -441,7 +443,7 @@ onMounted(async () => {
size="small"
class="mr-1"
>
{{ deviceGroups.find((g: any) => g.id === groupId)?.name }}
{{ deviceGroups.find((g) => g.id === groupId)?.name }}
</Tag>
</template>
<span v-else>-</span>
@@ -452,7 +454,7 @@ onMounted(async () => {
<TableAction
:actions="[
{
label: '查看',
label: $t('common.detail'),
type: 'link',
onClick: openDetail.bind(null, row.id!),
},
@@ -473,7 +475,7 @@ onMounted(async () => {
danger: true,
icon: ACTION_ICON.DELETE,
popConfirm: {
title: `确认删除设备 ${row.deviceName} 吗?`,
title: $t('ui.actionMessage.deleteConfirm', [row.deviceName]),
confirm: handleDelete.bind(null, row),
},
},

View File

@@ -1,11 +1,8 @@
<script lang="ts" setup>
// TODO @haohaoproduct 的 card-view 的意见,这里看看要不要也改改下。
import { onMounted, ref } from 'vue';
import { DeviceStateEnum, DICT_TYPE } from '@vben/constants';
import { getDictLabel, getDictObj } from '@vben/hooks';
import { DICT_TYPE } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { isValidColor, TinyColor } from '@vben/utils';
import {
Button,
@@ -15,10 +12,10 @@ import {
Pagination,
Popconfirm,
Row,
Tag,
Tooltip,
} from 'ant-design-vue';
import { DictTag } from '#/components/dict-tag';
import { getDevicePage } from '#/api/iot/device/device';
interface Props {
@@ -53,94 +50,6 @@ const queryParams = ref({
pageSize: 12,
});
/** 默认状态映射 */
const DEFAULT_STATUS_MAP: Record<
'default' | number,
{ bgColor: string; borderColor: string; color: string; text: string }
> = {
[DeviceStateEnum.ONLINE]: {
text: '在线',
color: '#52c41a',
bgColor: '#f6ffed',
borderColor: '#b7eb8f',
},
[DeviceStateEnum.OFFLINE]: {
text: '离线',
color: '#faad14',
bgColor: '#fffbe6',
borderColor: '#ffe58f',
},
[DeviceStateEnum.INACTIVE]: {
text: '未激活',
color: '#ff4d4f',
bgColor: '#fff1f0',
borderColor: '#ffccc7',
},
default: {
text: '未知状态',
color: '#595959',
bgColor: '#fafafa',
borderColor: '#d9d9d9',
},
};
/** 颜色类型预设 */
const COLOR_TYPE_PRESETS: Record<
string,
{ bgColor: string; borderColor: string; color: string }
> = {
success: {
color: '#52c41a',
bgColor: '#f6ffed',
borderColor: '#b7eb8f',
},
processing: {
color: '#1890ff',
bgColor: '#e6f7ff',
borderColor: '#91d5ff',
},
warning: {
color: '#faad14',
bgColor: '#fffbe6',
borderColor: '#ffe58f',
},
error: {
color: '#ff4d4f',
bgColor: '#fff1f0',
borderColor: '#ffccc7',
},
default: {
color: '#595959',
bgColor: '#fafafa',
borderColor: '#d9d9d9',
},
};
/** 规范化颜色类型 */
function normalizeColorType(colorType?: string) {
switch (colorType) {
case 'danger': {
return 'error';
}
case 'default':
case 'error':
case 'processing':
case 'success':
case 'warning': {
return colorType;
}
case 'info': {
return 'default';
}
case 'primary': {
return 'processing';
}
default: {
return 'default';
}
}
}
/** 获取产品名称 */
function getProductName(productId: number) {
const product = props.products.find((p: any) => p.id === productId);
@@ -169,54 +78,6 @@ function handlePageChange(page: number, pageSize: number) {
getList();
}
/** 获取设备类型颜色 */
function getDeviceTypeColor(deviceType: number) {
const colors: Record<number, string> = {
0: 'blue',
1: 'cyan',
};
return colors[deviceType] || 'default';
}
/** 获取设备状态信息 */
// TODO @haohao这里可以简化下么体感看着有点复杂哈
function getStatusInfo(state: null | number | string | undefined) {
const parsedState = Number(state);
const hasNumericState = Number.isFinite(parsedState);
const fallback = hasNumericState
? DEFAULT_STATUS_MAP[parsedState] || DEFAULT_STATUS_MAP.default
: DEFAULT_STATUS_MAP.default;
const dict = getDictObj(
DICT_TYPE.IOT_DEVICE_STATE,
hasNumericState ? parsedState : state,
);
if (dict) {
if (!dict.colorType && !dict.cssClass) {
return {
...fallback,
text: dict.label || fallback.text,
};
}
const presetKey = normalizeColorType(dict.colorType);
if (isValidColor(dict.cssClass)) {
const baseColor = new TinyColor(dict.cssClass);
return {
text: dict.label || fallback.text,
color: baseColor.toHexString(),
bgColor: baseColor.clone().setAlpha(0.15).toRgbString(),
borderColor: baseColor.clone().lighten(30).toHexString(),
};
}
const preset = COLOR_TYPE_PRESETS[presetKey] || COLOR_TYPE_PRESETS.default;
return {
text: dict.label || fallback.text,
...preset,
};
}
return fallback;
}
defineExpose({
reload: getList,
search: () => {
@@ -260,17 +121,11 @@ onMounted(() => {
<div class="ml-3 min-w-0 flex-1">
<div class="device-title">{{ item.deviceName }}</div>
</div>
<div
class="status-badge"
:style="{
color: getStatusInfo(item.state).color,
backgroundColor: getStatusInfo(item.state).bgColor,
borderColor: getStatusInfo(item.state).borderColor,
}"
>
<span class="status-dot"></span>
{{ getStatusInfo(item.state).text }}
</div>
<DictTag
:type="DICT_TYPE.IOT_DEVICE_STATE"
:value="item.state"
class="status-tag"
/>
</div>
<!-- 内容区域 -->
<div class="mb-3">
@@ -291,17 +146,11 @@ onMounted(() => {
</div>
<div class="info-item">
<span class="info-label">设备类型</span>
<Tag
:color="getDeviceTypeColor(item.deviceType)"
<DictTag
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
:value="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">Deviceid</span>
@@ -395,7 +244,7 @@ onMounted(() => {
width: 36px;
height: 36px;
color: white;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: linear-gradient(135deg, #40a9ff 0%, #1890ff 100%);
border-radius: 8px;
}
@@ -409,24 +258,9 @@ onMounted(() => {
white-space: nowrap;
}
// 状态徽章
.status-badge {
display: flex;
gap: 4px;
align-items: center;
padding: 2px 10px;
// 状态标签
.status-tag {
font-size: 12px;
font-weight: 500;
line-height: 18px;
border: 1px solid;
border-radius: 12px;
.status-dot {
width: 6px;
height: 6px;
background: currentcolor;
border-radius: 50%;
}
}
// 信息列表
@@ -512,12 +346,12 @@ onMounted(() => {
}
&.action-btn-data {
color: #722ed1;
border-color: #722ed1;
color: #fa8c16;
border-color: #fa8c16;
&:hover {
color: white;
background: #722ed1;
background: #fa8c16;
}
}

View File

@@ -62,9 +62,6 @@ const [Modal, modalApi] = useVbenModal({
// 加载数据
const data = modalApi.getData<IotDeviceApi.Device>();
if (!data || !data.id) {
// 新增模式:设置默认值(如果需要)
// TODO @haohao是不是 return 就好啦;不用这里 undefined 啦;
formData.value = undefined;
return;
}
// 编辑模式:加载数据

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';

View File

@@ -89,9 +89,9 @@ async function handleDownload() {
<template #file>
<div class="w-full">
<Upload
:before-upload="beforeUpload"
:max-count="1"
accept=".xls,.xlsx"
:before-upload="beforeUpload"
>
<Button type="primary"> 选择 Excel 文件</Button>
</Upload>

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IotDeviceGroupApi } from '#/api/iot/device/group';

View File

@@ -38,8 +38,10 @@ export function useFormSchema(): VbenFormSchema[] {
placeholder: '请输入分类排序',
class: 'w-full',
min: 0,
precision: 0,
},
rules: z.number().min(0, '分类排序不能为空'),
defaultValue: 0,
rules: z.number().min(0, '分类排序不能小于 0'),
},
{
fieldName: 'status',

View File

@@ -68,13 +68,6 @@ const [Modal, modalApi] = useVbenModal({
// 加载数据
const data = modalApi.getData<IotProductCategoryApi.ProductCategory>();
if (!data || !data.id) {
// 新增模式:设置默认值
// TODO @AI可以参考部门进一步简化代码通过 defaultValue 在 schema 里设置默认值
formData.value = undefined;
await formApi.setValues({
sort: 0,
status: 1,
});
return;
}
// 编辑模式:加载数据

View File

@@ -16,161 +16,6 @@ import { getSimpleProductCategoryList } from '#/api/iot/product/category';
let categoryList: IotProductCategoryApi.ProductCategory[] = [];
getSimpleProductCategoryList().then((data) => (categoryList = data));
/** 新增/修改产品的表单 */
export function useFormSchema(
formApi?: any,
generateProductKey?: () => string,
): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'id',
dependencies: {
triggerFields: [''],
show: () => false,
},
},
{
fieldName: 'productKey',
label: 'ProductKey',
component: 'Input',
componentProps: {
placeholder: '请输入 ProductKey',
},
dependencies: {
triggerFields: ['id'],
if(values) {
return !values.id;
},
},
rules: z
.string()
.min(1, 'ProductKey 不能为空')
.max(32, 'ProductKey 长度不能超过 32 个字符'),
suffix: () => {
// 创建时的 ProductKey 字段(带生成按钮)
return h(
Button,
{
type: 'default',
onClick: () => {
if (generateProductKey) {
formApi?.setFieldValue('productKey', generateProductKey());
}
},
},
{ default: () => '重新生成' },
);
},
},
{
fieldName: 'productKey',
label: 'ProductKey',
component: 'Input',
componentProps: {
placeholder: '请输入 ProductKey',
disabled: true, // 编辑时的 ProductKey 字段(禁用,无按钮)
},
dependencies: {
triggerFields: ['id'],
if(values) {
return !!values.id;
},
},
rules: z
.string()
.min(1, 'ProductKey 不能为空')
.max(32, 'ProductKey 长度不能超过 32 个字符'),
},
{
fieldName: 'name',
label: '产品名称',
component: 'Input',
componentProps: {
placeholder: '请输入产品名称',
},
rules: z
.string()
.min(1, '产品名称不能为空')
.max(64, '产品名称长度不能超过 64 个字符'),
},
{
fieldName: 'categoryId',
label: '产品分类',
component: 'ApiSelect',
componentProps: {
api: getSimpleProductCategoryList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择产品分类',
},
rules: 'required',
},
{
fieldName: 'deviceType',
label: '设备类型',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: 'required',
},
{
fieldName: 'netType',
label: '联网方式',
component: 'Select',
componentProps: {
options: getDictOptions(DICT_TYPE.IOT_NET_TYPE, 'number'),
placeholder: '请选择联网方式',
},
rules: 'required',
},
{
fieldName: 'codecType',
label: '数据格式',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.IOT_CODEC_TYPE, 'string'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: 'required',
},
{
fieldName: 'status',
label: '产品状态',
component: 'RadioGroup',
componentProps: {
options: getDictOptions(DICT_TYPE.IOT_PRODUCT_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button',
},
rules: 'required',
},
{
fieldName: 'icon',
label: '产品图标',
component: 'ImageUpload',
},
{
fieldName: 'picUrl',
label: '产品图片',
component: 'ImageUpload',
},
{
fieldName: 'description',
label: '产品描述',
component: 'Textarea',
componentProps: {
placeholder: '请输入产品描述',
rows: 3,
},
},
];
}
/** 基础表单字段(不含图标、图片、描述) */
export function useBasicFormSchema(
formApi?: any,

View File

@@ -119,8 +119,8 @@ function handleUnpublish(product: IotProductApi.Product) {
<Descriptions.Item label="ProductKey">
{{ product.productKey }}
<Button
size="small"
class="ml-2"
size="small"
@click="copyToClipboard(product.productKey || '')"
>
复制

View File

@@ -22,7 +22,7 @@ function formatDate(date?: Date | string) {
<template>
<Card title="产品信息">
<Descriptions bordered :column="3" size="small">
<Descriptions :column="3" bordered size="small">
<Descriptions.Item label="产品名称">
{{ product.name }}
</Descriptions.Item>
@@ -57,7 +57,7 @@ function formatDate(date?: Date | string) {
>
<DictTag :type="DICT_TYPE.IOT_NET_TYPE" :value="product.netType" />
</Descriptions.Item>
<Descriptions.Item label="产品描述" :span="3">
<Descriptions.Item :span="3" label="产品描述">
{{ product.description || '-' }}
</Descriptions.Item>
</Descriptions>

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IotProductCategoryApi } from '#/api/iot/product/category';
import type { IotProductApi } from '#/api/iot/product/product';
@@ -107,19 +107,19 @@ function handleCreate() {
}
/** 编辑产品 */
function handleEdit(row: any) {
function handleEdit(row: IotProductApi.Product) {
formModalApi.setData(row).open();
}
/** 删除产品 */
async function handleDelete(row: any) {
async function handleDelete(row: IotProductApi.Product) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0,
});
try {
await deleteProduct(row.id!);
message.success($t('ui.actionMessage.deleteSuccess'));
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
hideLoading();
@@ -153,7 +153,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
} as VxeTableGridOptions<IotProductApi.Product>,
});
// 包装 gridApi.query() 方法,统一列表视图和卡片视图的查询接口
/** 包装 gridApi.query() 方法,统一列表视图和卡片视图的查询接口 */
const originalQuery = gridApi.query.bind(gridApi);
gridApi.query = async (params?: Record<string, any>) => {
if (viewMode.value === 'list') {
@@ -180,9 +180,9 @@ onMounted(() => {
<div class="mb-3 flex items-center gap-3">
<Input
v-model:value="queryParams.name"
placeholder="请输入产品名称"
allow-clear
class="w-[220px]"
placeholder="请输入产品名称"
@press-enter="handleSearch"
>
<template #prefix>
@@ -191,9 +191,9 @@ onMounted(() => {
</Input>
<Input
v-model:value="queryParams.productKey"
placeholder="请输入产品标识"
allow-clear
class="w-[220px]"
placeholder="请输入产品标识"
@press-enter="handleSearch"
>
<template #prefix>
@@ -201,11 +201,11 @@ onMounted(() => {
</template>
</Input>
<Button type="primary" @click="handleSearch">
<IconifyIcon icon="ant-design:search-outlined" class="mr-1" />
<IconifyIcon class="mr-1" icon="ant-design:search-outlined" />
搜索
</Button>
<Button @click="handleReset">
<IconifyIcon icon="ant-design:reload-outlined" class="mr-1" />
<IconifyIcon class="mr-1" icon="ant-design:reload-outlined" />
重置
</Button>
</div>
@@ -214,13 +214,13 @@ onMounted(() => {
<TableAction
:actions="[
{
label: '新增产品',
label: $t('ui.actionTitle.create', ['产品']),
type: 'primary',
icon: ACTION_ICON.ADD,
onClick: handleCreate,
},
{
label: '导出',
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
onClick: handleExport,
@@ -245,12 +245,12 @@ onMounted(() => {
</div>
</Card>
<Grid table-title="产品列表" v-show="viewMode === 'list'">
<Grid v-show="viewMode === 'list'" table-title="产品列表">
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '详情',
label: $t('common.detail'),
type: 'link',
onClick: openProductDetail.bind(null, row.id!),
},
@@ -272,7 +272,7 @@ onMounted(() => {
icon: ACTION_ICON.DELETE,
disabled: row.status === ProductStatusEnum.PUBLISHED,
popConfirm: {
title: `确认删除产品 ${row.name} 吗?`,
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row),
},
},
@@ -288,16 +288,10 @@ onMounted(() => {
:category-list="categoryList"
:search-params="queryParams"
@create="handleCreate"
@edit="handleEdit"
@delete="handleDelete"
@detail="openProductDetail"
@edit="handleEdit"
@thing-model="openThingModel"
/>
</Page>
</template>
<style scoped>
/* 隐藏 VxeGrid 自带的搜索表单区域 */
:deep(.vxe-grid--form-wrapper) {
display: none !important;
}
</style>

View File

@@ -2,7 +2,6 @@
import { onMounted, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons';
import {
@@ -14,10 +13,10 @@ import {
Pagination,
Popconfirm,
Row,
Tag,
Tooltip,
} from 'ant-design-vue';
import { DictTag } from '#/components/dict-tag';
import { getProductPage } from '#/api/iot/product/product';
interface Props {
@@ -74,15 +73,6 @@ function handlePageChange(page: number, pageSize: number) {
getList();
}
/** 获取设备类型颜色 */
function getDeviceTypeColor(deviceType: number) {
const colors: Record<number, string> = {
0: 'blue',
1: 'green',
};
return colors[deviceType] || 'default';
}
defineExpose({
reload: getList,
query: () => {
@@ -137,18 +127,11 @@ onMounted(() => {
</div>
<div class="info-item">
<span class="info-label">产品类型</span>
<!-- TODO @AI这个要不完全用字典的 dict-tag -->
<Tag
:color="getDeviceTypeColor(item.deviceType)"
<DictTag
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
:value="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>
@@ -267,8 +250,7 @@ onMounted(() => {
width: 36px;
height: 36px;
color: white;
// TODO @haohao这里的紫色和下面的紫色按钮看看能不能换下。嘿嘿感觉 AI 比较喜欢用紫色,但是放现有的后台,有点突兀
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: linear-gradient(135deg, #40a9ff 0%, #1890ff 100%);
border-radius: 8px;
}
@@ -337,8 +319,8 @@ onMounted(() => {
justify-content: center;
width: 80px;
height: 80px;
color: #667eea;
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
color: #1890ff;
background: linear-gradient(135deg, #40a9ff15 0%, #1890ff15 100%);
border-radius: 8px;
}
@@ -378,12 +360,12 @@ onMounted(() => {
}
&.action-btn-model {
color: #722ed1;
border-color: #722ed1;
color: #fa8c16;
border-color: #fa8c16;
&:hover {
color: white;
background: #722ed1;
background: #fa8c16;
}
}
@@ -419,8 +401,8 @@ html.dark {
}
.product-image {
color: #8b9cff;
background: linear-gradient(135deg, #667eea25 0%, #764ba225 100%);
color: #69c0ff;
background: linear-gradient(135deg, #40a9ff25 0%, #1890ff25 100%);
}
}
}

View File

@@ -106,7 +106,8 @@ const [Modal, modalApi] = useVbenModal({
// 加载数据
const data = modalApi.getData<IotProductApi.Product>();
if (!data || !data.id) {
// 新增:设置默认值status 通过 schema 中的 defaultValue 自动设置为 0
// 新增:确保 Collapse 折叠,并设置默认值
activeKey.value = [];
await formApi.setValues({
productKey: generateProductKey(),
});
@@ -117,10 +118,19 @@ const [Modal, modalApi] = useVbenModal({
try {
formData.value = await getProduct(data.id);
await formApi.setValues(formData.value);
// 设置高级表单(如果已挂载)
await nextTick();
if (advancedFormApi.isMounted) {
await advancedFormApi.setValues(formData.value);
// 如果存在高级字段数据,自动展开 Collapse
if (
formData.value?.icon ||
formData.value?.picUrl ||
formData.value?.description
) {
activeKey.value = ['advanced'];
// 等待 Collapse 展开后表单挂载
await nextTick();
await nextTick();
if (advancedFormApi.isMounted) {
await advancedFormApi.setValues(formData.value);
}
}
} finally {
modalApi.unlock();