develop-cloud #2

Merged
dashixiong merged 6 commits from develop-cloud into main-cloud 2026-04-01 06:32:13 +00:00
66 changed files with 12997 additions and 3013 deletions
Showing only changes of commit f88e9bedc2 - Show all commits

View File

@ -8,6 +8,14 @@ export function getDzjkHomeView(siteId) {
})
}
//获取单个站点总累计运行数据(基于日表)
export function getDzjkHomeTotalView(siteId) {
return request({
url: `/ems/siteMonitor/homeTotalView?siteId=${siteId}`,
method: 'get'
})
}
// 单站监控项目点位配置(供单站监控功能查询)
export function getProjectPointMapping(siteId) {
return request({
@ -738,88 +746,25 @@ export function getLoadNameList(siteId) {
// 电表报表
export function getAmmeterData({siteId, startTime, endTime, pageSize, pageNum}) {
const kind = 'day'
const aliasMap = {
activePeakKwh: 'activePeakKwh',
activeHighKwh: 'activeHighKwh',
activeFlatKwh: 'activeFlatKwh',
activeValleyKwh: 'activeValleyKwh',
activeTotalKwh: 'activeTotalKwh',
reActivePeakKwh: 'reActivePeakKwh',
reActiveHighKwh: 'reActiveHighKwh',
reActiveFlatKwh: 'reActiveFlatKwh',
reActiveValleyKwh: 'reActiveValleyKwh',
reActiveTotalKwh: 'reActiveTotalKwh',
effect: 'effect',
}
return queryMenuPointCurves({
siteId,
menuCode: 'TJBB_DBBB',
startDate: startTime,
endDate: endTime,
}).then((records) => {
const rowMap = new Map()
records.forEach((record) => {
const alias = aliasMap[record.fieldName]
if (!alias) return
;(record.curve || []).forEach((point) => {
const time = point?.dataTime ? new Date(point.dataTime) : null
if (!time || isNaN(time.getTime())) return
const label = formatTimeLabelByKind(time, kind)
if (!rowMap.has(label)) rowMap.set(label, {dataTime: label})
rowMap.get(label)[alias] = normalizePointValue(point?.pointValue)
})
})
const labels = buildSortedLabels(new Set(Array.from(rowMap.keys())), kind)
const fullRows = labels.map(label => rowMap.get(label))
return {
rows: paginateRows(fullRows, pageNum, pageSize),
total: fullRows.length,
return request({
url: `/ems/statsReport/getAmmeterDataFromDaily`,
method: 'get',
params: {
siteId,
startTime,
endTime,
pageSize,
pageNum,
}
})
}
// 电价报表
export function getAmmeterRevenueData(data) {
const {siteId, startTime, endTime, pageNum, pageSize} = data || {}
const kind = 'day'
const aliasMap = {
activePeakPrice: 'activePeakPrice',
activeHighPrice: 'activeHighPrice',
activeFlatPrice: 'activeFlatPrice',
activeValleyPrice: 'activeValleyPrice',
activeTotalPrice: 'activeTotalPrice',
reActivePeakPrice: 'reActivePeakPrice',
reActiveHighPrice: 'reActiveHighPrice',
reActiveFlatPrice: 'reActiveFlatPrice',
reActiveValleyPrice: 'reActiveValleyPrice',
reActiveTotalPrice: 'reActiveTotalPrice',
actualRevenue: 'actualRevenue',
}
return queryMenuPointCurves({
siteId,
menuCode: 'TJBB_SYBB',
startDate: startTime,
endDate: endTime,
}).then((records) => {
const rowMap = new Map()
records.forEach((record) => {
const alias = aliasMap[record.fieldName]
if (!alias) return
;(record.curve || []).forEach((point) => {
const time = point?.dataTime ? new Date(point.dataTime) : null
if (!time || isNaN(time.getTime())) return
const label = formatTimeLabelByKind(time, kind)
if (!rowMap.has(label)) rowMap.set(label, {dataTime: label})
rowMap.get(label)[alias] = normalizePointValue(point?.pointValue)
})
})
const labels = buildSortedLabels(new Set(Array.from(rowMap.keys())), kind)
const fullRows = labels.map(label => rowMap.get(label))
return {
rows: paginateRows(fullRows, pageNum, pageSize),
total: fullRows.length,
}
return request({
url: `/ems/statsReport/getAmmeterRevenueData`,
method: 'get',
params: data
})
}

View File

@ -8,6 +8,15 @@ export function getSiteInfoList({siteName, startTime, endTime, pageSize, pageNum
})
}
// 手动同步站点天气(收益报表)
export function syncSiteWeatherByDateRange({siteId, startTime, endTime}) {
return request({
url: `/ems/statsReport/syncWeatherByDateRange`,
method: 'post',
params: {siteId, startTime, endTime}
})
}
// 新增站点
export function addSite(data) {
return request({
@ -309,6 +318,18 @@ export function getPointConfigCurve(data) {
})
}
// 点位配置-生成最近7天数据
export function generatePointConfigRecent7Days(data) {
return request({
url: `/ems/pointConfig/generateRecent7Days`,
method: 'post',
data,
headers: {
repeatSubmit: false
}
})
}
// 计算点配置列表
export function getPointCalcConfigList(params) {
return request({
@ -352,6 +373,92 @@ export function deletePointCalcConfig(ids) {
})
}
// 数据修正列表ems_daily_energy_data
export function getDailyEnergyDataList(params) {
return request({
url: `/ems/dailyEnergyData/list`,
method: 'get',
params
})
}
// 数据修正详情
export function getDailyEnergyDataDetail(id) {
return request({
url: `/ems/dailyEnergyData/${id}`,
method: 'get',
})
}
// 新增数据修正
export function addDailyEnergyData(data) {
return request({
url: `/ems/dailyEnergyData`,
method: 'post',
data
})
}
// 编辑数据修正
export function updateDailyEnergyData(data) {
return request({
url: `/ems/dailyEnergyData`,
method: 'put',
data
})
}
// 删除数据修正
export function deleteDailyEnergyData(ids) {
return request({
url: `/ems/dailyEnergyData/${ids}`,
method: 'delete',
})
}
// 充放电修正列表ems_daily_charge_data
export function getDailyChargeDataList(params) {
return request({
url: `/ems/dailyChargeData/list`,
method: 'get',
params
})
}
// 充放电修正详情
export function getDailyChargeDataDetail(id) {
return request({
url: `/ems/dailyChargeData/${id}`,
method: 'get',
})
}
// 新增充放电修正
export function addDailyChargeData(data) {
return request({
url: `/ems/dailyChargeData`,
method: 'post',
data
})
}
// 编辑充放电修正
export function updateDailyChargeData(data) {
return request({
url: `/ems/dailyChargeData`,
method: 'put',
data
})
}
// 删除充放电修正
export function deleteDailyChargeData(ids) {
return request({
url: `/ems/dailyChargeData/${ids}`,
method: 'delete',
})
}
//mqtt
export function getMqttList({pageSize, pageNum, mqttTopic, topicName, siteId}) {
return request({

View File

@ -17,13 +17,13 @@
color:#666666;
text-align: left;
.single-square-box-title{
font-size: 12px;
line-height: 12px;
padding-bottom: 12px;
font-size: 10px;
line-height: 10px;
padding-bottom: 8px;
}
.single-square-box-value{
font-size: 26px;
line-height: 26px;
font-size: 18px;
line-height: 18px;
font-weight: 500;
}
.point-loading-icon{
@ -33,7 +33,7 @@
animation: pointLoadingSpinPulse 1.1s linear infinite;
}
::v-deep .el-card__body{
padding: 12px 10px;
padding: 8px 7px;
}
}
@keyframes pointLoadingSpinPulse {

View File

@ -42,7 +42,7 @@
class="sjgl-col power-col"
>
<div class="sjgl-wrapper">
<div class="sjgl-title">装机功率(MW)</div>
<div class="sjgl-title">装机功率(MWh)</div>
<div class="sjgl-value">
<i v-if="isBaseInfoLoading" class="el-icon-loading"></i>
<span v-else>{{ info.installPower | formatNumber }}</span>
@ -54,7 +54,7 @@
class="sjgl-col power-col"
>
<div class="sjgl-wrapper">
<div class="sjgl-title">装机容量(MW)</div>
<div class="sjgl-title">装机容量(MWh)</div>
<div class="sjgl-value">
<i v-if="isBaseInfoLoading" class="el-icon-loading"></i>
<span v-else>{{ info.installCapacity | formatNumber }}</span>
@ -151,7 +151,7 @@
<script>
import * as echarts from "echarts";
import {getSingleSiteBaseInfo} from "@/api/ems/zddt";
import {getDzjkHomeView, getProjectDisplayData} from "@/api/ems/dzjk";
import {getDzjkHomeTotalView, getProjectDisplayData} from "@/api/ems/dzjk";
import {getPointConfigCurve} from "@/api/ems/site";
import WeekChart from "./WeekChart.vue";
import ActiveChart from "./ActiveChart.vue";
@ -482,7 +482,7 @@ export default {
this.setRunningInfoLoading(true);
}
return Promise.all([
getDzjkHomeView(this.siteId),
getDzjkHomeTotalView(this.siteId),
getProjectDisplayData(this.siteId),
]).then(([homeResponse, displayResponse]) => {
const nextRunningInfo = homeResponse?.data || {};

View File

@ -149,7 +149,7 @@ export default {
exportTable() {
if (!this.dateRange?.length) return
const [startTime, endTime] = this.dateRange
this.download('ems/statsReport/exportAmmeterData', {
this.download('ems/statsReport/exportAmmeterDataFromDaily', {
siteId: this.siteId,
startTime,
endTime,

View File

@ -39,6 +39,17 @@
label="日期"
min-width="100px" align="center">
</el-table-column>
<el-table-column
prop="dayType"
label="日期类型"
min-width="100px" align="center">
</el-table-column>
<el-table-column
prop="weatherDesc"
label="天气情况"
min-width="180px"
align="center">
</el-table-column>
</el-table-column>
<!--充电量列-->
<el-table-column label="充电价格" align="center">
@ -187,8 +198,9 @@ export default {
const {siteId, pageNum, pageSize} = this
const [startTime = '', endTime = ''] = (this.dateRange || [])
getAmmeterRevenueData({siteId: siteId, startTime, endTime, pageSize, pageNum}).then(response => {
this.tableData = response?.rows || [];
const rows = response?.rows || [];
this.totalSize = response?.total || 0
this.tableData = rows
}).finally(() => {
this.loading = false
})

View File

@ -71,6 +71,7 @@
<el-form-item>
<el-button type="primary" @click="submitForm">生成图表</el-button>
<el-button style="margin-left: 8px" @click="handleExportData">导出数据</el-button>
</el-form-item>
</el-form>
<el-card
@ -102,7 +103,6 @@ import * as echarts from "echarts";
import resize from "@/mixins/ems/resize";
import {getPointValueList, pointFuzzyQuery} from "@/api/ems/search";
import DateTimeSelect from "./DateTimeSelect.vue";
import {debounce} from "@/utils";
export default {
name: "Search",
@ -132,8 +132,10 @@ export default {
queryGroups: [],
dataUnit: 1,
},
debouncedPointSearchMap: {},
queryGroupSeed: 0,
lastQueryResult: [],
sitePointOptionsCache: {},
sitePointRequestId: 0,
};
},
created() {
@ -143,6 +145,103 @@ export default {
}
},
methods: {
getChartColor(index = 0) {
const palette = [
"#5470C6",
"#91CC75",
"#FAC858",
"#EE6666",
"#73C0DE",
"#3BA272",
"#FC8452",
"#9A60B4",
"#EA7CCC",
];
return palette[index % palette.length];
},
getSelectedPointAxisKeys() {
const keys = [];
const seen = new Set();
(this.form.queryGroups || []).forEach((group) => {
const key = String(this.resolveSelectedPointName(group) || group?.pointId || "").trim();
if (!key || seen.has(key)) return;
seen.add(key);
keys.push(key);
});
return keys;
},
buildDynamicYAxisConfig(data = []) {
const axisIndexMap = {};
const yAxis = [];
const orderedAxisKeys = [];
const seenAxisKeys = new Set();
const pushAxisKey = (key) => {
const normalized = String(key || "").trim();
if (!normalized || seenAxisKeys.has(normalized)) return;
seenAxisKeys.add(normalized);
orderedAxisKeys.push(normalized);
};
this.getSelectedPointAxisKeys().forEach((key) => pushAxisKey(key));
data.forEach((item) => {
const pointId = String(item?.pointId || "").trim();
const pointName = String(item?.pointName || "").trim();
pushAxisKey(pointName || pointId);
(item?.deviceList || []).forEach((inner) => {
pushAxisKey(inner?.deviceId);
});
});
orderedAxisKeys.forEach((pointKey) => {
const axisIndex = yAxis.length;
const sideIndex = Math.floor(axisIndex / 2);
const position = axisIndex % 2 === 0 ? "left" : "right";
const color = this.getChartColor(axisIndex);
axisIndexMap[pointKey] = axisIndex;
yAxis.push({
type: "value",
name: pointKey || `点位${axisIndex + 1}`,
position,
offset: sideIndex * 55,
alignTicks: true,
axisLine: {
show: true,
lineStyle: {color},
},
axisLabel: {
color,
},
nameTextStyle: {
color,
},
splitLine: {
show: axisIndex === 0,
},
});
});
if (yAxis.length === 0) {
yAxis.push({type: "value"});
}
const axisCount = yAxis.length;
const leftCount = Math.ceil(axisCount / 2);
const rightCount = Math.floor(axisCount / 2);
return {
yAxis,
axisIndexMap,
grid: {
containLabel: true,
left: 40 + Math.max(0, leftCount - 1) * 55,
right: 40 + Math.max(0, rightCount - 1) * 55,
},
};
},
createEmptyQueryGroup(key) {
return {
key,
@ -164,7 +263,6 @@ export default {
return;
}
this.form.queryGroups.splice(groupIndex, 1);
this.debouncedPointSearchMap = {};
},
getQueryGroup(index) {
return this.form.queryGroups[index];
@ -209,11 +307,25 @@ export default {
},
setLineOption(data) {
let dataset = [];
const axisConfig = this.buildDynamicYAxisConfig(data);
data.forEach((item) => {
const pointId = String(item?.pointId || "").trim();
const pointName = String(item?.pointName || "").trim();
const pointKey = pointId || pointName;
item.deviceList.forEach((inner) => {
const seriesKey = String(inner?.deviceId || "").trim() || pointKey;
const yAxisIndex = axisConfig.axisIndexMap[seriesKey] ?? 0;
const axisColor = this.getChartColor(yAxisIndex);
dataset.push({
name: inner.deviceId,
type: "line",
yAxisIndex,
itemStyle: {
color: axisColor,
},
lineStyle: {
color: axisColor,
},
markPoint: {
symbolSize: 30,
emphasis: {
@ -263,9 +375,7 @@ export default {
this.chart.setOption({
legend: {},
grid: {
containLabel: true,
},
grid: axisConfig.grid,
tooltip: {
trigger: "axis",
axisPointer: {
@ -276,9 +386,7 @@ export default {
color: "#333333",
},
xAxis: {type: "category", data: dataset?.[0]?.xdata || []},
yAxis: {
type: "value",
},
yAxis: axisConfig.yAxis,
dataZoom: [
{
type: "inside",
@ -295,11 +403,22 @@ export default {
},
setBoxOption(data) {
let dataset = [];
const axisConfig = this.buildDynamicYAxisConfig(data);
data.forEach((item) => {
const pointId = String(item?.pointId || "").trim();
const pointName = String(item?.pointName || "").trim();
const pointKey = pointId || pointName;
item.deviceList.forEach((inner) => {
const seriesKey = String(inner?.deviceId || "").trim() || pointKey;
const yAxisIndex = axisConfig.axisIndexMap[seriesKey] ?? 0;
const axisColor = this.getChartColor(yAxisIndex);
dataset.push({
name: inner.deviceId,
type: "boxplot",
yAxisIndex,
itemStyle: {
color: axisColor,
},
xdata: [],
data: [],
});
@ -314,9 +433,7 @@ export default {
this.chart.setOption({
legend: {},
grid: {
containLabel: true,
},
grid: axisConfig.grid,
tooltip: {
trigger: "item",
formatter: function (params) {
@ -332,9 +449,7 @@ export default {
color: "#333333",
},
xAxis: {type: "category", data: dataset?.[0]?.xdata || []},
yAxis: {
type: "value",
},
yAxis: axisConfig.yAxis,
dataZoom: [
{
type: "inside",
@ -352,9 +467,218 @@ export default {
submitForm() {
this.getDate();
},
buildQueryPayload() {
const activeGroups = this.form.queryGroups
.map((group) => ({group}))
.filter(({group}) => !!group.pointId);
if (activeGroups.length === 0) {
this.$message.error("请至少选择1组点位");
return null;
}
if (!this.form.siteIds || this.form.siteIds.length === 0) {
this.$message.error("请先在顶部选择站点");
return null;
}
const {
siteIds,
dataUnit,
dataRange: [start = "", end = ""],
} = this.form;
if (!start || !end) {
this.$message.error("请选择时间");
return null;
}
let startDate = start;
let endDate = end;
if (start && dataUnit === 3) {
startDate = `${start} 00:00:00`;
}
if (end && dataUnit === 3) {
endDate = `${end} 00:00:00`;
}
const selectedPoints = [];
const pointIdSet = new Set();
activeGroups.forEach(({group}) => {
const pointId = String(group.pointId || "").trim();
if (!pointId || pointIdSet.has(pointId)) return;
pointIdSet.add(pointId);
selectedPoints.push({
pointId,
pointName: this.resolveSelectedPointName(group) || pointId,
});
});
if (selectedPoints.length === 0) {
this.$message.error("请至少选择1组点位");
return null;
}
const pointIds = selectedPoints.map((item) => item.pointId);
const pointNames = selectedPoints.map((item) => item.pointName);
return {
siteIds,
dataUnit,
pointIds,
pointNames,
pointId: pointIds.join(","),
startDate,
endDate,
};
},
requestPointData(payload) {
return getPointValueList(payload).then((response) => response?.data || []);
},
resolveExportPointValue(value = {}) {
if (value?.pointValue !== undefined && value?.pointValue !== null && value?.pointValue !== "") {
return value.pointValue;
}
if (value?.median !== undefined && value?.median !== null && value?.median !== "") {
return value.median;
}
return "";
},
normalizeExportRows(data = [], payload = {}) {
const pointIds = payload?.pointIds || [];
const pointNames = payload?.pointNames || [];
const selectedPoints = pointIds
.map((pointId, index) => {
const normalizedId = String(pointId || "").trim();
if (!normalizedId) return null;
return {
pointId: normalizedId,
pointName: String(pointNames[index] || normalizedId).trim(),
pointOrder: index,
};
})
.filter((item) => !!item);
const selectedPointById = {};
const selectedPointByName = {};
selectedPoints.forEach((point) => {
selectedPointById[point.pointId] = point;
if (point.pointName) {
selectedPointByName[point.pointName] = point;
}
});
const groups = [];
const groupMap = {};
const rowMap = {};
const rowDates = [];
data.forEach((item, itemIndex) => {
const itemPointId = String(item?.pointId || "").trim();
const itemPointName = String(item?.pointName || "").trim();
const pointByItemId = itemPointId ? selectedPointById[itemPointId] : null;
const pointByItemName = itemPointName ? selectedPointByName[itemPointName] : null;
(item?.deviceList || []).forEach((device) => {
const deviceId = String(device?.deviceId || "").trim();
const pointByDeviceId = deviceId ? selectedPointById[deviceId] : null;
const pointByDeviceName = deviceId ? selectedPointByName[deviceId] : null;
const pointByOrder = selectedPoints[itemIndex] || null;
const matchedPoint = pointByItemId || pointByItemName || pointByDeviceId || pointByDeviceName || pointByOrder || null;
const pointId = itemPointId || matchedPoint?.pointId || "";
const pointName = itemPointName || matchedPoint?.pointName || pointId;
const pointOrder = matchedPoint?.pointOrder ?? Number.MAX_SAFE_INTEGER;
const pointKey = pointId || pointName || `point_${itemIndex + 1}`;
const deviceKey = deviceId || "unknown";
const groupKey = `${pointKey}__${deviceKey}`;
if (!groupMap[groupKey]) {
groupMap[groupKey] = true;
groups.push({
groupKey,
pointId,
pointName,
deviceId,
pointOrder,
});
}
(device?.pointValueList || []).forEach((value) => {
const valueDate = String(value?.valueDate || "").trim();
if (!valueDate) return;
if (!rowMap[valueDate]) {
rowMap[valueDate] = {valueDate};
rowDates.push(valueDate);
}
rowMap[valueDate][groupKey] = this.resolveExportPointValue(value);
});
});
});
groups.sort((a, b) => {
const ao = a.pointOrder === -1 ? Number.MAX_SAFE_INTEGER : a.pointOrder;
const bo = b.pointOrder === -1 ? Number.MAX_SAFE_INTEGER : b.pointOrder;
if (ao !== bo) return ao - bo;
if (a.pointId !== b.pointId) return a.pointId.localeCompare(b.pointId);
return a.deviceId.localeCompare(b.deviceId);
});
return {
groups,
rows: rowDates.sort().map((valueDate) => rowMap[valueDate]),
};
},
csvCell(value) {
const text = value === undefined || value === null ? "" : String(value);
return `"${text.replace(/"/g, '""')}"`;
},
exportRowsToCsv({groups = [], rows = []} = {}) {
const headers = ["时间"];
groups.forEach((_, index) => {
const order = index + 1;
headers.push(`点位ID${order}`, `点位名称${order}`, `设备ID${order}`, `点位值${order}`);
});
const lines = [headers.map((item) => this.csvCell(item)).join(",")];
rows.forEach((row) => {
const line = [row.valueDate || ""];
groups.forEach((group) => {
line.push(group.pointId || "", group.pointName || "", group.deviceId || "", row[group.groupKey] ?? "");
});
lines.push(line.map((item) => this.csvCell(item)).join(","));
});
const csv = `\uFEFF${lines.join("\n")}`;
this.$download.saveAs(new Blob([csv], {type: "text/csv;charset=utf-8;"}), `综合查询数据_${new Date().getTime()}.csv`);
},
handleExportData() {
this.$refs.form.validate((valid) => {
if (!valid) return;
const payload = this.buildQueryPayload();
if (!payload) return;
this.requestPointData(payload).then((data) => {
if (!data || data.length === 0) {
this.$message.warning("暂无可导出数据");
return;
}
const exportData = this.normalizeExportRows(data, payload);
if (!exportData?.rows?.length || !exportData?.groups?.length) {
this.$message.warning("暂无可导出数据");
return;
}
this.exportRowsToCsv(exportData);
this.$message.success("导出成功");
}).catch((error) => {
if (error?.code === "ECONNABORTED") {
this.$message.error("查询超时请缩短时间范围后重试");
return;
}
this.$message.error("导出失败请稍后重试");
});
});
},
getPointCacheKey(query = "") {
return `${this.form.siteIds.join(",")}_${query.trim()}`;
},
getSitePointCacheKey() {
return this.form.siteIds.join(",");
},
formatPointLabel({pointId = "", pointName = "", dataKey = ""} = {}) {
return `${pointId || "-"}-${pointName || "-"}(${dataKey || "-"})`;
},
@ -378,79 +702,93 @@ export default {
};
}).filter((item) => item.value);
},
fuzzyFilterPointOptions(options = [], query = "") {
const keyword = String(query || "").trim().toLowerCase();
if (!keyword) return options;
return (options || []).filter((item) => {
const marker = `${item?.pointId || ""} ${item?.pointName || ""} ${item?.dataKey || ""} ${item?.pointDesc || ""}`.toLowerCase();
return marker.includes(keyword);
});
},
setPointOptions(group, data = []) {
const normalized = this.normalizePointOptions(data);
const selected = group.pointId
? [{
value: group.pointId,
label: this.formatPointLabel({pointId: group.pointId}),
pointId: group.pointId,
pointName: "",
dataKey: "",
pointDesc: "",
}]
: [];
const merged = [...normalized, ...group.pointOptions, ...selected];
? (group.pointOptions || []).find((item) => item?.value === group.pointId)
|| {
value: group.pointId,
label: this.formatPointLabel({pointId: group.pointId}),
pointId: group.pointId,
pointName: group.selectedPointName || "",
dataKey: "",
pointDesc: "",
}
: null;
const nextOptions = selected ? [...normalized, selected] : normalized;
const seen = {};
group.pointOptions = merged.filter((item) => {
if (!item?.value || seen[item.value]) {
return false;
}
group.pointOptions = nextOptions.filter((item) => {
if (!item?.value || seen[item.value]) return false;
seen[item.value] = true;
return true;
});
},
fetchPointOptions(groupIndex, query = "", {force = false} = {}) {
const group = this.getQueryGroup(groupIndex);
if (!group || !this.canSelectPoint(group)) return Promise.resolve([]);
const normalizedQuery = (query || "").trim();
const cacheKey = this.getPointCacheKey(normalizedQuery);
if (!force && group.pointOptionsCache[cacheKey]) {
this.setPointOptions(group, group.pointOptionsCache[cacheKey]);
return Promise.resolve(group.pointOptionsCache[cacheKey]);
applyPointOptionsToGroups(pointOptions = []) {
(this.form.queryGroups || []).forEach((group) => {
if (!group) return;
const baseCacheKey = this.getPointCacheKey("");
group.pointOptionsCache[baseCacheKey] = pointOptions;
this.setPointOptions(group, pointOptions);
});
},
loadSitePointOptions({force = false} = {}) {
const siteCacheKey = this.getSitePointCacheKey();
if (!siteCacheKey) {
return Promise.resolve([]);
}
const requestId = ++group.pointRequestId;
group.pointLoading = true;
if (!force && this.sitePointOptionsCache[siteCacheKey]) {
const cached = this.sitePointOptionsCache[siteCacheKey];
this.applyPointOptionsToGroups(cached);
return Promise.resolve(cached);
}
const requestId = ++this.sitePointRequestId;
(this.form.queryGroups || []).forEach((group) => {
group.pointLoading = true;
});
return pointFuzzyQuery({
siteIds: this.form.siteIds,
pointName: normalizedQuery,
pointName: "",
})
.then((response) => {
if (requestId !== group.pointRequestId) return [];
if (requestId !== this.sitePointRequestId) return [];
const data = this.normalizePointOptions(response?.data || []);
group.pointOptionsCache[cacheKey] = data;
this.setPointOptions(group, data);
this.sitePointOptionsCache[siteCacheKey] = data;
this.applyPointOptionsToGroups(data);
return data;
})
.finally(() => {
if (requestId === group.pointRequestId) {
if (requestId !== this.sitePointRequestId) return;
(this.form.queryGroups || []).forEach((group) => {
group.pointLoading = false;
}
});
});
},
getDebouncedPointSearch(groupIndex) {
if (!this.debouncedPointSearchMap[groupIndex]) {
this.debouncedPointSearchMap[groupIndex] = debounce((query) => {
this.fetchPointOptions(groupIndex, query);
}, 260);
}
return this.debouncedPointSearchMap[groupIndex];
},
remotePointSearch(groupIndex, query) {
const group = this.getQueryGroup(groupIndex);
if (!group || !this.canSelectPoint(group)) return;
this.getDebouncedPointSearch(groupIndex)(query);
const baseCacheKey = this.getPointCacheKey("");
const baseOptions = group.pointOptionsCache?.[baseCacheKey] || group.pointOptions || [];
const localFiltered = this.fuzzyFilterPointOptions(baseOptions, query);
this.setPointOptions(group, localFiltered);
},
handlePointDropdownVisible(groupIndex, visible) {
const group = this.getQueryGroup(groupIndex);
if (visible && group && this.canSelectPoint(group)) {
this.fetchPointOptions(groupIndex, "");
this.loadSitePointOptions();
}
},
refreshPointOptions(groupIndex) {
const group = this.getQueryGroup(groupIndex);
if (!group || !this.canSelectPoint(group)) return;
this.fetchPointOptions(groupIndex, "", {force: true});
this.loadSitePointOptions({force: true});
},
clearPointSelection(groupIndex) {
const group = this.getQueryGroup(groupIndex);
@ -480,66 +818,31 @@ export default {
syncQuerySiteIds(routeSiteId) {
const siteId = routeSiteId || this.$route?.query?.siteId;
const normalizedSiteId = siteId === undefined || siteId === null ? "" : String(siteId).trim();
const prevSiteIds = (this.form.siteIds || []).join(",");
this.form.siteIds = normalizedSiteId ? [normalizedSiteId] : [];
const nextSiteIds = (this.form.siteIds || []).join(",");
if (prevSiteIds !== nextSiteIds) {
(this.form.queryGroups || []).forEach((group) => {
group.pointOptions = [];
group.pointOptionsCache = {};
group.pointLoading = false;
});
}
if (nextSiteIds) {
this.loadSitePointOptions();
}
},
getDate() {
this.$refs.form.validate((valid) => {
if (!valid) {
return;
}
const payload = this.buildQueryPayload();
if (!payload) return;
const activeGroups = this.form.queryGroups
.map((group) => ({group}))
.filter(({group}) => !!group.pointId);
if (activeGroups.length === 0) {
return this.$message.error("请至少选择1组点位");
}
if (!this.form.siteIds || this.form.siteIds.length === 0) {
return this.$message.error("请先在顶部选择站点");
}
const {
siteIds,
dataUnit,
dataRange: [start = "", end = ""],
} = this.form;
if (!start || !end) return this.$message.error("请选择时间");
let startDate = start;
let endDate = end;
if (start && dataUnit === 3) {
startDate = start + " 00:00:00";
}
if (end && dataUnit === 3) {
endDate = end + " 00:00:00";
}
const selectedPoints = [];
const pointIdSet = new Set();
activeGroups.forEach(({group}) => {
const pointId = String(group.pointId || "").trim();
if (!pointId || pointIdSet.has(pointId)) return;
pointIdSet.add(pointId);
selectedPoints.push({
pointId,
pointName: this.resolveSelectedPointName(group) || pointId,
});
});
const pointIds = selectedPoints.map((item) => item.pointId);
const pointNames = selectedPoints.map((item) => item.pointName);
getPointValueList({
siteIds,
dataUnit,
pointIds,
pointNames,
pointId: pointIds.join(","),
startDate,
endDate,
}).then((response) => {
this.setOption(response?.data || []);
this.requestPointData(payload).then((data) => {
this.lastQueryResult = data || [];
this.setOption(this.lastQueryResult);
}).catch((error) => {
if (error?.code === "ECONNABORTED") {
this.$message.error("查询超时请缩短时间范围后重试");

View File

@ -0,0 +1,183 @@
<template>
<el-dialog
:visible.sync="dialogVisible"
:close-on-press-escape="false"
:close-on-click-modal="false"
:show-close="false"
destroy-on-close
lock-scroll
append-to-body
width="760px"
class="ems-dialog"
:title="mode === 'add' ? '新增充放电收益修正' : '编辑充放电收益修正'"
>
<el-form
ref="formRef"
v-loading="loading"
:model="formData"
:rules="rules"
label-width="120px"
size="medium"
class="form-grid"
>
<el-form-item label="站点" prop="siteId">
<el-input v-model="formData.siteId" placeholder="请先在顶部选择站点" disabled />
</el-form-item>
<el-form-item label="数据日期" prop="dateTime">
<el-date-picker
v-model="formData.dateTime"
type="date"
value-format="yyyy-MM-dd"
placeholder="请选择数据日期"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="总充电量" prop="totalChargeData">
<el-input-number v-model="formData.totalChargeData" :controls="false" :min="-999999999" :max="999999999" :step="0.001" :precision="3" style="width: 100%" />
</el-form-item>
<el-form-item label="总放电量" prop="totalDischargeData">
<el-input-number v-model="formData.totalDischargeData" :controls="false" :min="-999999999" :max="999999999" :step="0.001" :precision="3" style="width: 100%" />
</el-form-item>
<el-form-item label="当日充电量" prop="chargeData">
<el-input-number v-model="formData.chargeData" :controls="false" :min="-999999999" :max="999999999" :step="0.001" :precision="3" style="width: 100%" />
</el-form-item>
<el-form-item label="当日放电量" prop="dischargeData">
<el-input-number v-model="formData.dischargeData" :controls="false" :min="-999999999" :max="999999999" :step="0.001" :precision="3" style="width: 100%" />
</el-form-item>
<el-form-item label="累计收益" prop="totalRevenue">
<el-input-number v-model="formData.totalRevenue" :controls="false" :min="-999999999" :max="999999999" :step="0.0001" :precision="4" style="width: 100%" />
</el-form-item>
<el-form-item label="当日收益" prop="dayRevenue">
<el-input-number v-model="formData.dayRevenue" :controls="false" :min="-999999999" :max="999999999" :step="0.0001" :precision="4" style="width: 100%" />
</el-form-item>
<el-form-item label="备注" prop="remark" class="full-row">
<el-input v-model="formData.remark" type="textarea" :rows="3" maxlength="300" show-word-limit />
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="closeDialog">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveDialog">确定</el-button>
</div>
</el-dialog>
</template>
<script>
import {
addDailyChargeData,
getDailyChargeDataDetail,
updateDailyChargeData,
} from '@/api/ems/site'
const buildEmptyForm = () => ({
id: '',
siteId: '',
dateTime: '',
totalChargeData: null,
totalDischargeData: null,
chargeData: null,
dischargeData: null,
totalRevenue: null,
dayRevenue: null,
remark: '',
})
export default {
name: 'AddChargeDataCorrection',
data() {
return {
dialogVisible: false,
mode: 'add',
loading: false,
saving: false,
formData: buildEmptyForm(),
rules: {
siteId: [{ required: true, message: '请先在顶部选择站点', trigger: 'blur' }],
dateTime: [{ required: true, message: '请选择数据日期', trigger: 'change' }],
},
}
},
methods: {
getRouteSiteId() {
const siteId = this.$route?.query?.siteId
return siteId === undefined || siteId === null ? '' : String(siteId).trim()
},
showDialog(id, siteId = '') {
this.dialogVisible = true
if (id) {
this.mode = 'edit'
this.formData.id = id
this.fetchDetail(id)
} else {
this.mode = 'add'
this.formData = buildEmptyForm()
this.formData.siteId = siteId || this.getRouteSiteId()
}
},
fetchDetail(id) {
this.loading = true
getDailyChargeDataDetail(id)
.then((res) => {
this.formData = Object.assign(buildEmptyForm(), res?.data || {})
})
.finally(() => {
this.loading = false
})
},
saveDialog() {
this.$refs.formRef.validate((valid) => {
if (!valid) return
this.saving = true
const request = this.mode === 'add' ? addDailyChargeData : updateDailyChargeData
const payload = {
id: this.formData.id,
siteId: this.formData.siteId,
dateTime: this.formData.dateTime,
totalChargeData: this.formData.totalChargeData,
totalDischargeData: this.formData.totalDischargeData,
chargeData: this.formData.chargeData,
dischargeData: this.formData.dischargeData,
totalRevenue: this.formData.totalRevenue,
dayRevenue: this.formData.dayRevenue,
remark: this.formData.remark,
}
request(payload)
.then((res) => {
if (res?.code === 200) {
this.$emit('update')
this.closeDialog()
}
})
.finally(() => {
this.saving = false
})
})
},
closeDialog() {
this.dialogVisible = false
this.formData = buildEmptyForm()
this.$nextTick(() => {
this.$refs.formRef && this.$refs.formRef.resetFields()
})
},
},
}
</script>
<style lang="scss" scoped>
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 16px;
.full-row {
grid-column: 1 / span 2;
}
}
</style>

View File

@ -0,0 +1,218 @@
<template>
<el-dialog
:visible.sync="dialogVisible"
:close-on-press-escape="false"
:close-on-click-modal="false"
:show-close="false"
destroy-on-close
lock-scroll
append-to-body
width="760px"
class="ems-dialog"
:title="mode === 'add' ? '新增数据修正' : '编辑数据修正'"
>
<el-form
ref="formRef"
v-loading="loading"
:model="formData"
:rules="rules"
label-width="120px"
size="medium"
class="form-grid"
>
<el-form-item label="站点" prop="siteId">
<el-input v-model="formData.siteId" placeholder="请先在顶部选择站点" disabled />
</el-form-item>
<el-form-item label="数据日期" prop="dataDate">
<el-date-picker
v-model="formData.dataDate"
type="date"
value-format="yyyy-MM-dd"
placeholder="请选择数据日期"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="数据小时" prop="dataHour">
<el-input-number
v-model="formData.dataHour"
:controls="false"
:min="0"
:max="23"
:step="1"
:precision="0"
placeholder="0-23"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="尖充电差值" prop="peakChargeDiff">
<el-input-number v-model="formData.peakChargeDiff" :controls="false" :min="-999999999" :max="999999999" :step="0.001" :precision="3" style="width: 100%" />
</el-form-item>
<el-form-item label="尖放电差值" prop="peakDischargeDiff">
<el-input-number v-model="formData.peakDischargeDiff" :controls="false" :min="-999999999" :max="999999999" :step="0.001" :precision="3" style="width: 100%" />
</el-form-item>
<el-form-item label="峰充电差值" prop="highChargeDiff">
<el-input-number v-model="formData.highChargeDiff" :controls="false" :min="-999999999" :max="999999999" :step="0.001" :precision="3" style="width: 100%" />
</el-form-item>
<el-form-item label="峰放电差值" prop="highDischargeDiff">
<el-input-number v-model="formData.highDischargeDiff" :controls="false" :min="-999999999" :max="999999999" :step="0.001" :precision="3" style="width: 100%" />
</el-form-item>
<el-form-item label="平充电差值" prop="flatChargeDiff">
<el-input-number v-model="formData.flatChargeDiff" :controls="false" :min="-999999999" :max="999999999" :step="0.001" :precision="3" style="width: 100%" />
</el-form-item>
<el-form-item label="平放电差值" prop="flatDischargeDiff">
<el-input-number v-model="formData.flatDischargeDiff" :controls="false" :min="-999999999" :max="999999999" :step="0.001" :precision="3" style="width: 100%" />
</el-form-item>
<el-form-item label="谷充电差值" prop="valleyChargeDiff">
<el-input-number v-model="formData.valleyChargeDiff" :controls="false" :min="-999999999" :max="999999999" :step="0.001" :precision="3" style="width: 100%" />
</el-form-item>
<el-form-item label="谷放电差值" prop="valleyDischargeDiff">
<el-input-number v-model="formData.valleyDischargeDiff" :controls="false" :min="-999999999" :max="999999999" :step="0.001" :precision="3" style="width: 100%" />
</el-form-item>
<el-form-item label="计算时间" prop="calcTime" class="full-row">
<el-date-picker
v-model="formData.calcTime"
type="datetime"
value-format="yyyy-MM-dd HH:mm:ss"
placeholder="请选择计算时间"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="备注" prop="remark" class="full-row">
<el-input v-model="formData.remark" type="textarea" :rows="3" maxlength="300" show-word-limit />
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="closeDialog">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveDialog">确定</el-button>
</div>
</el-dialog>
</template>
<script>
import {
addDailyEnergyData,
getDailyEnergyDataDetail,
updateDailyEnergyData,
} from '@/api/ems/site'
const buildEmptyForm = () => ({
id: '',
siteId: '',
dataDate: '',
dataHour: null,
peakChargeDiff: null,
peakDischargeDiff: null,
highChargeDiff: null,
highDischargeDiff: null,
flatChargeDiff: null,
flatDischargeDiff: null,
valleyChargeDiff: null,
valleyDischargeDiff: null,
calcTime: '',
remark: '',
})
export default {
name: 'AddDataCorrection',
data() {
return {
dialogVisible: false,
mode: 'add',
loading: false,
saving: false,
formData: buildEmptyForm(),
rules: {
siteId: [{ required: true, message: '请先在顶部选择站点', trigger: 'blur' }],
dataDate: [{ required: true, message: '请选择数据日期', trigger: 'change' }],
dataHour: [
{
validator: (rule, value, callback) => {
if (value === '' || value === null || value === undefined) {
callback()
return
}
if (Number.isInteger(value) && value >= 0 && value <= 23) {
callback()
return
}
callback(new Error('数据小时需为 0-23 的整数'))
},
trigger: 'change',
},
],
},
}
},
methods: {
getRouteSiteId() {
const siteId = this.$route?.query?.siteId
return siteId === undefined || siteId === null ? '' : String(siteId).trim()
},
showDialog(id, siteId = '') {
this.dialogVisible = true
if (id) {
this.mode = 'edit'
this.formData.id = id
this.fetchDetail(id)
} else {
this.mode = 'add'
this.formData = buildEmptyForm()
this.formData.siteId = siteId || this.getRouteSiteId()
}
},
fetchDetail(id) {
this.loading = true
getDailyEnergyDataDetail(id)
.then((res) => {
this.formData = Object.assign(buildEmptyForm(), res?.data || {})
})
.finally(() => {
this.loading = false
})
},
saveDialog() {
this.$refs.formRef.validate((valid) => {
if (!valid) return
this.saving = true
const request = this.mode === 'add' ? addDailyEnergyData : updateDailyEnergyData
request(this.formData)
.then((res) => {
if (res?.code === 200) {
this.$emit('update')
this.closeDialog()
}
})
.finally(() => {
this.saving = false
})
})
},
closeDialog() {
this.dialogVisible = false
this.formData = buildEmptyForm()
this.$nextTick(() => {
this.$refs.formRef && this.$refs.formRef.resetFields()
})
},
},
}
</script>
<style lang="scss" scoped>
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 16px;
.full-row {
grid-column: 1 / span 2;
}
}
</style>

View File

@ -0,0 +1,187 @@
<template>
<div style="background-color: #ffffff" v-loading="loading">
<el-form :inline="true" class="select-container" @submit.native.prevent>
<el-form-item label="数据日期">
<el-date-picker
v-model="form.dateTime"
type="date"
value-format="yyyy-MM-dd"
clearable
placeholder="请选择日期"
style="width: 180px"
/>
</el-form-item>
<el-form-item>
<el-button native-type="button" type="primary" @click="onSearch">搜索</el-button>
<el-button native-type="button" @click="onReset">重置</el-button>
</el-form-item>
</el-form>
<el-button type="primary" @click="openDialog('')">新增</el-button>
<el-table
:data="tableData"
class="common-table"
max-height="620px"
stripe
style="width: 100%; margin-top: 20px"
>
<el-table-column label="站点" prop="siteId" min-width="120" />
<el-table-column label="数据日期" prop="dateTime" width="120" />
<el-table-column label="总充电量" prop="totalChargeData" min-width="110" />
<el-table-column label="总放电量" prop="totalDischargeData" min-width="110" />
<el-table-column label="当日充电量" prop="chargeData" min-width="110" />
<el-table-column label="当日放电量" prop="dischargeData" min-width="110" />
<el-table-column label="累计收益" prop="totalRevenue" min-width="110" />
<el-table-column label="当日收益" prop="dayRevenue" min-width="110" />
<el-table-column label="备注" prop="remark" min-width="180" show-overflow-tooltip />
<el-table-column fixed="right" label="操作" width="150">
<template slot-scope="scope">
<el-button size="mini" type="warning" @click="openDialog(scope.row.id)">编辑</el-button>
<el-button size="mini" type="danger" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-show="tableData.length > 0"
background
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="pageNum"
:page-size="pageSize"
:page-sizes="[10, 20, 30, 40]"
layout="total, sizes, prev, pager, next, jumper"
:total="totalSize"
style="margin-top: 15px; text-align: center"
/>
<add-charge-data-correction ref="addChargeDataCorrection" @update="getData" />
</div>
</template>
<script>
import {
deleteDailyChargeData,
getDailyChargeDataList,
} from '@/api/ems/site'
import AddChargeDataCorrection from './AddChargeDataCorrection.vue'
export default {
name: 'DailyChargeDataTab',
components: { AddChargeDataCorrection },
data() {
return {
loading: false,
form: {
siteId: '',
dateTime: '',
},
tableData: [],
pageSize: 10,
pageNum: 1,
totalSize: 0,
}
},
watch: {
'$route.query.siteId'(newSiteId) {
const normalizedSiteId = this.normalizeSiteId(newSiteId)
if (normalizedSiteId === this.form.siteId) {
return
}
this.form.siteId = normalizedSiteId
this.onSearch()
},
},
mounted() {
this.form.siteId = this.normalizeSiteId(this.$route.query.siteId)
this.getData()
},
methods: {
normalizeSiteId(siteId) {
return siteId === undefined || siteId === null ? '' : String(siteId).trim()
},
handleSizeChange(val) {
this.pageSize = val
this.pageNum = 1
this.getData()
},
handleCurrentChange(val) {
this.pageNum = val
this.getData()
},
onSearch() {
this.pageNum = 1
this.getData()
},
onReset() {
this.form = {
siteId: this.form.siteId,
dateTime: '',
}
this.pageNum = 1
this.getData()
},
getData() {
if (!this.form.siteId) {
this.tableData = []
this.totalSize = 0
return
}
this.loading = true
const params = {
pageNum: this.pageNum,
pageSize: this.pageSize,
siteId: this.form.siteId,
}
if (this.form.dateTime) {
params.dateTime = this.form.dateTime
}
getDailyChargeDataList(params)
.then((res) => {
this.tableData = res?.rows || []
this.totalSize = res?.total || 0
})
.finally(() => {
this.loading = false
})
},
openDialog(id) {
this.$refs.addChargeDataCorrection.showDialog(id, this.form.siteId)
},
handleDelete(row) {
this.$confirm('确认要删除该条数据吗?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
showClose: false,
closeOnClickModal: false,
type: 'warning',
beforeClose: (action, instance, done) => {
if (action === 'confirm') {
instance.confirmButtonLoading = true
deleteDailyChargeData(row.id)
.then((res) => {
if (res?.code === 200) {
done()
}
})
.finally(() => {
instance.confirmButtonLoading = false
})
} else {
done()
}
},
})
.then(() => {
this.$message.success('删除成功!')
this.getData()
})
.catch(() => {})
},
},
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,191 @@
<template>
<div style="background-color: #ffffff" v-loading="loading">
<el-form :inline="true" class="select-container" @submit.native.prevent>
<el-form-item label="数据日期">
<el-date-picker
v-model="form.dataDate"
type="date"
value-format="yyyy-MM-dd"
clearable
placeholder="请选择日期"
style="width: 180px"
/>
</el-form-item>
<el-form-item>
<el-button native-type="button" type="primary" @click="onSearch">搜索</el-button>
<el-button native-type="button" @click="onReset">重置</el-button>
</el-form-item>
</el-form>
<el-button type="primary" @click="openDialog('')">新增</el-button>
<el-table
:data="tableData"
class="common-table"
max-height="620px"
stripe
style="width: 100%; margin-top: 20px"
>
<el-table-column label="站点" prop="siteId" width="130" />
<el-table-column label="数据日期" prop="dataDate" width="120" />
<el-table-column label="data_hour" prop="dataHour" width="100" />
<el-table-column label="尖充" prop="peakChargeDiff" min-width="90" />
<el-table-column label="尖放" prop="peakDischargeDiff" min-width="90" />
<el-table-column label="峰充" prop="highChargeDiff" min-width="90" />
<el-table-column label="峰放" prop="highDischargeDiff" min-width="90" />
<el-table-column label="平充" prop="flatChargeDiff" min-width="90" />
<el-table-column label="平放" prop="flatDischargeDiff" min-width="90" />
<el-table-column label="谷充" prop="valleyChargeDiff" min-width="90" />
<el-table-column label="谷放" prop="valleyDischargeDiff" min-width="90" />
<el-table-column label="计算时间" prop="calcTime" min-width="170" />
<el-table-column label="备注" prop="remark" min-width="180" show-overflow-tooltip />
<el-table-column fixed="right" label="操作" width="150">
<template slot-scope="scope">
<el-button size="mini" type="warning" @click="openDialog(scope.row.id)">编辑</el-button>
<el-button size="mini" type="danger" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-show="tableData.length > 0"
background
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="pageNum"
:page-size="pageSize"
:page-sizes="[10, 20, 30, 40]"
layout="total, sizes, prev, pager, next, jumper"
:total="totalSize"
style="margin-top: 15px; text-align: center"
/>
<add-data-correction ref="addDataCorrection" @update="getData" />
</div>
</template>
<script>
import {
deleteDailyEnergyData,
getDailyEnergyDataList,
} from '@/api/ems/site'
import AddDataCorrection from './AddDataCorrection.vue'
export default {
name: 'DailyEnergyDataTab',
components: { AddDataCorrection },
data() {
return {
loading: false,
form: {
siteId: '',
dataDate: '',
},
tableData: [],
pageSize: 10,
pageNum: 1,
totalSize: 0,
}
},
watch: {
'$route.query.siteId'(newSiteId) {
const normalizedSiteId = this.normalizeSiteId(newSiteId)
if (normalizedSiteId === this.form.siteId) {
return
}
this.form.siteId = normalizedSiteId
this.onSearch()
},
},
mounted() {
this.form.siteId = this.normalizeSiteId(this.$route.query.siteId)
this.getData()
},
methods: {
normalizeSiteId(siteId) {
return siteId === undefined || siteId === null ? '' : String(siteId).trim()
},
handleSizeChange(val) {
this.pageSize = val
this.pageNum = 1
this.getData()
},
handleCurrentChange(val) {
this.pageNum = val
this.getData()
},
onSearch() {
this.pageNum = 1
this.getData()
},
onReset() {
this.form = {
siteId: this.form.siteId,
dataDate: '',
}
this.pageNum = 1
this.getData()
},
getData() {
if (!this.form.siteId) {
this.tableData = []
this.totalSize = 0
return
}
this.loading = true
const params = {
pageNum: this.pageNum,
pageSize: this.pageSize,
siteId: this.form.siteId,
}
if (this.form.dataDate) {
params.dataDate = this.form.dataDate
}
getDailyEnergyDataList(params)
.then((res) => {
this.tableData = res?.rows || []
this.totalSize = res?.total || 0
})
.finally(() => {
this.loading = false
})
},
openDialog(id) {
this.$refs.addDataCorrection.showDialog(id, this.form.siteId)
},
handleDelete(row) {
this.$confirm('确认要删除该条数据吗?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
showClose: false,
closeOnClickModal: false,
type: 'warning',
beforeClose: (action, instance, done) => {
if (action === 'confirm') {
instance.confirmButtonLoading = true
deleteDailyEnergyData(row.id)
.then((res) => {
if (res?.code === 200) {
done()
}
})
.finally(() => {
instance.confirmButtonLoading = false
})
} else {
done()
}
},
})
.then(() => {
this.$message.success('删除成功!')
this.getData()
})
.catch(() => {})
},
},
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,30 @@
<template>
<div class="ems-dashboard-editor-container" style="background-color: #ffffff">
<el-tabs v-model="activeTab">
<el-tab-pane label="充放电量修正" name="energy" lazy>
<daily-energy-data-tab />
</el-tab-pane>
<el-tab-pane label="充放电收益修正" name="charge" lazy>
<daily-charge-data-tab />
</el-tab-pane>
</el-tabs>
</div>
</template>
<script>
import DailyChargeDataTab from './DailyChargeDataTab.vue'
import DailyEnergyDataTab from './DailyEnergyDataTab.vue'
export default {
name: 'DataCorrection',
components: { DailyEnergyDataTab, DailyChargeDataTab },
data() {
return {
activeTab: 'energy',
}
},
}
</script>
<style scoped>
</style>

View File

@ -49,7 +49,7 @@
新增{{ activePointTab === 'calc' ? '计算点' : '数据点' }}
</el-button>
<el-button type="warning" :disabled="!hasValidSiteId(queryParams.siteId)" @click="openImportPointDialog">导入点位</el-button>
<el-checkbox v-model="overwrite" style="margin-left: 12px;">覆盖已存在点位数据</el-checkbox>
<el-button :disabled="!tableData.length" @click="handleExport">导出点位</el-button>
<input
ref="csvInput"
type="file"
@ -203,7 +203,7 @@
placeholder="例如voltageA * currentA + powerLoss"
/>
<div class="calc-expression-tips">
示例A + B * 2(A + B) / CvoltageA * currentA + powerLoss
示例A + B * 2IF(A > B, A, B)DAY_DIFF(POINT4092)MONTH_DIFF(POINT4092)HOUR_DIFF(POINT4092)
</div>
</el-form-item>
</el-col>
@ -271,6 +271,13 @@
</el-row>
</el-form>
<div slot="footer">
<el-button
v-if="form.pointType === 'calc'"
:loading="generateRecentLoading"
@click="handleGenerateRecent7Days"
>
生成最近7天数据
</el-button>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</div>
@ -343,7 +350,8 @@ import {
deletePointMatch,
getDeviceListBySiteAndCategory,
getPointConfigLatestValues,
getPointConfigCurve
getPointConfigCurve,
generatePointConfigRecent7Days
} from '@/api/ems/site'
export default {
@ -354,7 +362,6 @@ export default {
deviceCategoryList: [],
tableData: [],
total: 0,
overwrite: false,
activePointTab: 'data',
queryParams: {
pageNum: 1,
@ -371,6 +378,7 @@ export default {
curveDialogVisible: false,
curveDialogTitle: '曲线',
curveLoading: false,
generateRecentLoading: false,
curveChart: null,
curveCustomRange: [],
curveQuery: {
@ -523,80 +531,29 @@ export default {
return
}
const points = this.tableData
.filter(item => item.pointType === 'data' && item.siteId && item.deviceId && item.dataKey)
.filter(item => item.siteId && item.pointId)
.map(item => ({
siteId: item.siteId,
deviceId: item.deviceId,
dataKey: item.dataKey
pointId: item.pointId
}))
if (!points.length) {
const calcRows = this.tableData.filter(item => item.pointType !== 'data')
if (!calcRows.length) {
this.tableData = this.applyCalcLatestValues(this.tableData)
return
}
this.loadCalcDependencyRows(calcRows).then(depRows => {
if (!depRows.length) {
this.tableData = this.applyCalcLatestValues(this.tableData)
return
}
const depPoints = depRows.map(item => ({
siteId: item.siteId,
deviceId: item.deviceId,
dataKey: item.dataKey
}))
return getPointConfigLatestValues({ points: depPoints }).then(response => {
const latestList = response?.data || []
const latestMap = latestList.reduce((acc, item) => {
const key = `${item.siteId || ''}__${item.deviceId || ''}__${item.dataKey || ''}`
acc[key] = item.pointValue
return acc
}, {})
const depRowsWithLatest = depRows.map(row => {
const key = `${row.siteId || ''}__${row.deviceId || ''}__${row.dataKey || ''}`
const latestValue = latestMap[key]
return {
...row,
latestValue: (latestValue === null || latestValue === undefined || latestValue === '') ? '-' : latestValue
}
})
const mergedRows = this.applyCalcLatestValues([...depRowsWithLatest, ...this.tableData])
const calcLatestMap = mergedRows
.filter(item => item.pointType !== 'data')
.reduce((acc, item) => {
acc[this.getCalcRowKey(item)] = item.latestValue
return acc
}, {})
this.tableData = this.tableData.map(row => {
if (row.pointType === 'data') return row
const nextLatest = calcLatestMap[this.getCalcRowKey(row)]
return {
...row,
latestValue: (nextLatest === null || nextLatest === undefined || nextLatest === '') ? '-' : nextLatest
}
})
})
}).catch(() => {
this.tableData = this.applyCalcLatestValues(this.tableData)
})
return
}
getPointConfigLatestValues({ points }).then(response => {
const latestList = response?.data || []
const latestMap = latestList.reduce((acc, item) => {
const key = `${item.siteId || ''}__${item.deviceId || ''}__${item.dataKey || ''}`
const key = `${item.siteId || ''}__${item.pointId || ''}`
acc[key] = item.pointValue
return acc
}, {})
const withDataLatestValue = this.tableData.map(row => {
const key = `${row.siteId || ''}__${row.deviceId || ''}__${row.dataKey || ''}`
this.tableData = this.tableData.map(row => {
const key = `${row.siteId || ''}__${row.pointId || ''}`
const latestValue = latestMap[key]
return {
...row,
latestValue: (latestValue === null || latestValue === undefined || latestValue === '') ? '-' : latestValue
}
})
this.tableData = this.applyCalcLatestValues(withDataLatestValue)
}).catch(() => {})
},
getCalcRowKey(row) {
@ -607,8 +564,8 @@ export default {
if (!expr) {
return []
}
if (!/^[0-9A-Za-z_+\-*/().\s]+$/.test(expr)) {
throw new Error('计算表达式仅支持四则运算和括号')
if (!/^[0-9A-Za-z_+\-*/().,?:<>=!&|\s]+$/.test(expr)) {
throw new Error('计算表达式仅支持数字、字母、下划线、空格、运算符和函数语法')
}
const tokens = []
let index = 0
@ -657,7 +614,20 @@ export default {
index += 1
continue
}
if (['+', '-', '*', '/'].includes(ch)) {
if (ch === ',' || ch === '?' || ch === ':') {
tokens.push({ type: ch, value: ch })
index += 1
continue
}
if (index + 1 < expr.length) {
const twoChars = expr.slice(index, index + 2)
if (['&&', '||', '>=', '<=', '==', '!='].includes(twoChars)) {
tokens.push({ type: 'operator', value: twoChars })
index += 2
continue
}
}
if (['+', '-', '*', '/', '>', '<', '!'].includes(ch)) {
tokens.push({ type: 'operator', value: ch })
index += 1
continue
@ -668,7 +638,7 @@ export default {
return tokens
},
extractExpressionTokens(expression) {
const reserved = new Set(['IF'])
const reserved = new Set(['IF', 'DAY_DIFF', 'MONTH_DIFF', 'HOUR_DIFF'])
try {
const tokens = this.tokenizeCalcExpression(expression)
const identifiers = tokens
@ -874,6 +844,24 @@ export default {
? evaluateNode(node.trueNode)
: evaluateNode(node.falseNode)
}
if (node.type === 'function') {
const fnName = String(node.name || '').toUpperCase()
if (fnName === 'DAY_DIFF') {
if (!Array.isArray(node.args) || node.args.length !== 1) {
throw new Error('DAY_DIFF函数参数数量错误需1个参数')
}
// 单参数模式依赖后端历史基线,前端仅做语法校验
throw new Error('DAY_DIFF单参数仅支持后端计算')
}
if (fnName === 'MONTH_DIFF' || fnName === 'HOUR_DIFF') {
if (!Array.isArray(node.args) || node.args.length !== 1) {
throw new Error(`${fnName}函数参数数量错误需1个参数`)
}
// 单参数模式依赖后端历史基线,前端仅做语法校验
throw new Error(`${fnName}单参数仅支持后端计算`)
}
throw new Error(`不支持的函数: ${node.name}`)
}
throw new Error(`不支持的节点类型: ${node.type}`)
}
@ -1009,21 +997,85 @@ export default {
if (matchType('identifier')) {
const identifier = String(token.value || '')
if (matchType('(')) {
if (identifier.toUpperCase() !== 'IF') {
throw new Error(`不支持的函数: ${identifier}`)
const functionName = identifier.toUpperCase()
if (functionName === 'IF') {
const condition = parseExpression()
expectType(',', 'IF函数缺少第1个逗号')
const trueValue = parseExpression()
expectType(',', 'IF函数缺少第2个逗号')
const falseValue = parseExpression()
expectType(')', 'IF函数缺少右括号')
return {
type: 'ternary',
condition,
trueNode: trueValue,
falseNode: falseValue
}
}
const condition = parseExpression()
expectType(',', 'IF函数缺少第1个逗号')
const trueValue = parseExpression()
expectType(',', 'IF函数缺少第2个逗号')
const falseValue = parseExpression()
expectType(')', 'IF函数缺少右括号')
return {
type: 'ternary',
condition,
trueNode: trueValue,
falseNode: falseValue
if (functionName === 'DAY_DIFF') {
const args = []
if (!matchType(')')) {
while (true) {
args.push(parseExpression())
if (matchType(',')) {
continue
}
expectType(')', 'DAY_DIFF函数缺少右括号')
break
}
}
if (args.length !== 1) {
throw new Error('DAY_DIFF函数参数数量错误需1个参数')
}
return {
type: 'function',
name: functionName,
args
}
}
if (functionName === 'MONTH_DIFF') {
const args = []
if (!matchType(')')) {
while (true) {
args.push(parseExpression())
if (matchType(',')) {
continue
}
expectType(')', 'MONTH_DIFF函数缺少右括号')
break
}
}
if (args.length !== 1) {
throw new Error('MONTH_DIFF函数参数数量错误需1个参数')
}
return {
type: 'function',
name: functionName,
args
}
}
if (functionName === 'HOUR_DIFF') {
const args = []
if (!matchType(')')) {
while (true) {
args.push(parseExpression())
if (matchType(',')) {
continue
}
expectType(')', 'HOUR_DIFF函数缺少右括号')
break
}
}
if (args.length !== 1) {
throw new Error('HOUR_DIFF函数参数数量错误需1个参数')
}
return {
type: 'function',
name: functionName,
args
}
}
throw new Error(`不支持的函数: ${identifier}`)
}
return { type: 'variable', name: identifier }
}
@ -1115,7 +1167,6 @@ export default {
const formData = new FormData()
formData.append('file', file)
formData.append('siteId', siteId)
formData.append('overwrite', String(!!this.overwrite))
this.loading = true
return importPointConfigCsv(formData)
}).then(response => {
@ -1127,6 +1178,72 @@ export default {
this.loading = false
})
},
escapeCsvCell(value) {
if (value === null || value === undefined) {
return ''
}
const text = String(value)
if (/[",\n\r]/.test(text)) {
return `"${text.replace(/"/g, '""')}"`
}
return text
},
handleExport() {
if (!this.tableData.length) {
this.$message.warning('暂无可导出数据')
return
}
const headers = [
'站点ID',
'点位ID',
'点位名',
'设备类型',
'设备ID',
'数据键',
'点位描述',
'寄存器地址',
'A系数',
'K系数',
'B系数',
'位偏移',
'类型',
'计算表达式',
'最新值',
'单位'
]
const rows = this.tableData.map(item => {
return [
item.siteId || '',
item.pointId || '',
item.pointName || '',
item.deviceCategory || '',
item.deviceId || '',
item.dataKey || '',
item.pointDesc || '',
item.registerAddress || '',
item.dataA === undefined || item.dataA === null ? '' : item.dataA,
item.dataK === undefined || item.dataK === null ? '' : item.dataK,
item.dataB === undefined || item.dataB === null ? '' : item.dataB,
item.dataBit === undefined || item.dataBit === null ? '' : item.dataBit,
item.pointType === 'calc' ? '计算点' : '数据点',
item.pointType === 'calc' ? (item.calcExpression || '') : '',
item.latestValue === undefined || item.latestValue === null ? '' : item.latestValue,
item.dataUnit || ''
].map(cell => this.escapeCsvCell(cell))
})
const csvText = [headers.map(cell => this.escapeCsvCell(cell)).join(','), ...rows.map(row => row.join(','))].join('\n')
const blob = new Blob([`\uFEFF${csvText}`], { type: 'text/csv;charset=utf-8;' })
const fileName = `point_list_${new Date().getTime()}.csv`
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
this.$message.success('导出成功')
},
resetForm() {
const querySiteId = this.hasValidSiteId(this.queryParams.siteId) ? String(this.queryParams.siteId).trim() : ''
this.form = {
@ -1444,6 +1561,33 @@ export default {
}
this.curveLoading = false
},
handleGenerateRecent7Days() {
if (this.form.pointType !== 'calc') {
return
}
const siteId = String(this.form.siteId || '').trim()
const pointId = String(this.form.pointId || '').trim()
if (!siteId) {
this.$message.warning('站点ID不能为空')
return
}
if (!pointId) {
this.$message.warning('请先输入点位ID')
return
}
this.generateRecentLoading = true
generatePointConfigRecent7Days({
siteId,
pointId,
deviceId: ''
}).then(response => {
this.$message.success(response?.msg || '最近7天数据生成成功')
}).catch(err => {
this.$message.error(err?.message || '最近7天数据生成失败')
}).finally(() => {
this.generateRecentLoading = false
})
},
submitForm() {
this.$refs.pointForm.validate(valid => {
if (!valid) return

View File

@ -262,7 +262,8 @@
<script>
import { mapState } from "vuex";
import { validText } from "@/utils/validate";
import { addProtectPlan, getPointMatchList, getProtectPlan, updateProtectPlan } from "@/api/ems/site";
import { addProtectPlan, getProtectPlan, updateProtectPlan } from "@/api/ems/site";
import { pointFuzzyQuery } from "@/api/ems/search";
export default {
data() {
@ -308,6 +309,8 @@ export default {
{ validator: validateText, trigger: "blur" },
],
},
sitePointOptionsCache: {},
sitePointRequestId: 0,
};
},
computed: {
@ -370,22 +373,37 @@ export default {
return `${pointId || "-"}-${pointName || "-"}(${pointDesc || "-"})`;
},
normalizePointOptions(data = []) {
return (data || []).map((item) => {
const pointId = item?.pointId || item?.point || item?.value || "";
const pointName = item?.pointName || pointId || "";
const pointDesc = item?.pointDesc || "";
return {
value: pointId,
label: this.formatPointLabel({ pointId, pointName, pointDesc }),
pointId,
pointName,
pointDesc,
deviceId: item?.deviceId || "",
deviceName: item?.deviceName || "",
deviceCategory: item?.deviceCategory || "",
categoryName: item?.categoryName || item?.deviceCategory || "",
};
});
return (data || [])
.map((item) => {
if (typeof item === "string") {
return {
value: item,
label: this.formatPointLabel({ pointName: item }),
pointId: "",
pointName: item,
pointDesc: "",
deviceId: "",
deviceName: "",
deviceCategory: "",
categoryName: "",
};
}
const pointId = item?.pointId || "";
const pointName = item?.pointName || item?.value || "";
const pointDesc = item?.pointDesc || "";
return {
value: pointId || pointName,
label: this.formatPointLabel({ pointId, pointName, pointDesc }),
pointId,
pointName,
pointDesc,
deviceId: item?.deviceId || "",
deviceName: item?.deviceName || "",
deviceCategory: item?.deviceCategory || "",
categoryName: item?.categoryName || item?.deviceCategory || "",
};
})
.filter((item) => item.value);
},
enhancePointRow(row = {}) {
const nextRow = Object.assign(
@ -398,7 +416,6 @@ export default {
pointName: "",
pointOptions: [],
pointOptionsCache: {},
pointRequestId: 0,
pointLoading: false,
},
row || {}
@ -523,67 +540,119 @@ export default {
const row = this.getRow(type, index);
if (!row) return;
const normalized = this.normalizePointOptions(data);
const selectedPoint = row.point
? this.normalizePointOptions([
{
const selected =
row.point
? (row.pointOptions || []).find((item) => item?.value === row.point) || {
value: row.point,
label: this.formatPointLabel({ pointId: row.point }),
pointId: row.point,
pointName: row.pointName || row.point,
pointName: row.pointName || "",
pointDesc: "",
deviceId: row.deviceId || "",
deviceName: row.deviceName || "",
deviceCategory: row.deviceCategory || "",
categoryName: row.categoryName || "",
},
])
: [];
const merged = [...normalized, ...(row.pointOptions || []), ...selectedPoint];
const optionMap = {};
const uniqueOptions = merged.filter((item) => {
if (!item?.value || optionMap[item.value]) return false;
optionMap[item.value] = true;
}
: null;
const nextOptions = selected ? [...normalized, selected] : normalized;
const seen = {};
const uniqueOptions = nextOptions.filter((item) => {
if (!item?.value || seen[item.value]) return false;
seen[item.value] = true;
return true;
});
this.setRow(type, index, Object.assign({}, row, { pointOptions: uniqueOptions }));
},
fetchPointOptions(index, type, query = "", { force = false } = {}) {
const row = this.getRow(type, index);
if (!row || !this.formData.siteId) return Promise.resolve([]);
const normalizedQuery = String(query || "").trim();
const cacheKey = `${this.formData.siteId}_${normalizedQuery}`;
if (!force && row.pointOptionsCache?.[cacheKey]) {
this.setPointOptions(index, type, row.pointOptionsCache[cacheKey]);
return Promise.resolve(row.pointOptionsCache[cacheKey]);
getPointCacheKey(query = "") {
return `${this.formData.siteId || ""}_${String(query || "").trim()}`;
},
getSitePointCacheKey() {
return String(this.formData.siteId || "").trim();
},
fuzzyFilterPointOptions(options = [], query = "") {
const keyword = String(query || "").trim().toLowerCase();
if (!keyword) return options;
return (options || []).filter((item) => {
const marker = `${item?.pointId || ""} ${item?.pointName || ""} ${item?.pointDesc || ""} ${
item?.deviceId || ""
} ${item?.deviceName || ""} ${item?.categoryName || ""}`.toLowerCase();
return marker.includes(keyword);
});
},
applyPointOptionsToType(type, pointOptions = []) {
const rows = this[type] || [];
const cacheKey = this.getPointCacheKey("");
rows.forEach((row, index) => {
const latest = this.getRow(type, index);
if (!latest) return;
const nextCache = Object.assign({}, latest.pointOptionsCache || {}, { [cacheKey]: pointOptions });
this.setRow(type, index, Object.assign({}, latest, { pointOptionsCache: nextCache }));
this.setPointOptions(index, type, pointOptions);
});
},
applyPointOptionsToGroups(pointOptions = []) {
this.applyPointOptionsToType("faultProtectionSettings", pointOptions);
this.applyPointOptionsToType("releaseProtectionSettings", pointOptions);
},
loadSitePointOptions({ force = false } = {}) {
const siteCacheKey = this.getSitePointCacheKey();
if (!siteCacheKey) return Promise.resolve([]);
if (!force && this.sitePointOptionsCache[siteCacheKey]) {
const cached = this.sitePointOptionsCache[siteCacheKey];
this.applyPointOptionsToGroups(cached);
return Promise.resolve(cached);
}
const requestId = Number(row.pointRequestId || 0) + 1;
this.setRow(type, index, Object.assign({}, row, { pointRequestId: requestId, pointLoading: true }));
return getPointMatchList({
siteId: this.formData.siteId,
pageNum: 1,
pageSize: 100,
pointId: normalizedQuery,
pointDesc: normalizedQuery,
const requestId = ++this.sitePointRequestId;
["faultProtectionSettings", "releaseProtectionSettings"].forEach((type) => {
(this[type] || []).forEach((row, index) => {
this.setRow(type, index, Object.assign({}, row, { pointLoading: true }));
});
});
return pointFuzzyQuery({
siteIds: [this.formData.siteId],
pointName: "",
})
.then((response) => {
const latestRow = this.getRow(type, index);
if (!latestRow || latestRow.pointRequestId !== requestId) return [];
const result = response?.rows || [];
const cache = Object.assign({}, latestRow.pointOptionsCache || {}, { [cacheKey]: result });
this.setRow(type, index, Object.assign({}, latestRow, { pointOptionsCache: cache }));
this.setPointOptions(index, type, result);
return result;
if (requestId !== this.sitePointRequestId) return [];
const data = this.normalizePointOptions(response?.data || []);
this.sitePointOptionsCache[siteCacheKey] = data;
this.applyPointOptionsToGroups(data);
return data;
})
.finally(() => {
const latestRow = this.getRow(type, index);
if (!latestRow || latestRow.pointRequestId !== requestId) return;
this.setRow(type, index, Object.assign({}, latestRow, { pointLoading: false }));
if (requestId !== this.sitePointRequestId) return;
["faultProtectionSettings", "releaseProtectionSettings"].forEach((type) => {
(this[type] || []).forEach((row, index) => {
const latest = this.getRow(type, index);
if (!latest) return;
this.setRow(type, index, Object.assign({}, latest, { pointLoading: false }));
});
});
});
},
remotePointSearch(index, type, query) {
this.fetchPointOptions(index, type, query);
const row = this.getRow(type, index);
if (!row || !this.formData.siteId) return;
const baseCacheKey = this.getPointCacheKey("");
const baseOptions = row.pointOptionsCache?.[baseCacheKey];
if (!baseOptions) {
this.loadSitePointOptions().then(() => {
const latestRow = this.getRow(type, index);
if (!latestRow) return;
const options = latestRow.pointOptionsCache?.[baseCacheKey] || [];
const localFiltered = this.fuzzyFilterPointOptions(options, query);
this.setPointOptions(index, type, localFiltered);
});
return;
}
const localFiltered = this.fuzzyFilterPointOptions(baseOptions, query);
this.setPointOptions(index, type, localFiltered);
},
handlePointDropdownVisible(index, type, visible) {
if (!visible) return;
this.fetchPointOptions(index, type, "");
this.loadSitePointOptions();
},
handlePointChange(index, type, value) {
const row = this.getRow(type, index);
@ -679,11 +748,6 @@ export default {
}
}
if (!faultSettingsValidateStatus) break;
if (!row.deviceId) {
this.$message.error(`请选择故障保护第${i + 1}行的点位(需从点位列表中选择)`);
faultSettingsValidateStatus = false;
break;
}
if (faultProtectionSettings[i + 1] && !row.relationNext) {
this.$message.error(getToastMsg("relationNext", "faultProtectionSettings", i + 1));
faultSettingsValidateStatus = false;
@ -703,11 +767,6 @@ export default {
}
}
if (!releaseSettingsValidateStatus) break;
if (!row.deviceId) {
this.$message.error(`请选择释放保护第${i + 1}行的点位(需从点位列表中选择)`);
releaseSettingsValidateStatus = false;
break;
}
if (releaseProtectionSettings[i + 1] && !row.relationNext) {
this.$message.error(getToastMsg("relationNext", "releaseProtectionSettings", i + 1));
releaseSettingsValidateStatus = false;

View File

@ -33,6 +33,17 @@
class="quick-filter-input"
placeholder="按字段名/展示名/设备名筛选"
/>
<div class="filter-actions">
<el-button size="small" :disabled="!siteId" @click="openImportConfigDialog">导入配置</el-button>
<el-button size="small" type="primary" :disabled="!siteId" @click="exportConfig">导出配置</el-button>
<input
ref="configInput"
type="file"
accept=".csv"
style="display: none"
@change="handleConfigFileChange"
/>
</div>
</div>
<el-tabs v-model="activeTopMenu" style="margin-top: 16px;">
@ -407,7 +418,7 @@
>
<el-form :inline="true" class="select-container">
<el-form-item label="点位ID">
<el-input v-model.trim="pointSelectorQuery.pointId" clearable placeholder="请输入点位ID" style="width: 180px" />
<el-input v-model.trim="pointSelectorQuery.pointId" clearable placeholder="请输入点位ID(支持模糊)" style="width: 180px" />
</el-form-item>
<el-form-item label="点位描述">
<el-input v-model.trim="pointSelectorQuery.pointDesc" clearable placeholder="请输入点位描述" style="width: 180px" />
@ -503,6 +514,8 @@ export default {
},
autoSaveTimer: null,
autoSaveDelay: 900,
pointSelectorQueryTimer: null,
pointSelectorQueryDelay: 280,
suppressAutoSave: false,
isSaving: false,
saveStatusText: '自动保存已开启',
@ -540,6 +553,12 @@ export default {
handler() {
this.scheduleAutoSave()
}
},
'pointSelectorQuery.pointId'() {
this.schedulePointSelectorSearch()
},
'pointSelectorQuery.pointDesc'() {
this.schedulePointSelectorSearch()
}
},
methods: {
@ -687,13 +706,446 @@ export default {
this.pointSelectorQuery.pageNum = pageNum
this.loadPointSelectorList()
},
loadPointSelectorList() {
openImportConfigDialog() {
if (!this.siteId) {
this.$message.warning('请先选择站点')
return
}
if (this.$refs.configInput) {
this.$refs.configInput.value = ''
this.$refs.configInput.click()
}
},
handleConfigFileChange(event) {
const file = event && event.target && event.target.files && event.target.files[0]
if (!file) {
return
}
const isCsv = /\.csv$/i.test(file.name || '')
if (!isCsv) {
this.$message.error('仅支持导入 CSV 文件')
return
}
const reader = new FileReader()
reader.onload = async () => {
const text = (reader.result || '').toString()
const parsed = this.parseCsvImportData(text)
if (!parsed) {
this.$message.error('CSV 格式不正确,导入失败')
return
}
await this.confirmAndApplyImport(parsed, file.name || '配置文件')
}
reader.onerror = () => {
this.$message.error('读取文件失败,请重试')
}
reader.readAsText(file, 'utf-8')
},
async confirmAndApplyImport(parsedData, fileName) {
if (!this.siteId) {
this.$message.warning('请先选择站点')
return
}
const importedSiteId = (((parsedData || {}).meta || {}).siteId || (parsedData || {}).siteId || '').toString().trim()
if (importedSiteId && importedSiteId !== this.siteId) {
try {
await this.$confirm(
`文件站点(${importedSiteId})与当前站点(${this.siteId})不一致,仍要导入并覆盖当前配置吗?`,
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
showClose: false,
closeOnClickModal: false
}
)
} catch (error) {
return
}
} else {
try {
await this.$confirm(
`确认导入文件 ${fileName} 并覆盖当前页面配置吗?`,
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
showClose: false,
closeOnClickModal: false
}
)
} catch (error) {
return
}
}
this.applyImportedConfig(parsedData)
},
normalizeImportMappings(parsedData) {
const data = parsedData || {}
const mappings = []
if (Array.isArray(data.mappings)) {
return data.mappings
}
if (data.pointMapping && Array.isArray(data.pointMapping.mappings)) {
return data.pointMapping.mappings
}
if (data.pointPayload && Array.isArray(data.pointPayload.mappings)) {
return data.pointPayload.mappings
}
return mappings
},
normalizeImportDeletedFieldCodes(parsedData) {
const data = parsedData || {}
const source = data.deletedFieldCodes || ((data.pointMapping || {}).deletedFieldCodes) || ((data.pointPayload || {}).deletedFieldCodes) || []
if (!Array.isArray(source)) {
return []
}
return source
.map(item => (item === null || item === undefined ? '' : String(item).trim()))
.filter(item => !!item)
},
normalizeImportEnumMappings(parsedData) {
const data = parsedData || {}
if (Array.isArray(data.enumMappings)) {
return data.enumMappings
}
if (data.workStatusEnum && Array.isArray(data.workStatusEnum.mappings)) {
return data.workStatusEnum.mappings
}
return []
},
escapeCsvCell(value) {
if (value === null || value === undefined) {
return ''
}
const text = String(value)
if (/[",\n\r]/.test(text)) {
return `"${text.replace(/"/g, '""')}"`
}
return text
},
parseCsvText(text) {
if (!text) {
return []
}
const normalized = String(text).replace(/^\uFEFF/, '')
const rows = []
let row = []
let cell = ''
let inQuotes = false
for (let i = 0; i < normalized.length; i += 1) {
const ch = normalized[i]
if (inQuotes) {
if (ch === '"') {
if (normalized[i + 1] === '"') {
cell += '"'
i += 1
} else {
inQuotes = false
}
} else {
cell += ch
}
continue
}
if (ch === '"') {
inQuotes = true
continue
}
if (ch === ',') {
row.push(cell)
cell = ''
continue
}
if (ch === '\n') {
row.push(cell)
rows.push(row)
row = []
cell = ''
continue
}
if (ch === '\r') {
if (normalized[i + 1] === '\n') {
continue
}
row.push(cell)
rows.push(row)
row = []
cell = ''
continue
}
cell += ch
}
row.push(cell)
if (row.some(item => item !== '')) {
rows.push(row)
}
return rows
},
mapCsvHeaderIndexes(headers) {
const headerMap = {}
const aliasMap = {
type: ['type'],
siteId: ['siteId'],
siteName: ['siteName'],
exportedAt: ['exportedAt'],
fieldCode: ['fieldCode'],
deviceId: ['deviceId'],
dataPoint: ['dataPoint', 'pointId'],
fixedDataPoint: ['fixedDataPoint'],
useFixedDisplay: ['useFixedDisplay'],
deletedFieldCode: ['deletedFieldCode'],
deviceCategory: ['deviceCategory'],
matchField: ['matchField'],
enumCode: ['enumCode'],
enumName: ['enumName'],
dataEnumCode: ['dataEnumCode'],
enumDesc: ['enumDesc']
}
const normalized = headers.map(item => String(item || '').trim())
Object.keys(aliasMap).forEach(key => {
const aliases = aliasMap[key]
const index = normalized.findIndex(item => aliases.includes(item))
if (index >= 0) {
headerMap[key] = index
}
})
return headerMap
},
parseCsvImportData(text) {
const rows = this.parseCsvText(text)
if (!rows.length) {
return null
}
const headerIndexes = this.mapCsvHeaderIndexes(rows[0] || [])
if (Object.keys(headerIndexes).length === 0) {
return null
}
const meta = {}
const mappings = []
const deletedFieldCodes = []
const parseUseFixedDisplay = value => {
const textValue = String(value || '').trim().toLowerCase()
if (['1', 'true', '是', 'y', 'yes'].includes(textValue)) {
return 1
}
return 0
}
for (let i = 1; i < rows.length; i += 1) {
const row = rows[i]
if (!row || row.every(cell => String(cell || '').trim() === '')) {
continue
}
const getValue = key => {
const index = headerIndexes[key]
if (index === undefined) {
return ''
}
return row[index] === undefined || row[index] === null ? '' : String(row[index]).trim()
}
const type = getValue('type').toLowerCase()
const siteId = getValue('siteId')
const siteName = getValue('siteName')
const exportedAt = getValue('exportedAt')
if (type === 'meta' || (!type && (siteId || siteName || exportedAt))) {
if (siteId) {
meta.siteId = siteId
}
if (siteName) {
meta.siteName = siteName
}
if (exportedAt) {
meta.exportedAt = exportedAt
}
continue
}
const fieldCode = getValue('fieldCode')
const deviceId = getValue('deviceId')
const dataPoint = getValue('dataPoint')
const fixedDataPoint = getValue('fixedDataPoint')
const useFixedDisplay = parseUseFixedDisplay(getValue('useFixedDisplay'))
const deletedFieldCode = getValue('deletedFieldCode')
if (type === 'deleted' || (!type && deletedFieldCode && !fieldCode)) {
if (deletedFieldCode) {
deletedFieldCodes.push(deletedFieldCode)
}
continue
}
if (type === 'point' || fieldCode) {
if (!fieldCode) {
continue
}
mappings.push({
fieldCode,
deviceId,
dataPoint,
fixedDataPoint,
useFixedDisplay
})
}
}
return {
meta,
mappings,
deletedFieldCodes
}
},
buildMappingKey(fieldCode, deviceId) {
return `${(fieldCode || '').toString().trim()}::${(deviceId || '').toString().trim()}`
},
applyImportedConfig(parsedData) {
const importedMappings = this.normalizeImportMappings(parsedData)
const importedDeletedFieldCodes = this.normalizeImportDeletedFieldCodes(parsedData)
if (!Array.isArray(importedMappings) || importedMappings.length === 0) {
this.$message.error('导入文件缺少 mappings 数据')
return
}
const mappingByKey = new Map()
importedMappings.forEach(item => {
const fieldCode = ((item && item.fieldCode) || '').toString().trim()
if (!fieldCode) {
return
}
const deviceId = ((item && item.deviceId) || '').toString().trim()
const key = this.buildMappingKey(fieldCode, deviceId)
mappingByKey.set(key, {
dataPoint: ((item && item.dataPoint) || '').toString(),
fixedDataPoint: ((item && item.fixedDataPoint) || '').toString(),
useFixedDisplay: item && item.useFixedDisplay === 1 ? 1 : 0
})
})
const allRows = this.getAllMappingRows()
let hitCount = 0
allRows.forEach(row => {
const key = this.buildMappingKey(row && row.field, row && row.deviceId)
const mapped = mappingByKey.get(key)
if (mapped) {
row.point = mapped.dataPoint
row.fixedValue = mapped.fixedDataPoint
row.useFixedDisplay = mapped.useFixedDisplay
hitCount += 1
} else {
row.point = ''
row.fixedValue = ''
row.useFixedDisplay = 0
}
})
this.suppressAutoSave = true
this.deletedFieldCodes = importedDeletedFieldCodes
this.$nextTick(() => {
this.suppressAutoSave = false
this.scheduleAutoSave()
})
const missCount = Math.max(mappingByKey.size - hitCount, 0)
if (missCount > 0) {
this.$message.warning(`导入完成:匹配 ${hitCount} 条,忽略 ${missCount} 条(当前页面无对应字段/设备)`)
} else {
this.$message.success(`导入完成:匹配 ${hitCount} 条,系统将自动保存`)
}
},
escapeJsonValue(value) {
if (value === undefined || value === null) {
return ''
}
return String(value)
},
exportConfig() {
if (!this.siteId) {
this.$message.warning('请先选择站点')
return
}
const pointPayload = this.buildSavePayload()
const headers = [
'type',
'siteId',
'siteName',
'exportedAt',
'fieldCode',
'deviceId',
'dataPoint',
'fixedDataPoint',
'useFixedDisplay',
'deletedFieldCode'
]
const rows = []
rows.push([
'meta',
this.siteId,
this.siteName || '',
new Date().toISOString(),
'',
'',
'',
'',
'',
''
])
;(pointPayload.mappings || []).forEach(item => {
rows.push([
'point',
this.siteId,
this.siteName || '',
'',
item.fieldCode || '',
item.deviceId || '',
item.dataPoint || '',
item.fixedDataPoint || '',
item.useFixedDisplay === 1 ? '1' : '0',
''
])
})
;(pointPayload.deletedFieldCodes || []).forEach(code => {
rows.push([
'deleted',
this.siteId,
this.siteName || '',
'',
'',
'',
'',
'',
'',
code || ''
])
})
const csvText = [headers.map(cell => this.escapeCsvCell(cell)).join(','), ...rows.map(row => row.map(cell => this.escapeCsvCell(cell)).join(','))].join('\n')
const blob = new Blob([`\uFEFF${csvText}`], { type: 'text/csv;charset=utf-8;' })
const safeSiteId = this.escapeJsonValue(this.siteId).replace(/[^\w-]/g, '_')
const fileName = `single_monitor_mapping_${safeSiteId || 'site'}_${Date.now()}.csv`
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
this.$message.success('导出成功')
},
schedulePointSelectorSearch() {
if (!this.pointSelectorVisible) {
return
}
if (this.pointSelectorQueryTimer) {
clearTimeout(this.pointSelectorQueryTimer)
}
this.pointSelectorQueryTimer = setTimeout(() => {
this.pointSelectorQuery.pageNum = 1
this.loadPointSelectorList()
}, this.pointSelectorQueryDelay)
},
loadPointSelectorList(options = {}) {
const { silent = false } = options
if (!this.pointSelectorQuery.siteId) {
this.pointSelectorList = []
this.pointSelectorTotal = 0
return
}
this.pointSelectorLoading = true
if (!silent) {
this.pointSelectorLoading = true
}
const query = {
...this.pointSelectorQuery
}
@ -705,7 +1157,9 @@ export default {
this.pointSelectorTotal = response?.total || 0
})
.finally(() => {
this.pointSelectorLoading = false
if (!silent) {
this.pointSelectorLoading = false
}
})
},
getFilteredQuickGroups(list) {
@ -1018,6 +1472,10 @@ export default {
if (this.autoSaveTimer) {
clearTimeout(this.autoSaveTimer)
}
if (this.pointSelectorQueryTimer) {
clearTimeout(this.pointSelectorQueryTimer)
this.pointSelectorQueryTimer = null
}
},
async mounted() {
this.siteId = this.$route.query.siteId || ''
@ -1093,6 +1551,7 @@ export default {
align-items: center;
justify-content: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.save-status-text {
@ -1105,6 +1564,12 @@ export default {
width: 280px;
}
.filter-actions {
display: flex;
align-items: center;
gap: 8px;
}
.quick-wrapper {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));

View File

@ -63,10 +63,17 @@
</el-table-column>
<el-table-column
label="操作"
width="120"
width="220"
fixed="right">
<template slot-scope="scope">
<el-button type="text" size="small" @click="openEditDialog(scope.row)">编辑</el-button>
<el-button
type="text"
size="small"
:loading="syncingSiteId === scope.row.siteId"
:disabled="syncingSiteId === scope.row.siteId"
@click="syncWeather(scope.row)"
>同步天气</el-button>
</template>
</el-table-column>
</el-table>
@ -164,7 +171,7 @@
</template>
<script>
import {addSite, getSiteInfoList, updateSite} from '@/api/ems/site'
import {addSite, getSiteInfoList, syncSiteWeatherByDateRange, updateSite} from '@/api/ems/site'
import { formatDate } from '@/filters/ems'
const emptySiteForm = () => ({
@ -199,6 +206,7 @@ export default {
pageSize: 10,
pageNum: 1,
totalSize: 0,
syncingSiteId: '',
dialogVisible: false,
isEdit: false,
siteForm: emptySiteForm(),
@ -239,6 +247,38 @@ export default {
this.pageNum = 1
this.getData()
},
getSyncDateRange() {
const now = new Date()
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
return [formatDate(monthStart), formatDate(now)]
},
syncWeather(row) {
const siteId = row?.siteId
if (!siteId) {
this.$message.warning('站点ID为空无法同步天气')
return
}
const [startTime, endTime] = this.getSyncDateRange()
this.$confirm(`将同步站点 ${siteId}${startTime}${endTime} 的天气数据,是否继续?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.syncingSiteId = siteId
return syncSiteWeatherByDateRange({siteId, startTime, endTime})
}).then((response) => {
const result = response?.data || {}
const successDays = result.successDays ?? 0
const totalDays = result.totalDays ?? 0
this.$message.success(`天气同步完成(${successDays}/${totalDays}天)`)
}).catch((err) => {
if (err !== 'cancel') {
this.$message.error('天气同步失败')
}
}).finally(() => {
this.syncingSiteId = ''
})
},
getData() {
this.loading = true
const {siteName, pageNum, pageSize} = this

View File

@ -1,6 +1,7 @@
<template>
<div class="map-wrapper">
<div ref="mapRef" class="map-canvas"></div>
<div v-if="selectedAddress" class="map-center-address">{{ selectedAddress }}</div>
<div v-if="!hasPoint" class="map-empty">暂无站点坐标</div>
</div>
</template>
@ -37,11 +38,13 @@ export default {
data() {
return {
hasPoint: false,
selectedAddress: '',
map: null,
overlays: [],
pendingPayload: null,
mapConfig: {
zoom: 12,
selectedZoom: 15,
tk: '01e99ab4472430e1c7dbfe4b5db99787'
}
}
@ -71,13 +74,14 @@ export default {
},
normalizePoint(site = {}) {
const name = site.siteName || site.name || ''
const address = site.siteAddress || site.address || ''
const value = site.value || site.siteLocation || []
const lonSource = site.longitude !== undefined && site.longitude !== null ? site.longitude : value[0]
const latSource = site.latitude !== undefined && site.latitude !== null ? site.latitude : value[1]
const lon = Number(lonSource)
const lat = Number(latSource)
if (!lon || !lat) return null
return { name, lon, lat }
return { name, address, lon, lat }
},
clearOverlays() {
if (!this.map || !this.overlays.length) return
@ -92,6 +96,7 @@ export default {
const points = (Array.isArray(sitesRaw) ? sitesRaw : [])
.map(item => this.normalizePoint(item))
.filter(Boolean)
this.selectedAddress = selected?.address || ''
if (selected && !points.find(item => item.lon === selected.lon && item.lat === selected.lat)) {
points.push(selected)
}
@ -118,7 +123,9 @@ export default {
this.overlays.push(label)
}
if (viewPoints.length === 1) {
if (selected) {
this.map.centerAndZoom(new window.T.LngLat(selected.lon, selected.lat), this.mapConfig.selectedZoom)
} else if (viewPoints.length === 1) {
this.map.centerAndZoom(viewPoints[0], this.mapConfig.zoom)
} else {
this.map.setViewport(viewPoints)
@ -160,4 +167,22 @@ export default {
background: rgba(245, 247, 250, 0.9);
pointer-events: none;
}
.map-center-address {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, calc(-50% - 26px));
max-width: min(70%, 520px);
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
line-height: 18px;
color: #ffffff;
background: rgba(0, 0, 0, 0.65);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
text-align: center;
pointer-events: none;
z-index: 10;
}
</style>

View File

@ -30,11 +30,11 @@
<span class="site-card-value">{{ formatSiteCardDate(item.runningTime) }}</span>
</div>
<div class="site-card-info-row">
<span class="site-card-label">装机功率(MW)</span>
<span class="site-card-label">装机功率(MWh)</span>
<span class="site-card-value">{{ formatSiteCardField(item.installPower) }}</span>
</div>
<div class="site-card-info-row">
<span class="site-card-label">装机容量(MW)</span>
<span class="site-card-label">装机容量(MWh)</span>
<span class="site-card-value">{{ formatSiteCardField(item.installCapacity) }}</span>
</div>
</div>
@ -67,6 +67,7 @@ export default {
loading: false,
singleSiteId: '',
singleSiteName: '',
singleSiteAddress: '',
singleSiteLocation: [],
allSites: [],
canScrollLeft: false,
@ -138,7 +139,7 @@ export default {
},
updateMapMarkers(){
this.$refs.mapChart && this.$refs.mapChart.setOption({
selected: {name:this.singleSiteName,value:this.singleSiteLocation},
selected: {name:this.singleSiteName,address:this.singleSiteAddress,value:this.singleSiteLocation},
sites:this.allSites
})
},
@ -147,6 +148,7 @@ export default {
this.singleSiteId = id
const currentSite = this.allSites.find(item => this.isSameSite(item.siteId, id)) || {}
this.singleSiteName = currentSite.siteName || ''
this.singleSiteAddress = currentSite.siteAddress || ''
this.singleSiteLocation = currentSite.siteLocation || []
if (!this.singleSiteLocation.length) {
this.singleSiteLocation = [currentSite.longitude, currentSite.latitude].filter(item => item !== undefined && item !== null)