feat: update docs and components, fix DLQ demo bug
This commit is contained in:
@@ -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>
|
||||
+563
@@ -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>
|
||||
+561
@@ -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 只需 10ms,B 在后台慢慢处理</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>
|
||||
Reference in New Issue
Block a user