feat: 新增平台设备管理功能、调整设备管理、运维概览列表查询为倒序,添加产品选择组件

- 新增平台设备分配相关API接口与数据模型
- 实现设备选择弹窗组件,支持多选和查询过滤
- 添加产品选择表格组件,支持单选/多选模式切换
- 在设备详情页增加所属平台信息展示与跳转功能
- 优化设备列表页,增加所属平台字段显示
- 更新多个列表页面默认排序为按创建时间倒序
- 修复CronPickerModal组件标签名错误问题
- 移除个人设置页面中的账号绑定模块注释
- 优化产品详情页设备数量链接样式与交互
- 新增平台管理相关API接口与数据模型定义
This commit is contained in:
fhysy 2025-09-30 16:10:38 +08:00
parent 8727d91415
commit 2ce63301fe
25 changed files with 2363 additions and 27 deletions

View File

@ -0,0 +1,87 @@
import type { AppDeviceForm, AppDeviceQuery, AppDeviceVO } from './model';
import type { ID, IDS, PageResult } from '#/api/common';
import { commonExport } from '#/api/helper';
import { requestClient } from '#/api/request';
/**
*
* @param params
* @returns
*/
export function appDeviceList(params?: AppDeviceQuery) {
return requestClient.get<PageResult<AppDeviceVO>>(
'/application/appDevice/list',
{ params },
);
}
/**
*
* @param params
* @returns
*/
export function appDeviceExport(params?: AppDeviceQuery) {
return commonExport('/application/appDevice/export', params ?? {});
}
/**
*
* @param id id
* @returns
*/
export function appDeviceInfo(id: ID) {
return requestClient.get<AppDeviceVO>(`/application/appDevice/${id}`);
}
/**
*
* @param data
* @returns void
*/
export function appDeviceAdd(data: AppDeviceForm) {
return requestClient.postWithMsg<void>('/application/appDevice', data);
}
/**
*
* @param data
* @returns void
*/
export function appDeviceUpdate(data: AppDeviceForm) {
return requestClient.putWithMsg<void>('/application/appDevice', data);
}
/**
*
* @param id id
* @returns void
*/
export function appDeviceRemove(id: ID | IDS) {
return requestClient.deleteWithMsg<void>(`/application/appDevice/${id}`);
}
/**
*
* @param params
* @returns
*/
export function queryUnBindDevice(params?: AppDeviceQuery) {
return requestClient.get<PageResult<AppDeviceVO>>(
'/application/appDevice/queryUnBindDevice',
{ params },
);
}
/**
*
* @param params
* @returns
*/
export function queryBindDevice(params?: AppDeviceQuery) {
return requestClient.get<PageResult<AppDeviceVO>>(
'/application/appDevice/queryBindDevice',
{ params },
);
}

View File

@ -0,0 +1,82 @@
import type { BaseEntity, PageQuery } from '#/api/common';
export interface AppDeviceVO {
/**
*
*/
id: number | string;
/**
*
*/
deviceKey: string;
/**
*
*/
productKey: string;
/**
*
*/
applicationCode: string;
/**
*
*/
allotTime: string;
}
export interface AppDeviceForm extends BaseEntity {
/**
*
*/
id?: number | string;
/**
*
*/
deviceKey?: string;
/**
*
*/
productKey?: string;
/**
*
*/
applicationCode?: string;
/**
*
*/
allotTime?: string;
}
export interface AppDeviceQuery extends PageQuery {
/**
*
*/
deviceKey?: string;
/**
*
*/
productKey?: string;
/**
*
*/
applicationCode?: string;
/**
*
*/
allotTime?: string;
/**
*
*/
params?: any;
}

View File

@ -0,0 +1,63 @@
import type { PlatformForm, PlatformQuery, PlatformVO } from './model';
import type { ID, IDS, PageResult } from '#/api/common';
import { commonExport } from '#/api/helper';
import { requestClient } from '#/api/request';
/**
*
* @param params
* @returns
*/
export function platformList(params?: PlatformQuery) {
return requestClient.get<PageResult<PlatformVO>>(
'/application/platform/list',
{ params },
);
}
/**
*
* @param params
* @returns
*/
export function platformExport(params?: PlatformQuery) {
return commonExport('/application/platform/export', params ?? {});
}
/**
*
* @param id id
* @returns
*/
export function platformInfo(id: ID) {
return requestClient.get<PlatformVO>(`/application/platform/${id}`);
}
/**
*
* @param data
* @returns void
*/
export function platformAdd(data: PlatformForm) {
return requestClient.postWithMsg<void>('/application/platform', data);
}
/**
*
* @param data
* @returns void
*/
export function platformUpdate(data: PlatformForm) {
return requestClient.putWithMsg<void>('/application/platform', data);
}
/**
*
* @param id id
* @returns void
*/
export function platformRemove(id: ID | IDS) {
return requestClient.deleteWithMsg<void>(`/application/platform/${id}`);
}

View File

@ -0,0 +1,122 @@
import type { BaseEntity, PageQuery } from '#/api/common';
export interface PlatformVO {
/**
*
*/
id: number | string;
/**
*
*/
applicationName: string;
/**
*
*/
applicationCode: string;
/**
*
*/
contactUser: string;
/**
*
*/
contactPhone: string;
/**
*
*/
applicationAdmin: string;
/**
*
*/
applicationPassword: string;
/**
*
*/
remark: string;
}
export interface PlatformForm extends BaseEntity {
/**
*
*/
id?: number | string;
/**
*
*/
applicationName?: string;
/**
*
*/
applicationCode?: string;
/**
*
*/
contactUser?: string;
/**
*
*/
contactPhone?: string;
/**
*
*/
applicationAdmin?: string;
/**
*
*/
applicationPassword?: string;
/**
*
*/
remark?: string;
}
export interface PlatformQuery extends PageQuery {
/**
*
*/
applicationName?: string;
/**
*
*/
applicationCode?: string;
/**
*
*/
contactUser?: string;
/**
*
*/
contactPhone?: string;
/**
*
*/
applicationAdmin?: string;
/**
*
*/
applicationPassword?: string;
/**
*
*/
params?: any;
}

View File

