chore: 清理误提交的.bak备份文件(git历史可恢复,源文件均在)
This commit is contained in:
@@ -1,143 +0,0 @@
|
||||
<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>
|
||||
@@ -1,221 +0,0 @@
|
||||
<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>
|
||||
@@ -1,201 +0,0 @@
|
||||
<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>
|
||||
@@ -1,288 +0,0 @@
|
||||
<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>
|
||||
@@ -1,287 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<h3>预测模拟</h3>
|
||||
|
||||
<el-tabs v-model="activeTab" style="margin-top:16px;">
|
||||
<!-- Tab 1: CVP本量利分析 -->
|
||||
<el-tab-pane label="本量利分析" name="cvp">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="10">
|
||||
<el-card>
|
||||
<template #header>输入参数</template>
|
||||
<el-form :model="cvpForm" label-width="140px">
|
||||
<el-form-item label="产品单价"><el-input-number v-model="cvpForm.unit_price" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="单位变动成本"><el-input-number v-model="cvpForm.unit_variable_cost" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="固定成本"><el-input-number v-model="cvpForm.fixed_cost" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="实际销量(可选)"><el-input-number v-model="cvpForm.actual_volume" :min="0" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="目标利润(可选)"><el-input-number v-model="cvpForm.target_profit" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="doCvp" :loading="cvpLoading">计算</el-button>
|
||||
<el-button @click="cvpForm = { unit_price: 100, unit_variable_cost: 60, fixed_cost: 50000, actual_volume: 2000, target_profit: 20000 }">填充示例</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="14">
|
||||
<el-card v-if="cvpResult.bep_units">
|
||||
<template #header>分析结果</template>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">保本销量</div><div class="r-value">{{ cvpResult.bep_units }} 件</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">保本销售额</div><div class="r-value">{{ formatMoney(cvpResult.bep_revenue) }}</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">边际贡献率</div><div class="r-value blue">{{ cvpResult.contribution_ratio }}%</div></div></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12" style="margin-top:12px;">
|
||||
<el-col :span="8" v-if="cvpResult.safety_margin_units !== undefined"><div class="result-card"><div class="r-label">安全边际(量)</div><div class="r-value green">{{ cvpResult.safety_margin_units }} 件</div></div></el-col>
|
||||
<el-col :span="8" v-if="cvpResult.safety_margin_ratio !== undefined"><div class="result-card"><div class="r-label">安全边际率</div><div class="r-value" :class="safetyColor(cvpResult.safety_margin_ratio)">{{ cvpResult.safety_margin_ratio }}%</div></div></el-col>
|
||||
<el-col :span="8" v-if="cvpResult.actual_profit !== undefined"><div class="result-card"><div class="r-label">当前利润</div><div class="r-value orange">{{ formatMoney(cvpResult.actual_profit) }}</div></div></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12" style="margin-top:12px;" v-if="cvpResult.target_units">
|
||||
<el-col :span="12"><div class="result-card"><div class="r-label">目标利润所需销量</div><div class="r-value purple">{{ cvpResult.target_units }} 件</div></div></el-col>
|
||||
<el-col :span="12"><div class="result-card"><div class="r-label">目标利润所需收入</div><div class="r-value purple">{{ formatMoney(cvpResult.target_revenue) }}</div></div></el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<el-empty v-else-if="!cvpLoading" description="输入参数后点击「计算」" style="padding:40px 0;" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 2: 投资决策 -->
|
||||
<el-tab-pane label="投资决策" name="investment">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="10">
|
||||
<el-card>
|
||||
<template #header>输入参数</template>
|
||||
<el-form :model="invForm" label-width="140px">
|
||||
<el-form-item label="初始投资"><el-input-number v-model="invForm.initial_investment" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="折现率(%)"><el-input-number v-model="invForm.discount_rate" :min="0" :max="100" :precision="1" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="现金流(每年)">
|
||||
<div v-for="(cf, idx) in invForm.cash_flows" :key="idx" style="display:flex;gap:8px;margin-bottom:4px;">
|
||||
<span style="line-height:32px;width:80px;">第{{ idx+1 }}年</span>
|
||||
<el-input-number v-model="invForm.cash_flows[idx]" :min="0" :precision="2" style="width:150px;" />
|
||||
<el-button v-if="invForm.cash_flows.length > 1" size="small" type="danger" @click="invForm.cash_flows.splice(idx, 1)">×</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="invForm.cash_flows.push(0)">+ 添加年份</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="doInvestment" :loading="invLoading">计算</el-button>
|
||||
<el-button @click="fillInvExample">填充示例</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="14">
|
||||
<el-card v-if="invResult.npv_analysis">
|
||||
<template #header>分析结果</template>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">净现值 (NPV)</div><div class="r-value" :class="invResult.npv_analysis.npv > 0 ? 'green' : 'red'">{{ formatMoney(invResult.npv_analysis.npv) }}</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">内部收益率 (IRR)</div><div class="r-value blue">{{ invResult.irr_analysis.irr }}%</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">盈利指数 (PI)</div><div class="r-value purple">{{ invResult.npv_analysis.profitability_index }}</div></div></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12" style="margin-top:12px;">
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">静态回收期</div><div class="r-value">{{ invResult.irr_analysis.payback_period || '--' }} 年</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">动态回收期</div><div class="r-value">{{ invResult.irr_analysis.discounted_payback_period || '>N期' }} 年</div></div></el-col>
|
||||
<el-col :span="8"><div class="result-card"><div class="r-label">可行性</div><div class="r-value" :class="invResult.npv_analysis.is_viable ? 'green' : 'red'">{{ invResult.npv_analysis.is_viable ? '✅ 可行' : '❌ 不可行' }}</div></div></el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
<el-empty v-else-if="!invLoading" description="输入参数后点击「计算」" style="padding:40px 0;" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 3: 敏感性分析 -->
|
||||
<el-tab-pane label="敏感性分析" name="sensitivity">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="10">
|
||||
<el-card>
|
||||
<template #header>输入参数</template>
|
||||
<el-form :model="sensForm" label-width="140px">
|
||||
<el-form-item label="基准收入"><el-input-number v-model="sensForm.base_revenue" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="基准成本"><el-input-number v-model="sensForm.base_cost" :min="0" :precision="2" style="width:200px;" /></el-form-item>
|
||||
<el-form-item label="变动步长(%)"><el-input-number v-model="sensForm.step" :min="1" :max="50" style="width:200px;" /></el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="doSensitivity" :loading="sensLoading">计算</el-button>
|
||||
<el-button @click="sensForm = { base_revenue: 1000000, base_cost: 700000, step: 5 }">填充示例</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="14">
|
||||
<el-card v-if="sensResult.base_revenue">
|
||||
<template #header>
|
||||
敏感性分析结果 — 基准利润:{{ formatMoney(sensResult.base_profit) }}
|
||||
</template>
|
||||
<el-table :data="sensResult.factors" border stripe size="small" style="width:100%;" max-height="400">
|
||||
<el-table-column prop="change_pct" label="变动%" width="70" align="center">
|
||||
<template #default="{ row }"><span :style="{ color: row.change_pct > 0 ? '#f56c6c' : '#67c23a', fontWeight:600 }">{{ row.change_pct > 0 ? '+' : '' }}{{ row.change_pct }}%</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="revenue_change_profit" label="收入变动→利润" width="130" align="right"><template #default="{ row }">{{ formatMoney(row.revenue_change_profit) }}</template></el-table-column>
|
||||
<el-table-column prop="revenue_sensitivity" label="收入敏感度" width="100" align="right"><template #default="{ row }"><span :style="{ color: row.revenue_sensitivity < 0 ? '#f56c6c' : '#67c23a' }">{{ row.revenue_sensitivity > 0 ? '+' : '' }}{{ row.revenue_sensitivity }}%</span></template></el-table-column>
|
||||
<el-table-column prop="cost_change_profit" label="成本变动→利润" width="130" align="right"><template #default="{ row }">{{ formatMoney(row.cost_change_profit) }}</template></el-table-column>
|
||||
<el-table-column prop="cost_sensitivity" label="成本敏感度" width="100" align="right"><template #default="{ row }"><span :style="{ color: row.cost_sensitivity < 0 ? '#f56c6c' : '#67c23a' }">{{ row.cost_sensitivity > 0 ? '+' : '' }}{{ row.cost_sensitivity }}%</span></template></el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<el-empty v-else-if="!sensLoading" description="输入参数后点击「计算」" style="padding:40px 0;" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 4: 情景模拟 -->
|
||||
<el-tab-pane label="情景模拟" name="scenario">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="10">
|
||||
<el-card>
|
||||
<template #header>输入三个情景</template>
|
||||
<el-form label-width="120px">
|
||||
<div style="margin-bottom:12px;">
|
||||
<el-tag type="success" size="small">乐观情景</el-tag>
|
||||
<div style="display:flex;gap:8px;margin-top:4px;">
|
||||
<el-input-number v-model="scenarioForm.optimistic.revenue" placeholder="收入" :min="0" :precision="2" style="width:150px;" />
|
||||
<el-input-number v-model="scenarioForm.optimistic.cost" placeholder="成本" :min="0" :precision="2" style="width:150px;" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<el-tag type="warning" size="small">中性情景</el-tag>
|
||||
<div style="display:flex;gap:8px;margin-top:4px;">
|
||||
<el-input-number v-model="scenarioForm.base.revenue" :min="0" :precision="2" style="width:150px;" />
|
||||
<el-input-number v-model="scenarioForm.base.cost" :min="0" :precision="2" style="width:150px;" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<el-tag type="danger" size="small">悲观情景</el-tag>
|
||||
<div style="display:flex;gap:8px;margin-top:4px;">
|
||||
<el-input-number v-model="scenarioForm.pessimistic.revenue" :min="0" :precision="2" style="width:150px;" />
|
||||
<el-input-number v-model="scenarioForm.pessimistic.cost" :min="0" :precision="2" style="width:150px;" />
|
||||
</div>
|
||||
</div>
|
||||
<el-button type="primary" @click="doScenario" :loading="scenarioLoading">模拟</el-button>
|
||||
<el-button @click="fillScenarioExample">填充示例</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="14">
|
||||
<el-card v-if="scenarioResult.scenarios">
|
||||
<template #header>
|
||||
模拟结果
|
||||
<span style="margin-left:12px;font-size:12px;color:#999;">
|
||||
期望利润:{{ formatMoney(scenarioResult.expected_profit) }}
|
||||
| 标准差:{{ formatMoney(scenarioResult.std_deviation) }}
|
||||
</span>
|
||||
</template>
|
||||
<el-table :data="scenarioResult.scenarios" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="scenario" label="情景" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.scenario === '乐观' ? 'success' : row.scenario === '中性' ? 'warning' : 'danger'" size="small">{{ row.scenario }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="revenue" label="收入" width="100" align="right"><template #default="{ row }">{{ formatMoney(row.revenue) }}</template></el-table-column>
|
||||
<el-table-column prop="cost" label="成本" width="100" align="right"><template #default="{ row }">{{ formatMoney(row.cost) }}</template></el-table-column>
|
||||
<el-table-column prop="profit" label="利润" width="100" align="right">
|
||||
<template #default="{ row }"><span :style="{ color: row.profit >= 0 ? '#67c23a' : '#f56c6c', fontWeight:600 }">{{ formatMoney(row.profit) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="profit_margin" label="利润率" width="80" align="right"><template #default="{ row }">{{ row.profit_margin }}%</template></el-table-column>
|
||||
<el-table-column prop="deviation_from_base" label="偏差" width="100" align="right">
|
||||
<template #default="{ row }"><span :style="{ color: row.deviation_from_base > 0 ? '#67c23a' : '#f56c6c' }">{{ row.deviation_from_base > 0 ? '+' : '' }}{{ formatMoney(row.deviation_from_base) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deviation_pct" label="偏差%" width="80" align="right"><template #default="{ row }">{{ row.deviation_pct }}%</template></el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<el-empty v-else-if="!scenarioLoading" description="输入三个情景后点击「模拟」" style="padding:40px 0;" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { predictApi } from '../api/index'
|
||||
|
||||
const activeTab = ref('cvp')
|
||||
|
||||
function formatMoney(v: any) {
|
||||
if (v === null || v === undefined) return '--'
|
||||
return Number(v).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
function safetyColor(r: number) { return r > 25 ? 'green' : r > 10 ? 'orange' : 'red' }
|
||||
|
||||
// ── CVP ──
|
||||
const cvpLoading = ref(false)
|
||||
const cvpForm = ref({ unit_price: 100, unit_variable_cost: 60, fixed_cost: 50000, actual_volume: 2000, target_profit: 20000 })
|
||||
const cvpResult = ref<any>({})
|
||||
|
||||
async function doCvp() {
|
||||
cvpLoading.value = true
|
||||
try {
|
||||
cvpResult.value = await predictApi.cvp(cvpForm.value) as any
|
||||
} catch { ElMessage.error('CVP计算失败') }
|
||||
cvpLoading.value = false
|
||||
}
|
||||
|
||||
// ── 投资决策 ──
|
||||
const invLoading = ref(false)
|
||||
const invForm = ref({ initial_investment: 100000, discount_rate: 10, cash_flows: [30000, 40000, 50000, 40000] })
|
||||
const invResult = ref<any>({})
|
||||
|
||||
function fillInvExample() {
|
||||
invForm.value = { initial_investment: 100000, discount_rate: 10, cash_flows: [30000, 40000, 50000, 40000] }
|
||||
}
|
||||
|
||||
async function doInvestment() {
|
||||
invLoading.value = true
|
||||
try {
|
||||
invResult.value = await predictApi.investment(invForm.value) as any
|
||||
} catch { ElMessage.error('投资决策计算失败') }
|
||||
invLoading.value = false
|
||||
}
|
||||
|
||||
// ── 敏感性 ──
|
||||
const sensLoading = ref(false)
|
||||
const sensForm = ref({ base_revenue: 1000000, base_cost: 700000, step: 5 })
|
||||
const sensResult = ref<any>({})
|
||||
|
||||
async function doSensitivity() {
|
||||
sensLoading.value = true
|
||||
try {
|
||||
sensResult.value = await predictApi.sensitivity(sensForm.value) as any
|
||||
} catch { ElMessage.error('敏感性分析失败') }
|
||||
sensLoading.value = false
|
||||
}
|
||||
|
||||
// ── 情景 ──
|
||||
const scenarioLoading = ref(false)
|
||||
const scenarioForm = ref({
|
||||
optimistic: { revenue: 130, cost: 90 },
|
||||
base: { revenue: 100, cost: 100 },
|
||||
pessimistic: { revenue: 80, cost: 110 },
|
||||
})
|
||||
const scenarioResult = ref<any>({})
|
||||
|
||||
function fillScenarioExample() {
|
||||
scenarioForm.value = {
|
||||
optimistic: { revenue: 130, cost: 90 },
|
||||
base: { revenue: 100, cost: 100 },
|
||||
pessimistic: { revenue: 80, cost: 110 },
|
||||
}
|
||||
}
|
||||
|
||||
async function doScenario() {
|
||||
scenarioLoading.value = true
|
||||
try {
|
||||
scenarioResult.value = await predictApi.scenario(scenarioForm.value) as any
|
||||
} catch { ElMessage.error('情景模拟失败') }
|
||||
scenarioLoading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.result-card { background: #f5f7fa; border-radius: 8px; padding: 16px; text-align: center; }
|
||||
.r-label { font-size: 12px; color: #909399; margin-bottom: 6px; }
|
||||
.r-value { font-size: 22px; font-weight: bold; color: #303133; }
|
||||
.r-value.green { color: #67c23a; }
|
||||
.r-value.blue { color: #409eff; }
|
||||
.r-value.orange { color: #e6a23c; }
|
||||
.r-value.red { color: #f56c6c; }
|
||||
.r-value.purple { color: #8b5cf6; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user