feat: 路线图R1决策建议一键落地+R2机会推送+R5预算闭环

R1(P0): AI建议一键应用到KPI/预算/行动方案
- 新表 ai_suggestions + AISuggestion 模型(init_db自动建)
- /api/cma/ai/suggestions CRUD + /{id}/apply(复用kpis/budget/action_plans) + dismiss
- 应用写 OperationLog(action=ai_suggestion_apply, detail含suggestion_id/before/after)
- 规则驱动建议生成 generate_rule_suggestions(低执行率/高执行率/预算超支/pending预警)
- 幂等: 同entity+type+target_id+title+unapplied不重复建; applied后拒绝重复应用
- 前端: Dashboard AI面板建议卡(应用到/忽略) + 建议中心页 /ai-suggestions

R2(P1): 数据找人扩大-机会类推送
- scripts/opportunity_detector.py: KPI向好(执行率>110%)/预算余量(<70%且actual>0)/预测上行
- scripts/daily_push.py: 异常+机会 每日9:15推企微(8800/send, --dry-run调试)
- crontab: 15 9 * * * (alert_generator 9:00之后)

R5(P0): 预算闭环加固
- auto-decompose批量幂等: 只取年度行(period=YYYY-00)+同KPI多版本取一行
- scripts/closed_loop_check.py: 预算执行率异常→检查现金流/行动同步→缺失提示+报告
- scripts/verify_decompose_idempotent.py: 幂等验证脚本