@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Modal } from 'ant-design-vue';
import { EasyCronInner } from 'shiyzhangcron';
import 'shiyzhangcron/dist/style.css';
@ -45,7 +44,7 @@ const onCronChange = (v: string) => {
</script>
<template>
<Modal
<Modalng1
:open="isVisible"
:title="title || 'Cron 配置'"
width="860px"
@ -61,5 +60,5 @@ const onCronChange = (v: string) => {
<div class="mt-4 flex items-center justify-between">
<div>当前选择: {{ innerValue }}</div>
</div>
</Modal>
</Modalng1>
</template>

View File

@ -0,0 +1,77 @@
import type { FormSchemaGetter } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { deviceTypeOptions, enabledOptions } from '#/constants/dicts';
export const querySchema: FormSchemaGetter = () => [
{
component: 'Input',
fieldName: 'productKey',
label: '产品KEY',
},
{
component: 'Input',
fieldName: 'productName',
label: '产品名称',
},
{
component: 'TreeSelect',
fieldName: 'categoryId',
componentProps: {
fieldNames: { children: 'children', label: 'label', value: 'id' },
},
label: '产品分类',
},
{
component: 'Select',
componentProps: {
options: deviceTypeOptions,
},
fieldName: 'deviceType',
label: '设备类型',
},
{
component: 'Select',
componentProps: {
options: enabledOptions,
},
fieldName: 'enabled',
label: '启用状态',
},
];
// 需要使用i18n注意这里要改成getter形式 否则切换语言不会刷新
// export const columns: () => VxeGridProps['columns'] = () => [
export const columns: VxeGridProps['columns'] = [
{ type: 'radio', width: 60 },
// {
// title: '编号',
// field: 'id',
// },
{
title: '产品KEY',
field: 'productKey',
},
{
title: '产品名称',
field: 'productName',
},
{
title: '产品分类',
field: 'categoryName',
},
{
title: '设备类型',
field: 'deviceType',
slots: { default: 'deviceType' },
},
{
title: '启用状态',
field: 'enabled',
slots: { default: 'enabled' },
},
{
title: '描述',
field: 'description',
},
];

View File

@ -0,0 +1,236 @@
<script setup lang="ts" name="ProductSelectTable">
import type { VbenFormProps } from '@vben/common-ui';
import type { VxeGridProps } from '#/adapter/vxe-table';
import type { ProductQuery, ProductVO } from '#/api/device/product/model';
import { computed, nextTick, onMounted, watch } from 'vue';
import { Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { productList } from '#/api/device/product';
import { productCategoryTreeList } from '#/api/device/productCategory';
import { deviceTypeOptions } from '#/constants/dicts';
import { columns, querySchema } from './data';
interface Props {
// `id`
modelValue?: ProductVO[];
// true=checkboxfalse=radio
multiple?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
multiple: false,
});
const emit = defineEmits<{
(e: 'update:modelValue', value: ProductVO[]): void;
(e: 'change', value: ProductVO[]): void;
}>();
// key 便
const selectedKeySet = computed(
() => new Set((props.modelValue || []).map((r) => r.id)),
);
//
const formOptions: VbenFormProps = {
commonConfig: {
labelWidth: 80,
},
schema: querySchema(),
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
};
//
columns[0] = props.multiple
? { type: 'checkbox', width: 60 }
: { type: 'radio', width: 60 };
const gridOptions: VxeGridProps = {
columns,
minHeight: 300,
height: 660,
keepSource: true,
pagerConfig: {
pageSize: 10,
pageSizes: [10, 20, 50, 100],
},
rowConfig: {
keyField: 'id',
},
// / props
checkboxConfig: props.multiple
? {
highlight: true,
reserve: true,
trigger: 'row',
}
: {},
radioConfig: props.multiple
? {}
: {
highlight: true,
trigger: 'row',
},
proxyConfig: {
ajax: {
//
query: async ({ page }, formValues = {}) => {
const params: any = {
pageNum: page.currentPage,
pageSize: page.pageSize,
orderByColumn: 'createTime',
isAsc: 'desc',
...formValues,
};
return await productList(params as ProductQuery);
},
},
},
};
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
gridEvents: {
//
checkboxChange() {
if (!props.multiple) return;
const curr = tableApi.grid.getCheckboxRecords();
const reserve = tableApi.grid.getCheckboxReserveRecords();
const all = [...curr, ...reserve];
emit('update:modelValue', all as ProductVO[]);
emit('change', all as ProductVO[]);
},
//
checkboxAll() {
if (!props.multiple) return;
const curr = tableApi.grid.getCheckboxRecords();
const reserve = tableApi.grid.getCheckboxReserveRecords();
const all = [...curr, ...reserve];
emit('update:modelValue', all as ProductVO[]);
emit('change', all as ProductVO[]);
},
//
radioChange() {
if (props.multiple) return;
const record = tableApi.grid.getRadioRecord();
const list = record ? [record] : [];
emit('update:modelValue', list as ProductVO[]);
emit('change', list as ProductVO[]);
},
//
pageChange() {
//
nextTick(() => {
setTimeout(() => {
applySelectionToCurrentPage();
}, 100);
});
},
//
formSubmit() {
//
nextTick(() => {
setTimeout(() => {
applySelectionToCurrentPage();
}, 100);
});
},
},
});
async function applySelectionToCurrentPage() {
await nextTick();
const pageRows = tableApi.grid.getData();
if (!pageRows || pageRows.length === 0) {
return;
}
if (props.multiple) {
//
await tableApi.grid.clearCheckboxRow();
const needCheck = pageRows.filter((r: any) =>
selectedKeySet.value.has(r.id),
);
if (needCheck.length > 0) {
await tableApi.grid.setCheckboxRow(needCheck, true);
}
} else {
//
await tableApi.grid.clearRadioRow();
const target = pageRows.find((r: any) => selectedKeySet.value.has(r.id));
if (target) {
await tableApi.grid.setRadioRow(target);
}
}
}
//
const loadFormOptions = async () => {
try {
const categoryOptions = await productCategoryTreeList();
const placeholder = categoryOptions.length > 0 ? '请选择' : '暂无产品分类';
tableApi.formApi.updateSchema([
{
componentProps: {
treeData: categoryOptions || [],
placeholder,
},
fieldName: 'categoryId',
},
]);
} catch (error) {
console.error('加载表单选项失败:', error);
}
};
//
watch(
() => props.modelValue,
async () => {
//
await nextTick();
setTimeout(() => {
applySelectionToCurrentPage();
}, 50);
},
{ deep: true },
);
onMounted(async () => {
//
await loadFormOptions();
//
setTimeout(() => {
applySelectionToCurrentPage();
}, 200);
});
</script>
<template>
<BasicTable>
<template #toolbar-actions>
<span class="pl-[7px] text-[16px]">产品选择</span>
</template>
<template #deviceType="{ row }">
<Tag color="processing">
{{
deviceTypeOptions.find((option) => option.value === row.deviceType)
?.label
}}
</Tag>
</template>
<template #enabled="{ row }">
<Tag :color="row.enabled === '1' ? 'success' : 'error'">
{{ row.enabled === '1' ? '启用' : '禁用' }}
</Tag>
</template>
</BasicTable>
</template>

View File

