Files
cma-management/frontend/src/views/SuggestionCenter.vue
T

248 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="sug-center-page">
<div class="page-head">
<h3>🤖 AI决策建议中心</h3>
<div class="head-right">
<el-radio-group v-model="statusFilter" size="small" @change="loadList">
<el-radio-button value="">全部</el-radio-button>
<el-radio-button value="unapplied">未应用</el-radio-button>
<el-radio-button value="applied">已应用</el-radio-button>
<el-radio-button value="dismissed">已忽略</el-radio-button>
</el-radio-group>
<el-button size="small" type="primary" @click="loadList" :loading="loading">刷新</el-button>
</div>
</div>
<div class="sug-grid">
<el-empty v-if="!loading && items.length === 0" description="暂无建议" />
<div v-for="s in items" :key="s.id" class="sug-card" :class="['type-' + s.suggestion_type, s.status]">
<div class="sug-top">
<el-tag size="small" :type="sugTagType(s.suggestion_type)">{{ sugTypeLabel(s.suggestion_type) }}</el-tag>
<el-tag size="small" :type="s.category === 'alert' ? 'danger' : 'success'">{{ s.category === 'alert' ? '预警' : '决策' }}</el-tag>
<el-tag size="small" :type="statusTagType(s.status)">{{ statusLabel(s.status) }}</el-tag>
<span class="sug-source">来源: {{ sourceLabel(s.source) }}</span>
</div>
<div class="sug-title">{{ s.title }}</div>
<div class="sug-content">{{ s.content }}</div>
<div class="sug-meta">
<span>创建: {{ (s.created_at || '').slice(0, 16) }}</span>
<span v-if="s.status === 'applied'" class="applied-info"> {{ s.applied_by }} {{ (s.applied_at || '').slice(0, 16) }} 应用</span>
</div>
<div v-if="s.status === 'applied' && s.apply_detail?.length" class="apply-detail">
<div v-for="(d, i) in s.apply_detail" :key="i" class="apply-detail-item">
<el-tag size="mini" type="info">{{ applyTargetLabel(d.target_type) }}</el-tag>
<span>{{ d.target_name }}</span>
<span class="before-after">改前: {{ fmtVal(d.before) }} 改后: {{ fmtVal(d.after) }}</span>
</div>
</div>
<div class="sug-foot">
<el-button v-if="s.status === 'unapplied'" size="small" type="primary" @click="openApplyDialog(s)">应用到</el-button>
<el-button v-if="s.status === 'unapplied'" size="small" @click="dismissSuggestion(s)">忽略</el-button>
<el-button size="small" @click="toggleDetail(s)">{{ s.showDetail ? '收起' : '参数详情' }}</el-button>
</div>
<pre v-if="s.showDetail" class="sug-json">{{ JSON.stringify(s.suggestion_data, null, 2) }}</pre>
</div>
</div>
<!-- 应用建议弹窗 -->
<el-dialog v-model="showApplyDialog" :title="'应用到:' + (applySug?.title || '')" width="520px">
<div v-if="previewLoading" class="preview-box">预览加载中...</div>
<div v-else-if="previewData" class="preview-box">
<div class="preview-title">📋 将变更什么</div>
<template v-if="previewData.type === 'kpi_target'">
<div class="preview-row"><span>KPI</span><b>{{ previewData.kpi_name }}</b></div>
<div class="preview-row"><span>目标值</span><b class="from">{{ fmtVal(previewData.current_target) }}</b><span class="arrow"></span><b class="to">{{ fmtVal(previewData.new_target) }}</b></div>
</template>
<template v-else-if="previewData.type === 'budget_adjust'">
<div class="preview-row"><span>KPI</span><b>{{ previewData.kpi_name }}</b></div>
<div class="preview-row"><span>期间</span><b>{{ previewData.period }}</b></div>
<div class="preview-row"><span>预算值</span><b class="from">{{ fmtVal(previewData.current_budget) }}</b><span class="arrow"></span><b class="to">{{ fmtVal(previewData.new_budget) }}</b></div>
</template>
<template v-else>
<div class="preview-row"><span>KPI</span><b>{{ previewData.kpi_name }}</b></div>
<div class="preview-row"><span>计划</span><b>{{ previewData.plan_title }}</b></div>
<div class="preview-row"><span>负责人</span><b>{{ previewData.assignee || '-' }}</b></div>
<div class="preview-row"><span>优先级</span><b>{{ { high: '高', medium: '中', low: '低' }[previewData.priority] || previewData.priority }}</b></div>
<div class="preview-row"><span>截止</span><b>{{ previewData.due_date || '-' }}</b></div>
</template>
<div class="preview-tip">确认后将写入系统并留痕请核对后操作</div>
</div>
<el-form label-width="100px">
<template v-if="applySug?.suggestion_type === 'kpi_target'">
<el-form-item label="KPI ID"><el-input :model-value="applySug?.target_id" disabled /></el-form-item>
<el-form-item label="新目标值" required>
<el-input-number v-model="applyForm.target_value" :precision="2" style="width:220px" />
</el-form-item>
</template>
<template v-else-if="applySug?.suggestion_type === 'budget_adjust'">
<el-form-item label="KPI ID"><el-input :model-value="applySug?.target_id" disabled /></el-form-item>
<el-form-item label="期间" required>
<el-input v-model="applyForm.period" placeholder="2026-09" style="width:220px" />
</el-form-item>
<el-form-item label="预算值" required>
<el-input-number v-model="applyForm.budget_value" :precision="2" style="width:220px" />
</el-form-item>
</template>
<template v-else>
<el-form-item label="KPI ID"><el-input :model-value="applySug?.target_id" disabled /></el-form-item>
<el-form-item label="计划标题" required><el-input v-model="applyForm.title" /></el-form-item>
<el-form-item label="负责人"><el-input v-model="applyForm.assignee" /></el-form-item>
<el-form-item label="优先级">
<el-select v-model="applyForm.priority" style="width:220px">
<el-option label="高" value="high" />
<el-option label="中" value="medium" />
<el-option label="低" value="low" />
</el-select>
</el-form-item>
<el-form-item label="截止日期">
<el-date-picker v-model="applyForm.due_date" type="date" value-format="YYYY-MM-DD" style="width:220px" />
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="showApplyDialog = false">取消</el-button>
<el-button type="primary" :loading="applying" @click="submitApply">确认应用</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { aiSuggestionApi } from '../api/index'
const statusFilter = ref('')
const items = ref<any[]>([])
const loading = ref(false)
const showApplyDialog = ref(false)
const applySug = ref<any>(null)
const applyForm = ref<any>({})
const applying = ref(false)
const previewData = ref<any>(null)
const previewLoading = ref(false)
const sugTypeLabel = (t: string) => ({ kpi_target: '改KPI目标', budget_adjust: '调预算', action_plan: '建行动方案' }[t] || t)
const sugTagType = (t: string) => ({ kpi_target: 'warning', budget_adjust: 'danger', action_plan: 'primary' }[t] || 'info')
const statusLabel = (s: string) => ({ unapplied: '未应用', applied: '已应用', dismissed: '已忽略' }[s] || s)
const statusTagType = (s: string) => ({ unapplied: 'info', applied: 'success', dismissed: 'info' }[s] || 'info')
const sourceLabel = (s: string) => ({ dashboard: '看板分析', kpi: 'KPI分析', budget: '预算分析', manual: '手动', rule: '规则' }[s] || s)
const applyTargetLabel = (t: string) => ({ kpi: 'KPI', budget: '预算', action_plan: '行动方案', alert: '预警' }[t] || t)
function fmtVal(v: any): string {
if (v == null) return '-'
return Number.isInteger(v) ? String(v) : Number(v).toFixed(2)
}
async function loadList() {
loading.value = true
try {
const params: any = {}
if (statusFilter.value) params.status = statusFilter.value
const r: any = await aiSuggestionApi.list(params)
items.value = ((r as any)?.data || []).map((s: any) => ({ ...s, showDetail: false }))
} catch { items.value = [] }
loading.value = false
}
function toggleDetail(s: any) { s.showDetail = !s.showDetail }
function openApplyDialog(s: any) {
applySug.value = s
const sd = s.suggestion_data || {}
applyForm.value = {
target_value: sd.target_value ?? null,
period: sd.period || '',
budget_value: sd.budget_value ?? null,
title: sd.title || '',
assignee: sd.assignee || '',
priority: sd.priority || 'medium',
due_date: sd.due_date || '',
}
showApplyDialog.value = true
// 应用前预览:加载"将变更什么"对比信息
previewData.value = null
previewLoading.value = true
aiSuggestionApi.preview(s.id).then((r: any) => {
previewData.value = (r as any)?.data || null
}).catch(() => {
previewData.value = null
}).finally(() => {
previewLoading.value = false
})
}
async function submitApply() {
if (!applySug.value) return
const action = applySug.value.suggestion_type
const body: any = { action, kpi_id: applySug.value.target_id || applySug.value.suggestion_data?.kpi_id }
if (action === 'kpi_target') {
if (applyForm.value.target_value == null) { ElMessage.warning('请输入新目标值'); return }
body.target_value = applyForm.value.target_value
} else if (action === 'budget_adjust') {
if (!applyForm.value.period || applyForm.value.budget_value == null) { ElMessage.warning('请填写期间和预算值'); return }
body.period = applyForm.value.period
body.budget_value = applyForm.value.budget_value
} else {
if (!applyForm.value.title) { ElMessage.warning('请输入计划标题'); return }
body.title = applyForm.value.title
body.assignee = applyForm.value.assignee
body.priority = applyForm.value.priority
body.due_date = applyForm.value.due_date
}
applying.value = true
try {
await aiSuggestionApi.apply(applySug.value.id, body)
ElMessage.success('建议已应用并留痕')
showApplyDialog.value = false
await loadList()
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '应用失败')
}
applying.value = false
}
async function dismissSuggestion(s: any) {
try {
await aiSuggestionApi.dismiss(s.id)
ElMessage.success('建议已忽略')
await loadList()
} catch { ElMessage.error('忽略失败') }
}
onMounted(loadList)
</script>
<style scoped>
.sug-center-page { max-width: 1200px; margin: 0 auto; padding: 20px; }
.page-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.head-right { display: flex; gap: 12px; align-items: center; }
.sug-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 14px; }
.sug-card { background: #fff; border-radius: 10px; padding: 14px 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); border: 1px solid #f0f0f0; border-left: 4px solid #909399; }
.sug-card.type-kpi_target { border-left-color: #e6a23c; }
.sug-card.type-budget_adjust { border-left-color: #f56c6c; }
.sug-card.type-action_plan { border-left-color: #409eff; }
.sug-card.applied { background: #f8fbf8; }
.sug-card.dismissed { opacity: .6; }
.sug-top { display: flex; gap: 6px; align-items: center; margin-bottom: 8px; }
.sug-source { font-size: 11px; color: #aaa; margin-left: auto; }
.sug-title { font-size: 14px; font-weight: 600; color: #333; margin-bottom: 6px; }
.sug-content { font-size: 13px; color: #666; line-height: 1.6; margin-bottom: 10px; }
.sug-meta { font-size: 11px; color: #aaa; margin-bottom: 8px; display: flex; gap: 14px; }
.applied-info { color: #67c23a; }
.apply-detail { background: #f5f7fa; border-radius: 6px; padding: 8px 10px; margin-bottom: 8px; font-size: 12px; color: #555; display: flex; flex-direction: column; gap: 4px; }
.apply-detail-item { display: flex; gap: 8px; align-items: center; }
.before-after { color: #888; }
.sug-foot { display: flex; gap: 6px; }
.sug-json { background: #f8f8f8; border-radius: 6px; padding: 8px; font-size: 11px; color: #666; margin-top: 8px; max-height: 160px; overflow: auto; }
.preview-box { background: #f5f9ff; border: 1px solid #d6e4ff; border-radius: 8px; padding: 10px 12px; margin-bottom: 14px; font-size: 13px; color: #555; }
.preview-title { font-size: 13px; font-weight: 600; color: #409eff; margin-bottom: 8px; }
.preview-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.preview-row span:first-child { min-width: 56px; color: #888; font-size: 12px; }
.preview-row b { color: #333; }
.preview-row .from { color: #999; text-decoration: line-through; font-weight: 400; }
.preview-row .to { color: #f56c6c; }
.preview-row .arrow { color: #409eff; font-weight: 600; }
.preview-tip { font-size: 11px; color: #aaa; margin-top: 6px; }
</style>