first commit
This commit is contained in:
571
src/views/iot/check/CheckExecute.vue
Normal file
571
src/views/iot/check/CheckExecute.vue
Normal 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>
|
||||
493
src/views/iot/check/CheckPlan.vue
Normal file
493
src/views/iot/check/CheckPlan.vue
Normal 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>
|
||||
244
src/views/iot/check/Execute.vue
Normal file
244
src/views/iot/check/Execute.vue
Normal 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>
|
||||
444
src/views/iot/check/Scan.vue
Normal file
444
src/views/iot/check/Scan.vue
Normal 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>
|
||||
484
src/views/iot/check/TemplateManage.vue
Normal file
484
src/views/iot/check/TemplateManage.vue
Normal 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>
|
||||
370
src/views/iot/check/log/CheckLog.vue
Normal file
370
src/views/iot/check/log/CheckLog.vue
Normal 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>
|
||||
39
src/views/iot/device/device/DeviceDataDialog.vue
Normal file
39
src/views/iot/device/device/DeviceDataDialog.vue
Normal 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>
|
||||
263
src/views/iot/device/device/DeviceForm.vue
Normal file
263
src/views/iot/device/device/DeviceForm.vue
Normal 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>
|
||||
90
src/views/iot/device/device/DeviceGroupForm.vue
Normal file
90
src/views/iot/device/device/DeviceGroupForm.vue
Normal 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>
|
||||
139
src/views/iot/device/device/DeviceImportForm.vue
Normal file
139
src/views/iot/device/device/DeviceImportForm.vue
Normal 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>仅允许导入 xls、xlsx 格式文件。</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>
|
||||
110
src/views/iot/device/device/detail/DeviceDataDetail.vue
Normal file
110
src/views/iot/device/device/detail/DeviceDataDetail.vue
Normal 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>
|
||||
119
src/views/iot/device/device/detail/DeviceDetailConfig.vue
Normal file
119
src/views/iot/device/device/detail/DeviceDetailConfig.vue
Normal 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>
|
||||
360
src/views/iot/device/device/detail/DeviceDetailsData.vue
Normal file
360
src/views/iot/device/device/detail/DeviceDetailsData.vue
Normal 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>
|
||||
69
src/views/iot/device/device/detail/DeviceDetailsHeader.vue
Normal file
69
src/views/iot/device/device/detail/DeviceDetailsHeader.vue
Normal 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>
|
||||
154
src/views/iot/device/device/detail/DeviceDetailsInfo.vue
Normal file
154
src/views/iot/device/device/detail/DeviceDetailsInfo.vue
Normal 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>
|
||||
168
src/views/iot/device/device/detail/DeviceDetailsLog.vue
Normal file
168
src/views/iot/device/device/detail/DeviceDetailsLog.vue
Normal 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 @super:autoRefreshEnable,autoRefreshTimer;对应上
|
||||
|
||||
// 类型映射 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>
|
||||
134
src/views/iot/device/device/detail/DeviceDetailsModel.vue
Normal file
134
src/views/iot/device/device/detail/DeviceDetailsModel.vue
Normal 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>
|
||||
331
src/views/iot/device/device/detail/DeviceDetailsSimulator.vue
Normal file
331
src/views/iot/device/device/detail/DeviceDetailsSimulator.vue
Normal 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 @super:upstream 上行、downstream 下行
|
||||
const subTab = ref('property') // TODO @super:upstreamTab
|
||||
|
||||
const loading = ref(false)
|
||||
const queryParams = reactive({
|
||||
type: undefined, // TODO @super:type 默认给个第一个 tab 对应的,避免下面 watch 爆红
|
||||
productId: -1
|
||||
})
|
||||
const list = ref<SimulatorData[]>([]) // 物模型列表的数据 TODO @super:thingModelList
|
||||
// TODO @super:dataTypeOptionsLabel 是不是不用定义,直接用 getDataTypeOptionsLabel 在 template 中使用即可?
|
||||
const dataTypeOptionsLabel = computed(() => (value: string) => getDataTypeOptionsLabel(value)) // 解析数据类型
|
||||
|
||||
/** 查询物模型列表 */
|
||||
// TODO @super:getThingModelList 更精准
|
||||
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>
|
||||
92
src/views/iot/device/device/detail/index.vue
Normal file
92
src/views/iot/device/device/detail/index.vue
Normal 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>
|
||||
526
src/views/iot/device/device/index.vue
Normal file
526
src/views/iot/device/device/index.vue
Normal 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>
|
||||
61
src/views/iot/device/deviceInfo/DeviceInfoDetail.vue
Normal file
61
src/views/iot/device/deviceInfo/DeviceInfoDetail.vue
Normal 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>
|
||||
184
src/views/iot/device/deviceInfo/DeviceInfoForm.vue
Normal file
184
src/views/iot/device/deviceInfo/DeviceInfoForm.vue
Normal 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>
|
||||
139
src/views/iot/device/deviceInfo/DeviceInfoImportForm.vue
Normal file
139
src/views/iot/device/deviceInfo/DeviceInfoImportForm.vue
Normal 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>仅允许导入 xls、xlsx 格式文件。</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>
|
||||
298
src/views/iot/device/deviceInfo/index.vue
Normal file
298
src/views/iot/device/deviceInfo/index.vue
Normal 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>
|
||||
112
src/views/iot/device/group/DeviceGroupForm.vue
Normal file
112
src/views/iot/device/group/DeviceGroupForm.vue
Normal 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>
|
||||
169
src/views/iot/device/group/index.vue
Normal file
169
src/views/iot/device/group/index.vue
Normal 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>
|
||||
223
src/views/iot/devicedata/DeviceDataForm.vue
Normal file
223
src/views/iot/devicedata/DeviceDataForm.vue
Normal 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>
|
||||
480
src/views/iot/devicedata/index.vue
Normal file
480
src/views/iot/devicedata/index.vue
Normal 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>
|
||||
509
src/views/iot/home/index.vue
Normal file
509
src/views/iot/home/index.vue
Normal 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>
|
||||
488
src/views/iot/inspection/instance/index.vue
Normal file
488
src/views/iot/inspection/instance/index.vue
Normal 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>
|
||||
583
src/views/iot/inspection/task/index.vue
Normal file
583
src/views/iot/inspection/task/index.vue
Normal 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>
|
||||
515
src/views/iot/maintain/RepairOrder.vue
Normal file
515
src/views/iot/maintain/RepairOrder.vue
Normal 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">-->
|
||||
<!-- <!– 调试信息,只在需要时启用 v-if="true" –>-->
|
||||
<!-- <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>
|
||||
189
src/views/iot/maintain/index.vue
Normal file
189
src/views/iot/maintain/index.vue
Normal 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>
|
||||
10
src/views/iot/material-inspection/index.vue
Normal file
10
src/views/iot/material-inspection/index.vue
Normal 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
262
src/views/iot/oee/index.vue
Normal 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>
|
||||
106
src/views/iot/plugin/PluginConfigForm.vue
Normal file
106
src/views/iot/plugin/PluginConfigForm.vue
Normal 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>
|
||||
99
src/views/iot/plugin/detail/PluginImportForm.vue
Normal file
99
src/views/iot/plugin/detail/PluginImportForm.vue
Normal 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>
|
||||
120
src/views/iot/plugin/detail/index.vue
Normal file
120
src/views/iot/plugin/detail/index.vue
Normal 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>
|
||||
329
src/views/iot/plugin/index.vue
Normal file
329
src/views/iot/plugin/index.vue
Normal 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>
|
||||
10
src/views/iot/process-inspection/index.vue
Normal file
10
src/views/iot/process-inspection/index.vue
Normal 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>
|
||||
10
src/views/iot/product-inspection/index.vue
Normal file
10
src/views/iot/product-inspection/index.vue
Normal 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>
|
||||
119
src/views/iot/product/category/ProductCategoryForm.vue
Normal file
119
src/views/iot/product/category/ProductCategoryForm.vue
Normal 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>
|
||||
170
src/views/iot/product/category/index.vue
Normal file
170
src/views/iot/product/category/index.vue
Normal 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>
|
||||
401
src/views/iot/product/product/ProductForm.vue
Normal file
401
src/views/iot/product/product/ProductForm.vue
Normal 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>
|
||||
110
src/views/iot/product/product/detail/ProductDetailsHeader.vue
Normal file
110
src/views/iot/product/product/detail/ProductDetailsHeader.vue
Normal 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>
|
||||
52
src/views/iot/product/product/detail/ProductDetailsInfo.vue
Normal file
52
src/views/iot/product/product/detail/ProductDetailsInfo.vue
Normal 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>
|
||||
247
src/views/iot/product/product/detail/ProductTopic.vue
Normal file
247
src/views/iot/product/product/detail/ProductTopic.vue
Normal 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: '广播 Topic,identifier 为用户自定义字符串'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// 物模型通信 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>
|
||||
82
src/views/iot/product/product/detail/index.vue
Normal file
82
src/views/iot/product/product/detail/index.vue
Normal 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>
|
||||
880
src/views/iot/product/product/index.vue
Normal file
880
src/views/iot/product/product/index.vue
Normal 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>
|
||||
207
src/views/iot/rule/databridge/IoTDataBridgeForm.vue
Normal file
207
src/views/iot/rule/databridge/IoTDataBridgeForm.vue
Normal 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>
|
||||
84
src/views/iot/rule/databridge/config/HttpConfigForm.vue
Normal file
84
src/views/iot/rule/databridge/config/HttpConfigForm.vue
Normal 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>
|
||||
45
src/views/iot/rule/databridge/config/KafkaMQConfigForm.vue
Normal file
45
src/views/iot/rule/databridge/config/KafkaMQConfigForm.vue
Normal 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>
|
||||
45
src/views/iot/rule/databridge/config/MqttConfigForm.vue
Normal file
45
src/views/iot/rule/databridge/config/MqttConfigForm.vue
Normal 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>
|
||||
63
src/views/iot/rule/databridge/config/RabbitMQConfigForm.vue
Normal file
63
src/views/iot/rule/databridge/config/RabbitMQConfigForm.vue
Normal 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>
|
||||
@@ -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>
|
||||
57
src/views/iot/rule/databridge/config/RocketMQConfigForm.vue
Normal file
57
src/views/iot/rule/databridge/config/RocketMQConfigForm.vue
Normal 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>
|
||||
@@ -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>
|
||||
15
src/views/iot/rule/databridge/config/index.ts
Normal file
15
src/views/iot/rule/databridge/config/index.ts
Normal 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
|
||||
}
|
||||
234
src/views/iot/rule/databridge/index.vue
Normal file
234
src/views/iot/rule/databridge/index.vue
Normal 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>
|
||||
1071
src/views/iot/shared/QualityForm.vue
Normal file
1071
src/views/iot/shared/QualityForm.vue
Normal file
File diff suppressed because it is too large
Load Diff
490
src/views/iot/shared/QualityInspectionList.vue
Normal file
490
src/views/iot/shared/QualityInspectionList.vue
Normal 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>
|
||||
56
src/views/iot/thingmodel/ThingModelEvent.vue
Normal file
56
src/views/iot/thingmodel/ThingModelEvent.vue
Normal 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>
|
||||
214
src/views/iot/thingmodel/ThingModelForm.vue
Normal file
214
src/views/iot/thingmodel/ThingModelForm.vue
Normal 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>
|
||||
155
src/views/iot/thingmodel/ThingModelInputOutputParam.vue
Normal file
155
src/views/iot/thingmodel/ThingModelInputOutputParam.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<!-- 产品的物模型表单(event、service 项里的参数) -->
|
||||
<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>
|
||||
169
src/views/iot/thingmodel/ThingModelProperty.vue
Normal file
169
src/views/iot/thingmodel/ThingModelProperty.vue
Normal 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>
|
||||
59
src/views/iot/thingmodel/ThingModelService.vue
Normal file
59
src/views/iot/thingmodel/ThingModelService.vue
Normal 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>
|
||||
61
src/views/iot/thingmodel/components/DataDefinition.vue
Normal file
61
src/views/iot/thingmodel/components/DataDefinition.vue
Normal 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>
|
||||
3
src/views/iot/thingmodel/components/index.ts
Normal file
3
src/views/iot/thingmodel/components/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import DataDefinition from './DataDefinition.vue'
|
||||
|
||||
export { DataDefinition }
|
||||
213
src/views/iot/thingmodel/config.ts
Normal file
213
src/views/iot/thingmodel/config.ts
Normal 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()
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<!-- dataType:array 数组类型 -->
|
||||
<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>
|
||||
159
src/views/iot/thingmodel/dataSpecs/ThingModelEnumDataSpecs.vue
Normal file
159
src/views/iot/thingmodel/dataSpecs/ThingModelEnumDataSpecs.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<!-- dataType:enum 数组类型 -->
|
||||
<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>
|
||||
139
src/views/iot/thingmodel/dataSpecs/ThingModelNumberDataSpecs.vue
Normal file
139
src/views/iot/thingmodel/dataSpecs/ThingModelNumberDataSpecs.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<!-- dataType:number 数组类型 -->
|
||||
<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>
|
||||
170
src/views/iot/thingmodel/dataSpecs/ThingModelStructDataSpecs.vue
Normal file
170
src/views/iot/thingmodel/dataSpecs/ThingModelStructDataSpecs.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<!-- dataType:struct 数组类型 -->
|
||||
<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>
|
||||
11
src/views/iot/thingmodel/dataSpecs/index.ts
Normal file
11
src/views/iot/thingmodel/dataSpecs/index.ts
Normal 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
|
||||
}
|
||||
180
src/views/iot/thingmodel/index.vue
Normal file
180
src/views/iot/thingmodel/index.vue
Normal 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>
|
||||
4
src/views/iot/utils/constants.ts
Normal file
4
src/views/iot/utils/constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** iot 依赖注入 KEY */
|
||||
export const IOT_PROVIDE_KEY = {
|
||||
PRODUCT: 'IOT_PRODUCT'
|
||||
}
|
||||
Reference in New Issue
Block a user