This commit is contained in:
xingyu4j
2025-10-11 10:56:12 +08:00
parent 28566a659f
commit a156873437
30 changed files with 471 additions and 501 deletions

View File

@@ -1,19 +1,10 @@
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';
import { getSimpleProductCategoryList } from '#/api/iot/product/category';
/** 新增/修改产品分类的表单 */
export function useFormSchema(): VbenFormSchema[] {
@@ -160,35 +151,3 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
},
];
}
/** 删除分类 */
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'),
};
}

View File

@@ -3,16 +3,18 @@ import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { IotProductCategoryApi } from '#/api/iot/product/category';
import { Page, useVbenModal } from '@vben/common-ui';
import { handleTree } from '@vben/utils';
import { message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteProductCategory,
getProductCategoryPage,
} from '#/api/iot/product/category';
import { $t } from '#/locales';
import {
handleDeleteCategory,
queryProductCategoryList,
useGridColumns,
useGridFormSchema,
} from './data';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/ProductCategoryForm.vue';
defineOptions({ name: 'IoTProductCategory' });
@@ -39,7 +41,17 @@ function handleEdit(row: IotProductCategoryApi.ProductCategory) {
/** 删除分类 */
async function handleDelete(row: IotProductCategoryApi.ProductCategory) {
await handleDeleteCategory(row, handleRefresh);
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]));
handleRefresh();
} finally {
hideLoading();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
@@ -57,7 +69,18 @@ const [Grid, gridApi] = useVbenVxeGrid({
},
proxyConfig: {
ajax: {
query: queryProductCategoryList,
query: async ({ page }, formValues) => {
const data = await getProductCategoryPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
// 转换为树形结构
return {
...data,
list: handleTree(data.list, 'id', 'parentId'),
};
},
},
},
rowConfig: {

View File

@@ -5,17 +5,10 @@ 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';
import { getProductPage } from '#/api/iot/product/product';
/** 新增/修改产品的表单 */
export function useFormSchema(): VbenFormSchema[] {
@@ -208,38 +201,6 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
];
}
/** 加载产品分类列表 */
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({

View File

@@ -6,21 +6,20 @@ import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, Card, Image, Input, Space } from 'ant-design-vue';
import { Button, Card, Image, Input, message, Space } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteProduct,
exportProduct,
getProductPage,
} from '#/api/crm/product';
import { getSimpleProductCategoryList } from '#/api/iot/product/category';
import { $t } from '#/locales';
import {
getCategoryName,
handleDeleteProduct,
handleExportProduct,
loadCategoryList,
queryProductList,
useGridColumns,
useImagePreview,
} from './data';
import { useGridColumns, useImagePreview } from './data';
// @ts-ignore
import ProductCardView from './modules/ProductCardView.vue';
import ProductForm from './modules/ProductForm.vue';
@@ -47,14 +46,15 @@ const [FormModal, formModalApi] = useVbenModal({
});
// 加载产品分类列表
const loadCategories = async () => {
categoryList.value = await loadCategoryList();
};
async function loadCategories() {
categoryList.value = await getSimpleProductCategoryList();
}
// 获取分类名称
const getCategoryNameByValue = (categoryId: number) => {
return getCategoryName(categoryList.value, categoryId);
};
function getCategoryNameByValue(categoryId: number) {
const category = categoryList.value.find((c: any) => c.id === categoryId);
return category?.name || '未分类';
}
/** 搜索 */
function handleSearch() {
@@ -84,7 +84,8 @@ function handleRefresh() {
/** 导出表格 */
async function handleExport() {
await handleExportProduct(searchParams.value);
const data = await exportProduct(searchParams.value);
await downloadFileFromBlobPart({ fileName: '产品列表.xls', source: data });
}
/** 打开产品详情 */
@@ -116,7 +117,17 @@ function handleEdit(row: any) {
/** 删除产品 */
async function handleDelete(row: any) {
await handleDeleteProduct(row, handleRefresh);
const hideLoading = message.loading({
content: `正在删除 ${row.name}...`,
duration: 0,
});
try {
await deleteProduct(row.id);
message.success(`删除 ${row.name} 成功`);
handleRefresh();
} finally {
hideLoading();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
@@ -129,7 +140,13 @@ const [Grid, gridApi] = useVbenVxeGrid({
keepSource: true,
proxyConfig: {
ajax: {
query: ({ page }) => queryProductList({ page }, searchParams.value),
query: async ({ page }) => {
return await getProductPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...searchParams.value,
});
},
},
},
rowConfig: {
@@ -336,6 +353,6 @@ onMounted(() => {
}
.ant-image-preview-operations {
background: rgba(0, 0, 0, 0.7) !important;
background: rgb(0 0 0 / 70%) !important;
}
</style>

View File

@@ -48,13 +48,13 @@ const queryParams = ref({
});
// 获取分类名称
const getCategoryName = (categoryId: number) => {
function getCategoryName(categoryId: number) {
const category = props.categoryList.find((c: any) => c.id === categoryId);
return category?.name || '未分类';
};
}
// 获取产品列表
const getList = async () => {
async function getList() {
loading.value = true;
try {
const data = await getProductPage({
@@ -66,23 +66,23 @@ const getList = async () => {
} finally {
loading.value = false;
}
};
}
// 处理页码变化
const handlePageChange = (page: number, pageSize: number) => {
function handlePageChange(page: number, pageSize: number) {
queryParams.value.pageNo = page;
queryParams.value.pageSize = pageSize;
getList();
};
}
// 获取设备类型颜色
const getDeviceTypeColor = (deviceType: number) => {
function getDeviceTypeColor(deviceType: number) {
const colors: Record<number, string> = {
0: 'blue',
1: 'green',
};
return colors[deviceType] || 'default';
};
}
onMounted(() => {
getList();
@@ -131,9 +131,9 @@ defineExpose({
<div class="info-list flex-1">
<div class="info-item">
<span class="info-label">产品分类</span>
<span class="info-value text-primary">{{
getCategoryName(item.categoryId)
}}</span>
<span class="info-value text-primary">
{{ getCategoryName(item.categoryId) }}
</span>
</div>
<div class="info-item">
<span class="info-label">产品类型</span>
@@ -152,9 +152,9 @@ defineExpose({
<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>
<span class="info-value product-key">
{{ item.productKey || item.id }}
</span>
</Tooltip>
</div>
</div>
@@ -236,44 +236,44 @@ defineExpose({
.product-card-view {
.product-card {
height: 100%;
transition: all 0.3s ease;
overflow: hidden;
border: 1px solid #e8e8e8;
border-radius: 8px;
overflow: hidden;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
border-color: #d9d9d9;
box-shadow: 0 4px 16px rgb(0 0 0 / 8%);
transform: translateY(-2px);
}
:deep(.ant-card-body) {
height: 100%;
display: flex;
flex-direction: column;
height: 100%;
}
// 产品图标
.product-icon {
width: 48px;
height: 48px;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
color: white;
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;
font-size: 16px;
font-weight: 600;
line-height: 1.5;
color: #1f2937;
white-space: nowrap;
}
@@ -290,16 +290,16 @@ defineExpose({
}
.info-label {
color: #6b7280;
margin-right: 8px;
flex-shrink: 0;
margin-right: 8px;
color: #6b7280;
}
.info-value {
color: #1f2937;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
color: #1f2937;
white-space: nowrap;
&.text-primary {
@@ -308,15 +308,15 @@ defineExpose({
}
.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;
font-family: 'Courier New', monospace;
font-size: 12px;
vertical-align: middle;
color: #374151;
white-space: nowrap;
cursor: help;
}
@@ -328,15 +328,15 @@ defineExpose({
// 3D 图标
.product-3d-icon {
width: 100px;
height: 100px;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 100px;
height: 100px;
color: #667eea;
background: linear-gradient(135deg, #667eea15 0%, #764ba215 100%);
border-radius: 8px;
flex-shrink: 0;
color: #667eea;
}
// 按钮组
@@ -344,8 +344,8 @@ defineExpose({
display: flex;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
margin-top: auto;
border-top: 1px solid #f0f0f0;
.action-btn {
flex: 1;
@@ -359,8 +359,8 @@ defineExpose({
border-color: #1890ff;
&:hover {
background: #1890ff;
color: white;
background: #1890ff;
}
}
@@ -369,8 +369,8 @@ defineExpose({
border-color: #52c41a;
&:hover {
background: #52c41a;
color: white;
background: #52c41a;
}
}
@@ -379,8 +379,8 @@ defineExpose({
border-color: #722ed1;
&:hover {
background: #722ed1;
color: white;
background: #722ed1;
}
}

View File

@@ -5,8 +5,9 @@ import type { IotProductApi } from '#/api/iot/product/product';
import { reactive, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { message } from 'ant-design-vue';
import { Button, Form, Input, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getProductPage } from '#/api/iot/product/product';
@@ -101,24 +102,24 @@ const [Grid, gridApi] = useVbenVxeGrid({
});
// 打开选择器
const open = async () => {
async function open() {
selectedProducts.value = [];
selectedRowKeys.value = [];
modalApi.open();
gridApi.reload();
};
}
// 搜索
const handleSearch = () => {
function handleSearch() {
gridApi.reload();
};
}
// 重置搜索
const handleReset = () => {
function handleReset() {
queryParams.name = '';
queryParams.productKey = '';
gridApi.reload();
};
}
// 确认选择
async function handleConfirm() {
@@ -151,40 +152,40 @@ defineExpose({ open });
<template>
<Modal class="!w-[900px]">
<div class="mb-4">
<a-form layout="inline" :model="queryParams">
<a-form-item label="产品名称">
<a-input
<Form layout="inline" :model="queryParams">
<Form.Item label="产品名称">
<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
</Form.Item>
<Form.Item label="ProductKey">
<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">
</Form.Item>
<Form.Item>
<Button type="primary" @click="handleSearch">
<template #icon>
<Icon icon="ant-design:search-outlined" />
<IconifyIcon icon="ant-design:search-outlined" />
</template>
搜索
</a-button>
<a-button class="ml-2" @click="handleReset">
</Button>
<Button class="ml-2" @click="handleReset">
<template #icon>
<Icon icon="ant-design:reload-outlined" />
<IconifyIcon icon="ant-design:reload-outlined" />
</template>
重置
</a-button>
</a-form-item>
</a-form>
</Button>
</Form.Item>
</Form>
</div>
<Grid />

View File

@@ -4,7 +4,7 @@ import type { IotProductApi } from '#/api/iot/product/product';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
import { Button, Card, Descriptions, message } from 'ant-design-vue';
import { updateProductStatus } from '#/api/iot/product/product';
@@ -27,30 +27,30 @@ const router = useRouter();
const formRef = ref();
/** 复制到剪贴板 */
const copyToClipboard = async (text: string) => {
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
message.success('复制成功');
} catch {
message.error('复制失败');
}
};
}
/** 跳转到设备管理 */
const goToDeviceList = (productId: number) => {
function goToDeviceList(productId: number) {
router.push({
path: '/iot/device/device',
query: { productId: String(productId) },
});
};
}
/** 打开编辑表单 */
const openForm = (type: string, id?: number) => {
function openForm(type: string, id?: number) {
formRef.value?.open(type, id);
};
}
/** 发布产品 */
const confirmPublish = async (id: number) => {
async function confirmPublish(id: number) {
try {
await updateProductStatus(id, 1);
message.success('发布成功');
@@ -58,10 +58,10 @@ const confirmPublish = async (id: number) => {
} catch {
message.error('发布失败');
}
};
}
/** 撤销发布 */
const confirmUnpublish = async (id: number) => {
async function confirmUnpublish(id: number) {
try {
await updateProductStatus(id, 0);
message.success('撤销发布成功');
@@ -69,7 +69,7 @@ const confirmUnpublish = async (id: number) => {
} catch {
message.error('撤销发布失败');
}
};
}
</script>
<template>
@@ -79,51 +79,51 @@ const confirmUnpublish = async (id: number) => {
<h2 class="text-xl font-bold">{{ product.name }}</h2>
</div>
<div class="space-x-2">
<a-button
<Button
:disabled="product.status === 1"
@click="openForm('update', product.id)"
>
编辑
</a-button>
<a-button
</Button>
<Button
v-if="product.status === 0"
type="primary"
@click="confirmPublish(product.id!)"
>
发布
</a-button>
<a-button
</Button>
<Button
v-if="product.status === 1"
danger
@click="confirmUnpublish(product.id!)"
>
撤销发布
</a-button>
</Button>
</div>
</div>
<a-card class="mt-4">
<a-descriptions :column="1">
<a-descriptions-item label="ProductKey">
<Card class="mt-4">
<Descriptions :column="1">
<Descriptions.Item label="ProductKey">
{{ product.productKey }}
<a-button
<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!)">
</Button>
</Descriptions.Item>
<Descriptions.Item label="设备总数">
<span class="ml-5 mr-2">
{{ product.deviceCount ?? '加载中...' }}
</span>
<Button size="small" @click="goToDeviceList(product.id!)">
前往管理
</a-button>
</a-descriptions-item>
</a-descriptions>
</a-card>
</Button>
</Descriptions.Item>
</Descriptions>
</Card>
<!-- 表单弹窗 -->
<ProductForm ref="formRef" @success="emit('refresh')" />

View File

@@ -3,6 +3,8 @@ import type { IotProductApi } from '#/api/iot/product/product';
import { DICT_TYPE } from '@vben/constants';
import { Card, Descriptions } from 'ant-design-vue';
import { DeviceTypeEnum } from '#/api/iot/product/product';
import { DictTag } from '#/components/dict-tag';
@@ -20,33 +22,33 @@ const formatDate = (date?: Date | string) => {
</script>
<template>
<a-card title="产品信息">
<a-descriptions bordered :column="3">
<a-descriptions-item label="产品名称">
<Card title="产品信息">
<Descriptions bordered :column="3">
<Descriptions.Item label="产品名称">
{{ product.name }}
</a-descriptions-item>
<a-descriptions-item label="所属分类">
</Descriptions.Item>
<Descriptions.Item label="所属分类">
{{ product.categoryName || '-' }}
</a-descriptions-item>
<a-descriptions-item label="设备类型">
</Descriptions.Item>
<Descriptions.Item label="设备类型">
<DictTag
:type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE"
:value="product.deviceType"
/>
</a-descriptions-item>
<a-descriptions-item label="定位类型">
</Descriptions.Item>
<Descriptions.Item label="定位类型">
{{ product.locationType ?? '-' }}
</a-descriptions-item>
<a-descriptions-item label="创建时间">
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{{ formatDate(product.createTime) }}
</a-descriptions-item>
<a-descriptions-item label="数据格式">
</Descriptions.Item>
<Descriptions.Item label="数据格式">
{{ product.codecType || '-' }}
</a-descriptions-item>
<a-descriptions-item label="产品状态">
</Descriptions.Item>
<Descriptions.Item label="产品状态">
<DictTag :type="DICT_TYPE.COMMON_STATUS" :value="product.status" />
</a-descriptions-item>
<a-descriptions-item
</Descriptions.Item>
<Descriptions.Item
v-if="
[DeviceTypeEnum.DEVICE, DeviceTypeEnum.GATEWAY].includes(
product.deviceType!,
@@ -55,10 +57,10 @@ const formatDate = (date?: Date | string) => {
label="联网方式"
>
<DictTag :type="DICT_TYPE.IOT_NET_TYPE" :value="product.netType" />
</a-descriptions-item>
<a-descriptions-item label="产品描述" :span="3">
</Descriptions.Item>
<Descriptions.Item label="产品描述" :span="3">
{{ product.description || '-' }}
</a-descriptions-item>
</a-descriptions>
</a-card>
</Descriptions.Item>
</Descriptions>
</Card>
</template>

View File

@@ -6,7 +6,7 @@ import { useRoute, useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { message, Tabs } from 'ant-design-vue';
import { getDeviceCount } from '#/api/iot/device/device';
import { getProduct } from '#/api/iot/product/product';
@@ -29,7 +29,7 @@ const activeTab = ref('info');
provide('product', product);
/** 获取产品详情 */
const getProductData = async (productId: number) => {
async function getProductData(productId: number) {
loading.value = true;
try {
product.value = await getProduct(productId);
@@ -38,10 +38,10 @@ const getProductData = async (productId: number) => {
} finally {
loading.value = false;
}
};
}
/** 查询设备数量 */
const getDeviceCountData = async (productId: number) => {
async function getDeviceCountData(productId: number) {
try {
return await getDeviceCount(productId);
} catch (error) {
@@ -53,7 +53,7 @@ const getDeviceCountData = async (productId: number) => {
);
return 0;
}
};
}
/** 初始化 */
onMounted(async () => {
@@ -86,16 +86,16 @@ onMounted(async () => {
@refresh="() => getProductData(id)"
/>
<a-tabs v-model:active-key="activeTab" class="mt-4">
<a-tab-pane key="info" tab="产品信息">
<Tabs v-model:active-key="activeTab" class="mt-4">
<Tabs.TabPane key="info" tab="产品信息">
<ProductDetailsInfo v-if="activeTab === 'info'" :product="product" />
</a-tab-pane>
<a-tab-pane key="thingModel" tab="物模型(功能定义)">
</Tabs.TabPane>
<Tabs.TabPane key="thingModel" tab="物模型(功能定义)">
<IoTProductThingModel
v-if="activeTab === 'thingModel'"
:product-id="id"
/>
</a-tab-pane>
</a-tabs>
</Tabs.TabPane>
</Tabs>
</Page>
</template>