feat: update docs and components, fix DLQ demo bug

This commit is contained in:
sanbuphy
2026-01-18 12:21:49 +08:00
parent 26ed39e1eb
commit e41063a1cd
159 changed files with 54236 additions and 2525 deletions
@@ -0,0 +1,564 @@
<!--
CouplingDemo.vue
系统解耦演示 - 同步 vs 异步对比
-->
<template>
<div class="coupling-demo">
<div class="header">
<div class="title">系统解耦从紧耦合到松耦合</div>
<div class="subtitle">观察同步调用与异步消息的区别</div>
</div>
<div class="mode-switch">
<button
class="mode-btn"
:class="{ active: !useAsync }"
@click="useAsync = false"
>
🔗 紧耦合 (同步)
</button>
<button
class="mode-btn"
:class="{ active: useAsync }"
@click="useAsync = true"
>
🔓 松耦合 (异步)
</button>
</div>
<div class="demo-container">
<!-- 紧耦合模式 -->
<div v-if="!useAsync" class="synchronous-mode">
<div class="scenario">
<div class="scenario-title"> 紧耦合问题</div>
<div class="flow-diagram">
<div class="service-box order">
<div class="service-name">订单服务</div>
<div class="service-desc">创建订单</div>
</div>
<div class="arrows">
<div
v-for="call in syncCalls"
:key="call.id"
class="sync-call"
:class="{ active: call.active }"
>
<div class="call-line"></div>
<div class="call-label">{{ call.service }}</div>
<div v-if="call.active" class="call-status">
{{ call.status }}
</div>
</div>
</div>
<div
class="service-box notification"
:class="{ failed: notificationFailed }"
>
<div class="service-name">通知服务</div>
<div class="service-desc">发送短信/邮件</div>
<div v-if="notificationFailed" class="error-msg">服务宕机 </div>
</div>
</div>
<div class="problem-list">
<div class="problem-item">
<span class="icon"></span>
<span><strong>依赖性强</strong>通知服务宕机订单创建失败</span>
</div>
<div class="problem-item">
<span class="icon"></span>
<span
><strong>响应慢</strong>总耗时 = 300ms + 500ms + 400ms =
1200ms</span
>
</div>
<div class="problem-item">
<span class="icon"></span>
<span><strong>扩展难</strong>增加新服务需要修改订单代码</span>
</div>
</div>
<button class="test-btn fail" @click="testSyncCall">
模拟通知服务故障
</button>
</div>
</div>
<!-- 松耦合模式 -->
<div v-else class="asynchronous-mode">
<div class="scenario">
<div class="scenario-title"> 松耦合优势</div>
<div class="flow-diagram">
<div class="service-box order">
<div class="service-name">订单服务</div>
<div class="service-desc">创建订单 + 发送消息</div>
</div>
<div class="mq-bridge">
<div class="mq-box">
<div class="mq-icon">📨</div>
<div class="mq-label">消息队列</div>
<div v-if="messageInQueue" class="msg-indicator">
消息已发送
</div>
</div>
<div class="flow-arrow"></div>
</div>
<div class="consumers-group">
<div class="consumer-box" :class="{ failed: consumerFailed }">
<div class="consumer-name">短信服务</div>
<div class="consumer-status">
{{ consumerFailed ? '离线(不影响订单)' : '运行中' }}
</div>
</div>
<div class="consumer-box">
<div class="consumer-name">邮件服务</div>
<div class="consumer-status">运行中</div>
</div>
<div class="consumer-box">
<div class="consumer-name">积分服务</div>
<div class="consumer-status">运行中</div>
</div>
<div class="consumer-box new">
<div class="consumer-name">数据分析</div>
<div class="consumer-status">新增 </div>
</div>
</div>
</div>
<div class="benefit-list">
<div class="benefit-item">
<span class="icon"></span>
<span><strong>独立运行</strong>通知服务宕机不影响订单创建</span>
</div>
<div class="benefit-item">
<span class="icon"></span>
<span
><strong>响应快</strong>订单服务只耗时 50ms发送消息</span
>
</div>
<div class="benefit-item">
<span class="icon"></span>
<span><strong>易扩展</strong>增加新消费者无需修改订单代码</span>
</div>
</div>
<button class="test-btn success" @click="testAsyncCall">
发送订单消息
</button>
</div>
</div>
</div>
<div class="comparison-summary">
<div class="summary-title">📊 对比总结</div>
<div class="summary-table">
<table>
<thead>
<tr>
<th>维度</th>
<th>紧耦合 (同步)</th>
<th>松耦合 (异步)</th>
</tr>
</thead>
<tbody>
<tr>
<td>服务依赖</td>
<td class="bad">强依赖一个挂全挂</td>
<td class="good">弱依赖独立运行</td>
</tr>
<tr>
<td>响应时间</td>
<td class="bad">1200ms串行执行</td>
<td class="good">50ms只发消息</td>
</tr>
<tr>
<td>扩展性</td>
<td class="bad">修改订单服务代码</td>
<td class="good">增加新消费者即可</td>
</tr>
<tr>
<td>可用性</td>
<td class="bad">90%任一服务故障</td>
<td class="good">99.9%独立故障域</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const useAsync = ref(false)
const notificationFailed = ref(false)
const consumerFailed = ref(false)
const messageInQueue = ref(false)
const syncCalls = ref([
{ id: 1, service: '调用库存服务', active: false, status: '处理中...' },
{ id: 2, service: '调用积分服务', active: false, status: '处理中...' },
{ id: 3, service: '调用通知服务', active: false, status: '失败!订单回滚' }
])
const testSyncCall = () => {
notificationFailed.value = true
syncCalls.value.forEach((call, index) => {
setTimeout(() => {
call.active = true
if (index === syncCalls.value.length - 1) {
setTimeout(() => {
call.active = false
}, 2000)
}
}, index * 800)
})
}
const testAsyncCall = () => {
messageInQueue.value = true
setTimeout(() => {
messageInQueue.value = false
}, 2000)
}
</script>
<style scoped>
.coupling-demo {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
border-radius: 12px;
padding: 1.5rem;
margin: 1.5rem 0;
font-family: var(--vp-font-family-base);
}
.header {
margin-bottom: 1rem;
}
.title {
font-weight: 700;
font-size: 1.05rem;
}
.subtitle {
color: var(--vp-c-text-2);
font-size: 0.9rem;
margin-top: 0.25rem;
}
.mode-switch {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.mode-btn {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid var(--vp-c-divider);
background: var(--vp-c-bg);
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
}
.mode-btn:hover {
border-color: var(--vp-c-brand);
}
.mode-btn.active {
background: var(--vp-c-brand);
color: #fff;
border-color: var(--vp-c-brand);
}
.demo-container {
margin-bottom: 1.5rem;
}
.scenario-title {
font-weight: 600;
font-size: 1rem;
margin-bottom: 1rem;
text-align: center;
}
.flow-diagram {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 1.5rem;
background: var(--vp-c-bg);
border-radius: 10px;
margin-bottom: 1rem;
}
.service-box {
background: var(--vp-c-bg-soft);
border: 2px solid var(--vp-c-brand);
border-radius: 10px;
padding: 1rem;
text-align: center;
min-width: 180px;
transition: all 0.3s;
}
.service-box.failed {
border-color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
.service-name {
font-weight: 600;
font-size: 0.95rem;
margin-bottom: 0.25rem;
}
.service-desc {
font-size: 0.8rem;
color: var(--vp-c-text-2);
}
.error-msg {
margin-top: 0.5rem;
padding: 0.35rem 0.5rem;
background: #ef4444;
color: white;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
}
.arrows {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 100%;
max-width: 300px;
}
.sync-call {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 6px;
transition: all 0.3s;
}
.sync-call.active {
background: rgba(239, 68, 68, 0.1);
}
.call-line {
width: 2px;
height: 20px;
background: var(--vp-c-divider);
}
.sync-call.active .call-line {
background: #ef4444;
}
.call-label {
font-size: 0.8rem;
color: var(--vp-c-text-2);
flex: 1;
}
.call-status {
font-size: 0.75rem;
color: #ef4444;
font-weight: 600;
}
.mq-bridge {
display: flex;
align-items: center;
gap: 1rem;
}
.mq-box {
background: rgba(59, 130, 246, 0.1);
border: 2px solid var(--vp-c-brand);
border-radius: 10px;
padding: 1rem;
text-align: center;
min-width: 140px;
}
.mq-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.mq-label {
font-weight: 600;
font-size: 0.9rem;
}
.msg-indicator {
margin-top: 0.5rem;
padding: 0.35rem 0.5rem;
background: #dcfce7;
color: #166534;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.flow-arrow {
font-size: 1.5rem;
color: var(--vp-c-brand);
}
.consumers-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 0.75rem;
width: 100%;
max-width: 500px;
}
.consumer-box {
background: var(--vp-c-bg-soft);
border: 2px solid var(--vp-c-brand);
border-radius: 8px;
padding: 0.75rem;
text-align: center;
transition: all 0.3s;
}
.consumer-box.failed {
border-color: #f59e0b;
background: rgba(245, 158, 11, 0.1);
}
.consumer-box.new {
border-color: #22c55e;
background: rgba(34, 197, 94, 0.1);
}
.consumer-name {
font-size: 0.8rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
.consumer-status {
font-size: 0.7rem;
color: var(--vp-c-text-2);
}
.problem-list,
.benefit-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1rem;
}
.problem-item,
.benefit-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem;
border-radius: 8px;
font-size: 0.9rem;
line-height: 1.5;
}
.problem-item {
background: rgba(239, 68, 68, 0.1);
}
.benefit-item {
background: rgba(34, 197, 94, 0.1);
}
.icon {
font-size: 1.2rem;
flex-shrink: 0;
}
.test-btn {
width: 100%;
padding: 0.75rem;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
}
.test-btn.fail {
background: #ef4444;
color: white;
}
.test-btn.fail:hover {
background: #dc2626;
}
.test-btn.success {
background: #22c55e;
color: white;
}
.test-btn.success:hover {
background: #16a34a;
}
.comparison-summary {
background: var(--vp-c-bg);
border-radius: 10px;
padding: 1rem;
border: 1px solid var(--vp-c-divider);
}
.summary-title {
font-weight: 600;
margin-bottom: 0.75rem;
}
.summary-table {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th,
td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--vp-c-divider);
}
th {
background: var(--vp-c-bg-soft);
font-weight: 600;
}
.bad {
color: #ef4444;
}
.good {
color: #16a34a;
}
</style>
@@ -0,0 +1,559 @@
<!--
DeadLetterQueueDemo.vue
死信队列演示 - 处理失败消息
-->
<template>
<div class="dlq-demo">
<div class="header">
<div class="title">死信队列消息的"急救站"</div>
<div class="subtitle">处理无法消费的消息避免阻塞队列</div>
</div>
<div class="controls">
<div class="control">
<label>失败率</label>
<input v-model="failureRate" type="range" min="0" max="100" step="10" />
<span class="value">{{ failureRate }}%</span>
</div>
<div class="control">
<label>最大重试次数</label>
<input v-model="maxRetries" type="range" min="1" max="5" step="1" />
<span class="value">{{ maxRetries }}</span>
</div>
</div>
<div class="flow-container">
<div class="main-queue-section">
<div class="section-title">📦 主队列</div>
<div class="queue-box main-queue">
<div class="queue-header">
<span>正常消息队列</span>
<span class="count">{{ mainQueue.length }} </span>
</div>
<div class="message-list">
<div
v-for="msg in mainQueue"
:key="msg.id"
class="message-item"
:class="{ processing: msg.processing }"
>
<div class="msg-id">#{{ msg.id }}</div>
<div class="msg-retries" v-if="msg.retries > 0">
重试: {{ msg.retries }}/{{ maxRetries }}
</div>
</div>
<div v-if="mainQueue.length === 0" class="empty">队列为空</div>
</div>
</div>
<button class="add-btn" @click="addMessage" :disabled="processing">
+ 添加消息
</button>
</div>
<div class="processing-section">
<div class="section-title"> 消费处理</div>
<div class="processor-box">
<div class="processor-icon" :class="{ active: processing }">
{{ processing ? '⚙️' : '💤' }}
</div>
<div class="processor-status">
{{ processing ? '处理中...' : '空闲' }}
</div>
<div v-if="currentMessage" class="current-msg">
处理: #{{ currentMessage.id }}
</div>
<div v-if="lastResult" class="last-result" :class="lastResult.type">
{{ lastResult.message }}
</div>
</div>
</div>
<div class="dlq-section">
<div class="section-title"> 死信队列</div>
<div class="queue-box dead-letter">
<div class="queue-header">
<span>失败消息</span>
<span class="count">{{ deadLetterQueue.length }} </span>
</div>
<div class="message-list">
<div
v-for="msg in deadLetterQueue"
:key="msg.id"
class="message-item failed"
>
<div class="msg-id">#{{ msg.id }}</div>
<div class="msg-error">{{ msg.error }}</div>
</div>
<div v-if="deadLetterQueue.length === 0" class="empty">
无失败消息
</div>
</div>
</div>
<button
class="retry-btn"
@click="retryDeadLetters"
:disabled="deadLetterQueue.length === 0"
>
🔄 重试死信
</button>
</div>
</div>
<div class="stats">
<div class="stat-card">
<div class="stat-label">总消息数</div>
<div class="stat-value">{{ totalMessages }}</div>
</div>
<div class="stat-card success">
<div class="stat-label">成功处理</div>
<div class="stat-value">{{ successCount }}</div>
</div>
<div class="stat-card warning">
<div class="stat-label">进入死信</div>
<div class="stat-value">{{ deadLetterCount }}</div>
</div>
<div class="stat-card">
<div class="stat-label">成功率</div>
<div class="stat-value">{{ successRate }}%</div>
</div>
</div>
<div class="explanation">
<div class="exp-title">💡 死信队列的作用</div>
<div class="exp-content">
<div class="exp-item">
<strong>1. 隔离异常消息</strong>失败消息不会阻塞正常消息的处理
</div>
<div class="exp-item">
<strong>2. 保留失败记录</strong>可以后续人工介入或自动重试
</div>
<div class="exp-item">
<strong>3. 系统保护</strong>避免因持续失败导致消费者崩溃
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const failureRate = ref(30)
const maxRetries = ref(3)
const processing = ref(false)
const currentMessage = ref(null)
const lastResult = ref(null)
let messageId = 0
const mainQueue = ref([])
const deadLetterQueue = ref([])
const successCount = ref(0)
const totalMessages = computed(
() =>
successCount.value + deadLetterQueue.value.length + mainQueue.value.length
)
const deadLetterCount = computed(() => deadLetterQueue.value.length)
const successRate = computed(() => {
if (totalMessages.value === 0) return 0
return Math.round((successCount.value / totalMessages.value) * 100)
})
let addMessage = () => {
messageId++
mainQueue.value.push({
id: messageId,
retries: 0,
processing: false
})
}
const processNext = () => {
if (mainQueue.value.length === 0 || processing.value) {
processing.value = false
return
}
let msg = mainQueue[0]
msg.processing = true
processing.value = true
currentMessage.value = msg
lastResult.value = null
setTimeout(() => {
const shouldFail = Math.random() * 100 < failureRate.value
if (shouldFail) {
msg.retries++
msg.processing = false
if (msg.retries >= maxRetries.value) {
// 超过最大重试次数,进入死信队列
mainQueue.value.shift()
deadLetterQueue.value.push({
id: msg.id,
error: `重试 ${msg.retries} 次后仍失败`
})
lastResult.value = {
type: 'error',
message: `❌ 消息 #${msg.id} 进入死信队列`
}
} else {
// 重新入队
lastResult.value = {
type: 'warning',
message: `⚠️ 消息 #${msg.id} 处理失败,重试 ${msg.retries}/${maxRetries.value}`
}
}
setTimeout(processNext, 500)
} else {
// 成功处理
mainQueue.value.shift()
successCount.value++
msg.processing = false
currentMessage.value = null
lastResult.value = {
type: 'success',
message: `✅ 消息 #${msg.id} 处理成功`
}
setTimeout(processNext, 300)
}
}, 1000)
}
const retryDeadLetters = () => {
const failed = deadLetterQueue.value.splice(0)
failed.forEach((msg) => {
msg.retries = 0
mainQueue.value.push(msg)
})
if (!processing.value && mainQueue.value.length > 0) {
processNext()
}
}
// 自动开始处理
const startProcessing = () => {
if (!processing.value && mainQueue.value.length > 0) {
processNext()
}
}
// 监听队列变化
const checkAndProcess = () => {
startProcessing()
}
// 添加消息后自动开始处理
const originalAddMessage = addMessage
const addMessageWithAutoProcess = () => {
originalAddMessage()
checkAndProcess()
}
// 覆盖 addMessage 方法
addMessage = addMessageWithAutoProcess
</script>
<style scoped>
.dlq-demo {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
border-radius: 12px;
padding: 1.5rem;
margin: 1.5rem 0;
font-family: var(--vp-font-family-base);
}
.header {
margin-bottom: 1rem;
}
.title {
font-weight: 700;
font-size: 1.05rem;
}
.subtitle {
color: var(--vp-c-text-2);
font-size: 0.9rem;
margin-top: 0.25rem;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.control {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.control input[type='range'] {
flex: 1;
}
.control .value {
font-weight: 600;
min-width: 3rem;
text-align: right;
}
.flow-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.section-title {
font-size: 0.85rem;
font-weight: 600;
color: var(--vp-c-text-2);
text-align: center;
margin-bottom: 0.75rem;
}
.queue-box {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 10px;
overflow: hidden;
}
.queue-box.main-queue {
border-color: var(--vp-c-brand);
}
.queue-box.dead-letter {
border-color: #ef4444;
}
.queue-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: var(--vp-c-bg-soft);
font-size: 0.8rem;
font-weight: 600;
}
.message-list {
max-height: 200px;
overflow-y: auto;
padding: 0.5rem;
}
.message-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: var(--vp-c-bg-soft);
border-radius: 6px;
margin-bottom: 0.4rem;
font-size: 0.8rem;
}
.message-item.processing {
border: 1px solid #f59e0b;
background: rgba(245, 158, 11, 0.1);
}
.message-item.failed {
border: 1px solid #ef4444;
background: rgba(239, 68, 68, 0.1);
}
.msg-id {
font-weight: 600;
}
.msg-retries {
font-size: 0.7rem;
color: #f59e0b;
}
.msg-error {
font-size: 0.7rem;
color: #ef4444;
}
.empty {
text-align: center;
padding: 1.5rem;
color: var(--vp-c-text-3);
font-size: 0.85rem;
}
.add-btn,
.retry-btn {
width: 100%;
padding: 0.6rem;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 0.85rem;
margin-top: 0.75rem;
transition: all 0.2s;
}
.add-btn {
background: var(--vp-c-brand);
color: white;
}
.add-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.add-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.retry-btn {
background: #f59e0b;
color: white;
}
.retry-btn:hover:not(:disabled) {
background: #d97706;
}
.processor-box {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 10px;
padding: 1.5rem;
text-align: center;
min-height: 200px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.processor-icon {
font-size: 2.5rem;
margin-bottom: 0.75rem;
}
.processor-icon.active {
animation: spin 1s linear infinite;
}
.processor-status {
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.current-msg {
font-size: 0.85rem;
color: var(--vp-c-text-2);
margin-bottom: 0.5rem;
}
.last-result {
font-size: 0.8rem;
padding: 0.5rem 0.75rem;
border-radius: 6px;
margin-top: 0.5rem;
}
.last-result.success {
background: #dcfce7;
color: #166534;
}
.last-result.warning {
background: rgba(245, 158, 11, 0.1);
color: #d97706;
}
.last-result.error {
background: rgba(239, 68, 68, 0.1);
color: #dc2626;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: var(--vp-c-bg);
border-radius: 10px;
padding: 1rem;
text-align: center;
border: 1px solid var(--vp-c-divider);
}
.stat-card.success {
border-color: #22c55e;
background: rgba(34, 197, 94, 0.05);
}
.stat-card.warning {
border-color: #ef4444;
background: rgba(239, 68, 68, 0.05);
}
.stat-label {
font-size: 0.8rem;
color: var(--vp-c-text-2);
margin-bottom: 0.35rem;
}
.stat-value {
font-size: 1.3rem;
font-weight: 700;
}
.explanation {
background: var(--vp-c-bg);
border-radius: 10px;
padding: 1rem;
border: 1px solid var(--vp-c-divider);
}
.exp-title {
font-weight: 600;
margin-bottom: 0.75rem;
}
.exp-content {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.exp-item {
font-size: 0.9rem;
line-height: 1.5;
color: var(--vp-c-text-2);
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,696 @@
<!--
DelayedMessageDemo.vue
延迟消息演示 - 定时任务可视化
-->
<template>
<div class="delayed-message-demo">
<div class="header">
<div class="title">延迟消息让消息"定时送达"</div>
<div class="subtitle">实现订单超时取消定时提醒等功能</div>
</div>
<div class="scenarios">
<button
v-for="scenario in scenarios"
:key="scenario.id"
class="scenario-btn"
:class="{ active: selectedScenario === scenario.id }"
@click="selectScenario(scenario.id)"
>
{{ scenario.icon }} {{ scenario.name }}
</button>
</div>
<div class="demo-area">
<div class="sender-section">
<div class="section-title">📤 发送延迟消息</div>
<div class="scenario-info">
<div class="scenario-name">{{ currentScenario.name }}</div>
<div class="scenario-desc">{{ currentScenario.description }}</div>
</div>
<div class="delay-setting">
<label>延迟时间</label>
<div class="delay-presets">
<button
v-for="preset in delayPresets"
:key="preset.value"
class="preset-btn"
:class="{ active: delaySeconds === preset.value }"
@click="delaySeconds = preset.value"
>
{{ preset.label }}
</button>
</div>
<div class="delay-custom">
<input v-model="customDelay" type="number" min="1" max="3600" />
<span></span>
</div>
</div>
<button
class="send-btn"
@click="sendDelayedMessage"
:disabled="sending"
>
{{ sending ? '发送中...' : '📨 发送延迟消息' }}
</button>
</div>
<div class="timeline-section">
<div class="section-title"> 延迟队列时间轴</div>
<div class="timeline">
<div class="timeline-now">
<div class="now-marker">现在</div>
</div>
<div class="delayed-messages">
<div
v-for="msg in delayedMessages"
:key="msg.id"
class="delayed-msg"
:style="{ left: msg.position + '%' }"
>
<div class="msg-bubble">
<div class="msg-id">#{{ msg.id }}</div>
<div class="msg-time">{{ msg.remaining }}s </div>
</div>
<div
class="msg-timer"
:style="{ height: msg.timerHeight + '%' }"
></div>
</div>
</div>
<div class="timeline-scale">
<div v-for="tick in timelineTicks" :key="tick" class="tick">
<div class="tick-line"></div>
<div class="tick-label">{{ tick }}s</div>
</div>
</div>
</div>
</div>
<div class="result-section">
<div class="section-title">📥 到期消息</div>
<div class="result-box">
<div v-if="deliveredMessages.length === 0" class="empty">
等待消息到期...
</div>
<div
v-for="msg in deliveredMessages"
:key="msg.id"
class="delivered-msg"
>
<div class="msg-header">
<span class="msg-id">#{{ msg.id }}</span>
<span class="msg-time">{{ msg.deliveredAt }}</span>
</div>
<div class="msg-content">{{ msg.content }}</div>
</div>
</div>
</div>
</div>
<div class="use-cases">
<div class="cases-title">💡 典型应用场景</div>
<div class="cases-grid">
<div class="case-card">
<div class="case-icon">🛒</div>
<div class="case-name">订单超时取消</div>
<div class="case-desc">下单后 30 分钟未支付自动取消订单</div>
</div>
<div class="case-card">
<div class="case-icon">🔔</div>
<div class="case-name">定时提醒</div>
<div class="case-desc">会议开始前 15 分钟发送提醒通知</div>
</div>
<div class="case-card">
<div class="case-icon">🎁</div>
<div class="case-name">会员过期提醒</div>
<div class="case-desc">会员到期前 3 发送续费提醒</div>
</div>
<div class="case-card">
<div class="case-icon">📊</div>
<div class="case-name">数据统计</div>
<div class="case-desc">每天凌晨 2 统计前一天的日报数据</div>
</div>
</div>
</div>
<div class="implementation">
<div class="impl-title">🔧 实现方式对比</div>
<div class="impl-table">
<table>
<thead>
<tr>
<th>方式</th>
<th>优点</th>
<th>缺点</th>
<th>适用场景</th>
</tr>
</thead>
<tbody>
<tr>
<td>RocketMQ 延迟消息</td>
<td>原生支持精度高</td>
<td>只能固定延迟级别</td>
<td>电商金融</td>
</tr>
<tr>
<td>RabbitMQ TTL + DLQ</td>
<td>灵活可精确控制</td>
<td>实现复杂</td>
<td>传统业务</td>
</tr>
<tr>
<td>Redis + 定时扫描</td>
<td>简单易于理解</td>
<td>精度依赖扫描间隔</td>
<td>小规模应用</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onUnmounted } from 'vue'
const selectedScenario = ref('order')
const sending = ref(false)
const delaySeconds = ref(30)
const customDelay = ref(30)
const delayedMessages = ref([])
const deliveredMessages = ref([])
let messageId = 0
let timer = null
const scenarios = [
{
id: 'order',
icon: '🛒',
name: '订单超时取消',
description: '下单后 30 分钟未支付,自动取消订单'
},
{
id: 'reminder',
icon: '🔔',
name: '定时提醒',
description: '会议开始前 15 分钟,发送提醒通知'
},
{
id: 'vip',
icon: '🎁',
name: '会员过期',
description: '会员到期前 3 天,发送续费提醒'
}
]
const delayPresets = [
{ label: '10秒', value: 10 },
{ label: '30秒', value: 30 },
{ label: '1分钟', value: 60 },
{ label: '5分钟', value: 300 }
]
const currentScenario = computed(() => {
return scenarios.find((s) => s.id === selectedScenario.value) || scenarios[0]
})
const timelineTicks = computed(() => {
const max = Math.max(...delayPresets.map((p) => p.value))
const ticks = []
for (let i = 10; i <= max; i += 10) {
ticks.push(i)
}
return ticks
})
const selectScenario = (id) => {
selectedScenario.value = id
}
const sendDelayedMessage = () => {
if (sending.value) return
sending.value = true
messageId++
const totalSeconds = delaySeconds.value
const now = new Date()
delayedMessages.value.push({
id: messageId,
remaining: totalSeconds,
total: totalSeconds,
position: 10,
timerHeight: 100,
scenario: currentScenario.value
})
setTimeout(() => {
sending.value = false
}, 500)
}
const updateTimers = () => {
const now = new Date()
delayedMessages.value.forEach((msg, index) => {
msg.remaining--
// 更新位置和高度
const maxTime = Math.max(...delayPresets.map((p) => p.value))
msg.position = 10 + ((msg.total - msg.remaining) / msg.total) * 80
msg.timerHeight = (msg.remaining / msg.total) * 100
if (msg.remaining <= 0) {
// 消息到期
delayedMessages.value.splice(index, 1)
const timeStr = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
deliveredMessages.value.unshift({
id: msg.id,
content: `${msg.scenario.name} - 消息已触发`,
deliveredAt: timeStr
})
if (deliveredMessages.value.length > 5) {
deliveredMessages.value.pop()
}
}
})
}
timer = setInterval(updateTimers, 1000)
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
})
</script>
<style scoped>
.delayed-message-demo {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
border-radius: 12px;
padding: 1.5rem;
margin: 1.5rem 0;
font-family: var(--vp-font-family-base);
}
.header {
margin-bottom: 1rem;
}
.title {
font-weight: 700;
font-size: 1.05rem;
}
.subtitle {
color: var(--vp-c-text-2);
font-size: 0.9rem;
margin-top: 0.25rem;
}
.scenarios {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.scenario-btn {
padding: 0.6rem 1rem;
border: 2px solid var(--vp-c-divider);
background: var(--vp-c-bg);
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 0.9rem;
transition: all 0.2s;
}
.scenario-btn:hover {
border-color: var(--vp-c-brand);
}
.scenario-btn.active {
background: var(--vp-c-brand);
color: white;
border-color: var(--vp-c-brand);
}
.demo-area {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.section-title {
font-size: 0.85rem;
font-weight: 600;
color: var(--vp-c-text-2);
margin-bottom: 0.75rem;
}
.sender-section,
.timeline-section,
.result-section {
background: var(--vp-c-bg);
border-radius: 10px;
padding: 1rem;
border: 1px solid var(--vp-c-divider);
}
.scenario-info {
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--vp-c-bg-soft);
border-radius: 8px;
}
.scenario-name {
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.scenario-desc {
font-size: 0.8rem;
color: var(--vp-c-text-2);
line-height: 1.4;
}
.delay-setting {
margin-bottom: 1rem;
}
.delay-setting > label {
display: block;
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
.delay-presets {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.preset-btn {
padding: 0.4rem 0.75rem;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
transition: all 0.2s;
}
.preset-btn:hover {
border-color: var(--vp-c-brand);
}
.preset-btn.active {
background: var(--vp-c-brand);
color: white;
border-color: var(--vp-c-brand);
}
.delay-custom {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
}
.delay-custom input {
width: 80px;
padding: 0.35rem 0.5rem;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg);
}
.send-btn {
width: 100%;
padding: 0.75rem;
background: var(--vp-c-brand);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
}
.send-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.send-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.timeline {
position: relative;
height: 150px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 0.5rem;
margin-top: 0.5rem;
}
.timeline-now {
position: absolute;
left: 10px;
top: 0;
bottom: 0;
width: 2px;
background: var(--vp-c-brand);
}
.now-marker {
position: absolute;
top: -25px;
left: 50%;
transform: translateX(-50%);
font-size: 0.75rem;
font-weight: 600;
color: var(--vp-c-brand);
white-space: nowrap;
}
.delayed-messages {
position: relative;
height: 100%;
}
.delayed-msg {
position: absolute;
top: 10px;
transform: translateX(-50%);
transition: left 1s linear;
}
.msg-bubble {
background: white;
border: 2px solid var(--vp-c-brand);
border-radius: 8px;
padding: 0.5rem;
text-align: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.msg-id {
font-weight: 600;
font-size: 0.75rem;
margin-bottom: 0.25rem;
}
.msg-time {
font-size: 0.7rem;
color: var(--vp-c-text-2);
}
.msg-timer {
width: 3px;
background: linear-gradient(180deg, var(--vp-c-brand), transparent);
margin: 0.5rem auto 0;
border-radius: 2px;
transition: height 1s linear;
}
.timeline-scale {
position: absolute;
bottom: 0;
left: 10px;
right: 0;
display: flex;
justify-content: space-between;
padding: 0 10px;
}
.tick {
display: flex;
flex-direction: column;
align-items: center;
}
.tick-line {
width: 1px;
height: 10px;
background: var(--vp-c-divider);
}
.tick-label {
font-size: 0.65rem;
color: var(--vp-c-text-3);
margin-top: 0.2rem;
}
.result-box {
max-height: 250px;
overflow-y: auto;
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 0.75rem;
}
.empty {
text-align: center;
color: var(--vp-c-text-3);
font-size: 0.85rem;
padding: 1.5rem;
}
.delivered-msg {
background: white;
border-radius: 6px;
padding: 0.75rem;
margin-bottom: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
animation: slideIn 0.3s ease;
}
.msg-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.35rem;
font-size: 0.8rem;
}
.msg-header .msg-id {
font-weight: 600;
}
.msg-time {
color: var(--vp-c-text-3);
font-size: 0.7rem;
}
.msg-content {
font-size: 0.8rem;
color: var(--vp-c-text-2);
}
.use-cases {
background: var(--vp-c-bg);
border-radius: 10px;
padding: 1rem;
margin-bottom: 1rem;
border: 1px solid var(--vp-c-divider);
}
.cases-title {
font-weight: 600;
margin-bottom: 0.75rem;
}
.cases-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.case-card {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 0.75rem;
text-align: center;
}
.case-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.case-name {
font-weight: 600;
font-size: 0.85rem;
margin-bottom: 0.35rem;
}
.case-desc {
font-size: 0.75rem;
color: var(--vp-c-text-2);
line-height: 1.4;
}
.implementation {
background: var(--vp-c-bg);
border-radius: 10px;
padding: 1rem;
border: 1px solid var(--vp-c-divider);
}
.impl-title {
font-weight: 600;
margin-bottom: 0.75rem;
}
.impl-table {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
th,
td {
padding: 0.6rem;
text-align: left;
border-bottom: 1px solid var(--vp-c-divider);
}
th {
background: var(--vp-c-bg-soft);
font-weight: 600;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
@@ -0,0 +1,563 @@
<!--
MessageQueueComparisonDemo.vue
主流消息队列对比交互演示
-->
<template>
<div class="mq-comparison-demo">
<div class="header">
<div class="title">主流消息队列对比</div>
<div class="subtitle">选择不同 MQ查看特性对比和适用场景</div>
</div>
<div class="mq-selector">
<button
v-for="mq in messageQueues"
:key="mq.name"
class="mq-btn"
:class="{ active: selectedMQ === mq.name }"
@click="selectMQ(mq.name)"
>
{{ mq.label }}
</button>
</div>
<div class="mq-details">
<div class="mq-card">
<div class="mq-header">
<div class="mq-name">{{ currentMQ.label }}</div>
<div class="mq-tag">{{ currentMQ.positioning }}</div>
</div>
<div class="metrics-grid">
<div class="metric">
<div class="metric-label">吞吐量</div>
<div class="metric-value">{{ currentMQ.throughput }}</div>
<div class="metric-bar">
<div
class="bar-fill"
:style="{ width: currentMQ.throughputPercent + '%' }"
></div>
</div>
</div>
<div class="metric">
<div class="metric-label">延迟</div>
<div class="metric-value">{{ currentMQ.latency }}</div>
<div class="metric-desc">{{ currentMQ.latencyDesc }}</div>
</div>
<div class="metric">
<div class="metric-label">可靠性</div>
<div class="metric-value">{{ currentMQ.reliability }}</div>
<div class="metric-desc">{{ currentMQ.reliabilityDesc }}</div>
</div>
<div class="metric">
<div class="metric-label">学习曲线</div>
<div class="metric-value">{{ currentMQ.learning }}</div>
<div class="metric-bar">
<div
class="bar-fill learning"
:style="{ width: currentMQ.learningPercent + '%' }"
></div>
</div>
</div>
</div>
<div class="features">
<div class="feature-title">核心特性</div>
<div class="feature-list">
<div
v-for="feature in currentMQ.features"
:key="feature"
class="feature-item"
>
{{ feature }}
</div>
</div>
</div>
<div class="use-cases">
<div class="use-case-title"> 适用场景</div>
<ul class="use-case-list">
<li v-for="useCase in currentMQ.useCases" :key="useCase">
{{ useCase }}
</li>
</ul>
</div>
<div class="not-recommended">
<div class="not-title"> 不推荐场景</div>
<ul class="not-list">
<li v-for="item in currentMQ.notRecommended" :key="item">
{{ item }}
</li>
</ul>
</div>
</div>
</div>
<div class="comparison-table">
<div class="table-title">快速对比表</div>
<table>
<thead>
<tr>
<th>特性</th>
<th
v-for="mq in messageQueues"
:key="mq.name"
:class="{ highlight: mq.name === selectedMQ }"
>
{{ mq.label }}
</th>
</tr>
</thead>
<tbody>
<tr>
<td>吞吐量</td>
<td
v-for="mq in messageQueues"
:key="mq.name"
:class="{ highlight: mq.name === selectedMQ }"
>
{{ mq.throughput }}
</td>
</tr>
<tr>
<td>延迟</td>
<td
v-for="mq in messageQueues"
:key="mq.name"
:class="{ highlight: mq.name === selectedMQ }"
>
{{ mq.latency }}
</td>
</tr>
<tr>
<td>消息顺序</td>
<td
v-for="mq in messageQueues"
:key="mq.name"
:class="{ highlight: mq.name === selectedMQ }"
>
{{ mq.ordering }}
</td>
</tr>
<tr>
<td>消息回溯</td>
<td
v-for="mq in messageQueues"
:key="mq.name"
:class="{ highlight: mq.name === selectedMQ }"
>
{{ mq.rewind }}
</td>
</tr>
<tr>
<td>最佳场景</td>
<td
v-for="mq in messageQueues"
:key="mq.name"
:class="{ highlight: mq.name === selectedMQ }"
>
{{ mq.bestScenario }}
</td>
</tr>
</tbody>
</table>
</div>
<div class="recommendation">
<div class="rec-title">💡 选择建议</div>
<div class="rec-content">
<div v-if="selectedMQ === 'rabbitmq'" class="rec-text">
<strong>RabbitMQ</strong>
是最稳妥的选择适合大多数传统业务场景如果团队有 AMQP
经验或者需要复杂的路由规则优先选择它
</div>
<div v-else-if="selectedMQ === 'kafka'" class="rec-text">
<strong>Kafka</strong> 适合大数据量和流式处理场景如果需要处理百万级
TPS或者需要消息回溯与大数据生态集成选择 Kafka
</div>
<div v-else-if="selectedMQ === 'rocketmq'" class="rec-text">
<strong>RocketMQ</strong>
是阿里开源特别适合电商金融场景如果需要事务消息顺序消息延迟消息等高级特性RocketMQ
是最佳选择
</div>
<div v-else class="rec-text">
<strong>Redis Stream</strong> 最轻量适合小团队和 MVP
验证如果已经有 Redis 基础设施且对可靠性要求不是极高可以先用
Redis Stream 快速实现
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const selectedMQ = ref('rabbitmq')
const messageQueues = [
{
name: 'rabbitmq',
label: 'RabbitMQ',
positioning: '传统消息队列',
throughput: '1 万/秒',
throughputPercent: 10,
latency: '微秒级',
latencyDesc: '极低延迟',
reliability: '高',
reliabilityDesc: '持久化支持',
learning: '中等',
learningPercent: 40,
ordering: '支持(单队列)',
rewind: '不支持',
bestScenario: '传统业务',
features: [
'AMQP 协议标准',
'灵活的路由规则',
'多种消息模式',
'管理界面友好',
'成熟的生态'
],
useCases: [
'传统业务系统',
'任务队列',
'需要复杂路由规则',
'对延迟敏感(微秒级)',
'团队熟悉 AMQP'
],
notRecommended: ['吞吐量要求百万级', '需要消息回溯功能']
},
{
name: 'kafka',
label: 'Kafka',
positioning: '分布式日志系统',
throughput: '100 万/秒',
throughputPercent: 100,
latency: '毫秒级',
latencyDesc: '相对较高',
reliability: '高',
reliabilityDesc: '多副本机制',
learning: '陡峭',
learningPercent: 80,
ordering: '支持(分区内)',
rewind: '支持',
bestScenario: '日志/流处理',
features: [
'超高吞吐量',
'消息回溯能力',
'分布式架构',
'与大数据生态集成',
'分区机制'
],
useCases: [
'日志收集',
'流式处理',
'事件溯源',
'用户行为分析',
'百万级 TPS 场景'
],
notRecommended: ['对延迟极度敏感', '简单的任务队列', '小团队快速开发']
},
{
name: 'rocketmq',
label: 'RocketMQ',
positioning: '电商级消息队列',
throughput: '10 万/秒',
throughputPercent: 30,
latency: '毫秒级',
latencyDesc: '低延迟',
reliability: '高',
reliabilityDesc: '同步/异步刷盘',
learning: '陡峭',
learningPercent: 70,
ordering: '支持',
rewind: '支持',
bestScenario: '电商/金融',
features: ['事务消息', '顺序消息', '延迟消息', '消息过滤', '金融级可靠性'],
useCases: [
'电商交易系统',
'金融支付',
'订单处理',
'需要事务一致性',
'需要定时/延迟消息'
],
notRecommended: ['简单的异步任务', '小团队快速验证', '不需要高级特性']
},
{
name: 'redis',
label: 'Redis Stream',
positioning: '轻量级队列',
throughput: '5 万/秒',
throughputPercent: 20,
latency: '毫秒级',
latencyDesc: '低延迟',
reliability: '中',
reliabilityDesc: 'AOF 持久化',
learning: '简单',
learningPercent: 15,
ordering: '支持',
rewind: '支持',
bestScenario: '小规模队列',
features: ['轻量级', '基于 Redis', '学习成本低', '易于部署', '性能优秀'],
useCases: [
'小团队项目',
'MVP 快速验证',
'已有 Redis 基础设施',
'简单队列需求',
'对可靠性要求不高'
],
notRecommended: ['对可靠性要求极高', '复杂的路由需求', '需要事务消息']
}
]
const currentMQ = computed(() => {
return (
messageQueues.find((mq) => mq.name === selectedMQ.value) || messageQueues[0]
)
})
const selectMQ = (name) => {
selectedMQ.value = name
}
</script>
<style scoped>
.mq-comparison-demo {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
border-radius: 12px;
padding: 1.5rem;
margin: 1.5rem 0;
font-family: var(--vp-font-family-base);
}
.header {
margin-bottom: 1rem;
}
.title {
font-weight: 700;
font-size: 1.05rem;
}
.subtitle {
color: var(--vp-c-text-2);
font-size: 0.9rem;
margin-top: 0.25rem;
}
.mq-selector {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.mq-btn {
padding: 0.75rem 1rem;
border: 2px solid var(--vp-c-divider);
background: var(--vp-c-bg);
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
}
.mq-btn:hover {
border-color: var(--vp-c-brand);
}
.mq-btn.active {
background: var(--vp-c-brand);
color: #fff;
border-color: var(--vp-c-brand);
}
.mq-details {
margin-bottom: 1.5rem;
}
.mq-card {
background: var(--vp-c-bg);
border-radius: 12px;
padding: 1.5rem;
border: 1px solid var(--vp-c-divider);
}
.mq-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 0.5rem;
}
.mq-name {
font-size: 1.3rem;
font-weight: 700;
}
.mq-tag {
padding: 0.4rem 0.8rem;
background: rgba(59, 130, 246, 0.15);
color: var(--vp-c-brand);
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.metric {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 1rem;
}
.metric-label {
font-size: 0.85rem;
color: var(--vp-c-text-2);
margin-bottom: 0.5rem;
}
.metric-value {
font-size: 1.1rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.metric-desc {
font-size: 0.8rem;
color: var(--vp-c-text-3);
}
.metric-bar {
height: 6px;
background: var(--vp-c-bg);
border-radius: 3px;
overflow: hidden;
margin-top: 0.5rem;
}
.bar-fill {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #1d4ed8);
transition: width 0.5s ease;
}
.bar-fill.learning {
background: linear-gradient(90deg, #f59e0b, #d97706);
}
.features {
margin-bottom: 1.5rem;
}
.feature-title {
font-weight: 600;
margin-bottom: 0.75rem;
}
.feature-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.5rem;
}
.feature-item {
padding: 0.5rem 0.75rem;
background: rgba(34, 197, 94, 0.1);
border-radius: 6px;
font-size: 0.85rem;
color: #166534;
}
.use-cases,
.not-recommended {
margin-bottom: 1rem;
}
.use-case-title,
.not-title {
font-weight: 600;
margin-bottom: 0.5rem;
}
.use-case-list,
.not-list {
margin: 0;
padding-left: 1.5rem;
}
.use-case-list li,
.not-list li {
margin-bottom: 0.35rem;
font-size: 0.9rem;
line-height: 1.5;
}
.not-list li {
color: var(--vp-c-text-2);
}
.comparison-table {
margin-bottom: 1.5rem;
overflow-x: auto;
}
.table-title {
font-weight: 600;
margin-bottom: 0.75rem;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th,
td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--vp-c-divider);
}
th {
background: var(--vp-c-bg);
font-weight: 600;
}
td.highlight,
th.highlight {
background: rgba(59, 130, 246, 0.1);
font-weight: 600;
}
.recommendation {
background: rgba(59, 130, 246, 0.1);
border-radius: 10px;
padding: 1rem;
border: 1px solid rgba(59, 130, 246, 0.3);
}
.rec-title {
font-weight: 600;
margin-bottom: 0.5rem;
}
.rec-text {
font-size: 0.9rem;
line-height: 1.6;
}
</style>
@@ -0,0 +1,561 @@
<!--
MessageQueueComponentsDemo.vue
消息队列三要素可视化 - 生产者/Broker/消费者
-->
<template>
<div class="mq-components-demo">
<div class="header">
<div class="title">消息队列的三要素</div>
<div class="subtitle">生产者消息代理消费者的关系</div>
</div>
<div class="components-flow">
<div class="component producer">
<div class="comp-header">
<div class="comp-icon">📤</div>
<div class="comp-name">生产者 Producer</div>
</div>
<div class="comp-content">
<div class="comp-desc">发送消息的一方</div>
<div class="comp-example">例子订单服务</div>
<button
class="action-btn"
@click="produceMessage"
:disabled="producing"
>
{{ producing ? '发送中...' : '发送消息' }}
</button>
</div>
</div>
<div class="arrow" :class="{ active: messageInTransit }">
{{ messageInTransit ? '📨' : '→' }}
</div>
<div class="component broker">
<div class="comp-header">
<div class="comp-icon">📦</div>
<div class="comp-name">消息代理 Broker</div>
</div>
<div class="comp-content">
<div class="comp-desc">存储和转发消息</div>
<div class="comp-example">例子RabbitMQ, Kafka</div>
<div class="broker-storage">
<div class="storage-label">消息存储</div>
<div class="storage-box">
<transition-group name="message">
<div
v-for="msg in brokerMessages"
:key="msg.id"
class="broker-msg"
>
消息 #{{ msg.id }}
</div>
</transition-group>
<div v-if="brokerMessages.length === 0" class="empty">
暂无消息
</div>
</div>
</div>
</div>
</div>
<div class="arrow" :class="{ active: consuming }">
{{ consuming ? '📨' : '→' }}
</div>
<div class="component consumer">
<div class="comp-header">
<div class="comp-icon">📥</div>
<div class="comp-name">消费者 Consumer</div>
</div>
<div class="comp-content">
<div class="comp-desc">接收并处理消息</div>
<div class="comp-example">例子库存服务</div>
<button
class="action-btn consume"
@click="consumeMessage"
:disabled="brokerMessages.length === 0 || consuming"
>
{{ consuming ? '处理中...' : '消费消息' }}
</button>
<div v-if="lastConsumed" class="last-consumed">
已处理: #{{ lastConsumed }}
</div>
</div>
</div>
</div>
<div class="component-details">
<div class="detail-card producer">
<div class="detail-title">📤 生产者 (Producer)</div>
<div class="detail-content">
<div class="detail-item">
<strong>职责</strong>创建并发送消息到 Broker
</div>
<div class="detail-item">
<strong>特点</strong>发送后立即返回不等待处理完成
</div>
<div class="detail-item">
<strong>例子</strong>
<ul>
<li>订单服务下单成功后发送消息</li>
<li>用户服务用户注册后发送消息</li>
<li>支付服务支付完成后发送消息</li>
</ul>
</div>
</div>
</div>
<div class="detail-card broker">
<div class="detail-title">📦 消息代理 (Broker)</div>
<div class="detail-content">
<div class="detail-item">
<strong>职责</strong>存储转发管理消息
</div>
<div class="detail-item">
<strong>特点</strong>
<ul>
<li>消息持久化防止丢失</li>
<li>消息确认机制ACK</li>
<li>支持多种消息模式</li>
</ul>
</div>
<div class="detail-item">
<strong>常见实现</strong>
RabbitMQ, Kafka, RocketMQ, Redis Stream
</div>
</div>
</div>
<div class="detail-card consumer">
<div class="detail-title">📥 消费者 (Consumer)</div>
<div class="detail-content">
<div class="detail-item">
<strong>职责</strong> Broker 接收并处理消息
</div>
<div class="detail-item">
<strong>特点</strong>
<ul>
<li>可以单机或集群部署</li>
<li>处理失败可以重试</li>
<li>处理完成后发送 ACK</li>
</ul>
</div>
<div class="detail-item">
<strong>例子</strong>
<ul>
<li>库存服务扣减库存</li>
<li>短信服务发送通知</li>
<li>积分服务增加积分</li>
</ul>
</div>
</div>
</div>
</div>
<div class="message-flow">
<div class="flow-title">🔄 完整的消息流程</div>
<div class="flow-steps">
<div class="flow-step">
<div class="step-num">1</div>
<div class="step-content">
<div class="step-title">生产者发送消息</div>
<div class="step-desc">订单服务创建订单后发送"订单创建"消息</div>
</div>
</div>
<div class="flow-arrow"></div>
<div class="flow-step">
<div class="step-num">2</div>
<div class="step-content">
<div class="step-title">Broker 存储消息</div>
<div class="step-desc">消息队列接收并存储消息持久化到磁盘</div>
</div>
</div>
<div class="flow-arrow"></div>
<div class="flow-step">
<div class="step-num">3</div>
<div class="step-content">
<div class="step-title">消费者拉取消息</div>
<div class="step-desc">库存服务从队列中拉取消息</div>
</div>
</div>
<div class="flow-arrow"></div>
<div class="flow-step">
<div class="step-num">4</div>
<div class="step-content">
<div class="step-title">处理业务逻辑</div>
<div class="step-desc">扣减库存创建出库记录</div>
</div>
</div>
<div class="flow-arrow"></div>
<div class="flow-step">
<div class="step-num">5</div>
<div class="step-content">
<div class="step-title">发送 ACK</div>
<div class="step-desc">告诉 Broker 消息处理成功可以删除</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const producing = ref(false)
const consuming = ref(false)
const messageInTransit = ref(false)
const brokerMessages = ref([])
const lastConsumed = ref(null)
let messageId = 0
const produceMessage = () => {
if (producing.value) return
producing.value = true
messageInTransit.value = true
setTimeout(() => {
messageId++
brokerMessages.value.push({ id: messageId })
producing.value = false
messageInTransit.value = false
}, 500)
}
const consumeMessage = () => {
if (consuming.value || brokerMessages.value.length === 0) return
consuming.value = true
const msg = brokerMessages.value.shift()
setTimeout(() => {
consuming.value = false
lastConsumed.value = msg.id
setTimeout(() => {
lastConsumed.value = null
}, 2000)
}, 1000)
}
</script>
<style scoped>
.mq-components-demo {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
border-radius: 12px;
padding: 1.5rem;
margin: 1.5rem 0;
font-family: var(--vp-font-family-base);
}
.header {
margin-bottom: 1.5rem;
}
.title {
font-weight: 700;
font-size: 1.05rem;
}
.subtitle {
color: var(--vp-c-text-2);
font-size: 0.9rem;
margin-top: 0.25rem;
}
.components-flow {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
align-items: center;
margin-bottom: 2rem;
}
.component {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 10px;
padding: 1rem;
text-align: center;
transition: all 0.3s;
}
.component.producer {
border-color: #3b82f6;
}
.component.broker {
border-color: #8b5cf6;
}
.component.consumer {
border-color: #22c55e;
}
.comp-header {
margin-bottom: 0.75rem;
}
.comp-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.comp-name {
font-weight: 600;
font-size: 0.9rem;
}
.comp-desc {
font-size: 0.8rem;
color: var(--vp-c-text-2);
margin-bottom: 0.25rem;
}
.comp-example {
font-size: 0.75rem;
color: var(--vp-c-text-3);
margin-bottom: 0.75rem;
}
.action-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
font-size: 0.85rem;
transition: all 0.2s;
}
.action-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.action-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.action-btn {
background: #3b82f6;
color: white;
}
.action-btn.consume {
background: #22c55e;
color: white;
}
.arrow {
font-size: 1.5rem;
color: var(--vp-c-text-3);
text-align: center;
transition: all 0.3s;
}
.arrow.active {
color: var(--vp-c-brand);
animation: bounce 0.5s ease;
}
.broker-storage {
margin-top: 0.75rem;
}
.storage-label {
font-size: 0.75rem;
color: var(--vp-c-text-2);
margin-bottom: 0.5rem;
}
.storage-box {
min-height: 80px;
background: var(--vp-c-bg-soft);
border-radius: 6px;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.broker-msg {
padding: 0.35rem 0.5rem;
background: white;
border-radius: 4px;
font-size: 0.75rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
animation: slideIn 0.3s ease;
}
.empty {
text-align: center;
color: var(--vp-c-text-3);
font-size: 0.75rem;
padding: 1rem 0;
}
.last-consumed {
margin-top: 0.5rem;
padding: 0.35rem 0.5rem;
background: #dcfce7;
color: #166534;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.component-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.detail-card {
background: var(--vp-c-bg);
border-radius: 10px;
padding: 1rem;
border: 1px solid var(--vp-c-divider);
}
.detail-card.producer {
border-left: 4px solid #3b82f6;
}
.detail-card.broker {
border-left: 4px solid #8b5cf6;
}
.detail-card.consumer {
border-left: 4px solid #22c55e;
}
.detail-title {
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.75rem;
}
.detail-content {
font-size: 0.85rem;
line-height: 1.6;
}
.detail-item {
margin-bottom: 0.75rem;
}
.detail-item:last-child {
margin-bottom: 0;
}
.detail-item ul {
margin: 0.35rem 0 0 1rem;
padding: 0;
}
.detail-item li {
margin-bottom: 0.25rem;
color: var(--vp-c-text-2);
}
.message-flow {
background: var(--vp-c-bg);
border-radius: 10px;
padding: 1rem;
border: 1px solid var(--vp-c-divider);
}
.flow-title {
font-weight: 600;
margin-bottom: 1rem;
}
.flow-steps {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: flex-start;
}
.flow-step {
display: flex;
gap: 0.5rem;
flex: 1;
min-width: 150px;
}
.step-num {
width: 24px;
height: 24px;
background: var(--vp-c-brand);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
flex-shrink: 0;
}
.step-content {
flex: 1;
}
.step-title {
font-weight: 600;
font-size: 0.85rem;
margin-bottom: 0.25rem;
}
.step-desc {
font-size: 0.8rem;
color: var(--vp-c-text-2);
line-height: 1.4;
}
.flow-arrow {
font-size: 1.2rem;
color: var(--vp-c-text-3);
align-self: center;
padding: 0 0.25rem;
}
.message-enter-active {
transition: all 0.3s ease;
}
.message-enter-from {
opacity: 0;
transform: translateX(-10px);
}
@keyframes bounce {
0%,
100% {
transform: translateX(0);
}
50% {
transform: translateX(5px);
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
@@ -0,0 +1,425 @@
<!--
MessageQueueDemo.vue
消息队列概览 - 异步通信可视化
-->
<template>
<div class="mq-demo">
<div class="header">
<div class="title">消息队列异步通信的"缓冲器"</div>
<div class="subtitle">观察消息如何通过队列实现异步处理</div>
</div>
<div class="flow-container">
<div class="section producer">
<div class="section-title">生产者 Producer</div>
<div class="box producer-box">
<div class="icon">📤</div>
<div class="label">订单服务</div>
</div>
<button class="send-btn" @click="sendMessage" :disabled="sending">
{{ sending ? '发送中...' : '发送消息' }}
</button>
</div>
<div class="section broker">
<div class="section-title">消息代理 Broker</div>
<div class="queue-container">
<div class="queue-label">消息队列 Queue</div>
<div class="queue-box">
<transition-group name="message">
<div
v-for="msg in messages"
:key="msg.id"
class="message-item"
:style="{ backgroundColor: msg.color }"
>
#{{ msg.id }}
</div>
</transition-group>
<div v-if="messages.length === 0" class="empty-queue">队列为空</div>
</div>
<div class="queue-stats">
<div class="stat">消息数: {{ messages.length }}</div>
<div class="stat">容量: {{ queueCapacity }}</div>
</div>
</div>
</div>
<div class="section consumer">
<div class="section-title">消费者 Consumer</div>
<div class="box consumer-box" :class="{ processing: isProcessing }">
<div class="icon">{{ isProcessing ? '⚙️' : '📥' }}</div>
<div class="label">{{ isProcessing ? '处理中...' : '库存服务' }}</div>
</div>
<div v-if="processedMessage" class="processed-msg">
已处理: #{{ processedMessage }}
</div>
</div>
</div>
<div class="controls">
<div class="control">
<label>
<input v-model="autoConsume" type="checkbox" />
自动消费
</label>
</div>
<div class="control">
<label>
<input v-model="showSync" type="checkbox" />
显示同步对比
</label>
</div>
</div>
<div v-if="showSync" class="comparison">
<div class="compare-col sync">
<div class="compare-title">同步调用 (Synchronous)</div>
<div class="compare-flow">
<div class="flow-item">A 调用 B</div>
<div class="arrow"></div>
<div class="flow-item wait">B 处理 (阻塞等待)</div>
<div class="arrow"></div>
<div class="flow-item">B 返回结果</div>
</div>
<div class="compare-desc">总耗时 = 300ms + 500ms = 800ms</div>
</div>
<div class="compare-col async">
<div class="compare-title">异步调用 (Asynchronous)</div>
<div class="compare-flow">
<div class="flow-item">A 发送消息</div>
<div class="arrow"></div>
<div class="flow-item highlight">消息队列缓冲</div>
<div class="arrow"></div>
<div class="flow-item">B 稍后处理</div>
</div>
<div class="compare-desc">A 只需 10msB 在后台慢慢处理</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const messages = ref([])
const isProcessing = ref(false)
const sending = ref(false)
const processedMessage = ref(null)
const autoConsume = ref(false)
const showSync = ref(false)
const queueCapacity = 10
let messageId = 0
const colors = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981']
const sendMessage = () => {
if (messages.value.length >= queueCapacity) {
return
}
sending.value = true
messageId++
messages.value.push({
id: messageId,
color: colors[messageId % colors.length]
})
setTimeout(() => {
sending.value = false
if (autoConsume.value && messages.value.length > 0) {
consumeMessage()
}
}, 300)
}
const consumeMessage = () => {
if (messages.value.length === 0 || isProcessing.value) return
isProcessing.value = true
const msg = messages.value.shift()
setTimeout(() => {
isProcessing.value = false
processedMessage.value = msg.id
setTimeout(() => {
processedMessage.value = null
}, 2000)
if (autoConsume.value && messages.value.length > 0) {
setTimeout(consumeMessage, 500)
}
}, 1500)
}
</script>
<style scoped>
.mq-demo {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
border-radius: 12px;
padding: 1.5rem;
margin: 1.5rem 0;
font-family: var(--vp-font-family-base);
}
.header {
margin-bottom: 1.5rem;
}
.title {
font-weight: 700;
font-size: 1.05rem;
}
.subtitle {
color: var(--vp-c-text-2);
font-size: 0.9rem;
margin-top: 0.25rem;
}
.flow-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
align-items: start;
}
.section {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.section-title {
font-size: 0.85rem;
font-weight: 600;
color: var(--vp-c-text-2);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.box {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-brand);
border-radius: 10px;
padding: 1rem 0.75rem;
text-align: center;
min-width: 140px;
transition: all 0.3s ease;
}
.box.processing {
border-color: #f59e0b;
animation: pulse 1.5s infinite;
}
.icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.label {
font-size: 0.9rem;
font-weight: 600;
}
.send-btn {
background: var(--vp-c-brand);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.6rem 1.2rem;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
}
.send-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.send-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.queue-container {
width: 100%;
min-width: 200px;
}
.queue-label {
font-size: 0.85rem;
font-weight: 600;
text-align: center;
margin-bottom: 0.5rem;
color: var(--vp-c-text-2);
}
.queue-box {
background: var(--vp-c-bg);
border: 2px dashed var(--vp-c-divider);
border-radius: 10px;
min-height: 200px;
max-height: 280px;
overflow-y: auto;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.message-item {
padding: 0.5rem 0.75rem;
border-radius: 6px;
color: white;
font-size: 0.85rem;
font-weight: 600;
text-align: center;
animation: slideIn 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.empty-queue {
text-align: center;
color: var(--vp-c-text-3);
font-size: 0.85rem;
padding: 2rem 0;
}
.queue-stats {
display: flex;
justify-content: space-around;
margin-top: 0.75rem;
font-size: 0.8rem;
color: var(--vp-c-text-2);
}
.processed-msg {
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: #dcfce7;
color: #166534;
border-radius: 6px;
font-size: 0.85rem;
text-align: center;
}
.controls {
display: flex;
gap: 1.5rem;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--vp-c-divider);
}
.control label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
cursor: pointer;
}
.comparison {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
.compare-col {
background: var(--vp-c-bg);
border-radius: 10px;
padding: 1rem;
border: 1px solid var(--vp-c-divider);
}
.compare-title {
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.75rem;
text-align: center;
}
.compare-flow {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.flow-item {
background: var(--vp-c-bg-soft);
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.85rem;
text-align: center;
width: 100%;
}
.flow-item.wait {
color: #f59e0b;
font-weight: 600;
}
.flow-item.highlight {
background: rgba(59, 130, 246, 0.1);
color: var(--vp-c-brand);
font-weight: 600;
border: 1px solid var(--vp-c-brand);
}
.arrow {
font-size: 0.75rem;
}
.compare-desc {
margin-top: 0.75rem;
font-size: 0.8rem;
color: var(--vp-c-text-2);
text-align: center;
line-height: 1.5;
}
.message-enter-active,
.message-leave-active {
transition: all 0.3s ease;
}
.message-enter-from {
opacity: 0;
transform: translateX(-20px);
}
.message-leave-to {
opacity: 0;
transform: translateX(20px);
}
@keyframes pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.4);
}
50% {
box-shadow: 0 0 0 8px rgba(245, 158, 11, 0);
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
@@ -0,0 +1,504 @@
<!--
PeakShavingDemo.vue
削峰填谷演示 - 流量缓冲可视化
-->
<template>
<div class="peak-shaving-demo">
<div class="header">
<div class="title">削峰填谷把高峰"摊平"</div>
<div class="subtitle">调整请求速率观察队列如何缓冲流量</div>
</div>
<div class="controls">
<div class="control">
<label>
请求速率<strong>{{ requestRate }}</strong> 请求/
</label>
<input
v-model="requestRate"
type="range"
min="100"
max="10000"
step="100"
/>
</div>
<div class="control">
<label>
处理速率<strong>{{ processRate }}</strong> 请求/
</label>
<input
v-model="processRate"
type="range"
min="50"
max="500"
step="10"
/>
</div>
<div class="control">
<label>
队列容量<strong>{{ queueCapacity }}</strong>
</label>
<input
v-model="queueCapacity"
type="range"
min="100"
max="5000"
step="100"
/>
</div>
</div>
<div class="simulation">
<button class="sim-btn" @click="toggleSimulation">
{{ running ? ' 暂停' : ' 开始模拟' }}
</button>
<button class="sim-btn reset" @click="reset">🔄 重置</button>
</div>
<div class="flow-visualization">
<div class="column incoming">
<div class="col-header">📥 入站流量</div>
<div class="rate-display">{{ requestRate }}/s</div>
<div class="bar-container">
<div class="bar-fill" :style="{ height: requestHeight + '%' }"></div>
</div>
<div class="particles incoming-particles">
<div
v-for="p in incomingParticles"
:key="p.id"
class="particle"
:style="{ animationDelay: p.delay + 'ms' }"
></div>
</div>
</div>
<div class="column queue">
<div class="col-header">📦 消息队列</div>
<div class="queue-info">
<div class="queue-count">{{ queueLength }} / {{ queueCapacity }}</div>
<div class="queue-percent">{{ queuePercent }}%</div>
</div>
<div class="queue-bar-container">
<div
class="queue-bar"
:class="queueStatus"
:style="{ width: queuePercent + '%' }"
></div>
</div>
<div class="queue-status-text">{{ queueStatusText }}</div>
</div>
<div class="column outgoing">
<div class="col-header">📤 处理流量</div>
<div class="rate-display">{{ processRate }}/s</div>
<div class="bar-container">
<div
class="bar-fill stable"
:style="{ height: processHeight + '%' }"
></div>
</div>
<div class="particles outgoing-particles">
<div
v-for="p in outgoingParticles"
:key="p.id"
class="particle processed"
:style="{ animationDelay: p.delay + 'ms' }"
></div>
</div>
</div>
</div>
<div class="metrics">
<div class="metric-card">
<div class="metric-label">队列积压</div>
<div class="metric-value">{{ queueLength }}</div>
</div>
<div class="metric-card">
<div class="metric-label">平均等待时间</div>
<div class="metric-value">{{ avgWaitTime }}s</div>
</div>
<div class="metric-card">
<div class="metric-label">已处理请求</div>
<div class="metric-value">{{ processedCount }}</div>
</div>
<div class="metric-card">
<div class="metric-label">拒绝请求</div>
<div class="metric-value error">{{ rejectedCount }}</div>
</div>
</div>
<div class="scenario-tips">
<div class="tip">
<strong>💡 典型场景</strong>秒杀活动 1 秒内 10 万请求数据库只能处理
1000/
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
const requestRate = ref(5000)
const processRate = ref(200)
const queueCapacity = ref(2000)
const queueLength = ref(0)
const processedCount = ref(0)
const rejectedCount = ref(0)
const running = ref(false)
let interval = null
let particleId = 0
const incomingParticles = ref([])
const outgoingParticles = ref([])
const requestHeight = computed(() =>
Math.min(100, (requestRate.value / 10000) * 100)
)
const processHeight = computed(() =>
Math.min(100, (processRate.value / 500) * 100)
)
const queuePercent = computed(() =>
Math.round((queueLength.value / queueCapacity.value) * 100)
)
const queueStatus = computed(() => {
if (queuePercent.value >= 90) return 'critical'
if (queuePercent.value >= 70) return 'warning'
return 'normal'
})
const queueStatusText = computed(() => {
if (queuePercent.value >= 90) return '⚠️ 队列接近满载'
if (queuePercent.value >= 70) return '⚡ 队列积压较多'
return '✅ 队列状态良好'
})
const avgWaitTime = computed(() => {
if (processRate.value === 0) return '∞'
return (queueLength.value / processRate.value).toFixed(1)
})
const toggleSimulation = () => {
running.value = !running.value
if (running.value) {
startSimulation()
} else {
stopSimulation()
}
}
const startSimulation = () => {
interval = setInterval(() => {
// 模拟请求到达
const requests = Math.floor(requestRate.value / 10)
// 尝试加入队列
const available = queueCapacity.value - queueLength.value
const accepted = Math.min(requests, available)
const rejected = requests - accepted
queueLength.value += accepted
rejectedCount.value += rejected
// 生成入站粒子
for (let i = 0; i < Math.min(5, accepted); i++) {
particleId++
incomingParticles.value.push({ id: particleId, delay: i * 50 })
setTimeout(
() => {
incomingParticles.value = incomingParticles.value.filter(
(p) => p.id !== particleId
)
},
500 + i * 50
)
}
// 模拟处理请求
const processed = Math.min(
Math.floor(processRate.value / 10),
queueLength.value
)
queueLength.value -= processed
processedCount.value += processed
// 生成出站粒子
for (let i = 0; i < Math.min(5, processed); i++) {
particleId++
outgoingParticles.value.push({ id: particleId, delay: i * 50 })
setTimeout(
() => {
outgoingParticles.value = outgoingParticles.value.filter(
(p) => p.id !== particleId
)
},
500 + i * 50
)
}
}, 100)
}
const stopSimulation = () => {
if (interval) {
clearInterval(interval)
interval = null
}
}
const reset = () => {
stopSimulation()
running.value = false
queueLength.value = 0
processedCount.value = 0
rejectedCount.value = 0
incomingParticles.value = []
outgoingParticles.value = []
}
onUnmounted(() => {
stopSimulation()
})
</script>
<style scoped>
.peak-shaving-demo {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
border-radius: 12px;
padding: 1.5rem;
margin: 1.5rem 0;
font-family: var(--vp-font-family-base);
}
.header {
margin-bottom: 1rem;
}
.title {
font-weight: 700;
font-size: 1.05rem;
}
.subtitle {
color: var(--vp-c-text-2);
font-size: 0.9rem;
margin-top: 0.25rem;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.control label {
display: block;
margin-bottom: 0.4rem;
font-size: 0.9rem;
}
.control input[type='range'] {
width: 100%;
}
.simulation {
display: flex;
gap: 0.75rem;
justify-content: center;
margin: 1rem 0;
}
.sim-btn {
background: var(--vp-c-brand);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.6rem 1.5rem;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
}
.sim-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.sim-btn.reset {
background: var(--vp-c-text-2);
}
.flow-visualization {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1.5rem;
margin: 1.5rem 0;
padding: 1rem;
background: var(--vp-c-bg);
border-radius: 10px;
}
.column {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.col-header {
font-weight: 600;
font-size: 0.9rem;
}
.rate-display {
font-size: 1.1rem;
font-weight: 700;
color: var(--vp-c-brand);
}
.bar-container {
width: 60px;
height: 120px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
position: relative;
overflow: hidden;
border: 1px solid var(--vp-c-divider);
}
.bar-fill {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(180deg, #3b82f6, #1d4ed8);
border-radius: 0 0 8px 8px;
transition: height 0.3s ease;
}
.bar-fill.stable {
background: linear-gradient(180deg, #22c55e, #16a34a);
}
.queue-info {
text-align: center;
}
.queue-count {
font-size: 1.2rem;
font-weight: 700;
}
.queue-percent {
font-size: 0.85rem;
color: var(--vp-c-text-2);
}
.queue-bar-container {
width: 100%;
height: 24px;
background: var(--vp-c-bg-soft);
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--vp-c-divider);
position: relative;
}
.queue-bar {
height: 100%;
transition:
width 0.3s ease,
background 0.3s ease;
}
.queue-bar.normal {
background: linear-gradient(90deg, #22c55e, #16a34a);
}
.queue-bar.warning {
background: linear-gradient(90deg, #f59e0b, #d97706);
}
.queue-bar.critical {
background: linear-gradient(90deg, #ef4444, #dc2626);
}
.queue-status-text {
font-size: 0.85rem;
text-align: center;
margin-top: 0.25rem;
}
.particles {
position: relative;
height: 60px;
width: 100%;
}
.particle {
position: absolute;
width: 8px;
height: 8px;
background: var(--vp-c-brand);
border-radius: 50%;
animation: fall 0.5s linear infinite;
}
.particle.processed {
background: #22c55e;
}
@keyframes fall {
0% {
transform: translateY(0);
opacity: 1;
}
100% {
transform: translateY(60px);
opacity: 0;
}
}
.metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.metric-card {
background: var(--vp-c-bg);
border-radius: 10px;
padding: 1rem;
text-align: center;
border: 1px solid var(--vp-c-divider);
}
.metric-label {
font-size: 0.85rem;
color: var(--vp-c-text-2);
margin-bottom: 0.5rem;
}
.metric-value {
font-size: 1.3rem;
font-weight: 700;
color: var(--vp-c-text-1);
}
.metric-value.error {
color: #ef4444;
}
.scenario-tips {
margin-top: 1rem;
padding: 0.75rem 1rem;
background: rgba(59, 130, 246, 0.1);
border-radius: 8px;
font-size: 0.9rem;
line-height: 1.5;
}
</style>
@@ -0,0 +1,462 @@
<!--
PointToPointVsPubSubDemo.vue
点对点 vs 发布订阅对比演示
-->
<template>
<div class="messaging-patterns-demo">
<div class="header">
<div class="title">消息模式点对点 vs 发布订阅</div>
<div class="subtitle">选择模式观察消息如何分发</div>
</div>
<div class="mode-selector">
<button
class="mode-btn"
:class="{ active: mode === 'p2p' }"
@click="setMode('p2p')"
>
点对点 (P2P)
</button>
<button
class="mode-btn"
:class="{ active: mode === 'pubsub' }"
@click="setMode('pubsub')"
>
发布订阅 (Pub/Sub)
</button>
</div>
<div class="description">
<div v-if="mode === 'p2p'" class="desc-text">
<strong>点对点模式</strong
>一条消息只能被<strong>一个消费者</strong>消费适合任务分配负载均衡场景
</div>
<div v-else class="desc-text">
<strong>发布订阅模式</strong
>一条消息可以被<strong>多个消费者</strong>同时接收适合事件通知广播场景
</div>
</div>
<div class="demo-area">
<div class="producer-section">
<div class="section-title">生产者 Producer</div>
<div class="producer-box">
<div class="icon">📤</div>
<div class="label">订单服务</div>
</div>
<button class="send-btn" @click="sendMessage" :disabled="sending">
{{ sending ? '发送中...' : '发送消息' }}
</button>
</div>
<div class="broker-section">
<div class="section-title">
{{ mode === 'p2p' ? '队列 Queue' : '主题 Topic' }}
</div>
<div class="broker-box">
<div class="broker-icon">{{ mode === 'p2p' ? '📦' : '📡' }}</div>
<div class="broker-label">
{{ mode === 'p2p' ? '消息队列' : '发布主题' }}
</div>
<div class="message-indicator" v-if="lastMessage">
消息 #{{ lastMessage }}
</div>
</div>
<div class="mode-badge">
{{ mode === 'p2p' ? '竞争消费' : '广播' }}
</div>
</div>
<div class="consumer-section">
<div class="section-title">消费者 Consumers</div>
<div class="consumers-grid">
<div
v-for="consumer in consumers"
:key="consumer.id"
class="consumer-box"
:class="{ active: consumer.active }"
>
<div class="consumer-icon">
{{ consumer.active ? '⚙️' : '💤' }}
</div>
<div class="consumer-label">{{ consumer.name }}</div>
<div class="consumer-count">已处理: {{ consumer.count }}</div>
<div class="consumer-status">
{{ consumer.active ? '处理中' : '空闲' }}
</div>
</div>
</div>
</div>
</div>
<div class="comparison-table">
<table>
<thead>
<tr>
<th>特性</th>
<th>点对点 (P2P)</th>
<th>发布订阅 (Pub/Sub)</th>
</tr>
</thead>
<tbody>
<tr>
<td>消息消费</td>
<td>一个消费者</td>
<td>多个消费者</td>
</tr>
<tr>
<td>典型场景</td>
<td>任务分配负载均衡</td>
<td>事件通知数据广播</td>
</tr>
<tr>
<td>消费关系</td>
<td>竞争消费</td>
<td>独立订阅</td>
</tr>
<tr>
<td>例子</td>
<td>Excel 导出任务分发给工作节点</td>
<td>用户注册后发邮件+短信+优惠券</td>
</tr>
</tbody>
</table>
</div>
<div class="example-scenario">
<div class="scenario-title">📌 实际场景</div>
<div v-if="mode === 'p2p'" class="scenario-content">
<div>
<strong>任务分配</strong>批量导入 10000 条用户数据分发给 3
个工作节点并行处理
</div>
<div class="flow">
任务入队 [Worker1, Worker2, Worker3] 竞争抢任务
每个任务只被处理一次
</div>
</div>
<div v-else class="scenario-content">
<div><strong>事件通知</strong>用户下单成功后同时通知多个系统</div>
<div class="flow">
发布事件 [库存服务, 积分服务, 通知服务, 数据仓库] 各自独立处理
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const mode = ref('p2p')
const sending = ref(false)
const lastMessage = ref(null)
let messageId = 0
const consumers = ref([
{ id: 1, name: '消费者 A', count: 0, active: false },
{ id: 2, name: '消费者 B', count: 0, active: false },
{ id: 3, name: '消费者 C', count: 0, active: false }
])
const setMode = (newMode) => {
mode.value = newMode
consumers.value.forEach((c) => {
c.count = 0
c.active = false
})
lastMessage.value = null
}
const sendMessage = () => {
if (sending.value) return
sending.value = true
messageId++
lastMessage.value = messageId
setTimeout(() => {
if (mode.value === 'p2p') {
// P2P: 随机选择一个消费者
const availableConsumers = consumers.value.filter((c) => !c.active)
if (availableConsumers.length > 0) {
const consumer =
availableConsumers[
Math.floor(Math.random() * availableConsumers.length)
]
processMessage(consumer)
}
} else {
// Pub/Sub: 所有消费者都接收
consumers.value.forEach((consumer) => {
setTimeout(() => {
processMessage(consumer)
}, Math.random() * 500)
})
}
sending.value = false
}, 500)
}
const processMessage = (consumer) => {
consumer.active = true
setTimeout(
() => {
consumer.count++
consumer.active = false
},
1000 + Math.random() * 1000
)
}
</script>
<style scoped>
.messaging-patterns-demo {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
border-radius: 12px;
padding: 1.5rem;
margin: 1.5rem 0;
font-family: var(--vp-font-family-base);
}
.header {
margin-bottom: 1rem;
}
.title {
font-weight: 700;
font-size: 1.05rem;
}
.subtitle {
color: var(--vp-c-text-2);
font-size: 0.9rem;
margin-top: 0.25rem;
}
.mode-selector {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.mode-btn {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid var(--vp-c-divider);
background: var(--vp-c-bg);
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
}
.mode-btn:hover {
border-color: var(--vp-c-brand);
}
.mode-btn.active {
background: var(--vp-c-brand);
color: #fff;
border-color: var(--vp-c-brand);
}
.description {
margin-bottom: 1.5rem;
padding: 0.75rem 1rem;
background: rgba(59, 130, 246, 0.1);
border-radius: 8px;
}
.desc-text {
font-size: 0.9rem;
line-height: 1.5;
}
.demo-area {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.section-title {
font-size: 0.85rem;
font-weight: 600;
color: var(--vp-c-text-2);
text-align: center;
margin-bottom: 0.75rem;
text-transform: uppercase;
}
.producer-section,
.broker-section,
.consumer-section {
display: flex;
flex-direction: column;
align-items: center;
}
.producer-box,
.broker-box {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-brand);
border-radius: 10px;
padding: 1rem;
text-align: center;
min-width: 140px;
margin-bottom: 0.75rem;
}
.icon,
.broker-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.label,
.broker-label {
font-size: 0.9rem;
font-weight: 600;
}
.message-indicator {
margin-top: 0.5rem;
padding: 0.35rem 0.5rem;
background: rgba(59, 130, 246, 0.1);
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
}
.send-btn {
background: var(--vp-c-brand);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.6rem 1.2rem;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
}
.send-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.send-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.mode-badge {
padding: 0.4rem 0.8rem;
background: rgba(59, 130, 246, 0.15);
color: var(--vp-c-brand);
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
}
.consumers-grid {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.consumer-box {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
padding: 0.75rem;
text-align: center;
transition: all 0.3s;
}
.consumer-box.active {
border-color: #f59e0b;
background: rgba(245, 158, 11, 0.05);
}
.consumer-icon {
font-size: 1.5rem;
margin-bottom: 0.25rem;
}
.consumer-label {
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
.consumer-count {
font-size: 0.8rem;
color: var(--vp-c-text-2);
}
.consumer-status {
font-size: 0.75rem;
margin-top: 0.25rem;
color: var(--vp-c-text-3);
}
.comparison-table {
margin: 1.5rem 0;
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th,
td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--vp-c-divider);
}
th {
background: var(--vp-c-bg);
font-weight: 600;
}
tr:hover td {
background: var(--vp-c-bg-soft);
}
.example-scenario {
background: var(--vp-c-bg);
border-radius: 10px;
padding: 1rem;
border: 1px solid var(--vp-c-divider);
}
.scenario-title {
font-weight: 600;
margin-bottom: 0.75rem;
}
.scenario-content {
font-size: 0.9rem;
line-height: 1.6;
}
.scenario-content > div:first-child {
margin-bottom: 0.5rem;
}
.flow {
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--vp-c-bg-soft);
border-radius: 6px;
font-size: 0.85rem;
font-family: monospace;
}
</style>
@@ -0,0 +1,668 @@
<!--
PubSubDemo.vue
发布订阅模式演示 - 一条消息多消费者
-->
<template>
<div class="pubsub-demo">
<div class="header">
<div class="title">发布订阅模式一条消息多处消费</div>
<div class="subtitle">发布一次事件多个订阅者独立处理</div>
</div>
<div class="main-flow">
<div class="publisher-section">
<div class="section-title">📤 发布者 Publisher</div>
<div class="event-selector">
<label>选择事件</label>
<select v-model="selectedEvent" @change="onEventChange">
<option value="order.created">订单创建成功</option>
<option value="user.registered">用户注册成功</option>
<option value="product.updated">商品信息更新</option>
</select>
</div>
<div class="event-details">
<div class="event-name">{{ eventDetails.name }}</div>
<div class="event-desc">{{ eventDetails.description }}</div>
</div>
<button
class="publish-btn"
@click="publishEvent"
:disabled="publishing"
>
{{ publishing ? '发布中...' : '🚀 发布事件' }}
</button>
</div>
<div class="topic-section">
<div class="section-title">📡 主题 Topic</div>
<div class="topic-box" :class="{ active: hasMessage }">
<div class="topic-icon">📨</div>
<div class="topic-name">{{ selectedEvent }}</div>
<div v-if="hasMessage" class="message-indicator">消息已发布</div>
</div>
<div class="topic-desc">所有订阅者都会收到这条消息</div>
</div>
<div class="subscribers-section">
<div class="section-title">📥 订阅者 Subscribers</div>
<div class="subscribers-grid">
<div
v-for="sub in currentSubscribers"
:key="sub.id"
class="subscriber-card"
:class="{ processing: sub.processing, completed: sub.completed }"
>
<div class="sub-icon">{{ sub.icon }}</div>
<div class="sub-name">{{ sub.name }}</div>
<div class="sub-action">{{ sub.action }}</div>
<div class="sub-status">
<span v-if="sub.processing"> 处理中...</span>
<span v-else-if="sub.completed"> 已完成</span>
<span v-else>💤 等待消息</span>
</div>
<div class="sub-count">已处理: {{ sub.count }} </div>
</div>
</div>
</div>
</div>
<div class="real-time-log">
<div class="log-header">
<div class="log-title">📋 实时日志</div>
<button class="clear-btn" @click="clearLog">清空</button>
</div>
<div class="log-content">
<div v-if="logs.length === 0" class="log-empty">暂无日志</div>
<div
v-for="(log, index) in logs"
:key="index"
class="log-entry"
:class="log.type"
>
<span class="log-time">{{ log.time }}</span>
<span class="log-message">{{ log.message }}</span>
</div>
</div>
</div>
<div class="use-cases">
<div class="case-title">💡 典型应用场景</div>
<div class="case-grid">
<div class="case-card">
<div class="case-icon">🛒</div>
<div class="case-name">电商订单</div>
<div class="case-desc">
订单创建 库存服务积分服务通知服务数据仓库同时处理
</div>
</div>
<div class="case-card">
<div class="case-icon">👤</div>
<div class="case-name">用户注册</div>
<div class="case-desc">
用户注册 欢迎邮件短信验证发放优惠券创建用户画像
</div>
</div>
<div class="case-card">
<div class="case-icon">📊</div>
<div class="case-name">数据分析</div>
<div class="case-desc">
用户行为 推荐系统实时统计数据仓库风控系统
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const selectedEvent = ref('order.created')
const publishing = ref(false)
const hasMessage = ref(false)
const logs = ref([])
const eventConfigs = {
'order.created': {
name: '订单创建成功',
description: '用户完成支付,订单创建成功',
subscribers: [
{
id: 1,
name: '库存服务',
icon: '📦',
action: '扣减库存',
processing: false,
completed: false,
count: 0
},
{
id: 2,
name: '积分服务',
icon: '💎',
action: '增加积分',
processing: false,
completed: false,
count: 0
},
{
id: 3,
name: '短信服务',
icon: '📱',
action: '发送短信',
processing: false,
completed: false,
count: 0
},
{
id: 4,
name: '邮件服务',
icon: '📧',
action: '发送邮件',
processing: false,
completed: false,
count: 0
},
{
id: 5,
name: '数据仓库',
icon: '📊',
action: '记录订单数据',
processing: false,
completed: false,
count: 0
}
]
},
'user.registered': {
name: '用户注册成功',
description: '新用户完成注册流程',
subscribers: [
{
id: 1,
name: '欢迎邮件',
icon: '📧',
action: '发送欢迎邮件',
processing: false,
completed: false,
count: 0
},
{
id: 2,
name: '短信验证',
icon: '📱',
action: '发送验证短信',
processing: false,
completed: false,
count: 0
},
{
id: 3,
name: '优惠券服务',
icon: '🎫',
action: '发放新用户券',
processing: false,
completed: false,
count: 0
},
{
id: 4,
name: '用户画像',
icon: '👤',
action: '创建用户档案',
processing: false,
completed: false,
count: 0
}
]
},
'product.updated': {
name: '商品信息更新',
description: '商家更新商品信息',
subscribers: [
{
id: 1,
name: '搜索服务',
icon: '🔍',
action: '更新搜索索引',
processing: false,
completed: false,
count: 0
},
{
id: 2,
name: '推荐服务',
icon: '⭐',
action: '更新推荐列表',
processing: false,
completed: false,
count: 0
},
{
id: 3,
name: '缓存服务',
icon: '⚡',
action: '刷新缓存',
processing: false,
completed: false,
count: 0
}
]
}
}
const subscribers = ref(
JSON.parse(JSON.stringify(eventConfigs['order.created'].subscribers))
)
const eventDetails = computed(() => {
return eventConfigs[selectedEvent.value]
})
const currentSubscribers = computed(() => {
return subscribers.value
})
const onEventChange = () => {
subscribers.value = JSON.parse(
JSON.stringify(eventConfigs[selectedEvent.value].subscribers)
)
hasMessage.value = false
}
const publishEvent = () => {
if (publishing.value) return
publishing.value = true
hasMessage.value = true
addLog('info', `📤 发布事件: ${eventDetails.value.name}`)
// 所有订阅者都收到消息
subscribers.value.forEach((sub, index) => {
setTimeout(() => {
sub.processing = true
sub.completed = false
addLog('info', `📥 ${sub.name} 开始处理`)
// 模拟处理时间
setTimeout(
() => {
sub.processing = false
sub.completed = true
sub.count++
addLog('success', `${sub.name} 处理完成: ${sub.action}`)
setTimeout(() => {
sub.completed = false
}, 2000)
},
1500 + Math.random() * 1000
)
}, index * 200)
})
setTimeout(() => {
publishing.value = false
setTimeout(() => {
hasMessage.value = false
}, 1000)
}, 3000)
}
const addLog = (type, message) => {
const now = new Date()
const time = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
logs.value.unshift({ type, time, message })
logs.value = logs.value.slice(0, 20)
}
const clearLog = () => {
logs.value = []
}
</script>
<style scoped>
.pubsub-demo {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
border-radius: 12px;
padding: 1.5rem;
margin: 1.5rem 0;
font-family: var(--vp-font-family-base);
}
.header {
margin-bottom: 1.5rem;
}
.title {
font-weight: 700;
font-size: 1.05rem;
}
.subtitle {
color: var(--vp-c-text-2);
font-size: 0.9rem;
margin-top: 0.25rem;
}
.main-flow {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.section-title {
font-size: 0.85rem;
font-weight: 600;
color: var(--vp-c-text-2);
text-align: center;
margin-bottom: 0.75rem;
text-transform: uppercase;
}
.publisher-section,
.topic-section,
.subscribers-section {
display: flex;
flex-direction: column;
align-items: center;
}
.event-selector {
width: 100%;
margin-bottom: 1rem;
}
.event-selector label {
display: block;
font-size: 0.85rem;
margin-bottom: 0.4rem;
color: var(--vp-c-text-2);
}
.event-selector select {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg);
font-size: 0.9rem;
}
.event-details {
text-align: center;
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--vp-c-bg);
border-radius: 8px;
width: 100%;
}
.event-name {
font-weight: 600;
font-size: 0.95rem;
margin-bottom: 0.35rem;
}
.event-desc {
font-size: 0.8rem;
color: var(--vp-c-text-2);
line-height: 1.4;
}
.publish-btn {
width: 100%;
padding: 0.75rem;
background: var(--vp-c-brand);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
}
.publish-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.publish-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.topic-box {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 10px;
padding: 1rem;
text-align: center;
min-width: 160px;
transition: all 0.3s;
}
.topic-box.active {
border-color: var(--vp-c-brand);
background: rgba(59, 130, 246, 0.1);
animation: pulse 2s infinite;
}
.topic-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.topic-name {
font-weight: 600;
font-size: 0.85rem;
margin-bottom: 0.5rem;
font-family: monospace;
}
.message-indicator {
margin-top: 0.5rem;
padding: 0.35rem 0.5rem;
background: #dcfce7;
color: #166534;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.topic-desc {
margin-top: 0.75rem;
font-size: 0.8rem;
color: var(--vp-c-text-3);
text-align: center;
}
.subscribers-grid {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 100%;
}
.subscriber-card {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
padding: 0.75rem;
transition: all 0.3s;
}
.subscriber-card.processing {
border-color: #f59e0b;
background: rgba(245, 158, 11, 0.05);
}
.subscriber-card.completed {
border-color: #22c55e;
background: rgba(34, 197, 94, 0.05);
}
.sub-icon {
font-size: 1.5rem;
margin-bottom: 0.25rem;
text-align: center;
}
.sub-name {
font-size: 0.85rem;
font-weight: 600;
text-align: center;
margin-bottom: 0.25rem;
}
.sub-action {
font-size: 0.75rem;
color: var(--vp-c-text-2);
text-align: center;
margin-bottom: 0.35rem;
}
.sub-status {
font-size: 0.75rem;
text-align: center;
margin-bottom: 0.25rem;
}
.sub-count {
font-size: 0.7rem;
color: var(--vp-c-text-3);
text-align: center;
}
.real-time-log {
background: var(--vp-c-bg);
border-radius: 10px;
padding: 1rem;
border: 1px solid var(--vp-c-divider);
margin-bottom: 1.5rem;
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.log-title {
font-weight: 600;
font-size: 0.9rem;
}
.clear-btn {
padding: 0.35rem 0.75rem;
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
transition: all 0.2s;
}
.clear-btn:hover {
background: var(--vp-c-divider);
}
.log-content {
max-height: 250px;
overflow-y: auto;
background: var(--vp-c-bg-soft);
border-radius: 6px;
padding: 0.75rem;
font-size: 0.8rem;
}
.log-empty {
text-align: center;
color: var(--vp-c-text-3);
padding: 1rem;
}
.log-entry {
padding: 0.4rem 0;
border-bottom: 1px solid var(--vp-c-divider);
display: flex;
gap: 0.5rem;
}
.log-entry:last-child {
border-bottom: none;
}
.log-time {
color: var(--vp-c-text-3);
font-family: monospace;
}
.log-message {
flex: 1;
}
.log-entry.info .log-message {
color: var(--vp-c-text-1);
}
.log-entry.success .log-message {
color: #16a34a;
}
.use-cases {
background: var(--vp-c-bg);
border-radius: 10px;
padding: 1rem;
border: 1px solid var(--vp-c-divider);
}
.case-title {
font-weight: 600;
margin-bottom: 1rem;
}
.case-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.case-card {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 1rem;
text-align: center;
}
.case-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.case-name {
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.case-desc {
font-size: 0.8rem;
color: var(--vp-c-text-2);
line-height: 1.5;
}
@keyframes pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4);
}
50% {
box-shadow: 0 0 0 8px rgba(59, 130, 246, 0);
}
}
</style>
@@ -0,0 +1,792 @@
<!--
SeckillSystemDemo.vue
秒杀系统架构演示 - 完整的 MQ 应用场景
-->
<template>
<div class="seckill-demo">
<div class="header">
<div class="title">秒杀系统消息队列的典型应用</div>
<div class="subtitle">处理 10 /秒的并发请求保证不超卖</div>
</div>
<div class="scenario-settings">
<div class="setting">
<label>
商品库存
<strong>{{ stock }}</strong>
</label>
<input v-model="stock" type="range" min="10" max="1000" step="10" />
</div>
<div class="setting">
<label>
请求速率
<strong>{{ requestRate }}</strong>
请求/
</label>
<input
v-model="requestRate"
type="range"
min="100"
max="10000"
step="100"
/>
</div>
<div class="setting">
<label>
订单处理
<strong>{{ processRate }}</strong>
订单/
</label>
<input
v-model="processRate"
type="range"
min="50"
max="500"
step="10"
/>
</div>
</div>
<div class="action-bar">
<button class="start-btn" @click="startSeckill" :disabled="running">
🚀 开始秒杀
</button>
<button class="reset-btn" @click="reset">🔄 重置</button>
</div>
<div class="architecture">
<div class="arch-layer gateway">
<div class="layer-title">🌐 网关层 - 限流</div>
<div class="layer-content">
<div class="stat-box">
<div class="stat-label">总请求数</div>
<div class="stat-value">{{ totalRequests.toLocaleString() }}</div>
</div>
<div class="stat-box">
<div class="stat-label">限流通过</div>
<div class="stat-value success">
{{ passedRequests.toLocaleString() }}
</div>
</div>
<div class="stat-box">
<div class="stat-label">被拒绝</div>
<div class="stat-value error">
{{ rejectedRequests.toLocaleString() }}
</div>
</div>
</div>
</div>
<div class="arch-arrow"></div>
<div class="arch-layer redis">
<div class="layer-title"> Redis 预扣库存</div>
<div class="layer-content">
<div class="stock-display">
<div class="stock-bar">
<div
class="stock-fill"
:style="{ width: stockPercent + '%' }"
></div>
</div>
<div class="stock-text">
剩余: {{ remainingStock }} / {{ stock }}
</div>
</div>
<div class="redis-status" :class="redisStatus.class">
{{ redisStatus.text }}
</div>
</div>
</div>
<div class="arch-arrow"></div>
<div class="arch-layer queue">
<div class="layer-title">📦 消息队列缓冲</div>
<div class="layer-content">
<div class="queue-visual">
<div class="queue-box">
<div class="queue-header">
<span>秒杀订单队列</span>
<span class="queue-count">{{ queueLength }}</span>
</div>
<div class="queue-bar-container">
<div
class="queue-bar"
:class="queueStatus"
:style="{ width: queuePercent + '%' }"
></div>
</div>
</div>
</div>
</div>
</div>
<div class="arch-arrow"></div>
<div class="arch-layer consumer">
<div class="layer-title"> 订单服务处理</div>
<div class="layer-content">
<div class="stat-box">
<div class="stat-label">处理中</div>
<div class="stat-value">{{ processing }}</div>
</div>
<div class="stat-box">
<div class="stat-label">成功订单</div>
<div class="stat-value success">{{ successOrders }}</div>
</div>
<div class="stat-box">
<div class="stat-label">失败订单</div>
<div class="stat-value error">{{ failedOrders }}</div>
</div>
</div>
</div>
</div>
<div class="real-time-stats">
<div class="stats-title">📊 实时监控</div>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-label">平均响应时间</div>
<div class="stat-value">{{ avgLatency }}ms</div>
</div>
<div class="stat-item">
<div class="stat-label">订单成功率</div>
<div class="stat-value">{{ orderSuccessRate }}%</div>
</div>
<div class="stat-item">
<div class="stat-label">队列积压</div>
<div class="stat-value">{{ queueLength }}</div>
</div>
<div class="stat-item">
<div class="stat-label">预计清空时间</div>
<div class="stat-value">{{ estimatedTime }}</div>
</div>
</div>
</div>
<div class="log-section">
<div class="log-header">
<div class="log-title">📋 事件日志</div>
<button class="clear-log" @click="clearLogs">清空</button>
</div>
<div class="log-content">
<div v-if="logs.length === 0" class="log-empty">暂无日志</div>
<div
v-for="(log, index) in logs.slice(0, 15)"
:key="index"
class="log-entry"
:class="log.type"
>
<span class="log-time">{{ log.time }}</span>
<span class="log-msg">{{ log.message }}</span>
</div>
</div>
</div>
<div class="key-points">
<div class="point-title">🎯 核心设计要点</div>
<div class="point-list">
<div class="point-item">
<span class="point-icon">1</span>
<div>
<strong>网关限流</strong>只放行系统能处理的请求数 1
/避免打爆后端
</div>
</div>
<div class="point-item">
<span class="point-icon">2</span>
<div>
<strong>Redis 预扣</strong
>原子操作扣减库存快速判断是否有货避免无效请求
</div>
</div>
<div class="point-item">
<span class="point-icon">3</span>
<div>
<strong>消息队列</strong
>将成功的扣库存请求放入队列异步处理削峰填谷
</div>
</div>
<div class="point-item">
<span class="point-icon">4</span>
<div>
<strong>异步处理</strong
>订单服务慢慢消费队列创建订单保证不超卖
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onUnmounted } from 'vue'
const stock = ref(100)
const requestRate = ref(5000)
const processRate = ref(200)
const running = ref(false)
const totalRequests = ref(0)
const passedRequests = ref(0)
const rejectedRequests = ref(0)
const remainingStock = ref(100)
const queueLength = ref(0)
const processing = ref(0)
const successOrders = ref(0)
const failedOrders = ref(0)
const logs = ref([])
let simulationInterval = null
let processInterval = null
const stockPercent = computed(() => {
if (stock.value === 0) return 0
return Math.round((remainingStock.value / stock.value) * 100)
})
const redisStatus = computed(() => {
if (remainingStock.value === 0) {
return { text: '🔴 已售罄', class: 'soldout' }
}
if (stockPercent.value < 20) {
return { text: '⚠️ 库存紧张', class: 'low' }
}
return { text: '✅ 库存充足', class: 'normal' }
})
const queuePercent = computed(() => {
const maxQueue = 5000
return Math.min(100, Math.round((queueLength.value / maxQueue) * 100))
})
const queueStatus = computed(() => {
if (queuePercent.value >= 80) return 'critical'
if (queuePercent.value >= 50) return 'warning'
return 'normal'
})
const avgLatency = computed(() => {
return 15 + Math.floor(queueLength.value / 100)
})
const orderSuccessRate = computed(() => {
const total = successOrders.value + failedOrders.value
if (total === 0) return 0
return Math.round((successOrders.value / total) * 100)
})
const estimatedTime = computed(() => {
if (queueLength.value === 0 || processRate.value === 0) return '0s'
const seconds = Math.ceil(queueLength.value / (processRate.value / 10))
if (seconds < 60) return `${seconds}s`
return `${Math.ceil(seconds / 60)}m`
})
const startSeckill = () => {
if (running.value) return
running.value = true
remainingStock.value = stock.value
successOrders.value = 0
failedOrders.value = 0
addLog(
'info',
`🚀 秒杀开始!库存: ${stock.value}, 请求速率: ${requestRate.value}/s`
)
simulationInterval = setInterval(() => {
const requests = Math.floor(requestRate.value / 10)
totalRequests.value += requests
// 网关限流:只放行 80%
const passed = Math.floor(requests * 0.8)
const rejected = requests - passed
passedRequests.value += passed
rejectedRequests.value += rejected
// Redis 预扣库存
let successfulPreDeduct = 0
for (let i = 0; i < passed; i++) {
if (remainingStock.value > 0) {
remainingStock.value--
queueLength.value++
successfulPreDeduct++
}
}
if (remainingStock.value === 0 && stock.value > 0) {
addLog('error', '🔴 商品已售罄!')
}
if (successfulPreDeduct > 0 && Math.random() < 0.1) {
addLog('info', `${successfulPreDeduct} 个请求预扣成功,进入队列`)
}
}, 100)
processInterval = setInterval(() => {
if (queueLength.value > 0) {
const toProcess = Math.min(
Math.floor(processRate.value / 10),
queueLength.value
)
queueLength.value -= toProcess
processing.value = toProcess
setTimeout(() => {
processing.value = 0
// 90% 成功率
const success = Math.floor(toProcess * 0.9)
const failed = toProcess - success
successOrders.value += success
failedOrders.value += failed
if (success > 0) {
addLog('success', `✅ 创建 ${success} 个订单`)
}
if (failed > 0) {
addLog('warning', `⚠️ ${failed} 个订单创建失败(库存不足)`)
}
}, 200)
}
}, 100)
}
const reset = () => {
stopSimulation()
totalRequests.value = 0
passedRequests.value = 0
rejectedRequests.value = 0
remainingStock.value = stock.value
queueLength.value = 0
processing.value = 0
successOrders.value = 0
failedOrders.value = 0
logs.value = []
}
const stopSimulation = () => {
running.value = false
if (simulationInterval) {
clearInterval(simulationInterval)
simulationInterval = null
}
if (processInterval) {
clearInterval(processInterval)
processInterval = null
}
}
const addLog = (type, message) => {
const now = new Date()
const time = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
logs.value.unshift({ type, time, message })
if (logs.value.length > 50) {
logs.value = logs.value.slice(0, 50)
}
}
const clearLogs = () => {
logs.value = []
}
onUnmounted(() => {
stopSimulation()
})
</script>
<style scoped>
.seckill-demo {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
border-radius: 12px;
padding: 1.5rem;
margin: 1.5rem 0;
font-family: var(--vp-font-family-base);
}
.header {
margin-bottom: 1rem;
}
.title {
font-weight: 700;
font-size: 1.05rem;
}
.subtitle {
color: var(--vp-c-text-2);
font-size: 0.9rem;
margin-top: 0.25rem;
}
.scenario-settings {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.setting label {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
margin-bottom: 0.4rem;
}
.setting input[type='range'] {
width: 100%;
}
.action-bar {
display: flex;
gap: 0.75rem;
justify-content: center;
margin: 1rem 0;
}
.start-btn,
.reset-btn {
padding: 0.75rem 2rem;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
transition: all 0.2s;
}
.start-btn {
background: var(--vp-c-brand);
color: white;
}
.start-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.start-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.reset-btn {
background: var(--vp-c-text-2);
color: white;
}
.architecture {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
margin: 1.5rem 0;
}
.arch-layer {
width: 100%;
max-width: 600px;
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 10px;
padding: 1rem;
}
.layer-title {
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.75rem;
text-align: center;
}
.layer-content {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 0.75rem;
}
.stat-box {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 0.75rem;
text-align: center;
}
.stat-label {
font-size: 0.75rem;
color: var(--vp-c-text-2);
margin-bottom: 0.35rem;
}
.stat-value {
font-size: 1.1rem;
font-weight: 700;
}
.stat-value.success {
color: #22c55e;
}
.stat-value.error {
color: #ef4444;
}
.arch-arrow {
font-size: 1.5rem;
color: var(--vp-c-text-3);
}
.stock-display {
grid-column: 1 / -1;
padding: 0.75rem;
background: var(--vp-c-bg-soft);
border-radius: 8px;
}
.stock-bar {
height: 20px;
background: var(--vp-c-bg);
border-radius: 10px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.stock-fill {
height: 100%;
background: linear-gradient(90deg, #22c55e, #16a34a);
transition: width 0.3s ease;
}
.stock-text {
text-align: center;
font-size: 0.85rem;
font-weight: 600;
}
.redis-status {
grid-column: 1 / -1;
padding: 0.5rem;
border-radius: 6px;
text-align: center;
font-size: 0.85rem;
font-weight: 600;
margin-top: 0.5rem;
}
.redis-status.normal {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
.redis-status.low {
background: rgba(245, 158, 11, 0.1);
color: #d97706;
}
.redis-status.soldout {
background: rgba(239, 68, 68, 0.1);
color: #dc2626;
}
.queue-visual {
grid-column: 1 / -1;
}
.queue-box {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 0.75rem;
}
.queue-header {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.queue-bar-container {
height: 24px;
background: var(--vp-c-bg);
border-radius: 12px;
overflow: hidden;
}
.queue-bar {
height: 100%;
transition:
width 0.3s ease,
background 0.3s ease;
}
.queue-bar.normal {
background: linear-gradient(90deg, #22c55e, #16a34a);
}
.queue-bar.warning {
background: linear-gradient(90deg, #f59e0b, #d97706);
}
.queue-bar.critical {
background: linear-gradient(90deg, #ef4444, #dc2626);
}
.real-time-stats {
background: var(--vp-c-bg);
border-radius: 10px;
padding: 1rem;
margin: 1rem 0;
border: 1px solid var(--vp-c-divider);
}
.stats-title {
font-weight: 600;
margin-bottom: 0.75rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 0.75rem;
}
.stat-item {
text-align: center;
padding: 0.75rem;
background: var(--vp-c-bg-soft);
border-radius: 8px;
}
.stat-item .stat-label {
font-size: 0.75rem;
color: var(--vp-c-text-2);
margin-bottom: 0.35rem;
}
.stat-item .stat-value {
font-size: 1.1rem;
font-weight: 700;
color: var(--vp-c-text-1);
}
.log-section {
background: var(--vp-c-bg);
border-radius: 10px;
padding: 1rem;
margin: 1rem 0;
border: 1px solid var(--vp-c-divider);
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.log-title {
font-weight: 600;
font-size: 0.9rem;
}
.clear-log {
padding: 0.35rem 0.75rem;
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
}
.log-content {
max-height: 300px;
overflow-y: auto;
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 0.75rem;
font-size: 0.8rem;
}
.log-empty {
text-align: center;
color: var(--vp-c-text-3);
padding: 1rem;
}
.log-entry {
display: flex;
gap: 0.5rem;
padding: 0.35rem 0;
border-bottom: 1px solid var(--vp-c-divider);
}
.log-entry:last-child {
border-bottom: none;
}
.log-time {
color: var(--vp-c-text-3);
font-family: monospace;
}
.log-msg {
flex: 1;
}
.log-entry.info .log-msg {
color: var(--vp-c-text-1);
}
.log-entry.success .log-msg {
color: #16a34a;
}
.log-entry.warning .log-msg {
color: #d97706;
}
.log-entry.error .log-msg {
color: #dc2626;
}
.key-points {
background: rgba(59, 130, 246, 0.1);
border-radius: 10px;
padding: 1rem;
border: 1px solid rgba(59, 130, 246, 0.3);
}
.point-title {
font-weight: 600;
margin-bottom: 0.75rem;
}
.point-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.point-item {
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.point-icon {
font-size: 1.2rem;
flex-shrink: 0;
}
.point-item div {
font-size: 0.9rem;
line-height: 1.5;
}
</style>