feat: P0/P1/P2全部功能 — 四层泳道/视角切换/KPI看板/预警/差异反打/预算/知识面板/回顾会/情景预测/Excel导入/角色权限

This commit is contained in:
Hermes CI Fix
2026-07-12 17:46:08 +08:00
parent cdf00efd69
commit ee25d5fa1d
8871 changed files with 1778433 additions and 0 deletions
+521
View File
@@ -0,0 +1,521 @@
<template>
<div style="padding:0 16px;">
<!-- 顶部标题+操作 -->
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h3 style="margin:0;">行动方案库</h3>
<div style="display:flex;gap:8px;align-items:center;">
<el-input v-model="searchKeyword" placeholder="搜索标题/KPI名称/编码" clearable style="width:240px;" @clear="loadData" @keyup.enter="loadData" />
<el-button type="primary" @click="openCreate">+ 新建行动方案</el-button>
</div>
</div>
<!-- 状态统计卡片 -->
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;">
<div v-for="card in statCards" :key="card.key"
:class="['stat-card', { active: filterStatus === card.key }]"
@click="filterStatus = card.key; page = 1; loadData()"
style="cursor:pointer;min-width:100px;padding:14px 20px;border-radius:8px;border:2px solid transparent;transition:all .2s;background:#f5f7fa;text-align:center;">
<div style="font-size:12px;color:#909399;">{{ card.label }}</div>
<div style="font-size:24px;font-weight:600;margin-top:4px;" :style="{ color: card.color }">{{ stats[card.key] ?? '-' }}</div>
</div>
</div>
<!-- 筛选栏 -->
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap;align-items:center;">
<el-select v-model="filterDimension" placeholder="维度" clearable style="width:100px;" @change="page=1;loadData()">
<el-option v-for="d in dimensions" :key="d.value" :label="d.label" :value="d.value" />
</el-select>
<el-select v-model="filterKpi" placeholder="关联KPI" clearable filterable style="width:180px;" @change="page=1;loadData()">
<el-option v-for="k in kpiOptions" :key="k.id" :label="`${k.kpi_code} ${k.kpi_name}`" :value="k.id" />
</el-select>
<el-select v-model="filterAssignee" placeholder="负责人" clearable filterable style="width:120px;" @change="page=1;loadData()">
<el-option v-for="u in userOptions" :key="u" :label="u" :value="u" />
</el-select>
<el-select v-model="filterPriority" placeholder="优先级" clearable style="width:100px;" @change="page=1;loadData()">
<el-option label="高" value="high" />
<el-option label="中" value="medium" />
<el-option label="低" value="low" />
</el-select>
<el-button v-if="hasFilter" size="small" @click="clearFilter">清除筛选</el-button>
</div>
<!-- 列表 -->
<el-table :data="list" v-loading="loading" border stripe size="small" style="width:100%;" @selection-change="(sel: any[]) => selectedRows = sel">
<el-table-column type="selection" width="40" />
<el-table-column label="行动方案" min-width="200">
<template #default="{ row }">
<div>
<span style="font-weight:500;cursor:pointer;color:#409eff;" @click="viewDetail(row)">{{ row.title }}</span>
<div v-if="row.target_value" style="font-size:11px;color:#67c23a;margin-top:2px;display:flex;align-items:center;gap:4px;">
<span style="font-size:10px;border:1px solid #67c23a;border-radius:2px;padding:0 3px;line-height:14px;">S</span>
{{ row.target_value }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="关联KPI" width="180">
<template #default="{ row }">
<div style="font-size:12px;line-height:1.4;">
<span style="color:#666;">{{ row.kpi_code }}</span><br>
<span>{{ row.kpi_name }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="维度" width="80">
<template #default="{ row }">
<el-tag size="small" :type="dimTagType(row.kpi_dimension)">{{ dimLabel(row.kpi_dimension) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="负责人" width="90" prop="assignee" />
<el-table-column label="优先级" width="80">
<template #default="{ row }">
<el-tag size="small" :type="row.priority === 'high' ? 'danger' : row.priority === 'low' ? 'info' : 'warning'">
{{ row.priority === 'high' ? '高' : row.priority === 'low' ? '低' : '中' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="进度" width="120">
<template #default="{ row }">
<div style="display:flex;align-items:center;gap:6px;">
<el-progress :percentage="row.progress || 0" :stroke-width="8" style="flex:1;" />
<span style="font-size:11px;color:#999;min-width:28px;">{{ row.progress || 0 }}%</span>
</div>
</template>
</el-table-column>
<el-table-column label="截止日" width="100" prop="due_date">
<template #default="{ row }">
<span v-if="!row.due_date" style="color:#ccc;"></span>
<span v-else :style="{ color: isOverdue(row) ? '#f56c6c' : '#666', fontWeight: isOverdue(row) ? 600 : 400 }">
{{ row.due_date?.slice(0, 10) }}
<el-tooltip v-if="isOverdue(row)" content="已逾期" placement="top"><span style="margin-left:2px;"></span></el-tooltip>
</span>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="statusTagType(row.status)">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button size="small" link type="primary" @click="editPlan(row)">编辑</el-button>
<el-button size="small" link type="danger" @click="deletePlan(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 批量操作栏 -->
<div v-if="selectedRows.length > 0" style="margin-top:8px;display:flex;gap:8px;align-items:center;padding:8px 12px;background:#f0f9ff;border-radius:6px;">
<span style="font-size:13px;color:#409eff;">已选 {{ selectedRows.length }} </span>
<el-button size="small" @click="batchSetStatus('in_progress')">置为进行中</el-button>
<el-button size="small" type="success" @click="batchSetStatus('completed')">置为已完成</el-button>
<el-button size="small" @click="clearSelected">取消选择</el-button>
</div>
<!-- 分页 -->
<div style="display:flex;justify-content:center;margin-top:16px;">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next, total" @current-change="loadData" background small />
</div>
<!-- 新建/编辑弹窗 -->
<MyDialog v-model="showForm" :title="formTitle" :width="550">
<div style="margin-bottom:12px;display:flex;gap:8px;flex-wrap:wrap;">
<el-tag size="small" type="success">S 具体的</el-tag>
<el-tag size="small" type="warning">M 可衡量</el-tag>
<el-tag size="small">A 可实现</el-tag>
<el-tag size="small" type="primary">R 相关已关联KPI</el-tag>
<el-tag size="small" type="danger">T 有时限截止日</el-tag>
</div>
<el-form :model="form" label-width="80px" size="small">
<el-form-item label="行动标题" required>
<el-input v-model="form.title" placeholder="请输入行动方案标题" />
</el-form-item>
<el-form-item label="目标值" help="SMART:具体、可衡量的成功标准">
<div style="display:flex;gap:4px;flex-direction:column;width:100%;">
<el-input v-model="form.target_value" placeholder="例:满意度从85%提升至92%、不良率降低50%以下" />
<span style="font-size:11px;color:#999;line-height:1.4;">设置可量化的成功标准完成后对照核验</span>
</div>
</el-form-item>
<el-form-item label="关联KPI" required>
<el-select v-model="form.kpi_id" placeholder="选择关联KPI" filterable style="width:100%;" :teleported="false">
<el-option v-for="k in kpiOptions" :key="k.id" :label="`${k.kpi_code} ${k.kpi_name}`" :value="k.id" />
</el-select>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="详细描述(可选)" />
</el-form-item>
<el-form-item label="负责人">
<el-select v-model="form.assignee" placeholder="选择负责人" filterable allow-create style="width:100%;" :teleported="false">
<el-option v-for="u in userOptions" :key="u" :label="u" :value="u" />
</el-select>
</el-form-item>
<el-form-item label="优先级">
<el-radio-group v-model="form.priority">
<el-radio value="high"></el-radio>
<el-radio value="medium"></el-radio>
<el-radio value="low"></el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="截止日期">
<el-date-picker v-model="form.due_date" type="date" placeholder="选择截止日期" style="width:100%;" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item v-if="editingId" label="状态">
<el-select v-model="form.status" style="width:100%;" :teleported="false">
<el-option label="待处理" value="pending" />
<el-option label="进行中" value="in_progress" />
<el-option label="已完成" value="completed" />
<el-option label="已取消" value="cancelled" />
</el-select>
</el-form-item>
<el-form-item v-if="editingId" label="进度 %">
<el-slider v-model="form.progress" :min="0" :max="100" :step="5" show-input style="width:100%;" />
</el-form-item>
<el-form-item v-if="editingId && form.status === 'completed'" label="改善结果">
<el-input v-model="form.result" type="textarea" :rows="3" placeholder="描述改善结果,对照目标值核验" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showForm = false">取消</el-button>
<el-button type="primary" @click="savePlan" :loading="saving">{{ editingId ? '保存修改' : '创建' }}</el-button>
</template>
</MyDialog>
<!-- 详情弹窗 -->
<MyDialog v-model="showDetail" :title="detailTitle" :width="600">
<div v-if="detailPlan">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div>
<div style="font-size:12px;color:#999;">关联KPI</div>
<div style="margin-top:2px;">{{ detailPlan.kpi_code }} {{ detailPlan.kpi_name }}</div>
</div>
<div>
<div style="font-size:12px;color:#999;">维度</div>
<div style="margin-top:2px;">
<el-tag size="small" :type="dimTagType(detailPlan.kpi_dimension)">{{ dimLabel(detailPlan.kpi_dimension) }}</el-tag>
</div>
</div>
<div>
<div style="font-size:12px;color:#999;">负责人</div>
<div style="margin-top:2px;">{{ detailPlan.assignee || '未指定' }}</div>
</div>
<div>
<div style="font-size:12px;color:#999;">优先级</div>
<div style="margin-top:2px;">
<el-tag size="small" :type="detailPlan.priority === 'high' ? 'danger' : detailPlan.priority === 'low' ? 'info' : 'warning'">
{{ detailPlan.priority === 'high' ? '高' : detailPlan.priority === 'low' ? '低' : '中' }}
</el-tag>
</div>
</div>
<div>
<div style="font-size:12px;color:#999;">截止日期</div>
<div style="margin-top:2px;" :class="{ 'overdue-text': isOverdue(detailPlan) }">
{{ detailPlan.due_date?.slice(0, 10) || '未设置' }}
<span v-if="isOverdue(detailPlan)" style="color:#f56c6c;font-size:12px;margin-left:4px;">已逾期</span>
</div>
</div>
<div>
<div style="font-size:12px;color:#999;">状态</div>
<div style="margin-top:2px;">
<el-tag size="small" :type="statusTagType(detailPlan.status)">{{ statusLabel(detailPlan.status) }}</el-tag>
</div>
</div>
</div>
<div style="margin-top:12px;">
<div style="font-size:12px;color:#999;">进度</div>
<el-progress :percentage="detailPlan.progress || 0" :stroke-width="12" style="margin-top:4px;" />
</div>
<!-- 目标值 -->
<div v-if="detailPlan.target_value" style="margin-top:12px;padding:10px 14px;background:#f0f9eb;border-radius:6px;border-left:3px solid #67c23a;">
<div style="font-size:12px;color:#67c23a;font-weight:500;display:flex;align-items:center;gap:4px;">
<span style="font-size:10px;border:1px solid #67c23a;border-radius:2px;padding:0 3px;line-height:14px;">SMART</span>
目标值 / 成功标准
</div>
<div style="margin-top:4px;line-height:1.6;font-size:14px;">{{ detailPlan.target_value }}</div>
</div>
<div v-if="detailPlan.description" style="margin-top:12px;">
<div style="font-size:12px;color:#999;">详细描述</div>
<div style="margin-top:4px;line-height:1.6;background:#f9f9f9;padding:8px 12px;border-radius:4px;">{{ detailPlan.description }}</div>
</div>
<div v-if="detailPlan.result" style="margin-top:12px;">
<div style="font-size:12px;color:#999;">改善结果</div>
<div style="margin-top:4px;line-height:1.6;background:#f0f9eb;padding:8px 12px;border-radius:4px;">{{ detailPlan.result }}</div>
</div>
<div style="margin-top:12px;font-size:12px;color:#ccc;display:flex;gap:16px;">
<span>创建人: {{ detailPlan.created_by }}</span>
<span>创建时间: {{ detailPlan.created_at?.slice(0, 16) }}</span>
</div>
</div>
<template #footer>
<el-button @click="showDetail = false">关闭</el-button>
</template>
</MyDialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { actionPlanApi, kpiApi, userApi } from '../api/index'
import MyDialog from '../components/MyDialog.vue'
const route = useRoute()
// ── 数据 ──
const list = ref<any[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const stats = ref<Record<string, number>>({})
const selectedRows = ref<any[]>([])
const kpiOptions = ref<any[]>([])
const userOptions = ref<string[]>([])
// ── 筛选 ──
const filterStatus = ref('')
const filterDimension = ref('')
const filterKpi = ref<number | null>(null)
const filterAssignee = ref('')
const filterPriority = ref('')
const searchKeyword = ref('')
const dimensions = [
{ value: 'finance', label: '财务' },
{ value: 'customer', label: '客户' },
{ value: 'process', label: '内部流程' },
{ value: 'learning', label: '学习成长' },
]
const hasFilter = computed(() =>
filterStatus.value || filterDimension.value || filterKpi.value ||
filterAssignee.value || filterPriority.value || searchKeyword.value
)
function clearFilter() {
filterStatus.value = ''
filterDimension.value = ''
filterKpi.value = null
filterAssignee.value = ''
filterPriority.value = ''
searchKeyword.value = ''
page.value = 1
loadData()
}
function clearSelected() {
selectedRows.value = []
}
// ── 统计卡片 ──
const statCards = [
{ key: '', label: '全部', color: '#409eff' },
{ key: 'pending', label: '待处理', color: '#909399' },
{ key: 'in_progress', label: '进行中', color: '#e6a23c' },
{ key: 'completed', label: '已完成', color: '#67c23a' },
{ key: 'overdue', label: '已逾期', color: '#f56c6c' },
]
// ── 加载数据 ──
async function loadData() {
loading.value = true
try {
const params: any = { page: page.value, page_size: pageSize.value }
if (filterStatus.value) params.status = filterStatus.value
if (filterDimension.value) params.dimension = filterDimension.value
if (filterKpi.value) params.kpi_id = filterKpi.value
if (filterAssignee.value) params.assignee = filterAssignee.value
if (filterPriority.value) params.priority = filterPriority.value
if (searchKeyword.value) params.keyword = searchKeyword.value
const r: any = await actionPlanApi.list(params)
const d = r.data || []
list.value = Array.isArray(d) ? d : []
total.value = r.total || list.value.length
} catch { ElMessage.error('加载行动方案失败') }
loading.value = false
}
async function loadStats() {
try {
const r: any = await actionPlanApi.stats()
stats.value = r
} catch {}
}
async function loadKpis() {
try {
const r: any = await kpiApi.list({ page_size: 500 })
const d = r.data || r || []
kpiOptions.value = Array.isArray(d) ? d : []
} catch {}
}
async function loadUsers() {
try {
const r: any = await userApi.list()
const d = r.data || []
if (Array.isArray(d)) {
userOptions.value = d.map((u: any) => u.name || u.username).filter(Boolean)
}
} catch {}
}
// ── 新建/编辑 ──
const showForm = ref(false)
const formTitle = ref('')
const editingId = ref<number | null>(null)
const saving = ref(false)
const form = ref({
title: '',
target_value: '',
kpi_id: null as number | null,
description: '',
assignee: '',
priority: 'medium',
due_date: null as string | null,
status: 'pending',
progress: 0,
result: '',
})
function openCreate() {
editingId.value = null
formTitle.value = '新建行动方案'
form.value = { title: '', target_value: '', kpi_id: null, description: '', assignee: '', priority: 'medium', due_date: null, status: 'pending', progress: 0, result: '' }
// 如果路由携带 kpi_id 参数,自动填入
if (route.query.kpi_id) {
form.value.kpi_id = Number(route.query.kpi_id)
}
showForm.value = true
}
function editPlan(row: any) {
editingId.value = row.id
formTitle.value = '编辑行动方案'
form.value = {
title: row.title,
target_value: row.target_value || '',
kpi_id: row.kpi_id,
description: row.description || '',
assignee: row.assignee || '',
priority: row.priority || 'medium',
due_date: row.due_date?.slice(0, 10) || null,
status: row.status || 'pending',
progress: row.progress || 0,
result: row.result || '',
}
showForm.value = true
}
async function savePlan() {
if (!form.value.title || !form.value.kpi_id) {
ElMessage.warning('请填写标题并选择关联KPI')
return
}
saving.value = true
try {
const data = { ...form.value }
if (editingId.value) {
await actionPlanApi.update(editingId.value, data)
ElMessage.success('已更新')
} else {
await actionPlanApi.create(data)
ElMessage.success('已创建')
}
showForm.value = false
loadData()
loadStats()
} catch { ElMessage.error('保存失败') }
saving.value = false
}
// ── 删除 ──
async function deletePlan(id: number) {
try {
await ElMessageBox.confirm('确定删除此行动方案?')
await actionPlanApi.delete(id)
ElMessage.success('已删除')
loadData()
loadStats()
} catch (e) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
// ── 批量操作 ──
async function batchSetStatus(status: string) {
const ids = selectedRows.value.map(r => r.id)
if (ids.length === 0) return
try {
await ElMessageBox.confirm(`确定将选中的 ${ids.length} 项置为「${status === 'completed' ? '已完成' : '进行中'}」?`)
for (const id of ids) {
await actionPlanApi.update(id, { status })
}
ElMessage.success(`已更新 ${ids.length}`)
selectedRows.value = []
loadData()
loadStats()
} catch (e) { if (e !== 'cancel') ElMessage.error('批量操作失败') }
}
// ── 详情 ──
const showDetail = ref(false)
const detailTitle = ref('')
const detailPlan = ref<any>(null)
function viewDetail(row: any) {
detailPlan.value = row
detailTitle.value = row.title
showDetail.value = true
}
// ── 辅助函数 ──
function isOverdue(row: any) {
if (!row.due_date) return false
if (row.status === 'completed' || row.status === 'cancelled') return false
return new Date(row.due_date.slice(0, 10)) < new Date(new Date().toLocaleDateString())
}
function dimTagType(dim: string) {
const map: Record<string, string> = { finance: 'primary', customer: 'success', process: 'warning', learning: 'danger' }
return map[dim] || 'info'
}
function dimLabel(dim: string) {
const map: Record<string, string> = { finance: '财务', customer: '客户', process: '内部流程', learning: '学习成长' }
return map[dim] || dim || '-'
}
function statusTagType(status: string) {
const map: Record<string, string> = { pending: 'info', in_progress: 'warning', completed: 'success', cancelled: 'info' }
return map[status] || 'info'
}
function statusLabel(status: string) {
const map: Record<string, string> = { pending: '待处理', in_progress: '进行中', completed: '已完成', cancelled: '已取消' }
return map[status] || status
}
// ── 初始化 ──
onMounted(() => {
loadStats()
loadData()
loadKpis()
loadUsers()
})
</script>
<style scoped>
.stat-card:hover {
border-color: #dcdfe6;
background: #fff;
}
.stat-card.active {
border-color: #409eff;
background: #ecf5ff;
}
.overdue-text {
color: #f56c6c;
font-weight: 600;
}
</style>