248 lines
13 KiB
Vue
248 lines
13 KiB
Vue
<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>
|