fix(frontend): BUG-001慢API超时兜底+BUG-002清理views残留.bak (产品打磨批次1 2026-09-03)

- api/index.ts: axios网络层错误(超时ECONNABORTED/断连)3s节流ElMessage提示,页面不再静默加载失败
- views: 删除6个.bak历史备份(BudgetManagement/CMAKnowledge/DeviationDashboard/KPIList/MapCanvas/MapReview),均已git跟踪可回溯
- nginx缓存策略另改(不进git): /assets/ immutable长缓存+index.html etag条件请求
This commit is contained in:
Hermes CI Fix
2026-09-03 18:32:24 +08:00
parent 9a823e80a6
commit 45cacedfdd
7 changed files with 11 additions and 3684 deletions
+11
View File
@@ -1,10 +1,14 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
const api = axios.create({
baseURL: '/api/cma',
timeout: 15000,
})
// 网络层错误提示节流标记(3 秒内只弹一次)
let lastNetErrToast = 0
api.interceptors.request.use((config) => {
const token = localStorage.getItem('cma_token')
if (token) config.headers.Authorization = `Bearer ${token}`
@@ -19,6 +23,13 @@ api.interceptors.response.use(
localStorage.removeItem('cma_token')
localStorage.removeItem('cma_user')
window.location.href = '/login'
} else if (error.code === 'ECONNABORTED' || !error.response) {
// BUG-001 兜底(2026-09-03):网络层失败(超时/断连)节流提示,避免并行请求重复弹窗
const now = Date.now()
if (now - lastNetErrToast > 3000) {
lastNetErrToast = now
ElMessage.warning(error.code === 'ECONNABORTED' ? '请求超时,请稍后重试' : '网络异常,请检查连接后重试')
}
}
return Promise.reject(error)
}
-475
View File
@@ -1,475 +0,0 @@
<template>
<div>
<h3>预算管理</h3>
<div v-if="noMap" style="padding:60px 0;text-align:center;color:#999;">
<p style="font-size:16px;margin-bottom:12px;">暂无已发布的战略地图</p>
<p style="font-size:13px;">预算编制需要基于已发布的战略地图,请先在【战略地图】中创建并发布</p>
<el-button type="primary" style="margin-top:16px;" @click="$router.push('/maps')">去创建战略地图</el-button>
</div>
<div v-else>
<el-tabs v-model="activeTab" style="margin-top:16px;">
<el-tab-pane label="预算录入" name="input">
<div style="margin-bottom:8px;font-size:12px;color:#909399;display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
选择战略地图
<el-select v-model="selectedMapId" placeholder="选择已发布的战略地图" style="width:260px;" @change="onMapSelect">
<el-option v-for="m in publishedMaps" :key="m.id" :label="m.title" :value="m.id" />
</el-select>
<el-tag v-if="selectedMapId" size="small" type="success">已选择</el-tag>
<span style="color:#999;">共 {{ total }} 条KPI</span>
</div>
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
<el-select v-model="filterYear" placeholder="年份" style="width:100px;" @change="loadBudget"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
<el-select v-model="filterMonth" placeholder="月份" style="width:90px;" @change="loadBudget"><el-option label="全年" :value="0" /><el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" /></el-select>
<el-input v-model="searchKpi" placeholder="搜索KPI名称" clearable style="width:200px;" @clear="loadBudget" @keyup.enter="loadBudget" />
<el-button type="primary" @click="loadBudget">查询</el-button>
<el-button type="success" @click="showAddBudget = true">+ 新增预算</el-button>
<el-button @click="showDecompose = true" :disabled="!filterYear">年度分解</el-button>
<el-divider direction="vertical" />
<el-radio-group v-model="budgetViewMode" size="small" @change="onViewModeChange"><el-radio-button value="list">KPI列表</el-radio-button><el-radio-button value="summary">维度汇总</el-radio-button></el-radio-group>
<el-switch v-model="batchEditMode" active-text="批量编辑" inactive-text="逐行编辑" @change="onBatchEditToggle" />
<el-button v-if="batchEditMode" type="primary" @click="batchSaveAll" :loading="batchSavingAll" :disabled="changedRows.length === 0">批量保存 ({{ changedRows.length }})</el-button>
<el-button v-if="batchEditMode" @click="batchCancelAll">取消</el-button>
</div>
<template v-if="budgetViewMode === 'list'">
<el-table :data="budgetList" v-loading="loading" border stripe size="small" style="width:100%;" :row-class-name="rowClass" :empty-text="filterYear ? '暂无数据,在行内编辑填值后保存即可创建' : '请先选择年份'">
<el-table-column type="index" label="#" width="40" />
<el-table-column prop="kpi_code" label="KPI编码" width="120" />
<el-table-column prop="kpi_name" label="KPI名称" min-width="160" />
<el-table-column prop="dimension" label="维度" width="80"><template #default="{ row }"><el-tag size="small" :type="dimTag(row.dimension)">{{ dimLabel(row.dimension) }}</el-tag></template></el-table-column>
<el-table-column prop="period" label="期间" width="90" />
<el-table-column prop="budget_value" label="预算值" width="170">
<template #default="{ row }"><el-input-number v-if="row._editing" v-model="row._editValue" :min="0" :precision="row.precision || 0" :step="row.step || 1" controls-position="right" style="width:155px;" @change="onEditChange(row)" /><span v-else>{{ formatNumber(row.budget_value, row.unit) }}</span></template>
</el-table-column>
<el-table-column prop="unit" label="单位" width="60" />
<!-- 行动方案列(非财务维度显示) -->
<el-table-column label="行动方案" width="120">
<template #default="{ row }">
<span v-if="row.dimension === 'finance'" style="color:#ccc;font-size:12px;">—</span>
<el-button v-else size="small" link type="primary" @click="showActionPlans(row)">
{{ row._actionCount != null ? `${row._actionCount}项` : '关联' }}
</el-button>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button v-if="!batchEditMode && !row._editing" size="small" @click="startEdit(row)">编辑</el-button>
<template v-else-if="!batchEditMode"><el-button size="small" type="primary" @click="saveEdit(row)" :loading="row._saving">保存</el-button><el-button size="small" @click="cancelEdit(row)">取消</el-button></template>
<el-button v-if="!batchEditMode && row.id" size="small" :type="row.dimension === 'finance' ? 'info' : 'danger'" :disabled="row.dimension === 'finance'" @click="row.dimension !== 'finance' && doDelete(row)">{{ row.dimension === 'finance' ? '财务指标' : '删除' }}</el-button>
</template>
</el-table-column>
</el-table>
</template>
<template v-if="budgetViewMode === 'summary'">
<el-row :gutter="16" style="margin-bottom:16px;">
<el-col :span="6" v-for="card in summaryCards" :key="card.key">
<el-card shadow="hover" :body-style="{ borderLeft: '4px solid ' + card.color }">
<div style="text-align:center;"><div style="font-size:14px;margin-bottom:4px;">{{ card.icon }} {{ card.name }}</div><div style="font-size:24px;font-weight:600;color:#333;">{{ formatNumber(card.totalBudget) }}</div><div style="font-size:11px;color:#999;margin-top:4px;">{{ card.kpiCount }}个KPI · 占比{{ card.ratio }}%</div></div>
</el-card>
</el-col>
</el-row>
<el-table :data="dimSummaryRows" border stripe size="small" style="width:100%;">
<el-table-column prop="dimension" label="维度" width="100"><template #default="{ row }"><el-tag size="small" :type="dimTag(row.dimension)">{{ dimLabel(row.dimension) }}</el-tag></template></el-table-column>
<el-table-column prop="kpiCount" label="KPI数" width="80" />
<el-table-column prop="totalBudget" label="预算总额" width="140"><template #default="{ row }">{{ formatNumber(row.totalBudget) }}</template></el-table-column>
<el-table-column prop="ratio" label="占比" width="100"><template #default="{ row }"><el-progress :percentage="row.ratio" :stroke-width="12" text-inside /></template></el-table-column>
<el-table-column prop="avgPerKpi" label="KPI均值" width="140"><template #default="{ row }">{{ formatNumber(row.avgPerKpi) }}</template></el-table-column>
</el-table>
</template>
</el-tab-pane>
<el-tab-pane label="年度分解" name="decompose">
<el-card>
<template #header><div style="display:flex;justify-content:space-between;align-items:center;"><span>年度预算 → 月度自动分解</span><el-button type="primary" @click="doDecompose" :loading="decomposing">执行分解</el-button></div></template>
<el-form :model="decomposeForm" label-width="120px" style="max-width:500px;">
<el-form-item label="年份"><el-select v-model="decomposeForm.year" style="width:150px;"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select></el-form-item>
<el-form-item label="分解方式"><el-radio-group v-model="decomposeForm.method"><el-radio value="equal">均分(1/12)</el-radio><el-radio value="weighted">按历史权重</el-radio></el-radio-group></el-form-item>
</el-form>
<el-alert v-if="decomposeResult" :title="decomposeResult" type="success" show-icon :closable="false" style="margin-top:16px;" />
<el-table v-if="decomposeDetails.length > 0" :data="decomposeDetails" border stripe size="small" style="width:100%;margin-top:12px;" max-height="300">
<el-table-column prop="kpi_code" label="KPI编码" width="120" /><el-table-column prop="kpi_name" label="KPI名称" min-width="150" />
<el-table-column prop="annual_budget" label="年度预算" width="120"><template #default="{ row }">{{ formatNumber(row.annual_budget) }}</template></el-table-column>
<el-table-column prop="method" label="方式" width="80" /><el-table-column prop="monthly_count" label="月数" width="60" />
</el-table>
</el-card>
</el-tab-pane>
<el-tab-pane label="战略预算编制" name="strategy">
<div v-if="!selectedMap" style="padding:40px 0;text-align:center;color:#999;"><p>请先在预算录入中选择战略地图</p></div>
<template v-else>
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
<span style="font-weight:500;">{{ selectedMap.title }}</span><el-tag size="small" type="success">已发布</el-tag>
<el-select v-model="strategyFilterYear" placeholder="年份" style="width:100px;" @change="loadStrategyBudget"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
<el-select v-model="strategyFilterMonth" placeholder="月份" style="width:90px;" @change="loadStrategyBudget"><el-option label="全年" :value="0" /><el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" /></el-select>
<el-button type="primary" size="small" @click="loadStrategyBudget">刷新</el-button>
<el-divider direction="vertical" /><span style="font-size:13px;color:#666;">已选 {{ strategySelectedKpis.length }} 个KPI</span>
<el-button size="small" @click="strategyBatchSetSame">统一设值</el-button>
<el-input-number v-model="strategyBatchValue" :min="0" controls-position="right" style="width:120px;" size="small" />
<el-button v-if="strategySelectedKpis.length > 0" size="small" type="primary" :loading="strategyBatchSaving" @click="strategyBatchSave">批量保存</el-button>
</div>
<div v-for="dim in strategyTree" :key="dim.key" class="strategy-dim-block">
<div class="strategy-dim-header" :style="{ borderLeftColor: dim.color }">
<span class="strategy-dim-icon">{{ dim.icon }}</span><span class="strategy-dim-name">{{ dim.name }}</span>
<span class="strategy-dim-summary">({{ dim.kpiCount }}个KPI · 预算合计:{{ formatNumber(dim.totalBudget) }})</span>
<el-button size="small" link style="margin-left:auto;" @click="strategySelectDim(dim.key)">全选</el-button>
</div>
<div v-for="obj in dim.objectives" :key="obj.name" class="strategy-obj-block">
<div class="strategy-obj-header"><span class="strategy-obj-name">{{ obj.name }}</span></div>
<el-table :data="obj.kpis" border stripe size="small" style="width:100%;" :show-header="false" @selection-change="(sel: any[]) => onStrategyKpiSelect(sel, dim.key)">
<el-table-column type="selection" width="36" />
<el-table-column prop="kpi_code" label="编码" width="110" /><el-table-column prop="kpi_name" label="KPI名称" min-width="160" />
<el-table-column label="预算值" width="190">
<template #default="{ row }">
<el-input-number v-model="row._editValue" :min="0" :precision="row.precision || 0" :step="row.step || 1" controls-position="right" style="width:150px;" @change="row._changed = (row._editValue !== (row.budget_value ?? 0))" />
<span style="font-size:12px;color:#999;margin-left:2px;">{{ row.unit }}</span>
<el-button v-if="row._changed" size="small" type="primary" link @click="saveStrategyKpi(row)" :loading="row._saving" style="margin-left:4px;">保存</el-button>
</template>
</el-table-column>
<el-table-column prop="period" label="期间" width="90" /><el-table-column prop="version" label="版本" width="60" />
</el-table>
</div>
</div>
</template>
</el-tab-pane>
<el-tab-pane label="版本管理" name="versions">
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center;">
<el-select v-model="versionYear" placeholder="年份" style="width:120px;"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
<el-button type="primary" @click="loadVersions">刷新</el-button>
</div>
<el-table :data="versionsList" v-loading="versionsLoading" border stripe size="small" style="width:100%;">
<el-table-column prop="version" label="版本" width="90"><template #default="{ row }"><el-tag :type="row.version === currentVersion ? 'success' : 'info'" size="small">{{ row.version }}</el-tag></template></el-table-column>
<el-table-column prop="approval_status" label="状态" width="90">
<template #default="{ row }"><el-tag v-if="row.approval_status === 'approved'" type="success" size="small">已批准</el-tag><el-tag v-else-if="row.approval_status === 'submitted'" type="warning" size="small">待审批</el-tag><el-tag v-else-if="row.approval_status === 'rejected'" type="danger" size="small">已驳回</el-tag><el-tag v-else type="info" size="small">草稿</el-tag></template>
</el-table-column>
<el-table-column prop="plan_count" label="预算条目数" width="100" />
<el-table-column prop="total_budget" label="预算总额" width="130"><template #default="{ row }">{{ formatNumber(row.total_budget) }}</template></el-table-column>
<el-table-column prop="last_updated" label="最后更新" min-width="150" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button v-if="row.approval_status === 'draft'" size="small" @click="submitVersion(row)">提交审批</el-button>
<template v-if="row.approval_status === 'submitted'"><el-button size="small" type="success" @click="approveVersion(row, 'approved')">批准</el-button><el-button size="small" type="danger" @click="approveVersion(row, 'rejected')">驳回</el-button></template>
<el-button v-if="row.approval_status === 'rejected'" size="small" @click="resubmitVersion(row)">重新提交</el-button>
<el-checkbox v-model="row._selected" @change="onVersionSelect(row)" :disabled="selectedVersions.length >= 2 && !row._selected" style="margin-left:8px;" />
</template>
</el-table-column>
</el-table>
<div v-if="selectedVersions.length === 2" style="margin-top:16px;"><el-button type="primary" @click="loadVersionDiff" :loading="diffLoading">对比 {{ selectedVersions[0].version }} vs {{ selectedVersions[1].version }}</el-button></div>
</el-tab-pane>
<el-tab-pane label="预算执行" name="execution">
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
<el-select v-model="execFilterYear" placeholder="年份" style="width:100px;" @change="loadExecutionReport"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select>
<el-select v-model="execFilterMonth" placeholder="截止月份" style="width:110px;" @change="loadExecutionReport"><el-option v-for="m in 12" :key="m" :label="`至${m}月`" :value="m" /></el-select>
<el-select v-model="execFilterDim" placeholder="维度" clearable style="width:110px;" @change="loadExecutionReport"><el-option label="财务" value="finance" /><el-option label="客户" value="customer" /><el-option label="内部流程" value="process" /><el-option label="学习成长" value="learning" /></el-select>
<el-button type="primary" @click="loadExecutionReport">刷新</el-button>
</div>
<el-row :gutter="16" style="margin-bottom:16px;">
<el-col :span="6" v-for="card in execSummaryCards" :key="card.label"><el-card shadow="hover"><div style="text-align:center;"><div style="font-size:12px;color:#999;">{{ card.label }}</div><div style="font-size:22px;font-weight:600;margin-top:4px;" :style="{color: card.color}">{{ card.value }}</div></div></el-card></el-col>
</el-row>
<el-table :data="execReport" v-loading="execLoading" border stripe size="small" style="width:100%;">
<el-table-column prop="kpi_code" label="编码" width="110" /><el-table-column prop="kpi_name" label="KPI名称" min-width="140" />
<el-table-column prop="dimension" label="维度" width="80"><template #default="{ row }"><el-tag size="small" :type="dimTag(row.dimension)">{{ dimLabel(row.dimension) }}</el-tag></template></el-table-column>
<el-table-column prop="budget_value" label="预算值" width="130"><template #default="{ row }">{{ formatNumber(row.budget_value, row.unit) }}</template></el-table-column>
<el-table-column prop="actual_value" label="实际值" width="130"><template #default="{ row }">{{ formatNumber(row.actual_value, row.unit) }}</template></el-table-column>
<el-table-column prop="deviation_value" label="偏差" width="130"><template #default="{ row }"><span :style="{color: row.deviation_value > 0 ? '#f56c6c' : row.deviation_value < 0 ? '#67c23a' : '#999'}">{{ row.deviation_value > 0 ? '+' : '' }}{{ formatNumber(row.deviation_value, row.unit) }}</span></template></el-table-column>
<el-table-column label="执行率" width="100"><template #default="{ row }"><el-progress :percentage="row.execution_rate || 0" :status="row.execution_rate > 100 ? 'exception' : row.execution_rate > 80 ? 'warning' : 'success'" :stroke-width="16" :text-inside="true" /></template></el-table-column>
<el-table-column label="预警" width="80"><template #default="{ row }"><el-tag v-if="row.alert_level === 'red'" size="small" type="danger">严重</el-tag><el-tag v-else-if="row.alert_level === 'yellow'" size="small" type="warning">关注</el-tag><el-tag v-else size="small" type="success">正常</el-tag></template></el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<MyDialog v-model="showAddBudget" title="新增预算" :width="500">
<el-form :model="addForm" label-width="80px">
<el-form-item label="KPI"><el-select v-model="addForm.kpi_id" placeholder="搜索并选择KPI" filterable remote :remote-method="searchKpiForAdd" :loading="addKpiLoading" style="width:100%;" :teleported="false"><el-option v-for="k in addKpiOptions" :key="k.id" :label="`${k.kpi_code} - ${k.kpi_name}`" :value="k.id" /></el-select></el-form-item>
<el-form-item label="年份"><el-select v-model="addForm.year" style="width:120px;"><el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" /></el-select></el-form-item>
<el-form-item label="月份"><el-select v-model="addForm.month" style="width:120px;"><el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" /></el-select></el-form-item>
<el-form-item label="预算值"><el-input-number v-model="addForm.value" :min="0" :precision="0" style="width:200px;" /></el-form-item>
</el-form>
<template #footer><el-button @click="showAddBudget = false">取消</el-button><el-button type="primary" @click="doAddBudget" :loading="addSaving">创建</el-button></template>
</MyDialog>
<MyDialog v-model="showVersionDiff" title="版本差异对比" :width="900">
<template v-if="diffData">
<el-alert show-icon :closable="false" style="margin-bottom:12px;"><template #title><span>对比 {{ diffData.summary.version_a }} → {{ diffData.summary.version_b }}</span></template></el-alert>
<el-table :data="diffData.diffs" border stripe size="small" max-height="500" style="width:100%;">
<el-table-column prop="kpi_code" label="编码" width="110" /><el-table-column prop="kpi_name" label="名称" min-width="140" /><el-table-column prop="period" label="期间" width="90" />
<el-table-column prop="old_value" label="旧值" width="110"><template #default="{ row }">{{ formatNumber(row.old_value) }}</template></el-table-column>
<el-table-column prop="new_value" label="新值" width="110"><template #default="{ row }">{{ formatNumber(row.new_value) }}</template></el-table-column>
<el-table-column prop="diff_value" label="差值" width="110"><template #default="{ row }"><span :style="{color: row.diff_value > 0 ? '#f56c6c' : row.diff_value < 0 ? '#67c23a' : '#999'}">{{ row.diff_value > 0 ? '+' : '' }}{{ formatNumber(row.diff_value) }}</span></template></el-table-column>
<el-table-column prop="diff_rate" label="变动率" width="90"><template #default="{ row }"><el-tag v-if="row.diff_rate === 0" size="small" type="info">持平</el-tag><el-tag v-else :type="row.diff_rate > 0 ? 'danger' : 'success'" size="small">{{ row.diff_rate > 0 ? '+' : '' }}{{ row.diff_rate }}%</el-tag></template></el-table-column>
</el-table>
</template>
<template #footer><el-button @click="showVersionDiff = false">关闭</el-button></template>
</MyDialog>
<!-- 行动方案关联弹窗 -->
<MyDialog v-model="showActionDialog" :title="actionDialogTitle" :width="600">
<template v-if="actionPlansForKpi.length > 0">
<div style="margin-bottom:12px;font-size:13px;color:#666;">已关联 {{ actionPlansForKpi.length }} 项行动方案</div>
<div v-for="plan in actionPlansForKpi" :key="plan.id" style="border:1px solid #e8e8e8;border-radius:8px;padding:12px;margin-bottom:8px;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;">
<div>
<div style="font-weight:500;">{{ plan.title }}</div>
<div v-if="plan.description" style="font-size:12px;color:#888;margin-top:4px;">{{ plan.description }}</div>
</div>
<el-tag v-if="plan.status === 'completed'" type="success" size="small">已完成</el-tag>
<el-tag v-else-if="plan.status === 'in_progress'" type="warning" size="small">进行中</el-tag>
<el-tag v-else type="info" size="small">{{ plan.status }}</el-tag>
</div>
<div style="display:flex;gap:16px;margin-top:8px;font-size:12px;color:#999;">
<span>负责人: {{ plan.assignee || '未指定' }}</span>
<span v-if="plan.due_date">截止: {{ plan.due_date?.slice(0,10) }}</span>
<span>优先级: {{ plan.priority }}</span>
</div>
</div>
</template>
<div v-else style="padding:40px 0;text-align:center;color:#999;">
<p>暂无关联行动方案</p>
<p style="font-size:12px;margin-top:4px;">非财务维度KPI需要配套行动方案才能将战略意图转化为实际行动</p>
</div>
<div style="margin-top:16px;display:flex;gap:8px;">
<el-button type="primary" @click="goCreateActionPlan">+ 新建行动方案</el-button>
<el-button @click="goActionPlansPage">去行动方案库查看</el-button>
</div>
<template #footer><el-button @click="showActionDialog = false">关闭</el-button></template>
</MyDialog>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { budgetApi, kpiApi, mapApi, actionPlanApi } from '../api/index'
import MyDialog from '../components/MyDialog.vue'
const activeTab = ref('input')
const currentYear = new Date().getFullYear()
const yearOptions = computed(() => { const y: number[] = []; for (let i = currentYear - 2; i <= currentYear + 2; i++) y.push(i); return y })
const filterYear = ref(currentYear); const filterMonth = ref(0); const searchKpi = ref('')
const loading = ref(false); const budgetList = ref<any[]>([]); const page = ref(1); const pageSize = ref(20); const total = ref(0); const noMap = ref(false)
const batchEditMode = ref(false); const batchSavingAll = ref(false)
// ── 战略地图选择 ──
const publishedMaps = ref<any[]>([])
const selectedMapId = ref<number | null>(null)
const selectedMap = ref<any>(null)
async function loadPublishedMaps() {
try {
const r: any = await mapApi.list(); const maps = Array.isArray(r) ? r : (r.data || r.items || [])
const published = maps.filter((m: any) => m.status === 'published')
.sort((a: any, b: any) => new Date(b.updated_at || b.created_at).getTime() - new Date(a.updated_at || a.created_at).getTime())
publishedMaps.value = published
noMap.value = published.length === 0
// 如果有默认选中的地图ID没变,保持
if (selectedMapId.value && published.find(m => m.id === selectedMapId.value)) return
// 默认选中第一个
if (published.length > 0) {
selectedMapId.value = published[0].id
selectedMap.value = published[0]
}
} catch { publishedMaps.value = []; noMap.value = true }
}
function onMapSelect(id: number) {
selectedMap.value = publishedMaps.value.find(m => m.id === id) || null
loadBudget()
}
function getMapKpiCodes(): Set<string> {
const codes = new Set<string>(); if (!selectedMap.value) return codes
let dims = selectedMap.value.dimensions
if (typeof dims === 'string') { try { dims = JSON.parse(dims) } catch { return codes } }
if (!Array.isArray(dims)) return codes
for (const dim of dims) for (const obj of (dim.objectives || [])) for (const code of (obj.kpis || [])) if (code) codes.add(code)
return codes
}
const budgetViewMode = ref('list'); const summaryCards = ref<any[]>([]); const dimSummaryRows = ref<any[]>([])
function onViewModeChange(mode: string) { if (mode === 'summary') loadSummary() }
function loadSummary() {
const cfg: Record<string, any> = { finance: { n: '财务维度', i: '💰', c: '#409eff' }, customer: { n: '客户维度', i: '🤝', c: '#67c23a' }, process: { n: '内部流程', i: '⚙️', c: '#e6a23c' }, learning: { n: '学习成长', i: '📚', c: '#f56c6c' } }
const dd: Record<string, { t: number; n: number }> = {}
for (const item of budgetList.value) { const d = item.dimension || 'other'; if (!dd[d]) dd[d] = { t: 0, n: 0 }; dd[d].t += (item.budget_value || 0); dd[d].n++ }
const tb = Object.values(dd).reduce((s, v) => s + v.t, 0); const cards: any[] = []; const rows: any[] = []
for (const [k, v] of Object.entries(dd)) { const c = cfg[k] || { n: k, i: '📊', c: '#909399' }; const r = tb > 0 ? Math.round(v.t / tb * 100) : 0; cards.push({ key: k, name: c.n, icon: c.i, color: c.c, totalBudget: v.t, kpiCount: v.n, ratio: r }); rows.push({ dimension: k, kpiCount: v.n, totalBudget: v.t, ratio: r, avgPerKpi: v.n > 0 ? Math.round(v.t / v.n) : 0 }) }
summaryCards.value = cards; dimSummaryRows.value = rows
}
const showAddBudget = ref(false)
const addForm = ref({ kpi_id: null as any, year: currentYear, month: new Date().getMonth() + 1, value: 0 })
const addKpiOptions = ref<any[]>([]); const addKpiLoading = ref(false); const addSaving = ref(false)
async function searchKpiForAdd(query: string) {
addKpiLoading.value = true
try { const r: any = await kpiApi.list({ keyword: query, page_size: 20 }); const d = r.data || r || []; addKpiOptions.value = Array.isArray(d) ? d : (d.items || []) } catch { }
addKpiLoading.value = false
}
async function doAddBudget() {
if (!addForm.value.kpi_id) { ElMessage.warning('请选择KPI'); return }; addSaving.value = true
try { await budgetApi.create({ kpi_id: addForm.value.kpi_id, period: `${addForm.value.year}-${String(addForm.value.month).padStart(2, '0')}`, budget_value: addForm.value.value, budget_year: addForm.value.year, budget_month: addForm.value.month }); ElMessage.success('预算已创建'); showAddBudget.value = false; loadBudget(); addForm.value = { kpi_id: null, year: currentYear, month: new Date().getMonth() + 1, value: 0 } } catch (e: any) { ElMessage.error(e?.response?.data?.detail || e?.message || '创建失败') }
addSaving.value = false
}
const dimMap: Record<string, string> = { finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长' }
const dimTagMap: Record<string, string> = { finance: 'danger', customer: 'warning', process: 'primary', learning: 'success' }
function dimLabel(d: string) { return dimMap[d] || d }; function dimTag(d: string) { return dimTagMap[d] || 'info' }
function formatNumber(v: any, unit?: string) { if (v === null || v === undefined) return '--'; const n = Number(v); const p = unit === '%' ? 2 : 0; return n.toLocaleString('zh-CN', { minimumFractionDigits: p, maximumFractionDigits: p }) + (unit ? ` ${unit}` : '') }
function rowClass({ row }: any) { return row._changed ? 'row-changed' : '' }
async function loadBudget() {
if (batchEditMode.value && changedRows.value.length > 0) { try { await ElMessageBox.confirm(`有 ${changedRows.value.length} 条修改未保存,是否放弃?`, '提示', { confirmButtonText: '放弃修改', cancelButtonText: '取消' }) } catch { return } }
if (!selectedMap.value) return
const mapCodes = getMapKpiCodes()
if (mapCodes.size === 0) { budgetList.value = []; total.value = 0; return }
noMap.value = false; loading.value = true
try {
const params: any = { page: page.value, page_size: pageSize.value }; if (filterYear.value) params.year = filterYear.value; if (filterMonth.value > 0) params.budget_month = filterMonth.value; if (searchKpi.value) params.keyword = searchKpi.value
const r: any = await budgetApi.list(params); const d = r.data || r || []; const allPlans = (Array.isArray(d) ? d : (d.items || [])) as any[]
// 只保留选中地图关联KPI的预算记录
const plans = allPlans.filter((p: any) => mapCodes.has(p.kpi_code))
const kr: any = await kpiApi.list({ page_size: 100, keyword: searchKpi.value || undefined }); const kd = kr.data || kr || []; const ak = Array.isArray(kd) ? kd : (kd.items || [])
const mk = ak.filter((k: any) => mapCodes.has(k.kpi_code)); const pk = new Set(plans.map((p: any) => p.kpi_code))
const ek = mk.filter((k: any) => !pk.has(k.kpi_code)).map((k: any) => ({ kpi_id: k.id, kpi_code: k.kpi_code, kpi_name: k.kpi_name, dimension: k.dimension, unit: k.unit || '', period: `${filterYear.value || currentYear}-${String(filterMonth.value || 1).padStart(2, '0')}`, budget_value: null, version: 'v1.0', status: '', precision: 0, step: 1 }))
budgetList.value = [...plans.map((item: any) => initRow({ ...item, _empty: false })), ...ek.map(item => initRow({ ...item, _empty: true }))]
total.value = budgetList.value.length; if (budgetViewMode.value === 'summary') loadSummary()
// 加载非财务维度KPI的行动方案计数
loadActionPlanCounts()
} catch (e) { ElMessage.error('加载预算数据失败') }; loading.value = false
}
function initRow(item: any) { return { ...item, _editing: false, _editValue: item.budget_value ?? 0, _originalValue: item.budget_value, _saving: false, _changed: false } }
// ── 行动方案关联 ──
const showActionDialog = ref(false)
const actionDialogTitle = ref('')
const actionPlansForKpi = ref<any[]>([])
const actionDialogCurrentRow = ref<any>(null)
async function loadActionPlanCounts() {
// 筛选出非财务维度且有关联kpi_id的行
const rows = budgetList.value.filter(r => r.dimension !== 'finance' && r.kpi_id)
for (const row of rows) {
row._actionCount = null // 重置
try {
const r: any = await actionPlanApi.list({ kpi_id: row.kpi_id, page_size: 1 })
const d = r.data || []
row._actionCount = Array.isArray(d) ? d.length : 0
} catch { row._actionCount = 0 }
}
}
function showActionPlans(row: any) {
actionDialogCurrentRow.value = row
actionDialogTitle.value = `行动方案 - ${row.kpi_name || row.kpi_code}`
actionPlansForKpi.value = []
if (!row.kpi_id) { showActionDialog.value = true; return }
actionPlanApi.list({ kpi_id: row.kpi_id }).then((r: any) => {
const d = r.data || []
actionPlansForKpi.value = Array.isArray(d) ? d : []
}).catch(() => { actionPlansForKpi.value = [] })
showActionDialog.value = true
}
function goCreateActionPlan() {
const row = actionDialogCurrentRow.value
if (!row) return
// 跳转到差异分析页面并预填KPI
window.open(`/action-plans?kpi_id=${row.kpi_id}&kpi_name=${encodeURIComponent(row.kpi_name || '')}`, '_blank')
}
function goActionPlansPage() {
window.open('/action-plans', '_blank')
}
function onBatchEditToggle(val: boolean) { budgetList.value.forEach(r => { r._editing = val; r._editValue = r.budget_value ?? 0; r._originalValue = r.budget_value; r._changed = false }) }
function onEditChange(row: any) { row._changed = row._editValue !== row._originalValue }
const changedRows = computed(() => budgetList.value.filter(r => r._changed))
async function batchSaveAll() {
const ts = changedRows.value; if (ts.length === 0) return; batchSavingAll.value = true; let ok = 0; let no = 0
for (const r of ts) { try { if (r._empty || !r.id) { await budgetApi.create({ kpi_id: r.kpi_id, period: r.period, budget_value: r._editValue, budget_year: filterYear.value || currentYear, budget_month: filterMonth.value || 1 }) } else { await budgetApi.update(r.id, { budget_value: r._editValue }) }; r.budget_value = r._editValue; r._originalValue = r._editValue; r._changed = false; ok++ } catch { no++ } }
ElMessage.success(`批量保存完成:${ok} 成功,${no} 失败`); batchSavingAll.value = false
}
function batchCancelAll() { budgetList.value.forEach(r => { r._editValue = r._originalValue ?? 0; r._changed = false }) }
function startEdit(row: any) { row._editing = true; row._editValue = row.budget_value ?? 0; row._originalValue = row.budget_value }
function cancelEdit(row: any) { row._editing = false; row._editValue = row.budget_value ?? 0; row._originalValue = row.budget_value; row._changed = false }
async function saveEdit(row: any) {
row._saving = true
try { if (row._empty || !row.id) { await budgetApi.create({ kpi_id: row.kpi_id, period: row.period, budget_value: row._editValue, budget_year: filterYear.value || currentYear, budget_month: filterMonth.value || 1 }); ElMessage.success('预算已创建') } else { await budgetApi.update(row.id, { budget_value: row._editValue }); ElMessage.success('已更新') }; row.budget_value = row._editValue; row._originalValue = row._editValue; row._editing = false; row._changed = false; row._empty = false } catch (e) { ElMessage.error('保存失败') }
row._saving = false
}
async function doDelete(row: any) { try { await ElMessageBox.confirm(`确认删除「${row.kpi_name || row.kpi_code}」的预算?`, '确认'); await budgetApi.delete(row.id); ElMessage.success('已删除'); loadBudget() } catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') } }
const showDecompose = ref(false); const decomposeForm = ref({ year: currentYear, method: 'equal' }); const decomposing = ref(false); const decomposeResult = ref(''); const decomposeDetails = ref<any[]>([])
const strategyTree = ref<any[]>([]); const strategyFilterYear = ref(currentYear); const strategyFilterMonth = ref(0); const strategyAllBudgetPlans = ref<any[]>([])
async function loadStrategyBudget() {
if (!selectedMap.value) return
try { const p: any = { budget_year: strategyFilterYear.value }; if (strategyFilterMonth.value > 0) p.budget_month = strategyFilterMonth.value; const r: any = await budgetApi.list(p); const d = r.data || r || []; const plans = (Array.isArray(d) ? d : (d.items || [])) as any[]; strategyAllBudgetPlans.value = plans
let dims = selectedMap.value.dimensions; if (typeof dims === 'string') { try { dims = JSON.parse(dims) } catch { dims = [] } }; if (!Array.isArray(dims) || dims.length === 0) { strategyTree.value = []; return }
const kr: any = await kpiApi.list({ page_size: 100 }); const kd = kr.data || kr || []; const km = new Map<string, any>(); (Array.isArray(kd) ? kd : (kd.items || [])).forEach((k: any) => km.set(k.kpi_code, k))
const tree: any[] = []
for (const dim of dims) { const dt = { budget: 0, kpiCount: 0 }; const objs = (dim.objectives || []).map((obj: any) => { const kpis = (obj.kpis || []).map((code: string) => { const kpi = km.get(code); const plan = plans.find((p: any) => p.kpi_code === code); const bv = plan ? plan.budget_value : null; dt.kpiCount++; if (bv) dt.budget += bv; return { kpi_code: code, kpi_id: kpi?.id || null, kpi_name: kpi?.kpi_name || code, unit: kpi?.unit || '', precision: kpi?.precision || 0, step: kpi?.step || 1, dimension: dim.key, period: plan?.period || `${strategyFilterYear.value}-${String(strategyFilterMonth.value || 1).padStart(2, '0')}`, version: plan?.version || 'v1.0', budget_value: bv, plan_id: plan?.id || null, _editValue: bv ?? 0, _changed: false, _saving: false, _dimKey: dim.key } }).filter(Boolean); return { name: obj.name, kpis } }); tree.push({ key: dim.key, name: dim.name, icon: dim.icon, color: dim.color, kpiCount: dt.kpiCount, totalBudget: dt.budget, objectives: objs }) }
strategyTree.value = tree
} catch (e: any) { ElMessage.error('加载战略预算数据失败: ' + (e?.response?.data?.detail || e?.message || '未知错误')) }
}
const strategySelectedKpis = ref<any[]>([]); const strategyBatchValue = ref(0); const strategyBatchSaving = ref(false)
function onStrategyKpiSelect(sel: any[], dimKey: string) { sel.forEach((i: any) => { i._dimKey = dimKey }); strategySelectedKpis.value = [...strategySelectedKpis.value.filter((k: any) => k._dimKey !== dimKey), ...sel] }
function strategySelectDim(dimKey: string) { const dim = strategyTree.value.find((d: any) => d.key === dimKey); if (!dim) return; const all: any[] = []; for (const o of dim.objectives) for (const k of o.kpis) { k._dimKey = dimKey; all.push(k) }; strategySelectedKpis.value = [...strategySelectedKpis.value.filter((k: any) => k._dimKey !== dimKey), ...all] }
function strategyBatchSetSame() { strategySelectedKpis.value.forEach((k: any) => { k._editValue = strategyBatchValue.value; k._changed = (k._editValue !== (k.budget_value ?? 0)) }) }
async function strategyBatchSave() {
const ts = strategySelectedKpis.value.filter((k: any) => k._changed); if (ts.length === 0) { ElMessage.warning('没有需要保存的变更'); return }; strategyBatchSaving.value = true; let ok = 0; let no = 0
for (const r of ts) { try { if (r.plan_id) { await budgetApi.update(r.plan_id, { budget_value: r._editValue }) } else { const rr: any = await budgetApi.create({ kpi_id: r.kpi_id, period: r.period, budget_value: r._editValue, budget_year: strategyFilterYear.value, budget_month: strategyFilterMonth.value || 1 }); r.plan_id = rr.id }; r.budget_value = r._editValue; r._changed = false; ok++ } catch { no++ } }
ElMessage.success(`批量保存完成:${ok} 成功,${no} 失败`); strategyBatchSaving.value = false; loadStrategyBudget()
}
async function saveStrategyKpi(row: any) {
row._saving = true
try { if (row.plan_id) { await budgetApi.update(row.plan_id, { budget_value: row._editValue }) } else { const rr: any = await budgetApi.create({ kpi_id: row.kpi_id, period: row.period, budget_value: row._editValue, budget_year: strategyFilterYear.value, budget_month: strategyFilterMonth.value || 1 }); row.plan_id = rr.id }; row.budget_value = row._editValue; row._changed = false; ElMessage.success('已保存') } catch (e) { ElMessage.error('保存失败') }
row._saving = false
}
async function submitVersion(row: any) { try { await ElMessageBox.confirm(`确认将版本「${row.version}」提交审批?`, '确认'); await budgetApi.versionSubmit({ version: row.version }); ElMessage.success('已提交审批'); loadVersions() } catch { } }
async function approveVersion(row: any, action: string) { const l = action === 'approved' ? '批准' : '驳回'; try { await ElMessageBox.confirm(`确认${l}版本「${row.version}」?`, '确认'); await budgetApi.versionApprove({ version: row.version, action }); ElMessage.success(`已${l}`); loadVersions() } catch { } }
async function resubmitVersion(row: any) { try { await ElMessageBox.confirm(`确认重新提交版本「${row.version}」?`, '确认'); await budgetApi.versionSubmit({ version: row.version }); ElMessage.success('已重新提交'); loadVersions() } catch { } }
const versionYear = ref(currentYear); const versionsList = ref<any[]>([]); const versionsLoading = ref(false); const selectedVersions = ref<any[]>([]); const showVersionDiff = ref(false); const diffLoading = ref(false); const diffData = ref<any>(null); const currentVersion = ref('')
async function loadVersions() { versionsLoading.value = true; try { const r: any = await budgetApi.versions({ year: versionYear.value }); const d = r.data || []; versionsList.value = d.map((v: any) => ({ ...v, _selected: false })); currentVersion.value = d[0]?.version || '' } catch (e) { ElMessage.error('加载版本列表失败') }; versionsLoading.value = false }
function onVersionSelect(row: any) { selectedVersions.value = versionsList.value.filter((v: any) => v._selected); if (selectedVersions.value.length > 2) { row._selected = false; selectedVersions.value = versionsList.value.filter((v: any) => v._selected) } }
async function loadVersionDiff() { if (selectedVersions.value.length !== 2) return; diffLoading.value = true; try { const [a, b] = selectedVersions.value; const r: any = await budgetApi.versionDiff({ version_a: a.version, version_b: b.version, year: versionYear.value }); diffData.value = r; showVersionDiff.value = true } catch (e) { ElMessage.error('加载版本对比失败') }; diffLoading.value = false }
const execFilterYear = ref(currentYear); const execFilterMonth = ref(new Date().getMonth()); const execFilterDim = ref(''); const execLoading = ref(false); const execReport = ref<any[]>([]); const execSummaryCards = ref<any[]>([])
async function loadExecutionReport() {
execLoading.value = true
try { const r: any = await budgetApi.deviationReport({ year: execFilterYear.value, month: execFilterMonth.value, dimension: execFilterDim.value || undefined, hierarchical: true }); const items: any[] = r.items || []; const en = items.map((i: any) => ({ ...i, execution_rate: i.budget_value && i.budget_value > 0 ? Math.round((i.actual_value || 0) / i.budget_value * 100) : 0, deviation_value: (i.actual_value || 0) - (i.budget_value || 0) })); execReport.value = en; const hb = en.filter((i: any) => i.budget_value != null); const ob = en.filter((i: any) => i.is_over_budget); const ar = hb.length > 0 ? Math.round(hb.reduce((s, i) => s + (i.execution_rate || 0), 0) / hb.length) : 0; const tb = hb.reduce((s, i) => s + (i.budget_value || 0), 0); const ta = hb.reduce((s, i) => s + (i.actual_value || 0), 0); execSummaryCards.value = [{ label: '有预算KPI', value: `${hb.length}/${en.length}`, color: '#409eff' }, { label: '超预算KPI', value: ob.length.toString(), color: '#f56c6c' }, { label: '平均执行率', value: `${ar}%`, color: ar > 100 ? '#f56c6c' : '#67c23a' }, { label: '预算执行进度', value: tb > 0 ? `${Math.round(ta / tb * 100)}%` : '-', color: '#e6a23c' }] } catch (e) { ElMessage.error('加载执行报告失败') }
execLoading.value = false
}
async function doDecompose() {
decomposing.value = true; decomposeResult.value = ''; decomposeDetails.value = []
try { const r: any = await budgetApi.autoDecompose({ year: decomposeForm.value.year, method: decomposeForm.value.method }); decomposeResult.value = r.message || '分解成功'; if (r.results) decomposeDetails.value = r.results.map((res: any) => ({ kpi_code: res.kpi_code, kpi_name: res.kpi_name, annual_budget: res.annual_budget, method: res.method === 'equal' ? '均分' : '加权', monthly_count: res.monthly.length })); ElMessage.success('年度预算分解完成'); loadBudget() } catch (e: any) { ElMessage.error(e.detail || e.message || '分解失败') }
decomposing.value = false
}
watch(activeTab, (tab) => {
if (tab === 'decompose') loadBudget()
else if (tab === 'strategy') loadStrategyBudget()
else if (tab === 'versions') loadVersions()
else if (tab === 'execution') loadExecutionReport()
})
onMounted(async () => {
await loadPublishedMaps()
loadBudget()
})
</script>
<style scoped>
:deep(.row-changed) { --el-table-tr-bg-color: #fff7e6 !important; }
.strategy-dim-block { margin-bottom: 20px; border: 1px solid #ebeef5; border-radius: 8px; overflow: hidden; }
.strategy-dim-header { padding: 10px 16px; background: #f5f7fa; border-left: 4px solid #409eff; display: flex; align-items: center; gap: 8px; }
.strategy-dim-icon { font-size: 20px; }
.strategy-dim-name { font-weight: 600; font-size: 15px; }
.strategy-dim-summary { font-size: 12px; color: #999; }
.strategy-obj-block { padding: 8px 16px; }
.strategy-obj-block:not(:last-child) { border-bottom: 1px solid #f0f0f0; }
.strategy-obj-header { padding: 6px 0 8px; font-size: 13px; color: #555; font-weight: 500; }
.strategy-obj-name::before { content: '◆ '; color: #999; font-size: 10px; }
</style>
-731
View File
@@ -1,731 +0,0 @@
<template>
<div class="kb-page">
<!-- Tab切换 -->
<el-tabs v-model="activeTab" class="kb-tabs">
<!-- Tab 1: 知识库 -->
<el-tab-pane label="📖 知识库" name="knowledge">
<div class="kb-header">
<h3>CMA知识库</h3>
<el-input v-model="search" placeholder="搜索术语、计算公式、最佳实践..." prefix-icon="Search" clearable style="width:360px;" />
</div>
<!-- 搜索结果 -->
<div v-if="search" class="search-results">
<h4>搜索结果({{ searchResults.length }}条)</h4>
<div v-for="item in searchResults" :key="item.title" class="search-item" @click="openItem(item)">
<span class="search-cat-tag">{{ catLabel(item.category) }}</span>
<span class="search-title">{{ item.title }}</span>
<span class="search-summary">{{ item.summary }}</span>
</div>
<div v-if="searchResults.length === 0" class="empty-search">未找到相关内容</div>
</div>
<!-- 分类展示 -->
<div v-else class="kb-grid">
<div v-for="(cat, ci) in categories" :key="ci" class="kb-category">
<div class="cat-head">
<span class="cat-icon">{{ cat.icon }}</span>
<span class="cat-title">{{ cat.name }}</span>
<span class="cat-count">{{ cat.items.length }}篇</span>
</div>
<div class="cat-body">
<div v-for="(item, ii) in cat.items" :key="ii" class="kb-item" @click="openItem(item)">
<span class="kb-item-title">{{ item.title }}</span>
<span class="kb-item-summary">{{ item.summary }}</span>
</div>
</div>
</div>
</div>
<!-- 详情弹窗 -->
<MyDialog v-model="showDetail" :title="detailItem?.title || '文章详情'" :width="700">
<div v-if="detailItem" class="detail-content" v-html="detailItem.content"></div>
<template #footer>
<el-button @click="showDetail = false">关闭</el-button>
</template>
</MyDialog>
</el-tab-pane>
<!-- Tab 2: 职业道德自测 -->
<el-tab-pane label="⚖️ 职业道德自测" name="ethics-quiz">
<div v-if="!quizStarted" class="quiz-intro">
<div class="quiz-intro-card">
<h2>⚖️ CMA职业道德自测</h2>
<p>本测试包含 <strong>10道</strong> IMA职业道德情景题,涵盖:</p>
<ul class="quiz-standards">
<li><span class="std-tag std-integrity">诚信</span> Integrity — 诚实、公正地处理事务</li>
<li><span class="std-tag std-objectivity">客观</span> Objectivity — 保持独立性和职业判断</li>
<li><span class="std-tag std-confidentiality">保密</span> Confidentiality — 保护公司机密信息</li>
<li><span class="std-tag std-competence">胜任</span> Competence — 持续提升专业能力</li>
</ul>
<el-button type="primary" size="large" @click="startQuiz" class="quiz-start-btn">
开始自测
</el-button>
</div>
</div>
<div v-else class="quiz-body">
<!-- 进度条 -->
<div class="quiz-progress">
<div class="quiz-progress-text">
第 {{ currentQuestion + 1 }} / {{ questions.length }} 题
</div>
<el-progress :percentage="progressPct" :stroke-width="8" color="#409eff" />
</div>
<!-- 题目卡片 -->
<div class="quiz-question-card" v-if="currentQuestion < questions.length">
<div class="q-scenario">
<span class="q-number">Q{{ currentQuestion + 1 }}</span>
<p>{{ questions[currentQuestion].scenario }}</p>
</div>
<div class="q-options">
<div
v-for="opt in questions[currentQuestion].options"
:key="opt.id"
class="q-option"
:class="{
'selected': selectedOption?.id === opt.id,
'correct': answered && opt.correct,
'wrong': answered && selectedOption?.id === opt.id && !opt.correct,
'disabled': answered
}"
@click="selectOption(opt)"
>
<span class="opt-id">{{ opt.id.toUpperCase() }}</span>
<span class="opt-text">{{ opt.text }}</span>
</div>
</div>
<!-- 反馈 -->
<div v-if="answered" class="q-feedback" :class="selectedOption?.correct ? 'feedback-correct' : 'feedback-wrong'">
<div class="feedback-icon">{{ selectedOption?.correct ? '✅' : '❌' }}</div>
<div class="feedback-text">
<strong>{{ selectedOption?.correct ? '正确!' : '选择有误' }}</strong>
<p>{{ feedbackMessage }}</p>
</div>
</div>
<!-- 操作按钮 -->
<div class="q-actions">
<el-button v-if="answered && currentQuestion < questions.length - 1" type="primary" @click="nextQuestion">
下一题 →
</el-button>
<el-button v-if="answered && currentQuestion === questions.length - 1" type="success" @click="showResult">
查看成绩 🏆
</el-button>
</div>
</div>
<!-- 成绩页面 -->
<div v-if="showScore" class="quiz-result">
<div class="result-card">
<div class="result-emoji">{{ scoreEmoji }}</div>
<div class="result-title">{{ scoreTitle }}</div>
<div class="result-score">{{ correctCount }} / {{ questions.length }}</div>
<el-progress :percentage="scorePct" :stroke-width="12" :color="scoreColor" class="result-bar" />
<div class="result-detail">
<div class="result-stat correct-stat">✅ 正确:{{ correctCount }} 题</div>
<div class="result-stat wrong-stat">❌ 错误:{{ questions.length - correctCount }} 题</div>
</div>
<div class="result-actions">
<el-button type="primary" @click="retryQuiz">重新测试</el-button>
<el-button @click="backToIntro">返回首页</el-button>
</div>
</div>
<!-- 每题回顾 -->
<div class="result-review">
<h4>逐题回顾</h4>
<div v-for="(q, idx) in questions" :key="idx" class="review-item" :class="userAnswers[idx]?.correct ? 'review-correct' : 'review-wrong'">
<div class="review-head">
<span class="review-icon">{{ userAnswers[idx]?.correct ? '✅' : '❌' }}</span>
<span class="review-q">Q{{ idx + 1 }}. {{ q.scenario }}</span>
</div>
<div class="review-answer">
你的选择:<strong>{{ userAnswers[idx]?.text || '未作答' }}</strong>
</div>
<div class="review-correct-answer">
正确答案:<strong>{{ q.options.find(o => o.correct)?.text }}</strong>
</div>
<div class="review-feedback" v-if="!userAnswers[idx]?.correct">
💡 {{ userAnswers[idx]?.feedback || q.options.find(o => o.correct)?.feedback }}
</div>
</div>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue"
import { ElMessage } from "element-plus"
import { ethicsQuizApi } from "../api/index"
// ── Tab ──
const activeTab = ref("knowledge")
// ── 原有知识库逻辑 ──
const search = ref("")
const showDetail = ref(false)
const detailItem = ref<any>(null)
const loading = ref(true)
// 从API加载知识库文章
const categories = ref<any[]>([])
const allArticles = ref<any[]>([])
async function loadArticles() {
try {
const { knowledgeArticleApi } = await import('../api/index')
const r: any = await knowledgeArticleApi.list()
const articles = r.data || r || []
allArticles.value = articles
// 按category分组
const groupMap: Record<string, any[]> = {}
for (const a of articles) {
const cat = a.category || '其他'
if (!groupMap[cat]) groupMap[cat] = []
groupMap[cat].push(a)
}
const catIcons: Record<string, string> = {
'CMA完整文档': '📘', '管理会计方法': '📊', 'KPI设定': '🎯',
'OKR': '📋', '其他': '📄',
}
categories.value = Object.entries(groupMap).map(([key, items]) => ({
name: key, icon: catIcons[key] || '📄', key, items,
}))
} catch (e) {
console.error('加载知识库失败', e)
}
loading.value = false
}
// 搜索
const searchResults = ref<any[]>([])
watch(search, (val) => {
if (!val) { searchResults.value = []; return }
const kw = val.toLowerCase()
searchResults.value = allArticles.value.filter((a: any) =>
(a.title || '').toLowerCase().includes(kw) || (a.category || '').toLowerCase().includes(kw)
)
})
const renderedContent = computed(() => detailItem.value?.content || '')
{
name: "术语解释", icon: "📖", key: "term",
items: [
{
title: "KPI(关键绩效指标)",
summary: "衡量战略目标达成情况的量化指标",
content: `<h4>KPI(Key Performance Indicator)</h4>
<p>关键绩效指标,是衡量企业战略目标达成情况的量化指标。在管理会计OS中,KPI贯穿战略地图、经营看板、预警中心等所有模块。</p>
<h5>特征</h5>
<ul>
<li><b>可量化</b>:每个KPI有明确的计算公式和数值</li>
<li><b>有时效</b>:按日/周/月/季/年采集和考核</li>
<li><b>有标准</b>:每个KPI设定了目标值和红黄绿灯阈值</li>
</ul>
<h5>编码规则</h5>
<p>KPI编码按 BSC维度_类别_序号 命名,如 <code>F_REVENUE_001</code>:</p>
<ul>
<li>F = 财务维度 (Finance)</li>
<li>C = 客户维度 (Customer)</li>
<li>P = 内部流程 (Process)</li>
<li>L = 学习成长 (Learning)</li>
</ul>`,
},
{
title: "BSC(平衡计分卡)",
summary: "从财务、客户、内部流程、学习成长四个维度衡量企业绩效",
content: `<h4>BSC(Balanced Scorecard)</h4>
<p>平衡计分卡,由Kaplan和Norton提出,从四个维度衡量企业绩效:</p>
<ul>
<li><b>财务维度</b>:最终结果指标,如收入、利润、成本</li>
<li><b>客户维度</b>:市场反馈指标,如客户数量、满意度</li>
<li><b>内部流程</b>:运营效率指标,如交付及时率、库存周转</li>
<li><b>学习成长</b>:驱动因素指标,如培训完成率、人才梯队</li>
</ul>
<p>四个维度形成因果链:<b>学习成长 → 内部流程 → 客户 → 财务</b></p>`,
},
{
title: "红黄绿灯预警",
summary: "KPI达成情况的颜色标识:绿=正常 黄=预警 红=异常",
content: `<h4>红黄绿灯预警机制</h4>
<p>系统自动根据KPI实际值与目标值的比率,生成三种预警等级:</p>
<table border="1" cellpadding="8" cellspacing="0" style="border-collapse:collapse;width:100%;margin:12px 0;">
<tr style="background:#f5f5f5;"><th>颜色</th><th>等级</th><th>判定标准</th><th>建议动作</th></tr>
<tr><td style="color:#67c23a;font-weight:bold;">🟢 绿色</td><td>正常</td><td>达成率 ≥ 90%</td><td>保持现状</td></tr>
<tr><td style="color:#e6a23c;font-weight:bold;">🟡 黄色</td><td>预警</td><td>70% ≤ 达成率 < 90%</td><td>关注趋势,制定改善计划</td></tr>
<tr><td style="color:#f56c6c;font-weight:bold;">🔴 红色</td><td>异常</td><td>达成率 < 70%</td><td>立即分析原因,启动改善行动</td></tr>
</table>`,
},
{
title: "战略地图",
summary: "用BSC四维框架可视化展示战略目标与因果关系的工具",
content: `<h4>战略地图</h4>
<p>战略地图是平衡计分卡的图形化表达,将战略目标按BSC四个维度排列,并用箭头展示因果驱动关系。</p>
<h5>使用方法</h5>
<ol>
<li>在「战略地图」页面创建一张新地图</li>
<li>在每个维度下添加战略目标</li>
<li>给目标关联KPI,用于量化衡量</li>
<li>从低维度向高维度画因果连线(如 人才培养→流程优化→客户满意→收入增长)</li>
<li>发布后可在「战略回顾会」中查看执行情况</li>
</ol>`,
},
{
title: "改善行动计划(CAP)",
summary: "针对预警问题制定的改进措施,跟踪执行和效果",
content: `<h4>改善行动计划</h4>
<p>当KPI出现黄色或红色预警时,需要制定改善行动计划来解决问题。</p>
<h5>流程</h5>
<ol>
<li>在预警中心识别异常KPI</li>
<li>创建改善行动计划:指定标题、负责人、截止日期、优先级</li>
<li>执行过程中更新进度(0-100%)</li>
<li>完成后填写改善结果</li>
<li>系统会持续跟踪逾期和进度</li>
</ol>`,
},
],
},
{
name: "计算公式", icon: "📐", key: "formula",
items: [
{
title: "达成率",
summary: "实际值 ÷ 目标值 × 100%",
content: `<h4>达成率</h4>
<p><b>公式</b>:达成率 = 实际值 / 目标值 × 100%</p>
<h5>示例</h5>
<p>销售总额目标2000万,实际1640万:1640/2000 = <b>82%</b></p>
<h5>说明</h5>
<ul>
<li>达成率 ≥ 90% 为绿灯</li>
<li>70% ≤ 达成率 < 90% 为黄灯</li>
<li>达成率 < 70% 为红灯</li>
<li>部分反向指标(如成本控制率)需取相反判断</li>
</ul>`,
},
{
title: "偏差率",
summary: "(实际值 - 目标值)÷ 目标值 × 100%",
content: `<h4>偏差率</h4>
<p><b>公式</b>:偏差率 = (实际值 - 目标值) / 目标值 × 100%</p>
<h5>示例</h5>
<p>预算100万,实际支出120万:(120-100)/100 = <b>+20%</b>(超支)</p>
<h5>说明</h5>
<ul>
<li>正偏差 = 超过目标(可能好也可能坏,取决于指标性质)</li>
<li>负偏差 = 低于目标</li>
</ul>`,
},
{
title: "销售毛利率",
summary: "(销售收入 - 销售成本)÷ 销售收入 × 100%",
content: `<h4>销售毛利率</h4>
<p><b>公式</b>:毛利率 = (销售收入 - 销售成本) / 销售收入 × 100%</p>
<h5>示例</h5>
<p>收入1000万,成本700万:(1000-700)/1000 = <b>30%</b></p>
<h5>说明</h5>
<p>毛利率是衡量企业盈利能力的核心指标,越高代表产品附加值越大。</p>`,
},
{
title: "库存周转率",
summary: "销售成本 ÷ 平均库存金额",
content: `<h4>库存周转率</h4>
<p><b>公式</b>:库存周转率 = 销售成本 / 平均库存金额</p>
<h5>示例</h5>
<p>年销售成本1.2亿,平均库存1500万:1.2亿/1500万 = <b>8次/年</b></p>
<h5>说明</h5>
<p>库存周转率反映企业存货管理效率,越高说明资金占用越少。</p>`,
},
{
title: "达成率 vs 偏差率 对照",
summary: "两个指标的区别与使用场景",
content: `<h4>达成率 vs 偏差率</h4>
<table border="1" cellpadding="8" cellspacing="0" style="border-collapse:collapse;width:100%;margin:12px 0;">
<tr style="background:#f5f5f5;"><th></th><th>达成率</th><th>偏差率</th></tr>
<tr><td><b>公式</b></td><td>实际/目标 × 100%</td><td>(实际-目标)/目标 × 100%</td></tr>
<tr><td><b>理想值</b></td><td>≥ 100%</td><td>接近0%</td></tr>
<tr><td><b>适用场景</b></td><td>正向指标(收入、满意度)</td><td>预算控制、成本差异</td></tr>
<tr><td><b>红黄绿灯</b></td><td>≥90%绿,≥70%黄,<70%红</td><td>按偏差幅度自定义</td></tr>
</table>`,
},
],
},
{
name: "最佳实践", icon: "⭐", key: "practice",
items: [
{
title: "如何绘制一张好的战略地图",
summary: "从设定目标到画因果链的实操指南",
content: `<h4>绘制战略地图的5个步骤</h4>
<ol>
<li><b>明确战略主题</b>:确定本年度最重要的3-5个战略方向</li>
<li><b>从财务开始</b>:先在财务维度设定最终目标(如"提升销售总额30%")</li>
<li><b>追溯驱动因素</b>:思考"要达成财务目标,客户需要什么?流程需要怎么优化?员工需要什么能力?"</li>
<li><b>关联KPI</b>:每个目标绑定1-2个可量化的KPI</li>
<li><b>画因果连线</b>:从下到上连接各维度目标,形成因果链</li>
</ol>
<h5>常见错误</h5>
<ul>
<li>❌ 每个维度设太多目标(建议不超过4个)</li>
<li>❌ 目标不关联KPI(无法量化跟踪)</li>
<li>❌ 因果链混乱(学习→流程→客户→财务,不要跳层)</li>
</ul>`,
},
{
title: "预算编制的10个原则",
summary: "预算管理模块使用的最佳实践",
content: `<h4>预算编制最佳实践</h4>
<ol>
<li><b>自上而下 vs 自下而上</b>:先定总盘子,再分解到各部门</li>
<li><b>零基预算</b>:每年从零开始编制,不以上年基数为准</li>
<li><b>弹性预算</b>:按业务量设定不同层级的预算标准</li>
<li><b>滚动预算</b>:每季度更新一次全年预算</li>
<li><b>差异分析</b>:每月对比实际与预算,找出偏差原因</li>
</ol>`,
},
{
title: "预警阈值设置的技巧",
summary: "如何设置合理的红黄绿灯阈值",
content: `<h4>阈值设置建议</h4>
<p>合理的红黄绿灯阈值应该:</p>
<ul>
<li><b>不要过严</b>:绿灯设90%而不是95%,否则大部分KPI都是黄色</li>
<li><b>区分指标性质</b>:正向指标(收入)和反向指标(成本)的绿/红判断逻辑不同</li>
<li><b>结合历史数据</b>:参考过去6个月的实际值来设定合理目标</li>
<li><b>动态调整</b>:每季度review一次阈值,随业务发展调整</li>
</ul>`,
},
{
title: "每日工作台的使用方法",
summary: "如何高效使用我的工作台进行日常管理",
content: `<h4>工作台使用三部曲</h4>
<ol>
<li><b>早上看KPI</b>:登录先看一眼我的KPI红黄绿灯,了解整体状态</li>
<li><b>处理待办</b>:查看逾期改善行动和待审批事项</li>
<li><b>跟踪异常</b>:红色预警的KPI立即查看详情,安排改善行动</li>
</ol>`,
},
],
},
{
name: "常见问题", icon: "❓", key: "faq",
items: [
{
title: "数据不同步怎么办?",
summary: "ERP数据同步失败的排查步骤",
content: `<h4>数据同步问题排查</h4>
<ol>
<li>检查ERP API网关是否正常运行(端口8300)</li>
<li>在「数据管理」页面查看最近一次同步时间</li>
<li>手动触发同步:数据管理 → 点击「手动同步」</li>
<li>如仍失败,联系IT管理员查看后端日志</li>
</ol>`,
},
{
title: "为什么看不到某些KPI?",
summary: "角色权限和KPI可见性的说明",
content: `<h4>KPI可见性规则</h4>
<ul>
<li><b>CEO</b>:全量KPI可见</li>
<li><b>财务</b>:财务维度KPI + 通用KPI</li>
<li><b>业务</b>:客户、流程、学习维度KPI</li>
<li><b>IT</b>:全量KPI可见(运维用途)</li>
</ul>
<p>如需调整可见范围,请联系管理员修改权限配置。</p>`,
},
{
title: "如何删除一个战略目标?",
summary: "画布上目标的编辑和删除操作",
content: `<h4>删除战略目标</h4>
<ol>
<li>进入战略地图画布页面</li>
<li>点击要删除的目标卡片(会弹出编辑弹窗)</li>
<li>点击弹窗底部的「删除」按钮</li>
<li>确认删除</li>
<li>点击「保存」使改动生效</li>
</ol>`,
},
],
},
])
const searchResults = computed(() => {
if (!search.value.trim()) return []
const q = search.value.toLowerCase()
const results: any[] = []
for (const cat of categories.value) {
for (const item of cat.items) {
if (
item.title.toLowerCase().includes(q) ||
item.summary.toLowerCase().includes(q) ||
item.content.toLowerCase().includes(q)
) {
results.push({ ...item, category: cat.key })
}
}
}
return results
})
function catLabel(key: string) {
const map: Record<string, string> = { term: "术语", formula: "公式", practice: "实践", faq: "FAQ" }
return map[key] || key
}
function openItem(item: any) {
detailItem.value = item
showDetail.value = true
}
// ── 职业道德自测逻辑 ──
const questions = ref<any[]>([])
const quizStarted = ref(false)
const currentQuestion = ref(0)
const selectedOption = ref<any>(null)
const answered = ref(false)
const userAnswers = ref<any[]>([])
const showScore = ref(false)
const loading = ref(false)
const progressPct = computed(() => {
if (questions.value.length === 0) return 0
return ((currentQuestion.value + (answered.value ? 1 : 0)) / questions.value.length) * 100
})
const correctCount = computed(() => {
return userAnswers.value.filter(a => a?.correct).length
})
const scorePct = computed(() => {
if (questions.value.length === 0) return 0
return Math.round((correctCount.value / questions.value.length) * 100)
})
const scoreEmoji = computed(() => {
const pct = scorePct.value
if (pct >= 90) return "🏆"
if (pct >= 70) return "👍"
if (pct >= 50) return "💪"
return "📖"
})
const scoreTitle = computed(() => {
const pct = scorePct.value
if (pct >= 90) return "优秀!你对IMA职业道德准则掌握得很好!"
if (pct >= 70) return "良好!建议复习一下出错的部分"
if (pct >= 50) return "需加强!请回顾IMA四项基本准则"
return "建议重新学习IMA职业道德准则"
})
const scoreColor = computed(() => {
const pct = scorePct.value
if (pct >= 90) return "#67c23a"
if (pct >= 70) return "#409eff"
if (pct >= 50) return "#e6a23c"
return "#f56c6c"
})
const feedbackMessage = computed(() => {
if (!selectedOption.value) return ""
return selectedOption.value.feedback || (selectedOption.value.correct ? "回答正确!" : "回答错误")
})
async function startQuiz() {
loading.value = true
try {
const data: any = await ethicsQuizApi.getQuestions()
questions.value = data || []
quizStarted.value = true
currentQuestion.value = 0
selectedOption.value = null
answered.value = false
userAnswers.value = []
showScore.value = false
} catch (e: any) {
ElMessage.error("加载题目失败,请重试")
}
loading.value = false
}
function selectOption(opt: any) {
if (answered.value) return
selectedOption.value = opt
answered.value = true
userAnswers.value[currentQuestion.value] = opt
}
function nextQuestion() {
currentQuestion.value++
selectedOption.value = null
answered.value = false
}
function showResult() {
showScore.value = true
}
function retryQuiz() {
quizStarted.value = false
questions.value = []
currentQuestion.value = 0
selectedOption.value = null
answered.value = false
userAnswers.value = []
showScore.value = false
}
function backToIntro() {
quizStarted.value = false
showScore.value = false
}
</script>
<style scoped>
.kb-page { padding: 20px; height: calc(100vh - 60px); overflow-y: auto; }
.kb-tabs { height: 100%; }
.kb-tabs :deep(.el-tabs__content) { height: calc(100% - 40px); overflow-y: auto; }
.kb-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.kb-header h3 { margin: 0; font-size: 20px; }
.kb-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
.kb-category { background: #fff; border-radius: 10px; padding: 16px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
.cat-head { display: flex; align-items: center; gap: 6px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #f0f0f0; }
.cat-icon { font-size: 20px; }
.cat-title { font-size: 16px; font-weight: 600; flex: 1; }
.cat-count { font-size: 12px; color: #999; background: #f5f5f5; padding: 2px 8px; border-radius: 10px; }
.cat-body { display: flex; flex-direction: column; gap: 6px; }
.kb-item { padding: 10px; border-radius: 8px; cursor: pointer; transition: background 0.2s; }
.kb-item:hover { background: #f5f7fa; }
.kb-item-title { display: block; font-size: 14px; font-weight: 500; margin-bottom: 2px; }
.kb-item-summary { display: block; font-size: 12px; color: #999; }
.search-results { margin-bottom: 20px; }
.search-results h4 { margin: 0 0 12px 0; font-size: 15px; color: #666; }
.search-item { display: flex; align-items: center; gap: 8px; padding: 12px; border-radius: 8px; cursor: pointer; background: #fff; margin-bottom: 6px; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
.search-item:hover { background: #f5f7fa; }
.search-cat-tag { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: #ecf5ff; color: #409eff; flex-shrink: 0; }
.search-title { font-weight: 500; font-size: 14px; flex-shrink: 0; }
.search-summary { font-size: 12px; color: #999; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.empty-search { text-align: center; color: #999; padding: 40px; }
.detail-content { line-height: 1.8; font-size: 14px; }
.detail-content h4 { margin: 16px 0 8px; }
.detail-content h5 { margin: 12px 0 6px; color: #666; }
.detail-content ul, .detail-content ol { padding-left: 20px; }
.detail-content li { margin-bottom: 4px; }
.detail-content code { background: #f5f5f5; padding: 2px 6px; border-radius: 4px; font-size: 13px; }
.detail-content table { width: 100%; border-collapse: collapse; margin: 12px 0; }
.detail-content th, .detail-content td { border: 1px solid #e0e0e0; padding: 8px; text-align: left; }
.detail-content th { background: #f5f5f5; font-weight: 600; }
/* ── Quiz Intro ── */
.quiz-intro { display: flex; justify-content: center; padding: 40px 0; }
.quiz-intro-card {
background: #fff;
border-radius: 16px;
padding: 40px;
max-width: 560px;
width: 100%;
text-align: center;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.quiz-intro-card h2 { margin: 0 0 16px; font-size: 24px; color: #303133; }
.quiz-intro-card p { color: #606266; font-size: 15px; margin-bottom: 20px; }
.quiz-standards { list-style: none; padding: 0; margin: 0 0 32px; text-align: left; }
.quiz-standards li { padding: 10px 16px; margin-bottom: 8px; border-radius: 8px; background: #f5f7fa; font-size: 14px; color: #303133; display: flex; align-items: center; gap: 8px; }
.std-tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; color: #fff; flex-shrink: 0; }
.std-integrity { background: #409eff; }
.std-objectivity { background: #67c23a; }
.std-confidentiality { background: #e6a23c; }
.std-competence { background: #909399; }
.quiz-start-btn { min-width: 200px; font-size: 16px; }
/* ── Quiz Body ── */
.quiz-body { max-width: 700px; margin: 0 auto; padding: 20px 0; }
/* Progress */
.quiz-progress { margin-bottom: 24px; }
.quiz-progress-text { font-size: 14px; color: #606266; margin-bottom: 8px; font-weight: 500; }
/* Question Card */
.quiz-question-card { background: #fff; border-radius: 12px; padding: 28px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
.q-scenario { margin-bottom: 24px; }
.q-number { display: inline-block; background: #409eff; color: #fff; padding: 4px 12px; border-radius: 20px; font-size: 13px; font-weight: 600; margin-bottom: 12px; }
.q-scenario p { font-size: 16px; line-height: 1.7; color: #303133; margin: 0; }
/* Options */
.q-options { display: flex; flex-direction: column; gap: 12px; margin-bottom: 20px; }
.q-option {
display: flex; align-items: flex-start; gap: 12px;
padding: 14px 16px; border-radius: 10px;
border: 2px solid #e4e7ed;
cursor: pointer; transition: all 0.2s;
background: #fff;
}
.q-option:hover { border-color: #409eff; background: #f0f7ff; }
.q-option.selected { border-color: #409eff; background: #ecf5ff; }
.q-option.correct { border-color: #67c23a; background: #f0f9eb; }
.q-option.wrong { border-color: #f56c6c; background: #fef0f0; }
.q-option.disabled { cursor: default; }
.opt-id {
flex-shrink: 0; width: 28px; height: 28px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-weight: 600; font-size: 14px;
background: #f0f2f5; color: #606266;
}
.q-option.correct .opt-id { background: #67c23a; color: #fff; }
.q-option.wrong .opt-id { background: #f56c6c; color: #fff; }
.opt-text { font-size: 15px; line-height: 1.6; color: #303133; padding-top: 3px; }
/* Feedback */
.q-feedback {
display: flex; align-items: flex-start; gap: 12px;
padding: 16px; border-radius: 10px; margin-bottom: 20px;
}
.feedback-correct { background: #f0f9eb; border: 1px solid #b7eb8f; }
.feedback-wrong { background: #fef0f0; border: 1px solid #fbc4c4; }
.feedback-icon { font-size: 24px; flex-shrink: 0; }
.feedback-text { flex: 1; }
.feedback-text strong { display: block; margin-bottom: 4px; font-size: 15px; }
.feedback-text p { margin: 0; font-size: 14px; color: #606266; line-height: 1.6; }
.q-actions { text-align: right; }
/* Result */
.quiz-result { max-width: 700px; margin: 0 auto; padding: 10px 0; }
.result-card {
background: #fff; border-radius: 16px; padding: 36px;
text-align: center; box-shadow: 0 2px 12px rgba(0,0,0,0.06); margin-bottom: 24px;
}
.result-emoji { font-size: 56px; margin-bottom: 12px; }
.result-title { font-size: 18px; color: #303133; font-weight: 600; margin-bottom: 16px; }
.result-score { font-size: 42px; font-weight: 700; color: #409eff; margin-bottom: 16px; }
.result-bar { max-width: 400px; margin: 0 auto 20px; }
.result-detail { margin-bottom: 24px; }
.result-stat { font-size: 15px; margin-bottom: 6px; }
.correct-stat { color: #67c23a; }
.wrong-stat { color: #f56c6c; }
.result-actions { display: flex; gap: 12px; justify-content: center; }
/* Review */
.result-review { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 1px 6px rgba(0,0,0,0.04); }
.result-review h4 { margin: 0 0 16px; font-size: 16px; color: #303133; }
.review-item {
padding: 16px; border-radius: 8px; margin-bottom: 10px;
border-left: 4px solid transparent;
}
.review-correct { background: #f0f9eb; border-left-color: #67c23a; }
.review-wrong { background: #fef0f0; border-left-color: #f56c6c; }
.review-head { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 8px; }
.review-icon { flex-shrink: 0; font-size: 16px; }
.review-q { font-size: 14px; font-weight: 500; line-height: 1.5; }
.review-answer, .review-correct-answer { font-size: 13px; color: #606266; margin-bottom: 4px; }
.review-feedback { font-size: 13px; color: #e6a23c; background: #fdf6ec; padding: 8px 12px; border-radius: 6px; margin-top: 6px; }
</style>
@@ -1,410 +0,0 @@
<template>
<div>
<h3>差异分析看板</h3>
<!-- 筛选栏 -->
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
<el-select v-model="filterYear" placeholder="年份" style="width:100px;" @change="loadReport">
<el-option v-for="y in yearOptions" :key="y" :label="y" :value="y" />
</el-select>
<el-select v-model="filterMonth" placeholder="月份" style="width:90px;" @change="loadReport">
<el-option v-for="m in 12" :key="m" :label="`${m}月`" :value="m" />
</el-select>
<el-select v-model="filterDimension" placeholder="维度" clearable style="width:110px;" @change="filterChanged">
<el-option label="财务" value="finance" />
<el-option label="客户" value="customer" />
<el-option label="内部流程" value="process" />
<el-option label="学习成长" value="learning" />
</el-select>
<el-select v-model="alertFilter" placeholder="预警级别" clearable style="width:120px;" @change="filterChanged">
<el-option label="全部" value="" />
<el-option label="红色预警(超20%)" value="red" />
<el-option label="黄色预警(超10%)" value="yellow" />
<el-option label="正常(10%以内)" value="normal" />
</el-select>
<el-button type="primary" @click="loadReport">刷新</el-button>
<el-button @click="exportReport">导出CSV</el-button>
</div>
<!-- 汇总统计卡片(维度级) -->
<el-row :gutter="16" style="margin-bottom:16px;">
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-label">已录预算KPI</div>
<div class="stat-value">{{ stats.total_kpis || 0 }}</div>
</div>
</el-card>
</el-col>
<el-col :span="6" v-for="dim in dimensionCards" :key="dim.key">
<el-card shadow="hover" :body-style="{ borderLeft: '4px solid ' + dim.color }" style="cursor:pointer;" @click="scrollToDim(dim.key)">
<div class="stat-card">
<div class="stat-label">{{ dim.icon }} {{ dim.name }}</div>
<div class="stat-value" :class="dim.summary.level + '-text'">
{{ dim.summary.deviation_rate != null ? dim.summary.deviation_rate.toFixed(1) + '%' : '-' }}
</div>
<div style="font-size:11px;color:#999;margin-top:2px;">
{{ dim.summary.has_budget }}/{{ dim.summary.count }} KPI有预算
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 差异趋势图(简易柱状图) -->
<el-card style="margin-bottom:16px;">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center;">
<span>差异趋势(各月实际 vs 预算)</span>
<el-select v-model="trendKpi" placeholder="选择KPI" style="width:220px;" @change="loadTrend">
<el-option v-for="k in kpiOptions" :key="k.id" :label="`${k.kpi_code} ${k.kpi_name}`" :value="k.id" />
</el-select>
</div>
</template>
<div v-if="trendData.length > 0" style="display:flex;align-items:flex-end;gap:8px;height:200px;padding:20px 0;overflow-x:auto;">
<div v-for="(item, idx) in trendData" :key="idx" style="display:flex;flex-direction:column;align-items:center;min-width:60px;">
<div :title="`实际: ${item.actual}, 预算: ${item.budget}`" class="trend-bar-group" style="display:flex;gap:3px;height:160px;align-items:flex-end;">
<div
class="trend-bar actual-bar"
:style="{ height: barHeight(item.actual, maxTrend) + 'px' }"
:title="`实际 ${item.actual}`"
></div>
<div
class="trend-bar budget-bar"
:style="{ height: barHeight(item.budget, maxTrend) + 'px' }"
:title="`预算 ${item.budget}`"
></div>
</div>
<span style="font-size:11px;color:#909399;margin-top:4px;">{{ item.period }}</span>
<span v-if="item.deviation !== undefined" :style="{ fontSize:'10px', color: item.deviation > 0 ? '#f56c6c' : '#67c23a' }">
{{ item.deviation > 0 ? '+' : '' }}{{ item.deviation.toFixed(1) }}%
</span>
</div>
</div>
<div v-else style="text-align:center;color:#999;padding:40px;">
{{ trendLoading ? '加载中...' : '选择一个KPI查看趋势' }}
</div>
</el-card>
<!-- 维度→目标→KPI 三层树形表格 -->
<el-card v-if="hierarchyData.length > 0" :body-style="{ padding: 0 }">
<el-table
:data="flattenHierarchy"
v-loading="loading"
border stripe
size="small"
style="width:100%;"
row-key="__uid"
:tree-props="{ children: '_children' }"
:indent="24"
:default-expand-all="expandedAll"
@row-click="onRowClick"
>
<el-table-column label="层级" min-width="280">
<template #default="{ row }">
<div style="display:flex;align-items:center;gap:6px;">
<span v-if="row._type === 'dimension'" style="font-size:16px;">{{ row.icon }}</span>
<span :style="{
fontWeight: row._type === 'dimension' ? 700 : row._type === 'objective' ? 600 : 400,
fontSize: row._type === 'dimension' ? '14px' : '13px',
color: row._type === 'dimension' ? '#303133' : '#606266'
}">{{ row._type === 'dimension' ? row.name + ' 维度' : row.name }}</span>
<el-tag v-if="row._type === 'dimension'" size="small" :type="levelTag(row.summary.level)" style="margin-left:4px;">
{{ row.summary.deviation_rate != null ? row.summary.deviation_rate.toFixed(1) + '%' : '--' }}
</el-tag>
<el-tag v-if="row._type === 'objective'" size="small" :type="levelTag(row.summary.level)" style="margin-left:4px;">
{{ row.summary.deviation_rate != null ? row.summary.deviation_rate.toFixed(1) + '%' : '--' }}
</el-tag>
<span v-if="row._type === 'dimension'" style="font-size:11px;color:#999;margin-left:8px;">
{{ row.summary.has_budget }}/{{ row.summary.count }} KPI
</span>
<span v-if="row._type === 'objective'" style="font-size:11px;color:#999;margin-left:8px;">
{{ row.summary.has_budget }}/{{ row.summary.count }} KPI
</span>
</div>
</template>
</el-table-column>
<el-table-column prop="kpi_code" label="KPI编码" width="110">
<template #default="{ row }">
<span v-if="row._type === 'kpi'" style="font-family:monospace;font-size:12px;">{{ row.kpi_code }}</span>
</template>
</el-table-column>
<el-table-column label="预算值" width="120" align="right">
<template #default="{ row }">
<span v-if="row._type === 'kpi'" :style="{ color: row.budget_value != null ? '#303133' : '#999' }">
{{ row.budget_value != null ? fmtVal(row.budget_value) : '--' }}
</span>
</template>
</el-table-column>
<el-table-column label="实际值" width="120" align="right">
<template #default="{ row }">
<span v-if="row._type === 'kpi'" :style="{ color: row.actual_value != null ? '#303133' : '#999' }">
{{ row.actual_value != null ? fmtVal(row.actual_value) : '--' }}
</span>
</template>
</el-table-column>
<el-table-column label="差异额" width="120" align="right">
<template #default="{ row }">
<span v-if="row._type === 'kpi'" :style="{ color: (row.deviation_amount || 0) > 0 ? '#f56c6c' : '#67c23a' }">
{{ row.deviation_amount !== undefined ? fmtVal(row.deviation_amount) : '--' }}
</span>
</template>
</el-table-column>
<el-table-column label="差异率" width="100" align="right">
<template #default="{ row }">
<el-tag v-if="row._type === 'kpi' && row.deviation_rate !== undefined" :type="deviationTag(row.deviation_rate)" size="small">
{{ row.deviation_rate > 0 ? '+' : '' }}{{ row.deviation_rate.toFixed(2) }}%
</el-tag>
</template>
</el-table-column>
<el-table-column label="同比" width="85" align="right">
<template #default="{ row }">
<span v-if="row._type === 'kpi' && row.yoy?.diff_rate != null"
:style="{ color: (row.yoy.diff_rate || 0) > 0 ? '#f56c6c' : '#67c23a', fontSize:'12px' }">
{{ row.yoy.diff_rate > 0 ? '+' : '' }}{{ row.yoy.diff_rate.toFixed(1) }}%
</span>
</template>
</el-table-column>
<el-table-column label="环比" width="85" align="right">
<template #default="{ row }">
<span v-if="row._type === 'kpi' && row.mom?.diff_rate != null"
:style="{ color: (row.mom.diff_rate || 0) > 0 ? '#f56c6c' : '#67c23a', fontSize:'12px' }">
{{ row.mom.diff_rate > 0 ? '+' : '' }}{{ row.mom.diff_rate.toFixed(1) }}%
</span>
</template>
</el-table-column>
<el-table-column label="预警" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row._type === 'kpi' && row.deviation_rate != null" :type="deviationTag(row.deviation_rate)" size="small">
{{ row.deviation_rate > 20 ? '红色' : row.deviation_rate > 10 ? '黄色' : '正常' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 无数据提示 -->
<el-empty v-if="!loading && hierarchyData.length === 0 && reportData.length === 0" description="暂无差异分析数据,请先在预算管理中录入预算" style="padding:40px 0;" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { budgetApi, kpiApi } from '../api/index'
const currentYear = new Date().getFullYear()
const yearOptions = computed(() => {
const years: number[] = []
for (let y = currentYear - 2; y <= currentYear + 2; y++) years.push(y)
return years
})
const filterYear = ref(currentYear)
const filterMonth = ref(new Date().getMonth() + 1)
const filterDimension = ref('')
const alertFilter = ref('')
const loading = ref(false)
const reportData = ref<any[]>([])
const hierarchyData = ref<any[]>([])
const stats = ref<any>({})
const expandedAll = ref(true)
// ── 维度卡片计算 ──
const dimensionCards = computed(() => {
return hierarchyData.value.map(d => ({
key: d.key,
name: d.name,
icon: d.icon,
color: d.color,
summary: d.summary
}))
})
// ── 平铺树结构为el-table可用的带_children的列表 ──
const flattenHierarchy = computed(() => {
const result: any[] = []
let uid = 0
for (const dim of hierarchyData.value) {
const dimRow = { ...dim, _type: 'dimension', _children: [], __uid: `dim_${++uid}` }
for (const obj of dim.objectives) {
const objRow = { ...obj, _type: 'objective', _children: [], __uid: `obj_${++uid}` }
for (const kpi of obj.kpis) {
objRow._children.push({
...kpi,
_type: 'kpi',
_children: undefined,
__uid: `kpi_${++uid}_${kpi.kpi_id}`
})
}
dimRow._children.push(objRow)
}
result.push(dimRow)
}
return result
})
// ── 工具函数 ──
const dimMap: Record<string, string> = { finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长' }
const dimTagMap: Record<string, string> = { finance: 'danger', customer: 'warning', process: 'primary', learning: 'success' }
function levelTag(level: string) {
if (level === 'red') return 'danger'
if (level === 'yellow') return 'warning'
if (level === 'green') return 'success'
return 'info'
}
function deviationTag(rate: number) {
if (rate > 20) return 'danger'
if (rate > 10) return 'warning'
return 'success'
}
function fmtVal(v: any) {
if (v === null || v === undefined) return '--'
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
// ── 筛选变更(前端过滤树) ──
function filterChanged() {
// 维度或预警级别变化时,后端的hierarchical接口不支持前端过滤
// 重新加载(后端过滤)
loadReport()
}
function scrollToDim(key: string) {
// 展开所有,让目标维度可见
expandedAll.value = true
}
function onRowClick(row: any) {
// KPI级点击跳到详情
if (row._type === 'kpi' && row.kpi_id) {
window.open('/#/kpis/' + row.kpi_id, '_blank')
}
}
// ── 加载差异报告 ──
async function loadReport() {
loading.value = true
try {
const params: any = { hierarchical: true }
if (filterYear.value) params.year = filterYear.value
if (filterMonth.value) params.month = filterMonth.value
if (filterDimension.value) params.dimension = filterDimension.value
if (alertFilter.value) params.alert_level = alertFilter.value
const r: any = await budgetApi.deviationReport(params)
reportData.value = r.items || []
hierarchyData.value = r.hierarchy || []
stats.value = r.summary || {}
// 如果hierarchy有数据,用层级中的统计补全stats
if (hierarchyData.value.length > 0) {
let total = 0, hasBudget = 0
for (const dim of hierarchyData.value) {
total += dim.summary.count || 0
hasBudget += dim.summary.has_budget || 0
}
stats.value.total_kpis = total
stats.value.has_budget = hasBudget
}
} catch (e) {
ElMessage.error('加载差异报告失败')
}
loading.value = false
}
// ── 趋势图 ──
const trendKpi = ref<number | null>(null)
const kpiOptions = ref<any[]>([])
const trendData = ref<any[]>([])
const trendLoading = ref(false)
const maxTrend = ref(0)
async function loadKpiOptions() {
try {
const r: any = await kpiApi.list({ page_size: 200 })
const data = r.data || r || []
kpiOptions.value = Array.isArray(data) ? data : (data.items || [])
} catch (e) { /* ignore */ }
}
async function loadTrend() {
if (!trendKpi.value) { trendData.value = []; return }
trendLoading.value = true
try {
const r: any = await budgetApi.deviationReport({ kpi_id: trendKpi.value })
const data = r.data || r || []
const items = Array.isArray(data) ? data : (data.items || [])
const monthly = items.filter((i: any) => i.budget_value !== null)
.sort((a: any, b: any) => {
const keyA = a.period || ''
const keyB = b.period || ''
return keyA.localeCompare(keyB)
})
trendData.value = monthly.map((i: any) => ({
period: i.period,
actual: i.actual_value || 0,
budget: i.budget_value || 0,
deviation: i.deviation_rate,
}))
maxTrend.value = Math.max(...trendData.value.flatMap((d: any) => [d.actual, d.budget]), 1)
} catch (e) { /* ignore */ }
trendLoading.value = false
}
function barHeight(val: number, max: number) {
if (!max) return 0
return Math.max(4, (val / max) * 150)
}
// ── 导出CSV ──
function exportReport() {
const dims = hierarchyData.value
if (dims.length === 0) { ElMessage.warning('无数据可导出'); return }
const headers = ['维度', '战略目标', 'KPI编码', 'KPI名称', '预算值', '实际值', '差异额', '差异率(%)', '同比(%)', '环比(%)', '预警']
const csvRows = [headers.join(',')]
for (const dim of dims) {
for (const obj of dim.objectives) {
if (obj.kpis.length === 0) {
csvRows.push([dim.name, `"${obj.name}"`].join(','))
}
for (const k of obj.kpis) {
csvRows.push([
dim.name, `"${obj.name}"`, k.kpi_code || '', `"${k.kpi_name || ''}"`,
k.budget_value ?? '', k.actual_value ?? '',
k.deviation_amount ?? '', k.deviation_rate?.toFixed(2) ?? '',
k.yoy?.diff_rate?.toFixed(1) ?? '', k.mom?.diff_rate?.toFixed(1) ?? '',
k.deviation_rate != null ? (k.deviation_rate > 20 ? '红色' : k.deviation_rate > 10 ? '黄色' : '正常') : ''
].join(','))
}
}
}
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `偏差分析报告_${filterYear.value}年${filterMonth.value}月.csv`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
}
onMounted(() => {
loadReport()
loadKpiOptions()
})
</script>
<style scoped>
.stat-card { text-align: center; padding: 4px 0; }
.stat-label { font-size: 13px; color: #909399; margin-bottom: 6px; }
.stat-value { font-size: 24px; font-weight: bold; color: #303133; }
.red-text { color: #f56c6c; }
.yellow-text { color: #e6a23c; }
.green-text { color: #67c23a; }
.gray-text { color: #909399; }
.trend-bar { width: 18px; border-radius: 3px 3px 0 0; transition: height 0.3s; }
.actual-bar { background: #409eff; }
.budget-bar { background: #e6a23c; }
</style>
-738
View File
@@ -1,738 +0,0 @@
<template>
<div class="kpi-dict-page">
<!-- 页头 -->
<div class="page-header">
<h3>KPI字典</h3>
<div class="header-actions">
<el-button @click="showTemplateSelect = true">📋 从模板创建</el-button>
<el-button type="primary" @click="showForm=true; form={}; editMode=false">+ 新建KPI</el-button>
</div>
</div>
<div class="main-layout">
<!-- 左侧:BSC分类导航树 -->
<div class="left-tree">
<div class="tree-header">BSC分类导航</div>
<el-tree
ref="treeRef"
:data="categoryTree"
node-key="key"
:props="{ label: 'label', children: 'children' }"
:default-expanded-keys="defaultExpanded"
:show-checkbox="true"
:check-strictly="true"
@check="onTreeCheck"
highlight-current
>
<template #default="{ node, data }">
<span class="tree-node">
<span class="tree-node-label">{{ data.label }}</span>
<el-tag size="small" :type="data.children && data.children.length > 0 ? '' : 'info'" class="tree-count">{{ data.count }}</el-tag>
</span>
</template>
</el-tree>
</div>
<!-- 右侧:搜索+表格 -->
<div class="right-content">
<!-- 搜索栏 -->
<div class="search-bar">
<el-input v-model="searchKeyword" placeholder="搜索KPI名称/编码" clearable style="width:220px" @clear="loadKpis" @keyup.enter="loadKpis" />
<el-select v-model="searchDimension" placeholder="维度" clearable style="width:110px" @change="loadKpis">
<el-option label="财务" value="finance" />
<el-option label="客户" value="customer" />
<el-option label="内部流程" value="process" />
<el-option label="学习成长" value="learning" />
</el-select>
<el-select v-model="searchCategory" placeholder="二级类别" clearable style="width:140px" @change="loadKpis">
<el-option v-for="c in allCategories" :key="c.value" :label="c.label" :value="c.value" />
</el-select>
<el-button type="primary" @click="loadKpis">查询</el-button>
<el-button @click="resetSearch">重置</el-button>
</div>
<!-- 批量操作栏 -->
<div class="batch-bar" v-if="selectedIds.length > 0">
<span class="selected-info">已选 {{ selectedIds.length }} 条</span>
<el-button size="small" @click="batchDelete">批量删除</el-button>
<el-button size="small" @click="batchExport">导出CSV</el-button>
<el-button size="small" @click="selectedIds=[]">取消选择</el-button>
</div>
<!-- 表格 -->
<el-table
:data="kpis"
v-loading="loading"
style="width:100%"
@selection-change="onSelectionChange"
@row-click="(row) => $router.push('/kpis/' + row.id)"
border
stripe
size="small"
>
<el-table-column type="selection" width="40" />
<el-table-column prop="kpi_code" label="编码" width="120">
<template #default="{ row }">
<span class="kpi-code">{{ row.kpi_code }}</span>
</template>
</el-table-column>
<el-table-column prop="kpi_name" label="名称" min-width="170" />
<el-table-column prop="dimension" label="维度" width="70">
<template #default="{ row }">
<el-tag :type="dimTagType(row.dimension)" size="small">{{ dimLabel(row.dimension) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="二级类别" width="90">
<template #default="{ row }">{{ catLabel(row.category) }}</template>
</el-table-column>
<el-table-column prop="target_value" label="目标值" width="90">
<template #default="{ row }">{{ formatTarget(row.target_value, row.unit) }}</template>
</el-table-column>
<el-table-column prop="frequency" label="频率" width="70">
<template #default="{ row }">{{ freqLabel(row.frequency) }}</template>
</el-table-column>
<el-table-column prop="data_source_type" label="数据源" width="70">
<template #default="{ row }">{{ sourceLabel(row.data_source_type) }}</template>
</el-table-column>
<el-table-column label="当前值" width="100">
<template #default="{ row }">
<span v-if="row.actual_value !== null" :class="'status-' + (row.alert_level || 'none')">
{{ row.actual_value }}{{ row.unit }}
</span>
<span v-else style="color:#ccc">-</span>
</template>
</el-table-column>
<el-table-column label="来源" width="70">
<template #default="{ row }">
<el-tag v-if="row.is_system" size="small" type="info">系统</el-tag>
<el-tag v-else size="small" type="success">自定义</el-tag>
</template>
</el-table-column>
<el-table-column label="预警" width="70">
<template #default="{ row }">
<el-tag v-if="row.alert_level && row.alert_level !== 'none'" :type="row.alert_level === 'red' ? 'danger' : row.alert_level === 'yellow' ? 'warning' : 'success'" size="small">
{{ row.alert_level === 'red' ? '红灯' : row.alert_level === 'yellow' ? '黄灯' : '绿灯' }}
</el-tag>
<span v-else style="color:#ccc">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }">
<el-button size="small" text type="primary" @click.stop="$router.push('/kpis/' + row.id)">编辑</el-button>
<el-button size="small" text type="danger" @click.stop="doDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-bar">
<span class="total-info">共 {{ total }} 条</span>
<el-pagination
v-model:current-page="page"
:page-size="pageSize"
:total="total"
layout="prev, pager, next"
@current-change="loadKpis"
/>
</div>
</div>
</div>
<!-- 新建/编辑对话框 -->
<MyDialog v-model="showForm" :title="editMode ? '编辑KPI' : '新建KPI'" :width="640">
<el-form :model="form" label-width="110px" size="small">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="KPI编码"><el-input v-model="form.kpi_code" :disabled="editMode" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="KPI名称"><el-input v-model="form.kpi_name" /></el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="维度">
<el-select v-model="form.dimension" style="width:100%" @change="onDimensionChange">
<el-option label="财务" value="finance" />
<el-option label="客户" value="customer" />
<el-option label="内部流程" value="process" />
<el-option label="学习成长" value="learning" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="二级类别">
<el-select v-model="form.category" style="width:100%" :disabled="!form.dimension">
<el-option v-for="c in categoryOptions" :key="c.value" :label="c.label" :value="c.value" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="目标值"><el-input-number v-model="form.target_value" :min="0" style="width:100%" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="单位"><el-input v-model="form.unit" placeholder="%, 元, 次, 个..." /></el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="频率">
<el-select v-model="form.frequency" style="width:100%">
<el-option label="月度" value="monthly" />
<el-option label="季度" value="quarterly" />
<el-option label="年度" value="yearly" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="数据源">
<el-select v-model="form.data_source_type" style="width:100%">
<el-option label="ERP自动" value="erp" />
<el-option label="Excel导入" value="excel" />
<el-option label="手工填报" value="manual" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="负责部门"><el-input v-model="form.responsible_dept" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="负责人"><el-input v-model="form.responsible_user" /></el-form-item>
</el-col>
</el-row>
<el-form-item label="计算公式"><el-input v-model="form.formula" type="textarea" :rows="2" /></el-form-item>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="绿灯阈值">
<el-input v-model="form.threshold_green" placeholder="如 >=80" />
<div class="threshold-hint">超过此值视为正常</div>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="黄灯阈值">
<el-input v-model="form.threshold_yellow" placeholder="如 >=60" />
<div class="threshold-hint">超过此值预警</div>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="红灯阈值">
<el-input v-model="form.threshold_red" placeholder="如 <60" />
<div class="threshold-hint">触发此值紧急</div>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="所属Epic">
<el-select v-model="form.epic" style="width:100%">
<el-option v-for="i in 10" :key="i" :label="'Epic ' + i" :value="'Epic' + i" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showForm=false">取消</el-button>
<el-button type="primary" @click="saveKPI">{{ editMode ? '保存' : '创建' }}</el-button>
</template>
</MyDialog>
<!-- 从模板创建弹窗 -->
<el-dialog v-model="showTemplateSelect" title="从模板创建KPI" width="760" :close-on-click-modal="false">
<div v-loading="templateLoading">
<!-- 筛选栏 -->
<div class="search-bar" style="margin-bottom:12px;">
<el-input v-model="templateKeyword" placeholder="搜索模板名称/编码" clearable style="width:200px" />
<el-select v-model="templateDimFilter" placeholder="维度" clearable style="width:110px">
<el-option label="财务" value="finance" />
<el-option label="客户" value="customer" />
<el-option label="内部流程" value="process" />
<el-option label="学习成长" value="learning" />
</el-select>
<el-button @click="loadTemplates">查询</el-button>
</div>
<!-- 模板列表 -->
<el-table :data="templates" style="width:100%" border stripe size="small"
:highlight-current-row="true"
@current-change="onTemplateSelect"
>
<el-table-column type="index" width="40" />
<el-table-column prop="kpi_code" label="编码" width="120">
<template #default="{ row }"><span class="kpi-code">{{ row.kpi_code }}</span></template>
</el-table-column>
<el-table-column prop="kpi_name" label="名称" min-width="140" />
<el-table-column prop="dimension" label="维度" width="70">
<template #default="{ row }"><el-tag :type="dimTagType(row.dimension)" size="small">{{ dimLabel(row.dimension) }}</el-tag></template>
</el-table-column>
<el-table-column label="类别" width="80">
<template #default="{ row }">{{ catLabel(row.category) }}</template>
</el-table-column>
<el-table-column prop="unit" label="单位" width="60" />
<el-table-column prop="target_value" label="建议目标" width="80">
<template #default="{ row }">{{ row.target_value ?? '-' }}</template>
</el-table-column>
<el-table-column prop="description" label="说明" min-width="120" show-overflow-tooltip />
<el-table-column label="来源" width="60">
<template #default="{ row }">
<el-tag v-if="row.is_system" size="small" type="info">系统</el-tag>
<el-tag v-else size="small" type="success">自定义</el-tag>
</template>
</el-table-column>
</el-table>
<div v-if="!selectedTemplate" style="color:#999;font-size:13px;margin-top:8px;">请选择一个模板,然后填写实例化信息</div>
<!-- 实例化表单 -->
<div v-if="selectedTemplate" class="instantiate-form" style="margin-top:12px;border-top:1px solid #eee;padding-top:12px;">
<div style="font-size:14px;font-weight:500;margin-bottom:8px;">从「{{ selectedTemplate.kpi_name }}」创建KPI实例</div>
<el-form :model="instantiateForm" label-width="90px" size="small">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="KPI编码"><el-input v-model="instantiateForm.kpi_code" :placeholder="selectedTemplate.kpi_code" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="KPI名称"><el-input v-model="instantiateForm.kpi_name" :placeholder="selectedTemplate.kpi_name" /></el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="目标值"><el-input-number v-model="instantiateForm.target_value" :min="0" style="width:100%" :placeholder="String(selectedTemplate.target_value ?? '')" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="数据源">
<el-select v-model="instantiateForm.data_source_type" style="width:100%">
<el-option label="ERP自动" value="erp" />
<el-option label="Excel导入" value="excel" />
<el-option label="手工填报" value="manual" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="负责部门"><el-input v-model="instantiateForm.responsible_dept" /></el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="负责人"><el-input v-model="instantiateForm.responsible_user" /></el-form-item>
</el-col>
</el-row>
</el-form>
<div style="color:#999;font-size:12px;margin-top:4px;">* 编码和名称不填则使用模板默认值,后续可在KPI字典中修改</div>
</div>
</div>
<template #footer>
<el-button @click="showTemplateSelect=false">取消</el-button>
<el-button type="primary" :disabled="!selectedTemplate" @click="doInstantiate">从模板创建</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { kpiApi, templateApi, dashboardApi } from '../api/index'
import MyDialog from '../components/MyDialog.vue'
// ── 数据 ──
const kpis = ref<any[]>([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const pageSize = 20
// ── 搜索筛选 ──
const searchKeyword = ref('')
const searchDimension = ref('')
const searchCategory = ref('')
// ── 分类树 ──
const categoryTree = ref<any[]>([])
const defaultExpanded = ref<string[]>([])
const treeRef = ref<any>(null)
const treeFilterDims = ref<string[]>([])
const treeFilterCats = ref<string[]>([])
// ── 多选 ──
const selectedIds = ref<number[]>([])
// ── 新建/编辑表单 ──
const showForm = ref(false)
const editMode = ref(false)
const form = ref<any>({})
// ── 维度映射 ──
const DIM_MAP: Record<string, string> = { finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长' }
const CAT_MAP: Record<string, string> = {
revenue_growth: '收入增长', profitability: '盈利水平', cost_control: '成本费用',
asset_efficiency: '资产效率', cash_risk: '现金流风控',
customer_scale: '客户规模', customer_concentration: '客户集中度', customer_satisfaction: '客户满意',
supply_chain: '供应链效率', delivery_quality: '交付质量',
talent_pipeline: '人才梯队', employee_engagement: '员工敬业', innovation: '创新改善',
}
const FREQ_MAP: Record<string, string> = { daily: '日', weekly: '周', monthly: '月', quarterly: '季', yearly: '年' }
const SOURCE_MAP: Record<string, string> = { erp: 'ERP', excel: 'Excel', manual: '手工' }
// 维度→二级类别 对照映射
const DIM_CAT_MAP: Record<string, string[]> = {
finance: ['revenue_growth', 'profitability', 'cost_control', 'asset_efficiency', 'cash_risk'],
customer: ['customer_scale', 'customer_concentration', 'customer_satisfaction'],
process: ['supply_chain', 'delivery_quality'],
learning: ['talent_pipeline', 'employee_engagement', 'innovation'],
}
// 二级类别选项(联动维度)
const categoryOptions = computed(() => {
const dim = form.value?.dimension
if (!dim) return []
const catKeys = DIM_CAT_MAP[dim] || []
return catKeys.map(key => ({ value: key, label: CAT_MAP[key] || key }))
})
// 所有二级类别(搜索栏用)
const allCategories = computed(() => {
const seen = new Set<string>()
const result: { value: string; label: string }[] = []
for (const key in DIM_CAT_MAP) {
for (const cat of DIM_CAT_MAP[key]) {
if (!seen.has(cat)) {
seen.add(cat)
result.push({ value: cat, label: CAT_MAP[cat] || cat })
}
}
}
return result
})
function dimLabel(d: string) { return DIM_MAP[d] || d }
function catLabel(c: string) { return CAT_MAP[c] || c }
function freqLabel(f: string) { return FREQ_MAP[f] || f }
function sourceLabel(s: string) { return SOURCE_MAP[s] || s }
function dimTagType(d: string) { return ({ finance: '', customer: 'success', process: 'warning', learning: 'info' } as any)[d] || '' }
function formatTarget(val: number, unit: string) {
if (val === null || val === undefined) return '-'
if (val >= 10000) return (val / 10000).toFixed(0) + '万' + (unit || '')
return val + (unit || '')
}
// ── 加载分类树 ──
async function loadCategories() {
try {
const r: any = await kpiApi.listCategories()
categoryTree.value = [
{ key: '_all', label: '全部', count: r.total, children: r.tree || [] }
]
defaultExpanded.value = ['_all', ...(r.tree || []).map((n: any) => n.key)]
} catch (e) {
console.error('加载分类树失败', e)
}
}
// ── 树节点勾选 ──
function onTreeCheck(data: any, { checkedKeys }: any) {
// 简单模式:只支持单选过滤
if (data.key === '_all') {
// 勾选全部=清空过滤
treeFilterDims.value = []
treeFilterCats.value = []
} else if (data.children) {
// 维度节点
treeFilterDims.value = checkedKeys.includes(data.key) ? [data.key] : []
treeFilterCats.value = []
} else {
// 类别节点
treeFilterCats.value = checkedKeys.includes(data.key) ? [data.key] : []
treeFilterDims.value = []
}
loadKpis()
}
// ── 加载KPI列表 ──
async function loadKpis() {
loading.value = true
try {
const params: any = { page: page.value, page_size: pageSize }
if (searchKeyword.value) params.keyword = searchKeyword.value
if (searchDimension.value) params.dimension = searchDimension.value
if (searchCategory.value) params.category = searchCategory.value
if (treeFilterDims.value.length > 0 && !params.dimension) params.dimension = treeFilterDims.value[0]
if (treeFilterCats.value.length > 0 && !params.category) params.category = treeFilterCats.value[0]
const r: any = await kpiApi.list(params)
kpis.value = r.data || []
total.value = r.total || 0
} catch (e) {
console.error('加载KPI失败', e)
}
loading.value = false
}
// 获取当前值
async function loadActualValues() {
try {
const r: any = await dashboardApi.kpis({ role: 'ceo', period: 'month' })
const vals = r.data || []
const valMap: Record<number, any> = {}
for (const v of vals) {
valMap[v.id] = v
}
for (const kpi of kpis.value) {
if (valMap[kpi.id]) {
kpi.actual_value = valMap[kpi.id].actual_value
kpi.alert_level = valMap[kpi.id].alert_level
}
}
} catch (e) {
// 静默失败
}
}
function resetSearch() {
searchKeyword.value = ''
searchDimension.value = ''
searchCategory.value = ''
treeFilterDims.value = []
treeFilterCats.value = []
if (treeRef.value) treeRef.value.setCheckedKeys([])
page.value = 1
loadKpis()
}
// ── 多选 ──
function onSelectionChange(rows: any[]) {
selectedIds.value = rows.map((r: any) => r.id)
}
function batchDelete() {
ElMessageBox.confirm(`确定删除选中的 ${selectedIds.value.length} 条KPI?`, '提示').then(async () => {
for (const id of selectedIds.value) {
try { await kpiApi.delete(id) } catch (e) {}
}
ElMessage.success('批量删除完成')
selectedIds.value = []
loadKpis()
loadCategories()
}).catch(() => {})
}
function batchExport() {
const header = '编码,名称,维度,二级类别,目标值,频率,数据源,负责人,计算公式'
const rows = kpis.value.map((k: any) =>
[k.kpi_code, k.kpi_name, dimLabel(k.dimension), catLabel(k.category), k.target_value, freqLabel(k.frequency), sourceLabel(k.data_source_type), k.responsible_user, (k.formula || '')].join(',')
)
const bom = '\uFEFF' // Excel UTF-8 BOM
const blob = new Blob([bom + header + '\n' + rows.join('\n')], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = 'KPI字典_' + new Date().toISOString().slice(0, 10) + '.csv'
a.click()
URL.revokeObjectURL(url)
}
// ── 新建/保存 ──
function onDimensionChange(val: string) {
form.value.category = ''
}
async function saveKPI() {
try {
if (editMode.value) {
await kpiApi.update(form.value.id, form.value)
ElMessage.success('保存成功')
} else {
await kpiApi.create(form.value)
ElMessage.success('创建成功')
}
showForm.value = false
loadKpis()
loadCategories()
} catch (e) {
ElMessage.error(editMode.value ? '保存失败' : '创建失败')
}
}
function doDelete(id: number) {
ElMessageBox.confirm('确定删除此KPI?', '提示').then(async () => {
try { await kpiApi.delete(id); ElMessage.success('已删除'); loadKpis(); loadCategories() }
catch (e: any) { ElMessage.error(e?.response?.data?.detail || '删除失败') }
}).catch(() => {})
}
// ── 从模板创建 ──
const showTemplateSelect = ref(false)
const templateLoading = ref(false)
const templates = ref<any[]>([])
const templateKeyword = ref('')
const templateDimFilter = ref('')
const selectedTemplate = ref<any>(null)
const instantiateForm = ref<any>({})
async function loadTemplates() {
templateLoading.value = true
try {
const params: any = {}
if (templateKeyword.value) params.keyword = templateKeyword.value
if (templateDimFilter.value) params.dimension = templateDimFilter.value
const r: any = await templateApi.list(params)
templates.value = r.data || []
} catch (e) {
console.error('加载模板失败', e)
}
templateLoading.value = false
}
function onTemplateSelect(tpl: any) {
selectedTemplate.value = tpl
instantiateForm.value = {
kpi_code: '',
kpi_name: '',
target_value: undefined,
data_source_type: 'manual',
responsible_dept: '',
responsible_user: '',
}
}
async function doInstantiate() {
if (!selectedTemplate.value) return
try {
const data: any = {}
if (instantiateForm.value.kpi_code) data.kpi_code = instantiateForm.value.kpi_code
if (instantiateForm.value.kpi_name) data.kpi_name = instantiateForm.value.kpi_name
if (instantiateForm.value.target_value !== undefined && instantiateForm.value.target_value !== null)
data.target_value = instantiateForm.value.target_value
if (instantiateForm.value.data_source_type) data.data_source_type = instantiateForm.value.data_source_type
if (instantiateForm.value.responsible_dept) data.responsible_dept = instantiateForm.value.responsible_dept
if (instantiateForm.value.responsible_user) data.responsible_user = instantiateForm.value.responsible_user
await templateApi.instantiate(selectedTemplate.value.id, data)
ElMessage.success(`已从模板「${selectedTemplate.value.kpi_name}」创建KPI`)
showTemplateSelect.value = false
selectedTemplate.value = null
instantiateForm.value = {}
loadKpis()
loadCategories()
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || '创建失败')
}
}
onMounted(() => {
loadCategories()
loadKpis()
})
</script>
<style scoped>
.kpi-dict-page {
height: 100%;
display: flex;
flex-direction: column;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.page-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 8px;
}
.main-layout {
display: flex;
gap: 12px;
flex: 1;
overflow: hidden;
}
.left-tree {
width: 260px;
min-width: 260px;
background: #fff;
border-radius: 4px;
border: 1px solid #e4e7ed;
padding: 8px 0;
overflow-y: auto;
}
.tree-header {
padding: 8px 16px;
font-size: 14px;
font-weight: 600;
color: #303133;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 4px;
}
.tree-node {
display: flex;
align-items: center;
justify-content: space-between;
flex: 1;
padding-right: 8px;
}
.tree-node-label {
font-size: 13px;
}
.tree-count {
font-size: 11px;
min-width: 20px;
text-align: center;
}
.right-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.search-bar {
display: flex;
gap: 8px;
margin-bottom: 12px;
align-items: center;
flex-wrap: wrap;
}
.batch-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: #ecf5ff;
border-radius: 4px;
margin-bottom: 8px;
}
.selected-info {
font-size: 13px;
color: #409eff;
font-weight: 500;
}
.kpi-code {
font-family: 'Courier New', monospace;
font-size: 12px;
color: #606266;
}
.threshold-hint {
font-size: 11px;
color: #909399;
line-height: 1.4;
margin-top: 2px;
}
.status-red { color: #f56c6c; font-weight: 500; }
.status-yellow { color: #e6a23c; font-weight: 500; }
.status-green { color: #67c23a; font-weight: 500; }
.status-none { color: #606266; }
.pagination-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
}
.total-info {
font-size: 13px;
color: #909399;
}
:deep(.el-tree-node__content) {
height: 32px;
}
</style>
-979
View File
@@ -1,979 +0,0 @@
<template>
<div class="map-canvas-page" ref="canvasRef">
<!-- 工具栏 -->
<div class="toolbar">
<h3>战略地图画布 — 四层泳道</h3>
<div class="toolbar-right">
<el-select v-model="selectedMap" placeholder="选择地图" style="width:200px;margin-right:8px;" @change="loadCanvas">
<el-option v-for="m in maps" :key="m.id" :label="m.title" :value="m.id" />
</el-select>
<!-- 缩放控制 -->
<div class="zoom-controls" title="画布缩放">
<el-button size="small" @click="zoomOut" :disabled="zoomLevel <= 0.5">−</el-button>
<span class="zoom-label">{{ Math.round(zoomLevel * 100) }}%</span>
<el-button size="small" @click="zoomIn" :disabled="zoomLevel >= 1.5">+</el-button>
<el-button size="small" @click="zoomReset">100%</el-button>
</div>
<el-button type="primary" @click="saveCanvas">保存</el-button>
<el-button type="success" @click="publishMap" v-if="currentMap?.status === 'draft'">发布</el-button>
<el-button @click="showVersions = true; loadVersions()">版本历史</el-button>
<el-button @click="goAlignment" type="info" plain>
<svg style="width:14px;height:14px;margin-right:4px;vertical-align:-2px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12h4l2-3 3 3 4-7 3 5 2-2 4 4"/>
</svg>
查看执行看板
</el-button>
</div>
</div>
<!-- 连线模式提示 -->
<div v-if="linkingFrom" class="linking-bar">
<span>🔗 从「{{ linkingFrom.obj.name }}」连线 — 点击另一个目标完成连线</span>
<el-button size="small" @click="cancelLink">取消</el-button>
</div>
<!-- 空状态提示 -->
<div v-if="!loading && dimensions.every(d => d.objectives.length === 0)" class="empty-state">
<el-empty description="四层泳道已就绪,点击各层的「+ 添加目标」开始填写">
<template #image>
<svg viewBox="0 0 120 120" width="120" height="120">
<rect x="10" y="10" width="100" height="22" rx="4" fill="#FEF0F0" />
<rect x="10" y="38" width="100" height="22" rx="4" fill="#ECF5FF" />
<rect x="10" y="66" width="100" height="22" rx="4" fill="#F0F9EB" />
<rect x="10" y="94" width="100" height="22" rx="4" fill="#FDF6EC" />
</svg>
</template>
</el-empty>
</div>
<!-- 画布主体(纵向泳道布局,CSS zoom缩放) -->
<div class="canvas-scroll-wrap" ref="scrollWrapRef" :style="{ zoom: zoomLevel }">
<div class="canvas-body" ref="bodyRef">
<!-- 四层泳道(纵向排列,固定顺序:财务→客户→流程→学习) -->
<div
v-for="(cfg, idx) in orderedLayerConfigs"
:key="cfg.key"
:ref="el => setLayerRef(cfg.key, el)"
class="layer-swimlane-wrapper"
:style="{ borderLeft: '4px solid ' + cfg.color }"
>
<StrategyLayer
:ref="el => setLayerCompRef(cfg.key, el)"
:layer-key="cfg.key"
:label="cfg.label"
:icon="cfg.icon"
:color="cfg.color"
:objectives="getLayerNodes(cfg.key)"
:linking-from-key="linkingFrom?.key ?? null"
:drag-connect-target="dragConnectTarget"
:icon-map="iconMap"
:kpi-name-map="kpiNameMap"
:all-kpis="allKpis"
:get-node-level="getNodeLevel"
:get-node-progress="getNodeProgress"
@add-objective="(k: string) => openAddDialog(k)"
@edit-objective="(p: any) => openEditDialog(p.dimKey, p.idx, p.obj)"
@delete-objective="(p: any) => deleteNode(p.dimKey, p.idx)"
@start-link="(p: any) => { const ps = p.key.split('-'); startLink(ps[0], parseInt(ps[ps.length-1]), p.obj) }"
@link-drag-start="(e: MouseEvent, dk: string, oi: number, obj: any) => onLinkDragStart(e, dk, oi, obj)"
@kpi-click="(code: string) => goKPI(code)"
@show-plan-list="(p: any) => showPlanList(p.dimKey, p.idx, p.obj)"
@node-click="(dk: string, oi: number, obj: any) => onNodeClick(dk, oi, obj)"
@reorder="(dk: string) => onLayerReorder(dk)"
/>
<!-- 跨层箭头指示器(本层→下层) -->
<div v-if="idx < orderedLayerConfigs.length - 1" class="cross-layer-arrow">
<svg width="24" height="32" viewBox="0 0 24 32">
<line x1="12" y1="0" x2="12" y2="24" stroke="#909399" stroke-width="2" stroke-dasharray="4,3" />
<polygon points="4,22 12,30 20,22" fill="#909399" />
</svg>
</div>
</div>
</div><!-- end canvas-body -->
</div><!-- end canvas-scroll-wrap -->
<!-- SVG 连线层 (覆盖整个画布,绘制跨层+同层箭头) -->
<ConnectionLines
ref="connectionLinesRef"
:layer-refs="layerRefs"
:node-refs="nodeRefs"
:layer-keys="layerKeysOrdered"
:connections="connections"
:temp-line="tempLine"
:container-key="recalcTrigger"
@select-connection="onSelectConnection"
@delete-connection="onDeleteConnection"
/>
<!-- 节点编辑弹窗 -->
<NodeEditDialog
:visible="showNodeDialog"
:node-data="editingNodeData"
:layer-key="editingLayerKey"
@update:visible="showNodeDialog = $event"
@save="onNodeSave"
/>
<!-- 版本历史弹窗 -->
<div v-if="showVersions" class="mc-dialog-overlay" @click.self="showVersions=false">
<div class="mc-dialog-box" style="width:620px;">
<div class="mc-dialog-header">
<span>版本历史</span>
<button class="mc-dialog-close" @click="showVersions=false">×</button>
</div>
<div class="mc-dialog-body" style="max-height:400px;overflow-y:auto;">
<table class="mc-table" v-if="versions.length > 0">
<thead><tr><th>说明</th><th>创建时间</th><th>操作</th></tr></thead>
<tbody>
<tr v-for="v in versions" :key="v.id">
<td>{{ v.comment || '-' }}</td>
<td>{{ v.created_at }}</td>
<td><button class="mc-btn mc-btn-sm mc-btn-warn" @click="rollback(v.id)">回滚</button></td>
</tr>
</tbody>
</table>
<div v-else style="text-align:center;color:#999;padding:20px;">暂无版本记录</div>
</div>
</div>
</div>
<!-- KPI详情浮层 -->
<div v-if="showKpiDetail" class="mc-dialog-overlay" @click.self="showKpiDetail=false">
<div class="mc-dialog-box" style="width:560px;">
<div class="mc-dialog-header">
<span>📊 {{ kpiDetailObjName }} — KPI详情</span>
<button class="mc-dialog-close" @click="showKpiDetail=false">×</button>
</div>
<div class="mc-dialog-body" style="max-height:360px;overflow-y:auto;">
<div v-if="kpiDetailList.length === 0" style="text-align:center;color:#999;padding:20px;">
该目标暂未关联KPI
</div>
<div v-for="(item, idx) in kpiDetailList" :key="idx" class="kpi-detail-card" :class="'kpi-detail-' + getKpiLevel(item)">
<div class="kpi-detail-top">
<span class="kpi-detail-badge">{{ badgeIcon(getKpiLevel(item)) }}</span>
<span class="kpi-detail-code">{{ item.code }}</span>
<span class="kpi-detail-name">{{ item.name }}</span>
</div>
<div class="kpi-detail-values">
<div class="kdv-item">
<span class="kdv-label">实际值</span>
<span class="kdv-val">{{ item.actual != null ? fmtKpiVal(item.actual) : '—' }}</span>
</div>
<div class="kdv-item">
<span class="kdv-label">目标值</span>
<span class="kdv-val">{{ item.target != null ? fmtKpiVal(item.target) : '—' }}</span>
</div>
<div v-if="item.actual != null && item.target" class="kdv-item">
<span class="kdv-label">达成率</span>
<span class="kdv-val" :style="{ color: getKpiLevel(item) === 'red' ? '#f56c6c' : getKpiLevel(item) === 'yellow' ? '#e6a23c' : '#67c23a' }">
{{ (item.actual / item.target * 100).toFixed(1) }}%
</span>
</div>
<div v-if="item.unit" class="kdv-item">
<span class="kdv-label">单位</span>
<span class="kdv-val">{{ item.unit }}</span>
</div>
</div>
</div>
</div>
<div class="mc-dialog-footer">
<el-button size="small" @click="showKpiDetail=false">关闭</el-button>
<el-button size="small" type="primary" @click="editKpiDetailObj">编辑目标</el-button>
</div>
</div>
</div>
<!-- 行动方案列表弹窗 -->
<div v-if="showPlanPopup" class="mc-dialog-overlay" @click.self="showPlanPopup=false">
<div class="mc-dialog-box" style="width:600px;">
<div class="mc-dialog-header">
<span>📋 {{ planPopupObjName }} — 关联行动方案</span>
<button class="mc-dialog-close" @click="showPlanPopup=false">×</button>
</div>
<div class="mc-dialog-body" style="max-height:400px;overflow-y:auto;">
<div v-if="planPopupList.length === 0" style="text-align:center;color:#999;padding:20px;">
暂无关联的行动方案
</div>
<div v-for="p in planPopupList" :key="p.id" class="plan-popup-card">
<div class="plan-popup-top">
<span class="plan-popup-status" :class="'pps-' + p.status">{{ planStatusLabel(p.status) }}</span>
<span class="plan-popup-title">{{ p.title }}</span>
</div>
<div class="plan-popup-meta">
<span v-if="p.assignee">👤 {{ p.assignee }}</span>
<span v-if="p.due_date">📅 {{ p.due_date.slice(0, 10) }}</span>
<span v-if="p.progress > 0">进度 {{ p.progress }}%</span>
</div>
</div>
</div>
<div class="mc-dialog-footer">
<el-button size="small" @click="showPlanPopup=false">关闭</el-button>
<el-button size="small" type="primary" @click="quickCreatePlan">+ 新建行动方案</el-button>
</div>
</div>
</div>
</div>
<!-- P1-3: 关联知识点 -->
<div style="margin-top:12px;">
<KnowledgePanel related-page="/maps" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, nextTick, onUnmounted } from "vue"
import { ElMessage, ElMessageBox } from "element-plus"
import { mapApi, kpiApi } from "../api/index"
import KnowledgePanel from '../components/KnowledgePanel.vue'
import api from "../api/index"
import { LAYER_CONFIG, LAYER_KEYS, getLayerList } from "../config/layers"
import StrategyLayer from "../components/strategy-map/StrategyLayer.vue"
import NodeEditDialog from "../components/strategy-map/NodeEditDialog.vue"
import ConnectionLines from "../components/strategy/ConnectionLines.vue"
// ── 四层泳道配置 ──
const layerKeysOrdered = [...LAYER_KEYS]
const orderedLayerConfigs = getLayerList()
// ── 状态 ──
const maps = ref<any[]>([])
const selectedMap = ref<number | null>(null)
const currentMap = ref<any>(null)
const allKpis = ref<any[]>([])
const kpiNameMap = reactive<Record<string, string>>({})
// 维度数据(对应backend的dimensions)
const dimensions = reactive<any[]>([])
const loading = ref(true)
// 连线
const connections = ref<any[]>([])
const linkingFrom = ref<{ key: string; obj: any } | null>(null)
const tempLine = ref<{ x1: number; y1: number; x2: number; y2: number } | null>(null)
const dragConnectTarget = ref<string | null>(null)
const selectedConnIdx = ref<number | null>(null)
// 缩放
const zoomLevel = ref(1)
const scrollWrapRef = ref<HTMLElement | null>(null)
// 节点状态(红黄绿灯)
const objectiveLevels = reactive<Record<string, string>>({})
interface KpiProgressItem {
ratio: number
level: string
kpiList: { code: string; name: string; actual: number|null; target: number|null; unit: string|null }[]
planSummary: { total: number; pending: number; in_progress: number; completed: number; overdue: number } | null
}
const kpiProgressMap = reactive<Record<string, KpiProgressItem>>({})
// 弹窗
const showNodeDialog = ref(false)
const editingNodeData = ref<any>(null)
const editingLayerKey = ref('')
const showVersions = ref(false)
const versions = ref<any[]>([])
// DOM引用
const bodyRef = ref<HTMLElement | null>(null)
const layerRefs: Record<string, HTMLElement> = {}
const nodeRefs: Record<string, HTMLElement> = {}
const connectionLinesRef = ref<any>(null)
const recalcTrigger = ref(0)
// 泳道组件实例引用(用于汇聚各层内部的nodeRefs给ConnectionLines)
const layerCompRefs: Record<string, any> = {}
function setLayerCompRef(key: string, el: any) {
if (el) layerCompRefs[key] = el
}
/** 从四个泳道组件汇聚所有节点DOM引用用于连线渲染,同步到nodeRefs */
function collectAllNodeRefs(): Record<string, any> {
void (0) // keep as side-effect function
// 清空旧refs
Object.keys(nodeRefs).forEach(k => delete (nodeRefs as any)[k])
// 从各泳道组件汇聚
for (const key of layerKeysOrdered) {
const comp = layerCompRefs[key]
if (comp?.nodeRefs) {
Object.assign(nodeRefs, comp.nodeRefs)
}
}
return nodeRefs
}
// 拖拽引用(由 vuedraggable 接管,内部不维护)
// KPI详情浮层
const showKpiDetail = ref(false)
const kpiDetailObjName = ref("")
const kpiDetailDimKey = ref("")
const kpiDetailObjIdx = ref(-1)
const kpiDetailList = ref<any[]>([])
// 行动方案弹窗
const showPlanPopup = ref(false)
const planPopupObjName = ref("")
const planPopupDimKey = ref("")
const planPopupObjIdx = ref(-1)
const planPopupList = ref<any[]>([])
// ── 图标映射(从旧MapCanvas复用) ──
const iconMap: Record<string, string> = {
target: "Star", star: "StarFilled", rocket: "Top", chart: "DataLine",
team: "UserFilled", light: "Lightning", shield: "Shield", gear: "Setting",
handshake: "Handshake", medal: "Trophy",
}
// ── 辅助方法 ──
function getLayerNodes(key: string): any[] {
const dim = dimensions.find((d: any) => d.key === key)
return dim?.objectives || []
}
function setLayerRef(key: string, el: any) {
if (el) layerRefs[key] = el
}
function getNodeLevel(key: string): string {
return objectiveLevels[key] || 'gray'
}
function getNodeProgress(key: string): any {
return kpiProgressMap[key] || null
}
/** 确保dimensions中存在所有4层 */
function ensureFourLayers() {
for (const cfg of orderedLayerConfigs) {
if (!dimensions.find((d: any) => d.key === cfg.key)) {
dimensions.push({
key: cfg.key,
name: cfg.label,
label: cfg.label,
icon: cfg.icon,
color: cfg.color,
objectives: [],
})
}
}
}
// ── 缩放 ──
function zoomIn() { zoomLevel.value = Math.min(1.5, Math.round((zoomLevel.value + 0.1) * 10) / 10) }
function zoomOut() { zoomLevel.value = Math.max(0.5, Math.round((zoomLevel.value - 0.1) * 10) / 10) }
function zoomReset() { zoomLevel.value = 1 }
// ── 画布加载 ──
function loadCanvas() {
if (!selectedMap.value) return
dimensions.splice(0, dimensions.length)
connections.value = []
linkingFrom.value = null
selectedConnIdx.value = null
loading.value = true
api.get("/maps").then((r: any) => {
const data = r.data || []
const map = data.find((m: any) => m.id === selectedMap.value)
currentMap.value = map
if (map?.dimensions) {
for (const dim of map.dimensions) {
dimensions.push({
key: dim.key,
label: dim.name || dim.label,
icon: dim.icon || '📋',
color: dim.color || LAYER_CONFIG[dim.key]?.color || '#409eff',
objectives: dim.objectives || [],
})
}
}
// 确保四层都存在
ensureFourLayers()
connections.value = map?.canvas_data?.connections || []
loading.value = false
nextTick(() => {
triggerRecalc()
loadObjectiveLevels()
})
}).catch(() => {
ensureFourLayers()
loading.value = false
})
}
// ── 触发连线重算 ──
function triggerRecalc() {
recalcTrigger.value++
collectAllNodeRefs()
nextTick(() => {
connectionLinesRef.value?.recalcAll()
})
}
// ── 连线交互 ──
function startLink(dimKey: string, oi: number, obj: any) {
selectedConnIdx.value = null
linkingFrom.value = { key: `${dimKey}-${oi}`, obj }
}
function cancelLink() {
linkingFrom.value = null
tempLine.value = null
dragConnectTarget.value = null
}
function onNodeClick(dimKey: string, oi: number, obj: any) {
const key = `${dimKey}-${oi}`
// 连线模式
if (linkingFrom.value) {
if (linkingFrom.value.key === key) { cancelLink(); return }
completeConnection(linkingFrom.value.key, key)
return
}
// 有KPI则展示详情
if (kpiProgressMap[key]?.kpiList?.length > 0) {
showKpiDetailModal(dimKey, oi, obj)
} else {
openEditDialog(dimKey, oi, obj)
}
}
/**
* 层内重排完成回调 — 由 vuedraggable 在拖拽排序后触发
* SortableJS 已通过 :list 引用直接更新了响应式数组,
* 这里只需触发连线重算和自动保存
*/
function onLayerReorder(dimKey: string) {
nextTick(() => {
triggerRecalc()
debouncedSaveCanvas()
})
}
function completeConnection(fromKey: string, toKey: string) {
const fromDim = fromKey.split('-')[0]
const toDim = toKey.split('-')[0]
if (fromDim === toDim) {
// 同层连线 -> 同层右侧箭头
}
// 检查重复
if (connections.value.some((c: any) => c.from === fromKey && c.to === toKey)) {
ElMessage.warning("已存在相同连线"); cancelLink(); return
}
api.post(`/maps/${selectedMap.value}/connections`, {
from: fromKey, to: toKey,
}).then(() => {
ElMessage.success("连线已添加")
connections.value.push({ from: fromKey, to: toKey, style: "solid" })
cancelLink()
nextTick(() => { triggerRecalc(); saveConnections() })
}).catch((e: any) => {
if (e?.response?.status !== 400) {
connections.value.push({ from: fromKey, to: toKey, style: "solid" })
ElMessage.success("连线已添加(本地)")
} else {
ElMessage.warning(e?.response?.data?.detail || "连线失败")
}
cancelLink()
nextTick(triggerRecalc)
})
}
// 跳转KPI详情
function goKPI(kpiCode: string) {
const found = allKpis.value.find((k: any) => k.kpi_code === kpiCode)
window.location.href = found ? '/kpis/' + found.id : '/kpis'
}
// ── 拖拽连线的鼠标跟踪 ──
function onLinkDragStart(e: MouseEvent, dimKey: string, oi: number, obj: any) {
const startX = e.clientX
const startY = e.clientY
startLink(dimKey, oi, obj)
const onUp = (up: MouseEvent) => {
document.removeEventListener('mouseup', onUp)
const dist = Math.hypot(up.clientX - startX, up.clientY - startY)
if (dist >= 5 && linkingFrom.value) {
let targetKey: string | null = null
for (const key of Object.keys(nodeRefs)) {
if (key === linkingFrom.value.key) continue
const el = nodeRefs[key]
if (!el) continue
const rect = el.getBoundingClientRect()
if (up.clientX >= rect.left && up.clientX <= rect.right &&
up.clientY >= rect.top && up.clientY <= rect.bottom) {
targetKey = key; break
}
}
if (targetKey) {
const parts = targetKey.split('-')
const tDim = parts[0]
const tIdx = parseInt(parts[1])
const dim = dimensions.find((d: any) => d.key === tDim)
if (dim && dim.objectives[tIdx]) {
completeConnection(linkingFrom.value.key, targetKey)
}
}
cancelLink()
}
dragConnectTarget.value = null
}
document.addEventListener('mouseup', onUp)
}
function onSelectConnection(idx: number) {
selectedConnIdx.value = selectedConnIdx.value === idx ? null : idx
}
function onDeleteConnection(idx: number) {
if (selectedConnIdx.value !== idx) return
const conn = connections.value[idx]
if (!conn) return
api.delete(`/maps/${selectedMap.value}/connections`, {
data: { from: conn.from, to: conn.to },
}).then(() => {
ElMessage.success("连线已删除")
connections.value.splice(idx, 1)
selectedConnIdx.value = null
nextTick(triggerRecalc)
}).catch(() => {
connections.value.splice(idx, 1)
selectedConnIdx.value = null
nextTick(triggerRecalc)
})
}
// ── 节点CRUD ──
function openAddDialog(layerKey: string) {
editingLayerKey.value = layerKey
editingNodeData.value = null
showNodeDialog.value = true
}
function openEditDialog(dimKey: string, oi: number, obj: any) {
editingLayerKey.value = dimKey
editingNodeData.value = {
id: obj.id,
name: obj.name,
targetValue: obj.targetValue ?? obj.target_value ?? null,
currentValue: obj.currentValue ?? obj.current_value ?? null,
unit: obj.unit || '%',
owner: obj.owner || '',
isLeading: obj.isLeading ?? obj.is_leading ?? false,
description: obj.description || '',
}
// 保存编辑索引以便更新
editingNodeData.value._dimKey = dimKey
editingNodeData.value._index = oi
showNodeDialog.value = true
}
function onNodeSave(data: any) {
const dim = dimensions.find((d: any) => d.key === data.layer)
if (!dim) return
// 构建节点对象
const node = {
name: data.name,
targetValue: data.targetValue,
currentValue: data.currentValue,
unit: data.unit,
owner: data.owner,
isLeading: data.isLeading,
description: data.description || '',
layer: data.layer,
}
if (editingNodeData.value?._index != null && editingNodeData.value._dimKey === data.layer) {
// 编辑已有节点
const idx = editingNodeData.value._index
if (dim.objectives[idx]) {
// 保留原有kpis等额外字段
const extra = {
kpis: dim.objectives[idx].kpis || [],
icon: dim.objectives[idx].icon || 'target',
}
dim.objectives[idx] = { ...node, ...extra }
ElMessage.success("节点已更新")
}
} else {
// 新增节点
dim.objectives.push({
...node,
kpis: [],
icon: 'target',
})
ElMessage.success("节点已添加")
}
editingNodeData.value = null
debouncedSaveCanvas()
nextTick(triggerRecalc)
}
async function deleteNode(dimKey: string, oi: number) {
try {
await ElMessageBox.confirm("确定要删除这个节点吗?", "确认删除")
const dim = dimensions.find((d: any) => d.key === dimKey)
if (dim) {
const key = `${dimKey}-${oi}`
connections.value = connections.value.filter(c => c.from !== key && c.to !== key)
dim.objectives.splice(oi, 1)
ElMessage.success("节点已删除")
nextTick(() => { triggerRecalc(); debouncedSaveCanvas() })
}
} catch { /* cancel */ }
}
// ── 保存 ──
let saveTimer: ReturnType<typeof setTimeout> | null = null
let isSaving = false
function debouncedSaveCanvas() {
if (isSaving) return
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => { saveCanvas() }, 500)
}
async function saveCanvas() {
if (!selectedMap.value) return
if (isSaving) return
isSaving = true
const mapData = dimensions.map((d: any) => ({
key: d.key, name: d.label || d.name, icon: d.icon, color: d.color,
objectives: d.objectives,
}))
try {
await mapApi.update(selectedMap.value, {
dimensions: mapData,
canvas_data: { connections: connections.value },
version_num: currentMap.value?.version_num,
})
ElMessage.success("已保存")
} catch (e: any) {
if (e?.response?.status === 409) {
ElMessage.warning("地图已被修改,刷新后重试")
} else {
console.error("saveCanvas error:", e)
}
} finally {
isSaving = false
}
}
async function saveConnections() {
if (!selectedMap.value) return
try {
await mapApi.update(selectedMap.value, {
canvas_data: { connections: connections.value },
})
} catch { /* silent */ }
}
// ── 发布/版本 ──
async function publishMap() {
if (!selectedMap.value) return
try {
await mapApi.update(selectedMap.value, { status: "published" })
ElMessage.success("已发布(自动创建版本快照)")
currentMap.value.status = "published"
loadCanvas()
} catch (e) {
ElMessage.error("发布失败")
}
}
async function loadVersions() {
if (!selectedMap.value) return
try {
const r: any = await api.get(`/maps/${selectedMap.value}/versions`)
versions.value = r.data || []
} catch { versions.value = [] }
}
async function rollback(verId: number) {
try {
await ElMessageBox.confirm("确定回滚到此版本?当前内容将被覆盖。")
await api.post(`/maps/${selectedMap.value}/versions/${verId}/rollback`)
ElMessage.success("回滚成功,刷新页面")
window.location.reload()
} catch (e: any) {
if (e !== "cancel") ElMessage.error("回滚失败")
}
}
// ── 加载目标状态 ──
async function loadObjectiveLevels() {
if (!selectedMap.value) return
try {
const r: any = await api.get(`/maps/${selectedMap.value}/review`)
for (const key of Object.keys(objectiveLevels)) delete objectiveLevels[key]
for (const key of Object.keys(kpiProgressMap)) delete kpiProgressMap[key]
const dims = r.dimensions || []
for (const dim of dims) {
const dimKey = dim.key
const localDim = dimensions.find((d: any) => d.key === dimKey)
if (!localDim) continue
for (let i = 0; i < localDim.objectives.length; i++) {
const key = `${dimKey}-${i}`
kpiProgressMap[key] = { ratio: 0, level: 'gray', kpiList: [], planSummary: null }
objectiveLevels[key] = 'gray'
}
dim.objectives.forEach((obj: any) => {
const localIdx = localDim.objectives.findIndex((o: any) => o.name === obj.name)
if (localIdx >= 0) {
const key = `${dimKey}-${localIdx}`
objectiveLevels[key] = obj.level || 'gray'
const kpis = obj.kpis || []
const kpiList: any[] = []
let totalRatio = 0
let count = 0
for (const k of kpis) {
kpiList.push({
code: k.kpi_code, name: k.kpi_name, actual: k.actual_value, target: k.target_value, unit: k.unit,
})
if (k.actual_value != null && k.target_value) {
totalRatio += k.actual_value / k.target_value; count++
}
}
if (kpiList.length > 0) {
kpiProgressMap[key] = {
ratio: count > 0 ? Math.round((totalRatio / count) * 100) : 0,
level: obj.level || 'gray', kpiList,
planSummary: obj.action_plan_summary || null,
}
} else if (obj.action_plan_summary?.total > 0) {
kpiProgressMap[key] = { ratio: 0, level: obj.level || 'gray', kpiList: [], planSummary: obj.action_plan_summary }
}
}
})
}
} catch { /* silent */ }
}
// ── KPI详情浮层 ──
function showKpiDetailModal(dimKey: string, oi: number, obj: any) {
const key = `${dimKey}-${oi}`
kpiDetailObjName.value = obj.name
kpiDetailDimKey.value = dimKey
kpiDetailObjIdx.value = oi
kpiDetailList.value = kpiProgressMap[key]?.kpiList || []
showKpiDetail.value = true
}
function editKpiDetailObj() {
showKpiDetail.value = false
const dim = dimensions.find((d: any) => d.key === kpiDetailDimKey.value)
if (dim && dim.objectives[kpiDetailObjIdx.value]) {
openEditDialog(kpiDetailDimKey.value, kpiDetailObjIdx.value, dim.objectives[kpiDetailObjIdx.value])
}
}
function getKpiLevel(item: any): string {
if (item.actual != null && item.target) {
const ratio = item.actual / item.target
if (ratio >= 0.9) return 'green'
if (ratio >= 0.7) return 'yellow'
return 'red'
}
return 'gray'
}
function fmtKpiVal(val: any): string {
if (val == null) return '—'
if (typeof val === 'number') {
if (Math.abs(val) >= 10000) return (val / 10000).toFixed(1) + '万'
return val.toLocaleString()
}
return String(val)
}
function badgeIcon(level: string): string {
return level === 'green' ? '🟢' : level === 'yellow' ? '🟡' : level === 'red' ? '🔴' : '⚪'
}
// ── 行动方案 ──
async function showPlanList(dimKey: string, oi: number, obj: any) {
planPopupObjName.value = obj.name
planPopupDimKey.value = dimKey
planPopupObjIdx.value = oi
planPopupList.value = []
try {
const r: any = await api.get(`/maps/${selectedMap.value}/review`)
const dims = r.dimensions || []
const allPlans = r.action_plans || []
for (const dim of dims) {
if (dim.key !== dimKey) continue
for (const robj of dim.objectives || []) {
if (robj.name !== obj.name) continue
const objKpiIds = (robj.kpis || []).map((k: any) => k.kpi_id)
planPopupList.value = allPlans.filter((p: any) => objKpiIds.includes(p.kpi_id))
break
}
break
}
} catch { /* silent */ }
showPlanPopup.value = true
}
function planStatusLabel(status: string): string {
const map: Record<string, string> = { pending: '待处理', in_progress: '进行中', completed: '已完成', cancelled: '已取消' }
return map[status] || status
}
function quickCreatePlan() {
showPlanPopup.value = false
window.location.href = `/action-plans?dimension=${planPopupDimKey.value}`
}
function goAlignment() {
const url = currentMap.value ? `/alignment?map_id=${currentMap.value.id}` : '/alignment'
window.location.href = url
}
onMounted(async () => {
const r: any = await mapApi.list()
maps.value = r.data || []
if (maps.value.length > 0) {
selectedMap.value = maps.value[0].id
loadCanvas()
} else {
ensureFourLayers()
loading.value = false
}
const k: any = await kpiApi.list({ page_size: 100 })
allKpis.value = k.data || []
for (const kpi of allKpis.value) {
if (kpi.kpi_code) kpiNameMap[kpi.kpi_code] = kpi.kpi_name || kpi.kpi_code
}
window.addEventListener('resize', triggerRecalc)
})
onUnmounted(() => {
window.removeEventListener('resize', triggerRecalc)
if (saveTimer) clearTimeout(saveTimer)
})
</script>
<style scoped>
/* ── 页面布局 ── */
.map-canvas-page { padding: 16px; height: 100vh; display: flex; flex-direction: column; position: relative; overflow: hidden; }
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-shrink: 0; flex-wrap: wrap; gap: 8px; }
.toolbar h3 { margin: 0; }
.toolbar-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.zoom-controls { display: flex; align-items: center; gap: 2px; margin-right: 8px; }
.zoom-controls .el-button { padding: 4px 8px !important; font-size: 13px; min-height: auto; }
.zoom-label { display: inline-block; min-width: 32px; text-align: center; font-size: 12px; color: #666; font-variant-numeric: tabular-nums; }
.linking-bar {
display: flex; align-items: center; gap: 12px;
padding: 8px 16px; background: #fdf6ec; border: 1px solid #f5dab1;
border-radius: 8px; margin-bottom: 8px; font-size: 14px; flex-shrink: 0;
}
.empty-state {
flex: 1; display: flex; align-items: center; justify-content: center;
}
/* ── 画布滚动容器 ── */
.canvas-scroll-wrap {
flex: 1; min-height: 0; overflow: auto;
position: relative; width: 100%;
}
.canvas-body {
display: flex; flex-direction: column; gap: 0;
position: relative; isolation: isolate;
width: 100%; padding: 8px 0;
}
/* ── 泳道外层包装 ── */
.layer-swimlane-wrapper {
width: 100%;
margin-bottom: 4px;
}
/* ── 跨层箭头指示器 ── */
.cross-layer-arrow {
display: flex;
justify-content: center;
align-items: center;
height: 32px;
flex-shrink: 0;
opacity: 0.5;
pointer-events: none;
}
/* ── 重新调用的样式(从旧MapCanvas复用) ── */
.kpi-detail-card {
border: 1px solid #eee; border-radius: 8px; padding: 12px; margin-bottom: 8px;
}
.kpi-detail-red { border-left: 4px solid #f56c6c; }
.kpi-detail-yellow { border-left: 4px solid #e6a23c; }
.kpi-detail-green { border-left: 4px solid #67c23a; }
.kpi-detail-gray { border-left: 4px solid #ccc; }
.kpi-detail-top { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.kpi-detail-badge { font-size: 14px; }
.kpi-detail-code { font-size: 11px; color: #999; font-family: monospace; }
.kpi-detail-name { font-weight: 600; font-size: 14px; }
.kpi-detail-values { padding-left: 24px; display: flex; flex-direction: column; gap: 4px; }
.kdv-item { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.kdv-label { color: #999; width: 56px; }
.kdv-val { font-weight: 500; }
.plan-popup-card {
border: 1px solid #eee; border-radius: 8px; padding: 10px 12px; margin-bottom: 6px;
}
.plan-popup-card:hover { background: #fafafa; }
.plan-popup-top { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.plan-popup-status {
font-size: 10px; padding: 1px 6px; border-radius: 3px; font-weight: 500; flex-shrink: 0;
}
.pps-pending { background: #f5f7fa; color: #909399; }
.pps-in_progress { background: #fdf6ec; color: #e6a23c; }
.pps-completed { background: #f0f9eb; color: #67c23a; }
.pps-cancelled { background: #f5f7fa; color: #bbb; }
.plan-popup-title { font-weight: 500; font-size: 13px; }
.plan-popup-meta { display: flex; gap: 12px; font-size: 11px; color: #999; padding-left: 4px; }
/* ── 自定义弹窗(从旧MapCanvas复用) ── */
.mc-dialog-overlay {
position: fixed; top:0; left:0; right:0; bottom:0;
background:rgba(0,0,0,0.5); z-index:9999;
display:flex; align-items:center; justify-content:center;
}
.mc-dialog-box {
background:#fff; border-radius:12px; width:520px;
box-shadow:0 8px 32px rgba(0,0,0,0.18); overflow:hidden;
}
.mc-dialog-header {
display:flex; justify-content:space-between; align-items:center;
padding:16px 20px; border-bottom:1px solid #f0f0f0;
font-weight:600; font-size:16px;
}
.mc-dialog-close {
background:none; border:none; font-size:22px; color:#999;
cursor:pointer; padding:0 4px; line-height:1;
}
.mc-dialog-close:hover { color:#333; }
.mc-dialog-body { padding:20px; }
.mc-dialog-footer {
display:flex; justify-content:flex-end; gap:10px;
padding:12px 20px; border-top:1px solid #f0f0f0;
}
.mc-table { width:100%; border-collapse:collapse; font-size:13px; }
.mc-table th, .mc-table td { padding:10px 12px; text-align:left; border-bottom:1px solid #f0f0f0; }
.mc-table th { background:#fafafa; font-weight:600; color:#666; font-size:12px; }
.mc-table tr:hover td { background:#f5f7fa; }
.mc-btn {
padding:8px 20px; border:1px solid #dcdfe6; border-radius:6px;
background:#fff; color:#333; font-size:14px; cursor:pointer;
}
.mc-btn-primary { background:#409eff; color:#fff; border-color:#409eff; }
.mc-btn-sm { padding:5px 12px; font-size:12px; }
.mc-btn-warn { color:#e6a23c; border-color:#e6a23c; }
.mc-btn-warn:hover { background:#e6a23c; color:#fff; }
.mc-btn:hover { opacity:0.85; }
</style>
-351
View File
@@ -1,351 +0,0 @@
<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 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>
<!-- 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>
</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)
const selectedObjName = ref("")
const selectedKpis = ref<any[]>([])
// ── 计算属性 ──
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 || []
} catch (e: any) {
ElMessage.error("加载战略回顾数据失败")
} finally {
loading.value = false
}
}
// ── 工具函数 ──
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; }
</style>