Files
xgy-oa/klp-ui/src/views/ems/rate/index.vue

2522 lines
82 KiB
Vue
Raw Normal View History

2025-09-28 14:38:41 +08:00
<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch" label-width="68px">
<el-form-item label="能源类型" prop="energyTypeId">
<el-select v-model="queryParams.energyTypeId" placeholder="请选择能源类型">
<el-option v-for="item in energyTypeList" :key="item.energyTypeId" :label="item.name" :value="item.energyTypeId" />
</el-select>
</el-form-item>
<el-form-item label="费率" prop="rate">
<el-input
v-model="queryParams.rate"
placeholder="请输入费率"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="货币单位" prop="currency">
<el-select v-model="queryParams.currency" placeholder="请选择货币单位">
<el-option v-for="item in dict.type.currency_unit" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="生效日期" prop="effectiveDate">
<el-date-picker clearable
v-model="queryParams.effectiveDate"
type="date"
value-format="yyyy-MM-dd"
placeholder="请选择生效日期">
</el-date-picker>
</el-form-item>
<el-form-item label="失效日期" prop="expiryDate">
<el-date-picker clearable
v-model="queryParams.expiryDate"
type="date"
value-format="yyyy-MM-dd"
placeholder="请选择失效日期">
</el-date-picker>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
type="primary"
plain
icon="el-icon-plus"
size="mini"
@click="handleAdd"
>新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="el-icon-edit"
size="mini"
:disabled="single"
@click="handleUpdate"
>修改</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="danger"
plain
icon="el-icon-delete"
size="mini"
:disabled="multiple"
@click="handleDelete"
>删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="warning"
plain
icon="el-icon-download"
size="mini"
@click="handleExport"
>导出</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
<el-table v-loading="loading" :data="energyRateList" @selection-change="handleSelectionChange" @row-click="handleRowClick">
2025-09-28 14:38:41 +08:00
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="" align="center" prop="energyRateId" v-if="false"/>
<el-table-column label="能源类型" align="center" prop="energyTypeId">
<template slot-scope="scope">
<span>{{ getEnergyName(scope.row.energyTypeId) }}</span>
</template>
</el-table-column>
<el-table-column label="费率信息" align="left" min-width="280">
<template slot-scope="scope">
<div class="rate-info-detail">
<!-- 固定费率 -->
<div v-if="parseInt(scope.row.usePeakValley) === 0 && parseInt(scope.row.useTieredPricing) === 0" class="rate-item">
<span class="rate-label">固定费率:</span>
<span class="rate-value">¥{{ scope.row.rate }}/{{ getEnergyUnit(scope.row.energyTypeId) }}</span>
</div>
<!-- 峰谷时段费率 -->
<div v-if="parseInt(scope.row.usePeakValley) === 1 && parseInt(scope.row.useTieredPricing) === 0" class="rate-item">
<span class="rate-label">峰谷电价:</span>
<div v-if="getRatePeriods(scope.row.energyRateId).length > 0" class="period-rates">
<span v-for="period in getRatePeriods(scope.row.energyRateId)" :key="period.periodId" class="period-badge" :style="{ backgroundColor: getPeriodBgColor(period.periodType) }">
{{ getPeriodName(period.periodType) }}: ¥{{ period.rate }}
</span>
</div>
<div v-else class="config-warning">
<span> 未配置峰谷电价请点击"配置"按钮进行设置</span>
</div>
</div>
<!-- 梯度收费 -->
<div v-if="parseInt(scope.row.useTieredPricing) === 1 && parseInt(scope.row.usePeakValley) === 0" class="rate-item">
<span class="rate-label">梯度电价:</span>
<div class="tier-rates">
<span v-if="!scope.row.tiers || scope.row.tiers.length === 0" style="color: #999;">未配置</span>
<span v-for="tier in scope.row.tiers" :key="tier.tierId" class="tier-badge">
{{ tier.tierLevel }}: {{ tier.minUsage }}-{{ tier.maxUsage ? tier.maxUsage : '∞' }} ¥{{ tier.rate }}
</span>
</div>
</div>
<!-- 梯度+峰谷组合 -->
<div v-if="parseInt(scope.row.useTieredPricing) === 1 && parseInt(scope.row.usePeakValley) === 1" class="rate-item">
<span class="rate-label">梯度+峰谷:</span>
<div class="combo-rates">
<div v-for="tier in scope.row.tiers" :key="tier.tierId" class="combo-tier-row">
<div class="tier-header">{{ tier.tierLevel }} ({{ tier.minUsage }}-{{ tier.maxUsage ? tier.maxUsage : '∞' }})</div>
<div class="tier-rates-container">
<span v-if="tier.tierPeriodRates && Object.keys(tier.tierPeriodRates).length > 0">
<span v-for="(rate, periodId) in tier.tierPeriodRates" :key="periodId" class="rate-badge">
¥{{ rate }}
</span>
</span>
<span v-else style="color: #999; font-size: 12px;">未配置</span>
</div>
</div>
</div>
</div>
</div>
</template>
</el-table-column>
2025-09-28 14:38:41 +08:00
<el-table-column label="货币单位" align="center" prop="currency">
<template slot-scope="scope">
<dict-tag :options="dict.type.currency_unit" :value="scope.row.currency" />
</template>
</el-table-column>
<el-table-column label="生效日期" align="center" prop="effectiveDate" width="180">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.effectiveDate, '{y}-{m}-{d}') }}</span>
</template>
</el-table-column>
<el-table-column label="失效日期" align="center" prop="expiryDate" width="180">
<template slot-scope="scope">
<span v-if="scope.row.expiryDate" class="expiry-date">{{ parseTime(scope.row.expiryDate, '{y}-{m}-{d}') }}</span>
<el-tag v-else type="success" size="small">至今生效</el-tag>
</template>
</el-table-column>
<el-table-column label="峰谷时段" align="center" prop="usePeakValley">
<template slot-scope="scope">
<el-tag :type="scope.row.usePeakValley === 1 ? 'success' : 'info'">
{{ scope.row.usePeakValley === 1 ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="梯度收费" align="center" prop="useTieredPricing">
<template slot-scope="scope">
<el-tag :type="scope.row.useTieredPricing === 1 ? 'success' : 'info'">
{{ scope.row.useTieredPricing === 1 ? '是' : '否' }}
</el-tag>
2025-09-28 14:38:41 +08:00
</template>
</el-table-column>
<el-table-column label="备注" align="center" prop="remark" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-edit"
@click="handleUpdate(scope.row)"
>修改</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-setting"
@click="handleConfig(scope.row)"
>配置</el-button>
2025-09-28 14:38:41 +08:00
<el-button
size="mini"
type="text"
icon="el-icon-delete"
@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="getList"
/>
<!-- 费率可视化展示浮动 -->
<div v-if="selectedRateRow" class="rate-visualization-floating">
<div class="visualization-header">
<span class="visualization-title">
{{ getEnergyName(selectedRateRow.energyTypeId) }} - 费率分布
</span>
<el-button size="mini" @click="selectedRateRow = null" style="color: #666; border: none; background: transparent;"></el-button>
</div>
<!-- 峰谷电费率展示梯度+峰谷组合模式下不显示 -->
<div v-if="selectedRateRow && parseInt(selectedRateRow.usePeakValley) === 1 && parseInt(selectedRateRow.useTieredPricing) === 0" class="visualization-section">
<div class="section-subtitle">峰谷电费率分布</div>
<div v-if="selectedPeriodList && selectedPeriodList.length > 0" class="bar-chart">
<div class="chart-container">
<div v-for="period in selectedPeriodList" :key="period.periodId" class="bar-item">
<div class="bar-wrapper">
<div
class="bar"
:style="{
height: period.rate ? (period.rate / selectedMaxPeriodRate) * 100 + '%' : '5%',
backgroundColor: getPeriodBgColor(period.periodType)
}">
</div>
</div>
<div class="bar-label">
<div class="time">{{ period.startTime }}-{{ period.endTime }}</div>
<div class="rate">¥{{ period.rate || 0 }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 梯度电费率展示 -->
<div v-if="selectedRateRow && parseInt(selectedRateRow.useTieredPricing) === 1" class="visualization-section">
<div class="section-subtitle">梯度电费率分布</div>
<div v-if="selectedTierList && selectedTierList.length > 0" class="bar-chart">
<div class="chart-container">
<div v-for="tier in selectedTierList" :key="tier.tierId" class="bar-item">
<div class="bar-wrapper">
<div
class="bar tier-bar"
:style="{
height: tier.rate ? (tier.rate / selectedMaxTierRate) * 100 + '%' : '5%'
}">
</div>
</div>
<div class="bar-label">
<div class="tier-info">{{ tier.tierLevel }}</div>
<div class="usage">{{ tier.minUsage }}-{{ tier.maxUsage ? tier.maxUsage : '∞' }}</div>
<div class="rate">¥{{ tier.rate || 0 }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 固定费率展示 -->
<div v-if="parseInt(selectedRateRow.usePeakValley) === 0 && parseInt(selectedRateRow.useTieredPricing) === 0" class="visualization-section">
<div class="section-subtitle">📌 固定费率</div>
<div class="fixed-rate-display">
<div class="rate-box">
<span class="rate-label">费率</span>
<span class="rate-value">¥{{ selectedRateRow.rate }}/{{ getEnergyUnit(selectedRateRow.energyTypeId) }}</span>
</div>
</div>
</div>
</div>
<!-- 添加或修改能源费率对话框 -->
<el-dialog :title="title" :visible.sync="open" width="600px" append-to-body @close="cancel">
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
2025-09-28 14:38:41 +08:00
<el-form-item label="能源类型" prop="energyTypeId">
<el-select v-model="form.energyTypeId" placeholder="请选择能源类型">
<el-option v-for="item in energyTypeList" :key="item.energyTypeId" :label="item.name" :value="item.energyTypeId" />
</el-select>
</el-form-item>
<el-form-item label="基础费率" prop="rate">
<el-input
v-model="form.rate"
placeholder="请输入费率"
:disabled="form.usePeakValley === 1 || form.useTieredPricing === 1"
@focus="onRateFieldFocus" />
<span v-if="form.usePeakValley === 1 || form.useTieredPricing === 1" class="tip-text">
已启用峰谷或梯度基础费率由配置决定
</span>
2025-09-28 14:38:41 +08:00
</el-form-item>
<el-form-item label="货币单位" prop="currency">
<el-select v-model="form.currency" placeholder="请选择货币单位">
<el-option v-for="item in dict.type.currency_unit" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="生效日期" prop="effectiveDate">
<el-date-picker clearable
v-model="form.effectiveDate"
type="datetime"
value-format="yyyy-MM-dd HH:mm:ss"
placeholder="请选择生效日期">
</el-date-picker>
</el-form-item>
<el-form-item label="失效日期" prop="expiryDate">
<el-date-picker clearable
v-model="form.expiryDate"
type="datetime"
value-format="yyyy-MM-dd HH:mm:ss"
placeholder="请选择失效日期">
</el-date-picker>
</el-form-item>
<el-divider></el-divider>
<el-form-item label="峰谷时段" prop="usePeakValley">
<el-switch v-model="form.usePeakValley" :active-value="1" :inactive-value="0"></el-switch>
<span class="ml10">{{ form.usePeakValley === 1 ? '启用' : '禁用' }}</span>
</el-form-item>
<el-form-item label="梯度收费" prop="useTieredPricing">
<el-switch v-model="form.useTieredPricing" :active-value="1" :inactive-value="0"></el-switch>
<span class="ml10">{{ form.useTieredPricing === 1 ? '启用' : '禁用' }}</span>
</el-form-item>
2025-09-28 14:38:41 +08:00
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" placeholder="请输入内容" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button :loading="buttonLoading" type="primary" @click="submitForm"> </el-button>
<el-button @click="cancel"> </el-button>
</div>
</el-dialog>
<!-- 费率配置对话框分步骤向导式 -->
<el-dialog title="费率配置向导" :visible.sync="configOpen" width="900px" :fullscreen="false" append-to-body @close="closeConfig">
<!-- 步骤条 -->
<el-steps :active="configStep" process-status="process" align-center class="wizard-steps">
<el-step title="选择模式" icon="el-icon-setting"></el-step>
<el-step v-if="form.usePeakValley === 1" title="配置峰谷" icon="el-icon-time"></el-step>
<el-step v-if="form.useTieredPricing === 1" title="配置梯度" icon="el-icon-s-unfold"></el-step>
<el-step title="预览保存" icon="el-icon-check"></el-step>
</el-steps>
<!-- 步骤内容 -->
<div class="wizard-content" style="margin-top: 30px;">
<!-- 第1步选择模式 -->
<div v-if="configStep === 0" class="step-content">
<div class="mode-selection">
<h3>请选择计价模式</h3>
<div class="mode-cards">
<div class="mode-card" @click="selectMode('fixed')" :class="{ active: form.usePeakValley === 0 && form.useTieredPricing === 0 }">
<div class="mode-icon">📌</div>
<div class="mode-name">固定费率</div>
<div class="mode-desc">使用统一的费率</div>
</div>
<div class="mode-card" @click="selectMode('peak')" :class="{ active: form.usePeakValley === 1 && form.useTieredPricing === 0 }">
<div class="mode-icon">📊</div>
<div class="mode-name">峰谷分时</div>
<div class="mode-desc">按时段设置费率</div>
</div>
<div class="mode-card" @click="selectMode('tier')" :class="{ active: form.usePeakValley === 0 && form.useTieredPricing === 1 }">
<div class="mode-icon">📈</div>
<div class="mode-name">梯度收费</div>
<div class="mode-desc">按用量设置费率</div>
</div>
<div class="mode-card" @click="selectMode('combo')" :class="{ active: form.usePeakValley === 1 && form.useTieredPricing === 1 }">
<div class="mode-icon">🔗</div>
<div class="mode-name">峰谷+梯度</div>
<div class="mode-desc">组合计价模式</div>
</div>
</div>
</div>
</div>
<!-- 第2步配置峰谷仅在峰谷模式下显示 -->
<div v-if="configStep === 1 && form.usePeakValley === 1" class="step-content">
<div class="peak-valley-config">
<h3>配置峰谷时段</h3>
<el-alert
title="峰谷时段说明"
type="info"
description="根据不同时段设置不同的费率。例如8:00-12:00为峰时段1.5元/度12:00-18:00为平时段1.0元/度18:00-22:00为谷时段0.5元/度)。"
:closable="false"
class="mb15">
</el-alert>
<div class="period-actions mb15">
<el-button type="primary" size="small" icon="el-icon-plus" @click="addPeriod">添加时段</el-button>
<span class="ml10" style="color: #909399;">已配置 {{ periodList.length }} 个时段</span>
</div>
<el-table :data="periodList" border stripe max-height="400" size="small">
<el-table-column label="时段类型" align="center" min-width="110">
<template slot-scope="scope">
<el-select v-model="scope.row.periodType" placeholder="请选择" size="small" style="width: 100%;">
<el-option label="🔴 峰" :value="0" />
<el-option label="🔵 谷" :value="1" />
<el-option label="🟡 平" :value="2" />
</el-select>
</template>
</el-table-column>
<el-table-column label="时段名称" align="center" min-width="120">
<template slot-scope="scope">
<el-input
v-model="scope.row.periodName"
placeholder="如:峰时段"
maxlength="15"
size="small"
style="width: 100%;" />
</template>
</el-table-column>
<el-table-column label="开始时间" align="center" min-width="130">
<template slot-scope="scope">
<el-time-picker
v-model="scope.row.startTime"
format="HH:mm"
value-format="HH:mm"
placeholder="HH:mm"
size="small"
style="width: 100%;"
@change="validatePeriodTime(scope.$index)" />
</template>
</el-table-column>
<el-table-column label="结束时间" align="center" min-width="130">
<template slot-scope="scope">
<el-time-picker
v-model="scope.row.endTime"
format="HH:mm"
value-format="HH:mm"
placeholder="HH:mm"
size="small"
style="width: 100%;"
@change="validatePeriodTime(scope.$index)" />
</template>
</el-table-column>
<!-- 仅在非组合模式下显示费率列 -->
<el-table-column v-if="form.useTieredPricing === 0" label="费率" align="center" min-width="120">
<template slot-scope="scope">
<el-input-number
v-model="scope.row.rate"
:precision="4"
:step="0.01"
:min="0"
size="small"
placeholder="如1.5"
style="width: 100%;" />
</template>
</el-table-column>
<el-table-column label="操作" align="center" min-width="80">
<template slot-scope="scope">
<el-button size="mini" type="danger" icon="el-icon-delete" @click="removePeriod(scope.$index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
<!-- 第2或3步配置梯度 -->
<div v-if="(configStep === 1 && form.useTieredPricing === 1 && form.usePeakValley === 0) || (configStep === 2 && form.useTieredPricing === 1)" class="step-content">
<div class="tier-config">
<h3>配置梯度收费</h3>
<el-alert
title="梯度收费说明"
type="info"
description="根据用量区间设置不同的费率。例如0-100度按1.0元/度收费100-200度按1.5元/度收费200度以上按2.0元/度收费。"
:closable="false"
class="mb15">
</el-alert>
<div class="tier-actions mb15">
<el-button type="primary" size="small" icon="el-icon-plus" @click="addTier">添加梯度</el-button>
<span class="ml10" style="color: #909399;">已配置 {{ tierList.length }} 个梯度</span>
</div>
<el-table :data="tierList" border stripe max-height="400" size="small">
<el-table-column label="梯度等级" align="center" min-width="100">
<template slot-scope="scope">
<span class="tier-level-badge">{{ scope.row.tierLevel }}</span>
</template>
</el-table-column>
<el-table-column label="最小用量" align="center" min-width="140">
<template slot-scope="scope">
<div class="tier-input-wrapper">
<el-input-number
v-model="scope.row.minUsage"
:precision="2"
:step="0.01"
:min="0"
size="small"
placeholder="如0"
style="width: 100%;"
@change="onTierMinUsageChange(scope.$index)" />
<span v-if="getTierValidationError(scope.$index, 'min')" class="tier-error">
{{ getTierValidationError(scope.$index, 'min') }}
</span>
</div>
</template>
</el-table-column>
<el-table-column label="最大用量" align="center" min-width="140">
<template slot-scope="scope">
<div class="tier-input-wrapper">
<el-input-number
v-model="scope.row.maxUsage"
:precision="2"
:step="0.01"
size="small"
:placeholder="scope.$index === tierList.length - 1 ? '无上限' : '自动填充'"
style="width: 100%;"
@change="onTierMaxUsageChange(scope.$index)" />
<span v-if="getTierValidationError(scope.$index, 'max')" class="tier-error">
{{ getTierValidationError(scope.$index, 'max') }}
</span>
</div>
</template>
</el-table-column>
<!-- 仅在非组合模式下显示费率列 -->
<el-table-column v-if="form.usePeakValley === 0" label="费率" align="center" min-width="140">
<template slot-scope="scope">
<el-input-number
v-model="scope.row.rate"
:precision="4"
:step="0.01"
:min="0"
size="small"
placeholder="如1.5"
style="width: 100%;" />
</template>
</el-table-column>
<!-- 组合模式下显示配置峰谷按钮 -->
<el-table-column v-if="form.usePeakValley === 1" label="配置峰谷" align="center" min-width="140">
<template slot-scope="scope">
<el-button
size="mini"
type="primary"
icon="el-icon-edit"
@click="editTierPeriods(scope.row, scope.$index)">
配置峰谷
</el-button>
</template>
</el-table-column>
<el-table-column label="操作" align="center" min-width="80">
<template slot-scope="scope">
<el-button size="mini" type="danger" icon="el-icon-delete" @click="removeTier(scope.$index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
<!-- 最后一步预览和保存 -->
<div v-if="isLastStep" class="step-content">
<div class="preview-section">
<h3>预览配置信息</h3>
<!-- 模式说明 -->
<div class="preview-card">
<div class="card-title">计价模式</div>
<div class="card-content">
<span v-if="form.useTieredPricing === 0 && form.usePeakValley === 0" class="tip-badge fixed-mode">
📌 固定费率模式
</span>
<span v-else-if="form.useTieredPricing === 0 && form.usePeakValley === 1" class="tip-badge peak-mode">
📊 峰谷分时模式
</span>
<span v-else-if="form.useTieredPricing === 1 && form.usePeakValley === 0" class="tip-badge tier-mode">
📈 梯度收费模式
</span>
<span v-else class="tip-badge combo-mode">
🔗 梯度+峰谷组合模式
</span>
</div>
</div>
<!-- 峰谷预览 -->
<div v-if="form.usePeakValley === 1" class="preview-card">
<div class="card-title">峰谷时段</div>
<div class="card-content">
<div v-for="period in periodList" :key="period.periodId" class="preview-item">
<span class="period-badge" :style="{ backgroundColor: getPeriodBgColor(period.periodType) }">
{{ getPeriodName(period.periodType) }}
</span>
<span>{{ period.startTime }} - {{ period.endTime }}</span>
<!-- 仅在非组合模式下显示费率 -->
<span v-if="form.useTieredPricing === 0" class="rate-value">¥{{ period.rate }}/{{ energyUnit }}</span>
</div>
</div>
</div>
<!-- 梯度预览 -->
<div v-if="form.useTieredPricing === 1" class="preview-card">
<div class="card-title">梯度收费</div>
<div class="card-content">
<!-- 仅梯度模式显示梯度费率 -->
<div v-if="form.usePeakValley === 0">
<div v-for="tier in tierList" :key="tier.tierId" class="preview-item">
<span class="tier-badge">{{ tier.tierLevel }}</span>
<span>{{ tier.minUsage }} - {{ tier.maxUsage ? tier.maxUsage : '∞' }} {{ energyUnit }}</span>
<span class="rate-value">¥{{ tier.rate }}/{{ energyUnit }}</span>
</div>
</div>
<!-- 组合模式显示梯度的峰谷费率 -->
<div v-else>
<div v-for="tier in tierList" :key="tier.tierId" class="tier-group">
<div class="tier-header">{{ tier.tierLevel }} ({{ tier.minUsage }} - {{ tier.maxUsage ? tier.maxUsage : '∞' }} {{ energyUnit }})</div>
<div v-for="period in periodList" :key="`${tier.tierId}-${period.periodId}`" class="preview-item" style="margin-left: 20px;">
<span class="period-badge" :style="{ backgroundColor: getPeriodBgColor(period.periodType) }">
{{ getPeriodName(period.periodType) }}
</span>
<span>{{ period.startTime }} - {{ period.endTime }}</span>
<span class="rate-value">¥{{ tier.tierPeriodRates && tier.tierPeriodRates[period.periodId || period._key] ? tier.tierPeriodRates[period.periodId || period._key] : 0 }}/{{ energyUnit }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div slot="footer" class="dialog-footer">
<el-button v-if="configStep > 0" @click="prevStep"> </el-button>
<el-button v-if="configStep < 3" type="primary" @click="nextStep"> </el-button>
<el-button v-if="configStep === 3" :loading="buttonLoading" type="primary" @click="saveConfig"> </el-button>
<el-button @click="closeConfig"> </el-button>
</div>
</el-dialog>
<!-- 梯度-时段费率编辑对话框 -->
<el-dialog
v-if="currentTier"
:title="`第${currentTier.tierLevel}梯 (${currentTier.minUsage}~${currentTier.maxUsage ? currentTier.maxUsage : '∞'}${energyUnit}) 的峰谷费率配置`"
:visible.sync="tierPeriodDialogOpen"
width="900px"
append-to-body
@close="closeTierPeriodDialog">
<el-alert
title="梯度峰谷费率说明"
type="info"
description="为该梯度的每个时段(峰/谷/平设置不同的费率。例如第1梯度的峰时段1.5元/度谷时段0.5元/度。"
:closable="false"
class="mb15">
</el-alert>
<el-table :data="currentTierPeriods" border stripe max-height="400" size="small">
<el-table-column label="时段类型" align="center" width="100">
<template slot-scope="scope">
<el-select v-model="scope.row.periodType" placeholder="请选择" size="small" style="width: 100%;" disabled>
<el-option label="🔴 峰" :value="0" />
<el-option label="🔵 谷" :value="1" />
<el-option label="🟡 平" :value="2" />
</el-select>
</template>
</el-table-column>
<el-table-column label="时段名称" align="center" width="120">
<template slot-scope="scope">
<span>{{ scope.row.periodName }}</span>
</template>
</el-table-column>
<el-table-column label="时间范围" align="center" width="140">
<template slot-scope="scope">
<span style="font-size: 12px;">{{ scope.row.startTime }} - {{ scope.row.endTime }}</span>
</template>
</el-table-column>
<el-table-column label="费率" align="center" width="140">
<template slot-scope="scope">
<el-input-number
v-model="scope.row.rate"
:precision="4"
:step="0.01"
:min="0"
size="small"
placeholder="如1.5"
style="width: 100%;" />
</template>
</el-table-column>
<el-table-column label="说明" align="center" min-width="150">
<template slot-scope="scope">
<span style="color: #606266; font-size: 11px;">
{{ scope.row.startTime }} - {{ scope.row.endTime }}<br/>
@ {{ scope.row.rate }} /{{ energyUnit }}
</span>
</template>
</el-table-column>
</el-table>
<div slot="footer" class="dialog-footer">
<el-button :loading="buttonLoading" type="primary" @click="saveTierPeriods"> </el-button>
<el-button @click="closeTierPeriodDialog"> </el-button>
</div>
</el-dialog>
2025-09-28 14:38:41 +08:00
</div>
</template>
<script>
import { listEnergyRate, getEnergyRate, delEnergyRate, addEnergyRate, updateEnergyRate, getRateTiers, getRateTimePeriods, saveTiers, saveTimePeriods, getTierPeriodLinks, saveTierPeriodLinks } from "@/api/ems/energyRate";
2025-09-28 14:38:41 +08:00
import { listEnergyType } from "@/api/ems/energyType";
export default {
name: "EnergyRate",
dicts: ['currency_unit'],
data() {
return {
energyTypeList: [],
// 按钮loading
buttonLoading: false,
// 遮罩层
loading: true,
// 选中数组
ids: [],
// 非单个禁用
single: true,
// 非多个禁用
multiple: true,
// 显示搜索条件
showSearch: true,
// 总条数
total: 0,
// 能源费率表格数据
2025-09-28 14:38:41 +08:00
energyRateList: [],
// 费率详情缓存energyRateId -> {tiers, periods, tierPeriodRates}
rateDetailsCache: {},
2025-09-28 14:38:41 +08:00
// 弹出层标题
title: "",
// 是否显示弹出层
open: false,
// 是否显示配置对话框
configOpen: false,
// 梯度费率列表
tierList: [],
// 时段费率列表
periodList: [],
// 能源单位
energyUnit: '度',
// 梯度-时段关联对话框
tierPeriodDialogOpen: false,
// 当前编辑的梯度
currentTier: null,
// 当前梯度的峰谷费率列表
currentTierPeriods: [],
// 向导步骤0=选择模式, 1=配置峰谷或梯度, 2=配置梯度(组合模式), 3=预览保存)
configStep: 0,
// 选中的费率行(用于显示条形图)
selectedRateRow: null,
2025-09-28 14:38:41 +08:00
// 查询参数
queryParams: {
pageNum: 1,
pageSize: 10,
energyTypeId: undefined,
rate: undefined,
currency: undefined,
effectiveDate: undefined,
expiryDate: undefined,
},
// 表单参数
form: {},
// 表单校验
rules: {
energyTypeId: [
{ required: true, message: "能源类型不能为空", trigger: "change" }
],
effectiveDate: [
{ required: true, message: "生效日期不能为空", trigger: "change" }
],
rate: [
{
validator: (rule, value, callback) => {
// 如果启用了梯度或峰谷,基础费率不是必需的
if (this.form.usePeakValley === 1 || this.form.useTieredPricing === 1) {
callback();
} else {
// 固定费率模式下,基础费率是必需的
if (!value) {
callback(new Error("固定费率模式下,基础费率不能为空"));
} else if (value < 0) {
callback(new Error("基础费率不能为负数"));
} else {
callback();
}
}
},
trigger: "change"
}
]
2025-09-28 14:38:41 +08:00
}
};
},
computed: {
/** 选中行的时段列表 */
selectedPeriodList() {
if (!this.selectedRateRow) return [];
const periods = this.rateDetailsCache[this.selectedRateRow.energyRateId]?.periods || [];
return periods;
},
/** 选中行的梯度列表 */
selectedTierList() {
if (!this.selectedRateRow) return [];
// 直接使用列表返回的梯度数据
return this.selectedRateRow.tiers || [];
},
/** 选中行的最大时段费率 */
selectedMaxPeriodRate() {
if (this.selectedPeriodList.length === 0) return 1;
const max = Math.max(...this.selectedPeriodList.map(item => item.rate || 0));
return max > 0 ? max : 1;
},
/** 选中行的最大梯度费率 */
selectedMaxTierRate() {
if (this.selectedTierList.length === 0) return 1;
const max = Math.max(...this.selectedTierList.map(item => item.rate || 0));
return max > 0 ? max : 1;
},
/** 是否是最后一步 */
isLastStep() {
if (!this.form) return false;
return this.configStep === 3;
}
},
2025-09-28 14:38:41 +08:00
created() {
this.getList();
this.getEnergyTypeList();
},
methods: {
/** 查询能源费率currency 为 INT0=CNY,1=USD,2=EUR列表 */
getList() {
this.loading = true;
listEnergyRate(this.queryParams).then(response => {
this.energyRateList = response.rows;
this.total = response.total;
this.loading = false;
});
},
getEnergyTypeList() {
listEnergyType({ pageNum: 1, pageSize: 9999 }).then(response => {
this.energyTypeList = response.rows;
});
},
// 取消按钮
cancel() {
this.open = false;
this.reset();
},
// 表单重置
reset() {
this.form = {
energyRateId: undefined,
energyTypeId: undefined,
rate: undefined,
currency: undefined,
effectiveDate: undefined,
expiryDate: undefined,
createBy: undefined,
updateBy: undefined,
createTime: undefined,
updateTime: undefined,
delFlag: undefined,
remark: undefined
};
this.resetForm("form");
},
/** 搜索按钮操作 */
handleQuery() {
this.queryParams.pageNum = 1;
this.getList();
},
/** 重置按钮操作 */
resetQuery() {
this.resetForm("queryForm");
this.handleQuery();
},
// 多选框选中数据
handleSelectionChange(selection) {
this.ids = selection.map(item => item.energyRateId)
this.single = selection.length!==1
this.multiple = !selection.length
},
/** 新增按钮操作 */
handleAdd() {
this.reset();
this.open = true;
this.title = "添加能源费率";
},
/** 修改按钮操作 */
handleUpdate(row) {
this.loading = true;
this.reset();
const energyRateId = row.energyRateId || this.ids
getEnergyRate(energyRateId).then(response => {
this.loading = false;
this.form = response.data;
this.open = true;
this.title = "修改能源费率";
});
},
/** 提交按钮 */
submitForm() {
this.$refs["form"].validate(valid => {
if (valid) {
this.buttonLoading = true;
if (this.form.energyRateId != null) {
updateEnergyRate(this.form).then(response => {
this.$modal.msgSuccess("修改成功");
this.open = false;
this.getList();
}).finally(() => {
this.buttonLoading = false;
});
} else {
addEnergyRate(this.form).then(response => {
this.$modal.msgSuccess("新增成功");
this.open = false;
this.getList();
}).finally(() => {
this.buttonLoading = false;
});
}
}
});
},
/** 删除按钮操作 */
handleDelete(row) {
const energyRateIds = row.energyRateId || this.ids;
this.$modal.confirm('是否确认删除能源费率currency 为 INT0=CNY,1=USD,2=EUR编号为"' + energyRateIds + '"的数据项?').then(() => {
this.loading = true;
return delEnergyRate(energyRateIds);
}).then(() => {
this.loading = false;
this.getList();
this.$modal.msgSuccess("删除成功");
}).catch(() => {
}).finally(() => {
this.loading = false;
});
},
getEnergyName(id) {
const item = this.energyTypeList.find(item => item.energyTypeId === id);
return item?.name ?? '';
},
getEnergyUnit(id) {
return this.energyTypeList.find(item => item.energyTypeId === id)?.unit ?? '度';
},
/** 获取费率的梯度列表 */
getRateTiers(energyRateId) {
if (!this.rateDetailsCache[energyRateId]) {
this.rateDetailsCache[energyRateId] = { tiers: [], periods: [], tierPeriodRates: {} };
getRateTiers(energyRateId).then(response => {
this.$set(this.rateDetailsCache[energyRateId], 'tiers', response.data || []);
});
}
return this.rateDetailsCache[energyRateId].tiers || [];
},
/** 获取费率的时段列表 */
getRatePeriods(energyRateId) {
if (!this.rateDetailsCache[energyRateId]) {
this.rateDetailsCache[energyRateId] = { tiers: [], periods: [], tierPeriodRates: {} };
}
if (!this.rateDetailsCache[energyRateId].periods || this.rateDetailsCache[energyRateId].periods.length === 0) {
getRateTimePeriods(energyRateId).then(response => {
this.$set(this.rateDetailsCache[energyRateId], 'periods', response.data || []);
});
}
return this.rateDetailsCache[energyRateId].periods || [];
},
/** 获取时段名称 */
getPeriodName(periodType) {
const names = {
0: '峰',
1: '谷',
2: '平'
};
return names[periodType] || '未知';
},
/** 获取时段背景颜色 */
getPeriodBgColor(periodType) {
const colors = {
0: '#FF6B6B', // 峰 - 红色
1: '#4ECDC4', // 谷 - 青色
2: '#FFD93D' // 平 - 黄色
};
return colors[periodType] || '#409EFF';
},
/** 获取梯度-时段的费率 */
getTierPeriodRate(tierId, periodId) {
if (!this.selectedRateRow || !this.selectedRateRow.tiers) return '-';
// 从梯度对象中查找对应的梯度
const tier = this.selectedRateRow.tiers.find(t => t.tierId === tierId);
if (!tier || !tier.tierPeriodRates) return '-';
// 从梯度的时段费率映射中获取该时段的费率
const rate = tier.tierPeriodRates[periodId];
return rate !== undefined && rate !== null ? rate : '-';
2025-09-28 14:38:41 +08:00
},
/** 导出按钮操作 */
handleExport() {
this.download('ems/energyRate/export', {
...this.queryParams
}, `energyRate_${new Date().getTime()}.xlsx`)
},
/** 配置按钮操作 */
handleConfig(row) {
this.loading = true;
this.form = JSON.parse(JSON.stringify(row));
this.tierList = [];
this.periodList = [];
this.configStep = 0; // 重置步骤
// 获取能源单位
const energyType = this.energyTypeList.find(item => item.energyTypeId === row.energyTypeId);
this.energyUnit = energyType ? energyType.unit : '度';
// 根据模式直接跳到对应步骤
if (this.form.usePeakValley === 0 && this.form.useTieredPricing === 0) {
// 固定费率模式,直接到预览
this.configStep = 3;
this.loading = false;
this.configOpen = true;
} else if (this.form.usePeakValley === 1 && this.form.useTieredPricing === 0) {
// 峰谷模式,进入峰谷配置
this.configStep = 1;
getRateTimePeriods(row.energyRateId).then(response => {
this.periodList = response.data || [];
if (this.periodList.length === 0) {
this.addPeriod();
}
this.loading = false;
this.configOpen = true;
});
} else if (this.form.usePeakValley === 0 && this.form.useTieredPricing === 1) {
// 梯度模式,进入梯度配置
this.configStep = 1;
getRateTiers(row.energyRateId).then(response => {
this.tierList = response.data || [];
if (this.tierList.length === 0) {
this.addTier();
}
this.loading = false;
this.configOpen = true;
});
} else {
// 组合模式(峰谷+梯度),进入峰谷配置
this.configStep = 1;
getRateTimePeriods(row.energyRateId).then(response => {
this.periodList = response.data || [];
if (this.periodList.length === 0) {
this.addPeriod();
}
// 加载梯度费率(需要在时段加载完成后)
this.loadTiersWithPeriods(row.energyRateId);
});
}
},
/** 加载梯度费率及其峰谷关联 */
loadTiersWithPeriods(energyRateId) {
if (this.form.useTieredPricing === 1) {
getRateTiers(energyRateId).then(response => {
this.tierList = response.data || [];
if (this.tierList.length === 0) {
this.addTier();
this.loading = false;
this.configOpen = true;
return;
}
// 如果是梯度+峰谷组合模式,需要加载每个梯度的峰谷费率
if (this.form.usePeakValley === 1) {
this.loadTierPeriodRates();
} else {
this.loading = false;
this.configOpen = true;
}
});
} else {
this.loading = false;
this.configOpen = true;
}
},
/** 加载梯度的峰谷费率关联 */
loadTierPeriodRates() {
// 为每个梯度加载其对应的峰谷费率
const promises = this.tierList.map(tier => {
return getTierPeriodLinks(tier.tierId).then(response => {
tier.tierPeriodRates = {};
if (response.data && response.data.length > 0) {
response.data.forEach(link => {
tier.tierPeriodRates[link.periodId] = link.rate;
});
}
}).catch(() => {
tier.tierPeriodRates = {};
});
});
Promise.all(promises).then(() => {
this.loading = false;
this.configOpen = true;
}).catch(() => {
this.loading = false;
this.configOpen = true;
});
},
/** 添加梯度 */
addTier() {
const tierLevel = this.tierList.length + 1;
this.tierList.push({
tierLevel: tierLevel,
minUsage: 0,
maxUsage: null,
rate: 0
});
},
/** 删除梯度 */
removeTier(index) {
this.tierList.splice(index, 1);
},
/** 添加时段 */
addPeriod() {
this.periodList.push({
periodType: 0,
periodName: '',
startTime: '00:00',
endTime: '23:59',
rate: 0
});
},
/** 删除时段 */
removePeriod(index) {
this.periodList.splice(index, 1);
},
/** 保存配置 */
saveConfig() {
// 验证梯度数据
if (this.form.useTieredPricing === 1) {
if (!this.validateTiers()) {
return;
}
}
// 验证时段数据
if (this.form.usePeakValley === 1) {
if (!this.validatePeriods()) {
return;
}
}
this.buttonLoading = true;
// 先保存梯度费率,然后再保存时段费率和梯度-时段关联
const savePromise = new Promise((resolve, reject) => {
const promises = [];
// 1. 保存梯度费率
if (this.form.useTieredPricing === 1 && this.tierList.length > 0) {
// 处理梯度数据:最后一个梯度的 maxUsage 应该为 null
const tiersToSave = this.tierList.map((tier, index) => {
const tierCopy = JSON.parse(JSON.stringify(tier));
if (index === this.tierList.length - 1) {
// 最后一个梯度的 maxUsage 设为 null
tierCopy.maxUsage = null;
}
// 为了避免唯一约束冲突,清除 tierId 让后端重新生成
// 这样可以确保后端执行 INSERT 而不是 UPDATE
// 但需要先删除旧梯度
delete tierCopy.tierId;
return tierCopy;
});
promises.push(
saveTiers(this.form.energyRateId, tiersToSave).then(response => {
// 梯度保存成功后,更新本地梯度的 tierId保留 tierPeriodRates
if (response.data && Array.isArray(response.data)) {
// 按 tier_level 匹配,更新 tierId
response.data.forEach((savedTier) => {
const localTier = this.tierList.find(t => t.tierLevel === savedTier.tierLevel);
if (localTier) {
// 保留本地的 tierPeriodRates只更新 tierId
localTier.tierId = savedTier.tierId;
localTier.minUsage = savedTier.minUsage;
localTier.maxUsage = savedTier.maxUsage;
localTier.rate = savedTier.rate;
}
});
}
return response;
})
);
}
// 2. 保存时段费率
if (this.form.usePeakValley === 1 && this.periodList.length > 0) {
// 组合模式下不需要费率,仅峰谷模式需要费率
const validPeriods = this.form.useTieredPricing === 0
? this.periodList.filter(p => p.periodType !== null && p.periodType !== undefined && p.rate)
: this.periodList.filter(p => p.periodType !== null && p.periodType !== undefined);
if (validPeriods.length > 0) {
// 分离新增和已有的时段
const newPeriods = validPeriods.filter(p => !p.periodId);
const existingPeriods = validPeriods.filter(p => p.periodId);
// 如果有新增时段,需要先保存时段定义再保存费率
if (newPeriods.length > 0) {
// 新增时段需要先保存到 ems_time_period 表
const saveNewPeriodsPromise = new Promise((resolve, reject) => {
// 调用后端保存时段定义的接口(假设存在)
// 这里暂时直接保存费率,后端需要处理 periodId 为 null 的情况
const periodsToSave = validPeriods.map(p => {
const periodCopy = { ...p };
periodCopy.periodId = p.periodId || null; // 新增时段的 periodId 为 null
// 在组合模式下,清除费率(不需要保存到 ems_rate_time_period_link
if (this.form.useTieredPricing === 1) {
periodCopy.rate = null;
}
return periodCopy;
});
saveTimePeriods(this.form.energyRateId, periodsToSave).then(response => {
// 保存成功后,更新本地 periodList 中的 periodId
if (response.data && Array.isArray(response.data)) {
response.data.forEach((savedPeriod, index) => {
if (validPeriods[index] && !validPeriods[index].periodId) {
validPeriods[index].periodId = savedPeriod.periodId;
}
});
}
resolve(response);
}).catch(reject);
});
promises.push(saveNewPeriodsPromise);
} else {
// 只有已有的时段,直接保存
const periodsToSave = validPeriods.map(p => {
const periodCopy = { ...p };
// 在组合模式下,清除费率(不需要保存到 ems_rate_time_period_link
if (this.form.useTieredPricing === 1) {
periodCopy.rate = null;
}
return periodCopy;
});
promises.push(saveTimePeriods(this.form.energyRateId, periodsToSave));
}
}
}
if (promises.length === 0) {
this.$modal.msgWarning('请至少配置一种收费方式');
this.buttonLoading = false;
reject('没有需要保存的数据');
return;
}
// 等待梯度和时段都保存完成
Promise.all(promises).then(() => {
// 3. 保存梯度-时段关联费率(梯度+峰谷组合模式)
const tierPeriodPromises = [];
if (this.form.useTieredPricing === 1 && this.form.usePeakValley === 1) {
console.log('开始保存梯度-时段关联,梯度数量:', this.tierList.length);
this.tierList.forEach((tier, index) => {
console.log(`梯度${index}: tierId=${tier.tierId}, tierPeriodRates=${tier.tierPeriodRates ? Object.keys(tier.tierPeriodRates).length : 0}`);
if (tier.tierId && tier.tierPeriodRates && Object.keys(tier.tierPeriodRates).length > 0) {
// 将 tierPeriodRates 对象转换为数组格式
// 需要使用 periodList 中的实际 periodId而不是 tierPeriodRates 的 key
const tierPeriodLinks = this.periodList.map(period => {
// 使用 periodId 或 _key 作为 key 查找费率
const key = period.periodId || period._key;
const rate = tier.tierPeriodRates[key];
if (rate !== undefined && rate !== null) {
return {
tierId: tier.tierId,
periodId: period.periodId, // 使用真实的 periodId
rate: rate
};
}
return null;
}).filter(link => link !== null);
console.log('保存梯度-时段关联: tierId=' + tier.tierId + ', links=', tierPeriodLinks);
if (tierPeriodLinks.length > 0) {
tierPeriodPromises.push(saveTierPeriodLinks(tier.tierId, tierPeriodLinks));
}
}
});
console.log('梯度-时段关联Promise数量:', tierPeriodPromises.length);
}
if (tierPeriodPromises.length > 0) {
Promise.all(tierPeriodPromises).then(() => {
resolve();
}).catch(reject);
} else {
resolve();
}
}).catch(reject);
});
savePromise.then(() => {
this.$modal.msgSuccess('保存成功');
// 保存成功后,重新加载当前费率的配置
const energyRateId = this.form.energyRateId;
// 重新加载时段和梯度-时段关联数据(梯度已在保存时更新)
const reloadPromises = [];
if (this.form.usePeakValley === 1) {
reloadPromises.push(
getRateTimePeriods(energyRateId).then(response => {
this.periodList = response.data || [];
})
);
}
// 如果是组合模式,需要重新加载梯度-时段关联
if (this.form.useTieredPricing === 1 && this.form.usePeakValley === 1) {
reloadPromises.push(
new Promise((resolve) => {
this.loadTierPeriodRates();
resolve();
})
);
}
Promise.all(reloadPromises).then(() => {
// 延迟关闭,确保对话框完全关闭
setTimeout(() => {
this.closeConfig();
this.getList();
}, 500);
}).catch(() => {
this.$modal.msgWarning('数据刷新失败,请手动刷新');
setTimeout(() => {
this.closeConfig();
this.getList();
}, 500);
});
}).catch(() => {
this.$modal.msgError('保存失败');
}).finally(() => {
this.buttonLoading = false;
});
},
/** 将时间字符串转换为分钟数(便于比较) */
timeToMinutes(timeStr) {
if (!timeStr) return 0;
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
},
/** 检查是否跨天 */
isCrossDay(startTime, endTime) {
if (!startTime || !endTime) return false;
const startMin = this.timeToMinutes(startTime);
const endMin = this.timeToMinutes(endTime);
return startMin > endMin; // 开始时间 > 结束时间 说明跨天
},
/** 验证时段时间范围和交叉 */
validatePeriodTime(periodIndex) {
const period = this.periodList[periodIndex];
if (!period) return;
// 如果两个时间都有,检查是否交叉
if (period.startTime && period.endTime) {
const startMin = this.timeToMinutes(period.startTime);
const endMin = this.timeToMinutes(period.endTime);
const isCross = this.isCrossDay(period.startTime, period.endTime);
// 如果是跨天时段,提示用户
if (isCross) {
// 跨天时段是允许的,但要提示用户
// 不需要报错,只是记录跨天状态
period.crossDay = 1;
} else {
period.crossDay = 0;
}
// 检查与其他时段是否交叉(需要考虑跨天情况)
for (let i = 0; i < this.periodList.length; i++) {
if (i === periodIndex) continue;
const otherPeriod = this.periodList[i];
if (!otherPeriod.startTime || !otherPeriod.endTime) continue;
const otherStartMin = this.timeToMinutes(otherPeriod.startTime);
const otherEndMin = this.timeToMinutes(otherPeriod.endTime);
const otherIsCross = this.isCrossDay(otherPeriod.startTime, otherPeriod.endTime);
// 简化交叉检查:如果都不跨天,用简单逻辑;如果有跨天,需要特殊处理
let hasConflict = false;
if (!isCross && !otherIsCross) {
// 都不跨天:检查是否有重叠
hasConflict = startMin < otherEndMin && endMin > otherStartMin;
} else if (isCross && otherIsCross) {
// 都跨天:都包含午夜,肯定有冲突
hasConflict = true;
} else {
// 一个跨天一个不跨天:需要特殊检查
if (isCross) {
// 当前跨天,其他不跨天
hasConflict = !(otherEndMin <= startMin || otherStartMin >= endMin);
} else {
// 当前不跨天,其他跨天
hasConflict = !(endMin <= otherStartMin || startMin >= otherEndMin);
}
}
if (hasConflict) {
this.$modal.msgWarning(`${periodIndex + 1}个时段与第${i + 1}个时段时间交叉,请调整`);
return;
}
}
// 如果当前时段完整,自动填充下一个时段的开始时间
if (periodIndex < this.periodList.length - 1) {
const nextPeriod = this.periodList[periodIndex + 1];
if (!nextPeriod.startTime) {
// 下一个时段的开始时间 = 当前时段的结束时间 + 1分钟
const endMin = this.timeToMinutes(period.endTime);
const nextMin = endMin + 1;
const hours = Math.floor(nextMin / 60) % 24; // 防止超过24小时
const minutes = nextMin % 60;
nextPeriod.startTime = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
}
}
}
},
/** 验证所有时段数据 */
validatePeriods() {
if (this.periodList.length === 0) {
return true;
}
for (let i = 0; i < this.periodList.length; i++) {
const period = this.periodList[i];
// 检查时段类型periodType 是必需的)
if (period.periodType === null || period.periodType === undefined) {
this.$modal.msgError(`${i + 1}个时段:时段类型不能为空`);
return false;
}
// 检查开始时间
if (!period.startTime) {
this.$modal.msgError(`${i + 1}个时段:开始时间不能为空`);
return false;
}
// 检查结束时间
if (!period.endTime) {
this.$modal.msgError(`${i + 1}个时段:结束时间不能为空`);
return false;
}
// 检查时间范围(支持跨天)
const startMin = this.timeToMinutes(period.startTime);
const endMin = this.timeToMinutes(period.endTime);
const isCross = this.isCrossDay(period.startTime, period.endTime);
// 跨天时段是允许的(如 22:00 - 02:00不需要报错
// 只有当不跨天且结束时间 <= 开始时间时才报错
if (!isCross && startMin >= endMin) {
this.$modal.msgError(`${i + 1}个时段:结束时间必须晚于开始时间`);
return false;
}
// 仅在非组合模式下检查费率
if (this.form.useTieredPricing === 0) {
if (period.rate === null || period.rate === undefined) {
this.$modal.msgError(`${i + 1}个时段:费率不能为空`);
return false;
}
if (period.rate < 0) {
this.$modal.msgError(`${i + 1}个时段:费率不能为负数`);
return false;
}
}
}
return true;
},
/** 选择计价模式 */
selectMode(mode) {
switch(mode) {
case 'fixed':
this.form.usePeakValley = 0;
this.form.useTieredPricing = 0;
this.configStep = 3; // 直接到预览
break;
case 'peak':
this.form.usePeakValley = 1;
this.form.useTieredPricing = 0;
this.configStep = 1;
if (this.periodList.length === 0) {
this.addPeriod();
}
break;
case 'tier':
this.form.usePeakValley = 0;
this.form.useTieredPricing = 1;
this.configStep = 1;
if (this.tierList.length === 0) {
this.addTier();
}
break;
case 'combo':
this.form.usePeakValley = 1;
this.form.useTieredPricing = 1;
this.configStep = 1;
if (this.periodList.length === 0) {
this.addPeriod();
}
if (this.tierList.length === 0) {
this.addTier();
}
break;
}
},
/** 下一步 */
nextStep() {
// 验证当前步骤的数据
if (this.configStep === 1 && this.form.usePeakValley === 1 && this.form.useTieredPricing === 0) {
// 峰谷模式,验证时段
if (!this.validatePeriods()) {
return;
}
this.configStep = 3; // 直接到预览
} else if (this.configStep === 1 && this.form.usePeakValley === 0 && this.form.useTieredPricing === 1) {
// 梯度模式,验证梯度
if (!this.validateTiers()) {
return;
}
this.configStep = 3; // 直接到预览
} else if (this.configStep === 1 && this.form.usePeakValley === 1 && this.form.useTieredPricing === 1) {
// 组合模式,验证时段后进入梯度配置
if (!this.validatePeriods()) {
return;
}
this.configStep = 2;
} else if (this.configStep === 2) {
// 梯度配置,验证梯度后进入预览
if (!this.validateTiers()) {
return;
}
this.configStep = 3;
}
},
/** 上一步 */
prevStep() {
if (this.configStep > 0) {
if (this.configStep === 3) {
// 从预览回到配置
if (this.form.usePeakValley === 1 && this.form.useTieredPricing === 1) {
this.configStep = 2; // 组合模式回到梯度配置
} else if (this.form.usePeakValley === 1 || this.form.useTieredPricing === 1) {
this.configStep = 1; // 单一模式回到配置
} else {
this.configStep = 0; // 固定费率回到模式选择
}
} else if (this.configStep === 2) {
// 从梯度配置回到峰谷配置
this.configStep = 1;
} else if (this.configStep === 1) {
// 回到模式选择
this.configStep = 0;
}
}
},
/** 关闭配置对话框 */
closeConfig() {
this.configOpen = false;
this.configStep = 0;
this.tierList = [];
this.periodList = [];
this.energyUnit = '度';
},
/** 表格行点击事件 */
handleRowClick(row) {
// 如果点击的是同一行,则关闭条形图
if (this.selectedRateRow && this.selectedRateRow.energyRateId === row.energyRateId) {
this.selectedRateRow = null;
} else {
// 显示该行的条形图
this.selectedRateRow = row;
}
},
/** 费率字段焦点事件 */
onRateFieldFocus() {
if (this.form.usePeakValley === 1 || this.form.useTieredPricing === 1) {
this.$modal.msgInfo('已启用峰谷或梯度收费,请在配置页面设置具体费率');
}
},
/** 编辑梯度的峰谷费率 */
editTierPeriods(tier, tierIndex) {
this.currentTier = tier;
this.currentTierPeriods = this.periodList.map((period, index) => {
// 使用 periodId 作为 key必须有 periodId
const key = period.periodId;
return {
...period,
_key: key, // 保存原始 key 用于后续保存
rate: tier.tierPeriodRates && tier.tierPeriodRates[key] ? tier.tierPeriodRates[key] : null
};
});
this.tierPeriodDialogOpen = true;
},
/** 保存梯度的峰谷费率 */
saveTierPeriods() {
// 将当前梯度的峰谷费率保存到梯度对象中
if (!this.currentTier.tierPeriodRates) {
this.currentTier.tierPeriodRates = {};
}
this.currentTierPeriods.forEach(period => {
// 使用 periodId 作为 key必须有 periodId
const key = period.periodId;
this.currentTier.tierPeriodRates[key] = period.rate;
});
console.log('保存梯度峰谷费率: tierId=' + this.currentTier.tierId + ', rates=', this.currentTier.tierPeriodRates);
this.closeTierPeriodDialog();
},
/** 关闭梯度-时段编辑对话框 */
closeTierPeriodDialog() {
this.tierPeriodDialogOpen = false;
this.currentTier = null;
this.currentTierPeriods = [];
},
/** 获取最大费率 */
getMaxRate(list) {
if (!list || list.length === 0) return 1;
const max = Math.max(...list.map(item => item.rate || 0));
return max > 0 ? max : 1;
},
/** 获取梯度验证错误信息 */
getTierValidationError(tierIndex, field) {
const tier = this.tierList[tierIndex];
if (!tier) return '';
if (field === 'min') {
if (tier.minUsage === null || tier.minUsage === undefined) {
return '最小用量必填';
}
if (tier.minUsage < 0) {
return '不能为负数';
}
} else if (field === 'max') {
// 最后一个梯度的最大值可以为空(表示无上限)
if (tierIndex === this.tierList.length - 1) {
return '';
}
if (tier.maxUsage === null || tier.maxUsage === undefined) {
return '最大用量必填';
}
if (tier.minUsage !== null && tier.minUsage !== undefined) {
if (tier.maxUsage <= tier.minUsage) {
return '必须大于最小用量';
}
}
}
return '';
},
/** 验证所有梯度数据 */
validateTiers() {
if (this.tierList.length === 0) {
return true;
}
for (let i = 0; i < this.tierList.length; i++) {
const tier = this.tierList[i];
// 检查最小用量
if (tier.minUsage === null || tier.minUsage === undefined) {
this.$modal.msgError(`${i + 1}梯:最小用量不能为空`);
return false;
}
if (tier.minUsage < 0) {
this.$modal.msgError(`${i + 1}梯:最小用量不能为负数`);
return false;
}
// 检查最大用量(最后一个梯度可以为空)
if (i < this.tierList.length - 1) {
if (tier.maxUsage === null || tier.maxUsage === undefined) {
this.$modal.msgError(`${i + 1}梯:最大用量不能为空`);
return false;
}
if (tier.maxUsage <= tier.minUsage) {
this.$modal.msgError(`${i + 1}梯:最大用量必须大于最小用量`);
return false;
}
}
// 检查费率(仅梯度模式)
if (this.form.usePeakValley === 0) {
if (tier.rate === null || tier.rate === undefined) {
this.$modal.msgError(`${i + 1}梯:费率不能为空`);
return false;
}
if (tier.rate < 0) {
this.$modal.msgError(`${i + 1}梯:费率不能为负数`);
return false;
}
}
}
return true;
},
/** 梯度最小值变化时的联动处理 */
onTierMinUsageChange(tierIndex) {
const currentTier = this.tierList[tierIndex];
if (currentTier.minUsage === null || currentTier.minUsage === undefined) {
return;
}
// 如果当前梯度没有设置最大值,自动设置为当前最小值 + 最小单位
if (currentTier.maxUsage === null || currentTier.maxUsage === undefined) {
currentTier.maxUsage = parseFloat((currentTier.minUsage + 0.01).toFixed(2));
}
// 自动为下一个梯度设置最小值(当前最大值 + 最小单位)
if (tierIndex < this.tierList.length - 1) {
const nextTier = this.tierList[tierIndex + 1];
// 只有在下一个梯度的最小值为空时才自动填充
if (nextTier.minUsage === null || nextTier.minUsage === undefined) {
nextTier.minUsage = parseFloat((currentTier.maxUsage + 0.01).toFixed(2));
}
}
},
/** 梯度最大值变化时的联动处理 */
onTierMaxUsageChange(tierIndex) {
const currentTier = this.tierList[tierIndex];
if (currentTier.maxUsage === null || currentTier.maxUsage === undefined) {
return;
}
// 自动为下一个梯度设置最小值(当前最大值 + 最小单位)
if (tierIndex < this.tierList.length - 1) {
const nextTier = this.tierList[tierIndex + 1];
// 更新下一个梯度的最小值为当前最大值 + 最小单位
nextTier.minUsage = parseFloat((currentTier.maxUsage + 0.01).toFixed(2));
}
2025-09-28 14:38:41 +08:00
}
}
};
2025-09-28 14:38:41 +08:00
</script>
<style scoped>
.tier-level-badge {
display: inline-block;
background-color: #f56c6c;
color: white;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: bold;
}
.tier-actions,
.period-actions {
display: flex;
align-items: center;
justify-content: space-between;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: #909399;
}
.empty-state i {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-state p {
font-size: 14px;
margin: 0;
}
.mb10 {
margin-bottom: 10px;
}
.mb15 {
margin-bottom: 15px;
}
.ml10 {
margin-left: 10px;
}
/* 表格优化 */
.el-table {
font-size: 13px;
}
.el-table /deep/ .el-input-number {
width: 100%;
}
.el-table /deep/ .el-select {
width: 100%;
}
.el-table /deep/ .el-time-picker {
width: 100%;
}
/* 对话框优化 */
.el-dialog /deep/ .el-dialog__body {
padding: 20px;
max-height: 600px;
overflow-y: auto;
}
/* Alert 优化 */
.el-alert {
border-radius: 4px;
}
/* 提示文本 */
.tip-text {
display: block;
color: #e6a23c;
font-size: 12px;
margin-top: 4px;
}
/* 费率信息样式 */
.rate-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.rate-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: bold;
text-align: center;
}
.rate-badge.peak {
background-color: #f56c6c;
color: white;
}
.rate-badge.tier {
background-color: #409eff;
color: white;
}
.rate-badge.fixed {
background-color: #67c23a;
color: white;
}
/* 向导式样式 */
.wizard-steps {
margin-bottom: 30px;
}
.wizard-content {
min-height: 400px;
}
.step-content {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* 模式选择卡片 */
.mode-selection {
text-align: center;
}
.mode-selection h3 {
margin-bottom: 30px;
font-size: 18px;
color: #303133;
}
.mode-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.mode-card {
padding: 20px;
border: 2px solid #ebeef5;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
background-color: #fff;
}
.mode-card:hover {
border-color: #409eff;
box-shadow: 0 2px 12px 0 rgba(64, 158, 255, 0.1);
transform: translateY(-2px);
}
.mode-card.active {
border-color: #409eff;
background-color: #f0f9ff;
box-shadow: 0 2px 12px 0 rgba(64, 158, 255, 0.2);
}
.mode-icon {
font-size: 32px;
margin-bottom: 10px;
}
.mode-name {
font-weight: bold;
font-size: 14px;
color: #303133;
margin-bottom: 8px;
}
.mode-desc {
font-size: 12px;
color: #909399;
}
/* 预览卡片 */
.preview-section {
padding: 20px;
}
.preview-card {
margin-bottom: 20px;
padding: 15px;
border: 1px solid #ebeef5;
border-radius: 4px;
background-color: #fafafa;
}
.card-title {
font-weight: bold;
font-size: 14px;
color: #303133;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 2px solid #409eff;
}
.card-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.preview-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
font-size: 13px;
}
.preview-item .period-badge,
.preview-item .tier-badge {
min-width: 60px;
text-align: center;
}
.preview-item .rate-value {
margin-left: auto;
color: #409eff;
font-weight: bold;
}
/* 配置页面样式 */
.config-section {
padding: 15px;
border: 1px solid #ebeef5;
border-radius: 4px;
background-color: #fafafa;
}
.section-title {
display: flex;
align-items: center;
font-size: 14px;
font-weight: bold;
color: #303133;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #409eff;
}
.section-title i {
margin-right: 8px;
color: #409eff;
font-size: 16px;
}
/* 模式提示 */
.mode-tips {
margin-bottom: 20px;
padding: 12px 16px;
background-color: #f0f9ff;
border-left: 4px solid #409eff;
border-radius: 4px;
}
.tip-badge {
display: inline-block;
padding: 6px 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
}
.tip-badge.fixed-mode {
background-color: #f0f9ff;
color: #0a5cff;
border: 1px solid #b3d8ff;
}
.tip-badge.peak-mode {
background-color: #fef0f0;
color: #f56c6c;
border: 1px solid #fde2e2;
}
.tip-badge.tier-mode {
background-color: #f0f9ff;
color: #409eff;
border: 1px solid #b3d8ff;
}
.tip-badge.combo-mode {
background-color: #f4f4f5;
color: #606266;
border: 1px solid #dcdfe6;
}
/* 费率可视化样式 */
.rate-visualization {
margin: 20px 0;
padding: 20px;
background-color: #fafafa;
border-radius: 4px;
border: 1px solid #ebeef5;
}
.visualization-section {
margin-bottom: 20px;
}
.visualization-section:last-child {
margin-bottom: 0;
}
.visualization-title {
font-size: 14px;
font-weight: bold;
color: #303133;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #409eff;
}
.bar-chart {
padding: 20px 10px;
background-color: #ffffff;
border-radius: 4px;
border: 1px solid #ebeef5;
}
.chart-container {
display: flex;
align-items: flex-end;
justify-content: space-around;
height: 250px;
gap: 15px;
}
.bar-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
max-width: 80px;
}
.bar-wrapper {
width: 100%;
height: 200px;
background-color: #f5f7fa;
border-radius: 4px 4px 0 0;
display: flex;
align-items: flex-end;
justify-content: center;
padding: 5px 0;
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05);
}
.bar {
width: 100%;
max-width: 60px;
min-height: 5%;
border-radius: 4px 4px 0 0;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.bar:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: scaleY(1.05);
}
.bar-label {
margin-top: 10px;
text-align: center;
font-size: 12px;
width: 100%;
}
.bar-label .time {
color: #606266;
font-size: 11px;
margin-bottom: 4px;
}
.bar-label .tier-info {
color: #303133;
font-weight: bold;
margin-bottom: 2px;
}
.bar-label .usage {
color: #909399;
font-size: 11px;
margin-bottom: 4px;
}
.bar-label .rate {
color: #409eff;
font-weight: bold;
font-size: 13px;
}
.tier-bar {
background: linear-gradient(180deg, #409eff 0%, #66b1ff 100%);
}
/* 梯度输入框验证样式 */
.tier-input-wrapper {
display: flex;
flex-direction: column;
gap: 4px;
}
.tier-error {
color: #f56c6c;
font-size: 11px;
line-height: 1;
}
/* 费率信息详情样式 */
.rate-info-detail {
padding: 8px 0;
}
.rate-item {
margin-bottom: 8px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
}
.rate-label {
font-weight: 600;
color: #303133;
min-width: 80px;
display: inline-block;
}
.rate-value {
color: #409eff;
font-weight: 600;
font-size: 13px;
}
/* 时段费率样式 */
.period-rates {
display: flex;
flex-wrap: wrap;
gap: 6px;
flex: 1;
}
.period-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 3px;
color: white;
font-size: 12px;
font-weight: 500;
}
/* 梯度费率样式 */
.tier-rates {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.tier-badge {
display: inline-block;
padding: 4px 8px;
background-color: #f0f9ff;
border-left: 3px solid #409eff;
color: #303133;
font-size: 12px;
border-radius: 2px;
}
/* 梯度分组样式 */
.tier-group {
margin-bottom: 12px;
padding: 8px;
background-color: #f5f7fa;
border-radius: 4px;
}
.tier-header {
font-weight: 600;
color: #303133;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid #dcdfe6;
}
/* 组合模式样式 */
.combo-rates {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.combo-tier-row {
padding: 8px;
background-color: #f9fafb;
border-radius: 3px;
border-left: 3px solid #8c8c8c;
}
.tier-header {
font-weight: 600;
color: #262626;
font-size: 13px;
margin-bottom: 6px;
}
.tier-rates-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.rate-badge {
display: inline-block;
padding: 4px 10px;
background-color: #5b8cde;
color: white;
border-radius: 2px;
font-size: 12px;
font-weight: 500;
transition: all 0.2s ease;
}
.rate-badge:hover {
background-color: #4a7bc4;
box-shadow: 0 2px 4px rgba(91, 140, 222, 0.2);
}
.combo-tier {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
padding: 4px 0;
border-bottom: 1px solid #ebeef5;
}
.combo-tier:last-child {
border-bottom: none;
}
.tier-range {
display: inline-block;
min-width: 120px;
color: #606266;
font-size: 12px;
font-weight: 500;
}
.combo-period {
display: inline-block;
padding: 3px 8px;
border-radius: 3px;
color: white;
font-size: 11px;
font-weight: 500;
}
/* 配置警告样式 */
.config-warning {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background-color: #fef0f0;
border: 1px solid #fde2e2;
border-radius: 3px;
color: #f56c6c;
font-size: 12px;
}
.config-warning i {
font-size: 14px;
}
.expiry-date {
color: #606266;
}
/* 浮动条形图样式 */
.rate-visualization-floating {
margin-top: 30px;
padding: 20px;
background-color: #fff;
border: 1px solid #ebeef5;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
animation: slideDown 0.3s ease-in;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.visualization-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 2px solid #d9d9d9;
}
.visualization-title {
font-size: 16px;
font-weight: bold;
color: #262626;
}
.visualization-section {
margin-bottom: 20px;
}
.visualization-section:last-child {
margin-bottom: 0;
}
.section-subtitle {
font-size: 14px;
font-weight: bold;
color: #595959;
margin-bottom: 15px;
padding-left: 10px;
border-left: 3px solid #8c8c8c;
}
/* 条形图容器 */
.bar-chart {
width: 100%;
height: 280px;
display: flex;
align-items: flex-end;
justify-content: center;
padding: 20px;
background-color: #f5f5f5;
border-radius: 2px;
border: 1px solid #e8e8e8;
}
.chart-container {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 30px;
width: 100%;
height: 100%;
}
.bar-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
flex: 1;
max-width: 80px;
}
.bar-wrapper {
width: 100%;
height: 150px;
display: flex;
align-items: flex-end;
justify-content: center;
background-color: #ffffff;
border-radius: 2px 2px 0 0;
border: 1px solid #e8e8e8;
border-bottom: none;
flex-shrink: 0;
}
.bar {
width: 60%;
background: #5b8cde;
border-radius: 2px 2px 0 0;
transition: all 0.3s ease;
min-height: 5%;
max-height: 100%;
overflow: hidden;
}
.bar:hover {
opacity: 0.85;
box-shadow: 0 2px 6px rgba(91, 140, 222, 0.25);
}
.bar.tier-bar {
background: #7b9e89;
}
.bar-label {
width: 100%;
text-align: center;
font-size: 12px;
}
.bar-label .time,
.bar-label .tier-info,
.bar-label .usage {
color: #606266;
margin-bottom: 4px;
}
.bar-label .rate {
color: #409eff;
font-weight: bold;
font-size: 13px;
}
/* 固定费率显示 */
.fixed-rate-display {
display: flex;
justify-content: center;
align-items: center;
padding: 40px 20px;
background-color: #fafafa;
border-radius: 4px;
}
.rate-box {
display: flex;
flex-direction: column;
align-items: center;
gap: 15px;
padding: 30px 50px;
background-color: #fff;
border: 2px solid #409eff;
border-radius: 8px;
}
.rate-box .rate-label {
font-size: 14px;
color: #606266;
}
.rate-box .rate-value {
font-size: 28px;
font-weight: bold;
color: #409eff;
}
</style>