d35211071a
- standardize border-radius from 8px to 6px for consistent styling - adjust padding values from 1rem to 0.75rem for better visual hierarchy - remove redundant overflow-y properties for cleaner code
571 lines
12 KiB
Vue
571 lines
12 KiB
Vue
<!--
|
||
RoutingRulesDemo.vue
|
||
路由规则 - 路径匹配/重写/转发
|
||
-->
|
||
<template>
|
||
<div class="routing-rules-demo">
|
||
<div class="header">
|
||
<div class="title">🧭 路由规则:如何把请求送到正确的服务?</div>
|
||
<div class="subtitle">想象成快递分拣中心——根据地址把包裹分配到不同的配送站</div>
|
||
</div>
|
||
|
||
<div class="playground">
|
||
<div class="playground-header">
|
||
<div class="playground-title">🎮 路由规则实验室</div>
|
||
<div class="playground-subtitle">输入一个 URL,看看它会被路由到哪个服务</div>
|
||
</div>
|
||
|
||
<div class="input-section">
|
||
<div class="input-group">
|
||
<label>HTTP 方法</label>
|
||
<select v-model="request.method">
|
||
<option value="GET">GET</option>
|
||
<option value="POST">POST</option>
|
||
<option value="PUT">PUT</option>
|
||
<option value="DELETE">DELETE</option>
|
||
</select>
|
||
</div>
|
||
<div class="input-group flex-2">
|
||
<label>URL 路径</label>
|
||
<input
|
||
type="text"
|
||
v-model="request.path"
|
||
placeholder="/api/users/123"
|
||
@keyup.enter="matchRoute"
|
||
/>
|
||
</div>
|
||
<div class="input-group">
|
||
<label>Header (可选)</label>
|
||
<input
|
||
type="text"
|
||
v-model="request.header"
|
||
placeholder="X-Version: v2"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<button class="match-btn" @click="matchRoute" :disabled="isMatching">
|
||
{{ isMatching ? '匹配中...' : '🔍 开始匹配' }}
|
||
</button>
|
||
|
||
<div class="result-section" v-if="matchResult">
|
||
<div :class="['result-card', matchResult.found ? 'success' : 'fail']">
|
||
<div class="result-header">
|
||
<div class="result-icon">{{ matchResult.found ? '✅' : '❌' }}</div>
|
||
<div class="result-title">{{ matchResult.found ? '匹配成功' : '未找到匹配规则' }}</div>
|
||
</div>
|
||
<div class="result-detail" v-if="matchResult.found">
|
||
<div class="detail-row">
|
||
<span class="label">目标服务:</span>
|
||
<span class="value service">{{ matchResult.service }}</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="label">匹配规则:</span>
|
||
<span class="value">{{ matchResult.rule }}</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="label">重写后路径:</span>
|
||
<span class="value path">{{ matchResult.rewrittenPath }}</span>
|
||
</div>
|
||
<div class="detail-row">
|
||
<span class="label">目标地址:</span>
|
||
<span class="value url">{{ matchResult.targetUrl }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="result-suggestion" v-else>
|
||
<p>💡 建议检查:</p>
|
||
<ul>
|
||
<li>路径是否以 /api 开头?</li>
|
||
<li>HTTP 方法是否匹配?(GET/POST)</li>
|
||
<li>Header 条件是否满足?</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="rules-table">
|
||
<div class="table-title">📋 当前路由规则表</div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>优先级</th>
|
||
<th>匹配规则</th>
|
||
<th>目标服务</th>
|
||
<th>路径重写</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(rule, index) in routingRules" :key="index"
|
||
:class="{ active: matchResult && matchResult.ruleIndex === index }">
|
||
<td>{{ index + 1 }}</td>
|
||
<td><code>{{ rule.match }}</code></td>
|
||
<td><span class="service-tag">{{ rule.service }}</span></td>
|
||
<td><code v-if="rule.rewrite">{{ rule.rewrite }}</code><span v-else class="no-rewrite">无</span></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="quick-presets">
|
||
<div class="presets-title">🚀 快速测试示例</div>
|
||
<div class="preset-buttons">
|
||
<button
|
||
v-for="preset in presets"
|
||
:key="preset.name"
|
||
class="preset-btn"
|
||
@click="applyPreset(preset)"
|
||
>
|
||
{{ preset.name }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive } from 'vue'
|
||
|
||
const request = reactive({
|
||
method: 'GET',
|
||
path: '/api/users/123',
|
||
header: ''
|
||
})
|
||
|
||
const isMatching = ref(false)
|
||
const matchResult = ref(null)
|
||
|
||
const routingRules = [
|
||
{
|
||
match: 'Header: X-Version=v2',
|
||
service: '用户服务V2',
|
||
rewrite: null
|
||
},
|
||
{
|
||
match: 'Path: /api/users/*',
|
||
service: '用户服务',
|
||
rewrite: '/users/*'
|
||
},
|
||
{
|
||
match: 'Path: /api/orders/*',
|
||
service: '订单服务',
|
||
rewrite: '/orders/*'
|
||
},
|
||
{
|
||
match: 'Path: /api/pay/*',
|
||
service: '支付服务',
|
||
rewrite: '/payments/*'
|
||
},
|
||
{
|
||
match: 'Method: GET, Path: /health',
|
||
service: '健康检查',
|
||
rewrite: null
|
||
}
|
||
]
|
||
|
||
const presets = [
|
||
{ name: '👤 查询用户', method: 'GET', path: '/api/users/123', header: '' },
|
||
{ name: '📦 创建订单', method: 'POST', path: '/api/orders', header: '' },
|
||
{ name: '💳 发起支付', method: 'POST', path: '/api/pay/checkout', header: '' },
|
||
{ name: '🔍 健康检查', method: 'GET', path: '/health', header: '' },
|
||
{ name: '🆕 V2版本', method: 'GET', path: '/api/users/456', header: 'X-Version: v2' }
|
||
]
|
||
|
||
const matchRoute = async () => {
|
||
isMatching.value = true
|
||
matchResult.value = null
|
||
|
||
await new Promise(resolve => setTimeout(resolve, 500))
|
||
|
||
const path = request.path
|
||
const method = request.method
|
||
const header = request.header
|
||
|
||
let found = false
|
||
let matchedIndex = -1
|
||
let service = ''
|
||
let rule = ''
|
||
let rewrittenPath = path
|
||
let targetUrl = ''
|
||
|
||
if (header.includes('X-Version=v2')) {
|
||
found = true
|
||
matchedIndex = 0
|
||
service = '用户服务V2 (新版本)'
|
||
rule = 'Header: X-Version=v2'
|
||
targetUrl = 'http://user-service-v2:8080' + path
|
||
} else if (path.startsWith('/api/users/')) {
|
||
found = true
|
||
matchedIndex = 1
|
||
service = '用户服务'
|
||
rule = 'Path: /api/users/*'
|
||
rewrittenPath = path.replace('/api/users/', '/users/')
|
||
targetUrl = 'http://user-service:8080' + rewrittenPath
|
||
} else if (path.startsWith('/api/orders')) {
|
||
found = true
|
||
matchedIndex = 2
|
||
service = '订单服务'
|
||
rule = 'Path: /api/orders/*'
|
||
rewrittenPath = path.replace('/api/orders/', '/orders/')
|
||
targetUrl = 'http://order-service:8080' + rewrittenPath
|
||
} else if (path.startsWith('/api/pay/')) {
|
||
found = true
|
||
matchedIndex = 3
|
||
service = '支付服务'
|
||
rule = 'Path: /api/pay/*'
|
||
rewrittenPath = path.replace('/api/pay/', '/payments/')
|
||
targetUrl = 'http://payment-service:8080' + rewrittenPath
|
||
} else if (method === 'GET' && path === '/health') {
|
||
found = true
|
||
matchedIndex = 4
|
||
service = '健康检查'
|
||
rule = 'Method: GET, Path: /health'
|
||
targetUrl = 'http://health-check-service:8080/health'
|
||
}
|
||
|
||
matchResult.value = {
|
||
found,
|
||
service,
|
||
rule,
|
||
ruleIndex: matchedIndex,
|
||
originalPath: path,
|
||
rewrittenPath,
|
||
targetUrl
|
||
}
|
||
|
||
isMatching.value = false
|
||
}
|
||
|
||
const applyPreset = (preset) => {
|
||
request.method = preset.method
|
||
request.path = preset.path
|
||
request.header = preset.header
|
||
matchResult.value = null
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.routing-rules-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;
|
||
}
|
||
|
||
.playground {
|
||
background: var(--vp-c-bg);
|
||
border-radius: 12px;
|
||
padding: 1.5rem;
|
||
margin-bottom: 1.5rem;
|
||
border: 1px solid var(--vp-c-divider);
|
||
}
|
||
|
||
.playground-header {
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
|
||
.playground-title {
|
||
font-weight: 700;
|
||
font-size: 1.1rem;
|
||
margin-bottom: 0.25rem;
|
||
color: var(--vp-c-text-1);
|
||
}
|
||
|
||
.playground-subtitle {
|
||
font-size: 0.85rem;
|
||
color: var(--vp-c-text-2);
|
||
}
|
||
|
||
.input-section {
|
||
display: grid;
|
||
grid-template-columns: auto 2fr 1fr;
|
||
gap: 1rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.input-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.input-group.flex-2 {
|
||
flex: 2;
|
||
}
|
||
|
||
.input-group label {
|
||
font-size: 0.8rem;
|
||
font-weight: 600;
|
||
color: var(--vp-c-text-2);
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.input-group input,
|
||
.input-group select {
|
||
padding: 0.75rem;
|
||
border: 1px solid var(--vp-c-divider);
|
||
border-radius: 6px;
|
||
font-size: 0.95rem;
|
||
background: var(--vp-c-bg);
|
||
color: var(--vp-c-text-1);
|
||
}
|
||
|
||
.input-group input:focus,
|
||
.input-group select:focus {
|
||
outline: none;
|
||
border-color: var(--vp-c-brand);
|
||
}
|
||
|
||
.match-btn {
|
||
width: 100%;
|
||
padding: 0.75rem;
|
||
background: var(--vp-c-brand);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-weight: 600;
|
||
font-size: 1rem;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.match-btn:hover:not(:disabled) {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(var(--vp-c-brand-rgb), 0.3);
|
||
}
|
||
|
||
.match-btn:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.result-section {
|
||
margin-top: 1.5rem;
|
||
}
|
||
|
||
.result-card {
|
||
border-radius: 10px;
|
||
padding: 1.25rem;
|
||
border: 2px solid;
|
||
}
|
||
|
||
.result-card.success {
|
||
background: rgba(34, 197, 94, 0.1);
|
||
border-color: #22c55e;
|
||
}
|
||
|
||
.result-card.fail {
|
||
background: rgba(239, 68, 68, 0.1);
|
||
border-color: #ef4444;
|
||
}
|
||
|
||
.result-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.result-icon {
|
||
font-size: 1.5rem;
|
||
}
|
||
|
||
.result-title {
|
||
font-weight: 700;
|
||
font-size: 1.1rem;
|
||
}
|
||
|
||
.result-detail {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.detail-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.detail-row .label {
|
||
font-weight: 600;
|
||
color: var(--vp-c-text-2);
|
||
min-width: 100px;
|
||
}
|
||
|
||
.detail-row .value {
|
||
font-family: monospace;
|
||
background: var(--vp-c-bg-soft);
|
||
padding: 0.25rem 0.5rem;
|
||
border-radius: 4px;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.detail-row .value.service {
|
||
background: rgba(34, 197, 94, 0.2);
|
||
color: #15803d;
|
||
}
|
||
|
||
.detail-row .value.path {
|
||
background: rgba(59, 130, 246, 0.2);
|
||
color: #1d4ed8;
|
||
}
|
||
|
||
.detail-row .value.url {
|
||
background: rgba(168, 85, 247, 0.2);
|
||
color: #7c3aed;
|
||
}
|
||
|
||
.result-suggestion {
|
||
color: var(--vp-c-text-2);
|
||
}
|
||
|
||
.result-suggestion ul {
|
||
margin: 0.5rem 0 0 0;
|
||
padding-left: 1.5rem;
|
||
}
|
||
|
||
.result-suggestion li {
|
||
margin: 0.25rem 0;
|
||
}
|
||
|
||
.rules-table {
|
||
background: var(--vp-c-bg);
|
||
border-radius: 12px;
|
||
padding: 1.5rem;
|
||
margin-bottom: 1.5rem;
|
||
border: 1px solid var(--vp-c-divider);
|
||
}
|
||
|
||
.table-title {
|
||
font-weight: 700;
|
||
font-size: 1.1rem;
|
||
margin-bottom: 1rem;
|
||
text-align: center;
|
||
color: var(--vp-c-text-1);
|
||
}
|
||
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
th, td {
|
||
padding: 0.75rem;
|
||
text-align: left;
|
||
border-bottom: 1px solid var(--vp-c-divider);
|
||
}
|
||
|
||
th {
|
||
font-weight: 600;
|
||
background: var(--vp-c-bg-soft);
|
||
color: var(--vp-c-text-1);
|
||
}
|
||
|
||
tr:hover {
|
||
background: var(--vp-c-bg-soft);
|
||
}
|
||
|
||
tr.active {
|
||
background: rgba(34, 197, 94, 0.1);
|
||
}
|
||
|
||
.service-tag {
|
||
display: inline-block;
|
||
padding: 0.25rem 0.5rem;
|
||
background: var(--vp-c-brand);
|
||
color: white;
|
||
border-radius: 4px;
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
code {
|
||
font-family: monospace;
|
||
background: var(--vp-c-bg-soft);
|
||
padding: 0.2rem 0.4rem;
|
||
border-radius: 4px;
|
||
font-size: 0.85em;
|
||
}
|
||
|
||
.quick-presets {
|
||
background: var(--vp-c-bg);
|
||
border-radius: 12px;
|
||
padding: 1.5rem;
|
||
border: 1px solid var(--vp-c-divider);
|
||
}
|
||
|
||
.presets-title {
|
||
font-weight: 700;
|
||
font-size: 1.1rem;
|
||
margin-bottom: 1rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.preset-buttons {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.75rem;
|
||
justify-content: center;
|
||
}
|
||
|
||
.preset-btn {
|
||
padding: 0.6rem 1rem;
|
||
background: var(--vp-c-bg-soft);
|
||
border: 1px solid var(--vp-c-divider);
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.preset-btn:hover {
|
||
border-color: var(--vp-c-brand);
|
||
background: rgba(var(--vp-c-brand-rgb), 0.1);
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.input-section {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.detail-row {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.detail-row .label {
|
||
min-width: auto;
|
||
}
|
||
|
||
table {
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
th, td {
|
||
padding: 0.5rem;
|
||
}
|
||
}
|
||
</style>
|