Files
sanbuphy f35cddeb8b 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
2026-02-24 13:03:21 +08:00

481 lines
10 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>