@ -1,7 +1,6 @@
<script setup lang="ts">
import { TabPane, Tabs } from 'ant-design-vue';
import AccountBind from './components/account-bind.vue';
import BaseSetting from './components/base-setting.vue';
import OnlineDevice from './components/online-device.vue';
import SecureSetting from './components/secure-setting.vue';
@ -17,11 +16,11 @@ const settingList = [
key: '2',
name: '安全设置',
},
{
component: AccountBind,
key: '3',
name: '账号绑定',
},
// {
// component: AccountBind,
// key: '3',
// name: '',
// },
{
component: OnlineDevice,
key: '4',

View File

@ -0,0 +1,104 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { cloneDeep } from '@vben/utils';
import { useVbenForm } from '#/adapter/form';
import {
appDeviceAdd,
appDeviceInfo,
appDeviceUpdate,
} from '#/api/application/appDevice';
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
import { drawerSchema } from './data';
const emit = defineEmits<{ reload: [] }>();
const isUpdate = ref(false);
const title = computed(() => {
return isUpdate.value ? $t('pages.common.edit') : $t('pages.common.add');
});
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
//
formItemClass: 'col-span-2',
// label px
labelWidth: 80,
//
componentProps: {
class: 'w-full',
},
},
schema: drawerSchema(),
showDefaultActions: false,
wrapperClass: 'grid-cols-2',
});
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
{
initializedGetter: defaultFormValueGetter(formApi),
currentGetter: defaultFormValueGetter(formApi),
},
);
const [BasicDrawer, drawerApi] = useVbenDrawer({
//
class: 'w-[550px]',
fullscreenButton: false,
onBeforeClose,
onClosed: handleClosed,
onConfirm: handleConfirm,
onOpenChange: async (isOpen) => {
if (!isOpen) {
return null;
}
drawerApi.drawerLoading(true);
const { id } = drawerApi.getData() as { id?: number | string };
isUpdate.value = !!id;
if (isUpdate.value && id) {
const record = await appDeviceInfo(id);
await formApi.setValues(record);
}
await markInitialized();
drawerApi.drawerLoading(false);
},
});
async function handleConfirm() {
try {
drawerApi.lock(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
// getValuesreadonly
const data = cloneDeep(await formApi.getValues());
await (isUpdate.value ? appDeviceUpdate(data) : appDeviceAdd(data));
resetInitialized();
emit('reload');
drawerApi.close();
} catch (error) {
console.error(error);
} finally {
drawerApi.lock(false);
}
}
async function handleClosed() {
await formApi.resetForm();
resetInitialized();
}
</script>
<template>
<BasicDrawer :title="title">
<BasicForm />
</BasicDrawer>
</template>

View File

@ -0,0 +1,125 @@
import type { FormSchemaGetter } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
export const querySchema: FormSchemaGetter = () => [
{
component: 'Input',
fieldName: 'deviceKey',
label: '设备KEY',
},
{
component: 'Input',
fieldName: 'deviceName',
label: '设备名称',
},
{
component: 'Select',
fieldName: 'productKey',
label: '所属产品',
},
// {
// component: 'Select',
// componentProps: {
// options: deviceStateOptions,
// },
// fieldName: 'deviceState',
// label: '设备状态',
// },
// {
// component: 'Select',
// componentProps: {
// options: deviceTypeOptions,
// },
// fieldName: 'deviceType',
// label: '设备类型',
// },
];
// 需要使用i18n注意这里要改成getter形式 否则切换语言不会刷新
// export const columns: () => VxeGridProps['columns'] = () => [
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 },
// {
// title: '编号',
// field: 'id',
// },
{
title: '设备KEY',
field: 'deviceKey',
},
{
title: '设备名称',
field: 'deviceName',
},
{
title: '所属产品',
field: 'productKey',
slots: { default: 'productKey' },
},
{
title: '设备类型',
field: 'deviceType',
slots: { default: 'deviceType' },
},
{
title: '设备状态',
field: 'deviceState',
slots: { default: 'deviceState' },
},
{
title: '描述',
field: 'description',
},
// {
// field: 'action',
// fixed: 'right',
// slots: { default: 'action' },
// title: '操作',
// width: 180,
// },
];
export const drawerSchema: FormSchemaGetter = () => [
{
label: '编号',
fieldName: 'id',
component: 'Input',
dependencies: {
show: () => false,
triggerFields: [''],
},
},
{
label: '设备KEY',
fieldName: 'deviceKey',
component: 'Input',
rules: 'required',
},
{
label: '设备名称',
fieldName: 'deviceName',
component: 'Input',
rules: 'required',
},
{
label: '所属产品',
fieldName: 'productId',
component: 'Select',
componentProps: {},
rules: 'selectRequired',
},
{
label: '设备图片',
fieldName: 'imgId',
component: 'ImageUpload',
componentProps: {
// accept: 'image/*', // 可选拓展名或者mime类型 ,拼接
// maxCount: 1, // 最大上传文件数 默认为1 为1会绑定为string而非string[]类型
},
},
{
label: '描述',
fieldName: 'description',
component: 'Textarea',
},
];

View File

