feat(docs): add interactive demos and complete content for development tools
- Add Vue components for interactive demos (SSH auth, regex, env vars, ports) - Complete markdown content for SSH, regex, environment variables, and ports - Remove placeholder "待实现" sections and replace with detailed guides - Add visual explanations for key concepts like ports and localhost - Include practical examples and troubleshooting tips - Add component for showing evolution from transistors to CPU - Improve documentation structure and navigation - Add security best practices for API keys and environment variables
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="demo-root">
|
||||
<div class="demo-header">
|
||||
<span class="title">硬编码密钥 vs 用环境变量</span>
|
||||
<span class="subtitle">同样的功能,两种写法,安全性天壤之别</span>
|
||||
</div>
|
||||
|
||||
<div class="two-col">
|
||||
<!-- Bad -->
|
||||
<div class="panel bad">
|
||||
<div class="panel-title">
|
||||
<span class="icon">❌</span> 危险写法:密钥写在代码里
|
||||
</div>
|
||||
<div class="code-area">
|
||||
<div class="code-line comment"># Python</div>
|
||||
<div class="code-line normal">import openai</div>
|
||||
<div class="code-line normal"> </div>
|
||||
<div class="code-line highlight-bad">client = openai.OpenAI(</div>
|
||||
<div class="code-line highlight-bad"> api_key=<span class="key-literal">"sk-proj-abc123..."</span></div>
|
||||
<div class="code-line highlight-bad">)</div>
|
||||
</div>
|
||||
<div class="consequences">
|
||||
<div v-for="c in badConsequences" :key="c" class="consequence bad-item">
|
||||
<span class="ci">💀</span><span>{{ c }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Good -->
|
||||
<div class="panel good">
|
||||
<div class="panel-title">
|
||||
<span class="icon">✅</span> 正确写法:从环境变量读取
|
||||
</div>
|
||||
<div class="code-area">
|
||||
<div class="code-line comment"># Python</div>
|
||||
<div class="code-line normal">import openai, os</div>
|
||||
<div class="code-line normal"> </div>
|
||||
<div class="code-line highlight-good">client = openai.OpenAI(</div>
|
||||
<div class="code-line highlight-good"> api_key=<span class="key-env">os.environ.get(<span class="key-name">"OPENAI_API_KEY"</span>)</span></div>
|
||||
<div class="code-line highlight-good">)</div>
|
||||
</div>
|
||||
<div class="consequences">
|
||||
<div v-for="c in goodConsequences" :key="c" class="consequence good-item">
|
||||
<span class="ci">✅</span><span>{{ c }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>黄金法则:</strong>代码里出现密钥字符串 = 密钥已泄露。GitHub 的 Secret Scanner 会在推送后秒级扫描,发现 <code>sk-</code> 等前缀就通知厂商吊销。即使立刻删除提交,Git 历史里仍然保存着。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const badConsequences = [
|
||||
'git push 后,密钥就公开在 GitHub 上',
|
||||
'爬虫秒级扫描,密钥被盗用并产生费用',
|
||||
'GitHub Secret Scanner 自动吊销密钥',
|
||||
'删除提交也没用,Git 历史仍保留'
|
||||
]
|
||||
|
||||
const goodConsequences = [
|
||||
'代码里没有任何密钥信息,可以安全开源',
|
||||
'不同环境(开发/测试/生产)用不同密钥',
|
||||
'密钥泄露时只需重新生成,不用改代码',
|
||||
'团队成员各用各的密钥,互不影响'
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 0.75rem 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.demo-header .title { font-size: 1rem; font-weight: bold; color: var(--vp-c-text-1); }
|
||||
.demo-header .subtitle { font-size: 0.82rem; color: var(--vp-c-text-2); }
|
||||
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.two-col { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 2px solid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel.bad { border-color: #f87171; }
|
||||
.panel.good { border-color: var(--vp-c-green-1); }
|
||||
|
||||
.panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.panel.bad .panel-title { background: color-mix(in srgb, #f87171 15%, var(--vp-c-bg-alt)); color: #ef4444; }
|
||||
.panel.good .panel-title { background: color-mix(in srgb, var(--vp-c-green-1) 12%, var(--vp-c-bg-alt)); color: var(--vp-c-green-1); }
|
||||
|
||||
.code-area {
|
||||
background: #1e1e2e;
|
||||
padding: 0.5rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.77rem;
|
||||
line-height: 1.7;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-line {
|
||||
padding: 0 0.7rem;
|
||||
white-space: pre;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.code-line.comment { color: #6c7086; font-style: italic; }
|
||||
.code-line.normal { color: #cdd6f4; }
|
||||
.code-line.highlight-bad { background: color-mix(in srgb, #f87171 10%, transparent); color: #cdd6f4; }
|
||||
.code-line.highlight-good { background: color-mix(in srgb, #4ade80 6%, transparent); color: #cdd6f4; }
|
||||
|
||||
.key-literal { color: #f38ba8; }
|
||||
.key-env { color: #a6e3a1; }
|
||||
.key-name { color: #89b4fa; }
|
||||
|
||||
.consequences {
|
||||
padding: 0.55rem 0.65rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.consequence {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.bad-item { color: color-mix(in srgb, #f87171 80%, var(--vp-c-text-2)); }
|
||||
.good-item { color: var(--vp-c-text-2); }
|
||||
|
||||
.ci { flex-shrink: 0; font-size: 0.8rem; }
|
||||
|
||||
.info-box {
|
||||
display: block;
|
||||
background: color-mix(in srgb, #ef4444 8%, var(--vp-c-bg-alt));
|
||||
border: 1px solid color-mix(in srgb, #ef4444 30%, transparent);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.84rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-box strong { white-space: nowrap; color: #ef4444; }
|
||||
|
||||
.info-box code {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 3px;
|
||||
color: #ef4444;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,847 @@
|
||||
<template>
|
||||
<div class="demo-root">
|
||||
<div class="demo-header">
|
||||
<span class="title">依赖树 & 版本语义</span>
|
||||
<span class="subtitle">理解语义化版本号与依赖关系图</span>
|
||||
</div>
|
||||
|
||||
<div class="control-panel">
|
||||
<div class="tab-group">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
:class="['tab-btn', { active: activeTab === tab.id }]"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: 语义化版本 -->
|
||||
<div v-if="activeTab === 'semver'" class="visualization-area">
|
||||
<div class="semver-display">
|
||||
<div class="version-number">
|
||||
<div
|
||||
v-for="part in versionParts"
|
||||
:key="part.id"
|
||||
:class="['ver-part', { highlight: hoveredPart === part.id }]"
|
||||
@mouseenter="hoveredPart = part.id"
|
||||
@mouseleave="hoveredPart = null"
|
||||
>
|
||||
<div class="ver-num">{{ part.num }}</div>
|
||||
<div class="ver-name" :style="{ color: part.color }">{{ part.label }}</div>
|
||||
</div>
|
||||
<div class="ver-dots">
|
||||
<span>.</span>
|
||||
<span>.</span>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div v-if="hoveredPart" class="ver-detail" :style="{ borderColor: currentPart.color }">
|
||||
<div class="ver-detail-title" :style="{ color: currentPart.color }">
|
||||
{{ currentPart.label }} 版本
|
||||
</div>
|
||||
<div class="ver-detail-desc">{{ currentPart.desc }}</div>
|
||||
<div class="ver-detail-example">
|
||||
<span class="example-label">示例:</span>
|
||||
<code>{{ currentPart.example }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<div v-if="!hoveredPart" class="ver-hint">← 鼠标悬停数字查看含义</div>
|
||||
</div>
|
||||
|
||||
<div class="range-grid">
|
||||
<div class="range-title">常用版本范围符号</div>
|
||||
<div
|
||||
v-for="r in ranges"
|
||||
:key="r.sym"
|
||||
:class="['range-card', { active: activeRange === r.sym }]"
|
||||
@click="activeRange = activeRange === r.sym ? null : r.sym"
|
||||
>
|
||||
<code class="range-sym">{{ r.sym }}</code>
|
||||
<div class="range-name">{{ r.name }}</div>
|
||||
<div class="range-desc">{{ r.desc }}</div>
|
||||
<div v-if="activeRange === r.sym" class="range-example">
|
||||
<div v-for="ex in r.examples" :key="ex.v" class="range-ex-row">
|
||||
<code>{{ ex.v }}</code>
|
||||
<span :class="['ex-status', ex.ok ? 'ok' : 'no']">{{ ex.ok ? '✓ 接受' : '✗ 拒绝' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: 依赖树 -->
|
||||
<div v-if="activeTab === 'tree'" class="visualization-area">
|
||||
<div class="scenario-select">
|
||||
<button
|
||||
v-for="sc in scenarios"
|
||||
:key="sc.id"
|
||||
:class="['scenario-btn', { active: activeScenario === sc.id }]"
|
||||
@click="activeScenario = sc.id"
|
||||
>
|
||||
{{ sc.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tree-container">
|
||||
<div class="tree-root-node node">
|
||||
<span class="node-name">{{ currentScenario.root }}</span>
|
||||
<span class="node-badge root-badge">你的项目</span>
|
||||
</div>
|
||||
|
||||
<div class="tree-level">
|
||||
<div
|
||||
v-for="dep in currentScenario.direct"
|
||||
:key="dep.name"
|
||||
:class="['tree-branch', dep.conflict ? 'conflict' : '']"
|
||||
>
|
||||
<div class="branch-line"></div>
|
||||
<div class="node dep-node">
|
||||
<span class="node-name">{{ dep.name }}</span>
|
||||
<span class="node-ver">{{ dep.version }}</span>
|
||||
<span v-if="dep.conflict" class="conflict-badge">⚠ 冲突</span>
|
||||
</div>
|
||||
<div v-if="dep.children && dep.children.length" class="sub-level">
|
||||
<div
|
||||
v-for="child in dep.children"
|
||||
:key="child.name + dep.name"
|
||||
:class="['sub-branch', child.conflict ? 'conflict' : '']"
|
||||
>
|
||||
<div class="sub-line"></div>
|
||||
<div class="node sub-node">
|
||||
<span class="node-name">{{ child.name }}</span>
|
||||
<span class="node-ver">{{ child.version }}</span>
|
||||
<span v-if="child.conflict" class="conflict-badge small">⚠</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario-desc" :class="currentScenario.type">
|
||||
<div class="desc-icon">{{ currentScenario.icon }}</div>
|
||||
<div class="desc-text">{{ currentScenario.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: 锁文件 -->
|
||||
<div v-if="activeTab === 'lockfile'" class="visualization-area">
|
||||
<div class="lockfile-compare">
|
||||
<div class="lf-col">
|
||||
<div class="lf-title">📄 package.json(声明意图)</div>
|
||||
<div class="lf-content">
|
||||
<pre class="code-block">{{ packageJsonExample }}</pre>
|
||||
</div>
|
||||
<div class="lf-note">用范围符号声明「可以接受哪些版本」</div>
|
||||
</div>
|
||||
<div class="lf-arrow">→</div>
|
||||
<div class="lf-col">
|
||||
<div class="lf-title">🔒 package-lock.json(固定现实)</div>
|
||||
<div class="lf-content">
|
||||
<pre class="code-block">{{ lockfileExample }}</pre>
|
||||
</div>
|
||||
<div class="lf-note">锁定实际安装的精确版本,团队共享</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lockfile-rules">
|
||||
<div
|
||||
v-for="rule in lockfileRules"
|
||||
:key="rule.title"
|
||||
class="rule-card"
|
||||
>
|
||||
<div class="rule-icon">{{ rule.icon }}</div>
|
||||
<div class="rule-body">
|
||||
<div class="rule-title">{{ rule.title }}</div>
|
||||
<div class="rule-desc">{{ rule.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>黄金法则:</strong>
|
||||
<span v-if="activeTab === 'semver'">语义化版本 = MAJOR.MINOR.PATCH,MAJOR 变说明有破坏性改动,升级需谨慎。</span>
|
||||
<span v-else-if="activeTab === 'tree'">依赖的依赖也是依赖,一个包可以间接引入几十个包,这就是"依赖树"。</span>
|
||||
<span v-else>把锁文件提交到 Git,保证团队每个人、每次 CI 安装的包版本完全一致。</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const activeTab = ref('semver')
|
||||
const hoveredPart = ref(null)
|
||||
const activeRange = ref(null)
|
||||
const activeScenario = ref('normal')
|
||||
|
||||
const tabs = [
|
||||
{ id: 'semver', label: '语义化版本' },
|
||||
{ id: 'tree', label: '依赖树' },
|
||||
{ id: 'lockfile', label: '锁文件' }
|
||||
]
|
||||
|
||||
const versionParts = [
|
||||
{
|
||||
id: 'major',
|
||||
num: '2',
|
||||
label: 'MAJOR',
|
||||
color: '#ef4444',
|
||||
desc: '主版本号。有破坏性 API 变更时递增,通常不向后兼容。升级前必须看 CHANGELOG。',
|
||||
example: 'React 16 → 17 → 18,每次都有较大改动'
|
||||
},
|
||||
{
|
||||
id: 'minor',
|
||||
num: '8',
|
||||
label: 'MINOR',
|
||||
color: '#f59e0b',
|
||||
desc: '次版本号。新增功能但向后兼容时递增,可以放心升级。',
|
||||
example: 'axios 1.5.0 → 1.6.0,新增了功能但不影响老用法'
|
||||
},
|
||||
{
|
||||
id: 'patch',
|
||||
num: '3',
|
||||
label: 'PATCH',
|
||||
color: '#22c55e',
|
||||
desc: '补丁版本号。只修复 bug,完全向后兼容,建议及时升级。',
|
||||
example: 'lodash 4.17.20 → 4.17.21,修复安全漏洞'
|
||||
}
|
||||
]
|
||||
|
||||
const currentPart = computed(
|
||||
() => versionParts.find(p => p.id === hoveredPart.value) || versionParts[0]
|
||||
)
|
||||
|
||||
const ranges = [
|
||||
{
|
||||
sym: '^2.8.3',
|
||||
name: '兼容范围(推荐)',
|
||||
desc: '允许 MINOR 和 PATCH 升级,锁定 MAJOR',
|
||||
examples: [
|
||||
{ v: '2.8.3', ok: true }, { v: '2.9.0', ok: true },
|
||||
{ v: '3.0.0', ok: false }, { v: '2.8.2', ok: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
sym: '~2.8.3',
|
||||
name: '近似范围(保守)',
|
||||
desc: '只允许 PATCH 升级,锁定 MAJOR 和 MINOR',
|
||||
examples: [
|
||||
{ v: '2.8.3', ok: true }, { v: '2.8.9', ok: true },
|
||||
{ v: '2.9.0', ok: false }, { v: '3.0.0', ok: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
sym: '2.8.3',
|
||||
name: '精确版本(严格)',
|
||||
desc: '只接受这一个版本,完全锁定',
|
||||
examples: [
|
||||
{ v: '2.8.3', ok: true }, { v: '2.8.4', ok: false },
|
||||
{ v: '2.9.0', ok: false }, { v: '2.8.2', ok: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
sym: '*',
|
||||
name: '任意版本(危险)',
|
||||
desc: '接受任何版本,包括主版本升级,生产环境禁止',
|
||||
examples: [
|
||||
{ v: '1.0.0', ok: true }, { v: '2.8.3', ok: true },
|
||||
{ v: '99.0.0', ok: true }, { v: '0.0.1', ok: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const scenarios = [
|
||||
{ id: 'normal', label: '正常依赖' },
|
||||
{ id: 'shared', label: '共享依赖' },
|
||||
{ id: 'conflict', label: '版本冲突' }
|
||||
]
|
||||
|
||||
const allScenarios = {
|
||||
normal: {
|
||||
root: 'my-app',
|
||||
type: 'success',
|
||||
icon: '✅',
|
||||
description: '正常情况:直接依赖 axios 和 lodash,它们各自有少量子依赖,无冲突。',
|
||||
direct: [
|
||||
{
|
||||
name: 'axios',
|
||||
version: '^1.6.8',
|
||||
children: [
|
||||
{ name: 'follow-redirects', version: '^1.15.6' },
|
||||
{ name: 'form-data', version: '^4.0.0' }
|
||||
]
|
||||
},
|
||||
{ name: 'lodash', version: '^4.17.21', children: [] }
|
||||
]
|
||||
},
|
||||
shared: {
|
||||
root: 'my-app',
|
||||
type: 'info',
|
||||
icon: '📌',
|
||||
description: '共享依赖:react-dom 和 react-router 都依赖同一个 react,npm 会自动复用,不重复安装。',
|
||||
direct: [
|
||||
{
|
||||
name: 'react-dom',
|
||||
version: '^18.2.0',
|
||||
children: [{ name: 'react', version: '^18.2.0' }]
|
||||
},
|
||||
{
|
||||
name: 'react-router',
|
||||
version: '^6.22.0',
|
||||
children: [{ name: 'react', version: '^18.2.0' }]
|
||||
}
|
||||
]
|
||||
},
|
||||
conflict: {
|
||||
root: 'my-app',
|
||||
type: 'warning',
|
||||
icon: '⚠️',
|
||||
description: '版本冲突:pkg-a 需要 lodash@^3.0.0,pkg-b 需要 lodash@^4.0.0,MAJOR 不同无法共享,npm 会安装两份,导致包体积膨胀。',
|
||||
direct: [
|
||||
{
|
||||
name: 'pkg-a',
|
||||
version: '^1.0.0',
|
||||
children: [{ name: 'lodash', version: '^3.10.1', conflict: true }]
|
||||
},
|
||||
{
|
||||
name: 'pkg-b',
|
||||
version: '^2.0.0',
|
||||
children: [{ name: 'lodash', version: '^4.17.21', conflict: true }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const currentScenario = computed(() => allScenarios[activeScenario.value])
|
||||
|
||||
const packageJsonExample = `{
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"lodash": "^4.17.0"
|
||||
}
|
||||
}`
|
||||
|
||||
const lockfileExample = `{
|
||||
"node_modules/axios": {
|
||||
"version": "1.6.8",
|
||||
"resolved": "https://registry.npmjs.org/...",
|
||||
"integrity": "sha512-..."
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/..."
|
||||
}
|
||||
}`
|
||||
|
||||
const lockfileRules = [
|
||||
{ icon: '📌', title: '必须提交到 Git', desc: '锁文件是团队契约,让所有成员、CI/CD 安装完全相同的版本。' },
|
||||
{ icon: '🚫', title: '不要手动编辑', desc: '锁文件由包管理器自动维护,手动修改极易引入错误。' },
|
||||
{ icon: '🔄', title: 'npm install 会更新它', desc: '每次 install/update 后,锁文件会自动更新到最新解析结果。' },
|
||||
{ icon: '🧪', title: 'npm ci 严格遵守它', desc: 'CI 环境用 npm ci 而非 npm install,保证精确复现锁文件记录的版本。' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin: 1.5rem 0;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
padding: 0.85rem 1.1rem 0.7rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.tab-group {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 0.3rem 0.9rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.83rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
border-color: var(--vp-c-brand);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
/* === Semver Tab === */
|
||||
.semver-display {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.version-number {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ver-dots {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-3);
|
||||
gap: 1.5rem;
|
||||
padding: 0 0.1rem;
|
||||
line-height: 1;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ver-part {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.8rem;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
margin: 0 0.2rem;
|
||||
}
|
||||
|
||||
.ver-part.highlight {
|
||||
border-color: currentColor;
|
||||
background: var(--vp-c-bg-soft);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.ver-num {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ver-name {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.ver-detail {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border: 1.5px solid;
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.ver-detail-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.ver-detail-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.ver-detail-example {
|
||||
font-size: 0.76rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.example-label {
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
|
||||
.ver-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-3);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.range-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.range-title {
|
||||
grid-column: 1 / -1;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.range-card {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.range-card:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.range-card.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.range-sym {
|
||||
font-size: 0.95rem;
|
||||
color: var(--vp-c-brand);
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.range-name {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.range-desc {
|
||||
font-size: 0.74rem;
|
||||
color: var(--vp-c-text-3);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.range-example {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.4rem;
|
||||
border-top: 1px dashed var(--vp-c-divider);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.range-ex-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.ex-status.ok { color: #22c55e; }
|
||||
.ex-status.no { color: #ef4444; }
|
||||
|
||||
/* === Tree Tab === */
|
||||
.scenario-select {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.scenario-btn {
|
||||
padding: 0.28rem 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.scenario-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
border-color: var(--vp-c-brand);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tree-container {
|
||||
padding: 0.8rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tree-root-node {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.node {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.node-name {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.node-ver {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-3);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.node-badge {
|
||||
font-size: 0.68rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.root-badge {
|
||||
background: var(--vp-c-brand);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.conflict-badge {
|
||||
font-size: 0.7rem;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.conflict-badge.small {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.tree-level {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding-left: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tree-branch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.tree-branch.conflict .dep-node {
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.branch-line {
|
||||
width: 2px;
|
||||
height: 16px;
|
||||
background: var(--vp-c-divider);
|
||||
margin-left: 0.8rem;
|
||||
}
|
||||
|
||||
.sub-level {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
.sub-branch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.sub-branch.conflict .sub-node {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.sub-line {
|
||||
width: 16px;
|
||||
height: 2px;
|
||||
background: var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.sub-node {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.scenario-desc {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.scenario-desc.success { background: color-mix(in srgb, #22c55e 10%, var(--vp-c-bg)); border: 1px solid color-mix(in srgb, #22c55e 30%, transparent); }
|
||||
.scenario-desc.info { background: color-mix(in srgb, var(--vp-c-brand) 10%, var(--vp-c-bg)); border: 1px solid color-mix(in srgb, var(--vp-c-brand) 30%, transparent); }
|
||||
.scenario-desc.warning { background: color-mix(in srgb, #f59e0b 10%, var(--vp-c-bg)); border: 1px solid color-mix(in srgb, #f59e0b 30%, transparent); }
|
||||
|
||||
.desc-icon { font-size: 1rem; flex-shrink: 0; }
|
||||
.desc-text { color: var(--vp-c-text-2); }
|
||||
|
||||
/* === Lockfile Tab === */
|
||||
.lockfile-compare {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.lf-col {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.lf-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.lf-content {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: auto;
|
||||
max-height: 160px;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
margin: 0;
|
||||
padding: 0.6rem 0.8rem;
|
||||
font-size: 0.74rem;
|
||||
color: var(--vp-c-text-2);
|
||||
background: var(--vp-c-bg-soft);
|
||||
white-space: pre;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.lf-note {
|
||||
font-size: 0.73rem;
|
||||
color: var(--vp-c-text-3);
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.lf-arrow {
|
||||
font-size: 1.5rem;
|
||||
color: var(--vp-c-brand);
|
||||
padding-top: 3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.lockfile-compare { flex-direction: column; }
|
||||
.lf-arrow { transform: rotate(90deg); padding-top: 0; align-self: center; }
|
||||
}
|
||||
|
||||
.lockfile-rules {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.rule-card {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.rule-icon {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rule-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.rule-desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* === Info Box === */
|
||||
.info-box {
|
||||
display: block;
|
||||
padding: 0.65rem 1rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,350 @@
|
||||
<template>
|
||||
<div class="demo-root">
|
||||
<div class="demo-header">
|
||||
<span class="title">.env 文件 + 代码读取</span>
|
||||
<span class="subtitle">左边写配置,右边读取——两者之间只有变量名这一条线</span>
|
||||
</div>
|
||||
|
||||
<div class="lang-tabs">
|
||||
<button
|
||||
v-for="lang in langs"
|
||||
:key="lang.id"
|
||||
class="lang-tab"
|
||||
:class="{ active: currentLang === lang.id }"
|
||||
@click="currentLang = lang.id"
|
||||
>
|
||||
{{ lang.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="two-col">
|
||||
<!-- Left: .env file -->
|
||||
<div class="file-panel">
|
||||
<div class="file-title">
|
||||
<span class="file-icon">📄</span> .env
|
||||
<span class="file-badge no-commit">不提交 Git</span>
|
||||
</div>
|
||||
<div class="code-area">
|
||||
<div v-for="(line, i) in envLines" :key="i" class="code-line" :class="line.type">
|
||||
<span
|
||||
v-if="line.key"
|
||||
class="env-key"
|
||||
:class="{ active: hoveredKey === line.key }"
|
||||
@mouseenter="hoveredKey = line.key"
|
||||
@mouseleave="hoveredKey = null"
|
||||
>{{ line.key }}</span>
|
||||
<span v-if="line.key" class="env-eq">=</span>
|
||||
<span v-if="line.key" class="env-val">{{ line.value }}</span>
|
||||
<span v-else class="env-comment">{{ line.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-title example">
|
||||
<span class="file-icon">📋</span> .env.example
|
||||
<span class="file-badge can-commit">可以提交 Git</span>
|
||||
</div>
|
||||
<div class="code-area dim">
|
||||
<div v-for="(line, i) in exampleLines" :key="i" class="code-line" :class="line.type">
|
||||
<span v-if="line.key" class="env-key">{{ line.key }}</span>
|
||||
<span v-if="line.key" class="env-eq">=</span>
|
||||
<span v-if="line.key" class="env-val empty">(值留空)</span>
|
||||
<span v-else class="env-comment">{{ line.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: code -->
|
||||
<div class="code-panel">
|
||||
<div class="file-title">
|
||||
<span class="file-icon">💻</span> {{ currentLangObj.filename }}
|
||||
</div>
|
||||
<div class="code-area">
|
||||
<div v-for="(line, i) in currentLangObj.lines" :key="i" class="code-line" :class="line.type">
|
||||
<span class="line-content" v-html="line.text" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="read-result">
|
||||
<div class="result-title">程序实际读到的值</div>
|
||||
<div v-for="kv in readResults" :key="kv.key" class="result-row">
|
||||
<span
|
||||
class="result-key"
|
||||
:class="{ active: hoveredKey === kv.key }"
|
||||
@mouseenter="hoveredKey = kv.key"
|
||||
@mouseleave="hoveredKey = null"
|
||||
>{{ kv.key }}</span>
|
||||
<span class="result-arrow">→</span>
|
||||
<span class="result-val">{{ kv.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>工作流程:</strong><code>load_dotenv()</code> / <code>import 'dotenv/config'</code> 在启动时读取 <code>.env</code> 文件,把里面的键值注入到进程环境变量中,代码里再用 <code>os.environ</code> 或 <code>process.env</code> 读取,两端只靠变量名连接。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const hoveredKey = ref(null)
|
||||
const currentLang = ref('python')
|
||||
|
||||
const langs = [
|
||||
{ id: 'python', label: 'Python' },
|
||||
{ id: 'node', label: 'Node.js' }
|
||||
]
|
||||
|
||||
const envLines = [
|
||||
{ type: 'comment', text: '# 本地开发配置,不提交到 Git' },
|
||||
{ key: 'OPENAI_API_KEY', value: 'sk-proj-abc123...' },
|
||||
{ key: 'DATABASE_URL', value: 'postgresql://localhost/dev' },
|
||||
{ key: 'PORT', value: '3000' },
|
||||
{ key: 'NODE_ENV', value: 'development' }
|
||||
]
|
||||
|
||||
const exampleLines = [
|
||||
{ type: 'comment', text: '# 复制为 .env,填入真实值' },
|
||||
{ key: 'OPENAI_API_KEY', value: '' },
|
||||
{ key: 'DATABASE_URL', value: '' },
|
||||
{ key: 'PORT', value: '' },
|
||||
{ key: 'NODE_ENV', value: '' }
|
||||
]
|
||||
|
||||
const readResults = [
|
||||
{ key: 'OPENAI_API_KEY', value: 'sk-proj-abc123...' },
|
||||
{ key: 'DATABASE_URL', value: 'postgresql://localhost/dev' },
|
||||
{ key: 'PORT', value: '3000' }
|
||||
]
|
||||
|
||||
const pythonLines = [
|
||||
{ type: 'comment', text: '# pip install python-dotenv openai' },
|
||||
{ type: 'normal', text: 'from dotenv import load_dotenv' },
|
||||
{ type: 'normal', text: 'import os, openai' },
|
||||
{ type: 'normal', text: ' ' },
|
||||
{ type: 'highlight', text: 'load_dotenv() <span class="comment-inline"># 读取 .env 文件</span>' },
|
||||
{ type: 'normal', text: ' ' },
|
||||
{ type: 'normal', text: 'client = openai.OpenAI(' },
|
||||
{ type: 'highlight', text: ' api_key=os.environ.get(<span class="key-ref">"OPENAI_API_KEY"</span>)' },
|
||||
{ type: 'normal', text: ')' },
|
||||
{ type: 'normal', text: ' ' },
|
||||
{ type: 'normal', text: 'db = os.environ.get(<span class="key-ref">"DATABASE_URL"</span>)' },
|
||||
{ type: 'normal', text: 'port = int(os.environ.get(<span class="key-ref">"PORT"</span>, 8000))' }
|
||||
]
|
||||
|
||||
const nodeLines = [
|
||||
{ type: 'comment', text: '# npm install dotenv openai' },
|
||||
{ type: 'highlight', text: "import 'dotenv/config' <span class=\"comment-inline\">// 读取 .env 文件</span>" },
|
||||
{ type: 'normal', text: "import OpenAI from 'openai'" },
|
||||
{ type: 'normal', text: ' ' },
|
||||
{ type: 'normal', text: 'const client = new OpenAI({' },
|
||||
{ type: 'highlight', text: ' apiKey: process.env.<span class="key-ref">OPENAI_API_KEY</span>' },
|
||||
{ type: 'normal', text: '})' },
|
||||
{ type: 'normal', text: ' ' },
|
||||
{ type: 'normal', text: 'const db = process.env.<span class="key-ref">DATABASE_URL</span>' },
|
||||
{ type: 'normal', text: 'const port = process.env.<span class="key-ref">PORT</span> ?? 8000' }
|
||||
]
|
||||
|
||||
const currentLangObj = computed(() => {
|
||||
if (currentLang.value === 'python') {
|
||||
return { filename: 'main.py', lines: pythonLines }
|
||||
}
|
||||
return { filename: 'index.js', lines: nodeLines }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 0.75rem 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header .title { font-size: 1rem; font-weight: bold; color: var(--vp-c-text-1); }
|
||||
.demo-header .subtitle { font-size: 0.82rem; color: var(--vp-c-text-2); }
|
||||
|
||||
.lang-tabs {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.lang-tab {
|
||||
padding: 0.25rem 0.7rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-2);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.lang-tab.active {
|
||||
background: var(--vp-c-brand);
|
||||
border-color: var(--vp-c-brand);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.two-col { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.file-panel, .code-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.file-title.example {
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.file-icon { flex-shrink: 0; }
|
||||
|
||||
.file-badge {
|
||||
margin-left: auto;
|
||||
font-size: 0.65rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-family: var(--vp-font-family-base);
|
||||
}
|
||||
|
||||
.file-badge.no-commit { background: color-mix(in srgb, #f87171 15%, transparent); color: #ef4444; }
|
||||
.file-badge.can-commit { background: color-mix(in srgb, var(--vp-c-green-1) 15%, transparent); color: var(--vp-c-green-1); }
|
||||
|
||||
.code-area {
|
||||
background: #1e1e2e;
|
||||
padding: 0.45rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.77rem;
|
||||
line-height: 1.7;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-area.dim { background: #16131e; opacity: 0.75; }
|
||||
|
||||
.code-line {
|
||||
padding: 0 0.65rem;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.code-line.highlight { background: color-mix(in srgb, var(--vp-c-brand) 8%, transparent); }
|
||||
.code-line.comment .env-comment { color: #6c7086; font-style: italic; }
|
||||
|
||||
.env-key {
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: bold;
|
||||
cursor: default;
|
||||
transition: background 0.15s;
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.env-key.active { background: color-mix(in srgb, var(--vp-c-brand) 25%, transparent); }
|
||||
.env-eq { color: #45475a; margin: 0 1px; }
|
||||
.env-val { color: #a6e3a1; }
|
||||
.env-val.empty { color: #45475a; font-style: italic; }
|
||||
.env-comment { color: #6c7086; font-style: italic; }
|
||||
|
||||
.line-content { color: #cdd6f4; white-space: pre; }
|
||||
.code-line.comment .line-content { color: #6c7086; font-style: italic; }
|
||||
.code-line.highlight .line-content { color: #cdd6f4; }
|
||||
|
||||
:deep(.key-ref) { color: var(--vp-c-brand); font-weight: bold; }
|
||||
:deep(.comment-inline) { color: #6c7086; font-style: italic; }
|
||||
|
||||
.read-result {
|
||||
background: #11111b;
|
||||
border-top: 1px solid #313244;
|
||||
padding: 0.5rem 0.65rem;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: 0.68rem;
|
||||
color: #6c7086;
|
||||
margin-bottom: 0.3rem;
|
||||
font-family: var(--vp-font-family-base);
|
||||
}
|
||||
|
||||
.result-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.result-key {
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: bold;
|
||||
cursor: default;
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.result-key.active { background: color-mix(in srgb, var(--vp-c-brand) 25%, transparent); }
|
||||
.result-arrow { color: #45475a; }
|
||||
.result-val { color: #a6e3a1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.info-box {
|
||||
display: block;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.84rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-box strong { white-space: nowrap; color: var(--vp-c-text-1); }
|
||||
|
||||
.info-box code {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 3px;
|
||||
color: var(--vp-c-brand);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<div class="demo-root">
|
||||
<div class="demo-header">
|
||||
<span class="title">export 决定子进程能不能"看见"变量</span>
|
||||
<span class="subtitle">切换开关,观察子进程是否能读到父进程设置的变量</span>
|
||||
</div>
|
||||
|
||||
<div class="control-panel">
|
||||
<label class="toggle-wrap">
|
||||
<span class="toggle-label">使用 <code>export</code></span>
|
||||
<button class="toggle-btn" :class="{ on: useExport }" @click="useExport = !useExport">
|
||||
<span class="thumb" />
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="two-col">
|
||||
<!-- Parent shell -->
|
||||
<div class="shell-box parent">
|
||||
<div class="shell-title">父进程(Shell)</div>
|
||||
<div class="shell-body">
|
||||
<div class="cmd-line">
|
||||
<span class="prompt">$</span>
|
||||
<span class="cmd" :class="{ exported: useExport }">
|
||||
<span v-if="useExport">export </span>MY_VAR="hello"
|
||||
</span>
|
||||
</div>
|
||||
<div class="cmd-line">
|
||||
<span class="prompt">$</span>
|
||||
<span class="cmd">echo $MY_VAR</span>
|
||||
</div>
|
||||
<div class="output">hello</div>
|
||||
<div class="cmd-line">
|
||||
<span class="prompt">$</span>
|
||||
<span class="cmd">bash -c 'echo $MY_VAR'</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<div class="arrow-col">
|
||||
<div class="arrow-label">启动子进程</div>
|
||||
<div class="arrow-icon">→</div>
|
||||
<div class="inherit-tag" :class="useExport ? 'yes' : 'no'">
|
||||
{{ useExport ? '变量已继承' : '变量未继承' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Child shell -->
|
||||
<div class="shell-box child" :class="{ has: useExport, missing: !useExport }">
|
||||
<div class="shell-title">子进程(bash -c ...)</div>
|
||||
<div class="shell-body">
|
||||
<div class="cmd-line">
|
||||
<span class="prompt">$</span>
|
||||
<span class="cmd">echo $MY_VAR</span>
|
||||
</div>
|
||||
<div v-if="useExport" class="output success">hello</div>
|
||||
<div v-else class="output empty">(空,什么都没有)</div>
|
||||
<div class="cmd-line muted">
|
||||
<span class="prompt">#</span>
|
||||
<span class="cmd muted-text">子进程无法修改父进程的变量</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>{{ useExport ? '有 export:' : '没有 export:' }}</strong>
|
||||
{{ useExport
|
||||
? '变量被标记为"可导出",子进程启动时自动继承一份副本。'
|
||||
: '变量只存在于当前 Shell,子进程读到的是空字符串。' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
const useExport = ref(false)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 0.75rem 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.demo-header .title { font-size: 1rem; font-weight: bold; color: var(--vp-c-text-1); }
|
||||
.demo-header .subtitle { font-size: 0.82rem; color: var(--vp-c-text-2); }
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 0.75rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.toggle-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-1);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toggle-label code {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 3px;
|
||||
color: var(--vp-c-brand);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-alt);
|
||||
cursor: pointer;
|
||||
transition: all 0.25s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-btn.on {
|
||||
background: var(--vp-c-brand);
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-text-2);
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: all 0.25s;
|
||||
}
|
||||
|
||||
.toggle-btn.on .thumb {
|
||||
left: 22px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* ── Two column layout ── */
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.two-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.arrow-col { flex-direction: row; justify-content: center; }
|
||||
}
|
||||
|
||||
.shell-box {
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #1e1e2e;
|
||||
transition: border-color 0.3s;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shell-box.has { border-color: var(--vp-c-green-1); }
|
||||
.shell-box.missing { border-color: color-mix(in srgb, #f87171 60%, transparent); }
|
||||
|
||||
.shell-title {
|
||||
background: #181825;
|
||||
padding: 0.28rem 0.65rem;
|
||||
font-size: 0.72rem;
|
||||
color: #6c7086;
|
||||
}
|
||||
|
||||
.shell-body {
|
||||
padding: 0.5rem 0.65rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.cmd-line { display: flex; gap: 0.4rem; align-items: baseline; }
|
||||
|
||||
.prompt { color: #6c7086; flex-shrink: 0; }
|
||||
.cmd { color: #cdd6f4; word-break: break-all; }
|
||||
.cmd.exported { color: #a6e3a1; }
|
||||
|
||||
.muted .prompt { color: #45475a; }
|
||||
.muted-text { color: #45475a; font-style: italic; font-size: 0.72rem; }
|
||||
|
||||
.output {
|
||||
padding-left: 1rem;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.output.success { color: #a6e3a1; }
|
||||
.output.empty { color: #585b70; font-style: italic; }
|
||||
|
||||
.arrow-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.arrow-label { font-size: 0.7rem; color: var(--vp-c-text-3); white-space: nowrap; }
|
||||
|
||||
.arrow-icon {
|
||||
font-size: 1.4rem;
|
||||
color: var(--vp-c-text-3);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.inherit-tag {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.inherit-tag.yes { background: color-mix(in srgb, var(--vp-c-green-1) 15%, transparent); color: var(--vp-c-green-1); border: 1px solid var(--vp-c-green-1); }
|
||||
.inherit-tag.no { background: color-mix(in srgb, #f87171 12%, transparent); color: #f87171; border: 1px solid #f87171; }
|
||||
|
||||
.info-box {
|
||||
display: block;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.84rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-box strong { white-space: nowrap; color: var(--vp-c-text-1); }
|
||||
</style>
|
||||
@@ -0,0 +1,331 @@
|
||||
<template>
|
||||
<div class="demo-root">
|
||||
<div class="demo-header">
|
||||
<span class="title">环境变量的三个层级</span>
|
||||
<span class="subtitle">变量从外到内单向传递,子进程继承父进程的副本</span>
|
||||
</div>
|
||||
|
||||
<div class="scope-stack">
|
||||
<div class="scope-layer system">
|
||||
<div class="layer-header">
|
||||
<span class="layer-icon">🖥️</span>
|
||||
<div>
|
||||
<div class="layer-title">系统级 <code>/etc/environment</code></div>
|
||||
<div class="layer-desc">所有用户、所有进程都能看到,由管理员配置</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="var-list">
|
||||
<div v-for="v in systemVars" :key="v.key" class="var-chip system-chip">
|
||||
<span class="chip-key">{{ v.key }}</span><span class="chip-eq">=</span><span class="chip-val">{{ v.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow-row">
|
||||
<span class="arrow-line" />
|
||||
<span class="arrow-label">▼ 子进程继承父进程环境</span>
|
||||
<span class="arrow-line" />
|
||||
</div>
|
||||
|
||||
<div class="scope-layer user">
|
||||
<div class="layer-header">
|
||||
<span class="layer-icon">👤</span>
|
||||
<div>
|
||||
<div class="layer-title">用户级 <code>~/.zshrc</code></div>
|
||||
<div class="layer-desc">只影响当前用户,登录 Shell 启动时自动加载</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="var-list">
|
||||
<div v-for="v in userVars" :key="v.key" class="var-chip user-chip">
|
||||
<span class="chip-key">{{ v.key }}</span><span class="chip-eq">=</span><span class="chip-val">{{ v.value }}</span>
|
||||
</div>
|
||||
<div class="add-row">
|
||||
<input v-model="newKey" class="var-input" placeholder="KEY" maxlength="18" @keyup.enter="addVar" />
|
||||
<span class="eq-sign">=</span>
|
||||
<input v-model="newVal" class="var-input" placeholder="value" maxlength="24" @keyup.enter="addVar" />
|
||||
<button class="add-btn" :disabled="!newKey || !newVal" @click="addVar">export</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arrow-row">
|
||||
<span class="arrow-line" />
|
||||
<span class="arrow-label">▼ 启动子进程(如 node app.js)</span>
|
||||
<span class="arrow-line" />
|
||||
</div>
|
||||
|
||||
<div class="scope-layer process">
|
||||
<div class="layer-header">
|
||||
<span class="layer-icon">⚙️</span>
|
||||
<div>
|
||||
<div class="layer-title">进程级(当前运行的程序)</div>
|
||||
<div class="layer-desc">继承所有上层变量,退出后消失,修改不影响父进程</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="var-list">
|
||||
<div v-for="v in processVars" :key="v.key" class="var-chip process-chip" :class="{ 'is-new': v.isNew }">
|
||||
<span class="chip-key">{{ v.key }}</span><span class="chip-eq">=</span><span class="chip-val">{{ v.value }}</span>
|
||||
<span v-if="v.isNew" class="new-badge">你加的</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>单向传递:</strong>变量只能向下继承,子进程修改变量值不会影响父进程。关闭终端后,直接 <code>export</code> 的变量也会消失。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const systemVars = [
|
||||
{ key: 'PATH', value: '/usr/local/bin:/usr/bin:/bin' },
|
||||
{ key: 'LANG', value: 'zh_CN.UTF-8' },
|
||||
{ key: 'TZ', value: 'Asia/Shanghai' }
|
||||
]
|
||||
|
||||
const baseUserVars = [
|
||||
{ key: 'HOME', value: '/Users/alice' },
|
||||
{ key: 'SHELL', value: '/bin/zsh' },
|
||||
{ key: 'NVM_DIR', value: '$HOME/.nvm' }
|
||||
]
|
||||
|
||||
const extraVars = ref([])
|
||||
const newKey = ref('')
|
||||
const newVal = ref('')
|
||||
|
||||
const userVars = computed(() => [...baseUserVars, ...extraVars.value])
|
||||
|
||||
const processVars = computed(() => [
|
||||
...systemVars,
|
||||
...userVars.value.map((v) => ({ ...v })),
|
||||
{ key: 'NODE_ENV', value: 'development' },
|
||||
{ key: 'PORT', value: '3000' }
|
||||
])
|
||||
|
||||
const addVar = () => {
|
||||
if (!newKey.value || !newVal.value) return
|
||||
const key = newKey.value.toUpperCase().replace(/[^A-Z0-9_]/g, '_')
|
||||
if (extraVars.value.some((v) => v.key === key)) return
|
||||
extraVars.value.push({ key, value: newVal.value, isNew: true })
|
||||
newKey.value = ''
|
||||
newVal.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 0.75rem 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.demo-header .subtitle {
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.scope-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
margin-bottom: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.scope-layer {
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 0.7rem 0.85rem;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.scope-layer.system {
|
||||
border-color: var(--vp-c-yellow-1, #f59e0b);
|
||||
background: color-mix(in srgb, var(--vp-c-yellow-1, #f59e0b) 5%, var(--vp-c-bg));
|
||||
}
|
||||
|
||||
.scope-layer.user {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: color-mix(in srgb, var(--vp-c-brand) 5%, var(--vp-c-bg));
|
||||
}
|
||||
|
||||
.scope-layer.process {
|
||||
border-color: var(--vp-c-green-1);
|
||||
background: color-mix(in srgb, var(--vp-c-green-1) 5%, var(--vp-c-bg));
|
||||
}
|
||||
|
||||
.layer-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.55rem;
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
.layer-icon {
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.layer-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.layer-title code {
|
||||
font-size: 0.78rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 3px;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.layer-desc {
|
||||
font-size: 0.76rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.var-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.var-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.18rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.system-chip { border-color: var(--vp-c-yellow-1, #f59e0b); background: color-mix(in srgb, var(--vp-c-yellow-1, #f59e0b) 12%, var(--vp-c-bg)); }
|
||||
.user-chip { border-color: var(--vp-c-brand); background: color-mix(in srgb, var(--vp-c-brand) 10%, var(--vp-c-bg)); }
|
||||
.process-chip { border-color: var(--vp-c-green-1); background: color-mix(in srgb, var(--vp-c-green-1) 10%, var(--vp-c-bg)); }
|
||||
.process-chip.is-new { border-style: dashed; }
|
||||
|
||||
.chip-key { font-weight: bold; color: var(--vp-c-brand); }
|
||||
.chip-eq { color: var(--vp-c-text-3); margin: 0 1px; }
|
||||
.chip-val { color: var(--vp-c-text-2); max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.new-badge {
|
||||
margin-left: 0.35rem;
|
||||
background: var(--vp-c-green-1);
|
||||
color: white;
|
||||
font-size: 0.62rem;
|
||||
padding: 0 0.28rem;
|
||||
border-radius: 3px;
|
||||
font-family: var(--vp-font-family-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.add-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
width: 100%;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.var-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.22rem 0.4rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.76rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.var-input:focus { border-color: var(--vp-c-brand); }
|
||||
|
||||
.eq-sign { color: var(--vp-c-text-3); font-family: var(--vp-font-family-mono); }
|
||||
|
||||
.add-btn {
|
||||
padding: 0.22rem 0.6rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.76rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.add-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.arrow-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
|
||||
.arrow-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.arrow-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: block;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.84rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-box strong { white-space: nowrap; color: var(--vp-c-text-1); }
|
||||
|
||||
.info-box code {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.8rem;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 3px;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,354 @@
|
||||
<template>
|
||||
<div class="demo-root">
|
||||
<div class="demo-header">
|
||||
<span class="title">环境变量浏览器</span>
|
||||
<span class="subtitle">点击任意变量行,在终端中查看它的值和作用</span>
|
||||
</div>
|
||||
|
||||
<div class="content-layout">
|
||||
<div class="env-table">
|
||||
<div class="table-header">
|
||||
<span>变量名</span>
|
||||
<span>示例值</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="item in envVars"
|
||||
:key="item.key"
|
||||
class="env-row"
|
||||
:class="{ selected: selected?.key === item.key }"
|
||||
@click="echoVar(item)"
|
||||
>
|
||||
<span class="env-key">{{ item.key }}</span>
|
||||
<span class="env-value">{{ item.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="terminal-panel">
|
||||
<div class="term-titlebar">
|
||||
<span class="dot red" />
|
||||
<span class="dot yellow" />
|
||||
<span class="dot green" />
|
||||
<span class="term-name">bash</span>
|
||||
</div>
|
||||
<div ref="termBody" class="term-body">
|
||||
<div
|
||||
v-for="line in termLines"
|
||||
:key="line.id"
|
||||
:class="['term-line', `line-${line.type}`]"
|
||||
>
|
||||
{{ line.text }}
|
||||
</div>
|
||||
<div class="term-prompt">$ <span class="cursor">█</span></div>
|
||||
</div>
|
||||
|
||||
<div v-if="selected" class="term-desc">
|
||||
<div class="desc-title">{{ selected.key }}</div>
|
||||
<div class="desc-body">{{ selected.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心概念:</strong>环境变量是每个进程持有的一组「键=值」配置。程序启动时自动从父进程继承一份,可随时通过
|
||||
<code>echo $变量名</code> 查看,用 <code>export KEY=value</code> 设置。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, nextTick } from 'vue'
|
||||
|
||||
let lineId = 0
|
||||
const termLines = ref([{ id: lineId++, type: 'hint', text: '← 点击左侧任意变量行来查看它' }])
|
||||
const selected = ref(null)
|
||||
const termBody = ref(null)
|
||||
|
||||
const envVars = [
|
||||
{
|
||||
key: 'HOME',
|
||||
value: '/Users/alice',
|
||||
desc: '当前用户的主目录路径。cd ~ 本质上就是跳到 $HOME。很多程序把配置文件存在这里。'
|
||||
},
|
||||
{
|
||||
key: 'USER',
|
||||
value: 'alice',
|
||||
desc: '当前登录的用户名。服务器程序常用它做权限判断或日志记录。'
|
||||
},
|
||||
{
|
||||
key: 'SHELL',
|
||||
value: '/bin/zsh',
|
||||
desc: '当前使用的 Shell 程序路径。决定了你输入命令后由哪个程序来解释执行。'
|
||||
},
|
||||
{
|
||||
key: 'PATH',
|
||||
value: '/usr/local/bin:/usr/bin:/bin',
|
||||
desc: '最重要的环境变量!Shell 查找可执行文件时,依次在这些目录里搜索,用冒号分隔。见下方演示。'
|
||||
},
|
||||
{
|
||||
key: 'PWD',
|
||||
value: '/Users/alice/projects',
|
||||
desc: '当前工作目录(Print Working Directory)。就是你现在"站在"的那个目录。'
|
||||
},
|
||||
{
|
||||
key: 'LANG',
|
||||
value: 'zh_CN.UTF-8',
|
||||
desc: '系统语言和字符编码。影响程序的错误提示语言、日期格式、排序规则等。'
|
||||
},
|
||||
{
|
||||
key: 'NODE_ENV',
|
||||
value: 'development',
|
||||
desc: '开发者自定义变量。告诉 Node.js 应用当前是开发(development)还是生产(production)环境,影响日志、错误显示等行为。'
|
||||
},
|
||||
{
|
||||
key: 'OPENAI_API_KEY',
|
||||
value: 'sk-••••••••••••••••',
|
||||
desc: '开发者自定义变量,存储 API 密钥。把密钥放在环境变量里(而非写死在代码里)是重要的安全最佳实践。'
|
||||
}
|
||||
]
|
||||
|
||||
const echoVar = (item) => {
|
||||
selected.value = item
|
||||
termLines.value.push(
|
||||
{ id: lineId++, type: 'cmd', text: `$ echo $${item.key}` },
|
||||
{ id: lineId++, type: 'output', text: item.value }
|
||||
)
|
||||
nextTick(() => {
|
||||
if (termBody.value) {
|
||||
termBody.value.scrollTop = termBody.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 0.75rem 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.demo-header .subtitle {
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.content-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.content-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.env-table {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.4fr;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.env-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.4fr;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.8rem;
|
||||
transition: background 0.15s;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.env-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.env-row:hover {
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.env-row.selected {
|
||||
background: color-mix(in srgb, var(--vp-c-brand) 12%, transparent);
|
||||
border-left: 3px solid var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.env-key {
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: bold;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.env-value {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.76rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.terminal-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #1e1e2e;
|
||||
}
|
||||
|
||||
.term-titlebar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
background: #181825;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dot.red {
|
||||
background: #ff5f57;
|
||||
}
|
||||
.dot.yellow {
|
||||
background: #febc2e;
|
||||
}
|
||||
.dot.green {
|
||||
background: #28c840;
|
||||
}
|
||||
|
||||
.term-name {
|
||||
margin-left: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: #6c7086;
|
||||
}
|
||||
|
||||
.term-body {
|
||||
padding: 0.6rem 0.75rem;
|
||||
min-height: 150px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.7;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.term-line {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.line-hint {
|
||||
color: #585b70;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.line-cmd {
|
||||
color: #a6e3a1;
|
||||
}
|
||||
|
||||
.line-output {
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
.term-prompt {
|
||||
color: #585b70;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
animation: blink 1s step-end infinite;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.term-desc {
|
||||
border-top: 1px solid #313244;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: #11111b;
|
||||
}
|
||||
|
||||
.desc-title {
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.25rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.desc-body {
|
||||
font-size: 0.75rem;
|
||||
color: #7f849c;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: block;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.info-box code {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.8rem;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 3px;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,549 @@
|
||||
<template>
|
||||
<div class="demo-root">
|
||||
<div class="demo-header">
|
||||
<span class="title">npm install 全过程模拟</span>
|
||||
<span class="subtitle">观察一个包从命令行到磁盘的完整安装旅程</span>
|
||||
</div>
|
||||
|
||||
<div class="control-panel">
|
||||
<div class="input-row">
|
||||
<span class="pm-label">$ npm install</span>
|
||||
<select v-model="selectedPkg" class="pkg-select" :disabled="installing">
|
||||
<option v-for="p in packages" :key="p.name" :value="p.name">{{ p.name }}</option>
|
||||
</select>
|
||||
<button class="install-btn" :disabled="installing" @click="runInstall">
|
||||
{{ installing ? '安装中…' : '运行' }}
|
||||
</button>
|
||||
<button class="reset-btn" :disabled="installing" @click="resetAll">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="two-col">
|
||||
<!-- 左侧:安装日志 -->
|
||||
<div class="log-panel">
|
||||
<div class="panel-title">📟 安装日志</div>
|
||||
<div ref="logRef" class="log-body">
|
||||
<div
|
||||
v-for="(line, i) in logs"
|
||||
:key="i"
|
||||
:class="['log-line', `log-${line.type}`]"
|
||||
>
|
||||
<span class="log-time">{{ line.time }}</span>
|
||||
<span class="log-text">{{ line.text }}</span>
|
||||
</div>
|
||||
<div v-if="!logs.length" class="log-empty">等待运行…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:文件结构 + package.json -->
|
||||
<div class="right-panel">
|
||||
<div class="panel-title">📁 文件结构变化</div>
|
||||
<div class="file-tree">
|
||||
<div class="tree-line">my-project/</div>
|
||||
<div class="tree-line">├── package.json</div>
|
||||
<div :class="['tree-line', { highlight: showLock }]">
|
||||
{{ showLock ? '├── package-lock.json ✨' : '├── package-lock.json' }}
|
||||
</div>
|
||||
<div class="tree-line">└── node_modules/</div>
|
||||
<template v-for="dep in installedDeps" :key="dep.name">
|
||||
<div class="tree-line dep-line animate-in">
|
||||
{{ dep.isLast ? '└──' : '├──' }} {{ dep.name }}/ <span class="dep-ver">{{ dep.version }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="panel-title" style="margin-top: 0.8rem;">📄 package.json</div>
|
||||
<div class="json-view">
|
||||
<pre class="json-pre">{{ packageJsonStr }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 阶段进度条 -->
|
||||
<div class="phases">
|
||||
<div
|
||||
v-for="ph in phases"
|
||||
:key="ph.id"
|
||||
:class="['phase-item', ph.status]"
|
||||
>
|
||||
<div class="phase-dot"></div>
|
||||
<div class="phase-info">
|
||||
<div class="phase-name">{{ ph.name }}</div>
|
||||
<div class="phase-desc">{{ ph.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心机制:</strong>安装时先解析依赖树 → 去注册表下载 → 解压到 node_modules → 写入锁文件,锁文件确保团队所有人安装完全一致的版本。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
|
||||
const packages = [
|
||||
{
|
||||
name: 'axios',
|
||||
version: '1.6.8',
|
||||
deps: [
|
||||
{ name: 'follow-redirects', version: '1.15.6' },
|
||||
{ name: 'form-data', version: '4.0.0' },
|
||||
{ name: 'proxy-from-env', version: '1.1.0' }
|
||||
],
|
||||
type: 'dependencies'
|
||||
},
|
||||
{
|
||||
name: 'lodash',
|
||||
version: '4.17.21',
|
||||
deps: [],
|
||||
type: 'dependencies'
|
||||
},
|
||||
{
|
||||
name: 'typescript',
|
||||
version: '5.4.5',
|
||||
deps: [],
|
||||
type: 'devDependencies'
|
||||
},
|
||||
{
|
||||
name: 'vue',
|
||||
version: '3.4.21',
|
||||
deps: [
|
||||
{ name: '@vue/compiler-core', version: '3.4.21' },
|
||||
{ name: '@vue/reactivity', version: '3.4.21' },
|
||||
{ name: '@vue/runtime-dom', version: '3.4.21' }
|
||||
],
|
||||
type: 'dependencies'
|
||||
}
|
||||
]
|
||||
|
||||
const selectedPkg = ref('axios')
|
||||
const installing = ref(false)
|
||||
const logs = ref([])
|
||||
const installedDeps = ref([])
|
||||
const showLock = ref(false)
|
||||
const logRef = ref(null)
|
||||
|
||||
const phases = ref([
|
||||
{ id: 'resolve', name: '依赖解析', desc: '分析所有需要的包', status: 'pending' },
|
||||
{ id: 'fetch', name: '下载 & 解压', desc: '从 registry 拉取 tarball', status: 'pending' },
|
||||
{ id: 'link', name: '链接模块', desc: '写入 node_modules/', status: 'pending' },
|
||||
{ id: 'lockfile', name: '写锁文件', desc: '固化精确版本', status: 'pending' }
|
||||
])
|
||||
|
||||
const baseJson = {
|
||||
name: 'my-project',
|
||||
version: '1.0.0',
|
||||
dependencies: {},
|
||||
devDependencies: {}
|
||||
}
|
||||
|
||||
const jsonData = ref(JSON.parse(JSON.stringify(baseJson)))
|
||||
|
||||
const packageJsonStr = computed(() => JSON.stringify(jsonData.value, null, 2))
|
||||
|
||||
function getTime() {
|
||||
return new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function addLog(text, type = 'info') {
|
||||
logs.value.push({ time: getTime(), text, type })
|
||||
nextTick(() => {
|
||||
if (logRef.value) logRef.value.scrollTop = logRef.value.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(r => setTimeout(r, ms))
|
||||
}
|
||||
|
||||
function setPhase(id, status) {
|
||||
const ph = phases.value.find(p => p.id === id)
|
||||
if (ph) ph.status = status
|
||||
}
|
||||
|
||||
async function runInstall() {
|
||||
if (installing.value) return
|
||||
installing.value = true
|
||||
logs.value = []
|
||||
installedDeps.value = []
|
||||
showLock.value = false
|
||||
phases.value.forEach(p => (p.status = 'pending'))
|
||||
|
||||
const pkg = packages.find(p => p.name === selectedPkg.value)
|
||||
if (!pkg) { installing.value = false; return }
|
||||
|
||||
addLog(`> npm install ${pkg.name}`, 'cmd')
|
||||
await sleep(300)
|
||||
|
||||
// Phase 1: resolve
|
||||
setPhase('resolve', 'active')
|
||||
addLog(`正在解析 ${pkg.name}@${pkg.version} 的依赖…`, 'info')
|
||||
await sleep(500)
|
||||
const allPkgs = [pkg, ...pkg.deps]
|
||||
for (const dep of pkg.deps) {
|
||||
addLog(` 找到依赖: ${dep.name}@${dep.version}`, 'dep')
|
||||
await sleep(200)
|
||||
}
|
||||
addLog(`共需安装 ${allPkgs.length} 个包`, 'success')
|
||||
setPhase('resolve', 'done')
|
||||
await sleep(300)
|
||||
|
||||
// Phase 2: fetch
|
||||
setPhase('fetch', 'active')
|
||||
for (const dep of allPkgs) {
|
||||
addLog(`↓ 下载 ${dep.name}-${dep.version}.tgz`, 'fetch')
|
||||
await sleep(300)
|
||||
}
|
||||
setPhase('fetch', 'done')
|
||||
await sleep(200)
|
||||
|
||||
// Phase 3: link
|
||||
setPhase('link', 'active')
|
||||
for (let i = 0; i < allPkgs.length; i++) {
|
||||
const dep = allPkgs[i]
|
||||
addLog(`📂 解压 → node_modules/${dep.name}/`, 'link')
|
||||
installedDeps.value.push({
|
||||
name: dep.name,
|
||||
version: dep.version,
|
||||
isLast: i === allPkgs.length - 1
|
||||
})
|
||||
await sleep(250)
|
||||
}
|
||||
setPhase('link', 'done')
|
||||
await sleep(200)
|
||||
|
||||
// Phase 4: lockfile
|
||||
setPhase('lockfile', 'active')
|
||||
showLock.value = true
|
||||
addLog('✏️ 写入 package-lock.json', 'lock')
|
||||
await sleep(300)
|
||||
|
||||
// Update package.json
|
||||
const updated = JSON.parse(JSON.stringify(baseJson))
|
||||
if (pkg.type === 'dependencies') {
|
||||
updated.dependencies[pkg.name] = `^${pkg.version}`
|
||||
} else {
|
||||
updated.devDependencies[pkg.name] = `^${pkg.version}`
|
||||
}
|
||||
jsonData.value = updated
|
||||
setPhase('lockfile', 'done')
|
||||
|
||||
addLog(`✅ 完成!新增 ${pkg.name}@${pkg.version}`, 'success')
|
||||
installing.value = false
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
logs.value = []
|
||||
installedDeps.value = []
|
||||
showLock.value = false
|
||||
phases.value.forEach(p => (p.status = 'pending'))
|
||||
jsonData.value = JSON.parse(JSON.stringify(baseJson))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin: 1.5rem 0;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
padding: 0.85rem 1.1rem 0.7rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pm-label {
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-brand);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pkg-select {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.install-btn {
|
||||
padding: 0.3rem 0.9rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.83rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.install-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
padding: 0.3rem 0.7rem;
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-2);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
font-size: 0.83rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reset-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.two-col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.log-body {
|
||||
flex: 1;
|
||||
min-height: 160px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background: #1a1a2e;
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
padding: 0.1rem 0;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: #555;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.log-cmd .log-text { color: #7dd3fc; }
|
||||
.log-info .log-text { color: #94a3b8; }
|
||||
.log-dep .log-text { color: #fbbf24; }
|
||||
.log-fetch .log-text { color: #60a5fa; }
|
||||
.log-link .log-text { color: #a78bfa; }
|
||||
.log-lock .log-text { color: #fb923c; }
|
||||
.log-success .log-text { color: #4ade80; }
|
||||
.log-empty { color: #555; font-size: 0.75rem; }
|
||||
|
||||
.right-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.file-tree {
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.tree-line {
|
||||
padding: 0.05rem 0;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.tree-line.highlight {
|
||||
color: var(--vp-c-warning-1, #f59e0b);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dep-line {
|
||||
color: var(--vp-c-brand);
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
.dep-ver {
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateX(-6px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.json-view {
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: auto;
|
||||
max-height: 130px;
|
||||
}
|
||||
|
||||
.json-pre {
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.74rem;
|
||||
color: var(--vp-c-text-2);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.phases {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.phase-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-right: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-soft);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.phase-item:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.phase-item.active {
|
||||
background: color-mix(in srgb, var(--vp-c-brand) 10%, var(--vp-c-bg));
|
||||
}
|
||||
|
||||
.phase-item.done {
|
||||
background: color-mix(in srgb, #22c55e 8%, var(--vp-c-bg));
|
||||
}
|
||||
|
||||
.phase-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--vp-c-divider);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.phase-item.active .phase-dot {
|
||||
background: var(--vp-c-brand);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--vp-c-brand) 25%, transparent);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.phase-item.done .phase-dot {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--vp-c-brand) 25%, transparent); }
|
||||
50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--vp-c-brand) 10%, transparent); }
|
||||
}
|
||||
|
||||
.phase-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.phase-name {
|
||||
font-size: 0.77rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.phase-desc {
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: block;
|
||||
padding: 0.65rem 1rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
</style>
|
||||
+642
@@ -0,0 +1,642 @@
|
||||
<template>
|
||||
<div class="demo-root">
|
||||
<div class="demo-header">
|
||||
<span class="title">包管理器生态地图</span>
|
||||
<span class="subtitle">选择一个语言生态,探索它的包管理工具</span>
|
||||
</div>
|
||||
|
||||
<div class="control-panel">
|
||||
<button
|
||||
v-for="eco in ecosystems"
|
||||
:key="eco.id"
|
||||
:class="['eco-btn', { active: activeEco === eco.id }]"
|
||||
@click="selectEco(eco.id)"
|
||||
>
|
||||
<span class="eco-icon">{{ eco.icon }}</span>
|
||||
<span class="eco-name">{{ eco.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="managers-grid">
|
||||
<div
|
||||
v-for="pm in currentManagers"
|
||||
:key="pm.id"
|
||||
:class="['pm-card', { active: activePm === pm.id }]"
|
||||
@click="selectPm(pm.id)"
|
||||
>
|
||||
<div class="pm-badge" :style="{ background: pm.color }">{{ pm.name }}</div>
|
||||
<div class="pm-tagline">{{ pm.tagline }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div v-if="currentPm" class="pm-detail">
|
||||
<div class="detail-top">
|
||||
<span class="detail-name" :style="{ color: currentPm.color }">{{ currentPm.name }}</span>
|
||||
<span class="detail-full">{{ currentPm.fullName }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-sections">
|
||||
<div class="detail-section">
|
||||
<div class="section-label">安装命令</div>
|
||||
<div class="cmd-list">
|
||||
<div v-for="(cmd, i) in currentPm.commands" :key="i" class="cmd-row">
|
||||
<span class="cmd-op">{{ cmd.op }}</span>
|
||||
<code class="cmd-code">{{ cmd.cmd }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="section-label">配置文件</div>
|
||||
<div class="file-list">
|
||||
<div v-for="f in currentPm.files" :key="f.name" class="file-row">
|
||||
<code class="file-name">{{ f.name }}</code>
|
||||
<span class="file-desc">{{ f.desc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<div class="section-label">核心特点</div>
|
||||
<div class="feature-list">
|
||||
<div v-for="feat in currentPm.features" :key="feat" class="feature-tag">{{ feat }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="pm-placeholder">
|
||||
← 点击上方卡片查看详情
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心思想:</strong>包管理器 = 应用商店,帮你下载、安装、管理别人写好的代码(库/包),并自动处理版本兼容问题。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const activeEco = ref('js')
|
||||
const activePm = ref('npm')
|
||||
|
||||
|
||||
const ecosystems = [
|
||||
{ id: 'js', icon: '🟨', name: 'JavaScript' },
|
||||
{ id: 'python', icon: '🐍', name: 'Python' },
|
||||
{ id: 'rust', icon: '🦀', name: 'Rust' },
|
||||
{ id: 'go', icon: '🐹', name: 'Go' },
|
||||
{ id: 'mac', icon: '🍎', name: 'macOS/Linux' },
|
||||
{ id: 'windows', icon: '🪟', name: 'Windows' }
|
||||
]
|
||||
|
||||
const allManagers = {
|
||||
js: [
|
||||
{
|
||||
id: 'npm',
|
||||
name: 'npm',
|
||||
fullName: 'Node Package Manager',
|
||||
tagline: '最广泛使用,Node.js 自带',
|
||||
color: '#cc3534',
|
||||
commands: [
|
||||
{ op: '安装依赖', cmd: 'npm install lodash' },
|
||||
{ op: '安装开发依赖', cmd: 'npm install -D typescript' },
|
||||
{ op: '运行脚本', cmd: 'npm run build' },
|
||||
{ op: '查看已安装', cmd: 'npm list --depth=0' }
|
||||
],
|
||||
files: [
|
||||
{ name: 'package.json', desc: '项目声明文件,记录依赖和脚本' },
|
||||
{ name: 'package-lock.json', desc: '锁定精确版本,保证环境一致' },
|
||||
{ name: 'node_modules/', desc: '实际安装的包存放目录' }
|
||||
],
|
||||
features: ['Node.js 内置', '最大生态(200万+包)', '支持 workspaces', 'npx 直接运行']
|
||||
},
|
||||
{
|
||||
id: 'yarn',
|
||||
name: 'Yarn',
|
||||
fullName: 'Yet Another Resource Negotiator',
|
||||
tagline: '并行下载快,Plug\'n\'Play 免 node_modules',
|
||||
color: '#2c8ebb',
|
||||
commands: [
|
||||
{ op: '安装依赖', cmd: 'yarn add lodash' },
|
||||
{ op: '安装开发依赖', cmd: 'yarn add -D typescript' },
|
||||
{ op: '运行脚本', cmd: 'yarn build' },
|
||||
{ op: '查看已安装', cmd: 'yarn list --depth=0' }
|
||||
],
|
||||
files: [
|
||||
{ name: 'package.json', desc: '与 npm 兼容的项目声明文件' },
|
||||
{ name: 'yarn.lock', desc: 'Yarn 专属锁文件,格式更易读' },
|
||||
{ name: '.yarnrc.yml', desc: 'Yarn Berry 配置文件' }
|
||||
],
|
||||
features: ['并行安装更快', 'Plug\'n\'Play 零 node_modules', 'Workspace 原生支持', '离线缓存']
|
||||
},
|
||||
{
|
||||
id: 'pnpm',
|
||||
name: 'pnpm',
|
||||
fullName: 'Performant npm',
|
||||
tagline: '硬链接共享,节省磁盘,速度最快',
|
||||
color: '#f9ad00',
|
||||
commands: [
|
||||
{ op: '安装依赖', cmd: 'pnpm add lodash' },
|
||||
{ op: '安装开发依赖', cmd: 'pnpm add -D typescript' },
|
||||
{ op: '运行脚本', cmd: 'pnpm run build' },
|
||||
{ op: '查看已安装', cmd: 'pnpm list --depth=0' }
|
||||
],
|
||||
files: [
|
||||
{ name: 'package.json', desc: '与 npm 兼容的项目声明文件' },
|
||||
{ name: 'pnpm-lock.yaml', desc: 'pnpm 专属锁文件' },
|
||||
{ name: '.pnpm-store/', desc: '全局内容寻址存储,跨项目共享' }
|
||||
],
|
||||
features: ['磁盘空间最省', '安装速度最快', '严格隔离防幽灵依赖', 'Monorepo 友好']
|
||||
}
|
||||
],
|
||||
python: [
|
||||
{
|
||||
id: 'pip',
|
||||
name: 'pip',
|
||||
fullName: 'Pip Installs Packages',
|
||||
tagline: 'Python 官方标准,简单直接',
|
||||
color: '#3776ab',
|
||||
commands: [
|
||||
{ op: '安装包', cmd: 'pip install requests' },
|
||||
{ op: '安装指定版本', cmd: 'pip install requests==2.28.0' },
|
||||
{ op: '导出依赖', cmd: 'pip freeze > requirements.txt' },
|
||||
{ op: '批量安装', cmd: 'pip install -r requirements.txt' }
|
||||
],
|
||||
files: [
|
||||
{ name: 'requirements.txt', desc: '依赖列表,每行一个包和版本' },
|
||||
{ name: 'setup.py / pyproject.toml', desc: '项目元数据和打包配置' }
|
||||
],
|
||||
features: ['Python 内置', '使用最广泛', '配合 venv 隔离环境', '简单直接']
|
||||
},
|
||||
{
|
||||
id: 'conda',
|
||||
name: 'conda',
|
||||
fullName: 'Conda Package Manager',
|
||||
tagline: '科学计算利器,同时管理 Python 版本',
|
||||
color: '#44a833',
|
||||
commands: [
|
||||
{ op: '创建环境', cmd: 'conda create -n myenv python=3.11' },
|
||||
{ op: '激活环境', cmd: 'conda activate myenv' },
|
||||
{ op: '安装包', cmd: 'conda install numpy' },
|
||||
{ op: '导出环境', cmd: 'conda env export > env.yml' }
|
||||
],
|
||||
files: [
|
||||
{ name: 'environment.yml', desc: '完整环境配置,包含 Python 版本' },
|
||||
{ name: '.condarc', desc: 'conda 全局配置文件' }
|
||||
],
|
||||
features: ['管理 Python 版本', '支持非 Python 包(CUDA等)', '科学计算首选', '跨平台环境复现']
|
||||
},
|
||||
{
|
||||
id: 'uv',
|
||||
name: 'uv',
|
||||
fullName: 'Ultra-fast Python Package Manager',
|
||||
tagline: 'Rust 编写,比 pip 快 10-100 倍',
|
||||
color: '#7c3aed',
|
||||
commands: [
|
||||
{ op: '安装包', cmd: 'uv pip install requests' },
|
||||
{ op: '创建虚拟环境', cmd: 'uv venv' },
|
||||
{ op: '同步依赖', cmd: 'uv pip sync requirements.txt' },
|
||||
{ op: '运行脚本', cmd: 'uv run python script.py' }
|
||||
],
|
||||
files: [
|
||||
{ name: 'requirements.txt', desc: '与 pip 完全兼容的依赖文件' },
|
||||
{ name: 'pyproject.toml', desc: '现代 Python 项目配置标准' }
|
||||
],
|
||||
features: ['Rust 编写极速', '与 pip 完全兼容', '内置虚拟环境管理', '2024年新秀']
|
||||
}
|
||||
],
|
||||
rust: [
|
||||
{
|
||||
id: 'cargo',
|
||||
name: 'Cargo',
|
||||
fullName: 'Rust\'s Package Manager & Build System',
|
||||
tagline: 'Rust 官方工具,集构建/测试/发布于一体',
|
||||
color: '#dea584',
|
||||
commands: [
|
||||
{ op: '添加依赖', cmd: 'cargo add serde' },
|
||||
{ op: '构建项目', cmd: 'cargo build --release' },
|
||||
{ op: '运行项目', cmd: 'cargo run' },
|
||||
{ op: '运行测试', cmd: 'cargo test' }
|
||||
],
|
||||
files: [
|
||||
{ name: 'Cargo.toml', desc: '项目清单,声明依赖和元数据' },
|
||||
{ name: 'Cargo.lock', desc: '精确锁定版本,应用项目必须提交' }
|
||||
],
|
||||
features: ['官方唯一标准', '内置构建系统', '包 = Crate', 'crates.io 生态']
|
||||
}
|
||||
],
|
||||
go: [
|
||||
{
|
||||
id: 'gomod',
|
||||
name: 'Go Modules',
|
||||
fullName: 'Go 官方模块系统(go mod)',
|
||||
tagline: '内置于 Go 工具链,无需额外安装',
|
||||
color: '#00acd7',
|
||||
commands: [
|
||||
{ op: '初始化模块', cmd: 'go mod init github.com/user/project' },
|
||||
{ op: '添加依赖', cmd: 'go get github.com/gin-gonic/gin' },
|
||||
{ op: '整理依赖', cmd: 'go mod tidy' },
|
||||
{ op: '下载到本地', cmd: 'go mod download' }
|
||||
],
|
||||
files: [
|
||||
{ name: 'go.mod', desc: '模块声明文件,记录依赖路径和版本' },
|
||||
{ name: 'go.sum', desc: '哈希校验文件,防止依赖被篡改' }
|
||||
],
|
||||
features: ['Go 工具链内置', '路径即包名', '自动校验完整性', 'pkg.go.dev 生态']
|
||||
}
|
||||
],
|
||||
mac: [
|
||||
{
|
||||
id: 'brew',
|
||||
name: 'Homebrew',
|
||||
fullName: 'The Missing Package Manager for macOS',
|
||||
tagline: 'macOS/Linux 必备,安装开发工具首选',
|
||||
color: '#fbb040',
|
||||
commands: [
|
||||
{ op: '安装软件', cmd: 'brew install git' },
|
||||
{ op: '更新所有', cmd: 'brew upgrade' },
|
||||
{ op: '搜索软件', cmd: 'brew search node' },
|
||||
{ op: '查看已安装', cmd: 'brew list' }
|
||||
],
|
||||
files: [
|
||||
{ name: 'Brewfile', desc: '批量安装清单,可版本控制' }
|
||||
],
|
||||
features: ['macOS/Linux 通用', '管理系统级工具', 'Cask 安装 GUI 应用', '社区驱动']
|
||||
},
|
||||
{
|
||||
id: 'apt',
|
||||
name: 'apt',
|
||||
fullName: 'Advanced Package Tool',
|
||||
tagline: 'Ubuntu/Debian 系统包管理器',
|
||||
color: '#e95420',
|
||||
commands: [
|
||||
{ op: '更新列表', cmd: 'sudo apt update' },
|
||||
{ op: '安装软件', cmd: 'sudo apt install nginx' },
|
||||
{ op: '更新系统', cmd: 'sudo apt upgrade' },
|
||||
{ op: '卸载软件', cmd: 'sudo apt remove nginx' }
|
||||
],
|
||||
files: [
|
||||
{ name: '/etc/apt/sources.list', desc: '软件源配置文件' }
|
||||
],
|
||||
features: ['Ubuntu/Debian 官方', '系统级权限', '依赖自动解析', '服务器运维必备']
|
||||
},
|
||||
{
|
||||
id: 'dnf',
|
||||
name: 'dnf / yum',
|
||||
fullName: 'Dandified YUM(Fedora / RHEL / CentOS)',
|
||||
tagline: 'Red Hat 系 Linux 的系统包管理器',
|
||||
color: '#e00',
|
||||
commands: [
|
||||
{ op: '安装软件', cmd: 'sudo dnf install git' },
|
||||
{ op: '更新系统', cmd: 'sudo dnf upgrade' },
|
||||
{ op: '搜索软件', cmd: 'dnf search nginx' },
|
||||
{ op: '卸载软件', cmd: 'sudo dnf remove nginx' }
|
||||
],
|
||||
files: [
|
||||
{ name: '/etc/dnf/dnf.conf', desc: 'dnf 全局配置文件' }
|
||||
],
|
||||
features: ['Fedora/RHEL/CentOS 官方', '支持模块流', 'DNF5 大幅提速', '企业级 Linux 首选']
|
||||
}
|
||||
],
|
||||
windows: [
|
||||
{
|
||||
id: 'winget',
|
||||
name: 'winget',
|
||||
fullName: 'Windows Package Manager',
|
||||
tagline: 'Microsoft 官方出品,Win 10/11 内置',
|
||||
color: '#0078d4',
|
||||
commands: [
|
||||
{ op: '安装软件', cmd: 'winget install Git.Git' },
|
||||
{ op: '更新所有', cmd: 'winget upgrade --all' },
|
||||
{ op: '搜索软件', cmd: 'winget search nodejs' },
|
||||
{ op: '卸载软件', cmd: 'winget uninstall Git.Git' }
|
||||
],
|
||||
files: [
|
||||
{ name: 'winget-packages.json', desc: '导出的软件清单,可用于批量恢复' }
|
||||
],
|
||||
features: ['Windows 10/11 内置', 'Microsoft Store 集成', '软件包签名验证', '官方持续更新中']
|
||||
},
|
||||
{
|
||||
id: 'choco',
|
||||
name: 'Chocolatey',
|
||||
fullName: 'Chocolatey Package Manager',
|
||||
tagline: 'Windows 最成熟的第三方包管理器',
|
||||
color: '#4a154b',
|
||||
commands: [
|
||||
{ op: '安装软件', cmd: 'choco install git' },
|
||||
{ op: '更新所有', cmd: 'choco upgrade all' },
|
||||
{ op: '搜索软件', cmd: 'choco search nodejs' },
|
||||
{ op: '卸载软件', cmd: 'choco uninstall git' }
|
||||
],
|
||||
files: [
|
||||
{ name: 'packages.config', desc: 'XML 格式的软件清单,批量安装用' }
|
||||
],
|
||||
features: ['生态最成熟(10000+包)', '企业版商业支持', 'PowerShell 集成', '支持无人值守安装']
|
||||
},
|
||||
{
|
||||
id: 'scoop',
|
||||
name: 'Scoop',
|
||||
fullName: 'Scoop — A command-line installer for Windows',
|
||||
tagline: '无需管理员权限,专为开发者设计',
|
||||
color: '#1a73e8',
|
||||
commands: [
|
||||
{ op: '安装软件', cmd: 'scoop install git' },
|
||||
{ op: '更新所有', cmd: 'scoop update *' },
|
||||
{ op: '搜索软件', cmd: 'scoop search nodejs' },
|
||||
{ op: '卸载软件', cmd: 'scoop uninstall git' }
|
||||
],
|
||||
files: [
|
||||
{ name: 'Scoopfile / apps.json', desc: '应用清单,用于环境还原' }
|
||||
],
|
||||
features: ['无需管理员权限', '安装到用户目录', '版本共存切换', '开发者工具首选']
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const currentManagers = computed(() => allManagers[activeEco.value] || [])
|
||||
|
||||
const currentPm = computed(() => {
|
||||
const list = currentManagers.value
|
||||
return list.find(p => p.id === activePm.value) || null
|
||||
})
|
||||
|
||||
function selectEco(id) {
|
||||
activeEco.value = id
|
||||
activePm.value = allManagers[id]?.[0]?.id || null
|
||||
}
|
||||
|
||||
function selectPm(id) {
|
||||
activePm.value = id
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin: 1.5rem 0;
|
||||
background: var(--vp-c-bg);
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
padding: 0.85rem 1.1rem 0.7rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.eco-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.8rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.eco-btn:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.eco-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
border-color: var(--vp-c-brand);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.eco-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.managers-grid {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pm-card {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1.5px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.pm-card:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.pm-card.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: var(--vp-c-bg-alt);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--vp-c-brand) 20%, transparent);
|
||||
}
|
||||
|
||||
.pm-badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.pm-tagline {
|
||||
font-size: 0.76rem;
|
||||
color: var(--vp-c-text-3);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.pm-detail {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
|
||||
.pm-placeholder {
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.detail-top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.8rem;
|
||||
padding-bottom: 0.6rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.detail-full {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.detail-sections {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.detail-sections {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.cmd-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.cmd-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.cmd-op {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.cmd-code {
|
||||
font-size: 0.76rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
color: var(--vp-c-brand);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.file-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-1);
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.file-desc {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.feature-tag {
|
||||
font-size: 0.73rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
color: var(--vp-c-text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: block;
|
||||
padding: 0.65rem 1rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,478 @@
|
||||
<template>
|
||||
<div class="demo-root">
|
||||
<div class="demo-header">
|
||||
<span class="title">PATH 搜索过程</span>
|
||||
<span class="subtitle">输入命令名,看 Shell 是如何逐目录查找的</span>
|
||||
</div>
|
||||
|
||||
<div class="control-panel">
|
||||
<div class="preset-label">选择命令:</div>
|
||||
<div class="preset-btns">
|
||||
<button
|
||||
v-for="cmd in presets"
|
||||
:key="cmd.name"
|
||||
class="preset-btn"
|
||||
:class="{ active: command === cmd.name }"
|
||||
:disabled="isSearching"
|
||||
@click="selectCommand(cmd)"
|
||||
>
|
||||
{{ cmd.name }}
|
||||
</button>
|
||||
</div>
|
||||
<button class="action-btn" :disabled="isSearching || !command" @click="startSearch">
|
||||
{{ isSearching ? '搜索中...' : '▶ 开始搜索' }}
|
||||
</button>
|
||||
<button class="reset-btn" :disabled="isSearching" @click="reset">重置</button>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="path-display">
|
||||
<div class="path-label">当前 PATH:</div>
|
||||
<div class="path-value">
|
||||
<span
|
||||
v-for="(dir, idx) in pathDirs"
|
||||
:key="dir"
|
||||
class="path-segment"
|
||||
:class="{ active: currentDirIdx === idx }"
|
||||
>{{ dir }}<span v-if="idx < pathDirs.length - 1" class="sep">:</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-grid">
|
||||
<div
|
||||
v-for="(dir, idx) in pathDirs"
|
||||
:key="dir"
|
||||
class="dir-card"
|
||||
:class="getDirClass(idx)"
|
||||
>
|
||||
<div class="dir-name">{{ dir }}</div>
|
||||
<div v-if="dirStates[idx] === 'searching'" class="dir-status searching">
|
||||
<span class="spin">⟳</span> 查找 {{ command }}...
|
||||
</div>
|
||||
<div v-else-if="dirStates[idx] === 'found'" class="dir-status found">
|
||||
✓ 找到了!
|
||||
</div>
|
||||
<div v-else-if="dirStates[idx] === 'notfound'" class="dir-status notfound">
|
||||
✗ 没有
|
||||
</div>
|
||||
<div v-else class="dir-status idle">待查找</div>
|
||||
|
||||
<div v-if="dirStates[idx] === 'found' && currentCmd" class="found-path">
|
||||
{{ dir }}/{{ command }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="result" class="result-panel" :class="result.type">
|
||||
<span class="result-icon">{{ result.type === 'success' ? '✅' : '❌' }}</span>
|
||||
<div class="result-text">
|
||||
<strong>{{ result.title }}</strong>
|
||||
<div class="result-detail">{{ result.detail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心机制:</strong>Shell 拿到命令名后,按 PATH 里目录的顺序依次查找。找到第一个匹配就立即使用,停止继续搜索。所以 PATH 中目录的顺序非常重要——先出现的目录优先级更高。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
const pathDirs = ['/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin']
|
||||
|
||||
const presets = [
|
||||
{ name: 'git', foundAt: 1, desc: 'Git 版本控制工具' },
|
||||
{ name: 'python3', foundAt: 2, desc: 'Python 解释器' },
|
||||
{ name: 'node', foundAt: 0, desc: 'Node.js 运行时(通常安装在 /usr/local/bin)' },
|
||||
{ name: 'ls', foundAt: 2, desc: '列出目录内容的内置命令' },
|
||||
{ name: 'foobar', foundAt: -1, desc: '一个不存在的命令' }
|
||||
]
|
||||
|
||||
const command = ref('')
|
||||
const currentCmd = ref(null)
|
||||
const isSearching = ref(false)
|
||||
const currentDirIdx = ref(-1)
|
||||
const dirStates = reactive(Array(pathDirs.length).fill('idle'))
|
||||
const result = ref(null)
|
||||
|
||||
const selectCommand = (cmd) => {
|
||||
if (isSearching.value) return
|
||||
command.value = cmd.name
|
||||
currentCmd.value = cmd
|
||||
reset()
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
currentDirIdx.value = -1
|
||||
for (let i = 0; i < pathDirs.length; i++) dirStates[i] = 'idle'
|
||||
result.value = null
|
||||
}
|
||||
|
||||
const getDirClass = (idx) => {
|
||||
const s = dirStates[idx]
|
||||
return {
|
||||
searching: s === 'searching',
|
||||
found: s === 'found',
|
||||
notfound: s === 'notfound',
|
||||
'past-current': idx < currentDirIdx.value && s !== 'found'
|
||||
}
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
const startSearch = async () => {
|
||||
if (isSearching.value || !currentCmd.value) return
|
||||
reset()
|
||||
isSearching.value = true
|
||||
|
||||
const cmd = currentCmd.value
|
||||
const foundIdx = cmd.foundAt
|
||||
|
||||
for (let i = 0; i < pathDirs.length; i++) {
|
||||
currentDirIdx.value = i
|
||||
dirStates[i] = 'searching'
|
||||
await sleep(700)
|
||||
|
||||
if (i === foundIdx) {
|
||||
dirStates[i] = 'found'
|
||||
result.value = {
|
||||
type: 'success',
|
||||
title: `命令找到了!`,
|
||||
detail: `在 ${pathDirs[i]}/${cmd.name} 找到可执行文件,搜索停止。`
|
||||
}
|
||||
break
|
||||
} else {
|
||||
dirStates[i] = 'notfound'
|
||||
}
|
||||
|
||||
if (i === pathDirs.length - 1 || (foundIdx === -1 && i === pathDirs.length - 1)) {
|
||||
result.value = {
|
||||
type: 'error',
|
||||
title: `command not found: ${cmd.name}`,
|
||||
detail: `已搜索 PATH 中所有 ${pathDirs.length} 个目录,均未找到。需要先安装该程序,或将其所在目录加入 PATH。`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentDirIdx.value = -1
|
||||
isSearching.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 0.75rem 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.demo-header .subtitle {
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
}
|
||||
|
||||
.preset-label {
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preset-btns {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
padding: 0.25rem 0.65rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
color: var(--vp-c-text-2);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.preset-btn:hover:not(:disabled) {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.preset-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
border-color: var(--vp-c-brand);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.preset-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.3rem 0.9rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
transition: opacity 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
padding: 0.3rem 0.7rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
color: var(--vp-c-text-2);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reset-btn:hover:not(:disabled) {
|
||||
border-color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.reset-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.path-display {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.path-label {
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-2);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.path-value {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.76rem;
|
||||
color: var(--vp-c-text-2);
|
||||
min-width: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.path-segment {
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.path-segment.active {
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.sep {
|
||||
color: var(--vp-c-divider);
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
.search-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.search-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.dir-card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
transition: all 0.3s ease;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.dir-card.searching {
|
||||
border-color: var(--vp-c-brand);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--vp-c-brand) 40%, transparent);
|
||||
}
|
||||
|
||||
.dir-card.found {
|
||||
border-color: var(--vp-c-green-1);
|
||||
background: color-mix(in srgb, var(--vp-c-green-1) 8%, var(--vp-c-bg));
|
||||
}
|
||||
|
||||
.dir-card.notfound {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.dir-name {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-1);
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.4rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.dir-status {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dir-status.idle {
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.dir-status.searching {
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.dir-status.found {
|
||||
color: var(--vp-c-green-1);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.dir-status.notfound {
|
||||
color: var(--vp-c-danger-1, #f87171);
|
||||
}
|
||||
|
||||
.spin {
|
||||
display: inline-block;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.found-path {
|
||||
margin-top: 0.3rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-green-1);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.result-panel {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.result-panel.success {
|
||||
background: color-mix(in srgb, var(--vp-c-green-1) 8%, var(--vp-c-bg));
|
||||
border-color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.result-panel.error {
|
||||
background: color-mix(in srgb, var(--vp-c-danger-1, #f87171) 8%, var(--vp-c-bg));
|
||||
border-color: var(--vp-c-danger-1, #f87171);
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.result-text strong {
|
||||
font-size: 0.88rem;
|
||||
color: var(--vp-c-text-1);
|
||||
display: block;
|
||||
margin-bottom: 0.2rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.result-detail {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: block;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,978 @@
|
||||
<template>
|
||||
<div class="regex-demo">
|
||||
<div class="demo-header">
|
||||
<span class="title">正则表达式:文本的搜索引擎</span>
|
||||
<span class="subtitle">模式匹配 · 分组捕获 · 实时预览</span>
|
||||
</div>
|
||||
|
||||
<div class="control-panel">
|
||||
<div class="mode-btns">
|
||||
<button
|
||||
v-for="m in modes"
|
||||
:key="m.id"
|
||||
:class="['mode-btn', { active: activeMode === m.id }]"
|
||||
@click="activeMode = m.id"
|
||||
>
|
||||
{{ m.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<!-- Mode 1: Live Playground -->
|
||||
<div v-if="activeMode === 'playground'" class="playground-section">
|
||||
<div class="input-group">
|
||||
<label>正则表达式</label>
|
||||
<div class="regex-input-wrapper">
|
||||
<span class="regex-slash">/</span>
|
||||
<input
|
||||
v-model="regexPattern"
|
||||
type="text"
|
||||
placeholder="输入正则..."
|
||||
class="regex-input"
|
||||
/>
|
||||
<span class="regex-slash">/</span>
|
||||
<input
|
||||
v-model="regexFlags"
|
||||
type="text"
|
||||
placeholder="g"
|
||||
class="flags-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>测试文本</label>
|
||||
<textarea
|
||||
v-model="testText"
|
||||
rows="3"
|
||||
placeholder="输入要匹配的文本..."
|
||||
class="test-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="match-results">
|
||||
<div class="results-header">
|
||||
<span class="results-title">匹配结果</span>
|
||||
<span
|
||||
class="match-count"
|
||||
:class="{ 'has-match': matches.length > 0 }"
|
||||
>
|
||||
{{ matches.length }} 个匹配
|
||||
</span>
|
||||
</div>
|
||||
<div class="highlighted-text" v-html="highlightedText" />
|
||||
<div v-if="matches.length > 0" class="match-list">
|
||||
<div v-for="(m, i) in matches" :key="i" class="match-item">
|
||||
<span class="match-index">#{{ i + 1 }}</span>
|
||||
<code class="match-value">"{{ m }}"</code>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="regexError" class="regex-error">{{ regexError }}</div>
|
||||
</div>
|
||||
|
||||
<div class="preset-btns">
|
||||
<span class="preset-label">试试预设:</span>
|
||||
<button
|
||||
v-for="p in presets"
|
||||
:key="p.name"
|
||||
class="preset-btn"
|
||||
@click="applyPreset(p)"
|
||||
>
|
||||
{{ p.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode 2: Cheat Sheet -->
|
||||
<div v-if="activeMode === 'cheatsheet'" class="cheatsheet-section">
|
||||
<div
|
||||
v-for="cat in cheatsheet"
|
||||
:key="cat.category"
|
||||
class="cheat-category"
|
||||
>
|
||||
<div class="cat-title">{{ cat.category }}</div>
|
||||
<div class="cheat-grid">
|
||||
<div
|
||||
v-for="item in cat.items"
|
||||
:key="item.pattern"
|
||||
class="cheat-item"
|
||||
@click="tryCheat(item)"
|
||||
>
|
||||
<code class="cheat-pattern">{{ item.pattern }}</code>
|
||||
<span class="cheat-desc">{{ item.desc }}</span>
|
||||
<span class="cheat-example">{{ item.example }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode 3: Common Patterns -->
|
||||
<div v-if="activeMode === 'patterns'" class="patterns-section">
|
||||
<div class="patterns-grid">
|
||||
<div v-for="p in commonPatterns" :key="p.name" class="pattern-card">
|
||||
<div class="pattern-name">{{ p.name }}</div>
|
||||
<code class="pattern-regex">{{ p.regex }}</code>
|
||||
<div class="pattern-matches">
|
||||
<div
|
||||
v-for="(ex, i) in p.examples"
|
||||
:key="i"
|
||||
class="pattern-example"
|
||||
>
|
||||
<span class="ex-text">{{ ex.text }}</span>
|
||||
<span :class="['ex-result', ex.match ? 'pass' : 'fail']">
|
||||
{{ ex.match ? '✓ 匹配' : '✗ 不匹配' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode 4: Visual Breakdown -->
|
||||
<div v-if="activeMode === 'visual'" class="visual-section">
|
||||
<div class="visual-example">
|
||||
<div class="visual-title">正则解剖:拆解一个邮箱匹配模式</div>
|
||||
<div class="visual-regex">
|
||||
<span
|
||||
v-for="(part, i) in regexParts"
|
||||
:key="i"
|
||||
:class="['regex-part', part.type]"
|
||||
@mouseenter="activePart = i"
|
||||
@mouseleave="activePart = -1"
|
||||
>
|
||||
{{ part.text }}
|
||||
<span v-if="activePart === i" class="part-tooltip">{{
|
||||
part.desc
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="visual-legend">
|
||||
<span
|
||||
v-for="l in legend"
|
||||
:key="l.type"
|
||||
:class="['legend-item', l.type]"
|
||||
>
|
||||
<span class="legend-dot" />{{ l.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visual-flow">
|
||||
<div class="flow-title">正则引擎的工作过程</div>
|
||||
<div class="flow-steps">
|
||||
<div v-for="(step, i) in engineSteps" :key="i" class="flow-step">
|
||||
<div class="flow-num">{{ i + 1 }}</div>
|
||||
<div class="flow-content">
|
||||
<div class="flow-action">{{ step.action }}</div>
|
||||
<div class="flow-detail">{{ step.detail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心思想:</strong>
|
||||
<span v-if="activeMode === 'playground'"
|
||||
>正则表达式是一种用特殊符号描述文本模式的语言,在搜索、替换、数据验证中无处不在。</span
|
||||
>
|
||||
<span v-else-if="activeMode === 'cheatsheet'"
|
||||
>记住几个核心符号(. * + ? \d \w [] ())就能覆盖 80%
|
||||
的使用场景。点击任意符号可直接试验。</span
|
||||
>
|
||||
<span v-else-if="activeMode === 'patterns'"
|
||||
>不需要自己从零写正则——常见场景(邮箱、手机号、URL)都有成熟的模式可以直接复用。</span
|
||||
>
|
||||
<span v-else
|
||||
>正则引擎从左到右逐字符匹配,遇到量词会"贪婪"地尽量多匹配,失败时"回溯"尝试其他路径。</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const activeMode = ref('playground')
|
||||
|
||||
const modes = [
|
||||
{ id: 'playground', label: '实时试验' },
|
||||
{ id: 'cheatsheet', label: '速查表' },
|
||||
{ id: 'patterns', label: '常用模式' },
|
||||
{ id: 'visual', label: '可视化解析' }
|
||||
]
|
||||
|
||||
const regexPattern = ref('\\d+')
|
||||
const regexFlags = ref('g')
|
||||
const testText = ref(
|
||||
'我的手机号是 13812345678,座机是 010-12345678,邮箱是 test@example.com'
|
||||
)
|
||||
|
||||
function buildRegex(pattern, flags) {
|
||||
try {
|
||||
if (!pattern) return { regex: null, error: '' }
|
||||
return { regex: new RegExp(pattern, flags), error: '' }
|
||||
} catch (e) {
|
||||
return { regex: null, error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
const regexResult = computed(() =>
|
||||
buildRegex(regexPattern.value, regexFlags.value)
|
||||
)
|
||||
const regexError = computed(() => regexResult.value.error)
|
||||
|
||||
const matches = computed(() => {
|
||||
const { regex } = regexResult.value
|
||||
if (!regex) return []
|
||||
try {
|
||||
const result = []
|
||||
let match
|
||||
if (regexFlags.value.includes('g')) {
|
||||
while ((match = regex.exec(testText.value)) !== null) {
|
||||
result.push(match[0])
|
||||
if (!match[0]) break
|
||||
}
|
||||
} else {
|
||||
match = regex.exec(testText.value)
|
||||
if (match) result.push(match[0])
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
const highlightedText = computed(() => {
|
||||
try {
|
||||
if (!regexPattern.value || regexError.value) {
|
||||
return escapeHtml(testText.value)
|
||||
}
|
||||
const regex = new RegExp(regexPattern.value, regexFlags.value)
|
||||
return escapeHtml(testText.value).replace(
|
||||
regex,
|
||||
(m) => `<mark class="highlight">${escapeHtml(m)}</mark>`
|
||||
)
|
||||
} catch {
|
||||
return escapeHtml(testText.value)
|
||||
}
|
||||
})
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
const presets = [
|
||||
{
|
||||
name: '找数字',
|
||||
pattern: '\\d+',
|
||||
flags: 'g',
|
||||
text: '价格是 99 元,优惠 20 元,共 79 元'
|
||||
},
|
||||
{
|
||||
name: '找邮箱',
|
||||
pattern: '[\\w.+-]+@[\\w-]+\\.[\\w.]+',
|
||||
flags: 'g',
|
||||
text: 'admin@test.com 和 user@example.org 是有效邮箱'
|
||||
},
|
||||
{
|
||||
name: '找手机号',
|
||||
pattern: '1[3-9]\\d{9}',
|
||||
flags: 'g',
|
||||
text: '联系我:13812345678 或 15099887766'
|
||||
},
|
||||
{
|
||||
name: '找 URL',
|
||||
pattern: 'https?://[^\\s]+',
|
||||
flags: 'g',
|
||||
text: '访问 https://github.com 或 http://example.com/path'
|
||||
},
|
||||
{
|
||||
name: '找中文',
|
||||
pattern: '[\\u4e00-\\u9fa5]+',
|
||||
flags: 'g',
|
||||
text: 'Hello世界,你好World!'
|
||||
}
|
||||
]
|
||||
|
||||
function applyPreset(p) {
|
||||
regexPattern.value = p.pattern
|
||||
regexFlags.value = p.flags
|
||||
testText.value = p.text
|
||||
}
|
||||
|
||||
const cheatsheet = [
|
||||
{
|
||||
category: '字符类',
|
||||
items: [
|
||||
{ pattern: '.', desc: '任意字符(除换行)', example: 'a.c → abc, a1c' },
|
||||
{ pattern: '\\d', desc: '数字 [0-9]', example: '\\d → 3, 7' },
|
||||
{ pattern: '\\w', desc: '字母数字下划线', example: '\\w → a, 5, _' },
|
||||
{ pattern: '\\s', desc: '空白字符', example: '空格、Tab、换行' },
|
||||
{ pattern: '[abc]', desc: '字符集合', example: '[aeiou] → 元音' },
|
||||
{ pattern: '[^abc]', desc: '否定集合', example: '[^0-9] → 非数字' }
|
||||
]
|
||||
},
|
||||
{
|
||||
category: '量词',
|
||||
items: [
|
||||
{ pattern: '*', desc: '0 或多次', example: 'ab* → a, ab, abb' },
|
||||
{ pattern: '+', desc: '1 或多次', example: 'ab+ → ab, abb' },
|
||||
{ pattern: '?', desc: '0 或 1 次', example: 'colou?r → color, colour' },
|
||||
{ pattern: '{n}', desc: '恰好 n 次', example: '\\d{4} → 2024' },
|
||||
{ pattern: '{n,m}', desc: 'n 到 m 次', example: '\\d{2,4} → 12, 123' }
|
||||
]
|
||||
},
|
||||
{
|
||||
category: '位置',
|
||||
items: [
|
||||
{ pattern: '^', desc: '行首', example: '^Hello → 以 Hello 开头' },
|
||||
{ pattern: '$', desc: '行尾', example: 'end$ → 以 end 结尾' },
|
||||
{
|
||||
pattern: '\\b',
|
||||
desc: '单词边界',
|
||||
example: '\\bcat\\b → cat(不匹配 catch)'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
category: '分组与引用',
|
||||
items: [
|
||||
{ pattern: '(abc)', desc: '捕获组', example: '(\\d+)-(\\d+) → 分别捕获' },
|
||||
{ pattern: 'a|b', desc: '或', example: 'cat|dog → cat 或 dog' },
|
||||
{ pattern: '(?:abc)', desc: '非捕获组', example: '(?:ab)+ → abab' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
function tryCheat(item) {
|
||||
activeMode.value = 'playground'
|
||||
regexPattern.value = item.pattern.replace(/\\/g, '\\')
|
||||
regexFlags.value = 'g'
|
||||
}
|
||||
|
||||
const commonPatterns = [
|
||||
{
|
||||
name: '邮箱',
|
||||
regex: '^[\\w.+-]+@[\\w-]+\\.[\\w.]+$',
|
||||
examples: [
|
||||
{ text: 'user@example.com', match: true },
|
||||
{ text: 'a.b+c@test.org', match: true },
|
||||
{ text: 'invalid@', match: false },
|
||||
{ text: '@no-user.com', match: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '手机号(中国)',
|
||||
regex: '^1[3-9]\\d{9}$',
|
||||
examples: [
|
||||
{ text: '13812345678', match: true },
|
||||
{ text: '15099887766', match: true },
|
||||
{ text: '12345678901', match: false },
|
||||
{ text: '1381234567', match: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'URL',
|
||||
regex: '^https?://[^\\s]+$',
|
||||
examples: [
|
||||
{ text: 'https://github.com', match: true },
|
||||
{ text: 'http://example.com/path?q=1', match: true },
|
||||
{ text: 'ftp://not-http.com', match: false },
|
||||
{ text: 'just-text', match: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'IPv4 地址',
|
||||
regex: '^(\\d{1,3}\\.){3}\\d{1,3}$',
|
||||
examples: [
|
||||
{ text: '192.168.1.1', match: true },
|
||||
{ text: '10.0.0.255', match: true },
|
||||
{ text: '999.999.999.999', match: true },
|
||||
{ text: '1.2.3', match: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '日期 (YYYY-MM-DD)',
|
||||
regex: '^\\d{4}-\\d{2}-\\d{2}$',
|
||||
examples: [
|
||||
{ text: '2024-01-15', match: true },
|
||||
{ text: '2023-12-31', match: true },
|
||||
{ text: '24-1-5', match: false },
|
||||
{ text: '2024/01/15', match: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: '强密码',
|
||||
regex: '^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).{8,}$',
|
||||
examples: [
|
||||
{ text: 'Passw0rd', match: true },
|
||||
{ text: 'MyP@ss123', match: true },
|
||||
{ text: 'password', match: false },
|
||||
{ text: 'SHORT1a', match: false }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const activePart = ref(-1)
|
||||
|
||||
const regexParts = [
|
||||
{ text: '[', type: 'bracket', desc: '字符集合开始' },
|
||||
{ text: '\\w', type: 'char-class', desc: '字母、数字或下划线' },
|
||||
{ text: '.+-', type: 'literal', desc: '点号、加号、横杠(字面量)' },
|
||||
{ text: ']', type: 'bracket', desc: '字符集合结束' },
|
||||
{ text: '+', type: 'quantifier', desc: '一个或多个(贪婪匹配)' },
|
||||
{ text: '@', type: 'literal', desc: '字面量 @ 符号' },
|
||||
{ text: '[', type: 'bracket', desc: '字符集合开始' },
|
||||
{ text: '\\w', type: 'char-class', desc: '字母、数字或下划线' },
|
||||
{ text: '-', type: 'literal', desc: '横杠(字面量)' },
|
||||
{ text: ']', type: 'bracket', desc: '字符集合结束' },
|
||||
{ text: '+', type: 'quantifier', desc: '一个或多个' },
|
||||
{ text: '\\.', type: 'escape', desc: '转义的点号(匹配字面量 .)' },
|
||||
{ text: '[', type: 'bracket', desc: '字符集合开始' },
|
||||
{ text: '\\w', type: 'char-class', desc: '字母、数字或下划线' },
|
||||
{ text: '.', type: 'literal', desc: '点号(在字符集中是字面量)' },
|
||||
{ text: ']', type: 'bracket', desc: '字符集合结束' },
|
||||
{ text: '+', type: 'quantifier', desc: '一个或多个' }
|
||||
]
|
||||
|
||||
const legend = [
|
||||
{ type: 'char-class', label: '字符类' },
|
||||
{ type: 'quantifier', label: '量词' },
|
||||
{ type: 'literal', label: '字面量' },
|
||||
{ type: 'bracket', label: '集合边界' },
|
||||
{ type: 'escape', label: '转义字符' }
|
||||
]
|
||||
|
||||
const engineSteps = [
|
||||
{
|
||||
action: '从左到右扫描',
|
||||
detail: '正则引擎从文本第一个字符开始,逐个尝试匹配'
|
||||
},
|
||||
{ action: '贪婪匹配', detail: '遇到 * + 等量词时,尽量多匹配字符' },
|
||||
{ action: '回溯', detail: '如果贪婪匹配失败,退回一步尝试更少的字符' },
|
||||
{ action: '捕获分组', detail: '遇到 () 时,记录匹配的子串供后续引用' },
|
||||
{ action: '返回结果', detail: '全部匹配完成,返回所有匹配项和捕获组' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.regex-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;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.demo-header .subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.mode-btns {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
/* Playground */
|
||||
.input-group {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.regex-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
padding: 0 0.35rem;
|
||||
}
|
||||
|
||||
.regex-slash {
|
||||
color: var(--vp-c-text-3);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.regex-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0.4rem 0.25rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.flags-input {
|
||||
width: 30px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0.4rem 0.15rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.85rem;
|
||||
outline: none;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.test-input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 4px;
|
||||
padding: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.match-results {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.results-title {
|
||||
font-weight: bold;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.match-count {
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-3);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 10px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.match-count.has-match {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.highlighted-text {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
padding: 0.35rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
:deep(.highlight) {
|
||||
background: rgba(59, 130, 246, 0.25);
|
||||
padding: 0.05rem 0.15rem;
|
||||
border-radius: 2px;
|
||||
border-bottom: 2px solid var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.match-list {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.match-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.match-index {
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.match-value {
|
||||
background: var(--vp-c-brand-soft);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.regex-error {
|
||||
color: var(--vp-c-danger-1);
|
||||
font-size: 0.78rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.preset-btns {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.preset-label {
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
padding: 0.2rem 0.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.preset-btn:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
/* Cheatsheet */
|
||||
.cheat-category {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.cat-title {
|
||||
font-weight: bold;
|
||||
font-size: 0.88rem;
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.cheat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.cheat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.cheat-item:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.cheat-pattern {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.cheat-desc {
|
||||
font-size: 0.78rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.cheat-example {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
/* Patterns */
|
||||
.patterns-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pattern-card {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.pattern-name {
|
||||
font-weight: bold;
|
||||
font-size: 0.88rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.pattern-regex {
|
||||
display: block;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.75rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--vp-c-brand);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.pattern-matches {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.pattern-example {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.2rem 0.35rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 3px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.ex-text {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.ex-result.pass {
|
||||
color: var(--vp-c-green-1);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ex-result.fail {
|
||||
color: var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
/* Visual */
|
||||
.visual-example {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.visual-title,
|
||||
.flow-title {
|
||||
font-weight: bold;
|
||||
font-size: 0.88rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.visual-regex {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
padding: 0.5rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.regex-part {
|
||||
position: relative;
|
||||
padding: 0.15rem 0.1rem;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.regex-part.char-class {
|
||||
color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
.regex-part.quantifier {
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
}
|
||||
.regex-part.literal {
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
.regex-part.bracket {
|
||||
color: #8b5cf6;
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
.regex-part.escape {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.part-tooltip {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--vp-c-text-1);
|
||||
color: var(--vp-c-bg);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.visual-legend {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.legend-item.char-class .legend-dot {
|
||||
background: #3b82f6;
|
||||
}
|
||||
.legend-item.quantifier .legend-dot {
|
||||
background: #f59e0b;
|
||||
}
|
||||
.legend-item.literal .legend-dot {
|
||||
background: var(--vp-c-text-2);
|
||||
}
|
||||
.legend-item.bracket .legend-dot {
|
||||
background: #8b5cf6;
|
||||
}
|
||||
.legend-item.escape .legend-dot {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.visual-flow {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.flow-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.flow-num {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 0.72rem;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.flow-action {
|
||||
font-weight: bold;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.flow-detail {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
/* Info Box */
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.cheat-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.patterns-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,835 @@
|
||||
<template>
|
||||
<div class="ssh-auth-demo">
|
||||
<div class="demo-header">
|
||||
<span class="title">SSH 密钥认证:你的数字身份证</span>
|
||||
<span class="subtitle"
|
||||
>对称加密 vs 非对称加密 · 密钥对生成 · 认证流程</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="control-panel">
|
||||
<div class="scenario-btns">
|
||||
<button
|
||||
v-for="s in scenarios"
|
||||
:key="s.id"
|
||||
:class="['scenario-btn', { active: activeScenario === s.id }]"
|
||||
@click="activeScenario = s.id"
|
||||
>
|
||||
{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<!-- Scenario 1: Password vs Key -->
|
||||
<div v-if="activeScenario === 'compare'" class="compare-section">
|
||||
<div class="compare-grid">
|
||||
<div class="compare-card password">
|
||||
<div class="card-icon">🔑</div>
|
||||
<div class="card-title">密码登录</div>
|
||||
<div class="card-flow">
|
||||
<div class="flow-step" v-for="(step, i) in passwordFlow" :key="i">
|
||||
<span class="step-num">{{ i + 1 }}</span>
|
||||
<span class="step-text">{{ step }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-verdict danger">
|
||||
<span class="verdict-icon">⚠️</span>
|
||||
<span>密码在网络上传输,可能被截获</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="compare-card key">
|
||||
<div class="card-icon">🔐</div>
|
||||
<div class="card-title">密钥登录</div>
|
||||
<div class="card-flow">
|
||||
<div class="flow-step" v-for="(step, i) in keyFlow" :key="i">
|
||||
<span class="step-num">{{ i + 1 }}</span>
|
||||
<span class="step-text">{{ step }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-verdict success">
|
||||
<span class="verdict-icon">✅</span>
|
||||
<span>私钥永远不离开你的电脑</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scenario 2: Key Pair Generation -->
|
||||
<div v-if="activeScenario === 'keygen'" class="keygen-section">
|
||||
<div class="keygen-visual">
|
||||
<div class="keygen-command">
|
||||
<code>ssh-keygen -t ed25519 -C "your@email.com"</code>
|
||||
<button
|
||||
class="gen-btn"
|
||||
:disabled="isGenerating"
|
||||
@click="generateKeys"
|
||||
>
|
||||
{{ isGenerating ? '生成中...' : '生成密钥对' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="key-pair" :class="{ generated: keysGenerated }">
|
||||
<div class="key-card private" :class="{ visible: keysGenerated }">
|
||||
<div class="key-header">
|
||||
<span class="key-icon">🔒</span>
|
||||
<span class="key-name">私钥 (Private Key)</span>
|
||||
</div>
|
||||
<div class="key-location">~/.ssh/id_ed25519</div>
|
||||
<div class="key-content">
|
||||
<code>{{ privateKeyDisplay }}</code>
|
||||
</div>
|
||||
<div class="key-rule danger">绝不外泄 · 留在本机</div>
|
||||
</div>
|
||||
|
||||
<div class="key-arrow" :class="{ visible: keysGenerated }">
|
||||
<span class="arrow-text">数学关联</span>
|
||||
<span class="arrow-icon">↔</span>
|
||||
</div>
|
||||
|
||||
<div class="key-card public" :class="{ visible: keysGenerated }">
|
||||
<div class="key-header">
|
||||
<span class="key-icon">🌍</span>
|
||||
<span class="key-name">公钥 (Public Key)</span>
|
||||
</div>
|
||||
<div class="key-location">~/.ssh/id_ed25519.pub</div>
|
||||
<div class="key-content">
|
||||
<code>{{ publicKeyDisplay }}</code>
|
||||
</div>
|
||||
<div class="key-rule success">可以给任何人 · 放到服务器</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="keysGenerated" class="key-analogy">
|
||||
<strong>生活类比:</strong>公钥 = 锁(可以随便装)· 私钥 =
|
||||
钥匙(只有你有)· 用锁锁住的东西,只有对应的钥匙能打开
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scenario 3: Auth Flow -->
|
||||
<div v-if="activeScenario === 'auth'" class="auth-section">
|
||||
<div class="auth-controls">
|
||||
<button
|
||||
class="action-btn"
|
||||
:disabled="authStep > 0 && authStep < 5"
|
||||
@click="startAuth"
|
||||
>
|
||||
{{
|
||||
authStep === 0
|
||||
? '开始认证'
|
||||
: authStep >= 5
|
||||
? '重新演示'
|
||||
: '认证中...'
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="auth-flow">
|
||||
<div class="auth-parties">
|
||||
<div class="party client">
|
||||
<div class="party-icon">💻</div>
|
||||
<div class="party-name">你的电脑</div>
|
||||
<div class="party-has">持有:私钥</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-messages">
|
||||
<div
|
||||
:class="['msg', { active: authStep >= 1 }]"
|
||||
class="msg-right"
|
||||
>
|
||||
<span class="msg-label">① 请求连接</span>
|
||||
<span class="msg-detail">"我要用密钥登录"</span>
|
||||
</div>
|
||||
<div :class="['msg', { active: authStep >= 2 }]" class="msg-left">
|
||||
<span class="msg-label">② 发送随机挑战</span>
|
||||
<span class="msg-detail"
|
||||
>"请证明你有私钥:用它签名这段随机数据"</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
:class="['msg', { active: authStep >= 3 }]"
|
||||
class="msg-right"
|
||||
>
|
||||
<span class="msg-label">③ 返回签名</span>
|
||||
<span class="msg-detail"
|
||||
>"用私钥签名后的结果(私钥本身不发送)"</span
|
||||
>
|
||||
</div>
|
||||
<div :class="['msg', { active: authStep >= 4 }]" class="msg-left">
|
||||
<span class="msg-label">④ 用公钥验证</span>
|
||||
<span class="msg-detail">"用存储的公钥验证签名 → 匹配!"</span>
|
||||
</div>
|
||||
<div :class="['msg', 'msg-result', { active: authStep >= 5 }]">
|
||||
<span class="msg-label">⑤ 认证成功</span>
|
||||
<span class="msg-detail"
|
||||
>"欢迎登录!从始至终,私钥没离开过你的电脑"</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="party server">
|
||||
<div class="party-icon">🖥️</div>
|
||||
<div class="party-name">远程服务器</div>
|
||||
<div class="party-has">持有:公钥</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scenario 4: Common Uses -->
|
||||
<div v-if="activeScenario === 'uses'" class="uses-section">
|
||||
<div class="uses-grid">
|
||||
<div v-for="use in commonUses" :key="use.name" class="use-card">
|
||||
<div class="use-icon">{{ use.icon }}</div>
|
||||
<div class="use-name">{{ use.name }}</div>
|
||||
<div class="use-cmd">
|
||||
<code>{{ use.command }}</code>
|
||||
</div>
|
||||
<div class="use-desc">{{ use.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-tips">
|
||||
<div class="tip-title">~/.ssh/config 快捷配置</div>
|
||||
<pre class="tip-code"><code>Host my-server
|
||||
HostName 192.168.1.100
|
||||
User deploy
|
||||
IdentityFile ~/.ssh/id_ed25519
|
||||
|
||||
Host github.com
|
||||
HostName github.com
|
||||
User git
|
||||
IdentityFile ~/.ssh/id_ed25519</code></pre>
|
||||
<div class="tip-result">
|
||||
配置后:<code>ssh my-server</code> 即可一键连接
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>核心思想:</strong>
|
||||
<span v-if="activeScenario === 'compare'"
|
||||
>SSH
|
||||
密钥登录比密码更安全,因为私钥从不在网络上传输,无法被中间人窃取。</span
|
||||
>
|
||||
<span v-else-if="activeScenario === 'keygen'"
|
||||
>一次 ssh-keygen
|
||||
生成一对密钥:私钥自己保管,公钥放到目标服务器或平台。</span
|
||||
>
|
||||
<span v-else-if="activeScenario === 'auth'"
|
||||
>认证过程基于"挑战-响应"机制:服务器出题,你的私钥签名作答,公钥验证答案。全程私钥不离开本机。</span
|
||||
>
|
||||
<span v-else
|
||||
>SSH 密钥不仅用于服务器登录,也是 Git (GitHub/GitLab)
|
||||
等开发工具的标准身份认证方式。</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const activeScenario = ref('compare')
|
||||
|
||||
const scenarios = [
|
||||
{ id: 'compare', label: '密码 vs 密钥' },
|
||||
{ id: 'keygen', label: '生成密钥对' },
|
||||
{ id: 'auth', label: '认证流程' },
|
||||
{ id: 'uses', label: '常见用途' }
|
||||
]
|
||||
|
||||
const passwordFlow = [
|
||||
'输入用户名和密码',
|
||||
'密码通过网络发送到服务器',
|
||||
'服务器比对密码是否正确',
|
||||
'每次都要输密码'
|
||||
]
|
||||
|
||||
const keyFlow = [
|
||||
'事先把公钥放到服务器',
|
||||
'连接时发送身份标识(不发私钥)',
|
||||
'服务器用公钥出"数学题"',
|
||||
'你的私钥在本地"答题",只发答案'
|
||||
]
|
||||
|
||||
const isGenerating = ref(false)
|
||||
const keysGenerated = ref(false)
|
||||
const privateKeyDisplay = ref(
|
||||
'-----BEGIN OPENSSH PRIVATE KEY-----\n(等待生成...)\n-----END OPENSSH PRIVATE KEY-----'
|
||||
)
|
||||
const publicKeyDisplay = ref('(等待生成...)')
|
||||
|
||||
const generateKeys = async () => {
|
||||
if (isGenerating.value) return
|
||||
isGenerating.value = true
|
||||
keysGenerated.value = false
|
||||
|
||||
await new Promise((r) => setTimeout(r, 800))
|
||||
|
||||
privateKeyDisplay.value =
|
||||
'-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAA...\n(2048 位密钥,绝不外传)\n-----END OPENSSH PRIVATE KEY-----'
|
||||
publicKeyDisplay.value =
|
||||
'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA\nIGx...kF your@email.com'
|
||||
|
||||
keysGenerated.value = true
|
||||
isGenerating.value = false
|
||||
}
|
||||
|
||||
const authStep = ref(0)
|
||||
|
||||
const startAuth = async () => {
|
||||
if (authStep.value > 0 && authStep.value < 5) return
|
||||
authStep.value = 0
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await new Promise((r) => setTimeout(r, 800))
|
||||
authStep.value = i
|
||||
}
|
||||
}
|
||||
|
||||
const commonUses = [
|
||||
{
|
||||
icon: '🖥️',
|
||||
name: '远程服务器',
|
||||
command: 'ssh user@server',
|
||||
desc: '免密码登录 Linux/Mac 服务器'
|
||||
},
|
||||
{
|
||||
icon: '🐙',
|
||||
name: 'GitHub',
|
||||
command: 'git push origin main',
|
||||
desc: '用 SSH 协议推送代码'
|
||||
},
|
||||
{
|
||||
icon: '🦊',
|
||||
name: 'GitLab',
|
||||
command: 'git clone git@gitlab.com:...',
|
||||
desc: '克隆私有仓库'
|
||||
},
|
||||
{
|
||||
icon: '📦',
|
||||
name: 'SCP 传文件',
|
||||
command: 'scp file.txt user@server:~/',
|
||||
desc: '安全复制文件到远程'
|
||||
},
|
||||
{
|
||||
icon: '🚇',
|
||||
name: 'SSH 隧道',
|
||||
command: 'ssh -L 8080:localhost:3000 server',
|
||||
desc: '将远程端口映射到本地'
|
||||
},
|
||||
{
|
||||
icon: '🐳',
|
||||
name: '部署服务',
|
||||
command: 'ssh deploy@prod "docker pull..."',
|
||||
desc: '远程执行部署命令'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ssh-auth-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;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.demo-header .title {
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.demo-header .subtitle {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.scenario-btns {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.scenario-btn {
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.scenario-btn.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
/* Compare Section */
|
||||
.compare-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.compare-card {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.card-flow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.flow-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 50%;
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-verdict {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.card-verdict.danger {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
.card-verdict.success {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
/* Keygen Section */
|
||||
.keygen-command {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.keygen-command code {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.82rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.gen-btn {
|
||||
padding: 0.35rem 0.7rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gen-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.key-pair {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.key-card {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
opacity: 0.4;
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
.key-card.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.key-card.private {
|
||||
border-color: var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
.key-card.public {
|
||||
border-color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.key-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-weight: bold;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.key-location {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.key-content code {
|
||||
display: block;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.72rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.35rem;
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.key-rule {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
padding: 0.2rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.key-rule.danger {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--vp-c-danger-1);
|
||||
}
|
||||
|
||||
.key-rule.success {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: var(--vp-c-green-1);
|
||||
}
|
||||
|
||||
.key-arrow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.5s;
|
||||
}
|
||||
|
||||
.key-arrow.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.arrow-text {
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
font-size: 1.2rem;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.key-analogy {
|
||||
margin-top: 0.75rem;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
/* Auth Section */
|
||||
.auth-controls {
|
||||
text-align: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.4rem 1rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auth-flow {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.auth-parties {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr 100px;
|
||||
gap: 0.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.party {
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.party-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.party-name {
|
||||
font-weight: bold;
|
||||
font-size: 0.82rem;
|
||||
margin: 0.15rem 0;
|
||||
}
|
||||
|
||||
.party-has {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.auth-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.msg {
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.2;
|
||||
transition: all 0.4s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.msg.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.msg-right {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border-color: rgba(59, 130, 246, 0.2);
|
||||
margin-right: 20%;
|
||||
}
|
||||
|
||||
.msg-left {
|
||||
background: rgba(16, 185, 129, 0.08);
|
||||
border-color: rgba(16, 185, 129, 0.2);
|
||||
margin-left: 20%;
|
||||
}
|
||||
|
||||
.msg-result {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.msg-label {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.msg-detail {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
/* Uses Section */
|
||||
.uses-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.use-card {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.use-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.use-name {
|
||||
font-weight: bold;
|
||||
font-size: 0.82rem;
|
||||
margin: 0.15rem 0;
|
||||
}
|
||||
|
||||
.use-cmd code {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.7rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.15rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.use-desc {
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.config-tips {
|
||||
background: var(--vp-c-bg);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.tip-title {
|
||||
font-weight: bold;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.tip-code {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
margin: 0 0 0.35rem 0;
|
||||
font-size: 0.75rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tip-code code {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.tip-result {
|
||||
font-size: 0.82rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.tip-result code {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* Info Box */
|
||||
.info-box {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box strong {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.compare-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.key-pair {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.key-arrow {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.auth-parties {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.party {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div class="demo-root">
|
||||
<div class="demo-header">
|
||||
<span class="title">生产环境如何注入密钥</span>
|
||||
<span class="subtitle">.env 是开发工具,服务器上不能靠它</span>
|
||||
</div>
|
||||
|
||||
<div class="tab-bar">
|
||||
<button
|
||||
v-for="s in scenarios"
|
||||
:key="s.id"
|
||||
class="tab-btn"
|
||||
:class="{ active: current === s.id }"
|
||||
@click="current = s.id"
|
||||
>
|
||||
{{ s.icon }} {{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="scenario-body">
|
||||
<div class="code-block">
|
||||
<div class="code-title">{{ currentScenario.codeTitle }}</div>
|
||||
<div class="code-area">
|
||||
<div
|
||||
v-for="(line, i) in currentScenario.lines"
|
||||
:key="i"
|
||||
class="code-line"
|
||||
:class="line.type"
|
||||
>
|
||||
<span class="line-content" v-html="line.text" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tips">
|
||||
<div v-for="tip in currentScenario.tips" :key="tip.text" class="tip" :class="tip.level">
|
||||
<span class="tip-dot" />
|
||||
<span class="tip-text">{{ tip.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>原则:</strong>.env 文件是本地开发便利工具,生产环境应由运行平台负责注入环境变量——代码完全不感知密钥存在哪、怎么来的。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const current = ref('systemd')
|
||||
|
||||
const scenarios = [
|
||||
{ id: 'systemd', icon: '🖥️', label: '服务器 (systemd)' },
|
||||
{ id: 'cloud', icon: '☁️', label: '云平台 (Vercel 等)' },
|
||||
{ id: 'docker', icon: '🐳', label: 'Docker' }
|
||||
]
|
||||
|
||||
const scenarioData = {
|
||||
systemd: {
|
||||
codeTitle: '/etc/systemd/system/myapp.service',
|
||||
lines: [
|
||||
{ type: 'comment', text: '# 推荐:用独立密钥文件,权限可控' },
|
||||
{ type: 'normal', text: '[Service]' },
|
||||
{ type: 'highlight', text: 'EnvironmentFile=/etc/myapp/secrets.env' },
|
||||
{ type: 'normal', text: 'ExecStart=/usr/bin/node /app/index.js' },
|
||||
{ type: 'normal', text: '' },
|
||||
{ type: 'comment', text: '# 设置文件权限:只有所有者可读' },
|
||||
{ type: 'good', text: 'sudo chmod 600 /etc/myapp/secrets.env' },
|
||||
{ type: 'good', text: 'sudo chown deploy:deploy /etc/myapp/secrets.env' },
|
||||
{ type: 'normal', text: '' },
|
||||
{ type: 'comment', text: '# 应用配置后重启服务' },
|
||||
{ type: 'normal', text: 'sudo systemctl daemon-reload' },
|
||||
{ type: 'normal', text: 'sudo systemctl restart myapp' }
|
||||
],
|
||||
tips: [
|
||||
{ level: 'safe', text: '密钥文件 chmod 600 后,只有 deploy 用户可读,其他账号无法访问' },
|
||||
{ level: 'safe', text: '密钥和代码完全分离,更新密钥不需要重新部署代码' },
|
||||
{ level: 'warn', text: '不要直接在 systemd 文件里写 Environment="KEY=val"——改动需要 reload,且明文在配置里' }
|
||||
]
|
||||
},
|
||||
cloud: {
|
||||
codeTitle: '云平台控制台(Vercel / Railway / Render / Netlify)',
|
||||
lines: [
|
||||
{ type: 'comment', text: '# 在平台控制台界面操作,无需写配置文件' },
|
||||
{ type: 'normal', text: '' },
|
||||
{ type: 'comment', text: '# 平台会自动将变量注入到运行时环境' },
|
||||
{ type: 'normal', text: '# 代码不变,照常读取:' },
|
||||
{ type: 'highlight', text: 'const key = process.env.OPENAI_API_KEY' },
|
||||
{ type: 'highlight', text: 'api_key = os.environ.get("OPENAI_API_KEY")' },
|
||||
{ type: 'normal', text: '' },
|
||||
{ type: 'comment', text: '# 通常支持按环境设置不同的值:' },
|
||||
{ type: 'normal', text: '# Preview → OPENAI_API_KEY = sk-test-...' },
|
||||
{ type: 'normal', text: '# Production → OPENAI_API_KEY = sk-prod-...' }
|
||||
],
|
||||
tips: [
|
||||
{ level: 'safe', text: '平台加密存储密钥,你自己都不能再次查看原始值(只能重新生成)' },
|
||||
{ level: 'safe', text: '支持 Preview / Production 分环境设置,测试和生产用不同密钥' },
|
||||
{ level: 'info', text: '不要把 .env 文件提交到 Git 再让平台读取——这样密钥就进代码仓库了' }
|
||||
]
|
||||
},
|
||||
docker: {
|
||||
codeTitle: 'docker run / docker-compose.yml',
|
||||
lines: [
|
||||
{ type: 'comment', text: '# ❌ 错误:写在 Dockerfile ENV 里会固化到镜像层' },
|
||||
{ type: 'bad', text: 'ENV OPENAI_API_KEY=sk-xxx <span class="warn-inline">← 任何人都能 docker inspect 取到</span>' },
|
||||
{ type: 'normal', text: '' },
|
||||
{ type: 'comment', text: '# ✅ 正确:运行时从宿主机环境注入' },
|
||||
{ type: 'highlight', text: 'docker run \\' },
|
||||
{ type: 'highlight', text: ' -e OPENAI_API_KEY="$OPENAI_API_KEY" \\' },
|
||||
{ type: 'highlight', text: ' -e DATABASE_URL="$DATABASE_URL" \\' },
|
||||
{ type: 'highlight', text: ' myapp:latest' },
|
||||
{ type: 'normal', text: '' },
|
||||
{ type: 'comment', text: '# 或用 --env-file(文件不进 Git)' },
|
||||
{ type: 'good', text: 'docker run --env-file .env myapp:latest' }
|
||||
],
|
||||
tips: [
|
||||
{ level: 'safe', text: '镜像本身不含任何密钥,可以安全上传到公开 Registry' },
|
||||
{ level: 'safe', text: '--env-file 在运行时读取,文件不需要进入镜像' },
|
||||
{ level: 'warn', text: 'docker history 可以查看所有镜像层内容——写在 Dockerfile ENV 里就永远泄露了' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const currentScenario = computed(() => scenarioData[current.value])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-root {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1rem;
|
||||
margin: 0.75rem 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.demo-header .title { font-size: 1rem; font-weight: bold; color: var(--vp-c-text-1); }
|
||||
.demo-header .subtitle { font-size: 0.82rem; color: var(--vp-c-text-2); }
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 0.28rem 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-2);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-btn:hover { border-color: var(--vp-c-brand); color: var(--vp-c-brand); }
|
||||
.tab-btn.active { background: var(--vp-c-brand); border-color: var(--vp-c-brand); color: white; }
|
||||
|
||||
.scenario-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.scenario-body { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.code-block {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.code-title {
|
||||
background: var(--vp-c-bg-alt);
|
||||
padding: 0.3rem 0.65rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--vp-c-text-2);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.code-area {
|
||||
background: #1e1e2e;
|
||||
padding: 0.45rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.7;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-line {
|
||||
padding: 0 0.7rem;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.code-line.highlight { background: color-mix(in srgb, var(--vp-c-brand) 8%, transparent); }
|
||||
.code-line.good { background: color-mix(in srgb, #4ade80 6%, transparent); }
|
||||
.code-line.bad { background: color-mix(in srgb, #f87171 10%, transparent); }
|
||||
|
||||
.line-content { color: #cdd6f4; white-space: pre; }
|
||||
.code-line.comment .line-content { color: #6c7086; font-style: italic; }
|
||||
.code-line.bad .line-content { color: #f38ba8; }
|
||||
.code-line.good .line-content { color: #a6e3a1; }
|
||||
|
||||
:deep(.warn-inline) { color: #f87171; font-size: 0.7em; }
|
||||
|
||||
.tips {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.tip {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 5px;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-left: 3px solid;
|
||||
}
|
||||
|
||||
.tip.safe { border-left-color: var(--vp-c-green-1); }
|
||||
.tip.warn { border-left-color: var(--vp-c-yellow-1, #f59e0b); }
|
||||
.tip.info { border-left-color: var(--vp-c-brand); }
|
||||
|
||||
.tip-dot { flex-shrink: 0; margin-top: 5px; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
|
||||
.tip.safe .tip-dot { color: var(--vp-c-green-1); }
|
||||
.tip.warn .tip-dot { color: var(--vp-c-yellow-1, #f59e0b); }
|
||||
.tip.info .tip-dot { color: var(--vp-c-brand); }
|
||||
|
||||
.tip-text {
|
||||
font-size: 0.76rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: block;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.84rem;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-box strong { white-space: nowrap; color: var(--vp-c-text-1); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user