<template>
|
<div class="architecture-page">
|
<section ref="canvasRef" class="canvas">
|
<svg
|
v-if="linkPath"
|
class="link-overlay"
|
:width="svgSize.width"
|
:height="svgSize.height"
|
aria-hidden="true"
|
>
|
<defs>
|
<marker
|
id="erp-link-arrow"
|
markerWidth="10"
|
markerHeight="10"
|
refX="7"
|
refY="3.5"
|
orient="auto"
|
markerUnits="strokeWidth"
|
>
|
<path d="M0,0 L0,7 L7,3.5 z" fill="#94a3b8" />
|
</marker>
|
</defs>
|
<path
|
:d="linkPath"
|
class="link-overlay__path"
|
marker-end="url(#erp-link-arrow)"
|
/>
|
</svg>
|
|
<section
|
v-for="row in architectureRows"
|
:key="row.key"
|
class="lane"
|
>
|
<aside class="lane__aside">
|
<svg-icon :icon-class="row.icon" class="lane__icon" />
|
<h2>{{ row.label }}</h2>
|
<span class="lane__arrow"></span>
|
</aside>
|
|
<div class="lane__flow">
|
<div
|
v-for="(item, index) in row.items"
|
:key="item.name"
|
class="node-wrap"
|
>
|
<article
|
class="node"
|
:class="{ 'node--accent': item.accent }"
|
:ref="setNodeRef(item)"
|
>
|
<span class="node__mark">
|
<span class="node__cap"></span>
|
<span class="node__base"></span>
|
<svg-icon :icon-class="item.icon" class="node__icon" />
|
</span>
|
<h3>{{ item.name }}</h3>
|
</article>
|
<span v-if="index < row.items.length - 1" class="flow-arrow"></span>
|
</div>
|
</div>
|
</section>
|
</section>
|
</div>
|
</template>
|
|
<script setup>
|
import { nextTick, onBeforeUnmount, onMounted, onUpdated, ref } from 'vue'
|
|
const architectureRows = [
|
{
|
key: 'basic',
|
label: '基础配置',
|
icon: 'system',
|
items: [
|
{ name: '角色用户管理', icon: 'user' },
|
{ name: '产品维护', icon: 'monitor' },
|
{ name: '审批管理', icon: 'tree', accent: true }
|
]
|
},
|
{
|
key: 'sale',
|
label: '销售',
|
icon: 'chart',
|
items: [
|
{ name: '客户档案', icon: 'peoples' },
|
{ name: '销售报价', icon: 'form' },
|
{ name: '销售台账', icon: 'table' },
|
{ name: '发货台账', icon: 'clipboard', accent: true, linkSource: true },
|
{ name: '销售退货', icon: 'nested' },
|
{ name: '客户往来', icon: 'money' },
|
{ name: '指标统计', icon: 'chart' }
|
]
|
},
|
{
|
key: 'purchase',
|
label: '采购',
|
icon: 'shopping',
|
items: [
|
{ name: '供应商档案', icon: 'people' },
|
{ name: '采购台账', icon: 'table', accent: true },
|
{ name: '采购退货', icon: 'nested' },
|
{ name: '供应商往来', icon: 'money' },
|
{ name: '采购报表', icon: 'chart' }
|
]
|
},
|
{
|
key: 'produce',
|
label: '生产',
|
icon: 'build',
|
items: [
|
{ name: '工序', icon: 'tree' },
|
{ name: 'BOM', icon: 'list' },
|
{ name: '工艺路线', icon: 'guide' },
|
{ name: '生产订单', icon: 'form' },
|
{ name: '生产排产', icon: 'date' },
|
{ name: '生产报工', icon: 'edit' },
|
{ name: '报工台账', icon: 'clipboard' },
|
{ name: '生产核算', icon: 'money', accent: true }
|
]
|
},
|
{
|
key: 'store',
|
label: '仓储物流',
|
icon: 'redis',
|
items: [
|
{ name: '入库管理', icon: 'download', accent: true },
|
{ name: '出库管理', icon: 'upload', accent: true, linkTarget: true },
|
{ name: '库存管理', icon: 'redis-list' }
|
]
|
}
|
]
|
|
const canvasRef = ref(null)
|
const sourceNodeRef = ref(null)
|
const targetNodeRef = ref(null)
|
const linkPath = ref('')
|
const svgSize = ref({ width: 0, height: 0 })
|
let resizeObserver = null
|
|
function setNodeRef(item) {
|
return (el) => {
|
if (item.linkSource) sourceNodeRef.value = el
|
if (item.linkTarget) targetNodeRef.value = el
|
}
|
}
|
|
function resetLine() {
|
linkPath.value = ''
|
}
|
|
function updateLinkLine() {
|
if (!canvasRef.value || !sourceNodeRef.value || !targetNodeRef.value || window.innerWidth <= 768) {
|
resetLine()
|
return
|
}
|
|
const canvasRect = canvasRef.value.getBoundingClientRect()
|
const sourceRect = sourceNodeRef.value.getBoundingClientRect()
|
const targetRect = targetNodeRef.value.getBoundingClientRect()
|
|
svgSize.value = {
|
width: Math.ceil(canvasRef.value.scrollWidth),
|
height: Math.ceil(canvasRef.value.scrollHeight)
|
}
|
|
const startX = sourceRect.left + sourceRect.width / 2 - canvasRect.left
|
const startY = sourceRect.bottom - canvasRect.top + 2
|
const endX = targetRect.left + targetRect.width / 2 - canvasRect.left
|
const endY = targetRect.top - canvasRect.top - 6
|
const middleY = startY + Math.max(24, (endY - startY) / 2)
|
|
linkPath.value = `M ${startX} ${startY} L ${startX} ${middleY} L ${endX} ${middleY} L ${endX} ${endY}`
|
}
|
|
function handleResize() {
|
requestAnimationFrame(() => {
|
requestAnimationFrame(updateLinkLine)
|
})
|
}
|
|
onMounted(async () => {
|
await nextTick()
|
handleResize()
|
window.addEventListener('resize', handleResize)
|
if (window.ResizeObserver && canvasRef.value) {
|
resizeObserver = new ResizeObserver(handleResize)
|
resizeObserver.observe(canvasRef.value)
|
}
|
})
|
|
onUpdated(() => {
|
nextTick(handleResize)
|
})
|
|
onBeforeUnmount(() => {
|
window.removeEventListener('resize', handleResize)
|
if (resizeObserver) resizeObserver.disconnect()
|
})
|
</script>
|
|
<style scoped>
|
.architecture-page {
|
min-height: calc(100vh - 84px);
|
padding: 18px;
|
background: #f6f7f9;
|
}
|
|
.canvas {
|
position: relative;
|
display: grid;
|
gap: 10px;
|
}
|
|
.lane {
|
display: grid;
|
grid-template-columns: 94px 1fr;
|
gap: 10px;
|
align-items: stretch;
|
}
|
|
.lane__aside {
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
gap: 8px;
|
padding: 8px 6px;
|
}
|
|
.lane__icon {
|
font-size: 18px;
|
color: #2563eb;
|
flex-shrink: 0;
|
}
|
|
.lane__aside h2 {
|
margin: 0;
|
width: 34px;
|
font-size: 12px;
|
line-height: 1.15;
|
font-weight: 600;
|
color: #1f2937;
|
text-align: center;
|
}
|
|
.lane__arrow {
|
position: relative;
|
width: 16px;
|
height: 1px;
|
background: #cbd5e1;
|
flex-shrink: 0;
|
}
|
|
.lane__arrow::after {
|
content: '';
|
position: absolute;
|
top: 50%;
|
right: -1px;
|
width: 6px;
|
height: 6px;
|
border-top: 1px solid #cbd5e1;
|
border-right: 1px solid #cbd5e1;
|
transform: translateY(-50%) rotate(45deg);
|
}
|
|
.lane__flow {
|
position: relative;
|
z-index: 2;
|
display: flex;
|
flex-wrap: wrap;
|
align-items: center;
|
gap: 8px;
|
padding: 12px 14px;
|
background: #f1f3f5;
|
}
|
|
.node-wrap {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
position: relative;
|
}
|
|
.node {
|
width: 100px;
|
min-height: 72px;
|
padding: 4px 2px;
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
justify-content: center;
|
gap: 6px;
|
}
|
|
.node__mark {
|
position: relative;
|
width: 34px;
|
height: 28px;
|
display: flex;
|
align-items: flex-start;
|
justify-content: center;
|
}
|
|
.node__cap {
|
position: absolute;
|
top: 0;
|
width: 18px;
|
height: 14px;
|
border-radius: 4px 4px 3px 3px;
|
background: linear-gradient(180deg, #60a5fa, #2563eb);
|
box-shadow: 0 3px 6px rgba(37, 99, 235, 0.16);
|
}
|
|
.node__base {
|
position: absolute;
|
bottom: 0;
|
width: 30px;
|
height: 12px;
|
border-radius: 4px;
|
background: linear-gradient(180deg, #ffffff, #e8edf3);
|
border: 1px solid #d7dee7;
|
}
|
|
.node__icon {
|
position: relative;
|
z-index: 1;
|
margin-top: 2px;
|
font-size: 15px;
|
color: #ffffff;
|
}
|
|
.node--accent .node__cap {
|
background: linear-gradient(180deg, #2dd4bf, #0f766e);
|
box-shadow: 0 3px 6px rgba(15, 118, 110, 0.16);
|
}
|
|
.node h3 {
|
margin: 0;
|
font-size: 12px;
|
line-height: 1.3;
|
font-weight: 600;
|
color: #111827;
|
text-align: center;
|
}
|
|
.flow-arrow {
|
width: 18px;
|
height: 1px;
|
background: #b6c1ce;
|
position: relative;
|
flex-shrink: 0;
|
}
|
|
.flow-arrow::after {
|
content: '';
|
position: absolute;
|
top: 50%;
|
right: -1px;
|
width: 6px;
|
height: 6px;
|
border-top: 1px solid #94a3b8;
|
border-right: 1px solid #94a3b8;
|
transform: translateY(-50%) rotate(45deg);
|
}
|
|
.link-overlay {
|
position: absolute;
|
inset: 0;
|
z-index: 20;
|
pointer-events: none;
|
overflow: visible;
|
}
|
|
.link-overlay__path {
|
fill: none;
|
stroke: #94a3b8;
|
stroke-width: 1;
|
stroke-dasharray: 4 4;
|
stroke-linecap: round;
|
stroke-linejoin: round;
|
}
|
|
@media (max-width: 1180px) {
|
.lane {
|
grid-template-columns: 1fr;
|
gap: 6px;
|
}
|
|
.lane__aside {
|
justify-content: flex-start;
|
}
|
}
|
|
@media (max-width: 768px) {
|
.architecture-page {
|
padding: 12px;
|
}
|
|
.lane__flow {
|
display: grid;
|
gap: 8px;
|
padding: 12px;
|
}
|
|
.node-wrap {
|
display: grid;
|
gap: 8px;
|
}
|
|
.node {
|
width: 100%;
|
min-height: 52px;
|
flex-direction: row;
|
justify-content: flex-start;
|
gap: 10px;
|
}
|
|
.node h3 {
|
text-align: left;
|
}
|
|
.flow-arrow {
|
width: 1px;
|
height: 16px;
|
margin: 0 auto;
|
}
|
|
.flow-arrow::after {
|
top: auto;
|
bottom: -1px;
|
right: 50%;
|
transform: translateX(50%) rotate(135deg);
|
}
|
}
|
</style>
|