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:
@@ -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>
|
||||
+480
@@ -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>
|
||||
+233
@@ -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>
|
||||
+109
@@ -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>
|
||||
+234
@@ -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">✓</span>
|
||||
<span v-else-if="l.perms[p.id] === false" class="lc-no">✗</span>
|
||||
<span v-else class="lc-cond">⚠</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="lc-legend">
|
||||
<span><span class="lc-yes">✓</span> 允许</span>
|
||||
<span><span class="lc-no">✗</span> 不允许/限制</span>
|
||||
<span><span class="lc-cond">⚠</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>
|
||||
+82
@@ -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>
|
||||
+476
@@ -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: 'DRY(Don\'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>
|
||||
+295
@@ -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>
|
||||
+121
@@ -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>
|
||||
@@ -645,6 +645,22 @@ import TypeInferenceDemo from './components/appendix/typescript-intro/TypeInfere
|
||||
import SerializationDemo from './components/appendix/server-backend/SerializationDemo.vue'
|
||||
import HttpProtocolDemo from './components/appendix/server-backend/HttpProtocolDemo.vue'
|
||||
|
||||
// Engineering Excellence Components
|
||||
import CodeSmellDemo from './components/appendix/engineering-excellence/CodeSmellDemo.vue'
|
||||
import RefactoringDemo from './components/appendix/engineering-excellence/RefactoringDemo.vue'
|
||||
import TestPyramidDemo from './components/appendix/engineering-excellence/TestPyramidDemo.vue'
|
||||
import TDDCycleDemo from './components/appendix/engineering-excellence/TDDCycleDemo.vue'
|
||||
import DesignPatternCatalogDemo from './components/appendix/engineering-excellence/DesignPatternCatalogDemo.vue'
|
||||
import PatternPlaygroundDemo from './components/appendix/engineering-excellence/PatternPlaygroundDemo.vue'
|
||||
import WebSecurityDemo from './components/appendix/engineering-excellence/WebSecurityDemo.vue'
|
||||
import SecurityChecklistDemo from './components/appendix/engineering-excellence/SecurityChecklistDemo.vue'
|
||||
import DocStructureDemo from './components/appendix/engineering-excellence/DocStructureDemo.vue'
|
||||
import TechWritingPracticeDemo from './components/appendix/engineering-excellence/TechWritingPracticeDemo.vue'
|
||||
import OpenSourceWorkflowDemo from './components/appendix/engineering-excellence/OpenSourceWorkflowDemo.vue'
|
||||
import LicenseComparisonDemo from './components/appendix/engineering-excellence/LicenseComparisonDemo.vue'
|
||||
import TechRadarDemo from './components/appendix/engineering-excellence/TechRadarDemo.vue'
|
||||
import DecisionMatrixDemo from './components/appendix/engineering-excellence/DecisionMatrixDemo.vue'
|
||||
|
||||
// Data Components
|
||||
import SqlDemo from './components/appendix/data/SqlDemo.vue'
|
||||
import DataModelsDemo from './components/appendix/data/DataModelsDemo.vue'
|
||||
@@ -1321,6 +1337,22 @@ export default {
|
||||
app.component('ABTestingDemo', ABTestingDemo)
|
||||
app.component('DataAnalysisDemo', DataAnalysisDemo)
|
||||
app.component('DataTrackingDemo', DataTrackingDemo)
|
||||
|
||||
// Engineering Excellence Components Registration
|
||||
app.component('CodeSmellDemo', CodeSmellDemo)
|
||||
app.component('RefactoringDemo', RefactoringDemo)
|
||||
app.component('TestPyramidDemo', TestPyramidDemo)
|
||||
app.component('TDDCycleDemo', TDDCycleDemo)
|
||||
app.component('DesignPatternCatalogDemo', DesignPatternCatalogDemo)
|
||||
app.component('PatternPlaygroundDemo', PatternPlaygroundDemo)
|
||||
app.component('WebSecurityDemo', WebSecurityDemo)
|
||||
app.component('SecurityChecklistDemo', SecurityChecklistDemo)
|
||||
app.component('DocStructureDemo', DocStructureDemo)
|
||||
app.component('TechWritingPracticeDemo', TechWritingPracticeDemo)
|
||||
app.component('OpenSourceWorkflowDemo', OpenSourceWorkflowDemo)
|
||||
app.component('LicenseComparisonDemo', LicenseComparisonDemo)
|
||||
app.component('TechRadarDemo', TechRadarDemo)
|
||||
app.component('DecisionMatrixDemo', DecisionMatrixDemo)
|
||||
},
|
||||
setup() {
|
||||
const route = useRoute()
|
||||
|
||||
Reference in New Issue
Block a user