测试: test_ai_suggestions(10例)+test_roadmap_r2r5(14例); 修test_budget幂等契约适配年度行
全量: 673 passed
This commit is contained in:
Hermes CI Fix
2026-08-30 12:05:36 +08:00
parent 5b920df8a0
commit ad68471b29
22 changed files with 2170 additions and 25 deletions
+9
View File
@@ -465,4 +465,13 @@ export const taxApi = {
seedDemo: (params?: any) => api.post('/tax/demo-data', null, { params }),
}
// ── AI决策建议(路线图R1 2026-08-30):建议→一键应用到KPI/预算/行动方案 ──
export const aiSuggestionApi = {
list: (params?: any) => api.get('/ai/suggestions', { params }),
get: (id: number) => api.get(`/ai/suggestions/${id}`),
create: (data: any) => api.post('/ai/suggestions', data),
apply: (id: number, data: any) => api.post(`/ai/suggestions/${id}/apply`, data),
dismiss: (id: number) => api.post(`/ai/suggestions/${id}/dismiss`),
}
export default api
+5 -4
View File
@@ -9,10 +9,10 @@ interface MenuItem {
// ── 角色路由映射 ──
export const ROLE_ROUTES: Record<string, string[]> = {
ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance', '/data-classification'],
ceo: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
finance: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
business: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
it: ['/my-dashboard', '/dashboard', '/ai-suggestions', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance', '/data-classification'],
}
export const ROLE_ACTIONS: Record<string, string[]> = {
@@ -42,6 +42,7 @@ export const MENU_ITEMS: MenuItem[] = [
// ── GROUP 3: 监控与评价(Check)──
{ path: '/dashboard', label: '经营看板', icon: 'DataBoard', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
{ path: '/ai-suggestions', label: 'AI建议中心', icon: 'Opportunity', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
{ path: '/reports', label: '管理报表', icon: 'DataAnalysis', roles: ['ceo', 'finance', 'business'], group: '🟡 C 监控与评价' },
{ path: '/dupont-analysis', label: '杜邦分析', icon: 'TrendCharts', roles: ['ceo', 'finance', 'it'], group: '🟡 C 监控与评价' },
{ path: '/customer', label: '客户维度', icon: 'User', roles: ['ceo', 'finance', 'business', 'it'], group: '🟡 C 监控与评价' },
+1
View File
@@ -6,6 +6,7 @@ const routes = [
{ path: '/', component: () => import('@/layouts/MainLayout.vue'), redirect: '/my-dashboard',
children: [
{ path: 'dashboard', name: 'Dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '经营看板', roles: ['ceo', 'finance', 'business', 'it'] } },
{ path: 'ai-suggestions', name: 'SuggestionCenter', component: () => import('@/views/SuggestionCenter.vue'), meta: { title: 'AI建议中心', roles: ['ceo', 'finance', 'business', 'it'] } },
{ path: 'kpis', name: 'KPIs', component: () => import('@/views/KPIList.vue'), meta: { title: 'KPI字典', roles: ['ceo', 'finance', 'business', 'it'], editable: true } },
{ path: 'kpis/:id', name: 'KPIDetail', component: () => import('@/views/KPIDetail.vue'), meta: { title: 'KPI详情', roles: ['ceo', 'finance', 'business', 'it'], editable: true } },
{ path: 'maps', name: 'Maps', component: () => import('@/views/MapList.vue'), meta: { title: '战略地图', roles: ['ceo', 'finance'], editable: true } },
+170 -8
View File
@@ -205,17 +205,87 @@
</el-card>
</template>
<!-- AI分析浮窗 -->
<!-- AI分析浮窗R1AI建议一键落地 -->
<div v-if="showSidebar" class="ai-panel">
<div class="ai-head"><span>🤖 AI 分析</span><el-button text size="small" @click="showSidebar = false"></el-button></div>
<div class="ai-head">
<span>🤖 AI 分析</span>
<el-button text size="small" @click="router.push('/ai-suggestions')">建议中心</el-button>
<el-button text size="small" @click="showSidebar = false"></el-button>
</div>
<div class="ai-body">
<div class="ai-section-title">📋 决策建议</div>
<div v-if="suggestionsLoading" class="ai-loading"><el-icon class="is-loading" :size="16"><Loading /></el-icon><p>加载建议...</p></div>
<div v-else-if="suggestions.length === 0" class="ai-empty">暂无待应用建议</div>
<div v-else class="sug-list">
<div v-for="s in suggestions" :key="s.id" class="sug-card" :class="'type-' + s.suggestion_type">
<div class="sug-top">
<el-tag size="small" :type="sugTagType(s.suggestion_type)">{{ sugTypeLabel(s.suggestion_type) }}</el-tag>
<el-tag v-if="s.status === 'applied'" size="small" type="success">已应用</el-tag>
<el-tag v-else size="small" type="info">未应用</el-tag>
</div>
<div class="sug-title">{{ s.title }}</div>
<div class="sug-content">{{ s.content }}</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>
<span v-if="s.status === 'applied'" class="sug-applied-by"> {{ s.applied_by }} {{ (s.applied_at || '').slice(0, 16) }} 应用</span>
</div>
</div>
</div>
<el-divider />
<div class="ai-section-title">💡 AI分析文本</div>
<div v-if="aiLoading" class="ai-loading"><el-icon class="is-loading" :size="20"><Loading /></el-icon><p>分析中...</p></div>
<div v-else-if="aiAnalysis" class="ai-content" v-html="renderMd(aiAnalysis)"></div>
<el-empty v-else description="暂无分析" />
</div>
<div class="ai-foot"><el-button size="small" type="primary" @click="refreshAnalysis" :loading="aiLoading">刷新</el-button></div>
<div class="ai-foot">
<el-button size="small" type="primary" @click="refreshAnalysis" :loading="aiLoading">刷新分析</el-button>
</div>
</div>
<!-- 应用建议弹窗 -->
<el-dialog v-model="showApplyDialog" :title="'应用到:' + (applySug?.title || '')" width="520px">
<el-form label-width="100px">
<template v-if="applySug?.suggestion_type === 'kpi_target'">
<el-form-item label="KPI"><el-input :model-value="applyKpiName" 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>
<div class="apply-tip">应用后将修改KPI目标值并写入操作日志留痕</div>
</template>
<template v-else-if="applySug?.suggestion_type === 'budget_adjust'">
<el-form-item label="KPI"><el-input :model-value="applyKpiName" 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>
<div class="apply-tip">应用后将新增/更新该KPI对应期间的预算并写入操作日志留痕</div>
</template>
<template v-else>
<el-form-item label="关联KPI"><el-input :model-value="applyKpiName" 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>
<div class="apply-tip">应用后将创建行动方案并写入操作日志留痕</div>
</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>
<!-- KPI详情弹窗 -->
<el-dialog v-model="showDetail" :title="selectedKPI?.kpi_name" width="500px">
<div v-if="selectedKPI">
@@ -241,7 +311,7 @@ import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Loading, ArrowRight } from '@element-plus/icons-vue'
import { dashboardApi, alertApi } from '../api/index'
import { dashboardApi, alertApi, aiSuggestionApi } from '../api/index'
import axios from 'axios'
import CountUp from '../components/CountUp.vue'
import GrowthQuality from '../components/GrowthQuality.vue'
@@ -299,6 +369,81 @@ const enabledChannels = ref(0)
const aiAnalysis = ref('')
const aiLoading = ref(false)
// R1: AI决策建议(建议卡 + 应用到弹窗)
const suggestions = ref<any[]>([])
const suggestionsLoading = ref(false)
const showApplyDialog = ref(false)
const applySug = ref<any>(null)
const applyForm = ref<any>({})
const applying = ref(false)
const applyKpiName = ref('')
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')
async function loadSuggestions() {
suggestionsLoading.value = true
try {
const r: any = await aiSuggestionApi.list({ status: 'unapplied' })
suggestions.value = (r as any)?.data || []
} catch { suggestions.value = [] }
suggestionsLoading.value = false
}
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 || '',
}
applyKpiName.value = s.target_name || ''
showApplyDialog.value = true
}
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 loadSuggestions()
} 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 loadSuggestions()
} catch { ElMessage.error('忽略失败') }
}
const aiApi = axios.create({ baseURL: '/api/cma', timeout: 30000 })
aiApi.interceptors.request.use((config: any) => {
const token = localStorage.getItem('cma_token')
@@ -376,8 +521,8 @@ async function loadKPIAnalysis() {
if (!selectedKPI.value?.id) return
loadingDetail.value = true
try {
const r: any = await aiApi.post('/ai/kpi-analysis', { kpi_id: selectedKPI.value.id, period: periodType.value })
selectedKPIDetail.value = r.data || r.analysis || '暂无分析结果'
const r: any = await aiApi.get(`/ai/kpi-analysis/${selectedKPI.value.id}`)
selectedKPIDetail.value = r.data?.analysis || r.analysis || '暂无分析结果'
} catch { selectedKPIDetail.value = 'AI分析请求失败' }
loadingDetail.value = false
}
@@ -385,8 +530,10 @@ async function loadKPIAnalysis() {
async function refreshAnalysis() {
aiLoading.value = true
try {
const r: any = await aiApi.post('/ai/dashboard-analysis', { role: userRole.value, period: periodType.value })
aiAnalysis.value = r.data || r.analysis || '暂无分析'
const r: any = await aiApi.get('/ai/dashboard-analysis', { params: { role: userRole.value } })
aiAnalysis.value = r.data?.analysis || r.analysis || '暂无分析'
// 建议列表从服务端拉取(含dashboard分析自动生成的规则建议)
await loadSuggestions()
} catch { aiAnalysis.value = 'AI分析暂时不可用' }
aiLoading.value = false
}
@@ -555,6 +702,21 @@ onMounted(() => { loadData() })
.ai-foot { padding:10px 16px; border-top:1px solid #f0f0f0; }
.ai-loading { text-align:center; padding:30px 0; color:#999; }
/* R1: 建议卡 */
.ai-section-title { font-size:13px; font-weight:600; color:#333; margin-bottom:10px; }
.ai-empty { text-align:center; color:#bbb; padding:16px 0; font-size:12px; }
.sug-list { display:flex; flex-direction:column; gap:10px; }
.sug-card { border:1px solid #eee; border-radius:8px; padding:10px 12px; background:#fafafa; border-left:3px 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-top { display:flex; gap:6px; margin-bottom:6px; }
.sug-title { font-size:13px; font-weight:600; color:#333; margin-bottom:4px; }
.sug-content { font-size:12px; color:#666; line-height:1.5; margin-bottom:8px; }
.sug-foot { display:flex; gap:6px; align-items:center; }
.sug-applied-by { font-size:11px; color:#999; }
.apply-tip { font-size:12px; color:#999; padding:0 0 10px 100px; }
/* KPI详情弹窗 */
.dlg-summary { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; }
.kpi-bar.large { height:10px; background:#f0f0f0; border-radius:5px; overflow:hidden; }
+204
View File
@@ -0,0 +1,204 @@
<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="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">
<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 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
}
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; }
</style>