Files
cma-management/frontend/src/views/MapReview.vue
T
Hermes CI Fix 9f787e628b feat: 复盘结果持久化 — 大PDCA Act闭环(P1, yanxue-review-persistence)
- review_records表+模型(entity_id多租户)
- API: POST/GET/GET:id /maps/{id}/review-records
- 前端: MapReview 保存复盘结论弹窗+历史记录时间线(并发agent完成主体, 本项目补齐script逻辑)
- 验证: POST 200保存成功/GET列表/跨企业404/pytest 11 passed/构建部署
2026-08-27 15:59:55 +08:00

683 lines
30 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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="review-page">
<!-- 顶部 -->
<div class="review-header">
<div class="header-left">
<el-button text @click="$router.push('/maps')"> 返回战略地图</el-button>
<el-button text type="primary" @click="goCanvas" v-if="selectedMap">🗺 打开画布</el-button>
<h3>{{ mapTitle }} · 战略回顾</h3>
</div>
<div class="header-right">
<el-select v-model="selectedMap" placeholder="选择地图" style="width:200px;margin-right:8px;" @change="loadReview">
<el-option v-for="m in maps" :key="m.id" :label="m.title" :value="m.id" />
</el-select>
<el-radio-group v-model="period" size="small">
<el-radio-button value="month">本月</el-radio-button>
<el-radio-button value="quarter">本季</el-radio-button>
<el-radio-button value="year">本年</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 会议类型选择 -->
<div class="meeting-type-bar">
<el-radio-group v-model="meetingType" size="default" @change="onMeetingTypeChange">
<el-radio-button value="strategic">
<span style="font-weight:600;">🔵 战略回顾会季度</span>
</el-radio-button>
<el-radio-button value="operational">
<span style="font-weight:600;">🟢 运营复盘会月度</span>
</el-radio-button>
</el-radio-group>
<el-tag v-if="meetingType === 'strategic'" type="primary" size="small" style="margin-left:12px;">
议程:①战略地图O检查 因果链验证 O调整 下季度优先级
</el-tag>
<el-tag v-else type="success" size="small" style="margin-left:12px;">
议程①OKR进度检查 ②KPI达标情况 ③偏差分析 ④改善行动确认
</el-tag>
</div>
<!-- 复盘结论持久化工具条大PDCA Act闭环 2026-08-27 -->
<div class="review-toolbar">
<el-button type="primary" :disabled="!selectedMap || !summary" @click="openSaveDialog">
📝 保存本次复盘结论
</el-button>
<el-button :disabled="!selectedMap" @click="showHistoryDialog = true">
📚 历史复盘记录
<el-badge v-if="reviewRecords.length > 0" :value="reviewRecords.length" type="primary" style="margin-left:6px;" />
</el-button>
</div>
<!-- 运营复盘会特殊内容 -->
<template v-if="meetingType === 'operational' && !loading && summary">
<div class="op-agenda-cards">
<div class="agenda-card">
<div class="agenda-card-title"> OKR进度检查</div>
<div class="agenda-card-body">
<div v-for="dim in dimensions" :key="dim.key" class="agenda-dim-row">
<span class="agenda-dim-icon">{{ dim.icon }}</span>
<span class="agenda-dim-name">{{ dim.name }}</span>
<span class="agenda-dim-count">{{ dim.objectives?.length || 0 }} 个O</span>
</div>
</div>
</div>
<div class="agenda-card">
<div class="agenda-card-title"> KPI达标情况</div>
<div class="agenda-card-body">
<div class="kpi-summary-row"><span class="green-dot">🟢</span> 达成: {{ summary.green || 0 }} </div>
<div class="kpi-summary-row"><span class="yellow-dot">🟡</span> 预警: {{ summary.yellow || 0 }} </div>
<div class="kpi-summary-row"><span class="red-dot">🔴</span> 异常: {{ summary.red || 0 }} </div>
</div>
</div>
<div class="agenda-card">
<div class="agenda-card-title"> 偏差分析</div>
<div class="agenda-card-body">
<div v-if="deviations.length > 0">
<div v-for="d in deviations.slice(0,5)" :key="d.id" class="deviation-row">
<span class="deviation-badge" :class="'badge-' + (d.alert_level || 'yellow')">{{ d.alert_level === 'red' ? '🔴' : '🟡' }}</span>
<span class="deviation-kpi">{{ d.kpi_name || d.kpi_code }}</span>
<span class="deviation-rate">{{ d.deviation_rate != null ? d.deviation_rate.toFixed(1) + '%' : '-' }}</span>
</div>
<el-empty v-if="deviations.length === 0" description="本期无偏差" :image-size="60" />
</div>
<div v-else class="no-data">加载中...</div>
</div>
</div>
<div class="agenda-card">
<div class="agenda-card-title"> 改善行动确认</div>
<div class="agenda-card-body">
<div class="kpi-summary-row"> 已完成: {{ doneCount() }} </div>
<div class="kpi-summary-row">🔄 进行中: {{ inProgressCount() }} </div>
<div class="kpi-summary-row"> 待处理: {{ pendingCount() }} </div>
<div class="kpi-summary-row" v-if="overdueCount() > 0">🔴 逾期: {{ overdueCount() }} </div>
<el-button size="small" type="primary" style="margin-top:10px;" @click="$router.push('/action-plans')">查看改善行动 →</el-button>
</div>
</div>
</div>
</template>
<!-- 战略回顾会数据联动卡片 -->
<template v-if="meetingType === 'strategic' && !loading && summary">
<div class="linkage-cards">
<div class="linkage-card">
<div class="linkage-card-title">📊 各层O完成情况</div>
<div class="linkage-card-body">
<div v-for="dim in dimensions" :key="dim.key" class="linkage-dim-row">
<span class="linkage-dim-icon">{{ dim.icon }}</span>
<div class="linkage-dim-info">
<span class="linkage-dim-name">{{ dim.name }}</span>
<span class="linkage-dim-ratio">
{{ dim.objectives?.filter((o:any) => o.level === 'green').length || 0 }}/{{ dim.objectives?.length || 0 }} 达成
</span>
</div>
</div>
<el-empty v-if="dimensions.length === 0" description="暂无O数据" :image-size="60" />
</div>
</div>
<div class="linkage-card">
<div class="linkage-card-title">📈 KR进度</div>
<div class="linkage-card-body">
<div v-if="krs.length > 0">
<div v-for="kr in krs.slice(0,8)" :key="kr.id" class="kr-row">
<span class="kr-name">{{ kr.name || kr.title }}</span>
<el-progress :percentage="kr.progress || 0" :stroke-width="10" style="width:100px;" />
</div>
</div>
<div v-else class="no-data">从OKR模块同步中...</div>
</div>
</div>
<div class="linkage-card">
<div class="linkage-card-title">🚨 偏差项</div>
<div class="linkage-card-body">
<div v-if="deviations.length > 0">
<div v-for="d in deviations.slice(0,8)" :key="d.id" class="deviation-row">
<span class="deviation-badge" :class="'badge-' + (d.alert_level || 'yellow')">{{ d.alert_level === 'red' ? '🔴' : '🟡' }}</span>
<span class="deviation-kpi">{{ d.kpi_name || d.kpi_code }}</span>
<span class="deviation-rate">{{ d.deviation_rate != null ? d.deviation_rate.toFixed(1) + '%' : '-' }}</span>
</div>
<el-button size="small" style="margin-top:8px;" @click="$router.push('/deviations')">查看全部偏差 →</el-button>
</div>
<el-empty v-else-if="!loadingDeviations" description="本期无偏差" :image-size="60" />
<div v-else class="no-data">加载中...</div>
</div>
</div>
<div class="linkage-card">
<div class="linkage-card-title">📋 上期改善行动完成情况</div>
<div class="linkage-card-body">
<div class="kpi-summary-row"> 已完成: {{ doneCount() }} </div>
<div class="kpi-summary-row">🔄 进行中: {{ inProgressCount() }} </div>
<div class="kpi-summary-row"> 待处理: {{ pendingCount() }} </div>
<div class="kpi-summary-row" v-if="overdueCount() > 0">🔴 逾期: {{ overdueCount() }} </div>
<div v-if="actionPlans.length > 0" class="action-plan-preview">
<div v-for="p in actionPlans.slice(0,4)" :key="p.id" class="ap-row">
<span class="ap-status">{{ planIcon(p) }}</span>
<span class="ap-title">{{ p.title }}</span>
</div>
</div>
<el-empty v-else-if="actionPlans.length === 0 && !loading" description="无上期改善行动" :image-size="60" />
</div>
</div>
</div>
</template>
<!-- 加载中 -->
<div v-if="loading" class="loading-wrap">
<el-skeleton :rows="6" animated />
</div>
<template v-if="!loading && summary">
<!-- 总体得分卡片 -->
<div class="summary-cards">
<div class="s-card overall">
<div class="s-card-top">
<span class="score">{{ summary.health_score }}%</span>
<span class="score-label">整体健康度</span>
</div>
<div class="s-card-btm">
<span class="stat-item green">🟢 {{ summary.green }}个达成</span>
<span class="stat-item yellow">🟡 {{ summary.yellow }}个预警</span>
<span class="stat-item red">🔴 {{ summary.red }}个异常</span>
</div>
</div>
</div>
<!-- 四维度矩阵 -->
<div class="dim-matrix">
<div v-for="dim in dimensions" :key="dim.key" class="dim-col" :style="{ borderLeft: '4px solid ' + dim.color }">
<div class="dim-head">
<span class="dim-icon">{{ dim.icon }}</span>
<span class="dim-name">{{ dim.name }}</span>
</div>
<div class="dim-body">
<div v-for="obj in dim.objectives" :key="obj.name"
class="obj-row"
:class="'obj-' + obj.level"
@click="showObjKpis(obj)">
<span class="obj-badge">{{ badgeIcon(obj.level) }}</span>
<div class="obj-info">
<span class="obj-name">{{ obj.name }}</span>
<span v-if="obj.has_data" class="obj-ratio">
<template v-for="k in obj.kpis.slice(0,2)" :key="k.kpi_code">
{{ fmtValue(k.actual_value, k.kpi_code) }}/{{ k.target_value ?? '-' }}
</template>
</span>
</div>
</div>
</div>
</div>
</div>
<!-- 🔴 需重点关注 -->
<div v-if="focusItems.length > 0" class="focus-section">
<h4 class="focus-title">🔴 需重点关注</h4>
<div v-for="item in focusItems" :key="item.name" class="focus-card" :class="'focus-' + item.level" @click="showObjKpis(item)">
<div class="focus-head">
<span class="focus-level">{{ item.level === 'red' ? '紧急' : '预警' }}</span>
<span class="focus-name">{{ item.name }}</span>
</div>
<div class="focus-kpis">
<div v-for="k in item.kpis" :key="k.kpi_code" class="fkpi-row">
<span class="fkpi-name">{{ k.kpi_name }}</span>
<span class="fkpi-val" :style="{ color: k.level === 'red' ? '#f56c6c' : '#e6a23c' }">
{{ fmtValue(k.actual_value, k.kpi_code) }} / {{ k.target_value ?? '-' }}
</span>
</div>
</div>
<!-- 关联的改善行动 -->
<div v-if="getPlansByKpi(item.kpis)" class="focus-plans">
<div v-for="p in getPlansByKpi(item.kpis)" :key="p.id" class="fplan-row" :class="'plan-' + planStatus(p)">
<span class="fplan-status">{{ planIcon(p) }}</span>
<span class="fplan-title">{{ p.title }}</span>
<span class="fplan-assignee">{{ p.assignee }}</span>
<span v-if="p.due_date && p.status !== 'completed'" class="fplan-due">{{ dueText(p) }}</span>
</div>
</div>
</div>
</div>
<!-- 📋 改善行动进度 -->
<div v-if="actionPlans.length > 0" class="plans-section">
<h4 class="plans-title">📋 改善行动进度</h4>
<div class="plans-stats">
<el-tag>{{ pendingCount() }} 待处理</el-tag>
<el-tag type="warning">{{ inProgressCount() }} 进行中</el-tag>
<el-tag type="success">{{ doneCount() }} 已完成</el-tag>
<el-tag type="danger">{{ overdueCount() }} 逾期</el-tag>
</div>
<el-table :data="actionPlans" size="small" style="width:100%" @row-click="(r:any)=>$router.push('/action-plans?kpi='+r.kpi_id)">
<el-table-column prop="title" label="行动标题" min-width="200" />
<el-table-column prop="assignee" label="负责人" width="120" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="planTagType(row)" size="small">{{ planLabel(row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="进度" width="120">
<template #default="{ row }">
<el-progress :percentage="row.progress || 0" :status="row.status==='completed'?'success':undefined" :stroke-width="12" />
</template>
</el-table-column>
<el-table-column label="截止" width="120">
<template #default="{ row }">
<span :style="{ color: isOverdue(row) ? '#f56c6c' : '#666' }">{{ row.due_date ? row.due_date.slice(0,10) : '-' }}</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
<el-empty v-if="!loading && !summary && selectedMap" description="暂无战略回顾数据" />
<!-- KPI详情弹出 -->
<el-dialog v-model="showKpiDialog" :title="selectedObjName" width="600">
<div v-for="k in selectedKpis" :key="k.kpi_code" class="kpi-detail-row" :class="'kpi-' + k.level">
<div class="kpi-detail-head">
<span class="kpi-detail-badge">{{ badgeIcon(k.level) }}</span>
<span class="kpi-detail-name">{{ k.kpi_name }} ({{ k.kpi_code }})</span>
</div>
<div class="kpi-detail-body">
<div class="kpi-detail-item">
<span class="label">实际值</span>
<span class="val">{{ fmtValue(k.actual_value, k.kpi_code) }}</span>
</div>
<div class="kpi-detail-item">
<span class="label">目标值</span>
<span class="val">{{ k.target_value ?? '-' }} {{ k.unit }}</span>
</div>
<div class="kpi-detail-item" v-if="k.actual_value != null && k.target_value">
<span class="label">达成率</span>
<span class="val" :style="{ color: k.level === 'red' ? '#f56c6c' : 'inherit' }">
{{ (k.actual_value / k.target_value * 100).toFixed(1) }}%
</span>
</div>
<el-button size="small" @click="$router.push('/kpis/'+k.kpi_id)" style="margin-top:8px;">查看KPI详情 →</el-button>
</div>
</div>
</el-dialog>
<!-- 保存复盘结论弹窗(大PDCA Act闭环 2026-08-27 -->
<el-dialog v-model="saveDialogVisible" title="📝 保存复盘结论" width="560">
<el-form label-width="110px">
<el-form-item label="复盘类型">
<el-select v-model="reviewForm.review_type" style="width: 200px;">
<el-option label="月度运营复盘" value="monthly" />
<el-option label="季度战略回顾" value="quarterly" />
</el-select>
</el-form-item>
<el-form-item label="复盘日期">
<el-date-picker v-model="reviewForm.review_date" type="date" value-format="YYYY-MM-DD" style="width: 200px;" />
</el-form-item>
<el-form-item label="复盘结论">
<el-input v-model="reviewForm.summary" type="textarea" :rows="3"
placeholder="为什么没达成 / 关键偏差原因 / 需要怎么调整" />
</el-form-item>
<el-form-item label="调整项(JSON)">
<el-input v-model="reviewForm.adjustments_text" type="textarea" :rows="2"
placeholder='[{"type":"kpi","target":"F_REVENUE","action":"调高目标"}]' />
</el-form-item>
<el-form-item label="下期优先级">
<el-input v-model="reviewForm.next_priorities_text" type="textarea" :rows="2"
placeholder='["聚焦营收增长", "改善现金流"]' />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="saveDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveReview">保存</el-button>
</template>
</el-dialog>
<!-- 历史复盘记录弹窗 -->
<el-dialog v-model="showHistoryDialog" title="📚 历史复盘记录" width="600">
<div v-if="reviewRecords.length === 0" style="color:#909399;padding:20px;text-align:center;">暂无历史复盘记录</div>
<el-timeline v-else>
<el-timeline-item v-for="rec in reviewRecords" :key="rec.id" :timestamp="rec.review_date + ' · ' + (rec.review_type === 'quarterly' ? '季度战略回顾' : '月度运营复盘')" placement="top">
<el-card shadow="never" style="border:1px solid #e2e8f0;">
<div style="font-size:13px;color:#334155;white-space:pre-wrap;">{{ rec.summary }}</div>
<div v-if="rec.next_priorities?.length" style="margin-top:8px;font-size:12px;color:#64748b;">
下期优先级:{{ rec.next_priorities.join(' / ') }}
</div>
</el-card>
</el-timeline-item>
</el-timeline>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from "vue"
import { useRoute, useRouter } from "vue-router"
import { ElMessage } from "element-plus"
import api from "../api/index"
const route = useRoute()
const $router = useRouter()
// ── 状态 ──
const maps = ref<any[]>([])
const selectedMap = ref<number | null>(null)
const mapTitle = ref("")
const loading = ref(false)
const period = ref("month")
const summary = ref<any>(null)
const dimensions = ref<any[]>([])
const focusItems = ref<any[]>([])
const actionPlans = ref<any[]>([])
const showKpiDialog = ref(false)
// ── 复盘持久化(大PDCA Act闭环 2026-08-27 ──
const reviewRecords = ref<any[]>([])
const saveDialogVisible = ref(false)
const showHistoryDialog = ref(false)
const saving = ref(false)
const reviewForm = reactive({
review_type: "quarterly",
review_date: "",
summary: "",
adjustments_text: "",
next_priorities_text: "",
})
const selectedObjName = ref("")
const selectedKpis = ref<any[]>([])
// ── 双会议模板状态 ──
const meetingType = ref("strategic")
const deviations = ref<any[]>([])
const krs = ref<any[]>([])
const loadingDeviations = ref(false)
// ── 计算属性 ──
const pendingCount = () => actionPlans.value.filter((p:any) => p.status === 'pending').length
const inProgressCount = () => actionPlans.value.filter((p:any) => p.status === 'in_progress').length
const doneCount = () => actionPlans.value.filter((p:any) => p.status === 'completed').length
const overdueCount = () => actionPlans.value.filter((p:any) => p.status !== 'completed' && isOverdue(p)).length
// ── 加载 ──
async function loadReview() {
if (!selectedMap.value) return
loading.value = true
try {
const r: any = await api.get(`/maps/${selectedMap.value}/review`)
mapTitle.value = r.title
summary.value = r.summary
dimensions.value = r.dimensions || []
focusItems.value = r.focus_items || []
actionPlans.value = r.action_plans || []
// 联动加载偏差数据
await loadDeviations()
await loadKrs()
await loadReviewRecords()
} catch (e: any) {
ElMessage.error("加载战略回顾数据失败")
} finally {
loading.value = false
}
}
// ── 加载偏差数据(从差异分析模块联动) ──
async function loadDeviations() {
loadingDeviations.value = true
try {
const r: any = await api.get('/deviations', { params: { hierarchical: true, page_size: 20 } })
const data = r.data || r.items || r || []
deviations.value = Array.isArray(data) ? data : []
} catch (_) {
deviations.value = []
} finally {
loadingDeviations.value = false
}
}
// ── 加载KR进度数据 ──
async function loadKrs() {
try {
const r: any = await api.get('/maps/' + selectedMap.value + '/krs')
krs.value = r.data || r || []
} catch (_) {
krs.value = []
}
}
// ── 复盘记录加载/保存(大PDCA Act闭环 2026-08-27 ──
async function loadReviewRecords() {
if (!selectedMap.value) return
try {
const r: any = await api.get(`/maps/${selectedMap.value}/review-records`)
reviewRecords.value = r.data || []
} catch (_) {
reviewRecords.value = []
}
}
function openSaveDialog() {
reviewForm.review_type = "quarterly"
reviewForm.review_date = new Date().toISOString().slice(0, 10)
reviewForm.summary = ""
reviewForm.adjustments_text = ""
reviewForm.next_priorities_text = ""
saveDialogVisible.value = true
}
async function saveReview() {
if (!selectedMap.value || !reviewForm.summary.trim()) {
ElMessage.warning("请填写复盘结论")
return
}
saving.value = true
try {
let adjustments: any[] = []
let nextPriorities: string[] = []
try { adjustments = reviewForm.adjustments_text ? JSON.parse(reviewForm.adjustments_text) : [] } catch { ElMessage.warning("调整项JSON格式错误"); return }
try { nextPriorities = reviewForm.next_priorities_text ? JSON.parse(reviewForm.next_priorities_text) : [] } catch { ElMessage.warning("优先级JSON格式错误"); return }
await api.post(`/maps/${selectedMap.value}/review-records`, {
review_type: reviewForm.review_type,
review_date: reviewForm.review_date || new Date().toISOString().slice(0, 10),
summary: reviewForm.summary,
adjustments,
next_priorities: nextPriorities,
})
ElMessage.success("复盘结论已保存")
saveDialogVisible.value = false
await loadReviewRecords()
} catch (e: any) {
ElMessage.error("保存失败: " + (e?.message || ""))
} finally {
saving.value = false
}
}
// ── 会议类型切换 ──
function onMeetingTypeChange() {
if (selectedMap.value) {
loadReview()
}
}
// ── 工具函数 ──
function badgeIcon(level: string) {
return level === 'green' ? '🟢' : level === 'yellow' ? '🟡' : level === 'red' ? '🔴' : '⚪'
}
function fmtValue(val: any, code: string) {
if (val == null) return '-'
if (typeof val === 'number') {
if (Math.abs(val) >= 10000) return (val / 10000).toFixed(1) + '万'
return val.toLocaleString()
}
return val
}
function showObjKpis(obj: any) {
selectedObjName.value = obj.name
selectedKpis.value = obj.kpis || []
showKpiDialog.value = true
}
function getPlansByKpi(kpis: any[]) {
if (!kpis || !actionPlans.value.length) return null
const kpiIds = kpis.map((k:any) => k.kpi_id)
return actionPlans.value.filter((p:any) => kpiIds.includes(p.kpi_id))
}
function planStatus(p: any) {
if (p.status === 'completed') return 'done'
if (isOverdue(p)) return 'overdue'
if (p.status === 'in_progress') return 'active'
return 'pending'
}
function planIcon(p: any) {
if (p.status === 'completed') return '✅'
if (isOverdue(p)) return '🔴'
if (p.status === 'in_progress') return '🔄'
return '⏳'
}
function planLabel(p: any) {
const labels: Record<string, string> = { pending: '待处理', in_progress: '进行中', completed: '已完成', cancelled: '已取消' }
return labels[p.status] || p.status
}
function planTagType(p: any) {
if (p.status === 'completed') return 'success'
if (isOverdue(p)) return 'danger'
if (p.status === 'in_progress') return 'warning'
return 'info'
}
function isOverdue(p: any) {
if (p.status === 'completed' || !p.due_date) return false
return new Date(p.due_date) < new Date()
}
function dueText(p: any) {
if (!p.due_date) return ''
const due = new Date(p.due_date)
const now = new Date()
const diff = Math.ceil((due.getTime() - now.getTime()) / 86400000)
if (diff < 0) return `逾期${Math.abs(diff)}天`
if (diff === 0) return '今日截止'
return `剩余${diff}天`
}
function goCanvas() {
if (selectedMap.value) {
$router.push('/maps/canvas/' + selectedMap.value)
}
}
onMounted(async () => {
const r: any = await api.get('/maps')
maps.value = r.data || []
const id = route.params.id ? Number(route.params.id) : (maps.value[0]?.id || null)
if (id) {
selectedMap.value = id
await loadReview()
}
})
</script>
<style scoped>
.review-page { padding: 20px; height: calc(100vh - 60px); overflow-y: auto; }
.review-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-shrink: 0; }
.review-header .header-left { display: flex; align-items: center; gap: 12px; }
.review-header h3 { margin: 0; font-size: 18px; }
.review-header .header-right { display: flex; align-items: center; }
.loading-wrap { padding: 40px; }
.summary-cards { display: flex; gap: 16px; margin-bottom: 20px; }
.s-card { padding: 20px; border-radius: 12px; flex: 1; }
.s-card.overall { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; }
.s-card-top { display: flex; align-items: baseline; gap: 8px; margin-bottom: 12px; }
.score { font-size: 36px; font-weight: 700; }
.score-label { font-size: 14px; opacity: 0.8; }
.s-card-btm { display: flex; gap: 16px; font-size: 13px; }
.stat-item { opacity: 0.9; }
.dim-matrix { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 24px; }
.dim-col { background: #fff; border-radius: 8px; padding: 12px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
.dim-head { font-size: 14px; font-weight: 600; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.dim-body { display: flex; flex-direction: column; gap: 6px; }
.obj-row { display: flex; align-items: center; gap: 6px; padding: 8px; border-radius: 6px; cursor: pointer; transition: background 0.2s; font-size: 13px; }
.obj-row:hover { background: #f5f7fa; }
.obj-red { background: #fef0f0; }
.obj-yellow { background: #fdf6ec; }
.obj-green { background: #f0f9eb; }
.obj-gray { background: #fafafa; }
.obj-badge { font-size: 12px; }
.obj-info { display: flex; flex-direction: column; flex: 1; min-width: 0; }
.obj-name { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.obj-ratio { font-size: 11px; color: #999; }
.focus-section { margin-bottom: 24px; }
.focus-title { font-size: 16px; margin: 0 0 12px 0; }
.focus-card { background: #fff; border-radius: 8px; padding: 12px; margin-bottom: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); cursor: pointer; }
.focus-red { border-left: 4px solid #f56c6c; }
.focus-yellow { border-left: 4px solid #e6a23c; }
.focus-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.focus-level { font-size: 11px; padding: 2px 6px; border-radius: 4px; font-weight: 600; }
.focus-red .focus-level { background: #fef0f0; color: #f56c6c; }
.focus-yellow .focus-level { background: #fdf6ec; color: #e6a23c; }
.focus-name { font-weight: 600; font-size: 14px; }
.focus-kpis { padding-left: 20px; margin-bottom: 6px; }
.fkpi-row { display: flex; justify-content: space-between; font-size: 13px; padding: 4px 0; }
.fkpi-name { color: #666; }
.fkpi-val { font-weight: 500; }
.focus-plans { padding-left: 20px; margin-top: 4px; border-top: 1px solid #f0f0f0; padding-top: 6px; }
.fplan-row { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 4px 0; }
.fplan-status { flex-shrink: 0; }
.fplan-title { flex: 1; }
.fplan-assignee { color: #999; }
.fplan-due { color: #f56c6c; font-size: 11px; }
.plans-section { margin-bottom: 24px; }
.plans-title { font-size: 16px; margin: 0 0 12px 0; }
.plans-stats { display: flex; gap: 8px; margin-bottom: 12px; }
.kpi-detail-row { border: 1px solid #eee; border-radius: 8px; padding: 12px; margin-bottom: 8px; }
.kpi-red { border-left: 4px solid #f56c6c; }
.kpi-yellow { border-left: 4px solid #e6a23c; }
.kpi-green { border-left: 4px solid #67c23a; }
.kpi-gray { border-left: 4px solid #ccc; }
.kpi-detail-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.kpi-detail-name { font-weight: 600; font-size: 14px; }
.kpi-detail-body { padding-left: 24px; }
.kpi-detail-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; }
.kpi-detail-item .label { color: #999; width: 60px; }
.kpi-detail-item .val { font-weight: 500; }
/* ── 双会议模板 ── */
.meeting-type-bar { display: flex; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 8px; }
.op-agenda-cards,
.linkage-cards { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-bottom: 24px; }
.agenda-card,
.linkage-card { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); border: 1px solid #f0f0f0; }
.agenda-card-title,
.linkage-card-title { font-size: 15px; font-weight: 600; margin-bottom: 12px; color: #333; padding-bottom: 8px; border-bottom: 2px solid #f0f2f5; }
.agenda-card-body,
.linkage-card-body { font-size: 13px; }
.agenda-dim-row,
.linkage-dim-row { display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #f5f5f5; }
.agenda-dim-row:last-child,
.linkage-dim-row:last-child { border-bottom: none; }
.agenda-dim-icon,
.linkage-dim-icon { font-size: 16px; }
.agenda-dim-name,
.linkage-dim-name { flex: 1; }
.agenda-dim-count { color: #999; font-size: 12px; }
.linkage-dim-info { flex: 1; display: flex; justify-content: space-between; align-items: center; }
.linkage-dim-ratio { color: #409EFF; font-size: 12px; font-weight: 500; }
.kpi-summary-row { padding: 4px 0; font-size: 13px; display: flex; align-items: center; gap: 6px; }
.deviation-row { display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #f5f5f5; font-size: 13px; }
.deviation-row:last-child { border-bottom: none; }
.deviation-badge { font-size: 12px; flex-shrink: 0; }
.deviation-kpi { flex: 1; color: #333; }
.deviation-rate { font-weight: 500; color: #e6a23c; }
.badge-red .deviation-rate { color: #f56c6c; }
.kr-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid #f5f5f5; }
.kr-row:last-child { border-bottom: none; }
.kr-name { flex: 1; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.action-plan-preview { margin-top: 8px; border-top: 1px solid #f0f0f0; padding-top: 8px; }
.ap-row { display: flex; align-items: center; gap: 6px; padding: 4px 0; font-size: 12px; }
.ap-status { flex-shrink: 0; }
.ap-title { flex: 1; color: #666; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.no-data { color: #999; padding: 12px; text-align: center; font-size: 13px; }
@media screen and (max-width: 768px) {
.op-agenda-cards,
.linkage-cards { grid-template-columns: 1fr; }
}
</style>