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

2522 lines
82 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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">
<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>
<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>
</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>
<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">
<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>
</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>
<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>
</div>
</template>
<script>
import { listEnergyRate, getEnergyRate, delEnergyRate, addEnergyRate, updateEnergyRate, getRateTiers, getRateTimePeriods, saveTiers, saveTimePeriods, getTierPeriodLinks, saveTierPeriodLinks } from "@/api/ems/energyRate";
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,
// 能源费率表格数据
energyRateList: [],
// 费率详情缓存energyRateId -> {tiers, periods, tierPeriodRates}
rateDetailsCache: {},
// 弹出层标题
title: "",
// 是否显示弹出层
open: false,
// 是否显示配置对话框
configOpen: false,
// 梯度费率列表
tierList: [],
// 时段费率列表
periodList: [],
// 能源单位
energyUnit: '度',
// 梯度-时段关联对话框
tierPeriodDialogOpen: false,
// 当前编辑的梯度
currentTier: null,
// 当前梯度的峰谷费率列表
currentTierPeriods: [],
// 向导步骤0=选择模式, 1=配置峰谷或梯度, 2=配置梯度(组合模式), 3=预览保存)
configStep: 0,
// 选中的费率行(用于显示条形图)
selectedRateRow: null,
// 查询参数
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"
}
]
}
};
},
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;
}
},
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 : '-';
},
/** 导出按钮操作 */
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));
}
}
}
};
</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>