[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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "子系统列表",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: '设备状态',
|
||||
|
||||
@@ -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>
|
||||
<!-- 子系统列(F11):subsystemId=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"
|
||||
|
||||
@@ -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>
|
||||
<!-- 子系统(F11):subsystemId=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">
|
||||
|
||||
Reference in New Issue
Block a user