feat: 新增产品接入方式选择功能

- 添加 AccessSelector组件用于选择接入方式
- 实现设备接入方式的查询和展示
- 增加接入方式选择和更换功能
- 优化产品详情页面布局
This commit is contained in:
fhysy 2025-08-13 14:04:45 +08:00
parent 1e5302ee64
commit b86957758a
4 changed files with 1012 additions and 10 deletions

View File

@ -1,18 +1,19 @@
import type { GatewayVO, GatewayForm, GatewayQuery } from './model';
import type { GatewayForm, GatewayQuery, GatewayVO } from './model';
import type { ID, IDS } from '#/api/common';
import type { PageResult } from '#/api/common';
import type { ID, IDS, PageResult } from '#/api/common';
import { commonExport } from '#/api/helper';
import { requestClient } from '#/api/request';
/**
*
* @param params
* @returns
*/
*
* @param params
* @returns
*/
export function gatewayList(params?: GatewayQuery) {
return requestClient.get<PageResult<GatewayVO>>('/operations/gateway/list', { params });
return requestClient.get<PageResult<GatewayVO>>('/operations/gateway/list', {
params,
});
}
/**

View File

@ -0,0 +1,460 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import {
Button,
Card,
Col,
Empty,
Form,
FormItem,
Input,
message,
Pagination,
Row,
Select,
Space,
Tag,
} from 'ant-design-vue';
import { gatewayList } from '#/api/operations/gateway';
import { enabledOptions, networkTypeOptions } from '#/constants/dicts';
const emit = defineEmits<{
close: [];
select: [access: any];
}>();
const loading = ref(false);
const selectedGateway = ref<any>(null);
//
const searchForm = ref({
name: '',
provider: undefined,
enabled: undefined,
});
//
const pagination = reactive({
current: 1,
pageSize: 4,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total: number, range: [number, number]) =>
`${range[0]}-${range[1]}条/总共${total}`,
});
//
const gatewayListData = ref<any[]>([]);
//
const providerOptions = [
...networkTypeOptions,
{
label: '网关子设备接入',
value: 'child-device',
channel: 'child-device',
transport: 'Gateway',
description:
'需要通过网关与平台进行数据通信的设备,将作为网关子设备接入到平台。',
},
];
//
const loadGatewayList = async () => {
try {
loading.value = true;
const response = await gatewayList({
pageNum: pagination.current,
pageSize: pagination.pageSize,
...searchForm.value,
});
if (response && response.rows) {
gatewayListData.value = response.rows.map((item) => ({
...item,
selected: false,
}));
pagination.total = response.total;
}
} catch (error) {
console.error('加载网关列表失败:', error);
message.error('加载网关列表失败');
} finally {
loading.value = false;
}
};
//
const resetSearch = () => {
searchForm.value.name = '';
searchForm.value.provider = undefined;
searchForm.value.enabled = undefined;
pagination.current = 1;
loadGatewayList();
};
//
const handleSearch = () => {
pagination.current = 1;
loadGatewayList();
};
//
const handlePageChange = (page: number, pageSize: number) => {
pagination.current = page;
pagination.pageSize = pageSize;
loadGatewayList();
};
//
const selectGateway = (gateway: any) => {
//
gatewayListData.value.forEach((item) => {
item.selected = false;
});
//
gateway.selected = true;
selectedGateway.value = gateway;
};
//
const confirmSelection = () => {
if (!selectedGateway.value) {
message.warning('请先选择一个接入方式');
return;
}
emit('select', selectedGateway.value);
};
//
const handleCancel = () => {
emit('close');
};
//
const getStatusColor = (enabled: string) => {
return enabled === '1' ? 'success' : 'error';
};
//
const getStatusText = (enabled: string) => {
return enabled === '1' ? '启用' : '禁用';
};
//
const getProviderColor = (provider: string) => {
switch (provider) {
case 'child-device': {
return 'purple';
}
case 'HTTP_SERVER': {
return 'green';
}
case 'MQTT_CLIENT': {
return 'blue';
}
default: {
return 'default';
}
}
};
//
const getProviderText = (provider: string) => {
const option = providerOptions.find((item) => item.value === provider);
return option ? option.label : provider;
};
onMounted(() => {
loadGatewayList();
});
</script>
<template>
<div class="access-selector">
<!-- 搜索区域 -->
<div class="search-section">
<Form layout="inline" :model="searchForm">
<FormItem label="名称">
<Input
v-model="searchForm.name"
placeholder="请输入名称"
allow-clear
style="width: 140px"
@input="(e) => (searchForm.name = e.target.value)"
/>
</FormItem>
<FormItem label="接入方式">
<Select
v-model="searchForm.provider"
placeholder="请选择"
allow-clear
style="width: 140px"
:options="providerOptions"
@change="(value) => (searchForm.provider = value)"
/>
</FormItem>
<FormItem label="状态">
<Select
v-model="searchForm.enabled"
placeholder="请选择"
allow-clear
style="width: 140px"
:options="enabledOptions"
@change="(value) => (searchForm.enabled = value)"
/>
</FormItem>
<FormItem>
<Space>
<Button @click="resetSearch">重置</Button>
<Button type="primary" @click="handleSearch">搜索</Button>
</Space>
</FormItem>
</Form>
</div>
<!-- 网关列表 -->
<div class="gateway-list" v-loading="loading">
<Row :gutter="[16, 16]">
<Col v-for="gateway in gatewayListData" :key="gateway.id" :span="12">
<Card
class="gateway-card"
:class="[{ selected: gateway.selected }]"
hoverable
@click="selectGateway(gateway)"
>
<div class="gateway-main">
<div class="gateway-icon">
<div class="icon-placeholder">
{{ gateway.provider.toUpperCase().slice(0, 12) }}
</div>
</div>
<div class="gateway-content">
<div class="gateway-header">
<h4 class="gateway-name">{{ gateway.name }}</h4>
</div>
<div>
<Tag :color="getProviderColor(gateway.provider)">
{{ getProviderText(gateway.provider) }}
</Tag>
</div>
<Tag
class="gateway-status"
:color="getStatusColor(gateway.enabled)"
>
{{ getStatusText(gateway.enabled) }}
</Tag>
<div class="gateway-footer">
<div class="gateway-footer-item" v-if="gateway.channelName">
<div class="gateway-footer-item-label">网络组件</div>
<div class="gateway-footer-item-value">
{{ gateway.channelName }}
</div>
</div>
<div class="gateway-footer-item">
<div class="gateway-footer-item-label">消息协议</div>
<div class="gateway-footer-item-value">
{{ gateway.protocolName }}
</div>
</div>
</div>
<div class="selected-indicator" v-if="gateway.selected">
<div class="checkmark"></div>
</div>
<!-- <p class="gateway-description">{{ gateway.description }}</p> -->
</div>
</div>
</Card>
</Col>
</Row>
</div>
<!-- 空状态 -->
<div v-if="gatewayListData.length === 0" class="empty-state">
<Empty description="暂无符合条件的接入方式" />
</div>
<!-- 分页 -->
<div v-if="gatewayListData.length > 0" class="pagination-section">
<Pagination
:current="pagination.current"
:page-size="pagination.pageSize"
:total="pagination.total"
:show-size-changer="pagination.showSizeChanger"
:show-quick-jumper="pagination.showQuickJumper"
:show-total="pagination.showTotal"
@change="handlePageChange"
/>
</div>
<!-- 底部操作按钮 -->
<div class="footer-actions">
<Space>
<Button @click="handleCancel">取消</Button>
<Button type="primary" @click="confirmSelection">确定</Button>
</Space>
</div>
</div>
</template>
<style lang="scss" scoped>
.access-selector {
padding: 16px;
.search-section {
padding: 16px;
:deep(.ant-form-item) {
margin-bottom: 12px;
}
}
.add-section {
margin-bottom: 16px;
text-align: center;
}
.gateway-list {
margin-bottom: 24px;
.gateway-card {
height: 100%;
cursor: pointer;
border: 1px solid #e4e4e7;
transition: all 0.3s ease;
&:hover {
border-color: #1890ff;
box-shadow: 0 4px 12px rgb(24 144 255 / 15%);
}
&.selected {
background: #f0f8ff;
border-color: #1890ff;
box-shadow: 0 4px 12px rgb(24 144 255 / 20%);
}
.gateway-main {
position: relative;
display: flex;
align-items: center;
width: 100%;
.gateway-icon {
margin-right: 12px;
text-align: center;
.icon-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 96px;
margin: 0 auto;
font-size: 12px;
font-weight: bold;
color: white;
background: #1890ff;
border-radius: 8px;
}
}
.gateway-content {
flex: 1;
.gateway-status {
position: absolute;
top: 0;
right: 0;
}
.gateway-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
.gateway-name {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #262626;
}
}
.gateway-description {
display: -webkit-box;
margin: 0 0 12px;
overflow: hidden;
-webkit-line-clamp: 2;
font-size: 12px;
line-height: 1.5;
-webkit-box-orient: vertical;
}
.gateway-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
.gateway-footer-item {
width: 50%;
.gateway-footer-item-label {
font-size: 12px;
color: #3d3d3d;
}
.gateway-footer-item-value {
font-size: 14px;
color: #000;
}
}
}
.selected-indicator {
position: absolute;
right: 0;
bottom: 0;
.checkmark {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
font-size: 12px;
font-weight: bold;
color: white;
background: #1890ff;
border-radius: 50%;
}
}
}
}
}
}
.empty-state {
padding: 40px 0;
text-align: center;
}
.pagination-section {
margin-bottom: 24px;
text-align: center;
}
.footer-actions {
padding-top: 16px;
text-align: right;
border-top: 1px solid #f0f0f0;
}
}
</style>

View File

@ -0,0 +1,543 @@
<script setup lang="ts">
import type { ProductVO } from '#/api/device/product/model';
import { computed, onMounted, reactive, ref } from 'vue';
import { useAccess } from '@vben/access';
import { QuestionCircleOutlined } from '@ant-design/icons-vue';
import {
Badge,
Col,
Empty,
Form,
FormItem,
Input,
InputNumber,
InputPassword,
message,
Modal,
Row,
Select,
Table,
Tooltip,
} from 'ant-design-vue';
import { getPoliciesList, productUpdateById } from '#/api/device/product';
import { gatewayInfo } from '#/api/operations/gateway';
import AccessSelector from './AccessSelector.vue';
const props = defineProps<{
productInfo: ProductVO;
}>();
const emit = defineEmits<{
refresh: [];
}>();
const { hasAccessByCodes } = useAccess();
const accessModalVisible = ref(false);
const saveLoading = ref(false);
// const selectedProductType = ref('');
const selectedStorePolicy = ref('default');
const formData = reactive({});
//
const accessInfo = ref({
name: '',
description: '',
protocolName: '',
document: '',
addresses: [],
routes: [],
provider: '',
});
//
const storePolicyOptions = ref([
{ label: 'influxdb-列式', value: 'influxdb' },
// { label: '', value: 'high-performance' },
// { label: '', value: 'low-cost' },
]);
//
const accessConfigs = ref([]);
//
const tableColumns = computed(() => {
const isMQTT =
accessInfo.value.provider === 'mqtt-server-gateway' ||
accessInfo.value.provider === 'mqtt-client-gateway';
return isMQTT
? [
{ title: 'Topic', dataIndex: 'topic', key: 'topic', width: 200 },
{ title: '描述', dataIndex: 'description', key: 'description' },
{ title: '示例', dataIndex: 'example', key: 'example', width: 150 },
]
: [
{ title: '地址', dataIndex: 'address', key: 'address', width: 200 },
{ title: '描述', dataIndex: 'description', key: 'description' },
{ title: '示例', dataIndex: 'example', key: 'example', width: 150 },
];
});
// MarkdownHTML
const markdownToHtml = computed(() => {
// 使markdown-itmarkdownHTML
return accessInfo.value.document || '';
});
//
const handleSelectAccess = () => {
accessModalVisible.value = true;
};
//
const handleChangeAccess = () => {
accessModalVisible.value = true;
};
//
const handleModalSelect = (access: any) => {
console.log('access', access);
accessInfo.value = access;
accessModalVisible.value = false;
emit('refresh');
};
//
const handleAccessModalClose = () => {
accessModalVisible.value = false;
};
//
// const handleProductTypeChange = (value: string) => {
// selectedProductType.value = value;
// };
//
const getOptions = (item: any) => {
if (item?.type?.type === 'enum' && item?.type?.elements) {
return item.type.elements.map((el: any) => ({
label: el.text,
value: el.value,
}));
}
return [];
};
//
const getStream = (record: any) => {
return record.stream || '-';
};
//
const handleSave = async () => {
try {
saveLoading.value = true;
await productUpdateById(props.productInfo.id, {
id: props.productInfo.id,
provider: accessInfo.value.id,
storePolicy: selectedStorePolicy.value,
protocolConf: JSON.stringify(formData),
});
message.success('保存成功');
emit('refresh');
} catch {
message.error('保存失败');
} finally {
saveLoading.value = false;
}
};
const loadStorePolicy = async () => {
const res = await getPoliciesList();
storePolicyOptions.value = res.map((item: any) => ({
label: item.policyName,
value: item.id,
}));
};
//
const loadAccessInfo = async () => {
if (props.productInfo.provider) {
// API
const res = await gatewayInfo(props.productInfo.provider);
accessInfo.value = res;
//
// accessInfo.value = {
// name: 'MQTT',
// description: 'MQTT',
// protocolName: 'MQTT',
// document: '',
// addresses: [
// { address: 'mqtt://localhost:1883', health: 1 },
// { address: 'mqtt://192.168.1.100:1883', health: -1 },
// ],
// routes: [
// {
// topic: '/device/+/data',
// description: '',
// example: '{"temp": 25}',
// },
// {
// topic: '/device/+/command',
// description: '',
// example: '{"cmd": "restart"}',
// },
// ],
// provider: 'mqtt-server-gateway',
// };
}
};
onMounted(() => {
loadAccessInfo();
loadStorePolicy();
});
</script>
<template>
<div class="device-access">
<!-- 未配置接入方式时的空状态 -->
<div v-if="!accessInfo.id" class="empty-state">
<Empty>
<template #description>
<span v-if="hasAccessByCodes('device:product:edit')">
请先
<a-button type="link" @click="handleSelectAccess">选择</a-button>
设备接入网关用以提供设备接入能力
</span>
<span v-else>请联系管理员配置产品接入方式</span>
</template>
</Empty>
</div>
<!-- 已配置接入方式 -->
<div v-else class="access-config">
<Row :gutter="24">
<Col :span="12">
<!-- 接入方式 -->
<div class="config-section">
<div class="section-header">
<h4>接入方式</h4>
<a-button
type="primary"
size="small"
@click="handleChangeAccess"
v-access:code="['device:product:edit']"
:disabled="productInfo.deviceNum > 0"
>
更换
</a-button>
</div>
<div class="section-content">
<p class="access-name">{{ accessInfo.name }}</p>
<p class="access-desc">{{ accessInfo.description }}</p>
</div>
</div>
<!-- 消息协议 -->
<div class="config-section">
<div class="section-header">
<h4>消息协议</h4>
<Tooltip title="此配置来自于产品接入方式所选择的协议">
<QuestionCircleOutlined />
</Tooltip>
</div>
<div class="section-content">
<p>{{ accessInfo.protocolName }}</p>
<div v-if="accessInfo.document" v-html="markdownToHtml"></div>
</div>
</div>
<!-- 连接信息 -->
<div class="config-section">
<div class="section-header">
<h4>连接信息</h4>
</div>
<div class="section-content">
<div
v-if="accessInfo.addresses && accessInfo.addresses.length > 0"
>
<div
v-for="item in accessInfo.addresses"
:key="item.address"
class="address-item"
>
<Badge
:color="item.health === -1 ? 'red' : 'green'"
:text="item.address"
/>
</div>
</div>
<div v-else class="no-address">暂无连接信息</div>
</div>
</div>
<!-- 产品类型 -->
<!-- <div class="config-section" v-if="productTypes.length > 0">
<div class="section-header">
<h4>产品类型</h4>
</div>
<div class="section-content">
<Form layout="vertical">
<FormItem
label="产品类型"
:rules="[{ required: true, message: '请选择产品类型' }]"
>
<a-select
v-model:value="selectedProductType"
:options="productTypes"
placeholder="请选择产品类型"
@change="handleProductTypeChange"
/>
</FormItem>
</Form>
</div>
</div> -->
<!-- 其它接入配置 -->
<div
v-for="(config, index) in accessConfigs"
:key="index"
class="config-section"
>
<div class="section-header">
<h4>{{ config.name }}</h4>
<Tooltip title="此配置来自于产品接入方式所选择的协议">
<QuestionCircleOutlined />
</Tooltip>
</div>
<div class="section-content">
<Form layout="vertical">
<FormItem
v-for="item in config.properties"
:key="item.property"
:label="item.name"
:name="item.property"
:rules="[
{
required: !!item?.type?.expands?.required,
message: `${
item.type.type === 'enum' || 'boolean'
? '请选择'
: '请输入'
}${item.name}`,
},
]"
>
<Input
v-if="item.type.type === 'string'"
v-model:value="formData[item.property]"
placeholder="请输入"
/>
<InputPassword
v-if="item.type.type === 'password'"
v-model:value="formData[item.property]"
placeholder="请输入"
/>
<Select
v-if="
item.type.type === 'enum' || item.type.type === 'boolean'
"
v-model:value="formData[item.property]"
placeholder="请选择"
:options="getOptions(item)"
/>
<InputNumber
v-if="
['int', 'float', 'double', 'long'].includes(
item.type.type,
)
"
v-model:value="formData[item.property]"
placeholder="请输入"
/>
</FormItem>
</Form>
</div>
</div>
<!-- 存储策略 -->
<div class="config-section">
<div class="section-header">
<h4>存储策略</h4>
<Tooltip
title="若修改存储策略,需要手动做数据迁移,平台只能搜索最新存储策略中的数据"
>
<QuestionCircleOutlined />
</Tooltip>
</div>
<div class="section-content">
<Select
style="width: 100%"
v-model:value="selectedStorePolicy"
placeholder="请选择存储策略"
:options="storePolicyOptions"
/>
</div>
</div>
<!-- 保存按钮 -->
<div class="action-buttons">
<a-button
type="primary"
@click="handleSave"
:loading="saveLoading"
v-access:code="['device:product:edit']"
>
保存
</a-button>
</div>
</Col>
<!-- 右侧信息展示 -->
<Col
:span="12"
v-if="accessInfo.routes && accessInfo.routes.length > 0"
>
<div class="info-panel">
<h4>
{{
accessInfo.provider === 'mqtt-server-gateway' ||
accessInfo.provider === 'mqtt-client-gateway'
? 'Topic信息'
: 'URL信息'
}}
</h4>
<Table
:columns="tableColumns"
:data-source="accessInfo.routes"
:pagination="false"
:scroll="{ y: 500 }"
size="small"
>
<template #bodyCell="{ text, column, record }">
<template v-if="column.key === 'topic'">
<a-tooltip placement="topLeft" :title="text">
<div class="ellipsis-text">{{ text }}</div>
</a-tooltip>
</template>
<template v-if="column.key === 'stream'">
<div>{{ getStream(record) }}</div>
</template>
<template v-if="column.key === 'description'">
<a-tooltip placement="topLeft" :title="text">
<div class="ellipsis-text">{{ text }}</div>
</a-tooltip>
</template>
<template v-if="column.key === 'address'">
<a-tooltip placement="topLeft" :title="text">
<div class="ellipsis-text">{{ text }}</div>
</a-tooltip>
</template>
<template v-if="column.key === 'example'">
<a-tooltip placement="topLeft" :title="text">
<div class="ellipsis-text">{{ text }}</div>
</a-tooltip>
</template>
</template>
</Table>
</div>
</Col>
</Row>
</div>
<!-- 选择接入方式抽屉 -->
<Modal
:open="accessModalVisible"
title="选择接入方式"
width="1000px"
centered
@cancel="handleAccessModalClose"
:footer="null"
>
<AccessSelector
:product-id="productInfo.id"
@select="handleModalSelect"
@close="handleAccessModalClose"
/>
</Modal>
</div>
</template>
<style lang="scss" scoped>
.device-access {
.empty-state {
padding: 60px 0;
text-align: center;
}
.access-config {
.config-section {
padding: 16px;
margin-bottom: 24px;
border: 1px solid #f0f0f0;
border-radius: 6px;
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #262626;
}
}
.section-content {
.access-name {
margin: 0 0 8px;
font-size: 16px;
font-weight: 500;
color: #262626;
}
.access-desc {
margin: 0;
color: #8c8c8c;
}
.address-item {
margin-bottom: 8px;
}
.no-address {
color: #8c8c8c;
}
}
}
.action-buttons {
margin-top: 24px;
}
.info-panel {
h4 {
margin: 0 0 16px;
font-size: 14px;
font-weight: 600;
color: #262626;
}
.ellipsis-text {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
</style>

View File

@ -2,12 +2,10 @@
import { computed, onMounted, onUnmounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAccess } from '@vben/access';
import { Page } from '@vben/common-ui';
import { ArrowLeftOutlined } from '@ant-design/icons-vue';
import { message, Switch, TabPane, Tabs } from 'ant-design-vue';
import dayjs from 'dayjs';
import { productUpdate, productUpdateStatus } from '#/api/device/product';
import { useProductStore } from '#/store/product';