[F11] 设备查询页 subsystemId 筛选 + 未归属标签

- apps/web-antd/src/api/iot/device/device/index.ts
  - Device 接口加 subsystemId/subsystemName
  - 新增 BindSubsystemReqVO + bindDeviceSubsystem (PUT /iot/device/bindSubsystem)
- apps/web-antd/src/views/iot/device/device/index.vue
  - 筛选器:子系统下拉 + 未归属哨兵 -1 (→ queryParams.unassigned=true)
  - 单设备/批量绑定弹窗,批量 100 台/批
  - 行操作 + TableAction 增加"绑定子系统"按钮 (auth: iot:device:update)
- apps/web-antd/src/views/iot/device/device/data.ts
  - useGridColumns 追加"所属子系统"列 (slot: subsystem)
- apps/web-antd/src/views/iot/device/device/modules/card-view.vue
  - 卡片视图加子系统信息行 + 未归属红标签
  - Props 透传 subsystems / searchParams.subsystemId / unassigned
- apps/web-antd/src/views/iot/device/device/__tests__/device-subsystem-filter.spec.ts
  - 14 用例: 筛选参数转换 / 未归属标签条件 / 批量分批逻辑
- locales/langs/{zh-CN,en-US}/page.json: iot.device.filter.subsystem.* 12 键同步
- Known Pitfalls 落地: 评审 A2 NULL 醒目红标签 / 哨兵 -1 → unassigned=true /
  批量 100 台分批 / iot:device:update 权限 / simple-list 加载静默降级

后端 B11 API 补充契约:
- GET /iot/device/page?unassigned=true (subsystemId IS NULL 过滤)
- PUT /iot/device/bindSubsystem { deviceId, subsystemId: number | null }
- 分页响应 Device 含 subsystemId?: number | null + subsystemName?: string

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
lzh
2026-04-23 23:02:01 +08:00
parent ba459aa1d7
commit 887e51eaaa
7 changed files with 456 additions and 1 deletions

View File

@@ -25,6 +25,17 @@ export namespace IotDeviceApi {
latitude?: number; // 设备位置的纬度
longitude?: number; // 设备位置的经度
createTime?: Date; // 创建时间
/** 所属子系统编号null = 未归属,⚠️ A2 存量设备可能为 NULL */
subsystemId?: null | number;
/** 所属子系统名称(后端 join便于列表直接展示 */
subsystemName?: string;
}
/** 绑定子系统请求体(单设备) */
export interface BindSubsystemReqVO {
deviceId: number;
/** null 表示解绑(移除归属) */
subsystemId: null | number;
}
/** 设备更新分组 Request VO */
@@ -232,3 +243,12 @@ export function getUnboundSubDevicePage(params: PageParam) {
{ params },
);
}
/**
* 单设备绑定/解绑子系统
* ⚠️ [F11] B11 需要实现PUT /iot/device/bindSubsystem
* subsystemId=null 表示解绑(移除归属)
*/
export function bindDeviceSubsystem(data: IotDeviceApi.BindSubsystemReqVO) {
return requestClient.put<boolean>('/iot/device/bindSubsystem', data);
}

View File

