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
+538
View File
@@ -0,0 +1,538 @@
<template>
<div class="kpi-list-view">
<!-- 页头 -->
<div class="page-header">
<h3>
<span class="dim-icon">{{ icon }}</span>
{{ title }}
</h3>
<div class="header-actions">
<el-radio-group v-model="periodType" size="small" @change="loadData">
<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>
<el-dropdown v-if="templates && templates.length > 0" trigger="click" @command="selectTemplate">
<el-button size="small" type="success">
<el-icon><Plus /></el-icon> 从模板创建
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="(t, i) in templates" :key="t.name"
:command="t"
:divided="i > 0 && t.category !== templates[i-1]?.category"
>
<span class="template-dropdown-item">
<span
v-if="t.category"
class="template-cat-tag"
:style="{ background: categoryColor(t.category) }"
>{{ t.category }}</span>
<span>{{ t.name }}</span>
</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button type="primary" size="small" @click="openCreate">
+ 新建指标
</el-button>
<el-button size="small" @click="loadData" :loading="loading">
<el-icon><Refresh /></el-icon> 刷新
</el-button>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-state">
<el-skeleton :rows="5" animated />
</div>
<template v-else>
<!-- 摘要卡片行 -->
<div class="summary-row">
<div class="stat-card blue">
<div class="stat-val">{{ kpis.length }}</div>
<div class="stat-label">{{ statLabels.total }}</div>
</div>
<div class="stat-card green">
<div class="stat-val">{{ stats.green }}</div>
<div class="stat-label">{{ statLabels.green }}</div>
</div>
<div class="stat-card yellow">
<div class="stat-val">{{ stats.yellow }}</div>
<div class="stat-label">{{ statLabels.yellow }}</div>
</div>
<div class="stat-card red">
<div class="stat-val">{{ stats.red }}</div>
<div class="stat-label">{{ statLabels.red }}</div>
</div>
<div class="stat-card" style="background:#f0f5ff;">
<div class="stat-val">{{ stats.noData }}</div>
<div class="stat-label">{{ statLabels.noData }}</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="kpis.length === 0" class="empty-state">
<el-empty :description="'暂无' + statLabels.total + '' + (templates && templates.length > 0 ? '点击「从模板创建」快速添加预设指标' : '点击「+ 新建指标」创建')">
<el-button type="primary" @click="openCreate">新建指标</el-button>
</el-empty>
</div>
<!-- 分类卡片网格 -->
<template v-else>
<template v-if="hasCategories">
<div v-for="cat in categories" :key="cat.key" class="capital-section">
<div class="capital-title" :style="{ color: cat.color || '#409EFF' }">
{{ cat.icon || '📋' }} {{ cat.label }}
<span class="capital-subtitle" v-if="cat.subtitle">{{ cat.subtitle }}</span>
</div>
<div class="kpi-card-grid">
<div
v-for="k in kpisByCategory(cat.key)" :key="k.id"
class="kpi-card"
:class="'card-level-' + (k.alert_level || 'none')"
@click="showKPIDetail(k)"
>
<div class="card-head">
<span class="card-name">{{ k.kpi_name }}</span>
<el-tag size="small" :type="alertTagType(k.alert_level)" effect="dark">
{{ alertLevelIcon(k.alert_level) }} {{ alertLevelLabel(k.alert_level) }}
</el-tag>
</div>
<div class="card-value" :style="{ color: alertColor(k.alert_level) }">
<span class="val-num">{{ fmtValue(k.actual_value) }}</span>
<span class="val-unit">{{ k.unit }}</span>
</div>
<div class="card-target">目标值<strong>{{ k.target_value ?? '—' }}</strong></div>
<div class="card-progress" v-if="k.target_value && k.actual_value != null">
<el-progress
:percentage="Math.min(Math.round((k.actual_value / k.target_value) * 100), 100)"
:stroke-width="6" :color="alertColor(k.alert_level)" size="small"
/>
</div>
<div class="card-meta">
<span v-if="k.responsible_user">👤 {{ k.responsible_user }}</span>
</div>
</div>
</div>
</div>
</template>
<div v-else class="kpi-card-grid">
<div
v-for="k in kpis" :key="k.id"
class="kpi-card"
:class="'card-level-' + (k.alert_level || 'none')"
@click="showKPIDetail(k)"
>
<div class="card-head">
<span class="card-name">{{ k.kpi_name }}</span>
<el-tag size="small" :type="alertTagType(k.alert_level)" effect="dark">
{{ alertLevelIcon(k.alert_level) }} {{ alertLevelLabel(k.alert_level) }}
</el-tag>
</div>
<div class="card-value" :style="{ color: alertColor(k.alert_level) }">
<span class="val-num">{{ fmtValue(k.actual_value) }}</span>
<span class="val-unit">{{ k.unit }}</span>
</div>
<div class="card-target">目标值<strong>{{ k.target_value ?? '—' }}</strong></div>
<div class="card-progress" v-if="k.target_value && k.actual_value != null">
<el-progress
:percentage="Math.min(Math.round((k.actual_value / k.target_value) * 100), 100)"
:stroke-width="6" :color="alertColor(k.alert_level)" size="small"
/>
</div>
<div class="card-meta">
<span v-if="k.responsible_user">👤 {{ k.responsible_user }}</span>
<span v-if="k.frequency">{{ freqLabel(k.frequency) }}</span>
</div>
</div>
</div>
<!-- 数据表格 -->
<el-card shadow="never" class="section-card">
<template #header>
<div class="card-header-flex">
<span>📋 {{ title }}明细</span>
<el-button size="small" @click="exportCSV">导出CSV</el-button>
</div>
</template>
<el-table :data="kpis" stripe border size="small" style="width:100%">
<el-table-column v-if="hasCategories" label="类别" width="90">
<template #default="{ row }">
<el-tag :color="categoryColor(row.category || categories[0]?.key)" style="color:#fff;border:0;" size="small">
{{ row.category || categories[0]?.label }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="kpi_name" label="指标名称" min-width="150" />
<el-table-column label="当前值" width="100">
<template #default="{ row }">
<span :style="{ color: alertColor(row.alert_level), fontWeight: 600 }">
{{ row.actual_value != null ? row.actual_value : '—' }}
</span>
</template>
</el-table-column>
<el-table-column label="目标值" width="100">
<template #default="{ row }">{{ row.target_value ?? '—' }}</template>
</el-table-column>
<el-table-column label="单位" width="60" prop="unit" />
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="alertTagType(row.alert_level)" size="small" effect="plain">
{{ alertLevelLabel(row.alert_level) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="负责人" width="100" prop="responsible_user" />
<el-table-column label="频率" width="70" v-if="showFrequency">
<template #default="{ row }">{{ freqLabel(row.frequency) }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button size="small" text type="primary" @click.stop="editKPI(row)">编辑</el-button>
<el-button size="small" text type="danger" @click.stop="deleteKPI(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</template>
</template>
<!-- 新建/编辑弹窗 -->
<MyDialog v-model="showForm" :title="formTitle" :width="520">
<el-form :model="form" label-width="90px" size="small">
<el-form-item label="指标名称">
<el-input v-model="form.kpi_name" :placeholder="'如:' + (namePlaceholder || 'KPI名称')" />
</el-form-item>
<el-row :gutter="16">
<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-select v-model="form.unit" style="width:100%">
<el-option v-for="u in unitOptions" :key="u" :label="u" :value="u" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="实际值">
<el-input-number v-model="form.actual_value" :min="0" style="width:100%" />
</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 v-if="hasCategories" label="分类">
<el-select v-model="form.category" style="width:100%">
<el-option v-for="c in categories" :key="c.key" :label="c.label" :value="c.key" />
</el-select>
</el-form-item>
<!-- 频率字段 -->
<el-form-item v-if="showFrequency" 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-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" :placeholder="'数据来源和说明'" />
</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>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Plus } from '@element-plus/icons-vue'
import api from '../api/index'
interface Category {
key: string
label: string
icon?: string
subtitle?: string
color?: string
}
interface StatLabels {
total: string
green: string
yellow: string
red: string
noData: string
}
interface Template {
name: string
category?: string
unit?: string
targetValue?: number
}
const props = withDefaults(defineProps<{
dimension: string
title: string
icon?: string
statLabels?: StatLabels
templates?: Template[]
categories?: Category[]
hasCategories?: boolean
showFrequency?: boolean
namePlaceholder?: string
unitOptions?: string[]
formTitleCreate?: string
formTitleEdit?: string
}>(), {
icon: '📊',
statLabels: () => ({ total: '指标总数', green: '达标', yellow: '预警', red: '未达标', noData: '无数据' }),
templates: () => [],
categories: () => [],
hasCategories: false,
showFrequency: false,
namePlaceholder: '',
unitOptions: () => ['%', '元', '家', '分', '小时', '次', '个'],
formTitleCreate: '',
formTitleEdit: '',
})
const formTitle = computed(() => {
if (editMode.value) return props.formTitleEdit || `编辑${props.title}`
return props.formTitleCreate || `新建${props.title}`
})
const loading = ref(true)
const kpis = ref<any[]>([])
const periodType = ref('month')
const showForm = ref(false)
const editMode = ref(false)
const form = reactive<any>({})
const stats = computed(() => {
const s = { green: 0, yellow: 0, red: 0, noData: 0 }
for (const k of kpis.value) {
const level = k.alert_level || 'none'
if (level === 'green') s.green++
else if (level === 'yellow') s.yellow++
else if (level === 'red') s.red++
else s.noData++
}
return s
})
function kpisByCategory(catKey: string) {
return kpis.value.filter((k: any) => (k.category || props.categories[0]?.key) === catKey)
}
function categoryColor(catKey: string): string {
const cat = props.categories.find(c => c.key === catKey)
return cat?.color || '#909399'
}
function selectTemplate(t: Template) {
editMode.value = false
const extra: any = {}
if (props.hasCategories && t.category) extra.category = t.category
Object.assign(form, {
kpi_name: t.name,
target_value: t.targetValue,
unit: t.unit || '%',
actual_value: null,
responsible_user: '',
description: '',
frequency: 'monthly',
...extra,
})
showForm.value = true
}
function openCreate() {
editMode.value = false
const defaults: any = { kpi_name: '', target_value: null, actual_value: null, unit: '%', responsible_user: '', description: '', frequency: 'monthly' }
if (props.hasCategories && props.categories.length > 0) defaults.category = props.categories[0].key
Object.assign(form, defaults)
showForm.value = true
}
function loadData() {
loading.value = true
kpis.value = []
api.get('/kpis', { params: { dimension: props.dimension, page_size: 100 } })
.then((r: any) => {
kpis.value = (r.data || []).map((k: any) => ({
...k,
actual_value: k.actual_value ?? k.actualValue,
target_value: k.target_value ?? k.targetValue,
}))
loading.value = false
})
.catch(() => { loading.value = false })
}
function saveKPI() {
if (!form.kpi_name?.trim()) { ElMessage.warning('请输入指标名称'); return }
form.dimension = props.dimension
if (editMode.value) {
api.put(`/kpis/${form.id}`, form).then(() => {
ElMessage.success('已更新'); showForm.value = false; loadData()
}).catch((e: any) => ElMessage.error(e?.response?.data?.detail || '更新失败'))
} else {
api.post('/kpis', form).then(() => {
ElMessage.success('已创建'); showForm.value = false; loadData()
}).catch((e: any) => ElMessage.error(e?.response?.data?.detail || '创建失败'))
}
}
function editKPI(row: any) {
editMode.value = true
const base: any = {
id: row.id,
kpi_name: row.kpi_name,
kpi_code: row.kpi_code,
target_value: row.target_value ?? row.targetValue,
actual_value: row.actual_value ?? row.actualValue,
unit: row.unit || '%',
responsible_user: row.responsible_user || '',
frequency: row.frequency || 'monthly',
description: row.description || '',
dimension: props.dimension,
}
if (props.hasCategories) base.category = row.category || (props.categories[0]?.key || '')
Object.assign(form, base)
showForm.value = true
}
async function deleteKPI(id: number) {
try {
await ElMessageBox.confirm('确定删除此KPI', '确认')
await api.delete(`/kpis/${id}`)
ElMessage.success('已删除'); loadData()
} catch { /* cancel */ }
}
function showKPIDetail(k: any) {
window.location.href = `/kpis/${k.id}`
}
function exportCSV() {
const headers = ['指标名称', '当前值', '目标值', '单位', '状态', '负责人']
if (props.hasCategories) headers.unshift('类别')
const rows = kpis.value.map(k => {
const row = [k.kpi_name, k.actual_value ?? '', k.target_value ?? '', k.unit || '', alertLevelLabel(k.alert_level), k.responsible_user || '']
if (props.hasCategories) row.unshift(k.category || '')
return row
})
const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n')
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = `${props.title}_${new Date().toISOString().slice(0, 10)}.csv`
a.click(); URL.revokeObjectURL(url)
}
// ── 预警等级帮助函数 ──
function alertLevelLabel(level: string): string {
return { red: '未达标', yellow: '预警', green: '达标', none: '无数据' }[level] || '—'
}
function alertLevelIcon(level: string): string {
return { red: '🔴', yellow: '🟡', green: '🟢', none: '⚪' }[level] || '⚪'
}
function alertColor(level: string): string {
return { red: '#f56c6c', yellow: '#e6a23c', green: '#67c23a' }[level] || '#909399'
}
function alertTagType(level: string): string {
return { red: 'danger', yellow: 'warning', green: 'success' }[level] || 'info'
}
function freqLabel(freq: string): string {
return { monthly: '月度', quarterly: '季度', yearly: '年度' }[freq] || freq
}
function fmtValue(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)
}
onMounted(() => loadData())
</script>
<style scoped>
.kpi-list-view { padding: 16px; }
.page-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 16px; flex-wrap: wrap; gap: 8px;
}
.page-header h3 { margin: 0; font-size: 18px; display: flex; align-items: center; gap: 6px; }
.dim-icon { font-size: 20px; }
.header-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.loading-state { padding: 40px; }
.empty-state { padding: 60px 0; }
.summary-row { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
.stat-card {
flex: 1; min-width: 100px; padding: 16px; border-radius: 10px;
text-align: center; background: #f8f9fa; border: 1px solid #eee;
}
.stat-card.blue { background: #ecf5ff; }
.stat-card.green { background: #f0f9eb; }
.stat-card.yellow { background: #fdf6ec; }
.stat-card.red { background: #fef0f0; }
.stat-val { font-size: 28px; font-weight: 700; color: #333; line-height: 1.2; }
.stat-label { font-size: 13px; color: #666; margin-top: 4px; }
.capital-section { margin-bottom: 24px; }
.capital-title {
font-size: 16px; font-weight: 600; margin-bottom: 12px;
display: flex; align-items: center; gap: 8px;
}
.capital-subtitle { font-size: 12px; font-weight: 400; color: #999; }
.kpi-card-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px; margin-bottom: 24px;
}
.kpi-card {
background: #fff; border: 1px solid #e8e8e8; border-radius: 10px;
padding: 16px; cursor: pointer; transition: all 0.2s;
}
.kpi-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.08); transform: translateY(-1px); }
.kpi-card.card-level-red { border-left: 4px solid #f56c6c; }
.kpi-card.card-level-yellow { border-left: 4px solid #e6a23c; }
.kpi-card.card-level-green { border-left: 4px solid #67c23a; }
.kpi-card.card-level-none { border-left: 4px solid #dcdfe6; }
.card-head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px; }
.card-name { font-weight: 600; font-size: 14px; color: #333; }
.card-value { font-size: 24px; font-weight: 700; line-height: 1.3; }
.val-unit { font-size: 13px; font-weight: 400; margin-left: 4px; color: #999; }
.card-target { font-size: 12px; color: #888; margin: 4px 0; }
.card-progress { margin: 6px 0; }
.card-meta { display: flex; gap: 10px; font-size: 11px; color: #aaa; margin-top: 6px; }
.section-card { margin-bottom: 16px; }
.card-header-flex { display: flex; justify-content: space-between; align-items: center; }
.template-dropdown-item { display: flex; align-items: center; gap: 8px; }
.template-cat-tag {
display: inline-block; padding: 1px 6px; border-radius: 3px;
font-size: 11px; color: #fff; white-space: nowrap;
}
</style>
+151
View File
@@ -0,0 +1,151 @@
<template>
<div class="kp-panel" :class="{ 'kp-collapsed': collapsed }">
<div class="kp-header" @click="toggleCollapse">
<span class="kp-icon">{{ icon }}</span>
<span class="kp-title">📖 CMA知识点</span>
<span class="kp-toggle">{{ collapsed ? '展开' : '收起' }}</span>
</div>
<div v-if="!collapsed" class="kp-body">
<div v-if="loading" style="padding:12px;text-align:center;color:#999;">加载中...</div>
<div v-else-if="articles.length === 0" style="padding:12px;text-align:center;color:#ccc;">暂无关联知识</div>
<template v-else>
<div v-for="(article, idx) in articles" :key="article.id" class="kp-article" :class="{ 'kp-first': idx === 0 }">
<div class="kp-article-head" @click="toggleArticle(idx)">
<span class="kp-article-icon">{{ article.icon || '📖' }}</span>
<span class="kp-article-title">{{ article.title }}</span>
<span class="kp-article-arrow">{{ expandedArticle === idx ? '▼' : '▶' }}</span>
</div>
<div v-if="expandedArticle === idx" class="kp-article-body" v-html="renderedContent(article.content)"></div>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { knowledgeArticleApi } from '../api/index'
import { marked } from 'marked'
const props = defineProps<{
relatedPage: string
icon?: string
defaultCollapsed?: boolean
}>()
const collapsed = ref(props.defaultCollapsed !== false)
const loading = ref(false)
const articles = ref<any[]>([])
const expandedArticle = ref<number | null>(0)
// 首次使用tooltip引导
const hasSeenTooltip = ref(localStorage.getItem('cma_kb_tooltip_seen') === '1')
onMounted(() => {
if (!hasSeenTooltip.value) {
collapsed.value = false
localStorage.setItem('cma_kb_tooltip_seen', '1')
ElMessage.info('💡 右侧面板展示了关联的CMA知识点,可收起不干扰操作')
}
loadArticles()
})
watch(() => props.relatedPage, () => {
loadArticles()
})
function toggleCollapse() {
collapsed.value = !collapsed.value
}
function toggleArticle(idx: number) {
expandedArticle.value = expandedArticle.value === idx ? null : idx
}
function renderedContent(content: string) {
if (!content) return ''
try {
return marked(content)
} catch {
return content
}
}
async function loadArticles() {
if (!props.relatedPage) return
loading.value = true
try {
const r: any = await knowledgeArticleApi.list({ related_page: props.relatedPage })
articles.value = r.data || []
} catch {
articles.value = []
}
loading.value = false
}
</script>
<style scoped>
.kp-panel {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
width: 320px;
max-height: 480px;
border: 1px solid #e8e8e8;
border-radius: 8px;
background: #fff;
overflow: hidden;
transition: all 0.3s;
font-size: 13px;
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
}
.kp-panel.kp-collapsed {
max-height: 36px;
width: auto;
}
.kp-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
cursor: pointer;
background: linear-gradient(135deg, #f5f7fa 0%, #eef1f5 100%);
user-select: none;
}
.kp-icon { font-size: 16px; }
.kp-title { flex: 1; font-weight: 600; font-size: 13px; color: #555; }
.kp-toggle { font-size: 11px; color: #999; }
.kp-body { max-height: 400px; overflow-y: auto; }
.kp-article { border-top: 1px solid #f0f0f0; }
.kp-article.kp-first { border-top: none; }
.kp-article-head {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
cursor: pointer;
transition: background 0.2s;
}
.kp-article-head:hover { background: #f5f8ff; }
.kp-article-icon { font-size: 14px; }
.kp-article-title { flex: 1; font-size: 13px; color: #409eff; }
.kp-article-arrow { font-size: 10px; color: #999; }
.kp-article-body {
padding: 4px 12px 12px;
line-height: 1.7;
color: #555;
font-size: 12px;
}
.kp-article-body :deep(h4) { margin: 8px 0 4px; font-size: 13px; color: #333; }
.kp-article-body :deep(h5) { margin: 6px 0 3px; font-size: 12px; color: #666; }
.kp-article-body :deep(ul),
.kp-article-body :deep(ol) { padding-left: 16px; }
.kp-article-body :deep(li) { margin-bottom: 2px; }
.kp-article-body :deep(code) { background: #f5f5f5; padding: 1px 4px; border-radius: 3px; font-size: 11px; }
.kp-article-body :deep(table) { width: 100%; border-collapse: collapse; margin: 8px 0; }
.kp-article-body :deep(th),
.kp-article-body :deep(td) { border: 1px solid #e0e0e0; padding: 4px 6px; text-align: left; font-size: 11px; }
.kp-article-body :deep(th) { background: #f5f5f5; font-weight: 600; }
</style>
@@ -0,0 +1,143 @@
<template>
<div class="kp-panel" :class="{ 'kp-collapsed': collapsed }">
<div class="kp-header" @click="toggleCollapse">
<span class="kp-icon">{{ icon }}</span>
<span class="kp-title">📖 CMA知识点</span>
<span class="kp-toggle">{{ collapsed ? '展开' : '收起' }}</span>
</div>
<div v-if="!collapsed" class="kp-body">
<div v-if="loading" style="padding:12px;text-align:center;color:#999;">加载中...</div>
<div v-else-if="articles.length === 0" style="padding:12px;text-align:center;color:#ccc;">暂无关联知识</div>
<template v-else>
<div v-for="(article, idx) in articles" :key="article.id" class="kp-article" :class="{ 'kp-first': idx === 0 }">
<div class="kp-article-head" @click="toggleArticle(idx)">
<span class="kp-article-icon">{{ article.icon || '📖' }}</span>
<span class="kp-article-title">{{ article.title }}</span>
<span class="kp-article-arrow">{{ expandedArticle === idx ? '▼' : '▶' }}</span>
</div>
<div v-if="expandedArticle === idx" class="kp-article-body" v-html="renderedContent(article.content)"></div>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { knowledgeArticleApi } from '../api/index'
import { marked } from 'marked'
const props = defineProps<{
relatedPage: string
icon?: string
defaultCollapsed?: boolean
}>()
const collapsed = ref(props.defaultCollapsed !== false)
const loading = ref(false)
const articles = ref<any[]>([])
const expandedArticle = ref<number | null>(0)
// 首次使用tooltip引导
const hasSeenTooltip = ref(localStorage.getItem('cma_kb_tooltip_seen') === '1')
onMounted(() => {
if (!hasSeenTooltip.value) {
collapsed.value = false
localStorage.setItem('cma_kb_tooltip_seen', '1')
ElMessage.info('💡 右侧面板展示了关联的CMA知识点,可收起不干扰操作')
}
loadArticles()
})
watch(() => props.relatedPage, () => {
loadArticles()
})
function toggleCollapse() {
collapsed.value = !collapsed.value
}
function toggleArticle(idx: number) {
expandedArticle.value = expandedArticle.value === idx ? null : idx
}
function renderedContent(content: string) {
if (!content) return ''
try {
return marked(content)
} catch {
return content
}
}
async function loadArticles() {
if (!props.relatedPage) return
loading.value = true
try {
const r: any = await knowledgeArticleApi.list({ related_page: props.relatedPage })
articles.value = r.data || []
} catch {
articles.value = []
}
loading.value = false
}
</script>
<style scoped>
.kp-panel {
border: 1px solid #e8e8e8;
border-radius: 8px;
background: #fff;
overflow: hidden;
transition: all 0.3s;
font-size: 13px;
}
.kp-panel.kp-collapsed {
max-height: 36px;
}
.kp-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
cursor: pointer;
background: linear-gradient(135deg, #f5f7fa 0%, #eef1f5 100%);
user-select: none;
}
.kp-icon { font-size: 16px; }
.kp-title { flex: 1; font-weight: 600; font-size: 13px; color: #555; }
.kp-toggle { font-size: 11px; color: #999; }
.kp-body { max-height: 400px; overflow-y: auto; }
.kp-article { border-top: 1px solid #f0f0f0; }
.kp-article.kp-first { border-top: none; }
.kp-article-head {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
cursor: pointer;
transition: background 0.2s;
}
.kp-article-head:hover { background: #f5f8ff; }
.kp-article-icon { font-size: 14px; }
.kp-article-title { flex: 1; font-size: 13px; color: #409eff; }
.kp-article-arrow { font-size: 10px; color: #999; }
.kp-article-body {
padding: 4px 12px 12px;
line-height: 1.7;
color: #555;
font-size: 12px;
}
.kp-article-body :deep(h4) { margin: 8px 0 4px; font-size: 13px; color: #333; }
.kp-article-body :deep(h5) { margin: 6px 0 3px; font-size: 12px; color: #666; }
.kp-article-body :deep(ul),
.kp-article-body :deep(ol) { padding-left: 16px; }
.kp-article-body :deep(li) { margin-bottom: 2px; }
.kp-article-body :deep(code) { background: #f5f5f5; padding: 1px 4px; border-radius: 3px; font-size: 11px; }
.kp-article-body :deep(table) { width: 100%; border-collapse: collapse; margin: 8px 0; }
.kp-article-body :deep(th),
.kp-article-body :deep(td) { border: 1px solid #e0e0e0; padding: 4px 6px; text-align: left; font-size: 11px; }
.kp-article-body :deep(th) { background: #f5f5f5; font-weight: 600; }
</style>
+161
View File
@@ -0,0 +1,161 @@
<template>
<el-dialog v-model="visible" title="" :width="560" :show-close="true" :close-on-click-modal="false" class="cma-welcome-dialog">
<div class="welcome-body">
<div class="welcome-header">
<div class="welcome-icon">👋</div>
<h2>欢迎使用管理会计OS</h2>
<p class="welcome-sub">三步开启从战略到执行的管理闭环</p>
</div>
<div class="steps-wrap">
<div class="step-item" :class="{ active: currentStep === 0, done: currentStep > 0 }">
<div class="step-num">
<span v-if="currentStep > 0"></span>
<span v-else>1</span>
</div>
<div class="step-content">
<h4> 绘制战略地图</h4>
<p>在战略画布上定义财务客户内部流程学习成长四个维度的战略主题和因果关系链让战略"可视化"</p>
<div class="step-preview">🗺 战略地图 KPI目标对齐 预算配置</div>
</div>
</div>
<div class="step-connector" :class="{ active: currentStep >= 1 }"></div>
<div class="step-item" :class="{ active: currentStep === 1, done: currentStep > 1 }">
<div class="step-num">
<span v-if="currentStep > 1"></span>
<span v-else>2</span>
</div>
<div class="step-content">
<h4> 对齐KPI目标</h4>
<p>将战略地图上的战略主题分解为可衡量的KPI支持纵向分解公司部门横向支撑因果传导和BSC瀑布链三种对齐模式</p>
<div class="step-preview">🎯 KPI目标对齐 资源按目标分配</div>
</div>
</div>
<div class="step-connector" :class="{ active: currentStep >= 2 }"></div>
<div class="step-item" :class="{ active: currentStep === 2, done: currentStep > 2 }">
<div class="step-num">
<span v-if="currentStep > 2"></span>
<span v-else>3</span>
</div>
<div class="step-content">
<h4> 编制全面预算</h4>
<p>按对齐后的目标分配资源编制收入/成本/费用/投资预算从此预算不再是财务部闭门造而是从战略自上而下对齐出来的</p>
<div class="step-preview">💰 预算管理 驾驶舱监控执行 差异分析 改善行动</div>
</div>
</div>
</div>
<!-- 完整闭环说明 -->
<div class="pdca-hint" v-if="currentStep >= 2">
<div class="hint-title">三步走完后系统进入持续管理闭环</div>
<div class="hint-flow">
<span>🎯 P 战略规划</span>
<span></span>
<span>📊 D 执行监控</span>
<span></span>
<span>🔍 C 复盘评估</span>
<span></span>
<span>🔄 A 优化调整</span>
</div>
</div>
<div class="welcome-footer">
<el-button v-if="currentStep < 2" type="primary" size="large" @click="currentStep++">继续了解 </el-button>
<el-button v-else type="primary" size="large" @click="startUsing">开始使用 </el-button>
<el-button text @click="skip">跳过引导</el-button>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const visible = ref(false)
const currentStep = ref(0)
const GUIDE_SHOWN_KEY = 'cma_guide_shown'
function show() {
visible.value = true
currentStep.value = 0
}
function skip() {
visible.value = false
localStorage.setItem(GUIDE_SHOWN_KEY, '1')
}
function startUsing() {
visible.value = false
localStorage.setItem(GUIDE_SHOWN_KEY, '1')
router.push('/maps')
}
// 暴露show方法供父组件调用
defineExpose({ show })
onMounted(() => {
// 自动检测是否显示过引导
const shown = localStorage.getItem(GUIDE_SHOWN_KEY)
if (!shown) {
// 延迟弹窗让页面先渲染
setTimeout(() => { visible.value = true }, 500)
}
})
</script>
<style scoped>
.welcome-body { text-align: center; }
.welcome-header { margin-bottom: 24px; }
.welcome-icon { font-size: 48px; margin-bottom: 8px; }
.welcome-header h2 { margin: 0 0 6px; font-size: 22px; }
.welcome-sub { color: #666; font-size: 14px; margin: 0; }
.steps-wrap { text-align: left; margin-bottom: 20px; }
.step-item {
display: flex; gap: 14px; padding: 14px 16px;
border: 1px solid #eee; border-radius: 10px;
margin-bottom: 4px; transition: all 0.3s;
}
.step-item.active { border-color: #409eff; background: #f0f7ff; }
.step-item.done { border-color: #b7eb8f; background: #f6ffed; }
.step-num {
flex-shrink: 0; width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
border-radius: 50%; font-weight: 700; font-size: 14px;
background: #eee; color: #999;
}
.step-item.active .step-num { background: #409eff; color: #fff; }
.step-item.done .step-num { background: transparent; }
.step-content { flex: 1; }
.step-content h4 { margin: 0 0 4px; font-size: 15px; }
.step-content p { margin: 0 0 6px; font-size: 13px; color: #666; line-height: 1.5; }
.step-preview {
font-size: 12px; color: #909399;
background: #f5f7fa; padding: 4px 8px; border-radius: 4px;
display: inline-block;
}
.step-connector { text-align: center; color: #ddd; font-size: 18px; padding: 2px 0; }
.step-connector.active { color: #409eff; }
.pdca-hint {
background: #f0f7ff; border: 1px solid #d9ecff; border-radius: 10px;
padding: 14px; margin-bottom: 20px;
}
.hint-title { font-size: 14px; font-weight: 600; margin-bottom: 8px; }
.hint-flow {
display: flex; align-items: center; justify-content: center;
gap: 8px; font-size: 13px; flex-wrap: wrap;
}
.hint-flow span { white-space: nowrap; }
.welcome-footer { display: flex; align-items: center; justify-content: center; gap: 12px; }
</style>
@@ -0,0 +1,77 @@
<template>
<div :ref="el => containerRef = el" style="width:100%;height:100%;min-height:80px;"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import * as echarts from 'echarts'
const props = withDefaults(defineProps<{
actual: number
target: number
label?: string
unit?: string
}>(), { label: '', unit: '' })
const containerRef = ref<HTMLElement | null>(null)
let chart: echarts.ECharts | null = null
function render() {
if (!containerRef.value) return
if (!chart) chart = echarts.init(containerRef.value)
const maxVal = Math.max(props.actual, props.target, 1) * 1.3
const pct = props.target > 0 ? (props.actual / props.target * 100) : 0
chart.setOption({
tooltip: {
trigger: 'axis',
formatter: () =>
`${props.label}<br/>实际: <b>${props.actual?.toLocaleString()}${props.unit}</b><br/>目标: ${props.target?.toLocaleString()}${props.unit}<br/>完成率: <b>${pct.toFixed(1)}%</b>`,
},
grid: { left: 50, right: 50, top: 10, bottom: 5 },
xAxis: { type: 'category', data: [props.label], axisLabel: { show: false }, splitLine: { show: false } },
yAxis: { type: 'value', max: maxVal, splitLine: { show: false }, axisLabel: { fontSize: 10, color: '#bbb' } },
series: [
{
type: 'bar',
data: [props.actual],
barWidth: 16,
itemStyle: {
borderRadius: [4, 4, 0, 0],
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: pct >= 100 ? '#52c41a' : pct >= 80 ? '#faad14' : '#ff4d4f' },
{ offset: 1, color: pct >= 100 ? '#73d13d' : pct >= 80 ? '#ffc53d' : '#ff7875' },
]),
},
label: {
show: true,
position: 'top',
formatter: `${props.actual?.toLocaleString()}${props.unit}`,
fontSize: 13,
fontWeight: 700,
color: pct >= 100 ? '#52c41a' : '#ff4d4f',
},
},
{
type: 'bar',
data: [props.target],
barWidth: 16,
barGap: '-100%',
itemStyle: { color: 'rgba(0,0,0,0.06)', borderRadius: [4, 4, 0, 0] },
label: {
show: true,
position: 'bottom',
formatter: `目标: ${props.target?.toLocaleString()}${props.unit}`,
fontSize: 10,
color: '#999',
},
},
],
}, true)
}
watch(() => [props.actual, props.target], render)
onMounted(render)
onUnmounted(() => { chart?.dispose(); chart = null })
</script>
@@ -0,0 +1,57 @@
<template>
<div :ref="el => containerRef = el" style="width:100%;height:100%;min-height:150px;"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import * as echarts from 'echarts'
const props = withDefaults(defineProps<{
actual: number
target: number
label?: string
unit?: string
}>(), { label: '', unit: '' })
const containerRef = ref<HTMLElement | null>(null)
let chart: echarts.ECharts | null = null
function render() {
if (!containerRef.value) return
if (!chart) chart = echarts.init(containerRef.value)
const pct = props.target > 0 ? Math.min(props.actual / props.target * 100, 100) : 0
const isGood = props.actual >= props.target
chart.setOption({
series: [{
type: 'gauge',
startAngle: 220,
endAngle: -40,
min: 0,
max: props.target * 1.3 || 1,
progress: { show: true, width: 18, itemStyle: { color: isGood ? '#52c41a' : '#ff4d4f' } },
axisLine: { lineStyle: { width: 18, color: [[0.3, '#ff4d4f'], [0.7, '#faad14'], [1, '#52c41a']] } },
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
pointer: { show: false },
anchor: { show: false },
title: { show: true, offsetCenter: [0, '30%'], fontSize: 12, color: '#999' },
detail: {
offsetCenter: [0, '-10%'],
fontSize: 22,
fontWeight: 700,
color: isGood ? '#52c41a' : '#ff4d4f',
formatter: (v: number) => `${props.actual?.toLocaleString()}${props.unit}`,
},
data: [{ value: props.actual, name: props.label }],
}],
grid: { top: 5, bottom: 5, left: 5, right: 5 },
}, true)
}
watch(() => [props.actual, props.target], render)
onMounted(render)
onUnmounted(() => { chart?.dispose(); chart = null })
</script>
@@ -0,0 +1,238 @@
<template>
<div class="profit-breakdown">
<!-- 第1行营收 -->
<div class="pb-row pb-row-top">
<div class="pb-card pb-card-revenue">
<div class="pb-label">营收</div>
<div class="pb-value">{{ fmt(revenue) }}</div>
</div>
<div class="pb-arrow"></div>
<div class="pb-card pb-card-expense">
<div class="pb-label">成本费用</div>
<div class="pb-value pb-neg">{{ fmt(cost) }}</div>
</div>
</div>
<!-- 等号 + 结果 -->
<div class="pb-eq-row">
<div class="pb-eq-line"></div>
<div class="pb-eq-sign">=</div>
<div class="pb-eq-line"></div>
</div>
<!-- 第2行毛利额 -->
<div class="pb-row">
<div class="pb-card pb-card-gross">
<div class="pb-label">毛利润</div>
<div class="pb-value" :class="gross >= 0 ? 'pb-pos' : 'pb-neg'">{{ fmt(gross) }}</div>
<div class="pb-sub">毛利率 {{ grossRate }}</div>
</div>
</div>
<!-- 费用拆解 -->
<div v-if="hasDetail" class="pb-detail">
<div class="pb-detail-title">费用构成</div>
<div class="pb-detail-grid">
<div v-for="item in detailItems" :key="item.key" class="pb-detail-item">
<span class="pb-dot" :style="{ background: item.color }"></span>
<span class="pb-detail-label">{{ item.label }}</span>
<span class="pb-detail-val" :class="item.val >= 0 ? 'pb-pos' : 'pb-neg'">{{ fmt(item.val) }}</span>
</div>
</div>
</div>
<!-- 分割线 -->
<div class="pb-eq-row">
<div class="pb-eq-line"></div>
<div class="pb-eq-sign">=</div>
<div class="pb-eq-line"></div>
</div>
<!-- 最终结果净利润 -->
<div class="pb-row">
<div class="pb-card pb-card-net" :class="profit >= 0 ? 'pb-card-pos' : 'pb-card-neg'">
<div class="pb-label">净利润</div>
<div class="pb-value pb-big" :class="profit >= 0 ? 'pb-pos' : 'pb-neg'">{{ fmt(profit) }}</div>
<div class="pb-sub">净利率 {{ netRate }}</div>
</div>
</div>
<!-- 同期对比 -->
<div v-if="prevRevenue" class="pb-compare">
<div class="pb-compare-item">
<span class="pb-compare-label">营收较上期</span>
<span :class="revChg >= 0 ? 'pb-pos' : 'pb-neg'">
{{ revChg >= 0 ? '↑' : '↓' }} {{ (Math.abs(revChg) * 100).toFixed(1) }}%
</span>
</div>
<div class="pb-compare-item">
<span class="pb-compare-label">净利润较上期</span>
<span :class="profitChg >= 0 ? 'pb-pos' : 'pb-neg'">
{{ profitChg >= 0 ? '↑' : '↓' }} {{ (Math.abs(profitChg) * 100).toFixed(1) }}%
</span>
</div>
<div class="pb-compare-item">
<span class="pb-compare-label">净利率较上期</span>
<span :class="rateChg >= 0 ? 'pb-pos' : 'pb-neg'">
{{ rateChg >= 0 ? '↑' : '↓' }} {{ (Math.abs(rateChg) * 100).toFixed(1) }}%
</span>
</div>
</div>
<!-- 小结 -->
<div class="pb-summary">
<div class="pb-bar-track">
<div class="pb-bar-label">每100元营收中</div>
<div class="pb-bar-wrap">
<div class="pb-bar-seg" :style="barStyle.revenue">营收 100</div>
</div>
<div class="pb-bar-wrap">
<div class="pb-bar-seg pb-bar-cost" :style="barStyle.cost">成本 {{ costRate }}</div>
</div>
<div class="pb-bar-wrap">
<div class="pb-bar-seg" :class="profit >= 0 ? 'pb-bar-profit' : 'pb-bar-loss'" :style="barStyle.profit">
利润 {{ netRate }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
revenue: number
cost: number // 成本费用合计
profit: number // 净利润
// 可选:费用明细
detail?: { key: string; label: string; val: number; color?: string }[]
// 可选:上期数据
prevRevenue?: number
prevProfit?: number
}>()
const hasDetail = computed(() => props.detail && props.detail.length > 0)
const gross = computed(() => props.revenue - props.cost)
const grossRate = computed(() => {
if (!props.revenue) return '—'
return ((props.revenue - props.cost) / props.revenue * 100).toFixed(1) + '%'
})
const netRate = computed(() => {
if (!props.revenue) return '—'
return (props.profit / props.revenue * 100).toFixed(1) + '%'
})
const costRate = computed(() => {
if (!props.revenue) return '—'
return (props.cost / props.revenue * 100).toFixed(1) + '%'
})
const revChg = computed(() => {
if (!props.prevRevenue) return 0
return (props.revenue - props.prevRevenue) / props.prevRevenue
})
const profitChg = computed(() => {
if (!props.prevProfit) return 0
return (props.profit - props.prevProfit) / Math.abs(props.prevProfit)
})
const rateChg = computed(() => {
if (!props.prevRevenue || !props.prevProfit) return 0
const cur = props.profit / props.revenue
const prev = props.prevProfit / props.prevRevenue
return (cur - prev) / Math.abs(prev || 0.01)
})
const detailItems = computed(() => {
const colors = ['#f56c6c', '#e6a23c', '#909399', '#f8983b', '#d48265']
return (props.detail || []).map((d, i) => ({
...d,
color: d.color || colors[i % colors.length],
}))
})
const barStyle = computed(() => {
const max = props.revenue || 1
const costPct = Math.min(props.cost / max * 100, 100)
const profitPct = Math.max(Math.min(props.profit / max * 100, 100), 0)
return {
revenue: { width: '100%' },
cost: { width: costPct + '%' },
profit: { width: profitPct + '%' },
}
})
function fmt(v: number | undefined | null): string {
if (v == null) return '—'
return '¥' + Math.abs(v).toLocaleString(undefined, { maximumFractionDigits: 0 })
}
</script>
<style scoped>
.profit-breakdown {
padding: 12px 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* 卡片行(横向) */
.pb-row { display: flex; align-items: stretch; gap: 12px; justify-content: center; flex-wrap: wrap; margin: 8px 0; }
.pb-row-top { }
.pb-card {
padding: 14px 22px;
border-radius: 10px;
min-width: 140px;
text-align: center;
background: #f8f9fa;
border: 1px solid #e8eaed;
transition: all 0.2s;
}
.pb-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.pb-card-revenue { background: #e6f7ff; border-color: #91d5ff; }
.pb-card-expense { background: #fff2f0; border-color: #ffccc7; }
.pb-card-gross { background: #f6ffed; border-color: #b7eb8f; min-width: 180px; }
.pb-card-net { min-width: 180px; }
.pb-card-pos { background: #f0f5ff; border-color: #adc6ff; }
.pb-card-neg { background: #fff1f0; border-color: #ffa39e; }
.pb-arrow { display: flex; align-items: center; font-size: 24px; color: #999; font-weight: 300; }
.pb-label { font-size: 12px; color: #666; margin-bottom: 4px; }
.pb-value { font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; }
.pb-value.pb-big { font-size: 22px; }
.pb-pos { color: #389e0d; }
.pb-neg { color: #cf1322; }
.pb-sub { font-size: 11px; color: #999; margin-top: 2px; }
/* 等号行 */
.pb-eq-row { display: flex; align-items: center; gap: 10px; margin: 4px 0; justify-content: center; }
.pb-eq-line { flex: 0 1 80px; height: 1px; background: #e0e0e0; }
.pb-eq-sign { font-size: 18px; color: #999; font-weight: 300; }
/* 费用明细 */
.pb-detail { margin: 12px auto; max-width: 400px; background: #fafafa; border-radius: 8px; padding: 12px 16px; border: 1px solid #f0f0f0; }
.pb-detail-title { font-size: 12px; color: #999; margin-bottom: 8px; }
.pb-detail-grid { display: flex; flex-direction: column; gap: 6px; }
.pb-detail-item { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.pb-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.pb-detail-label { flex: 1; color: #555; }
.pb-detail-val { font-weight: 600; font-variant-numeric: tabular-nums; min-width: 80px; text-align: right; }
/* 同期对比 */
.pb-compare { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; margin: 16px 0 0; }
.pb-compare-item { font-size: 12px; color: #666; background: #fafafa; padding: 6px 14px; border-radius: 20px; border: 1px solid #f0f0f0; }
.pb-compare-label { margin-right: 6px; }
/* 底部 100元拆解条 */
.pb-summary { margin: 16px auto 0; max-width: 380px; }
.pb-bar-track { }
.pb-bar-label { font-size: 11px; color: #999; margin-bottom: 6px; }
.pb-bar-wrap { height: 22px; margin-bottom: 3px; border-radius: 4px; overflow: hidden; background: #f5f5f5; }
.pb-bar-seg { height: 100%; border-radius: 4px; font-size: 11px; color: #fff; display: flex; align-items: center; padding-left: 8px; font-weight: 600; }
.pb-bar-cost { background: #ff7875; }
.pb-bar-profit { background: #69b1ff; }
.pb-bar-loss { background: #ff4d4f; }
/* 杜邦拆分树 */</style>
@@ -0,0 +1,139 @@
<template>
<div :ref="el => containerRef = el" style="width:100%;height:100%;min-height:200px;"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import * as echarts from 'echarts'
const props = withDefaults(defineProps<{
/** 数据:{ name, value }[] */
data: { name: string; value: number }[]
totalLabel?: string
totalUnit?: string
}>(), {
totalLabel: '总计',
totalUnit: '',
})
const containerRef = ref<HTMLElement | null>(null)
let chart: echarts.ECharts | null = null
function render() {
if (!containerRef.value) return
if (!chart) chart = echarts.init(containerRef.value)
const total = props.data.reduce((s, d) => s + Math.abs(d.value), 0)
// 构建瀑布图数据
let accumulate = 0
const seriesData = props.data.map((d, i) => {
const isLast = i === props.data.length - 1
const isPos = d.value >= 0
const item: any = {
value: isPos ? d.value : -d.value,
itemStyle: {
color: isPos ? '#52c41a' : '#ff4d4f',
borderColor: isPos ? '#52c41a' : '#ff4d4f',
},
}
if (!isLast) {
if (isPos) {
// 正数:从accumulate开始
item.offset = accumulate
accumulate += d.value
} else {
// 负数:向下
accumulate += d.value
item.offset = Math.max(0, accumulate)
}
} else {
// 最后一项是总计
item.offset = 0
item.value = total
item.itemStyle = { color: '#409eff' }
}
return item
})
// 简化瀑布图:用堆积柱状图模拟
const posData = props.data.map(d => d.value > 0 ? d.value : 0)
const negData = props.data.map(d => d.value < 0 ? -d.value : 0)
chart.setOption({
tooltip: {
trigger: 'axis',
formatter: (params: any[]) => {
const idx = params[0]?.dataIndex
const d = props.data[idx]
if (!d) return ''
return `${d.name}: <b>${d.value?.toLocaleString()}${props.totalUnit}</b>`
},
},
grid: { left: 60, right: 30, top: 20, bottom: 40 },
xAxis: {
type: 'category',
data: [...props.data.map(d => d.name), props.totalLabel],
axisLabel: { rotate: 0, fontSize: 11, color: '#666', interval: 0 },
axisLine: { show: false },
axisTick: { show: false },
},
yAxis: {
type: 'value',
name: props.totalUnit,
splitLine: { lineStyle: { color: '#f0f0f0', type: 'dashed' } },
axisLabel: { fontSize: 10, color: '#999' },
},
series: [
{
type: 'bar',
stack: 'total',
data: posData,
barWidth: 24,
itemStyle: { color: '#52c41a', borderRadius: [4, 4, 0, 0] },
label: {
show: true,
position: 'top',
formatter: (p: any) => {
const v = props.data[p.dataIndex]?.value
return v && v > 0 ? v?.toLocaleString() : ''
},
fontSize: 10, color: '#52c41a',
},
},
{
type: 'bar',
stack: 'total',
data: negData,
barWidth: 24,
itemStyle: { color: '#ff4d4f', borderRadius: [4, 4, 0, 0] },
label: {
show: true,
position: 'bottom',
formatter: (p: any) => {
const v = props.data[p.dataIndex]?.value
return v && v < 0 ? v?.toLocaleString() : ''
},
fontSize: 10, color: '#ff4d4f',
},
},
{
type: 'bar',
stack: 'total',
data: props.data.map(() => null).concat(total),
barWidth: 24,
itemStyle: { color: '#409eff', borderRadius: [4, 4, 0, 0] },
label: {
show: true,
position: 'top',
formatter: total?.toLocaleString(),
fontSize: 13, fontWeight: 700, color: '#409eff',
},
},
],
}, true)
}
watch(() => props.data, render, { deep: true })
onMounted(render)
onUnmounted(() => { chart?.dispose(); chart = null })
</script>
@@ -0,0 +1,123 @@
<template>
<svg class="connection-svg" ref="svgRef">
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#409eff" />
</marker>
<marker id="arrowhead-warn" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#e6a23c" />
</marker>
</defs>
<!-- 固定跨层箭头从下层泳道到上层泳道因果连线 -->
<path
v-for="(arrow, idx) in fixedLayerArrows"
:key="'fixed-' + idx"
:d="arrow.path"
fill="none"
stroke="#c0c4cc"
stroke-width="1.5"
stroke-dasharray="6,4"
marker-end="url(#arrowhead)"
class="fixed-layer-arrow"
>
<title>{{ arrow.from }} {{ arrow.to }}</title>
</path>
<!-- 用户自定义连线 -->
<line
v-for="(line, idx) in connectionLines"
:key="'conn-' + idx"
:x1="line.x1" :y1="line.y1" :x2="line.x2" :y2="line.y2"
:class="['conn-line', { 'conn-selected': selectedConnIdx === idx }]"
:style="{ '--from-color': line.fromColor, '--to-color': line.toColor }"
marker-end="url(#arrowhead)"
:stroke="selectedConnIdx === idx ? '#f56c6c' : '#409eff'"
:stroke-width="selectedConnIdx === idx ? 3 : 2"
@click.stop="$emit('select-connection', idx)"
@mouseenter="hoverConnIdx = idx"
@mouseleave="hoverConnIdx = null"
/>
<!-- 连线提示浮层 -->
<g v-if="hoverConnIdx !== null && connectionLines[hoverConnIdx] && hoverConnIdx !== selectedConnIdx">
<rect
:x="connectionLines[hoverConnIdx].mx - 60"
:y="connectionLines[hoverConnIdx].my - 36"
width="120" height="22" rx="4" fill="rgba(0,0,0,0.65)"
/>
<text
:x="connectionLines[hoverConnIdx].mx"
:y="connectionLines[hoverConnIdx].my - 21"
text-anchor="middle" fill="#fff" font-size="11"
>点击选中连线</text>
</g>
<!-- 删除按钮选中连线时 -->
<g v-if="selectedConnIdx !== null && connectionLines[selectedConnIdx]">
<rect
:x="connectionLines[selectedConnIdx].mx - 18"
:y="connectionLines[selectedConnIdx].my - 24"
width="88" height="24" rx="12" fill="#f56c6c" class="del-btn-bg"
@click.stop="$emit('delete-connection', selectedConnIdx)"
/>
<text
:x="connectionLines[selectedConnIdx].mx"
:y="connectionLines[selectedConnIdx].my - 8"
text-anchor="middle" fill="#fff" font-size="13" font-weight="bold"
class="del-btn-text"
@click.stop="$emit('delete-connection', selectedConnIdx)"
> 删除连线</text>
</g>
<!-- 绘制中的临时线 -->
<line v-if="tempLine"
:x1="tempLine.x1" :y1="tempLine.y1"
:x2="tempLine.x2" :y2="tempLine.y2"
stroke="#e6a23c" stroke-width="2" stroke-dasharray="6,3"
marker-end="url(#arrowhead-warn)"
/>
</svg>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
const props = defineProps<{
connectionLines: any[]
selectedConnIdx: number | null
tempLine: any
fixedLayerArrows: { from: string; to: string; path: string }[]
}>()
const emit = defineEmits<{
(e: 'select-connection', idx: number): void
(e: 'delete-connection', idx: number): void
}>()
const svgRef = ref<SVGSVGElement | null>(null)
const hoverConnIdx = ref<number | null>(null)
defineExpose({ svgRef })
</script>
<style scoped>
.connection-svg {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
z-index: 5; pointer-events: none;
overflow: visible;
}
.conn-line {
cursor: pointer; transition: stroke .15s, stroke-width .15s;
pointer-events: stroke;
}
.conn-line:hover { stroke: #e6a23c !important; stroke-width: 4 !important; cursor: pointer; }
.conn-selected { stroke: #f56c6c !important; stroke-width: 3; }
.del-btn-bg { cursor: pointer; pointer-events: all; }
.del-btn-bg:hover { fill: #e74c3c !important; }
.del-btn-text { cursor: pointer; pointer-events: all; user-select: none; }
.fixed-layer-arrow {
pointer-events: none;
opacity: 0.5;
}
</style>
@@ -0,0 +1,259 @@
<template>
<div
class="map-node"
:class="{
'linking-source': isLinkingSource,
'linking-target': isLinkingTarget,
'drag-over-connect': isDragConnectTarget,
'drag-over': isDragOver,
'node-level-red': level === 'red',
'node-level-yellow': level === 'yellow',
'node-level-green': level === 'green',
}"
:draggable="draggable"
@dragstart="onDragStart"
@dragover.prevent="onDragOver"
@dragleave="onDragLeave"
@drop.prevent="onDrop"
@click="onClick"
>
<div class="node-title">
<span class="node-level-dot" :class="'dot-' + (level || 'gray')"></span>
<el-icon v-if="objIcon" style="margin-right:4px;"><component :is="objIcon" /></el-icon>
{{ objective.name }}
</div>
<div v-if="objective.description" class="node-desc">{{ objective.description }}</div>
<div class="node-kpis">
<el-tag
v-for="kpi in (objective.kpis||[])" :key="kpi"
size="small" style="margin:2px;cursor:pointer;"
:title="kpi"
@click.stop="$emit('kpi-click', kpi)"
>{{ getKpiName(kpi) }}</el-tag>
</div>
<!-- KPI达成率迷你进度条 -->
<div v-if="progressData" class="node-progress-bar">
<div class="node-progress-fill"
:style="{
width: Math.max(progressData.ratio, 4) + '%',
background: progressColor(progressData.level)
}"
></div>
<span class="node-progress-text">
{{ progressData.ratio > 0 ? progressData.ratio + '%' : '—' }}
</span>
</div>
<!-- KPI实际值快照 -->
<div v-if="progressData?.kpiList?.length" class="node-kpi-snapshots">
<div
v-for="item in progressData.kpiList.slice(0, 2)" :key="item.code"
class="kpi-snapshot-row"
:class="'snap-' + getKpiLevel(item)"
>
<span class="snap-name" :title="item.name">{{ item.name }}</span>
<span class="snap-value">
{{ item.actual != null ? fmtKpiVal(item.actual) : '—' }}
<span class="snap-target">/ {{ item.target != null ? fmtKpiVal(item.target) : '—' }}</span>
</span>
</div>
<div v-if="progressData.kpiList.length > 2" class="kpi-snapshot-more">
+{{ progressData.kpiList.length - 2 }} 更多
</div>
</div>
<div v-if="progressData?.kpiList?.length" class="node-click-hint">点击查看KPI详情</div>
<!-- 行动方案摘要 -->
<div v-if="progressData?.planSummary" class="node-plan-summary" @click.stop="$emit('plan-click')">
<span class="plan-summary-icon">📋</span>
<span class="plan-summary-text">{{ progressData.planSummary.total }} 项行动方案</span>
<span class="plan-summary-badges">
<span v-if="progressData.planSummary.overdue > 0" class="ps-badge ps-overdue">{{ progressData.planSummary.overdue }}逾期</span>
<span v-if="progressData.planSummary.in_progress > 0" class="ps-badge ps-in-progress">{{ progressData.planSummary.in_progress }}进行中</span>
<span v-if="progressData.planSummary.completed === progressData.planSummary.total && progressData.planSummary.total > 0" class="ps-badge ps-done">全部完成 </span>
</span>
</div>
<!-- 右上角连线入口 -->
<span class="link-btn-corner"
@mousedown.prevent="$emit('link-drag-start', $event)"
@click.stop="$emit('link-click')"
title="拖拽到另一个目标创建因果连线"></span>
<div class="node-actions">
<el-button text size="small" @click.stop="$emit('edit')" title="编辑目标"></el-button>
<el-button text size="small" @click.stop="$emit('delete')" title="删除目标" style="color:#f56c6c;">🗑</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
objective: any
nodeKey: string
level?: string
progressData?: any
index: number
draggable?: boolean
isLinkingSource?: boolean
isLinkingTarget?: boolean
isDragConnectTarget?: boolean
isDragOver?: boolean
iconMap?: Record<string, string>
kpiNameMap?: Record<string, string>
allKpis?: any[]
}>()
const emit = defineEmits<{
(e: 'drag-start', payload: { index: number; event: DragEvent }): void
(e: 'drag-over'): void
(e: 'drag-leave'): void
(e: 'drop'): void
(e: 'click'): void
(e: 'edit'): void
(e: 'delete'): void
(e: 'link-click'): void
(e: 'link-drag-start', event: MouseEvent): void
(e: 'kpi-click', kpiCode: string): void
(e: 'plan-click'): void
}>()
const objIcon = computed(() => {
if (props.objective.icon && props.iconMap) {
return (props.iconMap as Record<string, string>)[props.objective.icon]
}
return null
})
function onDragStart(e: DragEvent) {
emit('drag-start', { index: props.index, event: e })
}
function onDragOver() { emit('drag-over') }
function onDragLeave() { emit('drag-leave') }
function onDrop() { emit('drop') }
function onClick() { emit('click') }
function getKpiName(code: string): string {
if (props.kpiNameMap && props.kpiNameMap[code]) return props.kpiNameMap[code]
const k = props.allKpis?.find((k: any) => k.kpi_code === code)
return k?.kpi_name || code
}
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 progressColor(level: string): string {
if (level === 'green') return '#67c23a'
if (level === 'yellow') return '#e6a23c'
if (level === 'red') return '#f56c6c'
return '#dcdfe6'
}
</script>
<style scoped>
.map-node {
background: #fff; border: 1px solid #e0e0e0; border-radius: 8px;
padding: 10px; margin-bottom: 8px; cursor: pointer;
transition: all 0.2s; position: relative;
}
.map-node:hover { box-shadow: 0 2px 10px rgba(0,0,0,0.08); }
.map-node.linking-source {
border-color: #e6a23c; box-shadow: 0 0 0 2px rgba(230,162,60,0.3);
}
.map-node.linking-target:hover {
border-color: #67c23a; box-shadow: 0 0 0 2px rgba(103,194,58,0.3);
}
.map-node.drag-over-connect {
border-color: #67c23a !important; box-shadow: 0 0 0 3px rgba(103,194,58,0.5) !important;
transform: scale(1.03);
}
.map-node.drag-over {
border-color: #409eff !important; box-shadow: 0 0 0 2px rgba(64,158,255,0.3) !important;
transform: scale(1.02);
}
.node-level-red { border-left: 3px solid #f56c6c !important; background: #fef0f0; }
.node-level-yellow { border-left: 3px solid #e6a23c !important; background: #fdf6ec; }
.node-level-green { border-left: 3px solid #67c23a !important; background: #f0f9eb; }
.node-title { font-size: 13px; font-weight: 500; margin-bottom: 4px; display: flex; align-items: center; }
.node-desc { font-size: 12px; color: #888; margin-bottom: 4px; }
.node-kpis { display: flex; flex-wrap: wrap; gap: 2px; }
.node-kpi-snapshots { margin-top: 4px; display: flex; flex-direction: column; gap: 2px; }
.kpi-snapshot-row {
display: flex; justify-content: space-between; align-items: center;
font-size: 11px; padding: 1px 4px; border-radius: 3px;
}
.snap-green { background: #f0f9eb; }
.snap-yellow { background: #fdf6ec; }
.snap-red { background: #fef0f0; }
.snap-gray { background: #f5f5f5; }
.snap-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100px; color: #666; }
.snap-value { font-weight: 600; color: #333; white-space: nowrap; }
.snap-target { font-weight: 400; color: #999; font-size: 10px; }
.kpi-snapshot-more { font-size: 10px; color: #409eff; text-align: center; cursor: pointer; padding: 1px; }
.node-click-hint { font-size: 10px; color: #bbb; text-align: center; margin-top: 2px; }
.node-plan-summary {
display: flex; align-items: center; gap: 6px;
margin-top: 4px; padding: 4px 6px;
background: #f8f9fa; border-radius: 4px;
font-size: 11px; cursor: pointer; transition: background .15s;
}
.node-plan-summary:hover { background: #ecf5ff; }
.plan-summary-icon { font-size: 12px; }
.plan-summary-text { color: #409eff; font-weight: 500; flex-shrink: 0; }
.plan-summary-badges { display: flex; gap: 4px; margin-left: auto; }
.ps-badge { font-size: 10px; padding: 1px 5px; border-radius: 3px; font-weight: 500; }
.ps-overdue { background: #fef0f0; color: #f56c6c; }
.ps-in-progress { background: #fdf6ec; color: #e6a23c; }
.ps-done { background: #f0f9eb; color: #67c23a; }
.node-progress-bar {
display: flex; align-items: center; gap: 6px; margin-top: 4px;
height: 10px; position: relative;
}
.node-progress-fill {
height: 6px; border-radius: 3px; transition: width 0.4s ease;
min-width: 4px;
}
.node-progress-text {
font-size: 10px; font-weight: 600; color: #666;
font-variant-numeric: tabular-nums; line-height: 1;
}
.node-actions { display: flex; align-items: center; gap: 2px; margin-top: 6px; justify-content: flex-end; position: relative; z-index: 20; }
.node-actions .el-button { padding: 2px 4px !important; font-size: 12px; min-height: auto; }
.link-btn-corner {
position: absolute; top: 4px; right: 6px;
width: 22px; height: 22px; display: flex; align-items: center; justify-content: center;
background: #ecf5ff; color: #409eff; border-radius: 50%;
font-size: 14px; font-weight: bold; cursor: pointer; opacity: 0;
transition: opacity .2s, transform .2s;
}
.map-node:hover .link-btn-corner { opacity: 1; }
.link-btn-corner:hover { transform: scale(1.2); background: #409eff; color: #fff; }
.node-level-dot {
display: inline-block; width: 10px; height: 10px; border-radius: 50%;
margin-right: 4px; flex-shrink: 0;
}
.dot-green { background: #67c23a; }
.dot-yellow { background: #e6a23c; }
.dot-red { background: #f56c6c; }
.dot-gray { background: #dcdfe6; }
</style>
@@ -0,0 +1,231 @@
<template>
<div v-if="visible" class="mc-dialog-overlay" @click.self="emit('update:visible', false)">
<div class="mc-dialog-box">
<div class="mc-dialog-header">
<span>{{ isEditing ? '编辑目标' : '添加目标' }}</span>
<button class="mc-dialog-close" @click="emit('update:visible', false)">×</button>
</div>
<div class="mc-dialog-body">
<!-- 目标名称必填 -->
<div class="mc-form-item">
<label>目标名称 <span class="mc-required">*</span></label>
<input v-model="localForm.name" class="mc-input" placeholder="请输入目标名称" ref="nameInputRef" />
</div>
<!-- 描述 -->
<div class="mc-form-item">
<label>描述</label>
<textarea v-model="localForm.description" class="mc-input mc-textarea" rows="2" placeholder="目标描述(可选)"></textarea>
</div>
<!-- 目标值 -->
<div class="mc-form-item">
<label>目标值</label>
<input v-model.number="localForm.targetValue" class="mc-input" type="number" placeholder="预期达成值" />
</div>
<!-- 当前值 -->
<div class="mc-form-item">
<label>当前值</label>
<input v-model.number="localForm.currentValue" class="mc-input" type="number" placeholder="当前实际值" />
</div>
<!-- 单位 -->
<div class="mc-form-row">
<div class="mc-form-item mc-form-item-flex">
<label>单位</label>
<input v-model="localForm.unit" class="mc-input" placeholder="如:万元、%" />
</div>
<div class="mc-form-item mc-form-item-flex">
<label>责任人</label>
<input v-model="localForm.owner" class="mc-input" placeholder="责任人姓名" />
</div>
</div>
<!-- 领先/滞后指标 -->
<div class="mc-form-item">
<label>指标类型</label>
<div class="mc-leading-switch">
<label class="mc-radio-label" :class="{ active: !localForm.isLeading }">
<input type="radio" v-model="localForm.isLeading" :value="false" />
滞后指标结果
</label>
<label class="mc-radio-label" :class="{ active: localForm.isLeading }">
<input type="radio" v-model="localForm.isLeading" :value="true" />
领先指标驱动
</label>
</div>
</div>
</div>
<div class="mc-dialog-footer">
<button class="mc-btn" @click="emit('update:visible', false)">取消</button>
<button class="mc-btn mc-btn-primary" @click="onSave">保存</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps<{
visible: boolean
nodeData: any | null
layerKey: string
}>()
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void
(e: 'save', data: {
name: string
description: string
targetValue: number | null
currentValue: number | null
unit: string
owner: string
isLeading: boolean
layer: string
icon?: string
kpis?: string[]
}): void
}>()
const nameInputRef = ref<HTMLElement | null>(null)
const defaultForm = {
name: '',
description: '',
targetValue: null as number | null,
currentValue: null as number | null,
unit: '%',
owner: '',
isLeading: false,
icon: 'target',
kpis: [] as string[],
}
const localForm = reactive({ ...defaultForm })
const isEditing = ref(false)
watch(() => props.visible, (v) => {
if (v) {
if (props.nodeData) {
// 编辑模式
isEditing.value = true
localForm.name = props.nodeData.name || ''
localForm.description = props.nodeData.description || ''
localForm.targetValue = props.nodeData.targetValue ?? null
localForm.currentValue = props.nodeData.currentValue ?? null
localForm.unit = props.nodeData.unit || '%'
localForm.owner = props.nodeData.owner || ''
localForm.isLeading = props.nodeData.isLeading ?? false
localForm.icon = props.nodeData.icon || 'target'
localForm.kpis = props.nodeData.kpis ? [...props.nodeData.kpis] : []
} else {
// 新增模式 - 根据层智能设置默认单位
isEditing.value = false
Object.assign(localForm, { ...defaultForm })
const layerDefaults: Record<string, { unit: string; isLeading: boolean }> = {
financial: { unit: '万元', isLeading: false },
customer: { unit: '%', isLeading: true },
process: { unit: '%', isLeading: true },
learning: { unit: '%', isLeading: true },
}
const defaults = layerDefaults[props.layerKey]
if (defaults) {
localForm.unit = defaults.unit
localForm.isLeading = defaults.isLeading
}
}
nextTick(() => nameInputRef.value?.focus())
}
})
function onSave() {
if (!localForm.name?.trim()) {
ElMessage.warning('请输入目标名称')
nextTick(() => nameInputRef.value?.focus())
return
}
emit('save', {
name: localForm.name.trim(),
description: localForm.description?.trim() || '',
targetValue: localForm.targetValue ?? null,
currentValue: localForm.currentValue ?? null,
unit: localForm.unit?.trim() || '%',
owner: localForm.owner?.trim() || '',
isLeading: localForm.isLeading ?? false,
layer: props.layerKey,
icon: localForm.icon || 'target',
kpis: [...localForm.kpis],
})
}
</script>
<style scoped>
.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-form-item { margin-bottom:16px; }
.mc-form-item label {
display:block; font-size:13px; color:#666; margin-bottom:6px; font-weight:500;
}
.mc-form-row {
display:flex; gap:12px;
}
.mc-form-item-flex {
flex:1;
}
.mc-required { color:#f56c6c; margin-left:2px; }
.mc-input {
width:100%; padding:8px 12px; border:1px solid #dcdfe6;
border-radius:6px; font-size:14px; outline:none; box-sizing:border-box;
transition: border-color 0.2s;
}
.mc-input:focus { border-color:#409eff; }
.mc-input[type="number"] { font-variant-numeric: tabular-nums; }
.mc-textarea { resize:vertical; min-height:50px; font-family:inherit; }
.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:hover { opacity:0.85; }
.mc-leading-switch {
display:flex; gap:12px;
}
.mc-radio-label {
display:flex; align-items:center; gap:6px;
padding:8px 14px; border:2px solid #e8e8e8; border-radius:8px;
cursor:pointer; font-size:13px; color:#666; transition:all .15s;
flex:1; justify-content:center;
}
.mc-radio-label:hover { border-color:#409eff; }
.mc-radio-label.active {
border-color:#409eff; background:#ecf5ff; color:#409eff; font-weight:500;
}
.mc-radio-label input { display:none; }
</style>
@@ -0,0 +1,221 @@
<template>
<div v-if="visible" class="mc-dialog-overlay" @click.self="$emit('close')">
<div class="mc-dialog-box">
<div class="mc-dialog-header">
<span>{{ isEditing ? '编辑目标' : '添加目标' }}</span>
<button class="mc-dialog-close" @click="$emit('close')">×</button>
</div>
<div class="mc-dialog-body">
<div class="mc-form-item">
<label>目标名称 <span class="mc-required">*</span></label>
<input v-model="localForm.name" class="mc-input" placeholder="请输入目标名称" ref="nameInputRef" />
</div>
<div class="mc-form-item">
<label>描述</label>
<textarea v-model="localForm.description" class="mc-input mc-textarea" rows="3" placeholder="目标描述(可选)"></textarea>
</div>
<div class="mc-form-item">
<label>图标</label>
<div class="mc-icon-picker">
<button
v-for="(ico, key) in iconOptions" :key="key"
class="mc-icon-btn"
:class="{ active: localForm.icon === key }"
@click="localForm.icon = key"
:title="ico"
>{{ iconEmoji(key) }}</button>
</div>
</div>
<div class="mc-form-item">
<label>关联KPI <span class="mc-hint">(最多3个)</span></label>
<div class="mc-kpi-select-wrap">
<div class="mc-kpi-tags" v-if="localForm.kpis.length > 0">
<span class="mc-kpi-tag" v-for="(code, i) in localForm.kpis" :key="code">
{{ getKpiName(code) }}
<button class="mc-tag-remove" @click="localForm.kpis.splice(i, 1)">×</button>
</span>
</div>
<div class="mc-kpi-add-row" v-if="localForm.kpis.length < 3 && allKpis.length > 0">
<select class="mc-select mc-select-sm" @change="addKpi($event)" style="flex:1;">
<option value="">+ 选择KPI{{ dimLabel }}</option>
<option v-for="k in availableKpis" :key="k.id" :value="k.kpi_code">{{ k.kpi_name }}</option>
<option value="__new__">+ 新建KPI...</option>
</select>
</div>
<div v-if="allKpis.length === 0" style="color:#999;font-size:12px;">
暂无该维度KPI,
<el-button text size="small" type="primary" @click="onNewKpi">点击新建</el-button>
</div>
</div>
</div>
</div>
<div class="mc-dialog-footer">
<button class="mc-btn" @click="$emit('close')">取消</button>
<button class="mc-btn mc-btn-primary" @click="onSave">保存</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
const props = defineProps<{
visible: boolean
form: { name: string; description: string; icon: string; kpis: string[] }
dimKey: string
dimLabel: string
isEditing: boolean
allKpis: any[]
kpiNameMap: Record<string, string>
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'save', form: { name: string; description: string; icon: string; kpis: string[] }): void
(e: 'new-kpi'): void
}>()
const nameInputRef = ref<HTMLElement | null>(null)
const localForm = reactive({ name: '', description: '', icon: 'target', kpis: [] as string[] })
watch(() => props.visible, (v) => {
if (v) {
localForm.name = props.form.name
localForm.description = props.form.description
localForm.icon = props.form.icon || 'target'
localForm.kpis = [...(props.form.kpis || [])]
nextTick(() => nameInputRef.value?.focus())
}
})
const iconOptions: Record<string, string> = {
target: '目标', star: '星级', rocket: '火箭', chart: '图表', team: '团队',
light: '灯泡', shield: '盾牌', gear: '齿轮', handshake: '握手', medal: '奖牌',
}
function iconEmoji(key: string): string {
const map: Record<string, string> = {
target: '🎯', star: '⭐', rocket: '🚀', chart: '📊', team: '👥',
light: '💡', shield: '🛡️', gear: '⚙️', handshake: '🤝', medal: '🏅',
}
return map[key] || '📌'
}
const availableKpis = computed(() => {
return props.allKpis.filter((k: any) => {
if (k.dimension !== props.dimKey) return false
if (localForm.kpis.includes(k.kpi_code)) return false
return true
})
})
function getKpiName(code: string): string {
return props.kpiNameMap[code] || code
}
function addKpi(e: any) {
const val = e.target.value
if (val === '__new__') {
e.target.value = ''
emit('new-kpi')
return
}
if (val && !localForm.kpis.includes(val) && localForm.kpis.length < 3) {
localForm.kpis.push(val)
}
e.target.value = ''
}
function onNewKpi() {
emit('new-kpi')
}
function onSave() {
if (!localForm.name?.trim()) {
ElMessage.warning('请输入目标名称')
nextTick(() => nameInputRef.value?.focus())
return
}
emit('save', {
name: localForm.name.trim(),
description: localForm.description?.trim() || '',
icon: localForm.icon || 'target',
kpis: [...localForm.kpis],
})
}
</script>
<style scoped>
.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-form-item { margin-bottom:16px; }
.mc-form-item label {
display:block; font-size:13px; color:#666; margin-bottom:6px; font-weight:500;
}
.mc-required { color:#f56c6c; margin-left:2px; }
.mc-hint { font-weight:400; color:#999; font-size:12px; }
.mc-input {
width:100%; padding:8px 12px; border:1px solid #dcdfe6;
border-radius:6px; font-size:14px; outline:none; box-sizing:border-box;
}
.mc-input:focus { border-color:#409eff; }
.mc-textarea { resize:vertical; min-height:60px; font-family:inherit; }
.mc-select {
width:100%; padding:8px 12px; border:1px solid #dcdfe6;
border-radius:6px; font-size:14px; outline:none; background:#fff; box-sizing:border-box;
}
.mc-select:focus { border-color:#409eff; }
.mc-select-sm { font-size:13px; padding:6px 10px; }
.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:hover { opacity:0.85; }
.mc-icon-picker {
display:flex; flex-wrap:wrap; gap:6px;
}
.mc-icon-btn {
width:40px; height:40px; display:flex; align-items:center; justify-content:center;
font-size:20px; border:2px solid #e8e8e8; border-radius:8px;
background:#fff; cursor:pointer; transition:all .15s;
}
.mc-icon-btn:hover { border-color:#409eff; transform:scale(1.1); }
.mc-icon-btn.active { border-color:#409eff; background:#ecf5ff; box-shadow:0 0 0 2px rgba(64,158,255,0.2); }
.mc-kpi-select-wrap { display:flex; flex-direction:column; gap:6px; }
.mc-kpi-tags { display:flex; flex-wrap:wrap; gap:6px; }
.mc-kpi-tag {
display:inline-flex; align-items:center; gap:4px;
padding:4px 10px; background:#ecf5ff; color:#409eff;
border-radius:4px; font-size:12px;
}
.mc-tag-remove {
background:none; border:none; color:#a0cfff; cursor:pointer;
font-size:14px; padding:0; line-height:1;
}
.mc-tag-remove:hover { color:#f56c6c; }
</style>
@@ -0,0 +1,234 @@
<template>
<div class="strategy-layer" :class="`layer-${layerKey}`" :style="{ borderColor: color }">
<div class="layer-header" :style="{ background: color }">
<span class="layer-icon">{{ icon }}</span>
<span class="layer-name">{{ label }}</span>
<span class="layer-objective-count">{{ objectives.length }} 个目标</span>
<el-dropdown trigger="click" @command="onLayerAction">
<el-button class="layer-more-btn" size="small" text>
<el-icon><MoreFilled /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-item command="rename">重命名</el-dropdown-item>
<el-dropdown-item command="color">更换颜色</el-dropdown-item>
<el-dropdown-item command="icon">更换图标</el-dropdown-item>
</template>
</el-dropdown>
</div>
<div class="layer-body" ref="bodyRef">
<!-- vuedraggable: 每层独立group 禁止跨层拖拽 -->
<draggable
v-if="objectives.length > 0"
:list="objectives"
:group="layerKey"
item-key="name"
:animation="150"
@end="onDragEnd"
class="layer-draggable"
>
<template #item="{ element, index }">
<div class="layer-node-wrapper">
<MapNode
:key="`${layerKey}-${index}`"
:ref="el => setNodeRef(`${layerKey}-${index}`, el)"
:objective="element"
:node-key="`${layerKey}-${index}`"
:index="index"
:level="getNodeLevel(`${layerKey}-${index}`)"
:progress-data="getNodeProgress(`${layerKey}-${index}`)"
:draggable="false"
:is-linking-source="linkingFromKey === `${layerKey}-${index}`"
:is-linking-target="!!linkingFromKey && linkingFromKey !== `${layerKey}-${index}`"
:is-drag-connect-target="dragConnectTarget === `${layerKey}-${index}`"
:icon-map="iconMap"
:kpi-name-map="kpiNameMap"
:all-kpis="allKpis"
@click="onNodeClick(layerKey, index, element)"
@edit="$emit('edit-objective', { dimKey: layerKey, idx: index, obj: element })"
@delete="$emit('delete-objective', { dimKey: layerKey, idx: index })"
@link-click="$emit('start-link', { key: `${layerKey}-${index}`, obj: element })"
@link-drag-start="(e: MouseEvent) => $emit('link-drag-start', e, layerKey, index, element)"
@kpi-click="(code: string) => $emit('kpi-click', code)"
@plan-click="$emit('show-plan-list', { dimKey: layerKey, idx: index, obj: element })"
/>
</div>
</template>
</draggable>
<!-- 空状态占位提示 -->
<div v-if="objectives.length === 0" class="layer-empty">
<el-empty description="暂无目标" :image-size="60">
<el-button text type="primary" size="small" @click="$emit('add-objective', layerKey)">
点击 + 添加目标
</el-button>
</el-empty>
</div>
<!-- 底部添加按钮 -->
<el-button text type="primary" size="small" class="layer-add-btn" @click="$emit('add-objective', layerKey)">
+ 添加目标
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { MoreFilled } from '@element-plus/icons-vue'
import draggable from 'vuedraggable'
import MapNode from './MapNode.vue'
const props = defineProps<{
layerKey: string
label: string
icon: string
color: string
objectives: any[]
linkingFromKey?: string | null
dragConnectTarget?: string | null
iconMap?: Record<string, string>
kpiNameMap?: Record<string, string>
allKpis?: any[]
getNodeLevel: (key: string) => string
getNodeProgress: (key: string) => any
}>()
const emit = defineEmits<{
(e: 'add-objective', dimKey: string): void
(e: 'edit-objective', payload: { dimKey: string; idx: number; obj: any }): void
(e: 'delete-objective', payload: { dimKey: string; idx: number }): void
(e: 'start-link', payload: { key: string; obj: any }): void
(e: 'link-drag-start', event: MouseEvent, dimKey: string, oi: number, obj: any): void
(e: 'kpi-click', code: string): void
(e: 'show-plan-list', payload: { dimKey: string; idx: number; obj: any }): void
(e: 'node-click', dimKey: string, oi: number, obj: any): void
(e: 'reorder', dimKey: string): void
(e: 'layer-action', cmd: string): void
(e: 'update:label', val: string): void
(e: 'update:color', val: string): void
(e: 'update:icon', val: string): void
}>()
const bodyRef = ref<HTMLElement | null>(null)
// Node DOM refs for line calculation (reactive so parent can aggregate)
const nodeRefs = reactive<Record<string, any>>({})
function setNodeRef(key: string, el: any) {
if (el) nodeRefs[key] = el
}
// Expose nodeRefs to parent for connection line calculation
defineExpose({ nodeRefs, bodyRef })
function onLayerAction(cmd: string) {
emit('layer-action', cmd)
}
/**
* 拖拽排序完成回调(由 vuedraggable/SortableJS 触发)
* 由于 :list 传递的是父级响应式数组的引用,SortableJS 已直接完成了数组重排,
* 此处仅需通知父级重算连线位置和触发保存
*/
function onDragEnd() {
emit('reorder', props.layerKey)
}
function onNodeClick(dimKey: string, oi: number, obj: any) {
emit('node-click', dimKey, oi, obj)
}
</script>
<script lang="ts">
import { reactive } from 'vue'
export default {
inheritAttrs: false,
}
</script>
<style scoped>
.strategy-layer {
border: 2px solid #e8e8e8;
border-radius: 10px;
display: flex;
flex-direction: column;
background: #fafafa;
min-height: 120px;
transition: border-color 0.2s;
overflow: hidden;
width: 100%;
}
.layer-header {
padding: 10px 14px;
color: #fff;
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
flex-shrink: 0;
user-select: none;
}
.layer-icon {
font-size: 18px;
}
.layer-name {
flex: 1;
}
.layer-objective-count {
font-size: 11px;
opacity: 0.8;
font-weight: 400;
}
.layer-more-btn {
color: rgba(255,255,255,0.7) !important;
padding: 2px !important;
}
.layer-more-btn:hover {
color: #fff !important;
}
.layer-body {
padding: 10px 14px;
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
}
/* vuedraggable 容器 */
.layer-draggable {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-height: 40px;
}
/* 每个节点包裹元素 */
.layer-node-wrapper {
flex: 0 0 auto;
width: 100%;
}
/* 空状态 */
.layer-empty {
display: flex;
justify-content: center;
align-items: center;
min-height: 80px;
padding: 12px 0;
}
/* 底部添加按钮 */
.layer-add-btn {
width: 100% !important;
flex-shrink: 0;
margin-top: 4px;
}
</style>
@@ -0,0 +1,201 @@
<template>
<div class="strategy-layer" :class="`layer-${layerKey}`" :style="{ borderColor: color }">
<div class="layer-header" :style="{ background: color }">
<span class="layer-icon">{{ icon }}</span>
<span class="layer-name">{{ label }}</span>
<span class="layer-objective-count">{{ objectives.length }} 个目标</span>
<el-dropdown trigger="click" @command="onLayerAction">
<el-button class="layer-more-btn" size="small" text>
<el-icon><MoreFilled /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-item command="rename">重命名</el-dropdown-item>
<el-dropdown-item command="color">更换颜色</el-dropdown-item>
<el-dropdown-item command="icon">更换图标</el-dropdown-item>
</template>
</el-dropdown>
</div>
<div class="layer-body" ref="bodyRef">
<MapNode
v-for="(obj, oi) in objectives" :key="`${layerKey}-${oi}`"
:ref="el => setNodeRef(`${layerKey}-${oi}`, el)"
:objective="obj"
:node-key="`${layerKey}-${oi}`"
:index="oi"
:level="getNodeLevel(`${layerKey}-${oi}`)"
:progress-data="getNodeProgress(`${layerKey}-${oi}`)"
:draggable="true"
:is-linking-source="linkingFromKey === `${layerKey}-${oi}`"
:is-linking-target="!!linkingFromKey && linkingFromKey !== `${layerKey}-${oi}`"
:is-drag-connect-target="dragConnectTarget === `${layerKey}-${oi}`"
:is-drag-over="dragOverNodeKey === `${layerKey}-${oi}`"
:icon-map="iconMap"
:kpi-name-map="kpiNameMap"
:all-kpis="allKpis"
@drag-start="(p: any) => onNodeDragStart(layerKey, oi, p.event)"
@drag-over="onNodeDragOver(layerKey, oi)"
@drag-leave="onNodeDragLeave"
@drop="onNodeDrop(layerKey, oi)"
@click="onNodeClick(layerKey, oi, obj)"
@edit="$emit('edit-objective', { dimKey: layerKey, idx: oi, obj })"
@delete="$emit('delete-objective', { dimKey: layerKey, idx: oi })"
@link-click="$emit('start-link', { key: `${layerKey}-${oi}`, obj })"
@link-drag-start="(e: MouseEvent) => $emit('link-drag-start', e, layerKey, oi, obj)"
@kpi-click="(code: string) => $emit('kpi-click', code)"
@plan-click="$emit('show-plan-list', { dimKey: layerKey, idx: oi, obj })"
/>
<el-button text type="primary" size="small" style="margin-top:8px;width:100%;" @click="$emit('add-objective', layerKey)">
+ 添加目标
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { MoreFilled } from '@element-plus/icons-vue'
import MapNode from './MapNode.vue'
const props = defineProps<{
layerKey: string
label: string
icon: string
color: string
objectives: any[]
linkingFromKey?: string | null
dragConnectTarget?: string | null
dragOverNodeKey?: string | null
iconMap?: Record<string, string>
kpiNameMap?: Record<string, string>
allKpis?: any[]
getNodeLevel: (key: string) => string
getNodeProgress: (key: string) => any
}>()
const emit = defineEmits<{
(e: 'add-objective', dimKey: string): void
(e: 'edit-objective', payload: { dimKey: string; idx: number; obj: any }): void
(e: 'delete-objective', payload: { dimKey: string; idx: number }): void
(e: 'start-link', payload: { key: string; obj: any }): void
(e: 'link-drag-start', event: MouseEvent, dimKey: string, oi: number, obj: any): void
(e: 'kpi-click', code: string): void
(e: 'show-plan-list', payload: { dimKey: string; idx: number; obj: any }): void
(e: 'node-click', dimKey: string, oi: number, obj: any): void
(e: 'drag-start', dimKey: string, oi: number, event: DragEvent): void
(e: 'drag-over', dimKey: string, oi: number): void
(e: 'drag-leave'): void
(e: 'drop', dimKey: string, oi: number): void
(e: 'layer-action', cmd: string): void
(e: 'update:label', val: string): void
(e: 'update:color', val: string): void
(e: 'update:icon', val: string): void
}>()
const bodyRef = ref<HTMLElement | null>(null)
// Node DOM refs for line calculation (reactive so parent can aggregate)
const nodeRefs = reactive<Record<string, any>>({})
function setNodeRef(key: string, el: any) {
if (el) nodeRefs[key] = el
}
// Expose nodeRefs to parent for connection line calculation
defineExpose({ nodeRefs, bodyRef })
function onLayerAction(cmd: string) {
emit('layer-action', cmd)
}
function onNodeDragStart(dimKey: string, oi: number, event: DragEvent) {
emit('drag-start', dimKey, oi, event)
}
function onNodeDragOver(dimKey: string, oi: number) {
emit('drag-over', dimKey, oi)
}
function onNodeDragLeave() {
emit('drag-leave')
}
function onNodeDrop(dimKey: string, oi: number) {
emit('drop', dimKey, oi)
}
function onNodeClick(dimKey: string, oi: number, obj: any) {
emit('node-click', dimKey, oi, obj)
}
</script>
<style scoped>
.strategy-layer {
border: 2px solid #e8e8e8;
border-radius: 10px;
display: flex;
flex-direction: column;
background: #fafafa;
min-height: 120px;
transition: border-color 0.2s;
overflow: hidden;
width: 100%;
}
.layer-header {
padding: 10px 14px;
color: #fff;
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
flex-shrink: 0;
user-select: none;
}
.layer-icon {
font-size: 18px;
}
.layer-name {
flex: 1;
}
.layer-objective-count {
font-size: 11px;
opacity: 0.8;
font-weight: 400;
}
.layer-more-btn {
color: rgba(255,255,255,0.7) !important;
padding: 2px !important;
}
.layer-more-btn:hover {
color: #fff !important;
}
.layer-body {
padding: 10px 14px;
flex: 1;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
align-content: flex-start;
overflow-y: auto;
}
/* Each MapNode in a layer gets flex basis */
.layer-body > .map-node-wrapper {
flex: 0 0 auto;
}
/* Adjust for the direct MapNode child */
.layer-body :deep(.map-node) {
margin-bottom: 0;
flex: 1 1 220px;
max-width: 280px;
}
/* Add objective button full width row */
.layer-body > .el-button {
width: 100% !important;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,303 @@
<template>
<svg class="connection-svg" ref="svgRef">
<defs>
<!-- 跨层箭头方向 -->
<marker
id="arrow-down"
markerWidth="10"
markerHeight="7"
refX="5"
refY="7"
orient="auto"
>
<polygon points="0,0 10,3.5 0,7" fill="#909399" />
</marker>
<!-- 同层箭头方向 -->
<marker
id="arrow-right"
markerWidth="10"
markerHeight="7"
refX="10"
refY="3.5"
orient="auto"
>
<polygon points="0,0 10,3.5 0,7" fill="#409eff" />
</marker>
<!-- 高亮箭头 -->
<marker
id="arrow-active"
markerWidth="10"
markerHeight="7"
refX="10"
refY="3.5"
orient="auto"
>
<polygon points="0,0 10,3.5 0,7" fill="#f56c6c" />
</marker>
</defs>
<!-- 跨层固定箭头每层底部 下层顶部 -->
<line
v-for="(line, idx) in crossLayerLines"
:key="'cross-' + idx"
:x1="line.x1"
:y1="line.y1"
:x2="line.x2"
:y2="line.y2"
stroke="#909399"
stroke-width="2"
stroke-dasharray="6,3"
marker-end="url(#arrow-down)"
class="cross-layer-line"
/>
<!-- 同层用户手动连线箭头方向 -->
<g v-for="(conn, idx) in layerConnections" :key="'conn-' + idx">
<path
:d="conn.path"
:class="[
'conn-line',
{ 'conn-selected': selectedIdx === idx },
]"
:stroke="selectedIdx === idx ? '#f56c6c' : '#409eff'"
:stroke-width="selectedIdx === idx ? 3 : 2"
fill="none"
marker-end="url(#arrow-right)"
@click.stop="$emit('select-connection', idx)"
@mouseenter="hoverIdx = idx"
@mouseleave="hoverIdx = null"
/>
<!-- hover提示 -->
<rect
v-if="hoverIdx === idx"
:x="conn.mx - 50"
:y="conn.my - 10"
width="100"
height="20"
rx="4"
fill="rgba(0,0,0,0.65)"
class="conn-hover-bg"
/>
<text
v-if="hoverIdx === idx"
:x="conn.mx"
:y="conn.my + 4"
text-anchor="middle"
fill="#fff"
font-size="11"
class="conn-hover-text"
>
点击选中
</text>
<!-- 删除按钮选中时 -->
<g v-if="selectedIdx === idx">
<rect
:x="conn.mx - 20"
:y="conn.my - 28"
width="88"
height="24"
rx="12"
fill="#f56c6c"
class="del-btn-bg"
@click.stop="$emit('delete-connection', idx)"
/>
<text
:x="conn.mx + 24"
:y="conn.my - 12"
text-anchor="middle"
fill="#fff"
font-size="12"
font-weight="bold"
class="del-btn-text"
@click.stop="$emit('delete-connection', idx)"
>
删除连线
</text>
</g>
</g>
<!-- 绘制中的临时线 -->
<line
v-if="tempLine"
:x1="tempLine.x1"
:y1="tempLine.y1"
:x2="tempLine.x2"
:y2="tempLine.y2"
stroke="#e6a23c"
stroke-width="2"
stroke-dasharray="6,3"
marker-end="url(#arrow-down)"
/>
</svg>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
const props = defineProps({
/** 各层DOM元素引用,格式:{ financial: HTMLElement, customer: HTMLElement, ... } */
layerRefs: { type: Object, required: true },
/** 节点DOM元素引用,格式:{ 'financial-0': HTMLElement, ... } */
nodeRefs: { type: Object, default: () => ({}) },
/** 层顺序列表 */
layerKeys: { type: Array, default: () => ['financial', 'customer', 'process', 'learning'] },
/** 同层连线数据 [{from: 'financial-0', to: 'financial-1'}] */
connections: { type: Array, default: () => [] },
/** 临时连线 */
tempLine: { type: Object, default: null },
/** 容器尺寸变化触发重算 */
containerKey: { type: Number, default: 0 },
/** 视角方向:cascade(级联↓)| causal(因果↑) */
viewDirection: { type: String, default: 'cascade' },
})
const emit = defineEmits(['select-connection', 'delete-connection'])
const svgRef = ref(null)
const hoverIdx = ref(null)
const selectedIdx = ref(null)
const layerConnections = ref([])
const crossLayerLines = ref([])
/**
* 计算跨层固定箭头
* 级联视角(cascade):从上层底部中心 → 下层顶部中心(目标分解)
* 因果视角(causal):从下层顶部中心 → 上层底部中心(因果驱动)
*/
function calcCrossLayerLines() {
const lines = []
const keys = props.layerKeys
const isCausal = props.viewDirection === 'causal'
for (let i = 0; i < keys.length - 1; i++) {
const upperEl = props.layerRefs[keys[i]]
const lowerEl = props.layerRefs[keys[i + 1]]
if (!upperEl || !lowerEl) continue
const uRect = upperEl.getBoundingClientRect()
const lRect = lowerEl.getBoundingClientRect()
const svg = svgRef.value
if (!svg) continue
const svgRect = svg.getBoundingClientRect()
if (isCausal) {
// 因果视角:箭头从下层顶部 → 上层底部
lines.push({
x1: lRect.left + lRect.width / 2 - svgRect.left,
y1: lRect.top - svgRect.top,
x2: uRect.left + uRect.width / 2 - svgRect.left,
y2: uRect.bottom - svgRect.top,
})
} else {
// 级联视角:箭头从上层底部 → 下层顶部(原行为)
lines.push({
x1: uRect.left + uRect.width / 2 - svgRect.left,
y1: uRect.bottom - svgRect.top,
x2: lRect.left + lRect.width / 2 - svgRect.left,
y2: lRect.top - svgRect.top,
})
}
}
crossLayerLines.value = lines
}
/**
* 计算同层用户连线
* 使用贝塞尔曲线从源节点右侧 → 目标节点左侧
*/
function calcLayerConnections() {
const result = []
const svg = svgRef.value
if (!svg) return
const svgRect = svg.getBoundingClientRect()
for (const conn of props.connections) {
const fromEl = props.nodeRefs[conn.from]
const toEl = props.nodeRefs[conn.to]
if (!fromEl || !toEl) continue
const fr = fromEl.getBoundingClientRect()
const tr = toEl.getBoundingClientRect()
// 源节点右侧中点 → 目标节点左侧中点
const x1 = fr.right - svgRect.left
const y1 = fr.top + fr.height / 2 - svgRect.top
const x2 = tr.left - svgRect.left
const y2 = tr.top + tr.height / 2 - svgRect.top
// 贝塞尔曲线控制点
const dx = Math.abs(x2 - x1) * 0.5
const cp1x = x1 + dx
const cp1y = y1
const cp2x = x2 - dx
const cp2y = y2
const path = `M ${x1} ${y1} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${x2} ${y2}`
result.push({
path,
mx: (x1 + x2) / 2,
my: (y1 + y2) / 2,
from: conn.from,
to: conn.to,
})
}
layerConnections.value = result
}
function recalcAll() {
calcCrossLayerLines()
calcLayerConnections()
}
// 暴露给父组件调用
defineExpose({ recalcAll })
onMounted(() => {
nextTick(recalcAll)
})
</script>
<style scoped>
.connection-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 5;
pointer-events: none;
overflow: visible;
}
.cross-layer-line {
pointer-events: none;
}
.conn-line {
cursor: pointer;
transition: stroke 0.15s, stroke-width 0.15s;
pointer-events: stroke;
}
.conn-line:hover {
stroke: #e6a23c !important;
stroke-width: 4 !important;
}
.conn-selected {
stroke: #f56c6c !important;
stroke-width: 3;
}
.del-btn-bg {
cursor: pointer;
pointer-events: all;
}
.del-btn-bg:hover {
fill: #e74c3c !important;
}
.del-btn-text {
cursor: pointer;
pointer-events: all;
user-select: none;
}
.conn-hover-bg,
.conn-hover-text {
pointer-events: none;
}
</style>
@@ -0,0 +1,288 @@
<template>
<svg class="connection-svg" ref="svgRef">
<defs>
<!-- 跨层箭头(↓方向) -->
<marker
id="arrow-down"
markerWidth="10"
markerHeight="7"
refX="5"
refY="7"
orient="auto"
>
<polygon points="0,0 10,3.5 0,7" fill="#909399" />
</marker>
<!-- 同层箭头(→方向) -->
<marker
id="arrow-right"
markerWidth="10"
markerHeight="7"
refX="10"
refY="3.5"
orient="auto"
>
<polygon points="0,0 10,3.5 0,7" fill="#409eff" />
</marker>
<!-- 高亮箭头 -->
<marker
id="arrow-active"
markerWidth="10"
markerHeight="7"
refX="10"
refY="3.5"
orient="auto"
>
<polygon points="0,0 10,3.5 0,7" fill="#f56c6c" />
</marker>
</defs>
<!-- 跨层固定箭头:每层底部 → 下层顶部 -->
<line
v-for="(line, idx) in crossLayerLines"
:key="'cross-' + idx"
:x1="line.x1"
:y1="line.y1"
:x2="line.x2"
:y2="line.y2"
stroke="#909399"
stroke-width="2"
stroke-dasharray="6,3"
marker-end="url(#arrow-down)"
class="cross-layer-line"
/>
<!-- 同层用户手动连线箭头(→方向) -->
<g v-for="(conn, idx) in layerConnections" :key="'conn-' + idx">
<path
:d="conn.path"
:class="[
'conn-line',
{ 'conn-selected': selectedIdx === idx },
]"
:stroke="selectedIdx === idx ? '#f56c6c' : '#409eff'"
:stroke-width="selectedIdx === idx ? 3 : 2"
fill="none"
marker-end="url(#arrow-right)"
@click.stop="$emit('select-connection', idx)"
@mouseenter="hoverIdx = idx"
@mouseleave="hoverIdx = null"
/>
<!-- hover提示 -->
<rect
v-if="hoverIdx === idx"
:x="conn.mx - 50"
:y="conn.my - 10"
width="100"
height="20"
rx="4"
fill="rgba(0,0,0,0.65)"
class="conn-hover-bg"
/>
<text
v-if="hoverIdx === idx"
:x="conn.mx"
:y="conn.my + 4"
text-anchor="middle"
fill="#fff"
font-size="11"
class="conn-hover-text"
>
点击选中
</text>
<!-- 删除按钮(选中时) -->
<g v-if="selectedIdx === idx">
<rect
:x="conn.mx - 20"
:y="conn.my - 28"
width="88"
height="24"
rx="12"
fill="#f56c6c"
class="del-btn-bg"
@click.stop="$emit('delete-connection', idx)"
/>
<text
:x="conn.mx + 24"
:y="conn.my - 12"
text-anchor="middle"
fill="#fff"
font-size="12"
font-weight="bold"
class="del-btn-text"
@click.stop="$emit('delete-connection', idx)"
>
✕ 删除连线
</text>
</g>
</g>
<!-- 绘制中的临时线 -->
<line
v-if="tempLine"
:x1="tempLine.x1"
:y1="tempLine.y1"
:x2="tempLine.x2"
:y2="tempLine.y2"
stroke="#e6a23c"
stroke-width="2"
stroke-dasharray="6,3"
marker-end="url(#arrow-down)"
/>
</svg>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
const props = defineProps({
/** 各层DOM元素引用,格式:{ financial: HTMLElement, customer: HTMLElement, ... } */
layerRefs: { type: Object, required: true },
/** 节点DOM元素引用,格式:{ 'financial-0': HTMLElement, ... } */
nodeRefs: { type: Object, default: () => ({}) },
/** 层顺序列表 */
layerKeys: { type: Array, default: () => ['financial', 'customer', 'process', 'learning'] },
/** 同层连线数据 [{from: 'financial-0', to: 'financial-1'}] */
connections: { type: Array, default: () => [] },
/** 临时连线 */
tempLine: { type: Object, default: null },
/** 容器尺寸变化触发重算 */
containerKey: { type: Number, default: 0 },
})
const emit = defineEmits(['select-connection', 'delete-connection'])
const svgRef = ref(null)
const hoverIdx = ref(null)
const selectedIdx = ref(null)
const layerConnections = ref([])
const crossLayerLines = ref([])
/**
* 计算跨层固定箭头
* 从上层底部中心 → 下层顶部中心
*/
function calcCrossLayerLines() {
const lines = []
const keys = props.layerKeys
for (let i = 0; i < keys.length - 1; i++) {
const fromEl = props.layerRefs[keys[i]]
const toEl = props.layerRefs[keys[i + 1]]
if (!fromEl || !toEl) continue
const fr = fromEl.getBoundingClientRect()
const tr = toEl.getBoundingClientRect()
const svg = svgRef.value
if (!svg) continue
const svgRect = svg.getBoundingClientRect()
lines.push({
x1: fr.left + fr.width / 2 - svgRect.left,
y1: fr.bottom - svgRect.top,
x2: tr.left + tr.width / 2 - svgRect.left,
y2: tr.top - svgRect.top,
})
}
crossLayerLines.value = lines
}
/**
* 计算同层用户连线
* 使用贝塞尔曲线从源节点右侧 → 目标节点左侧
*/
function calcLayerConnections() {
const result = []
const svg = svgRef.value
if (!svg) return
const svgRect = svg.getBoundingClientRect()
for (const conn of props.connections) {
const fromEl = props.nodeRefs[conn.from]
const toEl = props.nodeRefs[conn.to]
if (!fromEl || !toEl) continue
const fr = fromEl.getBoundingClientRect()
const tr = toEl.getBoundingClientRect()
// 源节点右侧中点 → 目标节点左侧中点
const x1 = fr.right - svgRect.left
const y1 = fr.top + fr.height / 2 - svgRect.top
const x2 = tr.left - svgRect.left
const y2 = tr.top + tr.height / 2 - svgRect.top
// 贝塞尔曲线控制点
const dx = Math.abs(x2 - x1) * 0.5
const cp1x = x1 + dx
const cp1y = y1
const cp2x = x2 - dx
const cp2y = y2
const path = `M ${x1} ${y1} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${x2} ${y2}`
result.push({
path,
mx: (x1 + x2) / 2,
my: (y1 + y2) / 2,
from: conn.from,
to: conn.to,
})
}
layerConnections.value = result
}
function recalcAll() {
calcCrossLayerLines()
calcLayerConnections()
}
// 暴露给父组件调用
defineExpose({ recalcAll })
onMounted(() => {
nextTick(recalcAll)
})
</script>
<style scoped>
.connection-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 5;
pointer-events: none;
overflow: visible;
}
.cross-layer-line {
pointer-events: none;
}
.conn-line {
cursor: pointer;
transition: stroke 0.15s, stroke-width 0.15s;
pointer-events: stroke;
}
.conn-line:hover {
stroke: #e6a23c !important;
stroke-width: 4 !important;
}
.conn-selected {
stroke: #f56c6c !important;
stroke-width: 3;
}
.del-btn-bg {
cursor: pointer;
pointer-events: all;
}
.del-btn-bg:hover {
fill: #e74c3c !important;
}
.del-btn-text {
cursor: pointer;
pointer-events: all;
user-select: none;
}
.conn-hover-bg,
.conn-hover-text {
pointer-events: none;
}
</style>