Files
test-repo/docs/.vitepress/theme/components/appendix/frontend-routing/RouteMatchingDemo.vue
T

608 lines
13 KiB
Vue
Raw Normal View History

<template>
<div class="route-matching-demo">
<div class="demo-header">
<h4>路由匹配机制</h4>
<p class="demo-desc">输入URL路径查看路由是如何匹配和解析参数的</p>
</div>
<div class="demo-container">
<div class="input-section">
<div class="input-group">
<label>测试路径</label>
<div class="path-input-wrapper">
<span class="path-prefix">/</span>
<input
v-model="testPath"
type="text"
placeholder="user/123/posts"
class="path-input"
@keyup.enter="testMatch"
>
</div>
</div>
<button class="test-btn" @click="testMatch">
<span class="btn-icon"></span>
测试匹配
</button>
</div>
<div class="routes-section">
<div class="section-header">
<h5>已定义的路由</h5>
<span class="route-count">{{ routes.length }} </span>
</div>
<div class="routes-list">
<div
v-for="route in routes"
:key="route.path"
:class="['route-item', { matched: matchedRoute?.path === route.path }]"
>
<div class="route-path">
<span class="route-pattern">{{ route.path }}</span>
<span v-if="route.hasParams" class="param-badge">含参数</span>
</div>
<div class="route-name">{{ route.name }}</div>
</div>
</div>
</div>
</div>
<div v-if="matchResult" class="result-section">
<div class="result-header">
<h5>匹配结果</h5>
<span :class="['match-status', matchResult.matched ? 'success' : 'fail']">
{{ matchResult.matched ? '匹配成功' : '无匹配路由' }}
</span>
</div>
<div v-if="matchResult.matched" class="match-details">
<div class="detail-item">
<span class="detail-label">匹配路由</span>
<span class="detail-value code">{{ matchResult.route.path }}</span>
</div>
<div class="detail-item">
<span class="detail-label">路由名称</span>
<span class="detail-value">{{ matchResult.route.name }}</span>
</div>
<div v-if="Object.keys(matchResult.params).length > 0" class="params-section">
<div class="detail-label">路径参数</div>
<div class="params-list">
<div
v-for="(value, key) in matchResult.params"
:key="key"
class="param-item"
>
<span class="param-key">{{ key }}</span>
<span class="param-arrow"></span>
<span class="param-value">{{ value }}</span>
</div>
</div>
</div>
</div>
<div v-else class="no-match">
<div class="no-match-icon"></div>
<p>路径 "{{ testPath }}" 未匹配到任何路由</p>
<ul class="suggestions">
<li>检查路径拼写是否正确</li>
<li>确认路径是否以斜杠开头</li>
<li>查看是否缺少必要的参数</li>
</ul>
</div>
</div>
<div class="tips-section">
<h5>路由匹配规则速查</h5>
<div class="tips-grid">
<div class="tip-item">
<code>/user</code>
<span>精确匹配 /user</span>
</div>
<div class="tip-item">
<code>/user/:id</code>
<span>匹配 /user/123id=123</span>
</div>
<div class="tip-item">
<code>/user/:id?</code>
<span>id可选匹配 /user /user/123</span>
</div>
<div class="tip-item">
<code>/user/:id+</code>
<span>匹配一个或多个 /user/1/2</span>
</div>
<div class="tip-item">
<code>/user/:id*</code>
<span>匹配零个或多个</span>
</div>
<div class="tip-item">
<code>/user(.*)*</code>
<span>通配符匹配任意路径</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const testPath = ref('user/123/posts')
const matchResult = ref(null)
const matchedRoute = ref(null)
const routes = [
{ path: '/', name: '首页', hasParams: false },
{ path: '/user', name: '用户列表', hasParams: false },
{ path: '/user/:id', name: '用户详情', hasParams: true },
{ path: '/user/:id/posts', name: '用户文章', hasParams: true },
{ path: '/products', name: '产品列表', hasParams: false },
{ path: '/products/:category/:id', name: '产品详情', hasParams: true },
{ path: '/search', name: '搜索结果', hasParams: false },
{ path: '/:path(.*)*', name: '404页面', hasParams: true }
]
const parsePath = (path) => {
// 移除开头的斜杠
const cleanPath = path.replace(/^\//, '')
return cleanPath.split('/').filter(Boolean)
}
const matchPath = (routePath, testPath) => {
const routeParts = parsePath(routePath)
const testParts = parsePath(testPath)
const params = {}
for (let i = 0; i < routeParts.length; i++) {
const routePart = routeParts[i]
const testPart = testParts[i]
// 通配符匹配
if (routePart === '(.*)*' || routePart === ':path(.*)*') {
params['pathMatch'] = testParts.slice(i).join('/')
return { matched: true, params }
}
// 动态参数匹配
if (routePart.startsWith(':')) {
const paramName = routePart.replace(/^:/, '').replace(/\?$/, '')
const isOptional = routePart.endsWith('?')
if (testPart !== undefined) {
params[paramName] = testPart
continue
} else if (isOptional) {
continue
} else {
return { matched: false, params: {} }
}
}
// 精确匹配
if (routePart !== testPart) {
return { matched: false, params: {} }
}
}
// 检查是否有剩余的测试路径部分(除非是通配符路由)
if (testParts.length > routeParts.length) {
const lastRoutePart = routeParts[routeParts.length - 1]
if (!lastRoutePart || (!lastRoutePart.includes('*') && !lastRoutePart.endsWith('+'))) {
return { matched: false, params: {} }
}
}
return { matched: true, params }
}
const testMatch = () => {
if (!testPath.value.trim()) {
matchResult.value = { matched: false }
matchedRoute.value = null
return
}
let foundMatch = false
for (const route of routes) {
const { matched, params } = matchPath(route.path, testPath.value)
if (matched) {
matchResult.value = {
matched: true,
route,
params
}
matchedRoute.value = route
foundMatch = true
break
}
}
if (!foundMatch) {
matchResult.value = { matched: false }
matchedRoute.value = null
}
}
// 自动测试初始路径
testMatch()
</script>
<style scoped>
.route-matching-demo {
padding: 20px;
background: var(--vp-c-bg-soft);
border-radius: 12px;
margin: 20px 0;
}
.demo-header {
text-align: center;
margin-bottom: 24px;
}
.demo-header h4 {
margin: 0 0 8px 0;
color: var(--vp-c-text-1);
}
.demo-desc {
margin: 0;
color: var(--vp-c-text-2);
font-size: 14px;
}
.demo-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 20px;
}
.input-section {
background: var(--vp-c-bg);
padding: 20px;
border-radius: 8px;
border: 1px solid var(--vp-c-divider);
}
.input-group {
margin-bottom: 16px;
}
.input-group label {
display: block;
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
color: var(--vp-c-text-2);
}
.path-input-wrapper {
display: flex;
align-items: center;
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
overflow: hidden;
}
.path-prefix {
padding: 10px 8px 10px 12px;
color: var(--vp-c-text-3);
font-family: monospace;
font-size: 14px;
}
.path-input {
flex: 1;
border: none;
background: transparent;
padding: 10px 12px 10px 0;
font-size: 14px;
color: var(--vp-c-text-1);
outline: none;
}
.test-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
background: var(--vp-c-brand);
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.test-btn:hover {
background: var(--vp-c-brand-dark);
}
.btn-icon {
font-size: 10px;
}
.routes-section {
background: var(--vp-c-bg);
padding: 20px;
border-radius: 8px;
border: 1px solid var(--vp-c-divider);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.section-header h5 {
margin: 0;
font-size: 14px;
color: var(--vp-c-text-1);
}
.route-count {
font-size: 12px;
color: var(--vp-c-text-3);
background: var(--vp-c-bg-soft);
padding: 2px 8px;
border-radius: 10px;
}
.routes-list {
max-height: 280px;
overflow-y: auto;
}
.route-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
border-radius: 6px;
margin-bottom: 4px;
transition: all 0.2s;
}
.route-item:hover {
background: var(--vp-c-bg-soft);
}
.route-item.matched {
background: rgba(66, 184, 131, 0.1);
border: 1px solid rgba(66, 184, 131, 0.3);
}
.route-path {
display: flex;
align-items: center;
gap: 8px;
}
.route-pattern {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 13px;
color: var(--vp-c-text-1);
}
.param-badge {
font-size: 10px;
color: var(--vp-c-brand);
background: var(--vp-c-brand-soft);
padding: 2px 6px;
border-radius: 4px;
}
.route-name {
font-size: 12px;
color: var(--vp-c-text-3);
}
.result-section {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 20px;
border: 1px solid var(--vp-c-divider);
margin-top: 20px;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--vp-c-divider);
}
.result-header h5 {
margin: 0;
font-size: 14px;
color: var(--vp-c-text-1);
}
.match-status {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.match-status.success {
background: rgba(39, 201, 63, 0.15);
color: #27c93f;
}
.match-status.fail {
background: rgba(255, 95, 86, 0.15);
color: #ff5f56;
}
.match-details {
display: grid;
gap: 12px;
}
.detail-item {
display: flex;
align-items: center;
gap: 16px;
}
.detail-label {
width: 80px;
font-size: 13px;
color: var(--vp-c-text-3);
flex-shrink: 0;
}
.detail-value {
font-size: 14px;
color: var(--vp-c-text-1);
}
.detail-value.code {
font-family: 'Monaco', 'Menlo', monospace;
background: var(--vp-c-bg-soft);
padding: 4px 8px;
border-radius: 4px;
}
.params-section {
margin-top: 8px;
padding-top: 12px;
border-top: 1px solid var(--vp-c-divider);
}
.params-list {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.param-item {
display: flex;
align-items: center;
gap: 6px;
background: var(--vp-c-bg-soft);
padding: 6px 12px;
border-radius: 6px;
}
.param-key {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 13px;
color: var(--vp-c-brand);
}
.param-arrow {
font-size: 12px;
color: var(--vp-c-text-3);
}
.param-value {
font-size: 13px;
color: var(--vp-c-text-1);
font-weight: 500;
}
.no-match {
text-align: center;
padding: 32px;
}
.no-match-icon {
font-size: 48px;
margin-bottom: 16px;
}
.no-match p {
color: var(--vp-c-text-2);
margin-bottom: 16px;
}
.suggestions {
text-align: left;
display: inline-block;
color: var(--vp-c-text-3);
font-size: 13px;
}
.suggestions li {
margin: 4px 0;
}
.tips-section {
margin-top: 20px;
padding: 20px;
background: var(--vp-c-bg);
border-radius: 8px;
border: 1px solid var(--vp-c-divider);
}
.tips-section h5 {
margin: 0 0 16px 0;
font-size: 14px;
color: var(--vp-c-text-1);
}
.tips-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.tip-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
background: var(--vp-c-bg-soft);
border-radius: 6px;
}
.tip-item code {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 13px;
color: var(--vp-c-brand);
background: transparent;
padding: 0;
}
.tip-item span:last-child {
font-size: 12px;
color: var(--vp-c-text-3);
}
@media (max-width: 768px) {
.demo-container {
grid-template-columns: 1fr;
}
.detail-item {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.detail-label {
width: auto;
}
.tips-grid {
grid-template-columns: 1fr;
}
}
</style>