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 { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table'; 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 { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form'; import { z } from '#/adapter/form';
@@ -68,7 +68,7 @@ export function useFormSchema(): VbenFormSchema[] {
}, },
dependencies: { dependencies: {
triggerFields: ['deviceType'], 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: { dependencies: {
triggerFields: ['locationType'], triggerFields: ['locationType'],
show: (values) => values.locationType === 3, // MANUAL show: (values) => values.locationType === LocationTypeEnum.MANUAL,
}, },
}, },
{ {
fieldName: 'latitude', fieldName: 'latitude',
label: '设备度', label: '设备度',
component: 'InputNumber', component: 'InputNumber',
componentProps: { componentProps: {
placeholder: '请输入设备度', placeholder: '请输入设备度',
class: 'w-full', class: 'w-full',
}, },
dependencies: { dependencies: {
triggerFields: ['locationType'], 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 { IotDeviceApi } from '#/api/iot/device/device';
import type { IotProductApi } from '#/api/iot/product/product'; import type { IotProductApi } from '#/api/iot/product/product';
import type { ThingModelData } from '#/api/iot/thingmodel'; import type { ThingModelData } from '#/api/iot/thingmodel';
@@ -89,9 +89,9 @@ onMounted(async () => {
<template> <template>
<Page> <Page>
<DeviceDetailsHeader <DeviceDetailsHeader
:device="device"
:loading="loading" :loading="loading"
:product="product" :product="product"
:device="device"
@refresh="() => getDeviceData(id)" @refresh="() => getDeviceData(id)"
/> />
@@ -99,8 +99,8 @@ onMounted(async () => {
<Tabs.TabPane key="info" tab="设备信息"> <Tabs.TabPane key="info" tab="设备信息">
<DeviceDetailsInfo <DeviceDetailsInfo
v-if="activeTab === 'info'" v-if="activeTab === 'info'"
:product="product"
:device="device" :device="device"
:product="product"
/> />
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane key="model" tab="物模型数据"> <Tabs.TabPane key="model" tab="物模型数据">
@@ -129,8 +129,8 @@ onMounted(async () => {
<Tabs.TabPane key="simulator" tab="模拟设备"> <Tabs.TabPane key="simulator" tab="模拟设备">
<DeviceDetailsSimulator <DeviceDetailsSimulator
v-if="activeTab === 'simulator'" v-if="activeTab === 'simulator'"
:product="product"
:device="device" :device="device"
:product="product"
:thing-model-list="thingModelList" :thing-model-list="thingModelList"
/> />
</Tabs.TabPane> </Tabs.TabPane>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
// TODO @haohaoproduct 的 card-view 的意见,这里看看要不要也改改下。
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { DeviceStateEnum, DICT_TYPE } from '@vben/constants'; import { DICT_TYPE } from '@vben/constants';
import { getDictLabel, getDictObj } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons'; import { IconifyIcon } from '@vben/icons';
import { isValidColor, TinyColor } from '@vben/utils';
import { import {
Button, Button,
@@ -15,10 +12,10 @@ import {
Pagination, Pagination,
Popconfirm, Popconfirm,
Row, Row,
Tag,
Tooltip, Tooltip,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { DictTag } from '#/components/dict-tag';
import { getDevicePage } from '#/api/iot/device/device'; import { getDevicePage } from '#/api/iot/device/device';
interface Props { interface Props {
@@ -53,94 +50,6 @@ const queryParams = ref({
pageSize: 12, 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) { function getProductName(productId: number) {
const product = props.products.find((p: any) => p.id === productId); const product = props.products.find((p: any) => p.id === productId);
@@ -169,54 +78,6 @@ function handlePageChange(page: number, pageSize: number) {
getList(); 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({ defineExpose({
reload: getList, reload: getList,
search: () => { search: () => {
@@ -260,17 +121,11 @@ onMounted(() => {
<div class="ml-3 min-w-0 flex-1"> <div class="ml-3 min-w-0 flex-1">
<div class="device-title">{{ item.deviceName }}</div> <div class="device-title">{{ item.deviceName }}</div>
</div> </div>
<div <DictTag
class="status-badge" :type="DICT_TYPE.IOT_DEVICE_STATE"
:style="{ :value="item.state"
color: getStatusInfo(item.state).color, class="status-tag"
backgroundColor: getStatusInfo(item.state).bgColor, />
borderColor: getStatusInfo(item.state).borderColor,
}"
>
<span class="status-dot"></span>
{{ getStatusInfo(item.state).text }}
</div>
</div> </div>
<!-- 内容区域 --> <!-- 内容区域 -->
<div class="mb-3"> <div class="mb-3">
@@ -291,17 +146,11 @@ onMounted(() => {
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="info-label">设备类型</span> <span class="info-label">设备类型</span>
<Tag <DictTag
:color="getDeviceTypeColor(item.deviceType)" :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
:value="item.deviceType"
class="info-tag m-0" class="info-tag m-0"
> />
{{
getDictLabel(
DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE,
item.deviceType,
)
}}
</Tag>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="info-label">Deviceid</span> <span class="info-label">Deviceid</span>
@@ -395,7 +244,7 @@ onMounted(() => {
width: 36px; width: 36px;
height: 36px; height: 36px;
color: white; color: white;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #40a9ff 0%, #1890ff 100%);
border-radius: 8px; border-radius: 8px;
} }
@@ -409,24 +258,9 @@ onMounted(() => {
white-space: nowrap; white-space: nowrap;
} }
// 状态徽章 // 状态标签
.status-badge { .status-tag {
display: flex;
gap: 4px;
align-items: center;
padding: 2px 10px;
font-size: 12px; 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 { &.action-btn-data {
color: #722ed1; color: #fa8c16;
border-color: #722ed1; border-color: #fa8c16;
&:hover { &:hover {
color: white; color: white;
background: #722ed1; background: #fa8c16;
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,161 +16,6 @@ import { getSimpleProductCategoryList } from '#/api/iot/product/category';
let categoryList: IotProductCategoryApi.ProductCategory[] = []; let categoryList: IotProductCategoryApi.ProductCategory[] = [];
getSimpleProductCategoryList().then((data) => (categoryList = data)); 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( export function useBasicFormSchema(
formApi?: any, formApi?: any,

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
<script setup lang="ts"> <script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table'; import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IotProductCategoryApi } from '#/api/iot/product/category'; import type { IotProductCategoryApi } from '#/api/iot/product/category';
import type { IotProductApi } from '#/api/iot/product/product'; 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(); formModalApi.setData(row).open();
} }
/** 删除产品 */ /** 删除产品 */
async function handleDelete(row: any) { async function handleDelete(row: IotProductApi.Product) {
const hideLoading = message.loading({ const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]), content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0, duration: 0,
}); });
try { try {
await deleteProduct(row.id!); await deleteProduct(row.id!);
message.success($t('ui.actionMessage.deleteSuccess')); message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh(); handleRefresh();
} finally { } finally {
hideLoading(); hideLoading();
@@ -153,7 +153,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
} as VxeTableGridOptions<IotProductApi.Product>, } as VxeTableGridOptions<IotProductApi.Product>,
}); });
// 包装 gridApi.query() 方法,统一列表视图和卡片视图的查询接口 /** 包装 gridApi.query() 方法,统一列表视图和卡片视图的查询接口 */
const originalQuery = gridApi.query.bind(gridApi); const originalQuery = gridApi.query.bind(gridApi);
gridApi.query = async (params?: Record<string, any>) => { gridApi.query = async (params?: Record<string, any>) => {
if (viewMode.value === 'list') { if (viewMode.value === 'list') {
@@ -180,9 +180,9 @@ onMounted(() => {
<div class="mb-3 flex items-center gap-3"> <div class="mb-3 flex items-center gap-3">
<Input <Input
v-model:value="queryParams.name" v-model:value="queryParams.name"
placeholder="请输入产品名称"
allow-clear allow-clear
class="w-[220px]" class="w-[220px]"
placeholder="请输入产品名称"
@press-enter="handleSearch" @press-enter="handleSearch"
> >
<template #prefix> <template #prefix>
@@ -191,9 +191,9 @@ onMounted(() => {
</Input> </Input>
<Input <Input
v-model:value="queryParams.productKey" v-model:value="queryParams.productKey"
placeholder="请输入产品标识"
allow-clear allow-clear
class="w-[220px]" class="w-[220px]"
placeholder="请输入产品标识"
@press-enter="handleSearch" @press-enter="handleSearch"
> >
<template #prefix> <template #prefix>
@@ -201,11 +201,11 @@ onMounted(() => {
</template> </template>
</Input> </Input>
<Button type="primary" @click="handleSearch"> <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>
<Button @click="handleReset"> <Button @click="handleReset">
<IconifyIcon icon="ant-design:reload-outlined" class="mr-1" /> <IconifyIcon class="mr-1" icon="ant-design:reload-outlined" />
重置 重置
</Button> </Button>
</div> </div>
@@ -214,13 +214,13 @@ onMounted(() => {
<TableAction <TableAction
:actions="[ :actions="[
{ {
label: '新增产品', label: $t('ui.actionTitle.create', ['产品']),
type: 'primary', type: 'primary',
icon: ACTION_ICON.ADD, icon: ACTION_ICON.ADD,
onClick: handleCreate, onClick: handleCreate,
}, },
{ {
label: '导出', label: $t('ui.actionTitle.export'),
type: 'primary', type: 'primary',
icon: ACTION_ICON.DOWNLOAD, icon: ACTION_ICON.DOWNLOAD,
onClick: handleExport, onClick: handleExport,
@@ -245,12 +245,12 @@ onMounted(() => {
</div> </div>
</Card> </Card>
<Grid table-title="产品列表" v-show="viewMode === 'list'"> <Grid v-show="viewMode === 'list'" table-title="产品列表">
<template #actions="{ row }"> <template #actions="{ row }">
<TableAction <TableAction
:actions="[ :actions="[
{ {
label: '详情', label: $t('common.detail'),
type: 'link', type: 'link',
onClick: openProductDetail.bind(null, row.id!), onClick: openProductDetail.bind(null, row.id!),
}, },
@@ -272,7 +272,7 @@ onMounted(() => {
icon: ACTION_ICON.DELETE, icon: ACTION_ICON.DELETE,
disabled: row.status === ProductStatusEnum.PUBLISHED, disabled: row.status === ProductStatusEnum.PUBLISHED,
popConfirm: { popConfirm: {
title: `确认删除产品 ${row.name} 吗?`, title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row), confirm: handleDelete.bind(null, row),
}, },
}, },
@@ -288,16 +288,10 @@ onMounted(() => {
:category-list="categoryList" :category-list="categoryList"
:search-params="queryParams" :search-params="queryParams"
@create="handleCreate" @create="handleCreate"
@edit="handleEdit"
@delete="handleDelete" @delete="handleDelete"
@detail="openProductDetail" @detail="openProductDetail"
@edit="handleEdit"
@thing-model="openThingModel" @thing-model="openThingModel"
/> />
</Page> </Page>
</template> </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 { onMounted, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants'; import { DICT_TYPE } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { IconifyIcon } from '@vben/icons'; import { IconifyIcon } from '@vben/icons';
import { import {
@@ -14,10 +13,10 @@ import {
Pagination, Pagination,
Popconfirm, Popconfirm,
Row, Row,
Tag,
Tooltip, Tooltip,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { DictTag } from '#/components/dict-tag';
import { getProductPage } from '#/api/iot/product/product'; import { getProductPage } from '#/api/iot/product/product';
interface Props { interface Props {
@@ -74,15 +73,6 @@ function handlePageChange(page: number, pageSize: number) {
getList(); getList();
} }
/** 获取设备类型颜色 */
function getDeviceTypeColor(deviceType: number) {
const colors: Record<number, string> = {
0: 'blue',
1: 'green',
};
return colors[deviceType] || 'default';
}
defineExpose({ defineExpose({
reload: getList, reload: getList,
query: () => { query: () => {
@@ -137,18 +127,11 @@ onMounted(() => {
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="info-label">产品类型</span> <span class="info-label">产品类型</span>
<!-- TODO @AI这个要不完全用字典的 dict-tag --> <DictTag
<Tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
:color="getDeviceTypeColor(item.deviceType)" :value="item.deviceType"
class="info-tag m-0" class="info-tag m-0"
> />
{{
getDictLabel(
DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE,
item.deviceType,
)
}}
</Tag>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="info-label">产品标识</span> <span class="info-label">产品标识</span>
@@ -267,8 +250,7 @@ onMounted(() => {
width: 36px; width: 36px;
height: 36px; height: 36px;
color: white; color: white;
// TODO @haohao这里的紫色和下面的紫色按钮看看能不能换下。嘿嘿感觉 AI 比较喜欢用紫色,但是放现有的后台,有点突兀 background: linear-gradient(135deg, #40a9ff 0%, #1890ff 100%);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px; border-radius: 8px;
} }
@@ -337,8 +319,8 @@ onMounted(() => {
justify-content: center; justify-content: center;
width: 80px; width: 80px;
height: 80px; height: 80px;
color: #667eea; color: #1890ff;
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%); background: linear-gradient(135deg, #40a9ff15 0%, #1890ff15 100%);
border-radius: 8px; border-radius: 8px;
} }
@@ -378,12 +360,12 @@ onMounted(() => {
} }
&.action-btn-model { &.action-btn-model {
color: #722ed1; color: #fa8c16;
border-color: #722ed1; border-color: #fa8c16;
&:hover { &:hover {
color: white; color: white;
background: #722ed1; background: #fa8c16;
} }
} }
@@ -419,8 +401,8 @@ html.dark {
} }
.product-image { .product-image {
color: #8b9cff; color: #69c0ff;
background: linear-gradient(135deg, #667eea25 0%, #764ba225 100%); background: linear-gradient(135deg, #40a9ff25 0%, #1890ff25 100%);
} }
} }
} }

View File

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