2522 lines
82 KiB
Vue
2522 lines
82 KiB
Vue
<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 为 INT:0=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 为 INT:0=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>
|