@ -0,0 +1,230 @@
<script setup lang="ts">
import type { VbenFormProps } from '@vben/common-ui';
import type { VxeGridProps } from '#/adapter/vxe-table';
import type { DeviceForm } from '#/api/device/device/model';
import { computed, ref, watch } from 'vue';
import { Modal, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { queryUnBindDevice } from '#/api/application/appDevice';
import { productList } from '#/api/device/product';
import { deviceStateOptions, deviceTypeOptions } from '#/constants/dicts';
import { columns, querySchema } from './data';
interface Props {
visible: boolean;
title?: string;
multiple?: boolean;
selectedDevices?: DeviceForm[];
}
const props = withDefaults(defineProps<Props>(), {
title: '选择设备',
multiple: true,
selectedDevices: () => [],
});
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void;
(e: 'confirm', devices: DeviceForm[]): void;
(e: 'cancel'): void;
}>();
const formOptions: VbenFormProps = {
collapsed: true,
commonConfig: {
labelWidth: 80,
componentProps: {
allowClear: true,
},
},
schema: querySchema(),
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
};
const gridOptions: VxeGridProps = {
checkboxConfig: {
highlight: true,
reserve: true,
trigger: 'row',
},
columns,
minHeight: 300,
height: 660,
keepSource: true,
pagerConfig: {
pageSize: 10,
pageSizes: [10, 20, 50, 100],
},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
return await queryUnBindDevice({
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
id: 'device-select-modal',
};
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
});
//
const productOptions = ref<{ label: string; value: string }[]>([]);
//
const loadProductOptions = async () => {
try {
const res = await productList({ pageNum: 1, pageSize: 1000, enabled: '1' });
productOptions.value = (res?.rows || []).map((item: any) => ({
label: item.productName,
value: item.productKey,
}));
//
await tableApi.formApi.updateSchema(
querySchema().map((field: any) => {
if (field.fieldName === 'productKey') {
return {
...field,
componentProps: {
...field.componentProps,
options: productOptions.value,
},
};
}
return field;
}),
);
} catch (error) {
console.error('加载产品选项失败:', error);
}
};
//
const getDeviceTypeLabel = (value?: string) => {
const match = deviceTypeOptions.find((option) => option.value === value);
return match ? match.label : '-';
};
//
const getDeviceStateMeta = (value?: string) => {
const match = deviceStateOptions.find((option) => option.value === value);
return {
label: match ? match.label : '-',
type: match ? match.type : 'default',
};
};
//
const handleConfirm = () => {
const selectedRows = tableApi.grid.getCheckboxRecords();
emit('confirm', selectedRows);
emit('update:visible', false);
};
//
const handleCancel = () => {
emit('cancel');
emit('update:visible', false);
};
//
const isVisible = computed(() => props.visible);
watch(
() => props.visible,
(visible) => {
if (visible) {
//
loadProductOptions();
setTimeout(() => {
tableApi?.reload();
}, 100);
} else {
//
tableApi.grid.clearCheckboxRow();
}
},
);
</script>
<template>
<Modal
v-model:open="isVisible"
:title="title"
width="1200px"
@ok="handleConfirm"
@cancel="handleCancel"
>
<div class="device-select-modal">
<BasicTable>
<template #deviceType="{ row }">
<Tag color="processing">{{ getDeviceTypeLabel(row.deviceType) }}</Tag>
</template>
<template #deviceState="{ row }">
<Tag :color="getDeviceStateMeta(row.deviceState).type">
{{ getDeviceStateMeta(row.deviceState).label }}
</Tag>
</template>
<template #productKey="{ row }">
<div class="flex flex-col">
<span class="font-medium">{{
productOptions.find((option) => option.value === row.productKey)
?.label
}}</span>
</div>
</template>
<!-- <template #action="{ row }">
<Space>
<a-button
type="link"
size="small"
@click="tableApi.grid.toggleCheckboxRow(row)"
>
{{
tableApi.grid.isCheckedByCheckboxRow(row) ? '取消选择' : '选择'
}}
</a-button>
</Space>
</template> -->
</BasicTable>
<!-- <div class="mt-4 flex items-center justify-between">
<div class="text-sm text-gray-500">
已选择
{{
vxeCheckboxChecked(tableApi)
? tableApi.grid.getCheckboxRecords().length
: 0
}}
个设备
</div>
<Space>
<a-button @click="handleCancel">取消</a-button>
<a-button
type="primary"
:disabled="!vxeCheckboxChecked(tableApi)"
@click="handleConfirm"
>
确定
</a-button>
</Space>
</div> -->
</div>
</Modal>
</template>
<style scoped></style>

View File

@ -0,0 +1,99 @@
import type { FormSchemaGetter } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
export const querySchema: FormSchemaGetter = () => [
{
component: 'Select',
componentProps: {
allowClear: true,
filterOption: true,
placeholder: '请选择所属平台',
showSearch: true,
},
fieldName: 'applicationCode',
label: '所属平台',
},
{
component: 'Input',
fieldName: 'deviceKey',
label: '设备码',
},
{
component: 'Input',
fieldName: 'deviceName',
label: '设备名称',
},
{
component: 'Select',
fieldName: 'productKey',
label: '所属产品',
},
];
// 需要使用i18n注意这里要改成getter形式 否则切换语言不会刷新
// export const columns: () => VxeGridProps['columns'] = () => [
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 },
// {
// title: '编号',
// field: 'id',
// },
{
title: '所属平台',
field: 'applicationName',
},
{
title: '设备码',
field: 'deviceKey',
},
{
title: '设备名称',
field: 'deviceName',
},
{
title: '所属产品',
field: 'productKey',
slots: { default: 'productKey' },
},
{
title: '分配时间',
field: 'allotTime',
},
{
field: 'action',
fixed: 'right',
slots: { default: 'action' },
title: '操作',
width: 180,
},
];
export const drawerSchema: FormSchemaGetter = () => [
{
label: '编号',
fieldName: 'id',
component: 'Input',
dependencies: {
show: () => false,
triggerFields: [''],
},
},
{
label: '设备唯一码',
fieldName: 'deviceKey',
component: 'Input',
rules: 'required',
},
{
label: '产品码',
fieldName: 'productKey',
component: 'Input',
rules: 'required',
},
{
label: '所属平台码',
fieldName: 'applicationCode',
component: 'Input',
rules: 'required',
},
];

View File

