feat: comprehensive documentation and demo updates
- Update READMEs and docs across multiple languages - Enhance interactive demos for Agent, LLM, VLM, Audio, Image Gen, Terminal, and Web Basics - Add new appendix sections for Database and IDE intros - Update VitePress config, theme, and utility scripts - Clean up unused assets and components
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
<!--
|
||||
CdnCacheDemo.vue
|
||||
CDN 加速原理:快递柜隐喻
|
||||
-->
|
||||
<template>
|
||||
<div class="cdn">
|
||||
<div class="header">
|
||||
<div class="title">CDN 加速演示</div>
|
||||
<div class="subtitle">就像在小区楼下装了个“丰巢快递柜”</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="control">
|
||||
<label>你要取什么东西?(资源类型)</label>
|
||||
<div class="chips">
|
||||
<button
|
||||
v-for="r in resourceTypes"
|
||||
:key="r.id"
|
||||
:class="['chip', { active: r.id === resourceType }]"
|
||||
@click="resourceType = r.id"
|
||||
>
|
||||
{{ r.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label>快递柜里有吗?(命中率)</label>
|
||||
<input type="range" min="0" max="100" v-model.number="hit" />
|
||||
<div class="hint">当前概率:{{ hit }}% ({{ hit > 80 ? '大部分都有' : '经常要跑远路' }})</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="card">
|
||||
<div class="label">跑总仓库的次数 (回源)</div>
|
||||
<div class="value">{{ miss }}%</div>
|
||||
<div class="note">次数越少,总仓库越轻松</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">省下的路费 (带宽节省)</div>
|
||||
<div class="value">{{ saved }}%</div>
|
||||
<div class="note">省到就是赚到</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">老司机的建议</div>
|
||||
<div class="value">{{ cacheAdvice }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow">
|
||||
<div class="step" v-for="(s, idx) in flow" :key="idx">
|
||||
<div class="head">
|
||||
<span class="dot" :style="{ background: s.color }"></span>
|
||||
<span class="name">{{ s.name }}</span>
|
||||
<span class="time">{{ s.time }}</span>
|
||||
</div>
|
||||
<div class="desc">{{ s.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const resourceTypes = [
|
||||
{ id: 'static', label: '标准件 (图片/CSS/JS)' },
|
||||
{ id: 'html', label: '信件 (HTML)' },
|
||||
{ id: 'api', label: '生鲜 (API数据)' }
|
||||
]
|
||||
|
||||
const resourceType = ref('static')
|
||||
const hit = ref(85)
|
||||
|
||||
const miss = computed(() => 100 - hit.value)
|
||||
const saved = computed(() => hit.value)
|
||||
|
||||
const cacheAdvice = computed(() => {
|
||||
if (resourceType.value === 'static')
|
||||
return '标准件保质期长,建议放柜子里一年 (max-age=1年)'
|
||||
if (resourceType.value === 'html')
|
||||
return '信件可能随时更新,每次取之前问一下 (no-cache)'
|
||||
return '生鲜容易坏,不要放柜子,直接去产地拿 (no-store)'
|
||||
})
|
||||
|
||||
const flow = computed(() => {
|
||||
const base = [
|
||||
{
|
||||
name: '用户 🙋♂️',
|
||||
time: '0ms',
|
||||
desc: '我想取个包裹',
|
||||
color: '#6366f1'
|
||||
},
|
||||
{
|
||||
name: '家门口快递柜 📦',
|
||||
time: '15ms',
|
||||
desc: '看看柜子里有没有...',
|
||||
color: '#6366f1'
|
||||
}
|
||||
]
|
||||
if (hit.value >= 70 && resourceType.value === 'static') {
|
||||
base.push({
|
||||
name: '有货!✅',
|
||||
time: '+5ms',
|
||||
desc: '直接拿走,不用跑远路',
|
||||
color: '#22c55e'
|
||||
})
|
||||
} else {
|
||||
base.push({
|
||||
name: '没货... ❌',
|
||||
time: '+10ms',
|
||||
desc: '柜子是空的,得去总仓库',
|
||||
color: '#f59e0b'
|
||||
})
|
||||
base.push({
|
||||
name: '总仓库 (源站) 🏭',
|
||||
time: resourceType.value === 'api' ? '+60ms' : '+40ms',
|
||||
desc: '翻山越岭把货取回来',
|
||||
color: '#e11d48'
|
||||
})
|
||||
if (resourceType.value !== 'api') {
|
||||
base.push({
|
||||
name: '顺手存柜子',
|
||||
time: '+8ms',
|
||||
desc: '下次邻居来拿就不用跑了',
|
||||
color: '#22c55e'
|
||||
})
|
||||
}
|
||||
}
|
||||
base.push({
|
||||
name: '拿到手 🎁',
|
||||
time: 'Total',
|
||||
desc: '交易完成',
|
||||
color: '#0ea5e9'
|
||||
})
|
||||
return base
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cdn {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header .title {
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
}
|
||||
.header .subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.chip.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: white;
|
||||
background: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px dashed var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: 800;
|
||||
margin-top: 4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.note {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.flow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.step {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.time {
|
||||
margin-left: auto;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.desc {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,292 @@
|
||||
<!--
|
||||
CicdPipelineDemo.vue
|
||||
CI/CD 流水线:自动炒菜机隐喻
|
||||
-->
|
||||
<template>
|
||||
<div class="cicd">
|
||||
<div class="header">
|
||||
<div>
|
||||
<div class="title">自动化流水线 (CI/CD)</div>
|
||||
<div class="subtitle">就像一台“全自动炒菜机”</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<label class="fail-toggle"><input type="checkbox" v-model="failTest" /> 混入一颗烂菜 (模拟报错)</label>
|
||||
<button :disabled="running" @click="run" class="run-btn">
|
||||
{{ running ? '机器运转中...' : '开始做菜 (触发构建)' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="steps">
|
||||
<div class="step" v-for="step in steps" :key="step.id">
|
||||
<div class="step-head">
|
||||
<span class="badge" :class="step.status">{{
|
||||
statusIcon(step.status)
|
||||
}}</span>
|
||||
<span class="name">{{ step.name }}</span>
|
||||
</div>
|
||||
<div class="analogy">{{ step.analogy }}</div>
|
||||
<div class="desc">{{ step.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="log" v-if="log">
|
||||
<div class="log-title">🖥️ 机器日志</div>
|
||||
<pre><code>{{ log }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const steps = ref([
|
||||
{
|
||||
id: 'install',
|
||||
name: '安装依赖 (Install)',
|
||||
analogy: '🥬 准备食材',
|
||||
desc: 'npm install',
|
||||
status: 'idle'
|
||||
},
|
||||
{
|
||||
id: 'test',
|
||||
name: '自动测试 (Test)',
|
||||
analogy: '🔍 食品安检',
|
||||
desc: 'npm test',
|
||||
status: 'idle'
|
||||
},
|
||||
{
|
||||
id: 'build',
|
||||
name: '打包构建 (Build)',
|
||||
analogy: '🍳 下锅烹饪',
|
||||
desc: 'npm run build',
|
||||
status: 'idle'
|
||||
},
|
||||
{
|
||||
id: 'deploy',
|
||||
name: '自动部署 (Deploy)',
|
||||
analogy: '🍽️ 端上桌',
|
||||
desc: 'pm2 restart',
|
||||
status: 'idle'
|
||||
}
|
||||
])
|
||||
|
||||
const running = ref(false)
|
||||
const failTest = ref(false)
|
||||
const log = ref('')
|
||||
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
const statusIcon = (status) => {
|
||||
if (status === 'done') return '✔'
|
||||
if (status === 'running') return '⏳'
|
||||
if (status === 'fail') return '✖'
|
||||
return '•'
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
steps.value = steps.value.map((s) => ({ ...s, status: 'idle' }))
|
||||
log.value = ''
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
if (running.value) return
|
||||
running.value = true
|
||||
reset()
|
||||
|
||||
const timeline = [
|
||||
{
|
||||
id: 'install',
|
||||
ms: 1000,
|
||||
log: '> 正在去菜市场买菜...\n> 成功买到 842 个包裹 (node_modules)'
|
||||
},
|
||||
{
|
||||
id: 'test',
|
||||
ms: 800,
|
||||
log: '> 正在检查食材新鲜度...\n> 单元测试运行中...'
|
||||
},
|
||||
{
|
||||
id: 'build',
|
||||
ms: 1200,
|
||||
log: '> 开始烹饪...\n> 正在压缩混淆代码...\n> 产出 dist/ 目录 (一盘好菜)'
|
||||
},
|
||||
{
|
||||
id: 'deploy',
|
||||
ms: 1000,
|
||||
log: '> 正在把菜端给顾客...\n> 重启服务器...\n> 上线成功!'
|
||||
}
|
||||
]
|
||||
|
||||
for (const item of timeline) {
|
||||
const step = steps.value.find((s) => s.id === item.id)
|
||||
step.status = 'running'
|
||||
log.value = item.log
|
||||
await wait(item.ms)
|
||||
|
||||
if (item.id === 'test' && failTest.value) {
|
||||
step.status = 'fail'
|
||||
log.value = '❌ 警告:发现一颗烂白菜!(测试失败)\n❌ 立即停机,防止端给顾客。'
|
||||
steps.value
|
||||
.filter((s) => s.id !== 'test')
|
||||
.forEach((s) => (s.status = 'idle'))
|
||||
running.value = false
|
||||
return
|
||||
}
|
||||
|
||||
step.status = 'done'
|
||||
}
|
||||
|
||||
log.value = '✅ 流程结束:大家吃得很开心 (服务正常运行)'
|
||||
running.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cicd {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
}
|
||||
.subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.fail-toggle {
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.run-btn {
|
||||
background: var(--vp-c-brand);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.run-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.step {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.step-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.badge.running {
|
||||
border-color: #f59e0b;
|
||||
color: #f59e0b;
|
||||
}
|
||||
.badge.done {
|
||||
border-color: #22c55e;
|
||||
color: #22c55e;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
}
|
||||
.badge.fail {
|
||||
border-color: #ef4444;
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.analogy {
|
||||
color: var(--vp-c-brand);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-left: 32px;
|
||||
}
|
||||
|
||||
.desc {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 12px;
|
||||
margin-left: 32px;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.log {
|
||||
background: #1e1e20;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
color: #eee;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.log-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,308 @@
|
||||
<!--
|
||||
DeploymentArchitecture.vue
|
||||
全景图:快递配送隐喻
|
||||
-->
|
||||
<template>
|
||||
<div class="arch">
|
||||
<div class="header">
|
||||
<div>
|
||||
<div class="title">全景演示:一个请求的“奇幻漂流”</div>
|
||||
<div class="subtitle">
|
||||
点击下方按钮,看看三种模式的“配送路线”有什么不同
|
||||
</div>
|
||||
</div>
|
||||
<div class="modes">
|
||||
<button
|
||||
v-for="mode in modes"
|
||||
:key="mode.id"
|
||||
:class="['mode', { active: mode.id === currentMode }]"
|
||||
@click="currentMode = mode.id"
|
||||
>
|
||||
<span class="icon">{{ mode.icon }}</span>
|
||||
{{ mode.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flow">
|
||||
<div
|
||||
v-for="(node, idx) in nodes"
|
||||
:key="node.name"
|
||||
class="node"
|
||||
:style="{ borderColor: node.color }"
|
||||
>
|
||||
<div class="node-head">
|
||||
<div class="dot" :style="{ background: node.color }">
|
||||
{{ node.icon }}
|
||||
</div>
|
||||
<div class="name-box">
|
||||
<div class="role">{{ node.role }}</div>
|
||||
<div class="tech-name">{{ node.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="desc">{{ node.desc }}</div>
|
||||
<div v-if="idx < nodes.length - 1" class="arrow">→</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="metric">
|
||||
<div class="label">当前场景</div>
|
||||
<div class="value">{{ currentModeLabel }}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="label">瓶颈环节</div>
|
||||
<div class="value">{{ bottleneck }}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="label">通俗解释</div>
|
||||
<div class="value">{{ advice }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const modes = [
|
||||
{ id: 'static', label: '看海报 (静态)', icon: '🖼️' },
|
||||
{ id: 'spa', label: '玩 App (SPA)', icon: '📱' },
|
||||
{ id: 'ssr', label: '刷动态 (SSR)', icon: '🔄' },
|
||||
]
|
||||
|
||||
const currentMode = ref('spa')
|
||||
|
||||
const currentModeLabel = computed(() =>
|
||||
modes.find(m => m.id === currentMode.value)?.label
|
||||
)
|
||||
|
||||
// 角色:User(寄件人), DNS(查号台), CDN(快递柜), WAF(保安), LB(大堂经理), Server(办事员), DB(档案室)
|
||||
const commonNodes = {
|
||||
user: { role: '寄件人', name: 'User', icon: '🧑', color: '#64748b', desc: '发出请求' },
|
||||
dns: { role: '查号台', name: 'DNS', icon: '📒', color: '#0ea5e9', desc: '查询 IP 地址' },
|
||||
cdn: { role: '快递柜', name: 'CDN', icon: '📦', color: '#22c55e', desc: '就近取货' },
|
||||
waf: { role: '保安', name: 'WAF', icon: '🛡️', color: '#ef4444', desc: '拦截黑客' },
|
||||
lb: { role: '大堂经理', name: 'LB', icon: '💁', color: '#f59e0b', desc: '分配窗口' },
|
||||
server: { role: '办事员', name: 'Server', icon: '👨💼', color: '#8b5cf6', desc: '处理业务' },
|
||||
db: { role: '档案室', name: 'Database', icon: '🗄️', color: '#d946ef', desc: '存取数据' },
|
||||
obj: { role: '仓库', name: 'OSS', icon: '🏭', color: '#f97316', desc: '拿静态文件' }
|
||||
}
|
||||
|
||||
const flowMap = {
|
||||
static: [
|
||||
{ ...commonNodes.user, desc: '想看一张图片' },
|
||||
{ ...commonNodes.dns, desc: '找到图片仓库地址' },
|
||||
{ ...commonNodes.cdn, desc: '家门口就有?直接拿走!' },
|
||||
{ ...commonNodes.obj, desc: '没有?去总仓库拿' }
|
||||
],
|
||||
spa: [
|
||||
{ ...commonNodes.user, desc: '打开网页 App' },
|
||||
{ ...commonNodes.dns, desc: '找到服务器地址' },
|
||||
{ ...commonNodes.cdn, desc: '先拿网页外壳 (HTML/JS)' },
|
||||
{ ...commonNodes.server, desc: '再拿动态数据 (API)' },
|
||||
{ ...commonNodes.db, desc: '查用户数据' }
|
||||
],
|
||||
ssr: [
|
||||
{ ...commonNodes.user, desc: '打开复杂网页' },
|
||||
{ ...commonNodes.dns, desc: '找到服务器地址' },
|
||||
{ ...commonNodes.lb, desc: '人多排队,你以此去 2 号窗口' },
|
||||
{ ...commonNodes.server, desc: '现场拼装好整个页面' },
|
||||
{ ...commonNodes.db, desc: '查所有需要的数据' }
|
||||
]
|
||||
}
|
||||
|
||||
const nodes = computed(() => flowMap[currentMode.value])
|
||||
|
||||
const bottleneck = computed(() => {
|
||||
switch (currentMode.value) {
|
||||
case 'static': return '几乎没有瓶颈,起飞!'
|
||||
case 'spa': return 'API 接口响应速度'
|
||||
case 'ssr': return '办事员 (Server) 拼装页面的速度'
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
|
||||
const advice = computed(() => {
|
||||
switch (currentMode.value) {
|
||||
case 'static':
|
||||
return '这是最简单的模式。就像去看公告栏的海报(或者发传单),内容印死在上面了,所有人看到的都一样。速度最快!'
|
||||
case 'spa':
|
||||
return '就像送你一套乐高积木。先给你个空盒子和图纸(网页壳子),你的浏览器自己在本地把页面拼出来。拼好后怎么玩都快。'
|
||||
case 'ssr':
|
||||
return '就像点了一份热披萨。厨师(服务器)必须现场烤好,再热乎乎地送给你。虽然慢点,但保证新鲜、不仅能吃(能看)还能闻到香味(SEO友好)。'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.arch {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.header {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.modes {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.mode {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mode:hover {
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.mode.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.flow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.node {
|
||||
position: relative;
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.node:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.node-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.name-box {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.role {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.tech-name {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.desc {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
position: absolute;
|
||||
right: -14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--vp-c-divider);
|
||||
font-size: 18px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.arrow {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,216 @@
|
||||
<!--
|
||||
DnsFlowDemo.vue
|
||||
DNS 记录操练台:地址簿隐喻
|
||||
-->
|
||||
<template>
|
||||
<div class="dns">
|
||||
<div class="header">
|
||||
<div class="title">DNS 查号台</div>
|
||||
<div class="subtitle">把“好记的名字”变成“机器的 IP”</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="field">
|
||||
<label>你要配哪个域名?</label>
|
||||
<input v-model="domain" placeholder="例如:baidu.com" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>你要做什么?(记录类型)</label>
|
||||
<div class="chips">
|
||||
<button
|
||||
v-for="r in recordTypes"
|
||||
:key="r.type"
|
||||
:class="['chip', { active: recordType === r.type }]"
|
||||
@click="recordType = r.type"
|
||||
>
|
||||
{{ r.desc }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<span>类型 (Type)</span>
|
||||
<code class="highlight">{{ recordType }}</code>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span>前缀 (Host)</span>
|
||||
<code>{{ hostLabel }}</code>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span>目标 (Value)</span>
|
||||
<code>{{ recordValue }}</code>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span>记忆时间 (TTL)</span>
|
||||
<code>{{ ttlSuggestion }}</code>
|
||||
</div>
|
||||
|
||||
<div class="human-speak">
|
||||
<span class="emoji">💡</span>
|
||||
<div class="text">
|
||||
<strong>人话解释:</strong>
|
||||
{{ humanExplanation }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const domain = ref('my-site.com')
|
||||
const recordType = ref('A')
|
||||
|
||||
const recordTypes = [
|
||||
{
|
||||
type: 'A',
|
||||
desc: '直接指向 IP',
|
||||
value: '1.2.3.4',
|
||||
explanation: '告诉查号台:我家住在“1.2.3.4”这个门牌号。最常用!',
|
||||
ttl: '600s (10分钟)'
|
||||
},
|
||||
{
|
||||
type: 'CNAME',
|
||||
desc: '指向别名',
|
||||
value: 'shops.myshopify.com',
|
||||
explanation: '告诉查号台:我搬家了,你去问问“shops.myshopify.com”我在哪。',
|
||||
ttl: '600s (10分钟)'
|
||||
},
|
||||
{
|
||||
type: 'MX',
|
||||
desc: '配置邮箱',
|
||||
value: 'mxbiz1.qq.com',
|
||||
explanation: '告诉邮递员:寄给我的信,请送到“mxbiz1.qq.com”这个邮局去。',
|
||||
ttl: '3600s (1小时)'
|
||||
}
|
||||
]
|
||||
|
||||
const currentRecord = computed(() => recordTypes.find(r => r.type === recordType.value))
|
||||
|
||||
const hostLabel = computed(() => (recordType.value === 'CNAME' ? 'www' : '@'))
|
||||
const recordValue = computed(() => currentRecord.value?.value || '')
|
||||
const ttlSuggestion = computed(() => currentRecord.value?.ttl || '600s')
|
||||
const humanExplanation = computed(() => currentRecord.value?.explanation || '')
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dns {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header .title {
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
}
|
||||
.header .subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
input {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
background: var(--vp-c-bg);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chip {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 999px;
|
||||
padding: 6px 14px;
|
||||
background: var(--vp-c-bg);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.chip.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.row span {
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.human-speak {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed var(--vp-c-divider);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,208 @@
|
||||
<!--
|
||||
HttpsNginxDemo.vue
|
||||
Nginx 配置生成器:极简版
|
||||
-->
|
||||
<template>
|
||||
<div class="https">
|
||||
<div class="header">
|
||||
<div class="title">Nginx 配置生成器</div>
|
||||
<div class="subtitle">复制粘贴就能用,自动配置 HTTPS</div>
|
||||
</div>
|
||||
|
||||
<div class="options">
|
||||
<div
|
||||
class="option"
|
||||
:class="{ active: mode === 'static' }"
|
||||
@click="mode = 'static'"
|
||||
>
|
||||
<span class="icon">📄</span>
|
||||
<span>静态网站</span>
|
||||
</div>
|
||||
<div
|
||||
class="option"
|
||||
:class="{ active: mode === 'proxy' }"
|
||||
@click="mode = 'proxy'"
|
||||
>
|
||||
<span class="icon">🔄</span>
|
||||
<span>反向代理 (Node/Python)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-box">
|
||||
<div class="code-header">
|
||||
<span>/etc/nginx/sites-available/default</span>
|
||||
<button class="copy-btn" @click="copy">{{ copied ? '已复制' : '复制' }}</button>
|
||||
</div>
|
||||
<pre><code>{{ snippet }}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="tips">
|
||||
<div class="tip-item">
|
||||
<span class="emoji">🔑</span>
|
||||
<div>
|
||||
<strong>开启 HTTPS 神器:</strong>
|
||||
<div class="cmd">sudo certbot --nginx</div>
|
||||
<div class="desc">运行这行命令,它会自动修改上面的配置,帮你加上 SSL 证书。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const mode = ref('proxy')
|
||||
const copied = ref(false)
|
||||
|
||||
const snippet = computed(() => {
|
||||
if (mode.value === 'static') {
|
||||
return `server {
|
||||
listen 80;
|
||||
server_name example.com; # 改成你的域名
|
||||
|
||||
# 静态文件在哪里?
|
||||
root /var/www/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}`
|
||||
} else {
|
||||
return `server {
|
||||
listen 80;
|
||||
server_name example.com; # 改成你的域名
|
||||
|
||||
location / {
|
||||
# 把请求转发给 3000 端口的程序
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
|
||||
# 告诉后端真实的客户 IP 是多少
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}`
|
||||
}
|
||||
})
|
||||
|
||||
function copy() {
|
||||
navigator.clipboard.writeText(snippet.value)
|
||||
copied.value = true
|
||||
setTimeout(() => copied.value = false, 2000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.https {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.header .title {
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
}
|
||||
.header .subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.option {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.option.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.code-box {
|
||||
background: #1e1e20;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
color: #fff;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.code-header {
|
||||
background: #2d2d30;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
color: #fff;
|
||||
background: rgba(255,255,255,0.1);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 16px;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tips {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tip-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.cmd {
|
||||
background: #1e1e20;
|
||||
color: #22c55e;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
margin: 4px 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,259 @@
|
||||
<!--
|
||||
ObservabilityBackupDemo.vue
|
||||
监控与备份:买保险隐喻
|
||||
-->
|
||||
<template>
|
||||
<div class="obs">
|
||||
<div class="header">
|
||||
<div class="title">监控与备份</div>
|
||||
<div class="subtitle">给你的网站买份“保险”</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="control">
|
||||
<label>安保级别 (监控)</label>
|
||||
<select v-model="monitorLevel">
|
||||
<option value="lite">入门:只装个摄像头 (日志)</option>
|
||||
<option value="std">标准:雇个保安 (指标+告警)</option>
|
||||
<option value="pro">专业:24h 安保中心 (全链路追踪)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label>通知谁?(告警渠道)</label>
|
||||
<div class="chips">
|
||||
<button
|
||||
v-for="c in channels"
|
||||
:key="c.id"
|
||||
:class="['chip', { active: channel === c.id }]"
|
||||
@click="channel = c.id"
|
||||
>
|
||||
{{ c.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label>备份频率 (小时)</label>
|
||||
<input
|
||||
type="range"
|
||||
min="6"
|
||||
max="48"
|
||||
step="6"
|
||||
v-model.number="backupHours"
|
||||
/>
|
||||
<div class="hint">每 {{ backupHours }} 小时存一次盘</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<div class="label">安全评分</div>
|
||||
<div
|
||||
class="value big"
|
||||
:class="{
|
||||
green: riskScore <= 40,
|
||||
orange: riskScore > 40 && riskScore <= 70,
|
||||
red: riskScore > 70
|
||||
}"
|
||||
>
|
||||
{{ 100 - riskScore }} 分
|
||||
</div>
|
||||
<div class="note">{{ riskScore > 70 ? '极其危险!' : (riskScore > 40 ? '勉强及格' : '非常稳!') }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">最坏情况 (丢数据)</div>
|
||||
<div class="value">{{ rpo }}</div>
|
||||
<div class="note">最多丢失多少小时的数据</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">恢复速度</div>
|
||||
<div class="value">{{ rto }}</div>
|
||||
<div class="note">出事后多久能修好</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checklist-box">
|
||||
<div class="box-title">保命清单 (Checklist)</div>
|
||||
<div class="checks">
|
||||
<div class="check" v-for="item in checklist" :key="item.label">
|
||||
<input type="checkbox" v-model="item.done" />
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
const monitorLevel = ref('std')
|
||||
const channels = [
|
||||
{ id: 'email', label: '发邮件 (慢)' },
|
||||
{ id: 'chat', label: '企业微信/飞书 (快)' },
|
||||
{ id: 'pager', label: '电话轰炸 (急)' }
|
||||
]
|
||||
const channel = ref('chat')
|
||||
const backupHours = ref(24)
|
||||
|
||||
const checklist = reactive([
|
||||
{ label: '日志能不能查到?', done: true },
|
||||
{ label: 'CPU 飙高了会不会报警?', done: false },
|
||||
{ label: '关键接口慢了知不知道?', done: false },
|
||||
{ label: '数据库有没有自动备份?', done: true },
|
||||
{ label: '备份文件真的能恢复吗?(演练过)', done: false }
|
||||
])
|
||||
|
||||
const riskScore = computed(() => {
|
||||
let score = 70
|
||||
if (monitorLevel.value === 'pro') score -= 20
|
||||
else if (monitorLevel.value === 'std') score -= 10
|
||||
|
||||
if (channel.value === 'pager') score -= 10
|
||||
else if (channel.value === 'chat') score -= 5
|
||||
|
||||
score -= Math.min(20, (48 - backupHours.value) * 0.8)
|
||||
|
||||
const doneCount = checklist.filter((i) => i.done).length
|
||||
score -= doneCount * 4
|
||||
|
||||
return Math.max(0, Math.min(100, Math.round(score)))
|
||||
})
|
||||
|
||||
const rpo = computed(() => `${backupHours.value} 小时`)
|
||||
const rto = computed(() => {
|
||||
if (monitorLevel.value === 'pro') return '15-30 分钟'
|
||||
if (monitorLevel.value === 'std') return '30-60 分钟'
|
||||
return '1-2 小时 (甚至更久)'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.obs {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header .title {
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
}
|
||||
.header .subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.control {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
label {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
select,
|
||||
input[type='range'] {
|
||||
width: 100%;
|
||||
}
|
||||
.hint {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.chip {
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.chip.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: white;
|
||||
background: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px dashed var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
.label {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
.value {
|
||||
font-weight: 800;
|
||||
margin-top: 4px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.value.big {
|
||||
font-size: 24px;
|
||||
}
|
||||
.value.green {
|
||||
color: #22c55e;
|
||||
}
|
||||
.value.orange {
|
||||
color: #f59e0b;
|
||||
}
|
||||
.value.red {
|
||||
color: #ef4444;
|
||||
}
|
||||
.note {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.checklist-box {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
.box-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.checks {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.check {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,193 @@
|
||||
<!--
|
||||
RollbackSwitchDemo.vue
|
||||
发布策略:如何不关门装修
|
||||
-->
|
||||
<template>
|
||||
<div class="roll">
|
||||
<div class="header">
|
||||
<div class="title">发布策略对比</div>
|
||||
<div class="subtitle">网站升级就像店铺装修,怎么才能不影响做生意?</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button
|
||||
v-for="s in strategies"
|
||||
:key="s.id"
|
||||
:class="['tab', { active: current === s.id }]"
|
||||
@click="current = s.id"
|
||||
>
|
||||
<span class="emoji">{{ s.emoji }}</span>
|
||||
{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="card">
|
||||
<div class="label">操作方式</div>
|
||||
<div class="value">{{ flow }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">后悔药 (回滚时间)</div>
|
||||
<div class="value">{{ rollbackTime }}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">成本代价</div>
|
||||
<div class="value">{{ cost }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="analogy-section">
|
||||
<div class="col">
|
||||
<div class="section-title">🧐 通俗理解</div>
|
||||
<div class="analogy-text">{{ analogy }}</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="section-title">⚠️ 风险点</div>
|
||||
<div class="risk-text">{{ risk }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const strategies = [
|
||||
{ id: 'rolling', label: '滚动更新 (Rolling)', emoji: '🔄' },
|
||||
{ id: 'blue', label: '蓝绿发布 (Blue/Green)', emoji: '🔵🟢' },
|
||||
{ id: 'canary', label: '金丝雀发布 (Canary)', emoji: '🐦' }
|
||||
]
|
||||
|
||||
const current = ref('rolling')
|
||||
|
||||
const flow = computed(() => {
|
||||
if (current.value === 'rolling') return '分批替换'
|
||||
if (current.value === 'blue') return '全量切换'
|
||||
return '按比例慢慢切'
|
||||
})
|
||||
|
||||
const rollbackTime = computed(() => {
|
||||
if (current.value === 'rolling') return '慢 (3-10 分钟)'
|
||||
if (current.value === 'blue') return '极快 (秒级)'
|
||||
return '快 (秒级)'
|
||||
})
|
||||
|
||||
const cost = computed(() => {
|
||||
if (current.value === 'rolling') return '低 (资源利用率高)'
|
||||
if (current.value === 'blue') return '高 (需要双倍机器)'
|
||||
return '中 (需要复杂网关)'
|
||||
})
|
||||
|
||||
const analogy = computed(() => {
|
||||
switch (current.value) {
|
||||
case 'rolling':
|
||||
return '就像餐厅换桌布。不关门,一桌一桌换。客人来了坐新桌子,还没换好的桌子先空着。'
|
||||
case 'blue':
|
||||
return '有钱任性。在隔壁新开一家一模一样的店。装修好了,直接把大门指路牌改到新店。'
|
||||
case 'canary':
|
||||
return '先让 VIP 客户去新包间体验一下。如果 VIP 没投诉,再把所有客人都请进去。'
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
|
||||
const risk = computed(() => {
|
||||
if (current.value === 'rolling')
|
||||
return '中间状态比较乱,有的客人看到新装修,有的看到旧装修。'
|
||||
if (current.value === 'blue') return '太贵了!'
|
||||
return '技术要求高,得有能识别 VIP 的“门童” (流量网关)。'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.roll {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header .title {
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
}
|
||||
.header .subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tab {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tab.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
background: var(--vp-c-bg-soft);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px dashed var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
.label {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
.value {
|
||||
font-weight: 800;
|
||||
margin-top: 4px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.analogy-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.col {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
.section-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.analogy-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
.risk-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #ef4444;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,237 @@
|
||||
<!--
|
||||
ServerSizerDemo.vue
|
||||
服务器选购指南:租房隐喻
|
||||
-->
|
||||
<template>
|
||||
<div class="sizer">
|
||||
<div class="header">
|
||||
<div class="title">服务器选购指南</div>
|
||||
<div class="subtitle">不花冤枉钱,够用就好</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="control">
|
||||
<label>你的业务规模?</label>
|
||||
<div class="range-box">
|
||||
<input type="range" min="0" max="3" step="1" v-model.number="scaleIndex" />
|
||||
<div class="scale-labels">
|
||||
<span>个人博客</span>
|
||||
<span>初创官网</span>
|
||||
<span>小型 App</span>
|
||||
<span>中型平台</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-card">
|
||||
<div class="main-rec">
|
||||
<div class="icon">{{ recommendation.icon }}</div>
|
||||
<div class="details">
|
||||
<div class="rec-title">{{ recommendation.title }}</div>
|
||||
<div class="rec-spec">{{ recommendation.spec }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="analogy-box">
|
||||
<p><strong>🤔 通俗理解:</strong></p>
|
||||
<p>{{ recommendation.analogy }}</p>
|
||||
</div>
|
||||
|
||||
<div class="specs-grid">
|
||||
<div class="spec-item">
|
||||
<span class="label">带宽 (水管)</span>
|
||||
<span class="val">{{ recommendation.bandwidth }}</span>
|
||||
</div>
|
||||
<div class="spec-item">
|
||||
<span class="label">预算估算</span>
|
||||
<span class="val">{{ recommendation.cost }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const scaleIndex = ref(1)
|
||||
|
||||
const levels = [
|
||||
{
|
||||
title: '入门级 (Entry)',
|
||||
spec: '1核 1G ~ 2G',
|
||||
icon: '🚲',
|
||||
bandwidth: '1 Mbps',
|
||||
cost: '¥50~99 / 年 (活动价)',
|
||||
analogy: '就像租了个单间。放个博客、跑个脚本完全够用。人多了会挤不动。'
|
||||
},
|
||||
{
|
||||
title: '标准级 (Standard)',
|
||||
spec: '2核 4G',
|
||||
icon: '🚗',
|
||||
bandwidth: '3~5 Mbps',
|
||||
cost: '¥300 / 年',
|
||||
analogy: '就像租了个两居室。正经跑个公司官网、小程序后端没问题。大多数人的首选。'
|
||||
},
|
||||
{
|
||||
title: '专业级 (Pro)',
|
||||
spec: '4核 8G',
|
||||
icon: '🏎️',
|
||||
bandwidth: '5~10 Mbps',
|
||||
cost: '¥1000+ / 年',
|
||||
analogy: '就像租了个大平层办公室。能抗住几千人同时在线,跑复杂的计算任务也不虚。'
|
||||
},
|
||||
{
|
||||
title: '企业级 (Enterprise)',
|
||||
spec: '8核 16G + 负载均衡',
|
||||
icon: '✈️',
|
||||
bandwidth: '按量付费',
|
||||
cost: '¥5000+ / 年',
|
||||
analogy: '这已经不是租房了,是包下了一整层楼。通常需要多台机器配合,专人维护。'
|
||||
}
|
||||
]
|
||||
|
||||
const recommendation = computed(() => levels[scaleIndex.value])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sizer {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.header .title {
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
}
|
||||
.header .subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.control label {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.range-box {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
input[type='range'] {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.scale-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.result-card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-brand);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.result-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
background: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.main-rec {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 40px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.rec-title {
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.rec-spec {
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.analogy-box {
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.analogy-box p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.analogy-box strong {
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.specs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
border-top: 1px dashed var(--vp-c-divider);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.spec-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.spec-item .label {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.spec-item .val {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user