feat: update documentation and component demos for backend layered architecture
- Add new LanguageScopeDemo component for backend languages overview - Refactor and simplify existing demo components (ControllerLayerDemo, DtoFlowDemo, DependencyDirectionDemo) - Update .gitignore to exclude .claude/skills directory - Modify backend-related sections in documentation from "后端与全栈" to "后端开发" - Add new backend layered architecture demo components (CleanArchitectureDemo, DependencyDirectionDemo) - Improve documentation structure and content for stage-3 core skills - Fix component initialization timing in CompileVsInterpretDemo and RateLimiterDemo - Add design style prompt reference in frontend documentation
This commit is contained in:
+138
-555
@@ -1,228 +1,78 @@
|
||||
<template>
|
||||
<div class="clean-architecture-demo">
|
||||
<div class="demo-header">
|
||||
<h4>🏗️ 整洁架构(Clean Architecture)与分层架构</h4>
|
||||
<p class="subtitle">
|
||||
分层架构是整洁架构的基础,理解两者的关系有助于构建更灵活的系统
|
||||
</p>
|
||||
<div class="clean-arch-demo">
|
||||
<div class="header">
|
||||
<div class="title">整洁架构与分层架构对比</div>
|
||||
<div class="subtitle">分层架构是整洁架构的基础,理解两者关系有助于构建更灵活的系统</div>
|
||||
</div>
|
||||
|
||||
<!-- 架构对比 -->
|
||||
<div class="architecture-comparison">
|
||||
<div class="comparison-tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
:class="['tab-btn', { active: currentTab === tab.id }]"
|
||||
@click="currentTab = tab.id"
|
||||
>
|
||||
{{ tab.name }}
|
||||
</button>
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="t in tabs" :key="t.id"
|
||||
:class="['tab', { active: current === t.id }]"
|
||||
@click="current = t.id"
|
||||
>{{ t.name }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="current === 'layered'" class="panel">
|
||||
<div class="arch-layers">
|
||||
<div v-for="l in layeredLayers" :key="l.name" :class="['arch-layer', l.cls]">
|
||||
<strong>{{ l.name }}</strong> <span>{{ l.desc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="traits">
|
||||
<strong>传统分层架构特点</strong>
|
||||
<ul>
|
||||
<li>垂直依赖:上层直接依赖下层</li>
|
||||
<li>简单直观:结构清晰,易于理解</li>
|
||||
<li>适合中小型项目:快速开发,上手简单</li>
|
||||
<li>潜在问题:底层变更可能影响上层</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="comparison-content">
|
||||
<!-- 传统分层架构 -->
|
||||
<div
|
||||
v-if="currentTab === 'layered'"
|
||||
class="tab-panel"
|
||||
>
|
||||
<div class="arch-diagram layered">
|
||||
<div class="layer-box controller">
|
||||
<div class="layer-title">
|
||||
Controller 层
|
||||
</div>
|
||||
<div class="layer-desc">
|
||||
接收请求、参数校验
|
||||
</div>
|
||||
</div>
|
||||
<div class="arrow down">
|
||||
⬇️ 依赖
|
||||
</div>
|
||||
<div class="layer-box service">
|
||||
<div class="layer-title">
|
||||
Service 层
|
||||
</div>
|
||||
<div class="layer-desc">
|
||||
业务逻辑、事务管理
|
||||
</div>
|
||||
</div>
|
||||
<div class="arrow down">
|
||||
⬇️ 依赖
|
||||
</div>
|
||||
<div class="layer-box repository">
|
||||
<div class="layer-title">
|
||||
Repository 层
|
||||
</div>
|
||||
<div class="layer-desc">
|
||||
数据访问、ORM 映射
|
||||
</div>
|
||||
</div>
|
||||
<div class="arrow down">
|
||||
⬇️ 依赖
|
||||
</div>
|
||||
<div class="layer-box domain">
|
||||
<div class="layer-title">
|
||||
Domain 层
|
||||
</div>
|
||||
<div class="layer-desc">
|
||||
实体定义、业务规则
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arch-characteristics">
|
||||
<h5>📌 传统分层架构特点</h5>
|
||||
<ul>
|
||||
<li><strong>垂直依赖</strong>:上层直接依赖下层,依赖方向从上到下</li>
|
||||
<li><strong>简单直观</strong>:结构清晰,易于理解和实现</li>
|
||||
<li><strong>适合中小型项目</strong>:快速开发,上手简单</li>
|
||||
<li><strong>潜在问题</strong>:底层变更可能影响上层,循环依赖风险</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="current === 'clean'" class="panel">
|
||||
<div class="clean-layers">
|
||||
<div v-for="l in cleanLayers" :key="l.name" :class="['arch-layer', l.cls]">
|
||||
<strong>{{ l.name }}</strong> <span>{{ l.items }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dep-rule">依赖方向:外层 → 内层,内层不知道外层的存在</div>
|
||||
<div class="traits">
|
||||
<strong>整洁架构特点</strong>
|
||||
<ul>
|
||||
<li>依赖倒置:依赖方向从外到内,通过接口隔离</li>
|
||||
<li>领域为核心:业务逻辑位于中心,独立于框架</li>
|
||||
<li>可测试性强:核心业务可脱离框架单元测试</li>
|
||||
<li>技术无关:可轻松切换数据库、框架等</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 整洁架构 -->
|
||||
<div
|
||||
v-else-if="currentTab === 'clean'"
|
||||
class="tab-panel"
|
||||
>
|
||||
<div class="arch-diagram clean">
|
||||
<div class="clean-layers">
|
||||
<div class="clean-layer framework">
|
||||
<div class="layer-name">
|
||||
框架与驱动层
|
||||
</div>
|
||||
<div class="layer-items">
|
||||
Web / DB / UI / 外部接口
|
||||
</div>
|
||||
</div>
|
||||
<div class="clean-layer interface">
|
||||
<div class="layer-name">
|
||||
接口适配层
|
||||
</div>
|
||||
<div class="layer-items">
|
||||
Controller / Gateway / Presenter
|
||||
</div>
|
||||
</div>
|
||||
<div class="clean-layer application">
|
||||
<div class="layer-name">
|
||||
应用层
|
||||
</div>
|
||||
<div class="layer-items">
|
||||
Service / UseCase / DTO
|
||||
</div>
|
||||
</div>
|
||||
<div class="clean-layer domain">
|
||||
<div class="layer-name">
|
||||
领域层(核心)
|
||||
</div>
|
||||
<div class="layer-items">
|
||||
Entity / ValueObject / DomainService
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dependency-rule">
|
||||
<div class="rule-arrow">
|
||||
<span class="arrow-line" />
|
||||
<span class="arrow-head">◀ 依赖方向</span>
|
||||
</div>
|
||||
<div class="rule-text">
|
||||
外层依赖内层,内层不依赖外层
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arch-characteristics">
|
||||
<h5>📌 整洁架构特点</h5>
|
||||
<ul>
|
||||
<li><strong>依赖倒置</strong>:依赖方向从外到内,通过接口隔离</li>
|
||||
<li><strong>领域为核心</strong>:业务逻辑位于中心,独立于框架</li>
|
||||
<li><strong>可测试性强</strong>:核心业务可脱离框架进行单元测试</li>
|
||||
<li><strong>技术无关</strong>:可轻松切换数据库、框架等外部技术</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else class="panel">
|
||||
<table>
|
||||
<thead><tr><th>特性</th><th>传统分层</th><th>整洁架构</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in compareRows" :key="r.feature">
|
||||
<td>{{ r.feature }}</td><td>{{ r.layered }}</td><td>{{ r.clean }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="rec-grid">
|
||||
<div class="rec-card">
|
||||
<strong>选择传统分层当...</strong>
|
||||
<ul>
|
||||
<li>项目规模较小,业务简单</li>
|
||||
<li>团队对 DDD 不熟悉</li>
|
||||
<li>需要快速上线验证市场</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 对比总结 -->
|
||||
<div
|
||||
v-else
|
||||
class="tab-panel"
|
||||
>
|
||||
<div class="comparison-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>特性</th>
|
||||
<th>传统分层架构</th>
|
||||
<th>整洁架构</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>依赖方向</td>
|
||||
<td>从上到下</td>
|
||||
<td>从外到内</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>核心业务位置</td>
|
||||
<td>Service 层</td>
|
||||
<td>Domain 层(中心)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>框架依赖</td>
|
||||
<td>较深(如 Spring)</td>
|
||||
<td>较浅(通过接口隔离)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>可测试性</td>
|
||||
<td>需要集成测试</td>
|
||||
<td>核心可单元测试</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>学习曲线</td>
|
||||
<td>平缓</td>
|
||||
<td>较陡</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>适用场景</td>
|
||||
<td>中小型项目、快速迭代</td>
|
||||
<td>大型复杂业务、长期维护</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="recommendation">
|
||||
<h5>💡 选型建议</h5>
|
||||
<div class="rec-grid">
|
||||
<div class="rec-card">
|
||||
<div class="rec-title">
|
||||
选择传统分层架构当...
|
||||
</div>
|
||||
<ul>
|
||||
<li>项目规模较小,业务相对简单</li>
|
||||
<li>团队对 DDD 不熟悉</li>
|
||||
<li>需要快速上线,验证市场</li>
|
||||
<li>技术栈相对固定</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="rec-card recommended">
|
||||
<div class="rec-title">
|
||||
选择整洁架构当...
|
||||
</div>
|
||||
<ul>
|
||||
<li>业务复杂,领域模型丰富</li>
|
||||
<li>需要长期维护和演进</li>
|
||||
<li>需要频繁切换技术栈</li>
|
||||
<li>团队有较强的设计能力</li>
|
||||
</ul>
|
||||
<div class="rec-badge">
|
||||
推荐
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rec-card recommended">
|
||||
<strong>选择整洁架构当...</strong>
|
||||
<ul>
|
||||
<li>业务复杂,领域模型丰富</li>
|
||||
<li>需要长期维护和演进</li>
|
||||
<li>需要频繁切换技术栈</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -232,358 +82,91 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const currentTab = ref('layered')
|
||||
|
||||
const current = ref('layered')
|
||||
const tabs = [
|
||||
{ id: 'layered', name: '传统分层' },
|
||||
{ id: 'clean', name: '整洁架构' },
|
||||
{ id: 'comparison', name: '对比总结' }
|
||||
{ id: 'compare', name: '对比总结' }
|
||||
]
|
||||
|
||||
const layeredLayers = [
|
||||
{ name: 'Controller 层', desc: '接收请求、参数校验', cls: 'green' },
|
||||
{ name: 'Service 层', desc: '业务逻辑、事务管理', cls: 'orange' },
|
||||
{ name: 'Repository 层', desc: '数据访问、ORM 映射', cls: 'blue' },
|
||||
{ name: 'Domain 层', desc: '实体定义、业务规则', cls: 'teal' }
|
||||
]
|
||||
|
||||
const cleanLayers = [
|
||||
{ name: '领域层(核心)', items: 'Entity / ValueObject / DomainService', cls: 'teal' },
|
||||
{ name: '应用层', items: 'Service / UseCase / DTO', cls: 'orange' },
|
||||
{ name: '接口适配层', items: 'Controller / Gateway / Presenter', cls: 'blue' },
|
||||
{ name: '框架与驱动层', items: 'Web / DB / UI / 外部接口', cls: 'gray' }
|
||||
]
|
||||
|
||||
const compareRows = [
|
||||
{ feature: '依赖方向', layered: '从上到下', clean: '从外到内' },
|
||||
{ feature: '核心业务位置', layered: 'Service 层', clean: 'Domain 层(中心)' },
|
||||
{ feature: '框架依赖', layered: '较深', clean: '较浅(接口隔离)' },
|
||||
{ feature: '可测试性', layered: '需要集成测试', clean: '核心可单元测试' },
|
||||
{ feature: '学习曲线', layered: '平缓', clean: '较陡' },
|
||||
{ feature: '适用场景', layered: '中小型、快速迭代', clean: '大型复杂、长期维护' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.clean-architecture-demo {
|
||||
padding: 24px;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e4e8ec 100%);
|
||||
border-radius: 12px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
.clean-arch-demo { padding: 20px; background: var(--vp-c-bg-soft); border-radius: 12px; }
|
||||
.header { text-align: center; margin-bottom: 20px; }
|
||||
.title { font-size: 16px; font-weight: 600; color: var(--vp-c-text-1); }
|
||||
.subtitle { font-size: 13px; color: var(--vp-c-text-3); margin-top: 4px; }
|
||||
|
||||
.tabs { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
.tab {
|
||||
padding: 7px 16px; border: 1px solid var(--vp-c-divider); background: var(--vp-c-bg);
|
||||
border-radius: 6px; cursor: pointer; font-size: 13px; color: var(--vp-c-text-2); transition: all .2s;
|
||||
}
|
||||
.tab:hover { color: var(--vp-c-brand-1); border-color: var(--vp-c-brand-1); }
|
||||
.tab.active { background: var(--vp-c-brand-1); border-color: var(--vp-c-brand-1); color: #fff; }
|
||||
|
||||
.panel {
|
||||
padding: 18px; border-radius: 10px;
|
||||
background: var(--vp-c-bg); border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
.arch-layers, .clean-layers { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; }
|
||||
.arch-layer {
|
||||
padding: 12px 14px; border-radius: 6px;
|
||||
background: var(--vp-c-bg-soft); border-left: 3px solid var(--vp-c-divider);
|
||||
font-size: 13px; color: var(--vp-c-text-2);
|
||||
}
|
||||
.arch-layer strong { color: var(--vp-c-text-1); margin-right: 8px; }
|
||||
.arch-layer.green { border-left-color: #10b981; }
|
||||
.arch-layer.orange { border-left-color: #f59e0b; }
|
||||
.arch-layer.blue { border-left-color: #3b82f6; }
|
||||
.arch-layer.teal { border-left-color: #14b8a6; }
|
||||
.arch-layer.gray { border-left-color: #6b7280; }
|
||||
|
||||
.dep-rule {
|
||||
text-align: center; padding: 10px; margin-bottom: 16px; border-radius: 6px;
|
||||
border: 2px dashed var(--vp-c-brand-1); font-size: 13px; color: var(--vp-c-brand-1); font-weight: 500;
|
||||
}
|
||||
|
||||
.demo-header h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #1a1a2e;
|
||||
font-size: 18px;
|
||||
}
|
||||
.traits { padding: 14px; border-radius: 6px; background: var(--vp-c-bg-soft); font-size: 13px; }
|
||||
.traits strong { color: var(--vp-c-text-1); }
|
||||
.traits ul { margin: 8px 0 0; padding-left: 18px; }
|
||||
.traits li { margin: 4px 0; color: var(--vp-c-text-2); line-height: 1.5; }
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12px; margin-bottom: 16px; }
|
||||
th, td { padding: 10px; text-align: left; border-bottom: 1px solid var(--vp-c-divider); color: var(--vp-c-text-2); }
|
||||
th { background: var(--vp-c-bg-soft); font-weight: 600; color: var(--vp-c-text-1); }
|
||||
|
||||
.architecture-comparison {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.comparison-tabs {
|
||||
display: flex;
|
||||
background: #f5f7fa;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: #409eff;
|
||||
background: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tab-btn.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: #409eff;
|
||||
}
|
||||
|
||||
.comparison-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.tab-panel {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Layered Architecture */
|
||||
.arch-diagram.layered {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.layer-box {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
border-left: 4px solid;
|
||||
}
|
||||
|
||||
.layer-box.controller {
|
||||
background: #f0f9ff;
|
||||
border-left-color: #52c41a;
|
||||
}
|
||||
|
||||
.layer-box.service {
|
||||
background: #fff7e6;
|
||||
border-left-color: #fa8c16;
|
||||
}
|
||||
|
||||
.layer-box.repository {
|
||||
background: #e6f7ff;
|
||||
border-left-color: #1890ff;
|
||||
}
|
||||
|
||||
.layer-box.domain {
|
||||
background: #f6ffed;
|
||||
border-left-color: #73d13d;
|
||||
}
|
||||
|
||||
.layer-title {
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.layer-desc {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.arch-characteristics {
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.arch-characteristics h5 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #1a1a2e;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.arch-characteristics ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.arch-characteristics li {
|
||||
margin: 8px 0;
|
||||
color: #595959;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Clean Architecture */
|
||||
.arch-diagram.clean {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.clean-layers {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.clean-layer {
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid;
|
||||
}
|
||||
|
||||
.clean-layer.framework {
|
||||
background: #f0f0f0;
|
||||
border-left-color: #8c8c8c;
|
||||
}
|
||||
|
||||
.clean-layer.interface {
|
||||
background: #e6f7ff;
|
||||
border-left-color: #1890ff;
|
||||
}
|
||||
|
||||
.clean-layer.application {
|
||||
background: #fff7e6;
|
||||
border-left-color: #fa8c16;
|
||||
}
|
||||
|
||||
.clean-layer.domain {
|
||||
background: #f6ffed;
|
||||
border-left-color: #52c41a;
|
||||
}
|
||||
|
||||
.clean-layer .layer-name {
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.clean-layer .layer-items {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.dependency-rule {
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
border: 2px dashed #1890ff;
|
||||
}
|
||||
|
||||
.rule-arrow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.rule-arrow .arrow-line {
|
||||
width: 2px;
|
||||
height: 20px;
|
||||
background: #1890ff;
|
||||
}
|
||||
|
||||
.rule-arrow .arrow-head {
|
||||
color: #1890ff;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.rule-text {
|
||||
color: #595959;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Comparison Table */
|
||||
.comparison-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.comparison-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.comparison-table th,
|
||||
.comparison-table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.comparison-table th {
|
||||
background: #f5f7fa;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.comparison-table tr:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
/* Recommendation */
|
||||
.recommendation {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.recommendation h5 {
|
||||
margin: 0 0 16px 0;
|
||||
color: #1a1a2e;
|
||||
font-size: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rec-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.rec-card {
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rec-card.recommended {
|
||||
background: #f6ffed;
|
||||
border: 2px solid #52c41a;
|
||||
}
|
||||
|
||||
.rec-badge {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
right: 16px;
|
||||
background: #52c41a;
|
||||
color: white;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rec-title {
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.rec-card ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.rec-card li {
|
||||
margin: 6px 0;
|
||||
color: #595959;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.rec-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.rec-card { padding: 14px; border-radius: 6px; background: var(--vp-c-bg-soft); font-size: 12px; }
|
||||
.rec-card strong { font-size: 13px; color: var(--vp-c-text-1); display: block; margin-bottom: 8px; }
|
||||
.rec-card ul { margin: 0; padding-left: 16px; }
|
||||
.rec-card li { margin: 4px 0; color: var(--vp-c-text-2); }
|
||||
.rec-card.recommended { border: 2px solid var(--vp-c-green-1); background: var(--vp-c-green-soft); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.rec-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.rec-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
+95
-353
@@ -1,192 +1,70 @@
|
||||
<template>
|
||||
<div class="controller-layer-demo">
|
||||
<div class="demo-header">
|
||||
<h4>🎮 Controller 层:请求的"接待员"</h4>
|
||||
<p class="subtitle">
|
||||
点击流程节点查看 Controller 如何接收和处理请求
|
||||
</p>
|
||||
<div class="controller-demo">
|
||||
<div class="header">
|
||||
<div class="title">Controller 层:请求的"接待员"</div>
|
||||
<div class="subtitle">点击流程节点查看详情</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-container">
|
||||
<!-- 请求发起 -->
|
||||
<div class="flow-step">
|
||||
<div class="step-icon">
|
||||
🌐
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">
|
||||
客户端发起请求
|
||||
</div>
|
||||
<div class="step-code">
|
||||
POST /api/users/register
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "张三",
|
||||
"email": "zhangsan@example.com",
|
||||
"password": "123456"
|
||||
}
|
||||
</div>
|
||||
<div class="flow">
|
||||
<div class="step">
|
||||
<div class="step-label">客户端发起请求</div>
|
||||
<pre class="step-code">POST /api/users/register
|
||||
Content-Type: application/json
|
||||
{ "username": "张三", "email": "zhangsan@example.com", "password": "123456" }</pre>
|
||||
</div>
|
||||
|
||||
<div class="arrow">↓ 请求到达</div>
|
||||
|
||||
<div :class="['step', 'clickable', { active: detail === 'ctrl' }]" @click="toggle('ctrl')">
|
||||
<div class="step-label accent">Controller 接收并解析请求</div>
|
||||
<pre class="step-code">@RestController
|
||||
@RequestMapping("/api/users")
|
||||
public class UserController {
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<UserDTO> register(
|
||||
@RequestBody @Valid UserRegisterRequest request) {
|
||||
UserDTO user = userService.register(request);
|
||||
return ResponseEntity.ok(user);
|
||||
}
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
<div class="arrow">↓ 参数校验 + 调用</div>
|
||||
|
||||
<div :class="['step', 'clickable', { active: detail === 'valid' }]" @click="toggle('valid')">
|
||||
<div class="step-label warn">参数校验(Controller 的职责之一)</div>
|
||||
<pre class="step-code">public class UserRegisterRequest {
|
||||
@NotBlank(message = "用户名不能为空")
|
||||
@Size(min = 2, max = 20) private String username;
|
||||
@Email(message = "邮箱格式不正确") private String email;
|
||||
@Size(min = 6, message = "密码至少6位") private String password;
|
||||
}</pre>
|
||||
<div v-if="detail === 'valid'" class="detail-box">
|
||||
<strong>为什么校验要放在 Controller?</strong>
|
||||
<ul>
|
||||
<li>第一道防线:尽早拦截非法请求</li>
|
||||
<li>减轻下游压力:Service 层可以假设数据已清洗</li>
|
||||
<li>关注点分离:Service 专注于业务,不处理格式验证</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow-connector">
|
||||
⬇️ 请求到达
|
||||
</div>
|
||||
<div class="arrow">↓ 返回结果</div>
|
||||
|
||||
<!-- Controller 接收 -->
|
||||
<div
|
||||
class="flow-step controller-step"
|
||||
:class="{ active: showDetails === 'controller' }"
|
||||
@click="toggleDetails('controller')"
|
||||
>
|
||||
<div class="step-icon">
|
||||
🎮
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">
|
||||
Controller 接收并解析请求
|
||||
</div>
|
||||
<div class="step-code">
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
public class UserController {
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<UserDTO> register(
|
||||
@RequestBody @Valid UserRegisterRequest request
|
||||
) {
|
||||
// 调用 Service 处理业务
|
||||
UserDTO user = userService.register(request);
|
||||
return ResponseEntity.ok(user);
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow-connector">
|
||||
⬇️ 参数校验 + 调用
|
||||
</div>
|
||||
|
||||
<!-- 校验逻辑 -->
|
||||
<div
|
||||
class="flow-step validation-step"
|
||||
:class="{ active: showDetails === 'validation' }"
|
||||
@click="toggleDetails('validation')"
|
||||
>
|
||||
<div class="step-icon">
|
||||
✅
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">
|
||||
参数校验(Controller 的职责之一)
|
||||
</div>
|
||||
<div class="step-code">
|
||||
public class UserRegisterRequest {
|
||||
@NotBlank(message = "用户名不能为空")
|
||||
@Size(min = 2, max = 20, message = "用户名长度2-20")
|
||||
private String username;
|
||||
|
||||
@Email(message = "邮箱格式不正确")
|
||||
private String email;
|
||||
|
||||
@Size(min = 6, message = "密码至少6位")
|
||||
private String password;
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
v-if="showDetails === 'validation'"
|
||||
class="detail-panel"
|
||||
>
|
||||
<h5>为什么校验要放在 Controller?</h5>
|
||||
<ul>
|
||||
<li>🛡️ 第一道防线:尽早拦截非法请求</li>
|
||||
<li>📦 减轻下游压力:Service 层可以假设数据已清洗</li>
|
||||
<li>🔧 关注点分离:Service 专注于业务,不处理格式验证</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow-connector">
|
||||
⬇️ 返回结果
|
||||
</div>
|
||||
|
||||
<!-- 响应返回 -->
|
||||
<div class="flow-step">
|
||||
<div class="step-icon">
|
||||
📤
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">
|
||||
Controller 封装响应返回给客户端
|
||||
</div>
|
||||
<div class="step-code">
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"code": 200,
|
||||
"message": "注册成功",
|
||||
"data": {
|
||||
"id": 10001,
|
||||
"username": "张三",
|
||||
"email": "zhangsan@example.com",
|
||||
"createdAt": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-label">Controller 封装响应返回</div>
|
||||
<pre class="step-code">HTTP/1.1 200 OK
|
||||
{ "code": 200, "message": "注册成功",
|
||||
"data": { "id": 10001, "username": "张三", "email": "zhangsan@example.com" } }</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controller 职责总结 -->
|
||||
<div class="controller-summary">
|
||||
<h5>🎯 Controller 的核心职责</h5>
|
||||
<div class="duties">
|
||||
<div class="duties-title">Controller 的核心职责</div>
|
||||
<div class="duty-grid">
|
||||
<div class="duty-item">
|
||||
<div class="duty-icon">
|
||||
📡
|
||||
</div>
|
||||
<div class="duty-title">
|
||||
接收请求
|
||||
</div>
|
||||
<div class="duty-desc">
|
||||
映射 HTTP 请求到方法
|
||||
</div>
|
||||
</div>
|
||||
<div class="duty-item">
|
||||
<div class="duty-icon">
|
||||
✅
|
||||
</div>
|
||||
<div class="duty-title">
|
||||
参数校验
|
||||
</div>
|
||||
<div class="duty-desc">
|
||||
基础格式和必填校验
|
||||
</div>
|
||||
</div>
|
||||
<div class="duty-item">
|
||||
<div class="duty-icon">
|
||||
🔄
|
||||
</div>
|
||||
<div class="duty-title">
|
||||
调用 Service
|
||||
</div>
|
||||
<div class="duty-desc">
|
||||
将请求转发给业务层
|
||||
</div>
|
||||
</div>
|
||||
<div class="duty-item">
|
||||
<div class="duty-icon">
|
||||
📦
|
||||
</div>
|
||||
<div class="duty-title">
|
||||
封装响应
|
||||
</div>
|
||||
<div class="duty-desc">
|
||||
统一响应格式返回
|
||||
</div>
|
||||
<div class="duty" v-for="d in duties" :key="d.name">
|
||||
<div class="duty-name">{{ d.name }}</div>
|
||||
<div class="duty-desc">{{ d.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -196,196 +74,60 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const showDetails = ref('')
|
||||
const detail = ref('')
|
||||
const toggle = (s) => { detail.value = detail.value === s ? '' : s }
|
||||
|
||||
const toggleDetails = (section) => {
|
||||
showDetails.value = showDetails.value === section ? '' : section
|
||||
}
|
||||
const duties = [
|
||||
{ name: '接收请求', desc: '映射 HTTP 请求到方法' },
|
||||
{ name: '参数校验', desc: '基础格式和必填校验' },
|
||||
{ name: '调用 Service', desc: '将请求转发给业务层' },
|
||||
{ name: '封装响应', desc: '统一响应格式返回' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.controller-layer-demo {
|
||||
padding: 24px;
|
||||
background: linear-gradient(135deg, #f0f7ff 0%, #e6f0ff 100%);
|
||||
border-radius: 12px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
.controller-demo { padding: 20px; background: var(--vp-c-bg-soft); border-radius: 12px; }
|
||||
.header { text-align: center; margin-bottom: 20px; }
|
||||
.title { font-size: 16px; font-weight: 600; color: var(--vp-c-text-1); }
|
||||
.subtitle { font-size: 13px; color: var(--vp-c-text-3); margin-top: 4px; }
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.flow { display: flex; flex-direction: column; gap: 8px; }
|
||||
.arrow { text-align: center; color: var(--vp-c-text-3); font-size: 12px; }
|
||||
|
||||
.demo-header h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #1a1a2e;
|
||||
font-size: 18px;
|
||||
.step {
|
||||
padding: 14px; border-radius: 8px;
|
||||
background: var(--vp-c-bg); border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
.step.clickable { cursor: pointer; transition: all .2s; }
|
||||
.step.clickable:hover { box-shadow: 0 2px 8px rgba(0,0,0,.06); }
|
||||
.step.active { border-color: var(--vp-c-brand-1); box-shadow: 0 0 0 2px var(--vp-c-brand-soft); }
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.flow-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.flow-step:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.flow-step.active {
|
||||
border: 2px solid #409eff;
|
||||
box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.2);
|
||||
}
|
||||
|
||||
.controller-step {
|
||||
border-left: 4px solid #67c23a;
|
||||
}
|
||||
|
||||
.validation-step {
|
||||
border-left: 4px solid #e6a23c;
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.step-label { font-weight: 600; font-size: 13px; color: var(--vp-c-text-1); margin-bottom: 8px; }
|
||||
.step-label.accent { color: #10b981; }
|
||||
.step-label.warn { color: #f59e0b; }
|
||||
|
||||
.step-code {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 11px;
|
||||
color: #333;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
margin: 0; padding: 10px; border-radius: 6px; overflow-x: auto;
|
||||
background: var(--vp-c-bg-soft); font-size: 11px; line-height: 1.5;
|
||||
color: var(--vp-c-text-2); font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.arrow-connector {
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
font-weight: 500;
|
||||
.detail-box {
|
||||
margin-top: 12px; padding: 12px; border-radius: 6px;
|
||||
background: var(--vp-c-brand-soft); border-left: 3px solid var(--vp-c-brand-1);
|
||||
font-size: 12px; color: var(--vp-c-text-1); line-height: 1.6;
|
||||
}
|
||||
.detail-box ul { margin: 8px 0 0; padding-left: 18px; }
|
||||
.detail-box li { margin: 4px 0; }
|
||||
|
||||
.detail-panel {
|
||||
margin-top: 12px;
|
||||
padding: 16px;
|
||||
background: #f0f7ff;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid #409eff;
|
||||
}
|
||||
|
||||
.detail-panel h5 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #1a1a2e;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.detail-panel ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.detail-panel li {
|
||||
margin: 6px 0;
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.controller-summary {
|
||||
margin-top: 24px;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.controller-summary h5 {
|
||||
margin: 0 0 16px 0;
|
||||
color: #1a1a2e;
|
||||
font-size: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.duty-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.duty-item {
|
||||
text-align: center;
|
||||
padding: 16px 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.duty-item:hover {
|
||||
background: #e6f7ff;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.duty-icon {
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.duty-title {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
font-size: 13px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.duty-desc {
|
||||
color: #909399;
|
||||
font-size: 11px;
|
||||
}
|
||||
.duties { margin-top: 20px; padding: 16px; border-radius: 8px; background: var(--vp-c-bg); border: 1px solid var(--vp-c-divider); }
|
||||
.duties-title { text-align: center; font-weight: 600; font-size: 14px; color: var(--vp-c-text-1); margin-bottom: 12px; }
|
||||
.duty-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
|
||||
.duty { text-align: center; padding: 12px 8px; background: var(--vp-c-bg-soft); border-radius: 6px; }
|
||||
.duty-name { font-weight: 600; font-size: 13px; color: var(--vp-c-text-1); margin-bottom: 4px; }
|
||||
.duty-desc { font-size: 11px; color: var(--vp-c-text-3); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.duty-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
width: 100%;
|
||||
}
|
||||
.duty-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
</style>
|
||||
|
||||
+50
-308
@@ -1,341 +1,83 @@
|
||||
<template>
|
||||
<div class="dependency-direction-demo">
|
||||
<div class="demo-header">
|
||||
<h4>🔄 依赖方向:分层架构的核心规则</h4>
|
||||
<p class="subtitle">
|
||||
理解依赖方向,才能真正掌握分层架构
|
||||
</p>
|
||||
<div class="dep-demo">
|
||||
<div class="header">
|
||||
<div class="title">依赖方向:分层架构的核心规则</div>
|
||||
<div class="subtitle">理解依赖方向,才能真正掌握分层架构</div>
|
||||
</div>
|
||||
|
||||
<!-- 依赖方向可视化 -->
|
||||
<div class="direction-visualization">
|
||||
<div class="arch-diagram">
|
||||
<!-- 外层 -->
|
||||
<div class="content-box">
|
||||
<div class="layers">
|
||||
<div class="layer outer">
|
||||
<div class="layer-label">
|
||||
外层(UI / 外部系统)
|
||||
</div>
|
||||
<div class="layer-box">
|
||||
Controller
|
||||
</div>
|
||||
<div class="layer-label">外层(UI / 外部系统)</div>
|
||||
<div class="layer-box">Controller</div>
|
||||
</div>
|
||||
|
||||
<!-- 依赖箭头 -->
|
||||
<div class="dependency-arrow down">
|
||||
<span class="arrow-line" />
|
||||
<span class="arrow-head">▶️ 依赖</span>
|
||||
</div>
|
||||
|
||||
<!-- 中层 -->
|
||||
<div class="dep-arrow">↓ 依赖</div>
|
||||
<div class="layer middle">
|
||||
<div class="layer-label">
|
||||
中层(应用层)
|
||||
</div>
|
||||
<div class="layer-box">
|
||||
Service
|
||||
</div>
|
||||
<div class="layer-label">中层(应用层)</div>
|
||||
<div class="layer-box">Service</div>
|
||||
</div>
|
||||
|
||||
<!-- 依赖箭头 -->
|
||||
<div class="dependency-arrow down">
|
||||
<span class="arrow-line" />
|
||||
<span class="arrow-head">▶️ 依赖</span>
|
||||
</div>
|
||||
|
||||
<!-- 内层 -->
|
||||
<div class="dep-arrow">↓ 依赖</div>
|
||||
<div class="layer inner">
|
||||
<div class="layer-label">
|
||||
内层(领域层)
|
||||
</div>
|
||||
<div class="layer-box">
|
||||
Domain / Repository
|
||||
</div>
|
||||
<div class="layer-label">内层(领域层)</div>
|
||||
<div class="layer-box">Domain / Repository</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 核心原则说明 -->
|
||||
<div class="principle-box">
|
||||
<div class="principle-title">
|
||||
🎯 核心原则:依赖倒置(DIP)
|
||||
</div>
|
||||
<div class="principle-content">
|
||||
<p><strong>上层模块不应该依赖下层模块的具体实现,而应该依赖于抽象。</strong></p>
|
||||
<div class="rule-list">
|
||||
<div class="rule-item">
|
||||
<span class="rule-icon">✅</span>
|
||||
<div class="rule-text">
|
||||
<strong>Controller → Service 接口</strong>
|
||||
<div class="rule-desc">
|
||||
Controller 只依赖 Service 的接口,不依赖实现类
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rule-item">
|
||||
<span class="rule-icon">✅</span>
|
||||
<div class="rule-text">
|
||||
<strong>Service → Repository 接口</strong>
|
||||
<div class="rule-desc">
|
||||
Service 只依赖 Repository 接口,不关心数据怎么存
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rule-item">
|
||||
<span class="rule-icon">✅</span>
|
||||
<div class="rule-text">
|
||||
<strong>所有层依赖 Domain</strong>
|
||||
<div class="rule-desc">
|
||||
Domain 是核心,被所有上层依赖,但 Domain 不依赖任何层
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-title">核心原则:依赖倒置(DIP)</div>
|
||||
<p>上层模块不应该依赖下层模块的具体实现,而应该依赖于抽象。</p>
|
||||
<div class="rules">
|
||||
<div v-for="r in rules" :key="r.title" class="rule">
|
||||
<strong>{{ r.title }}</strong>
|
||||
<div class="rule-desc">{{ r.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 依赖方向示意图 -->
|
||||
<div class="direction-diagram">
|
||||
<h5>📊 依赖方向示意图</h5>
|
||||
<div class="diagram-content">
|
||||
<pre class="diagram-code">
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Controller Layer │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ UserController │ │
|
||||
│ │ - @Autowired private IUserService userService; │ │
|
||||
│ │ ✅ 依赖接口,不依赖实现 │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ 依赖(Dependency) │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ Service Layer │ │
|
||||
│ │ ┌────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ UserServiceImpl │ │ │
|
||||
│ │ │ - @Autowired private UserRepository repository; │ │ │
|
||||
│ │ │ ✅ 依赖 Repository 接口 │ │ │
|
||||
│ │ └────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ▼ 依赖 │ │
|
||||
│ │ ┌────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ Repository Layer │ │ │
|
||||
│ │ │ ┌──────────────────────────────────────────┐ │ │ │
|
||||
│ │ │ │ UserRepository │ │ │ │
|
||||
│ │ │ │ - extends JpaRepository<User, Long> │ │ │ │
|
||||
│ │ │ └──────────────────────────────────────────┘ │ │ │
|
||||
│ │ │ │ │ │ │
|
||||
│ │ │ ▼ 依赖 │ │ │
|
||||
│ │ │ ┌──────────────────────────────────────────┐ │ │ │
|
||||
│ │ │ │ Domain Layer (核心领域) │ │ │ │
|
||||
│ │ │ │ ┌────────────────────────────────────┐ │ │ │ │
|
||||
│ │ │ │ │ User (Entity) │ │ │ │ │
|
||||
│ │ │ │ │ - 不包含任何层依赖 │ │ │ │ │
|
||||
│ │ │ │ │ - 被所有层依赖 │ │ │ │ │
|
||||
│ │ │ │ └────────────────────────────────────┘ │ │ │ │
|
||||
│ │ │ └──────────────────────────────────────────┘ │ │ │
|
||||
│ │ └────────────────────────────────────────────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Component logic can be added here if needed
|
||||
const rules = [
|
||||
{ title: 'Controller → Service 接口', desc: 'Controller 只依赖 Service 的接口,不依赖实现类' },
|
||||
{ title: 'Service → Repository 接口', desc: 'Service 只依赖 Repository 接口,不关心数据怎么存' },
|
||||
{ title: '所有层依赖 Domain', desc: 'Domain 是核心,被所有上层依赖,但 Domain 不依赖任何层' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dependency-direction-demo {
|
||||
padding: 24px;
|
||||
background: linear-gradient(135deg, #f0f7ff 0%, #e6f0ff 100%);
|
||||
border-radius: 12px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
.dep-demo { padding: 20px; background: var(--vp-c-bg-soft); border-radius: 12px; }
|
||||
.header { text-align: center; margin-bottom: 20px; }
|
||||
.title { font-size: 16px; font-weight: 600; color: var(--vp-c-text-1); }
|
||||
.subtitle { font-size: 13px; color: var(--vp-c-text-3); margin-top: 4px; }
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
.content-box {
|
||||
padding: 18px; border-radius: 10px;
|
||||
background: var(--vp-c-bg); border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.demo-header h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #1a1a2e;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.direction-visualization {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.arch-diagram {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.layer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.layer-label {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.layers { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; }
|
||||
.layer-label { font-size: 11px; color: var(--vp-c-text-3); margin-bottom: 4px; }
|
||||
.layer-box {
|
||||
padding: 16px 20px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
text-align: center;
|
||||
border-left: 4px solid #409eff;
|
||||
}
|
||||
|
||||
.layer.outer .layer-box {
|
||||
border-left-color: #67c23a;
|
||||
}
|
||||
|
||||
.layer.middle .layer-box {
|
||||
border-left-color: #e6a23c;
|
||||
}
|
||||
|
||||
.layer.inner .layer-box {
|
||||
border-left-color: #409eff;
|
||||
}
|
||||
|
||||
.dependency-arrow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.arrow-line {
|
||||
width: 2px;
|
||||
height: 12px;
|
||||
background: #dcdfe6;
|
||||
}
|
||||
|
||||
.arrow-head {
|
||||
color: #909399;
|
||||
font-size: 11px;
|
||||
margin-top: 2px;
|
||||
padding: 14px; border-radius: 6px; text-align: center;
|
||||
font-weight: 500; color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-soft); border-left: 3px solid var(--vp-c-divider);
|
||||
}
|
||||
.layer.outer .layer-box { border-left-color: #10b981; }
|
||||
.layer.middle .layer-box { border-left-color: #f59e0b; }
|
||||
.layer.inner .layer-box { border-left-color: #3b82f6; }
|
||||
.dep-arrow { text-align: center; color: var(--vp-c-text-3); font-size: 12px; }
|
||||
|
||||
.principle-box {
|
||||
background: linear-gradient(135deg, #e6f7ff 0%, #f0f7ff 100%);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
border-left: 4px solid #1890ff;
|
||||
padding: 16px; border-radius: 8px;
|
||||
background: var(--vp-c-brand-soft); border-left: 3px solid var(--vp-c-brand-1);
|
||||
}
|
||||
.p-title { font-size: 14px; font-weight: 600; color: var(--vp-c-text-1); margin-bottom: 8px; }
|
||||
.principle-box p { margin: 0 0 12px; font-size: 13px; color: var(--vp-c-text-2); line-height: 1.6; }
|
||||
|
||||
.principle-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.principle-content p {
|
||||
margin: 0 0 12px 0;
|
||||
color: #595959;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.rule-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rule-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.rule-icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rule-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rule-text strong {
|
||||
color: #1a1a2e;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rule-desc {
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.direction-diagram {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.direction-diagram h5 {
|
||||
margin: 0 0 16px 0;
|
||||
color: #1a1a2e;
|
||||
font-size: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.diagram-content {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.diagram-code {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: #595959;
|
||||
margin: 0;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.model-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.comparison-tabs {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.rules { display: flex; flex-direction: column; gap: 8px; }
|
||||
.rule {
|
||||
padding: 10px; border-radius: 6px;
|
||||
background: var(--vp-c-bg); font-size: 13px; color: var(--vp-c-text-1);
|
||||
}
|
||||
.rule-desc { font-size: 12px; color: var(--vp-c-text-3); margin-top: 2px; }
|
||||
</style>
|
||||
|
||||
+177
-489
@@ -1,298 +1,68 @@
|
||||
<template>
|
||||
<div class="domain-model-demo">
|
||||
<div class="demo-header">
|
||||
<h4>📦 Domain 层:领域模型设计</h4>
|
||||
<p class="subtitle">
|
||||
Domain 是业务概念的载体,所有层的依赖基础
|
||||
</p>
|
||||
<div class="domain-demo">
|
||||
<div class="header">
|
||||
<div class="title">Domain 层:领域模型设计</div>
|
||||
<div class="subtitle">Domain 是业务概念的载体,所有层的依赖基础</div>
|
||||
</div>
|
||||
|
||||
<!-- 领域模型对比 -->
|
||||
<div class="model-comparison">
|
||||
<div class="comparison-tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
:class="['tab-btn', { active: currentTab === tab.id }]"
|
||||
@click="currentTab = tab.id"
|
||||
>
|
||||
{{ tab.name }}
|
||||
</button>
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="t in tabs" :key="t.id"
|
||||
:class="['tab', { active: current === t.id }]"
|
||||
@click="current = t.id"
|
||||
>{{ t.name }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="current === 'comparison'" class="cards">
|
||||
<div class="card bad">
|
||||
<div class="card-head">
|
||||
<span class="card-title">贫血模型 (Anemic)</span>
|
||||
<span class="card-badge bad">传统做法</span>
|
||||
</div>
|
||||
<pre class="code"><code>{{ anemicEntity }}</code></pre>
|
||||
<pre class="code"><code>{{ anemicService }}</code></pre>
|
||||
<div class="result-box bad">
|
||||
<strong>贫血模型的问题</strong>
|
||||
<ul>
|
||||
<li>违背面向对象:对象只有数据没有行为</li>
|
||||
<li>逻辑分散:同样的规则可能在多个 Service 重复</li>
|
||||
<li>难以维护:改一个规则要找所有用到的地方</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="comparison-content">
|
||||
<!-- 贫血模型 vs 充血模型 -->
|
||||
<div
|
||||
v-if="currentTab === 'comparison'"
|
||||
class="tab-panel"
|
||||
>
|
||||
<div class="model-cards">
|
||||
<div class="model-card anemic">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">📄</span>
|
||||
<span class="card-title">贫血模型 (Anemic)</span>
|
||||
<span class="card-badge">传统做法</span>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="code-section">
|
||||
<div class="code-label">
|
||||
Entity(只有 getter/setter)
|
||||
</div>
|
||||
<pre><code>@Entity
|
||||
public class Order {
|
||||
@Id
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private BigDecimal totalAmount;
|
||||
private OrderStatus status;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
// 只有 getter/setter,没有业务逻辑
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
// ... 其他 getter/setter
|
||||
}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="code-section">
|
||||
<div class="code-label">
|
||||
Service(所有业务逻辑都在这里)
|
||||
</div>
|
||||
<pre><code>@Service
|
||||
public class OrderService {
|
||||
|
||||
public void cancelOrder(Long orderId) {
|
||||
Order order = orderRepository.findById(orderId)
|
||||
.orElseThrow();
|
||||
|
||||
// 贫血模型:业务逻辑散落在 Service 里
|
||||
if (order.getStatus() == OrderStatus.SHIPPED) {
|
||||
throw new IllegalStateException("已发货订单不能取消");
|
||||
}
|
||||
if (order.getStatus() == OrderStatus.CANCELLED) {
|
||||
throw new IllegalStateException("订单已取消");
|
||||
}
|
||||
|
||||
// 修改状态
|
||||
order.setStatus(OrderStatus.CANCELLED);
|
||||
orderRepository.save(order);
|
||||
}
|
||||
}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="problems">
|
||||
<div class="problem-title">
|
||||
😫 贫血模型的问题
|
||||
</div>
|
||||
<ul>
|
||||
<li><strong>违背面向对象</strong>:对象只有数据没有行为,变成了 "数据结构"</li>
|
||||
<li><strong>逻辑分散</strong>:同样的业务规则可能在多个 Service 重复</li>
|
||||
<li><strong>难以维护</strong>:改一个规则要找所有用到的地方</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="model-card rich">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">🧠</span>
|
||||
<span class="card-title">充血模型 (Rich Domain)</span>
|
||||
<span class="card-badge tag-green">推荐做法</span>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="code-section">
|
||||
<div class="code-label">
|
||||
Entity(包含业务逻辑)
|
||||
</div>
|
||||
<pre><code>@Entity
|
||||
public class Order {
|
||||
@Id
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private BigDecimal totalAmount;
|
||||
private OrderStatus status;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
// 🎯 业务行为封装在实体里
|
||||
|
||||
/**
|
||||
* 取消订单
|
||||
*/
|
||||
public void cancel() {
|
||||
// 状态校验内聚在方法里
|
||||
if (this.status == OrderStatus.SHIPPED) {
|
||||
throw new IllegalStateException("已发货订单不能取消");
|
||||
}
|
||||
if (this.status == OrderStatus.CANCELLED) {
|
||||
throw new IllegalStateException("订单已取消");
|
||||
}
|
||||
|
||||
this.status = OrderStatus.CANCELLED;
|
||||
// 可以触发领域事件
|
||||
registerEvent(new OrderCancelledEvent(this.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付订单
|
||||
*/
|
||||
public void pay(Payment payment) {
|
||||
if (this.status != OrderStatus.PENDING_PAYMENT) {
|
||||
throw new IllegalStateException("订单状态不正确");
|
||||
}
|
||||
if (!payment.getAmount().equals(this.totalAmount)) {
|
||||
throw new IllegalArgumentException("支付金额不匹配");
|
||||
}
|
||||
|
||||
this.status = OrderStatus.PAID;
|
||||
this.paymentTime = LocalDateTime.now();
|
||||
}
|
||||
|
||||
// ... 其他业务方法
|
||||
}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="code-section">
|
||||
<div class="code-label">
|
||||
Service(只做协调,不做业务判断)
|
||||
</div>
|
||||
<pre><code>@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OrderService {
|
||||
|
||||
private final OrderRepository orderRepository;
|
||||
private final DomainEventPublisher eventPublisher;
|
||||
|
||||
@Transactional
|
||||
public void cancelOrder(Long orderId) {
|
||||
// 1. 加载聚合根
|
||||
Order order = orderRepository.findById(orderId)
|
||||
.orElseThrow(() -> new OrderNotFoundException(orderId));
|
||||
|
||||
// 2. 💡 调用领域对象的业务方法
|
||||
// 业务规则封装在 Order 里,Service 只做协调
|
||||
order.cancel();
|
||||
|
||||
// 3. 保存变更
|
||||
orderRepository.save(order);
|
||||
|
||||
// 4. 发布领域事件
|
||||
order.getDomainEvents().forEach(eventPublisher::publish);
|
||||
order.clearDomainEvents();
|
||||
}
|
||||
}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="benefits">
|
||||
<div class="benefit-title">
|
||||
😊 充血模型的优势
|
||||
</div>
|
||||
<ul>
|
||||
<li><strong>符合面向对象</strong>:数据和行为封装在一起,是真正的 "对象"</li>
|
||||
<li><strong>业务内聚</strong>:规则跟着对象走,改一处处处生效</li>
|
||||
<li><strong>可复用可测试</strong>:领域对象是纯内存对象,单元测试不需要数据库</li>
|
||||
<li><strong>表达力强</strong>:order.cancel() 比 orderService.cancel(order) 更自然</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card good">
|
||||
<div class="card-head">
|
||||
<span class="card-title">充血模型 (Rich Domain)</span>
|
||||
<span class="card-badge good">推荐做法</span>
|
||||
</div>
|
||||
<pre class="code"><code>{{ richEntity }}</code></pre>
|
||||
<pre class="code"><code>{{ richService }}</code></pre>
|
||||
<div class="result-box good">
|
||||
<strong>充血模型的优势</strong>
|
||||
<ul>
|
||||
<li>符合面向对象:数据和行为封装在一起</li>
|
||||
<li>业务内聚:规则跟着对象走,改一处处处生效</li>
|
||||
<li>可测试:领域对象是纯内存对象,不需要数据库</li>
|
||||
<li>表达力强:order.cancel() 比 orderService.cancel(order) 更自然</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 值对象 -->
|
||||
<div
|
||||
v-else-if="currentTab === 'valueobject'"
|
||||
class="tab-panel"
|
||||
>
|
||||
<div class="value-object-content">
|
||||
<div class="concept-intro">
|
||||
<h5>💎 什么是值对象(Value Object)?</h5>
|
||||
<p>值对象是没有唯一标识、不可变的对象,它描述了某种特征或属性。两个值对象如果所有属性相等,就被认为是同一个对象。</p>
|
||||
</div>
|
||||
|
||||
<div class="vo-examples">
|
||||
<div class="example-card">
|
||||
<div class="example-title">
|
||||
📍 地址 Address
|
||||
</div>
|
||||
<pre><code>// 值对象:不可变、无 ID
|
||||
public record Address(
|
||||
String province, // 省
|
||||
String city, // 市
|
||||
String district, // 区
|
||||
String street, // 街道
|
||||
String zipCode // 邮编
|
||||
) {
|
||||
// 值对象的方法通常是转换或计算
|
||||
public String toDisplayString() {
|
||||
return String.format("%s%s%s%s",
|
||||
province, city, district, street);
|
||||
}
|
||||
|
||||
// 校验逻辑
|
||||
public boolean isValid() {
|
||||
return StringUtils.isNotBlank(province)
|
||||
&& StringUtils.isNotBlank(city);
|
||||
}
|
||||
}
|
||||
|
||||
// 使用:地址相等只要属性相同
|
||||
Address addr1 = new Address("广东", "深圳", "南山", "科技园", "518000");
|
||||
Address addr2 = new Address("广东", "深圳", "南山", "科技园", "518000");
|
||||
|
||||
System.out.println(addr1.equals(addr2)); // true - 值对象比较的是值</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="example-card">
|
||||
<div class="example-title">
|
||||
💰 金钱 Money
|
||||
</div>
|
||||
<pre><code>// 金钱是经典的值对象
|
||||
public record Money(
|
||||
BigDecimal amount,
|
||||
Currency currency
|
||||
) {
|
||||
|
||||
// 工厂方法
|
||||
public static Money of(BigDecimal amount, String currencyCode) {
|
||||
return new Money(amount, Currency.getInstance(currencyCode));
|
||||
}
|
||||
|
||||
public static Money yuan(BigDecimal amount) {
|
||||
return new Money(amount, Currency.getInstance("CNY"));
|
||||
}
|
||||
|
||||
// 值对象的核心:运算返回新的值对象
|
||||
public Money add(Money other) {
|
||||
if (!this.currency.equals(other.currency)) {
|
||||
throw new IllegalArgumentException("Cannot add different currencies");
|
||||
}
|
||||
return new Money(this.amount.add(other.amount), this.currency);
|
||||
}
|
||||
|
||||
public Money multiply(int factor) {
|
||||
return new Money(this.amount.multiply(BigDecimal.valueOf(factor)), currency);
|
||||
}
|
||||
|
||||
public boolean isGreaterThan(Money other) {
|
||||
return this.amount.compareTo(other.amount) > 0;
|
||||
}
|
||||
|
||||
// 格式化显示
|
||||
public String toDisplayString() {
|
||||
return currency.getSymbol() + amount.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
}
|
||||
|
||||
// 使用示例
|
||||
Money price = Money.yuan(new BigDecimal("199.99"));
|
||||
Money shipping = Money.yuan(new BigDecimal("10.00"));
|
||||
Money discount = Money.yuan(new BigDecimal("20.00"));
|
||||
|
||||
Money total = price.add(shipping).add(discount.negate());
|
||||
System.out.println(total.toDisplayString()); // ¥189.99</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="vo-section">
|
||||
<div class="vo-intro">
|
||||
<strong>什么是值对象(Value Object)?</strong>
|
||||
<p>没有唯一标识、不可变的对象,描述某种特征或属性。两个值对象所有属性相等就被认为是同一个。</p>
|
||||
</div>
|
||||
<div class="vo-examples">
|
||||
<div class="vo-card">
|
||||
<div class="vo-name">地址 Address</div>
|
||||
<pre class="code"><code>{{ addressVO }}</code></pre>
|
||||
</div>
|
||||
<div class="vo-card">
|
||||
<div class="vo-name">金钱 Money</div>
|
||||
<pre class="code"><code>{{ moneyVO }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -302,224 +72,142 @@ System.out.println(total.toDisplayString()); // ¥189.99</code></pre>
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const currentTab = ref('comparison')
|
||||
|
||||
const current = ref('comparison')
|
||||
const tabs = [
|
||||
{ id: 'comparison', name: '贫血 vs 充血' },
|
||||
{ id: 'valueobject', name: '值对象设计' }
|
||||
]
|
||||
|
||||
const anemicEntity = `@Entity
|
||||
public class Order {
|
||||
@Id private Long id;
|
||||
private BigDecimal totalAmount;
|
||||
private OrderStatus status;
|
||||
// 只有 getter/setter,没有业务逻辑
|
||||
public Long getId() { return id; }
|
||||
public void setStatus(OrderStatus s) { this.status = s; }
|
||||
}`
|
||||
|
||||
const anemicService = `@Service
|
||||
public class OrderService {
|
||||
public void cancelOrder(Long orderId) {
|
||||
Order order = orderRepository.findById(orderId).orElseThrow();
|
||||
// 贫血模型:业务逻辑散落在 Service 里
|
||||
if (order.getStatus() == OrderStatus.SHIPPED)
|
||||
throw new IllegalStateException("已发货不能取消");
|
||||
order.setStatus(OrderStatus.CANCELLED);
|
||||
orderRepository.save(order);
|
||||
}
|
||||
}`
|
||||
|
||||
const richEntity = `@Entity
|
||||
public class Order {
|
||||
@Id private Long id;
|
||||
private BigDecimal totalAmount;
|
||||
private OrderStatus status;
|
||||
|
||||
// 业务行为封装在实体里
|
||||
public void cancel() {
|
||||
if (this.status == OrderStatus.SHIPPED)
|
||||
throw new IllegalStateException("已发货不能取消");
|
||||
this.status = OrderStatus.CANCELLED;
|
||||
registerEvent(new OrderCancelledEvent(this.id));
|
||||
}
|
||||
|
||||
public void pay(Payment payment) {
|
||||
if (this.status != OrderStatus.PENDING_PAYMENT)
|
||||
throw new IllegalStateException("状态不正确");
|
||||
this.status = OrderStatus.PAID;
|
||||
}
|
||||
}`
|
||||
|
||||
const richService = `@Service
|
||||
public class OrderService {
|
||||
@Transactional
|
||||
public void cancelOrder(Long orderId) {
|
||||
Order order = orderRepository.findById(orderId).orElseThrow();
|
||||
order.cancel(); // 调用领域对象的业务方法
|
||||
orderRepository.save(order);
|
||||
}
|
||||
}`
|
||||
|
||||
const addressVO = `// 值对象:不可变、无 ID
|
||||
public record Address(String province, String city, String district, String street) {
|
||||
public String toDisplayString() {
|
||||
return String.format("%s%s%s%s", province, city, district, street);
|
||||
}
|
||||
}
|
||||
// 地址相等只要属性相同
|
||||
Address a1 = new Address("广东", "深圳", "南山", "科技园");
|
||||
Address a2 = new Address("广东", "深圳", "南山", "科技园");
|
||||
a1.equals(a2); // true`
|
||||
|
||||
const moneyVO = `public record Money(BigDecimal amount, Currency currency) {
|
||||
public static Money yuan(BigDecimal amount) {
|
||||
return new Money(amount, Currency.getInstance("CNY"));
|
||||
}
|
||||
// 运算返回新的值对象(不可变)
|
||||
public Money add(Money other) {
|
||||
if (!this.currency.equals(other.currency))
|
||||
throw new IllegalArgumentException("Cannot add different currencies");
|
||||
return new Money(this.amount.add(other.amount), this.currency);
|
||||
}
|
||||
}
|
||||
Money price = Money.yuan(new BigDecimal("199.99"));
|
||||
Money shipping = Money.yuan(new BigDecimal("10.00"));
|
||||
Money total = price.add(shipping); // ¥209.99`
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.domain-model-demo {
|
||||
padding: 24px;
|
||||
background: linear-gradient(135deg, #f0f7ff 0%, #e6f0ff 100%);
|
||||
border-radius: 12px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
.domain-demo { padding: 20px; background: var(--vp-c-bg-soft); border-radius: 12px; }
|
||||
.header { text-align: center; margin-bottom: 20px; }
|
||||
.title { font-size: 16px; font-weight: 600; color: var(--vp-c-text-1); }
|
||||
.subtitle { font-size: 13px; color: var(--vp-c-text-3); margin-top: 4px; }
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
.tabs { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
.tab {
|
||||
padding: 7px 16px; border: 1px solid var(--vp-c-divider); background: var(--vp-c-bg);
|
||||
border-radius: 6px; cursor: pointer; font-size: 13px; color: var(--vp-c-text-2); transition: all .2s;
|
||||
}
|
||||
.tab:hover { color: var(--vp-c-brand-1); border-color: var(--vp-c-brand-1); }
|
||||
.tab.active { background: var(--vp-c-brand-1); border-color: var(--vp-c-brand-1); color: #fff; }
|
||||
|
||||
.demo-header h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #1a1a2e;
|
||||
font-size: 18px;
|
||||
.cards { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.card {
|
||||
padding: 16px; border-radius: 10px;
|
||||
background: var(--vp-c-bg); border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
.card.bad { border-left: 3px solid var(--vp-c-danger-1); }
|
||||
.card.good { border-left: 3px solid var(--vp-c-green-1); }
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
.card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.card-title { font-weight: 600; font-size: 14px; color: var(--vp-c-text-1); }
|
||||
.card-badge { padding: 2px 8px; border-radius: 10px; font-size: 11px; color: #fff; }
|
||||
.card-badge.bad { background: var(--vp-c-danger-1); }
|
||||
.card-badge.good { background: var(--vp-c-green-1); }
|
||||
|
||||
.model-comparison {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
.code {
|
||||
margin: 0 0 12px; padding: 10px; border-radius: 6px; overflow-x: auto;
|
||||
background: var(--vp-code-block-bg); font-size: 10px; line-height: 1.5;
|
||||
}
|
||||
.code code { color: var(--vp-c-text-1); font-family: var(--vp-font-family-mono); }
|
||||
|
||||
.comparison-tabs {
|
||||
display: flex;
|
||||
background: #f5f7fa;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
.result-box { padding: 10px; border-radius: 6px; font-size: 12px; line-height: 1.5; }
|
||||
.result-box.bad { background: var(--vp-c-danger-soft); border-left: 3px solid var(--vp-c-danger-1); }
|
||||
.result-box.good { background: var(--vp-c-green-soft); border-left: 3px solid var(--vp-c-green-1); }
|
||||
.result-box strong { font-size: 12px; color: var(--vp-c-text-1); }
|
||||
.result-box ul { margin: 6px 0 0; padding-left: 16px; }
|
||||
.result-box li { margin: 3px 0; color: var(--vp-c-text-2); }
|
||||
|
||||
.tab-btn {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: #409eff;
|
||||
background: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tab-btn.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: #409eff;
|
||||
}
|
||||
|
||||
.comparison-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.tab-panel {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.model-cards {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.model-card {
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.model-card.anemic {
|
||||
border-left: 4px solid #ff4d4f;
|
||||
}
|
||||
|
||||
.model-card.rich {
|
||||
border-left: 4px solid #52c41a;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card-badge {
|
||||
margin-left: auto;
|
||||
padding: 2px 8px;
|
||||
background: #ff4d4f;
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.card-badge.tag-green {
|
||||
background: #52c41a;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.code-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.code-label {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.code-section pre {
|
||||
background: #2d2d2d;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.code-section code {
|
||||
color: #f8f8f2;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.problems, .benefits {
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.problems {
|
||||
background: #fff1f0;
|
||||
border-left: 3px solid #ff4d4f;
|
||||
}
|
||||
|
||||
.benefits {
|
||||
background: #f6ffed;
|
||||
border-left: 3px solid #52c41a;
|
||||
}
|
||||
|
||||
.problem-title, .benefit-title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.problem-title {
|
||||
color: #cf1322;
|
||||
}
|
||||
|
||||
.benefit-title {
|
||||
color: #389e0d;
|
||||
}
|
||||
|
||||
.problems ul, .benefits ul {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.problems li, .benefits li {
|
||||
margin: 6px 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #595959;
|
||||
}
|
||||
.vo-section { background: var(--vp-c-bg); border-radius: 10px; padding: 18px; border: 1px solid var(--vp-c-divider); }
|
||||
.vo-intro { margin-bottom: 16px; font-size: 13px; color: var(--vp-c-text-2); line-height: 1.6; }
|
||||
.vo-intro strong { color: var(--vp-c-text-1); }
|
||||
.vo-intro p { margin: 6px 0 0; }
|
||||
.vo-examples { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.vo-card { background: var(--vp-c-bg-soft); border-radius: 8px; padding: 14px; }
|
||||
.vo-name { font-weight: 600; font-size: 13px; color: var(--vp-c-text-1); margin-bottom: 8px; }
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.model-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.cards, .vo-examples { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
+83
-332
@@ -1,362 +1,113 @@
|
||||
<template>
|
||||
<div class="dto-flow-demo">
|
||||
<div class="demo-header">
|
||||
<h4>🔄 DTO 流转:数据在不同层之间的转换</h4>
|
||||
<p class="subtitle">
|
||||
DTO(Data Transfer Object)是层与层之间传递数据的载体
|
||||
</p>
|
||||
<div class="dto-demo">
|
||||
<div class="header">
|
||||
<div class="title">DTO 流转:数据在不同层之间的转换</div>
|
||||
<div class="subtitle">DTO(Data Transfer Object)是层与层之间传递数据的载体</div>
|
||||
</div>
|
||||
|
||||
<!-- 流程图 -->
|
||||
<div class="flow-diagram">
|
||||
<div class="flow-step">
|
||||
<div class="step-title">
|
||||
Controller 层
|
||||
</div>
|
||||
<div class="step-code">
|
||||
<div class="code-line">
|
||||
<span class="comment">// 接收 Request DTO</span>
|
||||
</div>
|
||||
<div class="code-line">
|
||||
<span class="keyword">public</span> ResponseEntity<UserDTO> createUser(
|
||||
</div>
|
||||
<div class="code-line">
|
||||
@RequestBody <span class="highlight">@Valid UserCreateRequest request</span>
|
||||
</div>
|
||||
<div class="code-line">
|
||||
) { ... }
|
||||
</div>
|
||||
</div>
|
||||
<div class="flow-box">
|
||||
<div class="flow-step green">
|
||||
<div class="step-label">Controller 层</div>
|
||||
<pre class="step-code"><code>// 接收 Request DTO
|
||||
public ResponseEntity<UserDTO> createUser(
|
||||
@RequestBody @Valid UserCreateRequest request) { ... }</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="flow-arrow">
|
||||
⬇️ 转换为 Service 需要的参数
|
||||
<div class="arrow">↓ 转换为 Service 需要的参数</div>
|
||||
|
||||
<div class="flow-step orange">
|
||||
<div class="step-label">Service 层</div>
|
||||
<pre class="step-code"><code>public UserDTO createUser(UserCreateParam param) {
|
||||
User user = param.toEntity(); // 转换为 Entity
|
||||
userRepository.save(user);
|
||||
return UserDTO.from(user); // Entity → DTO
|
||||
}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="arrow">↓ 转换为 Repository 需要的 Entity</div>
|
||||
|
||||
<div class="flow-step blue">
|
||||
<div class="step-label">Repository 层</div>
|
||||
<pre class="step-code"><code>public interface UserRepository
|
||||
extends JpaRepository<User, Long> { }</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="arrow">↑ 返回 Entity,转换为 DTO</div>
|
||||
|
||||
<div class="flow-step">
|
||||
<div class="step-title">
|
||||
Service 层
|
||||
</div>
|
||||
<div class="step-code">
|
||||
<div class="code-line">
|
||||
<span class="comment">// 业务处理</span>
|
||||
</div>
|
||||
<div class="code-line">
|
||||
<span class="keyword">public</span> UserDTO createUser(UserCreateParam param) {
|
||||
</div>
|
||||
<div class="code-line">
|
||||
<span class="comment">// 转换为 Entity</span>
|
||||
</div>
|
||||
<div class="code-line">
|
||||
User user = <span class="highlight">param.toEntity()</span>;
|
||||
</div>
|
||||
<div class="code-line">
|
||||
userRepository.save(user);
|
||||
</div>
|
||||
<div class="code-line">
|
||||
<span class="keyword">return</span> <span class="highlight">UserDTO.from(user)</span>;
|
||||
</div>
|
||||
<div class="code-line">
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-arrow">
|
||||
⬇️ 转换为 Repository 需要的 Entity
|
||||
</div>
|
||||
|
||||
<div class="flow-step">
|
||||
<div class="step-title">
|
||||
Repository 层
|
||||
</div>
|
||||
<div class="step-code">
|
||||
<div class="code-line">
|
||||
<span class="comment">// 数据持久化</span>
|
||||
</div>
|
||||
<div class="code-line">
|
||||
<span class="keyword">public interface</span> UserRepository
|
||||
</div>
|
||||
<div class="code-line">
|
||||
<span class="keyword">extends</span> JpaRepository<<span class="highlight">User</span>, Long> {
|
||||
</div>
|
||||
<div class="code-line">
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow-arrow">
|
||||
⬆️ 返回 Entity,转换为 DTO
|
||||
</div>
|
||||
|
||||
<div class="flow-step">
|
||||
<div class="step-title">
|
||||
返回给客户端
|
||||
</div>
|
||||
<div class="step-code">
|
||||
<div class="code-line">
|
||||
<span class="comment">// Response DTO</span>
|
||||
</div>
|
||||
<div class="code-line">
|
||||
{
|
||||
</div>
|
||||
<div class="code-line">
|
||||
<span class="string">"id"</span>: 10001,
|
||||
</div>
|
||||
<div class="code-line">
|
||||
<span class="string">"username"</span>: <span class="string">"张三"</span>,
|
||||
</div>
|
||||
<div class="code-line">
|
||||
<span class="string">"email"</span>: <span class="string">"zhangsan@example.com"</span>,
|
||||
</div>
|
||||
<div class="code-line">
|
||||
<span class="string">"createdAt"</span>: <span class="string">"2024-01-15T10:30:00Z"</span>
|
||||
</div>
|
||||
<div class="code-line">
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-label">返回给客户端</div>
|
||||
<pre class="step-code"><code>{ "id": 10001, "username": "张三",
|
||||
"email": "zhangsan@example.com", "createdAt": "2024-01-15T10:30:00Z" }</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 不同层 DTO 对比 -->
|
||||
<div class="dto-comparison">
|
||||
<h5>📋 不同层的 DTO 职责</h5>
|
||||
<div class="comparison-table">
|
||||
<div class="table-header">
|
||||
<div class="col-layer">
|
||||
层级
|
||||
</div>
|
||||
<div class="col-dto">
|
||||
DTO 类型
|
||||
</div>
|
||||
<div class="col-purpose">
|
||||
职责
|
||||
</div>
|
||||
<div class="col-example">
|
||||
示例
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-row">
|
||||
<div class="col-layer">
|
||||
<span class="layer-tag controller">Controller</span>
|
||||
</div>
|
||||
<div class="col-dto">
|
||||
Request / Response DTO
|
||||
</div>
|
||||
<div class="col-purpose">
|
||||
定义 API 契约、参数校验、序列化
|
||||
</div>
|
||||
<div class="col-example">
|
||||
<code>UserCreateRequest</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-row">
|
||||
<div class="col-layer">
|
||||
<span class="layer-tag service">Service</span>
|
||||
</div>
|
||||
<div class="col-dto">
|
||||
Param / Result DTO
|
||||
</div>
|
||||
<div class="col-purpose">
|
||||
封装业务方法参数,解耦 Controller 与 Service
|
||||
</div>
|
||||
<div class="col-example">
|
||||
<code>UserCreateParam</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-row">
|
||||
<div class="col-layer">
|
||||
<span class="layer-tag repository">Repository</span>
|
||||
</div>
|
||||
<div class="col-dto">
|
||||
Entity / DO
|
||||
</div>
|
||||
<div class="col-purpose">
|
||||
映射数据库表结构,ORM 映射
|
||||
</div>
|
||||
<div class="col-example">
|
||||
<code>UserEntity</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-box">
|
||||
<div class="table-title">不同层的 DTO 职责</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>层级</th><th>DTO 类型</th><th>职责</th><th>示例</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in rows" :key="r.layer">
|
||||
<td><span :class="['tag', r.cls]">{{ r.layer }}</span></td>
|
||||
<td>{{ r.type }}</td>
|
||||
<td>{{ r.purpose }}</td>
|
||||
<td><code>{{ r.example }}</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const viewMode = ref('conversion')
|
||||
const rows = [
|
||||
{ layer: 'Controller', cls: 'green', type: 'Request / Response DTO', purpose: '定义 API 契约、参数校验', example: 'UserCreateRequest' },
|
||||
{ layer: 'Service', cls: 'orange', type: 'Param / Result DTO', purpose: '封装业务方法参数,解耦层间依赖', example: 'UserCreateParam' },
|
||||
{ layer: 'Repository', cls: 'blue', type: 'Entity / DO', purpose: '映射数据库表结构', example: 'UserEntity' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dto-flow-demo {
|
||||
padding: 24px;
|
||||
background: linear-gradient(135deg, #f0f7ff 0%, #e6f0ff 100%);
|
||||
border-radius: 12px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
.dto-demo { padding: 20px; background: var(--vp-c-bg-soft); border-radius: 12px; }
|
||||
.header { text-align: center; margin-bottom: 20px; }
|
||||
.title { font-size: 16px; font-weight: 600; color: var(--vp-c-text-1); }
|
||||
.subtitle { font-size: 13px; color: var(--vp-c-text-3); margin-top: 4px; }
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
.flow-box {
|
||||
padding: 18px; border-radius: 10px; margin-bottom: 16px;
|
||||
background: var(--vp-c-bg); border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.demo-header h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #1a1a2e;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.flow-diagram {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border-left: 4px solid #409eff;
|
||||
border-radius: 6px; overflow: hidden;
|
||||
background: var(--vp-c-bg-soft); border-left: 3px solid var(--vp-c-divider);
|
||||
}
|
||||
.flow-step.green { border-left-color: #10b981; }
|
||||
.flow-step.orange { border-left-color: #f59e0b; }
|
||||
.flow-step.blue { border-left-color: #3b82f6; }
|
||||
|
||||
.flow-step:nth-child(odd) {
|
||||
border-left-color: #67c23a;
|
||||
.step-label {
|
||||
padding: 10px 14px; font-weight: 600; font-size: 13px;
|
||||
color: var(--vp-c-text-1); border-bottom: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.step-title {
|
||||
padding: 12px 16px;
|
||||
background: white;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.step-code {
|
||||
padding: 16px;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
margin: 0; padding: 12px 14px; overflow-x: auto;
|
||||
font-size: 11px; line-height: 1.5;
|
||||
}
|
||||
.step-code code { color: var(--vp-c-text-2); font-family: var(--vp-font-family-mono); }
|
||||
.arrow { text-align: center; padding: 8px; color: var(--vp-c-text-3); font-size: 12px; }
|
||||
|
||||
.code-line {
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.comment {
|
||||
color: #6a9955;
|
||||
}
|
||||
|
||||
.keyword {
|
||||
color: #569cd6;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: #fff3cd;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.string {
|
||||
color: #ce9178;
|
||||
}
|
||||
|
||||
.flow-arrow {
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dto-comparison {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.dto-comparison h5 {
|
||||
margin: 0 0 16px 0;
|
||||
color: #1a1a2e;
|
||||
font-size: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.comparison-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table-header, .table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 150px 1fr 120px;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.table-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.layer-tag {
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.layer-tag.controller {
|
||||
background: #f0f9ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.layer-tag.service {
|
||||
background: #fff7e6;
|
||||
color: #fa8c16;
|
||||
}
|
||||
|
||||
.layer-tag.repository {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.table-header, .table-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: none;
|
||||
}
|
||||
.table-box {
|
||||
padding: 16px; border-radius: 10px;
|
||||
background: var(--vp-c-bg); border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
.table-title { text-align: center; font-weight: 600; font-size: 14px; color: var(--vp-c-text-1); margin-bottom: 12px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
th, td { padding: 10px; text-align: left; border-bottom: 1px solid var(--vp-c-divider); color: var(--vp-c-text-2); }
|
||||
th { background: var(--vp-c-bg-soft); font-weight: 600; color: var(--vp-c-text-1); }
|
||||
.tag { padding: 2px 8px; border-radius: 10px; font-size: 11px; color: #fff; }
|
||||
.tag.green { background: #10b981; }
|
||||
.tag.orange { background: #f59e0b; }
|
||||
.tag.blue { background: #3b82f6; }
|
||||
</style>
|
||||
|
||||
+112
-356
@@ -1,398 +1,154 @@
|
||||
<template>
|
||||
<div class="layered-architecture-demo">
|
||||
<div class="architecture-container">
|
||||
<!-- 客户端 -->
|
||||
<div class="client-layer">
|
||||
<div class="layer-box client">
|
||||
<div class="layer-icon">
|
||||
🌐
|
||||
</div>
|
||||
<div class="layer-title">
|
||||
客户端
|
||||
</div>
|
||||
<div class="layer-desc">
|
||||
Web / App / 小程序
|
||||
<div class="layered-arch-demo">
|
||||
<div class="header">
|
||||
<div class="title">后端四层架构总览</div>
|
||||
<div class="subtitle">点击各层查看详细说明</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div class="layers">
|
||||
<div class="client-box">客户端 (Web / App)</div>
|
||||
<div class="arrow">↓ HTTP</div>
|
||||
|
||||
<div
|
||||
v-for="layer in layers"
|
||||
:key="layer.id"
|
||||
:class="['layer-box', layer.id, { active: active === layer.id }]"
|
||||
@click="active = active === layer.id ? '' : layer.id"
|
||||
>
|
||||
<div class="layer-header">
|
||||
<span class="layer-name">{{ layer.name }}</span>
|
||||
<span class="layer-badge">{{ layer.badge }}</span>
|
||||
</div>
|
||||
<div class="layer-duty">{{ layer.duty }}</div>
|
||||
</div>
|
||||
<div class="arrow-down">
|
||||
⬇️ HTTP/HTTPS
|
||||
</div>
|
||||
|
||||
<div class="arrow">↓ SQL</div>
|
||||
<div class="client-box db">数据库 (MySQL / PostgreSQL)</div>
|
||||
</div>
|
||||
|
||||
<!-- 后端分层 -->
|
||||
<div class="backend-layers">
|
||||
<!-- Controller 层 -->
|
||||
<div
|
||||
class="layer-box controller"
|
||||
:class="{ active: activeLayer === 'controller' }"
|
||||
@click="setActiveLayer('controller')"
|
||||
>
|
||||
<div class="layer-header">
|
||||
<span class="layer-icon">🎮</span>
|
||||
<span class="layer-name">Controller</span>
|
||||
<span class="layer-badge">入口</span>
|
||||
</div>
|
||||
<div class="layer-content">
|
||||
<div class="duty">
|
||||
职责:接收请求、参数校验、调用 Service
|
||||
</div>
|
||||
<div class="tech">
|
||||
技术:Spring MVC / Gin / Echo
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow-down">
|
||||
⬇️ 调用
|
||||
</div>
|
||||
|
||||
<!-- Service 层 -->
|
||||
<div
|
||||
class="layer-box service"
|
||||
:class="{ active: activeLayer === 'service' }"
|
||||
@click="setActiveLayer('service')"
|
||||
>
|
||||
<div class="layer-header">
|
||||
<span class="layer-icon">⚙️</span>
|
||||
<span class="layer-name">Service</span>
|
||||
<span class="layer-badge">业务核心</span>
|
||||
</div>
|
||||
<div class="layer-content">
|
||||
<div class="duty">
|
||||
职责:业务逻辑编排、事务管理、跨模块协调
|
||||
</div>
|
||||
<div class="tech">
|
||||
技术:纯代码逻辑 / 无框架依赖
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow-down">
|
||||
⬇️ 调用
|
||||
</div>
|
||||
|
||||
<!-- Repository 层 -->
|
||||
<div
|
||||
class="layer-box repository"
|
||||
:class="{ active: activeLayer === 'repository' }"
|
||||
@click="setActiveLayer('repository')"
|
||||
>
|
||||
<div class="layer-header">
|
||||
<span class="layer-icon">🗄️</span>
|
||||
<span class="layer-name">Repository</span>
|
||||
<span class="layer-badge">数据访问</span>
|
||||
</div>
|
||||
<div class="layer-content">
|
||||
<div class="duty">
|
||||
职责:数据持久化、查询封装、ORM 映射
|
||||
</div>
|
||||
<div class="tech">
|
||||
技术:MyBatis / GORM / Hibernate
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow-down">
|
||||
⬇️ SQL
|
||||
</div>
|
||||
|
||||
<!-- Domain 层 -->
|
||||
<div
|
||||
class="layer-box domain"
|
||||
:class="{ active: activeLayer === 'domain' }"
|
||||
@click="setActiveLayer('domain')"
|
||||
>
|
||||
<div class="layer-header">
|
||||
<span class="layer-icon">📦</span>
|
||||
<span class="layer-name">Domain / Model</span>
|
||||
<span class="layer-badge">领域模型</span>
|
||||
</div>
|
||||
<div class="layer-content">
|
||||
<div class="duty">
|
||||
职责:实体定义、业务规则、值对象
|
||||
</div>
|
||||
<div class="tech">
|
||||
技术:POJO / Struct / Class
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow-down">
|
||||
⬇️ 持久化
|
||||
</div>
|
||||
|
||||
<!-- 数据库 -->
|
||||
<div class="layer-box database">
|
||||
<div class="layer-icon">
|
||||
💾
|
||||
</div>
|
||||
<div class="layer-title">
|
||||
数据库
|
||||
</div>
|
||||
<div class="layer-desc">
|
||||
MySQL / PostgreSQL / MongoDB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧说明面板 -->
|
||||
<div
|
||||
v-if="activeLayer"
|
||||
class="info-panel"
|
||||
>
|
||||
<h4>{{ layerInfo.title }}</h4>
|
||||
<p>{{ layerInfo.description }}</p>
|
||||
<div class="analogy">
|
||||
<strong>💡 类比:</strong>{{ layerInfo.analogy }}
|
||||
</div>
|
||||
<div class="common-mistakes">
|
||||
<strong>⚠️ 常见错误:</strong>
|
||||
<div v-if="active" class="info-panel">
|
||||
<div class="info-title">{{ activeInfo.title }}</div>
|
||||
<p>{{ activeInfo.desc }}</p>
|
||||
<div class="info-analogy">{{ activeInfo.analogy }}</div>
|
||||
<div class="info-mistakes">
|
||||
<strong>常见错误:</strong>
|
||||
<ul>
|
||||
<li
|
||||
v-for="mistake in layerInfo.mistakes"
|
||||
:key="mistake"
|
||||
>
|
||||
{{ mistake }}
|
||||
</li>
|
||||
<li v-for="m in activeInfo.mistakes" :key="m">{{ m }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部交互提示 -->
|
||||
<div class="interaction-hint">
|
||||
💡 点击各层查看详细说明 | 实际调用流向:从上到下,依赖从下到上
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const activeLayer = ref('')
|
||||
const active = ref('')
|
||||
|
||||
const setActiveLayer = (layer) => {
|
||||
activeLayer.value = activeLayer.value === layer ? '' : layer
|
||||
const layers = [
|
||||
{ id: 'controller', name: 'Controller', badge: '入口', duty: '接收请求、参数校验、调用 Service' },
|
||||
{ id: 'service', name: 'Service', badge: '业务核心', duty: '业务逻辑编排、事务管理、跨模块协调' },
|
||||
{ id: 'repository', name: 'Repository', badge: '数据访问', duty: '数据持久化、查询封装、ORM 映射' },
|
||||
{ id: 'domain', name: 'Domain', badge: '领域模型', duty: '实体定义、业务规则、值对象' }
|
||||
]
|
||||
|
||||
const infoMap = {
|
||||
controller: {
|
||||
title: 'Controller 层 — 请求的"门童"',
|
||||
desc: '负责接收 HTTP 请求、解析参数、进行基础校验,然后调用 Service 层处理业务。',
|
||||
analogy: '就像餐厅的门童,负责迎接客人、检查预约、引导入座,但不负责做菜。',
|
||||
mistakes: ['在 Controller 里写业务逻辑', '直接操作数据库', '不做参数校验']
|
||||
},
|
||||
service: {
|
||||
title: 'Service 层 — 业务逻辑的"厨师"',
|
||||
desc: '编排业务逻辑、管理事务、协调多个 Repository。包含所有的业务规则和流程。',
|
||||
analogy: '就像餐厅的厨师,按照菜谱做菜,协调各种食材,把控菜品质量。',
|
||||
mistakes: ['Service 之间循环依赖', '直接写 SQL', '单个方法过长包含多个业务场景']
|
||||
},
|
||||
repository: {
|
||||
title: 'Repository 层 — 数据的"仓管"',
|
||||
desc: '封装所有数据访问逻辑,上层不需要关心具体的数据库类型和 SQL 语句。',
|
||||
analogy: '就像仓管员,负责从仓库取食材、存放剩余食材,厨师只需说要什么。',
|
||||
mistakes: ['在 Repository 里写业务逻辑', '直接返回实体给前端', '一个 Repository 操作多个表']
|
||||
},
|
||||
domain: {
|
||||
title: 'Domain 层 — 业务概念的"蓝图"',
|
||||
desc: '定义实体、值对象、业务规则。是所有层的依赖基础,但不依赖任何其他层。',
|
||||
analogy: '就像菜单和菜品标准,定义了什么是"宫保鸡丁"、用什么食材、什么口味。',
|
||||
mistakes: ['Domain 包含持久化注解', '在 Domain 里写数据库操作', 'Domain 对象之间循环依赖']
|
||||
}
|
||||
}
|
||||
|
||||
const layerInfo = computed(() => {
|
||||
const infoMap = {
|
||||
controller: {
|
||||
title: 'Controller 层 - 请求的"门童"',
|
||||
description: 'Controller 是系统的入口,负责接收 HTTP 请求、解析参数、进行基础校验,然后调用 Service 层处理业务。',
|
||||
analogy: '就像餐厅的门童,负责迎接客人(接收请求)、检查预约(参数校验)、引导入座(路由到对应服务),但不负责做菜。',
|
||||
mistakes: [
|
||||
'在 Controller 里写业务逻辑(应该放在 Service)',
|
||||
'直接操作数据库(应该调用 Repository)',
|
||||
'不做参数校验,导致脏数据流入系统'
|
||||
]
|
||||
},
|
||||
service: {
|
||||
title: 'Service 层 - 业务逻辑的"厨师"',
|
||||
description: 'Service 是系统的核心,负责编排业务逻辑、管理事务、协调多个 Repository。这一层应该包含所有的业务规则和流程。',
|
||||
analogy: '就像餐厅的厨师,负责按照菜谱(业务规则)做菜,需要协调各种食材(数据),把控菜品质量(业务正确性)。',
|
||||
mistakes: [
|
||||
'Service 之间互相调用,形成循环依赖',
|
||||
'在 Service 里直接写 SQL(应该放在 Repository)',
|
||||
'一个 Service 方法过长,包含多个业务场景(应该拆分成多个方法)'
|
||||
]
|
||||
},
|
||||
repository: {
|
||||
title: 'Repository 层 - 数据的"仓管"',
|
||||
description: 'Repository 负责与数据库交互,封装所有的 CRUD 操作。上层不需要关心具体的数据库类型和 SQL 语句。',
|
||||
analogy: '就像餐厅的仓管员,负责从仓库(数据库)取食材、存放剩余食材,厨师(Service)只需要告诉他要什么,不需要知道仓库在哪。',
|
||||
mistakes: [
|
||||
'在 Repository 里写业务逻辑(应该只负责数据访问)',
|
||||
'直接返回数据库实体给前端(应该转换为 DTO)',
|
||||
'一个 Repository 操作多个表(应该拆分到不同 Repository)'
|
||||
]
|
||||
},
|
||||
domain: {
|
||||
title: 'Domain 层 - 业务概念的"蓝图"',
|
||||
description: 'Domain 定义了系统中的实体、值对象、业务规则。它是所有层的依赖基础,但不依赖任何其他层。',
|
||||
analogy: '就像餐厅的菜单和菜品标准,定义了什么是"宫保鸡丁"、用什么食材、什么口味。所有厨师都要按照这个标准来做。',
|
||||
mistakes: [
|
||||
'Domain 对象里包含持久化注解(应该保持纯净)',
|
||||
'在 Domain 里写业务逻辑(业务逻辑应该在 Service)',
|
||||
'Domain 对象之间循环依赖'
|
||||
]
|
||||
}
|
||||
}
|
||||
return infoMap[activeLayer.value] || {}
|
||||
})
|
||||
const activeInfo = computed(() => infoMap[active.value] || {})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layered-architecture-demo {
|
||||
.layered-arch-demo {
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e4e8ec 100%);
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 12px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
.header { text-align: center; margin-bottom: 20px; }
|
||||
.title { font-size: 16px; font-weight: 600; color: var(--vp-c-text-1); }
|
||||
.subtitle { font-size: 13px; color: var(--vp-c-text-3); margin-top: 4px; }
|
||||
|
||||
.architecture-container {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.main { display: flex; gap: 20px; align-items: flex-start; }
|
||||
.layers { flex: 1; display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
.client-layer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.backend-layers {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
.client-box {
|
||||
padding: 12px; text-align: center; border-radius: 8px;
|
||||
background: var(--vp-c-bg); color: var(--vp-c-text-2);
|
||||
font-size: 13px; border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
.client-box.db { border-left: 3px solid #8b5cf6; }
|
||||
.arrow { text-align: center; color: var(--vp-c-text-3); font-size: 12px; padding: 2px; }
|
||||
|
||||
.layer-box {
|
||||
padding: 16px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
background: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.layer-box:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.layer-box.active {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.2);
|
||||
}
|
||||
|
||||
/* 各层颜色主题 */
|
||||
.controller { border-left: 4px solid #67c23a; }
|
||||
.service { border-left: 4px solid #e6a23c; }
|
||||
.repository { border-left: 4px solid #409eff; }
|
||||
.domain { border-left: 4px solid #909399; }
|
||||
.client { border-left: 4px solid #f56c6c; }
|
||||
.database { border-left: 4px solid #8e44ad; }
|
||||
|
||||
.layer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.layer-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.layer-name {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: #303133;
|
||||
padding: 14px; border-radius: 8px; cursor: pointer;
|
||||
background: var(--vp-c-bg); border: 1px solid var(--vp-c-divider);
|
||||
border-left: 3px solid var(--vp-c-divider);
|
||||
transition: all .2s;
|
||||
}
|
||||
.layer-box:hover { box-shadow: 0 2px 8px rgba(0,0,0,.06); }
|
||||
.layer-box.active { border-color: var(--vp-c-brand-1); box-shadow: 0 0 0 2px var(--vp-c-brand-soft); }
|
||||
.layer-box.controller { border-left-color: #10b981; }
|
||||
.layer-box.service { border-left-color: #f59e0b; }
|
||||
.layer-box.repository { border-left-color: #3b82f6; }
|
||||
.layer-box.domain { border-left-color: #6b7280; }
|
||||
|
||||
.layer-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||||
.layer-name { font-weight: 600; font-size: 14px; color: var(--vp-c-text-1); }
|
||||
.layer-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
background: #f0f2f5;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.layer-content {
|
||||
padding-left: 30px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.duty, .tech {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.arrow-down {
|
||||
text-align: center;
|
||||
padding: 6px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
padding: 1px 8px; border-radius: 10px; font-size: 11px;
|
||||
background: var(--vp-c-bg-soft); color: var(--vp-c-text-3);
|
||||
}
|
||||
.layer-duty { font-size: 12px; color: var(--vp-c-text-2); }
|
||||
|
||||
.info-panel {
|
||||
width: 320px;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
width: 300px; padding: 18px; border-radius: 10px;
|
||||
background: var(--vp-c-bg); border: 1px solid var(--vp-c-divider);
|
||||
position: sticky; top: 20px;
|
||||
}
|
||||
|
||||
.info-panel h4 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #303133;
|
||||
font-size: 16px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #409eff;
|
||||
.info-title { font-weight: 600; font-size: 14px; color: var(--vp-c-text-1); margin-bottom: 10px; padding-bottom: 8px; border-bottom: 2px solid var(--vp-c-brand-1); }
|
||||
.info-panel p { font-size: 13px; color: var(--vp-c-text-2); line-height: 1.6; margin: 0 0 12px; }
|
||||
.info-analogy {
|
||||
padding: 10px; border-radius: 6px; font-size: 12px; line-height: 1.5;
|
||||
background: var(--vp-c-brand-soft); color: var(--vp-c-text-1);
|
||||
border-left: 3px solid var(--vp-c-brand-1); margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.info-panel p {
|
||||
margin: 0 0 16px 0;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
.info-mistakes {
|
||||
padding: 10px; border-radius: 6px; font-size: 12px; line-height: 1.5;
|
||||
background: var(--vp-c-danger-soft); color: var(--vp-c-text-1);
|
||||
border-left: 3px solid var(--vp-c-danger-1);
|
||||
}
|
||||
.info-mistakes strong { font-size: 12px; }
|
||||
.info-mistakes ul { margin: 6px 0 0; padding-left: 16px; }
|
||||
.info-mistakes li { margin: 3px 0; }
|
||||
|
||||
.analogy, .common-mistakes {
|
||||
margin: 12px 0;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.analogy {
|
||||
background: #f0f9ff;
|
||||
border-left: 3px solid #409eff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.common-mistakes {
|
||||
background: #fff2f0;
|
||||
border-left: 3px solid #ff4d4f;
|
||||
color: #cf1322;
|
||||
}
|
||||
|
||||
.common-mistakes ul {
|
||||
margin: 6px 0 0 0;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.common-mistakes li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.interaction-hint {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
background: #f6ffed;
|
||||
border: 1px solid #b7eb8f;
|
||||
border-radius: 6px;
|
||||
color: #389e0d;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.architecture-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
width: 100%;
|
||||
position: static;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.main { flex-direction: column; }
|
||||
.info-panel { width: 100%; position: static; }
|
||||
}
|
||||
</style>
|
||||
|
||||
+135
-1027
File diff suppressed because it is too large
Load Diff
+183
-644
@@ -1,123 +1,57 @@
|
||||
<template>
|
||||
<div class="service-layer-demo">
|
||||
<div class="demo-header">
|
||||
<h4>⚙️ Service 层:业务逻辑的"指挥家"</h4>
|
||||
<p class="subtitle">
|
||||
Service 层编排业务逻辑,协调多个 Repository,管理事务边界
|
||||
</p>
|
||||
<div class="service-demo">
|
||||
<div class="header">
|
||||
<div class="title">Service 层:业务逻辑的"指挥家"</div>
|
||||
<div class="subtitle">选择业务场景,查看 Service 层如何编排逻辑</div>
|
||||
</div>
|
||||
|
||||
<!-- 场景选择器 -->
|
||||
<div class="scenario-selector">
|
||||
<div class="selector-label">
|
||||
选择业务场景:
|
||||
</div>
|
||||
<div class="scenario-buttons">
|
||||
<button
|
||||
v-for="scenario in scenarios"
|
||||
:key="scenario.id"
|
||||
:class="['scenario-btn', { active: currentScenario === scenario.id }]"
|
||||
@click="currentScenario = scenario.id"
|
||||
>
|
||||
{{ scenario.name }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="s in scenarios" :key="s.id"
|
||||
:class="['tab', { active: current === s.id }]"
|
||||
@click="current = s.id; expanded = []"
|
||||
>{{ s.name }}</button>
|
||||
</div>
|
||||
|
||||
<!-- 流程图 -->
|
||||
<div class="flow-diagram">
|
||||
<div class="flow-header">
|
||||
<span class="flow-title">{{ currentScenarioData.title }}</span>
|
||||
<span class="flow-desc">{{ currentScenarioData.description }}</span>
|
||||
</div>
|
||||
<div class="flow-box">
|
||||
<div class="flow-title">{{ data.title }}</div>
|
||||
<div class="flow-desc">{{ data.desc }}</div>
|
||||
|
||||
<div class="flow-steps">
|
||||
<div class="steps">
|
||||
<div
|
||||
v-for="(step, index) in currentScenarioData.steps"
|
||||
:key="index"
|
||||
class="flow-step"
|
||||
:class="{ 'has-sub-steps': step.subSteps }"
|
||||
@click="toggleStep(index)"
|
||||
v-for="(step, i) in data.steps" :key="i"
|
||||
class="step" @click="toggleStep(i)"
|
||||
>
|
||||
<div class="step-header">
|
||||
<div class="step-number">
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<div class="step-head">
|
||||
<span class="step-num">{{ i + 1 }}</span>
|
||||
<div class="step-info">
|
||||
<div class="step-name">
|
||||
{{ step.name }}
|
||||
</div>
|
||||
<div class="step-layer">
|
||||
{{ step.layer }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="step.subSteps"
|
||||
class="expand-icon"
|
||||
>
|
||||
{{ expandedSteps.includes(index) ? '▼' : '▶' }}
|
||||
<div class="step-name">{{ step.name }}</div>
|
||||
<div class="step-layer">{{ step.layer }}</div>
|
||||
</div>
|
||||
<span v-if="step.subs" class="expand">{{ expanded.includes(i) ? '▼' : '▶' }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="step.code"
|
||||
class="step-code"
|
||||
>
|
||||
<pre><code>{{ step.code }}</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- 子步骤(事务管理) -->
|
||||
<div
|
||||
v-if="step.subSteps && expandedSteps.includes(index)"
|
||||
class="sub-steps"
|
||||
>
|
||||
<div
|
||||
v-for="(subStep, subIndex) in step.subSteps"
|
||||
:key="subIndex"
|
||||
class="sub-step"
|
||||
:class="subStep.status"
|
||||
>
|
||||
<div class="sub-step-icon">
|
||||
{{ subStep.icon }}
|
||||
</div>
|
||||
<div class="sub-step-content">
|
||||
<div class="sub-step-name">
|
||||
{{ subStep.name }}
|
||||
</div>
|
||||
<div class="sub-step-desc">
|
||||
{{ subStep.desc }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="sub-step-status">
|
||||
{{ subStep.statusText }}
|
||||
<pre v-if="step.code" class="step-code"><code>{{ step.code }}</code></pre>
|
||||
<div v-if="step.subs && expanded.includes(i)" class="subs">
|
||||
<div v-for="(sub, j) in step.subs" :key="j" class="sub">
|
||||
<span class="sub-icon">{{ sub.icon }}</span>
|
||||
<div class="sub-info">
|
||||
<div class="sub-name">{{ sub.name }}</div>
|
||||
<div class="sub-desc">{{ sub.desc }}</div>
|
||||
</div>
|
||||
<span class="sub-status">{{ sub.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Service 设计原则 -->
|
||||
<div class="design-principles">
|
||||
<h5>🎯 Service 层设计原则</h5>
|
||||
<div class="principles-grid">
|
||||
<div
|
||||
v-for="principle in principles"
|
||||
:key="principle.id"
|
||||
class="principle-card"
|
||||
>
|
||||
<div class="principle-icon">
|
||||
{{ principle.icon }}
|
||||
</div>
|
||||
<div class="principle-title">
|
||||
{{ principle.title }}
|
||||
</div>
|
||||
<div class="principle-desc">
|
||||
{{ principle.desc }}
|
||||
</div>
|
||||
<div class="principle-example">
|
||||
<code>{{ principle.example }}</code>
|
||||
</div>
|
||||
<div class="principles">
|
||||
<div class="principles-title">Service 层设计原则</div>
|
||||
<div class="principle-grid">
|
||||
<div v-for="p in principles" :key="p.title" class="principle">
|
||||
<div class="p-title">{{ p.title }}</div>
|
||||
<div class="p-desc">{{ p.desc }}</div>
|
||||
<code class="p-example">{{ p.example }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,8 +61,8 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const currentScenario = ref('order')
|
||||
const expandedSteps = ref([])
|
||||
const current = ref('order')
|
||||
const expanded = ref([])
|
||||
|
||||
const scenarios = [
|
||||
{ id: 'order', name: '下单流程' },
|
||||
@@ -136,596 +70,201 @@ const scenarios = [
|
||||
{ id: 'report', name: '报表生成' }
|
||||
]
|
||||
|
||||
const scenarioData = {
|
||||
const allData = {
|
||||
order: {
|
||||
title: '🛒 电商下单流程',
|
||||
description: '用户下单涉及库存扣减、订单创建、支付记录等多个操作,需要保证事务一致性',
|
||||
title: '电商下单流程',
|
||||
desc: '用户下单涉及库存扣减、订单创建、支付记录,需保证事务一致性',
|
||||
steps: [
|
||||
{
|
||||
name: '参数校验与DTO转换',
|
||||
layer: 'Controller',
|
||||
{ name: '参数校验与DTO转换', layer: 'Controller',
|
||||
code: `@PostMapping("/orders")
|
||||
public ResponseEntity<OrderDTO> createOrder(
|
||||
@RequestBody @Valid CreateOrderRequest request
|
||||
) {
|
||||
// 调用 Service
|
||||
public ResponseEntity<OrderDTO> createOrder(
|
||||
@RequestBody @Valid CreateOrderRequest request) {
|
||||
OrderDTO order = orderService.createOrder(request);
|
||||
return ResponseEntity.ok(order);
|
||||
}`
|
||||
},
|
||||
{
|
||||
name: '业务逻辑编排(事务管理)',
|
||||
layer: 'Service',
|
||||
code: `@Service
|
||||
@Transactional // 关键:事务管理
|
||||
public class OrderService {
|
||||
|
||||
public OrderDTO createOrder(CreateOrderRequest request) {
|
||||
// 1. 检查库存
|
||||
inventoryService.checkAndDeduct(request.getSkuId(),
|
||||
request.getQuantity());
|
||||
|
||||
// 2. 创建订单
|
||||
Order order = new Order();
|
||||
order.setUserId(request.getUserId());
|
||||
order.setTotalAmount(calculateTotal(request));
|
||||
orderRepository.save(order);
|
||||
|
||||
// 3. 创建支付记录
|
||||
Payment payment = createPayment(order);
|
||||
paymentRepository.save(payment);
|
||||
|
||||
// 任一失败都会回滚
|
||||
return convertToDTO(order);
|
||||
}
|
||||
}` },
|
||||
{ name: '业务逻辑编排(事务管理)', layer: 'Service',
|
||||
code: `@Transactional
|
||||
public OrderDTO createOrder(CreateOrderRequest request) {
|
||||
inventoryService.checkAndDeduct(request.getSkuId(), request.getQuantity());
|
||||
Order order = new Order();
|
||||
order.setUserId(request.getUserId());
|
||||
order.setTotalAmount(calculateTotal(request));
|
||||
orderRepository.save(order);
|
||||
Payment payment = createPayment(order);
|
||||
paymentRepository.save(payment);
|
||||
return convertToDTO(order);
|
||||
}`,
|
||||
subSteps: [
|
||||
{
|
||||
icon: '✅',
|
||||
name: '检查并扣减库存',
|
||||
desc: '确保库存充足,预先锁定',
|
||||
status: 'success',
|
||||
statusText: '成功'
|
||||
},
|
||||
{
|
||||
icon: '📝',
|
||||
name: '创建订单记录',
|
||||
desc: '生成订单主表数据',
|
||||
status: 'success',
|
||||
statusText: '成功'
|
||||
},
|
||||
{
|
||||
icon: '💳',
|
||||
name: '创建支付记录',
|
||||
desc: '初始化待支付状态',
|
||||
status: 'success',
|
||||
statusText: '成功'
|
||||
},
|
||||
{
|
||||
icon: '🔄',
|
||||
name: '事务提交',
|
||||
desc: '所有操作原子性提交',
|
||||
status: 'success',
|
||||
statusText: '已提交'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '数据持久化',
|
||||
layer: 'Repository',
|
||||
code: `public interface OrderRepository extends JpaRepository<Order, Long> {
|
||||
// 基本的 CRUD 已内置
|
||||
}
|
||||
|
||||
// 实际执行:INSERT INTO orders (...) VALUES (...)`
|
||||
}
|
||||
subs: [
|
||||
{ icon: '✅', name: '检查并扣减库存', desc: '确保库存充足', status: '成功' },
|
||||
{ icon: '📝', name: '创建订单记录', desc: '生成订单主表', status: '成功' },
|
||||
{ icon: '💳', name: '创建支付记录', desc: '初始化待支付', status: '成功' },
|
||||
{ icon: '🔄', name: '事务提交', desc: '原子性提交', status: '已提交' }
|
||||
] },
|
||||
{ name: '数据持久化', layer: 'Repository',
|
||||
code: `public interface OrderRepository extends JpaRepository<Order, Long> {
|
||||
// 基本 CRUD 已内置
|
||||
}` }
|
||||
]
|
||||
},
|
||||
refund: {
|
||||
title: '💰 退款处理流程',
|
||||
description: '退款涉及订单状态变更、支付原路返回、库存回滚等操作',
|
||||
title: '退款处理流程',
|
||||
desc: '退款涉及订单状态变更、支付原路返回、库存回滚',
|
||||
steps: [
|
||||
{
|
||||
name: '接收退款申请',
|
||||
layer: 'Controller',
|
||||
{ name: '接收退款申请', layer: 'Controller',
|
||||
code: `@PostMapping("/orders/{orderId}/refund")
|
||||
public ResponseEntity<RefundDTO> applyRefund(
|
||||
@PathVariable Long orderId,
|
||||
@RequestBody @Valid RefundRequest request
|
||||
) {
|
||||
RefundDTO refund = refundService.processRefund(orderId, request);
|
||||
return ResponseEntity.ok(refund);
|
||||
}`
|
||||
},
|
||||
{
|
||||
name: '退款业务处理',
|
||||
layer: 'Service',
|
||||
code: `@Service
|
||||
@Transactional
|
||||
public class RefundService {
|
||||
|
||||
public RefundDTO processRefund(Long orderId, RefundRequest request) {
|
||||
// 1. 验证订单状态
|
||||
Order order = orderRepository.findById(orderId)
|
||||
.orElseThrow(() -> new OrderNotFoundException("订单不存在"));
|
||||
|
||||
if (order.getStatus() != OrderStatus.PAID) {
|
||||
throw new InvalidOrderStateException("订单状态不允许退款");
|
||||
}
|
||||
|
||||
// 2. 计算退款金额
|
||||
BigDecimal refundAmount = calculateRefundAmount(order, request);
|
||||
|
||||
// 3. 调用支付渠道退款
|
||||
PaymentRefundResult result = paymentService.refund(
|
||||
order.getPaymentNo(),
|
||||
refundAmount,
|
||||
request.getReason()
|
||||
);
|
||||
|
||||
// 4. 更新订单状态
|
||||
order.setStatus(OrderStatus.REFUNDING);
|
||||
orderRepository.save(order);
|
||||
|
||||
// 5. 保存退款记录
|
||||
RefundRecord record = new RefundRecord();
|
||||
record.setOrderId(orderId);
|
||||
record.setAmount(refundAmount);
|
||||
record.setReason(request.getReason());
|
||||
record.setStatus(RefundStatus.PROCESSING);
|
||||
refundRecordRepository.save(record);
|
||||
|
||||
// 6. 异步恢复库存
|
||||
inventoryService.restoreStockAsync(order.getItems());
|
||||
|
||||
return convertToDTO(record);
|
||||
}
|
||||
public ResponseEntity<RefundDTO> applyRefund(
|
||||
@PathVariable Long orderId, @RequestBody @Valid RefundRequest request) {
|
||||
return ResponseEntity.ok(refundService.processRefund(orderId, request));
|
||||
}` },
|
||||
{ name: '退款业务处理', layer: 'Service',
|
||||
code: `@Transactional
|
||||
public RefundDTO processRefund(Long orderId, RefundRequest request) {
|
||||
Order order = orderRepository.findById(orderId).orElseThrow();
|
||||
if (order.getStatus() != OrderStatus.PAID)
|
||||
throw new InvalidOrderStateException("不允许退款");
|
||||
BigDecimal amount = calculateRefundAmount(order, request);
|
||||
paymentService.refund(order.getPaymentNo(), amount, request.getReason());
|
||||
order.setStatus(OrderStatus.REFUNDING);
|
||||
orderRepository.save(order);
|
||||
inventoryService.restoreStockAsync(order.getItems());
|
||||
return convertToDTO(saveRefundRecord(orderId, amount, request));
|
||||
}`,
|
||||
subSteps: [
|
||||
{ icon: '🔍', name: '验证订单状态', desc: '检查订单是否存在且可退款', status: 'success', statusText: '通过' },
|
||||
{ icon: '💰', name: '计算退款金额', desc: '根据规则计算应退金额', status: 'success', statusText: '完成' },
|
||||
{ icon: '🏦', name: '调用支付渠道', desc: '请求第三方支付退款', status: 'success', statusText: '处理中' },
|
||||
{ icon: '📝', name: '更新订单状态', desc: '标记为退款中', status: 'success', statusText: '已更新' },
|
||||
{ icon: '📊', name: '保存退款记录', desc: '记录退款流水', status: 'success', statusText: '已保存' },
|
||||
{ icon: '🔄', name: '异步恢复库存', desc: '后台恢复商品库存', status: 'success', statusText: '已提交' }
|
||||
]
|
||||
}
|
||||
subs: [
|
||||
{ icon: '🔍', name: '验证订单状态', desc: '检查是否可退款', status: '通过' },
|
||||
{ icon: '💰', name: '计算退款金额', desc: '根据规则计算', status: '完成' },
|
||||
{ icon: '🏦', name: '调用支付渠道', desc: '请求第三方退款', status: '处理中' },
|
||||
{ icon: '📝', name: '更新订单状态', desc: '标记为退款中', status: '已更新' },
|
||||
{ icon: '🔄', name: '异步恢复库存', desc: '后台恢复库存', status: '已提交' }
|
||||
] }
|
||||
]
|
||||
},
|
||||
report: {
|
||||
title: '📊 报表生成流程',
|
||||
description: '复杂的报表通常涉及多个数据源查询、数据聚合计算、异步导出等',
|
||||
title: '报表生成流程',
|
||||
desc: '复杂报表涉及多数据源查询、数据聚合、异步导出',
|
||||
steps: [
|
||||
{
|
||||
name: '接收报表请求',
|
||||
layer: 'Controller',
|
||||
{ name: '接收报表请求', layer: 'Controller',
|
||||
code: `@GetMapping("/reports/sales")
|
||||
public ResponseEntity<ReportTaskDTO> generateSalesReport(
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate startDate,
|
||||
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
|
||||
LocalDate endDate,
|
||||
@RequestParam(required = false) List<Long> regionIds
|
||||
) {
|
||||
// 大报表采用异步生成
|
||||
ReportTaskDTO task = reportService.createReportTask(
|
||||
ReportType.SALES, startDate, endDate, regionIds
|
||||
);
|
||||
@RequestParam LocalDate startDate, @RequestParam LocalDate endDate) {
|
||||
ReportTaskDTO task = reportService.createReportTask(startDate, endDate);
|
||||
return ResponseEntity.accepted().body(task);
|
||||
}`
|
||||
},
|
||||
{
|
||||
name: '复杂报表业务编排',
|
||||
layer: 'Service',
|
||||
code: `@Service
|
||||
public class ReportService {
|
||||
|
||||
@Async("reportExecutor")
|
||||
public void generateReportAsync(Long taskId) {
|
||||
ReportTask task = reportTaskRepository.findById(taskId)
|
||||
.orElseThrow();
|
||||
|
||||
try {
|
||||
task.setStatus(TaskStatus.RUNNING);
|
||||
reportTaskRepository.save(task);
|
||||
|
||||
// 1. 从多个数据源聚合数据
|
||||
SalesReportData data = aggregateSalesData(task);
|
||||
|
||||
// 2. 计算各项指标
|
||||
calculateMetrics(data);
|
||||
|
||||
// 3. 生成图表数据
|
||||
generateChartData(data);
|
||||
|
||||
// 4. 导出为 Excel
|
||||
String fileUrl = exportToExcel(data, task);
|
||||
|
||||
task.setStatus(TaskStatus.COMPLETED);
|
||||
task.setFileUrl(fileUrl);
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
|
||||
} catch (Exception e) {
|
||||
task.setStatus(TaskStatus.FAILED);
|
||||
task.setErrorMessage(e.getMessage());
|
||||
}
|
||||
|
||||
reportTaskRepository.save(task);
|
||||
}
|
||||
|
||||
private SalesReportData aggregateSalesData(ReportTask task) {
|
||||
// 协调多个 Repository 查询数据
|
||||
List<Order> orders = orderRepository
|
||||
.findByCreatedAtBetween(task.getStartDate(), task.getEndDate());
|
||||
|
||||
List<Payment> payments = paymentRepository
|
||||
.findByPaidAtBetween(task.getStartDate(), task.getEndDate());
|
||||
|
||||
List<RefundRecord> refunds = refundRecordRepository
|
||||
.findByCreatedAtBetween(task.getStartDate(), task.getEndDate());
|
||||
|
||||
// 数据聚合逻辑...
|
||||
return new SalesReportData(orders, payments, refunds);
|
||||
}
|
||||
}` },
|
||||
{ name: '异步报表编排', layer: 'Service',
|
||||
code: `@Async("reportExecutor")
|
||||
public void generateReportAsync(Long taskId) {
|
||||
ReportTask task = reportTaskRepository.findById(taskId).orElseThrow();
|
||||
task.setStatus(TaskStatus.RUNNING);
|
||||
reportTaskRepository.save(task);
|
||||
SalesReportData data = aggregateSalesData(task);
|
||||
calculateMetrics(data);
|
||||
String fileUrl = exportToExcel(data, task);
|
||||
task.setStatus(TaskStatus.COMPLETED);
|
||||
task.setFileUrl(fileUrl);
|
||||
reportTaskRepository.save(task);
|
||||
}`,
|
||||
subSteps: [
|
||||
{ icon: '📥', name: '从多个数据源查询', desc: 'Orders/Payments/Refunds', status: 'success', statusText: '已查询' },
|
||||
{ icon: '🔄', name: '数据聚合与清洗', desc: '关联数据、处理缺失值', status: 'success', statusText: '已完成' },
|
||||
{ icon: '📊', name: '计算业务指标', desc: 'GMV、订单数、客单价等', status: 'success', statusText: '已计算' },
|
||||
{ icon: '📈', name: '生成图表数据', desc: '趋势图、占比图数据结构', status: 'success', statusText: '已生成' },
|
||||
{ icon: '📄', name: '导出 Excel 文件', desc: '生成并上传至 OSS', status: 'success', statusText: '已完成' }
|
||||
]
|
||||
}
|
||||
subs: [
|
||||
{ icon: '📥', name: '多数据源查询', desc: 'Orders/Payments/Refunds', status: '已查询' },
|
||||
{ icon: '🔄', name: '数据聚合清洗', desc: '关联数据、处理缺失值', status: '已完成' },
|
||||
{ icon: '📊', name: '计算业务指标', desc: 'GMV、订单数、客单价', status: '已计算' },
|
||||
{ icon: '📄', name: '导出 Excel', desc: '生成并上传至 OSS', status: '已完成' }
|
||||
] }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const currentScenarioData = computed(() => scenarioData[currentScenario.value])
|
||||
const data = computed(() => allData[current.value])
|
||||
|
||||
const toggleStep = (index) => {
|
||||
const i = expandedSteps.value.indexOf(index)
|
||||
if (i > -1) {
|
||||
expandedSteps.value.splice(i, 1)
|
||||
} else {
|
||||
expandedSteps.value.push(index)
|
||||
}
|
||||
const toggleStep = (i) => {
|
||||
const idx = expanded.value.indexOf(i)
|
||||
if (idx > -1) expanded.value.splice(idx, 1)
|
||||
else expanded.value.push(i)
|
||||
}
|
||||
|
||||
const principles = [
|
||||
{
|
||||
id: 1,
|
||||
icon: '🎯',
|
||||
title: '单一职责',
|
||||
desc: '一个 Service 类只负责一块业务领域',
|
||||
example: 'UserService 只管用户,OrderService 只管订单'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
icon: '🔄',
|
||||
title: '事务边界',
|
||||
desc: '在 Service 层声明式管理事务',
|
||||
example: '@Transactional 放在 Service 方法上'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
icon: '🔗',
|
||||
title: '避免循环依赖',
|
||||
desc: 'Service 之间不要互相调用',
|
||||
example: 'A 调用 B,B 又调用 A 会导致循环'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
icon: '📦',
|
||||
title: 'DTO 转换',
|
||||
desc: '返回前转换为 DTO,不暴露实体',
|
||||
example: 'return new UserDTO(user)'
|
||||
}
|
||||
{ title: '单一职责', desc: '一个 Service 只负责一块业务领域', example: 'UserService 只管用户,OrderService 只管订单' },
|
||||
{ title: '事务边界', desc: '在 Service 层声明式管理事务', example: '@Transactional 放在 Service 方法上' },
|
||||
{ title: '避免循环依赖', desc: 'Service 之间不要互相调用', example: 'A→B→A 会导致循环' },
|
||||
{ title: 'DTO 转换', desc: '返回前转换为 DTO,不暴露实体', example: 'return new UserDTO(user)' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.service-layer-demo {
|
||||
padding: 24px;
|
||||
background: linear-gradient(135deg, #fff9f0 0%, #fff0e6 100%);
|
||||
border-radius: 12px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
.service-demo { padding: 20px; background: var(--vp-c-bg-soft); border-radius: 12px; }
|
||||
.header { text-align: center; margin-bottom: 20px; }
|
||||
.title { font-size: 16px; font-weight: 600; color: var(--vp-c-text-1); }
|
||||
.subtitle { font-size: 13px; color: var(--vp-c-text-3); margin-top: 4px; }
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
.tabs { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
.tab {
|
||||
padding: 7px 16px; border: 1px solid var(--vp-c-divider); background: var(--vp-c-bg);
|
||||
border-radius: 6px; cursor: pointer; font-size: 13px; color: var(--vp-c-text-2); transition: all .2s;
|
||||
}
|
||||
.tab:hover { border-color: #f59e0b; color: #f59e0b; }
|
||||
.tab.active { background: #f59e0b; border-color: #f59e0b; color: #fff; }
|
||||
|
||||
.demo-header h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #1a1a2e;
|
||||
font-size: 18px;
|
||||
.flow-box {
|
||||
padding: 18px; border-radius: 10px;
|
||||
background: var(--vp-c-bg); border: 1px solid var(--vp-c-divider); margin-bottom: 16px;
|
||||
}
|
||||
.flow-title { font-size: 15px; font-weight: 600; color: var(--vp-c-text-1); text-align: center; }
|
||||
.flow-desc { font-size: 12px; color: var(--vp-c-text-3); text-align: center; margin: 4px 0 16px; }
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
.steps { display: flex; flex-direction: column; gap: 10px; }
|
||||
.step {
|
||||
background: var(--vp-c-bg-soft); border-radius: 6px; border-left: 3px solid #f59e0b;
|
||||
cursor: pointer; transition: all .2s; overflow: hidden;
|
||||
}
|
||||
.step:hover { transform: translateX(3px); }
|
||||
|
||||
.scenario-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.selector-label {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scenario-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.scenario-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #dcdfe6;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.scenario-btn:hover {
|
||||
border-color: #e6a23c;
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
.scenario-btn.active {
|
||||
background: #e6a23c;
|
||||
border-color: #e6a23c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.flow-diagram {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.flow-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.flow-title {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.flow-desc {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.flow-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border-left: 4px solid #e6a23c;
|
||||
}
|
||||
|
||||
.flow-step:hover {
|
||||
background: #fff8f0;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #e6a23c;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.step-name {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.step-layer {
|
||||
font-size: 12px;
|
||||
color: #e6a23c;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
.step-head { display: flex; align-items: center; gap: 10px; padding: 10px 14px; }
|
||||
.step-num {
|
||||
width: 24px; height: 24px; display: flex; align-items: center; justify-content: center;
|
||||
background: #f59e0b; color: #fff; border-radius: 50%; font-size: 12px; font-weight: 600; flex-shrink: 0;
|
||||
}
|
||||
.step-info { flex: 1; }
|
||||
.step-name { font-weight: 600; font-size: 13px; color: var(--vp-c-text-1); }
|
||||
.step-layer { font-size: 11px; color: #f59e0b; }
|
||||
.expand { color: var(--vp-c-text-3); font-size: 11px; }
|
||||
|
||||
.step-code {
|
||||
padding: 0 16px 16px 56px;
|
||||
margin: 0 14px 14px 48px; padding: 10px; border-radius: 6px; overflow-x: auto;
|
||||
background: var(--vp-code-block-bg); font-size: 11px; line-height: 1.5;
|
||||
}
|
||||
.step-code code { color: var(--vp-c-text-1); font-family: var(--vp-font-family-mono); }
|
||||
|
||||
.step-code pre {
|
||||
margin: 0;
|
||||
background: #2d2d2d;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
.subs { padding: 0 14px 14px 48px; }
|
||||
.sub {
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 10px;
|
||||
background: var(--vp-c-bg); border-radius: 6px; margin-bottom: 6px;
|
||||
border-left: 2px solid var(--vp-c-green-1);
|
||||
}
|
||||
.sub-icon { font-size: 14px; }
|
||||
.sub-info { flex: 1; }
|
||||
.sub-name { font-size: 12px; font-weight: 500; color: var(--vp-c-text-1); }
|
||||
.sub-desc { font-size: 11px; color: var(--vp-c-text-3); }
|
||||
.sub-status { font-size: 10px; padding: 2px 6px; border-radius: 8px; background: var(--vp-c-green-soft); color: var(--vp-c-green-1); }
|
||||
|
||||
.step-code code {
|
||||
color: #f8f8f2;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
.principles {
|
||||
padding: 16px; border-radius: 10px;
|
||||
background: var(--vp-c-bg); border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.sub-steps {
|
||||
padding: 0 16px 16px 56px;
|
||||
.principles-title { text-align: center; font-weight: 600; font-size: 14px; color: var(--vp-c-text-1); margin-bottom: 12px; }
|
||||
.principle-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||||
.principle {
|
||||
padding: 12px; background: var(--vp-c-bg-soft); border-radius: 6px;
|
||||
border-left: 3px solid #f59e0b;
|
||||
}
|
||||
|
||||
.sub-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
border-left: 3px solid #dcdfe6;
|
||||
}
|
||||
|
||||
.sub-step.success {
|
||||
border-left-color: #67c23a;
|
||||
background: #f6ffed;
|
||||
}
|
||||
|
||||
.sub-step-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sub-step-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sub-step-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.sub-step-desc {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.sub-step-status {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: #f0f9ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.sub-step.success .sub-step-status {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.design-principles {
|
||||
margin-top: 24px;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.design-principles h5 {
|
||||
margin: 0 0 16px 0;
|
||||
color: #1a1a2e;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.principles-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.principle-card {
|
||||
padding: 14px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #e6a23c;
|
||||
}
|
||||
|
||||
.principle-icon {
|
||||
font-size: 20px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.principle-title {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
font-size: 13px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.principle-desc {
|
||||
color: #606266;
|
||||
font-size: 11px;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.principle-example {
|
||||
background: #2d2d2d;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.principle-example code {
|
||||
color: #f8f8f2;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
.p-title { font-weight: 600; font-size: 13px; color: var(--vp-c-text-1); margin-bottom: 4px; }
|
||||
.p-desc { font-size: 11px; color: var(--vp-c-text-2); margin-bottom: 6px; }
|
||||
.p-example {
|
||||
display: block; padding: 6px; border-radius: 4px; overflow-x: auto;
|
||||
background: var(--vp-code-block-bg); font-size: 10px; color: var(--vp-c-text-2);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.principles-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.scenario-selector {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.step-code {
|
||||
padding-left: 16px;
|
||||
}
|
||||
.principle-grid { grid-template-columns: 1fr; }
|
||||
.tabs { flex-wrap: wrap; }
|
||||
.step-code { margin-left: 14px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user