first commit

This commit is contained in:
2026-03-05 16:52:12 +08:00
commit 8ca2e6d52f
1899 changed files with 321565 additions and 0 deletions

View File

@@ -0,0 +1,571 @@
<template>
<div class="exec-page">
<div class="exec-header">
<div class="title">设备点检</div>
<div class="sub">设备ID{{ deviceId }}<span v-if="categoryName">分类{{ categoryName }}</span></div>
</div>
<el-card shadow="never">
<template #header>
<div class="card-header flex items-center justify-between">
<span>本次点检项目</span>
<div class="flex items-center gap-8px">
<el-select v-if="isBatchMode" v-model="activeInstanceId" placeholder="选择设备" size="small"
style="width: 220px;">
<el-option v-for="inst in batchInstances" :key="inst.id"
:label="(inst.deviceName || ('设备ID ' + inst.deviceId))"
:value="inst.id" />
</el-select>
<el-button size="small" @click="refreshInstance" type="warning" plain
v-if="!currentInstance && !isBatchMode">
刷新任务实例
</el-button>
<el-button size="small" @click="goBack" type="info" plain>
返回扫码
</el-button>
</div>
</div>
</template>
<div v-if="loading" class="text-center py-20">加载中...</div>
<div v-else>
<div v-if="displayItems.length === 0" class="text-gray-500">暂无模板项目请联系管理员配置</div>
<div v-for="(item, idx) in displayItems" :key="item.code" class="item-row">
<div class="item-left">
<div class="item-name flex items-center">
<span class="mr-8px">{{ idx + 1 }}.</span>
<span style="font-weight: 600; color: #1f2937;">{{ item.name }}</span>
<el-text v-if="item.desc" type="info" class="ml-16px text-12px"
style="color: #6b7280; background-color: #f9fafb; padding: 4px 8px; border-radius: 4px; margin-left: 16px;">{{
item.desc }}</el-text>
</div>
</div>
<div class="item-right">
<el-radio-group v-model="item.result" @change="onResultChange(item)">
<el-radio label="ok">正常</el-radio>
<el-radio label="bad">异常</el-radio>
</el-radio-group>
</div>
<div v-if="item.result === 'bad'" class="issue-area">
<el-input v-model="item.issueDesc" type="textarea" :rows="2" placeholder="请描述问题" />
<el-upload class="mt-8px" action="" :http-request="handleUpload" list-type="picture-card"
:file-list="item.photos"
:on-success="(response, file, fileList) => onUploadSuccess(response, file, fileList, item)"
:on-remove="(file, list) => onRemove(file, list, item)" :on-preview="(file) => onPreview(file)"
:limit="10" multiple accept="image/*">
<el-icon>
<Plus />
</el-icon>
</el-upload>
</div>
</div>
</div>
<div class="actions">
<el-button v-if="!isBatchMode" @click="saveProgress" :loading="saving">保存进度</el-button>
<el-button type="primary" @click="submit" :loading="submitting">{{ isBatchMode ? '批量提交点检' : '提交点检'
}}</el-button>
</div>
</el-card>
</div>
<!-- 图片预览对话框 -->
<el-dialog v-model="previewVisible" title="图片预览" width="80%" center>
<img :src="previewImageUrl" style="width: 100%; max-height: 70vh; object-fit: contain;" />
</el-dialog>
</template>
<script lang="ts" setup>
import { Plus } from '@element-plus/icons-vue'
import { CheckLogApi } from '@/api/iot/check/log'
import { ProductApi } from '@/api/iot/product/product'
import { ProductCategoryApi } from '@/api/iot/product/category'
import { InspectionRecordApi } from '@/api/iot/inspection/record'
import { InspectionInstanceApi } from '@/api/iot/inspection/instance'
const route = useRoute()
const router = useRouter()
const deviceId = computed(() => route.params.deviceId as string)
const categoryName = ref<string>('')
const isBatchMode = computed(() => (route.query.batch as string) === '1')
const loading = ref(true)
const saving = ref(false)
const submitting = ref(false)
// 当前任务实例信息(单实例模式)
const currentInstance = ref<any>(null)
// 批量模式的实例列表
const batchInstances = ref<any[]>([])
// 防重复加载标志
const isLoaded = ref(false)
// 图片预览相关
const previewVisible = ref(false)
const previewImageUrl = ref('')
interface ExecItem {
code: string
name: string
desc?: string
result?: 'ok' | 'bad'
issueDesc?: string
photos: any[]
}
const items = ref<ExecItem[]>([])
// 批量模式:为每个实例维护独立点检项
const instanceIdToItems = ref<Record<string, ExecItem[]>>({})
const activeInstanceId = ref<number>(-1)
const displayItems = computed<ExecItem[]>(() => {
if (!isBatchMode.value) return items.value
if (!activeInstanceId.value || activeInstanceId.value <= 0) return []
const key = String(activeInstanceId.value)
return instanceIdToItems.value[key] || []
})
onMounted(async () => {
// 防重复加载
if (isLoaded.value) {
console.log('🚫 页面已加载,跳过重复初始化')
return
}
isLoaded.value = true
if (!isBatchMode.value) {
// 从sessionStorage获取任务实例信息
const instanceData = sessionStorage.getItem('currentInspectionInstance')
console.log('🔍 从sessionStorage获取的实例数据:', instanceData)
if (instanceData) {
try {
currentInstance.value = JSON.parse(instanceData)
console.log('📋 获取到任务实例信息:', currentInstance.value)
} catch (e) {
console.warn('⚠️ 解析任务实例信息失败:', e)
}
} else {
console.warn('⚠️ sessionStorage中未找到任务实例信息')
// 尝试重新获取任务实例信息
await tryGetCurrentInstance()
}
}
await loadProductInfo()
})
// 尝试重新获取任务实例信息
async function tryGetCurrentInstance() {
try {
console.log('🔄 尝试重新获取任务实例信息设备ID:', deviceId.value)
const response = await InspectionInstanceApi.getCurrentByDevice(deviceId.value)
console.log('📋 重新获取到的任务实例响应:', response)
// 检查响应结构API返回的是 { code: 0, data: [...], msg: "" }
const instances = response.data || response
console.log('📋 解析后的任务实例数据:', instances)
if (instances && instances.length > 0) {
currentInstance.value = instances[0]
console.log('✅ 成功获取任务实例信息:', currentInstance.value)
// 重新存储到sessionStorage
sessionStorage.setItem('currentInspectionInstance', JSON.stringify(currentInstance.value))
} else {
console.warn('⚠️ 该设备当前没有进行中的任务实例')
}
} catch (e: any) {
console.error('❌ 重新获取任务实例失败:', e)
}
}
async function loadProductInfo() {
try {
console.log('🔍 CheckExecute: 开始加载产品信息ID:', deviceId.value)
const product = await ProductApi.getProduct(Number(deviceId.value))
console.log('📱 CheckExecute: 获取到产品信息:', product)
categoryName.value = (product as any)?.categoryName as string
if (!categoryName.value) {
ElMessage.warning('设备未设置分类,无法匹配点检模板')
return
}
// 保存分类ID用于后续获取模板
const categoryId = (product as any)?.categoryId
if (!categoryId) {
ElMessage.warning('产品未设置分类ID无法获取点检模板')
return
}
if (isBatchMode.value) {
await loadBatchInstances(product.id)
}
// 直接获取分类详情和模板(无论是否批量模式,模板相同)
await loadTemplate(categoryId)
} catch (e: any) {
ElMessage.error('获取设备信息失败:' + (e?.message || '设备不存在'))
}
}
async function loadTemplate(categoryId: number) {
loading.value = true
try {
console.log('🏷️ CheckExecute: 开始加载模板分类ID:', categoryId)
// 直接获取分类详情和模板
const category = await ProductCategoryApi.getProductCategory(categoryId)
console.log('📋 CheckExecute: 获取到分类信息:', category)
if (!category) {
ElMessage.warning(`未找到分类信息`)
return
}
// 检查是否有模板
const templateField = category.inspectTemplate || category.inspectionTemplate || category.template
if (!templateField || templateField.trim() === '') {
ElMessage.warning(`未找到"${categoryName.value}"分类的点检模板`)
return
}
let templateItems: any[] = []
try {
templateItems = JSON.parse(templateField)
} catch (e) {
ElMessage.error('模板格式错误')
return
}
if (!templateItems.length) {
ElMessage.warning(`"${categoryName.value}"分类的点检模板为空`)
return
}
const baseItems: ExecItem[] = templateItems.map((item: any, idx: number) => ({
code: `CHK-${String(idx + 1).padStart(3, '0')}`,
name: item.name,
desc: item.desc,
result: 'ok',
issueDesc: '',
photos: []
}))
items.value = baseItems
// 批量模式:为每个实例复制一份独立表单数据
if (isBatchMode.value && batchInstances.value.length > 0) {
const map: Record<string, ExecItem[]> = {}
for (const inst of batchInstances.value) {
map[String(inst.id)] = baseItems.map(b => ({ ...b, photos: [] }))
}
instanceIdToItems.value = map
activeInstanceId.value = batchInstances.value[0]?.id || -1
}
} finally {
loading.value = false
}
}
// 批量模式根据产品ID查询同分类进行中的实例
async function loadBatchInstances(productId: number) {
try {
console.log('🔍 批量模式按产品ID查询同分类实例productId:', productId)
const resp = await InspectionInstanceApi.getCurrentByProductCategory(productId)
const instances = (resp as any)?.data || resp || []
batchInstances.value = Array.isArray(instances) ? instances : []
console.log('📋 批量实例列表:', batchInstances.value)
if (batchInstances.value.length === 0) {
ElMessage.warning('未找到同分类进行中的任务实例,将按单设备执行')
}
} catch (e: any) {
console.error('❌ 加载批量实例失败:', e)
ElMessage.error('加载同分类实例失败:' + (e?.message || '未知错误'))
}
}
function onResultChange(item: ExecItem) {
if (item.result !== 'bad') {
item.issueDesc = ''
item.photos = []
}
}
async function handleUpload(options: any) {
const { file, onSuccess, onError } = options
try {
const res = await CheckLogApi.uploadImage(file as File)
// 传递完整的响应对象,包含 url, name, size, type
onSuccess(res)
} catch (e) {
onError(e)
}
}
function onUploadSuccess(response: any, file: any, fileList: any[], item: ExecItem) {
// 更新对应项目的图片列表使用服务器返回的真实URL
item.photos = fileList.map(f => ({
...f,
url: f.response?.url || f.url, // 优先使用服务器返回的URL
name: f.response?.name || f.name,
size: f.response?.size || f.size,
type: f.response?.type || f.type
}))
console.log('📸 图片上传成功:', response, '更新后的图片列表:', item.photos)
}
function onRemove(_: any, list: any[], item: ExecItem) {
item.photos = list
}
// 图片预览
function onPreview(file: any) {
previewImageUrl.value = file.url || file.response?.url
previewVisible.value = true
}
async function saveProgress() {
if (!currentInstance.value) {
console.warn('⚠️ 当前没有任务实例信息,尝试重新获取...')
// 尝试重新获取任务实例
await tryGetCurrentInstance()
if (!currentInstance.value) {
ElMessage.error('未找到任务实例信息,无法保存进度。请返回扫码页面重新扫描设备。')
return
}
}
saving.value = true
try {
const payload = buildInspectionRecordPayloadForSingle()
// 保存进度时将状态设为NORMAL临时保存
payload.resultStatus = 'NORMAL' as const
console.log('💾 保存点检进度:', payload)
await InspectionRecordApi.createInspectionRecord(payload)
ElMessage.success('已保存进度')
} catch (e: any) {
console.error('❌ 保存进度失败:', e)
ElMessage.error('保存失败:' + (e?.message || '未知错误'))
} finally {
saving.value = false
}
}
async function submit() {
if (!items.value.length) {
ElMessage.warning('暂无点检项')
return
}
if (!isBatchMode.value) {
// 非批量:检查单实例存在
if (!currentInstance.value) {
console.warn('⚠️ 当前没有任务实例信息,尝试重新获取...')
await tryGetCurrentInstance()
if (!currentInstance.value) {
ElMessage.error('未找到任务实例信息,无法提交点检记录。请返回扫码页面重新扫描设备。')
return
}
}
} else {
// 批量:需要有批量实例
if (!batchInstances.value.length) {
ElMessage.error('未找到同分类进行中的任务实例,无法批量提交')
return
}
}
submitting.value = true
try {
if (!isBatchMode.value) {
const payload = buildInspectionRecordPayloadForSingle()
console.log('📤 提交点检记录:', payload)
const result = await InspectionRecordApi.createInspectionRecord(payload)
console.log('✅ 点检记录创建成功:', result)
ElMessage.success('提交成功')
goBack()
} else {
const batchPayload = buildInspectionRecordPayloadForBatch()
console.log('📤 批量提交点检记录:', batchPayload)
const result = await InspectionRecordApi.createInspectionRecordBatchMixed(batchPayload)
console.log('✅ 批量点检记录创建成功:', result)
ElMessage.success('批量提交成功')
goBack()
}
} catch (e: any) {
console.error('❌ 提交点检记录失败:', e)
ElMessage.error('提交失败:' + (e?.message || '未知错误'))
} finally {
submitting.value = false
}
}
function buildInspectionRecordBaseSummary(targetItems: ExecItem[]) {
const abnormalItems = targetItems.filter(i => i.result === 'bad')
const hasAbnormal = abnormalItems.length > 0
// 构建简化的数据结构,只保留有用信息
const inspectionData = targetItems.map(item => {
const baseItem = {
name: item.name,
desc: item.desc,
status: item.result === 'ok' ? 'NORMAL' : 'ABNORMAL'
}
// 如果是异常项目,添加异常原因和图片
if (item.result === 'bad') {
return {
...baseItem,
abnormal_reason: item.issueDesc || '',
picurl: item.photos && item.photos.length > 0
? item.photos.map((photo: any) => photo.url).join(',')
: ''
}
}
return baseItem
})
// 将数据转换为JSON字符串作为结果摘要
const resultSummary = JSON.stringify(inspectionData, null, 2)
return { resultSummary, hasAbnormal }
}
function buildInspectionRecordPayloadForSingle() {
const base = buildInspectionRecordBaseSummary(items.value)
return {
instanceId: currentInstance.value.id,
resultStatus: (base.hasAbnormal ? 'ABNORMAL' : 'NORMAL') as 'ABNORMAL' | 'NORMAL',
resultSummary: base.resultSummary,
submitterUserId: currentInstance.value.assigneeUserId || '1001',
submitterUserName: currentInstance.value.assigneeUserName || '当前用户'
}
}
function buildInspectionRecordPayloadForBatch() {
const itemsPayload = batchInstances.value.map(inst => {
const perItems = instanceIdToItems.value[String(inst.id)] || items.value
const base = buildInspectionRecordBaseSummary(perItems)
return {
instanceId: inst.id,
resultStatus: (base.hasAbnormal ? 'ABNORMAL' : 'NORMAL') as 'ABNORMAL' | 'NORMAL',
resultSummary: base.resultSummary
}
})
// 取第一条实例的指派信息作为默认提交人,若后端需要认证用户可从上下文替换
const submitterUserId = (batchInstances.value[0]?.assigneeUserId) || '1001'
const submitterUserName = (batchInstances.value[0]?.assigneeUserName) || '当前用户'
return {
items: itemsPayload,
submitterUserId,
submitterUserName
}
}
function goBack() {
// 返回公开扫码页
router.replace({ name: 'IoTCheckScanPublic' })
}
// 手动刷新任务实例
async function refreshInstance() {
try {
ElMessage.info('正在刷新任务实例...')
await tryGetCurrentInstance()
if (currentInstance.value) {
ElMessage.success('任务实例刷新成功')
} else {
ElMessage.warning('未找到任务实例,请确保设备有进行中的任务')
}
} catch (e: any) {
ElMessage.error('刷新失败:' + (e?.message || '未知错误'))
}
}
</script>
<style scoped>
.exec-page {
padding: 12px;
height: 100vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
box-sizing: border-box;
}
.exec-header {
margin-bottom: 12px;
}
.title {
font-size: 18px;
font-weight: 600;
}
.sub {
color: #666;
font-size: 12px;
}
.card-header {
font-weight: 600;
}
.item-row {
border-bottom: 1px solid #f0f0f0;
padding: 12px 0;
}
.item-left {
margin-bottom: 8px;
}
.item-name {
font-weight: 600;
}
.item-desc {
color: #666;
font-size: 12px;
margin-top: 4px;
}
.item-right {
margin: 8px 0;
}
.issue-area {
margin-top: 8px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 12px;
}
.text-center {
text-align: center;
}
.py-20 {
padding: 20px 0;
}
.text-gray-500 {
color: #909399;
}
.mt-8px {
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,493 @@
<template>
<div class="check-plan-container">
<!-- 页面标题 -->
<div class="page-header">
<h2 class="page-title">设备点检计划管理</h2>
<div class="header-actions">
<el-button type="primary" @click="handleAdd">
<Icon icon="mdi:plus" class="mr-4px" />
新增点检计划
</el-button>
</div>
</div>
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<el-form :model="searchForm" inline>
<el-form-item label="计划编号">
<el-input v-model="searchForm.planCode" placeholder="请输入计划编号" clearable />
</el-form-item>
<el-form-item label="计划名称">
<el-input v-model="searchForm.planName" placeholder="请输入计划名称" clearable />
</el-form-item>
<el-form-item label="设备名称">
<el-select v-model.number="searchForm.equipmentId" filterable clearable placeholder="请选择设备"
style="width: 140px">
<el-option v-for="item in deviceList" :key="item.id" :label="item.deviceName" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable style="width: 120px">
<el-option label="草稿" value="draft" />
<el-option label="启用" value="active" />
<el-option label="暂停" value="paused" />
<el-option label="归档" value="archived" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<Icon icon="mdi:magnify" class="mr-4px" />
搜索
</el-button>
<el-button @click="handleReset">
<Icon icon="mdi:refresh" class="mr-4px" />
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 数据表格 -->
<el-card shadow="never" class="table-card" :body-style="{ padding: '0' }">
<el-table v-loading="loading" :data="tableData" border stripe style="width: 100%">
<el-table-column prop="planCode" label="计划编号" width="180" />
<el-table-column prop="planName" label="计划名称" />
<el-table-column prop="equipmentName" label="设备名称" />
<el-table-column prop="cycleType" label="周期类型" width="100">
<template #default="{ row }">
{{ cycleTypeMap[row.cycleType] || row.cycleType }}
</template>
</el-table-column>
<el-table-column prop="cycleValue" label="周期数值" width="100" />
<el-table-column prop="startDate" label="开始日期" width="110" />
<el-table-column prop="endDate" label="结束日期" width="110" />
<el-table-column prop="timeWindow" label="时间段" width="110" />
<el-table-column prop="assignee" label="负责人" width="100" />
<el-table-column prop="assigneePhone" label="负责人手机号" width="120" />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)">
{{ statusMap[row.status] || row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="text" @click="handleEdit(row)">
<Icon icon="mdi:pencil" class="mr-4px" />编辑
</el-button>
<el-button type="text" @click="handleView(row)">
<Icon icon="mdi:eye" class="mr-4px" />查看
</el-button>
<el-button type="text" @click="handleDelete(row)" class="text-red-500">
<Icon icon="mdi:delete" class="mr-4px" />删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrapper">
<el-pagination v-model:current-page="pagination.current" v-model:page-size="pagination.size"
:page-sizes="[10, 20, 50, 100]" :total="pagination.total" layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange" @current-change="handleCurrentChange" />
</div>
</el-card>
<!-- 新增/编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" :close-on-click-modal="false"
@close="handleDialogClose">
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px">
<el-form-item label="计划编号" prop="planCode">
<el-input v-model="formData.planCode" placeholder="如CHK-2024-001" />
</el-form-item>
<el-form-item label="计划名称" prop="planName">
<el-input v-model="formData.planName" placeholder="请输入计划名称" />
</el-form-item>
<el-form-item label="设备名称" prop="equipmentName">
<el-select v-model.number="formData.equipmentName" filterable clearable placeholder="请选择设备"
@change="onDeviceChange">
<el-option v-for="item in deviceList" :key="item.id" :label="item.deviceName" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="周期类型" prop="cycleType">
<el-select v-model="formData.cycleType" placeholder="请选择周期类型">
<el-option v-for="(label, value) in cycleTypeMap" :key="value" :label="label" :value="value" />
</el-select>
</el-form-item>
<el-form-item label="周期数值" prop="cycleValue">
<el-input-number v-model="formData.cycleValue" :min="1" />
</el-form-item>
<el-form-item label="开始日期" prop="startDate">
<el-date-picker v-model="formData.startDate" type="date" placeholder="选择日期" />
</el-form-item>
<el-form-item label="结束日期" prop="endDate">
<el-date-picker v-model="formData.endDate" type="date" placeholder="选择日期" />
</el-form-item>
<el-form-item label="时间段" prop="timeWindow">
<el-input v-model="formData.timeWindow" placeholder="如08:00-17:00" />
</el-form-item>
<el-form-item label="点检项目" prop="checkItems">
<el-input v-model="formData.checkItems" placeholder="请输入点检项目" />
</el-form-item>
<el-form-item label="SOP文档" prop="sopReference">
<el-input v-model="formData.sopReference" placeholder="操作规范文档链接" />
</el-form-item>
<el-form-item label="负责人" prop="assignee">
<el-input v-model="formData.assignee" placeholder="请输入负责人" />
</el-form-item>
<el-form-item label="负责人手机号" prop="assigneePhone">
<el-input v-model="formData.assigneePhone" placeholder="请输入负责人手机号" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="formData.status" placeholder="请选择状态">
<el-option v-for="(label, value) in statusMap" :key="value" :label="label" :value="value" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleDialogClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 查看详情对话框 -->
<el-dialog v-model="viewDialogVisible" title="点检计划详情" width="500px">
<div v-if="viewData" class="view-content">
<el-descriptions :column="1" border>
<el-descriptions-item label="计划编号">{{ viewData.planCode }}</el-descriptions-item>
<el-descriptions-item label="计划名称">{{ viewData.planName }}</el-descriptions-item>
<el-descriptions-item label="设备名称">{{ viewData.equipmentName }}</el-descriptions-item>
<el-descriptions-item label="周期类型">{{ cycleTypeMap[viewData.cycleType] || viewData.cycleType
}}</el-descriptions-item>
<el-descriptions-item label="周期数值">{{ viewData.cycleValue }}</el-descriptions-item>
<el-descriptions-item label="开始日期">{{ viewData.startDate }}</el-descriptions-item>
<el-descriptions-item label="结束日期">{{ viewData.endDate }}</el-descriptions-item>
<el-descriptions-item label="时间段">{{ viewData.timeWindow }}</el-descriptions-item>
<el-descriptions-item label="点检项目">{{ viewData.checkItems }}</el-descriptions-item>
<el-descriptions-item label="SOP文档">{{ viewData.sopReference }}</el-descriptions-item>
<el-descriptions-item label="负责人">{{ viewData.assignee }}</el-descriptions-item>
<el-descriptions-item label="负责人手机号">{{ viewData.assigneePhone }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusTagType(viewData.status)">
{{ statusMap[viewData.status] || viewData.status }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(viewData.createTime) }}</el-descriptions-item>
<el-descriptions-item label="最后更新时间">{{ formatDate(viewData.updateTime) }}</el-descriptions-item>
</el-descriptions>
</div>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
import { RepairOrderApi } from '@/api/iot/maintain/repairOrder'
import { CheckPlanApi, CheckPlanVO } from '@/api/iot/check/plan'
// import * as CheckPlanApi from '@/api/iot/check/plan' // 你需要实现对应的API文件
const cycleTypeMap = {
daily: '每日',
weekly: '每周',
monthly: '每月',
quarterly: '每季度',
yearly: '每年',
custom: '自定义',
}
const statusMap = {
draft: '草稿',
active: '启用',
paused: '暂停',
archived: '归档',
}
const statusTagType = (status: string) => {
switch (status) {
case 'active': return 'success'
case 'paused': return 'warning'
case 'archived': return 'info'
default: return 'primary'
}
}
// 搜索表单
const searchForm = reactive({
planCode: '',
planName: '',
equipmentId: '',
status: ''
})
// 分页
const pagination = reactive({
current: 1,
size: 10,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
const loading = ref(false)
// 对话框相关
const dialogVisible = ref(false)
const viewDialogVisible = ref(false)
const dialogType = ref<'add' | 'edit'>('add')
const submitLoading = ref(false)
// 表单相关
const formRef = ref<FormInstance>()
const formData = reactive({
planId: '',
planCode: '',
planName: '',
equipmentId: '',
equipmentName: '',
cycleType: 'monthly',
cycleValue: 1,
startDate: '',
endDate: '',
timeWindow: '08:00-17:00',
checkItems: '',
sopReference: '',
assignee: '',
assigneePhone: '',
status: 'active',
})
// 表单验证规则
const formRules = {
planCode: [{ required: true, message: '请输入计划编号', trigger: 'blur' }],
planName: [{ required: true, message: '请输入计划名称', trigger: 'blur' }],
equipmentName: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
cycleType: [{ required: true, message: '请选择周期类型', trigger: 'change' }],
cycleValue: [{ required: true, message: '请输入周期数值', trigger: 'blur' }],
startDate: [{ required: true, message: '请选择开始日期', trigger: 'change' }],
timeWindow: [{ required: true, message: '请输入时间段', trigger: 'blur' }],
checkItems: [{ required: true, message: '请输入点检项目', trigger: 'blur' }],
assignee: [{ required: true, message: '请输入负责人', trigger: 'blur' }],
assigneePhone: [{ required: true, message: '请输入负责人手机号', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}
// 查看相关
const viewData = ref<any | null>(null)
// 设备列表
const deviceList = ref<any[]>([])
const dialogTitle = computed(() => {
return dialogType.value === 'add' ? '新增点检计划' : '编辑点检计划'
})
const handleSearch = () => {
pagination.current = 1
fetchData()
}
const handleReset = () => {
Object.assign(searchForm, {
planCode: '',
planName: '',
equipmentId: '',
status: ''
})
handleSearch()
}
// 获取数据
const fetchData = async () => {
loading.value = true
try {
const params = {
pageNo: pagination.current,
pageSize: pagination.size,
...searchForm
}
const res = await CheckPlanApi.getCheckPlanPage(params)
tableData.value = res.list
pagination.total = res.total
} catch (error) {
ElMessage.error('获取数据失败')
} finally {
loading.value = false
}
}
const handleSizeChange = (size: number) => {
pagination.size = size
fetchData()
}
const handleCurrentChange = (current: number) => {
pagination.current = current
fetchData()
}
const handleAdd = () => {
dialogType.value = 'add'
resetForm()
dialogVisible.value = true
}
const handleEdit = (row: any) => {
dialogType.value = 'edit'
Object.assign(formData, row)
dialogVisible.value = true
}
const handleView = (row: any) => {
viewData.value = row
viewDialogVisible.value = true
}
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm('确定要删除该点检计划吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await CheckPlanApi.deleteCheckPlan(row.planId)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
const resetForm = () => {
Object.assign(formData, {
planId: '',
planCode: '',
planName: '',
equipmentId: '',
equipmentName: '',
cycleType: 'monthly',
cycleValue: 1,
startDate: '',
endDate: '',
timeWindow: '08:00-17:00',
checkItems: '',
sopReference: '',
assignee: '',
assigneePhone: '',
status: 'active',
})
formRef.value?.clearValidate()
}
const handleDialogClose = () => {
dialogVisible.value = false
resetForm()
}
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
submitLoading.value = true
const submitData = { ...formData } as CheckPlanVO
if (dialogType.value === 'add') {
await CheckPlanApi.createCheckPlan(submitData)
ElMessage.success('新增成功')
} else {
await CheckPlanApi.updateCheckPlan(submitData)
ElMessage.success('编辑成功')
}
dialogVisible.value = false
fetchData()
} catch (error) {
ElMessage.error('提交失败')
} finally {
submitLoading.value = false
}
}
// 设备选择后自动填充设备名称
const onDeviceChange = (id: number) => {
const device = deviceList.value.find(d => d.id === id)
debugger;
formData.equipmentName = device.deviceName
}
// 获取设备列表(来自维修接口)
const fetchDeviceList = async () => {
try {
const res = await RepairOrderApi.getDeviceList()
deviceList.value = res || []
} catch (e) {
deviceList.value = []
}
}
// 时间戳转日期字符串
function formatDate(ts: number | string | undefined) {
if (!ts) return ''
const d = new Date(Number(ts))
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
}
onMounted(() => {
fetchDeviceList()
fetchData()
})
</script>
<style scoped>
.check-plan-container {
width: 100vw;
max-width: 88vw;
padding: 20px;
box-sizing: border-box;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header-actions {
display: flex;
gap: 12px;
}
.page-title {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #303133;
}
.search-card {
width: 100%;
max-width: 100%;
margin-bottom: 20px;
}
.table-card {
width: 100%;
max-width: 100%;
padding: 0;
margin-bottom: 20px;
overflow-x: auto;
}
.el-table {
width: 100%;
min-width: 1200px;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.dialog-footer {
text-align: right;
}
.view-content {
padding: 20px 0;
}
</style>

View File

@@ -0,0 +1,244 @@
<template>
<el-dialog v-model="visibleInner" :title="dialogTitle" width="820px" :close-on-click-modal="false">
<div class="sub mb-10px">分类{{ categoryName || '-' }}</div>
<el-card shadow="never">
<template #header>
<div class="card-header flex items-center justify-between">
<span>检查项目</span>
<div class="flex items-center gap-8px">
<el-button size="small" type="primary" plain @click="addItem">新增项目</el-button>
</div>
</div>
</template>
<div v-if="localItems.length === 0" class="text-gray-500">暂无项目点击"新增项目"添加</div>
<div v-for="(item, idx) in localItems" :key="item.code" class="item-row">
<div class="item-left">
<div class="item-name flex items-center">
<span class="mr-8px">{{ idx + 1 }}.</span>
<el-input v-model="item.name" placeholder="检查项名称" size="small" style="width: 240px; margin-right: 12px;" />
<el-input v-model="item.desc" placeholder="检查项说明(可选)" size="small" style="width: 480px;" />
</div>
</div>
<div class="item-right">
<el-radio-group v-model="item.result" @change="onResultChange(item)">
<el-radio label="ok">正常</el-radio>
<el-radio label="bad">异常</el-radio>
</el-radio-group>
<div class="inline-actions ml-12px">
<el-button size="small" text @click="moveUp(idx)" :disabled="idx === 0">上移</el-button>
<el-button size="small" text @click="moveDown(idx)" :disabled="idx === localItems.length - 1">下移</el-button>
<el-button size="small" text type="danger" @click="removeItem(idx)">删除</el-button>
</div>
</div>
<div v-if="item.result === 'bad'" class="issue-area">
<el-input v-model="item.issueDesc" type="textarea" :rows="2" placeholder="请描述异常" />
<el-upload
class="mt-8px"
action=""
:http-request="handleUpload"
list-type="picture-card"
:file-list="item.photos"
:on-remove="(file, list) => onRemove(file, list, item)"
:on-preview="(file) => onPreview(file)"
:limit="10"
multiple
accept="image/*">
<el-icon>
<Plus />
</el-icon>
</el-upload>
</div>
</div>
</el-card>
<template #footer>
<div class="dialog-footer">
<el-button @click="close">取消</el-button>
<el-button type="primary" @click="save">保存</el-button>
</div>
</template>
</el-dialog>
<!-- 图片预览对话框 -->
<el-dialog v-model="previewVisible" title="图片预览" width="80%" center>
<img :src="previewImageUrl" style="width: 100%; max-height: 70vh; object-fit: contain;" />
</el-dialog>
</template>
<script lang="ts" setup>
import { Plus } from '@element-plus/icons-vue'
import { CheckLogApi } from '@/api/iot/check/log'
const props = defineProps<{
modelValue?: boolean
categoryName?: string
items?: Array<{ name: string; desc?: string }>
title?: string
}>()
const emit = defineEmits(['update:modelValue', 'save'])
const visibleInner = computed({
get: () => !!props.modelValue,
set: (v: boolean) => emit('update:modelValue', v)
})
const dialogTitle = computed(() => props.title || '新增模板')
interface ExecItem {
code: string
name: string
desc?: string
result?: 'ok' | 'bad'
issueDesc?: string
photos: any[]
}
const localItems = ref<ExecItem[]>([])
// 图片预览相关
const previewVisible = ref(false)
const previewImageUrl = ref('')
// 初始化本地数据
const initLocalItems = () => {
const defs = (props.items || []).length ? props.items! : []
localItems.value = defs.map((def, idx) => ({
code: `CHK-${String(idx + 1).padStart(3, '0')}`,
name: def.name,
desc: def.desc,
result: 'ok',
issueDesc: '',
photos: []
}))
}
onMounted(() => {
initLocalItems()
})
// 监听props.items变化用于编辑时回显数据
watch(() => props.items, () => {
initLocalItems()
}, { deep: true })
function addItem() {
const nextIndex = localItems.value.length + 1
localItems.value.push({
code: `CHK-${String(nextIndex).padStart(3, '0')}`,
name: '',
desc: '',
result: 'ok',
issueDesc: '',
photos: []
})
}
function removeItem(idx: number) {
localItems.value.splice(idx, 1)
// 重排 code
localItems.value.forEach((it, i) => (it.code = `CHK-${String(i + 1).padStart(3, '0')}`))
}
function moveUp(i: number) {
if (i <= 0) return
const t = localItems.value[i - 1]
localItems.value[i - 1] = localItems.value[i]
localItems.value[i] = t
}
function moveDown(i: number) {
if (i >= localItems.value.length - 1) return
const t = localItems.value[i + 1]
localItems.value[i + 1] = localItems.value[i]
localItems.value[i] = t
}
function onResultChange(item: ExecItem) {
if (item.result !== 'bad') {
item.issueDesc = ''
item.photos = []
}
}
async function handleUpload(options: any) {
const { file, onSuccess, onError } = options
try {
const res = await CheckLogApi.uploadImage(file as File)
onSuccess({ url: res.url, name: res.name })
} catch (e) {
onError(e)
}
}
function onRemove(_: any, list: any[], item: ExecItem) {
item.photos = list
}
// 图片预览
function onPreview(file: any) {
previewImageUrl.value = file.url || file.response?.url
previewVisible.value = true
}
function save() {
const payload = localItems.value.map(it => ({ name: (it.name || '').trim(), desc: (it.desc || '').trim() }))
if (!payload.length) {
ElMessage.warning('请至少新增一个检查项')
return
}
emit('save', payload)
visibleInner.value = false
}
function close() {
visibleInner.value = false
}
</script>
<style scoped>
.sub {
color: #666;
font-size: 12px;
}
.card-header {
font-weight: 600;
}
.item-row {
border-bottom: 1px solid #f0f0f0;
padding: 12px 0;
}
.item-left {
margin-bottom: 8px;
}
.item-name {
font-weight: 600;
}
.item-desc {
color: #666;
font-size: 12px;
margin-top: 4px;
}
.item-right {
margin: 8px 0;
}
.issue-area {
margin-top: 8px;
}
.text-gray-500 {
color: #909399;
}
.hint {
color: #909399;
font-size: 12px;
}
.mt-8px {
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,444 @@
<template>
<div class="scan-page">
<div class="scan-header">设备点检扫码</div>
<div class="scan-box">
<div class="tip">将二维码置于取景框内自动识别或手动输入设备ID</div>
<div class="camera-area">
<video ref="videoRef" playsinline autoplay muted class="camera-video"></video>
<canvas ref="canvasRef" class="hidden-canvas"></canvas>
<div class="scan-frame"></div>
</div>
<div class="row mt-8px">
<el-button size="small" @click="toggleCamera" :loading="cameraLoading">{{ cameraOn ? '关闭相机' : '开启相机'
}}</el-button>
<el-button size="small" @click="switchFacing" :disabled="!cameraOn">切换前后摄</el-button>
</div>
<el-input v-model.trim="manualId" placeholder="请输入设备ID" inputmode="numeric" class="mt-12px" clearable />
<el-checkbox v-model="batchMode" class="mt-8px">批量执行同分类任务</el-checkbox>
<el-button type="primary" class="mt-12px w-full" @click="go" :loading="loading">开始点检</el-button>
</div>
<div class="footer-actions">
<el-button text type="primary" @click="openHelp">使用说明</el-button>
<el-button text type="info" @click="toggleDebug">{{ showDebug ? '隐藏调试' : '显示调试' }}</el-button>
</div>
<!-- 调试信息面板 -->
<div v-if="showDebug" class="debug-panel">
<div class="debug-title">🔍 调试信息</div>
<div class="debug-content">
<div><strong>摄像头状态:</strong> {{ cameraOn ? '已开启' : '未开启' }}</div>
<div><strong>扫描状态:</strong> {{ scanningStatus }}</div>
<div><strong>设备ID:</strong> {{ manualId || '未输入' }}</div>
<div><strong>浏览器支持:</strong></div>
<div class="debug-indent">- MediaDevices: {{ browserSupport.mediaDevices ? '✅' : '❌' }}</div>
<div class="debug-indent">- BarcodeDetector: {{ browserSupport.barcodeDetector ? '✅' : '❌' }}</div>
<div class="debug-indent">- @zxing/browser: {{ zxingLoaded ? '✅' : '❌' }}</div>
<div><strong>最后扫描内容:</strong> {{ lastScannedContent || '无' }}</div>
<div><strong>扫描次数:</strong> {{ scanCount }}</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ProductApi } from '@/api/iot/product/product'
import { ProductCategoryApi } from '@/api/iot/product/category'
import { InspectionInstanceApi } from '@/api/iot/inspection/instance'
const router = useRouter()
const route = useRoute()
const message = useMessage()
const manualId = ref<string>('')
const loading = ref(false)
const showDebug = ref(false)
const scanningStatus = ref('未开始')
const lastScannedContent = ref('')
const scanCount = ref(0)
const zxingLoaded = ref(false)
const batchMode = ref(false)
// 防重复提交标志
const isProcessing = ref(false)
// 计算属性用于模板中的浏览器支持检测
const browserSupport = computed(() => ({
mediaDevices: !!globalThis.navigator?.mediaDevices,
barcodeDetector: !!(globalThis as any).BarcodeDetector
}))
const videoRef = ref<HTMLVideoElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)
const cameraOn = ref(false)
const cameraLoading = ref(false)
let mediaStream: MediaStream | null = null
let facingMode: 'environment' | 'user' = 'environment'
let stopScanLoop: (() => void) | null = null
onMounted(() => {
const did = (route.query.deviceId as string) || ''
if (did) {
manualId.value = did
}
const batch = (route.query.batch as string) || ''
batchMode.value = batch === '1'
})
onBeforeUnmount(() => {
stopCamera()
})
// 通用的设备验证和模板检查函数
const validateDeviceAndTemplate = async (deviceId: string) => {
// 防重复处理
if (isProcessing.value) {
console.log('🚫 正在处理中,跳过重复请求')
return false
}
isProcessing.value = true
loading.value = true
try {
console.log('🔍 开始验证设备ID:', deviceId)
// 获取设备(产品)信息
const product = await ProductApi.getProduct(Number(deviceId))
console.log('📱 获取到产品信息:', product)
const categoryName = (product as any)?.categoryName as string
if (!categoryName) {
message.warning('设备未设置分类,无法匹配点检模板')
return false
}
// 检查是否有对应的模板
const categoryId = (product as any)?.categoryId
if (!categoryId) {
message.warning('产品未设置分类,无法匹配点检模板')
return false
}
console.log('🏷️ 获取分类信息ID:', categoryId)
const category = await ProductCategoryApi.getProductCategory(categoryId)
console.log('📋 获取到分类信息:', category)
// 检查是否有模板
const templateField = category.inspectTemplate || category.inspectionTemplate || category.template
if (!templateField || templateField.trim() === '') {
message.warning(`未找到"${categoryName}"分类的点检模板,请联系管理员配置`)
return false
}
// 获取设备当前进行中的任务实例
let currentInstance: any = null
try {
const response = await InspectionInstanceApi.getCurrentByDevice(deviceId)
console.log('📋 设备任务实例响应:', response)
// 检查响应结构API返回的是 { code: 0, data: [...], msg: "" }
const instances = response.data || response
console.log('📋 解析后的任务实例数据:', instances)
if (instances && instances.length > 0) {
currentInstance = instances[0]
console.log('🎯 当前任务ID:', currentInstance?.taskId)
console.log('📝 任务名称:', currentInstance?.taskName)
console.log('👤 指派用户:', currentInstance?.assigneeUserName)
console.log('📊 实例状态:', currentInstance?.status)
console.log('🕐 窗口时间:', currentInstance?.windowStartTime, '~', currentInstance?.windowEndTime)
// 将实例信息存储到sessionStorage供CheckExecute页面使用
sessionStorage.setItem('currentInspectionInstance', JSON.stringify(currentInstance))
} else {
console.log(' 该设备当前没有进行中的任务实例')
// 清除之前的实例信息
sessionStorage.removeItem('currentInspectionInstance')
}
} catch (e: any) {
console.warn('⚠️ 获取任务实例失败:', e?.message || '未知错误')
// 清除之前的实例信息
sessionStorage.removeItem('currentInspectionInstance')
}
// 跳转到点检执行页面(公开无布局路由)
router.replace({
name: 'IoTCheckExecutePublic',
params: { deviceId },
query: batchMode.value ? { batch: '1' } : {}
})
return true
} catch (e: any) {
message.error('获取设备信息失败:' + (e?.message || '设备不存在'))
return false
} finally {
loading.value = false
isProcessing.value = false
}
}
const go = async () => {
if (!manualId.value) {
message.warning('请输入设备ID')
return
}
await validateDeviceAndTemplate(manualId.value)
}
const openHelp = () => {
message.info('推荐使用后置摄像头如相机不可用请手动输入设备ID。')
}
const toggleDebug = () => {
showDebug.value = !showDebug.value
}
const toggleCamera = async () => {
if (cameraOn.value) {
stopCamera()
} else {
await startCamera()
}
}
const switchFacing = async () => {
facingMode = facingMode === 'environment' ? 'user' : 'environment'
if (cameraOn.value) {
await startCamera(true)
}
}
async function startCamera(restart = false) {
if (!videoRef.value) return
cameraLoading.value = true
try {
if (restart) stopCamera()
mediaStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode, width: { ideal: 1280 }, height: { ideal: 720 } },
audio: false
})
videoRef.value.srcObject = mediaStream
await videoRef.value.play()
cameraOn.value = true
startScanLoop()
} catch (e: any) {
message.error('无法打开摄像头:' + (e?.message || '请检查浏览器权限'))
} finally {
cameraLoading.value = false
}
}
function stopCamera() {
stopScanLoop && stopScanLoop()
if (mediaStream) {
mediaStream.getTracks().forEach(t => t.stop())
}
mediaStream = null
cameraOn.value = false
}
async function parseAndGo(text: string) {
// 防重复处理
if (isProcessing.value) {
console.log('🚫 正在处理中,跳过重复扫码')
return
}
lastScannedContent.value = text
scanCount.value++
let deviceId = ''
// 检查是否为纯数字产品ID
if (/^\d+$/.test(text)) {
deviceId = text
} else {
// 兼容旧的URL格式
try {
const url = new URL(text)
const did = url.searchParams.get('deviceId') || ''
if (did) {
deviceId = did
}
} catch (e) {
// URL解析失败忽略
}
}
if (deviceId) {
// 立即停止扫描循环,防止重复扫码
stopCamera()
await validateDeviceAndTemplate(deviceId)
} else {
message.warning('无法识别的二维码内容请确保二维码包含有效的产品ID')
}
}
function startScanLoop() {
scanningStatus.value = '扫描中...'
let canceled = false
stopScanLoop = () => {
scanningStatus.value = '已停止'
canceled = true
}
// @ts-ignore
const NativeDetector = (window as any).BarcodeDetector
if (NativeDetector) {
const detector = new NativeDetector({ formats: ['qr_code'] })
let lastTick = 0
const tick = async (ts: number) => {
if (canceled || !videoRef.value || !canvasRef.value) return
if (ts - lastTick < 200) {
requestAnimationFrame(tick)
return
}
lastTick = ts
try {
const video = videoRef.value
const canvas = canvasRef.value
if (video.videoWidth && video.videoHeight) {
if (canvas.width !== video.videoWidth || canvas.height !== video.videoHeight) {
canvas.width = video.videoWidth
canvas.height = video.videoHeight
}
const ctx = canvas.getContext('2d')!
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
const codes = await detector.detect(canvas as any)
// @ts-ignore
if (codes && codes.length) {
const v = (codes[0].rawValue || codes[0].raw || '').toString()
if (v) {
parseAndGo(v)
return
}
}
}
} catch (e) {
// 检测器错误,忽略
}
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
return
}
// 使用@zxing/browser库
; (async () => {
try {
const { BrowserQRCodeReader } = await import('@zxing/browser')
zxingLoaded.value = true
const reader = new BrowserQRCodeReader()
const video = videoRef.value!
const controls = await reader.decodeFromVideoDevice(undefined, video, (result) => {
const text = result?.getText?.()
if (text) parseAndGo(text)
})
stopScanLoop = () => {
controls.stop();
canceled = true
}
} catch (e) {
zxingLoaded.value = false
message.warning('缺少扫码能力请手动输入设备ID')
}
})()
}
</script>
<style scoped>
.scan-page {
padding: 16px;
}
.scan-header {
font-size: 20px;
font-weight: 600;
margin-bottom: 12px;
}
.scan-box {
background: #fff;
border-radius: 8px;
padding: 16px;
}
.tip {
color: #666;
font-size: 13px;
margin-bottom: 12px;
}
.camera-area {
position: relative;
width: 100%;
height: 260px;
background: #000;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.camera-video {
width: 100%;
height: 100%;
object-fit: cover;
}
.hidden-canvas {
display: none;
}
.scan-frame {
position: absolute;
width: 70%;
height: 60%;
border: 2px solid rgba(255, 255, 255, 0.8);
border-radius: 8px;
}
.row {
display: flex;
gap: 8px;
}
.w-full {
width: 100%;
}
.mt-12px {
margin-top: 12px;
}
.mt-8px {
margin-top: 8px;
}
.debug-panel {
margin-top: 16px;
padding: 12px;
background: #f5f5f5;
border-radius: 8px;
border: 1px solid #ddd;
}
.debug-title {
font-weight: 600;
margin-bottom: 8px;
color: #333;
}
.debug-content {
font-size: 12px;
line-height: 1.5;
}
.debug-indent {
margin-left: 16px;
}
</style>

View File

@@ -0,0 +1,484 @@
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form ref="queryFormRef" :inline="true" :model="queryParams" class="-mb-15px" label-width="68px">
<el-form-item label="分类名称" prop="categoryName">
<el-input v-model="queryParams.categoryName" class="!w-240px" clearable placeholder="请输入分类名称"
@keyup.enter="handleQuery" />
</el-form-item>
<el-form-item>
<el-button @click="handleQuery">
<Icon icon="ep:search" class="mr-5px" />
搜索
</el-button>
<el-button @click="resetQuery">
<Icon icon="ep:refresh" class="mr-5px" />
重置
</el-button>
<el-button type="primary" plain @click="openCreate" v-hasPermi="['iot:check-template:create']">
<Icon icon="ep:plus" class="mr-5px" />
新增模板
</el-button>
<el-button type="success" plain @click="exportAll" v-hasPermi="['iot:check-template:export']">
<Icon icon="ep:download" class="mr-5px" />
导出
</el-button>
<el-button type="warning" plain @click="importAll" v-hasPermi="['iot:check-template:import']">
<Icon icon="ep:upload" class="mr-5px" />
导入
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
<el-table-column label="分类名称" align="center" prop="categoryName" />
<el-table-column label="项目数量" align="center" width="120">
<template #default="{ row }">{{ row.items?.length || 0 }}</template>
</el-table-column>
<el-table-column label="最后更新" align="center" width="180">
<template #default="{ row }">{{ formatDate(row.updateTime) }}</template>
</el-table-column>
<el-table-column label="操作" align="center" min-width="200px">
<template #default="{ row }">
<el-button link type="primary" @click="editRow(row)" v-hasPermi="['iot:check-template:update']">
编辑
</el-button>
<el-button link type="success" @click="previewRow(row)" v-hasPermi="['iot:check-template:query']">
预览
</el-button>
<el-button link type="danger" @click="removeRow(row)" v-hasPermi="['iot:check-template:delete']">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination :total="total" v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize"
@pagination="getList" />
</ContentWrap>
<el-dialog v-model="editVisible" :title="editMode === 'create' ? '新增模板' : '编辑模板'" width="520px"
:close-on-click-modal="false">
<el-form :model="form" label-width="120px">
<el-form-item label="分类名称">
<el-select v-model="form.categoryName" filterable placeholder="请选择分类名称" style="width: 100%">
<el-option v-for="c in categoryList" :key="c.id" :label="c.name" :value="c.name" />
</el-select>
</el-form-item>
<el-form-item label="检查项目">
<!-- <div class="text-gray-500 mb-8px">点击下方按钮配置检查项目</div> -->
<el-button type="primary" plain @click="openItemsEditor">配置检查项目</el-button>
<div v-if="form.items.length > 0" class="mt-8px">
<!-- <div class="text-12px text-gray-500 mb-4px">当前配置</div>
<div v-for="(it, i) in form.items" :key="i" class="text-12px">{{ i + 1 }}. {{ it.name }}{{ it.desc ? `
(${it.desc})` : '' }}</div> -->
</div>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" @click="saveRow">保存</el-button>
</div>
</template>
</el-dialog>
<!-- 检查项目配置弹窗 -->
<Execute v-model="itemsEditorVisible" :categoryName="form.categoryName" :items="form.items"
:title="editMode === 'create' ? '新增检查项目' : '编辑检查项目'" @save="onItemsSave" />
<el-dialog v-model="previewVisible" title="模板预览" width="820px">
<div v-if="previewItems.length === 0" class="text-gray">暂无项目</div>
<div v-else>
<div class="preview-header mb-16px">
<div class="text-14px text-gray-500">预览效果展示实际点检时的交互体验</div>
</div>
<el-card shadow="never">
<template #header>
<div class="card-header flex items-center justify-between">
<span>本次点检项目</span>
</div>
</template>
<div v-for="(item, idx) in previewItems" :key="idx" class="item-row">
<div class="item-left">
<div class="item-name flex items-center">
<span class="mr-8px">{{ idx + 1 }}.</span>
<span style="font-weight: 600; color: #1f2937;">{{ item.name }}</span>
<el-text v-if="item.desc" type="info" class="ml-16px text-12px"
style="color: #6b7280; background-color: #f9fafb; padding: 4px 8px; border-radius: 4px; margin-left: 16px;">{{
item.desc }}</el-text>
</div>
</div>
<div class="item-right">
<el-radio-group v-model="previewResults[idx]" @change="onPreviewResultChange(idx)">
<el-radio label="ok">正常</el-radio>
<el-radio label="bad">异常</el-radio>
</el-radio-group>
</div>
<div v-if="previewResults[idx] === 'bad'" class="issue-area">
<el-input v-model="previewIssueDescs[idx]" type="textarea" :rows="2" placeholder="请描述异常" />
<el-upload class="mt-8px" action="" list-type="picture-card" :file-list="previewPhotos[idx]" :limit="10"
multiple accept="image/*" disabled>
<el-icon>
<Plus />
</el-icon>
</el-upload>
</div>
</div>
</el-card>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ProductCategoryApi, ProductCategoryVO } from '@/api/iot/product/category'
import Execute from './Execute.vue'
import { Plus } from '@element-plus/icons-vue'
/** 点检模板管理 */
defineOptions({ name: 'IoTCheckTemplateManage' })
// const message = useMessage() // 消息弹窗
// const { t } = useI18n() // 国际化
interface Row {
id: number
categoryName: string
items: Array<{ name: string; desc?: string }>
updateTime?: string
}
const loading = ref(true) // 列表的加载中
const list = ref<Row[]>([]) // 列表的数据
const total = ref(0) // 列表的总页数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
categoryName: undefined as string | undefined
})
const queryFormRef = ref() // 搜索的表单
const editVisible = ref(false)
const previewVisible = ref(false)
const itemsEditorVisible = ref(false)
const editMode = ref<'create' | 'edit'>('create')
const form = reactive<{ id?: number; categoryName: string; items: Array<{ name: string; desc?: string }> }>({ categoryName: '', items: [] })
const previewItems = ref<Array<{ name: string; desc?: string }>>([])
const previewResults = ref<string[]>([])
const previewIssueDescs = ref<string[]>([])
const previewPhotos = ref<any[][]>([])
const categoryList = ref<ProductCategoryVO[]>([])
onMounted(async () => {
await getList()
})
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
// 从数据库获取产品分类列表
const data = await ProductCategoryApi.getProductCategoryPage({
pageNo: 1,
pageSize: 100,
hasTemplate: true
})
// 下拉选项应为全部分类,不仅限有模板
try {
const allCats = await ProductCategoryApi.getSimpleProductCategoryList()
categoryList.value = (allCats as any)?.list || (allCats as any) || []
} catch (e) {
categoryList.value = []
}
let allRows = (data.list || [])
.map((category: any) => {
let items: Array<{ name: string; desc?: string }> = []
try {
// 使用相同的字段名逻辑
const templateField = category.inspectTemplate || category.inspectionTemplate || category.template
items = JSON.parse(templateField || '[]')
} catch (e) {
console.error('解析模板失败:', e)
}
return {
id: category.id,
categoryName: category.name,
items,
updateTime: category.updateTime || category.createTime
}
})
.sort((a, b) => a.categoryName.localeCompare(b.categoryName))
// 搜索过滤
if (queryParams.categoryName) {
const searchTerm = (queryParams.categoryName as string).toLowerCase()
allRows = allRows.filter(row =>
row.categoryName.toLowerCase().includes(searchTerm)
)
}
// 分页
total.value = allRows.length
const start = (queryParams.pageNo - 1) * queryParams.pageSize
const end = start + queryParams.pageSize
list.value = allRows.slice(start, end)
} catch (e: any) {
console.error('获取模板列表失败:', e)
ElMessage.error('获取模板列表失败:' + (e?.message || '未知错误'))
list.value = []
total.value = 0
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
function openCreate() {
editMode.value = 'create'
Object.assign(form, { id: undefined, categoryName: '', items: [] })
editVisible.value = true
}
function editRow(row: Row) {
editMode.value = 'edit'
Object.assign(form, { id: row.id, categoryName: row.categoryName, items: JSON.parse(JSON.stringify(row.items)) })
editVisible.value = true
}
async function saveRow() {
try {
if (!form.categoryName) return ElMessage.warning('分类名称必选')
const arr = (form.items || []).map(it => ({ name: (it.name || '').trim(), desc: (it.desc || '').trim() }))
if (!arr.length) return ElMessage.warning('请至少新增一个检查项')
// 找到对应的分类ID
const category = categoryList.value.find(c => c.name === form.categoryName)
if (!category) {
ElMessage.error('未找到对应的产品分类')
return
}
// 保存到数据库
await ProductCategoryApi.updateTemplate(category.id, JSON.stringify(arr))
ElMessage.success('已保存')
editVisible.value = false
getList()
} catch (e: any) {
ElMessage.error('保存失败:' + (e?.message || ''))
}
}
function removeRow(row: Row) {
ElMessageBox.confirm('确认删除该分类的模板吗?', '提示').then(async () => {
try {
// 清空数据库中的模板
await ProductCategoryApi.updateTemplate(row.id, '')
ElMessage.success('已删除')
getList()
} catch (e: any) {
ElMessage.error('删除失败:' + (e?.message || ''))
}
}).catch(() => { })
}
function previewRow(row: Row) {
previewItems.value = row.items || []
// 初始化预览数据
previewResults.value = new Array(previewItems.value.length).fill('ok')
previewIssueDescs.value = new Array(previewItems.value.length).fill('')
previewPhotos.value = new Array(previewItems.value.length).fill([])
previewVisible.value = true
}
function onPreviewResultChange(idx: number) {
if (previewResults.value[idx] !== 'bad') {
previewIssueDescs.value[idx] = ''
previewPhotos.value[idx] = []
}
}
function openItemsEditor() {
itemsEditorVisible.value = true
}
function onItemsSave(items: Array<{ name: string; desc?: string }>) {
form.items = items
}
// 格式化日期
function formatDate(timestamp: string | number | undefined): string {
if (!timestamp) return '-'
try {
// 如果是字符串,先转换为数字
const time = typeof timestamp === 'string' ? parseInt(timestamp) : timestamp
// 检查是否是有效的时间戳
if (isNaN(time) || time <= 0) return '-'
// 创建日期对象
const date = new Date(time)
// 检查日期是否有效
if (isNaN(date.getTime())) return '-'
// 格式化为 YYYY-MM-DD HH:mm:ss
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
} catch (e) {
console.error('日期格式化失败:', e)
return '-'
}
}
async function exportAll() {
try {
// 从数据库获取所有模板
const data = await ProductCategoryApi.getProductCategoryPage({
pageNo: 1,
pageSize: 100
})
const templates: any = {}
data.list?.forEach((category: any) => {
if (category.inspectTemplate) {
templates[category.name] = JSON.parse(category.inspectTemplate)
}
})
const blob = new Blob([JSON.stringify(templates, null, 2)], { type: 'application/json' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = 'iot_check_templates.json'
a.click()
} catch (e: any) {
ElMessage.error('导出失败:' + (e?.message || ''))
}
}
function importAll() {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'application/json'
input.onchange = () => {
const file = input.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = async () => {
try {
const text = String(reader.result || '{}')
const obj = JSON.parse(text)
if (!obj || typeof obj !== 'object') throw new Error('格式错误')
// 批量导入到数据库
for (const [categoryName, items] of Object.entries(obj)) {
const category = categoryList.value.find(c => c.name === categoryName)
if (category && Array.isArray(items)) {
await ProductCategoryApi.updateTemplate(category.id, JSON.stringify(items))
}
}
ElMessage.success('导入成功')
getList()
} catch (e: any) {
ElMessage.error('导入失败:' + (e?.message || ''))
}
}
reader.readAsText(file)
}
input.click()
}
</script>
<style scoped>
.mt-8px {
margin-top: 8px;
}
.mb-8px {
margin-bottom: 8px;
}
.mb-4px {
margin-bottom: 4px;
}
.mb-16px {
margin-bottom: 16px;
}
.text-gray {
color: #909399;
}
.text-gray-500 {
color: #909399;
}
.text-12px {
font-size: 12px;
}
.text-14px {
font-size: 14px;
}
.name {
font-weight: 600;
}
.desc {
color: #666;
font-size: 12px;
}
.card-header {
font-weight: 600;
}
.item-row {
border-bottom: 1px solid #f0f0f0;
padding: 12px 0;
}
.item-left {
margin-bottom: 8px;
}
.item-name {
font-weight: 600;
}
.item-right {
margin: 8px 0;
}
.issue-area {
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,370 @@
<template>
<div class="check-log-page">
<el-card>
<div class="toolbar"
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<div style="display: flex; align-items: center;">
<el-input v-model="queryParams.operator" placeholder="执行人" style="width: 180px;" @keyup.enter="fetchList" />
<el-select v-model="queryParams.status" placeholder="点检状态" clearable style="width: 160px; margin-left: 12px;"
@change="fetchList">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-button @click="fetchList" style="margin-left: 12px;">查询</el-button>
</div>
<el-button type="primary" @click="openDialog()">新增记录</el-button>
</div>
<el-table :data="tableData" border stripe style="width: 100%; margin-top: 16px;">
<el-table-column prop="recordId" label="记录ID" min-width="120" />
<el-table-column prop="planName" label="点检计划" min-width="120" />
<el-table-column prop="equipmentName" label="设备名称" min-width="120" />
<el-table-column prop="operatorName" label="执行人" min-width="100" />
<el-table-column prop="checkTime" label="点检时间" min-width="140">
<template #default="scope">{{ formatDate(scope.row.checkTime) }}</template>
</el-table-column>
<el-table-column prop="finishTime" label="完成时间" min-width="140">
<template #default="scope">{{ formatDate(scope.row.finishTime) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" min-width="100">
<template #default="scope">
<el-tag :type="statusTagType(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="resultSummary" label="结果摘要" min-width="120" />
<el-table-column prop="abnormalDesc" label="异常描述" min-width="120" />
<!-- <el-table-column prop="photos" label="照片" min-width="120">-->
<!-- <template #default="scope">-->
<!-- <el-image v-for="(url, idx) in scope.row.photos || []" :key="idx" :src="url" style="width: 40px; height: 40px; margin-right: 4px;" :preview-src-list="scope.row.photos" preview-teleported />-->
<!-- </template>-->
<!-- </el-table-column>-->
<el-table-column prop="approver" label="验收人" min-width="100" />
<el-table-column prop="approveTime" label="验收时间" min-width="140">
<template #default="scope">{{ formatDate(scope.row.approveTime) }}</template>
</el-table-column>
<el-table-column label="操作" min-width="160" fixed="right">
<template #default="scope">
<el-button size="small" @click="openDialog(scope.row)">编辑</el-button>
<el-popconfirm title="确定删除该记录吗?" @confirm="handleDelete(scope.row.recordId)">
<template #reference>
<el-button size="small" type="danger" plain style="margin-left: 8px;">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<el-pagination v-model:current-page="pagination.pageNum" v-model:page-size="pagination.pageSize"
:total="pagination.total" layout="total, prev, pager, next, sizes" @current-change="fetchList"
@size-change="fetchList" style="margin-top: 16px; text-align: right;" />
</el-card>
<!-- 编辑/新增对话框 -->
<el-dialog :title="formData.recordId ? '编辑点检记录' : '新增点检记录'" v-model="dialogVisible" width="600px"
@close="resetForm">
<el-form :model="formData" :rules="rules" ref="formRef" label-width="110px">
<el-form-item label="点检计划" prop="planId">
<el-select v-model="formData.planId" placeholder="请选择点检计划" filterable @change="handlePlanChange">
<el-option v-for="item in planOptions" :key="item.planId" :label="item.planName" :value="item.planId" />
</el-select>
</el-form-item>
<el-form-item label="设备" prop="equipmentId">
<el-input v-model="formData.equipmentName" placeholder="设备名称" readonly />
</el-form-item>
<el-form-item label="执行人" prop="operator">
<el-select v-model="formData.operator" placeholder="请选择执行人" filterable @change="handleOperatorChange">
<el-option v-for="item in userOptions" :key="item.id" :label="item.nickname" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="点检时间" prop="checkTime">
<el-date-picker v-model="formData.checkTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择点检日期" style="width: 100%;" />
</el-form-item>
<el-form-item label="完成时间" prop="finishTime">
<el-date-picker v-model="formData.finishTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择完成日期" style="width: 100%;" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="formData.status" placeholder="请选择状态">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="结果摘要" prop="resultSummary">
<el-input v-model="formData.resultSummary" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="异常描述" prop="abnormalDesc">
<el-input v-model="formData.abnormalDesc" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="照片" prop="photos">
<el-upload ref="uploadRef" :file-list="fileList" :http-request="customUpload" :on-remove="handleUploadRemove"
list-type="picture-card" :limit="6" :multiple="true" :show-file-list="true" :auto-upload="true">
<el-icon>
<Plus />
</el-icon>
</el-upload>
</el-form-item>
<el-form-item label="验收人" prop="approver">
<el-input v-model="formData.approver" placeholder="请输入验收人" />
</el-form-item>
<el-form-item label="验收时间" prop="approveTime">
<el-date-picker v-model="formData.approveTime" type="datetime" value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择验收日期" style="width: 100%;" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { ElMessage } from 'element-plus';
import { Plus } from '@element-plus/icons-vue';
import { CheckLogApi } from '@/api/iot/check/log';
import { CheckPlanApi } from '@/api/iot/check/plan';
import { getSimpleUserList } from '@/api/system/user';
import dayjs from 'dayjs';
import type { UploadUserFile, UploadProps } from 'element-plus';
const tableData = ref<any[]>([]);
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
const queryParams = reactive({ operator: '', status: '' });
const dialogVisible = ref(false);
const formRef = ref();
const formData = reactive<any>({ planId: '', operatorName: '' });
const rules = {
planId: [{ required: true, message: '请选择点检计划', trigger: 'change' }],
operator: [{ required: true, message: '请选择执行人', trigger: 'change' }],
checkTime: [{ required: true, message: '请选择点检时间', trigger: 'change' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
};
const statusOptions = [
{ value: 'pending', label: '待点检' },
{ value: 'completed', label: '已完成' },
{ value: 'abnormal', label: '异常' },
{ value: 'timeout', label: '超时' },
];
const planOptions = ref<any[]>([]);
const userOptions = ref<any[]>([]);
const fileList = ref<UploadUserFile[]>([]);
async function fetchList() {
try {
console.log('开始获取列表数据');
const params = {
...queryParams,
pageNo: pagination.pageNum,
pageSize: pagination.pageSize,
};
console.log('请求参数:', params);
const res = await CheckLogApi.getCheckLogPage(params);
console.log('列表数据响应:', res);
if (res && res.data) {
tableData.value = res.list || [];
// 打印每条记录的photos字段
tableData.value.forEach((item, index) => {
console.log(`记录${index} photos:`, item.photos);
});
pagination.total = res.total || 0;
} else {
tableData.value = res.list || [];
// 打印每条记录的photos字段
tableData.value.forEach((item, index) => {
console.log(`记录${index} photos:`, item.photos);
});
pagination.total = res.total || 0;
}
} catch (error) {
console.error('获取列表数据失败:', error);
ElMessage.error('获取列表数据失败');
}
}
function openDialog(row?: any) {
console.log('openDialog row:', row);
if (row) {
Object.assign(formData, row);
formData.planId = String(row.planId || '');
formData.operatorName = row.operatorName || '';
console.log('编辑记录的photos原始值:', row.photos);
// 兼容 photos 为字符串或数组,赋值为图片对象数组
let photosArr: any[] = [];
if (Array.isArray(row.photos)) {
photosArr = row.photos;
console.log('photos是数组类型');
} else if (typeof row.photos === 'string' && row.photos) {
try {
const parsed = JSON.parse(row.photos);
if (Array.isArray(parsed)) {
photosArr = parsed;
console.log('photos是JSON字符串已解析为数组');
}
} catch (e) {
console.error('解析photos字符串失败:', e);
}
} else {
console.log('photos既不是数组也不是字符串:', typeof row.photos);
}
console.log('处理后的photosArr:', photosArr);
fileList.value = (photosArr || []).map((url: string | { url: string, name?: string }, idx: number) => {
if (typeof url === 'string') {
return { url, name: `图片${idx + 1}`, status: 'success', uid: idx }; // 将uid改为数字
} else {
return { url: url.url, name: url.name || `图片${idx + 1}`, status: 'success', uid: idx }; // 将uid改为数字
}
});
console.log('设置的fileList:', fileList.value);
} else {
Object.keys(formData).forEach(k => delete formData[k]);
formData.planId = '';
formData.operatorName = '';
fileList.value = [];
formData.status = 'pending';
formData.equipmentId = '';
formData.equipmentName = '';
formData.planName = '';
}
dialogVisible.value = true;
}
function resetForm() {
if (formRef.value) formRef.value.resetFields();
fileList.value = [];
}
function handleSubmit() {
formRef.value.validate(async (valid: boolean) => {
if (!valid) return;
// 确保设备ID存在
if (!formData.equipmentId) {
ElMessage.error('请先选择点检计划');
return;
}
// photos为图片对象数组
formData.photos = fileList.value
.filter(file => file.url && file.url.trim() !== '')
.map(file => ({ url: file.url, name: file.name || '未命名图片' }));
console.log('提交的photos数据:', JSON.stringify(formData.photos));
if (formData.recordId) {
await CheckLogApi.updateCheckLog(formData);
ElMessage.success('修改成功');
} else {
await CheckLogApi.createCheckLog(formData);
ElMessage.success('新增成功');
}
dialogVisible.value = false;
fetchList();
});
}
async function handleDelete(recordId: number) {
try {
await CheckLogApi.deleteCheckLog(recordId);
ElMessage.success('删除成功');
fetchList();
} catch (error) {
console.error('删除失败:', error);
ElMessage.error('删除失败');
}
}
function handleUploadRemove(file: any, fileListArr: any[]) {
// 前端 fileList 兜底同步删除
const idx = fileList.value.findIndex(f => f.uid === file.uid);
if (idx !== -1) {
fileList.value.splice(idx, 1);
}
}
function statusLabel(val: string) {
return statusOptions.find(i => i.value === val)?.label || val;
}
function statusTagType(val: string) {
switch (val) {
case 'completed': return 'success';
case 'abnormal': return 'danger';
case 'timeout': return 'warning';
default: return 'info';
}
}
function formatDate(val: string) {
if (!val) return '';
return val.length > 10 ? val.replace('T', ' ').slice(0, 19) : val;
}
function handlePlanChange(planId: string) {
const selectedPlan = planOptions.value.find(plan => String(plan.planId) === String(planId));
if (selectedPlan) {
formData.planId = String(selectedPlan.planId);
formData.equipmentId = selectedPlan.equipmentId;
formData.equipmentName = selectedPlan.equipmentName;
formData.planName = selectedPlan.planName;
}
}
function handleOperatorChange(userId) {
const user = userOptions.value.find(u => u.id === userId);
formData.operatorName = user ? user.nickname : '';
}
async function fetchOptions() {
try {
console.log('开始获取选项数据');
// 点检计划 - 使用分页接口获取所有数据
const planRes = await CheckPlanApi.getCheckPlanPage({ pageNo: 1, pageSize: 1000 });
// 兼容后端返回格式
const list = planRes?.data?.list || planRes?.list || [];
planOptions.value = list.map(item => ({
...item,
planId: String(item.planId)
}));
console.log('设置计划选项:', planOptions.value);
// 用户
const userRes = await getSimpleUserList();
userOptions.value = userRes || [];
console.log('设置用户选项:', userOptions.value);
} catch (error) {
console.error('获取选项数据失败:', error);
ElMessage.error('获取选项数据失败');
}
}
// 上传图片自定义请求
const customUpload: UploadProps['httpRequest'] = async (options) => {
const { file, onSuccess, onError } = options;
try {
// 假设你有CheckLogImageApi.uploadImage方法返回{url, name}
const response = await CheckLogApi.uploadImage(file);
fileList.value.push({
name: response.name || file.name,
url: response.url,
status: 'success',
uid: file.uid
});
ElMessage.success('上传成功');
onSuccess({ url: response.url, name: response.name || file.name });
} catch (error) {
ElMessage.error('上传失败');
onError({ status: 500, method: 'POST', url: '', name: 'UploadError', message: error instanceof Error ? error.message : '上传失败' });
}
};
onMounted(() => {
fetchList();
fetchOptions();
});
</script>
<style scoped>
.check-log-page .toolbar {
display: flex;
margin-bottom: 12px;
align-items: center;
}
</style>

View File

@@ -0,0 +1,39 @@
<!-- 设备历史数据弹窗 -->
<template>
<Dialog
v-model="dialogVisible"
:title="`设备历史数据 - ${device?.deviceName || ''}`"
width="80%"
max-height="80vh"
destroy-on-close
>
<DeviceDetailsData v-if="dialogVisible && device" :device="device" />
</Dialog>
</template>
<script setup lang="ts">
import { DeviceVO } from '@/api/iot/device/device'
import DeviceDetailsData from './detail/DeviceDetailsData.vue'
defineOptions({ name: 'DeviceDataDialog' })
const dialogVisible = ref(false)
const device = ref<DeviceVO | null>(null)
/** 打开弹窗 */
const open = (deviceData: DeviceVO) => {
device.value = deviceData
dialogVisible.value = true
}
/** 关闭弹窗 */
const close = () => {
dialogVisible.value = false
device.value = null
}
defineExpose({
open,
close
})
</script>

View File

@@ -0,0 +1,263 @@
<template>
<Dialog :title="dialogTitle" v-model="dialogVisible">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
v-loading="formLoading"
>
<el-form-item label="产品" prop="productId">
<el-select
v-model="formData.productId"
placeholder="请选择产品"
:disabled="formType === 'update'"
clearable
@change="handleProductChange"
>
<el-option
v-for="product in products"
:key="product.id"
:label="product.name"
:value="product.id"
/>
</el-select>
</el-form-item>
<el-form-item label="DeviceKey" prop="deviceKey">
<el-input
v-model="formData.deviceKey"
placeholder="请输入 DeviceKey"
:disabled="formType === 'update'"
>
<template #append>
<el-button @click="generateDeviceKey" :disabled="formType === 'update'">
重新生成
</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="DeviceName" prop="deviceName">
<el-input
v-model="formData.deviceName"
placeholder="请输入 DeviceName"
:disabled="formType === 'update'"
/>
</el-form-item>
<el-form-item
v-if="formData.deviceType === DeviceTypeEnum.GATEWAY_SUB"
label="网关设备"
prop="gatewayId"
>
<el-select v-model="formData.gatewayId" placeholder="子设备可选择父设备" clearable>
<el-option
v-for="gateway in gatewayDevices"
:key="gateway.id"
:label="gateway.nickname || gateway.deviceName"
:value="gateway.id"
/>
</el-select>
</el-form-item>
<el-collapse>
<el-collapse-item title="更多配置">
<el-form-item label="备注名称" prop="nickname">
<el-input v-model="formData.nickname" placeholder="请输入备注名称" />
</el-form-item>
<el-form-item label="设备图片" prop="picUrl">
<UploadImg v-model="formData.picUrl" :height="'120px'" :width="'120px'" />
</el-form-item>
<el-form-item label="设备分组" prop="groupIds">
<el-select v-model="formData.groupIds" placeholder="请选择设备分组" multiple clearable>
<el-option
v-for="group in deviceGroups"
:key="group.id"
:label="group.name"
:value="group.id"
/>
</el-select>
</el-form-item>
<el-form-item label="设备序列号" prop="serialNumber">
<el-input v-model="formData.serialNumber" placeholder="请输入设备序列号" />
</el-form-item>
</el-collapse-item>
</el-collapse>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
import { DeviceGroupApi } from '@/api/iot/device/group'
import { DeviceTypeEnum, ProductApi, ProductVO } from '@/api/iot/product/product'
import { UploadImg } from '@/components/UploadFile'
import { generateRandomStr } from '@/utils'
/** IoT 设备表单 */
defineOptions({ name: 'IoTDeviceForm' })
const { t } = useI18n() // 国际化
const message = useMessage() // 消息窗
const dialogVisible = ref(false) // 弹窗的是否展示
const dialogTitle = ref('') // 弹窗的标题
const formLoading = ref(false) // 表单的加载中1修改时的数据加载2提交的按钮禁用
const formType = ref('') // 表单的类型create - 新增update - 修改
const formData = ref({
id: undefined,
productId: undefined,
deviceKey: undefined as string | undefined,
deviceName: undefined,
nickname: undefined,
picUrl: undefined,
gatewayId: undefined,
deviceType: undefined as number | undefined,
serialNumber: undefined,
groupIds: [] as number[]
})
const formRules = reactive({
productId: [{ required: true, message: '产品不能为空', trigger: 'blur' }],
deviceKey: [
{ required: true, message: 'DeviceKey 不能为空', trigger: 'blur' },
{
pattern: /^[a-zA-Z0-9]+$/,
message: 'DeviceKey 只能包含字母和数字',
trigger: 'blur'
}
],
deviceName: [
{ required: true, message: 'DeviceName 不能为空', trigger: 'blur' },
{
pattern: /^[a-zA-Z0-9_.\-:@]{4,32}$/,
message:
'支持英文字母、数字、下划线_、中划线-)、点号(.)、半角冒号(:)和特殊字符@,长度限制为 4~32 个字符',
trigger: 'blur'
}
],
nickname: [
{
validator: (rule, value, callback) => {
if (value === undefined || value === null) {
callback()
return
}
const length = value.replace(/[\u4e00-\u9fa5\u3040-\u30ff]/g, 'aa').length
if (length < 4 || length > 64) {
callback(new Error('备注名称长度限制为 4~64 个字符,中文及日文算 2 个字符'))
} else if (!/^[\u4e00-\u9fa5\u3040-\u30ff_a-zA-Z0-9]+$/.test(value)) {
callback(new Error('备注名称只能包含中文、英文字母、日文、数字和下划线_'))
} else {
callback()
}
},
trigger: 'blur'
}
],
serialNumber: [
{
pattern: /^[a-zA-Z0-9-_]+$/,
message: '序列号只能包含字母、数字、中划线和下划线',
trigger: 'blur'
}
]
})
const formRef = ref() // 表单 Ref
const products = ref<ProductVO[]>([]) // 产品列表
const gatewayDevices = ref<DeviceVO[]>([]) // 网关设备列表
const deviceGroups = ref<any[]>([])
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
// 修改时,设置数据
if (id) {
formLoading.value = true
try {
formData.value = await DeviceApi.getDevice(id)
} finally {
formLoading.value = false
}
} else {
generateDeviceKey()
}
// 加载网关设备列表
try {
gatewayDevices.value = await DeviceApi.getSimpleDeviceList(DeviceTypeEnum.GATEWAY)
} catch (error) {
console.error('加载网关设备列表失败:', error)
}
// 加载产品列表
products.value = await ProductApi.getSimpleProductList()
// 加载设备分组列表
try {
deviceGroups.value = await DeviceGroupApi.getSimpleDeviceGroupList()
} catch (error) {
console.error('加载设备分组列表失败:', error)
}
}
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
/** 提交表单 */
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
const submitForm = async () => {
// 校验表单
await formRef.value.validate()
// 提交请求
formLoading.value = true
try {
const data = formData.value as unknown as DeviceVO
if (formType.value === 'create') {
await DeviceApi.createDevice(data)
message.success(t('common.createSuccess'))
} else {
await DeviceApi.updateDevice(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
// 发送操作成功的事件
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
productId: undefined,
deviceKey: undefined,
deviceName: undefined,
nickname: undefined,
picUrl: undefined,
gatewayId: undefined,
deviceType: undefined,
serialNumber: undefined,
groupIds: []
}
formRef.value?.resetFields()
}
/** 产品选择变化 */
const handleProductChange = (productId: number) => {
if (!productId) {
formData.value.deviceType = undefined
return
}
const product = products.value?.find((item) => item.id === productId)
formData.value.deviceType = product?.deviceType
}
/** 生成 DeviceKey */
const generateDeviceKey = () => {
formData.value.deviceKey = generateRandomStr(16)
}
</script>

View File

@@ -0,0 +1,90 @@
<template>
<Dialog :title="'添加设备到分组'" v-model="dialogVisible">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
v-loading="formLoading"
>
<el-form-item label="设备分组" prop="groupIds">
<el-select v-model="formData.groupIds" placeholder="请选择设备分组" multiple clearable>
<el-option
v-for="group in deviceGroups"
:key="group.id"
:label="group.name"
:value="group.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { DeviceApi } from '@/api/iot/device/device'
import { DeviceGroupApi } from '@/api/iot/device/group'
defineOptions({ name: 'IoTDeviceGroupForm' })
const { t } = useI18n() // 国际化
const message = useMessage() // 消息窗
const dialogVisible = ref(false) // 弹窗的是否展示
const formLoading = ref(false) // 表单的加载中
const formData = ref({
ids: [] as number[],
groupIds: [] as number[]
})
const formRules = reactive({
groupIds: [{ required: true, message: '设备分组不能为空', trigger: 'change' }]
})
const formRef = ref() // 表单 Ref
const deviceGroups = ref<any[]>([]) // 设备分组列表
/** 打开弹窗 */
const open = async (ids: number[]) => {
dialogVisible.value = true
resetForm()
formData.value.ids = ids
// 加载设备分组列表
try {
deviceGroups.value = await DeviceGroupApi.getSimpleDeviceGroupList()
} catch (error) {
console.error('加载设备分组列表失败:', error)
}
}
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
/** 提交表单 */
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
const submitForm = async () => {
// 校验表单
await formRef.value.validate()
// 提交请求
formLoading.value = true
try {
await DeviceApi.updateDeviceGroup(formData.value)
message.success(t('common.updateSuccess'))
dialogVisible.value = false
// 发送操作成功的事件
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
ids: [],
groupIds: []
}
formRef.value?.resetFields()
}
</script>

View File

@@ -0,0 +1,139 @@
<template>
<Dialog v-model="dialogVisible" title="设备导入" width="400">
<el-upload
ref="uploadRef"
v-model:file-list="fileList"
:action="importUrl + '?updateSupport=' + updateSupport"
:auto-upload="false"
:disabled="formLoading"
:headers="uploadHeaders"
:limit="1"
:on-error="submitFormError"
:on-exceed="handleExceed"
:on-success="submitFormSuccess"
accept=".xlsx, .xls"
drag
>
<Icon icon="ep:upload" />
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip text-center">
<div class="el-upload__tip">
<el-checkbox v-model="updateSupport" />
是否更新已经存在的设备数据
</div>
<span>仅允许导入 xlsxlsx 格式文件</span>
<el-link
:underline="false"
style="font-size: 12px; vertical-align: baseline"
type="primary"
@click="importTemplate"
>
下载模板
</el-link>
</div>
</template>
</el-upload>
<template #footer>
<el-button :disabled="formLoading" type="primary" @click="submitForm"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script lang="ts" setup>
import { DeviceApi } from '@/api/iot/device/device'
import { getAccessToken, getTenantId } from '@/utils/auth'
import download from '@/utils/download'
defineOptions({ name: 'IoTDeviceImportForm' })
const message = useMessage() // 消息弹窗
const dialogVisible = ref(false) // 弹窗的是否展示
const formLoading = ref(false) // 表单的加载中
const uploadRef = ref()
const importUrl =
import.meta.env.VITE_BASE_URL + import.meta.env.VITE_API_URL + '/iot/device/import'
const uploadHeaders = ref() // 上传 Header 头
const fileList = ref([]) // 文件列表
const updateSupport = ref(0) // 是否更新已经存在的设备数据
/** 打开弹窗 */
const open = () => {
dialogVisible.value = true
updateSupport.value = 0
fileList.value = []
resetForm()
}
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
/** 提交表单 */
const submitForm = async () => {
if (fileList.value.length == 0) {
message.error('请上传文件')
return
}
// 提交请求
uploadHeaders.value = {
Authorization: 'Bearer ' + getAccessToken(),
'tenant-id': getTenantId()
}
formLoading.value = true
uploadRef.value!.submit()
}
/** 文件上传成功 */
const emits = defineEmits(['success'])
const submitFormSuccess = (response: any) => {
if (response.code !== 0) {
message.error(response.msg)
formLoading.value = false
return
}
// 拼接提示语
const data = response.data
let text = '上传成功数量:' + data.createDeviceNames.length + ';'
for (let deviceName of data.createDeviceNames) {
text += '< ' + deviceName + ' >'
}
text += '更新成功数量:' + data.updateDeviceNames.length + ';'
for (const deviceName of data.updateDeviceNames) {
text += '< ' + deviceName + ' >'
}
text += '更新失败数量:' + Object.keys(data.failureDeviceNames).length + ';'
for (const deviceName in data.failureDeviceNames) {
text += '< ' + deviceName + ': ' + data.failureDeviceNames[deviceName] + ' >'
}
message.alert(text)
formLoading.value = false
dialogVisible.value = false
// 发送操作成功的事件
emits('success')
}
/** 上传错误提示 */
const submitFormError = (): void => {
message.error('上传失败,请您重新上传!')
formLoading.value = false
}
/** 重置表单 */
const resetForm = async (): Promise<void> => {
// 重置上传状态和文件
formLoading.value = false
await nextTick()
uploadRef.value?.clearFiles()
}
/** 文件数超出提示 */
const handleExceed = (): void => {
message.error('最多只能上传一个文件!')
}
/** 下载模板操作 */
const importTemplate = async () => {
const res = await DeviceApi.importDeviceTemplate()
download.excel(res, '设备导入模版.xls')
}
</script>

View File

@@ -0,0 +1,110 @@
<!-- 设备物模型 -> 运行状态 -> 查看数据设备的属性值历史-->
<template>
<Dialog title="查看数据" v-model="dialogVisible">
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="时间" prop="createTime">
<el-date-picker
v-model="queryParams.times"
value-format="YYYY-MM-DD HH:mm:ss"
type="datetimerange"
start-placeholder="开始日期"
end-placeholder="结束日期"
class="!w-350px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery">
<Icon icon="ep:search" class="mr-5px" />
搜索
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- TODO @haohao可参考阿里云 IoT改成图标表格两个选项 -->
<!-- 列表 -->
<ContentWrap>
<el-table v-loading="detailLoading" :data="list" :stripe="true" :show-overflow-tooltip="true">
<el-table-column
label="时间"
align="center"
prop="updateTime"
:formatter="dateFormatter"
width="180px"
/>
<el-table-column label="属性值" align="center" prop="value" />
</el-table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
</Dialog>
</template>
<script setup lang="ts">
import { DeviceApi, DeviceHistoryDataVO, DeviceVO } from '@/api/iot/device/device'
import { ProductVO } from '@/api/iot/product/product'
import { beginOfDay, dateFormatter, endOfDay, formatDate } from '@/utils/formatTime'
defineProps<{ product: ProductVO; device: DeviceVO }>()
/** IoT 设备数据详情 */
defineOptions({ name: 'IoTDeviceDataDetail' })
const dialogVisible = ref(false) // 弹窗的是否展示
const detailLoading = ref(false)
const list = ref<DeviceHistoryDataVO[]>([]) // 列表的数据
const total = ref(0) // 列表的总页数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
deviceId: -1,
identifier: '',
times: [
// 默认显示最近一周的数据
formatDate(beginOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24 * 7))),
formatDate(endOfDay(new Date()))
]
})
const queryFormRef = ref() // 搜索的表单
/** 获得设备历史数据 */
const getList = async () => {
detailLoading.value = true
try {
const data = await DeviceApi.getHistoryDevicePropertyPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
detailLoading.value = false
}
}
/** 打开弹窗 */
const open = (deviceId: number, identifier: string) => {
dialogVisible.value = true
queryParams.deviceId = deviceId
queryParams.identifier = identifier
getList()
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
</script>

View File

@@ -0,0 +1,119 @@
<!-- 设备配置 -->
<template>
<div>
<el-alert
title="支持远程更新设备的配置文件(JSON 格式),可以在下方编辑配置模板,对设备的系统参数、网络参数等进行远程配置。配置完成后,需点击「下发」按钮,设备即可进行远程配置。"
type="info"
show-icon
class="my-4"
description="如需编辑文件,请点击下方编辑按钮"
/>
<!-- JSON 编辑器读模式 -->
<Vue3Jsoneditor
v-if="isEditing"
v-model="config"
:options="editorOptions"
height="500px"
currentMode="code"
@error="onError"
/>
<!-- JSON 编辑器写模式 -->
<Vue3Jsoneditor
v-else
v-model="config"
:options="editorOptions"
height="500px"
currentMode="view"
v-loading.fullscreen.lock="loading"
@error="onError"
/>
<div class="mt-5 text-center">
<el-button v-if="isEditing" @click="cancelEdit">取消</el-button>
<el-button v-if="isEditing" type="primary" @click="saveConfig" :disabled="hasJsonError">
保存
</el-button>
<el-button v-else @click="enableEdit">编辑</el-button>
<!-- TODO @芋艿缺一个下发按钮 -->
</div>
</div>
</template>
<script lang="ts" setup>
import Vue3Jsoneditor from 'v3-jsoneditor/src/Vue3Jsoneditor.vue'
import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
import { jsonParse } from '@/utils'
const props = defineProps<{
device: DeviceVO
}>()
const emit = defineEmits<{
(e: 'success'): void // 定义 success 事件,不需要参数
}>()
const message = useMessage()
const loading = ref(false) // 加载中
const config = ref<any>({}) // 只存储 config 字段
const hasJsonError = ref(false) // 是否有 JSON 格式错误
/** 监听 props.device 的变化,只更新 config 字段 */
watchEffect(() => {
config.value = jsonParse(props.device.config)
})
const isEditing = ref(false) // 编辑状态
const editorOptions = computed(() => ({
mainMenuBar: false,
navigationBar: false,
statusBar: false
})) // JSON 编辑器的选项
/** 启用编辑模式的函数 */
const enableEdit = () => {
isEditing.value = true
hasJsonError.value = false // 重置错误状态
}
/** 取消编辑的函数 */
const cancelEdit = () => {
config.value = jsonParse(props.device.config)
isEditing.value = false
hasJsonError.value = false // 重置错误状态
}
/** 保存配置的函数 */
const saveConfig = async () => {
if (hasJsonError.value) {
message.error('JSON格式错误请修正后再提交')
return
}
await updateDeviceConfig()
isEditing.value = false
}
/** 更新设备配置 */
const updateDeviceConfig = async () => {
try {
// 提交请求
loading.value = true
await DeviceApi.updateDevice({
id: props.device.id,
config: JSON.stringify(config.value)
} as DeviceVO)
message.success('更新成功!')
// 触发 success 事件
emit('success')
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
/** 处理 JSON 编辑器错误的函数 */
const onError = (e: any) => {
console.log('onError', e)
hasJsonError.value = true
}
</script>

View File

@@ -0,0 +1,360 @@
<!-- 设备历史数据 -->
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="时间范围" prop="dateRange">
<el-date-picker
v-model="queryParams.dateRange"
value-format="YYYY-MM-DD HH:mm:ss"
type="datetimerange"
start-placeholder="开始日期"
end-placeholder="结束日期"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
class="!w-240px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery">
<Icon icon="ep:search" class="mr-5px" />
搜索
</el-button>
<el-button @click="resetQuery">
<Icon icon="ep:refresh" class="mr-5px" />
重置
</el-button>
</el-form-item>
</el-form>
<!-- 最新数据展示 -->
<el-card class="mb-4" v-if="latestData">
<template #header>
<div class="flex items-center">
<Icon icon="ep:data-line" class="mr-2" />
<span class="font-semibold">最新数据</span>
</div>
</template>
<el-row :gutter="20">
<el-col :span="4">
<div class="text-center">
<div class="text-2xl font-bold text-blue-600">{{ latestData.temperatureC || '-' }}</div>
<div class="text-sm text-gray-500">温度(°C)</div>
</div>
</el-col>
<el-col :span="4">
<div class="text-center">
<div class="text-2xl font-bold text-green-600">{{ latestData.currentValue || '-' }}</div>
<div class="text-sm text-gray-500">电流(A)</div>
</div>
</el-col>
<el-col :span="4">
<div class="text-center">
<div class="text-2xl font-bold text-orange-600">{{ latestData.powerW || '-' }}</div>
<div class="text-sm text-gray-500">功率(W)</div>
</div>
</el-col>
<el-col :span="4">
<div class="text-center">
<div class="text-2xl font-bold text-purple-600">{{ latestData.counter1Total || '0' }}</div>
<div class="text-sm text-gray-500 mb-2">计数器1累计</div>
<el-button
type="warning"
size="small"
@click="handleResetCounter(1)"
:loading="resetLoading1"
>
清零1
</el-button>
</div>
</el-col>
<el-col :span="4">
<div class="text-center">
<div class="text-2xl font-bold text-red-600">{{ latestData.counter2Total || '0' }}</div>
<div class="text-sm text-gray-500 mb-2">计数器2累计</div>
<el-button
type="warning"
size="small"
@click="handleResetCounter(2)"
:loading="resetLoading2"
>
清零2
</el-button>
</div>
</el-col>
<el-col :span="4">
<div class="text-center">
<div class="text-lg font-semibold text-gray-700">{{ formatDate(latestData.collectedAt) }}</div>
<div class="text-sm text-gray-500">采集时间</div>
</div>
</el-col>
</el-row>
</el-card>
<!-- 列表 -->
<el-table
v-loading="loading"
:data="list"
:stripe="true"
:show-overflow-tooltip="true"
height="600"
>
<el-table-column label="采集时间" align="center" prop="collectedAt" >
<template #default="scope">
<span>{{ formatDate(scope.row.collectedAt) }}</span>
</template>
</el-table-column>
<el-table-column label="温度(°C)" align="center" prop="temperatureC" >
<template #default="scope">
<span>{{ scope.row.temperatureC || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="电流(A)" align="center" prop="currentValue" >
<template #default="scope">
<span>{{ scope.row.currentValue || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="功率(W)" align="center" prop="powerW" >
<template #default="scope">
<span>{{ scope.row.powerW || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="运行状态" align="center" prop="isOn" >
<template #default="scope">
<el-tag :type="scope.row.isOn ? 'success' : 'danger'">
{{ scope.row.isOn ? '运行' : '停止' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="计数器1" align="center" prop="counter1" >
<template #default="scope">
<span>{{ scope.row.counter1 || '0' }}</span>
</template>
</el-table-column>
<el-table-column label="计数器2" align="center" prop="counter2" >
<template #default="scope">
<span>{{ scope.row.counter2 || '0' }}</span>
</template>
</el-table-column>
<!-- <el-table-column label="计数器1累计" align="center" prop="counter1Total" width="120">-->
<!-- <template #default="scope">-->
<!-- <span>{{ scope.row.counter1Total || '0' }}</span>-->
<!-- </template>-->
<!-- </el-table-column>-->
<!-- <el-table-column label="计数器2累计" align="center" prop="counter2Total" width="120">-->
<!-- <template #default="scope">-->
<!-- <span>{{ scope.row.counter2Total || '0' }}</span>-->
<!-- </template>-->
<!-- </el-table-column>-->
<!-- <el-table-column label="模拟量1" align="center" prop="analog1" width="90">-->
<!-- <template #default="scope">-->
<!-- <span>{{ scope.row.analog1 || '-' }}</span>-->
<!-- </template>-->
<!-- </el-table-column>-->
<!-- <el-table-column label="模拟量2" align="center" prop="analog2" width="90">-->
<!-- <template #default="scope">-->
<!-- <span>{{ scope.row.analog2 || '-' }}</span>-->
<!-- </template>-->
<!-- </el-table-column>-->
<!-- <el-table-column label="模拟量3" align="center" prop="analog3" width="90">-->
<!-- <template #default="scope">-->
<!-- <span>{{ scope.row.analog3 || '-' }}</span>-->
<!-- </template>-->
<!-- </el-table-column>-->
<!-- <el-table-column label="模拟量4" align="center" prop="analog4" width="90">-->
<!-- <template #default="scope">-->
<!-- <span>{{ scope.row.analog4 || '-' }}</span>-->
<!-- </template>-->
<!-- </el-table-column>-->
<!-- <el-table-column label="模拟量5" align="center" prop="analog5" width="90">-->
<!-- <template #default="scope">-->
<!-- <span>{{ scope.row.analog5 || '-' }}</span>-->
<!-- </template>-->
<!-- </el-table-column>-->
<!-- <el-table-column label="模拟量6" align="center" prop="analog6" width="90">-->
<!-- <template #default="scope">-->
<!-- <span>{{ scope.row.analog6 || '-' }}</span>-->
<!-- </template>-->
<!-- </el-table-column>-->
<!-- <el-table-column label="模拟量7" align="center" prop="analog7" width="90">-->
<!-- <template #default="scope">-->
<!-- <span>{{ scope.row.analog7 || '-' }}</span>-->
<!-- </template>-->
<!-- </el-table-column>-->
<!-- <el-table-column label="数字量1" align="center" prop="digital1" width="80">-->
<!-- <template #default="scope">-->
<!-- <el-tag :type="scope.row.digital1 ? 'success' : 'info'" size="small">-->
<!-- {{ scope.row.digital1 ? '1' : '0' }}-->
<!-- </el-tag>-->
<!-- </template>-->
<!-- </el-table-column>-->
<!-- <el-table-column label="数字量2" align="center" prop="digital2" width="80">-->
<!-- <template #default="scope">-->
<!-- <el-tag :type="scope.row.digital2 ? 'success' : 'info'" size="small">-->
<!-- {{ scope.row.digital2 ? '1' : '0' }}-->
<!-- </el-tag>-->
<!-- </template>-->
<!-- </el-table-column>-->
<!-- <el-table-column label="数字量3" align="center" prop="digital3" width="80">-->
<!-- <template #default="scope">-->
<!-- <el-tag :type="scope.row.digital3 ? 'success' : 'info'" size="small">-->
<!-- {{ scope.row.digital3 ? '1' : '0' }}-->
<!-- </el-tag>-->
<!-- </template>-->
<!-- </el-table-column>-->
<!-- <el-table-column label="数字量4" align="center" prop="digital4" width="80">-->
<!-- <template #default="scope">-->
<!-- <el-tag :type="scope.row.digital4 ? 'success' : 'info'" size="small">-->
<!-- {{ scope.row.digital4 ? '1' : '0' }}-->
<!-- </el-tag>-->
<!-- </template>-->
<!-- </el-table-column>-->
<!-- <el-table-column label="数字量5" align="center" prop="digital5" width="80">-->
<!-- <template #default="scope">-->
<!-- <el-tag :type="scope.row.digital5 ? 'success' : 'info'" size="small">-->
<!-- {{ scope.row.digital5 ? '1' : '0' }}-->
<!-- </el-tag>-->
<!-- </template>-->
<!-- </el-table-column>-->
<!-- <el-table-column label="数字量6" align="center" prop="digital6" width="80">-->
<!-- <template #default="scope">-->
<!-- <el-tag :type="scope.row.digital6 ? 'success' : 'info'" size="small">-->
<!-- {{ scope.row.digital6 ? '1' : '0' }}-->
<!-- </el-tag>-->
<!-- </template>-->
<!-- </el-table-column>-->
<!-- <el-table-column label="原始帧数据" align="center" prop="frameRaw" min-width="200">-->
<!-- <template #default="scope">-->
<!-- <el-tooltip :content="scope.row.frameRaw" placement="top">-->
<!-- <span class="truncate">{{ scope.row.frameRaw || '-' }}</span>-->
<!-- </el-tooltip>-->
<!-- </template>-->
<!-- </el-table-column>-->
</el-table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
</template>
<script setup lang="ts">
import { formatDate } from '@/utils/formatTime'
import { DeviceApi, DeviceHistoryDataVO, DeviceVO } from '@/api/iot/device/device'
defineOptions({ name: 'DeviceDetailsData' })
const { device } = defineProps<{ device: DeviceVO }>() // 定义 Props
const message = useMessage() // 消息弹窗
const loading = ref(true) // 列表的加载中
const list = ref<DeviceHistoryDataVO[]>([]) // 列表的数据
const total = ref(0) // 列表的总页数
const latestData = ref<DeviceHistoryDataVO | null>(null) // 最新数据
const resetLoading1 = ref(false) // 清零1按钮加载状态
const resetLoading2 = ref(false) // 清零2按钮加载状态
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
deviceId: device.id,
dateRange: [] as any[],
startTime: undefined as any,
endTime: undefined as any
})
const queryFormRef = ref() // 搜索的表单
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
// 处理时间范围
if (queryParams.dateRange && queryParams.dateRange.length === 2) {
queryParams.startTime = queryParams.dateRange[0]
queryParams.endTime = queryParams.dateRange[1]
} else {
queryParams.startTime = undefined
queryParams.endTime = undefined
}
const data = await DeviceApi.getDeviceDataPage(queryParams)
list.value = data.list
total.value = data.total
// 设置最新数据(列表第一条,因为已按时间倒序排序)
latestData.value = data.list && data.list.length > 0 ? data.list[0] : null
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
queryParams.dateRange = []
handleQuery()
}
/** 重置计数器累计值 */
const handleResetCounter = async (counterType: number) => {
try {
// 确认操作
await message.confirm(`确定要将计数器${counterType}的累计值重置为0吗\n此操作只会重置最新一条数据的累计值不影响历史记录。`, '警告')
// 设置加载状态
if (counterType === 1) {
resetLoading1.value = true
} else {
resetLoading2.value = true
}
// 调用API重置计数器
await DeviceApi.resetDeviceCounter(device.id, counterType)
// 显示成功消息
message.success(`计数器${counterType}累计值已清零`)
// 刷新数据
await getList()
} catch (error) {
// 用户取消或操作失败
console.error('重置计数器失败:', error)
} finally {
// 取消加载状态
if (counterType === 1) {
resetLoading1.value = false
} else {
resetLoading2.value = false
}
}
}
/** 初始化 **/
onMounted(() => {
getList()
})
</script>
<style scoped>
.truncate {
display: inline-block;
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@@ -0,0 +1,69 @@
<!-- 设备信息头部 -->
<template>
<div>
<div class="flex items-start justify-between">
<div>
<el-col>
<el-row>
<span class="text-xl font-bold">{{ device.deviceName }}</span>
</el-row>
</el-col>
</div>
<div>
<!-- 右上按钮 -->
<el-button
@click="openForm('update', device.id)"
v-hasPermi="['iot:device:update']"
v-if="product.status === 0"
>
编辑
</el-button>
</div>
</div>
</div>
<ContentWrap class="mt-10px">
<el-descriptions :column="5" direction="horizontal">
<el-descriptions-item label="产品">
<el-link @click="goToProductDetail(product.id)">{{ product.name }}</el-link>
</el-descriptions-item>
<el-descriptions-item label="ProductKey">
{{ product.productKey }}
<el-button @click="copyToClipboard(product.productKey)">复制</el-button>
</el-descriptions-item>
</el-descriptions>
</ContentWrap>
<!-- 表单弹窗添加/修改 -->
<DeviceForm ref="formRef" @success="emit('refresh')" />
</template>
<script setup lang="ts">
import DeviceForm from '@/views/iot/device/device/DeviceForm.vue'
import { ProductVO } from '@/api/iot/product/product'
import { DeviceVO } from '@/api/iot/device/device'
const message = useMessage()
const router = useRouter()
const { product, device } = defineProps<{ product: ProductVO; device: DeviceVO }>()
const emit = defineEmits(['refresh'])
/** 操作修改 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 复制到剪贴板方法 */
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
message.success('复制成功')
} catch (error) {
message.error('复制失败')
}
}
/** 跳转到产品详情页面 */
const goToProductDetail = (productId: number) => {
router.push({ name: 'IoTProductDetail', params: { id: productId } })
}
</script>

View File

@@ -0,0 +1,154 @@
<!-- 设备信息 -->
<template>
<ContentWrap>
<el-descriptions :column="3" title="设备信息">
<el-descriptions-item label="产品名称">{{ product.name }}</el-descriptions-item>
<el-descriptions-item label="ProductKey">
{{ product.productKey }}
<el-button @click="copyToClipboard(product.productKey)">复制</el-button>
</el-descriptions-item>
<el-descriptions-item label="设备类型">
<dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="product.deviceType" />
</el-descriptions-item>
<el-descriptions-item label="DeviceName">
{{ device.deviceName }}
<el-button @click="copyToClipboard(device.deviceName)">复制</el-button>
</el-descriptions-item>
<el-descriptions-item label="备注名称">{{ device.nickname }}</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ formatDate(device.createTime) }}
</el-descriptions-item>
<el-descriptions-item label="当前状态">
<dict-tag :type="DICT_TYPE.IOT_DEVICE_STATE" :value="device.state" />
</el-descriptions-item>
<el-descriptions-item label="激活时间">
{{ formatDate(device.activeTime) }}
</el-descriptions-item>
<el-descriptions-item label="最后上线时间">
{{ formatDate(device.onlineTime) }}
</el-descriptions-item>
<el-descriptions-item label="最后离线时间">
{{ formatDate(device.offlineTime) }}
</el-descriptions-item>
<!-- 新增三个数据字段 -->
<el-descriptions-item label="数据1">
{{ device.data1 || 'N/A' }}
</el-descriptions-item>
<el-descriptions-item label="数据2">
{{ device.data2 || 'N/A' }}
</el-descriptions-item>
<el-descriptions-item label="数据3">
{{ device.data3 || 'N/A' }}
</el-descriptions-item>
<!-- <el-descriptions-item label="MQTT 连接参数">-->
<!-- <el-button type="primary" @click="openMqttParams">查看</el-button>-->
<!-- </el-descriptions-item>-->
</el-descriptions>
</ContentWrap>
<!-- MQTT 连接参数弹框 -->
<Dialog
title="MQTT 连接参数"
v-model="mqttDialogVisible"
width="50%"
:before-close="handleCloseMqttDialog"
>
<el-form :model="mqttParams" label-width="120px">
<el-form-item label="clientId">
<el-input v-model="mqttParams.mqttClientId" readonly>
<template #append>
<el-button @click="copyToClipboard(mqttParams.mqttClientId)" type="primary">
<Icon icon="ph:copy" />
</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="username">
<el-input v-model="mqttParams.mqttUsername" readonly>
<template #append>
<el-button @click="copyToClipboard(mqttParams.mqttUsername)" type="primary">
<Icon icon="ph:copy" />
</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="passwd">
<el-input
v-model="mqttParams.mqttPassword"
readonly
:type="passwordVisible ? 'text' : 'password'"
>
<template #append>
<el-button @click="passwordVisible = !passwordVisible" type="primary">
<Icon :icon="passwordVisible ? 'ph:eye-slash' : 'ph:eye'" />
</el-button>
<el-button @click="copyToClipboard(mqttParams.mqttPassword)" type="primary">
<Icon icon="ph:copy" />
</el-button>
</template>
</el-input>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="mqttDialogVisible = false">关闭</el-button>
</template>
</Dialog>
<!-- TODO 待开发设备标签 -->
<!-- TODO 待开发设备地图 -->
</template>
<script setup lang="ts">
import { DICT_TYPE } from '@/utils/dict'
import { ProductVO } from '@/api/iot/product/product'
import { formatDate } from '@/utils/formatTime'
import { DeviceVO } from '@/api/iot/device/device'
import { DeviceApi, MqttConnectionParamsVO } from '@/api/iot/device/device/index'
const message = useMessage() // 消息提示
const { product, device } = defineProps<{ product: ProductVO; device: DeviceVO }>() // 定义 Props
const emit = defineEmits(['refresh']) // 定义 Emits
const mqttDialogVisible = ref(false) // 定义 MQTT 弹框的可见性
const passwordVisible = ref(false) // 定义密码可见性状态
const mqttParams = ref({
mqttClientId: '',
mqttUsername: '',
mqttPassword: ''
}) // 定义 MQTT 参数对象
/** 复制到剪贴板方法 */
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
message.success('复制成功')
} catch (error) {
message.error('复制失败')
}
}
/** 打开 MQTT 参数弹框的方法 */
const openMqttParams = async () => {
try {
const data = await DeviceApi.getMqttConnectionParams(device.id)
// 根据 API 响应结构正确获取数据
// TODO @haohao'N/A' 是不是在 ui 里处理哈
mqttParams.value = {
mqttClientId: data.mqttClientId || 'N/A',
mqttUsername: data.mqttUsername || 'N/A',
mqttPassword: data.mqttPassword || 'N/A'
}
// 显示 MQTT 弹框
mqttDialogVisible.value = true
} catch (error) {
console.error('获取 MQTT 连接参数出错:', error)
message.error('获取MQTT连接参数失败请检查网络连接或联系管理员')
}
}
/** 关闭 MQTT 弹框的方法 */
const handleCloseMqttDialog = () => {
mqttDialogVisible.value = false
}
</script>

View File

@@ -0,0 +1,168 @@
<!-- 设备日志 -->
<template>
<ContentWrap>
<!-- 搜索区域 -->
<el-form :model="queryParams" inline>
<el-form-item>
<el-select v-model="queryParams.type" placeholder="所有" class="!w-160px">
<el-option label="所有" value="" />
<!-- TODO @super搞成枚举 -->
<el-option label="状态" value="state" />
<el-option label="事件" value="event" />
<el-option label="属性" value="property" />
<el-option label="服务" value="service" />
</el-select>
</el-form-item>
<el-form-item>
<el-input v-model="queryParams.identifier" placeholder="日志识符" class="!w-200px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">
<Icon icon="ep:search" class="mr-5px" /> 搜索
</el-button>
<el-switch
size="large"
width="80"
v-model="autoRefresh"
class="ml-20px"
inline-prompt
active-text="定时刷新"
inactive-text="定时刷新"
style="
--el-switch-on-color: #13ce66"
/>
</el-form-item>
</el-form>
<!-- 日志列表 -->
<el-table v-loading="loading" :data="list" :stripe="true" class="whitespace-nowrap">
<el-table-column label="时间" align="center" prop="ts" width="180">
<template #default="scope">
{{ formatDate(scope.row.ts) }}
</template>
</el-table-column>
<el-table-column label="类型" align="center" prop="type" width="120" />
<!-- TODO @super标识符需要翻译 -->
<el-table-column label="标识符" align="center" prop="identifier" width="120" />
<el-table-column label="内容" align="center" prop="content" :show-overflow-tooltip="true" />
</el-table>
<!-- 分页 -->
<div class="mt-10px flex justify-end">
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getLogList"
/>
</div>
</ContentWrap>
</template>
<script setup lang="ts">
import { DeviceApi } from '@/api/iot/device/device'
import { formatDate } from '@/utils/formatTime'
const props = defineProps<{
deviceKey: string
}>()
// 查询参数
const queryParams = reactive({
deviceKey: props.deviceKey,
type: '',
identifier: '',
pageNo: 1,
pageSize: 10
})
// 列表数据
const loading = ref(false)
const total = ref(0)
const list = ref([])
const autoRefresh = ref(false)
let timer: any = null // TODO @superautoRefreshEnableautoRefreshTimer对应上
// 类型映射 TODO @super需要删除么
const typeMap = {
lifetime: '生命周期',
state: '设备状态',
property: '属性',
event: '事件',
service: '服务'
}
/** 查询日志列表 */
const getLogList = async () => {
if (!props.deviceKey) return
loading.value = true
try {
const data = await DeviceApi.getDeviceLogPage(queryParams)
total.value = data.total
list.value = data.list
} finally {
loading.value = false
}
}
/** 获取日志名称 */
const getLogName = (log: any) => {
const { type, identifier } = log
let name = '未知'
if (type === 'property') {
if (identifier === 'set_reply') name = '设置回复'
else if (identifier === 'report') name = '上报'
else if (identifier === 'set') name = '设置'
} else if (type === 'state') {
name = identifier === 'online' ? '上线' : '下线'
} else if (type === 'lifetime') {
name = identifier === 'register' ? '注册' : name
}
return `${name}(${identifier})`
}
/** 搜索操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getLogList()
}
/** 监听自动刷新 */
watch(autoRefresh, (newValue) => {
if (newValue) {
timer = setInterval(() => {
getLogList()
}, 5000)
} else {
clearInterval(timer)
timer = null
}
})
/** 监听设备标识变化 */
watch(
() => props.deviceKey,
(newValue) => {
if (newValue) {
handleQuery()
}
}
)
/** 组件卸载时清除定时器 */
onBeforeUnmount(() => {
if (timer) {
clearInterval(timer)
}
})
/** 初始化 */
onMounted(() => {
if (props.deviceKey) {
getLogList()
}
})
</script>

View File

@@ -0,0 +1,134 @@
<!-- 设备物模型运行状态属性事件管理服务调用 -->
<template>
<ContentWrap>
<el-tabs v-model="activeTab">
<el-tab-pane label="运行状态" name="status">
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="标识符" prop="identifier">
<el-input
v-model="queryParams.identifier"
placeholder="请输入标识符"
clearable
class="!w-240px"
/>
</el-form-item>
<el-form-item label="属性名称" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入属性名称"
clearable
class="!w-240px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"
><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button
>
<el-button @click="resetQuery"
><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button
>
</el-form-item>
</el-form>
</ContentWrap>
<ContentWrap>
<el-tabs>
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
<el-table-column label="属性标识符" align="center" prop="property.identifier" />
<el-table-column label="属性名称" align="center" prop="property.name" />
<el-table-column label="数据类型" align="center" prop="property.dataType" />
<el-table-column label="属性值" align="center" prop="value" />
<el-table-column
label="更新时间"
align="center"
prop="updateTime"
:formatter="dateFormatter"
width="180px"
/>
<el-table-column label="操作" align="center">
<template #default="scope">
<el-button
link
type="primary"
@click="openDetail(props.device.id, scope.row.property.identifier)"
>
查看数据
</el-button>
</template>
</el-table-column>
</el-table>
</el-tabs>
<!-- 表单弹窗添加/修改 -->
<DeviceDataDetail ref="detailRef" :device="device" :product="product" />
</ContentWrap>
</el-tab-pane>
<el-tab-pane label="事件管理" name="event">
<p>事件管理</p>
</el-tab-pane>
<el-tab-pane label="服务调用" name="service">
<p>服务调用</p>
</el-tab-pane>
</el-tabs>
</ContentWrap>
</template>
<script setup lang="ts">
import { ProductVO } from '@/api/iot/product/product'
import { DeviceApi, DeviceDataVO, DeviceVO } from '@/api/iot/device/device'
import { dateFormatter } from '@/utils/formatTime'
import DeviceDataDetail from './DeviceDataDetail.vue'
const props = defineProps<{ product: ProductVO; device: DeviceVO }>()
const loading = ref(true) // 列表的加载中
const list = ref<DeviceDataVO[]>([]) // 列表的数据
const queryParams = reactive({
deviceId: -1,
identifier: undefined as string | undefined,
name: undefined as string | undefined
})
const queryFormRef = ref() // 搜索的表单
const activeTab = ref('status') // 默认选中的标签
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
queryParams.deviceId = props.device.id
list.value = await DeviceApi.getLatestDeviceProperties(queryParams)
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
queryParams.identifier = undefined
queryParams.name = undefined
handleQuery()
}
/** 添加/修改操作 */
const detailRef = ref()
const openDetail = (deviceId: number, identifier: string) => {
detailRef.value.open(deviceId, identifier)
}
/** 初始化 **/
onMounted(() => {
getList()
})
</script>

View File

@@ -0,0 +1,331 @@
<!-- 模拟设备 -->
<template>
<ContentWrap>
<el-row :gutter="20">
<!-- 左侧指令调试区域 -->
<el-col :span="12">
<el-tabs v-model="activeTab" type="border-card">
<!-- 上行指令调试 -->
<el-tab-pane label="上行指令调试" name="up">
<el-tabs v-if="activeTab === 'up'" v-model="subTab">
<!-- 属性上报 -->
<el-tab-pane label="属性上报" name="property">
<ContentWrap>
<el-table
v-loading="loading"
:data="list"
:show-overflow-tooltip="true"
:stripe="true"
>
<!-- TODO @super每个 colum 搞下宽度避免 table 每一列最后有个 . -->
<!-- TODO @super可以左侧 fixed -->
<el-table-column align="center" label="功能名称" prop="name" />
<el-table-column align="center" label="标识符" prop="identifier" />
<el-table-column align="center" label="数据类型" prop="identifier">
<!-- TODO @super不用翻译可以减少宽度的占用 -->
<template #default="{ row }">
{{ dataTypeOptionsLabel(row.property?.dataType) ?? '-' }}
</template>
</el-table-column>
<el-table-column align="left" label="数据定义" prop="identifier">
<template #default="{ row }">
<DataDefinition :data="row" />
</template>
</el-table-column>
<!-- TODO @super可以右侧 fixed -->
<el-table-column align="center" label="值" width="80">
<template #default="scope">
<el-input v-model="scope.row.simulateValue" class="!w-60px" />
</template>
</el-table-column>
</el-table>
<!-- TODO @super发送按钮可以放在右侧哈因为我们的 simulateValue 就在最右侧 -->
<div class="mt-10px">
<el-button type="primary" @click="handlePropertyReport"> 发送</el-button>
</div>
</ContentWrap>
</el-tab-pane>
<!-- 事件上报 -->
<!-- TODO @super待实现 -->
<el-tab-pane label="事件上报" name="event">
<ContentWrap>
<!-- TODO @super因为事件是每个 event 去模拟而不是类似属性的批量上传所以可以每一列后面有个模拟按钮另外使用 textarea高度 3 -->
<!-- <el-table v-loading="loading" :data="eventList" :stripe="true">
<el-table-column label="功能名称" align="center" prop="name" />
<el-table-column label="标识符" align="center" prop="identifier" />
<el-table-column label="数据类型" align="center" prop="dataType" />
<el-table-column
label="数据定义"
align="center"
prop="specs"
:show-overflow-tooltip="true"
/>
<el-table-column label="值" align="center" width="80">
<template #default="scope">
<el-input v-model="scope.row.simulateValue" class="!w-60px" />
</template>
</el-table-column>
</el-table>
<div class="mt-10px">
<el-button type="primary" @click="handleEventReport">发送</el-button>
</div> -->
</ContentWrap>
</el-tab-pane>
<!-- 状态变更 -->
<el-tab-pane label="状态变更" name="status">
<ContentWrap>
<div class="flex gap-4">
<el-button type="primary" @click="handleDeviceState(DeviceStateEnum.ONLINE)">
设备上线
</el-button>
<el-button type="danger" @click="handleDeviceState(DeviceStateEnum.OFFLINE)">
设备下线
</el-button>
</div>
</ContentWrap>
</el-tab-pane>
</el-tabs>
</el-tab-pane>
<!-- 下行指令调试 -->
<!-- TODO @super待实现 -->
<el-tab-pane label="下行指令调试" name="down">
<el-tabs v-if="activeTab === 'down'" v-model="subTab">
<!-- 属性调试 -->
<el-tab-pane label="属性调试" name="propertyDebug">
<ContentWrap>
<!-- <el-table v-loading="loading" :data="propertyList" :stripe="true">
<el-table-column label="功能名称" align="center" prop="name" />
<el-table-column label="标识符" align="center" prop="identifier" />
<el-table-column label="数据类型" align="center" prop="dataType" />
<el-table-column
label="数据定义"
align="center"
prop="specs"
:show-overflow-tooltip="true"
/>
<el-table-column label="值" align="center" width="80">
<template #default="scope">
<el-input v-model="scope.row.simulateValue" class="!w-60px" />
</template>
</el-table-column>
</el-table>
<div class="mt-10px">
<el-button type="primary" @click="handlePropertyGet">获取</el-button>
</div> -->
</ContentWrap>
</el-tab-pane>
<!-- 服务调用 -->
<!-- TODO @super待实现 -->
<el-tab-pane label="服务调用" name="service">
<ContentWrap>
<!-- 服务调用相关内容 -->
</ContentWrap>
</el-tab-pane>
</el-tabs>
</el-tab-pane>
</el-tabs>
</el-col>
<!-- 右侧设备日志区域 -->
<el-col :span="12">
<el-tabs type="border-card">
<el-tab-pane label="设备日志">
<DeviceDetailsLog :device-key="device.deviceKey" />
</el-tab-pane>
</el-tabs>
</el-col>
</el-row>
</ContentWrap>
</template>
<script lang="ts" setup>
import { ProductVO } from '@/api/iot/product/product'
import { SimulatorData, ThingModelApi } from '@/api/iot/thingmodel'
import { DeviceApi, DeviceStateEnum, DeviceVO } from '@/api/iot/device/device'
import DeviceDetailsLog from './DeviceDetailsLog.vue'
import { getDataTypeOptionsLabel } from '@/views/iot/thingmodel/config'
import { DataDefinition } from '@/views/iot/thingmodel/components'
const props = defineProps<{
product: ProductVO
device: DeviceVO
}>()
const message = useMessage() // 消息弹窗
const activeTab = ref('up') // TODO @superupstream 上行、downstream 下行
const subTab = ref('property') // TODO @superupstreamTab
const loading = ref(false)
const queryParams = reactive({
type: undefined, // TODO @supertype 默认给个第一个 tab 对应的,避免下面 watch 爆红
productId: -1
})
const list = ref<SimulatorData[]>([]) // 物模型列表的数据 TODO @superthingModelList
// TODO @superdataTypeOptionsLabel 是不是不用定义,直接用 getDataTypeOptionsLabel 在 template 中使用即可?
const dataTypeOptionsLabel = computed(() => (value: string) => getDataTypeOptionsLabel(value)) // 解析数据类型
/** 查询物模型列表 */
// TODO @supergetThingModelList 更精准
const getList = async () => {
loading.value = true
try {
queryParams.productId = props.product?.id || -1
const data = await ThingModelApi.getThingModelList(queryParams)
// 转换数据,添加 simulateValue 字段
// TODO @super貌似下面的 simulateValue 不设置也可以?
list.value = data.map((item) => ({
...item,
simulateValue: ''
}))
} finally {
loading.value = false
}
}
// // 功能列表数据结构定义
// interface TableItem {
// name: string
// identifier: string
// value: string | number
// }
// // 添加计算属性来过滤物模型数据
// const propertyList = computed(() => {
// return list.value
// .filter((item) => item.type === 'property')
// .map((item) => ({
// name: item.name,
// identifier: item.identifier,
// value: ''
// }))
// })
// const eventList = computed(() => {
// return list.value
// .filter((item) => item.type === 'event')
// .map((item) => ({
// name: item.name,
// identifier: item.identifier,
// value: ''
// }))
// })
/** 监听标签页变化 */
// todo:后续改成查询字典
watch(
[activeTab, subTab],
([newActiveTab, newSubTab]) => {
// 根据标签页设置查询类型
if (newActiveTab === 'up') {
switch (newSubTab) {
case 'property':
queryParams.type = 1
break
case 'event':
queryParams.type = 3
break
// case 'status':
// queryParams.type = 'status'
// break
}
} else if (newActiveTab === 'down') {
switch (newSubTab) {
case 'propertyDebug':
queryParams.type = 1
break
case 'service':
queryParams.type = 2
break
}
}
getList() // 切换标签时重新获取数据
},
{ immediate: true }
)
/** 处理属性上报 */
const handlePropertyReport = async () => {
// TODO @super:数据类型效验
const data: Record<string, object> = {}
list.value.forEach((item) => {
// 只有当 simulateValue 有值时才添加到 content 中
// TODO @super直接 if (item.simulateValue) 就可以哈js 这块还是比较灵活的
if (item.simulateValue !== undefined && item.simulateValue !== '') {
// TODO @super这里有个红色的 idea 告警,觉得去除下
data[item.identifier] = item.simulateValue
}
})
try {
await DeviceApi.upstreamDevice({
id: props.device.id,
type: 'property',
identifier: 'report',
data: data
})
message.success('属性上报成功')
} catch (error) {
message.error('属性上报失败')
}
}
// // 处理事件上报
// const handleEventReport = async () => {
// const contentObj: Record<string, any> = {}
// list.value
// .filter(item => item.type === 'event')
// .forEach((item) => {
// if (item.simulateValue !== undefined && item.simulateValue !== '') {
// contentObj[item.identifier] = item.simulateValue
// }
// })
// const reportData: ReportData = {
// productKey: props.product.productKey,
// deviceKey: props.device.deviceKey,
// type: 'event',
// subType: list.value.find(item => item.type === 'event')?.identifier || '',
// reportTime: new Date().toISOString(),
// content: JSON.stringify(contentObj) // 转换为 JSON 字符串
// }
// try {
// // TODO: 调用API发送数据
// console.log('上报数据:', reportData)
// message.success('事件上报成功')
// } catch (error) {
// message.error('事件上报失败')
// }
// }
/** 处理设备状态 */
const handleDeviceState = async (state: number) => {
try {
await DeviceApi.upstreamDevice({
id: props.device.id,
type: 'state',
identifier: 'report',
data: state
})
message.success(`设备${state === DeviceStateEnum.ONLINE ? '上线' : '下线'}成功`)
} catch (error) {
message.error(`设备${state === DeviceStateEnum.ONLINE ? '上线' : '下线'}失败`)
}
}
// 处理属性获取
const handlePropertyGet = async () => {
// TODO: 实现属性获取逻辑
message.success('属性获取成功')
}
// 初始化
onMounted(() => {
getList()
})
// TODO @芋艿:后续再详细 review 下;
</script>

View File

@@ -0,0 +1,92 @@
<template>
<DeviceDetailsHeader
:loading="loading"
:product="product"
:device="device"
@refresh="getDeviceData(id)"
/>
<el-col>
<el-tabs v-model="activeTab">
<el-tab-pane label="设备信息" name="info">
<DeviceDetailsInfo v-if="activeTab === 'info'" :product="product" :device="device" />
</el-tab-pane>
<el-tab-pane label="历史数据" name="data">
<DeviceDetailsData v-if="activeTab === 'data'" :device="device" />
</el-tab-pane>
<!-- <el-tab-pane label="Topic 列表" />-->
<!-- <el-tab-pane label="物模型数据" name="model">-->
<!-- <DeviceDetailsModel v-if="activeTab === 'model'" :product="product" :device="device" />-->
<!-- </el-tab-pane>-->
<!-- <el-tab-pane label="子设备管理" v-if="product.deviceType === DeviceTypeEnum.GATEWAY" />-->
<!-- <el-tab-pane label="设备影子" />-->
<!-- <el-tab-pane label="设备日志" name="log">-->
<!-- <DeviceDetailsLog v-if="activeTab === 'log'" :device-key="device.deviceKey" />-->
<!-- </el-tab-pane>-->
<!-- <el-tab-pane label="模拟设备" name="simulator">-->
<!-- <DeviceDetailsSimulator-->
<!-- v-if="activeTab === 'simulator'"-->
<!-- :product="product"-->
<!-- :device="device"-->
<!-- />-->
<!-- </el-tab-pane>-->
<!-- <el-tab-pane label="设备配置" name="config">-->
<!-- <DeviceDetailConfig-->
<!-- v-if="activeTab === 'config'"-->
<!-- :device="device"-->
<!-- @success="getDeviceData"-->
<!-- />-->
<!-- </el-tab-pane>-->
</el-tabs>
</el-col>
</template>
<script lang="ts" setup>
import { useTagsViewStore } from '@/store/modules/tagsView'
import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
import { DeviceTypeEnum, ProductApi, ProductVO } from '@/api/iot/product/product'
import DeviceDetailsHeader from './DeviceDetailsHeader.vue'
import DeviceDetailsInfo from './DeviceDetailsInfo.vue'
import DeviceDetailsData from './DeviceDetailsData.vue'
import DeviceDetailsModel from './DeviceDetailsModel.vue'
import DeviceDetailsLog from './DeviceDetailsLog.vue'
import DeviceDetailsSimulator from './DeviceDetailsSimulator.vue'
import DeviceDetailConfig from './DeviceDetailConfig.vue'
defineOptions({ name: 'IoTDeviceDetail' })
const route = useRoute()
const message = useMessage()
const id = route.params.id // 将字符串转换为数字
const loading = ref(true) // 加载中
const product = ref<ProductVO>({} as ProductVO) // 产品详情
const device = ref<DeviceVO>({} as DeviceVO) // 设备详情
const activeTab = ref('info') // 默认激活的标签页
/** 获取设备详情 */
const getDeviceData = async () => {
loading.value = true
try {
device.value = await DeviceApi.getDevice(id)
await getProductData(device.value.productId)
} finally {
loading.value = false
}
}
/** 获取产品详情 */
const getProductData = async (id: number) => {
product.value = await ProductApi.getProduct(id)
}
/** 初始化 */
const { delView } = useTagsViewStore() // 视图操作
const { currentRoute } = useRouter() // 路由
onMounted(async () => {
if (!id) {
message.warning('参数错误,产品不能为空!')
delView(unref(currentRoute))
return
}
await getDeviceData()
activeTab.value = (route.query.tab as string) || 'info'
})
</script>

View File

@@ -0,0 +1,526 @@
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="产品" prop="productId">
<el-select
v-model="queryParams.productId"
placeholder="请选择产品"
clearable
class="!w-240px"
>
<el-option
v-for="product in products"
:key="product.id"
:label="product.name"
:value="product.id"
/>
</el-select>
</el-form-item>
<el-form-item label="DeviceName" prop="deviceName">
<el-input
v-model="queryParams.deviceName"
placeholder="请输入 DeviceName"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="备注名称" prop="nickname">
<el-input
v-model="queryParams.nickname"
placeholder="请输入备注名称"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="设备类型" prop="deviceType">
<el-select
v-model="queryParams.deviceType"
placeholder="请选择设备类型"
clearable
class="!w-240px"
>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="设备状态" prop="status">
<el-select
v-model="queryParams.status"
placeholder="请选择设备状态"
clearable
class="!w-240px"
>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DEVICE_STATE)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="设备分组" prop="groupId">
<el-select
v-model="queryParams.groupId"
placeholder="请选择设备分组"
clearable
class="!w-240px"
>
<el-option
v-for="group in deviceGroups"
:key="group.id"
:label="group.name"
:value="group.id"
/>
</el-select>
</el-form-item>
<el-form-item class="float-right !mr-0 !mb-0">
<el-button-group>
<el-button :type="viewMode === 'card' ? 'primary' : 'default'" @click="viewMode = 'card'">
<Icon icon="ep:grid" />
</el-button>
<el-button :type="viewMode === 'list' ? 'primary' : 'default'" @click="viewMode = 'list'">
<Icon icon="ep:list" />
</el-button>
</el-button-group>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery">
<Icon icon="ep:search" class="mr-5px" />
搜索
</el-button>
<el-button @click="resetQuery">
<Icon icon="ep:refresh" class="mr-5px" />
重置
</el-button>
<el-button
type="primary"
plain
@click="openForm('create')"
v-hasPermi="['iot:device:create']"
>
<Icon icon="ep:plus" class="mr-5px" />
新增
</el-button>
<el-button
type="success"
plain
@click="handleExport"
:loading="exportLoading"
v-hasPermi="['iot:device:export']"
>
<Icon icon="ep:download" class="mr-5px" /> 导出
</el-button>
<el-button type="warning" plain @click="handleImport" v-hasPermi="['iot:device:import']">
<Icon icon="ep:upload" /> 导入
</el-button>
<el-button
type="primary"
plain
@click="openGroupForm"
:disabled="selectedIds.length === 0"
v-hasPermi="['iot:device:update']"
>
<Icon icon="ep:folder-add" class="mr-5px" /> 添加到分组
</el-button>
<el-button
type="danger"
plain
@click="handleDeleteList"
:disabled="selectedIds.length === 0"
v-hasPermi="['iot:device:delete']"
>
<Icon icon="ep:delete" class="mr-5px" /> 批量删除
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<template v-if="viewMode === 'card'">
<el-row :gutter="16">
<el-col v-for="item in list" :key="item.id" :xs="24" :sm="12" :md="12" :lg="6" class="mb-4">
<el-card
class="h-full transition-colors relative overflow-hidden"
:class="[
item.state === DeviceStateEnum.ONLINE
? 'border-green-500 shadow-green-200'
: 'border-red-500 shadow-red-200'
]"
:body-style="{ padding: '0' }"
>
<!-- 添加渐变背景层 -->
<div
class="absolute top-0 left-0 right-0 h-[50px] pointer-events-none"
:class="[
item.state === DeviceStateEnum.ONLINE
? 'bg-gradient-to-b from-green-100 to-transparent'
: 'bg-gradient-to-b from-red-100 to-transparent'
]"
>
</div>
<div class="p-4 relative">
<!-- 标题区域 -->
<div class="flex items-center mb-3">
<div class="mr-2.5 flex items-center">
<el-image :src="defaultIconUrl" class="w-[18px] h-[18px]" />
</div>
<div class="text-[16px] font-600 flex-1">{{ item.deviceName }}</div>
<!-- 添加设备状态标签 -->
<div class="inline-flex items-center">
<div
class="w-1 h-1 rounded-full mr-1.5"
:class="
item.state === DeviceStateEnum.ONLINE
? 'bg-[var(--el-color-success)]'
: 'bg-[var(--el-color-danger)]'
"
>
</div>
<el-text
class="!text-xs font-bold"
:type="item.state === DeviceStateEnum.ONLINE ? 'success' : 'danger'"
>
{{ getDictLabel(DICT_TYPE.IOT_DEVICE_STATE, item.state) }}
</el-text>
</div>
</div>
<!-- 信息区域 -->
<div class="flex items-center text-[14px]">
<div class="flex-1">
<div class="mb-2.5 last:mb-0">
<span class="text-[#717c8e] mr-2.5">所属产品</span>
<span class="text-[#0070ff]">
{{ products.find((p) => p.id === item.productId)?.name }}
</span>
</div>
<div class="mb-2.5 last:mb-0">
<span class="text-[#717c8e] mr-2.5">设备类型</span>
<dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="item.deviceType" />
</div>
<div class="mb-2.5 last:mb-0">
<span class="text-[#717c8e] mr-2.5">DeviceKey</span>
<span
class="text-[#0b1d30] inline-block align-middle overflow-hidden text-ellipsis whitespace-nowrap max-w-[130px]"
>
{{ item.deviceKey }}
</span>
</div>
</div>
<div class="w-[100px] h-[100px]">
<el-image :src="item.picUrl || defaultPicUrl" class="w-full h-full" />
</div>
</div>
<!-- 分隔线 -->
<el-divider class="!my-3" />
<!-- 按钮 -->
<div class="flex items-center px-0">
<el-button
class="flex-1 !px-2 !h-[32px] text-[13px]"
type="primary"
plain
@click="openForm('update', item.id)"
v-hasPermi="['iot:device:update']"
>
<Icon icon="ep:edit-pen" class="mr-1" />
编辑
</el-button>
<el-button
class="flex-1 !px-2 !h-[32px] !ml-[10px] text-[13px]"
type="warning"
plain
@click="openDetail(item.id)"
>
<Icon icon="ep:view" class="mr-1" />
详情
</el-button>
<el-button
class="flex-1 !px-2 !h-[32px] !ml-[10px] text-[13px]"
type="info"
plain
@click="openDataDialog(item)"
>
<Icon icon="ep:tickets" class="mr-1" />
数据
</el-button>
<div class="mx-[10px] h-[20px] w-[1px] bg-[#dcdfe6]"></div>
<el-button
class="!px-2 !h-[32px] text-[13px]"
type="danger"
plain
@click="handleDelete(item.id)"
v-hasPermi="['iot:device:delete']"
>
<Icon icon="ep:delete" />
</el-button>
</div>
</div>
</el-card>
</el-col>
</el-row>
</template>
<!-- 列表视图 -->
<el-table
v-else
v-loading="loading"
:data="list"
:stripe="true"
:show-overflow-tooltip="true"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="DeviceName" align="center" prop="deviceName">
<template #default="scope">
<el-link @click="openDetail(scope.row.id)">{{ scope.row.deviceName }}</el-link>
</template>
</el-table-column>
<el-table-column label="备注名称" align="center" prop="nickname" />
<el-table-column label="所属产品" align="center" prop="productId">
<template #default="scope">
{{ products.find((p) => p.id === scope.row.productId)?.name || '-' }}
</template>
</el-table-column>
<el-table-column label="设备类型" align="center" prop="deviceType">
<template #default="scope">
<dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="scope.row.deviceType" />
</template>
</el-table-column>
<el-table-column label="所属分组" align="center" prop="groupId">
<template #default="scope">
<template v-if="scope.row.groupIds?.length">
<el-tag v-for="id in scope.row.groupIds" :key="id" class="ml-5px" size="small">
{{ deviceGroups.find((g) => g.id === id)?.name }}
</el-tag>
</template>
</template>
</el-table-column>
<el-table-column label="设备状态" align="center" prop="status">
<template #default="scope">
<dict-tag :type="DICT_TYPE.IOT_DEVICE_STATE" :value="scope.row.status" />
</template>
</el-table-column>
<el-table-column
label="最后上线时间"
align="center"
prop="onlineTime"
:formatter="dateFormatter"
width="180px"
/>
<el-table-column label="操作" align="center" min-width="120px">
<template #default="scope">
<el-button
link
type="primary"
@click="openDetail(scope.row.id)"
v-hasPermi="['iot:product:query']"
>
查看
</el-button>
<el-button link type="primary" @click="openDataDialog(scope.row)"> 数据 </el-button>
<el-button
link
type="primary"
@click="openForm('update', scope.row.id)"
v-hasPermi="['iot:device:update']"
>
编辑
</el-button>
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['iot:device:delete']"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<!-- 表单弹窗添加/修改 -->
<DeviceForm ref="formRef" @success="getList" />
<!-- 分组表单组件 -->
<DeviceGroupForm ref="groupFormRef" @success="getList" />
<!-- 导入表单组件 -->
<DeviceImportForm ref="importFormRef" @success="getList" />
<!-- 设备数据弹窗 -->
<DeviceDataDialog ref="dataDialogRef" />
</template>
<script setup lang="ts">
import { DICT_TYPE, getIntDictOptions, getDictLabel } from '@/utils/dict'
import { dateFormatter } from '@/utils/formatTime'
import { DeviceApi, DeviceVO, DeviceStateEnum } from '@/api/iot/device/device'
import DeviceForm from './DeviceForm.vue'
import { ProductApi, ProductVO } from '@/api/iot/product/product'
import { DeviceGroupApi, DeviceGroupVO } from '@/api/iot/device/group'
import download from '@/utils/download'
import DeviceGroupForm from './DeviceGroupForm.vue'
import DeviceImportForm from './DeviceImportForm.vue'
import DeviceDataDialog from './DeviceDataDialog.vue'
/** IoT 设备列表 */
defineOptions({ name: 'IoTDevice' })
const message = useMessage() // 消息弹窗
const { t } = useI18n() // 国际化
const loading = ref(true) // 列表加载中
const list = ref<DeviceVO[]>([]) // 列表的数据
const total = ref(0) // 列表的总页数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
deviceName: undefined,
productId: undefined,
deviceType: undefined,
nickname: undefined,
status: undefined,
groupId: undefined
})
const queryFormRef = ref() // 搜索的表单
const exportLoading = ref(false) // 导出加载状态
const products = ref<ProductVO[]>([]) // 产品列表
const deviceGroups = ref<DeviceGroupVO[]>([]) // 设备分组列表
const selectedIds = ref<number[]>([]) // 选中的设备编号数组
const viewMode = ref<'card' | 'list'>('card') // 视图模式状态
const defaultPicUrl = ref('/src/assets/imgs/iot/device.png') // 默认设备图片
const defaultIconUrl = ref('/src/assets/svgs/iot/card-fill.svg') // 默认设备图标
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await DeviceApi.getDevicePage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
selectedIds.value = [] // 清空选择
handleQuery()
}
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 打开详情 */
const { push } = useRouter()
const openDetail = (id: number) => {
push({ name: 'IoTDeviceDetail', params: { id } })
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
// 删除的二次确认
await message.delConfirm()
// 起删除
await DeviceApi.deleteDevice(id)
message.success(t('common.delSuccess'))
// 刷新列表
await getList()
} catch {}
}
/** 导出方法 */
const handleExport = async () => {
try {
// 导出的二次确认
await message.exportConfirm()
// 发起导出
exportLoading.value = true
const data = await DeviceApi.exportDeviceExcel(queryParams)
download.excel(data, '物联网设备.xls')
} catch {
} finally {
exportLoading.value = false
}
}
/** 多选框选中数据 */
const handleSelectionChange = (selection: DeviceVO[]) => {
selectedIds.value = selection.map((item) => item.id)
}
/** 批量删除按钮操作 */
const handleDeleteList = async () => {
try {
await message.delConfirm()
// 执行批量删除
await DeviceApi.deleteDeviceList(selectedIds.value)
message.success(t('common.delSuccess'))
// 刷新列表
await getList()
} catch {}
}
/** 添加到分组操作 */
const groupFormRef = ref()
const openGroupForm = () => {
groupFormRef.value.open(selectedIds.value)
}
/** 设备导入 */
const importFormRef = ref()
const handleImport = () => {
importFormRef.value.open()
}
/** 打开设备数据弹窗 */
const dataDialogRef = ref()
const openDataDialog = (device: DeviceVO) => {
dataDialogRef.value.open(device)
}
/** 初始化 **/
onMounted(async () => {
getList()
// 获取产品列表
products.value = await ProductApi.getSimpleProductList()
// 获取分组列表
deviceGroups.value = await DeviceGroupApi.getSimpleDeviceGroupList()
})
</script>

View File

@@ -0,0 +1,61 @@
<template>
<Dialog title="设备信息详情" v-model="dialogVisible" width="700px">
<el-descriptions :column="2" border>
<el-descriptions-item label="设备名称">{{ detailData.deviceName }}</el-descriptions-item>
<el-descriptions-item label="设备分类">{{ detailData.deviceCategory || '-' }}</el-descriptions-item>
<el-descriptions-item label="采购地方">{{ detailData.purchasePlace || '-' }}</el-descriptions-item>
<el-descriptions-item label="采购供应商">{{ detailData.supplier || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系方式">{{ detailData.contactInfo || '-' }}</el-descriptions-item>
<el-descriptions-item label="采购价格">
{{ detailData.purchasePrice ? '¥' + detailData.purchasePrice : '-' }}
</el-descriptions-item>
<el-descriptions-item label="产品参数" :span="2">
{{ detailData.productParams || '-' }}
</el-descriptions-item>
<el-descriptions-item label="产品要素" :span="2">
{{ detailData.productElements || '-' }}
</el-descriptions-item>
<el-descriptions-item label="使用部件" :span="2">
{{ detailData.usedParts || '-' }}
</el-descriptions-item>
<el-descriptions-item label="设备图片" :span="2">
<el-image
v-if="detailData.picUrl"
:src="detailData.picUrl"
:preview-src-list="[detailData.picUrl]"
class="w-[120px] h-[120px]"
fit="cover"
preview-teleported
/>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="备注信息" :span="2">
{{ detailData.remark || '-' }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ formatDate(detailData.createTime) }}
</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { DeviceInfoVO } from '@/api/iot/device/deviceInfo'
import { formatDate } from '@/utils/formatTime'
/** 设备信息详情 */
defineOptions({ name: 'DeviceInfoDetail' })
const dialogVisible = ref(false) // 弹窗的是否展示
const detailData = ref<Partial<DeviceInfoVO>>({}) // 详情数据
/** 打开弹窗 */
const open = (row: DeviceInfoVO) => {
dialogVisible.value = true
detailData.value = row
}
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
</script>

View File

@@ -0,0 +1,184 @@
<template>
<Dialog :title="dialogTitle" v-model="dialogVisible" width="700px">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
v-loading="formLoading"
>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="设备名称" prop="deviceName">
<el-input v-model="formData.deviceName" placeholder="请输入设备名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="设备分类" prop="deviceCategory">
<el-input v-model="formData.deviceCategory" placeholder="请输入设备分类" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="采购地方" prop="purchasePlace">
<el-input v-model="formData.purchasePlace" placeholder="请输入采购地方" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="采购供应商" prop="supplier">
<el-input v-model="formData.supplier" placeholder="请输入采购供应商" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="联系方式" prop="contactInfo">
<el-input v-model="formData.contactInfo" placeholder="请输入联系方式" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="采购价格" prop="purchasePrice">
<el-input-number
v-model="formData.purchasePrice"
:precision="2"
:min="0"
placeholder="请输入采购价格"
class="!w-full"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="产品参数" prop="productParams">
<el-input
v-model="formData.productParams"
type="textarea"
:rows="3"
placeholder="请输入产品参数"
/>
</el-form-item>
<el-form-item label="产品要素" prop="productElements">
<el-input
v-model="formData.productElements"
type="textarea"
:rows="3"
placeholder="请输入产品要素"
/>
</el-form-item>
<el-form-item label="使用部件" prop="usedParts">
<el-input v-model="formData.usedParts" placeholder="请输入使用部件" />
</el-form-item>
<el-form-item label="设备图片" prop="picUrl">
<UploadImg v-model="formData.picUrl" :height="'120px'" :width="'120px'" />
</el-form-item>
<el-form-item label="备注信息" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:rows="3"
placeholder="请输入备注信息"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { DeviceInfoApi, DeviceInfoVO } from '@/api/iot/device/deviceInfo'
import { UploadImg } from '@/components/UploadFile'
/** 设备信息库表单 */
defineOptions({ name: 'DeviceInfoForm' })
const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗
const dialogVisible = ref(false) // 弹窗的是否展示
const dialogTitle = ref('') // 弹窗的标题
const formLoading = ref(false) // 表单的加载中
const formType = ref('') // 表单的类型create - 新增update - 修改
const formData = ref({
id: undefined,
deviceName: undefined,
deviceCategory: undefined,
purchasePlace: undefined,
supplier: undefined,
contactInfo: undefined,
purchasePrice: undefined,
productParams: undefined,
productElements: undefined,
usedParts: undefined,
picUrl: undefined,
remark: undefined
})
const formRules = reactive({
deviceName: [{ required: true, message: '设备名称不能为空', trigger: 'blur' }]
})
const formRef = ref() // 表单 Ref
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
// 修改时,设置数据
if (id) {
formLoading.value = true
try {
formData.value = await DeviceInfoApi.getDeviceInfo(id)
} finally {
formLoading.value = false
}
}
}
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
/** 提交表单 */
const emit = defineEmits(['success']) // 定义 success 事件
const submitForm = async () => {
// 校验表单
await formRef.value.validate()
// 提交请求
formLoading.value = true
try {
const data = formData.value as unknown as DeviceInfoVO
if (formType.value === 'create') {
await DeviceInfoApi.createDeviceInfo(data)
message.success(t('common.createSuccess'))
} else {
await DeviceInfoApi.updateDeviceInfo(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
// 发送操作成功的事件
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
deviceName: undefined,
deviceCategory: undefined,
purchasePlace: undefined,
supplier: undefined,
contactInfo: undefined,
purchasePrice: undefined,
productParams: undefined,
productElements: undefined,
usedParts: undefined,
picUrl: undefined,
remark: undefined
}
formRef.value?.resetFields()
}
</script>

View File

@@ -0,0 +1,139 @@
<template>
<Dialog v-model="dialogVisible" title="设备信息导入" width="400">
<el-upload
ref="uploadRef"
v-model:file-list="fileList"
:action="importUrl + '?updateSupport=' + updateSupport"
:auto-upload="false"
:disabled="formLoading"
:headers="uploadHeaders"
:limit="1"
:on-error="submitFormError"
:on-exceed="handleExceed"
:on-success="submitFormSuccess"
accept=".xlsx, .xls"
drag
>
<Icon icon="ep:upload" />
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip text-center">
<div class="el-upload__tip">
<el-checkbox v-model="updateSupport" />
是否更新已经存在的设备数据
</div>
<span>仅允许导入 xlsxlsx 格式文件</span>
<el-link
:underline="false"
style="font-size: 12px; vertical-align: baseline"
type="primary"
@click="importTemplate"
>
下载模板
</el-link>
</div>
</template>
</el-upload>
<template #footer>
<el-button :disabled="formLoading" type="primary" @click="submitForm"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script lang="ts" setup>
import { DeviceInfoApi } from '@/api/iot/device/deviceInfo'
import { getAccessToken, getTenantId } from '@/utils/auth'
import download from '@/utils/download'
defineOptions({ name: 'DeviceInfoImportForm' })
const message = useMessage() // 消息弹窗
const dialogVisible = ref(false) // 弹窗的是否展示
const formLoading = ref(false) // 表单的加载中
const uploadRef = ref()
const importUrl =
import.meta.env.VITE_BASE_URL + import.meta.env.VITE_API_URL + '/iot/device-info/import'
const uploadHeaders = ref() // 上传 Header 头
const fileList = ref([]) // 文件列表
const updateSupport = ref(0) // 是否更新已经存在的设备数据
/** 打开弹窗 */
const open = () => {
dialogVisible.value = true
updateSupport.value = 0
fileList.value = []
resetForm()
}
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
/** 提交表单 */
const submitForm = async () => {
if (fileList.value.length == 0) {
message.error('请上传文件')
return
}
// 提交请求
uploadHeaders.value = {
Authorization: 'Bearer ' + getAccessToken(),
'tenant-id': getTenantId()
}
formLoading.value = true
uploadRef.value!.submit()
}
/** 文件上传成功 */
const emits = defineEmits(['success'])
const submitFormSuccess = (response: any) => {
if (response.code !== 0) {
message.error(response.msg)
formLoading.value = false
return
}
// 拼接提示语
const data = response.data
let text = '上传成功数量:' + data.createDeviceNames.length + ';'
for (let deviceName of data.createDeviceNames) {
text += '< ' + deviceName + ' >'
}
text += '更新成功数量:' + data.updateDeviceNames.length + ';'
for (const deviceName of data.updateDeviceNames) {
text += '< ' + deviceName + ' >'
}
text += '更新失败数量:' + Object.keys(data.failureDeviceNames).length + ';'
for (const deviceName in data.failureDeviceNames) {
text += '< ' + deviceName + ': ' + data.failureDeviceNames[deviceName] + ' >'
}
message.alert(text)
formLoading.value = false
dialogVisible.value = false
// 发送操作成功的事件
emits('success')
}
/** 上传错误提示 */
const submitFormError = (): void => {
message.error('上传失败,请您重新上传!')
formLoading.value = false
}
/** 重置表单 */
const resetForm = async (): Promise<void> => {
// 重置上传状态和文件
formLoading.value = false
await nextTick()
uploadRef.value?.clearFiles()
}
/** 文件数超出提示 */
const handleExceed = (): void => {
message.error('最多只能上传一个文件!')
}
/** 下载模板操作 */
const importTemplate = async () => {
const res = await DeviceInfoApi.importDeviceInfoTemplate()
download.excel(res, '设备信息库导入模版.xls')
}
</script>

View File

@@ -0,0 +1,298 @@
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="80px"
>
<el-form-item label="设备名称" prop="deviceName">
<el-input
v-model="queryParams.deviceName"
placeholder="请输入设备名称"
clearable
@keyup.enter="handleQuery"
class="!w-200px"
/>
</el-form-item>
<el-form-item label="设备分类" prop="deviceCategory">
<el-input
v-model="queryParams.deviceCategory"
placeholder="请输入设备分类"
clearable
@keyup.enter="handleQuery"
class="!w-200px"
/>
</el-form-item>
<el-form-item label="供应商" prop="supplier">
<el-input
v-model="queryParams.supplier"
placeholder="请输入供应商"
clearable
@keyup.enter="handleQuery"
class="!w-200px"
/>
</el-form-item>
<el-form-item label="采购地方" prop="purchasePlace">
<el-input
v-model="queryParams.purchasePlace"
placeholder="请输入采购地方"
clearable
@keyup.enter="handleQuery"
class="!w-200px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery">
<Icon icon="ep:search" class="mr-5px" />
搜索
</el-button>
<el-button @click="resetQuery">
<Icon icon="ep:refresh" class="mr-5px" />
重置
</el-button>
<el-button
type="primary"
plain
@click="openForm('create')"
v-hasPermi="['iot:device-info:create']"
>
<Icon icon="ep:plus" class="mr-5px" />
新增
</el-button>
<el-button
type="success"
plain
@click="handleExport"
:loading="exportLoading"
v-hasPermi="['iot:device-info:export']"
>
<Icon icon="ep:download" class="mr-5px" />
导出
</el-button>
<el-button
type="warning"
plain
@click="handleImport"
v-hasPermi="['iot:device-info:import']"
>
<Icon icon="ep:upload" class="mr-5px" />
导入
</el-button>
<el-button
type="danger"
plain
@click="handleDeleteList"
:disabled="selectedIds.length === 0"
v-hasPermi="['iot:device-info:delete']"
>
<Icon icon="ep:delete" class="mr-5px" />
批量删除
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table
v-loading="loading"
:data="list"
:stripe="true"
:show-overflow-tooltip="true"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="设备名称" align="center" prop="deviceName" min-width="120" />
<el-table-column label="设备分类" align="center" prop="deviceCategory" min-width="100" />
<el-table-column label="采购地方" align="center" prop="purchasePlace" min-width="100" />
<el-table-column label="供应商" align="center" prop="supplier" min-width="120" />
<el-table-column label="联系方式" align="center" prop="contactInfo" min-width="120" />
<el-table-column label="采购价格" align="center" prop="purchasePrice" min-width="100">
<template #default="scope">
{{ scope.row.purchasePrice ? '¥' + scope.row.purchasePrice : '-' }}
</template>
</el-table-column>
<el-table-column label="图片" align="center" prop="picUrl" min-width="80">
<template #default="scope">
<el-image
v-if="scope.row.picUrl"
:src="scope.row.picUrl"
:preview-src-list="[scope.row.picUrl]"
class="w-[50px] h-[50px]"
fit="cover"
preview-teleported
/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column
label="创建时间"
align="center"
prop="createTime"
:formatter="dateFormatter"
width="180px"
/>
<el-table-column label="操作" align="center" min-width="150" fixed="right">
<template #default="scope">
<el-button
link
type="primary"
@click="openForm('update', scope.row.id)"
v-hasPermi="['iot:device-info:update']"
>
编辑
</el-button>
<el-button
link
type="primary"
@click="openDetail(scope.row)"
>
详情
</el-button>
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['iot:device-info:delete']"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<!-- 表单弹窗添加/修改 -->
<DeviceInfoForm ref="formRef" @success="getList" />
<!-- 详情弹窗 -->
<DeviceInfoDetail ref="detailRef" />
<!-- 导入弹窗 -->
<DeviceInfoImportForm ref="importFormRef" @success="getList" />
</template>
<script setup lang="ts">
import { dateFormatter } from '@/utils/formatTime'
import { DeviceInfoApi, DeviceInfoVO } from '@/api/iot/device/deviceInfo'
import DeviceInfoForm from './DeviceInfoForm.vue'
import DeviceInfoDetail from './DeviceInfoDetail.vue'
import DeviceInfoImportForm from './DeviceInfoImportForm.vue'
import download from '@/utils/download'
/** 设备信息库列表 */
defineOptions({ name: 'IoTDeviceInfo' })
const message = useMessage() // 消息弹窗
const { t } = useI18n() // 国际化
const loading = ref(true) // 列表加载中
const list = ref<DeviceInfoVO[]>([]) // 列表的数据
const total = ref(0) // 列表的总页数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
deviceName: undefined,
deviceCategory: undefined,
supplier: undefined,
purchasePlace: undefined
})
const queryFormRef = ref() // 搜索的表单
const exportLoading = ref(false) // 导出加载状态
const selectedIds = ref<number[]>([]) // 选中的编号数组
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await DeviceInfoApi.getDeviceInfoPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
selectedIds.value = []
handleQuery()
}
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 详情操作 */
const detailRef = ref()
const openDetail = (row: DeviceInfoVO) => {
detailRef.value.open(row)
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
await message.delConfirm()
await DeviceInfoApi.deleteDeviceInfo(id)
message.success(t('common.delSuccess'))
await getList()
} catch {}
}
/** 批量删除按钮操作 */
const handleDeleteList = async () => {
try {
await message.delConfirm()
await DeviceInfoApi.deleteDeviceInfoList(selectedIds.value)
message.success(t('common.delSuccess'))
await getList()
} catch {}
}
/** 导出方法 */
const handleExport = async () => {
try {
await message.exportConfirm()
exportLoading.value = true
const data = await DeviceInfoApi.exportDeviceInfoExcel(queryParams)
download.excel(data, '设备信息库.xls')
} catch {
} finally {
exportLoading.value = false
}
}
/** 多选框选中数据 */
const handleSelectionChange = (selection: DeviceInfoVO[]) => {
selectedIds.value = selection.map((item) => item.id)
}
/** 设备导入 */
const importFormRef = ref()
const handleImport = () => {
importFormRef.value.open()
}
/** 初始化 **/
onMounted(() => {
getList()
})
</script>

View File

@@ -0,0 +1,112 @@
<template>
<Dialog :title="dialogTitle" v-model="dialogVisible">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
v-loading="formLoading"
>
<el-form-item label="分组名字" prop="name">
<el-input v-model="formData.name" placeholder="请输入分组名字" />
</el-form-item>
<el-form-item label="分组状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:label="dict.value"
>
{{ dict.label }}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="分组描述" prop="description">
<el-input type="textarea" v-model="formData.description" placeholder="请输入分组描述" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
import { DeviceGroupApi, DeviceGroupVO } from '@/api/iot/device/group'
/** IoT 设备分组 表单 */
defineOptions({ name: 'IoTDeviceGroupForm' })
const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗
const dialogVisible = ref(false) // 弹窗的是否展示
const dialogTitle = ref('') // 弹窗的标题
const formLoading = ref(false) // 表单的加载中1修改时的数据加载2提交的按钮禁用
const formType = ref('') // 表单的类型create - 新增update - 修改
const formData = ref({
id: undefined,
name: undefined,
status: undefined,
description: undefined
})
const formRules = reactive({
name: [{ required: true, message: '分组名字不能为空', trigger: 'blur' }],
status: [{ required: true, message: '分组状态不能为空', trigger: 'blur' }]
})
const formRef = ref() // 表单 Ref
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
// 修改时,设置数据
if (id) {
formLoading.value = true
try {
formData.value = await DeviceGroupApi.getDeviceGroup(id)
} finally {
formLoading.value = false
}
}
}
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
/** 提交表单 */
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
const submitForm = async () => {
// 校验表单
await formRef.value.validate()
// 提交请求
formLoading.value = true
try {
const data = formData.value as unknown as DeviceGroupVO
if (formType.value === 'create') {
await DeviceGroupApi.createDeviceGroup(data)
message.success(t('common.createSuccess'))
} else {
await DeviceGroupApi.updateDeviceGroup(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
// 发送操作成功的事件
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
name: undefined,
status: undefined,
description: undefined
}
formRef.value?.resetFields()
}
</script>

View File

@@ -0,0 +1,169 @@
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="分组名字" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入分组名字"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="创建时间" prop="createTime">
<el-date-picker
v-model="queryParams.createTime"
value-format="YYYY-MM-DD HH:mm:ss"
type="daterange"
start-placeholder="开始日期"
end-placeholder="结束日期"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
class="!w-220px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
<el-button
type="primary"
plain
@click="openForm('create')"
v-hasPermi="['iot:device-group:create']"
>
<Icon icon="ep:plus" class="mr-5px" /> 新增
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
<el-table-column label="分组 ID" align="center" prop="id" />
<el-table-column label="分组名字" align="center" prop="name" />
<el-table-column label="分组状态" align="center" prop="status">
<template #default="scope">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
</template>
</el-table-column>
<el-table-column label="分组描述" align="center" prop="description" />
<el-table-column
label="创建时间"
align="center"
prop="createTime"
:formatter="dateFormatter"
width="180px"
/>
<el-table-column label="设备数量" align="center" prop="deviceCount" />
<el-table-column label="操作" align="center" min-width="120px">
<template #default="scope">
<el-button
link
type="primary"
@click="openForm('update', scope.row.id)"
v-hasPermi="['iot:device-group:update']"
>
编辑
</el-button>
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['iot:device-group:delete']"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<!-- 表单弹窗添加/修改 -->
<DeviceGroupForm ref="formRef" @success="getList" />
</template>
<script setup lang="ts">
import { DICT_TYPE } from '@/utils/dict'
import { dateFormatter } from '@/utils/formatTime'
import { DeviceGroupApi, DeviceGroupVO } from '@/api/iot/device/group'
import DeviceGroupForm from './DeviceGroupForm.vue'
/** IoT 设备分组列表 */
defineOptions({ name: 'IoTDeviceGroup' })
const message = useMessage() // 消息弹窗
const { t } = useI18n() // 国际化
const loading = ref(true) // 列表的加载中
const list = ref<DeviceGroupVO[]>([]) // 列表的数据
const total = ref(0) // 列表的总页数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: undefined,
createTime: []
})
const queryFormRef = ref() // 搜索的表单
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await DeviceGroupApi.getDeviceGroupPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
// 删除的二次确认
await message.delConfirm()
// 发起删除
await DeviceGroupApi.deleteDeviceGroup(id)
message.success(t('common.delSuccess'))
// 刷新列表
await getList()
} catch {}
}
/** 初始化 **/
onMounted(() => {
getList()
})
</script>

View File

@@ -0,0 +1,223 @@
<template>
<el-dialog
v-model="historyDialogVisible"
:title="dialogTitle"
width="900px"
destroy-on-close
>
<div class="history-toolbar">
<el-form inline label-width="0">
<el-form-item>
<el-date-picker
v-model="historyRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
class="history-range-picker"
@change="onHistorySearch"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchHistoryData">查询</el-button>
</el-form-item>
</el-form>
</div>
<div class="history-chart" v-loading="historyLoading">
<template v-if="historyData.length">
<Echart :key="echartKey" :options="historyChartOption" height="320px" />
</template>
<el-empty v-else description="暂无历史数据" />
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import dayjs from 'dayjs'
import { ElMessage } from 'element-plus'
import type { EChartsOption } from 'echarts'
import { Echart } from '@/components/Echart'
import { DeviceApi } from '@/api/iot/device/device/index'
interface LatestData {
collectedAt?: number | string | null
temperatureC?: number | string | null
analog5?: number | string | null
deviceType?: number | string | null
}
interface ProductWithLatest {
id: number
name?: string
latestData?: LatestData | null
}
const historyDialogVisible = ref(false)
const selectedDevice = ref<ProductWithLatest | null>(null)
const historyLoading = ref(false)
const historyData = ref<any[]>([])
const historyRange = ref<[string, string]>(['', ''])
const echartKey = ref(0) // 强制 Echart 重新渲染,避免二次打开不显示
const dialogTitle = computed(() =>
selectedDevice.value ? `${selectedDevice.value.name || '设备'}历史数据` : '设备历史数据'
)
const formatTimestamp = (value: number | string | null | undefined) => {
if (value === null || value === undefined) return '-'
const num = typeof value === 'number' ? value : Number(value)
const ts = Number.isNaN(num) ? Date.parse(String(value)) : num < 1e12 ? num * 1000 : num
if (!Number.isFinite(ts)) return '-'
const date = dayjs(ts)
return date.isValid() ? date.format('YYYY-MM-DD HH:mm:ss') : '-'
}
const toNumeric = (value: number | string | null | undefined) => {
if (value === null || value === undefined || value === '') return null
const num = typeof value === 'number' ? value : Number(value)
return Number.isNaN(num) ? null : Number(num.toFixed(2))
}
const toTimestamp = (value: number | string | null | undefined) => {
if (value === null || value === undefined || value === '') return null
const num = typeof value === 'number' ? value : Number(value)
const ts = Number.isNaN(num) ? Date.parse(String(value)) : num < 1e12 ? num * 1000 : num
return Number.isFinite(ts) ? ts : null
}
// 按时间升序展示,让最新数据在图表右侧
const sortedHistoryList = computed(() => {
const list = historyData.value || []
return [...list].sort((a, b) => {
const ta = toTimestamp(a?.collectedAt) || 0
const tb = toTimestamp(b?.collectedAt) || 0
return ta - tb
})
})
const historyChartOption = computed<EChartsOption>(() => {
const list = sortedHistoryList.value
const times = list.map((item) => formatTimestamp(item.collectedAt))
const temperatures = list.map((item) => toNumeric(item.temperatureC))
const humidities = list.map((item) => toNumeric(item.analog5))
return {
tooltip: {
trigger: 'axis'
},
legend: {
data: ['温度(℃)', '湿度(RH)']
},
grid: {
left: 40,
right: 40,
top: 40,
bottom: 40
},
xAxis: {
type: 'category',
boundaryGap: false,
data: times
},
yAxis: [
{
type: 'value',
name: '温度(℃)'
},
{
type: 'value',
name: '湿度(RH)'
}
],
series: [
{
name: '温度(℃)',
type: 'line',
smooth: true,
data: temperatures,
showSymbol: false
},
{
name: '湿度(RH)',
type: 'line',
smooth: true,
yAxisIndex: 1,
data: humidities,
showSymbol: false
}
]
}
})
const resetHistoryRange = () => {
const end = dayjs()
const start = end.subtract(1, 'day')
historyRange.value = [start.format('YYYY-MM-DD 00:00:00'), end.format('YYYY-MM-DD 23:59:59')]
}
const fetchHistoryData = async () => {
if (!selectedDevice.value) return
if (!historyRange.value?.[0] || !historyRange.value?.[1]) {
ElMessage.warning('请选择时间范围')
return
}
historyLoading.value = true
try {
const params = {
deviceId: selectedDevice.value.id,
startTime: historyRange.value[0],
endTime: historyRange.value[1]
}
const res = await DeviceApi.getOeeBaseDataList(params)
const data = (res as any)?.data ?? (res as any) ?? []
historyData.value = Array.isArray(data) ? data : []
echartKey.value += 1
} catch (error) {
historyData.value = []
ElMessage.error('获取历史数据失败')
} finally {
historyLoading.value = false
}
}
const onHistorySearch = () => {
// 将结束时间调整为当天的 23:59:59日期选择器默认是 00:00:00
if (historyRange.value?.[1]) {
const end = dayjs(historyRange.value[1])
// 如果结束时间是 00:00:00说明是日期选择器默认值需要调整为当天的 23:59:59
if (end.format('HH:mm:ss') === '00:00:00') {
historyRange.value[1] = end.format('YYYY-MM-DD') + ' 23:59:59'
}
}
fetchHistoryData()
}
const openHistoryDialog = (device: ProductWithLatest) => {
selectedDevice.value = device
resetHistoryRange()
historyDialogVisible.value = true
fetchHistoryData()
}
defineExpose({
openHistoryDialog
})
</script>
<style scoped>
.history-toolbar {
margin-bottom: 12px;
}
.history-range-picker {
width: 360px;
}
.history-chart {
min-height: 320px;
}
</style>

View File

@@ -0,0 +1,480 @@
<template>
<ContentWrap class="device-data-page">
<div class="page-header" style="display: flex; justify-content: flex-start; margin-bottom: 16px;">
<el-button type="primary" @click="openBindDialog">
<el-icon class="mr-5px"><Plus /></el-icon>
绑定设备
</el-button>
</div>
<el-divider />
<div class="device-card-list" v-loading="loading">
<el-empty v-if="!loading && deviceCards.length === 0" description="暂无监测设备" />
<div v-else class="device-card-grid">
<el-card
v-for="item in deviceCards"
:key="item.id"
class="device-card"
shadow="hover"
@click="openHistoryDialog(item)"
>
<div class="device-card-header">
<div>
<div class="device-card-name">{{ item.name || '未命名设备' }}</div>
</div>
<el-button
class="card-action"
text
type="danger"
:disabled="!item.deviceId"
@click.stop="handleUnbindDevice(item)"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
<div class="device-card-meta meta-secondary">
<span>设备类型{{ deviceTypeText(item.latestData?.deviceType) }}</span>
</div>
<div class="metric-group">
<div
:class="[
'metric-item',
'metric-temperature',
{ 'metric-abnormal': isTemperatureAbnormal(item), 'metric-normal': isTemperatureNormal(item) }
]"
>
<div class="metric-label">温度()</div>
<div class="metric-value">{{ formatMetric(item.latestData?.temperatureC, 2) }}</div>
</div>
<div
:class="[
'metric-item',
'metric-humidity',
{ 'metric-abnormal': isHumidityAbnormal(item), 'metric-normal': isHumidityNormal(item) }
]"
>
<div class="metric-label">湿度(RH)</div>
<div class="metric-value">{{ formatMetric(item.latestData?.analog5, 2) }}</div>
</div>
</div>
<div class="device-card-footer">
<div>
<span class="footer-label">采集时间</span>
<span>{{ formatTimestamp(item.latestData?.collectedAt) }}</span>
</div>
</div>
</el-card>
</div>
</div>
</ContentWrap>
<DeviceDataForm ref="formRef" />
<!-- 绑定设备弹窗 -->
<el-dialog v-model="bindDialogVisible" title="绑定设备到产品" width="500px" destroy-on-close>
<el-form ref="bindFormRef" :model="bindForm" :rules="bindFormRules" label-width="100px">
<el-form-item label="选择产品" prop="productId">
<el-select v-model="bindForm.productId" placeholder="请选择产品" filterable class="!w-full">
<el-option
v-for="item in productList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="设备ID" prop="deviceId">
<el-input-number
v-model="bindForm.deviceId"
placeholder="请输入设备ID"
:min="1"
class="!w-full"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="bindDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleBindDevice" :loading="bindLoading">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Delete, Plus } from '@element-plus/icons-vue'
import dayjs from 'dayjs'
import DeviceDataForm from './DeviceDataForm.vue'
import { ProductApi } from '@/api/iot/product/product/index'
interface LatestData {
collectedAt?: number | string | null
temperatureC?: number | string | null
analog5?: number | string | null
currentValue?: number | string | null
counter1?: number | string | null
counter2?: number | string | null
counter1Total?: number | string | null
counter2Total?: number | string | null
powerW?: number | string | null
isOn?: boolean
deviceType?: number | string | null
frameRaw?: string | null
}
interface ProductWithLatest {
id: number
deviceId?: number
name?: string
productKey?: string
categoryName?: string | null
netType?: number | null
status?: number | null
latestData?: LatestData | null
tempLimit?: string | null
humidityLimit?: string | null
}
const formRef = ref<InstanceType<typeof DeviceDataForm> | null>(null)
const loading = ref(false)
const deviceCards = ref<ProductWithLatest[]>([])
// 绑定设备相关
const bindDialogVisible = ref(false)
const bindFormRef = ref<FormInstance>()
const bindLoading = ref(false)
const productList = ref<Array<{ id: number; name: string }>>([])
const bindForm = ref({
productId: undefined as number | undefined,
deviceId: undefined as number | undefined
})
const bindFormRules: FormRules = {
productId: [{ required: true, message: '请选择产品', trigger: 'change' }],
deviceId: [{ required: true, message: '请输入设备ID', trigger: 'blur' }]
}
const fetchDeviceCards = async () => {
loading.value = true
try {
const res = await ProductApi.getMonitoredProductList()
const data = (res as any)?.data ?? (res as any) ?? []
deviceCards.value = data
} catch (error) {
deviceCards.value = []
ElMessage.error('获取设备列表失败')
} finally {
loading.value = false
}
}
const formatMetric = (value: number | string | null | undefined, digits = 2) => {
if (value === null || value === undefined) return '-'
const num = typeof value === 'number' ? value : Number(value)
if (Number.isNaN(num)) return '-'
return digits >= 0 ? num.toFixed(digits) : String(num)
}
const formatTimestamp = (value: number | string | null | undefined) => {
if (value === null || value === undefined) return '-'
const num = typeof value === 'number' ? value : Number(value)
const ts = Number.isNaN(num) ? Date.parse(String(value)) : num < 1e12 ? num * 1000 : num
if (!Number.isFinite(ts)) return '-'
const date = dayjs(ts)
return date.isValid() ? date.format('YYYY-MM-DD HH:mm:ss') : '-'
}
const parseLimitRange = (limit?: string | null) => {
if (!limit) return null
const [minStr, maxStr] = limit.split(',').map((item) => item?.trim())
const min = Number(minStr)
const max = Number(maxStr)
if (!Number.isFinite(min) || !Number.isFinite(max)) return null
return { min, max }
}
const isValueOutOfRange = (value: number | string | null | undefined, limit?: string | null) => {
const range = parseLimitRange(limit)
if (!range) return false
if (value === null || value === undefined) return false
const num = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(num)) return false
return num < range.min || num > range.max
}
const isTemperatureAbnormal = (item: ProductWithLatest) =>
isValueOutOfRange(item.latestData?.temperatureC, item.tempLimit)
const isHumidityAbnormal = (item: ProductWithLatest) =>
isValueOutOfRange(item.latestData?.analog5, item.humidityLimit)
const isTemperatureNormal = (item: ProductWithLatest) => {
const range = parseLimitRange(item.tempLimit)
if (!range) return false
if (item.latestData?.temperatureC === null || item.latestData?.temperatureC === undefined) return false
const num = typeof item.latestData.temperatureC === 'number' ? item.latestData.temperatureC : Number(item.latestData.temperatureC)
if (!Number.isFinite(num)) return false
return num >= range.min && num <= range.max
}
const isHumidityNormal = (item: ProductWithLatest) => {
const range = parseLimitRange(item.humidityLimit)
if (!range) return false
if (item.latestData?.analog5 === null || item.latestData?.analog5 === undefined) return false
const num = typeof item.latestData.analog5 === 'number' ? item.latestData.analog5 : Number(item.latestData.analog5)
if (!Number.isFinite(num)) return false
return num >= range.min && num <= range.max
}
const deviceTypeText = (type?: number | string | null) => {
if (type === null || type === undefined) return '未知'
if (typeof type === 'number') {
const map: Record<number, string> = {
0: '直连设备',
1: '网关子设备',
2: '网关设备'
}
return map[type] || `类型${type}`
}
const trimmed = String(type).trim()
return trimmed.length > 0 ? trimmed : '未知'
}
const openHistoryDialog = (device: ProductWithLatest) => {
formRef.value?.openHistoryDialog(device)
}
// 打开绑定设备弹窗
const openBindDialog = async () => {
bindForm.value = {
productId: undefined,
deviceId: undefined
}
bindFormRef.value?.resetFields()
bindDialogVisible.value = true
await fetchProductList()
}
// 获取产品列表
const fetchProductList = async () => {
try {
const res = await ProductApi.getSimpleProductList()
const data = (res as any)?.data ?? (res as any) ?? []
productList.value = data.map((item: any) => ({
id: item.id,
name: item.name || `产品${item.id}`
}))
} catch (error) {
ElMessage.error('获取产品列表失败')
productList.value = []
}
}
// 绑定设备
const handleBindDevice = async () => {
if (!bindFormRef.value) return
await bindFormRef.value.validate(async (valid) => {
if (!valid) return
bindLoading.value = true
try {
// 先获取产品详情
const productRes = await ProductApi.getProduct(bindForm.value.productId!)
const productData = (productRes as any)?.data ?? (productRes as any)
// 更新产品的 deviceId
const updateData = {
...productData,
deviceId: bindForm.value.deviceId
}
await ProductApi.updateProduct(updateData)
ElMessage.success('绑定设备成功')
bindDialogVisible.value = false
// 刷新设备卡片列表
await fetchDeviceCards()
} catch (error) {
ElMessage.error('绑定设备失败')
} finally {
bindLoading.value = false
}
})
}
const handleUnbindDevice = async (product: ProductWithLatest) => {
if (!product.deviceId) {
ElMessage.info('当前产品未绑定设备')
return
}
try {
await ElMessageBox.confirm(`确认解除产品「${product.name || product.id}」与设备的绑定?`, '提示', {
type: 'warning'
})
} catch (error) {
return
}
try {
const productRes = await ProductApi.getProduct(product.id)
const productData = (productRes as any)?.data ?? (productRes as any)
await ProductApi.updateProduct({
...productData,
deviceId: null
})
ElMessage.success('解绑成功')
await fetchDeviceCards()
} catch (error) {
ElMessage.error('解绑失败')
}
}
onMounted(fetchDeviceCards)
</script>
<style scoped>
.device-data-page {
min-height: 400px;
}
.page-header {
margin-bottom: 16px;
display: flex;
justify-content: flex-end;
}
.device-card-list {
min-height: 320px;
}
.device-card-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
@media (max-width: 1400px) {
.device-card-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.device-card-grid {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
}
.device-card {
border-radius: 12px;
cursor: pointer;
transition: transform 0.2s;
}
.device-card:hover {
transform: translateY(-2px);
}
.device-card-header {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.device-card-name {
font-size: 18px;
font-weight: 600;
color: #303133;
}
.device-card-sub {
margin-top: 6px;
font-size: 12px;
color: #909399;
}
.device-card-meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #909399;
margin-bottom: 12px;
}
.meta-secondary {
justify-content: flex-start;
gap: 20px;
margin-top: -4px;
margin-bottom: 12px;
}
.metric-group {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.metric-item {
background-color: #f5f7fa;
border-radius: 8px;
padding: 10px 12px;
}
.metric-abnormal {
background-color: #ffecec;
}
.metric-normal {
background-color: #ddffe7;
}
.metric-label {
font-size: 12px;
color: #909399;
}
.metric-value {
margin-top: 4px;
font-size: 20px;
font-weight: 600;
color: #303133;
}
.metric-desc {
margin-top: 2px;
font-size: 12px;
color: #909399;
}
.device-card-footer {
margin-top: 14px;
padding-top: 10px;
border-top: 1px solid #ebeef5;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: #909399;
}
.footer-label {
color: #606266;
}
.card-action {
padding: 0;
min-height: auto;
}
.card-action[disabled] {
color: #dcdfe6;
}
.frame-text {
display: inline-block;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@@ -0,0 +1,509 @@
<template>
<!-- 第一行统计卡片行 -->
<el-row :gutter="16" class="mb-4">
<el-col :span="6">
<el-card class="stat-card" shadow="never">
<div class="flex flex-col">
<div class="flex justify-between items-center mb-1">
<span class="text-gray-500 text-base font-medium">分类数量</span>
<Icon icon="ep:menu" class="text-[32px] text-blue-400" />
</div>
<span class="text-3xl font-bold text-gray-700">
{{ statsData.productCategoryCount }}
</span>
<el-divider class="my-2" />
<div class="flex justify-between items-center text-gray-400 text-sm">
<span>今日新增</span>
<span class="text-green-500">+{{ statsData.productCategoryTodayCount }}</span>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card" shadow="never">
<div class="flex flex-col">
<div class="flex justify-between items-center mb-1">
<span class="text-gray-500 text-base font-medium">产品数量</span>
<Icon icon="ep:box" class="text-[32px] text-orange-400" />
</div>
<span class="text-3xl font-bold text-gray-700">{{ statsData.productCount }}</span>
<el-divider class="my-2" />
<div class="flex justify-between items-center text-gray-400 text-sm">
<span>今日新增</span>
<span class="text-green-500">+{{ statsData.productTodayCount }}</span>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card" shadow="never">
<div class="flex flex-col">
<div class="flex justify-between items-center mb-1">
<span class="text-gray-500 text-base font-medium">设备数量</span>
<Icon icon="ep:cpu" class="text-[32px] text-purple-400" />
</div>
<span class="text-3xl font-bold text-gray-700">{{ statsData.deviceCount }}</span>
<el-divider class="my-2" />
<div class="flex justify-between items-center text-gray-400 text-sm">
<span>今日新增</span>
<span class="text-green-500">+{{ statsData.deviceTodayCount }}</span>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card" shadow="never">
<div class="flex flex-col">
<div class="flex justify-between items-center mb-1">
<span class="text-gray-500 text-base font-medium">设备消息数</span>
<Icon icon="ep:message" class="text-[32px] text-teal-400" />
</div>
<span class="text-3xl font-bold text-gray-700">
{{ statsData.deviceMessageCount }}
</span>
<el-divider class="my-2" />
<div class="flex justify-between items-center text-gray-400 text-sm">
<span>今日新增</span>
<span class="text-green-500">+{{ statsData.deviceMessageTodayCount }}</span>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 第二行图表行 -->
<el-row :gutter="16" class="mb-4">
<el-col :span="12">
<el-card class="chart-card" shadow="never">
<template #header>
<div class="flex items-center">
<span class="text-base font-medium text-gray-600">设备数量统计</span>
</div>
</template>
<div ref="deviceCountChartRef" class="h-[240px]"></div>
</el-card>
</el-col>
<el-col :span="12">
<el-card class="chart-card" shadow="never">
<template #header>
<div class="flex items-center">
<span class="text-base font-medium text-gray-600">设备状态统计</span>
</div>
</template>
<el-row class="h-[240px]">
<el-col :span="8" class="flex flex-col items-center">
<div ref="deviceOnlineCountChartRef" class="h-[160px] w-full"></div>
<div class="text-center mt-2">
<span class="text-sm text-gray-600">在线设备</span>
</div>
</el-col>
<el-col :span="8" class="flex flex-col items-center">
<div ref="deviceOfflineChartRef" class="h-[160px] w-full"></div>
<div class="text-center mt-2">
<span class="text-sm text-gray-600">离线设备</span>
</div>
</el-col>
<el-col :span="8" class="flex flex-col items-center">
<div ref="deviceActiveChartRef" class="h-[160px] w-full"></div>
<div class="text-center mt-2">
<span class="text-sm text-gray-600">待激活设备</span>
</div>
</el-col>
</el-row>
</el-card>
</el-col>
</el-row>
<!-- 第三行消息统计行 -->
<el-row>
<el-col :span="24">
<el-card class="chart-card" shadow="never">
<template #header>
<div class="flex items-center justify-between">
<span class="text-base font-medium text-gray-600">上下行消息量统计</span>
<div class="flex items-center space-x-2">
<el-radio-group v-model="timeRange" @change="handleTimeRangeChange">
<el-radio-button label="1h">最近1小时</el-radio-button>
<el-radio-button label="24h">最近24小时</el-radio-button>
<el-radio-button label="7d">近一周</el-radio-button>
</el-radio-group>
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
:default-time="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 1, 1, 23, 59, 59)]"
@change="handleDateRangeChange"
/>
</div>
</div>
</template>
<div ref="deviceMessageCountChartRef" class="h-[300px]"></div>
</el-card>
</el-col>
</el-row>
<!-- TODO 第四行地图 -->
</template>
<script setup lang="ts" name="Index">
import * as echarts from 'echarts/core'
import {
GridComponent,
LegendComponent,
TitleComponent,
ToolboxComponent,
TooltipComponent
} from 'echarts/components'
import { GaugeChart, LineChart, PieChart } from 'echarts/charts'
import { LabelLayout, UniversalTransition } from 'echarts/features'
import { CanvasRenderer } from 'echarts/renderers'
import {
IotStatisticsDeviceMessageSummaryRespVO,
IotStatisticsSummaryRespVO,
ProductCategoryApi
} from '@/api/iot/statistics'
import { formatDate } from '@/utils/formatTime'
// TODO @super参考下 /Users/yunai/Java/yudao-ui-admin-vue3/src/views/mall/home/index.vue拆一拆组件
/** IoT 首页 */
defineOptions({ name: 'IoTHome' })
// TODO @super使用下 Echart 组件,参考 yudao-ui-admin-vue3/src/views/mall/home/components/TradeTrendCard.vue 等
echarts.use([
TooltipComponent,
LegendComponent,
PieChart,
CanvasRenderer,
LabelLayout,
TitleComponent,
ToolboxComponent,
GridComponent,
LineChart,
UniversalTransition,
GaugeChart
])
const timeRange = ref('7d') // 修改默认选择为近一周
const dateRange = ref<[Date, Date] | null>(null)
const queryParams = reactive({
startTime: Date.now() - 7 * 24 * 60 * 60 * 1000, // 设置默认开始时间为 7 天前
endTime: Date.now() // 设置默认结束时间为当前时间
})
const deviceCountChartRef = ref() // 设备数量统计的图表
const deviceOnlineCountChartRef = ref() // 在线设备统计的图表
const deviceOfflineChartRef = ref() // 离线设备统计的图表
const deviceActiveChartRef = ref() // 待激活设备统计的图表
const deviceMessageCountChartRef = ref() // 上下行消息量统计的图表
// 基础统计数据
// TODO @super初始为 -1然后界面展示先是加载中试试用 cursor 改哈
const statsData = ref<IotStatisticsSummaryRespVO>({
productCategoryCount: 0,
productCount: 0,
deviceCount: 0,
deviceMessageCount: 0,
productCategoryTodayCount: 0,
productTodayCount: 0,
deviceTodayCount: 0,
deviceMessageTodayCount: 0,
deviceOnlineCount: 0,
deviceOfflineCount: 0,
deviceInactiveCount: 0,
productCategoryDeviceCounts: {}
})
// 消息统计数据
const messageStats = ref<IotStatisticsDeviceMessageSummaryRespVO>({
upstreamCounts: {},
downstreamCounts: {}
})
/** 处理快捷时间范围选择 */
const handleTimeRangeChange = (timeRange: string) => {
const now = Date.now()
let startTime: number
// TODO @super这个的计算看看能不能结合 dayjs 简化。因为 1h、24h、7d 感觉是比较标准的。如果没有,抽到 utils/formatTime.ts 作为一个工具方法
switch (timeRange) {
case '1h':
startTime = now - 60 * 60 * 1000
break
case '24h':
startTime = now - 24 * 60 * 60 * 1000
break
case '7d':
startTime = now - 7 * 24 * 60 * 60 * 1000
break
default:
return
}
// 清空日期选择器
dateRange.value = null
// 更新查询参数
queryParams.startTime = startTime
queryParams.endTime = now
// 重新获取数据
getStats()
}
/** 处理自定义日期范围选择 */
const handleDateRangeChange = (value: [Date, Date] | null) => {
if (value) {
// 清空快捷选项
timeRange.value = ''
// 更新查询参数
queryParams.startTime = value[0].getTime()
queryParams.endTime = value[1].getTime()
// 重新获取数据
getStats()
}
}
/** 获取统计数据 */
const getStats = async () => {
// 获取基础统计数据
statsData.value = await ProductCategoryApi.getIotStatisticsSummary()
// 获取消息统计数据
messageStats.value = await ProductCategoryApi.getIotStatisticsDeviceMessageSummary(queryParams)
// 初始化图表
initCharts()
}
/** 初始化图表 */
const initCharts = () => {
// 设备数量统计
echarts.init(deviceCountChartRef.value).setOption({
tooltip: {
trigger: 'item'
},
legend: {
top: '5%',
right: '10%',
align: 'left',
orient: 'vertical',
icon: 'circle'
},
series: [
{
name: 'Access From',
type: 'pie',
radius: ['50%', '80%'],
avoidLabelOverlap: false,
center: ['30%', '50%'],
label: {
show: false,
position: 'outside'
},
emphasis: {
label: {
show: true,
fontSize: 20,
fontWeight: 'bold'
}
},
labelLine: {
show: false
},
data: Object.entries(statsData.value.productCategoryDeviceCounts).map(([name, value]) => ({
name,
value
}))
}
]
})
// 在线设备统计
initGaugeChart(deviceOnlineCountChartRef.value, statsData.value.deviceOnlineCount, '#0d9')
// 离线设备统计
initGaugeChart(deviceOfflineChartRef.value, statsData.value.deviceOfflineCount, '#f50')
// 待激活设备统计
initGaugeChart(deviceActiveChartRef.value, statsData.value.deviceInactiveCount, '#05b')
// 消息量统计
initMessageChart()
}
/** 初始化仪表盘图表 */
const initGaugeChart = (el: any, value: number, color: string) => {
echarts.init(el).setOption({
series: [
{
type: 'gauge',
startAngle: 360,
endAngle: 0,
min: 0,
max: statsData.value.deviceCount || 100, // 使用设备总数作为最大值
progress: {
show: true,
width: 12,
itemStyle: {
color: color
}
},
axisLine: {
lineStyle: {
width: 12,
color: [[1, '#E5E7EB']]
}
},
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
pointer: { show: false },
anchor: { show: false },
title: { show: false },
detail: {
valueAnimation: true,
fontSize: 24,
fontWeight: 'bold',
fontFamily: 'Inter, sans-serif',
color: color,
offsetCenter: [0, '0'],
formatter: (value: number) => {
return `${value}`
}
},
data: [{ value: value }]
}
]
})
}
/** 初始化消息统计图表 */
const initMessageChart = () => {
// 获取所有时间戳并排序
// TODO @super一些 idea 里的红色报错,要去处理掉噢。
const timestamps = Array.from(
new Set([
...messageStats.value.upstreamCounts.map((item) => Number(Object.keys(item)[0])),
...messageStats.value.downstreamCounts.map((item) => Number(Object.keys(item)[0]))
])
).sort((a, b) => a - b) // 确保时间戳从小到大排序
// 准备数据
const xdata = timestamps.map((ts) => formatDate(ts, 'YYYY-MM-DD HH:mm'))
const upData = timestamps.map((ts) => {
const item = messageStats.value.upstreamCounts.find(
(count) => Number(Object.keys(count)[0]) === ts
)
return item ? Object.values(item)[0] : 0
})
const downData = timestamps.map((ts) => {
const item = messageStats.value.downstreamCounts.find(
(count) => Number(Object.keys(count)[0]) === ts
)
return item ? Object.values(item)[0] : 0
})
// 配置图表
echarts.init(deviceMessageCountChartRef.value).setOption({
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderColor: '#E5E7EB',
textStyle: {
color: '#374151'
}
},
legend: {
data: ['上行消息量', '下行消息量'],
textStyle: {
color: '#374151',
fontWeight: 500
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: xdata,
axisLine: {
lineStyle: {
color: '#E5E7EB'
}
},
axisLabel: {
color: '#6B7280'
}
},
yAxis: {
type: 'value',
axisLine: {
lineStyle: {
color: '#E5E7EB'
}
},
axisLabel: {
color: '#6B7280'
},
splitLine: {
lineStyle: {
color: '#F3F4F6'
}
}
},
series: [
{
name: '上行消息量',
type: 'line',
smooth: true, // 添加平滑曲线
data: upData,
itemStyle: {
color: '#3B82F6'
},
lineStyle: {
width: 2
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(59, 130, 246, 0.2)' },
{ offset: 1, color: 'rgba(59, 130, 246, 0)' }
])
}
},
{
name: '下行消息量',
type: 'line',
smooth: true, // 添加平滑曲线
data: downData,
itemStyle: {
color: '#10B981'
},
lineStyle: {
width: 2
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(16, 185, 129, 0.2)' },
{ offset: 1, color: 'rgba(16, 185, 129, 0)' }
])
}
}
]
})
}
/** 初始化 */
onMounted(() => {
getStats()
})
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,488 @@
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form ref="queryFormRef" :inline="true" :model="queryParams" class="-mb-15px" label-width="68px">
<el-form-item label="任务ID" prop="taskId">
<el-input
v-model="queryParams.taskId" class="!w-240px" clearable placeholder="请输入任务ID"
@keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="设备名称" prop="deviceName">
<el-input
v-model="queryParams.deviceName" class="!w-240px" clearable placeholder="请输入设备名称"
@keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" class="!w-240px" clearable placeholder="请选择状态">
<el-option label="待执行" value="PENDING" />
<el-option label="正常" value="NORMAL" />
<el-option label="异常" value="ABNORMAL" />
<el-option label="已错过" value="MISSED" />
</el-select>
</el-form-item>
<el-form-item label="日期范围" prop="dateRange">
<el-date-picker
v-model="dateRange" type="daterange" range-separator="至" start-placeholder="开始日期"
end-placeholder="结束日期" format="YYYY-MM-DD" value-format="YYYY-MM-DD" class="!w-240px"
@change="handleDateRangeChange" />
</el-form-item>
<el-form-item>
<el-button @click="handleQuery">
<Icon icon="ep:search" class="mr-5px" />
搜索
</el-button>
<el-button @click="resetQuery">
<Icon icon="ep:refresh" class="mr-5px" />
重置
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
<el-table-column label="实例ID" align="center" prop="id" width="80" />
<el-table-column label="任务ID" align="center" prop="taskId" width="80" />
<el-table-column label="设备名称" align="center" prop="deviceName" min-width="120" />
<el-table-column label="执行时间窗口" align="center" min-width="200">
<template #default="{ row }">
<div class="text-12px">
<div>开始{{ formatDateTime(row.windowStartTime) }}</div>
<div>结束{{ formatDateTime(row.windowEndTime) }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="状态" align="center" prop="status" width="100">
<template #default="{ row }">
<el-tag :type="getStatusTag(row.status)">
{{ getStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="最新记录ID" align="center" prop="latestRecordId" width="120">
<template #default="{ row }">
{{ row.latestRecordId || '-' }}
</template>
</el-table-column>
<el-table-column label="创建时间" align="center" prop="createTime" width="180">
<template #default="{ row }">
{{ formatDateTime(row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row.id)" v-hasPermi="['iot:inspection-instance:query']">
查看
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination
:total="total" v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize"
@pagination="getList" />
</ContentWrap>
<!-- 详情弹窗 -->
<Dialog v-model="detailVisible" title="实例详情" width="800px">
<!-- 实例基本信息 -->
<el-descriptions :column="2" border>
<el-descriptions-item label="实例ID">{{ detailData.id }}</el-descriptions-item>
<el-descriptions-item label="任务ID">{{ detailData.taskId }}</el-descriptions-item>
<el-descriptions-item label="设备ID">{{ detailData.deviceId }}</el-descriptions-item>
<el-descriptions-item label="设备名称">{{ detailData.deviceName }}</el-descriptions-item>
<el-descriptions-item label="窗口开始时间" :span="2">{{ formatDateTime(detailData.windowStartTime)
}}</el-descriptions-item>
<el-descriptions-item label="窗口结束时间" :span="2">{{ formatDateTime(detailData.windowEndTime)
}}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusTag(detailData.status)">
{{ getStatusLabel(detailData.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="最新记录ID">{{ detailData.latestRecordId || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建者">{{ detailData.creator || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(detailData.createTime || '') }}</el-descriptions-item>
</el-descriptions>
<!-- 点检记录列表 -->
<div class="mt-4">
<div class="flex justify-between items-center mb-3">
<h4 class="text-lg font-medium">点检记录</h4>
</div>
<el-table :data="recordsList" :stripe="true" :show-overflow-tooltip="true" v-loading="recordsLoading">
<el-table-column label="记录ID" align="center" prop="id" width="80" />
<el-table-column label="提交人" align="center" prop="submitterUserName" width="100" />
<el-table-column label="点检结果" align="center" prop="resultStatus" width="100">
<template #default="{ row }">
<el-tag :type="getStatusTag(row.resultStatus)">
{{ getStatusLabel(row.resultStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="照片数量" align="center" prop="photos" width="100">
<template #default="{ row }">
{{ row.photos?.length || 0 }}
</template>
</el-table-column>
<el-table-column label="结果摘要" align="center" min-width="150">
<template #default="{ row }">
<el-button v-if="row.resultSummary" link type="primary" @click="openResultSummaryDialog(row.resultSummary)">
查看摘要
</el-button>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="提交时间" align="center" prop="submitTime" width="180">
<template #default="{ row }">
{{ formatDateTime(row.submitTime) }}
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="100">
<template #default="{ row }">
<el-button link type="primary" @click="viewRecordDetail(row.id)">
查看
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</Dialog>
<!-- 记录详情弹窗 -->
<Dialog v-model="recordDetailVisible" title="记录详情" width="600px">
<el-descriptions :column="2" border>
<el-descriptions-item label="记录ID">{{ recordDetailData.id }}</el-descriptions-item>
<el-descriptions-item label="实例ID">{{ recordDetailData.instanceId }}</el-descriptions-item>
<el-descriptions-item label="提交人ID">{{ recordDetailData.submitterUserId }}</el-descriptions-item>
<el-descriptions-item label="提交人姓名">{{ recordDetailData.submitterUserName }}</el-descriptions-item>
<el-descriptions-item label="点检结果">
<el-tag :type="getStatusTag(recordDetailData.resultStatus)">
{{ getStatusLabel(recordDetailData.resultStatus) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="结果摘要" :span="2">{{ recordDetailData.resultSummary || '-' }}</el-descriptions-item>
<el-descriptions-item label="异常描述" :span="2">{{ recordDetailData.abnormalDesc || '-' }}</el-descriptions-item>
<el-descriptions-item label="提交时间">{{ formatDateTime(recordDetailData.submitTime || '') }}</el-descriptions-item>
<el-descriptions-item label="照片" :span="2" v-if="recordDetailData.photos?.length">
<div class="flex flex-wrap gap-2">
<el-image
v-for="(photo, index) in recordDetailData.photos" :key="index" :src="photo.url"
:preview-src-list="recordDetailData.photos.map(p => p.url)"
style="width: 80px; height: 80px; border-radius: 4px;" fit="cover" />
</div>
</el-descriptions-item>
</el-descriptions>
</Dialog>
<!-- 结果摘要解析弹窗 -->
<Dialog v-model="resultSummaryVisible" title="点检结果摘要" width="800px">
<div v-if="parsedResultSummary.length > 0">
<div v-for="(item, index) in parsedResultSummary" :key="index" class="mb-4">
<el-card class="result-summary-card">
<template #header>
<div class="flex justify-between items-center">
<span class="font-medium text-lg">{{ item.name }}</span>
<el-tag :type="getStatusTag(item.status)">
{{ getStatusLabel(item.status) }}
</el-tag>
</div>
</template>
<div class="space-y-3">
<div>
<span class="text-gray-600 font-medium">检查内容:</span>
<span>{{ item.desc }}</span>
</div>
<div v-if="item.status === 'ABNORMAL'">
<div v-if="item.abnormal_reason" class="mb-2">
<span class="text-gray-600 font-medium">异常描述:</span>
<span class="text-red-600">{{ item.abnormal_reason }}</span>
</div>
<div v-if="item.picurl" class="mt-3">
<span class="text-gray-600 font-medium">异常图片:</span>
<div class="flex flex-wrap gap-2 mt-2">
<el-image
v-for="(url, urlIndex) in item.picurl.split(',')" :key="urlIndex" :src="url.trim()"
:preview-src-list="item.picurl.split(',').map(u => u.trim())"
style="width: 100px; height: 100px; border-radius: 4px;" fit="cover"
class="border border-gray-200" />
</div>
</div>
</div>
</div>
</el-card>
</div>
</div>
<div v-else class="text-center text-gray-500 py-8">
暂无解析数据
</div>
</Dialog>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
// 提交点检记录功能已移除,无需 Plus 图标与上传类型
import * as InspectionInstanceApi from '@/api/iot/inspection/instance'
import * as InspectionRecordApi from '@/api/iot/inspection/record'
import type { InspectionInstanceVO, InspectionInstancePageReqVO } from '@/api/iot/inspection/instance'
import type { InspectionRecordVO } from '@/api/iot/inspection/record'
defineOptions({ name: 'InspectionInstance' })
const message = useMessage() // 消息弹窗
const loading = ref(true) // 列表的加载中
const list = ref<InspectionInstanceVO[]>([]) // 列表的数据
const total = ref(0) // 列表的总页数
// 计算默认日期范围:昨天到三天后
const getDefaultDateRange = () => {
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
yesterday.setHours(0, 0, 0, 0)
const threeDaysLater = new Date()
threeDaysLater.setDate(threeDaysLater.getDate() + 3)
threeDaysLater.setHours(23, 59, 59, 999)
return {
dateFrom: yesterday.toISOString().split('T')[0],
dateTo: threeDaysLater.toISOString().split('T')[0]
}
}
const defaultDateRange = getDefaultDateRange()
const queryParams = reactive<InspectionInstancePageReqVO>({
pageNo: 1,
pageSize: 10,
taskId: undefined,
deviceName: undefined,
status: undefined,
dateFrom: defaultDateRange.dateFrom,
dateTo: defaultDateRange.dateTo
})
const queryFormRef = ref() // 搜索的表单
// 日期范围选择器
const dateRange = ref<[string, string]>([defaultDateRange.dateFrom, defaultDateRange.dateTo])
// 详情相关
const detailVisible = ref(false)
const detailData = ref<InspectionInstanceVO>({} as InspectionInstanceVO)
// 记录列表相关
const recordsList = ref<InspectionRecordVO[]>([])
const recordsLoading = ref(false)
// 记录详情相关
const recordDetailVisible = ref(false)
const recordDetailData = ref<InspectionRecordVO>({} as InspectionRecordVO)
// 结果摘要解析相关
const resultSummaryVisible = ref(false)
const parsedResultSummary = ref<any[]>([])
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await InspectionInstanceApi.getInspectionInstancePage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 日期范围变化处理 */
const handleDateRangeChange = (value: [string, string] | null) => {
if (value && value.length === 2) {
queryParams.dateFrom = value[0]
queryParams.dateTo = value[1]
} else {
queryParams.dateFrom = undefined
queryParams.dateTo = undefined
}
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
// 重置日期范围到默认值
dateRange.value = [defaultDateRange.dateFrom, defaultDateRange.dateTo]
queryParams.dateFrom = defaultDateRange.dateFrom
queryParams.dateTo = defaultDateRange.dateTo
handleQuery()
}
/** 打开详情 */
const openDetail = async (id: number) => {
try {
const data = await InspectionInstanceApi.getInspectionInstance(id)
detailData.value = data
detailVisible.value = true
// 同时加载记录列表
await loadRecords(id)
} catch (error) {
message.error('获取详情失败')
}
}
/** 加载记录列表 */
const loadRecords = async (instanceId: number) => {
recordsLoading.value = true
try {
const data = await InspectionRecordApi.getInspectionRecordList(instanceId)
recordsList.value = data
} catch (error) {
message.error('获取记录列表失败')
recordsList.value = []
} finally {
recordsLoading.value = false
}
}
/** 查看记录详情 */
const viewRecordDetail = async (id: number) => {
try {
const data = await InspectionRecordApi.getInspectionRecord(id)
recordDetailData.value = data
recordDetailVisible.value = true
} catch (error) {
message.error('获取记录详情失败')
}
}
/** 获取状态标签 */
const getStatusTag = (status: string) => {
switch (status) {
case 'PENDING':
return 'warning'
case 'NORMAL':
return 'success'
case 'ABNORMAL':
return 'danger'
case 'MISSED':
return 'info'
default:
return 'info'
}
}
/** 获取状态标签文本 */
const getStatusLabel = (status: string) => {
switch (status) {
case 'PENDING':
return '待执行'
case 'NORMAL':
return '正常'
case 'ABNORMAL':
return '异常'
case 'MISSED':
return '未执行'
default:
return status
}
}
/** 格式化日期时间 */
const formatDateTime = (date: string) => {
if (!date) return '-'
return new Date(date).toLocaleString()
}
/** 打开结果摘要弹窗 */
const openResultSummaryDialog = (resultSummary: string) => {
try {
// 解析JSON字符串
const parsed = JSON.parse(resultSummary)
// 确保是数组格式
if (Array.isArray(parsed)) {
parsedResultSummary.value = parsed
} else {
// 如果不是数组,包装成数组
parsedResultSummary.value = [parsed]
}
resultSummaryVisible.value = true
} catch (error) {
console.error('解析结果摘要JSON失败:', error)
message.error('结果摘要数据格式错误,无法解析')
parsedResultSummary.value = []
}
}
onMounted(() => {
getList()
})
</script>
<style scoped>
.flex {
display: flex;
}
.flex-wrap {
flex-wrap: wrap;
}
.gap-2 {
gap: 0.5rem;
}
.text-12px {
font-size: 12px;
}
.result-summary-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
}
.space-y-3>*+* {
margin-top: 0.75rem;
}
.text-gray-600 {
color: #6b7280;
}
.text-red-600 {
color: #dc2626;
}
.font-medium {
font-weight: 500;
}
.text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
}
.border-gray-200 {
border-color: #e5e7eb;
}
</style>

View File

@@ -0,0 +1,583 @@
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form ref="queryFormRef" :inline="true" :model="queryParams" class="-mb-15px" label-width="68px">
<el-form-item label="设备名称" prop="deviceName">
<el-input v-model="queryParams.deviceName" class="!w-240px" clearable placeholder="请输入设备名称"
@keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="任务名称" prop="name">
<el-input v-model="queryParams.name" class="!w-240px" clearable placeholder="请输入任务名称"
@keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="频率类型" prop="frequencyType">
<el-select v-model="queryParams.frequencyType" class="!w-240px" clearable placeholder="请选择频率类型">
<el-option label="每日" value="DAILY" />
<el-option label="每周" value="WEEKLY" />
<el-option label="每月" value="MONTHLY" />
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" class="!w-240px" clearable placeholder="请选择状态">
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery">
<Icon icon="ep:search" class="mr-5px" />
搜索
</el-button>
<el-button @click="resetQuery">
<Icon icon="ep:refresh" class="mr-5px" />
重置
</el-button>
<el-button type="primary" plain @click="openForm('create')" v-hasPermi="['iot:inspection-task:create']">
<Icon icon="ep:plus" class="mr-5px" />
新增任务
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
<el-table-column label="任务名称" align="center" prop="name" min-width="150" />
<el-table-column label="设备名称" align="center" prop="deviceName" min-width="120" />
<el-table-column label="频率类型" align="center" prop="frequencyType" width="100">
<template #default="{ row }">
<el-tag :type="getFrequencyTypeTag(row.frequencyType)">
{{ getFrequencyTypeLabel(row.frequencyType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="执行时间" align="center" prop="windowStart" width="120">
<template #default="{ row }">
{{ row.windowStart }} - {{ row.windowEnd }}
</template>
</el-table-column>
<el-table-column label="生效时间" align="center" prop="effectiveStart" width="120">
<template #default="{ row }">
{{ formatDate(row.effectiveStart) }}
</template>
</el-table-column>
<el-table-column label="状态" align="center" prop="status" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" align="center" prop="createTime" width="180">
<template #default="{ row }">
{{ formatDate(row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="250" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openForm('update', row.id)"
v-hasPermi="['iot:inspection-task:update']">
编辑
</el-button>
<el-button link type="info" @click="openForm('detail', row.id)" v-hasPermi="['iot:inspection-task:query']">
查看
</el-button>
<el-button link type="success" @click="handleRebuildInstance(row)"
v-hasPermi="['iot:inspection-instance:rebuild']">
发布任务
</el-button>
<el-button link type="danger" @click="handleDelete(row.id)" v-hasPermi="['iot:inspection-task:delete']">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination :total="total" v-model:page="paginationPage" v-model:limit="paginationSize" @pagination="getList" />
</ContentWrap>
<!-- 表单弹窗添加/修改 -->
<Dialog v-model="formVisible" :title="formTitle" width="600px">
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px" v-loading="formLoading">
<ProductSelector v-model="selectedProduct" form-item label="选择产品" prop="deviceId" :required="true"
@update:model-value="handleProductChange" />
<el-form-item label="任务名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入任务名称" />
</el-form-item>
<el-form-item label="频率类型" prop="frequencyType">
<el-select v-model="formData.frequencyType" placeholder="请选择频率类型" @change="handleFrequencyTypeChange">
<el-option label="每日" value="DAILY" />
<el-option label="每周" value="WEEKLY" />
<el-option label="每月" value="MONTHLY" />
</el-select>
</el-form-item>
<el-form-item v-if="formData.frequencyType === 'WEEKLY'" label="执行星期" prop="weekdays">
<el-checkbox-group v-model="formData.weekdays">
<el-checkbox :label="1">周一</el-checkbox>
<el-checkbox :label="2">周二</el-checkbox>
<el-checkbox :label="3">周三</el-checkbox>
<el-checkbox :label="4">周四</el-checkbox>
<el-checkbox :label="5">周五</el-checkbox>
<el-checkbox :label="6">周六</el-checkbox>
<el-checkbox :label="7">周日</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item v-if="formData.frequencyType === 'MONTHLY'" label="执行日期" prop="monthDays">
<el-select v-model="formData.monthDays" multiple placeholder="请选择执行日期">
<el-option v-for="day in 31" :key="day" :label="`${day}日`" :value="day" />
</el-select>
</el-form-item>
<el-form-item label="时间窗口" prop="windowStart">
<div class="flex items-center gap-2">
<el-time-picker v-model="formData.windowStart" placeholder="开始时间" format="HH:mm" value-format="HH:mm"
style="width: 120px" />
<span></span>
<el-time-picker v-model="formData.windowEnd" placeholder="结束时间" format="HH:mm" value-format="HH:mm"
style="width: 120px" />
</div>
</el-form-item>
<el-form-item label="生效开始时间" prop="effectiveStart">
<el-date-picker v-model="formData.effectiveStart" type="datetime" placeholder="选择生效开始时间"
value-format="YYYY-MM-DDTHH:mm:ss" />
</el-form-item>
<el-form-item label="生效结束时间" prop="effectiveEnd">
<el-date-picker v-model="formData.effectiveEnd" type="datetime" placeholder="选择生效结束时间"
value-format="YYYY-MM-DDTHH:mm:ss" />
</el-form-item>
<el-form-item label="指派用户ID" prop="assigneeUserId">
<el-input v-model="formData.assigneeUserId" placeholder="请输入指派用户ID" />
</el-form-item>
<el-form-item label="指派用户名称" prop="assigneeUserName">
<el-input v-model="formData.assigneeUserName" placeholder="请输入指派用户名称" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="请输入任务描述" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm" :loading="formLoading">确定</el-button>
</template>
</Dialog>
<!-- 详情弹窗 -->
<Dialog v-model="detailVisible" title="任务详情" width="600px">
<el-descriptions :column="2" border>
<el-descriptions-item label="任务名称">{{ detailData.name }}</el-descriptions-item>
<el-descriptions-item label="设备名称">{{ detailData.deviceName }}</el-descriptions-item>
<el-descriptions-item label="设备ID">{{ detailData.deviceId }}</el-descriptions-item>
<el-descriptions-item label="频率类型">
<el-tag :type="getFrequencyTypeTag(detailData.frequencyType)">
{{ getFrequencyTypeLabel(detailData.frequencyType) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item v-if="detailData.weekdays" label="执行星期">
{{detailData.weekdays.map(d => ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日'][d]).join('、')}}
</el-descriptions-item>
<el-descriptions-item v-if="detailData.monthDays" label="执行日期">
{{detailData.monthDays.map(d => `${d}`).join('、')}}
</el-descriptions-item>
<el-descriptions-item label="时间窗口">{{ detailData.windowStart }} - {{ detailData.windowEnd
}}</el-descriptions-item>
<el-descriptions-item label="生效开始时间">{{ formatDate(detailData.effectiveStart || '') }}</el-descriptions-item>
<el-descriptions-item label="生效结束时间">{{ formatDate(detailData.effectiveEnd || '') }}</el-descriptions-item>
<el-descriptions-item label="指派用户ID">{{ detailData.assigneeUserId || '-' }}</el-descriptions-item>
<el-descriptions-item label="指派用户名称">{{ detailData.assigneeUserName || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="detailData.status === 1 ? 'success' : 'danger'">
{{ detailData.status === 1 ? '启用' : '禁用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间" :span="2">{{ formatDate(detailData.createTime || '') }}</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">{{ detailData.description || '-' }}</el-descriptions-item>
</el-descriptions>
</Dialog>
<!-- 重建实例弹窗 -->
<Dialog v-model="rebuildVisible" title="发布任务" width="500px">
<el-form ref="rebuildFormRef" :model="rebuildData" :rules="rebuildRules" label-width="120px">
<el-form-item label="任务名称">
<el-input v-model="rebuildData.name" disabled />
</el-form-item>
<el-form-item label="开始日期" prop="dateFrom">
<el-date-picker v-model="rebuildData.dateFrom" type="date" placeholder="选择开始日期" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="结束日期" prop="dateTo">
<el-date-picker v-model="rebuildData.dateTo" type="date" placeholder="选择结束日期" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="强制发布">
<el-switch v-model="rebuildData.forceRecreate" />
<div class="text-12px text-gray-500 mt-1">开启后将删除已经发布的任务并重新发布</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rebuildVisible = false">取消</el-button>
<el-button type="primary" @click="submitRebuild" :loading="rebuildLoading">确定</el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessageBox } from 'element-plus'
import type { FormInstance } from 'element-plus'
import * as InspectionTaskApi from '@/api/iot/inspection/task'
import * as InspectionInstanceApi from '@/api/iot/inspection/instance'
import type { InspectionTaskVO, InspectionTaskPageReqVO } from '@/api/iot/inspection/task'
import type { ProductVO } from '@/api/iot/product/product'
import { ProductApi } from '@/api/iot/product/product'
import ProductSelector from '@/components/ProductSelector/index.vue'
defineOptions({ name: 'InspectionTask' })
const message = useMessage() // 消息弹窗
const loading = ref(true) // 列表的加载中
const list = ref<InspectionTaskVO[]>([]) // 列表的数据
const total = ref(0) // 列表的总页数
const paginationPage = ref(1) // 分页页码
const paginationSize = ref(10) // 分页大小
const queryParams = reactive<InspectionTaskPageReqVO>({
pageNo: '1',
pageSize: '10',
deviceName: undefined,
name: undefined,
frequencyType: undefined,
status: undefined
})
const queryFormRef = ref() // 搜索的表单
// 表单相关
const formVisible = ref(false)
const formLoading = ref(false)
const formType = ref<'create' | 'update' | 'detail'>('create')
const formRef = ref<FormInstance>()
const formData = ref<InspectionTaskVO>({
deviceId: '',
deviceName: '',
name: '',
frequencyType: 'DAILY',
windowStart: '08:00',
windowEnd: '17:00',
effectiveStart: '',
assigneeUserId: '',
assigneeUserName: '',
status: 1,
description: ''
})
// 产品选择相关
const selectedProduct = ref<ProductVO | null>(null)
const formRules = reactive({
name: [{ required: true, message: '任务名称不能为空', trigger: 'blur' }],
frequencyType: [{ required: true, message: '频率类型不能为空', trigger: 'change' }],
windowStart: [{ required: true, message: '开始时间不能为空', trigger: 'change' }],
windowEnd: [{ required: true, message: '结束时间不能为空', trigger: 'change' }],
effectiveStart: [{ required: true, message: '生效开始时间不能为空', trigger: 'change' }]
})
// 详情相关
const detailVisible = ref(false)
const detailData = ref<InspectionTaskVO>({} as InspectionTaskVO)
// 重建实例相关
const rebuildVisible = ref(false)
const rebuildLoading = ref(false)
const rebuildFormRef = ref<FormInstance>()
const rebuildData = ref({
taskId: 0,
name: '',
dateFrom: '',
dateTo: '',
forceRecreate: false
})
const rebuildRules = reactive({
dateFrom: [{ required: true, message: '开始日期不能为空', trigger: 'change' }],
dateTo: [{ required: true, message: '结束日期不能为空', trigger: 'change' }]
})
const formTitle = computed(() => {
switch (formType.value) {
case 'create':
return '新增点检任务'
case 'update':
return '编辑点检任务'
case 'detail':
return '查看点检任务'
default:
return ''
}
})
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
// 同步分页参数
queryParams.pageNo = paginationPage.value.toString()
queryParams.pageSize = paginationSize.value.toString()
const data = await InspectionTaskApi.getInspectionTaskPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
paginationPage.value = 1
queryParams.pageNo = '1'
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/** 打开表单弹窗 */
const openForm = async (type: 'create' | 'update' | 'detail', id?: number) => {
formType.value = type
formVisible.value = true
resetForm()
if (type !== 'create' && id) {
formLoading.value = true
try {
const data = await InspectionTaskApi.getInspectionTask(id)
// 转换时间戳为日期时间字符串
const processedData = {
...data,
effectiveStart: data.effectiveStart ? new Date(data.effectiveStart).toISOString().slice(0, 19).replace('T', 'T') : '',
effectiveEnd: data.effectiveEnd ? new Date(data.effectiveEnd).toISOString().slice(0, 19).replace('T', 'T') : '',
createTime: data.createTime ? new Date(data.createTime).toISOString().slice(0, 19).replace('T', 'T') : '',
updateTime: data.updateTime ? new Date(data.updateTime).toISOString().slice(0, 19).replace('T', 'T') : ''
}
formData.value = processedData
// 如果有设备ID需要查找对应的产品
if (processedData.deviceId) {
try {
const product = await ProductApi.getProduct(parseInt(processedData.deviceId))
selectedProduct.value = product
} catch (error) {
console.error('获取产品信息失败:', error)
selectedProduct.value = null
}
} else {
selectedProduct.value = null
}
if (type === 'detail') {
detailData.value = processedData
formVisible.value = false
detailVisible.value = true
return
}
} finally {
formLoading.value = false
}
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
deviceId: '',
deviceName: '',
name: '',
frequencyType: 'DAILY',
windowStart: '08:00',
windowEnd: '17:00',
effectiveStart: '',
assigneeUserId: '',
assigneeUserName: '',
status: 1,
description: ''
}
selectedProduct.value = null
formRef.value?.resetFields()
}
/** 处理产品选择变化 */
const handleProductChange = (product: ProductVO | null) => {
if (product) {
formData.value.deviceId = product.id.toString()
formData.value.deviceName = product.name
} else {
formData.value.deviceId = ''
formData.value.deviceName = ''
}
}
/** 提交表单 */
const submitForm = async () => {
if (!formRef.value) return
const valid = await formRef.value.validate()
if (!valid) return
formLoading.value = true
try {
const data = { ...formData.value }
// 转换日期时间字符串为时间戳
if (data.effectiveStart) {
data.effectiveStart = String(new Date(data.effectiveStart).getTime())
}
if (data.effectiveEnd) {
data.effectiveEnd = String(new Date(data.effectiveEnd).getTime())
}
if (formType.value === 'create') {
await InspectionTaskApi.createInspectionTask(data)
message.success('创建成功')
} else {
await InspectionTaskApi.updateInspectionTask(data)
message.success('更新成功')
}
formVisible.value = false
await getList()
} finally {
formLoading.value = false
}
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
await ElMessageBox.confirm('确定删除该点检任务吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await InspectionTaskApi.deleteInspectionTask(id)
message.success('删除成功')
await getList()
} catch (error) {
if (error !== 'cancel') {
message.error('删除失败')
}
}
}
/** 重建实例 */
const handleRebuildInstance = (row: InspectionTaskVO) => {
rebuildData.value = {
taskId: row.id!,
name: row.name,
dateFrom: '',
dateTo: '',
forceRecreate: false
}
rebuildVisible.value = true
}
/** 提交重建实例 */
const submitRebuild = async () => {
if (!rebuildFormRef.value) return
const valid = await rebuildFormRef.value.validate()
if (!valid) return
rebuildLoading.value = true
try {
await InspectionInstanceApi.rebuildInspectionInstance(rebuildData.value)
message.success('发布任务成功')
rebuildVisible.value = false
} finally {
rebuildLoading.value = false
}
}
/** 频率类型变化处理 */
const handleFrequencyTypeChange = (value: string) => {
if (value === 'WEEKLY') {
formData.value.weekdays = [1, 2, 3, 4, 5] // 默认工作日
formData.value.monthDays = undefined
} else if (value === 'MONTHLY') {
formData.value.monthDays = [1, 15] // 默认每月1号和15号
formData.value.weekdays = undefined
} else {
formData.value.weekdays = undefined
formData.value.monthDays = undefined
}
}
/** 获取频率类型标签 */
const getFrequencyTypeTag = (type: string) => {
switch (type) {
case 'DAILY':
return 'primary'
case 'WEEKLY':
return 'success'
case 'MONTHLY':
return 'warning'
default:
return 'info'
}
}
/** 获取频率类型标签文本 */
const getFrequencyTypeLabel = (type: string) => {
switch (type) {
case 'DAILY':
return '每日'
case 'WEEKLY':
return '每周'
case 'MONTHLY':
return '每月'
default:
return type
}
}
/** 格式化日期 */
const formatDate = (date: string | number) => {
if (!date) return '-'
// 如果是时间戳数字直接转换如果是字符串先转换为Date
const dateObj = typeof date === 'number' ? new Date(date) : new Date(date)
return dateObj.toLocaleString()
}
onMounted(() => {
getList()
})
</script>
<style scoped>
.flex {
display: flex;
}
.items-center {
align-items: center;
}
.gap-2 {
gap: 0.5rem;
}
.text-12px {
font-size: 12px;
}
.text-gray-500 {
color: #6b7280;
}
.mt-1 {
margin-top: 0.25rem;
}
</style>

View File

@@ -0,0 +1,515 @@
<template>
<Dialog :title="dialogTitle" v-model="dialogVisible">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="110px"
v-loading="formLoading"
>
<el-divider>报修信息</el-divider>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="报修单号" prop="repairNo">
<el-input v-model="formData.repairNo" placeholder="请输入报修单号" :readonly="formType === 'create'" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="报修日期" prop="reportTime">
<el-date-picker v-model="formData.reportTime" type="datetime" placeholder="请选择报修日期" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="报修人" prop="reporter">
<el-input v-model="formData.reporter" placeholder="请输入报修人" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="联系方式" prop="contact">
<el-input v-model="formData.contact" placeholder="请输入联系方式" />
</el-form-item>
</el-col>
</el-row>
<el-divider>产品信息</el-divider>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="产品" prop="deviceId">
<el-select
v-model="formData.deviceId"
filterable
remote
clearable
placeholder="请输入关键词搜索产品"
:remote-method="searchProduct"
:loading="productLoading"
@change="onProductChange"
style="width: 100%"
>
<el-option v-for="item in productOptions" :key="item.id" :label="item.productName || item.name" :value="item.id" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="产品编码" prop="deviceCode">
<el-input v-model="formData.deviceCode" readonly />
</el-form-item>
</el-col>
</el-row>
<el-divider>故障信息</el-divider>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="故障现象描述" prop="faultDesc">
<el-input v-model="formData.faultDesc" type="textarea" placeholder="请输入故障现象描述" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="故障发生日期" prop="faultTime">
<el-date-picker v-model="formData.faultTime" type="datetime" placeholder="请选择故障发生日期" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="故障严重程度" prop="faultLevel">
<el-select v-model="formData.faultLevel" placeholder="请选择严重程度" style="width: 100%">
<el-option label="紧急" value="urgent" />
<el-option label="高" value="high" />
<el-option label="中" value="medium" />
<el-option label="低" value="low" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="是否影响安全" prop="affectSafety">
<el-radio-group v-model="formData.affectSafety">
<el-radio :label="true"></el-radio>
<el-radio :label="false"></el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-divider>其他</el-divider>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="当前状态" prop="status">
<el-select v-model="formData.status" placeholder="请选择状态" style="width: 100%">
<el-option label="待受理" value="pending" />
<el-option label="已派工" value="assigned" />
<el-option label="维修中" value="repairing" />
<el-option label="待验收" value="to_accept" />
<el-option label="已完成" value="finished" />
<el-option label="已关闭" value="closed" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="附件上传" prop="attachments">
<!-- <el-upload-->
<!-- v-model:file-list="fileList"-->
<!-- :auto-upload="true"-->
<!-- :multiple="true"-->
<!-- :http-request="customUpload"-->
<!-- :on-remove="handleUploadRemove"-->
<!-- :limit="5"-->
<!-- list-type="picture-card"-->
<!-- >-->
<!-- <el-icon><Plus /></el-icon>-->
<!-- </el-upload>-->
<el-upload
ref="uploadRef"
:file-list="fileList"
:http-request="customUpload"
:on-remove="handleUploadRemove"
list-type="picture-card"
:limit="6"
:multiple="true"
:show-file-list="true"
:auto-upload="true"
>
<!-- <i class="el-icon-plus"></i>-->
<el-icon><Plus /></el-icon>
</el-upload>
<!-- <div v-if="fileList.length > 0">-->
<!-- <p class="tip-text">已上传 {{fileList.length}} 个文件</p>-->
<!-- <p class="tip-text" style="color:#f56c6c" v-if="fileList.length >= 2">文件数量: {{fileList.length}}, IDs: {{fileList.map(f => f.uid).join(', ')}}</p>-->
<!-- </div>-->
<!-- -->
<!-- <div v-if="true" class="debug-info">-->
<!-- &lt;!&ndash; 调试信息只在需要时启用 v-if="true" &ndash;&gt;-->
<!-- <p>fileList: {{JSON.stringify(fileList)}}</p>-->
<!-- <p>formData.attachments: {{JSON.stringify(formData.attachments)}}</p>-->
<!-- </div>-->
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { DeviceApi, DeviceVO } from '@/api/iot/device/device'
import { RepairOrderApi, AttachmentVO } from '@/api/iot/maintain/repairOrder'
import { ProductApi } from '@/api/iot/product/product'
// 定义FormData类型
interface RepairFormData {
id: number
repairNo: string
reportTime: string
reporter: string
contact: string
deviceId: number
deviceName: string
deviceCode: string
faultDesc: string
faultTime: string
faultLevel: string
affectSafety: boolean
status: string
attachments: AttachmentVO[]
}
const dialogVisible = ref(false)
const dialogTitle = ref('')
const formLoading = ref(false)
const formType = ref('')
const formData = ref<RepairFormData>({
id: 0,
repairNo: '',
reportTime: '',
reporter: '',
contact: '',
deviceId: 0,
deviceName: '',
deviceCode: '',
faultDesc: '',
faultTime: '',
faultLevel: '',
affectSafety: false,
status: '',
attachments: []
})
const formRules = reactive({
repairNo: [{ required: true, message: '报修单号不能为空', trigger: 'blur' }],
reportTime: [{ required: true, message: '报修日期不能为空', trigger: 'blur' }],
reporter: [{ required: true, message: '报修人不能为空', trigger: 'blur' }],
contact: [{ required: true, message: '联系方式不能为空', trigger: 'blur' }],
deviceId: [{ required: true, message: '请选择产品', trigger: 'change' }],
faultDesc: [{ required: true, message: '故障现象不能为空', trigger: 'blur' }],
faultTime: [{ required: true, message: '故障发生日期不能为空', trigger: 'blur' }],
faultLevel: [{ required: true, message: '请选择故障严重程度', trigger: 'change' }],
affectSafety: [{ required: true, message: '请选择是否影响安全', trigger: 'change' }],
status: [{ required: true, message: '请选择当前状态', trigger: 'change' }]
})
const formRef = ref()
// 产品下拉相关
const productOptions = ref<any[]>([])
const productLoading = ref(false)
async function searchProduct(query: string) {
productLoading.value = true
try {
const res = await ProductApi.getSimpleProductList()
productOptions.value = query
? res.filter(p => (p.productName || p.name).includes(query) || (p.productKey || p.code).includes(query))
: res
} finally {
productLoading.value = false
}
}
function onProductChange(val: number) {
const prod = productOptions.value.find(p => p.id === val)
if (prod) {
formData.value.deviceName = prod.productName || prod.name
formData.value.deviceCode = prod.productKey || prod.code
} else {
formData.value.deviceName = ''
formData.value.deviceCode = ''
}
}
// 附件上传相关
const fileList = ref<any[]>([])
// 自定义上传处理
const customUpload = async (options: any) => {
const { file, onSuccess, onError } = options;
try {
console.log('[DEBUG] 开始上传文件:', file.name);
// 使用RepairOrderApi.uploadImage上传图片
const response = await RepairOrderApi.uploadImage(file);
// 添加到文件列表
fileList.value.push({
name: response.name || file.name,
url: response.url || response,
status: 'success',
uid: file.uid
});
// 更新formData.attachments
formData.value.attachments = fileList.value.map(f => ({
url: f.url,
name: f.name || '未命名附件'
}));
console.log('[DEBUG] 上传成功,文件列表:', fileList.value);
console.log('[DEBUG] 附件数据:', formData.value.attachments);
ElMessage.success('上传成功');
onSuccess({ url: response.url || response, name: response.name || file.name });
} catch (error) {
console.error('[ERROR] 上传失败:', error);
ElMessage.error('上传失败');
onError({ status: 500, message: '上传失败' });
}
};
// 删除文件处理
function handleUploadRemove(file: any, fileListArr: any[]) {
console.log('[DEBUG] 删除文件:', file);
// 通过uid找到并删除文件
const idx = fileList.value.findIndex(f => f.uid === file.uid);
if (idx !== -1) {
fileList.value.splice(idx, 1);
// 更新formData中的attachments
formData.value.attachments = fileList.value.map(f => ({
url: f.url,
name: f.name || '未命名附件'
}));
console.log('[DEBUG] 删除后的文件列表:', fileList.value);
console.log('[DEBUG] 删除后的附件数据:', formData.value.attachments);
}
}
const emit = defineEmits(['success'])
const open = async (type: string, id?: number) => {
// 先确保表单和文件列表重置
resetForm();
// 设置对话框属性
dialogVisible.value = true;
dialogTitle.value = type === 'create' ? '新增报修单' : '编辑报修单';
formType.value = type;
// 如果是创建模式,自动生成报修单号
if (type === 'create') {
// 生成格式为 "BX" + 年月日 + 4位随机数
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const randomNum = Math.floor(1000 + Math.random() * 9000); // 生成1000-9999之间的随机数
formData.value.repairNo = `BX${year}${month}${day}${randomNum}`;
formData.value.reportTime = now.toISOString();
}
// 如果是编辑模式
if (type === 'update' && id) {
formLoading.value = true;
try {
// 获取报修单数据
const res = await RepairOrderApi.getRepairOrder(id);
console.log('[DEBUG] 获取到的数据:', res);
// 处理附件数据类型
let processedAttachments: Array<{url: string, name: string}> = [];
// 解析附件数据 - 确保是数组格式
if (typeof res.attachments === 'string' && res.attachments) {
try {
// 尝试解析JSON字符串
const parsed = JSON.parse(res.attachments);
console.log('[DEBUG] 解析JSON后的附件:', parsed);
if (Array.isArray(parsed)) {
processedAttachments = parsed.map(item => {
// 确保每个项都有url和name属性
if (typeof item === 'string') {
return { url: item, name: item.split('/').pop() || '附件' };
} else if (typeof item === 'object' && item !== null) {
return {
url: item.url || '',
name: item.name || item.url?.split('/').pop() || '附件'
};
}
return { url: '', name: '无效附件' };
}).filter(item => item.url); // 过滤掉没有url的项
}
} catch (e) {
console.error('[ERROR] 解析附件数据失败:', e);
// 如果解析失败,尝试处理逗号分隔的字符串
if (res.attachments.includes(',')) {
processedAttachments = res.attachments.split(',')
.filter(url => url.trim())
.map(url => ({
url,
name: url.split('/').pop() || '附件'
}));
} else if (res.attachments.trim()) {
// 单个URL字符串
processedAttachments = [{
url: res.attachments,
name: res.attachments.split('/').pop() || '附件'
}];
}
}
} else if (Array.isArray(res.attachments)) {
// 已经是数组格式
processedAttachments = res.attachments.map(item => {
if (typeof item === 'string') {
return { url: item, name: item.split('/').pop() || '附件' };
} else {
return {
url: item.url || '',
name: item.name || item.url?.split('/').pop() || '附件'
};
}
}).filter(item => item.url);
}
// 设置处理后的附件数据
res.attachments = processedAttachments;
console.log('[DEBUG] 处理后的附件数据:', processedAttachments);
// 将数据赋值给表单
Object.assign(formData.value, res);
// 设置文件列表
if (processedAttachments.length > 0) {
// 确保每个文件有唯一的uid
fileList.value = processedAttachments.map((item, idx) => ({
url: item.url,
name: item.name || `附件${idx+1}`,
status: 'success',
uid: Date.now() + idx // 使用时间戳+索引确保唯一性
}));
console.log('[DEBUG] 编辑模式文件列表:', JSON.stringify(fileList.value));
} else {
fileList.value = [];
formData.value.attachments = [];
}
} finally {
formLoading.value = false;
}
}
}
defineExpose({ open })
const resetForm = () => {
console.log('[DEBUG] 重置表单');
// 清空文件列表
fileList.value = [];
// 重置表单数据
formData.value = {
id: 0,
repairNo: '',
reportTime: '',
reporter: '',
contact: '',
deviceId: 0,
deviceName: '',
deviceCode: '',
faultDesc: '',
faultTime: '',
faultLevel: '',
affectSafety: false,
status: '',
attachments: []
};
// 重置表单字段
nextTick(() => {
if (formRef.value) {
formRef.value.resetFields();
}
});
}
const submitForm = async () => {
// 表单验证
try {
await formRef.value.validate();
} catch (e) {
console.error('[ERROR] 表单验证失败:', e);
return;
}
// 开始提交
formLoading.value = true;
try {
console.log('[DEBUG] 开始提交表单, 文件列表数量:', fileList.value.length);
// 创建一个新的表单数据对象避免直接修改formData
const submitData = { ...formData.value };
// 确保attachments中的内容与fileList同步
submitData.attachments = fileList.value
.filter(file => file && file.url && file.url.trim() !== '')
.map(file => ({
url: file.url,
name: file.name || file.url.split('/').pop() || '未命名附件'
}));
console.log('[DEBUG] 提交的附件数据:', JSON.stringify(submitData.attachments));
console.log('[DEBUG] 提交的附件数量:', submitData.attachments.length);
// 提交表单
if (formType.value === 'create') {
await RepairOrderApi.createRepairOrder(submitData);
ElMessage.success('新增成功');
} else {
await RepairOrderApi.updateRepairOrder(submitData);
ElMessage.success('编辑成功');
}
// 关闭对话框并刷新列表
dialogVisible.value = false;
emit('success');
} catch (error) {
console.error('[ERROR] 提交失败:', error);
ElMessage.error('提交失败: ' + (error instanceof Error ? error.message : String(error)));
} finally {
formLoading.value = false;
}
}
onMounted(() => {
searchProduct('')
})
</script>
<style scoped>
.tip-text {
margin-top: 5px;
font-size: 12px;
color: #409eff;
}
.debug-info {
margin-top: 10px;
font-size: 12px;
color: #999;
white-space: pre-wrap;
overflow-wrap: break-word;
}
</style>

View File

@@ -0,0 +1,189 @@
<template>
<ContentWrap>
<!-- 搜索栏 -->
<el-form :model="queryParams" ref="queryFormRef" :inline="true" label-width="90px">
<el-form-item label="报修单号" prop="repairNo">
<el-input v-model="queryParams.repairNo" placeholder="请输入报修单号" clearable @keyup.enter="handleQuery" class="!w-200px" />
</el-form-item>
<el-form-item label="报修人" prop="reporter">
<el-input v-model="queryParams.reporter" placeholder="请输入报修人" clearable class="!w-160px" />
</el-form-item>
<el-form-item label="设备编码" prop="deviceCode">
<el-input v-model="queryParams.deviceCode" placeholder="请输入设备编码" clearable class="!w-160px" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable class="!w-140px">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 查询</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
<el-button type="primary" plain @click="openForm('create')">
<Icon icon="ep:plus" class="mr-5px" /> 新增
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
<el-table-column label="报修单号" align="center" prop="repairNo" />
<el-table-column label="报修日期" align="center" prop="reportTime">
<template #default="scope">
{{ formatDate(scope.row.reportTime) }}
</template>
</el-table-column>
<el-table-column label="报修人" align="center" prop="reporter" />
<el-table-column label="联系方式" align="center" prop="contact" />
<el-table-column label="设备编码" align="center" prop="deviceCode" />
<el-table-column label="设备名称" align="center" prop="deviceName" />
<el-table-column label="故障现象" align="center" prop="faultDesc" />
<el-table-column label="故障发生日期" align="center" prop="faultTime">
<template #default="scope">
{{ formatDate(scope.row.faultTime) }}
</template>
</el-table-column>
<el-table-column label="故障严重程度" align="center" prop="faultLevel">
<template #default="scope">
{{ faultLevelLabel(scope.row.faultLevel) }}
</template>
</el-table-column>
<el-table-column label="是否影响安全" align="center" prop="affectSafety">
<template #default="scope">
<el-tag v-if="scope.row.affectSafety === true || scope.row.affectSafety === '是'" type="danger"></el-tag>
<el-tag v-else type="success"></el-tag>
</template>
</el-table-column>
<el-table-column label="当前状态" align="center" prop="status">
<template #default="scope">
<el-tag>{{ statusLabel(scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" align="center" min-width="200px">
<template #default="scope">
<el-button link type="primary" @click="openForm('update', scope.row.id)" style="margin-right: 8px;">
编辑
</el-button>
<el-button link type="danger" @click="handleDelete(scope.row.id)" style="margin-right: 8px;">
删除
</el-button>
<el-dropdown trigger="click">
<el-button link type="info" style="padding: 0 8px;">
状态 <el-icon style="margin-left: 2px;"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="s in statusOptions"
:key="s.value"
@click="changeStatus(scope.row, s.value)"
>
<el-tag :type="scope.row.status === s.value ? 'primary' : 'info'" effect="plain" style="margin-right: 8px;">
{{ s.label }}
</el-tag>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination :total="total" v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize" @pagination="getList" />
</ContentWrap>
<!-- 表单弹窗添加/修改 -->
<RepairOrder ref="formRef" @success="getList" />
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, Ref } from 'vue'
import RepairOrder from './RepairOrder.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { RepairOrderApi } from '@/api/iot/maintain/repairOrder'
import { ArrowDown } from '@element-plus/icons-vue'
const loading = ref(false)
const list: Ref<any[]> = ref([])
const total = ref(0)
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
repairNo: '',
reporter: '',
deviceCode: '',
status: ''
})
const queryFormRef = ref()
const statusOptions = [
{ label: '待受理', value: 'pending' },
{ label: '已派工', value: 'assigned' },
{ label: '维修中', value: 'repairing' },
{ label: '待验收', value: 'to_accept' },
{ label: '已完成', value: 'finished' },
{ label: '已关闭', value: 'closed' }
]
function statusLabel(val: string) {
const found = statusOptions.find(s => s.value === val)
return found ? found.label : val
}
const formRef = ref()
const faultLevelMap = {
urgent: '紧急',
high: '高',
medium: '中',
low: '低'
}
function faultLevelLabel(val: string) {
return faultLevelMap[val] || val
}
const getList = async () => {
loading.value = true
try {
const res = await RepairOrderApi.getRepairOrderList(queryParams as any)
list.value = res.list || []
console.log(list)
total.value = res.total || 0
} finally {
loading.value = false
}
}
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
const handleDelete = async (id: number) => {
await ElMessageBox.confirm('确定删除该报修单吗?')
await RepairOrderApi.deleteRepairOrder(id)
ElMessage.success('删除成功')
getList()
}
const changeStatus = async (row: any, status: string) => {
await RepairOrderApi.updateRepairOrder({ ...row, status })
ElMessage.success('状态已更新')
getList()
}
function formatDate(ts: number) {
if (!ts) return ''
const date = new Date(ts)
return date.toLocaleString()
}
onMounted(() => {
getList()
})
</script>

View File

@@ -0,0 +1,10 @@
<template>
<QualityInspectionList :inspection-type="1" page-title="原料质检" />
</template>
<script setup lang="ts">
import QualityInspectionList from '../shared/QualityInspectionList.vue'
/** 原料质检页面 */
defineOptions({ name: 'MaterialInspection' })
</script>

262
src/views/iot/oee/index.vue Normal file
View File

@@ -0,0 +1,262 @@
<template>
<div class="oee-container">
<h2>设备OEE综合效率分析</h2>
<el-row :gutter="20" class="mb-20">
<el-col :span="14">
<el-form label-width="80px" inline>
<el-form-item label="选择设备">
<el-select v-model="selectedDeviceId" placeholder="请选择设备" class="!w-300px" @change="onDeviceChange">
<el-option v-for="item in deviceList" :key="item.id" :label="item.deviceName" :value="item.id"
:data-product-id="item.productId" />
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker v-model="timeRange" type="datetimerange" range-separator="至" start-placeholder="开始时间"
end-placeholder="结束时间" value-format="YYYY-MM-DD HH:mm:ss" :clearable="false" @change="onSearch" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSearch">查询</el-button>
</el-form-item>
</el-form>
</el-col>
</el-row>
<el-row :gutter="20" class="mb-20">
<el-col :span="8">
<el-card>
<div class="oee-metric-title">总体可用率</div>
<div class="oee-metric-value">{{ formatPercent(dashboardData?.availableRate) }}</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card>
<div class="oee-metric-title">运行时长(分钟)</div>
<div class="oee-metric-value">{{ dashboardData?.runTime ?? '-' }}</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card>
<div class="oee-metric-title">停机时长(分钟)</div>
<div class="oee-metric-value">{{ dashboardData?.stopTime ?? '-' }}</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" class="mb-20">
<el-col :span="24">
<el-card>
<div class="oee-metric-title mb-10">可用率趋势</div>
<Echart :options="oeeTrendOption" height="300px" />
</el-card>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="24">
<el-card>
<div class="oee-metric-title mb-10">基础采集数据</div>
<el-table :data="baseData.list" border stripe>
<el-table-column prop="collectedAt" label="采集时间" width="180" />
<el-table-column prop="powerW" label="功率(W)" width="120" />
<el-table-column prop="currentValue" label="电流" width="120" />
<el-table-column prop="temperatureC" label="温度(℃)" width="120" />
<el-table-column prop="isOn" label="开启" width="100">
<template #default="scope">
<el-tag :type="scope.row.isOn ? 'success' : 'info'">{{ scope.row.isOn ? '是' : '否' }}</el-tag>
</template>
</el-table-column>
</el-table>
<div class="mt-10" style="text-align: right">
<el-pagination v-model:current-page="basePage.pageNo" v-model:page-size="basePage.pageSize"
:total="baseData.total" layout="total, prev, pager, next, sizes" @current-change="fetchBaseData"
@size-change="onPageSizeChange" />
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { DeviceApi } from '@/api/iot/device/device/index'
import { ProductApi } from '@/api/iot/product/product/index'
import { Echart } from '@/components/Echart'
import type { EChartsOption } from 'echarts'
import dayjs from 'dayjs'
const deviceList = ref<any[]>([])
const selectedDeviceId = ref<number>(0)
const selectedProductId = ref<number>(0)
const dashboardData = ref<any>(null)
const timeRange = ref<[string, string]>(['', ''])
const baseData = ref<{ list: any[]; total: number }>({ list: [], total: 0 })
const basePage = ref<{ pageNo: number; pageSize: number }>({ pageNo: 1, pageSize: 10 })
const fetchDeviceList = async () => {
// 获取监测的产品列表,仅含 id 和 name
const res = await ProductApi.getMonitoredProductList()
const data = (res as any)?.data ?? (res as any) ?? []
// 兼容下游使用:把产品列表映射为设备下拉的通用结构
deviceList.value = data.map((p: any) => ({ id: p.id, deviceName: p.name, productId: p.id }))
if (deviceList.value.length > 0) {
selectedDeviceId.value = deviceList.value[0].id
selectedProductId.value = deviceList.value[0].productId
await fetchAll()
}
}
const onDeviceChange = async () => {
const selectedDevice = deviceList.value.find(item => item.id === selectedDeviceId.value)
if (selectedDevice) {
selectedProductId.value = selectedDevice.productId
}
await onSearch()
}
const onSearch = async () => {
await fetchAll()
}
const fetchAll = async () => {
if (!selectedProductId.value || !timeRange.value?.[0] || !timeRange.value?.[1]) return
await Promise.all([fetchDashboard(), fetchBaseData()])
}
const fetchDashboard = async () => {
dashboardData.value = null
try {
const res = await DeviceApi.getOeeDashboard({
deviceId: selectedProductId.value,
startTime: timeRange.value![0],
endTime: timeRange.value![1]
})
const data = (res as any)?.data ?? (res as any)
dashboardData.value = {
...data,
trend: (data?.trend || []).map((item: any) => ({
...item,
time: formatTimestamp(item?.time)
}))
}
} catch (e) {
ElMessage.error('获取仪表盘数据失败')
}
}
const fetchBaseData = async () => {
if (!timeRange.value?.[0] || !timeRange.value?.[1]) return
try {
const res = await DeviceApi.getOeeBaseDataPage({
deviceId: selectedProductId.value,
startTime: timeRange.value[0],
endTime: timeRange.value[1],
pageNo: basePage.value.pageNo,
pageSize: basePage.value.pageSize
})
const data = (res as any)?.data ?? (res as any) ?? { list: [], total: 0 }
baseData.value = {
list: (data.list || []).map((row: any) => ({
...row,
collectedAt: formatTimestamp(row?.collectedAt)
})),
total: data.total || 0
}
} catch (e) {
ElMessage.error('获取基础数据失败')
}
}
const onPageSizeChange = async () => {
basePage.value.pageNo = 1
await fetchBaseData()
}
const oeeTrendOption = computed<EChartsOption>(() => {
if (!dashboardData.value) return {}
return {
tooltip: { trigger: 'axis' },
xAxis: {
type: 'category',
data: (dashboardData.value.trend || []).map((item: any) => item.time)
},
yAxis: {
type: 'value',
min: 0,
max: 1,
axisLabel: { formatter: (val: number) => (val * 100).toFixed(0) + '%' }
},
series: [
{
name: '可用率',
type: 'line',
data: (dashboardData.value.trend || []).map((item: any) => item.availableRate),
smooth: true,
areaStyle: {}
}
]
}
})
onMounted(() => {
// 默认最近 7 天
const end = dayjs()
const start = end.subtract(7, 'day')
timeRange.value = [start.format('YYYY-MM-DD 00:00:00'), end.format('YYYY-MM-DD 23:59:59')]
fetchDeviceList()
})
const formatPercent = (val?: number) => {
if (val === undefined || val === null) return '-'
return (val * 100).toFixed(2) + '%'
}
const toMilliseconds = (value: any): number | undefined => {
if (value === undefined || value === null) return undefined
if (typeof value === 'number') return value < 1e12 ? value * 1000 : value
if (typeof value === 'string') {
if (/^\d+$/.test(value)) {
const num = Number(value)
return value.length === 10 ? num * 1000 : num
}
const parsed = Date.parse(value)
return isNaN(parsed) ? undefined : parsed
}
return undefined
}
const formatTimestamp = (value: any): string => {
const ms = toMilliseconds(value)
if (!ms) return value != null ? String(value) : '-'
const d = dayjs(ms)
return d.isValid() ? d.format('YYYY-MM-DD HH:mm:ss') : String(value)
}
</script>
<style scoped>
.oee-container {
padding: 24px;
}
.oee-metric-title {
margin-bottom: 8px;
font-size: 16px;
color: #666;
}
.oee-metric-value {
font-size: 28px;
font-weight: bold;
color: #409eff;
}
.oee-metric-value.oee-main {
color: #67c23a;
}
.mb-10 {
margin-bottom: 10px;
}
.mb-20 {
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,106 @@
<template>
<Dialog :title="dialogTitle" v-model="dialogVisible">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
v-loading="formLoading"
>
<el-form-item label="插件名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入插件名称" />
</el-form-item>
<el-form-item label="部署方式" prop="deployType">
<el-select v-model="formData.deployType" placeholder="请选择部署方式">
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PLUGIN_DEPLOY_TYPE)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { PluginConfigApi, PluginConfigVO } from '@/api/iot/plugin'
/** IoT 插件配置 表单 */
defineOptions({ name: 'PluginConfigForm' })
const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗
const dialogVisible = ref(false) // 弹窗的是否展示
const dialogTitle = ref('') // 弹窗的标题
const formLoading = ref(false) // 表单的加载中1修改时的数据加载2提交的按钮禁用
const formType = ref('') // 表单的类型create - 新增update - 修改
const formData = ref({
id: undefined,
name: undefined,
deployType: undefined
})
const formRules = reactive({
name: [{ required: true, message: '插件名称不能为空', trigger: 'blur' }],
deployType: [{ required: true, message: '部署方式不能为空', trigger: 'change' }]
})
const formRef = ref() // 表单 Ref
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
// 修改时,设置数据
if (id) {
formLoading.value = true
try {
formData.value = await PluginConfigApi.getPluginConfig(id)
} finally {
formLoading.value = false
}
}
}
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
/** 提交表单 */
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
const submitForm = async () => {
// 校验表单
await formRef.value.validate()
// 提交请求
formLoading.value = true
try {
const data = formData.value as unknown as PluginConfigVO
if (formType.value === 'create') {
await PluginConfigApi.createPluginConfig(data)
message.success(t('common.createSuccess'))
} else {
await PluginConfigApi.updatePluginConfig(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
// 发送操作成功的事件
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
name: undefined,
deployType: undefined
}
formRef.value?.resetFields()
}
</script>

View File

@@ -0,0 +1,99 @@
<template>
<Dialog v-model="dialogVisible" title="插件导入" width="400">
<el-upload
ref="uploadRef"
v-model:file-list="fileList"
:action="importUrl + '?id=' + props.id"
:auto-upload="false"
:disabled="formLoading"
:headers="uploadHeaders"
:limit="1"
:on-error="submitFormError"
:on-exceed="handleExceed"
:on-success="submitFormSuccess"
accept=".jar"
drag
>
<Icon icon="ep:upload" />
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
</el-upload>
<template #footer>
<el-button :disabled="formLoading" type="primary" @click="submitForm"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script lang="ts" setup>
import { getAccessToken, getTenantId } from '@/utils/auth'
defineOptions({ name: 'PluginImportForm' })
const props = defineProps<{ id: number }>() // 接收 id 作为 props
const message = useMessage() // 消息弹窗
const dialogVisible = ref(false) // 弹窗的是否展示
const formLoading = ref(false) // 表单的加载中
const uploadRef = ref()
const importUrl =
import.meta.env.VITE_BASE_URL + import.meta.env.VITE_API_URL + '/iot/plugin-config/upload-file'
const uploadHeaders = ref() // 上传 Header 头
const fileList = ref([]) // 文件列表
/** 打开弹窗 */
const open = () => {
dialogVisible.value = true
fileList.value = []
resetForm()
}
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
/** 提交表单 */
const submitForm = async () => {
if (fileList.value.length == 0) {
message.error('请上传文件')
return
}
// 提交请求
uploadHeaders.value = {
Authorization: 'Bearer ' + getAccessToken(),
'tenant-id': getTenantId()
}
formLoading.value = true
uploadRef.value!.submit()
}
/** 文件上传成功 */
const emits = defineEmits(['success'])
const submitFormSuccess = (response: any) => {
if (response.code !== 0) {
message.error(response.msg)
formLoading.value = false
return
}
message.alert('上传成功')
formLoading.value = false
dialogVisible.value = false
// 发送操作成功的事件
emits('success')
}
/** 上传错误提示 */
const submitFormError = (): void => {
message.error('上传失败,请您重新上传!')
formLoading.value = false
}
/** 重置表单 */
const resetForm = async (): Promise<void> => {
// 重置上传状态和文件
formLoading.value = false
await nextTick()
uploadRef.value?.clearFiles()
}
/** 文件数超出提示 */
const handleExceed = (): void => {
message.error('最多只能上传一个文件!')
}
</script>

View File

@@ -0,0 +1,120 @@
<template>
<div>
<div class="flex items-start justify-between">
<div>
<el-col>
<el-row>
<span class="text-xl font-bold">插件配置</span>
</el-row>
</el-col>
</div>
</div>
<ContentWrap class="mt-10px">
<el-descriptions :column="2" direction="horizontal">
<el-descriptions-item label="插件名称">
{{ pluginConfig.name }}
</el-descriptions-item>
<el-descriptions-item label="插件标识">
{{ pluginConfig.pluginKey }}
</el-descriptions-item>
<el-descriptions-item label="版本号">
{{ pluginConfig.version }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-switch
v-model="pluginConfig.status"
:active-value="1"
:inactive-value="0"
:disabled="pluginConfig.id <= 0"
@change="handleStatusChange"
/>
</el-descriptions-item>
<el-descriptions-item label="插件描述">
{{ pluginConfig.description }}
</el-descriptions-item>
</el-descriptions>
</ContentWrap>
<!-- TODO @haohao如果是独立部署也是通过上传插件包哇 -->
<ContentWrap class="mt-10px">
<el-button type="warning" plain @click="handleImport" v-hasPermi="['system:user:import']">
<Icon icon="ep:upload" /> 上传插件包
</el-button>
</ContentWrap>
</div>
<!-- TODO @haohao待完成配置管理 -->
<!-- TODO @haohao待完成script 管理可以最后搞 -->
<!-- TODO @haohao插件实例的前端展示底部要不要加个分页展示运行中的实力默认勾选只展示 state 为在线的 -->
<!-- 插件导入对话框 -->
<PluginImportForm
ref="importFormRef"
:id="pluginConfig.id"
@success="getPluginConfig(pluginConfig.id)"
/>
</template>
<script lang="ts" setup>
import { PluginConfigApi, PluginConfigVO } from '@/api/iot/plugin'
import { useRoute } from 'vue-router'
import { onMounted, ref } from 'vue'
import PluginImportForm from './PluginImportForm.vue'
const message = useMessage()
const route = useRoute()
const pluginConfig = ref<PluginConfigVO>({
id: 0,
pluginKey: '',
name: '',
description: '',
version: '',
status: 0,
deployType: 0,
fileName: '',
type: 0,
protocol: '',
configSchema: '',
config: '',
script: ''
})
/** 获取插件配置 */
const getPluginConfig = async (id: number) => {
pluginConfig.value = await PluginConfigApi.getPluginConfig(id)
}
/** 处理状态变更 */
const handleStatusChange = async (status: number) => {
if (pluginConfig.value.id <= 0) {
return
}
try {
// 修改状态的二次确认
const text = status === 1 ? '启用' : '停用'
await message.confirm('确认要"' + text + '"插件吗?')
await PluginConfigApi.updatePluginStatus({
id: pluginConfig.value.id,
status
})
message.success('更新状态成功')
// 获取配置
await getPluginConfig(pluginConfig.value.id)
} catch (error) {
pluginConfig.value.status = status === 1 ? 0 : 1
message.error('更新状态失败')
}
}
/** 插件导入 */
const importFormRef = ref()
const handleImport = () => {
importFormRef.value.open()
}
/** 初始化插件配置 */
onMounted(() => {
const id = Number(route.params.id)
if (id) {
getPluginConfig(id)
}
})
</script>

View File

@@ -0,0 +1,329 @@
<!-- TODO @haohao搞到 config 目录会不会更好哈 -->
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="插件名称" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入插件名称"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select
v-model="queryParams.status"
placeholder="请选择状态"
clearable
@change="handleQuery"
class="!w-240px"
>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PLUGIN_STATUS)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item class="float-right !mr-0 !mb-0">
<el-button-group>
<el-button :type="viewMode === 'card' ? 'primary' : 'default'" @click="viewMode = 'card'">
<Icon icon="ep:grid" />
</el-button>
<el-button :type="viewMode === 'list' ? 'primary' : 'default'" @click="viewMode = 'list'">
<Icon icon="ep:list" />
</el-button>
</el-button-group>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
<el-button
type="primary"
plain
@click="openForm('create')"
v-hasPermi="['iot:plugin-config:create']"
>
<Icon icon="ep:plus" class="mr-5px" /> 新增
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<template v-if="viewMode === 'list'">
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
<el-table-column label="插件名称" align="center" prop="name" />
<el-table-column label="插件标识" align="center" prop="pluginKey" />
<el-table-column label="jar 包" align="center" prop="fileName" />
<el-table-column label="版本号" align="center" prop="version" />
<el-table-column label="部署方式" align="center" prop="deployType">
<template #default="scope">
<dict-tag :type="DICT_TYPE.IOT_PLUGIN_DEPLOY_TYPE" :value="scope.row.deployType" />
</template>
</el-table-column>
<el-table-column label="状态" align="center" prop="status">
<template #default="scope">
<el-switch
v-model="scope.row.status"
:active-value="1"
:inactive-value="0"
@change="handleStatusChange(scope.row.id, Number($event))"
/>
</template>
</el-table-column>
<el-table-column
label="创建时间"
align="center"
prop="createTime"
:formatter="dateFormatter"
width="180px"
/>
<el-table-column label="操作" align="center" min-width="120px">
<template #default="scope">
<el-button
link
type="primary"
@click="openDetail(scope.row.id)"
v-hasPermi="['iot:product:query']"
>
查看
</el-button>
<el-button
link
type="primary"
@click="openForm('update', scope.row.id)"
v-hasPermi="['iot:plugin-config:update']"
>
编辑
</el-button>
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['iot:plugin-config:delete']"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</template>
<template v-if="viewMode === 'card'">
<el-row :gutter="16">
<el-col v-for="item in list" :key="item.id" :xs="24" :sm="12" :md="12" :lg="6" class="mb-4">
<el-card
class="h-full transition-colors relative overflow-hidden"
:body-style="{ padding: '0' }"
>
<div class="p-4 relative">
<!-- 标题区域 -->
<div class="flex items-center mb-3">
<div class="mr-2.5 flex items-center">
<el-image :src="defaultIconUrl" class="w-[18px] h-[18px]" />
</div>
<div class="text-[16px] font-600 flex-1">{{ item.name }}</div>
<!-- 添加插件状态标签 -->
<div class="inline-flex items-center">
<div
class="w-1 h-1 rounded-full mr-1.5"
:class="
item.status === 1
? 'bg-[var(--el-color-success)]'
: 'bg-[var(--el-color-danger)]'
"
>
</div>
<el-text
class="!text-xs font-bold"
:type="item.status === 1 ? 'success' : 'danger'"
>
{{ item.status === 1 ? '开启' : '禁用' }}
</el-text>
</div>
</div>
<!-- 信息区域 -->
<div class="flex items-center text-[14px]">
<div class="flex-1">
<div class="mb-2.5 last:mb-0">
<span class="text-[#717c8e] mr-2.5">插件标识</span>
<span class="text-[#0b1d30] whitespace-normal break-all">
{{ item.pluginKey }}
</span>
</div>
<div class="mb-2.5 last:mb-0">
<span class="text-[#717c8e] mr-2.5">jar </span>
<span class="text-[#0b1d30]">{{ item.fileName }}</span>
</div>
<div class="mb-2.5 last:mb-0">
<span class="text-[#717c8e] mr-2.5">版本号</span>
<span class="text-[#0b1d30]">{{ item.version }}</span>
</div>
<div class="mb-2.5 last:mb-0">
<span class="text-[#717c8e] mr-2.5">部署方式</span>
<dict-tag :type="DICT_TYPE.IOT_PLUGIN_DEPLOY_TYPE" :value="item.deployType" />
</div>
</div>
</div>
<!-- 分隔线 -->
<el-divider class="!my-3" />
<!-- 按钮 -->
<div class="flex items-center px-0">
<el-button
class="flex-1 !px-2 !h-[32px] text-[13px]"
type="primary"
plain
@click="openForm('update', item.id)"
v-hasPermi="['iot:plugin-config:update']"
>
<Icon icon="ep:edit-pen" class="mr-1" />
编辑
</el-button>
<el-button
class="flex-1 !px-2 !h-[32px] !ml-[10px] text-[13px]"
type="warning"
plain
@click="openDetail(item.id)"
>
<Icon icon="ep:view" class="mr-1" />
详情
</el-button>
<div class="mx-[10px] h-[20px] w-[1px] bg-[#dcdfe6]"></div>
<el-button
class="!px-2 !h-[32px] text-[13px]"
type="danger"
plain
@click="handleDelete(item.id)"
v-hasPermi="['iot:device:delete']"
>
<Icon icon="ep:delete" />
</el-button>
</div>
</div>
</el-card>
</el-col>
</el-row>
</template>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<!-- 表单弹窗添加/修改 -->
<PluginConfigForm ref="formRef" @success="getList" />
</template>
<script setup lang="ts">
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { dateFormatter } from '@/utils/formatTime'
import { PluginConfigApi, PluginConfigVO } from '@/api/iot/plugin'
import PluginConfigForm from './PluginConfigForm.vue'
/** IoT 插件配置 列表 */
defineOptions({ name: 'IoTPlugin' })
const message = useMessage() // 消息弹窗
const { t } = useI18n() // 国际化
const loading = ref(true) // 列表的加载中
const list = ref<PluginConfigVO[]>([]) // 列表的数据
const total = ref(0) // 列表的总页数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: undefined,
status: undefined
})
const queryFormRef = ref() // 搜索的表单
const defaultIconUrl = ref('/src/assets/svgs/iot/card-fill.svg') // 默认插件图标
const viewMode = ref<'card' | 'list'>('card') // 视图模式状态
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await PluginConfigApi.getPluginConfigPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 打开详情 */
const { push } = useRouter()
const openDetail = (id: number) => {
push({ name: 'IoTPluginDetail', params: { id } })
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
// 删除的二次确认
await message.delConfirm()
// 发起删除
await PluginConfigApi.deletePluginConfig(id)
message.success(t('common.delSuccess'))
// 刷新列表
await getList()
} catch {}
}
/** 处理状态变更 */
const handleStatusChange = async (id: number, status: number) => {
try {
// 修改状态的二次确认
const text = status === 1 ? '启用' : '停用'
await message.confirm('确认要"' + text + '"插件吗?')
await PluginConfigApi.updatePluginStatus({
id: id,
status
})
message.success('更新状态成功')
getList()
} catch (error) {
message.error('更新状态失败')
}
}
/** 初始化 **/
onMounted(() => {
getList()
})
</script>

View File

@@ -0,0 +1,10 @@
<template>
<QualityInspectionList :inspection-type="2" page-title="过程质检" />
</template>
<script setup lang="ts">
import QualityInspectionList from '../shared/QualityInspectionList.vue'
/** 过程质检页面 */
defineOptions({ name: 'ProcessInspection' })
</script>

View File

@@ -0,0 +1,10 @@
<template>
<QualityInspectionList :inspection-type="3" page-title="产品质检" />
</template>
<script setup lang="ts">
import QualityInspectionList from '../shared/QualityInspectionList.vue'
/** 产品质检页面 */
defineOptions({ name: 'ProductInspection' })
</script>

View File

@@ -0,0 +1,119 @@
<template>
<Dialog :title="dialogTitle" v-model="dialogVisible">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
v-loading="formLoading"
>
<el-form-item label="分类名字" prop="name">
<el-input v-model="formData.name" placeholder="请输入分类名字" />
</el-form-item>
<el-form-item label="分类排序" prop="sort">
<el-input v-model="formData.sort" placeholder="请输入分类排序" />
</el-form-item>
<el-form-item label="分类状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:label="dict.value"
>
{{ dict.label }}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="分类描述" prop="description">
<el-input type="textarea" v-model="formData.description" placeholder="请输入分类描述" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
import { ProductCategoryApi, ProductCategoryVO } from '@/api/iot/product/category'
import { CommonStatusEnum } from '@/utils/constants'
/** IoT 产品分类 表单 */
defineOptions({ name: 'ProductCategoryForm' })
const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗
const dialogVisible = ref(false) // 弹窗的是否展示
const dialogTitle = ref('') // 弹窗的标题
const formLoading = ref(false) // 表单的加载中1修改时的数据加载2提交的按钮禁用
const formType = ref('') // 表单的类型create - 新增update - 修改
const formData = ref({
id: undefined,
name: undefined,
sort: 0,
status: CommonStatusEnum.ENABLE,
description: undefined
})
const formRules = reactive({
name: [{ required: true, message: '分类名字不能为空', trigger: 'blur' }],
status: [{ required: true, message: '分类状态不能为空', trigger: 'blur' }],
sort: [{ required: true, message: '分类排序不能为空', trigger: 'blur' }]
})
const formRef = ref() // 表单 Ref
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
// 修改时,设置数据
if (id) {
formLoading.value = true
try {
formData.value = await ProductCategoryApi.getProductCategory(id)
} finally {
formLoading.value = false
}
}
}
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
/** 提交表单 */
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
const submitForm = async () => {
// 校验表单
await formRef.value.validate()
// 提交请求
formLoading.value = true
try {
const data = formData.value as unknown as ProductCategoryVO
if (formType.value === 'create') {
await ProductCategoryApi.createProductCategory(data)
message.success(t('common.createSuccess'))
} else {
await ProductCategoryApi.updateProductCategory(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
// 发送操作成功的事件
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
name: undefined,
sort: 0,
status: CommonStatusEnum.ENABLE,
description: undefined
}
formRef.value?.resetFields()
}
</script>

View File

@@ -0,0 +1,170 @@
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form
class="-mb-15px"
:model="queryParams"
ref="queryFormRef"
:inline="true"
label-width="68px"
>
<el-form-item label="分类名字" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入分类名字"
clearable
@keyup.enter="handleQuery"
class="!w-240px"
/>
</el-form-item>
<el-form-item label="创建时间" prop="createTime">
<el-date-picker
v-model="queryParams.createTime"
value-format="YYYY-MM-DD HH:mm:ss"
type="daterange"
start-placeholder="开始日期"
end-placeholder="结束日期"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
class="!w-220px"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
<el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
<el-button
type="primary"
plain
@click="openForm('create')"
v-hasPermi="['iot:product-category:create']"
>
<Icon icon="ep:plus" class="mr-5px" /> 新增
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
<el-table-column label="ID" align="center" prop="id" />
<el-table-column label="名字" align="center" prop="name" />
<el-table-column label="排序" align="center" prop="sort" />
<el-table-column label="状态" align="center" prop="status">
<template #default="scope">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
</template>
</el-table-column>
<el-table-column label="描述" align="center" prop="description" />
<el-table-column
label="创建时间"
align="center"
prop="createTime"
:formatter="dateFormatter"
width="180px"
/>
<el-table-column label="操作" align="center" min-width="120px">
<template #default="scope">
<el-button
link
type="primary"
@click="openForm('update', scope.row.id)"
v-hasPermi="['iot:product-category:update']"
>
编辑
</el-button>
<el-button
link
type="danger"
@click="handleDelete(scope.row.id)"
v-hasPermi="['iot:product-category:delete']"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination
:total="total"
v-model:page="queryParams.pageNo"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</ContentWrap>
<!-- 表单弹窗添加/修改 -->
<ProductCategoryForm ref="formRef" @success="getList" />
</template>
<script setup lang="ts">
import { DICT_TYPE } from '@/utils/dict'
import { dateFormatter } from '@/utils/formatTime'
import { ProductCategoryApi, ProductCategoryVO } from '@/api/iot/product/category'
import ProductCategoryForm from './ProductCategoryForm.vue'
/** IoT 产品分类列表 */
defineOptions({ name: 'IotProductCategory' })
const message = useMessage() // 消息弹窗
const { t } = useI18n() // 国际化
const loading = ref(true) // 列表的加载中
const list = ref<ProductCategoryVO[]>([]) // 列表的数据
const total = ref(0) // 列表的总页数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: undefined,
createTime: []
})
const queryFormRef = ref() // 搜索的表单
const exportLoading = ref(false) // 导出的加载中
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await ProductCategoryApi.getProductCategoryPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
// 删除的二次确认
await message.delConfirm()
// 发起删除
await ProductCategoryApi.deleteProductCategory(id)
message.success(t('common.delSuccess'))
// 刷新列表
await getList()
} catch {}
}
/** 初始化 **/
onMounted(() => {
getList()
})
</script>

View File

@@ -0,0 +1,401 @@
<template>
<Dialog :title="dialogTitle" v-model="dialogVisible" width="60%">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="110px"
v-loading="formLoading"
>
<el-form-item label="ProductKey" prop="productKey">
<el-input
v-model="formData.productKey"
placeholder="请输入 ProductKey"
:readonly="formType === 'update'"
>
<template #append>
<el-button @click="generateProductKey" :disabled="formType === 'update'">
重新生成
</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item label="产品名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入产品名称" />
</el-form-item>
<el-form-item label="产品分类" prop="categoryId">
<el-select v-model="formData.categoryId" placeholder="请选择产品分类" clearable>
<el-option
v-for="category in categoryList"
:key="category.id"
:label="category.name"
:value="category.id"
/>
</el-select>
</el-form-item>
<!-- <el-form-item label="设备类型" prop="deviceType">-->
<!-- <el-radio-group v-model="formData.deviceType" :disabled="formType === 'update'">-->
<!-- <el-radio-->
<!-- v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE)"-->
<!-- :key="dict.value"-->
<!-- :label="dict.value"-->
<!-- >-->
<!-- {{ dict.label }}-->
<!-- </el-radio>-->
<!-- </el-radio-group>-->
<!-- </el-form-item>-->
<!-- <el-form-item-->
<!-- v-if="formData.deviceType !== undefined && [DeviceTypeEnum.DEVICE, DeviceTypeEnum.GATEWAY].includes(formData.deviceType)"-->
<!-- label="联网方式"-->
<!-- prop="netType"-->
<!-- >-->
<!-- <el-select-->
<!-- v-model="formData.netType"-->
<!-- placeholder="请选择联网方式"-->
<!-- :disabled="formType === 'update'"-->
<!-- >-->
<!-- <el-option-->
<!-- v-for="dict in getIntDictOptions(DICT_TYPE.IOT_NET_TYPE)"-->
<!-- :key="dict.value"-->
<!-- :label="dict.label"-->
<!-- :value="dict.value"-->
<!-- />-->
<!-- </el-select>-->
<!-- </el-form-item>-->
<!-- <el-form-item-->
<!-- v-if="formData.deviceType === DeviceTypeEnum.GATEWAY_SUB"-->
<!-- label="接入网关协议"-->
<!-- prop="protocolType"-->
<!-- >-->
<!-- <el-select-->
<!-- v-model="formData.protocolType"-->
<!-- placeholder="请选择接入网关协议"-->
<!-- :disabled="formType === 'update'"-->
<!-- >-->
<!-- <el-option-->
<!-- v-for="dict in getIntDictOptions(DICT_TYPE.IOT_PROTOCOL_TYPE)"-->
<!-- :key="dict.value"-->
<!-- :label="dict.label"-->
<!-- :value="dict.value"-->
<!-- />-->
<!-- </el-select>-->
<!-- </el-form-item>-->
<!-- <el-form-item label="数据格式" prop="dataFormat">-->
<!-- <el-radio-group v-model="formData.dataFormat" :disabled="formType === 'update'">-->
<!-- <el-radio-->
<!-- v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DATA_FORMAT)"-->
<!-- :key="dict.value"-->
<!-- :label="dict.value"-->
<!-- >-->
<!-- {{ dict.label }}-->
<!-- </el-radio>-->
<!-- </el-radio-group>-->
<!-- </el-form-item>-->
<!-- <el-form-item label="数据校验级别" prop="validateType">-->
<!-- <el-radio-group v-model="formData.validateType" :disabled="formType === 'update'">-->
<!-- <el-radio-->
<!-- v-for="dict in getIntDictOptions(DICT_TYPE.IOT_VALIDATE_TYPE)"-->
<!-- :key="dict.value"-->
<!-- :label="dict.value"-->
<!-- >-->
<!-- {{ dict.label }}-->
<!-- </el-radio>-->
<!-- </el-radio-group>-->
<!-- </el-form-item>-->
<el-form-item label="温度上下限">
<div class="flex items-center gap-2">
<el-input-number
v-model="tLimitRange[0]"
:precision="1"
:step="0.1"
:min="-1000"
class="!w-140px"
placeholder="下限"
/>
<span></span>
<el-input-number
v-model="tLimitRange[1]"
:precision="1"
:step="0.1"
:min="-1000"
class="!w-140px"
placeholder="上限"
/>
<span></span>
</div>
</el-form-item>
<el-form-item label="湿度上下限">
<div class="flex items-center gap-2">
<el-input-number
v-model="hLimitRange[0]"
:precision="1"
:step="0.1"
:min="0"
:max="100"
class="!w-140px"
placeholder="下限"
/>
<span></span>
<el-input-number
v-model="hLimitRange[1]"
:precision="1"
:step="0.1"
:min="0"
:max="100"
class="!w-140px"
placeholder="上限"
/>
<span>RH</span>
</div>
</el-form-item>
<el-collapse v-model="activeCollapse">
<el-collapse-item title="更多配置" name="moreConfig">
<el-form-item label="产品图片" prop="picUrl">
<UploadImg v-model="formData.picUrl" :height="'120px'" :width="'120px'" />
</el-form-item>
<el-form-item label="采购地方" prop="purchaseLocation">
<el-input v-model="formData.purchaseLocation" placeholder="请输入采购地方" />
</el-form-item>
<el-form-item label="采购供应商" prop="supplier">
<el-input v-model="formData.supplier" placeholder="请输入采购供应商" />
</el-form-item>
<el-form-item label="联系方式" prop="contactInfo">
<el-input v-model="formData.contactInfo" placeholder="请输入联系方式" />
</el-form-item>
<el-form-item label="设备数量" prop="purchasePrice">
<el-input-number v-model="formData.purchasePrice" :precision="2" :step="0.01" :min="0" placeholder="请输入设备数量" />
</el-form-item>
<el-form-item label="购买/使用年限" prop="productPrice">
<el-input-number v-model="formData.productPrice" :precision="2" :step="0.01" :min="0" placeholder="请输入购买/使用年限" />
</el-form-item>
<el-form-item label="产品参数" prop="productParams">
<el-input type="textarea" v-model="formData.productParams" placeholder="请输入产品参数" />
</el-form-item>
<el-form-item label="产品要素" prop="productFeatures">
<el-input type="textarea" v-model="formData.productFeatures" placeholder="请输入产品要素" />
</el-form-item>
<el-form-item label="使用部件" prop="components">
<el-input type="textarea" v-model="formData.components" placeholder="请输入使用部件" />
</el-form-item>
<el-form-item label="备注信息" prop="remarks">
<el-input type="textarea" v-model="formData.remarks" placeholder="请输入备注信息" />
</el-form-item>
<el-form-item label="产品描述" prop="description">
<el-input type="textarea" v-model="formData.description" placeholder="请输入产品描述" />
</el-form-item>
</el-collapse-item>
</el-collapse>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import type { Ref } from 'vue'
import {
ValidateTypeEnum,
ProductApi,
ProductVO,
DataFormatEnum,
} from '@/api/iot/product/product'
import { ProductCategoryApi, ProductCategoryVO } from '@/api/iot/product/category'
import { UploadImg } from '@/components/UploadFile'
import { generateRandomStr } from '@/utils'
defineOptions({ name: 'IoTProductForm' })
const { t } = useI18n()
const message = useMessage()
const dialogVisible = ref(false)
const dialogTitle = ref('')
const formLoading = ref(false)
const formType = ref('')
const activeCollapse = ref(['moreConfig']) // 默认展开更多配置
type LimitRange = [number | undefined, number | undefined]
const formData = ref({
id: undefined,
name: undefined,
productKey: '',
categoryId: undefined,
icon: undefined,
picUrl: undefined,
description: undefined,
deviceType: undefined,
netType: undefined,
protocolType: undefined,
protocolId: undefined,
dataFormat: DataFormatEnum.JSON,
validateType: ValidateTypeEnum.WEAK,
purchaseLocation: undefined,
supplier: undefined,
contactInfo: undefined,
purchasePrice: undefined,
productPrice: undefined,
tempLimit: undefined as string | undefined,
humidityLimit: undefined as string | undefined,
productParams: undefined,
productFeatures: undefined,
components: undefined,
remarks: undefined
})
const tLimitRange = ref<LimitRange>([undefined, undefined])
const hLimitRange = ref<LimitRange>([undefined, undefined])
const formRules = reactive({
productKey: [{ required: true, message: 'ProductKey 不能为空', trigger: 'blur' }],
name: [{ required: true, message: '产品名称不能为空', trigger: 'blur' }],
categoryId: [{ required: true, message: '产品分类不能为空', trigger: 'change' }],
// deviceType: [{ required: true, message: '设备类型不能为空', trigger: 'change' }],
// netType: [
// {
// required: true,
// message: '联网方式不能为空',
// trigger: 'change'
// }
// ],
// protocolType: [
// {
// required: true,
// message: '接入网关协议不能为空',
// trigger: 'change'
// }
// ],
// dataFormat: [{ required: true, message: '数据格式不能为空', trigger: 'change' }],
// validateType: [{ required: true, message: '数据校验级别不能为空', trigger: 'change' }]change
})
const formRef = ref()
const categoryList = ref<ProductCategoryVO[]>([]) // 产品分类列表
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
if (id) {
formLoading.value = true
try {
formData.value = await ProductApi.getProduct(id)
} finally {
formLoading.value = false
}
} else {
// 新增时,生成随机 productKey
generateProductKey()
}
// 加载分类列表
categoryList.value = await ProductCategoryApi.getSimpleProductCategoryList()
}
defineExpose({ open, close: () => (dialogVisible.value = false) })
/** 提交表单 */
const emit = defineEmits(['success'])
const submitForm = async () => {
await formRef.value.validate()
formLoading.value = true
try {
const data = formData.value as unknown as ProductVO
if (formType.value === 'create') {
await ProductApi.createProduct(data)
message.success(t('common.createSuccess'))
} else {
await ProductApi.updateProduct(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false // 确保关闭弹框
emit('success')
} finally {
formLoading.value = false
}
}
/** 重置表单 */
const parseLimit = (value?: string): LimitRange => {
if (!value) {
return [undefined, undefined]
}
const [minRaw, maxRaw] = value.split(',')
const parse = (origin?: string) => {
if (origin === undefined || origin.trim() === '') {
return undefined
}
const num = Number(origin)
return Number.isFinite(num) ? num : undefined
}
return [parse(minRaw), parse(maxRaw)]
}
const formatLimit = (range: LimitRange) => {
const [min, max] = range
if (min === undefined && max === undefined) {
return undefined
}
return `${min ?? ''},${max ?? ''}`
}
const isSameRange = (a: LimitRange, b: LimitRange) => a[0] === b[0] && a[1] === b[1]
const bindLimitRange = (key: 'tempLimit' | 'humidityLimit', rangeRef: Ref<LimitRange>) => {
watch(
() => formData.value[key],
(val) => {
const parsed = parseLimit(val)
if (!isSameRange(parsed, rangeRef.value)) {
rangeRef.value = [...parsed] as LimitRange
}
},
{ immediate: true }
)
watch(
rangeRef,
(val) => {
const formatted = formatLimit(val)
if (formData.value[key] !== formatted) {
formData.value[key] = formatted
}
},
{ deep: true }
)
}
bindLimitRange('tempLimit', tLimitRange)
bindLimitRange('humidityLimit', hLimitRange)
const resetForm = () => {
formData.value = {
id: undefined,
name: undefined,
productKey: '',
categoryId: undefined,
icon: undefined,
picUrl: undefined,
description: undefined,
deviceType: undefined,
netType: undefined,
protocolType: undefined,
protocolId: undefined,
dataFormat: DataFormatEnum.JSON,
validateType: ValidateTypeEnum.WEAK,
purchaseLocation: undefined,
supplier: undefined,
contactInfo: undefined,
purchasePrice: undefined,
productPrice: undefined,
tempLimit: undefined as string | undefined,
humidityLimit: undefined as string | undefined,
productParams: undefined,
productFeatures: undefined,
components: undefined,
remarks: undefined
}
tLimitRange.value = [undefined, undefined]
hLimitRange.value = [undefined, undefined]
formRef.value?.resetFields()
}
/** 生成 ProductKey */
const generateProductKey = () => {
formData.value.productKey = generateRandomStr(16)
}
</script>

View File

@@ -0,0 +1,110 @@
<template>
<div>
<div class="flex items-start justify-between">
<div>
<el-col>
<el-row>
<span class="text-xl font-bold">{{ product.name }}</span>
</el-row>
</el-col>
</div>
<div>
<!-- 右上按钮 -->
<el-button
@click="openForm('update', product.id)"
v-hasPermi="['iot:product:update']"
v-if="product.status === 0"
>
编辑
</el-button>
<!-- <el-button-->
<!-- type="primary"-->
<!-- @click="confirmPublish(product.id)"-->
<!-- v-hasPermi="['iot:product:update']"-->
<!-- v-if="product.status === 0"-->
<!-- >-->
<!-- 发布-->
<!-- </el-button>-->
<el-button
type="danger"
@click="confirmUnpublish(product.id)"
v-hasPermi="['iot:product:update']"
v-if="product.status === 1"
>
撤销发布
</el-button>
</div>
</div>
</div>
<!-- <ContentWrap class="mt-10px">-->
<!-- <el-descriptions :column="5" direction="horizontal">-->
<!-- <el-descriptions-item label="ProductKey">-->
<!-- {{ product.productKey }}-->
<!-- <el-button @click="copyToClipboard(product.productKey)">复制</el-button>-->
<!-- </el-descriptions-item>-->
<!-- </el-descriptions>-->
<!-- <el-descriptions :column="5" direction="horizontal">-->
<!-- <el-descriptions-item label="设备数">-->
<!-- {{ product.deviceCount ?? '加载中...' }}-->
<!-- <el-button @click="goToDeviceList(product.id)">前往管理</el-button>-->
<!-- </el-descriptions-item>-->
<!-- </el-descriptions>-->
<!-- </ContentWrap>-->
<!-- 表单弹窗添加/修改 -->
<ProductForm ref="formRef" @success="emit('refresh')" />
</template>
<script setup lang="ts">
import ProductForm from '@/views/iot/product/product/ProductForm.vue'
import { ProductApi, ProductVO } from '@/api/iot/product/product'
const message = useMessage()
const { product } = defineProps<{ product: ProductVO }>() // 定义 Props
/** 复制到剪贴板方法 */
// const copyToClipboard = async (text: string) => {
// try {
// await navigator.clipboard.writeText(text)
// message.success('复制成功')
// } catch (error) {
// message.error('复制失败')
// }
// }
/** 路由跳转到设备管理 */
// const { push } = useRouter()
// const goToDeviceList = (productId: number) => {
// push({ name: 'IoTDevice', params: { productId } })
// }
/** 修改操作 */
const emit = defineEmits(['refresh']) // 定义 Emits
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 发布操作 */
const confirmPublish = async (id: number) => {
try {
await ProductApi.updateProductStatus(id, 1)
message.success('发布成功')
formRef.value.close() // 关闭弹框
emit('refresh')
} catch (error) {
message.error('发布失败')
}
}
/** 撤销发布操作 */
const confirmUnpublish = async (id: number) => {
try {
await ProductApi.updateProductStatus(id, 0)
message.success('撤销发布成功')
formRef.value.close() // 关闭弹框
emit('refresh')
} catch (error) {
message.error('撤销发布失败')
}
}
</script>

View File

@@ -0,0 +1,52 @@
<template>
<ContentWrap>
<el-descriptions :column="3" title="产品信息">
<el-descriptions-item label="产品名称">{{ product.name }}</el-descriptions-item>
<el-descriptions-item label="所属分类">{{ product.categoryName }}</el-descriptions-item>
<el-descriptions-item label="设备类型">
<dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="product.deviceType" />
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ formatDate(product.createTime) }}
</el-descriptions-item>
<el-descriptions-item label="数据格式">
<dict-tag :type="DICT_TYPE.IOT_DATA_FORMAT" :value="product.dataFormat" />
</el-descriptions-item>
<el-descriptions-item label="数据校验级别">
<dict-tag :type="DICT_TYPE.IOT_VALIDATE_TYPE" :value="product.validateType" />
</el-descriptions-item>
<el-descriptions-item label="产品状态">
<dict-tag :type="DICT_TYPE.IOT_PRODUCT_STATUS" :value="product.status" />
</el-descriptions-item>
<el-descriptions-item
label="联网方式"
v-if="[DeviceTypeEnum.DEVICE, DeviceTypeEnum.GATEWAY].includes(product.deviceType)"
>
<dict-tag :type="DICT_TYPE.IOT_NET_TYPE" :value="product.netType" />
</el-descriptions-item>
<el-descriptions-item
label="接入网关协议"
v-if="product.deviceType === DeviceTypeEnum.GATEWAY_SUB"
>
<dict-tag :type="DICT_TYPE.IOT_PROTOCOL_TYPE" :value="product.protocolType" />
</el-descriptions-item>
<el-descriptions-item label="采购地方">{{ product.purchaseLocation }}</el-descriptions-item>
<el-descriptions-item label="采购供应商">{{ product.supplier }}</el-descriptions-item>
<el-descriptions-item label="联系方式">{{ product.contactInfo }}</el-descriptions-item>
<el-descriptions-item label="设备数量">{{ product.purchasePrice }}</el-descriptions-item>
<el-descriptions-item label="购买/使用年限">{{ product.productPrice }}</el-descriptions-item>
<el-descriptions-item label="产品参数" :span="3">{{ product.productParams }}</el-descriptions-item>
<el-descriptions-item label="产品要素" :span="3">{{ product.productFeatures }}</el-descriptions-item>
<el-descriptions-item label="使用部件" :span="3">{{ product.components }}</el-descriptions-item>
<el-descriptions-item label="备注信息" :span="3">{{ product.remarks }}</el-descriptions-item>
<el-descriptions-item label="产品描述" :span="3">{{ product.description }}</el-descriptions-item>
</el-descriptions>
</ContentWrap>
</template>
<script setup lang="ts">
import { DICT_TYPE } from '@/utils/dict'
import { DeviceTypeEnum, ProductVO } from '@/api/iot/product/product'
import { formatDate } from '@/utils/formatTime'
const { product } = defineProps<{ product: ProductVO }>()
</script>

View File

@@ -0,0 +1,247 @@
<template>
<ContentWrap>
<el-tabs>
<el-tab-pane label="基础通信 Topic">
<Table
:columns="basicColumn"
:data="basicData"
:span-method="createSpanMethod(basicData)"
align="left"
headerAlign="left"
border="true"
/>
</el-tab-pane>
<el-tab-pane label="物模型通信 Topic">
<Table
:columns="functionColumn"
:data="functionData"
:span-method="createSpanMethod(functionData)"
align="left"
headerAlign="left"
border="true"
/>
</el-tab-pane>
</el-tabs>
</ContentWrap>
</template>
<script setup lang="ts">
import { ProductVO } from '@/api/iot/product/product'
const props = defineProps<{ product: ProductVO }>()
// TODO 芋艿:不确定未来会不会改,所以先写死
// 基础通信 Topic 列
const basicColumn = reactive([
{ label: '功能', field: 'function', width: 150 },
{ label: 'Topic 类', field: 'topicClass', width: 800 },
{ label: '操作权限', field: 'operationPermission', width: 100 },
{ label: '描述', field: 'description' }
])
// 基础通信 Topic 数据
const basicData = computed(() => {
if (!props.product || !props.product.productKey) return []
return [
{
function: 'OTA 升级',
topicClass: `/ota/device/inform/${props.product.productKey}/\${deviceName}`,
operationPermission: '发布',
description: '设备上报固件升级信息'
},
{
function: 'OTA 升级',
topicClass: `/ota/device/upgrade/${props.product.productKey}/\${deviceName}`,
operationPermission: '订阅',
description: '固件升级信息下行'
},
{
function: 'OTA 升级',
topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/ota/firmware/get`,
operationPermission: '发布',
description: '设备上报固件升级进度'
},
{
function: 'OTA 升级',
topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/ota/firmware/get`,
operationPermission: '发布',
description: '设备主动拉取固件升级信息'
},
{
function: '设备标签',
topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/deviceinfo/update`,
operationPermission: '发布',
description: '设备上报标签数据'
},
{
function: '设备标签',
topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/deviceinfo/update_reply`,
operationPermission: '订阅',
description: '云端响应标签上报'
},
{
function: '设备标签',
topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/deviceinfo/delete`,
operationPermission: '订阅',
description: '设备删除标签信息'
},
{
function: '设备标签',
topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/deviceinfo/delete_reply`,
operationPermission: '订阅',
description: '云端响应标签删除'
},
{
function: '时钟同步',
topicClass: `/ext/ntp/${props.product.productKey}/\${deviceName}/request`,
operationPermission: '发布',
description: 'NTP 时钟同步请求'
},
{
function: '时钟同步',
topicClass: `/ext/ntp/${props.product.productKey}/\${deviceName}/response`,
operationPermission: '订阅',
description: 'NTP 时钟同步响应'
},
{
function: '设备影子',
topicClass: `/shadow/update/${props.product.productKey}/\${deviceName}`,
operationPermission: '发布',
description: '设备影子发布'
},
{
function: '设备影子',
topicClass: `/shadow/get/${props.product.productKey}/\${deviceName}`,
operationPermission: '订阅',
description: '设备接收影子变更'
},
{
function: '配置更新',
topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/config/push`,
operationPermission: '订阅',
description: '云端主动下推配置信息'
},
{
function: '配置更新',
topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/config/get`,
operationPermission: '发布',
description: '设备端查询配置信息'
},
{
function: '配置更新',
topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/config/get_reply`,
operationPermission: '订阅',
description: '云端响应配置信息'
},
{
function: '广播',
topicClass: `/broadcast/${props.product.productKey}/\${identifier}`,
operationPermission: '订阅',
description: '广播 Topicidentifier 为用户自定义字符串'
}
]
})
// 物模型通信 Topic 列
const functionColumn = reactive([
{ label: '功能', field: 'function', width: 150 },
{ label: 'Topic 类', field: 'topicClass', width: 800 },
{ label: '操作权限', field: 'operationPermission', width: 100 },
{ label: '描述', field: 'description' }
])
// 物模型通信 Topic 数据
const functionData = computed(() => {
if (!props.product || !props.product.productKey) return []
return [
{
function: '属性上报',
topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/event/property/post`,
operationPermission: '发布',
description: '设备属性上报'
},
{
function: '属性上报',
topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/event/property/post_reply`,
operationPermission: '订阅',
description: '云端响应属性上报'
},
{
function: '属性设置',
topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/service/property/set`,
operationPermission: '订阅',
description: '设备属性设置'
},
{
function: '事件上报',
topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/event/\${tsl.event.identifier}/post`,
operationPermission: '发布',
description: '设备事件上报'
},
{
function: '事件上报',
topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/event/\${tsl.event.identifier}/post_reply`,
operationPermission: '订阅',
description: '云端响应事件上报'
},
{
function: '服务调用',
topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/service/\${tsl.service.identifier}`,
operationPermission: '订阅',
description: '设备服务调用'
},
{
function: '服务调用',
topicClass: `/sys/${props.product.productKey}/\${deviceName}/thing/service/\${tsl.service.identifier}_reply`,
operationPermission: '发布',
description: '设备端响应服务调用'
}
]
})
// 通用的单元格合并方法生成器
const createSpanMethod = (data: any[]) => {
// 预处理,计算每个功能的合并行数
const rowspanMap: Record<number, number> = {}
let currentFunction = ''
let startIndex = 0
let count = 0
data.forEach((item, index) => {
if (item.function !== currentFunction) {
if (count > 0) {
rowspanMap[startIndex] = count
}
currentFunction = item.function
startIndex = index
count = 1
} else {
count++
}
})
// 处理最后一组
if (count > 0) {
rowspanMap[startIndex] = count
}
// 返回 span 方法
return ({ row, column, rowIndex, columnIndex }: SpanMethodProps) => {
if (columnIndex === 0) {
// 仅对“功能”列进行合并
const rowspan = rowspanMap[rowIndex] || 0
if (rowspan > 0) {
return {
rowspan,
colspan: 1
}
} else {
return {
rowspan: 0,
colspan: 0
}
}
}
}
}
</script>

View File

@@ -0,0 +1,82 @@
<template>
<ProductDetailsHeader :loading="loading" :product="product" @refresh="() => getProductData(id)" />
<el-col>
<el-tabs v-model="activeTab">
<el-tab-pane label="产品信息" name="info">
<ProductDetailsInfo v-if="activeTab === 'info'" :product="product" />
</el-tab-pane>
<!-- <el-tab-pane label="Topic 类列表" name="topic">-->
<!-- <ProductTopic v-if="activeTab === 'topic'" :product="product" />-->
<!-- </el-tab-pane>-->
<!-- <el-tab-pane label="功能定义" lazy name="thingModel">-->
<!-- <IoTProductThingModel ref="thingModelRef" />-->
<!-- </el-tab-pane>-->
<!-- <el-tab-pane label="消息解析" name="message" />-->
<!-- <el-tab-pane label="服务端订阅" name="subscription" />-->
</el-tabs>
</el-col>
</template>
<script lang="ts" setup>
import { ProductApi, ProductVO } from '@/api/iot/product/product'
import { DeviceApi } from '@/api/iot/device/device'
import ProductDetailsHeader from './ProductDetailsHeader.vue'
import ProductDetailsInfo from './ProductDetailsInfo.vue'
import ProductTopic from './ProductTopic.vue'
import IoTProductThingModel from '@/views/iot/thingmodel/index.vue'
import { useTagsViewStore } from '@/store/modules/tagsView'
import { useRouter } from 'vue-router'
import { IOT_PROVIDE_KEY } from '@/views/iot/utils/constants'
defineOptions({ name: 'IoTProductDetail' })
const { delView } = useTagsViewStore() // 视图操作
const { currentRoute } = useRouter()
const route = useRoute()
const message = useMessage()
const id = route.params.id // 编号
const loading = ref(true) // 加载中
const product = ref<ProductVO>({} as ProductVO) // 详情
const activeTab = ref('info') // 默认为 info 标签页
provide(IOT_PROVIDE_KEY.PRODUCT, product) // 提供产品信息给产品信息详情页的所有子组件
/** 获取详情 */
const getProductData = async (id: number) => {
loading.value = true
try {
product.value = await ProductApi.getProduct(id)
} finally {
loading.value = false
}
}
/** 查询设备数量 */
const getDeviceCount = async (productId: number) => {
try {
return await DeviceApi.getDeviceCount(productId)
} catch (error) {
console.error('Error fetching device count:', error, 'productId:', productId)
return 0
}
}
/** 初始化 */
onMounted(async () => {
if (!id) {
message.warning('参数错误,产品不能为空!')
delView(unref(currentRoute))
return
}
await getProductData(id)
// 处理 tab 参数
const { tab } = route.query
if (tab) {
activeTab.value = tab as string
}
// 查询设备数量
if (product.value.id) {
product.value.deviceCount = await getDeviceCount(product.value.id)
}
})
</script>

View File

@@ -0,0 +1,880 @@
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form ref="queryFormRef" :inline="true" :model="queryParams" class="-mb-15px" label-width="68px">
<el-form-item label="产品名称" prop="name">
<el-input v-model="queryParams.name" class="!w-240px" clearable placeholder="请输入产品名称"
@keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="ProductKey" prop="productKey">
<el-input v-model="queryParams.productKey" class="!w-240px" clearable placeholder="请输入产品标识"
@keyup.enter="handleQuery" />
</el-form-item>
<el-form-item>
<el-button @click="handleQuery">
<Icon class="mr-5px" icon="ep:search" />
搜索
</el-button>
<el-button @click="resetQuery">
<Icon class="mr-5px" icon="ep:refresh" />
重置
</el-button>
<el-button v-hasPermi="['iot:product:create']" plain type="primary" @click="openForm('create')">
<Icon class="mr-5px" icon="ep:plus" />
新增
</el-button>
<el-button v-hasPermi="['iot:product:export']" :loading="exportLoading" plain type="success"
@click="handleExport">
<Icon class="mr-5px" icon="ep:download" />
导出
</el-button>
<el-button plain type="warning" @click="openBatchQrDialog">
<Icon class="mr-5px" icon="mdi:qrcode" />
批量二维码
</el-button>
</el-form-item>
<!-- 视图切换按钮 -->
<el-form-item class="float-right !mr-0 !mb-0">
<el-button-group>
<el-button :type="viewMode === 'card' ? 'primary' : 'default'" @click="viewMode = 'card'">
<Icon icon="ep:grid" />
</el-button>
<el-button :type="viewMode === 'list' ? 'primary' : 'default'" @click="viewMode = 'list'">
<Icon icon="ep:list" />
</el-button>
</el-button-group>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 卡片视图 -->
<ContentWrap>
<el-row v-if="viewMode === 'card'" :gutter="16">
<el-col v-for="item in list" :key="item.id" :lg="6" :md="12" :sm="12" :xs="24" class="mb-4">
<el-card :body-style="{ padding: '0' }" class="h-full transition-colors relative">
<!-- 二维码图标按钮 -->
<div class="absolute top-3 right-3 z-10 flex items-center space-x-2">
<div
class="w-8 h-8 rounded-full bg-primary/10 hover:bg-primary/20 flex items-center justify-center cursor-pointer transition-colors group"
@click="openBindDialog(item)" title="绑定采集卡">
<Icon icon="ep:link" class="text-primary text-lg group-hover:scale-110 transition-transform" />
</div>
<div
class="w-8 h-8 rounded-full bg-primary/10 hover:bg-primary/20 flex items-center justify-center cursor-pointer transition-colors group"
@click="openQr(item)" title="生成二维码">
<Icon icon="mdi:qrcode" class="text-primary text-lg group-hover:scale-110 transition-transform" />
</div>
</div>
<!-- 内容区域 -->
<div class="p-4">
<!-- 标题区域 -->
<div class="flex items-center mb-3 pr-8">
<div class="mr-2.5 flex items-center">
<el-image :src="item.icon || defaultIconUrl" class="w-[35px] h-[35px]" />
</div>
<div class="flex items-center">
<div class="text-[16px] font-600">{{ item.name }}</div>
<div class="ml-2 rounded-full flex-shrink-0" :class="{
'w-2 h-2 w-2 bg-gray-400': item.status === 0,
'w-3 h-4 w-4 bg-yellow-400': item.status === 1,
'w-3 h-4 w-4 bg-green-400': item.status === 2,
'w-3 h-4 w-4 bg-red-400': item.status === 3
}"></div>
</div>
</div>
<!-- 信息区域 -->
<div class="flex items-center text-[14px]">
<div class="flex-1">
<div class="mb-2.5 last:mb-0">
<span class="text-[#717c8e] mr-2.5">产品分类</span>
<span class="text-[#0070ff]">{{ item.categoryName }}</span>
</div>
<!-- <div class="mb-2.5 last:mb-0">-->
<!-- <span class="text-[#717c8e] mr-2.5">产品类型</span>-->
<!-- <dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="item.deviceType" />-->
<!-- </div>-->
<div class="mb-2.5 last:mb-0">
<span class="text-[#717c8e] mr-2.5">产品标识</span>
<span
class="text-[#0b1d30] inline-block align-middle overflow-hidden text-ellipsis whitespace-nowrap max-w-[140px]">
{{ item.productKey }}
</span>
</div>
</div>
<div class="w-[100px] h-[100px]">
<el-image :src="item.picUrl || defaultPicUrl" class="w-full h-full" />
</div>
</div>
<!-- 分隔线 -->
<el-divider class="!my-3" />
<!-- 按钮组 -->
<div class="flex items-center px-0">
<el-button v-hasPermi="['iot:product:update']" class="flex-1 !px-2 !h-[32px] text-[13px]" plain
type="primary" @click="openForm('update', item.id)">
<Icon class="mr-1" icon="ep:edit-pen" />
编辑
</el-button>
<el-button class="flex-1 !px-2 !h-[32px] !ml-[10px] text-[13px]" plain type="warning"
@click="openDetail(item.id)">
<Icon class="mr-1" icon="ep:view" />
详情
</el-button>
<!-- <el-button class="flex-1 !px-2 !h-[32px] !ml-[10px] text-[13px]" plain type="success"-->
<!-- @click="openObjectModel(item)">-->
<!-- <Icon class="mr-1" icon="ep:scale-to-original" />-->
<!-- 物模型-->
<!-- </el-button>-->
<div class="mx-[10px] h-[20px] w-[1px] bg-[#dcdfe6]"></div>
<el-button v-hasPermi="['iot:product:delete']" :disabled="item.status === 1"
class="!px-2 !h-[32px] text-[13px]" plain type="danger" @click="handleDelete(item.id)">
<Icon icon="ep:delete" />
</el-button>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 列表视图 -->
<el-table v-else v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
<el-table-column align="center" label="ID" prop="id" />
<el-table-column align="center" label="ProductKey" prop="productKey" />
<el-table-column align="center" label="品类" prop="categoryName" />
<el-table-column align="center" label="设备类型" prop="deviceType">
<template #default="scope">
<dict-tag :type="DICT_TYPE.IOT_PRODUCT_DEVICE_TYPE" :value="scope.row.deviceType" />
</template>
</el-table-column>
<el-table-column align="center" label="产品图片" prop="picture">
<template #default="scope">
<el-image v-if="scope.row.picUrl" :preview-src-list="[scope.row.picture]" :src="scope.row.picUrl"
class="w-40px h-40px" />
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column :formatter="dateFormatter" align="center" label="创建时间" prop="createTime" width="180px" />
<el-table-column align="center" label="操作">
<template #default="scope">
<el-button v-hasPermi="['iot:product:query']" link type="primary" @click="openDetail(scope.row.id)">
查看
</el-button>
<el-button link type="primary" @click="openBindDialog(scope.row)"> 绑定采集卡 </el-button>
<el-button link type="primary" @click="openQr(scope.row)"> 二维码 </el-button>
<el-button v-hasPermi="['iot:product:update']" link type="primary" @click="openForm('update', scope.row.id)">
编辑
</el-button>
<el-button v-hasPermi="['iot:product:delete']" :disabled="scope.row.status === 1" link type="danger"
@click="handleDelete(scope.row.id)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination v-model:limit="queryParams.pageSize" v-model:page="queryParams.pageNo" :total="total"
@pagination="getList" />
</ContentWrap>
<!-- 表单弹窗添加/修改 -->
<ProductForm ref="formRef" @success="getList" />
<!-- 批量二维码弹窗 -->
<el-dialog v-model="batchQrDialogVisible" title="批量生成二维码" width="600px" :close-on-click-modal="false">
<div class="space-y-4">
<!-- 模式选择 -->
<div>
<div class="text-sm font-medium mb-3">选择生成模式</div>
<el-radio-group v-model="batchMode" class="w-full">
<el-radio value="shared" class="w-full mb-2">
<div
class="flex items-center p-3 border border-gray-200 rounded-lg hover:border-blue-300 hover:bg-blue-50 transition-all">
<div class="flex-1">
<div class="font-medium text-gray-800">统一Logo模式</div>
</div>
<Icon icon="ep:grid" class="text-xl text-blue-500" />
</div>
</el-radio>
<el-radio value="individual" class="w-full">
<div
class="flex items-center p-3 border border-gray-200 rounded-lg hover:border-green-300 hover:bg-green-50 transition-all">
<div class="flex-1">
<div class="font-medium text-gray-800">单独Logo模式</div>
</div>
<Icon icon="ep:list" class="text-xl text-green-500" />
</div>
</el-radio>
</el-radio-group>
</div>
<!-- 统一Logo模式 -->
<div v-if="batchMode === 'shared'" class="border border-gray-200 rounded-lg p-4">
<div class="text-sm font-medium mb-3">上传统一Logo</div>
<el-upload ref="batchLogoUploadRef" :auto-upload="false" :show-file-list="false" accept="image/*"
:on-change="handleBatchLogoChange" class="w-full">
<div
class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-400 transition-colors">
<div v-if="!batchLogoImageUrl" class="text-gray-500">
<Icon icon="ep:upload" class="text-3xl mb-3" />
<div class="font-medium mb-1">点击上传统一Logo图片</div>
<div class="text-sm text-gray-400">建议尺寸64x64px将应用到所有 {{ list.length }} 个设备</div>
</div>
<div v-else class="flex items-center justify-center">
<img :src="batchLogoImageUrl" class="w-16 h-16 object-contain rounded border" />
<div class="ml-4 text-left">
<div class="font-medium text-gray-800">已选择统一Logo</div>
<div class="text-sm text-gray-500 mt-1">将应用到所有 {{ list.length }} 个设备</div>
<el-button size="small" type="danger" plain @click.stop="clearBatchLogo" class="mt-2">重新选择</el-button>
</div>
</div>
</div>
</el-upload>
</div>
<!-- 单独Logo模式 -->
<div v-if="batchMode === 'individual'" class="border border-gray-200 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<div class="text-sm font-medium text-gray-700">设备Logo状态</div>
<div class="text-xs text-gray-500">已上传: {{ devicesWithLogo }}/{{ list.length }}</div>
</div>
<div class="max-h-40 overflow-y-auto space-y-2">
<div v-for="product in list" :key="product.id"
class="flex items-center space-x-3 p-2 border border-gray-100 rounded hover:bg-gray-50">
<div class="flex-1 min-w-0">
<div class="font-medium text-sm truncate">{{ product.name }}</div>
<div class="text-xs text-gray-500">ID: {{ product.id }}</div>
</div>
<div class="w-10 h-10 border border-gray-200 rounded flex items-center justify-center flex-shrink-0">
<img v-if="productLogos[product.id]" :src="productLogos[product.id]"
class="w-full h-full object-contain rounded" />
<Icon v-else icon="ep:picture" class="text-gray-300" />
</div>
<div class="text-xs text-gray-500 flex-shrink-0">
{{ productLogos[product.id] ? '已上传' : '未上传' }}
</div>
</div>
</div>
<div class="mt-3 p-2 bg-yellow-50 border border-yellow-200 rounded text-xs text-yellow-700">
<Icon icon="ep:warning" class="mr-1" />
如需为设备上传Logo请点击设备卡片上的"二维码"按钮
</div>
</div>
<!-- 预览区域 -->
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50">
<div class="text-sm font-medium mb-3 text-gray-700">预览效果</div>
<div class="flex justify-center">
<canvas ref="previewCanvasRef" width="128" height="158"
class="border border-gray-300 rounded shadow-sm"></canvas>
</div>
<div class="text-center mt-3">
<div class="text-xs text-gray-600">实际下载的二维码将包含设备名称和ID</div>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer flex justify-between items-center">
<div class="text-xs text-gray-500">
<Icon icon="ep:info-filled" class="mr-1" />
所有二维码将包含设备名称和ID信息
</div>
<div class="space-x-2">
<el-button @click="batchQrDialogVisible = false">取消</el-button>
<el-button type="primary" :disabled="!canBatchDownload" @click="handleBatchDownload">
<Icon class="mr-1" icon="ep:download" />
批量下载 ({{ list.length }})
</el-button>
</div>
</div>
</template>
</el-dialog>
<!-- 绑定采集卡弹窗 -->
<el-dialog v-model="bindDialogVisible" title="绑定设备到采集卡" width="420px" :close-on-click-modal="false">
<el-form ref="bindFormRef" :model="bindForm" :rules="bindFormRules" label-width="96px">
<el-form-item label="产品" prop="productId">
<el-input v-model="bindForm.productName" disabled />
</el-form-item>
<el-form-item label="采集卡ID" prop="deviceId">
<el-input-number v-model="bindForm.deviceId" :min="1" class="!w-full" placeholder="请输入采集卡ID" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="bindDialogVisible = false">取消</el-button>
<el-button plain type="danger" :disabled="bindForm.deviceId === undefined || bindForm.deviceId === null"
:loading="unbindLoading" @click="handleUnbindDevice">
<Icon class="mr-1" icon="ep:close-bold" />
取消绑定
</el-button>
<el-button type="primary" :loading="bindLoading" @click="handleBindDevice">
<Icon class="mr-1" icon="ep:check" />
确定
</el-button>
</template>
</el-dialog>
<!-- 设备二维码弹窗产品即设备直接用产品ID -->
<el-dialog v-model="qrDialogVisible" title="设备二维码" width="400px" :close-on-click-modal="false">
<div class="flex flex-col items-center">
<!-- Logo上传区域 -->
<div class="w-full mb-4">
<div class="text-sm text-gray-600 mb-2">二维码中心Logo可选</div>
<el-upload ref="logoUploadRef" :auto-upload="false" :show-file-list="false" accept="image/*"
:on-change="handleLogoChange" class="w-full">
<div
class="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-blue-400 transition-colors">
<div v-if="!logoImageUrl" class="text-gray-500">
<Icon icon="ep:upload" class="text-2xl mb-2" />
<div>点击上传Logo图片</div>
<div class="text-xs text-gray-400 mt-1">建议尺寸64x64px</div>
</div>
<div v-else class="flex items-center justify-center">
<img :src="logoImageUrl" class="w-16 h-16 object-contain rounded" />
<div class="ml-3">
<div class="text-sm text-gray-600">已选择Logo</div>
<el-button size="small" type="danger" plain @click.stop="clearLogo">清除</el-button>
</div>
</div>
</div>
</el-upload>
</div>
<!-- 二维码显示区域 -->
<div class="relative">
<canvas ref="qrCanvasRef"></canvas>
<div v-if="logoImageUrl" class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
<img :src="logoImageUrl" class="w-8 h-8 object-contain bg-white rounded" />
</div>
</div>
<div class="mt-2 text-12px text-gray-500">设备{{ selectedProductName }}ID{{ selectedProductId }}</div>
<div class="mt-1 text-12px break-all text-gray-500">{{ qrContent }}</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="qrDialogVisible = false">关闭</el-button>
<el-button type="primary" :disabled="!selectedProductId" @click="downloadQr">
<Icon class="mr-1" icon="ep:download" />
下载二维码
</el-button>
</div>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { dateFormatter } from '@/utils/formatTime'
import { ProductApi, ProductVO } from '@/api/iot/product/product'
import ProductForm from './ProductForm.vue'
import { DICT_TYPE } from '@/utils/dict'
import download from '@/utils/download'
import defaultPicUrl from '@/assets/imgs/iot/device.png'
import defaultIconUrl from '@/assets/svgs/iot/cube.svg'
import QRCode from 'qrcode'
/** iot 产品列表 */
defineOptions({ name: 'IoTProduct' })
const message = useMessage() // 消息弹窗
const { t } = useI18n() // 国际化
const { push } = useRouter()
const route = useRoute()
const loading = ref(true) // 列表的加载中
const activeName = ref('info') // 当前激活的标签页
const list = ref<ProductVO[]>([]) // 列表的数据
const total = ref(0) // 列表的总页数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: undefined,
productKey: undefined
})
const queryFormRef = ref() // 搜索的表单
const exportLoading = ref(false) // 导出加载中
const viewMode = ref<'card' | 'list'>('card') // 视图模式状态
// 二维码相关(产品即设备)
const qrDialogVisible = ref(false)
const qrCanvasRef = ref<HTMLCanvasElement | null>(null)
const selectedProductId = ref<number | undefined>(undefined)
const selectedProductName = ref<string>('')
const logoImageUrl = ref<string>('')
const logoUploadRef = ref()
const qrContent = computed(() => {
return `${selectedProductId.value || ''}`
})
// 批量二维码相关
const batchQrDialogVisible = ref(false)
const batchMode = ref<'individual' | 'shared'>('shared')
const batchLogoImageUrl = ref<string>('')
const batchLogoUploadRef = ref()
const previewCanvasRef = ref<HTMLCanvasElement | null>(null)
// 绑定采集卡
const bindDialogVisible = ref(false)
const bindLoading = ref(false)
const unbindLoading = ref(false)
const bindFormRef = ref()
const bindForm = reactive({
productId: undefined as number | undefined,
productName: '',
deviceId: undefined as number | undefined
})
const bindFormRules = {
productId: [{ required: true, message: '缺少产品信息', trigger: 'change' }],
deviceId: [{ required: true, message: '请输入采集卡ID', trigger: 'blur' }]
}
// 计算已上传Logo的设备数量
const devicesWithLogo = computed(() => {
return list.value.filter(product => productLogos.value[product.id]).length
})
// 存储每个设备的Logo从单个二维码弹窗上传的
const productLogos = ref<Record<number, string>>({})
// 计算是否可以批量下载
const canBatchDownload = computed(() => {
if (batchMode.value === 'shared') {
return !!batchLogoImageUrl.value
} else {
return true // 单独模式总是可以下载即使没有Logo
}
})
const openQr = async (product: ProductVO) => {
selectedProductId.value = product.id
selectedProductName.value = product.name
// 加载已保存的Logo
logoImageUrl.value = productLogos.value[product.id] || ''
qrDialogVisible.value = true
await nextTick()
await renderQr()
}
const openBindDialog = (product: ProductVO) => {
bindForm.productId = product.id
bindForm.productName = product.name
bindForm.deviceId = (product as any).deviceId ?? undefined
bindDialogVisible.value = true
nextTick(() => {
bindFormRef.value?.clearValidate()
})
}
const handleBindDevice = async () => {
if (!bindFormRef.value) return
await bindFormRef.value.validate(async (valid: boolean) => {
if (!valid || !bindForm.productId) return
bindLoading.value = true
try {
const productRes = await ProductApi.getProduct(bindForm.productId)
const productData = (productRes as any)?.data ?? (productRes as any)
await ProductApi.updateProduct({
...productData,
deviceId: bindForm.deviceId
})
message.success('绑定成功')
bindDialogVisible.value = false
await getList()
} catch (error) {
message.error('绑定失败')
} finally {
bindLoading.value = false
}
})
}
const handleUnbindDevice = async () => {
if (!bindForm.productId) return
unbindLoading.value = true
try {
const productRes = await ProductApi.getProduct(bindForm.productId)
const productData = (productRes as any)?.data ?? (productRes as any)
await ProductApi.updateProduct({
...productData,
deviceId: null
})
bindForm.deviceId = undefined
message.success('已取消绑定')
bindDialogVisible.value = false
await getList()
} catch (error) {
message.error('取消绑定失败')
} finally {
unbindLoading.value = false
}
}
const renderQr = async () => {
if (!qrCanvasRef.value || !selectedProductId.value) return
// 生成基础二维码
await QRCode.toCanvas(qrCanvasRef.value, qrContent.value, { width: 256 })
// 如果有logo在中心绘制
if (logoImageUrl.value) {
const ctx = qrCanvasRef.value.getContext('2d')
if (ctx) {
const logoSize = 32
const logoX = (256 - logoSize) / 2
const logoY = (256 - logoSize) / 2
// 绘制白色背景
ctx.fillStyle = 'white'
ctx.fillRect(logoX - 2, logoY - 2, logoSize + 4, logoSize + 4)
// 绘制Logo
const logoImg = new Image()
logoImg.onload = () => {
ctx.drawImage(logoImg, logoX, logoY, logoSize, logoSize)
}
logoImg.src = logoImageUrl.value
}
}
}
// Logo处理
const handleLogoChange = (file: any) => {
const reader = new FileReader()
reader.onload = (e) => {
logoImageUrl.value = e.target?.result as string
// 保存到productLogos中用于批量下载
if (selectedProductId.value) {
productLogos.value[selectedProductId.value] = logoImageUrl.value
}
// 重新渲染二维码
nextTick(() => {
renderQr()
})
}
reader.readAsDataURL(file.raw)
}
const clearLogo = () => {
logoImageUrl.value = ''
// 从productLogos中移除
if (selectedProductId.value) {
delete productLogos.value[selectedProductId.value]
}
logoUploadRef.value?.clearFiles()
// 重新渲染二维码
nextTick(() => {
renderQr()
})
}
// 创建带Logo和文字的二维码
const createQrWithLogoAndText = async (content: string, logoUrl?: string, deviceName?: string, deviceId?: string): Promise<HTMLCanvasElement> => {
const qrSize = 256
const textHeight = 60 // 文字区域高度
const canvas = document.createElement('canvas')
canvas.width = qrSize
canvas.height = qrSize + textHeight
const ctx = canvas.getContext('2d')
if (!ctx) return canvas
// 生成二维码到临时canvas
const qrCanvas = document.createElement('canvas')
qrCanvas.width = qrSize
qrCanvas.height = qrSize
await QRCode.toCanvas(qrCanvas, content, { width: qrSize })
// 绘制二维码到主canvas
ctx.drawImage(qrCanvas, 0, 0)
// 如果有Logo在二维码中心绘制
if (logoUrl) {
const logoSize = 32
const logoX = (qrSize - logoSize) / 2
const logoY = (qrSize - logoSize) / 2
// 绘制白色背景
ctx.fillStyle = 'white'
ctx.fillRect(logoX - 2, logoY - 2, logoSize + 4, logoSize + 4)
// 绘制Logo
const logoImg = new Image()
await new Promise((resolve) => {
logoImg.onload = () => {
ctx.drawImage(logoImg, logoX, logoY, logoSize, logoSize)
resolve(true)
}
logoImg.src = logoUrl
})
}
// 绘制文字信息
if (deviceName || deviceId) {
// 设置文字样式
ctx.fillStyle = '#333333'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
// 绘制设备名称
if (deviceName) {
ctx.font = 'bold 16px Arial'
ctx.fillText(deviceName, qrSize / 2, qrSize + 20)
}
// 绘制设备ID
if (deviceId) {
ctx.font = '14px Arial'
ctx.fillStyle = '#666666'
ctx.fillText(`ID: ${deviceId}`, qrSize / 2, qrSize + 40)
}
}
return canvas
}
const downloadQr = async () => {
if (!selectedProductId.value) return
try {
const canvas = await createQrWithLogoAndText(
qrContent.value,
logoImageUrl.value,
selectedProductName.value,
selectedProductId.value.toString()
)
const link = document.createElement('a')
link.download = `device-${selectedProductId.value}-${selectedProductName.value}-qrcode.png`
link.href = canvas.toDataURL('image/png')
link.click()
} catch (error) {
console.error('下载二维码失败:', error)
message.error('下载二维码失败')
}
}
// 打开批量二维码弹窗
const openBatchQrDialog = () => {
batchQrDialogVisible.value = true
// 重置状态
batchMode.value = 'shared'
batchLogoImageUrl.value = ''
// 生成预览
nextTick(() => {
generatePreview()
})
}
// 批量Logo处理
const handleBatchLogoChange = (file: any) => {
const reader = new FileReader()
reader.onload = (e) => {
batchLogoImageUrl.value = e.target?.result as string
// 生成预览
nextTick(() => {
generatePreview()
})
}
reader.readAsDataURL(file.raw)
}
const clearBatchLogo = () => {
batchLogoImageUrl.value = ''
batchLogoUploadRef.value?.clearFiles()
// 重新生成预览
nextTick(() => {
generatePreview()
})
}
// 生成预览
const generatePreview = async () => {
if (!previewCanvasRef.value) return
const canvas = previewCanvasRef.value
const ctx = canvas.getContext('2d')
if (!ctx) return
// 设置canvas尺寸
canvas.width = 128
canvas.height = 158 // 包含文字区域
// 生成小尺寸二维码到临时canvas
const qrCanvas = document.createElement('canvas')
qrCanvas.width = 128
qrCanvas.height = 128
await QRCode.toCanvas(qrCanvas, '123456', { width: 128 })
// 绘制二维码
ctx.drawImage(qrCanvas, 0, 0)
// 根据模式绘制Logo
if (batchMode.value === 'shared' && batchLogoImageUrl.value) {
// 共享模式绘制统一Logo
const logoSize = 16
const logoX = (128 - logoSize) / 2
const logoY = (128 - logoSize) / 2
// 绘制白色背景
ctx.fillStyle = 'white'
ctx.fillRect(logoX - 1, logoY - 1, logoSize + 2, logoSize + 2)
// 绘制Logo
const logoImg = new Image()
logoImg.onload = () => {
ctx.drawImage(logoImg, logoX, logoY, logoSize, logoSize)
}
logoImg.src = batchLogoImageUrl.value
}
// 绘制预览文字
ctx.fillStyle = '#333333'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.font = 'bold 10px Arial'
ctx.fillText('设备名称', 64, 128 + 15)
ctx.font = '8px Arial'
ctx.fillStyle = '#666666'
ctx.fillText('ID: 123456', 64, 128 + 30)
}
// 批量下载二维码
const handleBatchDownload = async () => {
const loadingInstance = ElLoading.service({
lock: true,
text: '正在生成二维码,请稍候...',
background: 'rgba(0, 0, 0, 0.7)'
})
try {
// 创建ZIP文件
const JSZip = (await import('jszip')).default
const zip = new JSZip()
if (batchMode.value === 'shared') {
// 共享模式所有二维码使用同一个Logo
for (const product of list.value) {
const canvas = await createQrWithLogoAndText(
`${product.id}`,
batchLogoImageUrl.value,
product.name,
product.id.toString()
)
const dataUrl = canvas.toDataURL('image/png')
const base64Data = dataUrl.split(',')[1]
zip.file(`device-${product.id}-${product.name}-qrcode.png`, base64Data, { base64: true })
}
} else {
// 单独模式每个二维码使用各自的Logo
for (const product of list.value) {
const logoUrl = productLogos.value[product.id] // 获取该设备已上传的Logo
const canvas = await createQrWithLogoAndText(
`${product.id}`,
logoUrl, // 如果有Logo就使用没有就是undefined
product.name,
product.id.toString()
)
const dataUrl = canvas.toDataURL('image/png')
const base64Data = dataUrl.split(',')[1]
zip.file(`device-${product.id}-${product.name}-qrcode.png`, base64Data, { base64: true })
}
}
// 生成并下载ZIP文件
const zipBlob = await zip.generateAsync({ type: 'blob' })
const link = document.createElement('a')
link.download = `devices-qrcodes-${new Date().getTime()}.zip`
link.href = URL.createObjectURL(zipBlob)
link.click()
const modeText = batchMode.value === 'shared' ? '统一Logo' : '单独Logo'
message.success(`批量下载完成,共${list.value.length}个设备二维码(${modeText}模式)`)
batchQrDialogVisible.value = false
} catch (error) {
console.error('批量下载失败:', error)
message.error('批量下载失败')
} finally {
loadingInstance.close()
}
}
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await ProductApi.getProductPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 打开详情 */
const openDetail = (id: number) => {
push({ name: 'IoTProductDetail', params: { id } })
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
// 删除的二次确认
await message.delConfirm()
// 发起删除
await ProductApi.deleteProduct(id)
message.success(t('common.delSuccess'))
// 刷新列表
await getList()
} catch { }
}
/** 导出按钮操作 */
const handleExport = async () => {
try {
// 导出的二次确认
await message.exportConfirm()
// 发起导出
exportLoading.value = true
const data = await ProductApi.exportProduct(queryParams)
download.excel(data, '物联网产品.xls')
} catch {
} finally {
exportLoading.value = false
}
}
/** 初始化 **/
onMounted(() => {
getList()
// 处理 tab 参数
const { tab } = route.query
if (tab) {
activeName.value = tab as string
}
})
</script>

View File

@@ -0,0 +1,207 @@
<template>
<Dialog v-model="dialogVisible" :title="dialogTitle">
<el-form
ref="formRef"
v-loading="formLoading"
:model="formData"
:rules="formRules"
label-width="120px"
>
<el-form-item label="桥梁名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入桥梁名称" />
</el-form-item>
<el-form-item label="桥梁方向" prop="direction">
<el-radio-group v-model="formData.direction">
<el-radio
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DATA_BRIDGE_DIRECTION_ENUM)"
:key="dict.value"
:label="dict.value"
>
{{ dict.label }}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="桥梁类型" prop="type">
<el-radio-group :model-value="formData.type" @change="handleTypeChange">
<el-radio
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DATA_BRIDGE_TYPE_ENUM)"
:key="dict.value"
:label="dict.value"
>
{{ dict.label }}
</el-radio>
</el-radio-group>
</el-form-item>
<HttpConfigForm v-if="showConfig(IoTDataBridgeConfigType.HTTP)" v-model="formData.config" />
<MqttConfigForm v-if="showConfig(IoTDataBridgeConfigType.MQTT)" v-model="formData.config" />
<RocketMQConfigForm
v-if="showConfig(IoTDataBridgeConfigType.ROCKETMQ)"
v-model="formData.config"
/>
<KafkaMQConfigForm
v-if="showConfig(IoTDataBridgeConfigType.KAFKA)"
v-model="formData.config"
/>
<RabbitMQConfigForm
v-if="showConfig(IoTDataBridgeConfigType.RABBITMQ)"
v-model="formData.config"
/>
<RedisStreamMQConfigForm
v-if="showConfig(IoTDataBridgeConfigType.REDIS_STREAM)"
v-model="formData.config"
/>
<el-form-item label="桥梁状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:label="dict.value"
>
{{ dict.label }}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="桥梁描述" prop="description">
<el-input v-model="formData.description" height="150px" type="textarea" />
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="formLoading" type="primary" @click="submitForm"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script lang="ts" setup>
import { DICT_TYPE, getDictObj, getIntDictOptions } from '@/utils/dict'
import { DataBridgeApi, DataBridgeVO, IoTDataBridgeConfigType } from '@/api/iot/rule/databridge'
import {
HttpConfigForm,
KafkaMQConfigForm,
MqttConfigForm,
RabbitMQConfigForm,
RedisStreamMQConfigForm,
RocketMQConfigForm
} from './config'
/** IoT 数据桥梁的表单 */
defineOptions({ name: 'IoTDataBridgeForm' })
const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗
const dialogVisible = ref(false) // 弹窗的是否展示
const dialogTitle = ref('') // 弹窗的标题
const formLoading = ref(false) // 表单的加载中1修改时的数据加载2提交的按钮禁用
const formType = ref('') // 表单的类型create - 新增update - 修改
const formData = ref<DataBridgeVO>({
status: 0,
direction: 1, // TODO @puhui999:枚举类
type: 1, // TODO @puhui999:枚举类
config: {} as any
})
const formRules = reactive({
// 通用字段
name: [{ required: true, message: '桥梁名称不能为空', trigger: 'blur' }],
status: [{ required: true, message: '桥梁状态不能为空', trigger: 'blur' }],
direction: [{ required: true, message: '桥梁方向不能为空', trigger: 'blur' }],
type: [{ required: true, message: '桥梁类型不能为空', trigger: 'change' }],
// HTTP 配置
'config.url': [{ required: true, message: '请求地址不能为空', trigger: 'blur' }],
'config.method': [{ required: true, message: '请求方法不能为空', trigger: 'blur' }],
// MQTT 配置
'config.username': [{ required: true, message: '用户名不能为空', trigger: 'blur' }],
'config.password': [{ required: true, message: '密码不能为空', trigger: 'blur' }],
'config.clientId': [{ required: true, message: '客户端ID不能为空', trigger: 'blur' }],
'config.topic': [{ required: true, message: '主题不能为空', trigger: 'blur' }],
// RocketMQ 配置
'config.nameServer': [{ required: true, message: 'NameServer 地址不能为空', trigger: 'blur' }],
'config.accessKey': [{ required: true, message: 'AccessKey 不能为空', trigger: 'blur' }],
'config.secretKey': [{ required: true, message: 'SecretKey 不能为空', trigger: 'blur' }],
'config.group': [{ required: true, message: '消费组不能为空', trigger: 'blur' }],
// Kafka 配置
'config.bootstrapServers': [{ required: true, message: '服务地址不能为空', trigger: 'blur' }],
'config.ssl': [{ required: true, message: 'SSL 配置不能为空', trigger: 'change' }],
// RabbitMQ 配置
'config.host': [{ required: true, message: '主机地址不能为空', trigger: 'blur' }],
'config.port': [
{ required: true, message: '端口不能为空', trigger: 'blur' },
{ type: 'number', min: 1, max: 65535, message: '端口号范围 1-65535', trigger: 'blur' }
],
'config.virtualHost': [{ required: true, message: '虚拟主机不能为空', trigger: 'blur' }],
'config.exchange': [{ required: true, message: '交换机不能为空', trigger: 'blur' }],
'config.routingKey': [{ required: true, message: '路由键不能为空', trigger: 'blur' }],
'config.queue': [{ required: true, message: '队列不能为空', trigger: 'blur' }],
// Redis Stream 配置
'config.database': [
{ required: true, message: '数据库索引不能为空', trigger: 'blur' },
{ type: 'number', min: 0, message: '数据库索引必须是非负整数', trigger: 'blur' }
]
})
const formRef = ref() // 表单 Ref
const showConfig = computed(() => (val: string) => {
const dict = getDictObj(DICT_TYPE.IOT_DATA_BRIDGE_TYPE_ENUM, formData.value.type)
return dict && dict.value + '' === val
}) // 显示对应的 Config 配置项
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
// 修改时,设置数据
if (id) {
formLoading.value = true
try {
formData.value = await DataBridgeApi.getDataBridge(id)
} finally {
formLoading.value = false
}
}
}
defineExpose({ open }) // 提供 open 方法,用于打开弹窗
/** 提交表单 */
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
const submitForm = async () => {
// 校验表单
await formRef.value.validate()
// 提交请求
formLoading.value = true
try {
const data = formData.value as unknown as DataBridgeVO
if (formType.value === 'create') {
await DataBridgeApi.createDataBridge(data)
message.success(t('common.createSuccess'))
} else {
await DataBridgeApi.updateDataBridge(data)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
// 发送操作成功的事件
emit('success')
} finally {
formLoading.value = false
}
}
/** 处理类型切换事件 */
const handleTypeChange = (val: number) => {
formData.value.type = val
// 切换类型时重置配置
formData.value.config = {} as any
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
// TODO @puhui999换成枚举值哈
status: 0,
direction: 1,
type: 1,
config: {} as any
}
formRef.value?.resetFields()
}
</script>

View File

@@ -0,0 +1,84 @@
<template>
<el-form-item label="请求地址" prop="config.url">
<el-input v-model="urlPath" placeholder="请输入请求地址">
<template #prepend>
<el-select v-model="urlPrefix" placeholder="Select" style="width: 115px">
<el-option label="http://" value="http://" />
<el-option label="https://" value="https://" />
</el-select>
</template>
</el-input>
</el-form-item>
<el-form-item label="请求方法" prop="config.method">
<el-select v-model="config.method" placeholder="请选择请求方法">
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
<el-option label="DELETE" value="DELETE" />
</el-select>
</el-form-item>
<el-form-item label="请求头" prop="config.headers">
<key-value-editor v-model="config.headers" add-button-text="添加请求头" />
</el-form-item>
<el-form-item label="请求参数" prop="config.query">
<key-value-editor v-model="config.query" add-button-text="添加参数" />
</el-form-item>
<el-form-item label="请求体" prop="config.body">
<el-input v-model="config.body" placeholder="请输入内容" type="textarea" />
</el-form-item>
</template>
<script lang="ts" setup>
import { HttpConfig, IoTDataBridgeConfigType } from '@/api/iot/rule/databridge'
import { useVModel } from '@vueuse/core'
import { isEmpty } from '@/utils/is'
import KeyValueEditor from './components/KeyValueEditor.vue'
defineOptions({ name: 'HttpConfigForm' })
const props = defineProps<{
modelValue: any
}>()
const emit = defineEmits(['update:modelValue'])
const config = useVModel(props, 'modelValue', emit) as Ref<HttpConfig>
/** URL处理 */
const urlPrefix = ref('http://')
const urlPath = ref('')
const fullUrl = computed(() => {
return urlPath.value ? urlPrefix.value + urlPath.value : ''
})
/** 监听 URL 变化 */
watch([urlPrefix, urlPath], () => {
config.value.url = fullUrl.value
})
/** 组件初始化 */
onMounted(() => {
if (!isEmpty(config.value)) {
// 初始化 URL
if (config.value.url) {
if (config.value.url.startsWith('https://')) {
urlPrefix.value = 'https://'
urlPath.value = config.value.url.substring(8)
} else if (config.value.url.startsWith('http://')) {
urlPrefix.value = 'http://'
urlPath.value = config.value.url.substring(7)
} else {
urlPath.value = config.value.url
}
}
return
}
config.value = {
type: IoTDataBridgeConfigType.HTTP,
url: '',
method: 'POST',
headers: {},
query: {},
body: ''
}
})
</script>

View File

@@ -0,0 +1,45 @@
<template>
<el-form-item label="服务地址" prop="config.bootstrapServers">
<el-input v-model="config.bootstrapServers" placeholder="请输入服务地址localhost:9092" />
</el-form-item>
<el-form-item label="用户名" prop="config.username">
<el-input v-model="config.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="密码" prop="config.password">
<el-input v-model="config.password" placeholder="请输入密码" show-password type="password" />
</el-form-item>
<el-form-item label="启用 SSL" prop="config.ssl">
<el-switch v-model="config.ssl" />
</el-form-item>
<el-form-item label="主题" prop="config.topic">
<el-input v-model="config.topic" placeholder="请输入主题" />
</el-form-item>
</template>
<script lang="ts" setup>
import { IoTDataBridgeConfigType, KafkaMQConfig } from '@/api/iot/rule/databridge'
import { useVModel } from '@vueuse/core'
import { isEmpty } from '@/utils/is'
defineOptions({ name: 'KafkaMQConfigForm' })
const props = defineProps<{
modelValue: any
}>()
const emit = defineEmits(['update:modelValue'])
const config = useVModel(props, 'modelValue', emit) as Ref<KafkaMQConfig>
/** 组件初始化 */
onMounted(() => {
if (!isEmpty(config.value)) {
return
}
config.value = {
type: IoTDataBridgeConfigType.KAFKA,
bootstrapServers: '',
username: '',
password: '',
ssl: false,
topic: ''
}
})
</script>

View File

@@ -0,0 +1,45 @@
<template>
<el-form-item label="服务地址" prop="config.url">
<el-input v-model="config.url" placeholder="请输入MQTT服务地址mqtt://localhost:1883" />
</el-form-item>
<el-form-item label="用户名" prop="config.username">
<el-input v-model="config.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="密码" prop="config.password">
<el-input v-model="config.password" placeholder="请输入密码" show-password type="password" />
</el-form-item>
<el-form-item label="客户端ID" prop="config.clientId">
<el-input v-model="config.clientId" placeholder="请输入客户端ID" />
</el-form-item>
<el-form-item label="主题" prop="config.topic">
<el-input v-model="config.topic" placeholder="请输入主题" />
</el-form-item>
</template>
<script lang="ts" setup>
import { IoTDataBridgeConfigType, MqttConfig } from '@/api/iot/rule/databridge'
import { useVModel } from '@vueuse/core'
import { isEmpty } from '@/utils/is'
defineOptions({ name: 'MqttConfigForm' })
const props = defineProps<{
modelValue: any
}>()
const emit = defineEmits(['update:modelValue'])
const config = useVModel(props, 'modelValue', emit) as Ref<MqttConfig>
/** 组件初始化 */
onMounted(() => {
if (!isEmpty(config.value)) {
return
}
config.value = {
type: IoTDataBridgeConfigType.MQTT,
url: '',
username: '',
password: '',
clientId: '',
topic: ''
}
})
</script>

View File

@@ -0,0 +1,63 @@
<template>
<el-form-item label="主机地址" prop="config.host">
<el-input v-model="config.host" placeholder="请输入主机地址localhost" />
</el-form-item>
<el-form-item label="端口" prop="config.port">
<el-input-number
v-model="config.port"
:max="65535"
:min="1"
controls-position="right"
placeholder="请输入端口"
/>
</el-form-item>
<el-form-item label="虚拟主机" prop="config.virtualHost">
<el-input v-model="config.virtualHost" placeholder="请输入虚拟主机" />
</el-form-item>
<el-form-item label="用户名" prop="config.username">
<el-input v-model="config.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="密码" prop="config.password">
<el-input v-model="config.password" placeholder="请输入密码" show-password type="password" />
</el-form-item>
<el-form-item label="交换机" prop="config.exchange">
<el-input v-model="config.exchange" placeholder="请输入交换机" />
</el-form-item>
<el-form-item label="路由键" prop="config.routingKey">
<el-input v-model="config.routingKey" placeholder="请输入路由键" />
</el-form-item>
<el-form-item label="队列" prop="config.queue">
<el-input v-model="config.queue" placeholder="请输入队列" />
</el-form-item>
</template>
<script lang="ts" setup>
import { IoTDataBridgeConfigType, RabbitMQConfig } from '@/api/iot/rule/databridge'
import { useVModel } from '@vueuse/core'
import { isEmpty } from '@/utils/is'
defineOptions({ name: 'RabbitMQConfigForm' })
const props = defineProps<{
modelValue: any
}>()
const emit = defineEmits(['update:modelValue'])
const config = useVModel(props, 'modelValue', emit) as Ref<RabbitMQConfig>
/** 组件初始化 */
onMounted(() => {
if (!isEmpty(config.value)) {
return
}
config.value = {
type: IoTDataBridgeConfigType.RABBITMQ,
host: '',
port: 5672,
virtualHost: '/',
username: '',
password: '',
exchange: '',
routingKey: '',
queue: ''
}
})
</script>

View File

@@ -0,0 +1,58 @@
<!-- TODO @puhui999去掉 MQ 关键字哈 -->
<template>
<el-form-item label="主机地址" prop="config.host">
<el-input v-model="config.host" placeholder="请输入主机地址localhost" />
</el-form-item>
<el-form-item label="端口" prop="config.port">
<el-input-number
v-model="config.port"
:max="65535"
:min="1"
controls-position="right"
placeholder="请输入端口"
/>
</el-form-item>
<el-form-item label="密码" prop="config.password">
<el-input v-model="config.password" placeholder="请输入密码" show-password type="password" />
</el-form-item>
<el-form-item label="数据库" prop="config.database">
<el-input-number
v-model="config.database"
:max="15"
:min="0"
controls-position="right"
placeholder="请输入数据库索引"
/>
</el-form-item>
<el-form-item label="主题" prop="config.topic">
<el-input v-model="config.topic" placeholder="请输入主题" />
</el-form-item>
</template>
<script lang="ts" setup>
import { IoTDataBridgeConfigType, RedisStreamMQConfig } from '@/api/iot/rule/databridge'
import { useVModel } from '@vueuse/core'
import { isEmpty } from '@/utils/is'
defineOptions({ name: 'RedisStreamMQConfigForm' })
const props = defineProps<{
modelValue: any
}>()
const emit = defineEmits(['update:modelValue'])
const config = useVModel(props, 'modelValue', emit) as Ref<RedisStreamMQConfig>
/** 组件初始化 */
onMounted(() => {
if (!isEmpty(config.value)) {
return
}
config.value = {
type: IoTDataBridgeConfigType.REDIS_STREAM,
host: '',
port: 6379,
password: '',
database: 0,
topic: ''
}
})
</script>

View File

@@ -0,0 +1,57 @@
<template>
<el-form-item label="NameServer" prop="config.nameServer">
<el-input
v-model="config.nameServer"
placeholder="请输入 NameServer 地址127.0.0.1:9876"
/>
</el-form-item>
<el-form-item label="AccessKey" prop="config.accessKey">
<el-input v-model="config.accessKey" placeholder="请输入 AccessKey" />
</el-form-item>
<el-form-item label="SecretKey" prop="config.secretKey">
<el-input
v-model="config.secretKey"
placeholder="请输入 SecretKey"
show-password
type="password"
/>
</el-form-item>
<el-form-item label="消费组" prop="config.group">
<el-input v-model="config.group" placeholder="请输入消费组" />
</el-form-item>
<el-form-item label="主题" prop="config.topic">
<el-input v-model="config.topic" placeholder="请输入主题" />
</el-form-item>
<el-form-item label="标签" prop="config.tags">
<el-input v-model="config.tags" placeholder="请输入标签" />
</el-form-item>
</template>
<script lang="ts" setup>
import { IoTDataBridgeConfigType, RocketMQConfig } from '@/api/iot/rule/databridge'
import { useVModel } from '@vueuse/core'
import { isEmpty } from '@/utils/is'
defineOptions({ name: 'RocketMQConfigForm' })
const props = defineProps<{
modelValue: any
}>()
const emit = defineEmits(['update:modelValue'])
const config = useVModel(props, 'modelValue', emit) as Ref<RocketMQConfig>
/** 组件初始化 */
onMounted(() => {
if (!isEmpty(config.value)) {
return
}
config.value = {
type: IoTDataBridgeConfigType.ROCKETMQ,
nameServer: '',
accessKey: '',
secretKey: '',
group: '',
topic: '',
tags: ''
}
})
</script>

View File

@@ -0,0 +1,74 @@
<template>
<div v-for="(item, index) in items" :key="index" class="flex mb-2 w-full">
<el-input v-model="item.key" class="mr-2" placeholder="键" />
<el-input v-model="item.value" placeholder="值" />
<el-button class="ml-2" text type="danger" @click="removeItem(index)">
<el-icon>
<Delete />
</el-icon>
删除
</el-button>
</div>
<el-button text type="primary" @click="addItem">
<el-icon>
<Plus />
</el-icon>
{{ addButtonText }}
</el-button>
</template>
<script lang="ts" setup>
import { Delete, Plus } from '@element-plus/icons-vue'
import { isEmpty } from '@/utils/is'
defineOptions({ name: 'KeyValueEditor' })
interface KeyValueItem {
key: string
value: string
}
const props = defineProps<{
modelValue: Record<string, string>
addButtonText: string
}>()
const emit = defineEmits(['update:modelValue'])
const items = ref<KeyValueItem[]>([]) // 内部 key-value 项列表
/** 添加项目 */
const addItem = () => {
items.value.push({ key: '', value: '' })
updateModelValue()
}
/** 移除项目 */
const removeItem = (index: number) => {
items.value.splice(index, 1)
updateModelValue()
}
/** 更新 modelValue */
const updateModelValue = () => {
const result: Record<string, string> = {}
items.value.forEach((item) => {
if (item.key) {
result[item.key] = item.value
}
})
emit('update:modelValue', result)
}
// TODO @puhui999有告警的地方尽量用 cursor 处理下
/** 监听项目变化 */
watch(items, updateModelValue, { deep: true })
watch(
() => props.modelValue,
(val) => {
// 列表有值后以列表中的值为准
if (isEmpty(val) || !isEmpty(items.value)) {
return
}
items.value = Object.entries(props.modelValue).map(([key, value]) => ({ key, value }))
}
)
</script>

View File

@@ -0,0 +1,15 @@
import HttpConfigForm from './HttpConfigForm.vue'
import MqttConfigForm from './MqttConfigForm.vue'
import RocketMQConfigForm from './RocketMQConfigForm.vue'
import KafkaMQConfigForm from './KafkaMQConfigForm.vue'
import RabbitMQConfigForm from './RabbitMQConfigForm.vue'
import RedisStreamMQConfigForm from './RedisStreamMQConfigForm.vue'
export {
HttpConfigForm,
MqttConfigForm,
RocketMQConfigForm,
KafkaMQConfigForm,
RabbitMQConfigForm,
RedisStreamMQConfigForm
}

View File

@@ -0,0 +1,234 @@
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form
ref="queryFormRef"
:inline="true"
:model="queryParams"
class="-mb-15px"
label-width="68px"
>
<el-form-item label="桥梁名称" prop="name">
<el-input
v-model="queryParams.name"
class="!w-240px"
clearable
placeholder="请输入桥梁名称"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="桥梁状态" prop="status">
<el-select
v-model="queryParams.status"
class="!w-240px"
clearable
placeholder="请选择桥梁状态"
>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="桥梁方向" prop="direction">
<el-select
v-model="queryParams.direction"
class="!w-240px"
clearable
placeholder="请选择桥梁方向"
>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DATA_BRIDGE_DIRECTION_ENUM)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="桥梁类型" prop="type">
<el-select
v-model="queryParams.type"
class="!w-240px"
clearable
placeholder="请选择桥梁类型"
>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_DATA_BRIDGE_TYPE_ENUM)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="创建时间" prop="createTime">
<el-date-picker
v-model="queryParams.createTime"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
class="!w-220px"
end-placeholder="结束日期"
start-placeholder="开始日期"
type="daterange"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery">
<Icon class="mr-5px" icon="ep:search" />
搜索
</el-button>
<el-button @click="resetQuery">
<Icon class="mr-5px" icon="ep:refresh" />
重置
</el-button>
<el-button
v-hasPermi="['iot:data-bridge:create']"
plain
type="primary"
@click="openForm('create')"
>
<Icon class="mr-5px" icon="ep:plus" />
新增
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
<el-table-column align="center" label="桥梁编号" prop="id" />
<el-table-column align="center" label="桥梁名称" prop="name" />
<el-table-column align="center" label="桥梁描述" prop="description" />
<el-table-column align="center" label="桥梁状态" prop="status">
<template #default="scope">
<dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
</template>
</el-table-column>
<el-table-column align="center" label="桥梁方向" prop="direction">
<template #default="scope">
<dict-tag :type="DICT_TYPE.IOT_DATA_BRIDGE_DIRECTION_ENUM" :value="scope.row.direction" />
</template>
</el-table-column>
<el-table-column align="center" label="桥梁类型" prop="type">
<template #default="scope">
<dict-tag :type="DICT_TYPE.IOT_DATA_BRIDGE_TYPE_ENUM" :value="scope.row.type" />
</template>
</el-table-column>
<el-table-column
:formatter="dateFormatter"
align="center"
label="创建时间"
prop="createTime"
width="180px"
/>
<el-table-column align="center" fixed="right" label="操作" width="120px">
<template #default="scope">
<el-button
v-hasPermi="['iot:data-bridge:update']"
link
type="primary"
@click="openForm('update', scope.row.id)"
>
编辑
</el-button>
<el-button
v-hasPermi="['iot:data-bridge:delete']"
link
type="danger"
@click="handleDelete(scope.row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination
v-model:limit="queryParams.pageSize"
v-model:page="queryParams.pageNo"
:total="total"
@pagination="getList"
/>
</ContentWrap>
<!-- 表单弹窗添加/修改 -->
<DataBridgeForm ref="formRef" @success="getList" />
</template>
<script lang="ts" setup>
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { dateFormatter } from '@/utils/formatTime'
import { DataBridgeApi, DataBridgeVO } from '@/api/iot/rule/databridge'
import DataBridgeForm from './IoTDataBridgeForm.vue'
/** IoT 数据桥梁 列表 */
defineOptions({ name: 'IotDataBridge' })
const message = useMessage() // 消息弹窗
const { t } = useI18n() // 国际化
const loading = ref(true) // 列表的加载中
const list = ref<DataBridgeVO[]>([]) // 列表的数据
const total = ref(0) // 列表的总页数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
name: undefined,
description: undefined,
status: undefined,
direction: undefined,
type: undefined,
createTime: []
})
const queryFormRef = ref() // 搜索的表单
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await DataBridgeApi.getDataBridgePage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
// 删除的二次确认
await message.delConfirm()
// 发起删除
await DataBridgeApi.deleteDataBridge(id)
message.success(t('common.delSuccess'))
// 刷新列表
await getList()
} catch {}
}
/** 初始化 **/
onMounted(() => {
getList()
})
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,490 @@
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form class="-mb-15px" :model="queryParams" ref="queryFormRef" :inline="true" label-width="120px">
<!-- <el-form-item label="罐标" prop="canLabel">
<el-input v-model="queryParams.canLabel" placeholder="请输入罐标" clearable @keyup.enter="handleQuery"
class="!w-240px" />
</el-form-item> -->
<el-form-item label="产品名称" prop="productName">
<el-select v-model="queryParams.productName" placeholder="请选择产品名称" clearable class="!w-240px">
<el-option label="请选择字典生成" value="" />
</el-select>
</el-form-item>
<el-form-item label="产品规格" prop="productSpec">
<el-input v-model="queryParams.productSpec" placeholder="请输入产品规格" clearable @keyup.enter="handleQuery"
class="!w-240px" />
</el-form-item>
<el-form-item label="质检员" prop="inspector">
<el-input v-model="queryParams.inspector" placeholder="请输入质检员" clearable @keyup.enter="handleQuery"
class="!w-240px" />
</el-form-item>
<el-form-item label="质检日期" prop="inspectionDate">
<el-date-picker v-model="queryParams.inspectionDate" value-format="YYYY-MM-DD HH:mm:ss" type="daterange"
start-placeholder="开始日期" end-placeholder="结束日期"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" class="!w-240px" />
</el-form-item>
<el-form-item label="创建时间" prop="createTime">
<el-date-picker v-model="queryParams.createTime" value-format="YYYY-MM-DD HH:mm:ss" type="daterange"
start-placeholder="开始日期" end-placeholder="结束日期"
:default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" class="!w-240px" />
</el-form-item>
<el-form-item label="合格状态" prop="qualifiedStatus">
<el-select v-model="queryParams.qualifiedStatus" placeholder="请选择合格状态" clearable class="!w-240px">
<el-option v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)" :key="String(dict.value)"
:label="dict.label" :value="dict.value" />
</el-select>
</el-form-item>
<el-form-item label="质检数据" prop="inspectionData">
<el-input v-model="queryParams.inspectionData" placeholder="请输入质检数据" clearable @keyup.enter="handleQuery"
class="!w-240px" />
</el-form-item>
<el-form-item label="关联单据号" prop="relatedDocumentNo">
<el-input v-model="queryParams.relatedDocumentNo" placeholder="请输入关联单据号" clearable @keyup.enter="handleQuery"
class="!w-240px" />
</el-form-item>
<el-form-item>
<el-button @click="handleQuery">
<Icon icon="ep:search" class="mr-5px" /> 搜索
</el-button>
<el-button @click="resetQuery">
<Icon icon="ep:refresh" class="mr-5px" /> 重置
</el-button>
<el-button type="primary" plain @click="openForm('create')" v-hasPermi="['iot:quality:create']">
<Icon icon="ep:plus" class="mr-5px" /> 新增
</el-button>
<el-button type="success" plain @click="handleExport" :loading="exportLoading"
v-hasPermi="['iot:quality:export']">
<Icon icon="ep:download" class="mr-5px" /> 导出
</el-button>
<el-button type="danger" plain :disabled="isEmpty(checkedIds)" @click="handleDeleteBatch"
v-hasPermi="['iot:quality:delete']">
<Icon icon="ep:delete" class="mr-5px" /> 批量删除
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-table row-key="id" v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true"
@selection-change="handleRowCheckboxChange">
<el-table-column type="selection" width="55" />
<!-- <el-table-column label="罐标" align="center" prop="canLabel" /> -->
<el-table-column label="产品名称" align="center" prop="productName" />
<el-table-column label="产品规格" align="center" prop="productSpec" />
<el-table-column label="质检员" align="center" prop="inspector" />
<el-table-column label="质检日期" align="center" prop="inspectionDate" :formatter="dateFormatter2" width="180px" />
<!-- <el-table-column label="创建时间" align="center" prop="createTime" :formatter="dateFormatter" width="180px" /> -->
<el-table-column label="合格状态" align="center" prop="qualifiedStatus">
<template #default="scope">
<dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.qualifiedStatus" />
</template>
</el-table-column>
<el-table-column label="附件" align="center" min-width="120px">
<template #default="scope">
<span v-if="scope.row.attachmentName">{{ scope.row.attachmentName }}</span>
<span v-else class="text-gray-400"></span>
</template>
</el-table-column>
<!-- <el-table-column label="关联单据号" align="center" prop="relatedDocumentNo" /> -->
<el-table-column label="操作" align="center" min-width="260px">
<template #default="scope">
<el-button link type="primary" @click="openForm('update', scope.row.id)" v-hasPermi="['iot:quality:update']">
编辑
</el-button>
<el-button link type="info" @click="openDetail(scope.row)">
质检详情
</el-button>
<el-button
v-if="scope.row.attachmentUrl"
link
type="success"
@click="handleDownloadAttachment(scope.row)"
v-hasPermi="['iot:quality:query']"
>
下载附件
</el-button>
<el-button link type="danger" @click="handleDelete(scope.row.id)" v-hasPermi="['iot:quality:delete']">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination :total="total" v-model:page="queryParams.pageNo" v-model:limit="queryParams.pageSize"
@pagination="getList" />
</ContentWrap>
<!-- 表单弹窗添加/修改 -->
<QualityForm ref="formRef" :inspection-type="inspectionType" @success="getList" />
<!-- 质检详情弹窗 -->
<Dialog v-model="detailVisible" title="质检详情" width="80%">
<el-table :data="detailData" border>
<el-table-column label="项目名称" prop="itemName" align="center" />
<el-table-column label="检测结果" prop="result" align="center" />
<el-table-column label="检测标准" prop="standard" align="center" />
<el-table-column label="是否合格" prop="qualified" align="center">
<template #default="scope">
<el-tag :type="scope.row.qualified === '合格' || scope.row.qualified === 'true' ? 'success' : 'danger'">
{{ scope.row.qualified === 'true' ? '合格' : scope.row.qualified === 'false' ? '不合格' : scope.row.qualified }}
</el-tag>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { getBoolDictOptions, DICT_TYPE } from '@/utils/dict'
import { isEmpty } from '@/utils/is'
import { dateFormatter, dateFormatter2 } from '@/utils/formatTime'
import download from '@/utils/download'
import { QualityApi, Quality } from '@/api/iot/product-inspection'
import QualityForm from './QualityForm.vue'
/** 质检列表通用组件 */
defineOptions({ name: 'QualityInspectionList' })
// 定义props
interface Props {
inspectionType: number // 质检类型1-原料质检2-过程质检3-产品质检
pageTitle?: string // 页面标题,用于导出文件名
}
const props = withDefaults(defineProps<Props>(), {
pageTitle: '质检'
})
const message = useMessage() // 消息弹窗
const { t } = useI18n() // 国际化
const loading = ref(true) // 列表的加载中
const list = ref<Quality[]>([]) // 列表的数据
const total = ref(0) // 列表的总页数
// 质检详情相关
const detailVisible = ref(false) // 详情弹窗显示状态
const detailData = ref<any[]>([]) // 详情数据
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
canLabel: undefined,
productName: undefined,
productSpec: undefined,
inspector: undefined,
inspectionDate: [],
createTime: [],
qualifiedStatus: undefined,
inspectionType: props.inspectionType,
inspectionData: undefined,
relatedDocumentNo: undefined
})
const queryFormRef = ref() // 搜索的表单
const exportLoading = ref(false) // 导出的加载中
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
const data = await QualityApi.getQualityPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
handleQuery()
}
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 打开质检详情 */
const openDetail = async (row: Quality) => {
try {
// 解析质检数据
let inspectionData = {}
if (row.inspectionData) {
inspectionData = JSON.parse(row.inspectionData)
}
// 构建详情数据
const detailItems: any[] = []
// 检查是否是新的LabView兼容数据结构
if (inspectionData.inspectionItems && Array.isArray(inspectionData.inspectionItems)) {
// 新格式LabView兼容的结构化数据
inspectionData.inspectionItems.forEach((item: any) => {
detailItems.push({
itemName: item.itemName || getFieldDisplayName(item.itemKey),
result: getItemResultLabel(item),
standard: getFieldStandardFromItem(item),
qualified: item.qualified === 'true' ? '合格' : item.qualified === 'false' ? '不合格' : item.qualified || '未设置'
})
})
} else {
// 旧格式:键值对结构(向后兼容)
// 获取产品标准信息
let productStandards: Record<string, any> = {}
if (row.productName) {
try {
const { ProductApi } = await import('@/api/erp/product/product')
// 通过产品名称查找产品
const productList = await ProductApi.getProductSimpleList()
const product = productList.find((item: any) => item.name === row.productName)
if (product) {
const productDetail = await ProductApi.getProduct(product.id)
if (productDetail.jsonField) {
const jsonConfig = JSON.parse(productDetail.jsonField)
if (jsonConfig.items && Array.isArray(jsonConfig.items)) {
// 新格式从FormBuilder配置中获取标准信息
jsonConfig.items.forEach((item: any) => {
if (item.standard || item.standardValue || item.standardRange) {
productStandards[item.key] = {
standard: item.standard,
standardType: item.standardType,
standardValue: item.standardValue,
standardRange: item.standardRange,
standardOptions: item.standardOptions
}
}
})
}
}
}
} catch (error) {
console.warn('获取产品标准信息失败:', error)
}
}
// 遍历质检数据,分离普通字段和合格状态字段
Object.keys(inspectionData).forEach(key => {
if (!key.endsWith('_qualified') && inspectionData[key] !== null && inspectionData[key] !== undefined) {
const qualifiedKey = `${key}_qualified`
const qualifiedValue = inspectionData[qualifiedKey]
detailItems.push({
itemName: getFieldDisplayName(key),
result: inspectionData[key],
standard: getFieldStandard(key, productStandards[key]),
qualified: qualifiedValue === 'true' ? '合格' : qualifiedValue === 'false' ? '不合格' : qualifiedValue || '未设置'
})
}
})
}
detailData.value = detailItems
detailVisible.value = true
} catch (error) {
console.error('解析质检数据失败:', error)
message.error('解析质检数据失败')
}
}
/** 获取字段显示名称 */
const getFieldDisplayName = (key: string): string => {
const nameMap: Record<string, string> = {
quality: '质量等级',
defectCount: '缺陷数量',
remark: '备注',
weight: '重量',
color: '颜色',
purity: '纯度',
moisture: '水分含量',
temperature: '温度',
pressure: '压力',
flowRate: '流量',
status: '状态'
}
return nameMap[key] || key
}
/** 获取检测结果展示值优先用选项label */
const getItemResultLabel = (item: any): string => {
const rawResult = item?.result
if (rawResult === null || rawResult === undefined || rawResult === '') {
return ''
}
const options = Array.isArray(item?.options) ? item.options : []
if (options.length > 0) {
const match = options.find((opt: any) => opt?.value === rawResult)
return match?.label ?? String(rawResult)
}
return String(rawResult)
}
/** 获取字段标准 */
const getFieldStandard = (_key: string, standardInfo?: any): string => {
if (!standardInfo) {
return '未设置标准'
}
// 优先显示标准范围
if (standardInfo.standardRange) {
const { min, max } = standardInfo.standardRange
// 处理不同的范围情况
if (min !== null && min !== undefined && max !== null && max !== undefined) {
// 有最小值和最大值10 - 20
return `${min} - ${max}`
} else if (min !== null && min !== undefined && (max === null || max === undefined)) {
// 只有最小值:≥ 10
return `${min}`
} else if ((min === null || min === undefined) && max !== null && max !== undefined) {
// 只有最大值:≤ 20
return `${max}`
} else {
// 都没有或都是null
return '未设置范围'
}
}
// 如果有标准描述,返回标准描述
if (standardInfo.standard) {
return standardInfo.standard
}
// 如果有标准值,显示标准值
if (standardInfo.standardValue) {
return `标准值: ${standardInfo.standardValue}`
}
// 如果是选择类型,显示可选值
if (standardInfo.standardType === 'select' && standardInfo.standardOptions && standardInfo.standardOptions.length > 0) {
const options = standardInfo.standardOptions.map((opt: any) => opt.label).join('、')
return `可选值: ${options}`
}
return '未设置标准'
}
/** 从LabView兼容的检测项目中获取字段标准 */
const getFieldStandardFromItem = (item: any): string => {
// 优先显示标准范围
if (item.standardRange && (item.standardRange.min !== 0 || item.standardRange.max !== 100)) {
const { min, max } = item.standardRange
// 处理不同的范围情况
if (min !== null && min !== undefined && max !== null && max !== undefined) {
// 有最小值和最大值10 - 20
return `${min} - ${max}`
} else if (min !== null && min !== undefined && (max === null || max === undefined)) {
// 只有最小值:≥ 10
return `${min}`
} else if ((min === null || min === undefined) && max !== null && max !== undefined) {
// 只有最大值:≤ 20
return `${max}`
} else {
// 都没有或都是null
return '未设置范围'
}
}
// 如果有标准描述,返回标准描述
if (item.standard) {
return item.standard
}
// 如果有标准值,显示标准值
if (item.standardValue) {
return `标准值: ${item.standardValue}`
}
// 如果是选择类型,显示可选值
if (item.standardType === 'select' && item.standardOptions && item.standardOptions.length > 0) {
const options = item.standardOptions.map((opt: any) => opt.label).join('、')
return `可选值: ${options}`
}
return '未设置标准'
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
// 删除的二次确认
await message.delConfirm()
// 发起删除
await QualityApi.deleteQuality(id)
message.success(t('common.delSuccess'))
// 刷新列表
await getList()
} catch { }
}
/** 批量删除 */
const handleDeleteBatch = async () => {
try {
// 删除的二次确认
await message.delConfirm()
await QualityApi.deleteQualityList(checkedIds.value);
message.success(t('common.delSuccess'))
await getList();
} catch { }
}
const checkedIds = ref<number[]>([])
const handleRowCheckboxChange = (records: Quality[]) => {
checkedIds.value = records.map((item) => item.id);
}
/** 下载附件 */
const handleDownloadAttachment = async (row: any) => {
try {
// 直接使用存储的URL下载文件
if (row.attachmentUrl) {
const link = document.createElement('a')
link.href = row.attachmentUrl
link.style.display = 'none'
link.target = '_blank' // 在新标签页打开
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
message.success('附件下载成功')
} else {
message.error('附件不存在')
}
} catch (error) {
console.error('下载附件失败:', error)
message.error('下载附件失败')
}
}
/** 导出按钮操作 */
const handleExport = async () => {
try {
// 导出的二次确认
await message.exportConfirm()
// 发起导出
exportLoading.value = true
const data = await QualityApi.exportQuality(queryParams)
download.excel(data, `定制-上能石化-${props.pageTitle}.xls`)
} catch {
} finally {
exportLoading.value = false
}
}
/** 初始化 **/
onMounted(() => {
getList()
})
</script>

View File

@@ -0,0 +1,56 @@
<!-- 产品的物模型表单event -->
<template>
<el-form-item
:rules="[{ required: true, message: '请选择事件类型', trigger: 'change' }]"
label="事件类型"
prop="event.type"
>
<el-radio-group v-model="thingModelEvent.type">
<el-radio :value="ThingModelEventType.INFO.value">
{{ ThingModelEventType.INFO.label }}
</el-radio>
<el-radio :value="ThingModelEventType.ALERT.value">
{{ ThingModelEventType.ALERT.label }}
</el-radio>
<el-radio :value="ThingModelEventType.ERROR.value">
{{ ThingModelEventType.ERROR.label }}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="输出参数">
<ThingModelInputOutputParam
v-model="thingModelEvent.outputParams"
:direction="ThingModelParamDirection.OUTPUT"
/>
</el-form-item>
</template>
<script lang="ts" setup>
import ThingModelInputOutputParam from './ThingModelInputOutputParam.vue'
import { useVModel } from '@vueuse/core'
import { ThingModelEvent } from '@/api/iot/thingmodel'
import { ThingModelEventType, ThingModelParamDirection } from './config'
import { isEmpty } from '@/utils/is'
/** IoT 物模型事件 */
defineOptions({ name: 'ThingModelEvent' })
const props = defineProps<{ modelValue: any; isStructDataSpecs?: boolean }>()
const emits = defineEmits(['update:modelValue'])
const thingModelEvent = useVModel(props, 'modelValue', emits) as Ref<ThingModelEvent>
// 默认选中INFO 信息
watch(
() => thingModelEvent.value.type,
(val: string) => isEmpty(val) && (thingModelEvent.value.type = ThingModelEventType.INFO.value),
{ immediate: true }
)
</script>
<style lang="scss" scoped>
:deep(.el-form-item) {
.el-form-item {
margin-bottom: 0;
}
}
</style>

View File

@@ -0,0 +1,214 @@
<!-- 产品的物模型表单 -->
<template>
<Dialog v-model="dialogVisible" :title="dialogTitle">
<el-form
ref="formRef"
v-loading="formLoading"
:model="formData"
:rules="ThingModelFormRules"
label-width="100px"
>
<el-form-item label="功能类型" prop="type">
<el-radio-group v-model="formData.type">
<el-radio-button
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_THING_MODEL_TYPE)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="功能名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入功能名称" />
</el-form-item>
<el-form-item label="标识符" prop="identifier">
<el-input v-model="formData.identifier" placeholder="请输入标识符" />
</el-form-item>
<!-- 属性配置 -->
<ThingModelProperty
v-if="formData.type === ThingModelType.PROPERTY"
v-model="formData.property"
/>
<!-- 服务配置 -->
<ThingModelService
v-if="formData.type === ThingModelType.SERVICE"
v-model="formData.service"
/>
<!-- 事件配置 -->
<ThingModelEvent v-if="formData.type === ThingModelType.EVENT" v-model="formData.event" />
<el-form-item label="描述" prop="description">
<el-input
v-model="formData.description"
:maxlength="200"
:rows="3"
placeholder="请输入属性描述"
type="textarea"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="formLoading" type="primary" @click="submitForm"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script lang="ts" setup>
import { ProductVO } from '@/api/iot/product/product'
import ThingModelProperty from './ThingModelProperty.vue'
import ThingModelService from './ThingModelService.vue'
import ThingModelEvent from './ThingModelEvent.vue'
import { ThingModelApi, ThingModelData } from '@/api/iot/thingmodel'
import { IOT_PROVIDE_KEY } from '@/views/iot/utils/constants'
import { DataSpecsDataType, ThingModelFormRules, ThingModelType } from './config'
import { cloneDeep } from 'lodash-es'
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import { isEmpty } from '@/utils/is'
/** IoT 物模型数据表单 */
defineOptions({ name: 'IoTThingModelForm' })
const product = inject<Ref<ProductVO>>(IOT_PROVIDE_KEY.PRODUCT) // 注入产品信息
const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗
const dialogVisible = ref(false) // 弹窗的是否展示
const dialogTitle = ref('') // 弹窗的标题
const formLoading = ref(false) // 表单的加载中1修改时的数据加载2提交的按钮禁用
const formType = ref('') // 表单的类型create - 新增update - 修改
const formData = ref<ThingModelData>({
type: ThingModelType.PROPERTY,
dataType: DataSpecsDataType.INT,
property: {
dataType: DataSpecsDataType.INT,
dataSpecs: {
dataType: DataSpecsDataType.INT
}
},
service: {},
event: {}
})
const formRef = ref() // 表单 Ref
/** 打开弹窗 */
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
if (id) {
formLoading.value = true
try {
formData.value = await ThingModelApi.getThingModel(id)
// 情况一:属性初始化
if (isEmpty(formData.value.property)) {
formData.value.dataType = DataSpecsDataType.INT
formData.value.property = {
dataType: DataSpecsDataType.INT,
dataSpecs: {
dataType: DataSpecsDataType.INT
}
}
}
// 情况二:服务初始化
if (isEmpty(formData.value.service)) {
formData.value.service = {}
}
// 情况三:事件初始化
if (isEmpty(formData.value.event)) {
formData.value.event = {}
}
} finally {
formLoading.value = false
}
}
}
defineExpose({ open, close: () => (dialogVisible.value = false) })
/** 提交表单 */
const emit = defineEmits(['success'])
const submitForm = async () => {
await formRef.value.validate()
formLoading.value = true
try {
const data = cloneDeep(formData.value) as ThingModelData
// 信息补全
data.productId = product!.value.id
data.productKey = product!.value.productKey
fillExtraAttributes(data)
if (formType.value === 'create') {
await ThingModelApi.createThingModel(data)
message.success(t('common.createSuccess'))
} else {
await ThingModelApi.updateThingModel(data)
message.success(t('common.updateSuccess'))
}
} finally {
dialogVisible.value = false // 确保关闭弹框
emit('success')
formLoading.value = false
}
}
/** 填写额外的属性 */
const fillExtraAttributes = (data: any) => {
// 处理不同类型的情况
// 属性
if (data.type === ThingModelType.PROPERTY) {
removeDataSpecs(data.property)
data.dataType = data.property.dataType
data.property.identifier = data.identifier
data.property.name = data.name
delete data.service
delete data.event
}
// 服务
if (data.type === ThingModelType.SERVICE) {
removeDataSpecs(data.service)
data.dataType = data.service.dataType
data.service.identifier = data.identifier
data.service.name = data.name
delete data.property
delete data.event
}
// 事件
if (data.type === ThingModelType.EVENT) {
removeDataSpecs(data.event)
data.dataType = data.event.dataType
data.event.identifier = data.identifier
data.event.name = data.name
delete data.property
delete data.service
}
}
/** 处理 dataSpecs 为空的情况 */
const removeDataSpecs = (val: any) => {
if (isEmpty(val.dataSpecs)) {
delete val.dataSpecs
}
if (isEmpty(val.dataSpecsList)) {
delete val.dataSpecsList
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
type: ThingModelType.PROPERTY,
dataType: DataSpecsDataType.INT,
property: {
dataType: DataSpecsDataType.INT,
dataSpecs: {
dataType: DataSpecsDataType.INT
}
},
service: {},
event: {}
}
formRef.value?.resetFields()
}
</script>

View File

@@ -0,0 +1,155 @@
<!-- 产品的物模型表单eventservice 项里的参数 -->
<template>
<div
v-for="(item, index) in thingModelParams"
:key="index"
class="w-1/1 param-item flex justify-between px-10px mb-10px"
>
<span>参数名称{{ item.name }}</span>
<div class="btn">
<el-button link type="primary" @click="openParamForm(item)">编辑</el-button>
<el-divider direction="vertical" />
<el-button link type="danger" @click="deleteParamItem(index)">删除</el-button>
</div>
</div>
<el-button link type="primary" @click="openParamForm(null)">+新增参数</el-button>
<!-- param 表单 -->
<Dialog v-model="dialogVisible" :title="dialogTitle" append-to-body>
<el-form
ref="paramFormRef"
v-loading="formLoading"
:model="formData"
:rules="ThingModelFormRules"
label-width="100px"
>
<el-form-item label="参数名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入功能名称" />
</el-form-item>
<el-form-item label="标识符" prop="identifier">
<el-input v-model="formData.identifier" placeholder="请输入标识符" />
</el-form-item>
<!-- 属性配置 -->
<ThingModelProperty v-model="formData.property" is-params />
</el-form>
<template #footer>
<el-button :disabled="formLoading" type="primary" @click="submitForm"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script lang="ts" setup>
import { useVModel } from '@vueuse/core'
import ThingModelProperty from './ThingModelProperty.vue'
import { DataSpecsDataType, ThingModelFormRules } from './config'
import { isEmpty } from '@/utils/is'
/** 输入输出参数配置组件 */
defineOptions({ name: 'ThingModelInputOutputParam' })
const props = defineProps<{ modelValue: any; direction: string }>()
const emits = defineEmits(['update:modelValue'])
const thingModelParams = useVModel(props, 'modelValue', emits) as Ref<any[]>
const dialogVisible = ref(false) // 弹窗的是否展示
const dialogTitle = ref('新增参数') // 弹窗的标题
const formLoading = ref(false) // 表单的加载中1修改时的数据加载2提交的按钮禁用
const paramFormRef = ref() // 表单 ref
const formData = ref<any>({
dataType: DataSpecsDataType.INT,
property: {
dataType: DataSpecsDataType.INT,
dataSpecs: {
dataType: DataSpecsDataType.INT
}
}
})
/** 打开 param 表单 */
const openParamForm = (val: any) => {
dialogVisible.value = true
resetForm()
if (isEmpty(val)) {
return
}
// 编辑时回显数据
formData.value = {
identifier: val.identifier,
name: val.name,
description: val.description,
property: {
dataType: val.dataType,
dataSpecs: val.dataSpecs,
dataSpecsList: val.dataSpecsList
}
}
}
/** 删除 param 项 */
const deleteParamItem = (index: number) => {
thingModelParams.value.splice(index, 1)
}
/** 添加参数 */
const submitForm = async () => {
// 初始化参数列表
if (isEmpty(thingModelParams.value)) {
thingModelParams.value = []
}
// 校验参数
await paramFormRef.value.validate()
try {
const data = unref(formData)
// 构建数据对象
const item = {
identifier: data.identifier,
name: data.name,
description: data.description,
dataType: data.property.dataType,
paraOrder: 0, // TODO @puhui999: 先写死默认看看后续
direction: props.direction,
dataSpecs:
!!data.property.dataSpecs && Object.keys(data.property.dataSpecs).length > 1
? data.property.dataSpecs
: undefined,
dataSpecsList: isEmpty(data.property.dataSpecsList) ? undefined : data.property.dataSpecsList
}
// 查找是否已有相同 identifier 的项
const existingIndex = thingModelParams.value.findIndex(
(spec) => spec.identifier === data.identifier
)
if (existingIndex > -1) {
// 更新已有项
thingModelParams.value[existingIndex] = item
} else {
// 添加新项
thingModelParams.value.push(item)
}
} finally {
// 隐藏对话框
dialogVisible.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
dataType: DataSpecsDataType.INT,
property: {
dataType: DataSpecsDataType.INT,
dataSpecs: {
dataType: DataSpecsDataType.INT
}
}
}
paramFormRef.value?.resetFields()
}
</script>
<style lang="scss" scoped>
.param-item {
background-color: #e4f2fd;
}
</style>

View File

@@ -0,0 +1,169 @@
<!-- 产品的物模型表单property -->
<template>
<el-form-item
:rules="[{ required: true, message: '请选择数据类型', trigger: 'change' }]"
label="数据类型"
prop="property.dataType"
>
<el-select v-model="property.dataType" placeholder="请选择数据类型" @change="handleChange">
<!-- ARRAY STRUCT 类型数据相互嵌套时最多支持递归嵌套 2 父和子 -->
<el-option
v-for="option in getDataTypeOptions"
:key="option.value"
:label="`${option.value}(${option.label})`"
:value="option.value"
/>
</el-select>
</el-form-item>
<!-- 数值型配置 -->
<ThingModelNumberDataSpecs
v-if="
[DataSpecsDataType.INT, DataSpecsDataType.DOUBLE, DataSpecsDataType.FLOAT].includes(
property.dataType || ''
)
"
v-model="property.dataSpecs"
/>
<!-- 枚举型配置 -->
<ThingModelEnumDataSpecs
v-if="property.dataType === DataSpecsDataType.ENUM"
v-model="property.dataSpecsList"
/>
<!-- 布尔型配置 -->
<el-form-item v-if="property.dataType === DataSpecsDataType.BOOL" label="布尔值">
<template v-for="(item, index) in property.dataSpecsList" :key="item.value">
<div class="flex items-center justify-start w-1/1 mb-5px">
<span>{{ item.value }}</span>
<span class="mx-2">-</span>
<el-form-item
:prop="`property.dataSpecsList[${index}].name`"
:rules="[
{ required: true, message: '枚举描述不能为空' },
{ validator: validateBoolName, trigger: 'blur' }
]"
class="flex-1 mb-0"
>
<el-input
v-model="item.name"
:placeholder="`如:${item.value === 0 ? '关' : '开'}`"
class="w-255px!"
/>
</el-form-item>
</div>
</template>
</el-form-item>
<!-- 文本型配置 -->
<el-form-item
v-if="property.dataType === DataSpecsDataType.TEXT"
label="数据长度"
prop="property.dataSpecs.length"
>
<el-input v-model="property.dataSpecs.length" class="w-255px!" placeholder="请输入文本字节长度">
<template #append>字节</template>
</el-input>
</el-form-item>
<!-- 时间型配置 -->
<el-form-item v-if="property.dataType === DataSpecsDataType.DATE" label="时间格式" prop="date">
<el-input class="w-255px!" disabled placeholder="String 类型的 UTC 时间戳(毫秒)" />
</el-form-item>
<!-- 数组型配置-->
<ThingModelArrayDataSpecs
v-if="property.dataType === DataSpecsDataType.ARRAY"
v-model="property.dataSpecs"
/>
<!-- Struct 型配置-->
<ThingModelStructDataSpecs
v-if="property.dataType === DataSpecsDataType.STRUCT"
v-model="property.dataSpecsList"
/>
<el-form-item v-if="!isStructDataSpecs && !isParams" label="读写类型" prop="property.accessMode">
<el-radio-group v-model="property.accessMode">
<el-radio :label="ThingModelAccessMode.READ_WRITE.value">
{{ ThingModelAccessMode.READ_WRITE.label }}
</el-radio>
<el-radio :label="ThingModelAccessMode.READ_ONLY.value">
{{ ThingModelAccessMode.READ_ONLY.label }}
</el-radio>
</el-radio-group>
</el-form-item>
</template>
<script lang="ts" setup>
import { useVModel } from '@vueuse/core'
import {
DataSpecsDataType,
dataTypeOptions,
ThingModelAccessMode,
validateBoolName
} from './config'
import {
ThingModelArrayDataSpecs,
ThingModelEnumDataSpecs,
ThingModelNumberDataSpecs,
ThingModelStructDataSpecs
} from './dataSpecs'
import { ThingModelProperty } from '@/api/iot/thingmodel'
import { isEmpty } from '@/utils/is'
/** IoT 物模型属性 */
defineOptions({ name: 'ThingModelProperty' })
const props = defineProps<{ modelValue: any; isStructDataSpecs?: boolean; isParams?: boolean }>()
const emits = defineEmits(['update:modelValue'])
const property = useVModel(props, 'modelValue', emits) as Ref<ThingModelProperty>
const getDataTypeOptions = computed(() => {
return !props.isStructDataSpecs
? dataTypeOptions
: dataTypeOptions.filter(
(item) =>
!([DataSpecsDataType.STRUCT, DataSpecsDataType.ARRAY] as any[]).includes(item.value)
)
}) // 获得数据类型列表
/** 属性值的数据类型切换时初始化相关数据 */
const handleChange = (dataType: any) => {
property.value.dataSpecs = {}
property.value.dataSpecsList = []
// 不是列表型数据才设置 dataSpecs.dataType
![DataSpecsDataType.ENUM, DataSpecsDataType.BOOL, DataSpecsDataType.STRUCT].includes(dataType) &&
(property.value.dataSpecs.dataType = dataType)
switch (dataType) {
case DataSpecsDataType.ENUM:
property.value.dataSpecsList.push({
dataType: DataSpecsDataType.ENUM,
name: '', // 枚举项的名称
value: undefined // 枚举值
})
break
case DataSpecsDataType.BOOL:
for (let i = 0; i < 2; i++) {
property.value.dataSpecsList.push({
dataType: DataSpecsDataType.BOOL,
name: '', // 布尔值的名称
value: i // 布尔值
})
}
break
}
}
// 默认选中读写
watch(
() => property.value.accessMode,
(val: string) => {
if (props.isStructDataSpecs || props.isParams) {
return
}
isEmpty(val) && (property.value.accessMode = ThingModelAccessMode.READ_WRITE.value)
},
{ immediate: true }
)
</script>
<style lang="scss" scoped>
:deep(.el-form-item) {
.el-form-item {
margin-bottom: 0;
}
}
</style>

View File

@@ -0,0 +1,59 @@
<!-- 产品的物模型表单service -->
<template>
<el-form-item
:rules="[{ required: true, message: '请选择调用方式', trigger: 'change' }]"
label="调用方式"
prop="service.callType"
>
<el-radio-group v-model="service.callType">
<el-radio :value="ThingModelServiceCallType.ASYNC.value">
{{ ThingModelServiceCallType.ASYNC.label }}
</el-radio>
<el-radio :value="ThingModelServiceCallType.SYNC.value">
{{ ThingModelServiceCallType.SYNC.label }}
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="输入参数">
<ThingModelInputOutputParam
v-model="service.inputParams"
:direction="ThingModelParamDirection.INPUT"
/>
</el-form-item>
<el-form-item label="输出参数">
<ThingModelInputOutputParam
v-model="service.outputParams"
:direction="ThingModelParamDirection.OUTPUT"
/>
</el-form-item>
</template>
<script lang="ts" setup>
import ThingModelInputOutputParam from './ThingModelInputOutputParam.vue'
import { useVModel } from '@vueuse/core'
import { ThingModelService } from '@/api/iot/thingmodel'
import { ThingModelParamDirection, ThingModelServiceCallType } from './config'
import { isEmpty } from '@/utils/is'
/** IoT 物模型服务 */
defineOptions({ name: 'ThingModelService' })
const props = defineProps<{ modelValue: any; isStructDataSpecs?: boolean }>()
const emits = defineEmits(['update:modelValue'])
const service = useVModel(props, 'modelValue', emits) as Ref<ThingModelService>
// 默认选中ASYNC 异步
watch(
() => service.value.callType,
(val: string) => isEmpty(val) && (service.value.callType = ThingModelServiceCallType.ASYNC.value),
{ immediate: true }
)
</script>
<style lang="scss" scoped>
:deep(.el-form-item) {
.el-form-item {
margin-bottom: 0;
}
}
</style>

View File

@@ -0,0 +1,61 @@
<template>
<!-- 属性 -->
<template v-if="data.type === ThingModelType.PROPERTY">
<!-- 非列表型数值 -->
<div
v-if="
[DataSpecsDataType.INT, DataSpecsDataType.DOUBLE, DataSpecsDataType.FLOAT].includes(
data.property.dataType
)
"
>
取值范围:{{ `${data.property.dataSpecs.min}~${data.property.dataSpecs.max}` }}
</div>
<!-- 非列表型:文本 -->
<div v-if="DataSpecsDataType.TEXT === data.property.dataType">
数据长度:{{ data.property.dataSpecs.length }}
</div>
<!-- 列表型: 数组、结构、时间(特殊) -->
<div
v-if="
[DataSpecsDataType.ARRAY, DataSpecsDataType.STRUCT, DataSpecsDataType.DATE].includes(
data.property.dataType
)
"
>
-
</div>
<!-- 列表型: 布尔值、枚举 -->
<div v-if="[DataSpecsDataType.BOOL, DataSpecsDataType.ENUM].includes(data.property.dataType)">
<div> {{ DataSpecsDataType.BOOL === data.property.dataType ? '布尔值' : '枚举值' }}</div>
<div v-for="item in data.property.dataSpecsList" :key="item.value">
{{ `${item.name}-${item.value}` }}
</div>
</div>
</template>
<!-- 服务 -->
<div v-if="data.type === ThingModelType.SERVICE">
调用方式:{{ getCallTypeByValue(data.service!.callType) }}
</div>
<!-- 事件 -->
<div v-if="data.type === ThingModelType.EVENT">
事件类型:{{ getEventTypeByValue(data.event!.type) }}
</div>
</template>
<script lang="ts" setup>
import {
DataSpecsDataType,
getCallTypeByValue,
getEventTypeByValue,
ThingModelType
} from '@/views/iot/thingmodel/config'
import { ThingModelData } from '@/api/iot/thingmodel'
/** 数据定义展示组件 */
defineOptions({ name: 'DataDefinition' })
defineProps<{ data: ThingModelData }>()
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,3 @@
import DataDefinition from './DataDefinition.vue'
export { DataDefinition }

View File

@@ -0,0 +1,213 @@
import { isEmpty } from '@/utils/is'
/** dataSpecs 数值型数据结构 */
export interface DataSpecsNumberDataVO {
dataType: 'int' | 'float' | 'double' // 数据类型,取值为 INT、FLOAT 或 DOUBLE
max: string // 最大值,必须与 dataType 设置一致,且为 STRING 类型
min: string // 最小值,必须与 dataType 设置一致,且为 STRING 类型
step: string // 步长,必须与 dataType 设置一致,且为 STRING 类型
precise?: string // 精度,当 dataType 为 FLOAT 或 DOUBLE 时可选
defaultValue?: string // 默认值,可选
unit: string // 单位的符号
unitName: string // 单位的名称
}
/** dataSpecs 枚举型数据结构 */
export interface DataSpecsEnumOrBoolDataVO {
dataType: 'enum' | 'bool'
defaultValue?: string // 默认值,可选
name: string // 枚举项的名称
value: number | undefined // 枚举值
}
/** 属性值的数据类型 */
export const DataSpecsDataType = {
INT: 'int',
FLOAT: 'float',
DOUBLE: 'double',
ENUM: 'enum',
BOOL: 'bool',
TEXT: 'text',
DATE: 'date',
STRUCT: 'struct',
ARRAY: 'array'
} as const
/** 物体模型数据类型配置项 */
export const dataTypeOptions = [
{ value: DataSpecsDataType.INT, label: '整数型' },
{ value: DataSpecsDataType.FLOAT, label: '单精度浮点型' },
{ value: DataSpecsDataType.DOUBLE, label: '双精度浮点型' },
{ value: DataSpecsDataType.ENUM, label: '枚举型' },
{ value: DataSpecsDataType.BOOL, label: '布尔型' },
{ value: DataSpecsDataType.TEXT, label: '文本型' },
{ value: DataSpecsDataType.DATE, label: '时间型' },
{ value: DataSpecsDataType.STRUCT, label: '结构体' },
{ value: DataSpecsDataType.ARRAY, label: '数组' }
]
/** 获得物体模型数据类型配置项名称 */
export const getDataTypeOptionsLabel = (value: string) => {
if (isEmpty(value)) {
return value
}
const dataType = dataTypeOptions.find((option) => option.value === value)
return dataType && `${dataType.value}(${dataType.label})`
}
// IOT 产品物模型类型枚举类
export const ThingModelType = {
PROPERTY: 1, // 属性
SERVICE: 2, // 服务
EVENT: 3 // 事件
} as const
// IOT 产品物模型访问模式枚举类
export const ThingModelAccessMode = {
READ_WRITE: {
label: '读写',
value: 'rw'
},
READ_ONLY: {
label: '只读',
value: 'r'
}
} as const
// IOT 产品物模型服务调用方式枚举
export const ThingModelServiceCallType = {
ASYNC: {
label: '异步调用',
value: 'async'
},
SYNC: {
label: '同步调用',
value: 'sync'
}
} as const
export const getCallTypeByValue = (value: string): string | undefined =>
Object.values(ThingModelServiceCallType).find((type) => type.value === value)?.label
// IOT 产品物模型事件类型枚举
export const ThingModelEventType = {
INFO: {
label: '信息',
value: 'info'
},
ALERT: {
label: '告警',
value: 'alert'
},
ERROR: {
label: '故障',
value: 'error'
}
} as const
export const getEventTypeByValue = (value: string): string | undefined =>
Object.values(ThingModelEventType).find((type) => type.value === value)?.label
// IOT 产品物模型参数是输入参数还是输出参数
export const ThingModelParamDirection = {
INPUT: 'input', // 输入参数
OUTPUT: 'output' // 输出参数
} as const
/** 公共校验规则 */
export const ThingModelFormRules = {
name: [
{ required: true, message: '功能名称不能为空', trigger: 'blur' },
{
pattern: /^[\u4e00-\u9fa5a-zA-Z0-9][\u4e00-\u9fa5a-zA-Z0-9\-_/\.]{0,29}$/,
message:
'支持中文、大小写字母、日文、数字、短划线、下划线、斜杠和小数点,必须以中文、英文或数字开头,不超过 30 个字符',
trigger: 'blur'
}
],
type: [{ required: true, message: '功能类型不能为空', trigger: 'blur' }],
identifier: [
{ required: true, message: '标识符不能为空', trigger: 'blur' },
{
pattern: /^[a-zA-Z0-9_]{1,50}$/,
message: '支持大小写字母、数字和下划线,不超过 50 个字符',
trigger: 'blur'
},
{
validator: (_: any, value: string, callback: any) => {
const reservedKeywords = ['set', 'get', 'post', 'property', 'event', 'time', 'value']
if (reservedKeywords.includes(value)) {
callback(
new Error(
'set, get, post, property, event, time, value 是系统保留字段,不能用于标识符定义'
)
)
} else if (/^\d+$/.test(value)) {
callback(new Error('标识符不能是纯数字'))
} else {
callback()
}
},
trigger: 'blur'
}
],
'property.dataSpecs.childDataType': [{ required: true, message: '元素类型不能为空' }],
'property.dataSpecs.size': [
{ required: true, message: '元素个数不能为空' },
{
validator: (_: any, value: any, callback: any) => {
if (isEmpty(value)) {
callback(new Error('元素个数不能为空'))
return
}
if (isNaN(Number(value))) {
callback(new Error('元素个数必须是数字'))
return
}
callback()
},
trigger: 'blur'
}
],
'property.dataSpecs.length': [
{ required: true, message: '请输入文本字节长度', trigger: 'blur' },
{
validator: (_: any, value: any, callback: any) => {
if (isEmpty(value)) {
callback(new Error('文本长度不能为空'))
return
}
if (isNaN(Number(value))) {
callback(new Error('文本长度必须是数字'))
return
}
callback()
},
trigger: 'blur'
}
],
'property.accessMode': [{ required: true, message: '请选择读写类型', trigger: 'change' }]
}
/** 校验布尔值名称 */
export const validateBoolName = (_: any, value: string, callback: any) => {
if (isEmpty(value)) {
callback(new Error('布尔值名称不能为空'))
return
}
// 检查开头字符
if (!/^[\u4e00-\u9fa5a-zA-Z0-9]/.test(value)) {
callback(new Error('布尔值名称必须以中文、英文字母或数字开头'))
return
}
// 检查整体格式
if (!/^[\u4e00-\u9fa5a-zA-Z0-9][a-zA-Z0-9\u4e00-\u9fa5_-]*$/.test(value)) {
callback(new Error('布尔值名称只能包含中文、英文字母、数字、下划线和短划线'))
return
}
// 检查长度(一个中文算一个字符)
if (value.length > 20) {
callback(new Error('布尔值名称长度不能超过 20 个字符'))
return
}
callback()
}

View File

@@ -0,0 +1,52 @@
<!-- dataTypearray 数组类型 -->
<template>
<el-form-item label="元素类型" prop="property.dataSpecs.childDataType">
<el-radio-group v-model="dataSpecs.childDataType" @change="handleChange">
<template v-for="item in dataTypeOptions" :key="item.value">
<el-radio
v-if="
!(
[DataSpecsDataType.ENUM, DataSpecsDataType.ARRAY, DataSpecsDataType.DATE] as any[]
).includes(item.value)
"
:value="item.value"
class="w-1/3"
>
{{ `${item.value}(${item.label})` }}
</el-radio>
</template>
</el-radio-group>
</el-form-item>
<el-form-item label="元素个数" prop="property.dataSpecs.size">
<el-input v-model="dataSpecs.size" placeholder="请输入数组中的元素个数" />
</el-form-item>
<!-- Struct 型配置-->
<ThingModelStructDataSpecs
v-if="dataSpecs.childDataType === DataSpecsDataType.STRUCT"
v-model="dataSpecs.dataSpecsList"
/>
</template>
<script lang="ts" setup>
import { useVModel } from '@vueuse/core'
import { DataSpecsDataType, dataTypeOptions } from '../config'
import ThingModelStructDataSpecs from './ThingModelStructDataSpecs.vue'
/** 数组型的 dataSpecs 配置组件 */
defineOptions({ name: 'ThingModelArrayDataSpecs' })
const props = defineProps<{ modelValue: any }>()
const emits = defineEmits(['update:modelValue'])
const dataSpecs = useVModel(props, 'modelValue', emits) as Ref<any>
/** 元素类型改变时间。当值为 struct 时,对 dataSpecs 中的 dataSpecsList 进行初始化 */
const handleChange = (val: string) => {
if (val !== DataSpecsDataType.STRUCT) {
return
}
dataSpecs.value.dataSpecsList = []
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,159 @@
<!-- dataTypeenum 数组类型 -->
<template>
<el-form-item
:rules="[{ required: true, validator: validateEnumList, trigger: 'change' }]"
label="枚举项"
>
<div class="flex flex-col">
<div class="flex items-center">
<span class="flex-1"> 参数值 </span>
<span class="flex-1"> 参数描述 </span>
</div>
<div
v-for="(item, index) in dataSpecsList"
:key="index"
class="flex items-center justify-between mb-5px"
>
<el-form-item
:prop="`property.dataSpecsList[${index}].value`"
:rules="[
{ required: true, message: '枚举值不能为空' },
{ validator: validateEnumValue, trigger: 'blur' }
]"
class="flex-1 mb-0"
>
<el-input v-model="item.value" placeholder="请输入枚举值,如'0'" />
</el-form-item>
<span class="mx-2">~</span>
<el-form-item
:prop="`property.dataSpecsList[${index}].name`"
:rules="[
{ required: true, message: '枚举描述不能为空' },
{ validator: validateEnumName, trigger: 'blur' }
]"
class="flex-1 mb-0"
>
<el-input v-model="item.name" placeholder="对该枚举项的描述" />
</el-form-item>
<el-button class="ml-10px" link type="primary" @click="deleteEnum(index)">删除</el-button>
</div>
<el-button link type="primary" @click="addEnum">+添加枚举项</el-button>
</div>
</el-form-item>
</template>
<script lang="ts" setup>
import { useVModel } from '@vueuse/core'
import { DataSpecsDataType, DataSpecsEnumOrBoolDataVO } from '../config'
import { isEmpty } from '@/utils/is'
/** 枚举型的 dataSpecs 配置组件 */
defineOptions({ name: 'ThingModelEnumDataSpecs' })
const props = defineProps<{ modelValue: any }>()
const emits = defineEmits(['update:modelValue'])
const dataSpecsList = useVModel(props, 'modelValue', emits) as Ref<DataSpecsEnumOrBoolDataVO[]>
const message = useMessage()
/** 添加枚举项 */
const addEnum = () => {
dataSpecsList.value.push({
dataType: DataSpecsDataType.ENUM,
name: '', // 枚举项的名称
value: undefined // 枚举值
})
}
/** 删除枚举项 */
const deleteEnum = (index: number) => {
if (dataSpecsList.value.length === 1) {
message.warning('至少需要一个枚举项')
return
}
dataSpecsList.value.splice(index, 1)
}
/** 校验枚举值 */
const validateEnumValue = (_: any, value: any, callback: any) => {
if (isEmpty(value)) {
callback(new Error('枚举值不能为空'))
return
}
if (isNaN(Number(value))) {
callback(new Error('枚举值必须是数字'))
return
}
// 检查枚举值是否重复
const values = dataSpecsList.value.map((item) => item.value)
if (values.filter((v) => v === value).length > 1) {
callback(new Error('枚举值不能重复'))
return
}
callback()
}
/** 校验枚举描述 */
const validateEnumName = (_: any, value: string, callback: any) => {
if (isEmpty(value)) {
callback(new Error('枚举描述不能为空'))
return
}
// 检查开头字符
if (!/^[\u4e00-\u9fa5a-zA-Z0-9]/.test(value)) {
callback(new Error('枚举描述必须以中文、英文字母或数字开头'))
return
}
// 检查整体格式
if (!/^[\u4e00-\u9fa5a-zA-Z0-9][a-zA-Z0-9\u4e00-\u9fa5_-]*$/.test(value)) {
callback(new Error('枚举描述只能包含中文、英文字母、数字、下划线和短划线'))
return
}
// 检查长度(一个中文算一个字符)
if (value.length > 20) {
callback(new Error('枚举描述长度不能超过20个字符'))
return
}
callback()
}
/** 校验整个枚举列表 */
const validateEnumList = (_: any, __: any, callback: any) => {
if (isEmpty(dataSpecsList.value)) {
callback(new Error('请至少添加一个枚举项'))
return
}
// 检查是否存在空值
const hasEmptyValue = dataSpecsList.value.some(
(item) => isEmpty(item.value) || isEmpty(item.name)
)
if (hasEmptyValue) {
callback(new Error('存在未填写的枚举值或描述'))
return
}
// 检查枚举值是否都是数字
const hasInvalidNumber = dataSpecsList.value.some((item) => isNaN(Number(item.value)))
if (hasInvalidNumber) {
callback(new Error('存在非数字的枚举值'))
return
}
// 检查是否有重复的枚举值
const values = dataSpecsList.value.map((item) => item.value)
const uniqueValues = new Set(values)
if (values.length !== uniqueValues.size) {
callback(new Error('存在重复的枚举值'))
return
}
callback()
}
</script>
<style lang="scss" scoped>
:deep(.el-form-item) {
.el-form-item {
margin-bottom: 0;
}
}
</style>

View File

@@ -0,0 +1,139 @@
<!-- dataTypenumber 数组类型 -->
<template>
<el-form-item label="取值范围">
<div class="flex items-center justify-between">
<el-form-item
:rules="[
{ required: true, message: '最小值不能为空' },
{ validator: validateMin, trigger: 'blur' }
]"
class="mb-0"
prop="property.dataSpecs.min"
>
<el-input v-model="dataSpecs.min" placeholder="请输入最小值" />
</el-form-item>
<span class="mx-2">~</span>
<el-form-item
:rules="[
{ required: true, message: '最大值不能为空' },
{ validator: validateMax, trigger: 'blur' }
]"
class="mb-0"
prop="property.dataSpecs.max"
>
<el-input v-model="dataSpecs.max" placeholder="请输入最大值" />
</el-form-item>
</div>
</el-form-item>
<el-form-item
:rules="[
{ required: true, message: '步长不能为空' },
{ validator: validateStep, trigger: 'blur' }
]"
label="步长"
prop="property.dataSpecs.step"
>
<el-input v-model="dataSpecs.step" placeholder="请输入步长" />
</el-form-item>
<el-form-item
:rules="[{ required: true, message: '请选择单位' }]"
label="单位"
prop="property.dataSpecs.unit"
>
<el-select
:model-value="dataSpecs.unit ? dataSpecs.unitName + '-' + dataSpecs.unit : ''"
filterable
placeholder="请选择单位"
class="w-1/1"
@change="unitChange"
>
<el-option
v-for="(item, index) in getStrDictOptions(DICT_TYPE.IOT_THING_MODEL_UNIT)"
:key="index"
:label="item.label + '-' + item.value"
:value="item.label + '-' + item.value"
/>
</el-select>
</el-form-item>
</template>
<script lang="ts" setup>
import { useVModel } from '@vueuse/core'
import { DataSpecsNumberDataVO } from '../config'
import { DICT_TYPE, getStrDictOptions } from '@/utils/dict'
/** 数值型的 dataSpecs 配置组件 */
defineOptions({ name: 'ThingModelNumberDataSpecs' })
const props = defineProps<{ modelValue: any }>()
const emits = defineEmits(['update:modelValue'])
const dataSpecs = useVModel(props, 'modelValue', emits) as Ref<DataSpecsNumberDataVO>
/** 单位发生变化时触发 */
const unitChange = (UnitSpecs: string) => {
const [unitName, unit] = UnitSpecs.split('-')
dataSpecs.value.unitName = unitName
dataSpecs.value.unit = unit
}
/** 校验最小值 */
const validateMin = (_: any, __: any, callback: any) => {
const min = Number(dataSpecs.value.min)
const max = Number(dataSpecs.value.max)
if (isNaN(min)) {
callback(new Error('请输入有效的数值'))
return
}
if (max !== undefined && !isNaN(max) && min >= max) {
callback(new Error('最小值必须小于最大值'))
return
}
callback()
}
/** 校验最大值 */
const validateMax = (_: any, __: any, callback: any) => {
const min = Number(dataSpecs.value.min)
const max = Number(dataSpecs.value.max)
if (isNaN(max)) {
callback(new Error('请输入有效的数值'))
return
}
if (min !== undefined && !isNaN(min) && max <= min) {
callback(new Error('最大值必须大于最小值'))
return
}
callback()
}
/** 校验步长 */
const validateStep = (_: any, __: any, callback: any) => {
const step = Number(dataSpecs.value.step)
if (isNaN(step)) {
callback(new Error('请输入有效的数值'))
return
}
if (step <= 0) {
callback(new Error('步长必须大于0'))
return
}
const min = Number(dataSpecs.value.min)
const max = Number(dataSpecs.value.max)
if (!isNaN(min) && !isNaN(max) && step > max - min) {
callback(new Error('步长不能大于最大值和最小值的差值'))
return
}
callback()
}
</script>
<style lang="scss" scoped>
:deep(.el-form-item) {
.el-form-item {
margin-bottom: 0;
}
}
</style>

View File

@@ -0,0 +1,170 @@
<!-- dataTypestruct 数组类型 -->
<template>
<!-- struct 数据展示 -->
<el-form-item
:rules="[{ required: true, validator: validateList, trigger: 'change' }]"
label="JSON 对象"
>
<div
v-for="(item, index) in dataSpecsList"
:key="index"
class="w-1/1 struct-item flex justify-between px-10px mb-10px"
>
<span>参数名称{{ item.name }}</span>
<div class="btn">
<el-button link type="primary" @click="openStructForm(item)">编辑</el-button>
<el-divider direction="vertical" />
<el-button link type="danger" @click="deleteStructItem(index)">删除</el-button>
</div>
</div>
<el-button link type="primary" @click="openStructForm(null)">+新增参数</el-button>
</el-form-item>
<!-- struct 表单 -->
<Dialog v-model="dialogVisible" :title="dialogTitle" append-to-body>
<el-form
ref="structFormRef"
v-loading="formLoading"
:model="formData"
:rules="ThingModelFormRules"
label-width="100px"
>
<el-form-item label="参数名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入功能名称" />
</el-form-item>
<el-form-item label="标识符" prop="identifier">
<el-input v-model="formData.identifier" placeholder="请输入标识符" />
</el-form-item>
<!-- 属性配置 -->
<ThingModelProperty v-model="formData.property" is-struct-data-specs />
</el-form>
<template #footer>
<el-button :disabled="formLoading" type="primary" @click="submitForm"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
</Dialog>
</template>
<script lang="ts" setup>
import { useVModel } from '@vueuse/core'
import ThingModelProperty from '../ThingModelProperty.vue'
import { DataSpecsDataType, ThingModelFormRules } from '../config'
import { isEmpty } from '@/utils/is'
/** Struct 型的 dataSpecs 配置组件 */
defineOptions({ name: 'ThingModelStructDataSpecs' })
const props = defineProps<{ modelValue: any }>()
const emits = defineEmits(['update:modelValue'])
const dataSpecsList = useVModel(props, 'modelValue', emits) as Ref<any[]>
const dialogVisible = ref(false) // 弹窗的是否展示
const dialogTitle = ref('新增参数') // 弹窗的标题
const formLoading = ref(false) // 表单的加载中1修改时的数据加载2提交的按钮禁用
const structFormRef = ref() // 表单 ref
const formData = ref<any>({
property: {
dataType: DataSpecsDataType.INT,
dataSpecs: {
dataType: DataSpecsDataType.INT
}
}
})
/** 打开 struct 表单 */
const openStructForm = (val: any) => {
dialogVisible.value = true
resetForm()
if (isEmpty(val)) {
return
}
// 编辑时回显数据
formData.value = {
identifier: val.identifier,
name: val.name,
description: val.description,
property: {
dataType: val.childDataType,
dataSpecs: val.dataSpecs,
dataSpecsList: val.dataSpecsList
}
}
}
/** 删除 struct 项 */
const deleteStructItem = (index: number) => {
dataSpecsList.value.splice(index, 1)
}
/** 添加参数 */
const submitForm = async () => {
await structFormRef.value.validate()
try {
const data = unref(formData)
// 构建数据对象
const item = {
identifier: data.identifier,
name: data.name,
description: data.description,
dataType: DataSpecsDataType.STRUCT,
childDataType: data.property.dataType,
dataSpecs:
!!data.property.dataSpecs && Object.keys(data.property.dataSpecs).length > 1
? data.property.dataSpecs
: undefined,
dataSpecsList: isEmpty(data.property.dataSpecsList) ? undefined : data.property.dataSpecsList
}
// 查找是否已有相同 identifier 的项
const existingIndex = dataSpecsList.value.findIndex(
(spec) => spec.identifier === data.identifier
)
if (existingIndex > -1) {
// 更新已有项
dataSpecsList.value[existingIndex] = item
} else {
// 添加新项
dataSpecsList.value.push(item)
}
} finally {
// 隐藏对话框
dialogVisible.value = false
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
property: {
dataType: DataSpecsDataType.INT,
dataSpecs: {
dataType: DataSpecsDataType.INT
}
}
}
structFormRef.value?.resetFields()
}
/** 校验 struct 不能为空 */
const validateList = (_: any, __: any, callback: any) => {
if (isEmpty(dataSpecsList.value)) {
callback(new Error('struct 不能为空'))
return
}
callback()
}
/** 组件初始化 */
onMounted(async () => {
await nextTick()
// 预防 dataSpecsList 空指针
isEmpty(dataSpecsList.value) && (dataSpecsList.value = [])
})
</script>
<style lang="scss" scoped>
.struct-item {
background-color: #e4f2fd;
}
</style>

View File

@@ -0,0 +1,11 @@
import ThingModelEnumDataSpecs from './ThingModelEnumDataSpecs.vue'
import ThingModelNumberDataSpecs from './ThingModelNumberDataSpecs.vue'
import ThingModelArrayDataSpecs from './ThingModelArrayDataSpecs.vue'
import ThingModelStructDataSpecs from './ThingModelStructDataSpecs.vue'
export {
ThingModelEnumDataSpecs,
ThingModelNumberDataSpecs,
ThingModelArrayDataSpecs,
ThingModelStructDataSpecs
}

View File

@@ -0,0 +1,180 @@
<!-- 产品的物模型列表 -->
<template>
<ContentWrap>
<!-- 搜索工作栏 -->
<el-form
ref="queryFormRef"
:inline="true"
:model="queryParams"
class="-mb-15px"
label-width="68px"
>
<el-form-item label="功能类型" prop="name">
<el-select
v-model="queryParams.type"
class="!w-240px"
clearable
placeholder="请选择功能类型"
>
<el-option
v-for="dict in getIntDictOptions(DICT_TYPE.IOT_THING_MODEL_TYPE)"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery">
<Icon class="mr-5px" icon="ep:search" />
搜索
</el-button>
<el-button @click="resetQuery">
<Icon class="mr-5px" icon="ep:refresh" />
重置
</el-button>
<el-button
v-hasPermi="[`iot:thing-model:create`]"
plain
type="primary"
@click="openForm('create')"
>
<Icon class="mr-5px" icon="ep:plus" />
添加功能
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<!-- 列表 -->
<ContentWrap>
<el-tabs>
<el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
<el-table-column align="center" label="功能类型" prop="type">
<template #default="scope">
<dict-tag :type="DICT_TYPE.IOT_THING_MODEL_TYPE" :value="scope.row.type" />
</template>
</el-table-column>
<el-table-column align="center" label="功能名称" prop="name" />
<el-table-column align="center" label="标识符" prop="identifier" />
<el-table-column align="center" label="数据类型" prop="identifier">
<template #default="{ row }">
{{ dataTypeOptionsLabel(row.property?.dataType) ?? '-' }}
</template>
</el-table-column>
<el-table-column align="left" label="数据定义" prop="identifier">
<template #default="{ row }">
<DataDefinition :data="row" />
</template>
</el-table-column>
<el-table-column align="center" label="操作">
<template #default="scope">
<el-button
v-hasPermi="[`iot:thing-model:update`]"
link
type="primary"
@click="openForm('update', scope.row.id)"
>
编辑
</el-button>
<el-button
v-hasPermi="['iot:thing-model:delete']"
link
type="danger"
@click="handleDelete(scope.row.id)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<Pagination
v-model:limit="queryParams.pageSize"
v-model:page="queryParams.pageNo"
:total="total"
@pagination="getList"
/>
</el-tabs>
</ContentWrap>
<!-- 表单弹窗添加/修改 -->
<ThingModelForm ref="formRef" @success="getList" />
</template>
<script lang="ts" setup>
import { ThingModelApi, ThingModelData } from '@/api/iot/thingmodel'
import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
import ThingModelForm from './ThingModelForm.vue'
import { ProductVO } from '@/api/iot/product/product'
import { IOT_PROVIDE_KEY } from '@/views/iot/utils/constants'
import { getDataTypeOptionsLabel } from './config'
import { DataDefinition } from './components'
defineOptions({ name: 'IoTThingModel' })
const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗
const loading = ref(true) // 列表的加载中
const list = ref<ThingModelData[]>([]) // 列表的数据
const total = ref(0) // 列表的总页数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
type: undefined,
productId: -1
})
const queryFormRef = ref() // 搜索的表单
const product = inject<Ref<ProductVO>>(IOT_PROVIDE_KEY.PRODUCT) // 注入产品信息
const dataTypeOptionsLabel = computed(() => (value: string) => getDataTypeOptionsLabel(value)) // 解析数据类型
/** 查询列表 */
const getList = async () => {
loading.value = true
try {
queryParams.productId = product?.value?.id || -1
const data = await ThingModelApi.getThingModelPage(queryParams)
list.value = data.list
total.value = data.total
} finally {
loading.value = false
}
}
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
/** 重置按钮操作 */
const resetQuery = () => {
queryFormRef.value.resetFields()
queryParams.type = undefined
handleQuery()
}
/** 添加/修改操作 */
const formRef = ref()
const openForm = (type: string, id?: number) => {
formRef.value.open(type, id)
}
/** 删除按钮操作 */
const handleDelete = async (id: number) => {
try {
// 删除的二次确认
await message.delConfirm()
// 发起删除
await ThingModelApi.deleteThingModel(id)
message.success(t('common.delSuccess'))
// 刷新列表
await getList()
} catch {}
}
/** 初始化 **/
onMounted(() => {
getList()
})
</script>

View File

@@ -0,0 +1,4 @@
/** iot 依赖注入 KEY */
export const IOT_PROVIDE_KEY = {
PRODUCT: 'IOT_PRODUCT'
}