@ -0,0 +1,332 @@
<script setup lang="ts">
import type { VbenFormProps } from '@vben/common-ui';
import type { VxeGridProps } from '#/adapter/vxe-table';
import type { AppDeviceForm } from '#/api/application/appDevice/model';
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { Page, prompt, useVbenDrawer } from '@vben/common-ui';
import { getVxePopupContainer } from '@vben/utils';
import { message, Modal, Popconfirm, Select, Space } from 'ant-design-vue';
import { useVbenVxeGrid, vxeCheckboxChecked } from '#/adapter/vxe-table';
import {
appDeviceAdd,
appDeviceExport,
appDeviceRemove,
queryBindDevice,
} from '#/api/application/appDevice';
import { platformList } from '#/api/application/platform';
import { productList } from '#/api/device/product';
import { commonDownloadExcel } from '#/utils/file/download';
import appDeviceDrawer from './appDevice-drawer.vue';
import DeviceSelectModal from './components/device-select-modal.vue';
import { columns, querySchema } from './data';
const applicationOptions = ref<{ label: string; value: string }[]>([]);
const deviceSelectModelVisible = ref(false);
const route = useRoute();
const deviceBindObject = ref<any>({
applicationCode: '',
deviceKeys: [],
});
const formOptions: VbenFormProps = {
collapsed: false,
commonConfig: {
labelWidth: 80,
componentProps: {
allowClear: true,
},
},
schema: querySchema(),
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
};
const gridOptions: VxeGridProps = {
checkboxConfig: {
//
highlight: true,
//
reserve: true,
//
// trigger: 'row',
},
// 使i18ngetter
// columns: columns(),
columns,
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
return await queryBindDevice({
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
//
id: 'application-appDevice-index',
};
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
});
const [AppDeviceDrawer] = useVbenDrawer({
connectedComponent: appDeviceDrawer,
});
//
const productOptions = ref<{ label: string; value: string }[]>([]);
//
const loadProductOptions = async () => {
try {
const res = await productList({ pageNum: 1, pageSize: 1000, enabled: '1' });
productOptions.value = (res?.rows || []).map((item: any) => ({
label: item.productName,
value: item.productKey,
}));
//
await tableApi.formApi.updateSchema(
querySchema().map((field: any) => {
if (field.fieldName === 'productKey') {
return {
...field,
componentProps: {
...field.componentProps,
options: productOptions.value,
},
};
}
return field;
}),
);
} catch (error) {
console.error('加载产品选项失败:', error);
}
};
const handleAdd = () => {
deviceBindObject.value = {
applicationCode: '',
deviceKeys: [],
};
loadapplication();
prompt({
component: Select,
componentProps: {
options: applicationOptions.value,
placeholder: '请选择',
popupClassName: 'pointer-events-auto',
},
content: '请选择要分配设备的平台',
modelPropName: 'value',
})
.then((val) => {
if (val) {
deviceBindObject.value.applicationCode = val;
deviceSelectModelVisible.value = true;
} else {
message.error('请选择一个平台');
}
})
.catch(() => {});
// drawerApi.setData({});
// drawerApi.open();
};
// async function handleEdit(row: Required<AppDeviceForm>) {
// drawerApi.setData({ id: row.id });
// drawerApi.open();
// }
async function handleDelete(row: Required<AppDeviceForm>) {
await appDeviceRemove(row.id);
await tableApi.query();
}
function handleMultiDelete() {
const rows = tableApi.grid.getCheckboxRecords();
const ids = rows.map((row: Required<AppDeviceForm>) => row.id);
Modal.confirm({
title: '提示',
okType: 'danger',
content: `确认解除分配选中的${ids.length}个设备吗?`,
onOk: async () => {
await appDeviceRemove(ids);
await tableApi.query();
},
});
}
function handleDownloadExcel() {
commonDownloadExcel(
appDeviceExport,
'平台设备分配数据',
tableApi.formApi.form.values,
{
fieldMappingTime: formOptions.fieldMappingTime,
},
);
}
// const getApplicationOptions = async () => {
// const res = await platformList({ pageNum: 1, pageSize: 1000 } as any);
// const option = (res?.rows || []).map((item: any) => ({
// label: item.applicationName,
// value: item.id,
// }));
// applicationOptions.value = option;
// };
const confirmDeviceSelect = async (arr: any[]) => {
console.log('确认选择设备', arr);
if (arr.length === 0) {
message.error('请至少选择一个设备');
return;
}
deviceBindObject.value.deviceKeys = arr.map((item) => item.deviceKey);
console.log('确认选择设备id列表', deviceBindObject.value);
//
await appDeviceAdd(deviceBindObject.value);
tableApi.query();
deviceSelectModelVisible.value = false;
};
const onDeviceSelectCancel = () => {
console.log('取消选择设备');
deviceSelectModelVisible.value = false;
};
const loadapplication = async () => {
const res = await platformList({ pageNum: 1, pageSize: 1000 } as any);
const option = (res?.rows || []).map((item: any) => ({
label: item.applicationName,
value: item.applicationCode,
}));
applicationOptions.value = option;
//
await tableApi.formApi.updateSchema(
querySchema().map((field: any) => {
if (field.fieldName === 'applicationCode') {
return {
...field,
componentProps: {
...field.componentProps,
options: option,
},
};
}
return field;
}),
);
};
onMounted(() => {
loadapplication();
loadProductOptions();
const applicationCode = route.query.applicationCode;
if (applicationCode && typeof applicationCode === 'string') {
tableApi.formApi.setFieldValue('applicationCode', applicationCode);
tableApi.query();
}
});
</script>
<template>
<Page :auto-content-height="true">
<BasicTable table-title="设备分配列表">
<template #toolbar-tools>
<Space>
<a-button
v-access:code="['application:appDevice:export']"
@click="handleDownloadExcel"
>
{{ $t('pages.common.export') }}
</a-button>
<a-button
:disabled="!vxeCheckboxChecked(tableApi)"
danger
type="primary"
v-access:code="['application:appDevice:remove']"
@click="handleMultiDelete"
>
解除分配
</a-button>
<a-button
type="primary"
v-access:code="['application:appDevice:add']"
@click="handleAdd"
>
分配设备
</a-button>
</Space>
</template>
<template #applicationCode="{ row }">
<div class="flex flex-col">
<span class="font-medium">{{
applicationOptions.find(
(option) => option.value === row.applicationCode,
)?.label
}}</span>
</div>
</template>
<template #productKey="{ row }">
<div class="flex flex-col">
<span class="font-medium">{{
productOptions.find((option) => option.value === row.productKey)
?.label
}}</span>
</div>
</template>
<template #action="{ row }">
<Space>
<!-- <ghost-button
v-access:code="['application:appDevice:edit']"
@click.stop="handleEdit(row)"
>
{{ $t('pages.common.edit') }}
</ghost-button> -->
<Popconfirm
:get-popup-container="getVxePopupContainer"
placement="left"
title="确认解除分配?"
@confirm="handleDelete(row)"
>
<ghost-button
danger
v-access:code="['application:appDevice:remove']"
@click.stop=""
>
解除分配
</ghost-button>
</Popconfirm>
</Space>
</template>
</BasicTable>
<AppDeviceDrawer @reload="tableApi.query()" />
<DeviceSelectModal
:visible="deviceSelectModelVisible"
@confirm="confirmDeviceSelect"
@cancel="onDeviceSelectCancel"
/>
</Page>
</template>

View File

@ -0,0 +1,146 @@
import type { FormSchemaGetter } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { z } from '#/adapter/form';
export const querySchema: FormSchemaGetter = () => [
{
component: 'Input',
fieldName: 'applicationCode',
label: '平台代码',
},
{
component: 'Input',
fieldName: 'applicationName',
label: '平台名称',
},
{
component: 'Input',
fieldName: 'applicationAdmin',
label: '平台账号',
},
{
component: 'Input',
fieldName: 'contactPhone',
label: '手机号',
},
];
// 需要使用i18n注意这里要改成getter形式 否则切换语言不会刷新
// export const columns: () => VxeGridProps['columns'] = () => [
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 },
// {
// title: '编号',
// field: 'id',
// },
{
title: '平台代码',
field: 'applicationCode',
},
{
title: '平台名称',
field: 'applicationName',
},
{
title: '联系人',
field: 'contactUser',
},
{
title: '手机号',
field: 'contactPhone',
},
{
title: '平台账号',
field: 'applicationAdmin',
},
{
title: '备注',
field: 'remark',
},
{
field: 'action',
fixed: 'right',
slots: { default: 'action' },
title: '操作',
width: 240,
},
];
export const drawerSchema: FormSchemaGetter = () => [
{
label: '编号',
fieldName: 'id',
component: 'Input',
dependencies: {
show: () => false,
triggerFields: [''],
},
},
{
component: 'Divider',
componentProps: {
orientation: 'center',
},
fieldName: 'divider1',
hideLabel: true,
renderComponentContent: () => ({
default: () => '平台信息',
}),
},
{
label: '平台代码',
fieldName: 'applicationCode',
component: 'Input',
rules: z
.string()
.regex(/^[a-z]\w*$/i, '平台代码需字母开头,且仅包含字母、数字、下划线'),
},
{
label: '平台名称',
fieldName: 'applicationName',
component: 'Input',
rules: 'required',
},
{
label: '平台账号',
fieldName: 'applicationAdmin',
component: 'Input',
rules: 'required',
},
{
label: '平台密码',
fieldName: 'applicationPassword',
component: 'InputPassword',
rules: 'required',
},
{
component: 'Divider',
componentProps: {
orientation: 'center',
},
fieldName: 'divider2',
hideLabel: true,
renderComponentContent: () => ({
default: () => '平台联系人',
}),
},
{
label: '联系人',
fieldName: 'contactUser',
component: 'Input',
rules: 'required',
},
{
label: '手机号',
fieldName: 'contactPhone',
component: 'Input',
rules: z.string().regex(/^1[3-9]\d{9}$/, '请输入正确的手机号码'),
},
{
label: '备注',
fieldName: 'remark',
component: 'Textarea',
},
];

