Files
cma-management/frontend/src/views/SuggestionCenter.vue
T
wecom-fullstack f51fc72ac7
ci/woodpecker/push/main Pipeline failed
style(page-title): B 全站规范——页面内同名标题清零 + 增量副标题弱化 + 标题 emoji 换 Lucide
- 39+ 页删除与布局标题同名/基本同名的页面内 h1-h4(同屏三标题→两处)
- 增量副标题弱化为 14px #64748B 小字:收付款计划/战略回顾/地图版本/会计科目打标
- 标题 emoji 清零,保留标题处改 Lucide 内联 SVG(bar-chart-3/video/scale/clipboard-list/alert-circle/map/history)
- KPIListView 增加 hideTitle(仅展示),客户/学习看板隐藏组件内重复页头
- 不动 MainLayout、不改接口/业务逻辑、不碰 MapCanvas.vue
2026-10-18 19:33:11 +08:00

257 lines
14 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">
<div class="head-right">
<el-radio-group v-model="typeFilter" size="small" @change="loadList">
<el-radio-button value="">全部类型</el-radio-button>
<el-radio-button value="kpi_target">改KPI目标</el-radio-button>
<el-radio-button value="budget_adjust">调预算</el-radio-button>
<el-radio-button value="action_plan">建行动方案</el-radio-button>
</el-radio-group>
<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>
<!-- 应用建议弹窗 -->
<MyDialog v-model="showApplyDialog" :title="'应用到:' + (applySug?.title || '')" :width="520">
<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>
</MyDialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { aiSuggestionApi } from '../api/index'
import MyDialog from '../components/MyDialog.vue'
// 待批列表默认"未应用"(高危建议=人拍板入口,应用过的历史不占首屏);类型过滤支持高危两类+行动方案
const statusFilter = ref('unapplied')
const typeFilter = 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: '规则', bot: 'Bot建议' }[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
if (typeFilter.value) params.suggestion_type = typeFilter.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>