feat(docs): add interactive demo components for technical appendices
Add placeholder Vue components for visualizing technical concepts across multiple domains including frontend routing, browser rendering, cache design, queue design, database principles, API design, cloud services, and backend evolution. These components provide interactive educational content for the documentation. Update documentation structure to include new appendix sections and enhance existing content with visual components. Remove unused 'codex' dependency from package.json.
This commit is contained in:
+534
@@ -0,0 +1,534 @@
|
||||
<template>
|
||||
<div class="clean-architecture-demo">
|
||||
<div class="demo-header">
|
||||
<h4>🏗️ 整洁架构(Clean Architecture)与分层架构</h4>
|
||||
<p class="subtitle">分层架构是整洁架构的基础,理解两者的关系有助于构建更灵活的系统</p>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- 整洁架构 -->
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- 对比总结 -->
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const currentTab = ref('layered')
|
||||
|
||||
const tabs = [
|
||||
{ id: 'layered', name: '传统分层' },
|
||||
{ id: 'clean', name: '整洁架构' },
|
||||
{ id: 'comparison', name: '对比总结' }
|
||||
]
|
||||
</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;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.demo-header h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #1a1a2e;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.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: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.layer-box {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
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: 8px;
|
||||
}
|
||||
|
||||
.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: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.clean-layers {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.clean-layer {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
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: 8px;
|
||||
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: 8px;
|
||||
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;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.rec-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
<template>
|
||||
<div class="controller-layer-demo">
|
||||
<div class="demo-header">
|
||||
<h4>🎮 Controller 层:请求的"接待员"</h4>
|
||||
<p class="subtitle">点击流程节点查看 Controller 如何接收和处理请求</p>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div class="arrow-connector">⬇️ 请求到达</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>
|
||||
</div>
|
||||
|
||||
<!-- Controller 职责总结 -->
|
||||
<div class="controller-summary">
|
||||
<h5>🎯 Controller 的核心职责</h5>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const showDetails = ref('')
|
||||
|
||||
const toggleDetails = (section) => {
|
||||
showDetails.value = showDetails.value === section ? '' : section
|
||||
}
|
||||
</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;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.demo-header h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #1a1a2e;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.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-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;
|
||||
}
|
||||
|
||||
.arrow-connector {
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
margin-top: 12px;
|
||||
padding: 16px;
|
||||
background: #f0f7ff;
|
||||
border-radius: 8px;
|
||||
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: 8px;
|
||||
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;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.duty-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
<template>
|
||||
<div class="dependency-direction-demo">
|
||||
<div class="demo-header">
|
||||
<h4>🔄 依赖方向:分层架构的核心规则</h4>
|
||||
<p class="subtitle">理解依赖方向,才能真正掌握分层架构</p>
|
||||
</div>
|
||||
|
||||
<!-- 依赖方向可视化 -->
|
||||
<div class="direction-visualization">
|
||||
<div class="arch-diagram">
|
||||
<!-- 外层 -->
|
||||
<div class="layer outer">
|
||||
<div class="layer-label">外层(UI / 外部系统)</div>
|
||||
<div class="layer-box">Controller</div>
|
||||
</div>
|
||||
|
||||
<!-- 依赖箭头 -->
|
||||
<div class="dependency-arrow down">
|
||||
<span class="arrow-line"></span>
|
||||
<span class="arrow-head">▶️ 依赖</span>
|
||||
</div>
|
||||
|
||||
<!-- 中层 -->
|
||||
<div class="layer middle">
|
||||
<div class="layer-label">中层(应用层)</div>
|
||||
<div class="layer-box">Service</div>
|
||||
</div>
|
||||
|
||||
<!-- 依赖箭头 -->
|
||||
<div class="dependency-arrow down">
|
||||
<span class="arrow-line"></span>
|
||||
<span class="arrow-head">▶️ 依赖</span>
|
||||
</div>
|
||||
|
||||
<!-- 内层 -->
|
||||
<div class="layer inner">
|
||||
<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>
|
||||
</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
|
||||
</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;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.layer-box {
|
||||
padding: 16px 20px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
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;
|
||||
}
|
||||
|
||||
.principle-box {
|
||||
background: linear-gradient(135deg, #e6f7ff 0%, #f0f7ff 100%);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
border-left: 4px solid #1890ff;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+501
@@ -0,0 +1,501 @@
|
||||
<template>
|
||||
<div class="domain-model-demo">
|
||||
<div class="demo-header">
|
||||
<h4>📦 Domain 层:领域模型设计</h4>
|
||||
<p class="subtitle">Domain 是业务概念的载体,所有层的依赖基础</p>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- 值对象 -->
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const currentTab = ref('comparison')
|
||||
|
||||
const tabs = [
|
||||
{ id: 'comparison', name: '贫血 vs 充血' },
|
||||
{ id: 'valueobject', name: '值对象设计' }
|
||||
]
|
||||
</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;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.demo-header h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #1a1a2e;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.model-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); }
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.model-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
<template>
|
||||
<div class="dto-flow-demo">
|
||||
<div class="demo-header">
|
||||
<h4>🔄 DTO 流转:数据在不同层之间的转换</h4>
|
||||
<p class="subtitle">DTO(Data Transfer Object)是层与层之间传递数据的载体</p>
|
||||
</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>
|
||||
|
||||
<div class="flow-arrow">⬇️ 转换为 Service 需要的参数</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>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const viewMode = ref('conversion')
|
||||
</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;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.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: 8px;
|
||||
overflow: hidden;
|
||||
border-left: 4px solid #409eff;
|
||||
}
|
||||
|
||||
.flow-step:nth-child(odd) {
|
||||
border-left-color: #67c23a;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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: 8px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+352
@@ -0,0 +1,352 @@
|
||||
<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>
|
||||
</div>
|
||||
<div class="arrow-down">⬇️ HTTP/HTTPS</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 class="info-panel" v-if="activeLayer">
|
||||
<h4>{{ layerInfo.title }}</h4>
|
||||
<p>{{ layerInfo.description }}</p>
|
||||
<div class="analogy">
|
||||
<strong>💡 类比:</strong>{{ layerInfo.analogy }}
|
||||
</div>
|
||||
<div class="common-mistakes">
|
||||
<strong>⚠️ 常见错误:</strong>
|
||||
<ul>
|
||||
<li v-for="mistake in layerInfo.mistakes" :key="mistake">{{ mistake }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部交互提示 -->
|
||||
<div class="interaction-hint">
|
||||
💡 点击各层查看详细说明 | 实际调用流向:从上到下,依赖从下到上
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const activeLayer = ref('')
|
||||
|
||||
const setActiveLayer = (layer) => {
|
||||
activeLayer.value = activeLayer.value === layer ? '' : layer
|
||||
}
|
||||
|
||||
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] || {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layered-architecture-demo {
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e4e8ec 100%);
|
||||
border-radius: 12px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.architecture-container {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.client-layer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.backend-layers {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.info-panel h4 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #303133;
|
||||
font-size: 16px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #409eff;
|
||||
}
|
||||
|
||||
.info-panel p {
|
||||
margin: 0 0 16px 0;
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.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: 8px;
|
||||
color: #389e0d;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.architecture-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
width: 100%;
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+1036
File diff suppressed because it is too large
Load Diff
+698
@@ -0,0 +1,698 @@
|
||||
<template>
|
||||
<div class="service-layer-demo">
|
||||
<div class="demo-header">
|
||||
<h4>⚙️ Service 层:业务逻辑的"指挥家"</h4>
|
||||
<p class="subtitle">Service 层编排业务逻辑,协调多个 Repository,管理事务边界</p>
|
||||
</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>
|
||||
|
||||
<!-- 流程图 -->
|
||||
<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-steps">
|
||||
<div
|
||||
v-for="(step, index) in currentScenarioData.steps"
|
||||
:key="index"
|
||||
class="flow-step"
|
||||
:class="{ 'has-sub-steps': step.subSteps }"
|
||||
@click="toggleStep(index)"
|
||||
>
|
||||
<div class="step-header">
|
||||
<div class="step-number">{{ index + 1 }}</div>
|
||||
<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>
|
||||
</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 }}</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const currentScenario = ref('order')
|
||||
const expandedSteps = ref([])
|
||||
|
||||
const scenarios = [
|
||||
{ id: 'order', name: '下单流程' },
|
||||
{ id: 'refund', name: '退款处理' },
|
||||
{ id: 'report', name: '报表生成' }
|
||||
]
|
||||
|
||||
const scenarioData = {
|
||||
order: {
|
||||
title: '🛒 电商下单流程',
|
||||
description: '用户下单涉及库存扣减、订单创建、支付记录等多个操作,需要保证事务一致性',
|
||||
steps: [
|
||||
{
|
||||
name: '参数校验与DTO转换',
|
||||
layer: 'Controller',
|
||||
code: `@PostMapping("/orders")
|
||||
public ResponseEntity<OrderDTO> createOrder(
|
||||
@RequestBody @Valid CreateOrderRequest request
|
||||
) {
|
||||
// 调用 Service
|
||||
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);
|
||||
}
|
||||
}`,
|
||||
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 (...)`
|
||||
}
|
||||
]
|
||||
},
|
||||
refund: {
|
||||
title: '💰 退款处理流程',
|
||||
description: '退款涉及订单状态变更、支付原路返回、库存回滚等操作',
|
||||
steps: [
|
||||
{
|
||||
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);
|
||||
}
|
||||
}`,
|
||||
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: '已提交' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
report: {
|
||||
title: '📊 报表生成流程',
|
||||
description: '复杂的报表通常涉及多个数据源查询、数据聚合计算、异步导出等',
|
||||
steps: [
|
||||
{
|
||||
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
|
||||
);
|
||||
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);
|
||||
}
|
||||
}`,
|
||||
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: '已完成' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const currentScenarioData = computed(() => scenarioData[currentScenario.value])
|
||||
|
||||
const toggleStep = (index) => {
|
||||
const i = expandedSteps.value.indexOf(index)
|
||||
if (i > -1) {
|
||||
expandedSteps.value.splice(i, 1)
|
||||
} else {
|
||||
expandedSteps.value.push(index)
|
||||
}
|
||||
}
|
||||
|
||||
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)'
|
||||
}
|
||||
]
|
||||
</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;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.demo-header h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #1a1a2e;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.scenario-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
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: 8px;
|
||||
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-code {
|
||||
padding: 0 16px 16px 56px;
|
||||
}
|
||||
|
||||
.step-code pre {
|
||||
margin: 0;
|
||||
background: #2d2d2d;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.step-code code {
|
||||
color: #f8f8f2;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sub-steps {
|
||||
padding: 0 16px 16px 56px;
|
||||
}
|
||||
|
||||
.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: 8px;
|
||||
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;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.principles-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.scenario-selector {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.step-code {
|
||||
padding-left: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user