View File

@ -0,0 +1,218 @@
<script setup lang="ts">
import type { VbenFormProps } from '@vben/common-ui';
import type { VxeGridProps } from '#/adapter/vxe-table';
import type { PlatformForm } from '#/api/application/platform/model';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { getVxePopupContainer } from '@vben/utils';
import { message, Modal, Popconfirm, Space } from 'ant-design-vue';
import { useVbenVxeGrid, vxeCheckboxChecked } from '#/adapter/vxe-table';
import {
platformExport,
platformList,
platformRemove,
} from '#/api/application/platform';
import { commonDownloadExcel } from '#/utils/file/download';
import { columns, querySchema } from './data';
import platformDrawer from './platform-drawer.vue';
const formOptions: VbenFormProps = {
collapsed: true,
commonConfig: {
labelWidth: 80,
componentProps: {
allowClear: true,
},
},
schema: querySchema(),
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
// RangePicker /
//
// fieldMappingTime: [
// [
// 'createTime',
// ['params[beginTime]', 'params[endTime]'],
// ['YYYY-MM-DD 00:00:00', 'YYYY-MM-DD 23:59:59'],
// ],
// ],
};
const gridOptions: VxeGridProps = {
checkboxConfig: {
//
highlight: true,
//
reserve: true,
//
// trigger: 'row',
},
// 使i18ngetter
// columns: columns(),
columns,
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
return await platformList({
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
},
//
id: 'application-platform-index',
};
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
});
const [PlatformDrawer, drawerApi] = useVbenDrawer({
connectedComponent: platformDrawer,
});
function handleAdd() {
drawerApi.setData({});
drawerApi.open();
}
async function handleCopy(row: Required<PlatformForm>) {
let text = `平台名:${row.applicationName}
平台地址http://192.168.1.16:5666/
平台账号${row.applicationAdmin}
平台密码${row.applicationPassword}`;
// MQTT
if (row.appSecrets && row.appSecrets.length > 0) {
row.appSecrets.forEach((item) => {
text += `\n${item.remark}`;
text += ` ${item.secret}`;
});
}
try {
if (navigator.clipboard) {
await navigator.clipboard.writeText(text);
} else {
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.append(textarea);
textarea.select();
document.execCommand('copy');
textarea.remove();
}
message.success('复制成功');
} catch {
message.error('复制失败');
}
}
async function handleEdit(row: Required<PlatformForm>) {
drawerApi.setData({ id: row.id });
drawerApi.open();
}
async function handleDelete(row: Required<PlatformForm>) {
await platformRemove(row.id);
await tableApi.query();
}
function handleMultiDelete() {
const rows = tableApi.grid.getCheckboxRecords();
const ids = rows.map((row: Required<PlatformForm>) => row.id);
Modal.confirm({
title: '提示',
okType: 'danger',
content: `确认删除选中的${ids.length}条记录吗?`,
onOk: async () => {
await platformRemove(ids);
await tableApi.query();
},
});
}
function handleDownloadExcel() {
commonDownloadExcel(
platformExport,
'平台管理数据',
tableApi.formApi.form.values,
{
fieldMappingTime: formOptions.fieldMappingTime,
},
);
}
</script>
<template>
<Page :auto-content-height="true">
<BasicTable table-title="平台管理列表">
<template #toolbar-tools>
<Space>
<a-button
v-access:code="['application:platform:export']"
@click="handleDownloadExcel"
>
{{ $t('pages.common.export') }}
</a-button>
<a-button
:disabled="!vxeCheckboxChecked(tableApi)"
danger
type="primary"
v-access:code="['application:platform:remove']"
@click="handleMultiDelete"
>
{{ $t('pages.common.delete') }}
</a-button>
<a-button
type="primary"
v-access:code="['application:platform:add']"
@click="handleAdd"
>
{{ $t('pages.common.add') }}
</a-button>
</Space>
</template>
<template #action="{ row }">
<Space>
<ghost-button
v-access:code="['application:platform:edit']"
@click.stop="handleCopy(row)"
>
复制配置
</ghost-button>
<ghost-button
v-access:code="['application:platform:edit']"
@click.stop="handleEdit(row)"
>
{{ $t('pages.common.edit') }}
</ghost-button>
<Popconfirm
:get-popup-container="getVxePopupContainer"
placement="left"
title="确认删除?"
@confirm="handleDelete(row)"
>
<ghost-button
danger
v-access:code="['application:platform:remove']"
@click.stop=""
>
{{ $t('pages.common.delete') }}
</ghost-button>
</Popconfirm>
</Space>
</template>
</BasicTable>
<PlatformDrawer @reload="tableApi.query()" />
</Page>
</template>

View File

