1208 lines
44 KiB
Vue
1208 lines
44 KiB
Vue
<template>
|
||
<div class="data-repair-page">
|
||
<!-- 密码验证遮罩 -->
|
||
<div v-if="!isAuthorized" class="auth-overlay">
|
||
<div class="auth-card">
|
||
<div class="auth-header">
|
||
<i class="el-icon-lock"></i>
|
||
<h2>数据修复工具</h2>
|
||
<p class="auth-desc">此工具仅限内部使用,请输入访问密码</p>
|
||
</div>
|
||
<el-form :model="authForm" :rules="authRules" ref="authForm" @submit.native.prevent>
|
||
<el-form-item prop="password">
|
||
<el-input
|
||
v-model="authForm.password"
|
||
type="password"
|
||
placeholder="请输入访问密码"
|
||
prefix-icon="el-icon-key"
|
||
@keyup.enter.native="handleAuth"
|
||
size="large"
|
||
></el-input>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" @click="handleAuth" style="width: 100%">验证进入</el-button>
|
||
</el-form-item>
|
||
<el-form-item style="margin-bottom: 0">
|
||
<el-button type="text" @click="goBack" style="width: 100%">返回上一页</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主内容区 -->
|
||
<div v-else class="main-content">
|
||
<el-card class="box-card">
|
||
<div slot="header" class="card-header">
|
||
<div class="header-left">
|
||
<span class="title">数据修复工具</span>
|
||
<el-tag type="danger" size="mini" effect="dark">内部工具</el-tag>
|
||
</div>
|
||
<div class="header-right">
|
||
<el-button type="text" icon="el-icon-back" @click="goBack">返回</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 筛选条件 -->
|
||
<el-form :model="queryParams" ref="queryForm" :inline="true" size="small" class="query-form">
|
||
<el-form-item label="设备">
|
||
<el-select
|
||
v-model="queryParams.deviceId"
|
||
placeholder="请选择设备"
|
||
filterable
|
||
clearable
|
||
@change="handleDeviceChange"
|
||
style="width: 220px"
|
||
>
|
||
<el-option
|
||
v-for="item in deviceList"
|
||
:key="item.deviceId"
|
||
:label="`${item.deviceName} (${item.serialNumber})`"
|
||
:value="item.deviceId"
|
||
></el-option>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="物模型">
|
||
<el-select
|
||
v-model="queryParams.identifier"
|
||
placeholder="请选择物模型"
|
||
filterable
|
||
clearable
|
||
style="width: 180px"
|
||
:disabled="!queryParams.deviceId"
|
||
>
|
||
<el-option
|
||
v-for="item in identifierList"
|
||
:key="item.identifier"
|
||
:label="item.modelName"
|
||
:value="item.identifier"
|
||
></el-option>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="时间范围">
|
||
<el-date-picker
|
||
v-model="queryParams.dateRange"
|
||
type="datetimerange"
|
||
value-format="yyyy-MM-dd HH:mm:ss"
|
||
range-separator="至"
|
||
start-placeholder="开始时间"
|
||
end-placeholder="结束时间"
|
||
:picker-options="pickerOptions"
|
||
style="width: 360px"
|
||
></el-date-picker>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" icon="el-icon-search" @click="handleSearch">查询</el-button>
|
||
<el-button icon="el-icon-refresh" @click="handleReset">重置</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<!-- 操作栏 -->
|
||
<div class="action-bar">
|
||
<div class="action-left">
|
||
<el-button type="warning" icon="el-icon-magic-stick" @click="handleAutoRepair" :loading="repairing" :disabled="tableData.length === 0">
|
||
一键修复
|
||
</el-button>
|
||
<el-button type="success" icon="el-icon-plus" @click="handleAdd" :disabled="!queryParams.deviceId || !queryParams.identifier">
|
||
添加数据
|
||
</el-button>
|
||
<!-- 自动校准配置按钮 -->
|
||
<el-button type="primary" icon="el-icon-setting" @click="handleAutoCalibrationConfig">
|
||
自动校准配置
|
||
</el-button>
|
||
</div>
|
||
<div class="action-right">
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
>
|
||
<template slot="title">
|
||
修复逻辑:当相邻三点 A、B、C 满足 <b>((A + C) / 2) - B > 30%</b> 时,修复 B = (A + C) / 2
|
||
</template>
|
||
</el-alert>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 自动校准配置弹窗 -->
|
||
<el-dialog title="自动校准配置" :visible.sync="calibrationConfigVisible" width="600px" append-to-body v-dialogDrag>
|
||
<el-alert
|
||
type="info"
|
||
:closable="false"
|
||
show-icon
|
||
style="margin-bottom: 15px"
|
||
>
|
||
<template slot="title">
|
||
配置后,系统将在设备上报数据时自动进行数据校准。校准逻辑:当相邻三点 A、B、C 满足 B 与 (A+C)/2 的差值超过 30% 时,自动修复 B = (A+C)/2
|
||
</template>
|
||
</el-alert>
|
||
<el-table
|
||
:data="calibrationConfigList"
|
||
border
|
||
style="width: 100%"
|
||
max-height="400"
|
||
>
|
||
<el-table-column label="设备" prop="deviceName" width="200">
|
||
<template slot-scope="scope">
|
||
{{ scope.row.deviceName }} ({{ scope.row.serialNumber }})
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="物模型" prop="modelName" width="150" />
|
||
<el-table-column label="标识符" prop="identifier" width="120" />
|
||
<el-table-column label="操作" width="100" align="center">
|
||
<template slot-scope="scope">
|
||
<el-button type="text" size="mini" style="color: #F56C6C" @click="removeCalibrationConfig(scope.$index)">
|
||
删除
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<div style="margin-top: 15px">
|
||
<el-form :inline="true" size="small">
|
||
<el-form-item label="设备">
|
||
<el-select
|
||
v-model="newConfig.deviceId"
|
||
placeholder="请选择设备"
|
||
filterable
|
||
clearable
|
||
@change="handleConfigDeviceChange"
|
||
style="width: 220px"
|
||
>
|
||
<el-option
|
||
v-for="item in deviceList"
|
||
:key="item.deviceId"
|
||
:label="`${item.deviceName} (${item.serialNumber})`"
|
||
:value="item.deviceId"
|
||
></el-option>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="物模型">
|
||
<el-select
|
||
v-model="newConfig.identifier"
|
||
placeholder="请选择物模型"
|
||
filterable
|
||
clearable
|
||
style="width: 180px"
|
||
:disabled="!newConfig.deviceId"
|
||
>
|
||
<el-option
|
||
v-for="item in configIdentifierList"
|
||
:key="item.identifier"
|
||
:label="item.modelName"
|
||
:value="item.identifier"
|
||
></el-option>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" @click="addCalibrationConfig" :disabled="!newConfig.deviceId || !newConfig.identifier">
|
||
添加
|
||
</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
</div>
|
||
<div slot="footer">
|
||
<el-button @click="calibrationConfigVisible = false">关 闭</el-button>
|
||
</div>
|
||
</el-dialog>
|
||
|
||
<!-- 数据表格 -->
|
||
<el-table
|
||
ref="dataTable"
|
||
v-loading="loading"
|
||
:data="tableData"
|
||
border
|
||
stripe
|
||
style="width: 100%"
|
||
@selection-change="handleSelectionChange"
|
||
:row-class-name="tableRowClassName"
|
||
max-height="500"
|
||
>
|
||
<el-table-column type="selection" width="50" align="center" />
|
||
<el-table-column label="序号" type="index" width="60" align="center" :index="indexMethod" />
|
||
<el-table-column label="记录时间" prop="createTime" width="180" align="center" sortable>
|
||
<template slot-scope="scope">
|
||
{{ formatTime(scope.row.createTime || scope.row.ts) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="物模型" prop="modelName" width="120" align="center" />
|
||
<el-table-column label="标识符" prop="identity" width="120" align="center" />
|
||
<el-table-column label="数值" min-width="160" align="center">
|
||
<template slot-scope="scope">
|
||
<template v-if="scope.row.editing">
|
||
<el-input
|
||
v-model="scope.row.tempValue"
|
||
size="mini"
|
||
style="width: 100px"
|
||
@keyup.enter.native="saveEdit(scope.row)"
|
||
@keyup.esc.native="cancelEdit(scope.row)"
|
||
ref="editInput"
|
||
></el-input>
|
||
<el-button type="text" icon="el-icon-check" @click="saveEdit(scope.row)" style="color: #67C23A; margin-left: 5px"></el-button>
|
||
<el-button type="text" icon="el-icon-close" @click="cancelEdit(scope.row)" style="color: #F56C6C"></el-button>
|
||
</template>
|
||
<template v-else>
|
||
<span
|
||
class="editable-value"
|
||
:class="{ 'need-repair': scope.row.needRepair, 'repaired': scope.row.repaired, 'missing': scope.row.isMissing }"
|
||
@dblclick="startEdit(scope.row)"
|
||
>
|
||
{{ scope.row.logValue || '缺失' }}
|
||
</span>
|
||
<el-tooltip content="双击编辑" placement="top">
|
||
<i class="el-icon-edit edit-icon" @click="startEdit(scope.row)"></i>
|
||
</el-tooltip>
|
||
</template>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="100" align="center">
|
||
<template slot-scope="scope">
|
||
<el-tag v-if="scope.row.isMissing" type="danger" size="mini" effect="plain">
|
||
<i class="el-icon-warning"></i> 缺失
|
||
</el-tag>
|
||
<el-tag v-else-if="scope.row.repaired" type="success" size="mini" effect="plain">
|
||
<i class="el-icon-check"></i> 已修复
|
||
</el-tag>
|
||
<el-tag v-else-if="scope.row.needRepair" type="warning" size="mini" effect="plain">
|
||
<i class="el-icon-warning"></i> 需修复
|
||
</el-tag>
|
||
<el-tag v-else type="info" size="mini" effect="plain">正常</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="建议值" width="120" align="center">
|
||
<template slot-scope="scope">
|
||
<span v-if="scope.row.needRepair && scope.row.suggestValue" class="suggest-value">
|
||
{{ scope.row.suggestValue }}
|
||
</span>
|
||
<span v-else>-</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="150" align="center" fixed="right">
|
||
<template slot-scope="scope">
|
||
<el-button
|
||
v-if="scope.row.needRepair && !scope.row.repaired"
|
||
type="text"
|
||
size="mini"
|
||
icon="el-icon-magic-stick"
|
||
@click="repairSingle(scope.row)"
|
||
>
|
||
修复
|
||
</el-button>
|
||
<el-button type="text" size="mini" icon="el-icon-delete" style="color: #F56C6C" @click="handleDelete(scope.row)">
|
||
删除
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<!-- 分页 -->
|
||
<pagination
|
||
v-show="total > 0"
|
||
:total="total"
|
||
:page.sync="queryParams.pageNum"
|
||
:limit.sync="queryParams.pageSize"
|
||
@pagination="fetchData"
|
||
style="margin-top: 15px"
|
||
/>
|
||
</el-card>
|
||
</div>
|
||
|
||
<!-- 添加数据弹窗 -->
|
||
<el-dialog title="添加数据" :visible.sync="addDialogVisible" width="480px" append-to-body>
|
||
<el-form :model="addForm" :rules="addRules" ref="addForm" label-width="90px">
|
||
<el-form-item label="设备">
|
||
<el-input :value="currentDeviceName" disabled></el-input>
|
||
</el-form-item>
|
||
<el-form-item label="物模型">
|
||
<el-input :value="currentModelName" disabled></el-input>
|
||
</el-form-item>
|
||
<el-form-item label="数值" prop="logValue">
|
||
<el-input v-model="addForm.logValue" placeholder="请输入数值" type="number"></el-input>
|
||
</el-form-item>
|
||
<el-form-item label="记录时间" prop="createTime">
|
||
<el-date-picker
|
||
v-model="addForm.createTime"
|
||
type="datetime"
|
||
value-format="yyyy-MM-dd HH:mm:ss"
|
||
placeholder="选择时间"
|
||
style="width: 100%"
|
||
></el-date-picker>
|
||
</el-form-item>
|
||
</el-form>
|
||
<div slot="footer">
|
||
<el-button @click="addDialogVisible = false">取 消</el-button>
|
||
<el-button type="primary" @click="submitAdd" :loading="submitting">确 定</el-button>
|
||
</div>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
import moment from 'moment';
|
||
import { listDeviceShort, listThingsModel } from '@/api/iot/device.js';
|
||
import {
|
||
getRepairDataList,
|
||
addDeviceLog,
|
||
updateDeviceLog,
|
||
batchUpdateDeviceLog,
|
||
getAutoCalibrationConfig,
|
||
addAutoCalibrationConfig,
|
||
removeAutoCalibrationConfig, deleteDeviceLogsByCreateTime
|
||
} from '@/api/data/repair.js';
|
||
|
||
// 访问密码(前端写死)
|
||
const ACCESS_PASSWORD = 'Yan@ancekongdata';
|
||
|
||
export default {
|
||
name: 'DataRepair',
|
||
data() {
|
||
return {
|
||
// 认证相关
|
||
isAuthorized: false,
|
||
authForm: { password: '' },
|
||
authRules: {
|
||
password: [{ required: true, message: '请输入访问密码', trigger: 'blur' }]
|
||
},
|
||
|
||
// 自动校准配置
|
||
calibrationConfigVisible: false,
|
||
calibrationConfigList: [],
|
||
newConfig: {
|
||
deviceId: null,
|
||
identifier: null
|
||
},
|
||
configIdentifierList: [],
|
||
|
||
// 查询参数
|
||
queryParams: {
|
||
deviceId: null,
|
||
identifier: null,
|
||
dateRange: [
|
||
moment().subtract(24, 'hours').format('YYYY-MM-DD HH:mm:ss'),
|
||
moment().format('YYYY-MM-DD HH:mm:ss')
|
||
],
|
||
pageNum: 1,
|
||
pageSize: 50
|
||
},
|
||
|
||
// 数据
|
||
deviceList: [],
|
||
identifierList: [],
|
||
tableData: [],
|
||
total: 0,
|
||
selectedRows: [],
|
||
|
||
// 状态
|
||
loading: false,
|
||
repairing: false,
|
||
submitting: false,
|
||
|
||
// 添加弹窗
|
||
addDialogVisible: false,
|
||
addForm: {
|
||
logValue: '',
|
||
createTime: ''
|
||
},
|
||
addRules: {
|
||
logValue: [{ required: true, message: '请输入数值', trigger: 'blur' }],
|
||
createTime: [{ required: true, message: '请选择时间', trigger: 'change' }]
|
||
},
|
||
|
||
// 时间选择器配置
|
||
pickerOptions: {
|
||
shortcuts: [
|
||
{
|
||
text: '最近2小时',
|
||
onClick(picker) {
|
||
const end = new Date();
|
||
const start = new Date();
|
||
start.setTime(start.getTime() - 3600 * 1000 * 2);
|
||
picker.$emit('pick', [start, end]);
|
||
}
|
||
},
|
||
{
|
||
text: '最近24小时',
|
||
onClick(picker) {
|
||
const end = new Date();
|
||
const start = new Date();
|
||
start.setTime(start.getTime() - 3600 * 1000 * 24);
|
||
picker.$emit('pick', [start, end]);
|
||
}
|
||
},
|
||
{
|
||
text: '最近3天',
|
||
onClick(picker) {
|
||
const end = new Date();
|
||
const start = new Date();
|
||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 3);
|
||
picker.$emit('pick', [start, end]);
|
||
}
|
||
},
|
||
{
|
||
text: '最近7天',
|
||
onClick(picker) {
|
||
const end = new Date();
|
||
const start = new Date();
|
||
start.setTime(start.getTime() - 3600 * 1000 * 24 * 7);
|
||
picker.$emit('pick', [start, end]);
|
||
}
|
||
}
|
||
]
|
||
}
|
||
};
|
||
},
|
||
computed: {
|
||
currentDeviceName() {
|
||
const device = this.deviceList.find(d => d.deviceId === this.queryParams.deviceId);
|
||
return device ? `${device.deviceName} (${device.serialNumber})` : '';
|
||
},
|
||
currentModelName() {
|
||
const model = this.identifierList.find(m => m.identifier === this.queryParams.identifier);
|
||
return model ? model.modelName : '';
|
||
},
|
||
currentDevice() {
|
||
return this.deviceList.find(d => d.deviceId === this.queryParams.deviceId);
|
||
},
|
||
currentModel() {
|
||
return this.identifierList.find(m => m.identifier === this.queryParams.identifier);
|
||
}
|
||
},
|
||
mounted() {
|
||
this.loadDeviceList();
|
||
},
|
||
methods: {
|
||
// 密码验证
|
||
handleAuth() {
|
||
this.$refs.authForm.validate(valid => {
|
||
if (valid) {
|
||
if (this.authForm.password === ACCESS_PASSWORD) {
|
||
this.isAuthorized = true;
|
||
this.$message.success('验证成功');
|
||
} else {
|
||
this.$message.error('密码错误,请重试');
|
||
}
|
||
}
|
||
});
|
||
},
|
||
|
||
// 返回
|
||
goBack() {
|
||
this.$router.go(-1);
|
||
},
|
||
|
||
// 打开自动校准配置
|
||
async handleAutoCalibrationConfig() {
|
||
this.calibrationConfigVisible = true;
|
||
await this.loadCalibrationConfig();
|
||
},
|
||
|
||
// 加载自动校准配置
|
||
async loadCalibrationConfig() {
|
||
try {
|
||
const res = await getAutoCalibrationConfig();
|
||
if (res.code === 200) {
|
||
this.calibrationConfigList = res.data || [];
|
||
}
|
||
} catch (e) {
|
||
console.error('加载自动校准配置失败', e);
|
||
}
|
||
},
|
||
|
||
// 配置设备变化
|
||
async handleConfigDeviceChange(deviceId) {
|
||
this.newConfig.identifier = null;
|
||
this.configIdentifierList = [];
|
||
if (!deviceId) return;
|
||
try {
|
||
const res = await listThingsModel({ deviceId, pageNum: 1, pageSize: 9999 });
|
||
if (res.code === 200) {
|
||
this.configIdentifierList = res.rows || [];
|
||
}
|
||
} catch (e) {
|
||
console.error('加载物模型失败', e);
|
||
}
|
||
},
|
||
|
||
// 添加自动校准配置
|
||
async addCalibrationConfig() {
|
||
if (!this.newConfig.deviceId || !this.newConfig.identifier) {
|
||
return this.$message.warning('请选择设备和物模型');
|
||
}
|
||
const device = this.deviceList.find(d => d.deviceId === this.newConfig.deviceId);
|
||
const model = this.configIdentifierList.find(m => m.identifier === this.newConfig.identifier);
|
||
if (!device || !model) {
|
||
return this.$message.error('设备或物模型不存在');
|
||
}
|
||
// 检查是否已存在
|
||
const exists = this.calibrationConfigList.some(
|
||
item => item.deviceId === this.newConfig.deviceId && item.identifier === this.newConfig.identifier
|
||
);
|
||
if (exists) {
|
||
return this.$message.warning('该配置已存在');
|
||
}
|
||
try {
|
||
const res = await addAutoCalibrationConfig({
|
||
deviceId: this.newConfig.deviceId,
|
||
serialNumber: device.serialNumber,
|
||
deviceName: device.deviceName,
|
||
identifier: this.newConfig.identifier,
|
||
modelName: model.modelName
|
||
});
|
||
if (res.code === 200) {
|
||
this.$message.success('添加成功');
|
||
await this.loadCalibrationConfig();
|
||
this.newConfig = {
|
||
deviceId: null,
|
||
identifier: null
|
||
};
|
||
}
|
||
} catch (e) {
|
||
this.$message.error('添加失败');
|
||
}
|
||
},
|
||
|
||
// 删除自动校准配置
|
||
async removeCalibrationConfig(index) {
|
||
const config = this.calibrationConfigList[index];
|
||
try {
|
||
await this.$confirm('确定要删除该配置吗?', '确认删除', {
|
||
type: 'warning'
|
||
});
|
||
const res = await removeAutoCalibrationConfig({
|
||
serialNumber: config.serialNumber,
|
||
identifier: config.identifier
|
||
});
|
||
if (res.code === 200) {
|
||
this.$message.success('删除成功');
|
||
await this.loadCalibrationConfig();
|
||
}
|
||
} catch (e) {
|
||
if (e !== 'cancel') {
|
||
this.$message.error('删除失败');
|
||
}
|
||
}
|
||
},
|
||
|
||
// 加载设备列表
|
||
async loadDeviceList() {
|
||
try {
|
||
const res = await listDeviceShort({ showChild: true, pageNum: 1, pageSize: 9999 });
|
||
if (res.code === 200) {
|
||
this.deviceList = res.rows || [];
|
||
}
|
||
} catch (e) {
|
||
console.error('加载设备列表失败', e);
|
||
}
|
||
},
|
||
|
||
// 设备变化
|
||
async handleDeviceChange(deviceId) {
|
||
this.queryParams.identifier = null;
|
||
this.identifierList = [];
|
||
this.tableData = [];
|
||
this.total = 0;
|
||
|
||
if (!deviceId) return;
|
||
|
||
try {
|
||
const res = await listThingsModel({ deviceId, pageNum: 1, pageSize: 9999 });
|
||
if (res.code === 200) {
|
||
// 显示所有物模型(属性type=1、功能type=2、事件type=3)
|
||
this.identifierList = res.rows || [];
|
||
}
|
||
} catch (e) {
|
||
console.error('加载物模型失败', e);
|
||
}
|
||
},
|
||
|
||
// 查询
|
||
handleSearch() {
|
||
if (!this.queryParams.deviceId) {
|
||
return this.$message.warning('请选择设备');
|
||
}
|
||
if (!this.queryParams.identifier) {
|
||
return this.$message.warning('请选择物模型');
|
||
}
|
||
if (!this.queryParams.dateRange || this.queryParams.dateRange.length !== 2) {
|
||
return this.$message.warning('请选择时间范围');
|
||
}
|
||
this.queryParams.pageNum = 1;
|
||
this.fetchData();
|
||
},
|
||
|
||
// 重置
|
||
handleReset() {
|
||
this.queryParams = {
|
||
deviceId: null,
|
||
identifier: null,
|
||
dateRange: [
|
||
moment().subtract(24, 'hours').format('YYYY-MM-DD HH:mm:ss'),
|
||
moment().format('YYYY-MM-DD HH:mm:ss')
|
||
],
|
||
pageNum: 1,
|
||
pageSize: 50
|
||
};
|
||
this.identifierList = [];
|
||
this.tableData = [];
|
||
this.total = 0;
|
||
},
|
||
|
||
// 获取数据
|
||
async fetchData() {
|
||
this.loading = true;
|
||
try {
|
||
const device = this.currentDevice;
|
||
const params = {
|
||
serialNumber: device?.serialNumber,
|
||
identity: this.queryParams.identifier,
|
||
productId: device?.productId,
|
||
beginTime: this.queryParams.dateRange[0],
|
||
endTime: this.queryParams.dateRange[1],
|
||
pageNum: this.queryParams.pageNum,
|
||
pageSize: this.queryParams.pageSize
|
||
};
|
||
|
||
const res = await getRepairDataList(params);
|
||
if (res.code === 200) {
|
||
// 处理数据,添加编辑状态
|
||
// 后端返回的是List,数据在res.data中,不是res.rows
|
||
const dataList = Array.isArray(res.data) ? res.data : (res.rows || []);
|
||
this.tableData = dataList.map(item => ({
|
||
...item,
|
||
editing: false,
|
||
tempValue: item.logValue,
|
||
needRepair: false,
|
||
repaired: false,
|
||
suggestValue: null,
|
||
isMissing: !item.logValue || item.logValue === '' || item.logValue === null
|
||
}));
|
||
// 如果后端返回了total就用total,否则用数组长度
|
||
this.total = res.total !== undefined ? res.total : dataList.length;
|
||
|
||
// 分析需要修复的数据
|
||
this.analyzeData();
|
||
}
|
||
} catch (e) {
|
||
this.$message.error('获取数据失败');
|
||
console.error(e);
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
},
|
||
|
||
// 分析数据,标记需要修复的项
|
||
analyzeData() {
|
||
const data = this.tableData;
|
||
|
||
// 先重置所有数据点的修复状态
|
||
data.forEach(item => {
|
||
item.needRepair = false;
|
||
item.suggestValue = null;
|
||
// 更新缺失状态
|
||
item.isMissing = !item.logValue || item.logValue === '' || item.logValue === null;
|
||
// 注意:不重置 repaired 状态,因为这是历史修复记录
|
||
});
|
||
|
||
for (let i = data.length - 2; i >= 1; i--) {
|
||
const A = parseFloat(data[i + 1].logValue);
|
||
const B = parseFloat(data[i].logValue);
|
||
const C = parseFloat(data[i - 1].logValue);
|
||
|
||
if (!isNaN(A) && !isNaN(B) && !isNaN(C)) {
|
||
const avg = (A + C) / 2;
|
||
// 修复条件:B与(A+C)/2的差值超过30%,无论偏大还是偏小
|
||
if (Math.abs(B - avg) > Math.abs(avg) * 0.3) {
|
||
data[i].needRepair = true;
|
||
data[i].suggestValue = avg.toFixed(3);
|
||
// 如果之前已修复但现在又需要修复,清除已修复状态,允许再次修复
|
||
if (data[i].repaired) {
|
||
data[i].repaired = false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
},
|
||
|
||
// 开始编辑
|
||
startEdit(row) {
|
||
// 取消其他行的编辑状态
|
||
this.tableData.forEach(item => {
|
||
if (item !== row) {
|
||
item.editing = false;
|
||
item.tempValue = item.logValue;
|
||
}
|
||
});
|
||
row.editing = true;
|
||
row.tempValue = row.logValue || '';
|
||
this.$nextTick(() => {
|
||
const input = this.$refs.editInput;
|
||
if (input) {
|
||
if (Array.isArray(input)) {
|
||
input[0]?.focus();
|
||
} else {
|
||
input.focus();
|
||
}
|
||
}
|
||
});
|
||
},
|
||
|
||
// 取消编辑
|
||
cancelEdit(row) {
|
||
row.editing = false;
|
||
row.tempValue = row.logValue;
|
||
},
|
||
|
||
// 保存编辑
|
||
async saveEdit(row) {
|
||
const newValue = row.tempValue;
|
||
const oldValue = row.logValue;
|
||
|
||
// 如果值没有变化,直接退出编辑
|
||
if (newValue === oldValue) {
|
||
row.editing = false;
|
||
return;
|
||
}
|
||
|
||
// 判断是新增还是更新:如果数据缺失(没有logId或logValue为空),使用新增接口
|
||
const isMissing = !row.logId || !row.logValue || row.logValue === '' || row.logValue === null;
|
||
|
||
try {
|
||
const device = this.currentDevice;
|
||
const model = this.currentModel;
|
||
|
||
if (isMissing) {
|
||
// 使用新增接口
|
||
const data = {
|
||
serialNumber: device?.serialNumber,
|
||
identity: this.queryParams.identifier,
|
||
logValue: newValue,
|
||
logType: 1,
|
||
modelName: model?.modelName,
|
||
createTime: row.createTime || row.ts || moment().format('YYYY-MM-DD HH:mm:ss')
|
||
};
|
||
|
||
const res = await addDeviceLog(data);
|
||
if (res.code === 200) {
|
||
// 更新本地数据
|
||
row.logValue = newValue;
|
||
row.logId = res.data?.logId || row.logId; // 如果有返回logId,更新它
|
||
row.editing = false;
|
||
row.isMissing = false;
|
||
row.needRepair = false;
|
||
row.repaired = true;
|
||
this.$message.success('添加成功');
|
||
// 重新分析数据
|
||
this.analyzeData();
|
||
}
|
||
} else {
|
||
// 使用更新接口
|
||
const res = await updateDeviceLog({
|
||
logId: row.logId,
|
||
logValue: newValue
|
||
});
|
||
if (res.code === 200) {
|
||
row.logValue = newValue;
|
||
row.editing = false;
|
||
row.isMissing = false;
|
||
row.needRepair = false;
|
||
row.repaired = true;
|
||
this.$message.success('修改成功');
|
||
// 重新分析数据
|
||
this.analyzeData();
|
||
}
|
||
}
|
||
} catch (e) {
|
||
this.$message.error(isMissing ? '添加失败' : '修改失败');
|
||
}
|
||
},
|
||
|
||
// 单条修复
|
||
async repairSingle(row) {
|
||
if (!row.suggestValue) return;
|
||
|
||
try {
|
||
const res = await updateDeviceLog({
|
||
logId: row.logId,
|
||
logValue: row.suggestValue
|
||
});
|
||
if (res.code === 200) {
|
||
row.logValue = row.suggestValue;
|
||
row.needRepair = false;
|
||
row.repaired = true;
|
||
this.$message.success('修复成功');
|
||
this.analyzeData();
|
||
}
|
||
} catch (e) {
|
||
this.$message.error('修复失败');
|
||
}
|
||
},
|
||
|
||
// 一键修复
|
||
async handleAutoRepair() {
|
||
const needRepairList = this.tableData.filter(item => item.needRepair && !item.repaired && item.suggestValue);
|
||
if (needRepairList.length === 0) {
|
||
return this.$message.info('没有需要修复的数据');
|
||
}
|
||
|
||
try {
|
||
await this.$confirm(`检测到 ${needRepairList.length} 条异常数据,确定要一键修复吗?`, '确认修复', {
|
||
type: 'warning',
|
||
confirmButtonText: '确定修复',
|
||
cancelButtonText: '取消'
|
||
});
|
||
|
||
this.repairing = true;
|
||
const updateList = needRepairList.map(item => ({
|
||
logId: item.logId,
|
||
logValue: item.suggestValue
|
||
}));
|
||
|
||
const res = await batchUpdateDeviceLog(updateList);
|
||
if (res.code === 200) {
|
||
// 更新本地数据
|
||
needRepairList.forEach(item => {
|
||
item.logValue = item.suggestValue;
|
||
item.needRepair = false;
|
||
item.repaired = true;
|
||
});
|
||
this.$message.success(`成功修复 ${needRepairList.length} 条数据`);
|
||
this.analyzeData();
|
||
}
|
||
} catch (e) {
|
||
if (e !== 'cancel') {
|
||
this.$message.error('修复失败');
|
||
}
|
||
} finally {
|
||
this.repairing = false;
|
||
}
|
||
},
|
||
|
||
// 添加数据
|
||
handleAdd() {
|
||
this.addForm = {
|
||
logValue: '',
|
||
createTime: moment().format('YYYY-MM-DD HH:mm:ss')
|
||
};
|
||
this.addDialogVisible = true;
|
||
},
|
||
|
||
// 提交添加
|
||
async submitAdd() {
|
||
this.$refs.addForm.validate(async valid => {
|
||
if (!valid) return;
|
||
|
||
this.submitting = true;
|
||
try {
|
||
const device = this.currentDevice;
|
||
const model = this.currentModel;
|
||
const data = {
|
||
serialNumber: device?.serialNumber,
|
||
identity: this.queryParams.identifier,
|
||
logValue: this.addForm.logValue,
|
||
logType: 1,
|
||
modelName: model?.modelName,
|
||
createTime: this.addForm.createTime
|
||
};
|
||
|
||
const res = await addDeviceLog(data);
|
||
if (res.code === 200) {
|
||
this.$message.success('添加成功');
|
||
this.addDialogVisible = false;
|
||
this.fetchData();
|
||
}
|
||
} catch (e) {
|
||
this.$message.error('添加失败');
|
||
} finally {
|
||
this.submitting = false;
|
||
}
|
||
});
|
||
},
|
||
|
||
// 删除单条
|
||
handleDelete(row) {
|
||
const deviceName = this.currentDeviceName || row.serialNumber || '未知设备';
|
||
const timeStr = this.formatTime(row.createTime || row.ts);
|
||
const message = `确定要删除设备 <strong>${deviceName}</strong> 在时间点 <strong>${timeStr}</strong> 的所有数据吗?<br/><br/>该时间点上的所有数据将被全部删除,删除后不可恢复!`;
|
||
|
||
this.$confirm(message, '确认删除', {
|
||
type: 'warning',
|
||
confirmButtonClass: 'el-button--danger',
|
||
dangerouslyUseHTMLString: true
|
||
}).then(async () => {
|
||
try {
|
||
const res = await deleteDeviceLogsByCreateTime(row.serialNumber, row.createTime);
|
||
if (res.code === 200) {
|
||
this.$message.success('删除成功');
|
||
this.fetchData();
|
||
}
|
||
} catch (e) {
|
||
this.$message.error('删除失败');
|
||
}
|
||
}).catch(() => {});
|
||
},
|
||
|
||
// 选择变化
|
||
handleSelectionChange(selection) {
|
||
this.selectedRows = selection;
|
||
},
|
||
|
||
// 表格行样式
|
||
tableRowClassName({ row }) {
|
||
if (row.isMissing) return 'missing-row';
|
||
if (row.repaired) return 'repaired-row';
|
||
if (row.needRepair) return 'warning-row';
|
||
return '';
|
||
},
|
||
|
||
// 序号
|
||
indexMethod(index) {
|
||
return (this.queryParams.pageNum - 1) * this.queryParams.pageSize + index + 1;
|
||
},
|
||
|
||
// 格式化时间
|
||
formatTime(time) {
|
||
if (!time) return '-';
|
||
return moment(time).format('YYYY-MM-DD HH:mm:ss');
|
||
}
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.data-repair-page {
|
||
min-height: 100vh;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
|
||
// 认证遮罩
|
||
.auth-overlay {
|
||
min-height: 100vh;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 20px;
|
||
|
||
.auth-card {
|
||
background: #fff;
|
||
border-radius: 12px;
|
||
padding: 40px;
|
||
width: 100%;
|
||
max-width: 400px;
|
||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||
|
||
.auth-header {
|
||
text-align: center;
|
||
margin-bottom: 30px;
|
||
|
||
.el-icon-lock {
|
||
font-size: 48px;
|
||
color: #667eea;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
h2 {
|
||
margin: 0 0 10px;
|
||
color: #333;
|
||
font-size: 24px;
|
||
}
|
||
|
||
.auth-desc {
|
||
margin: 0;
|
||
color: #999;
|
||
font-size: 14px;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 主内容
|
||
.main-content {
|
||
padding: 20px;
|
||
min-height: 100vh;
|
||
background: #f5f7fa;
|
||
|
||
.box-card {
|
||
.card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
|
||
.header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
|
||
.title {
|
||
font-size: 18px;
|
||
font-weight: bold;
|
||
margin-right: 10px;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
.query-form {
|
||
margin-bottom: 15px;
|
||
padding-bottom: 15px;
|
||
border-bottom: 1px solid #eee;
|
||
}
|
||
|
||
.action-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 15px;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
|
||
.action-left {
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
|
||
.action-right {
|
||
flex: 1;
|
||
min-width: 300px;
|
||
max-width: 560px;
|
||
}
|
||
}
|
||
|
||
// 可编辑值样式
|
||
.editable-value {
|
||
cursor: pointer;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
transition: all 0.3s;
|
||
|
||
&:hover {
|
||
background: #f0f0f0;
|
||
}
|
||
|
||
&.need-repair {
|
||
color: #E6A23C;
|
||
font-weight: bold;
|
||
}
|
||
|
||
&.repaired {
|
||
color: #67C23A;
|
||
}
|
||
|
||
&.missing {
|
||
color: #F56C6C;
|
||
font-weight: bold;
|
||
}
|
||
}
|
||
|
||
.edit-icon {
|
||
margin-left: 5px;
|
||
color: #409EFF;
|
||
cursor: pointer;
|
||
opacity: 0;
|
||
transition: opacity 0.3s;
|
||
}
|
||
|
||
.el-table__row:hover .edit-icon {
|
||
opacity: 1;
|
||
}
|
||
|
||
.suggest-value {
|
||
color: #67C23A;
|
||
font-weight: bold;
|
||
}
|
||
|
||
// 表格行样式
|
||
::v-deep .warning-row {
|
||
background: #fdf6ec !important;
|
||
}
|
||
|
||
::v-deep .repaired-row {
|
||
background: #f0f9eb !important;
|
||
}
|
||
|
||
::v-deep .missing-row {
|
||
background: #fef0f0 !important;
|
||
}
|
||
|
||
// 弹窗拖动样式
|
||
::v-deep .el-dialog__header {
|
||
cursor: move;
|
||
user-select: none;
|
||
}
|
||
|
||
::v-deep .el-dialog__headerbtn {
|
||
cursor: pointer;
|
||
}
|
||
|
||
// 动画效果
|
||
@keyframes borderBlink {
|
||
0%, 100% {
|
||
border-color: #F56C6C;
|
||
box-shadow: 0 0 20px rgba(245, 108, 108, 0.5), 0 0 40px rgba(245, 108, 108, 0.3);
|
||
}
|
||
50% {
|
||
border-color: #FF7875;
|
||
box-shadow: 0 0 30px rgba(245, 108, 108, 0.8), 0 0 60px rgba(245, 108, 108, 0.5);
|
||
}
|
||
}
|
||
|
||
@keyframes bannerShine {
|
||
0%, 100% {
|
||
background: linear-gradient(90deg, #F56C6C, #FF7875, #F56C6C);
|
||
}
|
||
50% {
|
||
background: linear-gradient(90deg, #FF7875, #F56C6C, #FF7875);
|
||
}
|
||
}
|
||
|
||
@keyframes rotate {
|
||
from {
|
||
transform: rotate(0deg);
|
||
}
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
@keyframes iconPulse {
|
||
0%, 100% {
|
||
transform: scale(1);
|
||
}
|
||
50% {
|
||
transform: scale(1.1);
|
||
}
|
||
}
|
||
|
||
@keyframes ripple {
|
||
0% {
|
||
transform: scale(1);
|
||
opacity: 1;
|
||
}
|
||
100% {
|
||
transform: scale(1.3);
|
||
opacity: 0;
|
||
}
|
||
}
|
||
|
||
@keyframes iconShake {
|
||
0%, 100% {
|
||
transform: rotate(0deg);
|
||
}
|
||
25% {
|
||
transform: rotate(-5deg);
|
||
}
|
||
75% {
|
||
transform: rotate(5deg);
|
||
}
|
||
}
|
||
|
||
@keyframes dangerPulse {
|
||
0%, 100% {
|
||
border-color: #FEF0F0;
|
||
box-shadow: 0 0 0 rgba(245, 108, 108, 0);
|
||
}
|
||
50% {
|
||
border-color: #FDE2E2;
|
||
box-shadow: 0 0 10px rgba(245, 108, 108, 0.3);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</style>
|
||
|