Vue3 + Element Plus版本iot前端迁移到vben版本
This commit is contained in:
191
apps/web-antd/src/views/iot/product/category/data.ts
Normal file
191
apps/web-antd/src/views/iot/product/category/data.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { IotProductCategoryApi } from '#/api/iot/product/category';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { handleTree } from '@vben/utils';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
import {
|
||||
deleteProductCategory,
|
||||
getProductCategoryPage,
|
||||
getSimpleProductCategoryList
|
||||
} from '#/api/iot/product/category';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
/** 新增/修改产品分类的表单 */
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'id',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '分类名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入分类名称',
|
||||
},
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, '分类名称不能为空')
|
||||
.max(64, '分类名称长度不能超过 64 个字符'),
|
||||
},
|
||||
{
|
||||
fieldName: 'parentId',
|
||||
label: '父级分类',
|
||||
component: 'ApiTreeSelect',
|
||||
componentProps: {
|
||||
api: getSimpleProductCategoryList,
|
||||
fieldNames: {
|
||||
label: 'name',
|
||||
value: 'id',
|
||||
},
|
||||
placeholder: '请选择父级分类',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'sort',
|
||||
label: '排序',
|
||||
component: 'InputNumber',
|
||||
componentProps: {
|
||||
placeholder: '请输入排序',
|
||||
class: 'w-full',
|
||||
min: 0,
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
component: 'RadioGroup',
|
||||
defaultValue: 1,
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '开启', value: 1 },
|
||||
{ label: '关闭', value: 0 },
|
||||
],
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'description',
|
||||
label: '描述',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入分类描述',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '分类名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入分类名称',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'createTime',
|
||||
label: '创建时间',
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
placeholder: ['开始日期', '结束日期'],
|
||||
allowClear: true,
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{
|
||||
type: 'seq',
|
||||
title: 'ID',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '名字',
|
||||
minWidth: 200,
|
||||
treeNode: true,
|
||||
},
|
||||
{
|
||||
field: 'sort',
|
||||
title: '排序',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.COMMON_STATUS },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
title: '描述',
|
||||
minWidth: 200,
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
width: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 删除分类 */
|
||||
export async function handleDeleteCategory(row: IotProductCategoryApi.ProductCategory, onSuccess?: () => void) {
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.name]),
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deleteProductCategory(row.id!);
|
||||
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
|
||||
onSuccess?.();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询分类列表 */
|
||||
export async function queryProductCategoryList({ page }: any, formValues: any) {
|
||||
const data = await getProductCategoryPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
// 转换为树形结构
|
||||
return {
|
||||
...data,
|
||||
list: handleTree(data.list, 'id', 'parentId'),
|
||||
};
|
||||
}
|
||||
@@ -1,28 +1,128 @@
|
||||
<script lang="ts" setup>
|
||||
import { Page } from '@vben/common-ui';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { IotProductCategoryApi } from '#/api/iot/product/category';
|
||||
|
||||
import { Button } from 'ant-design-vue';
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import {
|
||||
handleDeleteCategory,
|
||||
queryProductCategoryList,
|
||||
useGridColumns,
|
||||
useGridFormSchema
|
||||
} from './data';
|
||||
import Form from './modules/ProductCategoryForm.vue';
|
||||
|
||||
defineOptions({ name: 'IoTProductCategory' });
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: Form,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/** 刷新表格 */
|
||||
function handleRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 创建分类 */
|
||||
function handleCreate() {
|
||||
formModalApi.setData(null).open();
|
||||
}
|
||||
|
||||
/** 编辑分类 */
|
||||
function handleEdit(row: IotProductCategoryApi.ProductCategory) {
|
||||
formModalApi.setData(row).open();
|
||||
}
|
||||
|
||||
/** 删除分类 */
|
||||
async function handleDelete(row: IotProductCategoryApi.ProductCategory) {
|
||||
await handleDeleteCategory(row, handleRefresh);
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: useGridFormSchema(),
|
||||
showCollapseButton: true,
|
||||
collapsed: true,
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
pagerConfig: {
|
||||
enabled: true,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: queryProductCategoryList,
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search:true,
|
||||
},
|
||||
treeConfig: {
|
||||
parentField: 'parentId',
|
||||
rowField: 'id',
|
||||
transform: true,
|
||||
expandAll: true,
|
||||
reserve: true,
|
||||
trigger: 'default',
|
||||
iconOpen: '',
|
||||
iconClose: '',
|
||||
},
|
||||
} as VxeTableGridOptions<IotProductCategoryApi.ProductCategory>,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page>
|
||||
<Button
|
||||
danger
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
|
||||
>
|
||||
该功能支持 Vue3 + element-plus 版本!
|
||||
</Button>
|
||||
<br />
|
||||
<Button
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/iot/product/category/index"
|
||||
>
|
||||
可参考
|
||||
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/iot/product/category/index
|
||||
代码,pull request 贡献给我们!
|
||||
</Button>
|
||||
<Page auto-content-height>
|
||||
<FormModal @success="handleRefresh" />
|
||||
<Grid>
|
||||
<template #toolbar-tools>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('ui.actionTitle.create', ['分类']),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
onClick: handleCreate,
|
||||
},
|
||||
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('common.edit'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts" setup>
|
||||
import type { IotProductCategoryApi } from '#/api/iot/product/category';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import {
|
||||
createProductCategory,
|
||||
getProductCategory,
|
||||
updateProductCategory,
|
||||
} from '#/api/iot/product/category';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<IotProductCategoryApi.ProductCategory>();
|
||||
const getTitle = computed(() => {
|
||||
return formData.value?.id
|
||||
? $t('ui.actionTitle.edit', ['产品分类'])
|
||||
: $t('ui.actionTitle.create', ['产品分类']);
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
formItemClass: 'col-span-2',
|
||||
labelWidth: 80,
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
// 提交表单
|
||||
const data =
|
||||
(await formApi.getValues()) as IotProductCategoryApi.ProductCategory;
|
||||
try {
|
||||
await (formData.value?.id
|
||||
? updateProductCategory(data)
|
||||
: createProductCategory(data));
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = undefined;
|
||||
return;
|
||||
}
|
||||
// 加载数据
|
||||
let data = modalApi.getData<
|
||||
IotProductCategoryApi.ProductCategory & { parentId?: number }
|
||||
>();
|
||||
if (!data) {
|
||||
// 新增模式:设置默认值
|
||||
await formApi.setValues({
|
||||
sort: 0,
|
||||
status: 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
try {
|
||||
if (data.id) {
|
||||
// 编辑模式:加载完整数据
|
||||
data = await getProductCategory(data.id);
|
||||
} else if (data.parentId) {
|
||||
// 新增下级分类:设置父级ID
|
||||
await formApi.setValues({
|
||||
parentId: data.parentId,
|
||||
sort: 0,
|
||||
status: 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 设置到 values
|
||||
formData.value = data;
|
||||
await formApi.setValues(data);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-2/5" :title="getTitle">
|
||||
<Form class="mx-4" />
|
||||
</Modal>
|
||||
</template>
|
||||
267
apps/web-antd/src/views/iot/product/product/data.ts
Normal file
267
apps/web-antd/src/views/iot/product/product/data.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictOptions } from '@vben/hooks';
|
||||
import { downloadFileFromBlobPart } from '@vben/utils';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { z } from '#/adapter/form';
|
||||
import { getSimpleProductCategoryList } from '#/api/iot/product/category';
|
||||
import {
|
||||
deleteProduct,
|
||||
exportProduct,
|
||||
getProductPage
|
||||
} from '#/api/iot/product/product';
|
||||
|
||||
/** 新增/修改产品的表单 */
|
||||
export function useFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'id',
|
||||
dependencies: {
|
||||
triggerFields: [''],
|
||||
show: () => false,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '产品名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入产品名称',
|
||||
},
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, '产品名称不能为空')
|
||||
.max(64, '产品名称长度不能超过 64 个字符'),
|
||||
},
|
||||
{
|
||||
fieldName: 'categoryId',
|
||||
label: '产品分类',
|
||||
component: 'ApiSelect',
|
||||
componentProps: {
|
||||
api: getSimpleProductCategoryList,
|
||||
labelField: 'name',
|
||||
valueField: 'id',
|
||||
placeholder: '请选择产品分类',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'deviceType',
|
||||
label: '设备类型',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE, 'number'),
|
||||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'netType',
|
||||
label: '联网方式',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.IOT_NET_TYPE, 'number'),
|
||||
placeholder: '请选择联网方式',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'protocolType',
|
||||
label: '接入协议',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.IOT_PROTOCOL_TYPE, 'number'),
|
||||
placeholder: '请选择接入协议',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'dataFormat',
|
||||
label: '数据格式',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.IOT_DATA_FORMAT, 'number'),
|
||||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'description',
|
||||
label: '产品描述',
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: '请输入产品描述',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'validateType',
|
||||
label: '认证方式',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.IOT_VALIDATE_TYPE, 'number'),
|
||||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: '产品状态',
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
|
||||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
},
|
||||
rules: 'required',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的搜索表单 */
|
||||
export function useGridFormSchema(): VbenFormSchema[] {
|
||||
return [
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: '产品名称',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入产品名称',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'productKey',
|
||||
label: 'ProductKey',
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: '请输入产品标识',
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 列表的字段 */
|
||||
export function useGridColumns(): VxeTableGridOptions['columns'] {
|
||||
return [
|
||||
{ type: 'checkbox', width: 40 },
|
||||
{
|
||||
field: 'id',
|
||||
title: 'ID',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
field: 'productKey',
|
||||
title: 'ProductKey',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'categoryId',
|
||||
title: '品类',
|
||||
minWidth: 120,
|
||||
slots: { default: 'category' },
|
||||
},
|
||||
{
|
||||
field: 'deviceType',
|
||||
title: '设备类型',
|
||||
minWidth: 120,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'icon',
|
||||
title: '产品图标',
|
||||
width: 100,
|
||||
slots: { default: 'icon' },
|
||||
},
|
||||
{
|
||||
field: 'picUrl',
|
||||
title: '产品图片',
|
||||
width: 100,
|
||||
slots: { default: 'picUrl' },
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 180,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 加载产品分类列表 */
|
||||
export async function loadCategoryList() {
|
||||
return await getSimpleProductCategoryList();
|
||||
}
|
||||
|
||||
/** 获取分类名称 */
|
||||
export function getCategoryName(categoryList: any[], categoryId: number) {
|
||||
const category = categoryList.find((c: any) => c.id === categoryId);
|
||||
return category?.name || '未分类';
|
||||
}
|
||||
|
||||
/** 删除产品 */
|
||||
export async function handleDeleteProduct(row: any, onSuccess?: () => void) {
|
||||
const hideLoading = message.loading({
|
||||
content: `正在删除 ${row.name}...`,
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deleteProduct(row.id);
|
||||
message.success(`删除 ${row.name} 成功`);
|
||||
onSuccess?.();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/** 导出产品 */
|
||||
export async function handleExportProduct(searchParams: any) {
|
||||
const data = await exportProduct(searchParams);
|
||||
downloadFileFromBlobPart({ fileName: '产品列表.xls', source: data });
|
||||
}
|
||||
|
||||
/** 查询产品列表 */
|
||||
export async function queryProductList({ page }: any, searchParams: any) {
|
||||
return await getProductPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...searchParams,
|
||||
});
|
||||
}
|
||||
|
||||
/** 创建图片预览状态 */
|
||||
export function useImagePreview() {
|
||||
const previewVisible = ref(false);
|
||||
const previewImage = ref('');
|
||||
|
||||
function handlePreviewImage(url: string) {
|
||||
previewImage.value = url;
|
||||
previewVisible.value = true;
|
||||
}
|
||||
|
||||
return {
|
||||
previewVisible,
|
||||
previewImage,
|
||||
handlePreviewImage,
|
||||
};
|
||||
}
|
||||
@@ -1,28 +1,340 @@
|
||||
<script lang="ts" setup>
|
||||
import { Page } from '@vben/common-ui';
|
||||
<script setup lang="ts">
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { Button } from 'ant-design-vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { Button, Card, Image, Input, Space } from 'ant-design-vue';
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import ProductForm from './modules/ProductForm.vue';
|
||||
// @ts-ignore
|
||||
import ProductCardView from './modules/ProductCardView.vue';
|
||||
import {
|
||||
getCategoryName,
|
||||
handleDeleteProduct,
|
||||
handleExportProduct,
|
||||
loadCategoryList,
|
||||
queryProductList,
|
||||
useGridColumns,
|
||||
useImagePreview,
|
||||
} from './data';
|
||||
|
||||
defineOptions({ name: 'IoTProduct' });
|
||||
|
||||
const router = useRouter();
|
||||
const categoryList = ref<any[]>([]);
|
||||
const viewMode = ref<'list' | 'card'>('card');
|
||||
const cardViewRef = ref();
|
||||
|
||||
// 搜索参数
|
||||
const searchParams = ref({
|
||||
name: '',
|
||||
productKey: '',
|
||||
});
|
||||
|
||||
// 图片预览
|
||||
const { previewVisible, previewImage, handlePreviewImage } = useImagePreview();
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: ProductForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
// 加载产品分类列表
|
||||
const loadCategories = async () => {
|
||||
categoryList.value = await loadCategoryList();
|
||||
};
|
||||
|
||||
// 获取分类名称
|
||||
const getCategoryNameByValue = (categoryId: number) => {
|
||||
return getCategoryName(categoryList.value, categoryId);
|
||||
};
|
||||
|
||||
/** 搜索 */
|
||||
function handleSearch() {
|
||||
if (viewMode.value === 'list') {
|
||||
gridApi.formApi.setValues(searchParams.value);
|
||||
gridApi.query();
|
||||
} else {
|
||||
cardViewRef.value?.search(searchParams.value);
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置 */
|
||||
function handleReset() {
|
||||
searchParams.value.name = '';
|
||||
searchParams.value.productKey = '';
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
/** 刷新 */
|
||||
function handleRefresh() {
|
||||
if (viewMode.value === 'list') {
|
||||
gridApi.query();
|
||||
} else {
|
||||
cardViewRef.value?.reload();
|
||||
}
|
||||
}
|
||||
|
||||
/** 导出表格 */
|
||||
async function handleExport() {
|
||||
await handleExportProduct(searchParams.value);
|
||||
}
|
||||
|
||||
/** 打开产品详情 */
|
||||
function openProductDetail(productId: number) {
|
||||
router.push({
|
||||
name: 'IoTProductDetail',
|
||||
params: { id: productId },
|
||||
});
|
||||
}
|
||||
|
||||
/** 打开物模型管理 */
|
||||
function openThingModel(productId: number) {
|
||||
router.push({
|
||||
name: 'IoTProductDetail',
|
||||
params: { id: productId },
|
||||
query: { tab: 'thingModel' },
|
||||
});
|
||||
}
|
||||
|
||||
/** 新增产品 */
|
||||
function handleCreate() {
|
||||
formModalApi.setData(null).open();
|
||||
}
|
||||
|
||||
/** 编辑产品 */
|
||||
function handleEdit(row: any) {
|
||||
formModalApi.setData(row).open();
|
||||
}
|
||||
|
||||
/** 删除产品 */
|
||||
async function handleDelete(row: any) {
|
||||
await handleDeleteProduct(row, handleRefresh);
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions: {
|
||||
schema: [],
|
||||
},
|
||||
gridOptions: {
|
||||
columns: useGridColumns(),
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: ({ page }) => queryProductList({ page }, searchParams.value),
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
loadCategories();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page>
|
||||
<Button
|
||||
danger
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
|
||||
>
|
||||
该功能支持 Vue3 + element-plus 版本!
|
||||
</Button>
|
||||
<br />
|
||||
<Button
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/iot/product/product/index"
|
||||
>
|
||||
可参考
|
||||
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/iot/product/product/index
|
||||
代码,pull request 贡献给我们!
|
||||
</Button>
|
||||
<Page auto-content-height>
|
||||
<FormModal @success="handleRefresh" />
|
||||
|
||||
<!-- 统一搜索工具栏 -->
|
||||
<Card :body-style="{ padding: '16px' }" class="mb-4">
|
||||
<!-- 搜索表单 -->
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<Input
|
||||
v-model:value="searchParams.name"
|
||||
placeholder="请输入产品名称"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@pressEnter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="text-gray-400">产品名称</span>
|
||||
</template>
|
||||
</Input>
|
||||
<Input
|
||||
v-model:value="searchParams.productKey"
|
||||
placeholder="请输入产品标识"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
@pressEnter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="text-gray-400">ProductKey</span>
|
||||
</template>
|
||||
</Input>
|
||||
<Button type="primary" @click="handleSearch">
|
||||
<IconifyIcon icon="ant-design:search-outlined" class="mr-1" />
|
||||
搜索
|
||||
</Button>
|
||||
<Button @click="handleReset">
|
||||
<IconifyIcon icon="ant-design:reload-outlined" class="mr-1" />
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<Space :size="12">
|
||||
<Button type="primary" @click="handleCreate">
|
||||
<IconifyIcon icon="ant-design:plus-outlined" class="mr-1" />
|
||||
新增产品
|
||||
</Button>
|
||||
<Button type="primary" @click="handleExport">
|
||||
<IconifyIcon icon="ant-design:download-outlined" class="mr-1" />
|
||||
导出
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<!-- 视图切换 -->
|
||||
<Space :size="4">
|
||||
<Button
|
||||
:type="viewMode === 'card' ? 'primary' : 'default'"
|
||||
@click="viewMode = 'card'"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:appstore-outlined" />
|
||||
</Button>
|
||||
<Button
|
||||
:type="viewMode === 'list' ? 'primary' : 'default'"
|
||||
@click="viewMode = 'list'"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:unordered-list-outlined" />
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Grid v-show="viewMode === 'list'">
|
||||
<template #toolbar-tools>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<!-- 产品分类列 -->
|
||||
<template #category="{ row }">
|
||||
<span>{{ getCategoryNameByValue(row.categoryId) }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 产品图标列 -->
|
||||
<template #icon="{ row }">
|
||||
<Button
|
||||
v-if="row.icon"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handlePreviewImage(row.icon)"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:eye-outlined" class="text-lg" />
|
||||
</Button>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<!-- 产品图片列 -->
|
||||
<template #picUrl="{ row }">
|
||||
<Button
|
||||
v-if="row.picUrl"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handlePreviewImage(row.picUrl)"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:eye-outlined" class="text-lg" />
|
||||
</Button>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '详情',
|
||||
type: 'link',
|
||||
onClick: openProductDetail.bind(null, row.id),
|
||||
},
|
||||
{
|
||||
label: '物模型',
|
||||
type: 'link',
|
||||
onClick: openThingModel.bind(null, row.id),
|
||||
},
|
||||
{
|
||||
label: $t('common.edit'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
popConfirm: {
|
||||
title: `确认删除产品 ${row.name} 吗?`,
|
||||
confirm: handleDelete.bind(null, row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
|
||||
<!-- 卡片视图 -->
|
||||
<ProductCardView
|
||||
v-show="viewMode === 'card'"
|
||||
ref="cardViewRef"
|
||||
:category-list="categoryList"
|
||||
:search-params="searchParams"
|
||||
@create="handleCreate"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
@detail="openProductDetail"
|
||||
@thing-model="openThingModel"
|
||||
/>
|
||||
|
||||
<!-- 图片预览 -->
|
||||
<div style="display: none">
|
||||
<Image.PreviewGroup
|
||||
:preview="{
|
||||
visible: previewVisible,
|
||||
onVisibleChange: (visible) => (previewVisible = visible),
|
||||
}"
|
||||
>
|
||||
<Image :src="previewImage" />
|
||||
</Image.PreviewGroup>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
<style scoped>
|
||||
:deep(.vxe-toolbar div) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* 隐藏 VxeGrid 自带的搜索表单区域 */
|
||||
:deep(.vxe-grid--form-wrapper) {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 控制图片预览的大小 */
|
||||
.ant-image-preview-img {
|
||||
max-width: 80% !important;
|
||||
max-height: 80% !important;
|
||||
object-fit: contain !important;
|
||||
}
|
||||
|
||||
.ant-image-preview-operations {
|
||||
background: rgba(0, 0, 0, 0.7) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Pagination,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Tag,
|
||||
Tooltip,
|
||||
} from 'ant-design-vue';
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
import { getDictLabel } from '@vben/hooks';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { getProductPage } from '#/api/iot/product/product';
|
||||
|
||||
defineOptions({ name: 'ProductCardView' });
|
||||
|
||||
interface Props {
|
||||
categoryList: any[];
|
||||
searchParams?: {
|
||||
name: string;
|
||||
productKey: string;
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: [];
|
||||
edit: [row: any];
|
||||
delete: [row: any];
|
||||
detail: [productId: number];
|
||||
thingModel: [productId: number];
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
const queryParams = ref({
|
||||
pageNo: 1,
|
||||
pageSize: 12,
|
||||
});
|
||||
|
||||
// 获取分类名称
|
||||
const getCategoryName = (categoryId: number) => {
|
||||
const category = props.categoryList.find((c: any) => c.id === categoryId);
|
||||
return category?.name || '未分类';
|
||||
};
|
||||
|
||||
// 获取产品列表
|
||||
const getList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getProductPage({
|
||||
...queryParams.value,
|
||||
...props.searchParams,
|
||||
});
|
||||
list.value = data.list || [];
|
||||
total.value = data.total || 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理页码变化
|
||||
const handlePageChange = (page: number, pageSize: number) => {
|
||||
queryParams.value.pageNo = page;
|
||||
queryParams.value.pageSize = pageSize;
|
||||
getList();
|
||||
};
|
||||
|
||||
// 获取设备类型颜色
|
||||
const getDeviceTypeColor = (deviceType: number) => {
|
||||
const colors: Record<number, string> = {
|
||||
0: 'blue',
|
||||
1: 'green',
|
||||
};
|
||||
return colors[deviceType] || 'default';
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
});
|
||||
|
||||
// 暴露方法供父组件调用
|
||||
defineExpose({
|
||||
reload: getList,
|
||||
search: () => {
|
||||
queryParams.value.pageNo = 1;
|
||||
getList();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="product-card-view">
|
||||
<!-- 产品卡片列表 -->
|
||||
<div v-loading="loading" class="min-h-[400px]">
|
||||
<Row v-if="list.length > 0" :gutter="[16, 16]">
|
||||
<Col
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
:xs="24"
|
||||
:sm="12"
|
||||
:md="12"
|
||||
:lg="6"
|
||||
class="mb-4"
|
||||
>
|
||||
<Card
|
||||
:body-style="{ padding: '20px' }"
|
||||
class="product-card h-full"
|
||||
>
|
||||
<!-- 顶部标题区域 -->
|
||||
<div class="flex items-start mb-4">
|
||||
<div class="product-icon">
|
||||
<IconifyIcon icon="ant-design:inbox-outlined" class="text-[32px]" />
|
||||
</div>
|
||||
<div class="ml-3 flex-1 min-w-0">
|
||||
<div class="product-title">{{ item.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="flex items-start mb-4">
|
||||
<div class="flex-1 info-list">
|
||||
<div class="info-item">
|
||||
<span class="info-label">产品分类</span>
|
||||
<span class="info-value text-primary">{{ getCategoryName(item.categoryId) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">产品类型</span>
|
||||
<Tag :color="getDeviceTypeColor(item.deviceType)" class="m-0 info-tag">
|
||||
{{ getDictLabel(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE, item.deviceType) }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">产品标识</span>
|
||||
<Tooltip :title="item.productKey || item.id" placement="top">
|
||||
<span class="info-value product-key">{{ item.productKey || item.id }}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-3d-icon">
|
||||
<IconifyIcon icon="ant-design:box-plot-outlined" class="text-[80px]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按钮组 -->
|
||||
<div class="action-buttons">
|
||||
<Button
|
||||
size="small"
|
||||
class="action-btn action-btn-edit"
|
||||
@click="emit('edit', item)"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:edit-outlined" class="mr-1" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
class="action-btn action-btn-detail"
|
||||
@click="emit('detail', item.id)"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:eye-outlined" class="mr-1" />
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
class="action-btn action-btn-model"
|
||||
@click="emit('thingModel', item.id)"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:apartment-outlined" class="mr-1" />
|
||||
物模型
|
||||
</Button>
|
||||
<Popconfirm
|
||||
:title="`确认删除产品 ${item.name} 吗?`"
|
||||
@confirm="emit('delete', item)"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
class="action-btn action-btn-delete"
|
||||
>
|
||||
<IconifyIcon icon="ant-design:delete-outlined" />
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<Empty v-else description="暂无产品数据" class="my-20" />
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="list.length > 0" class="mt-6 flex justify-center">
|
||||
<Pagination
|
||||
v-model:current="queryParams.pageNo"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:total="total"
|
||||
:show-total="(total) => `共 ${total} 条`"
|
||||
show-quick-jumper
|
||||
show-size-changer
|
||||
:page-size-options="['12', '24', '36', '48']"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.product-card-view {
|
||||
.product-card {
|
||||
height: 100%;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
border-color: #d9d9d9;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
// 产品图标
|
||||
.product-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// 产品标题
|
||||
.product-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 信息列表
|
||||
.info-list {
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: #6b7280;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #1f2937;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.text-primary {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.product-key {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
display: inline-block;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.info-tag {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3D 图标
|
||||
.product-3d-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
// 按钮组
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: auto;
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
height: 32px;
|
||||
font-size: 13px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.action-btn-edit {
|
||||
color: #1890ff;
|
||||
border-color: #1890ff;
|
||||
|
||||
&:hover {
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
&.action-btn-detail {
|
||||
color: #52c41a;
|
||||
border-color: #52c41a;
|
||||
|
||||
&:hover {
|
||||
background: #52c41a;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
&.action-btn-model {
|
||||
color: #722ed1;
|
||||
border-color: #722ed1;
|
||||
|
||||
&:hover {
|
||||
background: #722ed1;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
&.action-btn-delete {
|
||||
flex: 0 0 32px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useVbenForm, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { createProduct, getProduct, updateProduct, type IotProductApi } from '#/api/iot/product/product';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { useFormSchema } from '../data';
|
||||
|
||||
defineOptions({ name: 'IoTProductForm' });
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const formData = ref<any>();
|
||||
const getTitle = computed(() => {
|
||||
return formData.value?.id ? '编辑产品' : '新增产品';
|
||||
});
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
wrapperClass: 'grid-cols-2',
|
||||
layout: 'horizontal',
|
||||
schema: useFormSchema(),
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
// 提交表单
|
||||
const data = (await formApi.getValues()) as IotProductApi.Product;
|
||||
try {
|
||||
await (formData.value?.id ? updateProduct(data) : createProduct(data));
|
||||
// 关闭并提示
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = undefined;
|
||||
return;
|
||||
}
|
||||
// 加载数据
|
||||
const data = modalApi.getData<any>();
|
||||
if (!data || !data.id) {
|
||||
// 设置默认值
|
||||
await formApi.setValues({
|
||||
deviceType: 0, // 默认直连设备
|
||||
dataFormat: 1, // 默认 JSON
|
||||
validateType: 1, // 默认设备密钥
|
||||
status: 0, // 默认启用
|
||||
});
|
||||
return;
|
||||
}
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getProduct(data.id);
|
||||
// 设置到 values
|
||||
await formApi.setValues(formData.value);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="getTitle" class="w-2/5">
|
||||
<Form class="mx-4" />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,190 @@
|
||||
<!-- IoT 产品选择器,使用弹窗展示 -->
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getProductPage } from '#/api/iot/product/product';
|
||||
import type { IotProductApi } from '#/api/iot/product/product';
|
||||
|
||||
defineOptions({ name: 'IoTProductTableSelect' });
|
||||
|
||||
interface Props {
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: [product: IotProductApi.Product | IotProductApi.Product[]];
|
||||
}>();
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
title: '产品选择器',
|
||||
onConfirm: handleConfirm,
|
||||
});
|
||||
|
||||
const selectedProducts = ref<IotProductApi.Product[]>([]);
|
||||
const selectedRowKeys = ref<number[]>([]);
|
||||
|
||||
// 搜索参数
|
||||
const queryParams = reactive({
|
||||
name: '',
|
||||
productKey: '',
|
||||
});
|
||||
|
||||
// 配置表格
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: [
|
||||
{
|
||||
type: props.multiple ? 'checkbox' : 'radio',
|
||||
width: 50,
|
||||
},
|
||||
{
|
||||
field: 'name',
|
||||
title: '产品名称',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'productKey',
|
||||
title: 'ProductKey',
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'categoryName',
|
||||
title: '品类',
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
field: 'deviceType',
|
||||
title: '设备类型',
|
||||
minWidth: 100,
|
||||
cellRender: {
|
||||
name: 'CellDict',
|
||||
props: { type: 'iot_product_device_type' },
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: '创建时间',
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
],
|
||||
checkboxConfig: {
|
||||
reserve: true,
|
||||
highlight: true,
|
||||
},
|
||||
radioConfig: {
|
||||
reserve: true,
|
||||
highlight: true,
|
||||
},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }: any) => {
|
||||
return await getProductPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...queryParams,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 打开选择器
|
||||
const open = async () => {
|
||||
selectedProducts.value = [];
|
||||
selectedRowKeys.value = [];
|
||||
modalApi.open();
|
||||
gridApi.reload();
|
||||
};
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
gridApi.reload();
|
||||
};
|
||||
|
||||
// 重置搜索
|
||||
const handleReset = () => {
|
||||
queryParams.name = '';
|
||||
queryParams.productKey = '';
|
||||
gridApi.reload();
|
||||
};
|
||||
|
||||
// 确认选择
|
||||
async function handleConfirm() {
|
||||
const grid = gridApi.grid;
|
||||
if (!grid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (props.multiple) {
|
||||
const checkboxRecords = grid.getCheckboxRecords();
|
||||
if (checkboxRecords.length === 0) {
|
||||
message.warning('请至少选择一个产品');
|
||||
return false;
|
||||
}
|
||||
emit('success', checkboxRecords);
|
||||
} else {
|
||||
const radioRecord = grid.getRadioRecord();
|
||||
if (!radioRecord) {
|
||||
message.warning('请选择一个产品');
|
||||
return false;
|
||||
}
|
||||
emit('success', radioRecord);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="!w-[900px]">
|
||||
<div class="mb-4">
|
||||
<a-form layout="inline" :model="queryParams">
|
||||
<a-form-item label="产品名称">
|
||||
<a-input
|
||||
v-model:value="queryParams.name"
|
||||
placeholder="请输入产品名称"
|
||||
allow-clear
|
||||
class="!w-[200px]"
|
||||
@press-enter="handleSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="ProductKey">
|
||||
<a-input
|
||||
v-model:value="queryParams.productKey"
|
||||
placeholder="请输入产品标识"
|
||||
allow-clear
|
||||
class="!w-[200px]"
|
||||
@press-enter="handleSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button type="primary" @click="handleSearch">
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:search-outlined" />
|
||||
</template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button class="ml-2" @click="handleReset">
|
||||
<template #icon>
|
||||
<Icon icon="ant-design:reload-outlined" />
|
||||
</template>
|
||||
重置
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<Grid />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { updateProductStatus } from '#/api/iot/product/product';
|
||||
import type { IotProductApi } from '#/api/iot/product/product';
|
||||
|
||||
import ProductForm from '../ProductForm.vue';
|
||||
|
||||
interface Props {
|
||||
product: IotProductApi.Product;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const formRef = ref();
|
||||
|
||||
/** 复制到剪贴板 */
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
message.success('复制成功');
|
||||
} catch {
|
||||
message.error('复制失败');
|
||||
}
|
||||
};
|
||||
|
||||
/** 跳转到设备管理 */
|
||||
const goToDeviceList = (productId: number) => {
|
||||
router.push({ path: '/iot/device/device', query: { productId: String(productId) } });
|
||||
};
|
||||
|
||||
/** 打开编辑表单 */
|
||||
const openForm = (type: string, id?: number) => {
|
||||
formRef.value?.open(type, id);
|
||||
};
|
||||
|
||||
/** 发布产品 */
|
||||
const confirmPublish = async (id: number) => {
|
||||
try {
|
||||
await updateProductStatus(id, 1);
|
||||
message.success('发布成功');
|
||||
emit('refresh');
|
||||
} catch {
|
||||
message.error('发布失败');
|
||||
}
|
||||
};
|
||||
|
||||
/** 撤销发布 */
|
||||
const confirmUnpublish = async (id: number) => {
|
||||
try {
|
||||
await updateProductStatus(id, 0);
|
||||
message.success('撤销发布成功');
|
||||
emit('refresh');
|
||||
} catch {
|
||||
message.error('撤销发布失败');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold">{{ product.name }}</h2>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<a-button
|
||||
:disabled="product.status === 1"
|
||||
@click="openForm('update', product.id)"
|
||||
>
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="product.status === 0"
|
||||
type="primary"
|
||||
@click="confirmPublish(product.id!)"
|
||||
>
|
||||
发布
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="product.status === 1"
|
||||
danger
|
||||
@click="confirmUnpublish(product.id!)"
|
||||
>
|
||||
撤销发布
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-card class="mt-4">
|
||||
<a-descriptions :column="1">
|
||||
<a-descriptions-item label="ProductKey">
|
||||
{{ product.productKey }}
|
||||
<a-button size="small" class="ml-2" @click="copyToClipboard(product.productKey || '')">
|
||||
复制
|
||||
</a-button>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="设备总数">
|
||||
<span class="ml-5 mr-2">{{ product.deviceCount ?? '加载中...' }}</span>
|
||||
<a-button size="small" @click="goToDeviceList(product.id!)">
|
||||
前往管理
|
||||
</a-button>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
|
||||
<!-- 表单弹窗 -->
|
||||
<ProductForm ref="formRef" @success="emit('refresh')" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { DICT_TYPE } from '@vben/constants';
|
||||
|
||||
import { DictTag } from '#/components/dict-tag';
|
||||
import type { IotProductApi } from '#/api/iot/product/product';
|
||||
import { DeviceTypeEnum } from '#/api/iot/product/product';
|
||||
|
||||
interface Props {
|
||||
product: IotProductApi.Product;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
/** 格式化日期 */
|
||||
const formatDate = (date?: Date | string) => {
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleString('zh-CN');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card title="产品信息">
|
||||
<a-descriptions bordered :column="3">
|
||||
<a-descriptions-item label="产品名称">
|
||||
{{ product.name }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="所属分类">
|
||||
{{ product.categoryName || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="设备类型">
|
||||
<DictTag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="product.deviceType" />
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="定位类型">
|
||||
{{ product.locationType ?? '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">
|
||||
{{ formatDate(product.createTime) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="数据格式">
|
||||
{{ product.codecType || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="产品状态">
|
||||
<DictTag :type="DICT_TYPE.COMMON_STATUS" :value="product.status" />
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item
|
||||
v-if="[DeviceTypeEnum.DEVICE, DeviceTypeEnum.GATEWAY].includes(product.deviceType!)"
|
||||
label="联网方式"
|
||||
>
|
||||
<DictTag :type="DICT_TYPE.IOT_NET_TYPE" :value="product.netType" />
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="产品描述" :span="3">
|
||||
{{ product.description || '-' }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, provide, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { getProduct } from '#/api/iot/product/product';
|
||||
import type { IotProductApi } from '#/api/iot/product/product';
|
||||
import { getDeviceCount } from '#/api/iot/device/device';
|
||||
|
||||
import ProductDetailsHeader from './ProductDetailsHeader.vue';
|
||||
import ProductDetailsInfo from './ProductDetailsInfo.vue';
|
||||
import IoTProductThingModel from '#/views/iot/thingmodel/index.vue';
|
||||
|
||||
defineOptions({ name: 'IoTProductDetail' });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const id = Number(route.params.id);
|
||||
const loading = ref(true);
|
||||
const product = ref<IotProductApi.Product>({} as IotProductApi.Product);
|
||||
const activeTab = ref('info');
|
||||
|
||||
// 提供产品信息给子组件
|
||||
provide('product', product);
|
||||
|
||||
/** 获取产品详情 */
|
||||
const getProductData = async (productId: number) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
product.value = await getProduct(productId);
|
||||
} catch {
|
||||
message.error('获取产品详情失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 查询设备数量 */
|
||||
const getDeviceCountData = async (productId: number) => {
|
||||
try {
|
||||
return await getDeviceCount(productId);
|
||||
} catch (error) {
|
||||
console.error('Error fetching device count:', error, 'productId:', productId);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
if (!id) {
|
||||
message.warning('参数错误,产品不能为空!');
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
|
||||
await getProductData(id);
|
||||
|
||||
// 处理 tab 参数
|
||||
const { tab } = route.query;
|
||||
if (tab) {
|
||||
activeTab.value = tab as string;
|
||||
}
|
||||
|
||||
// 查询设备数量
|
||||
if (product.value.id) {
|
||||
product.value.deviceCount = await getDeviceCountData(product.value.id);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page>
|
||||
<ProductDetailsHeader
|
||||
:loading="loading"
|
||||
:product="product"
|
||||
@refresh="() => getProductData(id)"
|
||||
/>
|
||||
|
||||
<a-tabs v-model:active-key="activeTab" class="mt-4">
|
||||
<a-tab-pane key="info" tab="产品信息">
|
||||
<ProductDetailsInfo v-if="activeTab === 'info'" :product="product" />
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="thingModel" tab="物模型(功能定义)">
|
||||
<IoTProductThingModel v-if="activeTab === 'thingModel'" :product-id="id" />
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</Page>
|
||||
</template>
|
||||
Reference in New Issue
Block a user