feat(docs): enhance interactive demos and improve documentation
- Add new interactive components for frontend routing, browser rendering pipeline, and database transactions - Improve existing demos with better visuals, explanations, and examples - Update documentation structure and content for better clarity - Add new utility scripts and update package.json with new commands - Fix formatting and alignment in documentation tables
This commit is contained in:
@@ -1,29 +1,34 @@
|
||||
<template>
|
||||
<div class="dynamic-routes-demo">
|
||||
<div class="demo-header">
|
||||
<h4>动态路由与参数</h4>
|
||||
<p class="demo-desc">探索动态参数、正则匹配和可选参数的使用方式</p>
|
||||
<span class="icon">🔗</span>
|
||||
<span class="title">动态路由</span>
|
||||
<span class="subtitle">让URL变身数据容器</span>
|
||||
</div>
|
||||
|
||||
<div class="intro-text">
|
||||
想象你在<span class="highlight">图书馆</span>找书:每本书都有编号(动态参数),你需要根据这个编号找到对应的书籍。动态路由就像这样,用<span class="highlight">占位符</span>匹配不同的内容。
|
||||
</div>
|
||||
|
||||
<div class="demo-content">
|
||||
<!-- 路由参数类型说明 -->
|
||||
<!-- 参数类型说明 -->
|
||||
<div class="param-types">
|
||||
<div
|
||||
v-for="type in paramTypes"
|
||||
:key="type.name"
|
||||
:class="['param-card', { active: selectedType === type.name }]"
|
||||
@click="selectedType = type.name"
|
||||
@click="selectType(type)"
|
||||
>
|
||||
<div class="param-pattern">{{ type.pattern }}</div>
|
||||
<div class="param-name">{{ type.name }}</div>
|
||||
<div class="param-desc">{{ type.description }}</div>
|
||||
<div class="param-name">{{ type.label }}</div>
|
||||
<div class="param-example">例: {{ type.example }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 参数解析演示 -->
|
||||
<div class="parsing-demo">
|
||||
<div class="demo-section">
|
||||
<h5>测试路径</h5>
|
||||
<h5>📍 测试路径</h5>
|
||||
<div class="input-group">
|
||||
<span class="input-prefix">/</span>
|
||||
<input
|
||||
@@ -34,10 +39,11 @@
|
||||
@input="parsePath"
|
||||
>
|
||||
</div>
|
||||
<div class="hint-text">试试输入:user/123 或 products/electronics/456</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-section">
|
||||
<h5>匹配结果</h5>
|
||||
<h5>🎯 匹配结果</h5>
|
||||
<div v-if="parseResult" class="result-box">
|
||||
<div class="result-row">
|
||||
<span class="result-label">匹配路由:</span>
|
||||
@@ -59,11 +65,17 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-result">
|
||||
输入路径查看解析结果
|
||||
<div class="no-match-icon">🔍</div>
|
||||
<div>输入路径查看解析结果</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>核心思想:</strong>动态路由用占位符(如 :id)捕获URL中的变量值,就像给数据贴上了"标签",让组件可以通过这些标签获取具体内容。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -77,30 +89,33 @@ const paramTypes = [
|
||||
{
|
||||
name: 'required',
|
||||
pattern: ':id',
|
||||
description: '必填参数,URL中必须有对应的值',
|
||||
example: '/user/123'
|
||||
label: '必填参数',
|
||||
example: '/user/123',
|
||||
description: 'URL中必须有对应的值'
|
||||
},
|
||||
{
|
||||
name: 'optional',
|
||||
pattern: ':id?',
|
||||
description: '可选参数,可以省略',
|
||||
example: '/user 或 /user/123'
|
||||
label: '可选参数',
|
||||
example: '/user 或 /user/123',
|
||||
description: '可以省略的参数'
|
||||
},
|
||||
{
|
||||
name: 'multiple',
|
||||
pattern: ':id+',
|
||||
description: '一个或多个,至少有一个值',
|
||||
example: '/files/a 或 /files/a/b/c'
|
||||
label: '重复参数',
|
||||
example: '/files/a/b/c',
|
||||
description: '一个或多个值'
|
||||
},
|
||||
{
|
||||
name: 'zeroOrMore',
|
||||
pattern: ':id*',
|
||||
description: '零个或多个,可以没有',
|
||||
example: '/tags 或 /tags/vue/router'
|
||||
label: '灵活参数',
|
||||
example: '/tags 或 /tags/vue/router',
|
||||
description: '零个或多个值'
|
||||
}
|
||||
]
|
||||
|
||||
// 模拟的路由配置
|
||||
const routePatterns = [
|
||||
{ pattern: '/user/:id', name: 'UserDetail' },
|
||||
{ pattern: '/user/:id/profile', name: 'UserProfile' },
|
||||
@@ -110,11 +125,15 @@ const routePatterns = [
|
||||
{ pattern: '/files/:path*', name: 'FileBrowser' }
|
||||
]
|
||||
|
||||
const selectType = (type) => {
|
||||
selectedType.value = type.name
|
||||
testPath.value = type.example.split(' 或 ')[0].replace('/', '')
|
||||
}
|
||||
|
||||
const parsePath = () => {
|
||||
const path = testPath.value.trim()
|
||||
if (!path) return null
|
||||
|
||||
// 简化的匹配逻辑
|
||||
for (const route of routePatterns) {
|
||||
const match = matchRoute(route.pattern, path)
|
||||
if (match) {
|
||||
@@ -129,18 +148,16 @@ const parsePath = () => {
|
||||
}
|
||||
|
||||
const matchRoute = (pattern, path) => {
|
||||
// 将 :param 转换为正则
|
||||
const regexPattern = pattern
|
||||
.replace(/:([^/]+)\*/g, '(.*)') // :path* → (.*)
|
||||
.replace(/:([^/]+)\?/g, '([^/]*)') // :keyword? → ([^/]*)
|
||||
.replace(/:([^/]+)/g, '([^/]+)') // :id → ([^/]+)
|
||||
.replace(/:([^/]+)\*/g, '(.*)')
|
||||
.replace(/:([^/]+)\?/g, '([^/]*)')
|
||||
.replace(/:([^/]+)/g, '([^/]+)')
|
||||
|
||||
const regex = new RegExp(`^${regexPattern}$`)
|
||||
const match = path.match(regex)
|
||||
|
||||
if (!match) return null
|
||||
|
||||
// 提取参数名
|
||||
const paramNames = []
|
||||
const paramRegex = /:([^/]+)/g
|
||||
let paramMatch
|
||||
@@ -148,7 +165,6 @@ const matchRoute = (pattern, path) => {
|
||||
paramNames.push(paramMatch[1].replace(/[?*+]$/, ''))
|
||||
}
|
||||
|
||||
// 构建参数对象
|
||||
const params = {}
|
||||
paramNames.forEach((name, index) => {
|
||||
params[name] = match[index + 1]
|
||||
@@ -159,57 +175,71 @@ const matchRoute = (pattern, path) => {
|
||||
|
||||
const parseResult = computed(() => parsePath())
|
||||
|
||||
// 初始化
|
||||
parsePath()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dynamic-routes-demo {
|
||||
padding: 20px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 12px;
|
||||
margin: 20px 0;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
.demo-header .icon { font-size: 1.25rem; }
|
||||
.demo-header .title { font-weight: bold; font-size: 1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.85rem; margin-left: 0.5rem; }
|
||||
|
||||
.demo-desc {
|
||||
margin: 0;
|
||||
.intro-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.intro-text .highlight {
|
||||
color: var(--vp-c-brand-1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.demo-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.param-types {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.param-card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.param-card:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.param-card.active {
|
||||
@@ -218,42 +248,42 @@ parsePath()
|
||||
}
|
||||
|
||||
.param-pattern {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 16px;
|
||||
font-family: monospace;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-brand);
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.param-name {
|
||||
font-size: 14px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.param-desc {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.4;
|
||||
.param-example {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.parsing-demo {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.demo-section {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.demo-section h5 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 13px;
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -265,73 +295,80 @@ parsePath()
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.input-prefix {
|
||||
padding: 10px 8px 10px 12px;
|
||||
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.demo-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 10px 12px 10px 0;
|
||||
font-size: 14px;
|
||||
padding: 0.5rem 0.75rem 0.5rem 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-1);
|
||||
outline: none;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.result-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.result-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.result-label {
|
||||
font-size: 12px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-3);
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.result-value {
|
||||
font-size: 13px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-1);
|
||||
font-family: monospace;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 4px 8px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.result-params {
|
||||
padding-top: 12px;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.params-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.param-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 0.25rem;
|
||||
background: var(--vp-c-brand-soft);
|
||||
padding: 4px 10px;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.param-key {
|
||||
@@ -349,14 +386,30 @@ parsePath()
|
||||
|
||||
.no-result {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
padding: 2rem 1rem;
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 13px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.no-match-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.info-box .icon { margin-right: 0.25rem; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.param-types {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.parsing-demo {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<template>
|
||||
<div class="hash-vs-history-demo">
|
||||
<div class="demo-header">
|
||||
<h4>Hash vs History 模式对比</h4>
|
||||
<p class="demo-desc">直观对比两种主流路由模式的URL变化、浏览器行为和兼容性</p>
|
||||
<span class="icon">⚖️</span>
|
||||
<span class="title">路由模式对比</span>
|
||||
<span class="subtitle">Hash vs History</span>
|
||||
</div>
|
||||
|
||||
<div class="intro-text">
|
||||
想象你在<span class="highlight">邮寄包裹</span>:Hash模式像是把地址写在<span class="highlight">便签条</span>上(#后面),History模式则是直接写在<span class="highlight">信封</span>上。前者简单但不够正式,后者美观但需要服务端配合。
|
||||
</div>
|
||||
|
||||
<div class="comparison-container">
|
||||
@@ -47,11 +52,7 @@
|
||||
|
||||
<div class="characteristics">
|
||||
<div class="char-item">
|
||||
<span class="char-label">URL格式</span>
|
||||
<code>/#/path</code>
|
||||
</div>
|
||||
<div class="char-item">
|
||||
<span class="char-label">浏览器兼容</span>
|
||||
<span class="char-label">兼容性</span>
|
||||
<span class="badge good">IE8+</span>
|
||||
</div>
|
||||
<div class="char-item">
|
||||
@@ -106,11 +107,7 @@
|
||||
|
||||
<div class="characteristics">
|
||||
<div class="char-item">
|
||||
<span class="char-label">URL格式</span>
|
||||
<code>/path</code>
|
||||
</div>
|
||||
<div class="char-item">
|
||||
<span class="char-label">浏览器兼容</span>
|
||||
<span class="char-label">兼容性</span>
|
||||
<span class="badge medium">IE10+</span>
|
||||
</div>
|
||||
<div class="char-item">
|
||||
@@ -125,26 +122,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-section">
|
||||
<h4>如何选择?</h4>
|
||||
<div class="decision-tree">
|
||||
<div class="decision-item">
|
||||
<span class="decision-q">需要支持IE8/9?</span>
|
||||
<span class="decision-a">→ 选 Hash 模式</span>
|
||||
</div>
|
||||
<div class="decision-item">
|
||||
<span class="decision-q">重视SEO?</span>
|
||||
<span class="decision-a">→ 选 History 模式</span>
|
||||
</div>
|
||||
<div class="decision-item">
|
||||
<span class="decision-q">无法修改服务端配置?</span>
|
||||
<span class="decision-a">→ 选 Hash 模式</span>
|
||||
</div>
|
||||
<div class="decision-item">
|
||||
<span class="decision-q">追求URL美观?</span>
|
||||
<span class="decision-a">→ 选 History 模式</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>选择建议:</strong>现代项目优先选History模式(URL美观、SEO友好),如果需要兼容老浏览器或无法修改服务端配置,再用Hash模式。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -182,47 +162,59 @@ const getPageContent = (path) => {
|
||||
|
||||
<style scoped>
|
||||
.hash-vs-history-demo {
|
||||
padding: 20px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 12px;
|
||||
margin: 20px 0;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
.demo-header .icon { font-size: 1.25rem; }
|
||||
.demo-header .title { font-weight: bold; font-size: 1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.85rem; margin-left: 0.5rem; }
|
||||
|
||||
.demo-desc {
|
||||
margin: 0;
|
||||
.intro-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.intro-text .highlight {
|
||||
color: var(--vp-c-brand-1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.comparison-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mode-column {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 12px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.mode-header {
|
||||
padding: 16px;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.mode-header.hash {
|
||||
@@ -236,12 +228,12 @@ const getPageContent = (path) => {
|
||||
}
|
||||
|
||||
.mode-icon {
|
||||
font-size: 20px;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.mode-title {
|
||||
font-size: 16px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -251,15 +243,15 @@ const getPageContent = (path) => {
|
||||
|
||||
.browser-toolbar {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 10px 12px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.dot {
|
||||
@@ -275,10 +267,10 @@ const getPageContent = (path) => {
|
||||
.address-bar {
|
||||
flex: 1;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 5px 10px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 0.7rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.protocol, .host { color: var(--vp-c-text-3); }
|
||||
@@ -287,21 +279,21 @@ const getPageContent = (path) => {
|
||||
|
||||
.browser-viewport {
|
||||
display: flex;
|
||||
min-height: 160px;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
width: 80px;
|
||||
width: 60px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 12px 0;
|
||||
padding: 0.5rem 0;
|
||||
border-right: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
padding: 0.5rem 0.5rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 13px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
@@ -319,31 +311,31 @@ const getPageContent = (path) => {
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.page-content h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.page-content p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.characteristics {
|
||||
padding: 16px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.char-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
@@ -352,21 +344,14 @@ const getPageContent = (path) => {
|
||||
}
|
||||
|
||||
.char-label {
|
||||
font-size: 13px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.char-item code {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 2px 8px;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -390,56 +375,20 @@ const getPageContent = (path) => {
|
||||
color: #ff5f56;
|
||||
}
|
||||
|
||||
.summary-section {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.summary-section h4 {
|
||||
margin: 0 0 16px 0;
|
||||
color: var(--vp-c-text-1);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.decision-tree {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.decision-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.decision-q {
|
||||
font-size: 14px;
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
flex: 1;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.decision-a {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-brand);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.info-box .icon { margin-right: 0.25rem; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@media (max-width: 768px) {
|
||||
.comparison-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.decision-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
<template>
|
||||
<div class="mpa-routing-demo">
|
||||
<div class="demo-header">
|
||||
<h4>MPA vs SPA 路由对比</h4>
|
||||
<p class="demo-desc">对比多页面应用和单页面应用的路由机制差异</p>
|
||||
<span class="icon">🔄</span>
|
||||
<span class="title">MPA vs SPA</span>
|
||||
<span class="subtitle">多页面 vs 单页面导航</span>
|
||||
</div>
|
||||
|
||||
<div class="comparison-table">
|
||||
<div class="table-header">
|
||||
<div class="col feature">特性</div>
|
||||
<div class="col mpa">MPA (多页面)</div>
|
||||
<div class="col spa">SPA (单页面)</div>
|
||||
</div>
|
||||
<div class="table-row" v-for="(row, index) in comparisonData" :key="index">
|
||||
<div class="col feature">{{ row.feature }}</div>
|
||||
<div class="col mpa">{{ row.mpa }}</div>
|
||||
<div class="col spa">{{ row.spa }}</div>
|
||||
</div>
|
||||
<div class="intro-text">
|
||||
想象你在<span class="highlight">餐厅吃饭</span>:MPA像是每次点菜都<span class="highlight">换一家餐厅</span>(重新加载整个页面),SPA则是在同一家餐厅换菜品(只更新需要变化的部分)。显然,SPA体验更流畅!
|
||||
</div>
|
||||
|
||||
<div class="flow-comparison">
|
||||
<div class="flow-box mpa-flow">
|
||||
<h5>MPA 导航流程</h5>
|
||||
<div class="comparison-container">
|
||||
<div class="mode-box mpa">
|
||||
<div class="mode-header">
|
||||
<span class="mode-icon">🏢</span>
|
||||
<span class="mode-title">MPA (多页面应用)</span>
|
||||
</div>
|
||||
<div class="flow-steps">
|
||||
<div class="step">1. 用户点击链接</div>
|
||||
<div class="step">2. 浏览器发送 HTTP 请求</div>
|
||||
@@ -28,19 +23,56 @@
|
||||
<div class="step">4. 浏览器解析并渲染新页面</div>
|
||||
<div class="step">5. 页面资源重新加载 (JS/CSS)</div>
|
||||
</div>
|
||||
<div class="mode-features">
|
||||
<div class="feature">
|
||||
<span class="feature-icon">✓</span>
|
||||
<span>SEO 友好</span>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="feature-icon">✓</span>
|
||||
<span>首屏快</span>
|
||||
</div>
|
||||
<div class="feature bad">
|
||||
<span class="feature-icon">✗</span>
|
||||
<span>页面有白屏</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flow-box spa-flow">
|
||||
<h5>SPA 导航流程</h5>
|
||||
|
||||
<div class="mode-box spa">
|
||||
<div class="mode-header">
|
||||
<span class="mode-icon">⚡</span>
|
||||
<span class="mode-title">SPA (单页面应用)</span>
|
||||
</div>
|
||||
<div class="flow-steps">
|
||||
<div class="step">1. 用户点击链接</div>
|
||||
<div class="step">2. 拦截默认行为 (preventDefault)</div>
|
||||
<div class="step">2. 拦截默认行为</div>
|
||||
<div class="step">3. 更新 URL (History API)</div>
|
||||
<div class="step">4. 匹配路由配置</div>
|
||||
<div class="step">5. 动态渲染新组件</div>
|
||||
<div class="step">6. 页面无刷新更新</div>
|
||||
</div>
|
||||
<div class="mode-features">
|
||||
<div class="feature">
|
||||
<span class="feature-icon">✓</span>
|
||||
<span>过渡流畅</span>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<span class="feature-icon">✓</span>
|
||||
<span>体验好</span>
|
||||
</div>
|
||||
<div class="feature bad">
|
||||
<span class="feature-icon">✗</span>
|
||||
<span>需要 SSR 支持 SEO</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>核心区别:</strong>MPA每次跳转都要重新下载整个页面,SPA只在首次加载时下载,后续只更新变化的内容。这就是为什么SPA感觉"更快"的原因。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -50,127 +82,147 @@ const comparisonData = [
|
||||
{ feature: 'URL 变化', mpa: '浏览器地址栏正常变化', spa: 'History API 控制 URL' },
|
||||
{ feature: '用户体验', mpa: '页面有白屏闪烁', spa: '过渡流畅无刷新' },
|
||||
{ feature: 'SEO 友好', mpa: '天生对搜索引擎友好', spa: '需要 SSR/预渲染优化' },
|
||||
{ feature: '首屏时间', mpa: '较快(只加载当前页)', spa: '较慢(需加载完整应用)' },
|
||||
{ feature: '服务端压力', mpa: '较高(每次请求都渲染)', spa: '较低(大部分逻辑在客户端)' },
|
||||
{ feature: '开发复杂度', mpa: '简单,传统开发模式', spa: '较复杂,需理解前端路由' }
|
||||
{ feature: '首屏时间', mpa: '较快(只加载当前页)', spa: '较慢(需加载完整应用)' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mpa-routing-demo {
|
||||
padding: 20px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 12px;
|
||||
margin: 20px 0;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
.demo-header .icon { font-size: 1.25rem; }
|
||||
.demo-header .title { font-weight: bold; font-size: 1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.85rem; margin-left: 0.5rem; }
|
||||
|
||||
.demo-desc {
|
||||
margin: 0;
|
||||
.intro-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.comparison-table {
|
||||
.intro-text .highlight {
|
||||
color: var(--vp-c-brand-1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.comparison-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mode-box {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.2fr 1.2fr;
|
||||
.mode-header {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.mode-box.mpa .mode-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mode-box.spa .mode-header {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mode-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.mode-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.2fr 1.2fr;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.col {
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.col.feature {
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.col.mpa {
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.col.spa {
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.flow-comparison {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.flow-box {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.flow-box h5 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.flow-steps {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.step {
|
||||
padding: 10px 12px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
border-left: 3px solid var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.mpa-flow .step {
|
||||
border-left-color: #888;
|
||||
.mode-features {
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.spa-flow .step {
|
||||
border-left-color: var(--vp-c-brand);
|
||||
.feature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.feature .feature-icon {
|
||||
color: #27c93f;
|
||||
}
|
||||
|
||||
.feature.bad .feature-icon {
|
||||
color: #ff5f56;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.info-box .icon { margin-right: 0.25rem; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.table-header,
|
||||
.table-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.flow-comparison {
|
||||
.comparison-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</content>
|
||||
@@ -1,11 +1,16 @@
|
||||
<template>
|
||||
<div class="nested-routes-demo">
|
||||
<div class="demo-header">
|
||||
<h4>嵌套路由可视化</h4>
|
||||
<p class="demo-desc">点击不同层级,观察嵌套路由的渲染位置和层级关系</p>
|
||||
<span class="icon">🪆</span>
|
||||
<span class="title">嵌套路由</span>
|
||||
<span class="subtitle">层层嵌套的视图容器</span>
|
||||
</div>
|
||||
|
||||
<div class="demo-container">
|
||||
<div class="intro-text">
|
||||
想象<span class="highlight">俄罗斯套娃</span>:每个大娃娃里都有小娃娃,小娃娃里还有更小的。嵌套路由就是这样,父组件的<span class="highlight">RouterView</span>里可以渲染子组件,一层套一层。
|
||||
</div>
|
||||
|
||||
<div class="demo-content">
|
||||
<!-- 路由层级可视化 -->
|
||||
<div class="routes-hierarchy">
|
||||
<div class="tree-view">
|
||||
@@ -13,19 +18,17 @@
|
||||
v-for="node in treeData"
|
||||
:key="node.path"
|
||||
class="tree-node"
|
||||
:style="{ paddingLeft: `${node.level * 24}px` }"
|
||||
:style="{ paddingLeft: `${node.level * 20}px` }"
|
||||
@click="selectNode(node)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'node-content',
|
||||
{ active: currentPath === node.path },
|
||||
{ 'has-children': node.children?.length }
|
||||
{ active: currentPath === node.path }
|
||||
]"
|
||||
>
|
||||
<span class="node-icon">{{ node.children?.length ? '📁' : '📄' }}</span>
|
||||
<span class="node-path">{{ node.name }}</span>
|
||||
<code class="node-route">{{ node.path || '/' }}</code>
|
||||
<span class="node-name">{{ node.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,7 +37,7 @@
|
||||
<!-- 渲染区域预览 -->
|
||||
<div class="render-preview">
|
||||
<div class="preview-header">
|
||||
<h5>渲染视图</h5>
|
||||
<h5>🔲 渲染视图</h5>
|
||||
<span class="current-path">{{ currentPath || '/' }}</span>
|
||||
</div>
|
||||
|
||||
@@ -43,14 +46,14 @@
|
||||
v-for="(route, index) in activeRouteChain"
|
||||
:key="route.path"
|
||||
class="router-view-level"
|
||||
:style="{ marginLeft: `${index * 20}px` }"
|
||||
:style="{ marginLeft: `${index * 16}px` }"
|
||||
>
|
||||
<div class="router-view-box">
|
||||
<div class="view-label">
|
||||
<span class="view-icon">🔲</span>
|
||||
<span class="view-icon">📦</span>
|
||||
<span class="view-name">{{ route.name }}</span>
|
||||
</div>
|
||||
<div class="view-path">{{ route.path }}</div>
|
||||
<div class="view-path">{{ route.path || '/' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,10 +72,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代码示例 -->
|
||||
<div class="code-section">
|
||||
<h5>路由配置示例</h5>
|
||||
<pre class="code-block"><code>{{ routeConfigCode }}</code></pre>
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>核心概念:</strong>嵌套路由通过在父组件中放置 RouterView 来实现子路由的渲染。每个路由层级都有自己的 RouterView,就像套娃一样一层层展示。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -112,45 +114,6 @@ const routeConfig = [
|
||||
path: ':id',
|
||||
name: 'UserDetail',
|
||||
component: 'UserDetail'
|
||||
},
|
||||
{
|
||||
path: ':id/edit',
|
||||
name: 'UserEdit',
|
||||
component: 'UserEdit'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'products',
|
||||
name: 'Products',
|
||||
component: 'ProductLayout',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'ProductList',
|
||||
component: 'ProductList'
|
||||
},
|
||||
{
|
||||
path: 'category/:categoryId',
|
||||
name: 'ProductCategory',
|
||||
component: 'ProductCategory'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'Settings',
|
||||
component: 'Settings',
|
||||
children: [
|
||||
{
|
||||
path: 'profile',
|
||||
name: 'ProfileSettings',
|
||||
component: 'ProfileSettings'
|
||||
},
|
||||
{
|
||||
path: 'security',
|
||||
name: 'SecuritySettings',
|
||||
component: 'SecuritySettings'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -158,36 +121,29 @@ const routeConfig = [
|
||||
}
|
||||
]
|
||||
|
||||
// 扁平化路由,添加层级信息
|
||||
const flattenRoutes = (routes, level = 0, parentPath = '') => {
|
||||
const result = []
|
||||
|
||||
routes.forEach(route => {
|
||||
const fullPath = route.path
|
||||
? `${parentPath}/${route.path}`.replace(/\/+/g, '/')
|
||||
: parentPath || '/'
|
||||
|
||||
const node = {
|
||||
...route,
|
||||
fullPath,
|
||||
level,
|
||||
children: []
|
||||
}
|
||||
|
||||
if (route.children?.length) {
|
||||
node.children = flattenRoutes(route.children, level + 1, fullPath)
|
||||
}
|
||||
|
||||
result.push(node)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const treeData = computed(() => {
|
||||
const flatten = (routes, level = 0) => {
|
||||
const result = []
|
||||
|
||||
routes.forEach(route => {
|
||||
const node = {
|
||||
name: route.name,
|
||||
@@ -199,10 +155,8 @@ const treeData = computed(() => {
|
||||
}
|
||||
result.push(node)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
return flatten(flattenRoutes(routeConfig))
|
||||
})
|
||||
|
||||
@@ -210,11 +164,9 @@ const activeRouteChain = computed(() => {
|
||||
const findChain = (routes, target, chain = []) => {
|
||||
for (const route of routes) {
|
||||
const currentChain = [...chain, route]
|
||||
|
||||
if (route.path === target || route.fullPath === target) {
|
||||
return currentChain
|
||||
}
|
||||
|
||||
if (route.children?.length) {
|
||||
const found = findChain(route.children, target, currentChain)
|
||||
if (found) return found
|
||||
@@ -222,7 +174,6 @@ const activeRouteChain = computed(() => {
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return findChain(flattenRoutes(routeConfig), currentPath.value) || []
|
||||
})
|
||||
|
||||
@@ -233,33 +184,6 @@ const breadcrumbs = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
const routeConfigCode = computed(() => `const routes = [
|
||||
{
|
||||
path: '/',
|
||||
component: Layout,
|
||||
children: [
|
||||
{ path: 'dashboard', component: Dashboard },
|
||||
{
|
||||
path: 'users',
|
||||
component: UserLayout,
|
||||
children: [
|
||||
{ path: '', component: UserList },
|
||||
{ path: ':id', component: UserDetail },
|
||||
{ path: ':id/edit', component: UserEdit }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
component: Settings,
|
||||
children: [
|
||||
{ path: 'profile', component: ProfileSettings },
|
||||
{ path: 'security', component: SecuritySettings }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]`)
|
||||
|
||||
const selectNode = (node) => {
|
||||
currentPath.value = node.fullPath || node.path
|
||||
}
|
||||
@@ -271,44 +195,57 @@ const navigateTo = (path) => {
|
||||
|
||||
<style scoped>
|
||||
.nested-routes-demo {
|
||||
padding: 20px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 12px;
|
||||
margin: 20px 0;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
.demo-header .icon { font-size: 1.25rem; }
|
||||
.demo-header .title { font-weight: bold; font-size: 1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.85rem; margin-left: 0.5rem; }
|
||||
|
||||
.demo-desc {
|
||||
margin: 0;
|
||||
.intro-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.demo-container {
|
||||
.intro-text .highlight {
|
||||
color: var(--vp-c-brand-1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.demo-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.routes-hierarchy {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.tree-view {
|
||||
max-height: 350px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -319,8 +256,8 @@ const navigateTo = (path) => {
|
||||
.node-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
@@ -336,25 +273,15 @@ const navigateTo = (path) => {
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
font-size: 14px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.node-path {
|
||||
font-size: 13px;
|
||||
.node-name {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.node-route {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--vp-c-text-3);
|
||||
font-family: monospace;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.render-preview {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
@@ -366,61 +293,61 @@ const navigateTo = (path) => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.preview-header h5 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.current-path {
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
font-family: monospace;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 2px 8px;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.router-view-hierarchy {
|
||||
padding: 16px;
|
||||
min-height: 200px;
|
||||
padding: 1rem;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.router-view-level {
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.router-view-box {
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.view-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
gap: 0.375rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.view-icon {
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.view-name {
|
||||
font-size: 13px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.view-path {
|
||||
font-size: 11px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-3);
|
||||
font-family: monospace;
|
||||
}
|
||||
@@ -428,8 +355,8 @@ const navigateTo = (path) => {
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 12px 16px;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
overflow-x: auto;
|
||||
@@ -438,8 +365,8 @@ const navigateTo = (path) => {
|
||||
.breadcrumb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
@@ -451,37 +378,22 @@ const navigateTo = (path) => {
|
||||
|
||||
.separator {
|
||||
color: var(--vp-c-text-3);
|
||||
margin: 0 2px;
|
||||
margin: 0 0.125rem;
|
||||
}
|
||||
|
||||
.code-section {
|
||||
margin-top: 20px;
|
||||
padding: 20px;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.code-section h5 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
padding: 16px;
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.info-box .icon { margin-right: 0.25rem; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.demo-container {
|
||||
.demo-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,47 +1,81 @@
|
||||
<template>
|
||||
<div class="route-guards-demo">
|
||||
<div class="demo-header">
|
||||
<h4>路由守卫机制</h4>
|
||||
<p class="demo-desc">了解全局守卫、路由独享守卫和组件内守卫的执行顺序和用途</p>
|
||||
<span class="icon">🛡️</span>
|
||||
<span class="title">路由守卫</span>
|
||||
<span class="subtitle">导航流程的安检员</span>
|
||||
</div>
|
||||
|
||||
<div class="guards-container">
|
||||
<div class="guard-type" v-for="guard in guardTypes" :key="guard.name">
|
||||
<div class="guard-header" :class="guard.type">
|
||||
<span class="guard-icon">{{ guard.icon }}</span>
|
||||
<span class="guard-title">{{ guard.name }}</span>
|
||||
</div>
|
||||
<div class="guard-content">
|
||||
<p class="guard-desc">{{ guard.description }}</p>
|
||||
<div class="guard-example">
|
||||
<code>{{ guard.example }}</code>
|
||||
<div class="intro-text">
|
||||
想象你在<span class="highlight">机场过安检</span>:登机前要检查身份、行李,登机后可能还要确认信息。路由守卫就像这些安检员,在导航的各个阶段进行检查和拦截。
|
||||
</div>
|
||||
|
||||
<div class="demo-content">
|
||||
<div class="guards-list">
|
||||
<div
|
||||
v-for="guard in guardTypes"
|
||||
:key="guard.name"
|
||||
:class="['guard-card', guard.type]"
|
||||
@click="activeGuard = guard.name"
|
||||
>
|
||||
<div class="guard-header">
|
||||
<span class="guard-icon">{{ guard.icon }}</span>
|
||||
<span class="guard-name">{{ guard.name }}</span>
|
||||
</div>
|
||||
<div class="guard-desc">{{ guard.shortDesc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="activeGuard" class="guard-detail">
|
||||
<div class="detail-header">
|
||||
<span class="detail-icon">{{ currentGuard?.icon }}</span>
|
||||
<span class="detail-title">{{ currentGuard?.name }}</span>
|
||||
</div>
|
||||
<div class="detail-desc">{{ currentGuard?.description }}</div>
|
||||
<div class="detail-example">
|
||||
<div class="example-label">💻 代码示例:</div>
|
||||
<pre class="code-block">{{ currentGuard?.example }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div class="execution-flow">
|
||||
<h5>守卫执行顺序</h5>
|
||||
<div class="flow-chart">
|
||||
<div class="flow-step" v-for="(step, index) in executionSteps" :key="index">
|
||||
<h5>📋 守卫执行顺序</h5>
|
||||
<div class="flow-steps">
|
||||
<div
|
||||
v-for="(step, index) in executionSteps"
|
||||
:key="index"
|
||||
class="flow-step"
|
||||
>
|
||||
<div class="step-number">{{ index + 1 }}</div>
|
||||
<div class="step-content">
|
||||
<div class="step-name">{{ step.name }}</div>
|
||||
<div class="step-desc">{{ step.description }}</div>
|
||||
</div>
|
||||
<div v-if="index < executionSteps.length - 1" class="flow-arrow">↓</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>核心用途:</strong>路由守卫常用于权限验证(检查用户是否登录)、页面预加载(获取数据)、防止误操作(离开前提示保存)等场景。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const activeGuard = ref('beforeEach')
|
||||
|
||||
const guardTypes = [
|
||||
{
|
||||
name: '全局前置守卫',
|
||||
name: 'beforeEach',
|
||||
type: 'global',
|
||||
icon: '🌍',
|
||||
shortDesc: '全局前置守卫',
|
||||
description: '在路由跳转前执行,常用于权限验证、登录检查等',
|
||||
example: `router.beforeEach((to, from, next) => {
|
||||
if (to.meta.requiresAuth && !isLoggedIn()) {
|
||||
@@ -52,233 +86,261 @@ const guardTypes = [
|
||||
})`
|
||||
},
|
||||
{
|
||||
name: '全局解析守卫',
|
||||
name: 'beforeResolve',
|
||||
type: 'global',
|
||||
icon: '🔍',
|
||||
shortDesc: '全局解析守卫',
|
||||
description: '在导航被确认之前、组件内守卫和异步路由组件被解析之后调用',
|
||||
example: `router.beforeResolve((to, from, next) => {
|
||||
// 可以在这里做数据预加载
|
||||
// 数据预加载
|
||||
next()
|
||||
})`
|
||||
},
|
||||
{
|
||||
name: '全局后置钩子',
|
||||
name: 'afterEach',
|
||||
type: 'global',
|
||||
icon: '✅',
|
||||
description: '在导航完成后执行,不接受 next 函数,不能改变导航',
|
||||
shortDesc: '全局后置钩子',
|
||||
description: '在导航完成后执行,不能改变导航,常用于页面统计',
|
||||
example: `router.afterEach((to, from) => {
|
||||
// 设置页面标题
|
||||
document.title = to.meta.title || '默认标题'
|
||||
// 发送页面浏览统计
|
||||
document.title = to.meta.title
|
||||
analytics.track(to.path)
|
||||
})`
|
||||
},
|
||||
{
|
||||
name: '路由独享守卫',
|
||||
name: 'beforeEnter',
|
||||
type: 'route',
|
||||
icon: '🛣️',
|
||||
shortDesc: '路由独享守卫',
|
||||
description: '在单个路由配置中定义,只在进入该路由时触发',
|
||||
example: `{
|
||||
path: '/admin',
|
||||
component: Admin,
|
||||
beforeEnter: (to, from, next) => {
|
||||
if (!isAdmin()) {
|
||||
next('/unauthorized')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
if (!isAdmin()) next('/unauthorized')
|
||||
else next()
|
||||
}
|
||||
}`
|
||||
},
|
||||
{
|
||||
name: '组件内守卫',
|
||||
name: 'beforeRouteEnter',
|
||||
type: 'component',
|
||||
icon: '🔧',
|
||||
description: '在组件内部定义,可以访问组件实例 this',
|
||||
example: `export default {
|
||||
beforeRouteEnter(to, from, next) {
|
||||
// 在渲染该组件的对应路由被验证前调用
|
||||
// 不能获取组件实例 this
|
||||
next(vm => {
|
||||
// 通过 vm 访问组件实例
|
||||
})
|
||||
},
|
||||
beforeRouteUpdate(to, from, next) {
|
||||
// 在当前路由改变,但该组件被复用时调用
|
||||
// 可以访问组件实例 this
|
||||
this.name = to.params.name
|
||||
next()
|
||||
},
|
||||
beforeRouteLeave(to, from, next) {
|
||||
// 在导航离开渲染该组件的对应路由时调用
|
||||
// 可以访问组件实例 this
|
||||
const answer = window.confirm('确定要离开吗?未保存的更改将丢失。')
|
||||
if (answer) {
|
||||
next()
|
||||
} else {
|
||||
next(false)
|
||||
}
|
||||
}
|
||||
shortDesc: '组件内守卫-进入',
|
||||
description: '在渲染该组件的对应路由被验证前调用,不能访问组件实例',
|
||||
example: `beforeRouteEnter(to, from, next) {
|
||||
next(vm => {
|
||||
// 通过 vm 访问组件实例
|
||||
})
|
||||
}`
|
||||
}
|
||||
]
|
||||
|
||||
const executionSteps = [
|
||||
{
|
||||
name: '导航触发',
|
||||
description: '用户点击链接或调用 router.push()'
|
||||
},
|
||||
{
|
||||
name: '组件内 beforeRouteLeave',
|
||||
description: '在离开的组件中调用,可以取消导航'
|
||||
},
|
||||
{
|
||||
name: '全局 beforeEach',
|
||||
description: '全局前置守卫,常用于权限检查'
|
||||
},
|
||||
{
|
||||
name: '路由独享 beforeEnter',
|
||||
description: '在重用的组件中调用'
|
||||
},
|
||||
{
|
||||
name: '组件内 beforeRouteEnter',
|
||||
description: '在进入新组件前调用,此时组件实例还未创建'
|
||||
},
|
||||
{
|
||||
name: '全局 beforeResolve',
|
||||
description: '在导航被确认前调用,所有组件内守卫和异步组件已解析'
|
||||
},
|
||||
{
|
||||
name: '全局 afterEach',
|
||||
description: '导航完成后调用,常用于页面统计、标题设置'
|
||||
},
|
||||
{
|
||||
name: 'DOM 更新',
|
||||
description: '触发组件更新,渲染新页面'
|
||||
}
|
||||
{ name: '触发导航', description: '用户点击链接或调用 router.push()' },
|
||||
{ name: 'beforeRouteLeave', description: '离开组件的守卫' },
|
||||
{ name: 'beforeEach', description: '全局前置守卫' },
|
||||
{ name: 'beforeEnter', description: '路由独享守卫' },
|
||||
{ name: 'beforeRouteEnter', description: '组件内守卫' },
|
||||
{ name: 'beforeResolve', description: '全局解析守卫' },
|
||||
{ name: 'afterEach', description: '全局后置钩子' },
|
||||
{ name: 'DOM 更新', description: '渲染新页面' }
|
||||
]
|
||||
|
||||
const selectedType = ref('global')
|
||||
const currentPath = ref('/dashboard')
|
||||
const currentGuard = computed(() => {
|
||||
return guardTypes.find(g => g.name === activeGuard.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.route-guards-demo {
|
||||
padding: 20px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 12px;
|
||||
margin: 20px 0;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
.demo-header .icon { font-size: 1.25rem; }
|
||||
.demo-header .title { font-weight: bold; font-size: 1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.85rem; margin-left: 0.5rem; }
|
||||
|
||||
.demo-desc {
|
||||
margin: 0;
|
||||
.intro-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.guards-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.guard-type {
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.intro-text .highlight {
|
||||
color: var(--vp-c-brand-1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.demo-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.guards-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.guard-card {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.guard-card:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.guard-card.global {
|
||||
border-top: 3px solid #667eea;
|
||||
}
|
||||
|
||||
.guard-card.route {
|
||||
border-top: 3px solid #f5576c;
|
||||
}
|
||||
|
||||
.guard-card.component {
|
||||
border-top: 3px solid #4facfe;
|
||||
}
|
||||
|
||||
.guard-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.guard-header.global {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.guard-header.route {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
.guard-header.component {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.guard-icon {
|
||||
font-size: 18px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.guard-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.guard-content {
|
||||
padding: 16px;
|
||||
.guard-name {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.guard-desc {
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.guard-example {
|
||||
.guard-detail {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.detail-desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-example {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
border-left: 3px solid var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.example-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.guard-example code {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-1);
|
||||
line-height: 1.6;
|
||||
white-space: pre;
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.execution-flow {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.execution-flow h5 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 14px;
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.flow-chart {
|
||||
.flow-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid var(--vp-c-brand);
|
||||
@@ -293,8 +355,9 @@ const currentPath = ref('/dashboard')
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
@@ -302,32 +365,30 @@ const currentPath = ref('/dashboard')
|
||||
}
|
||||
|
||||
.step-name {
|
||||
font-size: 13px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
font-size: 12px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-3);
|
||||
margin-top: 2px;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.flow-arrow {
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 14px;
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.info-box .icon { margin-right: 0.25rem; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.guards-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
.guards-list {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+175
-397
@@ -1,126 +1,74 @@
|
||||
<template>
|
||||
<div class="route-matching-demo">
|
||||
<div class="demo-header">
|
||||
<h4>路由匹配机制</h4>
|
||||
<p class="demo-desc">输入URL路径,查看路由是如何匹配和解析参数的</p>
|
||||
<span class="icon">🎯</span>
|
||||
<span class="title">路由匹配</span>
|
||||
<span class="subtitle">URL如何找到对应组件</span>
|
||||
</div>
|
||||
|
||||
<div class="demo-container">
|
||||
<div class="intro-text">
|
||||
想象你在<span class="highlight">查字典</span>:输入一个词,字典会帮你找到对应的解释。路由匹配也是这样,浏览器根据URL路径,在路由配置中找到最匹配的那一项,然后渲染对应组件。
|
||||
</div>
|
||||
|
||||
<div class="demo-content">
|
||||
<div class="input-section">
|
||||
<h5>📍 测试路径</h5>
|
||||
<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 }]"
|
||||
<span class="input-prefix">/</span>
|
||||
<input
|
||||
v-model="testPath"
|
||||
type="text"
|
||||
placeholder="user/123"
|
||||
class="path-input"
|
||||
@input="testMatch"
|
||||
>
|
||||
<div class="route-path">
|
||||
<span class="route-pattern">{{ route.path }}</span>
|
||||
<span v-if="route.hasParams" class="param-badge">含参数</span>
|
||||
</div>
|
||||
<div class="hint-text">试试:user/123 或 products/electronics/456</div>
|
||||
</div>
|
||||
|
||||
<div class="result-section">
|
||||
<h5>🎯 匹配结果</h5>
|
||||
<div v-if="matchResult && matchResult.matched" class="match-success">
|
||||
<div class="success-icon">✅</div>
|
||||
<div class="result-details">
|
||||
<div class="result-row">
|
||||
<span class="label">匹配路由:</span>
|
||||
<code class="value">{{ matchResult.route.path }}</code>
|
||||
</div>
|
||||
<div v-if="Object.keys(matchResult.params).length" class="params-box">
|
||||
<span class="label">提取参数:</span>
|
||||
<div class="params-list">
|
||||
<span v-for="(value, key) in matchResult.params" :key="key" class="param-tag">
|
||||
{{ key }} = {{ value }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="route-name">{{ route.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="match-fail">
|
||||
<div class="fail-icon">❌</div>
|
||||
<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 class="routes-list">
|
||||
<h5>📋 已定义的路由</h5>
|
||||
<div class="routes-grid">
|
||||
<div
|
||||
v-for="route in routes"
|
||||
:key="route.path"
|
||||
:class="['route-item', { matched: matchedRoute?.path === route.path }]"
|
||||
>
|
||||
<code class="route-path">{{ route.path }}</code>
|
||||
<span class="route-name">{{ route.name }}</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/123,id=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 class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>匹配规则:</strong>路由按定义顺序匹配,先定义的优先。动态参数(:id)可以匹配任意值,但精确匹配优先级更高。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -128,7 +76,7 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const testPath = ref('user/123/posts')
|
||||
const testPath = ref('user/123')
|
||||
const matchResult = ref(null)
|
||||
const matchedRoute = ref(null)
|
||||
|
||||
@@ -137,14 +85,11 @@ const routes = [
|
||||
{ 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)
|
||||
}
|
||||
@@ -152,20 +97,17 @@ const parsePath = (path) => {
|
||||
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('?')
|
||||
@@ -180,13 +122,11 @@ const matchPath = (routePath, testPath) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 精确匹配
|
||||
if (routePart !== testPart) {
|
||||
return { matched: false, params: {} }
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有剩余的测试路径部分(除非是通配符路由)
|
||||
if (testParts.length > routeParts.length) {
|
||||
const lastRoutePart = routeParts[routeParts.length - 1]
|
||||
if (!lastRoutePart || (!lastRoutePart.includes('*') && !lastRoutePart.endsWith('+'))) {
|
||||
@@ -227,380 +167,218 @@ const testMatch = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 自动测试初始路径
|
||||
testMatch()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.route-matching-demo {
|
||||
padding: 20px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 12px;
|
||||
margin: 20px 0;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
.demo-header .icon { font-size: 1.25rem; }
|
||||
.demo-header .title { font-weight: bold; font-size: 1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.85rem; margin-left: 0.5rem; }
|
||||
|
||||
.demo-desc {
|
||||
margin: 0;
|
||||
.intro-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.demo-container {
|
||||
.intro-text .highlight {
|
||||
color: var(--vp-c-brand-1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.demo-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.input-section {
|
||||
.input-section, .result-section {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
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;
|
||||
h5 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.path-input-wrapper {
|
||||
.input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.path-prefix {
|
||||
padding: 10px 8px 10px 12px;
|
||||
.input-prefix {
|
||||
padding: 0.5rem;
|
||||
color: var(--vp-c-text-3);
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.path-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 10px 12px 10px 0;
|
||||
font-size: 14px;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
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;
|
||||
.hint-text {
|
||||
font-size: 0.7rem;
|
||||
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 {
|
||||
.match-success {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.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);
|
||||
.success-icon {
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-1);
|
||||
.result-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.detail-value.code {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
.result-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-1);
|
||||
font-family: monospace;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 4px 8px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.params-section {
|
||||
margin-top: 8px;
|
||||
padding-top: 12px;
|
||||
.params-box {
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.params-list {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
.param-tag {
|
||||
background: var(--vp-c-brand-soft);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
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 {
|
||||
.match-fail {
|
||||
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;
|
||||
padding: 1rem;
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.suggestions li {
|
||||
margin: 4px 0;
|
||||
.fail-icon {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tips-section {
|
||||
margin-top: 20px;
|
||||
padding: 20px;
|
||||
.routes-list {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tips-section h5 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 14px;
|
||||
.routes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.route-item {
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.route-item.matched {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: rgba(66, 184, 131, 0.1);
|
||||
}
|
||||
|
||||
.route-path {
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
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;
|
||||
.route-name {
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.info-box .icon { margin-right: 0.25rem; }
|
||||
|
||||
@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 {
|
||||
.demo-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
+191
-79
@@ -1,139 +1,251 @@
|
||||
<template>
|
||||
<div class="router-architecture-demo">
|
||||
<div class="demo-header">
|
||||
<h4>前端路由架构</h4>
|
||||
<p class="demo-desc">展示前端路由系统的核心组件和数据流向</p>
|
||||
<span class="icon">🏗️</span>
|
||||
<span class="title">路由架构</span>
|
||||
<span class="subtitle">前端路由系统的组成部分</span>
|
||||
</div>
|
||||
|
||||
<div class="architecture-diagram">
|
||||
<div class="layer browser-layer">
|
||||
<div class="layer-title">浏览器层</div>
|
||||
<div class="layer-content">
|
||||
<div class="component">URL Bar</div>
|
||||
<div class="component">History API</div>
|
||||
<div class="component">Hash Change</div>
|
||||
<div class="intro-text">
|
||||
想象<span class="highlight">公司的组织架构</span>:有前台接待(URL监听)、有调度中心(路由匹配)、有各部门(组件渲染)。前端路由也是这样分层协作的,各司其职。
|
||||
</div>
|
||||
|
||||
<div class="architecture-layers">
|
||||
<div class="layer" v-for="(layer, index) in layers" :key="layer.name">
|
||||
<div class="layer-header">
|
||||
<span class="layer-icon">{{ layer.icon }}</span>
|
||||
<span class="layer-name">{{ layer.name }}</span>
|
||||
<span class="layer-desc">{{ layer.desc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow down">↓</div>
|
||||
|
||||
<div class="layer router-core">
|
||||
<div class="layer-title">路由核心层</div>
|
||||
<div class="layer-content">
|
||||
<div class="component main">Router Instance</div>
|
||||
<div class="sub-components">
|
||||
<div class="component">Route Matcher</div>
|
||||
<div class="component">History Manager</div>
|
||||
<div class="component">Guard Pipeline</div>
|
||||
<div class="layer-components">
|
||||
<div
|
||||
v-for="comp in layer.components"
|
||||
:key="comp"
|
||||
class="component-tag"
|
||||
>
|
||||
{{ comp }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="index < layers.length - 1" class="layer-arrow">↓</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow down">↓</div>
|
||||
|
||||
<div class="layer component-layer">
|
||||
<div class="layer-title">组件层</div>
|
||||
<div class="layer-content">
|
||||
<div class="component">Router View</div>
|
||||
<div class="component">Router Link</div>
|
||||
<div class="component">Page Components</div>
|
||||
<div class="data-flow">
|
||||
<h5>📊 数据流向</h5>
|
||||
<div class="flow-steps">
|
||||
<div class="flow-step">
|
||||
<span class="step-num">1</span>
|
||||
<span>用户点击链接,触发 URL 变化</span>
|
||||
</div>
|
||||
<div class="flow-step">
|
||||
<span class="step-num">2</span>
|
||||
<span>History 监听器捕获变化</span>
|
||||
</div>
|
||||
<div class="flow-step">
|
||||
<span class="step-num">3</span>
|
||||
<span>路由匹配器找到对应配置</span>
|
||||
</div>
|
||||
<div class="flow-step">
|
||||
<span class="step-num">4</span>
|
||||
<span>执行守卫进行验证</span>
|
||||
</div>
|
||||
<div class="flow-step">
|
||||
<span class="step-num">5</span>
|
||||
<span>渲染组件到 RouterView</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>核心思想:</strong>路由系统通过监听URL变化、匹配路由配置、执行守卫验证、渲染组件这一系列流程,实现了单页应用的无刷新导航。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const layers = [
|
||||
{
|
||||
name: '浏览器层',
|
||||
icon: '🌐',
|
||||
desc: '提供 URL 和 History API',
|
||||
components: ['URL Bar', 'History API', 'Hash Change', 'PopState']
|
||||
},
|
||||
{
|
||||
name: '路由核心层',
|
||||
icon: '⚙️',
|
||||
desc: '路由系统的核心逻辑',
|
||||
components: ['Router 实例', '路由匹配器', 'History 管理', '守卫管道']
|
||||
},
|
||||
{
|
||||
name: '组件层',
|
||||
icon: '🧩',
|
||||
desc: '用户界面渲染',
|
||||
components: ['RouterView', 'RouterLink', '页面组件']
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.router-architecture-demo {
|
||||
padding: 20px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 12px;
|
||||
margin: 20px 0;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
.demo-header .icon { font-size: 1.25rem; }
|
||||
.demo-header .title { font-weight: bold; font-size: 1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.85rem; margin-left: 0.5rem; }
|
||||
|
||||
.demo-desc {
|
||||
margin: 0;
|
||||
.intro-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.architecture-diagram {
|
||||
.intro-text .highlight {
|
||||
color: var(--vp-c-brand-1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.architecture-layers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.layer {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.layer-title {
|
||||
font-size: 13px;
|
||||
.layer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.layer-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.layer-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.layer-content {
|
||||
.layer-desc {
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-3);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.layer-components {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.component {
|
||||
.component-tag {
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-1);
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.component.main {
|
||||
background: var(--vp-c-brand-soft);
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: 500;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sub-components {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
.layer-arrow {
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 16px;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.data-flow {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.data-flow h5 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.flow-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid var(--vp-c-brand);
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.step-num {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.info-box .icon { margin-right: 0.25rem; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.layer-content {
|
||||
flex-direction: column;
|
||||
.layer-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sub-components {
|
||||
flex-direction: column;
|
||||
.layer-desc {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<template>
|
||||
<div class="routing-modes-demo">
|
||||
<div class="demo-header">
|
||||
<h4>路由模式对比</h4>
|
||||
<p class="demo-desc">点击切换不同路由模式,观察URL变化和浏览器行为</p>
|
||||
<span class="icon">🔀</span>
|
||||
<span class="title">路由模式</span>
|
||||
<span class="subtitle">不同的URL管理方式</span>
|
||||
</div>
|
||||
|
||||
<div class="intro-text">
|
||||
想象你在<span class="highlight">寄快递</span>:可以选择平邮(Hash,简单但慢)、快递(History,快速但需要配合)、或者专人送达(Memory,特殊场景)。不同模式适合不同需求。
|
||||
</div>
|
||||
|
||||
<div class="mode-selector">
|
||||
@@ -17,191 +22,153 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="demo-content">
|
||||
<div class="browser-mockup">
|
||||
<div class="browser-header">
|
||||
<div class="browser-controls">
|
||||
<span class="dot red"></span>
|
||||
<span class="dot yellow"></span>
|
||||
<span class="dot green"></span>
|
||||
</div>
|
||||
<div class="address-bar">
|
||||
<span class="url-protocol">{{ getProtocol() }}</span>
|
||||
<span class="url-host">example.com</span>
|
||||
<span class="url-path">{{ currentPath }}</span>
|
||||
<span v-if="currentMode === 'hash'" class="url-hash">#/home</span>
|
||||
</div>
|
||||
<div class="mode-detail">
|
||||
<div class="mode-info">
|
||||
<h5>{{ getCurrentMode().name }}</h5>
|
||||
<p class="mode-desc">{{ getCurrentMode().description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mode-features">
|
||||
<div class="feature-section">
|
||||
<h6>✅ 优点</h6>
|
||||
<ul>
|
||||
<li v-for="pro in getCurrentMode().pros" :key="pro">{{ pro }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="browser-content">
|
||||
<nav class="nav-menu">
|
||||
<a
|
||||
v-for="item in navItems"
|
||||
:key="item.path"
|
||||
:class="['nav-link', { active: currentPath === item.path }]"
|
||||
@click="navigate(item.path)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</a>
|
||||
</nav>
|
||||
<div class="page-content">
|
||||
<div class="page-header">
|
||||
<h2>{{ getPageTitle() }}</h2>
|
||||
</div>
|
||||
<div class="page-body">
|
||||
<p>{{ getPageContent() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="feature-section">
|
||||
<h6>❌ 缺点</h6>
|
||||
<ul>
|
||||
<li v-for="con in getCurrentMode().cons" :key="con">{{ con }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mode-info">
|
||||
<div class="info-card">
|
||||
<h5>{{ getCurrentMode().name }}</h5>
|
||||
<p class="info-desc">{{ getCurrentMode().description }}</p>
|
||||
<div class="pros-cons">
|
||||
<div class="pros">
|
||||
<h6>优点</h6>
|
||||
<ul>
|
||||
<li v-for="pro in getCurrentMode().pros" :key="pro">{{ pro }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="cons">
|
||||
<h6>缺点</h6>
|
||||
<ul>
|
||||
<li v-for="con in getCurrentMode().cons" :key="con">{{ con }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="url-example">
|
||||
<h6>🌐 URL 示例</h6>
|
||||
<div class="url-bar">
|
||||
<span class="url-prefix">https://example.com</span>
|
||||
<span class="url-suffix">{{ getUrlSuffix() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>选择建议:</strong>现代Web应用优先选History模式,老项目或特殊场景用Hash,移动端App或测试环境可用Memory模式。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const currentMode = ref('history')
|
||||
const currentPath = ref('/home')
|
||||
|
||||
const modes = [
|
||||
{
|
||||
key: 'hash',
|
||||
name: 'Hash 模式',
|
||||
icon: '#',
|
||||
description: '使用URL的hash部分(#)来模拟完整URL,当hash改变时不会触发页面刷新。',
|
||||
pros: ['兼容性好,支持IE8+', '无需服务端配置', '部署简单'],
|
||||
cons: ['URL带有#号,不够美观', '部分浏览器分享链接可能丢失hash', 'SEO不友好']
|
||||
description: '使用URL的hash部分(#)来模拟路由,兼容性最好',
|
||||
pros: ['兼容IE8+', '无需服务端配置', '部署简单'],
|
||||
cons: ['URL带有#号', 'SEO不友好', '分享可能丢失hash']
|
||||
},
|
||||
{
|
||||
key: 'history',
|
||||
name: 'History 模式',
|
||||
icon: '/',
|
||||
description: '使用HTML5 History API(pushState/replaceState)来实现无刷新导航。',
|
||||
pros: ['URL美观,没有#号', '更符合用户习惯', 'SEO友好'],
|
||||
cons: ['需要服务端支持', '兼容性要求IE10+', '配置相对复杂']
|
||||
description: '使用HTML5 History API实现URL管理,最常用的模式',
|
||||
pros: ['URL美观', 'SEO友好', '符合用户习惯'],
|
||||
cons: ['需要服务端配置', '兼容性IE10+', '刷新返回404']
|
||||
},
|
||||
{
|
||||
key: 'memory',
|
||||
name: 'Memory 模式',
|
||||
icon: 'M',
|
||||
description: '将路由信息保存在内存中,不修改浏览器URL,适用于非浏览器环境。',
|
||||
pros: ['无需浏览器环境', '适用于测试环境', '移动端App内嵌'],
|
||||
cons: ['不支持浏览器刷新', 'URL不会同步', '仅适用于特定场景']
|
||||
description: '将路由信息保存在内存中,不修改浏览器URL',
|
||||
pros: ['无需浏览器环境', '适用于测试', '移动端App内嵌'],
|
||||
cons: ['不支持刷新', 'URL不变化', '仅限特定场景']
|
||||
}
|
||||
]
|
||||
|
||||
const navItems = [
|
||||
{ name: '首页', path: '/home' },
|
||||
{ name: '产品', path: '/products' },
|
||||
{ name: '关于', path: '/about' },
|
||||
{ name: '用户', path: '/user/profile' }
|
||||
]
|
||||
|
||||
const switchMode = (mode) => {
|
||||
currentMode.value = mode
|
||||
// 重置路径
|
||||
currentPath.value = '/home'
|
||||
}
|
||||
|
||||
const navigate = (path) => {
|
||||
currentPath.value = path
|
||||
// 模拟路由切换效果
|
||||
if (currentMode.value === 'history') {
|
||||
// History模式下使用pushState(这里仅模拟)
|
||||
console.log(`pushState: ${path}`)
|
||||
} else if (currentMode.value === 'hash') {
|
||||
// Hash模式下修改hash
|
||||
console.log(`hashchange: #${path}`)
|
||||
}
|
||||
}
|
||||
|
||||
const getProtocol = () => {
|
||||
return 'https://'
|
||||
}
|
||||
|
||||
const getCurrentMode = () => {
|
||||
return modes.find(m => m.key === currentMode.value) || modes[0]
|
||||
}
|
||||
|
||||
const getPageTitle = () => {
|
||||
const item = navItems.find(i => i.path === currentPath.value)
|
||||
return item ? `${item.name}页面` : '首页'
|
||||
}
|
||||
|
||||
const getPageContent = () => {
|
||||
const contents = {
|
||||
'/home': '欢迎来到首页!这是SPA应用的入口页面,所有导航都在前端完成,无需刷新整个页面。',
|
||||
'/products': '产品列表页面展示了我们的核心产品。你可以在这里浏览、筛选和查看详情。',
|
||||
'/about': '关于我们页面介绍了公司的历史、愿景和团队信息。',
|
||||
'/user/profile': '个人中心页面,显示用户的基本信息、订单历史和设置选项。'
|
||||
const getUrlSuffix = () => {
|
||||
const path = '/home'
|
||||
switch (currentMode.value) {
|
||||
case 'hash':
|
||||
return `/#${path}`
|
||||
case 'history':
|
||||
return path
|
||||
case 'memory':
|
||||
return ' (URL不变)'
|
||||
default:
|
||||
return path
|
||||
}
|
||||
return contents[currentPath.value] || contents['/home']
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.routing-modes-demo {
|
||||
padding: 20px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 12px;
|
||||
margin: 20px 0;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
.demo-header .icon { font-size: 1.25rem; }
|
||||
.demo-header .title { font-weight: bold; font-size: 1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.85rem; margin-left: 0.5rem; }
|
||||
|
||||
.demo-desc {
|
||||
margin: 0;
|
||||
.intro-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.intro-text .highlight {
|
||||
color: var(--vp-c-brand-1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mode-selector {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-bottom: 24px;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.mode-btn:hover {
|
||||
@@ -215,212 +182,102 @@ const getPageContent = () => {
|
||||
|
||||
.mode-icon {
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.mode-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.mode-detail {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mode-info h5 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.mode-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mode-features {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.feature-section h6 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.feature-section ul {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.feature-section li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.url-example {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.url-example h6 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.url-bar {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.url-prefix {
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.url-suffix {
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.demo-content {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.browser-mockup {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.browser-header {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.browser-controls {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.dot.red { background: #ff5f56; }
|
||||
.dot.yellow { background: #ffbd2e; }
|
||||
.dot.green { background: #27c93f; }
|
||||
|
||||
.address-bar {
|
||||
flex: 1;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 6px 12px;
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-family: monospace;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.url-protocol { color: var(--vp-c-text-3); }
|
||||
.url-host { color: var(--vp-c-text-2); }
|
||||
.url-path { color: var(--vp-c-brand); font-weight: 500; }
|
||||
.url-hash { color: #e06c75; font-weight: 500; }
|
||||
|
||||
.browser-content {
|
||||
display: flex;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
width: 120px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 16px 0;
|
||||
border-right: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: block;
|
||||
padding: 10px 16px;
|
||||
color: var(--vp-c-text-2);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: var(--vp-c-brand-soft);
|
||||
color: var(--vp-c-brand);
|
||||
border-right: 2px solid var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0 0 12px 0;
|
||||
color: var(--vp-c-text-1);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.page-body {
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.mode-info {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.info-card h5 {
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--vp-c-text-1);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.info-desc {
|
||||
margin: 0 0 16px 0;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pros-cons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pros, .cons {
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.pros {
|
||||
background: rgba(39, 201, 63, 0.1);
|
||||
}
|
||||
|
||||
.cons {
|
||||
background: rgba(255, 95, 86, 0.1);
|
||||
}
|
||||
|
||||
.pros h6, .cons h6 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pros h6 {
|
||||
color: #27c93f;
|
||||
}
|
||||
|
||||
.cons h6 {
|
||||
color: #ff5f56;
|
||||
}
|
||||
|
||||
.pros ul, .cons ul {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
font-size: 12px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.pros li, .cons li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
.info-box .icon { margin-right: 0.25rem; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mode-selector {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.browser-content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
padding: 8px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: 8px 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pros-cons {
|
||||
.mode-features {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
<template>
|
||||
<div class="spa-navigation-demo">
|
||||
<div class="demo-header">
|
||||
<h4>SPA 导航流程</h4>
|
||||
<p class="demo-desc">完整展示从点击链接到页面更新的全流程</p>
|
||||
<span class="icon">🚀</span>
|
||||
<span class="title">SPA导航流程</span>
|
||||
<span class="subtitle">从点击到渲染的完整旅程</span>
|
||||
</div>
|
||||
|
||||
<div class="intro-text">
|
||||
想象你在<span class="highlight">餐厅点菜</span>:从看菜单、下单、厨房准备、最后上菜。SPA导航也是这样,用户触发后经过一系列步骤,最终把新"菜品"(页面)端到你面前。
|
||||
</div>
|
||||
|
||||
<div class="flow-container">
|
||||
<div class="step" v-for="(step, index) in steps" :key="index">
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
class="flow-step"
|
||||
>
|
||||
<div class="step-number">{{ index + 1 }}</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">{{ step.title }}</div>
|
||||
@@ -14,59 +23,111 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="highlight-box">
|
||||
<h5>⚡ 关键优化点</h5>
|
||||
<div class="optimization-tips">
|
||||
<div class="tip-item">
|
||||
<span class="tip-icon">🎯</span>
|
||||
<div class="tip-content">
|
||||
<div class="tip-title">路由懒加载</div>
|
||||
<div class="tip-desc">按需加载页面组件,减少初始包体积</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tip-item">
|
||||
<span class="tip-icon">🛡️</span>
|
||||
<div class="tip-content">
|
||||
<div class="tip-title">守卫预加载</div>
|
||||
<div class="tip-desc">在beforeEnter中预加载数据,提升用户体验</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tip-item">
|
||||
<span class="tip-icon">⚡</span>
|
||||
<div class="tip-content">
|
||||
<div class="tip-title">过渡动画</div>
|
||||
<div class="tip-desc">添加页面切换动画,让导航更流畅</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<span class="icon">💡</span>
|
||||
<strong>核心优势:</strong>整个流程在浏览器内完成,无需服务器参与,体验如原生应用般流畅。这就是SPA相比传统MPA的最大优势。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const steps = [
|
||||
{ title: '触发导航', desc: '用户点击链接或调用 router.push()' },
|
||||
{ title: '全局前置守卫', desc: '执行 router.beforeEach 钩子' },
|
||||
{ title: '解析路由', desc: '匹配路由配置,解析动态参数' },
|
||||
{ title: '组件内守卫', desc: '执行 beforeRouteEnter 钩子' },
|
||||
{ title: '全局解析守卫', desc: '执行 beforeResolve 钩子' },
|
||||
{ title: '更新视图', desc: '渲染匹配的组件,更新 DOM' },
|
||||
{ title: '全局后置钩子', desc: '执行 afterEach 钩子' }
|
||||
{ title: 'URL 变化', desc: '浏览器地址栏更新,History API 记录状态' },
|
||||
{ title: '路由匹配', desc: '路由器根据URL匹配对应的路由配置' },
|
||||
{ title: '守卫验证', desc: '执行全局、路由独享、组件内守卫' },
|
||||
{ title: '组件加载', desc: '懒加载的组件异步加载并解析' },
|
||||
{ title: '组件渲染', desc: '新组件挂载到 DOM,页面更新' },
|
||||
{ title: '后置钩子', desc: '执行 afterEach 钩子,完成导航' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.spa-navigation-demo {
|
||||
padding: 20px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 12px;
|
||||
margin: 20px 0;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
.demo-header .icon { font-size: 1.25rem; }
|
||||
.demo-header .title { font-weight: bold; font-size: 1rem; }
|
||||
.demo-header .subtitle { color: var(--vp-c-text-2); font-size: 0.85rem; margin-left: 0.5rem; }
|
||||
|
||||
.demo-desc {
|
||||
margin: 0;
|
||||
.intro-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.intro-text .highlight {
|
||||
color: var(--vp-c-brand-1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.flow-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.step {
|
||||
.flow-step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--vp-c-brand);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.flow-step:hover {
|
||||
background: var(--vp-c-bg-soft);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.step-number {
|
||||
@@ -78,7 +139,7 @@ const steps = [
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 13px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -88,21 +149,87 @@ const steps = [
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-size: 14px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 2px;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.highlight-box {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.highlight-box h5 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.optimization-tips {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tip-item {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.tip-icon {
|
||||
font-size: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tip-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tip-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.tip-desc {
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.info-box .icon { margin-right: 0.25rem; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.step {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
.flow-step {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user