feat: P0/P1/P2全部功能 — 四层泳道/视角切换/KPI看板/预警/差异反打/预算/知识面板/回顾会/情景预测/Excel导入/角色权限
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user