Files
test-repo/docs/.vitepress/theme/components/appendix/gateway-proxy/RateLimitingDemo.vue
T

1114 lines
25 KiB
Vue
Raw Normal View History

<!--
RateLimitingDemo.vue
限流算法 - 令牌桶/漏桶/滑动窗口
-->
<template>
<div class="rate-limiting-demo">
<div class="header">
<div class="title"> 限流算法系统不会被"流量洪水"冲垮的秘诀</div>
<div class="subtitle">想象成水坝的闸门控制水流速度防止下游被淹没</div>
</div>
<div class="algorithm-selector">
<div class="selector-title">选择限流算法</div>
<div class="algorithm-tabs">
<button
v-for="algo in algorithms"
:key="algo.id"
:class="['algo-tab', { active: currentAlgo === algo.id }]"
@click="currentAlgo = algo.id"
>
<span class="algo-icon">{{ algo.icon }}</span>
<span class="algo-name">{{ algo.name }}</span>
</button>
</div>
</div>
<div class="visualization-area">
<div class="vis-header">
<div class="vis-title">{{ currentAlgoData.visualTitle }}</div>
<div class="vis-controls">
<button class="control-btn" @click="toggleSimulation" :disabled="isSimulating">
{{ isSimulating ? '模拟中...' : '▶ 开始模拟' }}
</button>
<button class="control-btn reset" @click="resetSimulation"> 重置</button>
</div>
</div>
<!-- 令牌桶可视化 -->
<div v-if="currentAlgo === 'token'" class="token-bucket-vis">
<div class="bucket-container">
<div class="bucket">
<div class="bucket-label">令牌桶</div>
<div class="tokens-area">
<div
v-for="n in bucketState.tokens"
:key="n"
class="token"
:style="{ animationDelay: `${n * 0.1}s` }"
>🪙</div>
</div>
<div class="bucket-capacity">{{ bucketState.tokens }} / {{ bucketState.capacity }} 令牌</div>
</div>
<div class="token-producer">
<div class="producer-label"> 令牌产生器 ({{ bucketState.rate }}/)</div>
<div class="producer-stream">
<div
v-for="n in 3"
:key="n"
class="producing-token"
:style="{ animationDelay: `${n * 0.3}s` }"
>🪙</div>
</div>
</div>
</div>
<div class="requests-queue">
<div class="queue-title">📥 请求队列</div>
<div class="requests">
<div
v-for="(req, index) in requestQueue"
:key="index"
class="request-item"
:class="{ processing: req.status === 'processing', allowed: req.status === 'allowed', rejected: req.status === 'rejected' }"
>
<span class="req-method">{{ req.method }}</span>
<span class="req-path">{{ req.path }}</span>
<span class="req-status">{{ getStatusEmoji(req.status) }}</span>
</div>
</div>
</div>
</div>
<!-- 漏桶可视化 -->
<div v-if="currentAlgo === 'leaky'" class="leaky-bucket-vis">
<div class="leaky-container">
<div class="leaky-bucket">
<div class="bucket-label">漏桶</div>
<div class="bucket-content">
<div
class="water-level"
:style="{ height: `${(leakyState.current / leakyState.capacity) * 100}%` }"
></div>
</div>
<div class="bucket-stats">{{ leakyState.current }} / {{ leakyState.capacity }} 请求</div>
</div>
<div class="leak-hole">
<div class="hole">🔘</div>
<div class="leak-rate"> 流出速率: {{ leakyState.rate }}/</div>
</div>
</div>
<div class="leaky-legend">
<div class="legend-item">
<span class="legend-color water"></span>
<span>桶内请求排队中</span>
</div>
<div class="legend-item">
<span class="legend-color hole"></span>
<span>匀速流出处理中</span>
</div>
<div class="legend-item">
<span class="legend-color overflow"></span>
<span>桶满溢出被拒绝</span>
</div>
</div>
</div>
<!-- 滑动窗口可视化 -->
<div v-if="currentAlgo === 'sliding'" class="sliding-window-vis">
<div class="window-container">
<div class="window-label"> 时间窗口过去1分钟</div>
<div class="window-timeline">
<div class="time-marks">
<span v-for="n in 6" :key="n">{{ 60 - (n - 1) * 10 }}s</span>
</div>
<div class="window-bars">
<div
v-for="(slot, index) in slidingWindow.slots"
:key="index"
class="time-slot"
:class="{ active: slot.count > 0, current: index === slidingWindow.currentSlot }"
:style="{ height: `${Math.min((slot.count / 20) * 100, 100)}%` }"
>
<span class="slot-count" v-if="slot.count > 0">{{ slot.count }}</span>
</div>
</div>
</div>
<div class="window-stats">
<div class="stat">
<span class="stat-label">当前窗口请求数:</span>
<span class="stat-value">{{ slidingWindow.totalRequests }}</span>
</div>
<div class="stat">
<span class="stat-label">限流阈值:</span>
<span class="stat-value">{{ slidingWindow.limit }}/分钟</span>
</div>
<div class="stat">
<span class="stat-label">剩余额度:</span>
<span class="stat-value" :class="{ warning: slidingWindow.remaining < 20 }">{{ slidingWindow.remaining }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="comparison-section">
<div class="section-title">📊 三种算法对比</div>
<table class="comparison-table">
<thead>
<tr>
<th>维度</th>
<th>令牌桶 (Token Bucket)</th>
<th>漏桶 (Leaky Bucket)</th>
<th>滑动窗口 (Sliding Window)</th>
</tr>
</thead>
<tbody>
<tr>
<td class="dim">核心思想</td>
<td>桶里装令牌有令牌才能通过</td>
<td>请求进桶匀速流出处理</td>
<td>统计时间窗口内的请求数</td>
</tr>
<tr>
<td class="dim">突发流量</td>
<td> 允许一定程度的突发桶里有令牌</td>
<td> 强制平滑突发会被缓存或拒绝</td>
<td> 严格按窗口计数超出一律拒绝</td>
</tr>
<tr>
<td class="dim">适用场景</td>
<td>API 限流带宽控制允许突发</td>
<td>需要严格匀速处理的场景如消息队列</td>
<td>精确统计"1分钟内最多100次"</td>
</tr>
<tr>
<td class="dim">实现复杂度</td>
<td>中等</td>
<td>中等</td>
<td>较高需要记录每个时间窗口的请求</td>
</tr>
<tr>
<td class="dim">Nginx 配置</td>
<td>limit_req_zone (漏桶)</td>
<td>limit_req_zone (漏桶)</td>
<td>需第三方模块或 Lua</td>
</tr>
</tbody>
</table>
</div>
<div class="nginx-config">
<div class="config-title">📝 Nginx 限流配置示例</div>
<div class="config-tabs">
<button
v-for="config in nginxConfigs"
:key="config.id"
:class="['config-tab', { active: currentConfig === config.id }]"
@click="currentConfig = config.id"
>
{{ config.name }}
</button>
</div>
<pre class="config-code"><code>{{ currentNginxConfig.code }}</code></pre>
<div class="config-explanation">
<div class="exp-title">💡 配置说明</div>
<ul>
<li v-for="(item, index) in currentNginxConfig.explanation" :key="index">{{ item }}</li>
</ul>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, reactive, onMounted, onUnmounted } from 'vue'
const currentAlgo = ref('token')
const isMatching = ref(false)
const isSimulating = ref(false)
const algorithms = [
{
id: 'token',
icon: '🪙',
name: '令牌桶',
visualTitle: '🪙 令牌桶算法可视化'
},
{
id: 'leaky',
icon: '🚿',
name: '漏桶',
visualTitle: '🚿 漏桶算法可视化'
},
{
id: 'sliding',
icon: '📊',
name: '滑动窗口',
visualTitle: '📊 滑动窗口算法可视化'
}
]
const currentAlgoData = computed(() => algorithms.find(a => a.id === currentAlgo.value))
// 令牌桶状态
const bucketState = reactive({
tokens: 5,
capacity: 10,
rate: 2,
totalRequests: 0
})
// 请求队列
const requestQueue = ref([])
// 漏桶状态
const leakyState = reactive({
current: 3,
capacity: 8,
rate: 1
})
// 滑动窗口状态
const slidingWindow = reactive({
slots: Array(12).fill(0).map(() => ({ count: Math.floor(Math.random() * 10) })),
currentSlot: 11,
totalRequests: 45,
limit: 100,
remaining: 55
})
const currentConfig = ref('basic')
const nginxConfigs = [
{
id: 'basic',
name: '基础限流',
code: `# 定义限流区域
# $binary_remote_addr: 按 IP 限流
# zone=mylimit:10m: 区域名称和大小
# rate=10r/s: 每秒最多10个请求
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s;
server {
listen 80;
server_name api.example.com;
location / {
# 应用限流
# burst=20: 桶容量,允许突发20个请求
# nodelay: 不延迟处理突发请求
limit_req zone=mylimit burst=20 nodelay;
proxy_pass http://backend;
}
}`,
explanation: [
'limit_req_zone: 在 http 块中定义限流区域',
'$binary_remote_addr: 使用二进制 IP 地址作为限流键(省内存)',
'zone=mylimit:10m: 区域名称 mylimit,分配 10MB 内存',
'rate=10r/s: 每秒允许 10 个请求(漏桶算法)',
'burst=20: 桶的容量为 20,允许一定程度的突发流量',
'nodelay: 不延迟处理突发请求(立即处理或拒绝)'
]
},
{
id: 'connection',
name: '连接数限制',
code: `# 限制并发连接数
# zone=addr:10m: 区域名称为 addr,大小 10MB
limit_conn_zone $binary_remote_addr zone=addr:10m;
server {
listen 80;
server_name download.example.com;
location / {
# 每个 IP 最多 5 个并发连接
limit_conn addr 5;
# 同时应用限流:每秒 1 个请求
limit_req zone=mylimit rate=1r/s;
proxy_pass http://fileserver;
}
}`,
explanation: [
'limit_conn_zone: 定义连接数限制区域',
'limit_conn addr 5: 每个 IP 最多同时保持 5 个连接',
'适用于文件下载、视频流媒体等长连接场景',
'可以和 limit_req 同时使用(双重保护)',
'超过连接数限制时返回 503 Service Unavailable'
]
},
{
id: 'whiteblack',
name: '黑白名单',
code: `# 白名单 + 限流组合
# 公司内网 IP 不限流
geo $limit {
default 1;
10.0.0.0/8 0; # 内网网段
172.16.0.0/12 0; # 内网网段
192.168.0.0/16 0; # 内网网段
}
map $limit $limit_key {
0 "";
1 $binary_remote_addr;
}
# 只有外网 IP 会触发限流
limit_req_zone $limit_key zone=sensitive:10m rate=1r/s;
server {
listen 80;
server_name api.example.com;
location /admin {
# 管理后台严格限流
limit_req zone=sensitive burst=5 nodelay;
# 拒绝特定 IP
deny 1.2.3.4;
deny 5.6.7.8;
proxy_pass http://backend;
}
}`,
explanation: [
'geo 模块:根据 IP 地址设置变量值',
'内网 IP 设置为 0,外网 IP 默认为 1',
'map 模块:将 0 映射为空字符串(不限流),1 映射为 IP 地址',
'只有外网 IP 会被限流,内网访问畅通无阻',
'deny 指令:直接拒绝特定 IP 访问',
'适用于管理后台、敏感接口的安全防护'
]
}
]
const currentNginxConfig = computed(() => nginxConfigs.find(c => c.id === currentConfig.value))
const toggleSimulation = async () => {
isSimulating.value = true
// 模拟产生请求
for (let i = 0; i < 5; i++) {
await new Promise(resolve => setTimeout(resolve, 800))
const methods = ['GET', 'POST', 'GET', 'GET', 'DELETE']
const paths = ['/api/users', '/api/orders', '/api/products', '/health', '/api/pay']
const newRequest = {
id: Date.now() + i,
method: methods[Math.floor(Math.random() * methods.length)],
path: paths[Math.floor(Math.random() * paths.length)],
status: 'processing'
}
requestQueue.value.unshift(newRequest)
// 模拟处理
setTimeout(() => {
const req = requestQueue.value.find(r => r.id === newRequest.id)
if (req) {
if (currentAlgo.value === 'token') {
// 令牌桶逻辑
if (bucketState.tokens > 0) {
bucketState.tokens--
req.status = 'allowed'
bucketState.totalRequests++
} else {
req.status = 'rejected'
}
} else {
req.status = Math.random() > 0.3 ? 'allowed' : 'rejected'
}
}
}, 500)
}
isSimulating.value = false
}
const resetSimulation = () => {
requestQueue.value = []
bucketState.tokens = 5
bucketState.totalRequests = 0
leakyState.current = 3
isSimulating.value = false
}
const getStatusEmoji = (status) => {
switch (status) {
case 'processing': return '⏳'
case 'allowed': return '✅'
case 'rejected': return '❌'
default: return '⏳'
}
}
let tokenInterval = null
let leakyInterval = null
onMounted(() => {
tokenInterval = setInterval(() => {
if (bucketState.tokens < bucketState.capacity) {
bucketState.tokens = Math.min(
bucketState.tokens + bucketState.rate,
bucketState.capacity
)
}
}, 1000)
leakyInterval = setInterval(() => {
if (leakyState.current > 0) {
leakyState.current = Math.max(0, leakyState.current - leakyState.rate)
}
}, 1000)
})
onUnmounted(() => {
if (tokenInterval) {
clearInterval(tokenInterval)
tokenInterval = null
}
if (leakyInterval) {
clearInterval(leakyInterval)
leakyInterval = null
}
})
</script>
<style scoped>
.rate-limiting-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;
text-align: center;
}
.title {
font-weight: 700;
font-size: 1.2rem;
margin-bottom: 0.5rem;
color: var(--vp-c-text-1);
}
.subtitle {
color: var(--vp-c-text-2);
font-size: 0.9rem;
}
.algorithm-selector {
background: var(--vp-c-bg);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
border: 1px solid var(--vp-c-divider);
}
.selector-title {
font-weight: 700;
font-size: 1.1rem;
margin-bottom: 1rem;
text-align: center;
color: var(--vp-c-text-1);
}
.algorithm-tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.algo-tab {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.25rem;
background: var(--vp-c-bg-soft);
border: 2px solid var(--vp-c-divider);
border-radius: 12px;
cursor: pointer;
transition: all 0.3s;
}
.algo-tab:hover {
border-color: var(--vp-c-brand);
transform: translateY(-2px);
}
.algo-tab.active {
border-color: var(--vp-c-brand);
background: rgba(var(--vp-c-brand-rgb), 0.1);
box-shadow: 0 4px 12px rgba(var(--vp-c-brand-rgb), 0.2);
}
.algo-icon {
font-size: 2rem;
}
.algo-name {
font-weight: 600;
font-size: 0.95rem;
color: var(--vp-c-text-1);
}
.visualization-area {
background: var(--vp-c-bg);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
border: 1px solid var(--vp-c-divider);
}
.vis-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
.vis-title {
font-weight: 700;
font-size: 1.1rem;
color: var(--vp-c-text-1);
}
.vis-controls {
display: flex;
gap: 0.5rem;
}
.control-btn {
padding: 0.5rem 1rem;
background: var(--vp-c-brand);
color: white;
border: none;
border-radius: 6px;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
}
.control-btn:hover:not(:disabled) {
background: var(--vp-c-brand-dark);
}
.control-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.control-btn.reset {
background: var(--vp-c-bg-soft);
color: var(--vp-c-text-1);
border: 1px solid var(--vp-c-divider);
}
/* 令牌桶可视化 */
.token-bucket-vis {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1.5rem;
}
.bucket-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.bucket {
background: linear-gradient(180deg, #fef3c7, #fde68a);
border: 3px solid #f59e0b;
border-radius: 12px;
padding: 0.75rem;
min-height: 200px;
display: flex;
flex-direction: column;
}
.bucket-label {
font-weight: 700;
text-align: center;
margin-bottom: 0.5rem;
color: #92400e;
}
.tokens-area {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-content: flex-start;
padding: 0.5rem;
background: rgba(255, 255, 255, 0.5);
border-radius: 6px;
}
.token {
font-size: 1.5rem;
animation: float 2s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
.bucket-capacity {
text-align: center;
font-weight: 600;
margin-top: 0.5rem;
color: #92400e;
}
.token-producer {
background: var(--vp-c-bg-soft);
border: 2px solid var(--vp-c-divider);
border-radius: 10px;
padding: 0.75rem;
text-align: center;
}
.producer-label {
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--vp-c-text-1);
}
.producer-stream {
display: flex;
justify-content: center;
gap: 0.5rem;
height: 30px;
}
.producing-token {
font-size: 1.25rem;
animation: drop 1.5s ease-in infinite;
}
@keyframes drop {
0% { transform: translateY(-20px); opacity: 0; }
50% { opacity: 1; }
100% { transform: translateY(10px); opacity: 0; }
}
.requests-queue {
background: var(--vp-c-bg-soft);
border: 2px solid var(--vp-c-divider);
border-radius: 10px;
padding: 0.75rem;
}
.queue-title {
font-weight: 700;
margin-bottom: 0.75rem;
text-align: center;
color: var(--vp-c-text-1);
}
.requests {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 250px;
}
.request-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: white;
border-radius: 6px;
font-size: 0.85rem;
border-left: 3px solid var(--vp-c-divider);
}
.request-item.processing {
border-left-color: #f59e0b;
background: #fffbeb;
}
.request-item.allowed {
border-left-color: #22c55e;
background: #f0fdf4;
}
.request-item.rejected {
border-left-color: #ef4444;
background: #fef2f2;
}
.req-method {
font-weight: 700;
color: var(--vp-c-brand);
min-width: 50px;
}
.req-path {
flex: 1;
font-family: monospace;
color: var(--vp-c-text-1);
}
.req-status {
font-size: 1.1rem;
}
/* 漏桶可视化 */
.leaky-bucket-vis {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.leaky-container {
display: flex;
justify-content: center;
gap: 2rem;
align-items: flex-end;
}
.leaky-bucket {
width: 200px;
background: linear-gradient(180deg, #dbeafe, #bfdbfe);
border: 3px solid #3b82f6;
border-radius: 12px;
padding: 0.75rem;
position: relative;
}
.bucket-content {
height: 150px;
background: rgba(255, 255, 255, 0.7);
border-radius: 6px;
position: relative;
overflow: hidden;
}
.water-level {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(180deg, #60a5fa, #3b82f6);
transition: height 0.5s ease;
}
.leak-hole {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.hole {
font-size: 3rem;
animation: drip 1s ease-in-out infinite;
}
@keyframes drip {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
.leak-rate {
font-weight: 600;
color: var(--vp-c-text-2);
}
.leaky-legend {
display: flex;
justify-content: center;
gap: 2rem;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.legend-color {
width: 20px;
height: 20px;
border-radius: 4px;
}
.legend-color.water {
background: #3b82f6;
}
.legend-color.hole {
background: #6b7280;
}
.legend-color.overflow {
background: #ef4444;
}
/* 滑动窗口可视化 */
.sliding-window-vis {
padding: 0.75rem;
}
.window-container {
background: var(--vp-c-bg-soft);
border-radius: 12px;
padding: 1.5rem;
border: 2px solid var(--vp-c-divider);
}
.window-label {
font-weight: 700;
font-size: 1.1rem;
margin-bottom: 1rem;
text-align: center;
color: var(--vp-c-text-1);
}
.window-timeline {
margin-bottom: 1.5rem;
}
.time-marks {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
padding: 0 0.5rem;
font-size: 0.75rem;
color: var(--vp-c-text-2);
}
.window-bars {
display: flex;
gap: 0.25rem;
height: 120px;
align-items: flex-end;
padding: 0.5rem;
background: rgba(0, 0, 0, 0.05);
border-radius: 6px;
}
.time-slot {
flex: 1;
background: #e5e7eb;
border-radius: 4px 4px 0 0;
min-height: 5px;
position: relative;
transition: all 0.3s;
}
.time-slot.active {
background: #3b82f6;
}
.time-slot.current {
box-shadow: 0 0 0 2px #f59e0b;
}
.slot-count {
position: absolute;
top: -20px;
left: 50%;
transform: translateX(-50%);
font-size: 0.7rem;
font-weight: 700;
color: var(--vp-c-text-1);
}
.window-stats {
display: flex;
justify-content: space-around;
padding: 0.75rem;
background: var(--vp-c-bg);
border-radius: 6px;
border: 1px solid var(--vp-c-divider);
}
.stat {
text-align: center;
}
.stat-label {
font-size: 0.8rem;
color: var(--vp-c-text-2);
margin-bottom: 0.25rem;
}
.stat-value {
font-weight: 700;
font-size: 1.5rem;
color: var(--vp-c-brand);
}
.stat-value.warning {
color: #ef4444;
}
.comparison-section {
background: var(--vp-c-bg);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
border: 1px solid var(--vp-c-divider);
}
.section-title {
font-weight: 700;
font-size: 1.1rem;
margin-bottom: 1rem;
text-align: center;
color: var(--vp-c-text-1);
}
.comparison-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
overflow-x: auto;
display: block;
}
.comparison-table thead,
.comparison-table tbody {
display: table;
width: 100%;
}
.comparison-table th,
.comparison-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--vp-c-divider);
vertical-align: top;
}
.comparison-table th {
font-weight: 600;
background: var(--vp-c-bg-soft);
color: var(--vp-c-text-1);
white-space: nowrap;
}
.comparison-table td.dim {
font-weight: 600;
background: var(--vp-c-bg-soft);
white-space: nowrap;
}
.nginx-config {
background: var(--vp-c-bg);
border-radius: 12px;
padding: 1.5rem;
border: 1px solid var(--vp-c-divider);
}
.config-title {
font-weight: 700;
font-size: 1.1rem;
margin-bottom: 1rem;
text-align: center;
color: var(--vp-c-text-1);
}
.config-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.config-tab {
padding: 0.5rem 1rem;
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
font-weight: 600;
font-size: 0.85rem;
}
.config-tab:hover {
border-color: var(--vp-c-brand);
}
.config-tab.active {
background: var(--vp-c-brand);
color: white;
border-color: var(--vp-c-brand);
}
.config-code {
background: var(--vp-c-bg-alt);
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
padding: 0.75rem;
overflow-x: auto;
font-size: 0.85rem;
line-height: 1.6;
margin-bottom: 1rem;
}
.config-explanation {
background: var(--vp-c-bg-soft);
border-radius: 6px;
padding: 0.75rem;
}
.exp-title {
font-weight: 700;
margin-bottom: 0.75rem;
color: var(--vp-c-text-1);
}
.config-explanation ul {
margin: 0;
padding-left: 1.25rem;
color: var(--vp-c-text-2);
font-size: 0.9rem;
line-height: 1.8;
}
@media (max-width: 768px) {
.algorithm-tabs {
grid-template-columns: 1fr;
}
.input-section {
grid-template-columns: 1fr;
}
.comparison-table {
font-size: 0.75rem;
}
.comparison-table th,
.comparison-table td {
padding: 0.5rem;
}
.window-bars {
height: 80px;
}
}
</style>