feat(appendix): 重构工程实践章节,添加交互式演示组件

## 新增组件 (14个)
- CodeSmellDemo.vue: 代码异味识别演示
- DecisionMatrixDemo.vue: 决策矩阵工具
- DesignPatternCatalogDemo.vue: 设计模式目录
- DocStructureDemo.vue: 文档结构示例
- LicenseComparisonDemo.vue: 开源许可证对比
- OpenSourceWorkflowDemo.vue: 开源协作流程
- PatternPlaygroundDemo.vue: 设计模式演练场
- RefactoringDemo.vue: 重构实战演示
- SecurityChecklistDemo.vue: 安全检查清单
- TDDCycleDemo.vue: TDD 循环演示
- TechRadarDemo.vue: 技术雷达图
- TechWritingPracticeDemo.vue: 技术写作实践
- TestPyramidDemo.vue: 测试金字塔
- WebSecurityDemo.vue: Web 安全演示

## 文档更新 (7篇)
- code-quality-refactoring.md: 代码质量与重构
- design-patterns.md: 设计模式
- open-source-collaboration.md: 开源协作
- security-thinking.md: 安全思维
- technical-writing.md: 技术写作
- technology-selection.md: 技术选型
- testing-strategies.md: 测试策略

## 其他变更
- 将 browser-as-os.md 内容合并到 computer-networks.md
- 更新 .gitignore 和 theme/index.js
This commit is contained in:
sanbuphy
2026-02-24 12:54:06 +08:00
parent baba96e7ed
commit f35cddeb8b
28 changed files with 5149 additions and 1080 deletions
@@ -0,0 +1,205 @@
<template>
<div class="code-smell-demo">
<div class="demo-label">代码坏味道识别器 点击切换不同示例</div>
<div class="tabs">
<button
v-for="(item, i) in smells"
:key="i"
class="tab"
:class="{ active: current === i }"
@click="current = i"
>
{{ item.icon }} {{ item.name }}
</button>
</div>
<div class="content">
<div class="code-panel">
<div class="panel-title">问题代码</div>
<pre><code>{{ smells[current].bad }}</code></pre>
</div>
<div class="info-panel" :class="smells[current].cls">
<h4>{{ smells[current].icon }} {{ smells[current].name }}</h4>
<p class="desc">{{ smells[current].desc }}</p>
<div class="suggestion">
<strong>改进建议</strong>{{ smells[current].fix }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const current = ref(0)
const smells = [
{
name: '过长函数',
icon: '📏',
cls: 'red',
desc: '一个函数超过 50 行,做了太多事情,难以理解和测试。',
bad: `function processOrder(order) {
// 验证订单... (20行)
// 计算价格... (15行)
// 检查库存... (10行)
// 发送通知... (15行)
// 更新数据库... (10行)
// 生成报表... (10行)
// 总计 80+ 行!
}`,
fix: '将大函数拆分为多个职责单一的小函数:validateOrder()、calculatePrice()、checkInventory() 等。'
},
{
name: '魔法数字',
icon: '🔢',
cls: 'orange',
desc: '代码中直接使用含义不明的数字字面量,阅读者无法理解其含义。',
bad: `if (user.age >= 18) { ... }
if (password.length < 8) { ... }
if (retryCount > 3) { ... }
setTimeout(fn, 86400000)`,
fix: '用命名常量替代:const ADULT_AGE = 18、const MIN_PASSWORD_LENGTH = 8、const ONE_DAY_MS = 86400000。'
},
{
name: '重复代码',
icon: '📋',
cls: 'yellow',
desc: '相同或相似的代码出现在多处,修改时容易遗漏。',
bad: `// 文件 A
const tax = price * 0.13
const total = price + tax
// 文件 B(几乎一样)
const tax = amount * 0.13
const sum = amount + tax`,
fix: '提取公共函数 calculateTax(amount),在多处复用,修改只需改一处。'
},
{
name: '过深嵌套',
icon: '🪆',
cls: 'purple',
desc: '多层 if/for 嵌套导致代码难以阅读,逻辑像迷宫。',
bad: `if (user) {
if (user.isActive) {
if (user.hasPermission) {
if (order.isValid) {
// 终于到了真正的逻辑...
}
}
}
}`,
fix: '使用卫语句(Guard Clause)提前返回:if (!user) return; if (!user.isActive) return; ...'
}
]
</script>
<style scoped>
.code-smell-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg-soft);
padding: 1rem 1.2rem;
margin: 1rem 0;
}
.demo-label {
font-size: 0.78rem;
font-weight: bold;
color: var(--vp-c-text-2);
margin-bottom: 1rem;
text-align: center;
}
.tabs {
display: flex;
gap: 6px;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.tab {
padding: 6px 12px;
border-radius: 6px;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
cursor: pointer;
font-size: 0.82rem;
transition: all 0.2s;
}
.tab.active {
background: var(--vp-c-brand);
color: #fff;
border-color: var(--vp-c-brand);
}
.content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 640px) {
.content {
grid-template-columns: 1fr;
}
}
.code-panel {
background: var(--vp-c-bg);
border-radius: 6px;
overflow: hidden;
}
.panel-title {
font-size: 0.72rem;
padding: 4px 10px;
background: var(--vp-c-bg-soft);
color: var(--vp-c-text-3);
border-bottom: 1px solid var(--vp-c-divider);
}
.code-panel pre {
margin: 0;
padding: 10px;
font-size: 0.8rem;
line-height: 1.5;
overflow-x: auto;
}
.info-panel {
padding: 1rem;
border-radius: 6px;
}
.info-panel.red { background: #fef2f2; border: 1px solid #fecaca; }
.info-panel.orange { background: #fff7ed; border: 1px solid #fed7aa; }
.info-panel.yellow { background: #fefce8; border: 1px solid #fde68a; }
.info-panel.purple { background: #faf5ff; border: 1px solid #e9d5ff; }
:root.dark .info-panel.red { background: #1c0606; border-color: #7f1d1d; }
:root.dark .info-panel.orange { background: #1c0f03; border-color: #9a3412; }
:root.dark .info-panel.yellow { background: #1c1a03; border-color: #854d0e; }
:root.dark .info-panel.purple { background: #1a0a2e; border-color: #6b21a8; }
.info-panel h4 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.desc {
font-size: 0.85rem;
color: var(--vp-c-text-2);
margin-bottom: 0.6rem;
}
.suggestion {
font-size: 0.83rem;
padding: 8px;
background: var(--vp-c-bg);
border-radius: 4px;
}
</style>
@@ -0,0 +1,480 @@
<template>
<div class="decision-matrix-demo">
<div class="demo-header">
<span class="icon">📊</span>
<span class="title">决策矩阵</span>
<span class="subtitle">量化对比科学选型</span>
</div>
<div class="section">
<h6 class="section-title">待比较技术</h6>
<div class="options-row">
<span
v-for="opt in options"
:key="opt"
class="option-tag"
>
{{ opt }}
<button class="remove-btn" @click="removeOption(opt)">×</button>
</span>
<div v-if="options.length < 5" class="add-option">
<input
v-model="newOption"
placeholder="添加技术..."
class="add-input"
@keyup.enter="addOption"
/>
<button class="add-btn" @click="addOption">+</button>
</div>
</div>
</div>
<div class="section">
<h6 class="section-title">评估维度与权重</h6>
<div class="dimensions-list">
<div
v-for="dim in dimensions"
:key="dim.key"
class="dim-row"
>
<span class="dim-name">{{ dim.label }}</span>
<input
type="range"
min="1"
max="5"
:value="weights[dim.key]"
class="weight-slider"
@input="weights[dim.key] = Number($event.target.value)"
/>
<span class="weight-val">{{ weights[dim.key] }}</span>
</div>
</div>
</div>
<div class="section">
<h6 class="section-title">打分1-5</h6>
<div class="score-table-wrapper">
<table class="score-table">
<thead>
<tr>
<th>维度</th>
<th v-for="opt in options" :key="opt">{{ opt }}</th>
</tr>
</thead>
<tbody>
<tr v-for="dim in dimensions" :key="dim.key">
<td class="dim-cell">{{ dim.label }}</td>
<td v-for="opt in options" :key="opt">
<div class="score-btns">
<button
v-for="s in 5"
:key="s"
class="score-btn"
:class="{ active: scores[opt]?.[dim.key] >= s }"
@click="setScore(opt, dim.key, s)"
>
{{ s }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="hasAllScores" class="section results">
<h6 class="section-title">加权总分排名</h6>
<div class="bar-chart">
<div
v-for="(r, i) in ranked"
:key="r.name"
class="bar-row"
>
<span class="bar-rank" :class="{ first: i === 0 }">
{{ i === 0 ? '🏆' : `#${i + 1}` }}
</span>
<span class="bar-name">{{ r.name }}</span>
<div class="bar-track">
<div
class="bar-fill"
:style="{
width: (r.score / maxScore) * 100 + '%',
background: barColors[i % barColors.length]
}"
/>
</div>
<span class="bar-score">{{ r.score.toFixed(1) }}</span>
</div>
</div>
</div>
<div class="actions">
<button class="reset-btn" @click="resetAll">重置全部</button>
<button class="preset-btn" @click="loadPreset">加载预设</button>
</div>
<div class="info-box">
<span class="icon">💡</span>
<strong>使用方法</strong>
调整权重反映你的项目优先级为每个技术在各维度打分系统自动计算加权总分权重越高的维度对最终结果影响越大
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
const barColors = ['#22c55e', '#3b82f6', '#f59e0b', '#8b5cf6', '#ec4899']
const dimensions = [
{ key: 'learning', label: '学习曲线' },
{ key: 'ecosystem', label: '生态系统' },
{ key: 'performance', label: '性能' },
{ key: 'community', label: '社区活跃度' },
{ key: 'hiring', label: '招聘难度' }
]
const options = ref(['React', 'Vue', 'Svelte'])
const newOption = ref('')
const weights = reactive({
learning: 3,
ecosystem: 4,
performance: 3,
community: 3,
hiring: 2
})
const scores = reactive({
React: { learning: 3, ecosystem: 5, performance: 4, community: 5, hiring: 4 },
Vue: { learning: 4, ecosystem: 4, performance: 4, community: 4, hiring: 3 },
Svelte: { learning: 5, ecosystem: 2, performance: 5, community: 3, hiring: 1 }
})
const addOption = () => {
const name = newOption.value.trim()
if (name && !options.value.includes(name) && options.value.length < 5) {
options.value.push(name)
scores[name] = {}
newOption.value = ''
}
}
const removeOption = (opt) => {
if (options.value.length <= 2) return
options.value = options.value.filter((o) => o !== opt)
delete scores[opt]
}
const setScore = (opt, dim, val) => {
if (!scores[opt]) scores[opt] = {}
scores[opt][dim] = val
}
const hasAllScores = computed(() => {
return options.value.every((opt) =>
dimensions.every((dim) => scores[opt]?.[dim.key])
)
})
const ranked = computed(() => {
return options.value
.map((opt) => {
let total = 0
dimensions.forEach((dim) => {
total += (scores[opt]?.[dim.key] || 0) * weights[dim.key]
})
return { name: opt, score: total }
})
.sort((a, b) => b.score - a.score)
})
const maxScore = computed(() => {
return Math.max(...ranked.value.map((r) => r.score), 1)
})
const resetAll = () => {
options.value = ['React', 'Vue', 'Svelte']
Object.keys(scores).forEach((k) => delete scores[k])
Object.assign(weights, { learning: 3, ecosystem: 4, performance: 3, community: 3, hiring: 2 })
}
const loadPreset = () => {
options.value = ['React', 'Vue', 'Svelte']
Object.keys(scores).forEach((k) => delete scores[k])
Object.assign(scores, {
React: { learning: 3, ecosystem: 5, performance: 4, community: 5, hiring: 4 },
Vue: { learning: 4, ecosystem: 4, performance: 4, community: 4, hiring: 3 },
Svelte: { learning: 5, ecosystem: 2, performance: 5, community: 3, hiring: 1 }
})
}
</script>
<style scoped>
.decision-matrix-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg-soft);
padding: 0.75rem;
margin: 0.5rem 0;
}
.demo-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.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 }
.section {
margin-bottom: 0.75rem;
}
.section-title {
margin: 0 0 0.5rem 0;
font-size: 0.9rem;
color: var(--vp-c-text-1);
}
.options-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.option-tag {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0.6rem;
background: var(--vp-c-brand);
color: #fff;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
}
.remove-btn {
background: none;
border: none;
color: rgba(255,255,255,0.7);
cursor: pointer;
font-size: 0.9rem;
padding: 0 0.15rem;
}
.remove-btn:hover { color: #fff }
.add-option {
display: flex;
gap: 0.25rem;
}
.add-input {
padding: 0.25rem 0.5rem;
border: 1px solid var(--vp-c-divider);
border-radius: 4px;
font-size: 0.8rem;
width: 120px;
background: var(--vp-c-bg);
color: var(--vp-c-text-1);
}
.add-btn {
padding: 0.25rem 0.5rem;
background: var(--vp-c-brand);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.dimensions-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
background: var(--vp-c-bg);
padding: 0.75rem;
border-radius: 6px;
}
.dim-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.dim-name {
width: 80px;
font-size: 0.8rem;
flex-shrink: 0;
}
.weight-slider {
flex: 1;
accent-color: var(--vp-c-brand);
}
.weight-val {
width: 20px;
text-align: center;
font-weight: bold;
font-size: 0.85rem;
color: var(--vp-c-brand-1);
}
.score-table-wrapper {
overflow-x: auto;
}
.score-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
background: var(--vp-c-bg);
border-radius: 6px;
}
.score-table th,
.score-table td {
padding: 0.4rem 0.5rem;
text-align: center;
border-bottom: 1px solid var(--vp-c-divider);
}
.score-table th {
background: var(--vp-c-bg-soft);
font-weight: 600;
}
.dim-cell {
text-align: left !important;
font-weight: 500;
}
.score-btns {
display: flex;
gap: 2px;
justify-content: center;
}
.score-btn {
width: 24px;
height: 24px;
border: 1px solid var(--vp-c-divider);
border-radius: 3px;
background: var(--vp-c-bg-soft);
cursor: pointer;
font-size: 0.7rem;
transition: all 0.15s;
color: var(--vp-c-text-2);
}
.score-btn.active {
background: var(--vp-c-brand);
color: #fff;
border-color: var(--vp-c-brand);
}
.results {
background: var(--vp-c-bg);
padding: 0.75rem;
border-radius: 6px;
border: 2px solid var(--vp-c-brand);
}
.bar-chart {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.bar-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.bar-rank {
width: 32px;
text-align: center;
font-size: 0.85rem;
font-weight: 600;
color: var(--vp-c-text-2);
}
.bar-rank.first {
font-size: 1.1rem;
}
.bar-name {
width: 60px;
font-size: 0.85rem;
font-weight: 600;
flex-shrink: 0;
}
.bar-track {
flex: 1;
height: 22px;
background: var(--vp-c-bg-soft);
border-radius: 4px;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.4s ease;
}
.bar-score {
width: 40px;
text-align: right;
font-weight: bold;
font-size: 0.85rem;
color: var(--vp-c-brand-1);
}
.actions {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.reset-btn,
.preset-btn {
padding: 0.4rem 0.75rem;
border: 1px solid var(--vp-c-divider);
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
background: var(--vp-c-bg);
color: var(--vp-c-text-1);
}
.preset-btn {
background: var(--vp-c-brand);
color: #fff;
border-color: var(--vp-c-brand);
}
.reset-btn:hover { background: var(--vp-c-bg-alt) }
.preset-btn:hover { opacity: 0.9 }
.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 }
</style>
@@ -0,0 +1,233 @@
<template>
<div class="pattern-catalog-demo">
<div class="demo-label">设计模式图鉴 点击分类查看常用模式</div>
<div class="categories">
<div
v-for="(cat, i) in categories"
:key="cat.name"
class="cat-card"
:class="[cat.cls, { active: selected === i }]"
@click="selected = selected === i ? -1 : i"
>
<span class="cat-icon">{{ cat.icon }}</span>
<span class="cat-name">{{ cat.name }}</span>
<span class="cat-count">{{ cat.patterns.length }} 个模式</span>
</div>
</div>
<Transition name="fade">
<div v-if="selected >= 0" class="patterns-list">
<div
v-for="p in categories[selected].patterns"
:key="p.name"
class="pattern-item"
:class="categories[selected].cls"
>
<div class="pattern-header" @click="expanded = expanded === p.name ? '' : p.name">
<strong>{{ p.name }}</strong>
<span class="toggle">{{ expanded === p.name ? '▼' : '▶' }}</span>
</div>
<div class="pattern-intent">{{ p.intent }}</div>
<Transition name="fade">
<div v-if="expanded === p.name" class="pattern-detail">
<div class="detail-label">适用场景</div>
<div class="detail-text">{{ p.when }}</div>
<div class="detail-label">代码示例</div>
<pre><code>{{ p.code }}</code></pre>
</div>
</Transition>
</div>
</div>
</Transition>
</div>
</template>
<script setup>
import { ref } from 'vue'
const selected = ref(-1)
const expanded = ref('')
const categories = [
{
name: '创建型',
icon: '🏗️',
cls: 'create',
patterns: [
{
name: '单例模式 Singleton',
intent: '确保一个类只有一个实例,并提供全局访问点。',
when: '数据库连接池、全局配置管理、日志记录器。',
code: `class Database {
static instance = null
static getInstance() {
if (!this.instance) {
this.instance = new Database()
}
return this.instance
}
}`
},
{
name: '工厂模式 Factory',
intent: '定义创建对象的接口,让子类决定实例化哪个类。',
when: '需要根据条件创建不同类型对象时。',
code: `function createNotification(type) {
switch (type) {
case 'email': return new EmailNotify()
case 'sms': return new SmsNotify()
case 'push': return new PushNotify()
}
}`
}
]
},
{
name: '结构型',
icon: '🧱',
cls: 'structure',
patterns: [
{
name: '装饰器模式 Decorator',
intent: '动态地给对象添加额外职责,比继承更灵活。',
when: '需要在不修改原有代码的情况下扩展功能。',
code: `function withLogging(fn) {
return function(...args) {
console.log('调用:', fn.name)
return fn.apply(this, args)
}
}
const save = withLogging(saveUser)`
},
{
name: '适配器模式 Adapter',
intent: '将一个接口转换成客户端期望的另一个接口。',
when: '对接第三方 API、兼容旧系统接口。',
code: `class OldApi { getData() { ... } }
class ApiAdapter {
constructor(old) { this.old = old }
fetch() { return this.old.getData() }
}`
}
]
},
{
name: '行为型',
icon: '🎭',
cls: 'behavior',
patterns: [
{
name: '观察者模式 Observer',
intent: '定义一对多依赖,当状态变化时自动通知所有依赖者。',
when: '事件系统、状态管理、消息推送。',
code: `class EventBus {
listeners = {}
on(event, fn) {
(this.listeners[event] ||= []).push(fn)
}
emit(event, data) {
this.listeners[event]?.forEach(fn => fn(data))
}
}`
},
{
name: '策略模式 Strategy',
intent: '定义一系列算法,使它们可以互相替换。',
when: '排序策略、支付方式、验证规则的切换。',
code: `const strategies = {
bubble: arr => { /* 冒泡排序 */ },
quick: arr => { /* 快速排序 */ },
merge: arr => { /* 归并排序 */ }
}
function sort(arr, type) {
return strategies[type](arr)
}`
}
]
}
]
</script>
<style scoped>
.pattern-catalog-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg-soft);
padding: 1rem 1.2rem;
margin: 1rem 0;
}
.demo-label {
font-size: 0.78rem;
font-weight: bold;
color: var(--vp-c-text-2);
margin-bottom: 1rem;
text-align: center;
}
.categories { display: flex; gap: 8px; margin-bottom: 1rem; flex-wrap: wrap; }
.cat-card {
flex: 1;
min-width: 120px;
padding: 12px;
border-radius: 8px;
cursor: pointer;
text-align: center;
transition: transform 0.2s, box-shadow 0.2s;
user-select: none;
}
.cat-card:hover { transform: scale(1.03); }
.cat-card.active { box-shadow: 0 0 0 2px var(--vp-c-brand); transform: scale(1.05); }
.cat-card.create { background: #dbeafe; color: #1e40af; }
.cat-card.structure { background: #d1fae5; color: #065f46; }
.cat-card.behavior { background: #fef3c7; color: #92400e; }
:root.dark .cat-card.create { background: #172554; color: #93c5fd; }
:root.dark .cat-card.structure { background: #022c22; color: #6ee7b7; }
:root.dark .cat-card.behavior { background: #451a03; color: #fcd34d; }
.cat-icon { display: block; font-size: 1.5rem; margin-bottom: 4px; }
.cat-name { display: block; font-weight: 600; font-size: 0.9rem; }
.cat-count { display: block; font-size: 0.72rem; opacity: 0.7; }
.patterns-list { display: flex; flex-direction: column; gap: 8px; }
.pattern-item {
border-radius: 6px;
padding: 10px;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
}
.pattern-header {
display: flex;
justify-content: space-between;
cursor: pointer;
font-size: 0.9rem;
}
.toggle { font-size: 0.7rem; color: var(--vp-c-text-3); }
.pattern-intent { font-size: 0.82rem; color: var(--vp-c-text-2); margin-top: 4px; }
.pattern-detail { margin-top: 8px; }
.detail-label { font-size: 0.75rem; font-weight: 600; color: var(--vp-c-text-3); margin-top: 6px; }
.detail-text { font-size: 0.82rem; color: var(--vp-c-text-2); }
.pattern-detail pre {
margin: 4px 0 0;
padding: 8px;
background: var(--vp-c-bg-soft);
border-radius: 4px;
font-size: 0.78rem;
line-height: 1.5;
overflow-x: auto;
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.25s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
@@ -0,0 +1,109 @@
<template>
<div class="doc-structure-demo">
<div class="demo-label">文档结构模板 点击切换文档类型</div>
<div class="tabs">
<button
v-for="(doc, i) in docs"
:key="i"
class="tab"
:class="{ active: current === i }"
@click="current = i"
>{{ doc.icon }} {{ doc.name }}</button>
</div>
<div class="structure-card">
<div class="section-list">
<div
v-for="(sec, j) in docs[current].sections"
:key="j"
class="section-item"
:class="{ active: selectedSec === j }"
@click="selectedSec = selectedSec === j ? -1 : j"
>
<div class="sec-header">
<span class="sec-num">{{ j + 1 }}</span>
<span class="sec-name">{{ sec.name }}</span>
<span class="sec-toggle">{{ selectedSec === j ? '▼' : '▶' }}</span>
</div>
<Transition name="fade">
<div v-if="selectedSec === j" class="sec-detail">
<p>{{ sec.desc }}</p>
<pre v-if="sec.example"><code>{{ sec.example }}</code></pre>
</div>
</Transition>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
const current = ref(0)
const selectedSec = ref(-1)
watch(current, () => { selectedSec.value = -1 })
const docs = [
{
name: 'README',
icon: '📖',
sections: [
{ name: '项目名称 + 一句话描述', desc: '让读者在 3 秒内知道这个项目是什么。', example: '# MyApp\n> 一个轻量级的任务管理工具' },
{ name: '快速开始', desc: '最短路径让用户跑起来,通常是安装 + 运行命令。', example: 'npm install myapp\nnpx myapp init' },
{ name: '功能特性', desc: '用列表列出核心功能,让用户判断是否满足需求。', example: '- ✅ 任务看板\n- ✅ 团队协作\n- ✅ 数据导出' },
{ name: '使用示例', desc: '展示典型用法的代码片段,比文字描述更直观。', example: null },
{ name: '贡献指南 + 许可证', desc: '说明如何参与贡献,以及项目的开源许可证。', example: null }
]
},
{
name: 'API 文档',
icon: '🔌',
sections: [
{ name: '接口概述', desc: '说明 API 的基础 URL、认证方式、通用参数。', example: 'Base URL: https://api.example.com/v1\nAuth: Bearer Token' },
{ name: '请求参数', desc: '用表格列出每个参数的名称、类型、是否必填、说明。', example: '| 参数 | 类型 | 必填 | 说明 |\n| name | string | 是 | 用户名 |' },
{ name: '响应格式', desc: '展示成功和失败的 JSON 响应示例。', example: '{ "code": 200, "data": { ... } }' },
{ name: '错误码说明', desc: '列出所有可能的错误码及其含义。', example: '401 - 未授权\n404 - 资源不存在\n429 - 请求过于频繁' }
]
},
{
name: '架构文档',
icon: '🏛️',
sections: [
{ name: '系统概述', desc: '用一段话说明系统的目标、边界和核心约束。', example: null },
{ name: '架构图', desc: '展示系统的整体架构,包括各模块和它们之间的关系。', example: '[客户端] → [API 网关] → [微服务集群]\n ↓\n [数据库集群]' },
{ name: '技术选型', desc: '说明关键技术的选择理由和替代方案的对比。', example: null },
{ name: '部署架构', desc: '说明生产环境的部署方式、扩容策略。', example: null }
]
}
]
</script>
<style scoped>
.doc-structure-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg-soft);
padding: 1rem 1.2rem;
margin: 1rem 0;
}
.demo-label { font-size: 0.78rem; font-weight: bold; color: var(--vp-c-text-2); margin-bottom: 1rem; text-align: center; }
.tabs { display: flex; gap: 6px; margin-bottom: 1rem; flex-wrap: wrap; }
.tab { padding: 6px 14px; border-radius: 6px; border: 1px solid var(--vp-c-divider); background: var(--vp-c-bg); cursor: pointer; font-size: 0.85rem; transition: all 0.2s; }
.tab.active { background: var(--vp-c-brand); color: #fff; border-color: var(--vp-c-brand); }
.section-list { display: flex; flex-direction: column; gap: 6px; }
.section-item { border: 1px solid var(--vp-c-divider); border-radius: 6px; background: var(--vp-c-bg); overflow: hidden; }
.sec-header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; }
.sec-num { width: 22px; height: 22px; border-radius: 50%; background: var(--vp-c-brand); color: #fff; font-size: 0.72rem; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.sec-name { flex: 1; font-size: 0.88rem; font-weight: 600; }
.sec-toggle { font-size: 0.7rem; color: var(--vp-c-text-3); }
.section-item.active { border-color: var(--vp-c-brand); }
.sec-detail { padding: 0 12px 10px; }
.sec-detail p { font-size: 0.83rem; color: var(--vp-c-text-2); margin: 0 0 6px; }
.sec-detail pre { margin: 0; padding: 8px; background: var(--vp-c-bg-soft); border-radius: 4px; font-size: 0.78rem; line-height: 1.5; overflow-x: auto; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
@@ -0,0 +1,234 @@
<template>
<div class="lc-root">
<h4 class="lc-title">开源许可证对比工具</h4>
<!-- Filter -->
<div class="lc-filter">
<span class="lc-filter-label">我的需求</span>
<button
v-for="f in filters"
:key="f.id"
:class="['lc-tag', { 'lc-tag--on': activeFilters.includes(f.id) }]"
@click="toggle(f.id)"
>{{ f.label }}</button>
<button v-if="activeFilters.length" class="lc-tag lc-tag--clear" @click="activeFilters = []">清除筛选</button>
</div>
<!-- Recommendation -->
<div v-if="recommended" class="lc-recommend">
推荐许可证<strong>{{ recommended.name }}</strong> {{ recommended.summary }}
</div>
<!-- Table -->
<div class="lc-table-wrap">
<table class="lc-table">
<thead>
<tr>
<th>许可证</th>
<th v-for="p in permissions" :key="p.id">{{ p.label }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="l in licenses"
:key="l.id"
:class="{ 'lc-row--hl': recommended && recommended.id === l.id }"
>
<td class="lc-name-cell">
<strong>{{ l.name }}</strong>
<span class="lc-desc">{{ l.summary }}</span>
</td>
<td v-for="p in permissions" :key="p.id" class="lc-cell">
<span v-if="l.perms[p.id] === true" class="lc-yes">&#10003;</span>
<span v-else-if="l.perms[p.id] === false" class="lc-no">&#10007;</span>
<span v-else class="lc-cond">&#9888;</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Legend -->
<div class="lc-legend">
<span><span class="lc-yes">&#10003;</span> 允许</span>
<span><span class="lc-no">&#10007;</span> 不允许/限制</span>
<span><span class="lc-cond">&#9888;</span> 有条件</span>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const permissions = [
{ id: 'commercial', label: '商用' },
{ id: 'modify', label: '修改' },
{ id: 'distribute', label: '分发' },
{ id: 'patent', label: '专利授权' },
{ id: 'private', label: '私用' },
{ id: 'copyleft', label: '需开源衍生' },
{ id: 'liability', label: '免责' }
]
const licenses = [
{
id: 'mit', name: 'MIT', summary: '最宽松,几乎无限制',
perms: { commercial: true, modify: true, distribute: true, patent: false, private: true, copyleft: false, liability: true },
tags: ['commercial', 'simple', 'private']
},
{
id: 'apache2', name: 'Apache 2.0', summary: '宽松 + 专利保护',
perms: { commercial: true, modify: true, distribute: true, patent: true, private: true, copyleft: false, liability: true },
tags: ['commercial', 'patent', 'private']
},
{
id: 'gpl3', name: 'GPL 3.0', summary: '强 Copyleft,衍生必须开源',
perms: { commercial: true, modify: true, distribute: true, patent: true, private: true, copyleft: true, liability: true },
tags: ['copyleft', 'patent']
},
{
id: 'bsd2', name: 'BSD 2-Clause', summary: '类似 MIT,极简宽松',
perms: { commercial: true, modify: true, distribute: true, patent: false, private: true, copyleft: false, liability: true },
tags: ['commercial', 'simple', 'private']
},
{
id: 'mpl2', name: 'MPL 2.0', summary: '文件级 Copyleft,折中方案',
perms: { commercial: true, modify: true, distribute: true, patent: true, private: true, copyleft: 'cond', liability: true },
tags: ['commercial', 'patent', 'copyleft']
}
]
const filters = [
{ id: 'commercial', label: '允许商用' },
{ id: 'patent', label: '需要专利保护' },
{ id: 'simple', label: '尽量简单' },
{ id: 'copyleft', label: '要求衍生开源' },
{ id: 'private', label: '允许闭源使用' }
]
const activeFilters = ref([])
function toggle(id) {
const idx = activeFilters.value.indexOf(id)
if (idx >= 0) activeFilters.value.splice(idx, 1)
else activeFilters.value.push(id)
}
const recommended = computed(() => {
if (!activeFilters.value.length) return null
let best = null
let bestScore = -1
for (const l of licenses) {
const score = activeFilters.value.filter(f => l.tags.includes(f)).length
if (score > bestScore) { bestScore = score; best = l }
}
return bestScore > 0 ? best : null
})
</script>
<style scoped>
.lc-root {
margin: 1.5em 0;
padding: 1.2em;
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg-soft);
}
.lc-title {
margin: 0 0 1em;
font-size: 1.05em;
font-weight: 600;
text-align: center;
}
/* Filter */
.lc-filter {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 1em;
}
.lc-filter-label {
font-size: 0.9em;
color: var(--vp-c-text-2);
}
.lc-tag {
padding: 4px 12px;
border: 1px solid var(--vp-c-divider);
border-radius: 16px;
background: var(--vp-c-bg);
color: var(--vp-c-text-2);
font-size: 0.82em;
cursor: pointer;
transition: all 0.2s;
}
.lc-tag:hover { border-color: var(--vp-c-brand-1); }
.lc-tag--on {
background: var(--vp-c-brand-1);
border-color: var(--vp-c-brand-1);
color: #fff;
}
.lc-tag--clear {
color: var(--vp-c-text-3);
font-size: 0.78em;
}
/* Recommend */
.lc-recommend {
padding: 0.6em 1em;
margin-bottom: 1em;
border-radius: 8px;
background: var(--vp-c-brand-soft);
font-size: 0.9em;
color: var(--vp-c-brand-1);
}
/* Table */
.lc-table-wrap {
overflow-x: auto;
margin-bottom: 0.8em;
}
.lc-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88em;
}
.lc-table th,
.lc-table td {
padding: 8px 10px;
border: 1px solid var(--vp-c-divider);
text-align: center;
}
.lc-table th {
background: var(--vp-c-bg);
font-weight: 600;
font-size: 0.85em;
white-space: nowrap;
}
.lc-name-cell {
text-align: left !important;
min-width: 130px;
}
.lc-desc {
display: block;
font-size: 0.8em;
color: var(--vp-c-text-3);
font-weight: 400;
}
.lc-row--hl {
background: var(--vp-c-brand-soft);
}
.lc-cell { font-size: 1.1em; }
.lc-yes { color: #10b981; font-weight: 700; }
.lc-no { color: #ef4444; font-weight: 700; }
.lc-cond { color: #f59e0b; }
/* Legend */
.lc-legend {
display: flex;
gap: 1.5em;
font-size: 0.8em;
color: var(--vp-c-text-3);
}
</style>
@@ -0,0 +1,82 @@
<template>
<div class="os-workflow-demo">
<div class="demo-label">开源贡献流程 点击步骤查看详情</div>
<div class="steps-bar">
<div
v-for="(s, i) in steps"
:key="i"
class="step"
:class="{ active: current === i, done: i < current }"
@click="current = i"
>
<span class="step-dot">{{ i < current ? '✓' : i + 1 }}</span>
<span class="step-label">{{ s.name }}</span>
</div>
</div>
<div class="detail-card">
<h4>{{ steps[current].icon }} {{ steps[current].name }}</h4>
<p>{{ steps[current].desc }}</p>
<div class="cmd-block" v-if="steps[current].cmd">
<div class="cmd-title">对应命令</div>
<pre><code>{{ steps[current].cmd }}</code></pre>
</div>
</div>
<div class="controls">
<button class="btn" :disabled="current === 0" @click="current--">上一步</button>
<button class="btn primary" :disabled="current === steps.length - 1" @click="current++">下一步</button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const current = ref(0)
const steps = [
{ name: 'Fork', icon: '🍴', desc: '在 GitHub 上 Fork 目标仓库到自己的账号下,获得一份完整的副本。', cmd: '# 在 GitHub 页面点击 Fork 按钮' },
{ name: 'Clone', icon: '📥', desc: '将 Fork 后的仓库克隆到本地开发环境。', cmd: 'git clone https://github.com/你的用户名/项目.git\ncd 项目' },
{ name: 'Branch', icon: '🌿', desc: '创建功能分支,不要直接在 main 上开发。分支名应描述你要做的事。', cmd: 'git checkout -b fix/login-bug' },
{ name: 'Commit', icon: '💾', desc: '完成修改后提交,写清晰的 commit message。遵循项目的提交规范。', cmd: 'git add .\ngit commit -m "fix: 修复登录页白屏问题"' },
{ name: 'Push', icon: '🚀', desc: '将本地分支推送到你 Fork 的远程仓库。', cmd: 'git push origin fix/login-bug' },
{ name: 'PR', icon: '📬', desc: '在 GitHub 上创建 Pull Request,描述你的改动、关联的 Issue、测试方法。', cmd: '# 在 GitHub 页面点击 "New Pull Request"' },
{ name: 'Review', icon: '👀', desc: '维护者会审查你的代码,可能提出修改建议。根据反馈修改后再次 push 即可。', cmd: 'git add . && git commit -m "fix: 根据 review 反馈调整"\ngit push' },
{ name: 'Merge', icon: '🎉', desc: '审查通过后,维护者会合并你的 PR。恭喜,你成为了项目贡献者!', cmd: '# 维护者操作:Merge Pull Request' }
]
</script>
<style scoped>
.os-workflow-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg-soft);
padding: 1rem 1.2rem;
margin: 1rem 0;
}
.demo-label { font-size: 0.78rem; font-weight: bold; color: var(--vp-c-text-2); margin-bottom: 1rem; text-align: center; }
.steps-bar { display: flex; gap: 2px; margin-bottom: 1rem; overflow-x: auto; }
.step { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 6px 8px; border-radius: 6px; cursor: pointer; min-width: 56px; transition: all 0.2s; }
.step:hover { background: var(--vp-c-bg); }
.step.active { background: var(--vp-c-brand-soft); }
.step-dot { width: 24px; height: 24px; border-radius: 50%; border: 2px solid var(--vp-c-divider); display: flex; align-items: center; justify-content: center; font-size: 0.72rem; font-weight: 600; transition: all 0.2s; }
.step.active .step-dot { background: var(--vp-c-brand); color: #fff; border-color: var(--vp-c-brand); }
.step.done .step-dot { background: #10b981; color: #fff; border-color: #10b981; }
.step-label { font-size: 0.7rem; color: var(--vp-c-text-3); white-space: nowrap; }
.step.active .step-label { color: var(--vp-c-brand); font-weight: 600; }
.detail-card { border: 1px solid var(--vp-c-divider); border-radius: 8px; padding: 1rem; background: var(--vp-c-bg); margin-bottom: 1rem; }
.detail-card h4 { margin: 0 0 0.5rem; font-size: 1rem; }
.detail-card p { font-size: 0.85rem; color: var(--vp-c-text-2); margin: 0 0 0.6rem; }
.cmd-block { border-radius: 6px; overflow: hidden; }
.cmd-title { font-size: 0.72rem; padding: 4px 10px; background: var(--vp-c-bg-soft); color: var(--vp-c-text-3); border-bottom: 1px solid var(--vp-c-divider); }
.cmd-block pre { margin: 0; padding: 8px; font-size: 0.8rem; line-height: 1.5; overflow-x: auto; background: var(--vp-c-bg-soft); }
.controls { display: flex; gap: 8px; justify-content: center; }
.btn { padding: 6px 16px; border-radius: 6px; border: 1px solid var(--vp-c-divider); background: var(--vp-c-bg); color: var(--vp-c-text-1); cursor: pointer; font-size: 0.85rem; }
.btn:hover:not(:disabled) { background: var(--vp-c-bg-soft); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn.primary { background: var(--vp-c-brand); color: #fff; border-color: var(--vp-c-brand); }
</style>
@@ -0,0 +1,476 @@
<template>
<div class="pattern-playground">
<div class="demo-header">
<span class="icon">🎮</span>
<span class="title">设计模式演练场</span>
<span class="subtitle">选择模式动手体验</span>
</div>
<div class="mode-tabs">
<button
v-for="m in modes"
:key="m.key"
:class="['mode-btn', { active: activeMode === m.key }]"
@click="activeMode = m.key; resetState()"
>
{{ m.icon }} {{ m.name }}
</button>
</div>
<!-- 观察者模式演练 -->
<div v-if="activeMode === 'observer'" class="playground-area">
<div class="playground-desc">
模拟事件发布/订阅添加订阅者发布事件观察通知如何传播
</div>
<div class="observer-layout">
<div class="publisher-panel">
<div class="panel-title">📡 发布者 (Publisher)</div>
<div class="event-input">
<input
v-model="eventMessage"
placeholder="输入事件消息..."
@keyup.enter="publishEvent"
/>
<button class="action-btn publish" @click="publishEvent">发布事件</button>
</div>
</div>
<div class="subscribers-panel">
<div class="panel-title">
👥 订阅者
<button class="action-btn add" @click="addSubscriber">+ 添加</button>
</div>
<div v-if="subscribers.length === 0" class="empty-hint">暂无订阅者点击"添加"按钮</div>
<div
v-for="sub in subscribers"
:key="sub.id"
:class="['subscriber-card', { notified: sub.notified }]"
>
<span class="sub-name">{{ sub.name }}</span>
<span v-if="sub.lastMsg" class="sub-msg">收到: {{ sub.lastMsg }}</span>
<button class="remove-btn" @click="removeSubscriber(sub.id)">移除</button>
</div>
</div>
</div>
<div v-if="observerLog.length" class="event-log">
<div class="log-title">📋 事件日志</div>
<div v-for="(log, i) in observerLog" :key="i" class="log-item">{{ log }}</div>
</div>
</div>
<!-- 策略模式演练 -->
<div v-if="activeMode === 'strategy'" class="playground-area">
<div class="playground-desc">
选择不同的排序策略观察同一组数据如何被不同算法处理
</div>
<div class="strategy-layout">
<div class="data-panel">
<div class="panel-title">📊 待排序数据</div>
<div class="data-bars">
<div
v-for="(v, i) in strategyData"
:key="i"
class="bar"
:style="{ height: v * 3 + 'px', background: barColor(i) }"
>
<span class="bar-label">{{ v }}</span>
</div>
</div>
<button class="action-btn" @click="shuffleData" style="margin-top: 10px">🔀 打乱数据</button>
</div>
<div class="strategy-panel">
<div class="panel-title"> 选择策略</div>
<div class="strategy-options">
<button
v-for="s in sortStrategies"
:key="s.key"
:class="['strategy-btn', { active: activeStrategy === s.key }]"
@click="activeStrategy = s.key"
>
{{ s.name }}
<span class="strategy-complexity">{{ s.complexity }}</span>
</button>
</div>
<button class="action-btn publish" @click="executeSort" :disabled="sorting">
{{ sorting ? '排序中...' : '▶ 执行排序' }}
</button>
<div v-if="sortSteps.length" class="steps-info">
步骤数: {{ sortSteps.length }} | 策略: {{ sortStrategies.find(s => s.key === activeStrategy)?.name }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const activeMode = ref('observer')
const modes = [
{ key: 'observer', name: '观察者模式', icon: '📡' },
{ key: 'strategy', name: '策略模式', icon: '⚙️' }
]
// === 观察者模式状态 ===
let subIdCounter = 0
const subscribers = ref([])
const eventMessage = ref('')
const observerLog = ref([])
const subNames = ['小明', '小红', '小刚', '小美', '小李', '小王', '小张', '小赵']
function addSubscriber() {
const name = subNames[subIdCounter % subNames.length]
subscribers.value.push({
id: ++subIdCounter,
name: name + '#' + subIdCounter,
lastMsg: '',
notified: false
})
observerLog.value.unshift(`[订阅] ${name}#${subIdCounter} 加入了订阅列表`)
}
function removeSubscriber(id) {
const sub = subscribers.value.find(s => s.id === id)
subscribers.value = subscribers.value.filter(s => s.id !== id)
if (sub) observerLog.value.unshift(`[取消订阅] ${sub.name} 离开了`)
}
function publishEvent() {
const msg = eventMessage.value.trim() || '默认事件'
observerLog.value.unshift(`[发布] 事件: "${msg}" → 通知 ${subscribers.value.length} 个订阅者`)
subscribers.value.forEach((sub, i) => {
setTimeout(() => {
sub.lastMsg = msg
sub.notified = true
setTimeout(() => { sub.notified = false }, 600)
}, i * 150)
})
eventMessage.value = ''
}
// === 策略模式状态 ===
const strategyData = ref([38, 15, 72, 46, 91, 23, 64, 8, 55, 30])
const activeStrategy = ref('bubble')
const sorting = ref(false)
const sortSteps = ref([])
const highlightIdx = ref(-1)
const sortStrategies = [
{ key: 'bubble', name: '冒泡排序', complexity: 'O(n²)' },
{ key: 'selection', name: '选择排序', complexity: 'O(n²)' },
{ key: 'insertion', name: '插入排序', complexity: 'O(n²)' }
]
function shuffleData() {
const arr = [...strategyData.value]
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]]
}
strategyData.value = arr
sortSteps.value = []
}
function barColor(i) {
if (i === highlightIdx.value) return '#f59e0b'
return `hsl(${strategyData.value[i] * 2.5}, 65%, 55%)`
}
async function executeSort() {
sorting.value = true
sortSteps.value = []
const arr = [...strategyData.value]
const steps = []
if (activeStrategy.value === 'bubble') {
for (let i = 0; i < arr.length; i++) {
for (let j = 0; j < arr.length - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]
steps.push({ arr: [...arr], idx: j + 1 })
}
}
}
} else if (activeStrategy.value === 'selection') {
for (let i = 0; i < arr.length; i++) {
let min = i
for (let j = i + 1; j < arr.length; j++) {
if (arr[j] < arr[min]) min = j
}
if (min !== i) {
[arr[i], arr[min]] = [arr[min], arr[i]]
steps.push({ arr: [...arr], idx: i })
}
}
} else {
for (let i = 1; i < arr.length; i++) {
const key = arr[i]
let j = i - 1
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j]
j--
}
arr[j + 1] = key
steps.push({ arr: [...arr], idx: j + 1 })
}
}
sortSteps.value = steps
for (const step of steps) {
strategyData.value = step.arr
highlightIdx.value = step.idx
await new Promise(r => setTimeout(r, 200))
}
highlightIdx.value = -1
sorting.value = false
}
function resetState() {
observerLog.value = []
subscribers.value = []
subIdCounter = 0
eventMessage.value = ''
strategyData.value = [38, 15, 72, 46, 91, 23, 64, 8, 55, 30]
sortSteps.value = []
sorting.value = false
highlightIdx.value = -1
}
</script>
<style scoped>
.pattern-playground {
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
padding: 20px;
margin: 16px 0;
background: var(--vp-c-bg-soft);
}
.demo-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
font-size: 18px;
font-weight: 600;
}
.demo-header .icon { font-size: 24px }
.demo-header .subtitle {
font-size: 13px;
color: var(--vp-c-text-2);
font-weight: 400;
}
.mode-tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.mode-btn {
flex: 1;
padding: 10px;
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg);
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
}
.mode-btn.active {
border-color: var(--vp-c-brand-1);
background: rgba(100, 108, 255, 0.08);
color: var(--vp-c-brand-1);
}
.playground-area { margin-top: 8px }
.playground-desc {
font-size: 13px;
color: var(--vp-c-text-2);
margin-bottom: 14px;
line-height: 1.6;
}
.panel-title {
font-weight: 600;
font-size: 14px;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
}
.action-btn {
padding: 6px 14px;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.action-btn:hover { background: var(--vp-c-bg-soft) }
.action-btn.publish {
background: var(--vp-c-brand-1);
color: #fff;
border-color: var(--vp-c-brand-1);
}
.action-btn.publish:hover { opacity: 0.85 }
.action-btn.add {
font-size: 12px;
padding: 3px 10px;
}
/* Observer */
.observer-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.publisher-panel, .subscribers-panel {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 14px;
background: var(--vp-c-bg);
}
.event-input {
display: flex;
gap: 8px;
}
.event-input input {
flex: 1;
padding: 6px 10px;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
font-size: 13px;
background: var(--vp-c-bg-soft);
}
.subscriber-card {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 6px;
margin-bottom: 4px;
font-size: 13px;
background: var(--vp-c-bg-soft);
transition: all 0.3s;
}
.subscriber-card.notified {
background: rgba(16, 185, 129, 0.15);
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.3);
}
.sub-name { font-weight: 500 }
.sub-msg {
flex: 1;
color: var(--vp-c-text-2);
font-size: 12px;
}
.remove-btn {
padding: 2px 8px;
border: 1px solid #f87171;
border-radius: 4px;
background: transparent;
color: #f87171;
cursor: pointer;
font-size: 11px;
}
.empty-hint {
font-size: 12px;
color: var(--vp-c-text-3);
text-align: center;
padding: 16px;
}
.event-log {
margin-top: 12px;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 10px;
background: var(--vp-c-bg);
max-height: 150px;
overflow-y: auto;
}
.log-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
}
.log-item {
font-size: 12px;
color: var(--vp-c-text-2);
padding: 2px 0;
border-bottom: 1px solid var(--vp-c-divider);
}
/* Strategy */
.strategy-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.data-panel, .strategy-panel {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 14px;
background: var(--vp-c-bg);
}
.data-bars {
display: flex;
align-items: flex-end;
gap: 4px;
height: 280px;
padding: 10px 0;
}
.bar {
flex: 1;
border-radius: 4px 4px 0 0;
display: flex;
align-items: flex-start;
justify-content: center;
min-width: 20px;
transition: height 0.2s, background 0.2s;
}
.bar-label {
font-size: 10px;
color: #fff;
font-weight: 600;
margin-top: 4px;
}
.strategy-options {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.strategy-btn {
padding: 8px 12px;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg-soft);
cursor: pointer;
font-size: 13px;
text-align: left;
display: flex;
justify-content: space-between;
transition: all 0.2s;
}
.strategy-btn.active {
border-color: var(--vp-c-brand-1);
background: rgba(100, 108, 255, 0.08);
}
.strategy-complexity {
font-size: 11px;
color: var(--vp-c-text-3);
}
.steps-info {
margin-top: 8px;
font-size: 12px;
color: var(--vp-c-text-2);
}
@media (max-width: 640px) {
.observer-layout, .strategy-layout {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,267 @@
<template>
<div class="refactoring-demo">
<div class="demo-label">重构手法对比演示 选择一种手法查看前后对比</div>
<div class="tabs">
<button
v-for="(item, i) in techniques"
:key="i"
:class="['tab-btn', { active: activeTab === i }]"
@click="selectTab(i)"
>
{{ item.name }}
</button>
</div>
<div class="desc">{{ current.description }}</div>
<div class="compare-area">
<div class="compare-panel before">
<div class="panel-header">
<span class="dot red"></span> 重构前
</div>
<pre class="code-block"><template
v-for="(seg, j) in current.before"
:key="'b'+j"
><span :class="{ highlight: showHighlight && seg.changed }">{{ seg.text }}</span></template></pre>
</div>
<div class="arrow-col">
<span class="arrow-icon"></span>
</div>
<div class="compare-panel after">
<div class="panel-header">
<span class="dot green"></span> 重构后
</div>
<pre class="code-block"><template
v-for="(seg, j) in current.after"
:key="'a'+j"
><span :class="{ highlight: showHighlight && seg.changed }">{{ seg.text }}</span></template></pre>
</div>
</div>
<div class="tip-box">
<strong>要点</strong>{{ current.tip }}
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const activeTab = ref(0)
const showHighlight = ref(false)
function selectTab(i) {
activeTab.value = i
showHighlight.value = false
setTimeout(() => { showHighlight.value = true }, 300)
}
// 初始化高亮
setTimeout(() => { showHighlight.value = true }, 500)
const techniques = [
{
name: '提炼函数',
description: 'Extract Function:将一段代码从大函数中提取出来,放入一个命名清晰的新函数中。',
before: [
{ text: 'function printReport(invoice) {\n console.log("=== 账单 ===")\n' },
{ text: ' // 计算总额\n let total = 0\n for (let item of invoice.items) {\n total += item.price * item.qty\n }\n', changed: true },
{ text: ' console.log(`总计: ${total}`)\n}' }
],
after: [
{ text: 'function printReport(invoice) {\n console.log("=== 账单 ===")\n' },
{ text: ' const total = calcTotal(invoice.items)\n', changed: true },
{ text: ' console.log(`总计: ${total}`)\n}\n\n' },
{ text: 'function calcTotal(items) {\n return items.reduce(\n (s, i) => s + i.price * i.qty, 0\n )\n}', changed: true }
],
tip: '提炼函数是最常用的重构手法。好的函数名就是最好的注释——如果你需要写注释解释一段代码在做什么,那它就该被提炼成函数。'
},
{
name: '重命名变量',
description: 'Rename Variable:用清晰、有意义的名称替换含糊的变量名,让代码自解释。',
before: [
{ text: 'function calc(', changed: true },
{ text: 'a, b, c', changed: true },
{ text: ') {\n' },
{ text: ' const d = a * b\n const e = d * (1 - c)\n return e\n}', changed: true }
],
after: [
{ text: 'function calcOrderTotal(', changed: true },
{ text: 'price, quantity, discountRate', changed: true },
{ text: ') {\n' },
{ text: ' const subtotal = price * quantity\n const total = subtotal * (1 - discountRate)\n return total\n}', changed: true }
],
tip: '变量命名是程序员最重要的基本功之一。好的命名让代码像散文一样可读,差的命名让代码像密码一样难解。'
},
{
name: '消除重复',
description: 'Remove Duplication:将重复的逻辑抽取为共享函数或模板,遵循 DRY 原则。',
before: [
{ text: '// 员工报表\nfunction empReport(emp) {\n' },
{ text: ' return `${emp.name} | ${emp.dept} | ${emp.salary}`', changed: true },
{ text: '\n}\n\n// 经理报表\nfunction mgrReport(mgr) {\n' },
{ text: ' return `${mgr.name} | ${mgr.dept} | ${mgr.salary}`', changed: true },
{ text: '\n}' }
],
after: [
{ text: '' },
{ text: 'function formatReport(person) {\n return `${person.name} | ${person.dept} | ${person.salary}`\n}', changed: true },
{ text: '\n\n// 统一调用\n' },
{ text: 'formatReport(employee)\nformatReport(manager)', changed: true }
],
tip: 'DRYDon\'t Repeat Yourself)是软件工程的基本原则。每一处重复都是未来 bug 的温床——改了一处忘了另一处,就是典型的重复代码事故。'
},
{
name: '简化条件',
description: 'Simplify Conditional:用卫语句、策略模式等手法替代深层嵌套的 if-else,降低圈复杂度。',
before: [
{ text: 'function getDiscount(user) {\n' },
{ text: ' if (user.type === "vip") {\n if (user.years > 5) {\n return 0.3\n } else {\n return 0.2\n }\n } else {\n if (user.years > 3) {\n return 0.1\n } else {\n return 0\n }\n }', changed: true },
{ text: '\n}' }
],
after: [
{ text: 'function getDiscount(user) {\n' },
{ text: ' if (user.type === "vip" && user.years > 5) return 0.3\n if (user.type === "vip") return 0.2\n if (user.years > 3) return 0.1\n return 0', changed: true },
{ text: '\n}' }
],
tip: '卫语句(Guard Clause)通过提前返回来消除嵌套。扁平的代码结构比深层嵌套更容易理解和维护。'
}
]
const current = computed(() => techniques[activeTab.value])
</script>
<style scoped>
.refactoring-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg-soft);
padding: 1rem 1.2rem;
margin: 1rem 0;
}
.demo-label {
font-size: 0.78rem;
font-weight: bold;
color: var(--vp-c-text-2);
margin-bottom: 0.75rem;
}
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 0.6rem;
flex-wrap: wrap;
}
.tab-btn {
padding: 0.35rem 0.8rem;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg);
color: var(--vp-c-text-2);
cursor: pointer;
font-size: 0.82rem;
transition: all 0.2s;
}
.tab-btn.active {
background: var(--vp-c-brand-1);
color: #fff;
border-color: var(--vp-c-brand-1);
}
.desc {
font-size: 0.82rem;
color: var(--vp-c-text-2);
margin-bottom: 0.8rem;
line-height: 1.5;
}
.compare-area {
display: flex;
gap: 0.5rem;
align-items: stretch;
}
@media (max-width: 640px) {
.compare-area {
flex-direction: column;
}
.arrow-col {
transform: rotate(90deg);
}
}
.compare-panel {
flex: 1;
min-width: 0;
}
.panel-header {
font-size: 0.75rem;
font-weight: 600;
color: var(--vp-c-text-2);
margin-bottom: 0.3rem;
display: flex;
align-items: center;
gap: 0.4rem;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.dot.red { background: #ef4444; }
.dot.green { background: #22c55e; }
.code-block {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
padding: 0.8rem;
font-size: 0.75rem;
line-height: 1.6;
overflow-x: auto;
margin: 0;
white-space: pre;
font-family: 'Fira Code', 'Consolas', monospace;
min-height: 140px;
}
.highlight {
background: rgba(34, 197, 94, 0.15);
border-radius: 2px;
transition: background 0.6s ease;
}
.before .highlight {
background: rgba(239, 68, 68, 0.12);
}
.arrow-col {
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
color: var(--vp-c-text-3);
padding: 0 0.2rem;
}
.tip-box {
margin-top: 0.8rem;
padding: 0.6rem 0.8rem;
background: rgba(59, 130, 246, 0.08);
border-left: 3px solid var(--vp-c-brand-1);
border-radius: 0 6px 6px 0;
font-size: 0.8rem;
color: var(--vp-c-text-2);
line-height: 1.6;
}
</style>
@@ -0,0 +1,295 @@
<template>
<div class="checklist-demo">
<div class="header">
<div class="title">项目安全检查清单</div>
<div class="subtitle">勾选已完成的安全措施查看项目安全评分</div>
</div>
<div class="score-bar">
<div class="score-label">安全评分</div>
<div class="score-track">
<div
class="score-fill"
:style="{ width: score + '%', background: scoreColor }"
/>
</div>
<div class="score-value" :style="{ color: scoreColor }">
{{ score }}
</div>
<div class="score-level" :style="{ color: scoreColor }">
{{ scoreLevel }}
</div>
</div>
<div v-for="(cat, ci) in categories" :key="ci" class="category">
<div class="cat-header" @click="cat.open = !cat.open">
<span class="cat-icon">{{ cat.icon }}</span>
<span class="cat-name">{{ cat.name }}</span>
<span class="cat-progress">
{{ checkedCount(ci) }}/{{ cat.items.length }}
</span>
<span class="cat-arrow">{{ cat.open ? '▾' : '▸' }}</span>
</div>
<div v-if="cat.open" class="cat-items">
<div
v-for="(item, ii) in cat.items"
:key="ii"
class="check-item"
>
<div class="item-row" @click="item.checked = !item.checked">
<input type="checkbox" v-model="item.checked" @click.stop />
<span :class="['item-text', { done: item.checked }]">
{{ item.label }}
</span>
</div>
<div
class="item-detail"
v-if="item.showDetail"
>
{{ item.detail }}
</div>
<button
class="detail-toggle"
@click="item.showDetail = !item.showDetail"
>
{{ item.showDetail ? '收起' : '查看最佳实践' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { reactive, computed } from 'vue'
const categories = reactive([
{
icon: '🔍',
name: '输入验证',
open: true,
items: [
{ label: '所有用户输入在服务端进行校验', checked: false, showDetail: false, detail: '永远不要仅依赖前端校验。攻击者可以绕过浏览器直接发送请求,服务端必须对长度、类型、格式、范围做二次验证。' },
{ label: '使用白名单而非黑名单过滤', checked: false, showDetail: false, detail: '黑名单容易遗漏。应明确定义"允许什么"而非"禁止什么",例如只允许字母数字而非试图过滤所有特殊字符。' },
{ label: '对文件上传进行类型和大小限制', checked: false, showDetail: false, detail: '校验文件 MIME 类型和扩展名,限制文件大小,将上传文件存储在 Web 根目录之外,使用随机文件名。' }
]
},
{
icon: '🔐',
name: '认证授权',
open: false,
items: [
{ label: '密码使用 bcrypt/argon2 哈希存储', checked: false, showDetail: false, detail: '绝不明文存储密码。使用自带盐值的慢哈希算法(bcrypt cost>=10 或 argon2id),抵御彩虹表和暴力破解。' },
{ label: '实施多因素认证 (MFA)', checked: false, showDetail: false, detail: '在密码之外增加第二因素(TOTP、短信、硬件密钥),即使密码泄露也能阻止未授权登录。' },
{ label: '接口实施最小权限访问控制', checked: false, showDetail: false, detail: '每个 API 端点都应检查用户角色和权限,确保用户只能访问自己有权操作的资源(RBAC / ABAC)。' },
{ label: '会话管理安全(超时、轮换)', checked: false, showDetail: false, detail: '登录后重新生成 Session ID,设置合理的过期时间,登出时销毁服务端会话。' }
]
},
{
icon: '🛡️',
name: '数据保护',
open: false,
items: [
{ label: '敏感数据加密存储', checked: false, showDetail: false, detail: '对数据库中的敏感字段(手机号、身份证等)使用 AES-256 等算法加密,密钥与数据分离存储。' },
{ label: '日志中不记录敏感信息', checked: false, showDetail: false, detail: '日志中不应出现密码、Token、信用卡号等。使用脱敏处理,如只记录手机号后四位。' },
{ label: '实施 SQL 注入防护(参数化查询)', checked: false, showDetail: false, detail: '所有数据库操作使用参数化查询或 ORM,绝不拼接 SQL 字符串。' }
]
},
{
icon: '🌐',
name: '通信安全',
open: false,
items: [
{ label: '全站启用 HTTPS', checked: false, showDetail: false, detail: '使用 TLS 1.2+ 加密所有通信,配置 HSTS 头强制 HTTPS,防止中间人攻击和数据窃听。' },
{ label: '设置安全响应头(CSP、X-Frame-Options', checked: false, showDetail: false, detail: '配置 Content-Security-Policy 限制资源加载来源,X-Frame-Options 防止点击劫持,X-Content-Type-Options 防止 MIME 嗅探。' },
{ label: 'Cookie 设置 HttpOnly / Secure / SameSite', checked: false, showDetail: false, detail: 'HttpOnly 防止 JS 读取,Secure 确保仅 HTTPS 传输,SameSite=Lax 防止 CSRF 攻击。' }
]
}
])
const totalItems = computed(() =>
categories.reduce((sum, c) => sum + c.items.length, 0)
)
const totalChecked = computed(() =>
categories.reduce(
(sum, c) => sum + c.items.filter((i) => i.checked).length,
0
)
)
const score = computed(() =>
Math.round((totalChecked.value / totalItems.value) * 100)
)
const scoreColor = computed(() => {
if (score.value >= 80) return '#27ae60'
if (score.value >= 50) return '#f39c12'
return '#e74c3c'
})
const scoreLevel = computed(() => {
if (score.value >= 80) return '优秀'
if (score.value >= 50) return '及格'
return '危险'
})
const checkedCount = (ci) =>
categories[ci].items.filter((i) => i.checked).length
</script>
<style scoped>
.checklist-demo {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
border-radius: 6px;
padding: 1.5rem;
margin: 0.5rem 0;
}
.header { margin-bottom: 1rem; }
.title {
font-weight: 800;
color: var(--vp-c-text-1);
}
.subtitle {
margin-top: 0.25rem;
color: var(--vp-c-text-2);
font-size: 0.9rem;
}
.score-bar {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
}
.score-label {
font-weight: 700;
font-size: 0.85rem;
color: var(--vp-c-text-2);
white-space: nowrap;
}
.score-track {
flex: 1;
height: 8px;
background: var(--vp-c-divider);
border-radius: 4px;
overflow: hidden;
}
.score-fill {
height: 100%;
border-radius: 4px;
transition: width 0.4s, background 0.4s;
}
.score-value {
font-weight: 800;
font-size: 1.1rem;
white-space: nowrap;
}
.score-level {
font-weight: 700;
font-size: 0.85rem;
white-space: nowrap;
}
.category {
margin-bottom: 0.5rem;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
overflow: hidden;
}
.cat-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
background: var(--vp-c-bg);
cursor: pointer;
user-select: none;
}
.cat-icon { font-size: 1rem; }
.cat-name {
font-weight: 700;
color: var(--vp-c-text-1);
flex: 1;
}
.cat-progress {
font-size: 0.8rem;
color: var(--vp-c-text-3);
}
.cat-arrow {
color: var(--vp-c-text-3);
font-size: 0.8rem;
}
.cat-items {
border-top: 1px solid var(--vp-c-divider);
}
.check-item {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--vp-c-divider);
}
.check-item:last-child {
border-bottom: none;
}
.item-row {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.item-text {
color: var(--vp-c-text-1);
font-size: 0.9rem;
}
.item-text.done {
text-decoration: line-through;
color: var(--vp-c-text-3);
}
.item-detail {
margin-top: 0.4rem;
padding: 0.5rem 0.75rem;
background: var(--vp-c-bg-alt);
border-radius: 4px;
color: var(--vp-c-text-2);
font-size: 0.85rem;
line-height: 1.7;
}
.detail-toggle {
margin-top: 0.3rem;
background: none;
border: none;
color: var(--vp-c-brand);
cursor: pointer;
font-size: 0.8rem;
padding: 0;
}
@media (max-width: 720px) {
.score-bar { flex-wrap: wrap; }
}
</style>
@@ -0,0 +1,221 @@
<template>
<div class="tdd-cycle-demo">
<div class="demo-label">TDD 红绿重构循环 点击"下一步"推进</div>
<div class="cycle-visual">
<div
v-for="(phase, i) in phases"
:key="phase.name"
class="phase-node"
:class="[phase.cls, { active: current === i }]"
>
<span class="phase-icon">{{ phase.icon }}</span>
<span class="phase-name">{{ phase.name }}</span>
</div>
</div>
<div class="step-card" :class="steps[step].cls">
<div class="step-header">
<span class="step-badge"> {{ step + 1 }} / {{ steps.length }}</span>
<span class="step-phase">{{ steps[step].phase }}</span>
</div>
<div class="step-desc">{{ steps[step].desc }}</div>
<div class="code-block">
<div class="code-title">{{ steps[step].fileLabel }}</div>
<pre><code>{{ steps[step].code }}</code></pre>
</div>
<div class="step-result" :class="steps[step].cls">
{{ steps[step].result }}
</div>
</div>
<div class="controls">
<button class="btn" :disabled="step === 0" @click="step--">上一步</button>
<button class="btn primary" :disabled="step === steps.length - 1" @click="step++">下一步</button>
<button class="btn" @click="step = 0">重置</button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const step = ref(0)
const current = computed(() => {
const s = steps[step.value]
return s.cls === 'red' ? 0 : s.cls === 'green' ? 1 : 2
})
const phases = [
{ name: 'Red', icon: '🔴', cls: 'red' },
{ name: 'Green', icon: '🟢', cls: 'green' },
{ name: 'Refactor', icon: '🔵', cls: 'blue' }
]
const steps = [
{
phase: '🔴 Red — 先写一个失败的测试',
cls: 'red',
desc: '需求:实现 add(a, b) 函数。TDD 第一步不是写实现,而是先写测试。',
fileLabel: 'add.test.js',
code: `test('add(1, 2) 应该返回 3', () => {
expect(add(1, 2)).toBe(3)
})`,
result: '❌ 测试失败 — add is not defined'
},
{
phase: '🟢 Green — 写最小实现让测试通过',
cls: 'green',
desc: '不追求完美,只写刚好让测试通过的代码。',
fileLabel: 'add.js',
code: `function add(a, b) {
return a + b
}`,
result: '✅ 测试通过!'
},
{
phase: '🔵 Refactor — 重构优化',
cls: 'blue',
desc: '测试通过后安全地改进代码,测试是你的安全网。',
fileLabel: 'add.js',
code: `const add = (a, b) => a + b`,
result: '✅ 重构完成,测试仍然通过!'
},
{
phase: '🔴 Red — 添加新需求的测试',
cls: 'red',
desc: '新需求:add 应该能处理字符串数字。继续循环!',
fileLabel: 'add.test.js',
code: `test('add("1", "2") 应该返回 3', () => {
expect(add('1', '2')).toBe(3)
})`,
result: '❌ 测试失败 — 返回了 "12" 而不是 3'
},
{
phase: '🟢 Green — 修复实现',
cls: 'green',
desc: '修改实现以处理字符串输入。',
fileLabel: 'add.js',
code: `const add = (a, b) => Number(a) + Number(b)`,
result: '✅ 所有测试通过!'
}
]
</script>
<style scoped>
.tdd-cycle-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg-soft);
padding: 1rem 1.2rem;
margin: 1rem 0;
}
.demo-label {
font-size: 0.78rem;
font-weight: bold;
color: var(--vp-c-text-2);
margin-bottom: 1rem;
text-align: center;
}
.cycle-visual {
display: flex;
align-items: center;
justify-content: center;
gap: 2rem;
margin-bottom: 1rem;
}
.phase-node {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px 16px;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
opacity: 0.4;
transition: opacity 0.3s, transform 0.3s;
}
.phase-node.active { opacity: 1; transform: scale(1.15); }
.phase-node.red { background: #fee2e2; color: #991b1b; }
.phase-node.green { background: #d1fae5; color: #065f46; }
.phase-node.blue { background: #dbeafe; color: #1e40af; }
:root.dark .phase-node.red { background: #450a0a; color: #fca5a5; }
:root.dark .phase-node.green { background: #022c22; color: #6ee7b7; }
:root.dark .phase-node.blue { background: #172554; color: #93c5fd; }
.phase-icon { font-size: 1.4rem; }
.step-card {
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
border: 1px solid var(--vp-c-divider);
}
.step-card.red { border-color: #fca5a5; background: #fef2f2; }
.step-card.green { border-color: #6ee7b7; background: #ecfdf5; }
.step-card.blue { border-color: #93c5fd; background: #eff6ff; }
:root.dark .step-card.red { border-color: #7f1d1d; background: #1c0606; }
:root.dark .step-card.green { border-color: #065f46; background: #031c14; }
:root.dark .step-card.blue { border-color: #1e40af; background: #0c1529; }
.step-header { display: flex; align-items: center; gap: 8px; margin-bottom: 0.5rem; }
.step-badge {
font-size: 0.72rem;
padding: 2px 8px;
border-radius: 10px;
background: var(--vp-c-bg);
color: var(--vp-c-text-2);
}
.step-phase { font-weight: 600; font-size: 0.95rem; }
.step-desc { font-size: 0.85rem; color: var(--vp-c-text-2); margin-bottom: 0.6rem; }
.code-block {
background: var(--vp-c-bg);
border-radius: 6px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.code-title {
font-size: 0.72rem;
padding: 4px 10px;
background: var(--vp-c-bg-soft);
color: var(--vp-c-text-3);
border-bottom: 1px solid var(--vp-c-divider);
}
.code-block pre { margin: 0; padding: 10px; font-size: 0.82rem; line-height: 1.5; overflow-x: auto; }
.step-result { font-size: 0.85rem; font-weight: 600; padding: 6px 10px; border-radius: 4px; }
.step-result.red { color: #dc2626; }
.step-result.green { color: #059669; }
.step-result.blue { color: #2563eb; }
.controls { display: flex; gap: 8px; justify-content: center; }
.btn {
padding: 6px 16px;
border-radius: 6px;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
color: var(--vp-c-text-1);
cursor: pointer;
font-size: 0.85rem;
transition: background 0.2s;
}
.btn:hover:not(:disabled) { background: var(--vp-c-bg-soft); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn.primary { background: var(--vp-c-brand); color: #fff; border-color: var(--vp-c-brand); }
.btn.primary:hover:not(:disabled) { opacity: 0.9; }
</style>
@@ -0,0 +1,137 @@
<template>
<div class="tech-radar-demo">
<div class="demo-label">技术雷达 点击技术点查看详情</div>
<div class="radar-container">
<div class="radar-rings">
<div v-for="ring in rings" :key="ring.name" class="ring" :class="ring.cls">
<span class="ring-label">{{ ring.name }}</span>
</div>
</div>
<div
v-for="tech in techs"
:key="tech.name"
class="tech-dot"
:class="[tech.category, { active: selected === tech.name }]"
:style="tech.pos"
@click="selected = selected === tech.name ? '' : tech.name"
>
<span class="dot-label">{{ tech.name }}</span>
</div>
</div>
<div class="legend">
<span v-for="c in cats" :key="c.cls" class="legend-item">
<span class="legend-dot" :class="c.cls"></span>{{ c.name }}
</span>
</div>
<Transition name="fade">
<div v-if="selectedTech" class="info-card">
<h4>{{ selectedTech.name }}</h4>
<div class="info-ring">环位{{ selectedTech.ring }}</div>
<p>{{ selectedTech.desc }}</p>
</div>
</Transition>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const selected = ref('')
const selectedTech = computed(() => techs.find(t => t.name === selected.value))
const rings = [
{ name: '采纳', cls: 'adopt' },
{ name: '试验', cls: 'trial' },
{ name: '评估', cls: 'assess' },
{ name: '暂缓', cls: 'hold' }
]
const cats = [
{ name: '语言', cls: 'lang' },
{ name: '框架', cls: 'framework' },
{ name: '工具', cls: 'tool' },
{ name: '平台', cls: 'platform' }
]
const techs = [
{ name: 'TypeScript', category: 'lang', ring: '采纳', pos: { top: '42%', left: '30%' }, desc: '类型安全的 JavaScript 超集,已成为前端项目标配。' },
{ name: 'React', category: 'framework', ring: '采纳', pos: { top: '35%', left: '55%' }, desc: '生态最丰富的前端框架,适合大型团队和复杂应用。' },
{ name: 'Vue', category: 'framework', ring: '采纳', pos: { top: '50%', left: '45%' }, desc: '渐进式框架,学习曲线平缓,中文社区活跃。' },
{ name: 'Go', category: 'lang', ring: '采纳', pos: { top: '55%', left: '32%' }, desc: '高并发后端首选,编译快、部署简单。' },
{ name: 'Rust', category: 'lang', ring: '试验', pos: { top: '30%', left: '22%' }, desc: '内存安全无 GC,适合系统编程和高性能场景,学习曲线陡峭。' },
{ name: 'Svelte', category: 'framework', ring: '试验', pos: { top: '25%', left: '60%' }, desc: '编译时框架,无虚拟 DOM,包体积极小。' },
{ name: 'Bun', category: 'tool', ring: '评估', pos: { top: '18%', left: '42%' }, desc: '新一代 JS 运行时,速度极快但生态尚在完善。' },
{ name: 'Deno', category: 'platform', ring: '评估', pos: { top: '15%', left: '55%' }, desc: '安全优先的 JS/TS 运行时,内置工具链。' },
{ name: 'jQuery', category: 'framework', ring: '暂缓', pos: { top: '8%', left: '38%' }, desc: '历史功臣,但现代框架已全面替代,新项目不建议使用。' }
]
</script>
<style scoped>
.tech-radar-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg-soft);
padding: 1rem 1.2rem;
margin: 1rem 0;
}
.demo-label { font-size: 0.78rem; font-weight: bold; color: var(--vp-c-text-2); margin-bottom: 1rem; text-align: center; }
.radar-container { position: relative; width: 100%; padding-top: 70%; margin-bottom: 1rem; }
.radar-rings { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.ring {
border-radius: 50%;
position: absolute;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 4px;
}
.ring .ring-label { font-size: 0.68rem; font-weight: 600; opacity: 0.6; }
.ring.adopt { width: 90%; height: 90%; background: #d1fae520; border: 1px dashed #6ee7b7; }
.ring.trial { width: 66%; height: 66%; background: #dbeafe20; border: 1px dashed #93c5fd; }
.ring.assess { width: 42%; height: 42%; background: #fef3c720; border: 1px dashed #fcd34d; }
.ring.hold { width: 20%; height: 20%; background: #fee2e220; border: 1px dashed #fca5a5; }
.tech-dot {
position: absolute;
padding: 3px 8px;
border-radius: 12px;
font-size: 0.72rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
white-space: nowrap;
}
.tech-dot:hover { transform: scale(1.1); }
.tech-dot.active { box-shadow: 0 0 0 2px var(--vp-c-brand); transform: scale(1.15); }
.tech-dot.lang { background: #dbeafe; color: #1e40af; }
.tech-dot.framework { background: #d1fae5; color: #065f46; }
.tech-dot.tool { background: #fef3c7; color: #92400e; }
.tech-dot.platform { background: #fae8ff; color: #86198f; }
:root.dark .tech-dot.lang { background: #172554; color: #93c5fd; }
:root.dark .tech-dot.framework { background: #022c22; color: #6ee7b7; }
:root.dark .tech-dot.tool { background: #451a03; color: #fcd34d; }
:root.dark .tech-dot.platform { background: #4a044e; color: #f0abfc; }
.legend { display: flex; justify-content: center; gap: 1rem; font-size: 0.75rem; color: var(--vp-c-text-3); margin-bottom: 0.8rem; flex-wrap: wrap; }
.legend-item { display: flex; align-items: center; gap: 4px; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
.legend-dot.lang { background: #3b82f6; }
.legend-dot.framework { background: #10b981; }
.legend-dot.tool { background: #f59e0b; }
.legend-dot.platform { background: #d946ef; }
.info-card { border: 1px solid var(--vp-c-divider); border-radius: 8px; padding: 1rem; background: var(--vp-c-bg); }
.info-card h4 { margin: 0 0 4px; font-size: 1rem; }
.info-ring { font-size: 0.75rem; color: var(--vp-c-text-3); margin-bottom: 6px; }
.info-card p { font-size: 0.85rem; color: var(--vp-c-text-2); margin: 0; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.25s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
@@ -0,0 +1,121 @@
<template>
<div class="tech-writing-demo">
<div class="demo-label">技术写作对比 点击切换案例</div>
<div class="tabs">
<button
v-for="(c, i) in cases"
:key="i"
class="tab"
:class="{ active: current === i }"
@click="current = i"
>{{ c.icon }} {{ c.name }}</button>
</div>
<div class="compare">
<div class="col bad">
<div class="col-title"> 差的写法</div>
<pre><code>{{ cases[current].bad }}</code></pre>
</div>
<div class="col good">
<div class="col-title"> 好的写法</div>
<pre><code>{{ cases[current].good }}</code></pre>
</div>
</div>
<div class="tips">
<strong>改进要点</strong>
<span v-for="(t, i) in cases[current].tips" :key="i" class="tip-tag">{{ t }}</span>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const current = ref(0)
const cases = [
{
name: '函数注释',
icon: '💬',
bad: `// 处理数据
function process(d) {
// ...
}`,
good: `/**
* 将原始订单数据转换为发票格式
* @param {Order} order - 原始订单对象
* @returns {Invoice} 格式化后的发票
* @throws {ValidationError} 订单数据不完整时
*/
function toInvoice(order) {
// ...
}`,
tips: ['说明"为什么"而非"是什么"', '标注参数类型和返回值', '说明异常情况']
},
{
name: 'API 说明',
icon: '🔌',
bad: `POST /api/users
发送用户数据创建用户。`,
good: `POST /api/users
创建新用户账号。
请求体:
{
"name": "张三", // 必填,2-50字符
"email": "a@b.com" // 必填,有效邮箱
}
成功响应 201
{ "id": "u_123", "name": "张三" }
错误响应 400
{ "error": "邮箱格式无效" }`,
tips: ['提供完整的请求/响应示例', '标注必填/选填', '列出错误场景']
},
{
name: '变更日志',
icon: '📝',
bad: `v2.1 - 修了一些bug,加了新功能`,
good: `## v2.1.0 (2025-01-15)
### 新增
- 支持批量导出 PDF 格式报表
### 修复
- 修复登录页在 Safari 下白屏的问题 (#234)
### 变更
- 最低 Node.js 版本要求从 16 升至 18`,
tips: ['按类型分类(新增/修复/变更)', '关联 Issue 编号', '标注版本号和日期']
}
]
</script>
<style scoped>
.tech-writing-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg-soft);
padding: 1rem 1.2rem;
margin: 1rem 0;
}
.demo-label { font-size: 0.78rem; font-weight: bold; color: var(--vp-c-text-2); margin-bottom: 1rem; text-align: center; }
.tabs { display: flex; gap: 6px; margin-bottom: 1rem; flex-wrap: wrap; }
.tab { padding: 6px 14px; border-radius: 6px; border: 1px solid var(--vp-c-divider); background: var(--vp-c-bg); cursor: pointer; font-size: 0.85rem; transition: all 0.2s; }
.tab.active { background: var(--vp-c-brand); color: #fff; border-color: var(--vp-c-brand); }
.compare { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 10px; }
@media (max-width: 640px) { .compare { grid-template-columns: 1fr; } }
.col { border-radius: 6px; overflow: hidden; }
.col-title { font-size: 0.72rem; padding: 4px 10px; border-bottom: 1px solid var(--vp-c-divider); }
.col.bad .col-title { background: #fef2f2; color: #991b1b; }
.col.good .col-title { background: #ecfdf5; color: #065f46; }
:root.dark .col.bad .col-title { background: #1c0606; color: #fca5a5; }
:root.dark .col.good .col-title { background: #031c14; color: #6ee7b7; }
.col pre { margin: 0; padding: 8px; font-size: 0.78rem; line-height: 1.5; overflow-x: auto; background: var(--vp-c-bg); }
.tips { font-size: 0.83rem; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.tip-tag { padding: 2px 8px; border-radius: 10px; background: var(--vp-c-brand-soft); font-size: 0.75rem; }
</style>
@@ -0,0 +1,182 @@
<template>
<div class="test-pyramid-demo">
<div class="demo-label">交互式测试金字塔 点击每一层查看详情</div>
<div class="pyramid-container">
<div
v-for="(layer, i) in layers"
:key="layer.name"
class="pyramid-layer"
:class="[layer.cls, { active: selected === i }]"
:style="{ width: layer.width }"
@click="selected = selected === i ? -1 : i"
>
<span class="layer-icon">{{ layer.icon }}</span>
<span class="layer-name">{{ layer.name }}</span>
</div>
</div>
<Transition name="fade">
<div v-if="selected >= 0" class="detail-card" :class="layers[selected].cls">
<h4>{{ layers[selected].icon }} {{ layers[selected].name }}</h4>
<table>
<tr v-for="row in detailRows" :key="row.key">
<td class="row-label">{{ row.label }}</td>
<td>{{ layers[selected][row.key] }}</td>
</tr>
</table>
<div class="example">
<strong>示例</strong>{{ layers[selected].example }}
</div>
</div>
</Transition>
<div class="pyramid-legend">
<span class="legend-item"><span class="dot e2e"></span>越往上越慢越贵越接近用户</span>
<span class="legend-item"><span class="dot unit"></span>越往下越快越多越接近代码</span>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const selected = ref(-1)
const detailRows = [
{ key: 'count', label: '数量占比' },
{ key: 'speed', label: '执行速度' },
{ key: 'cost', label: '维护成本' },
{ key: 'scope', label: '覆盖范围' },
{ key: 'confidence', label: '信心指数' }
]
const layers = [
{
name: 'E2E 测试',
cls: 'e2e',
icon: '🖥️',
width: '40%',
count: '约 10%',
speed: '慢(秒~分钟级)',
cost: '高 — 环境依赖多,易碎',
scope: '完整用户流程',
confidence: '最高 — 模拟真实用户操作',
example: '用 Playwright 模拟用户登录 → 下单 → 支付的完整流程'
},
{
name: '集成测试',
cls: 'integration',
icon: '🔗',
width: '60%',
count: '约 20%',
speed: '中等(百毫秒级)',
cost: '中 — 需要部分外部依赖',
scope: '模块间协作',
confidence: '较高 — 验证组件间的配合',
example: '测试 API 接口能否正确读写数据库并返回预期 JSON'
},
{
name: '单元测试',
cls: 'unit',
icon: '🧪',
width: '85%',
count: '约 70%',
speed: '极快(毫秒级)',
cost: '低 — 无外部依赖',
scope: '单个函数/类',
confidence: '基础 — 确保每个零件正常',
example: '测试 formatPrice(100) 是否返回 "¥1.00"'
}
]
</script>
<style scoped>
.test-pyramid-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg-soft);
padding: 1rem 1.2rem;
margin: 1rem 0;
}
.demo-label {
font-size: 0.78rem;
font-weight: bold;
color: var(--vp-c-text-2);
margin-bottom: 1rem;
text-align: center;
}
.pyramid-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
margin-bottom: 1rem;
}
.pyramid-layer {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 0;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
font-size: 0.95rem;
transition: transform 0.2s, box-shadow 0.2s;
user-select: none;
}
.pyramid-layer:hover { transform: scale(1.03); }
.pyramid-layer.active { box-shadow: 0 0 0 2px var(--vp-c-brand); transform: scale(1.05); }
.pyramid-layer.e2e { background: #fee2e2; color: #991b1b; }
.pyramid-layer.integration { background: #fef3c7; color: #92400e; }
.pyramid-layer.unit { background: #d1fae5; color: #065f46; }
:root.dark .pyramid-layer.e2e { background: #450a0a; color: #fca5a5; }
:root.dark .pyramid-layer.integration { background: #451a03; color: #fcd34d; }
:root.dark .pyramid-layer.unit { background: #022c22; color: #6ee7b7; }
.detail-card {
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.detail-card.e2e { background: #fef2f2; border: 1px solid #fecaca; }
.detail-card.integration { background: #fffbeb; border: 1px solid #fde68a; }
.detail-card.unit { background: #ecfdf5; border: 1px solid #a7f3d0; }
:root.dark .detail-card.e2e { background: #1c0606; border-color: #7f1d1d; }
:root.dark .detail-card.integration { background: #1c1303; border-color: #78350f; }
:root.dark .detail-card.unit { background: #031c14; border-color: #065f46; }
.detail-card h4 { margin: 0 0 0.6rem; font-size: 1rem; }
.detail-card table { width: 100%; font-size: 0.85rem; border-collapse: collapse; }
.detail-card td { padding: 4px 8px; border-bottom: 1px solid var(--vp-c-divider); }
.row-label { font-weight: 600; white-space: nowrap; width: 80px; color: var(--vp-c-text-2); }
.example { margin-top: 0.6rem; font-size: 0.83rem; color: var(--vp-c-text-2); }
.pyramid-legend {
display: flex;
justify-content: center;
gap: 1.2rem;
font-size: 0.75rem;
color: var(--vp-c-text-3);
flex-wrap: wrap;
}
.legend-item { display: flex; align-items: center; gap: 4px; }
.dot { width: 8px; height: 8px; border-radius: 50%; }
.dot.e2e { background: #ef4444; }
.dot.unit { background: #10b981; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.25s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
@@ -0,0 +1,143 @@
<template>
<div class="web-security-demo">
<div class="demo-label">Web 安全漏洞演示教育用途 点击切换漏洞类型</div>
<div class="tabs">
<button
v-for="(v, i) in vulns"
:key="i"
class="tab"
:class="{ active: current === i }"
@click="current = i"
>{{ v.icon }} {{ v.name }}</button>
</div>
<div class="vuln-card">
<div class="attack-flow">
<div class="flow-title">攻击流程</div>
<div class="flow-steps">
<div v-for="(s, j) in vulns[current].flow" :key="j" class="flow-step">
<span class="step-num">{{ j + 1 }}</span>
<span class="step-text">{{ s }}</span>
</div>
</div>
</div>
<div class="code-compare">
<div class="code-col bad">
<div class="col-title"> 有漏洞的代码</div>
<pre><code>{{ vulns[current].bad }}</code></pre>
</div>
<div class="code-col good">
<div class="col-title"> 修复后的代码</div>
<pre><code>{{ vulns[current].good }}</code></pre>
</div>
</div>
<div class="defense-tip">
<strong>防御要点</strong>{{ vulns[current].defense }}
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const current = ref(0)
const vulns = [
{
name: 'XSS',
icon: '💉',
flow: [
'攻击者在输入框提交恶意脚本',
'服务器未过滤直接存入数据库',
'其他用户访问页面时脚本被执行',
'用户 Cookie/数据被窃取'
],
bad: '// 直接插入用户输入(危险!)\nel.innerHTML = userInput\n// 如果 userInput = \'<scr\' + \'ipt>steal(cookie)</scr\' + \'ipt>\'\n// 脚本会被执行!',
good: `// 使用 textContent 安全插入
el.textContent = userInput
// 或使用框架自动转义
// Vue: {{ userInput }} 自动转义
// React: {userInput} 自动转义`,
defense: '永远不要信任用户输入。使用框架自带的转义机制,避免 innerHTML,对输出进行编码。'
},
{
name: 'SQL 注入',
icon: '🗄️',
flow: [
'攻击者在登录框输入特殊字符串',
'字符串被拼接进 SQL 语句',
'数据库执行了被篡改的查询',
'攻击者绕过认证或获取数据'
],
bad: `// 字符串拼接 SQL(危险!)
const sql = "SELECT * FROM users " +
"WHERE name='" + username + "'" +
" AND pass='" + password + "'"
// 输入: admin' OR '1'='1
// 变成: WHERE name='admin' OR '1'='1'`,
good: `// 使用参数化查询(安全)
const sql = "SELECT * FROM users " +
"WHERE name = ? AND pass = ?"
db.query(sql, [username, password])
// 参数被安全转义,无法注入`,
defense: '始终使用参数化查询或 ORM,永远不要拼接 SQL 字符串。'
},
{
name: 'CSRF',
icon: '🎭',
flow: [
'用户登录了银行网站(有 Cookie',
'用户访问了恶意网站',
'恶意网站自动发起转账请求',
'浏览器自动携带 Cookie,请求成功'
],
bad: '<!-- 恶意网站的隐藏表单 -->\n<form action="https://bank.com/transfer"\n method="POST" id="evil">\n <input name="to" value="attacker" />\n <input name="amount" value="10000" />\n</form>\n<scr' + 'ipt>document.getElementById(\'evil\')\n .submit()</scr' + 'ipt>',
good: `// 服务端:生成并验证 CSRF Token
app.post('/transfer', (req, res) => {
if (req.body.token !== req.session.csrf) {
return res.status(403).send('拒绝')
}
// 执行转账...
})
// 同时设置 SameSite Cookie 属性`,
defense: '使用 CSRF Token、设置 SameSite Cookie 属性、验证 Referer/Origin 头。'
}
]
</script>
<style scoped>
.web-security-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg-soft);
padding: 1rem 1.2rem;
margin: 1rem 0;
}
.demo-label { font-size: 0.78rem; font-weight: bold; color: var(--vp-c-text-2); margin-bottom: 1rem; text-align: center; }
.tabs { display: flex; gap: 6px; margin-bottom: 1rem; flex-wrap: wrap; }
.tab { padding: 6px 14px; border-radius: 6px; border: 1px solid var(--vp-c-divider); background: var(--vp-c-bg); cursor: pointer; font-size: 0.85rem; transition: all 0.2s; }
.tab.active { background: var(--vp-c-brand); color: #fff; border-color: var(--vp-c-brand); }
.attack-flow { margin-bottom: 12px; }
.flow-title { font-size: 0.8rem; font-weight: 600; color: var(--vp-c-text-2); margin-bottom: 6px; }
.flow-steps { display: flex; gap: 4px; flex-wrap: wrap; }
.flow-step { display: flex; align-items: center; gap: 6px; font-size: 0.8rem; padding: 4px 8px; background: var(--vp-c-bg); border-radius: 4px; }
.flow-step::after { content: '→'; color: var(--vp-c-text-3); margin-left: 4px; }
.flow-step:last-child::after { content: ''; }
.step-num { width: 18px; height: 18px; border-radius: 50%; background: var(--vp-c-brand); color: #fff; font-size: 0.7rem; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.code-compare { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 10px; }
@media (max-width: 640px) { .code-compare { grid-template-columns: 1fr; } }
.code-col { border-radius: 6px; overflow: hidden; }
.col-title { font-size: 0.72rem; padding: 4px 10px; border-bottom: 1px solid var(--vp-c-divider); }
.code-col.bad .col-title { background: #fef2f2; color: #991b1b; }
.code-col.good .col-title { background: #ecfdf5; color: #065f46; }
:root.dark .code-col.bad .col-title { background: #1c0606; color: #fca5a5; }
:root.dark .code-col.good .col-title { background: #031c14; color: #6ee7b7; }
.code-col pre { margin: 0; padding: 8px; font-size: 0.78rem; line-height: 1.5; overflow-x: auto; background: var(--vp-c-bg); }
.defense-tip { font-size: 0.83rem; padding: 8px; background: var(--vp-c-bg); border-radius: 4px; border-left: 3px solid var(--vp-c-brand); }
</style>