<template>
|
<div class="architecture-page">
|
<div class="page-header">
|
<span class="header-line"></span>
|
<h1 class="page-title">系统架构图</h1>
|
</div>
|
|
<section ref="canvasRef" class="canvas">
|
<svg
|
v-if="svgSize.width && svgSize.height"
|
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="4.5"
|
orient="auto"
|
markerUnits="strokeWidth"
|
>
|
<path d="M0,0 L0,7 L7,3.5 z" fill="#a7bdda" />
|
</marker>
|
</defs>
|
|
<path
|
v-for="path in aiPaths"
|
:key="path.key"
|
:d="path.d"
|
class="link-overlay__path link-overlay__path--ai"
|
/>
|
|
<path
|
v-if="flowPath"
|
:d="flowPath"
|
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">
|
<div class="lane__aside-card" :class="`lane__aside-card--${row.key}`">
|
<div class="lane__aside-icon">
|
<svg-icon :icon-class="row.icon" class="lane__icon" />
|
</div>
|
<div class="lane__aside-content">
|
<h2>{{ row.label }}</h2>
|
</div>
|
</div>
|
</aside>
|
|
<div class="lane__flow" :class="`lane__flow--${row.key}`">
|
<template v-for="(item, index) in row.items" :key="item.name">
|
<div
|
class="node-wrap"
|
:class="{ 'node-wrap--spacer': item.spacer }"
|
>
|
<template v-if="item.isCore">
|
<article ref="coreRef" class="ai-core">
|
<div class="ai-core__halo"></div>
|
<div class="ai-core__brain">
|
<img :src="aiHead" alt="AI 核心" class="ai-core__head-image" />
|
</div>
|
</article>
|
</template>
|
|
<template v-else>
|
<article
|
class="node"
|
:class="{ 'node--accent': item.accent, 'node--small-gap': item.compact }"
|
:ref="setNodeRef(item)"
|
>
|
<div class="node__icon-wrapper">
|
<svg-icon :icon-class="item.icon" class="node__icon" />
|
</div>
|
<h3>{{ item.name }}</h3>
|
</article>
|
</template>
|
|
<span
|
v-if="index < row.items.length - 1"
|
class="flow-arrow"
|
:class="{ 'flow-arrow--hidden': item.hideArrowAfter }"
|
></span>
|
</div>
|
</template>
|
</div>
|
</section>
|
</section>
|
</div>
|
</template>
|
|
<script setup>
|
import { nextTick, onBeforeUnmount, onMounted, onUpdated, ref } from 'vue'
|
import aiHead from '@/assets/images/head.svg'
|
|
const architectureRows = [
|
{
|
key: 'basic',
|
label: '基础配置',
|
description: '系统基础信息管理',
|
icon: 'system',
|
items: [
|
{ name: '角色用户管理', icon: 'user' },
|
{ name: '产品维护', icon: 'monitor' },
|
{ name: '审批管理', icon: 'tree', accent: true }
|
]
|
},
|
{
|
key: 'sale',
|
label: '销售配置',
|
description: '客户与销售管理',
|
icon: 'chart',
|
items: [
|
{ name: '客户档案', icon: 'peoples' },
|
{ name: '销售报价', icon: 'form' },
|
{ name: '销售报价', icon: 'chart' },
|
{ name: '销售台账', icon: 'table' },
|
{ name: '发货台账', icon: 'clipboard', accent: true, linkSource: true, aiLink: true },
|
{ name: '销售退货', icon: 'nested', accent: true, aiLink: true },
|
{ name: '客户往来', icon: 'money' },
|
{ name: '指标统计', icon: 'pie-chart' }
|
]
|
},
|
{
|
key: 'purchase',
|
label: '采购配置',
|
description: '采购与供应商管理',
|
icon: 'shopping',
|
items: [
|
{ name: '供应商档案', icon: 'people' },
|
{ name: '采购台账', icon: 'table' },
|
{ name: '采购退货', icon: 'nested', accent: true, aiLink: true },
|
{ isCore: true, hideArrowAfter: true },
|
{ name: '供应商往来', icon: 'money' },
|
{ name: '采购报表', icon: 'chart' }
|
]
|
},
|
{
|
key: 'produce',
|
label: '生产配置',
|
description: '生产过程管理',
|
icon: 'build',
|
items: [
|
{ name: '工序', icon: 'tree' },
|
{ name: 'BOM', icon: 'list' },
|
{ name: '工艺路线', icon: 'guide', accent: true },
|
{ name: '生产订单', icon: 'peoples', aiLink: true },
|
{ name: '生产排产', icon: 'date' },
|
{ name: '生产报工', icon: 'edit' },
|
{ name: '报工台账', icon: 'clipboard' },
|
{ name: '生产核算', icon: 'money', accent: true }
|
]
|
},
|
{
|
key: 'store',
|
label: '仓储物流',
|
description: '库存与出入库管理',
|
icon: 'redis',
|
items: [
|
{ name: '入库管理', icon: 'download' },
|
{ name: '出库管理', icon: 'upload', linkTarget: true },
|
{ name: '库存管理', icon: 'redis-list' }
|
]
|
}
|
]
|
|
const canvasRef = ref(null)
|
const coreRef = ref(null)
|
const sourceNodeRef = ref(null)
|
const targetNodeRef = ref(null)
|
const svgSize = ref({ width: 0, height: 0 })
|
const flowPath = ref('')
|
const aiPaths = ref([])
|
const aiNodeRefs = []
|
let resizeObserver = null
|
|
function setNodeRef(item) {
|
return (el) => {
|
if (item.linkSource) sourceNodeRef.value = el
|
if (item.linkTarget) targetNodeRef.value = el
|
|
if (item.aiLink) {
|
if (el && !aiNodeRefs.some((entry) => entry.key === item.name && entry.el === el)) {
|
aiNodeRefs.push({ key: item.name, el })
|
}
|
}
|
}
|
}
|
|
function resetLines() {
|
flowPath.value = ''
|
aiPaths.value = []
|
}
|
|
function getCenter(rect) {
|
return {
|
x: rect.left + rect.width / 2,
|
y: rect.top + rect.height / 2
|
}
|
}
|
|
function getEdgePoint(sourceRect, targetRect) {
|
const sourceCenter = getCenter(sourceRect)
|
const targetCenter = getCenter(targetRect)
|
const deltaX = targetCenter.x - sourceCenter.x
|
const deltaY = targetCenter.y - sourceCenter.y
|
|
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
return {
|
x: deltaX > 0 ? sourceRect.right : sourceRect.left,
|
y: sourceCenter.y
|
}
|
}
|
|
return {
|
x: sourceCenter.x,
|
y: deltaY > 0 ? sourceRect.bottom : sourceRect.top
|
}
|
}
|
|
function updateLinkLines() {
|
if (!canvasRef.value) {
|
resetLines()
|
return
|
}
|
|
svgSize.value = {
|
width: Math.ceil(canvasRef.value.scrollWidth),
|
height: Math.ceil(canvasRef.value.scrollHeight)
|
}
|
|
if (window.innerWidth <= 1200 || !coreRef.value) {
|
resetLines()
|
return
|
}
|
|
const canvasRect = canvasRef.value.getBoundingClientRect()
|
const coreRect = coreRef.value.getBoundingClientRect()
|
const validAiNodes = aiNodeRefs.filter((entry) => entry.el?.isConnected)
|
|
aiPaths.value = validAiNodes.map((entry) => {
|
const nodeRect = entry.el.getBoundingClientRect()
|
const corePoint = getEdgePoint(coreRect, nodeRect)
|
const nodePoint = getEdgePoint(nodeRect, coreRect)
|
const startX = nodePoint.x - canvasRect.left
|
const startY = nodePoint.y - canvasRect.top
|
const endX = corePoint.x - canvasRect.left
|
const endY = corePoint.y - canvasRect.top
|
const controlY = startY < endY ? startY + (endY - startY) * 0.45 : startY - (startY - endY) * 0.45
|
const controlX = (startX + endX) / 2
|
|
return {
|
key: entry.key,
|
d: `M ${startX} ${startY} C ${controlX} ${controlY}, ${controlX} ${controlY}, ${endX} ${endY}`
|
}
|
})
|
|
if (!sourceNodeRef.value || !targetNodeRef.value) {
|
flowPath.value = ''
|
return
|
}
|
|
const sourceRect = sourceNodeRef.value.getBoundingClientRect()
|
const targetRect = targetNodeRef.value.getBoundingClientRect()
|
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 - 4
|
const middleY = startY + (endY - startY) / 2
|
|
flowPath.value = `M ${startX} ${startY} C ${startX} ${middleY}, ${endX} ${middleY}, ${endX} ${endY}`
|
}
|
|
function handleResize() {
|
requestAnimationFrame(() => {
|
requestAnimationFrame(updateLinkLines)
|
})
|
}
|
|
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: 24px;
|
background: #f0f4fb;
|
}
|
|
.page-header {
|
display: flex;
|
align-items: center;
|
gap: 12px;
|
margin-bottom: 24px;
|
}
|
|
.header-line {
|
width: 4px;
|
height: 20px;
|
background: #2563eb;
|
border-radius: 2px;
|
}
|
|
.page-title {
|
margin: 0;
|
font-size: 18px;
|
font-weight: 700;
|
color: #1f2937;
|
}
|
|
.canvas {
|
position: relative;
|
display: flex;
|
flex-direction: column;
|
gap: 20px;
|
padding: 28px 24px;
|
background: #fff;
|
border: 1px solid rgba(219, 234, 254, 0.9);
|
border-radius: 18px;
|
box-shadow: 0 10px 30px rgba(30, 64, 175, 0.06);
|
}
|
|
.lane {
|
display: grid;
|
grid-template-columns: 190px 1fr;
|
gap: 18px;
|
align-items: stretch;
|
}
|
|
.lane__aside-card {
|
height: 100%;
|
min-height: 138px;
|
padding: 22px 20px;
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
justify-content: center;
|
gap: 14px;
|
background: linear-gradient(180deg, #f8fbff 0%, #eef5ff 100%);
|
border: 1px solid rgba(219, 234, 254, 0.95);
|
border-radius: 18px;
|
box-shadow: inset 0 1px 8px rgba(255, 255, 255, 0.9);
|
}
|
|
.lane__aside-card--sale .lane__aside-icon {
|
background: linear-gradient(180deg, #f3e8ff 0%, #e9d5ff 100%);
|
color: #9333ea;
|
}
|
|
.lane__aside-card--purchase .lane__aside-icon {
|
background: linear-gradient(180deg, #e0f2fe 0%, #bae6fd 100%);
|
color: #0284c7;
|
}
|
|
.lane__aside-card--produce .lane__aside-icon {
|
background: linear-gradient(180deg, #ecfdf5 0%, #d1fae5 100%);
|
color: #16a34a;
|
}
|
|
.lane__aside-card--store .lane__aside-icon {
|
background: linear-gradient(180deg, #eef2ff 0%, #dbeafe 100%);
|
color: #2563eb;
|
}
|
|
.lane__aside-icon {
|
width: 44px;
|
height: 44px;
|
flex-shrink: 0;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
border-radius: 14px;
|
background: linear-gradient(180deg, #e0ecff 0%, #dbeafe 100%);
|
color: #2563eb;
|
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.12);
|
}
|
|
.lane__icon {
|
font-size: 24px;
|
}
|
|
.lane__aside-content h2 {
|
margin: 0;
|
font-size: 18px;
|
font-weight: 700;
|
color: #1d4ed8;
|
line-height: 1.2;
|
text-align: center;
|
}
|
|
.lane__aside-content p {
|
margin: 0;
|
font-size: 13px;
|
color: #7c8aa5;
|
}
|
|
.lane__flow {
|
position: relative;
|
z-index: 2;
|
display: flex;
|
align-items: center;
|
gap: 12px;
|
min-height: 138px;
|
padding: 18px 22px;
|
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
|
border: 1px solid rgba(219, 234, 254, 0.9);
|
border-radius: 18px;
|
box-shadow: inset 0 1px 10px rgba(255, 255, 255, 0.92);
|
}
|
|
.lane__flow--purchase,
|
.lane__flow--produce {
|
padding-left: 28px;
|
padding-right: 28px;
|
}
|
|
.node-wrap {
|
display: flex;
|
align-items: center;
|
gap: 12px;
|
flex-shrink: 0;
|
}
|
|
.node {
|
width: 96px;
|
min-height: 86px;
|
padding: 8px 6px;
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
justify-content: center;
|
gap: 8px;
|
background: transparent;
|
}
|
|
.node__icon-wrapper {
|
width: 58px;
|
height: 58px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
border-radius: 16px;
|
background: linear-gradient(180deg, #ffffff 0%, #f2f7ff 100%);
|
border: 1px solid rgba(191, 219, 254, 0.95);
|
box-shadow:
|
0 8px 18px rgba(37, 99, 235, 0.08),
|
inset 0 1px 8px rgba(255, 255, 255, 0.95);
|
}
|
|
.node__icon {
|
font-size: 26px;
|
color: #2563eb;
|
}
|
|
.node--accent .node__icon-wrapper {
|
background: linear-gradient(180deg, #ffffff 0%, #eef4ff 100%);
|
}
|
|
.node h3 {
|
margin: 0;
|
font-size: 12px;
|
line-height: 1.35;
|
font-weight: 600;
|
color: #334155;
|
text-align: center;
|
}
|
|
.flow-arrow {
|
width: 36px;
|
height: 1px;
|
position: relative;
|
background: repeating-linear-gradient(
|
to right,
|
#bfd7ff 0,
|
#bfd7ff 4px,
|
transparent 4px,
|
transparent 8px
|
);
|
}
|
|
.flow-arrow::after {
|
content: '';
|
position: absolute;
|
top: 50%;
|
right: -1px;
|
width: 6px;
|
height: 6px;
|
border-top: 1px solid #9ec5ff;
|
border-right: 1px solid #9ec5ff;
|
transform: translateY(-50%) rotate(45deg);
|
}
|
|
.flow-arrow--hidden {
|
visibility: hidden;
|
}
|
|
.ai-core {
|
position: relative;
|
width: 252px;
|
height: 176px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
}
|
|
.ai-core__halo {
|
position: absolute;
|
inset: 14px 28px 32px;
|
border-radius: 50%;
|
background: radial-gradient(circle, rgba(96, 165, 250, 0.12) 0%, rgba(96, 165, 250, 0) 72%);
|
transform: scale(1.08);
|
}
|
|
.ai-core__brain {
|
position: absolute;
|
inset: 6px 0 0;
|
left: 50%;
|
width: 232px;
|
height: 156px;
|
transform: translateX(-50%);
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
filter: drop-shadow(0 10px 18px rgba(96, 165, 250, 0.18));
|
}
|
|
.ai-core__head-image {
|
width: 100%;
|
height: 100%;
|
object-fit: contain;
|
}
|
|
.link-overlay {
|
position: absolute;
|
inset: 0;
|
z-index: 3;
|
pointer-events: none;
|
}
|
|
.link-overlay__path {
|
fill: none;
|
stroke: #bfd2ea;
|
stroke-width: 1.2;
|
stroke-dasharray: 4 5;
|
stroke-linecap: round;
|
stroke-linejoin: round;
|
}
|
|
.link-overlay__path--ai {
|
stroke: #bfd7ff;
|
stroke-width: 1.4;
|
}
|
|
@media (max-width: 1200px) {
|
.architecture-page {
|
padding: 16px;
|
}
|
|
.lane {
|
grid-template-columns: 1fr;
|
}
|
|
.lane__aside-card,
|
.lane__flow {
|
min-height: auto;
|
}
|
|
.lane__flow {
|
flex-wrap: wrap;
|
}
|
|
.ai-core {
|
order: 99;
|
width: 100%;
|
max-width: 268px;
|
margin: 0 auto;
|
}
|
|
.link-overlay {
|
display: none;
|
}
|
}
|
|
@media (max-width: 768px) {
|
.canvas {
|
padding: 16px;
|
gap: 16px;
|
}
|
|
.lane__aside-card {
|
min-height: auto;
|
padding: 18px 16px;
|
}
|
|
.lane__flow {
|
padding: 16px;
|
display: grid;
|
grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
|
gap: 12px;
|
}
|
|
.node-wrap {
|
flex-direction: column;
|
gap: 8px;
|
}
|
|
.node {
|
width: 100%;
|
}
|
|
.flow-arrow {
|
width: 1px;
|
height: 16px;
|
margin: 0 auto;
|
background: repeating-linear-gradient(
|
to bottom,
|
#bfd7ff 0,
|
#bfd7ff 4px,
|
transparent 4px,
|
transparent 8px
|
);
|
}
|
|
.flow-arrow::after {
|
top: auto;
|
right: 50%;
|
bottom: -1px;
|
transform: translateX(50%) rotate(135deg);
|
}
|
}
|
</style>
|