@ -0,0 +1,221 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { cloneDeep } from '@vben/utils';
import { Button, message, Popconfirm } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
platformAdd,
platformInfo,
platformUpdate,
} from '#/api/application/platform';
import { defaultFormValueGetter, useBeforeCloseDiff } from '#/utils/popup';
import { drawerSchema } from './data';
const emit = defineEmits<{ reload: [] }>();
const platformDetailInfo = ref({
appSecrets: [],
id: undefined,
applicationCode: '',
secret: '',
});
const isUpdate = ref(false);
const title = computed(() => {
return isUpdate.value ? $t('pages.common.edit') : $t('pages.common.add');
});
const [BasicForm, formApi] = useVbenForm({
commonConfig: {
//
formItemClass: 'col-span-2',
// label px
labelWidth: 80,
//
componentProps: {
class: 'w-full',
},
},
schema: drawerSchema(),
showDefaultActions: false,
wrapperClass: 'grid-cols-2',
});
// /
formApi.updateSchema([
{
fieldName: 'applicationCode',
componentProps: {
onChange: async () => {
const values = await formApi.getValues();
const applicationCode = values.applicationCode;
formApi.setFieldValue('applicationAdmin', `${applicationCode}Admin`);
formApi.setFieldValue(
'applicationPassword',
`${applicationCode}123456`,
);
},
},
},
]);
const { onBeforeClose, markInitialized, resetInitialized } = useBeforeCloseDiff(
{
initializedGetter: defaultFormValueGetter(formApi),
currentGetter: defaultFormValueGetter(formApi),
},
);
//
function generateRandomKey() {
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < 32; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
const upDateKey = (key: string) => {
const newKey = generateRandomKey();
formApi.setFieldValue(key, newKey);
};
//
const handleCopy = async (text: string) => {
try {
if (navigator.clipboard) {
await navigator.clipboard.writeText(text);
} else {
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.append(textarea);
textarea.select();
document.execCommand('copy');
textarea.remove();
}
message.success('复制成功');
} catch {
message.error('复制失败');
}
};
const [BasicDrawer, drawerApi] = useVbenDrawer({
//
class: 'w-[550px]',
fullscreenButton: false,
onBeforeClose,
onClosed: handleClosed,
onConfirm: handleConfirm,
onOpenChange: async (isOpen) => {
if (!isOpen) {
return null;
}
drawerApi.drawerLoading(true);
const { id } = drawerApi.getData() as { id?: number | string };
isUpdate.value = !!id;
const record = await platformInfo(id);
if (isUpdate.value && id) {
formApi.setState((prev) => {
const currentSchema = prev?.schema ?? [];
const newSchema = [];
if (currentSchema.length < 11) {
newSchema.push({
component: 'Divider',
componentProps: {
orientation: 'center',
},
fieldName: 'divider3',
hideLabel: true,
renderComponentContent: () => ({
default: () => '平台密钥',
}),
});
}
return {
schema: [...currentSchema, ...newSchema],
};
});
platformDetailInfo.value = record;
await formApi.setValues(record);
}
await markInitialized();
drawerApi.drawerLoading(false);
},
});
async function handleConfirm() {
try {
drawerApi.lock(true);
const { valid } = await formApi.validate();
if (!valid) {
return;
}
// getValuesreadonly
const data = cloneDeep(await formApi.getValues());
await (isUpdate.value ? platformUpdate(data) : platformAdd(data));
resetInitialized();
emit('reload');
drawerApi.close();
} catch (error) {
console.error(error);
} finally {
drawerApi.lock(false);
}
}
async function handleClosed() {
await formApi.resetForm();
resetInitialized();
}
</script>
<template>
<BasicDrawer :title="title" :destroy-on-close="true">
<BasicForm>
<div class="appSecret-box">
<div
class="appSecret-item mb-2 flex items-center gap-2"
v-for="item in platformDetailInfo.appSecrets"
:key="item.id"
>
<div
class="mr-2 flex flex-shrink-0 items-center justify-end text-sm font-medium leading-6 peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<span class="w-[80px]">{{ item.remark }}: </span>
<span>{{ item.secret }}</span>
</div>
<div class="flex gap-2">
<Button
type="primary"
size="small"
@click="handleCopy(item.secret)"
>
复制
</Button>
<Popconfirm
placement="right"
title="确认重置?"
@confirm="upDateKey(item.remark)"
>
<Button type="primary" size="small" @click.stop=""> 重置 </Button>
</Popconfirm>
</div>
</div>
</div>
</BasicForm>
</BasicDrawer>
</template>

View File

@ -1,15 +1,27 @@
<script setup lang="ts">
import type { ProductVO } from '#/api/device/product/model';
import { defineAsyncComponent, ref } from 'vue';
import { Modal } from 'ant-design-vue';
import 'shiyzhangcron/dist/style.css';
const CronPickerModal = defineAsyncComponent(
() => import('../../../components/CronPickerModal/index.vue'),
);
const easyCronInnerValue = ref('* * * * * ? *');
const ProductSelectTable = defineAsyncComponent(
() => import('../../../components/product-select/product-select-table.vue'),
);
const easyCronInnerValue = ref('* * * * * ? *');
const cronModalVisible = ref(false);
const productSelectModalVisible = ref(false);
//
const selectedProducts = ref<ProductVO[]>([]);
const isMultiple = ref(false);
const openCronModal = () => {
cronModalVisible.value = true;
@ -23,28 +35,135 @@ const onCronConfirm = (val: string) => {
const onCronCancel = () => {
cronModalVisible.value = false;
};
const onProductChange = (products: ProductVO[]) => {
console.log('选中的产品:', products);
};
const clearSelection = () => {
selectedProducts.value = [];
};
const toggleMultiple = () => {
isMultiple.value = !isMultiple.value;
//
selectedProducts.value = [];
};
const openProductSelectModal = () => {
productSelectModalVisible.value = true;
};
const okProductSelectModal = () => {
if (selectedProducts.value.length === 0) {
Modal.error({
title: '请选择产品',
});
} else {
console.log('已选择的产品:', selectedProducts.value);
productSelectModalVisible.value = false;
}
};
const closeProductSelectModal = () => {
productSelectModalVisible.value = false;
};
</script>
<template>
<div class="p-4">
<h1 class="mb-4 text-2xl font-bold">Test Detail Page</h1>
<div class="flex items-center gap-2" style="max-width: 640px">
<input
:value="easyCronInnerValue"
type="text"
placeholder="请输入 CRON 表达式"
class="flex-1 rounded border border-gray-300 px-3 py-1"
/>
<button
class="rounded bg-blue-600 px-3 py-1 text-white"
@click="openCronModal"
>
配置
</button>
<!-- CRON 配置区域 -->
<div class="mb-8">
<h2 class="mb-4 text-lg font-semibold">CRON 配置</h2>
<div class="flex items-center gap-2" style="max-width: 640px">
<input
:value="easyCronInnerValue"
type="text"
placeholder="请输入 CRON 表达式"
class="flex-1 rounded border border-gray-300 px-3 py-1"
/>
<button
class="rounded bg-blue-600 px-3 py-1 text-white"
@click="openCronModal"
>
配置
</button>
</div>
<p class="mt-4">当前cron值为: {{ easyCronInnerValue }}</p>
</div>
<p class="mt-4">当前cron值为: {{ easyCronInnerValue }}</p>
<!-- 产品选择区域 -->
<div class="mb-8">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold">产品选择</h2>
<div class="flex items-center gap-2">
<button
class="rounded bg-green-600 px-3 py-1 text-white"
@click="toggleMultiple"
>
{{ isMultiple ? '切换到单选' : '切换到多选' }}
</button>
<button
class="rounded bg-red-600 px-3 py-1 text-white"
@click="clearSelection"
>
清空选择
</button>
</div>
</div>
<div class="mb-4 rounded border border-gray-200 p-4">
<h3 class="mb-2 font-medium">
已选择的产品 ({{ selectedProducts.length }} ):
</h3>
<div v-if="selectedProducts.length === 0" class="text-gray-500">
暂无选择的产品
</div>
<div v-else class="space-y-2">
<div
v-for="product in selectedProducts"
:key="product.id"
class="flex items-center justify-between rounded bg-gray-50 p-2"
>
<div>
<span class="font-medium">{{ product.productName }}</span>
<span class="ml-2 text-sm text-gray-500">
({{ product.productKey }})
</span>
</div>
<div class="text-sm text-gray-500">
{{ product.categoryName }} - {{ product.deviceType }}
</div>
</div>
</div>
<button
class="mt-2 w-full rounded bg-blue-600 px-3 py-1 text-white"
@click="openProductSelectModal"
>
选择产品
</button>
</div>
<!-- 产品选择表格 -->
<Modal
title="产品选择"
:open="productSelectModalVisible"
:width="1100"
destroy-on-close
@ok="okProductSelectModal"
@cancel="closeProductSelectModal"
>
<div>
<ProductSelectTable
v-model="selectedProducts"
:multiple="isMultiple"
@change="onProductChange"
/>
</div>
</Modal>
</div>
<CronPickerModal
v-if="cronModalVisible"

View File

@ -116,6 +116,16 @@ const handleProductClick = () => {
router.push(`/device/product/detail/${currentDevice.value.productId}`);
};
const handleApplicationClick = () => {
if (!hasAccessByCodes(['application:platform:query'])) {
message.warning('暂无权限');
return;
}
router.push(
`/application/appDevice?applicationCode=${currentDevice.value.applicationCode}`,
);
};
const handleDeviceClick = () => {
router.push(`/device/device/detail/${currentDevice.value.parentId}`);
};
@ -218,6 +228,12 @@ onUnmounted(() => {
currentDevice?.parentName || '未知设备'
}}</a>
</div>
<div class="basic-item" v-if="currentDevice.applicationCode">
<span class="basic-label">所属平台:</span>
<a class="basic-value product-link" @click="handleApplicationClick">{{
currentDevice?.applicationName
}}</a>
</div>
</div>
<!-- 标签页内容 -->

View File

@ -33,6 +33,7 @@ import {
Tag,
} from 'ant-design-vue';
// import { platformList } from '#/api/application/platform';
import { deviceExport, deviceList, deviceRemove } from '#/api/device/device';
import { productList } from '#/api/device/product';
import { deviceStateOptions, deviceTypeOptions } from '#/constants/dicts';
@ -50,11 +51,13 @@ const formModel = reactive({
deviceKey: undefined as string | undefined,
deviceName: undefined as string | undefined,
productId: undefined as string | undefined,
applicationCode: undefined as string | undefined,
deviceState: undefined as string | undefined,
deviceType: undefined as string | undefined,
});
const productOptions = ref<{ label: string; value: string }[]>([]);
// const applicationOptions = ref<{ label: string; value: string }[]>([]);
// /
const loading = ref(false);
@ -83,6 +86,12 @@ const columns = [
align: 'center',
},
{ title: '设备类型', dataIndex: 'deviceType', width: 100, align: 'center' },
{
title: '所属平台',
dataIndex: 'applicationName',
width: 100,
align: 'center',
},
{ title: '设备状态', dataIndex: 'deviceState', width: 100, align: 'center' },
{ title: '描述', dataIndex: 'description', align: 'center' },
{ title: '操作', key: 'action', width: 200, fixed: 'right', align: 'center' },
@ -107,6 +116,8 @@ const fetchData = async () => {
const { rows = [], total = 0 } = (await deviceList({
pageNum: pagination.current,
pageSize: pagination.pageSize,
orderByColumn: 'createTime',
isAsc: 'desc',
...formModel,
})) as any;
dataSource.value = rows as any;
@ -125,6 +136,7 @@ const onReset = () => {
formModel.deviceKey = undefined;
formModel.deviceName = undefined;
formModel.productId = undefined;
formModel.applicationCode = undefined;
formModel.deviceType = undefined;
formModel.deviceState = undefined;
pagination.current = 1;
@ -182,6 +194,14 @@ const updateTableHeight = () => {
});
};
// const loadapplication = async () => {
// const res = await platformList({ pageNum: 1, pageSize: 1000 } as any);
// applicationOptions.value = (res?.rows || []).map((item: any) => ({
// label: item.applicationName,
// value: item.id,
// }));
// };
const loadProducts = async () => {
const res = await productList({ pageNum: 1, pageSize: 1000 } as any);
productOptions.value = (res?.rows || []).map((item: any) => ({
@ -246,6 +266,21 @@ const [DeviceDrawer, drawerApi] = useVbenDrawer({
</SelectOption>
</Select>
</FormItem>
<!-- <FormItem label="所属平台">
<Select
v-model:value="formModel.applicationCode"
allow-clear
style="min-width: 200px"
>
<SelectOption
v-for="opt in applicationOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</SelectOption>
</Select>
</FormItem> -->
<FormItem label="设备类型">
<Select
v-model:value="formModel.deviceType"

View File

@ -199,7 +199,9 @@ onUnmounted(() => {
</div>
<div class="basic-item">
<span>设备数量</span>
<a @click="jumpToDevices">{{ currentProduct.deviceCount }}</a>
<a class="basic-value product-link" @click="jumpToDevices">{{
currentProduct.deviceCount
}}</a>
</div>
</div>
@ -275,6 +277,22 @@ onUnmounted(() => {
display: flex;
gap: 24px;
align-items: center;
.basic-item {
.basic-value {
font-size: 14px;
font-weight: 500;
&.product-link {
color: hsl(var(--primary));
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
}
}
.detail-tabs {

View File

@ -106,6 +106,8 @@ const fetchData = async () => {
const { rows = [], total = 0 } = (await productList({
pageNum: pagination.current,
pageSize: pagination.pageSize,
orderByColumn: 'createTime',
isAsc: 'desc',
...formModel,
})) as any;
dataSource.value = rows as any;

View File

@ -60,6 +60,8 @@ const gridOptions: VxeGridProps = {
return await gatewayList({
pageNum: page.currentPage,
pageSize: page.pageSize,
orderByColumn: 'createTime',
isAsc: 'desc',
...formValues,
});
},

View File

@ -64,6 +64,8 @@ const gridOptions: VxeGridProps = {
return await networkList({
pageNum: page.currentPage,
pageSize: page.pageSize,
orderByColumn: 'createTime',
isAsc: 'desc',
...formValues,
});
},

View File

@ -63,6 +63,8 @@ const gridOptions: VxeGridProps = {
return await protocolList({
pageNum: page.currentPage,
pageSize: page.pageSize,
orderByColumn: 'createTime',
isAsc: 'desc',
...formValues,
});
},

View File

@ -29,8 +29,8 @@ export default defineConfig(async () => {
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
// mock代理目标地址
// target: 'http://192.168.1.17:6666',
target: 'http://192.168.1.100:6666',
target: 'http://192.168.1.17:6666',
// target: 'http://192.168.1.100:6666',
ws: true,
},
},