feat: AI事前预警 — 现金流预测+预警扩展+准确率+情景建议
This commit is contained in:
@@ -113,6 +113,9 @@ export const alertRulesApi = {
|
||||
checkAll: () => api.post('/alert-rules/check-all'),
|
||||
calculateDynamic: () => api.post('/alert-rules/calculate-dynamic'),
|
||||
dynamicThresholds: (params?: any) => api.get('/alert-rules/dynamic-thresholds', { params }),
|
||||
// AI事前预警
|
||||
checkForecast: () => api.post('/alert-rules/check-forecast'),
|
||||
generateSuggestions: () => api.post('/alert-rules/generate-suggestions'),
|
||||
}
|
||||
|
||||
export const userApi = {
|
||||
@@ -174,6 +177,12 @@ export const predictApi = {
|
||||
sensitivity: (data: any) => api.post('/predict/sensitivity', data),
|
||||
scenario: (data: any) => api.post('/predict/scenario', data),
|
||||
growthQuality: (data: any) => api.post('/predict/growth-quality', data),
|
||||
// AI事前预警
|
||||
cashForecast: (data: any) => api.post('/predict/cash-forecast', data),
|
||||
cashForecastHistory: (params?: any) => api.get('/predict/cash-forecast/history', { params }),
|
||||
forecastAccuracy: (params?: any) => api.get('/predict/accuracy', { params }),
|
||||
scenarioSuggestions: (params?: any) => api.get('/predict/scenario-suggestions', { params }),
|
||||
generateSuggestion: (data: any) => api.post('/predict/scenario-suggestion/generate', data),
|
||||
}
|
||||
|
||||
export const deviationPushApi = {
|
||||
|
||||
@@ -27,6 +27,7 @@ const routes = [
|
||||
{ path: 'deviations', name: 'DeviationDashboard', component: () => import('@/views/DeviationDashboard.vue'), meta: { title: '差异分析', roles: ['ceo', 'finance', 'business', 'it'] } },
|
||||
{ path: 'cost', name: 'CostDashboard', component: () => import('@/views/CostDashboard.vue'), meta: { title: '成本分析', roles: ['ceo', 'finance', 'it'] } },
|
||||
{ path: 'predict', name: 'PredictDashboard', component: () => import('@/views/PredictDashboard.vue'), meta: { title: '预测模拟', roles: ['ceo', 'finance', 'it'] } },
|
||||
{ path: 'predict/accuracy', name: 'PredictAccuracy', component: () => import('@/views/PredictAccuracy.vue'), meta: { title: '预测准确率', roles: ['ceo', 'finance', 'it'] } },
|
||||
{ path: 'real-options', name: 'RealOptions', component: () => import('@/views/RealOptions.vue'), meta: { title: '实物期权计算器', roles: ['ceo', 'finance'] } },
|
||||
{ path: 'action-plans', name: 'ActionPlans', component: () => import('@/views/ActionPlanLibrary.vue'), meta: { title: '改善行动', roles: ['ceo', 'finance', 'business', 'it'] } },
|
||||
{ path: 'reports', name: 'ReportCenter', component: () => import('@/views/ReportCenter.vue'), meta: { title: '管理报表', roles: ['ceo', 'finance', 'business'] } },
|
||||
|
||||
@@ -39,11 +39,11 @@
|
||||
</div>
|
||||
|
||||
<el-table :data="alerts" v-loading="loading" style="width:100%" border stripe size="small">
|
||||
<el-table-column prop="alert_message" label="预警信息" min-width="320">
|
||||
<el-table-column prop="alert_message" label="预警信息" min-width="300">
|
||||
<template #default="{ row }">
|
||||
<div style="display:flex;align-items:center;gap:4px;">
|
||||
<el-tag v-if="isTrendAlert(row.alert_message)" size="small" type="info" effect="plain">趋势</el-tag>
|
||||
<el-tag v-else size="small" type="primary" effect="plain">静态</el-tag>
|
||||
<el-tag v-if="row.alert_type === 'forecast'" size="small" type="warning" effect="plain">🔮预测</el-tag>
|
||||
<el-tag v-else size="small" type="primary" effect="plain">实际</el-tag>
|
||||
<span>{{ row.alert_message }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -147,6 +147,8 @@
|
||||
<el-option label="已禁用" :value="0" />
|
||||
</el-select>
|
||||
<el-button type="primary" size="small" @click="loadRules">查询</el-button>
|
||||
<el-button size="small" @click="checkForecastAlerts" :loading="fcLoading">🔮 检查预测值</el-button>
|
||||
<el-button size="small" @click="generateAlertSuggestions" :loading="sgLoading">💡 生成情景建议</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="rules" v-loading="rulesLoading" style="width:100%" border stripe size="small">
|
||||
@@ -254,7 +256,7 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑预警规则弹窗 -->
|
||||
<el-dialog v-model="showRuleForm" title="编辑预警规则" width="500px">
|
||||
<el-dialog v-model="showRuleForm" title="编辑预警规则" width="520px">
|
||||
<el-form :model="ruleForm" label-width="110px" size="small">
|
||||
<el-form-item label="规则类型">
|
||||
<el-select v-model="ruleForm.rule_type" style="width:100%">
|
||||
@@ -264,6 +266,14 @@
|
||||
<el-option label="下降趋势预警" value="trend_down" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="触发条件">
|
||||
<el-select v-model="ruleForm.trigger_on" style="width:100%">
|
||||
<el-option label="实际值超限" value="actual" />
|
||||
<el-option label="预测值超限" value="forecast" />
|
||||
<el-option label="两者都触发" value="both" />
|
||||
</el-select>
|
||||
<span style="color:#909399;font-size:12px;margin-top:2px;display:block;">预测值超限时,系统会检查未来7天现金流预测是否触及阈值</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否启用">
|
||||
<el-switch v-model="ruleForm.enabled" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
@@ -366,6 +376,7 @@ const calcLoading = ref(false)
|
||||
const ruleForm = ref({
|
||||
kpi_id: null as number | null,
|
||||
rule_type: 'static',
|
||||
trigger_on: 'actual',
|
||||
enabled: 1,
|
||||
params: { green: '', yellow: '', red: '', sensitivity: 1.0, threshold_pct: 10 },
|
||||
})
|
||||
@@ -467,6 +478,7 @@ function editRule(row: any) {
|
||||
ruleForm.value = {
|
||||
kpi_id: row.kpi_id,
|
||||
rule_type: row.rule_type,
|
||||
trigger_on: row.trigger_on || 'actual',
|
||||
enabled: row.enabled,
|
||||
params: { green: '', yellow: '', red: '', sensitivity: 1.0, threshold_pct: 10, ...(params || {}) },
|
||||
}
|
||||
@@ -478,6 +490,7 @@ async function saveRule() {
|
||||
try {
|
||||
const data = {
|
||||
rule_type: ruleForm.value.rule_type,
|
||||
trigger_on: ruleForm.value.trigger_on,
|
||||
enabled: ruleForm.value.enabled,
|
||||
params: ruleForm.value.params,
|
||||
}
|
||||
@@ -514,6 +527,29 @@ async function generateRules() {
|
||||
generating.value = false
|
||||
}
|
||||
|
||||
async function checkForecastAlerts() {
|
||||
fcLoading.value = true
|
||||
try {
|
||||
const r: any = await alertRulesApi.checkForecast()
|
||||
ElMessage.success(r.message || '预测值检查完成')
|
||||
loadAlerts()
|
||||
} catch (e) { ElMessage.error('检查失败') }
|
||||
fcLoading.value = false
|
||||
}
|
||||
|
||||
async function generateAlertSuggestions() {
|
||||
sgLoading.value = true
|
||||
try {
|
||||
const r: any = await alertRulesApi.generateSuggestions()
|
||||
ElMessage.success(r.message || '情景建议已生成')
|
||||
loadAlerts()
|
||||
} catch (e) { ElMessage.error('生成失败') }
|
||||
sgLoading.value = false
|
||||
}
|
||||
|
||||
const fcLoading = ref(false)
|
||||
const sgLoading = ref(false)
|
||||
|
||||
async function calcDynamic() {
|
||||
calcLoading.value = true
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h3>预测准确率报表</h3>
|
||||
<div class="header-actions">
|
||||
<el-select v-model="entityId" size="small" style="width:200px;" @change="loadAccuracy">
|
||||
<el-option label="陕西酣客(白酒经销)" :value="1" />
|
||||
<el-option label="陕西博海科技(IT服务)" :value="2" />
|
||||
</el-select>
|
||||
<el-button size="small" @click="loadAccuracy" :loading="loading" type="primary">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 汇总卡片 -->
|
||||
<el-row :gutter="16" style="margin-bottom:16px;">
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never">
|
||||
<div class="summary-card">
|
||||
<div class="s-label">平均绝对误差 (MAE)</div>
|
||||
<div class="s-value" :class="summary.avg_mae < 5 ? 'green' : summary.avg_mae < 10 ? 'orange' : 'red'">
|
||||
{{ summary.avg_mae?.toFixed(2) || '--' }}
|
||||
<span style="font-size:12px;color:#909399;">万元</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never">
|
||||
<div class="summary-card">
|
||||
<div class="s-label">平均百分比误差 (MAPE)</div>
|
||||
<div class="s-value" :class="summary.avg_mape < 15 ? 'green' : summary.avg_mape < 30 ? 'orange' : 'red'">
|
||||
{{ summary.avg_mape?.toFixed(2) || '--' }}
|
||||
<span style="font-size:12px;color:#909399;">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never">
|
||||
<div class="summary-card">
|
||||
<div class="s-label">统计周期数</div>
|
||||
<div class="s-value" style="color:#409eff;">
|
||||
{{ summary.total_periods || 0 }}
|
||||
<span style="font-size:12px;color:#909399;">个月</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 准确率趋势图 -->
|
||||
<el-card style="margin-bottom:16px;">
|
||||
<template #header>
|
||||
<span>📈 预测准确率趋势(MAE/MAPE)</span>
|
||||
</template>
|
||||
<div ref="accuracyChartRef" style="height:360px;width:100%;"></div>
|
||||
</el-card>
|
||||
|
||||
<!-- 每月明细 -->
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>📋 每月预测 vs 实际对比</span>
|
||||
<span style="margin-left:12px;font-size:12px;color:#999;" v-if="accuracyData.length">共 {{ accuracyData.length }} 期</span>
|
||||
</template>
|
||||
<el-table :data="accuracyData" v-loading="loading" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="period" label="期间" width="100" />
|
||||
<el-table-column prop="forecast_value" label="预测值(万)" width="130" align="right">
|
||||
<template #default="{ row }">{{ row.forecast_value?.toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="actual_value" label="实际值(万)" width="130" align="right">
|
||||
<template #default="{ row }">{{ row.actual_value?.toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="差异(万)" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: Math.abs(row.forecast_value - row.actual_value) < 5 ? '#67c23a' : '#f56c6c', fontWeight:600 }">
|
||||
{{ (row.forecast_value - row.actual_value) > 0 ? '+' : '' }}{{ (row.forecast_value - row.actual_value)?.toFixed(2) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="mae" label="MAE(万)" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.mae < 5 ? '#67c23a' : row.mae < 10 ? '#e6a23c' : '#f56c6c' }">{{ row.mae?.toFixed(2) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="mape" label="MAPE(%)" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.mape < 15 ? '#67c23a' : row.mape < 30 ? '#e6a23c' : '#f56c6c' }">{{ row.mape?.toFixed(2) }}%</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="评级" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.mape < 15 ? 'success' : row.mape < 30 ? 'warning' : 'danger'" size="small">
|
||||
{{ row.mape < 15 ? '准确' : row.mape < 30 ? '一般' : '偏差大' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { predictApi } from '../api/index'
|
||||
|
||||
const loading = ref(false)
|
||||
const entityId = ref(1)
|
||||
const accuracyData = ref<any[]>([])
|
||||
const summary = ref<any>({})
|
||||
const accuracyChartRef = ref<HTMLElement | null>(null)
|
||||
let accuracyChart: any = null
|
||||
|
||||
async function loadAccuracy() {
|
||||
loading.value = true
|
||||
try {
|
||||
const r: any = await predictApi.forecastAccuracy({ entity_id: entityId.value })
|
||||
accuracyData.value = r.data || []
|
||||
summary.value = r.summary || {}
|
||||
nextTick(() => renderAccuracyChart())
|
||||
} catch (e) {
|
||||
ElMessage.error('加载预测准确率失败')
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function renderAccuracyChart() {
|
||||
if (!accuracyChartRef.value || !accuracyData.value.length) return
|
||||
import('echarts').then(echarts => {
|
||||
if (accuracyChart) accuracyChart.dispose()
|
||||
accuracyChart = echarts.init(accuracyChartRef.value!)
|
||||
|
||||
const periods = accuracyData.value.map((d: any) => d.period)
|
||||
const maeData = accuracyData.value.map((d: any) => d.mae)
|
||||
const mapeData = accuracyData.value.map((d: any) => d.mape)
|
||||
const forecastVals = accuracyData.value.map((d: any) => d.forecast_value)
|
||||
const actualVals = accuracyData.value.map((d: any) => d.actual_value)
|
||||
|
||||
accuracyChart.setOption({
|
||||
grid: [{ left: 60, right: 30, top: 20, bottom: 50 }, { left: 60, right: 30, top: 320, bottom: 50 }],
|
||||
xAxis: [
|
||||
{ type: 'category', data: periods, axisLabel: { rotate: 45, fontSize: 10 }, gridIndex: 0 },
|
||||
{ type: 'category', data: periods, axisLabel: { rotate: 45, fontSize: 10 }, gridIndex: 1 },
|
||||
],
|
||||
yAxis: [
|
||||
{ type: 'value', name: '误差', gridIndex: 0 },
|
||||
{ type: 'value', name: '现金(万)', gridIndex: 1 },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: 'MAE', type: 'bar', data: maeData, xAxisIndex: 0, yAxisIndex: 0,
|
||||
itemStyle: { color: '#409eff', borderRadius: [4, 4, 0, 0] },
|
||||
barWidth: '30%',
|
||||
},
|
||||
{
|
||||
name: 'MAPE(%)', type: 'line', data: mapeData, xAxisIndex: 0, yAxisIndex: 0,
|
||||
lineStyle: { color: '#e6a23c', width: 2 },
|
||||
symbol: 'circle', symbolSize: 6,
|
||||
itemStyle: { color: '#e6a23c' },
|
||||
},
|
||||
{
|
||||
name: '预测值', type: 'line', data: forecastVals, xAxisIndex: 1, yAxisIndex: 1,
|
||||
lineStyle: { color: '#409eff', width: 2 },
|
||||
symbol: 'diamond', symbolSize: 8,
|
||||
itemStyle: { color: '#409eff' },
|
||||
},
|
||||
{
|
||||
name: '实际值', type: 'line', data: actualVals, xAxisIndex: 1, yAxisIndex: 1,
|
||||
lineStyle: { color: '#67c23a', width: 2 },
|
||||
symbol: 'circle', symbolSize: 6,
|
||||
itemStyle: { color: '#67c23a' },
|
||||
},
|
||||
],
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { bottom: 0, icon: 'circle', itemWidth: 8, itemHeight: 8 },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAccuracy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:16px; }
|
||||
.page-header h3 { margin:0; }
|
||||
.header-actions { display:flex; gap:8px; }
|
||||
.summary-card { text-align:center; padding:8px 0; }
|
||||
.s-label { font-size:13px; color:#909399; margin-bottom:8px; }
|
||||
.s-value { font-size:28px; font-weight:bold; }
|
||||
.s-value.green { color:#67c23a; }
|
||||
.s-value.orange { color:#e6a23c; }
|
||||
.s-value.red { color:#f56c6c; }
|
||||
</style>
|
||||
@@ -216,6 +216,111 @@
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 5: 现金流预测(AI事前预警) -->
|
||||
<el-tab-pane label="现金流预测" name="cashFlow">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6">
|
||||
<el-card>
|
||||
<template #header>预测参数</template>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="企业实体">
|
||||
<el-select v-model="cfEntityId" style="width:100%;">
|
||||
<el-option label="陕西酣客(白酒经销)" :value="1" />
|
||||
<el-option label="陕西博海科技(IT服务)" :value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="当前现金余额(万元)">
|
||||
<el-input-number v-model="cfCurrentCash" :min="0" :precision="1" style="width:100%;" />
|
||||
</el-form-item>
|
||||
<el-form-item label="预测天数">
|
||||
<el-select v-model="cfDays" style="width:100%;">
|
||||
<el-option label="未来7天" :value="7" />
|
||||
<el-option label="未来15天" :value="15" />
|
||||
<el-option label="未来30天" :value="30" />
|
||||
<el-option label="未来60天" :value="60" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="doCashForecast" :loading="cfLoading" style="width:100%;">🔮 开始预测</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<!-- 趋势指标 -->
|
||||
<el-card style="margin-top:12px;" v-if="cfResult.trends">
|
||||
<template #header>📊 趋势指标</template>
|
||||
<div class="trend-item"><span class="t-label">经营现金流趋势</span><span class="t-value" :class="cfResult.trends.operating_cash_flow_trend_pct >= 0 ? 'green' : 'red'">{{ cfResult.trends.operating_cash_flow_trend_pct }}%</span></div>
|
||||
<div class="trend-item"><span class="t-label">应收账款趋势</span><span class="t-value" :class="cfResult.trends.receivables_trend_pct >= 0 ? 'red' : 'green'">{{ cfResult.trends.receivables_trend_pct }}%</span></div>
|
||||
<div class="trend-item"><span class="t-label">应付账款趋势</span><span class="t-value" :class="cfResult.trends.payables_trend_pct >= 0 ? 'green' : 'red'">{{ cfResult.trends.payables_trend_pct }}%</span></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="18">
|
||||
<!-- 预测折线图 -->
|
||||
<el-card v-if="cfResult.forecast?.length">
|
||||
<template #header>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<span>📈 现金流预测 — 未来{{ cfDays }}天</span>
|
||||
<span>
|
||||
<span style="font-size:12px;color:#909399;">基准现金:</span>
|
||||
<strong>{{ cfResult.base_cash }}万</strong>
|
||||
<span style="margin-left:12px;font-size:12px;color:#909399;">最低点:</span>
|
||||
<strong :style="{color: cfResult.min_cash < 10 ? '#f56c6c' : cfResult.min_cash < 20 ? '#e6a23c' : '#67c23a'}">{{ cfResult.min_cash }}万</strong>
|
||||
<span style="margin-left:4px;font-size:12px;color:#909399;">({{ cfResult.min_cash_date }})</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="cfChartRef" style="height:380px;width:100%;"></div>
|
||||
</el-card>
|
||||
<el-empty v-else-if="!cfLoading" description="设置参数后点击「开始预测」" style="padding:60px 0;" />
|
||||
|
||||
<!-- 预警建议 -->
|
||||
<el-card style="margin-top:16px;" v-if="cfResult.suggestions?.length">
|
||||
<template #header>💡 预警建议</template>
|
||||
<div v-for="(sug, idx) in cfResult.suggestions" :key="idx" style="margin-bottom:12px;">
|
||||
<el-alert
|
||||
:title="sug.message"
|
||||
:type="sug.type === 'critical' ? 'error' : 'warning'"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom:8px;"
|
||||
/>
|
||||
<el-tag v-for="(act, aidx) in sug.actions" :key="aidx" style="margin:2px 4px 2px 0;" size="small" effect="plain">
|
||||
✅ {{ act }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 每日预测明细表 -->
|
||||
<el-card style="margin-top:16px;" v-if="cfResult.forecast?.length">
|
||||
<template #header>
|
||||
<span>📋 每日预测明细</span>
|
||||
<span style="margin-left:12px;font-size:12px;color:#999;">(共{{ cfResult.forecast.length }}天)</span>
|
||||
</template>
|
||||
<el-table :data="cfResult.forecast" border stripe size="small" max-height="300" style="width:100%;">
|
||||
<el-table-column prop="date" label="日期" width="110" />
|
||||
<el-table-column prop="predicted_cash" label="预测现金(万)" width="130" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.predicted_cash < 10 ? '#f56c6c' : row.predicted_cash < 20 ? '#e6a23c' : '#67c23a', fontWeight:600 }">{{ row.predicted_cash?.toFixed(2) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="lower_bound" label="下界(万)" width="110" align="right">
|
||||
<template #default="{ row }">{{ row.lower_bound?.toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="upper_bound" label="上界(万)" width="110" align="right">
|
||||
<template #default="{ row }">{{ row.upper_bound?.toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="alert_status" label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.alert_status === 'red' ? 'danger' : row.alert_status === 'yellow' ? 'warning' : 'success'" size="small">
|
||||
{{ row.alert_status === 'red' ? '🔴预警' : row.alert_status === 'yellow' ? '🟡关注' : '🟢安全' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
@@ -359,6 +464,141 @@ async function doScenario() {
|
||||
} catch { ElMessage.error('情景模拟失败') }
|
||||
scenarioLoading.value = false
|
||||
}
|
||||
|
||||
// ── 现金流预测(AI事前预警) ──
|
||||
const cfLoading = ref(false)
|
||||
const cfEntityId = ref(1)
|
||||
const cfCurrentCash = ref(30)
|
||||
const cfDays = ref(30)
|
||||
const cfResult = ref<any>({})
|
||||
const cfChartRef = ref<HTMLElement | null>(null)
|
||||
let cfChart: any = null
|
||||
|
||||
async function doCashForecast() {
|
||||
cfLoading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
entity_id: cfEntityId.value,
|
||||
days: cfDays.value,
|
||||
current_cash: cfCurrentCash.value,
|
||||
}
|
||||
cfResult.value = await predictApi.cashForecast(payload) as any
|
||||
nextTick(() => renderCfChart())
|
||||
} catch (e: any) {
|
||||
ElMessage.error('现金流预测失败: ' + (e?.message || ''))
|
||||
}
|
||||
cfLoading.value = false
|
||||
}
|
||||
|
||||
function renderCfChart() {
|
||||
if (!cfChartRef.value || !cfResult.value.forecast?.length) return
|
||||
import('echarts').then(echarts => {
|
||||
if (cfChart) cfChart.dispose()
|
||||
cfChart = echarts.init(cfChartRef.value!)
|
||||
|
||||
const forecast = cfResult.value.forecast
|
||||
const dates = forecast.map((d: any) => d.date.slice(5))
|
||||
const predicted = forecast.map((d: any) => d.predicted_cash)
|
||||
const lower = forecast.map((d: any) => d.lower_bound)
|
||||
const upper = forecast.map((d: any) => d.upper_bound)
|
||||
|
||||
// 标记预警点
|
||||
const alertData: any[] = []
|
||||
const markAreas: any[] = []
|
||||
let lastStatus: string | null = null
|
||||
const alertRanges: { startIdx: number, endIdx: number, color: string }[] = []
|
||||
let currentRange: { startIdx: number, color: string } | null = null
|
||||
|
||||
forecast.forEach((d: any, idx: number) => {
|
||||
if (d.alert_status !== 'green') {
|
||||
if (!currentRange) {
|
||||
currentRange = { startIdx: idx, color: d.alert_status === 'red' ? '#f56c6c' : '#e6a23c' }
|
||||
}
|
||||
if (d.alert_status === 'red') {
|
||||
alertData.push({
|
||||
name: d.date,
|
||||
coord: [idx, d.predicted_cash],
|
||||
itemStyle: { color: '#f56c6c' },
|
||||
symbol: 'pin',
|
||||
symbolSize: 30,
|
||||
label: { show: true, formatter: '🔴', fontSize: 16 }
|
||||
})
|
||||
}
|
||||
} else if (currentRange) {
|
||||
alertRanges.push({ startIdx: currentRange.startIdx, endIdx: idx - 1, color: currentRange.color })
|
||||
currentRange = null
|
||||
}
|
||||
})
|
||||
if (currentRange) {
|
||||
alertRanges.push({ startIdx: currentRange.startIdx, endIdx: forecast.length - 1, color: currentRange.color })
|
||||
}
|
||||
|
||||
cfChart.setOption({
|
||||
grid: { left: 60, right: 30, top: 20, bottom: 40 },
|
||||
xAxis: { type: 'category', data: dates, axisLabel: { rotate: 45, fontSize: 10 } },
|
||||
yAxis: { type: 'value', name: '现金余额(万元)', min: 0 },
|
||||
series: [
|
||||
{
|
||||
name: '现金余额',
|
||||
type: 'line',
|
||||
data: predicted,
|
||||
smooth: true,
|
||||
lineStyle: { width: 2, color: '#409eff' },
|
||||
itemStyle: { color: '#409eff' },
|
||||
symbol: 'circle',
|
||||
symbolSize: 4,
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(64,158,255,0.25)' },
|
||||
{ offset: 1, color: 'rgba(64,158,255,0.02)' },
|
||||
]
|
||||
}
|
||||
},
|
||||
markArea: {
|
||||
silent: true,
|
||||
data: alertRanges.map(r => [{
|
||||
xAxis: r.startIdx,
|
||||
itemStyle: { color: r.color, opacity: 0.1 }
|
||||
}, {
|
||||
xAxis: r.endIdx
|
||||
}])
|
||||
},
|
||||
markPoint: { data: alertData },
|
||||
},
|
||||
{
|
||||
name: '置信区间上界',
|
||||
type: 'line',
|
||||
data: upper,
|
||||
smooth: true,
|
||||
lineStyle: { width: 1, color: '#909399', type: 'dashed' },
|
||||
symbol: 'none',
|
||||
},
|
||||
{
|
||||
name: '置信区间下界',
|
||||
type: 'line',
|
||||
data: lower,
|
||||
smooth: true,
|
||||
lineStyle: { width: 1, color: '#909399', type: 'dashed' },
|
||||
symbol: 'none',
|
||||
areaStyle: { color: 'rgba(144,147,153,0.05)' },
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: function(params: any) {
|
||||
const idx = params[0]?.dataIndex
|
||||
const d = forecast[idx]
|
||||
if (!d) return ''
|
||||
const statusLabel = d.alert_status === 'red' ? '🔴预警' : d.alert_status === 'yellow' ? '🟡关注' : '🟢安全'
|
||||
return `<strong>${d.date}</strong><br/>现金余额: <strong>${d.predicted_cash.toFixed(2)}万</strong><br/>置信区间: ${d.lower_bound.toFixed(2)} ~ ${d.upper_bound.toFixed(2)}万<br/>状态: ${statusLabel}`
|
||||
}
|
||||
},
|
||||
legend: { bottom: 0, icon: 'circle', itemWidth: 8, itemHeight: 8 },
|
||||
})
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -370,4 +610,10 @@ async function doScenario() {
|
||||
.r-value.orange { color: #e6a23c; }
|
||||
.r-value.red { color: #f56c6c; }
|
||||
.r-value.purple { color: #8b5cf6; }
|
||||
.trend-item { display:flex; justify-content:space-between; align-items:center; padding:6px 0; border-bottom:1px solid #f0f0f0; }
|
||||
.trend-item:last-child { border-bottom:none; }
|
||||
.t-label { font-size:13px; color:#606266; }
|
||||
.t-value { font-weight:600; font-size:14px; }
|
||||
.t-value.green { color:#67c23a; }
|
||||
.t-value.red { color:#f56c6c; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user