- review_records表+模型(entity_id多租户)
- API: POST/GET/GET:id /maps/{id}/review-records
- 前端: MapReview 保存复盘结论弹窗+历史记录时间线(并发agent完成主体, 本项目补齐script逻辑)
- 验证: POST 200保存成功/GET列表/跨企业404/pytest 11 passed/构建部署
683 lines
30 KiB
Vue
683 lines
30 KiB
Vue
<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>
|