@@ -41,6 +41,24 @@
}
},
"iot": {
"device": {
"filter": {
"subsystem": {
"placeholder": "All Subsystems",
"unassigned": "Unassigned",
"bind": "Bind Subsystem",
"bindTitle": "Bind Subsystem",
"batchBind": "Batch Bind Subsystem",
"batchBindTitle": "Batch Bind Subsystem",
"batchBindSuccess": "{0} device(s) bound successfully",
"bindSuccess": "Bound successfully",
"selectRequired": "Please select a subsystem first",
"selectDeviceTip": "Please select devices first",
"binding": "Binding...",
"batchBindHint": "{0} device(s) selected, max 100 per batch"
}
}
},
"subsystem": {
"title": "Subsystem",
"listTitle": "Subsystem List",

View File

@@ -41,6 +41,24 @@
}
},
"iot": {
"device": {
"filter": {
"subsystem": {
"placeholder": "全部子系统",
"unassigned": "未归属",
"bind": "绑定子系统",
"bindTitle": "绑定子系统",
"batchBind": "批量绑定子系统",
"batchBindTitle": "批量绑定子系统",
"batchBindSuccess": "已成功绑定 {0} 台设备",
"bindSuccess": "绑定成功",
"selectRequired": "请先选择子系统",
"selectDeviceTip": "请先选择要绑定的设备",
"binding": "正在绑定...",
"batchBindHint": "已选 {0} 台设备,每批最多 100 台"
}
}
},
"subsystem": {
"title": "子系统",
"listTitle": "子系统列表",

View File

@@ -0,0 +1,154 @@
import { describe, expect, it } from 'vitest';
/**
* F11 — 设备查询页 subsystemId 筛选 + "未归属"标签
*
* 测试覆盖:
* 1. 前端哨兵值 -1 转换为 { unassigned: true } 后端参数
* 2. 正常子系统 ID 透传为 { subsystemId: X }
* 3. undefined全部不传任何子系统相关参数
* 4. 批量绑定分批逻辑(每批 ≤ 100 台,⚠️ B11 限制)
* 5. 未归属标签渲染条件subsystemId == null/undefined → 红标签)
*/
// ─── 1. 子系统筛选参数转换 ─────────────────────────────────────────────────────
const UNASSIGNED_SENTINEL = -1;
interface SubsystemQueryParam {
subsystemId?: number;
unassigned?: boolean;
}
/**
* 将前端筛选 Select 的值转换为后端查询参数
* -1 → { unassigned: true }
* 正常 ID → { subsystemId: id }
* undefined → {}(全部)
*/
function buildSubsystemQueryParam(
filterValue: number | undefined,
): SubsystemQueryParam {
if (filterValue === UNASSIGNED_SENTINEL) {
return { unassigned: true };
}
if (filterValue !== undefined) {
return { subsystemId: filterValue };
}
return {};
}
describe('buildSubsystemQueryParam — 筛选参数转换', () => {
it('哨兵值 -1 → unassigned=true未归属筛选', () => {
const result = buildSubsystemQueryParam(-1);
expect(result).toEqual({ unassigned: true });
expect(result.subsystemId).toBeUndefined();
});
it('正常子系统 ID → subsystemId 透传', () => {
const result = buildSubsystemQueryParam(42);
expect(result).toEqual({ subsystemId: 42 });
expect(result.unassigned).toBeUndefined();
});
it('undefined全部→ 空对象,不传任何子系统参数', () => {
const result = buildSubsystemQueryParam(undefined);
expect(result).toEqual({});
expect(result.subsystemId).toBeUndefined();
expect(result.unassigned).toBeUndefined();
});
it('subsystemId=0 不应被视为未归属(防御性测试)', () => {
// 0 是合法的数字 ID不是哨兵值
const result = buildSubsystemQueryParam(0);
expect(result).toEqual({ subsystemId: 0 });
expect(result.unassigned).toBeUndefined();
});
});
// ─── 2. 未归属标签渲染条件(⚠️ A2存量 NULL 醒目展示)────────────────────────
/**
* 判断设备是否未归属subsystemId 为 null 或 undefined
* 对应模板中 v-if="row.subsystemId == null" 的判断逻辑
* 注意:模板中使用宽松 == null同时处理 null/undefined
* 此处用严格判断等价替代。
*/
function isUnassigned(subsystemId: null | number | undefined): boolean {
return subsystemId === null || subsystemId === undefined;
}
describe('isUnassigned — 未归属标签渲染条件(⚠️ A2', () => {
it('subsystemId=null → 未归属(存量 NULL 设备)', () => {
expect(isUnassigned(null)).toBe(true);
});
it('subsystemId=undefined → 未归属(字段缺失时)', () => {
expect(isUnassigned(undefined)).toBe(true);
});
it('subsystemId=1 → 已归属,不展示红标签', () => {
expect(isUnassigned(1)).toBe(false);
});
it('subsystemId=0 → 已归属0 是合法 ID', () => {
expect(isUnassigned(0)).toBe(false);
});
});
// ─── 3. 批量绑定分批逻辑(⚠️ B11 限制 100 台/批)──────────────────────────────
const BATCH_SIZE = 100;
function splitIntoBatches<T>(items: T[], batchSize: number): T[][] {
const batches: T[][] = [];
for (let i = 0; i < items.length; i += batchSize) {
batches.push(items.slice(i, i + batchSize));
}
return batches;
}
describe('splitIntoBatches — 批量绑定分批(⚠️ B11 每批 ≤ 100 台)', () => {
it('50 台 → 1 批', () => {
const ids = Array.from({ length: 50 }, (_, i) => i + 1);
const batches = splitIntoBatches(ids, BATCH_SIZE);
expect(batches).toHaveLength(1);
expect(batches[0]).toHaveLength(50);
});
it('恰好 100 台 → 1 批', () => {
const ids = Array.from({ length: 100 }, (_, i) => i + 1);
const batches = splitIntoBatches(ids, BATCH_SIZE);
expect(batches).toHaveLength(1);
expect(batches[0]).toHaveLength(100);
});
it('101 台 → 2 批100 + 1', () => {
const ids = Array.from({ length: 101 }, (_, i) => i + 1);
const batches = splitIntoBatches(ids, BATCH_SIZE);
expect(batches).toHaveLength(2);
expect(batches[0]).toHaveLength(100);
expect(batches[1]).toHaveLength(1);
});
it('250 台 → 3 批100 + 100 + 50', () => {
const ids = Array.from({ length: 250 }, (_, i) => i + 1);
const batches = splitIntoBatches(ids, BATCH_SIZE);
expect(batches).toHaveLength(3);
expect(batches[0]).toHaveLength(100);
expect(batches[1]).toHaveLength(100);
expect(batches[2]).toHaveLength(50);
});
it('空数组 → 0 批', () => {
expect(splitIntoBatches([], BATCH_SIZE)).toHaveLength(0);
});
it('分批后所有 ID 不丢失', () => {
const ids = Array.from({ length: 220 }, (_, i) => i + 1);
const batches = splitIntoBatches(ids, BATCH_SIZE);
const flattened = batches.flat();
expect(flattened).toHaveLength(220);
expect(flattened).toEqual(ids);
});
});

View File

@@ -310,6 +310,12 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
minWidth: 150,
slots: { default: 'groups' },
},
{
field: 'subsystemId',
title: '所属子系统',
minWidth: 140,
slots: { default: 'subsystem' },
},
{
field: 'state',
title: '设备状态',

View File

@@ -1,9 +1,11 @@
<script setup lang="ts">
import type { PageParam } from '@vben/request';
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 type { IotSubsystemApi } from '#/api/iot/subsystem';
import { nextTick, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
@@ -19,6 +21,7 @@ import {
Card,
Input,
message,
Modal,
Select,
Space,
Tag,
@@ -26,6 +29,7 @@ import {
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
bindDeviceSubsystem,
deleteDevice,
deleteDeviceList,
exportDeviceExcel,
@@ -33,6 +37,10 @@ import {
} from '#/api/iot/device/device';
import { getSimpleDeviceGroupList } from '#/api/iot/device/group';
import { getSimpleProductList } from '#/api/iot/product/product';
import {
batchBindSubsystem,
getSubsystemSimpleList,
} from '#/api/iot/subsystem';
import { $t } from '#/locales';
import { useGridColumns } from './data';
@@ -48,9 +56,21 @@ const route = useRoute();
const router = useRouter();
const products = ref<IotProductApi.Product[]>([]);
const deviceGroups = ref<IotDeviceGroupApi.DeviceGroup[]>([]);
const subsystems = ref<IotSubsystemApi.SubsystemSimple[]>([]);
const viewMode = ref<'card' | 'list'>('card');
const cardViewRef = ref();
const checkedIds = ref<number[]>([]);
// 单设备绑定弹窗状态
const bindModalVisible = ref(false);
const bindingRow = ref<IotDeviceApi.Device | null>(null);
const singleBindSubsystemId = ref<number | undefined>(undefined);
const bindLoading = ref(false);
// 批量绑定弹窗状态
const batchBindModalVisible = ref(false);
// 批量绑定时选择的目标子系统
const batchBindSubsystemId = ref<number | undefined>(undefined);
const batchBindLoading = ref(false);
/** 判断是否为列表视图 */
const isListView = () => viewMode.value === 'list';
@@ -70,17 +90,41 @@ const [DeviceImportFormModal, deviceImportFormModalApi] = useVbenModal({
destroyOnClose: true,
});
const queryParams = ref<Partial<PageParam>>({
const queryParams = ref<
Partial<PageParam> & { subsystemId?: number; unassigned?: boolean }
>({
deviceName: '',
nickname: '',
productId: undefined,
deviceType: undefined,
status: undefined,
groupId: undefined,
subsystemId: undefined,
unassigned: undefined,
}); // 搜索参数
/**
* 子系统筛选选项值
* -1 是前端约定的"未归属"哨兵值,不传给后端;
* 转换时改为 unassigned=true
*/
const subsystemFilterValue = ref<number | undefined>(undefined);
/** 将前端筛选值(含哨兵 -1转换为后端参数 */
function applySubsystemFilter() {
if (subsystemFilterValue.value === -1) {
// "未归属":传 unassigned=true不传 subsystemId
queryParams.value.unassigned = true;
queryParams.value.subsystemId = undefined;
} else {
queryParams.value.unassigned = undefined;
queryParams.value.subsystemId = subsystemFilterValue.value;
}
}
/** 搜索 */
function handleSearch() {
applySubsystemFilter();
if (viewMode.value === 'list') {
gridApi.formApi.setValues(queryParams.value);
}
@@ -95,6 +139,9 @@ function handleReset() {
queryParams.value.deviceType = undefined;
queryParams.value.status = undefined;
queryParams.value.groupId = undefined;
queryParams.value.subsystemId = undefined;
queryParams.value.unassigned = undefined;
subsystemFilterValue.value = undefined;
handleSearch();
}
@@ -260,12 +307,89 @@ gridApi.query = async (params?: Record<string, any>) => {
}
};
/**
* 打开单设备绑定子系统弹窗
* ⚠️ [F11/A2] 权限iot:device:update
*/
function handleBindSubsystem(row: IotDeviceApi.Device) {
bindingRow.value = row;
singleBindSubsystemId.value = undefined;
bindModalVisible.value = true;
}
/** 确认单设备绑定 */
async function confirmBindSubsystem() {
if (!singleBindSubsystemId.value) {
message.warning($t('iot.device.filter.subsystem.selectRequired'));
return;
}
bindLoading.value = true;
try {
await bindDeviceSubsystem({
deviceId: bindingRow.value!.id!,
subsystemId: singleBindSubsystemId.value,
});
message.success($t('iot.device.filter.subsystem.bindSuccess'));
bindModalVisible.value = false;
handleRefresh();
} finally {
bindLoading.value = false;
}
}
/**
* 打开批量绑定子系统弹窗
* 每批最多 100 台(⚠️ F11/Known Pitfall B11 限制)
*/
function handleBatchBindSubsystem() {
if (checkedIds.value.length === 0) {
message.warning($t('iot.device.filter.subsystem.selectDeviceTip'));
return;
}
batchBindSubsystemId.value = undefined;
batchBindModalVisible.value = true;
}
/** 确认批量绑定 */
async function confirmBatchBindSubsystem() {
if (!batchBindSubsystemId.value) {
message.warning($t('iot.device.filter.subsystem.selectRequired'));
return;
}
const BATCH_SIZE = 100;
const ids = checkedIds.value;
batchBindLoading.value = true;
try {
// 分批请求,每批最多 100 台
for (let i = 0; i < ids.length; i += BATCH_SIZE) {
await batchBindSubsystem({
deviceIds: ids.slice(i, i + BATCH_SIZE),
subsystemId: batchBindSubsystemId.value,
});
}
message.success(
$t('iot.device.filter.subsystem.batchBindSuccess', [ids.length]),
);
checkedIds.value = [];
batchBindModalVisible.value = false;
handleRefresh();
} finally {
batchBindLoading.value = false;
}
}
/** 初始化 */
onMounted(async () => {
// 获取产品列表
products.value = await getSimpleProductList();
// 获取分组列表
deviceGroups.value = await getSimpleDeviceGroupList();
// 获取子系统精简列表(⚠️ F11 一次性加载)
try {
subsystems.value = await getSubsystemSimpleList();
} catch {
// 子系统接口可能未就绪B10 未完成时),忽略错误,不影响主功能
}
// 处理 productId 参数
const { productId } = route.query;
@@ -283,6 +407,47 @@ onMounted(async () => {
<DeviceGroupFormModal @success="handleRefresh" />
<DeviceImportFormModal @success="handleRefresh" />
<!-- 单设备绑定子系统弹窗F11 -->
<Modal
v-model:open="bindModalVisible"
:title="$t('iot.device.filter.subsystem.bindTitle')"
:confirm-loading="bindLoading"
@ok="confirmBindSubsystem"
>
<div class="py-2">
<Select
v-model:value="singleBindSubsystemId"
:placeholder="$t('iot.device.filter.subsystem.placeholder')"
allow-clear
style="width: 100%"
:options="subsystems.map((s) => ({ label: s.name, value: s.id }))"
/>
</div>
</Modal>
<!-- 批量绑定子系统弹窗F11每批 100 -->
<Modal
v-model:open="batchBindModalVisible"
:title="$t('iot.device.filter.subsystem.batchBindTitle')"
:confirm-loading="batchBindLoading"
@ok="confirmBatchBindSubsystem"
>
<div class="py-2">
<p class="mb-2 text-gray-500">
{{
$t('iot.device.filter.subsystem.batchBindHint', [checkedIds.length])
}}
</p>
<Select
v-model:value="batchBindSubsystemId"
:placeholder="$t('iot.device.filter.subsystem.placeholder')"
allow-clear
style="width: 100%"
:options="subsystems.map((s) => ({ label: s.name, value: s.id }))"
/>
</div>
</Modal>
<!-- 统一搜索工具栏 -->
<Card :body-style="{ padding: '16px' }" class="mb-4">
<!-- 搜索表单 -->
@@ -360,6 +525,22 @@ onMounted(async () => {
{{ group.name }}
</Select.Option>
</Select>
<!-- 子系统筛选F11-1 = 未归属哨兵值,展示为红色标签 -->
<Select
v-model:value="subsystemFilterValue"
:placeholder="$t('iot.device.filter.subsystem.placeholder')"
allow-clear
style="width: 200px"
>
<Select.Option :value="-1">
<Tag color="red" class="!m-0">
{{ $t('iot.device.filter.subsystem.unassigned') }}
</Tag>
</Select.Option>
<Select.Option v-for="s in subsystems" :key="s.id" :value="s.id">
{{ s.name }}
</Select.Option>
</Select>
<Button type="primary" @click="handleSearch">
<IconifyIcon icon="ant-design:search-outlined" class="mr-1" />
{{ $t('common.search') }}
@@ -404,6 +585,15 @@ onMounted(async () => {
disabled: isEmpty(checkedIds),
onClick: handleAddToGroup,
},
{
label: $t('iot.device.filter.subsystem.batchBind'),
type: 'primary',
icon: 'ant-design:apartment-outlined',
auth: ['iot:device:update'],
ifShow: isListView,
disabled: isEmpty(checkedIds),
onClick: handleBatchBindSubsystem,
},
{
label: $t('ui.actionTitle.deleteBatch'),
type: 'primary',
@@ -457,6 +647,19 @@ onMounted(async () => {
</template>
<span v-else>-</span>
</template>
<!-- 子系统列F11subsystemId=null/undefined → 红色"未归属"标签(⚠️ A2 -->
<template #subsystem="{ row }">
<Tag v-if="row.subsystemId == null" color="red" class="!m-0">
{{ $t('iot.device.filter.subsystem.unassigned') }}
</Tag>
<span v-else>
{{
subsystems.find((s) => s.id === row.subsystemId)?.name ??
row.subsystemName ??
String(row.subsystemId)
}}
</span>
</template>
<template #actions="{ row }">
<TableAction
:actions="[
@@ -476,6 +679,13 @@ onMounted(async () => {
icon: ACTION_ICON.EDIT,
onClick: handleEdit.bind(null, row),
},
{
label: $t('iot.device.filter.subsystem.bind'),
type: 'link',
icon: 'ant-design:apartment-outlined',
auth: ['iot:device:update'],
onClick: handleBindSubsystem.bind(null, row),
},
{
label: $t('common.delete'),
type: 'link',
@@ -497,6 +707,7 @@ onMounted(async () => {
ref="cardViewRef"
:products="products"
:device-groups="deviceGroups"
:subsystems="subsystems"
:search-params="{
deviceName: queryParams.deviceName || '',
nickname: queryParams.nickname || '',
@@ -504,6 +715,8 @@ onMounted(async () => {
deviceType: queryParams.deviceType,
status: queryParams.status,
groupId: queryParams.groupId,
subsystemId: queryParams.subsystemId,
unassigned: queryParams.unassigned,
}"
@create="handleCreate"
@edit="handleEdit"

View File

@@ -2,6 +2,7 @@
import type { PageParam } from '@vben/request';
import type { IotDeviceApi } from '#/api/iot/device/device';
import type { IotSubsystemApi } from '#/api/iot/subsystem';
import { onMounted, ref } from 'vue';
@@ -17,6 +18,7 @@ import {
Pagination,
Popconfirm,
Row,
Tag,
Tooltip,
} from 'ant-design-vue';
@@ -26,6 +28,7 @@ import { DictTag } from '#/components/dict-tag';
interface Props {
products: any[];
deviceGroups: any[];
subsystems?: IotSubsystemApi.SubsystemSimple[];
searchParams?: {
deviceName: string;
deviceType?: number;
@@ -33,6 +36,10 @@ interface Props {
nickname: string;
productId?: number;
status?: number;
/** 子系统 ID 筛选undefined = 全部) */
subsystemId?: number;
/** true = 仅展示未归属设备(⚠️ A2 */
unassigned?: boolean;
};
}
@@ -165,6 +172,25 @@ onMounted(() => {
</span>
</Tooltip>
</div>
<!-- 子系统F11subsystemId=null 红色"未归属"标签 A2 -->
<div class="info-item">
<span class="info-label">子系统</span>
<Tag
v-if="item.subsystemId == null"
color="red"
class="info-tag m-0"
>
未归属
</Tag>
<span v-else class="info-value">
{{
props.subsystems?.find((s) => s.id === item.subsystemId)
?.name ??
item.subsystemName ??
String(item.subsystemId)
}}
</span>
</div>
</div>
<!-- 设备图片 -->
<div class="device-image">