feat(docs): add interactive demo components for technical appendices

Add placeholder Vue components for visualizing technical concepts across multiple domains including frontend routing, browser rendering, cache design, queue design, database principles, API design, cloud services, and backend evolution. These components provide interactive educational content for the documentation.

Update documentation structure to include new appendix sections and enhance existing content with visual components. Remove unused 'codex' dependency from package.json.
This commit is contained in:
sanbuphy
2026-02-06 03:34:50 +08:00
parent e8bba6f7c0
commit 7c70c37072
171 changed files with 69830 additions and 6689 deletions
@@ -0,0 +1,347 @@
<template>
<div class="component-hierarchy-demo">
<div class="demo-header">
<h4>组件层级可视化</h4>
<p class="hint">点击组件查看详情观察组件树如何组织</p>
</div>
<div class="tree-container">
<div class="tree-node root-node" :class="{ active: selectedNode === 'app' }" @click="selectNode('app')">
<div class="node-icon">🌳</div>
<div class="node-label">App (根组件)</div>
<div class="node-desc">管理全局状态</div>
</div>
<div class="tree-children">
<div class="tree-branch">
<div class="connector"></div>
<div class="tree-node" :class="{ active: selectedNode === 'header' }" @click="selectNode('header')">
<div class="node-icon">📌</div>
<div class="node-label">Header</div>
<div class="node-desc">导航 + 用户信息</div>
</div>
</div>
<div class="tree-branch">
<div class="connector"></div>
<div class="tree-node" :class="{ active: selectedNode === 'main' }" @click="selectNode('main')">
<div class="node-icon">📄</div>
<div class="node-label">Main Content</div>
<div class="node-desc">页面主要内容</div>
</div>
<div class="tree-children">
<div class="tree-branch">
<div class="connector"></div>
<div class="tree-node" :class="{ active: selectedNode === 'sidebar' }" @click="selectNode('sidebar')">
<div class="node-icon">📑</div>
<div class="node-label">Sidebar</div>
<div class="node-desc">侧边栏菜单</div>
</div>
</div>
<div class="tree-branch">
<div class="connector"></div>
<div class="tree-node" :class="{ active: selectedNode === 'productlist' }" @click="selectNode('productlist')">
<div class="node-icon">🛍</div>
<div class="node-label">ProductList</div>
<div class="node-desc">商品列表展示</div>
</div>
<div class="tree-children">
<div class="tree-branch">
<div class="connector"></div>
<div class="tree-node leaf" :class="{ active: selectedNode === 'productcard' }" @click="selectNode('productcard')">
<div class="node-icon">🏷</div>
<div class="node-label">ProductCard</div>
<div class="node-desc">单个商品卡片</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="tree-branch">
<div class="connector"></div>
<div class="tree-node" :class="{ active: selectedNode === 'footer' }" @click="selectNode('footer')">
<div class="node-icon">🔻</div>
<div class="node-label">Footer</div>
<div class="node-desc">页脚信息</div>
</div>
</div>
</div>
</div>
<div v-if="selectedNodeInfo" class="node-details">
<h5>{{ selectedNodeInfo.title }}</h5>
<p>{{ selectedNodeInfo.description }}</p>
<div class="props-list" v-if="selectedNodeInfo.props">
<strong>接收的 Props:</strong>
<ul>
<li v-for="prop in selectedNodeInfo.props" :key="prop">{{ prop }}</li>
</ul>
</div>
<div class="events-list" v-if="selectedNodeInfo.events">
<strong>触发的事件:</strong>
<ul>
<li v-for="event in selectedNodeInfo.events" :key="event">{{ event }}</li>
</ul>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const selectedNode = ref(null)
const nodeInfoMap = {
app: {
title: 'App (根组件)',
description: '应用的入口组件,负责初始化全局状态、路由配置和全局样式。通常包含 RouterView 来渲染页面级组件。',
props: [],
events: []
},
header: {
title: 'Header (导航栏)',
description: '顶部导航组件,显示 Logo、主导航菜单、用户信息、购物车入口等。通常是全局组件,在大多数页面都显示。',
props: ['user', 'cartCount'],
events: ['logout', 'search']
},
main: {
title: 'Main Content (主内容区)',
description: '页面的主要内容区域,包含侧边栏和具体内容。使用 flex 或 grid 布局来组织内容。',
props: [],
events: []
},
sidebar: {
title: 'Sidebar (侧边栏)',
description: '左侧导航菜单,通常用于后台管理系统或分类浏览页面。包含可折叠的菜单组。',
props: ['menuItems', 'collapsed'],
events: ['select', 'toggle']
},
productlist: {
title: 'ProductList (商品列表)',
description: '展示商品列表的容器组件,负责数据获取、分页、排序和筛选逻辑。包含多个 ProductCard 组件。',
props: ['products', 'loading', 'total'],
events: ['loadMore', 'sort', 'filter']
},
productcard: {
title: 'ProductCard (商品卡片)',
description: '单个商品的展示卡片,显示商品图片、名称、价格、评分等信息。是最基础的 UI 组件之一。',
props: ['product', 'showAddToCart'],
events: ['addToCart', 'click']
},
footer: {
title: 'Footer (页脚)',
description: '页面底部的信息区域,包含版权信息、友情链接、联系方式、社交媒体链接等。',
props: [],
events: []
}
}
const selectedNodeInfo = computed(() => {
return selectedNode.value ? nodeInfoMap[selectedNode.value] : null
})
const selectNode = (nodeId) => {
selectedNode.value = nodeId
}
</script>
<style scoped>
.component-hierarchy-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 20px;
background: var(--vp-c-bg-soft);
}
.demo-header {
margin-bottom: 20px;
}
.demo-header h4 {
margin: 0 0 8px 0;
color: var(--vp-c-text-1);
}
.hint {
margin: 0;
font-size: 14px;
color: var(--vp-c-text-2);
}
.tree-container {
overflow-x: auto;
padding: 10px 0;
}
.tree-children {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 12px;
margin-left: 28px;
}
.tree-branch {
position: relative;
display: flex;
align-items: flex-start;
gap: 12px;
}
.connector {
width: 20px;
height: 2px;
background: var(--vp-c-divider);
margin-top: 24px;
position: relative;
}
.connector::before {
content: '';
position: absolute;
left: 0;
top: -10px;
width: 2px;
height: 12px;
background: var(--vp-c-divider);
}
.tree-node {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
min-width: 180px;
}
.tree-node:hover {
border-color: var(--vp-c-brand);
transform: translateX(4px);
}
.tree-node.active {
border-color: var(--vp-c-brand);
background: var(--vp-c-brand-soft);
box-shadow: 0 0 0 3px var(--vp-c-brand-soft);
}
.root-node {
background: linear-gradient(135deg, var(--vp-c-brand-soft), var(--vp-c-bg));
border-width: 3px;
}
.leaf .node-icon {
opacity: 0.8;
transform: scale(0.9);
}
.node-icon {
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: var(--vp-c-bg-soft);
border-radius: 6px;
}
.node-label {
font-weight: 600;
font-size: 14px;
color: var(--vp-c-text-1);
}
.node-desc {
font-size: 12px;
color: var(--vp-c-text-2);
margin-left: auto;
padding-left: 12px;
border-left: 1px solid var(--vp-c-divider);
}
.node-details {
margin-top: 20px;
padding: 16px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.node-details h5 {
margin: 0 0 8px 0;
color: var(--vp-c-brand);
font-size: 16px;
}
.node-details p {
margin: 0 0 12px 0;
color: var(--vp-c-text-2);
font-size: 14px;
line-height: 1.6;
}
.props-list, .events-list {
margin-top: 12px;
}
.props-list strong, .events-list strong {
display: block;
margin-bottom: 4px;
color: var(--vp-c-text-1);
font-size: 13px;
}
.props-list ul, .events-list ul {
margin: 0;
padding-left: 20px;
}
.props-list li, .events-list li {
color: var(--vp-c-text-2);
font-size: 13px;
font-family: monospace;
margin: 2px 0;
}
@media (max-width: 768px) {
.tree-node {
min-width: auto;
flex-wrap: wrap;
}
.node-desc {
width: 100%;
margin-top: 4px;
padding-left: 42px;
border-left: none;
}
.tree-children {
margin-left: 16px;
}
}
</style>
@@ -0,0 +1,599 @@
<template>
<div class="event-bus-demo">
<div class="demo-header">
<h4>事件总线通信演示</h4>
<p class="hint">观察多个独立组件如何通过事件总线进行通信注意内存管理的重要性</p>
</div>
<div class="architecture-view">
<div class="central-hub" :class="{ active: isTransmitting }">
<div class="hub-core">
<div class="hub-icon">🔌</div>
<div class="hub-label">Event Bus</div>
<div class="hub-status">{{ isTransmitting ? '传输中...' : '待机' }}</div>
</div>
<div class="hub-rings">
<div class="ring ring-1"></div>
<div class="ring ring-2"></div>
<div class="ring ring-3"></div>
</div>
</div>
<div class="connected-components">
<div
v-for="comp in components"
:key="comp.id"
class="component-node"
:class="{ active: comp.isActive, emitting: comp.isEmitting, listening: comp.isListening }"
:style="{ top: comp.y + '%', left: comp.x + '%' }"
>
<div class="node-header">
<span class="node-icon">{{ comp.icon }}</span>
<span class="node-name">{{ comp.name }}</span>
</div>
<div class="node-status">
<span class="status-dot" :class="{ active: comp.isListening }"></span>
{{ comp.isListening ? '监听中' : '未监听' }}
</div>
<button class="emit-btn" @click="emitEvent(comp)">
发送事件
</button>
</div>
</div>
<svg class="connection-lines" viewBox="0 0 100 100" preserveAspectRatio="none">
<defs>
<marker id="arrowhead" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto">
<polygon points="0 0, 6 3, 0 6" :fill="isTransmitting ? 'var(--vp-c-brand)' : 'var(--vp-c-divider)'" />
</marker>
</defs>
<line
v-for="(comp, index) in components"
:key="index"
class="connection-line"
:class="{ active: comp.isEmitting || isTransmitting }"
:x1="comp.x"
:y1="comp.y"
x2="50"
y2="50"
marker-end="url(#arrowhead)"
/>
</svg>
</div>
<div class="event-log">
<div class="log-header">
<h5>📨 事件日志</h5>
<button class="clear-btn" @click="clearLogs">清空</button>
</div>
<div class="log-content">
<div v-if="logs.length === 0" class="empty-log">
暂无事件记录点击组件上的"发送事件"按钮开始测试
</div>
<div
v-for="(log, index) in logs"
:key="index"
class="log-item"
:class="log.type"
>
<span class="log-time">{{ log.time }}</span>
<span class="log-type">{{ log.type === 'emit' ? '发送' : '接收' }}</span>
<span class="log-from">{{ log.from }}</span>
<span class="log-arrow"></span>
<span class="log-to">{{ log.to }}</span>
<span class="log-data">{{ log.data }}</span>
</div>
</div>
</div>
<div class="memory-warning">
<div class="warning-icon"></div>
<div class="warning-content">
<h6>内存泄漏风险提醒</h6>
<p>使用 Event Bus 如果组件销毁前没有取消订阅$off会导致内存泄漏推荐在 beforeUnmount 钩子中清理订阅</p>
<pre><code>// 正确做法
export default {
created() {
this.$bus.$on('event', this.handler)
},
beforeUnmount() {
this.$bus.$off('event', this.handler) // 必须取消订阅
}
}</code></pre>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onBeforeUnmount } from 'vue'
// 组件定义
const components = reactive([
{ id: 1, name: 'Header', icon: '📌', x: 15, y: 15, isActive: false, isEmitting: false, isListening: true },
{ id: 2, name: 'Sidebar', icon: '📑', x: 85, y: 15, isActive: false, isEmitting: false, isListening: true },
{ id: 3, name: 'ProductList', icon: '🛍️', x: 15, y: 85, isActive: false, isEmitting: false, isListening: true },
{ id: 4, name: 'Cart', icon: '🛒', x: 85, y: 85, isActive: false, isEmitting: false, isListening: true }
])
const isTransmitting = ref(false)
const logs = ref([])
// 格式化时间
const formatTime = () => {
const now = new Date()
return `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}.${now.getMilliseconds().toString().padStart(3, '0')}`
}
// 添加日志
const addLog = (type, from, to, data) => {
logs.value.unshift({
type,
time: formatTime(),
from,
to,
data: JSON.stringify(data)
})
if (logs.value.length > 50) {
logs.value = logs.value.slice(0, 50)
}
}
// 发送事件
const emitEvent = (comp) => {
// 触发发送动画
comp.isEmitting = true
isTransmitting.value = true
// 记录发送日志
addLog('emit', comp.name, 'Event Bus', { event: 'user:action', payload: { from: comp.name } })
// 模拟其他组件接收
components.forEach(target => {
if (target.id !== comp.id && target.isListening) {
setTimeout(() => {
target.isActive = true
addLog('receive', 'Event Bus', target.name, { event: 'user:action', payload: { from: comp.name } })
setTimeout(() => {
target.isActive = false
}, 500)
}, 300 + Math.random() * 200)
}
})
// 清理动画状态
setTimeout(() => {
comp.isEmitting = false
isTransmitting.value = false
}, 1000)
}
// 清空日志
const clearLogs = () => {
logs.value = []
}
onBeforeUnmount(() => {
// 模拟清理订阅
components.forEach(comp => {
comp.isListening = false
})
})
</script>
<style scoped>
.event-bus-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 20px;
background: var(--vp-c-bg-soft);
}
.demo-header {
margin-bottom: 20px;
}
.demo-header h4 {
margin: 0 0 8px 0;
color: var(--vp-c-text-1);
}
.hint {
margin: 0;
font-size: 14px;
color: var(--vp-c-text-2);
}
.architecture-view {
position: relative;
min-height: 400px;
background: var(--vp-c-bg);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
overflow: hidden;
}
.central-hub {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10;
}
.hub-core {
position: relative;
z-index: 2;
width: 100px;
height: 100px;
background: linear-gradient(135deg, var(--vp-c-brand), var(--vp-c-brand-light));
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.central-hub.active .hub-core {
transform: scale(1.1);
box-shadow: 0 0 30px var(--vp-c-brand);
}
.hub-icon {
font-size: 28px;
margin-bottom: 2px;
}
.hub-label {
font-size: 11px;
font-weight: 600;
color: white;
text-align: center;
}
.hub-status {
font-size: 9px;
color: rgba(255, 255, 255, 0.8);
margin-top: 2px;
}
.hub-rings {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.ring {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: 2px solid var(--vp-c-divider);
border-radius: 50%;
opacity: 0.3;
transition: all 0.3s ease;
}
.central-hub.active .ring {
border-color: var(--vp-c-brand);
opacity: 0.6;
}
.ring-1 { width: 140px; height: 140px; }
.ring-2 { width: 180px; height: 180px; }
.ring-3 { width: 220px; height: 220px; }
.central-hub.active .ring-1 { animation: pulse1 1s ease infinite; }
.central-hub.active .ring-2 { animation: pulse2 1s ease infinite 0.2s; }
.central-hub.active .ring-3 { animation: pulse3 1s ease infinite 0.4s; }
@keyframes pulse1 {
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.6; }
50% { transform: translate(-50%, -50%) scale(1.05); opacity: 0.3; }
}
@keyframes pulse2 {
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.4; }
50% { transform: translate(-50%, -50%) scale(1.05); opacity: 0.2; }
}
@keyframes pulse3 {
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.2; }
50% { transform: translate(-50%, -50%) scale(1.05); opacity: 0.1; }
}
.connected-components {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.component-node {
position: absolute;
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
padding: 12px;
min-width: 120px;
pointer-events: auto;
transition: all 0.3s ease;
cursor: pointer;
}
.component-node:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.component-node.active {
border-color: #22c55e;
background: #f0fdf4;
box-shadow: 0 0 0 3px #bbf7d0;
}
.component-node.emitting {
border-color: var(--vp-c-brand);
animation: emitPulse 0.5s ease;
}
@keyframes emitPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.node-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.node-icon {
font-size: 16px;
}
.node-name {
font-weight: 600;
font-size: 12px;
color: var(--vp-c-text-1);
}
.node-status {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--vp-c-text-2);
margin-bottom: 8px;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--vp-c-divider);
transition: all 0.3s ease;
}
.status-dot.active {
background: #22c55e;
box-shadow: 0 0 4px #22c55e;
animation: blink 1.5s ease infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.emit-btn {
width: 100%;
padding: 6px 10px;
background: var(--vp-c-brand);
color: white;
border: none;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s ease;
}
.emit-btn:hover {
background: var(--vp-c-brand-dark);
}
.connection-lines {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.connection-line {
stroke: var(--vp-c-divider);
stroke-width: 2;
fill: none;
transition: all 0.3s ease;
}
.connection-line.active {
stroke: var(--vp-c-brand);
stroke-width: 3;
filter: drop-shadow(0 0 4px var(--vp-c-brand));
}
.event-log {
margin-top: 20px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
overflow: hidden;
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--vp-c-bg-soft);
border-bottom: 1px solid var(--vp-c-divider);
}
.log-header h5 {
margin: 0;
font-size: 14px;
color: var(--vp-c-text-1);
}
.clear-btn {
padding: 4px 12px;
background: transparent;
border: 1px solid var(--vp-c-divider);
border-radius: 4px;
font-size: 12px;
color: var(--vp-c-text-2);
cursor: pointer;
transition: all 0.2s ease;
}
.clear-btn:hover {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
}
.log-content {
max-height: 200px;
overflow-y: auto;
padding: 8px;
}
.empty-log {
text-align: center;
padding: 40px;
color: var(--vp-c-text-3);
font-size: 13px;
}
.log-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
margin-bottom: 4px;
border-radius: 4px;
font-size: 12px;
font-family: monospace;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.log-item.emit {
background: #eff6ff;
border-left: 3px solid #3b82f6;
}
.log-item.receive {
background: #f0fdf4;
border-left: 3px solid #22c55e;
}
.log-time {
color: var(--vp-c-text-3);
font-size: 11px;
}
.log-type {
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
}
.log-item.emit .log-type {
background: #dbeafe;
color: #1d4ed8;
}
.log-item.receive .log-type {
background: #bbf7d0;
color: #15803d;
}
.log-from,
.log-to {
color: var(--vp-c-text-2);
}
.log-arrow {
color: var(--vp-c-divider);
}
.log-data {
color: var(--vp-c-brand);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.memory-warning {
margin-top: 20px;
padding: 16px;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 8px;
display: flex;
gap: 12px;
}
.warning-icon {
font-size: 24px;
flex-shrink: 0;
}
.warning-content h6 {
margin: 0 0 4px 0;
color: #92400e;
font-size: 14px;
}
.warning-content p {
margin: 0 0 8px 0;
color: #a16207;
font-size: 13px;
line-height: 1.5;
}
.warning-content pre {
margin: 0;
padding: 12px;
background: #fef3c7;
border-radius: 4px;
font-size: 12px;
overflow-x: auto;
}
.warning-content code {
color: #92400e;
font-family: monospace;
}
</style>
@@ -0,0 +1,639 @@
<template>
<div class="mobx-reactivity-demo">
<div class="demo-header">
<h4>MobX 响应式原理演示</h4>
<p class="hint">体验 MobX 的自动依赖追踪机制理解 ObservableAction Reaction 的关系</p>
</div>
<!-- 可视化图表 -->
<div class="visualization-area">
<div class="flow-diagram">
<!-- Observable -->
<div class="box-container observable-side">
<div class="box-title">Observable (可观察状态)</div>
<div class="state-boxes">
<div
v-for="item in todos"
:key="item.id"
class="state-item"
:class="{ completed: item.completed, changed: recentlyChanged === item.id }"
>
<span class="item-text">{{ item.text }}</span>
<span class="item-status">{{ item.completed ? '✓' : '○' }}</span>
</div>
</div>
</div>
<!-- 连接箭头 -->
<div class="connection-area">
<div class="arrow-bidirectional">
<div class="arrow-label top">追踪依赖</div>
<div class="arrow-line">
<div class="particles">
<span v-for="i in 3" :key="i" class="particle"></span>
</div>
</div>
<div class="arrow-label bottom">触发更新</div>
</div>
</div>
<!-- Reaction -->
<div class="box-container reaction-side">
<div class="box-title">Reaction (响应/副作用)</div>
<div class="reactions-list">
<div class="reaction-item computed">
<div class="reaction-header">
<span class="reaction-icon">🧮</span>
<span class="reaction-name">Computed: 待办统计</span>
</div>
<div class="reaction-value">
{{ todos.length }} 已完成 {{ completedCount }}
</div>
</div>
<div class="reaction-item autorun">
<div class="reaction-header">
<span class="reaction-icon">🔄</span>
<span class="reaction-name">Autorun: 自动保存</span>
</div>
<div class="reaction-status" :class="{ active: autoSaveActive }">
{{ autoSaveActive ? '💾 已自动保存到 localStorage' : '⏸️ 等待变更...' }}
</div>
</div>
<div class="reaction-item reaction">
<div class="reaction-header">
<span class="reaction-icon">👀</span>
<span class="reaction-name">Reaction: 变更日志</span>
</div>
<div class="reaction-log">
<div v-for="(log, index) in changeLogs" :key="index" class="log-entry">
<span class="log-time">{{ log.time }}</span>
<span class="log-action">{{ log.action }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Action 区域 -->
<div class="action-area">
<div class="action-title">🎮 交互控制台 (Action)</div>
<div class="action-controls">
<div class="input-group">
<input v-model="newTodoText" placeholder="输入待办事项..." @keyup.enter="addTodo" />
<button @click="addTodo">添加</button>
</div>
<div class="quick-actions">
<button @click="completeAll">全部完成</button>
<button @click="clearCompleted">清除已完成</button>
<button @click="reset">重置</button>
</div>
</div>
</div>
<!-- 说明区域 -->
<div class="explanation-area">
<div class="explanation-card">
<h5>📦 Observable (可观察状态)</h5>
<p>使用 <code>observable</code> 或类属性装饰器 <code>@observable</code> 定义的状态当状态变化时所有依赖它的 Reaction 会自动重新执行</p>
</div>
<div class="explanation-card">
<h5> Action (动作)</h5>
<p>使用 <code>action</code> <code>@action</code> 装饰器标记的方法用于修改 Observable 状态Action 会批量处理变更通知避免中间状态的重复渲染</p>
</div>
<div class="explanation-card"
>
<h5>🔄 Reaction (响应)</h5>
<p> Observable 状态变化时自动执行的副作用包括</p>
<ul>
<li><code>autorun</code>: 自动追踪依赖并执行</li>
<li><code>reaction</code>: 对特定数据变化作出反应</li>
<li><code>when</code>: 条件满足时执行一次</li>
</ul>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
// 状态
const todos = ref([
{ id: 1, text: '学习 MobX', completed: false },
{ id: 2, text: '理解响应式原理', completed: true }
])
const newTodoText = ref('')
const recentlyChanged = ref(null)
const autoSaveActive = ref(false)
const changeLogs = ref([])
// 计算属性(模拟 MobX 的 computed
const completedCount = computed(() => {
return todos.value.filter(t => t.completed).length
})
// 方法(模拟 MobX 的 action
const addTodo = () => {
if (!newTodoText.value.trim()) return
const newTodo = {
id: Date.now(),
text: newTodoText.value,
completed: false
}
todos.value.push(newTodo)
recentlyChanged.value = newTodo.id
newTodoText.value = ''
setTimeout(() => {
recentlyChanged.value = null
}, 500)
addLog('添加待办', newTodo.text)
}
const toggleTodo = (id) => {
const todo = todos.value.find(t => t.id === id)
if (todo) {
todo.completed = !todo.completed
recentlyChanged.value = id
setTimeout(() => {
recentlyChanged.value = null
}, 500)
addLog(todo.completed ? '完成待办' : '取消完成', todo.text)
}
}
const completeAll = () => {
todos.value.forEach(t => t.completed = true)
addLog('全部完成', `${todos.value.length}`)
}
const clearCompleted = () => {
const count = todos.value.filter(t => t.completed).length
todos.value = todos.value.filter(t => !t.completed)
addLog('清除已完成', `${count}`)
}
const reset = () => {
todos.value = [
{ id: 1, text: '学习 MobX', completed: false },
{ id: 2, text: '理解响应式原理', completed: true }
]
changeLogs.value = []
addLog('重置', '恢复初始状态')
}
const addLog = (action, detail) => {
const now = new Date()
const time = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
changeLogs.value.unshift({ time, action, detail })
if (changeLogs.value.length > 10) {
changeLogs.value = changeLogs.value.slice(0, 10)
}
}
// 模拟 autorun - 自动保存
watch(todos, () => {
autoSaveActive.value = true
setTimeout(() => {
autoSaveActive.value = false
}, 1000)
}, { deep: true })
</script>
<style scoped>
.mobx-reactivity-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 20px;
background: var(--vp-c-bg-soft);
}
.demo-header {
margin-bottom: 20px;
}
.demo-header h4 {
margin: 0 0 8px 0;
color: var(--vp-c-text-1);
}
.hint {
margin: 0;
font-size: 14px;
color: var(--vp-c-text-2);
}
.visualization-area {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.flow-diagram {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 20px;
align-items: start;
}
@media (max-width: 968px) {
.flow-diagram {
grid-template-columns: 1fr;
}
.connection-area {
transform: rotate(90deg);
padding: 40px 0 !important;
}
}
.box-container {
background: var(--vp-c-bg-soft);
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
padding: 16px;
}
.box-container.observable-side {
border-color: #ff6b6b;
}
.box-container.reaction-side {
border-color: #4ecdc4;
}
.box-title {
font-weight: 600;
font-size: 14px;
color: var(--vp-c-text-1);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--vp-c-divider);
}
.state-boxes {
display: flex;
flex-direction: column;
gap: 8px;
}
.state-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.state-item:hover {
border-color: var(--vp-c-brand);
transform: translateX(4px);
}
.state-item.completed {
background: #f0fdf4;
border-color: #86efac;
}
.state-item.changed {
animation: highlight 0.5s ease;
}
@keyframes highlight {
0%, 100% { background: var(--vp-c-bg); }
50% { background: #fef3c7; }
}
.item-text {
font-size: 13px;
color: var(--vp-c-text-1);
}
.state-item.completed .item-text {
text-decoration: line-through;
color: var(--vp-c-text-3);
}
.item-status {
font-size: 14px;
color: #22c55e;
}
.connection-area {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 0;
}
.arrow-bidirectional {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.arrow-label {
font-size: 11px;
color: var(--vp-c-text-3);
padding: 4px 8px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
white-space: nowrap;
}
.arrow-label.top {
margin-bottom: 4px;
}
.arrow-label.bottom {
margin-top: 4px;
}
.arrow-line {
position: relative;
width: 3px;
height: 80px;
background: linear-gradient(to bottom, #ff6b6b, #4ecdc4);
border-radius: 2px;
}
.particles {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
padding: 10px 0;
}
.particle {
font-size: 8px;
color: var(--vp-c-brand);
animation: flow 1.5s linear infinite;
opacity: 0;
}
.particle:nth-child(1) { animation-delay: 0s; }
.particle:nth-child(2) { animation-delay: 0.5s; }
.particle:nth-child(3) { animation-delay: 1s; }
@keyframes flow {
0% {
opacity: 0;
transform: translateY(0);
}
20% {
opacity: 1;
}
80% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateY(60px);
}
}
.reactions-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.reaction-item {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
padding: 12px;
transition: all 0.3s ease;
}
.reaction-item.computed {
border-left: 4px solid #8b5cf6;
}
.reaction-item.autorun {
border-left: 4px solid #f59e0b;
}
.reaction-item.reaction {
border-left: 4px solid #ec4899;
}
.reaction-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.reaction-icon {
font-size: 14px;
}
.reaction-name {
font-weight: 600;
font-size: 12px;
color: var(--vp-c-text-1);
}
.reaction-value {
font-size: 13px;
color: var(--vp-c-text-2);
font-family: monospace;
padding: 4px 8px;
background: var(--vp-c-bg-soft);
border-radius: 4px;
}
.reaction-status {
font-size: 12px;
color: var(--vp-c-text-3);
padding: 4px 8px;
background: var(--vp-c-bg-soft);
border-radius: 4px;
}
.reaction-status.active {
color: #22c55e;
background: #dcfce7;
}
.reaction-log {
max-height: 100px;
overflow-y: auto;
}
.log-entry {
display: flex;
gap: 8px;
font-size: 11px;
padding: 3px 0;
border-bottom: 1px solid var(--vp-c-divider);
}
.log-entry:last-child {
border-bottom: none;
}
.log-time {
color: var(--vp-c-text-3);
font-family: monospace;
}
.log-action {
color: var(--vp-c-text-2);
}
.action-area {
margin-top: 20px;
padding: 16px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
}
.action-title {
font-weight: 600;
color: var(--vp-c-text-1);
margin-bottom: 12px;
font-size: 14px;
}
.action-controls {
display: flex;
flex-direction: column;
gap: 12px;
}
.input-group {
display: flex;
gap: 8px;
}
.input-group input {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
font-size: 14px;
background: var(--vp-c-bg);
color: var(--vp-c-text-1);
}
.input-group input:focus {
outline: none;
border-color: var(--vp-c-brand);
}
.input-group button {
padding: 8px 16px;
background: var(--vp-c-brand);
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.input-group button:hover {
background: var(--vp-c-brand-dark);
}
.quick-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.quick-actions button {
padding: 6px 12px;
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
font-size: 12px;
color: var(--vp-c-text-2);
cursor: pointer;
transition: all 0.2s ease;
}
.quick-actions button:hover {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
}
.explanation-area {
margin-top: 20px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.explanation-card {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 16px;
}
.explanation-card h5 {
margin: 0 0 10px 0;
font-size: 14px;
color: var(--vp-c-text-1);
}
.explanation-card p {
margin: 0;
font-size: 13px;
color: var(--vp-c-text-2);
line-height: 1.6;
}
.explanation-card code {
background: var(--vp-c-bg-soft);
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
font-size: 12px;
color: var(--vp-c-brand);
}
.explanation-card ul {
margin: 8px 0 0 0;
padding-left: 18px;
}
.explanation-card li {
font-size: 12px;
color: var(--vp-c-text-2);
margin: 4px 0;
}
@media (max-width: 640px) {
.quick-actions {
justify-content: center;
}
}
</style>
@@ -0,0 +1,587 @@
<template>
<div class="props-flow-demo">
<div class="demo-header">
<h4>Props 数据流演示</h4>
<p class="hint">观察父组件如何通过 props 向子组件传递数据以及子组件如何通过事件向父组件通信</p>
</div>
<div class="flow-container">
<!-- 父组件可视化 -->
<div class="parent-component">
<div class="component-header">
<span class="tag">Parent.vue</span>
<span class="badge blue">数据持有者</span>
</div>
<div class="data-box">
<div class="data-title">data() {</div>
<div class="data-item" :class="{ changed: hasChanged }">
<span class="key">user:</span>
<span class="value">{ name: '{{ userName }}', age: {{ userAge }} }</span>
</div>
<div class="data-item">
<span class="key">theme:</span>
<span class="value">'{{ theme }}'</span>
</div>
<div class="data-title">}</div>
</div>
<div class="props-config">
<div class="config-title">传递给子组件的 Props:</div>
<div class="prop-tag" v-for="prop in activeProps" :key="prop">
:{{ prop }}="{{ prop }}"
</div>
</div>
</div>
<!-- 流动动画 -->
<div class="flow-animation">
<div class="flow-line" :class="{ active: isFlowing }">
<div class="flow-particles" v-if="isFlowing">
<span v-for="i in 5" :key="i" class="particle"></span>
</div>
</div>
<div class="flow-label" :class="{ active: isFlowing }">
{{ isFlowing ? '传递 Props' : '等待交互' }}
</div>
</div>
<!-- 子组件可视化 -->
<div class="child-component">
<div class="component-header">
<span class="tag">Child.vue</span>
<span class="badge green">数据展示</span>
</div>
<div class="props-box">
<div class="props-title">props: {</div>
<div class="prop-item" v-for="prop in activeProps" :key="prop" :class="{ receiving: isFlowing }">
<span class="prop-name">{{ prop }}</span>
<span class="prop-type">{ type: {{ getPropType(prop) }} }</span>
</div>
<div class="props-title">}</div>
</div>
<div class="render-preview">
<div class="preview-title">渲染预览:</div>
<div class="preview-content">
<div class="user-card">
<div class="avatar">👤</div>
<div class="user-info">
<div class="user-name">{{ userName || '未知用户' }}</div>
<div class="user-meta">
<span class="age">{{ userAge }}</span>
<span class="theme-badge" :class="theme">{{ theme }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="emit-section">
<div class="emit-title">向父组件通信:</div>
<button class="emit-btn" @click="emitUpdate">$emit('update', { name: '王五' })</button>
</div>
</div>
</div>
<div class="interaction-panel">
<div class="panel-title">🎮 交互控制台</div>
<div class="control-group">
<label>修改父组件数据:</label>
<input v-model="userName" placeholder="用户名" @input="triggerFlow" />
<input v-model.number="userAge" type="number" placeholder="年龄" @input="triggerFlow" />
<select v-model="theme" @change="triggerFlow">
<option value="light">Light 主题</option>
<option value="dark">Dark 主题</option>
</select>
</div>
<div class="control-group">
<label>选择传递的 Props:</label>
<label class="checkbox"><input type="checkbox" v-model="propSelection.user" /> user</label>
<label class="checkbox"><input type="checkbox" v-model="propSelection.theme" /> theme</label>
</div>
<div class="flow-status" :class="{ active: isFlowing }">
{{ isFlowing ? '⬇️ 数据正在流动...' : '⏸️ 等待数据变化' }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
//
const userName = ref('张三')
const userAge = ref(25)
const theme = ref('light')
const hasChanged = ref(false)
// Props
const propSelection = ref({
user: true,
theme: true
})
const activeProps = computed(() => {
return Object.entries(propSelection.value)
.filter(([, v]) => v)
.map(([k]) => k)
})
//
const isFlowing = ref(false)
let flowTimeout = null
const triggerFlow = () => {
hasChanged.value = true
isFlowing.value = true
clearTimeout(flowTimeout)
flowTimeout = setTimeout(() => {
isFlowing.value = false
hasChanged.value = false
}, 1500)
}
//
watch([userName, userAge, theme], () => {
triggerFlow()
}, { deep: true })
const getPropType = (prop) => {
const types = {
user: 'Object',
theme: 'String'
}
return types[prop] || 'Any'
}
const emitUpdate = () => {
userName.value = '王五'
triggerFlow()
}
</script>
<style scoped>
.props-flow-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 20px;
background: var(--vp-c-bg-soft);
}
.demo-header {
margin-bottom: 20px;
}
.demo-header h4 {
margin: 0 0 8px 0;
color: var(--vp-c-text-1);
}
.hint {
margin: 0;
font-size: 14px;
color: var(--vp-c-text-2);
}
.flow-container {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 16px;
margin-bottom: 20px;
}
@media (max-width: 968px) {
.flow-container {
grid-template-columns: 1fr;
}
.flow-animation {
flex-direction: row !important;
padding: 12px !important;
}
.flow-line {
width: 100% !important;
height: 2px !important;
}
}
.parent-component,
.child-component {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
padding: 16px;
}
.component-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--vp-c-divider);
}
.tag {
font-family: monospace;
font-size: 13px;
padding: 4px 8px;
background: var(--vp-c-brand-soft);
color: var(--vp-c-brand);
border-radius: 4px;
}
.badge {
font-size: 11px;
padding: 2px 6px;
border-radius: 10px;
}
.badge.blue {
background: #dbeafe;
color: #1e40af;
}
.badge.green {
background: #dcfce7;
color: #166534;
}
.data-box,
.props-box {
background: var(--vp-c-bg-soft);
border-radius: 6px;
padding: 12px;
margin-bottom: 12px;
font-family: monospace;
font-size: 13px;
}
.data-title,
.props-title {
color: var(--vp-c-text-3);
margin-bottom: 4px;
}
.data-item,
.prop-item {
padding: 4px 8px;
margin: 2px 0;
border-radius: 3px;
transition: all 0.3s ease;
}
.data-item.changed {
background: #fef3c7;
animation: pulse 0.5s ease;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.02); }
}
.data-item .key {
color: var(--vp-c-brand);
}
.data-item .value {
color: var(--vp-c-text-2);
}
.prop-item.receiving {
background: #dcfce7;
animation: receive 0.5s ease;
}
@keyframes receive {
0% { transform: translateX(-10px); opacity: 0.5; }
100% { transform: translateX(0); opacity: 1; }
}
.prop-name {
color: var(--vp-c-brand);
}
.prop-type {
color: var(--vp-c-text-3);
font-size: 11px;
margin-left: 8px;
}
.props-config {
margin-bottom: 12px;
}
.config-title {
font-size: 12px;
color: var(--vp-c-text-2);
margin-bottom: 6px;
}
.prop-tag {
display: inline-block;
font-family: monospace;
font-size: 12px;
padding: 4px 8px;
margin: 2px;
background: var(--vp-c-brand-soft);
color: var(--vp-c-brand);
border-radius: 4px;
}
.flow-animation {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.flow-line {
width: 2px;
height: 80px;
background: var(--vp-c-divider);
position: relative;
transition: all 0.3s ease;
}
.flow-line.active {
background: var(--vp-c-brand);
box-shadow: 0 0 10px var(--vp-c-brand);
}
.flow-particles {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.particle {
color: var(--vp-c-brand);
font-size: 8px;
animation: flowDown 1s linear infinite;
opacity: 0;
}
.particle:nth-child(1) { animation-delay: 0s; }
.particle:nth-child(2) { animation-delay: 0.2s; }
.particle:nth-child(3) { animation-delay: 0.4s; }
.particle:nth-child(4) { animation-delay: 0.6s; }
.particle:nth-child(5) { animation-delay: 0.8s; }
@keyframes flowDown {
0% {
opacity: 0;
transform: translateY(0);
}
20% {
opacity: 1;
}
80% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateY(60px);
}
}
.flow-label {
margin-top: 12px;
font-size: 13px;
color: var(--vp-c-text-3);
text-align: center;
transition: all 0.3s ease;
}
.flow-label.active {
color: var(--vp-c-brand);
font-weight: 600;
}
.render-preview {
background: var(--vp-c-bg-soft);
border-radius: 6px;
padding: 12px;
margin-bottom: 12px;
}
.preview-title {
font-size: 12px;
color: var(--vp-c-text-3);
margin-bottom: 8px;
}
.preview-content {
background: var(--vp-c-bg);
border-radius: 4px;
padding: 12px;
}
.user-card {
display: flex;
align-items: center;
gap: 12px;
}
.avatar {
font-size: 32px;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: var(--vp-c-brand-soft);
border-radius: 50%;
}
.user-info {
flex: 1;
}
.user-name {
font-weight: 600;
color: var(--vp-c-text-1);
font-size: 14px;
}
.user-meta {
display: flex;
gap: 8px;
margin-top: 4px;
font-size: 12px;
}
.age {
color: var(--vp-c-text-2);
}
.theme-badge {
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
text-transform: uppercase;
}
.theme-badge.light {
background: #fef3c7;
color: #92400e;
}
.theme-badge.dark {
background: #374151;
color: #f3f4f6;
}
.emit-section {
padding: 12px;
background: var(--vp-c-bg-soft);
border-radius: 6px;
}
.emit-title {
font-size: 12px;
color: var(--vp-c-text-3);
margin-bottom: 8px;
}
.emit-btn {
width: 100%;
padding: 8px 12px;
background: var(--vp-c-brand);
color: white;
border: none;
border-radius: 4px;
font-size: 13px;
font-family: monospace;
cursor: pointer;
transition: all 0.2s ease;
}
.emit-btn:hover {
background: var(--vp-c-brand-dark);
transform: translateY(-1px);
}
.interaction-panel {
margin-top: 20px;
padding: 16px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
}
.panel-title {
font-weight: 600;
color: var(--vp-c-text-1);
margin-bottom: 12px;
font-size: 14px;
}
.control-group {
margin-bottom: 16px;
}
.control-group label {
display: block;
font-size: 13px;
color: var(--vp-c-text-2);
margin-bottom: 8px;
}
.control-group input,
.control-group select {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--vp-c-divider);
border-radius: 4px;
font-size: 14px;
background: var(--vp-c-bg);
color: var(--vp-c-text-1);
margin-bottom: 8px;
}
.control-group input:focus,
.control-group select:focus {
outline: none;
border-color: var(--vp-c-brand);
}
.checkbox {
display: inline-flex !important;
align-items: center;
gap: 6px;
margin-right: 16px;
cursor: pointer;
}
.checkbox input {
width: auto !important;
margin: 0 !important;
}
.flow-status {
padding: 10px 16px;
background: var(--vp-c-bg-soft);
border-radius: 6px;
text-align: center;
font-size: 14px;
color: var(--vp-c-text-2);
transition: all 0.3s ease;
}
.flow-status.active {
background: var(--vp-c-brand-soft);
color: var(--vp-c-brand);
font-weight: 600;
}
</style>
@@ -0,0 +1,540 @@
<template>
<div class="redux-flow-demo">
<div class="demo-header">
<h4>Redux 数据流演示</h4>
<p class="hint">理解 Redux 的单向数据流Action Reducer Store View</p>
</div>
<div class="flow-diagram">
<!-- 视图层 -->
<div class="flow-layer view-layer">
<div class="layer-header">
<span class="layer-icon">👁</span>
<span class="layer-title">View (视图层)</span>
</div>
<div class="view-content">
<div class="counter-display">
<span class="counter-label">当前计数:</span>
<span class="counter-value" :class="{ changed: countChanged }">{{ count }}</span>
</div>
<div class="action-buttons">
<button class="action-btn" @click="dispatchAction('INCREMENT')">
<span class="btn-icon"></span>
增加
</button>
<button class="action-btn" @click="dispatchAction('DECREMENT')">
<span class="btn-icon"></span>
减少
</button>
<button class="action-btn" @click="dispatchAction('RESET')">
<span class="btn-icon">🔄</span>
重置
</button>
</div>
</div>
</div>
<!-- 箭头View -> Action -->
<div class="flow-arrow-container">
<div class="flow-arrow" :class="{ active: flowStage === 'action' }">
<div class="arrow-line"></div>
<div class="arrow-head"></div>
</div>
<div class="arrow-label" :class="{ active: flowStage === 'action' }">
dispatch(action)
</div>
</div>
<!-- Action -->
<div class="flow-layer action-layer" :class="{ active: flowStage === 'action' }">
<div class="layer-header">
<span class="layer-icon">📨</span>
<span class="layer-title">Action (动作)</span>
</div>
<div class="action-content">
<div class="action-object">
<div class="code-block">
<pre><code>{
type: "{{ currentAction.type }}",
payload: {{ currentAction.payload || 'undefined' }}
}</code></pre>
</div>
</div>
</div>
</div>
<!-- 箭头Action -> Reducer -->
<div class="flow-arrow-container">
<div class="flow-arrow" :class="{ active: flowStage === 'reducer' }">
<div class="arrow-line"></div>
<div class="arrow-head"></div>
</div>
<div class="arrow-label" :class="{ active: flowStage === 'reducer' }">
reduce(state, action)
</div>
</div>
<!-- Reducer -->
<div class="flow-layer reducer-layer" :class="{ active: flowStage === 'reducer' }">
<div class="layer-header">
<span class="layer-icon"></span>
<span class="layer-title">Reducer (纯函数)</span>
</div>
<div class="reducer-content">
<div class="reducer-function">
<div class="code-block">
<pre><code>function reducer(state, action) {
switch (action.type) {
case "{{ currentAction.type }}":
return {
...state,
count: (state?.count ?? 0) {{ currentAction.operator }} {{ currentAction.step || 1 }}
};
default:
return state;
}
}</code></pre>
</div>
</div>
</div>
</div>
<!-- 箭头Reducer -> Store -->
<div class="flow-arrow-container">
<div class="flow-arrow" :class="{ active: flowStage === 'store' }">
<div class="arrow-line"></div>
<div class="arrow-head"></div>
</div>
<div class="arrow-label" :class="{ active: flowStage === 'store' }">
update store
</div>
</div>
<!-- Store -->
<div class="flow-layer store-layer" :class="{ active: flowStage === 'store' }">
<div class="layer-header">
<span class="layer-icon">🏪</span>
<span class="layer-title">Store (单一数据源)</span>
</div>
<div class="store-content">
<div class="store-state">
<div class="state-label">Current State:</div>
<div class="code-block">
<pre><code>{
count: {{ count }}
}</code></pre>
</div>
</div>
</div>
</div>
<!-- 箭头Store -> View -->
<div class="flow-arrow-container">
<div class="flow-arrow" :class="{ active: flowStage === 'view' }">
<div class="arrow-line"></div>
<div class="arrow-head"></div>
</div>
<div class="arrow-label" :class="{ active: flowStage === 'view' }">
notify subscribers
</div>
</div>
</div>
<!-- 底部说明 -->
<div class="redux-principles">
<h5>📋 Redux 三大原则</h5>
<div class="principles-grid">
<div class="principle-card">
<div class="principle-number">1</div>
<h6>单一数据源</h6>
<p>整个应用的 state 储存在唯一的 store </p>
</div>
<div class="principle-card">
<div class="principle-number">2</div>
<h6>State 只读</h6>
<p>唯一改变 state 的方法是触发 action</p>
</div>
<div class="principle-card">
<div class="principle-number">3</div>
<h6>纯函数修改</h6>
<p>Reducer 必须是纯函数接收旧 state 返回新 state</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
//
const count = ref(0)
const countChanged = ref(false)
const flowStage = ref('')
// action
const currentAction = reactive({
type: '',
payload: null,
operator: '+',
step: 1
})
// action
const dispatchAction = async (actionType) => {
flowStage.value = 'action'
// action
currentAction.type = actionType
switch (actionType) {
case 'INCREMENT':
currentAction.payload = undefined
currentAction.operator = '+'
currentAction.step = 1
break
case 'DECREMENT':
currentAction.payload = undefined
currentAction.operator = '-'
currentAction.step = 1
break
case 'RESET':
currentAction.payload = undefined
currentAction.operator = '='
currentAction.step = 0
break
}
//
await wait(600)
flowStage.value = 'reducer'
await wait(800)
flowStage.value = 'store'
await wait(600)
flowStage.value = 'view'
//
switch (actionType) {
case 'INCREMENT':
count.value++
break
case 'DECREMENT':
count.value--
break
case 'RESET':
count.value = 0
break
}
countChanged.value = true
setTimeout(() => {
countChanged.value = false
}, 300)
await wait(400)
flowStage.value = ''
}
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms))
</script>
<style scoped>
.redux-flow-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 20px;
background: var(--vp-c-bg-soft);
}
.demo-header {
margin-bottom: 20px;
}
.demo-header h4 {
margin: 0 0 8px 0;
color: var(--vp-c-text-1);
}
.hint {
margin: 0;
font-size: 14px;
color: var(--vp-c-text-2);
}
.flow-diagram {
display: flex;
flex-direction: column;
gap: 16px;
}
.flow-layer {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
padding: 16px;
transition: all 0.3s ease;
}
.flow-layer.active {
border-color: var(--vp-c-brand);
box-shadow: 0 0 0 3px var(--vp-c-brand-soft);
transform: scale(1.02);
}
.layer-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--vp-c-divider);
}
.layer-icon {
font-size: 20px;
}
.layer-title {
font-weight: 600;
color: var(--vp-c-text-1);
}
.view-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.counter-display {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 20px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
}
.counter-label {
font-size: 14px;
color: var(--vp-c-text-2);
}
.counter-value {
font-size: 36px;
font-weight: 700;
color: var(--vp-c-brand);
transition: all 0.3s ease;
}
.counter-value.changed {
transform: scale(1.2);
color: #22c55e;
}
.action-buttons {
display: flex;
gap: 8px;
justify-content: center;
flex-wrap: wrap;
}
.action-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 16px;
background: var(--vp-c-brand);
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.action-btn:hover {
background: var(--vp-c-brand-dark);
transform: translateY(-1px);
}
.btn-icon {
font-size: 14px;
}
.flow-arrow-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 0;
}
.flow-arrow {
display: flex;
flex-direction: column;
align-items: center;
opacity: 0.5;
transition: all 0.3s ease;
}
.flow-arrow.active {
opacity: 1;
}
.arrow-line {
width: 2px;
height: 20px;
background: var(--vp-c-divider);
transition: all 0.3s ease;
}
.flow-arrow.active .arrow-line {
background: var(--vp-c-brand);
box-shadow: 0 0 8px var(--vp-c-brand);
}
.arrow-head {
color: var(--vp-c-divider);
font-size: 12px;
line-height: 1;
transition: all 0.3s ease;
}
.flow-arrow.active .arrow-head {
color: var(--vp-c-brand);
animation: bounce 0.5s ease infinite;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(2px); }
}
.arrow-label {
margin-top: 4px;
font-size: 11px;
color: var(--vp-c-text-3);
font-family: monospace;
transition: all 0.3s ease;
}
.arrow-label.active {
color: var(--vp-c-brand);
font-weight: 600;
}
.action-content,
.reducer-content,
.store-content {
padding: 12px;
background: var(--vp-c-bg-soft);
border-radius: 6px;
}
.action-object {
display: flex;
flex-direction: column;
gap: 8px;
}
.code-block {
background: #1e1e1e;
border-radius: 6px;
overflow: hidden;
}
.code-block pre {
margin: 0;
padding: 12px;
overflow-x: auto;
}
.code-block code {
font-family: 'Fira Code', 'Monaco', monospace;
font-size: 12px;
line-height: 1.5;
color: #d4d4d4;
}
.reducer-function {
display: flex;
flex-direction: column;
gap: 8px;
}
.store-state {
display: flex;
flex-direction: column;
gap: 8px;
}
.state-label {
font-size: 12px;
color: var(--vp-c-text-2);
}
.redux-principles {
margin-top: 24px;
padding: 20px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
}
.redux-principles h5 {
margin: 0 0 16px 0;
color: var(--vp-c-text-1);
font-size: 16px;
}
.principles-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
}
.principle-card {
padding: 16px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
border-left: 4px solid var(--vp-c-brand);
}
.principle-number {
width: 28px;
height: 28px;
background: var(--vp-c-brand);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 14px;
margin-bottom: 8px;
}
.principle-card h6 {
margin: 0 0 6px 0;
color: var(--vp-c-text-1);
font-size: 14px;
}
.principle-card p {
margin: 0;
color: var(--vp-c-text-2);
font-size: 12px;
line-height: 1.5;
}
@media (max-width: 768px) {
.action-buttons {
flex-direction: column;
}
.principles-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,544 @@
<template>
<div class="state-management-comparison">
<div class="demo-header">
<h4>状态管理库全景对比</h4>
<p class="hint">全面对比主流状态管理方案的特性适用场景和学习曲线</p>
</div>
<!-- 简化版对比表格 -->
<div class="comparison-table-wrapper">
<table class="comparison-table">
<thead>
<tr>
<th class="feature-col">特性</th>
<th v-for="lib in libraries" :key="lib.id" class="lib-col">
<div class="lib-header">
<span class="lib-icon">{{ lib.icon }}</span>
<span class="lib-name">{{ lib.name }}</span>
</div>
</th>
</tr>
</thead>
<tbody>
<tr>
<td class="feature-name">学习曲线</td>
<td v-for="lib in libraries" :key="lib.id" class="feature-value">
<div class="curve-bar">
<div class="curve-fill" :style="{ width: lib.learningCurve + '%', background: getCurveColor(lib.learningCurve) }"></div>
</div>
<span class="curve-label">{{ getCurveLabel(lib.learningCurve) }}</span>
</td>
</tr>
<tr>
<td class="feature-name">包大小</td>
<td v-for="lib in libraries" :key="lib.id" class="feature-value">
<span class="size-badge" :class="getSizeClass(lib.bundleSize)">{{ lib.bundleSize }}</span>
</td>
</tr>
<tr>
<td class="feature-name">TypeScript</td>
<td v-for="lib in libraries" :key="lib.id" class="feature-value">
<span class="boolean-badge" :class="{ yes: lib.typescript, no: !lib.typescript }">
{{ lib.typescript ? '✓' : '✗' }}
</span>
</td>
</tr>
<tr>
<td class="feature-name">开发工具</td>
<td v-for="lib in libraries" :key="lib.id" class="feature-value">
<span class="boolean-badge" :class="{ yes: lib.devtools, no: !lib.devtools }">
{{ lib.devtools ? '✓' : '✗' }}
</span>
</td>
</tr>
<tr>
<td class="feature-name">SSR 支持</td>
<td v-for="lib in libraries" :key="lib.id" class="feature-value">
<span class="boolean-badge" :class="{ yes: lib.ssr, no: !lib.ssr }">
{{ lib.ssr ? '✓' : '✗' }}
</span>
</td>
</tr>
<tr>
<td class="feature-name">适用框架</td>
<td v-for="lib in libraries" :key="lib.id" class="feature-value">
<span class="text-value">{{ lib.framework }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 选中库的详细信息 -->
<div v-if="selectedLibrary" class="library-detail">
<div class="detail-header">
<span class="detail-icon">{{ selectedLibrary.icon }}</span>
<div class="detail-title">
<h5>{{ selectedLibrary.name }}</h5>
<span class="detail-tagline">{{ selectedLibrary.tagline }}</span>
</div>
<a :href="selectedLibrary.docsUrl" target="_blank" class="docs-link">
官方文档
</a>
</div>
<div class="detail-grid">
<div class="detail-section">
<h6>🎯 适用场景</h6>
<ul>
<li v-for="(scenario, index) in selectedLibrary.scenarios" :key="index">{{ scenario }}</li>
</ul>
</div>
<div class="detail-section">
<h6> 优势</h6>
<ul class="advantages">
<li v-for="(pro, index) in selectedLibrary.pros" :key="index">{{ pro }}</li>
</ul>
</div>
<div class="detail-section">
<h6> 劣势</h6>
<ul class="disadvantages">
<li v-for="(con, index) in selectedLibrary.cons" :key="index">{{ con }}</li>
</ul>
</div>
</div>
</div>
<!-- 决策流程图 -->
<div class="decision-flow">
<h5>🤔 如何选择</h5>
<div class="flow-chart">
<div class="flow-node start">开始</div>
<div class="flow-arrow"></div>
<div class="flow-node question">需要跨框架支持</div>
<div class="flow-arrow"> </div>
<div class="flow-node result">考虑 Pinia / Vuex</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const libraries = [
{
id: 'redux',
name: 'Redux',
icon: '🔄',
tagline: 'JavaScript 应用的可预测状态容器',
docsUrl: 'https://redux.js.org/',
scenarios: ['大型企业级应用', '需要严格数据流控制', '复杂的状态逻辑'],
pros: ['严格的数据流,易于调试', '强大的中间件生态', '时间旅行调试', '可预测的状态更新'],
cons: ['学习曲线陡峭', '样板代码较多', '小型项目可能过于复杂'],
codeExample: '// Redux 示例代码',
learningCurve: 80,
bundleSize: '7KB',
typescript: true,
devtools: true,
ssr: true,
framework: 'React/Vue/Angular'
},
{
id: 'vuex',
name: 'Vuex',
icon: '🌿',
tagline: 'Vue.js 的官方状态管理库',
docsUrl: 'https://vuex.vuejs.org/',
scenarios: ['Vue 2/3 中大型项目', '需要模块化管理状态', '团队成员熟悉 Vue 生态'],
pros: ['与 Vue 深度集成', '响应式系统', '模块化管理', '优秀的开发工具'],
cons: ['仅适用于 Vue', 'Vue 3 中被 Pinia 取代', '相对冗余的 API'],
codeExample: '// Vuex 示例代码',
learningCurve: 60,
bundleSize: '4KB',
typescript: true,
devtools: true,
ssr: true,
framework: 'Vue Only'
},
{
id: 'pinia',
name: 'Pinia',
icon: '🍍',
tagline: '直观、类型安全、灵活的 Vue Store',
docsUrl: 'https://pinia.vuejs.org/',
scenarios: ['Vue 3 新项目首选', '重视 TypeScript 支持', '希望简化状态管理'],
pros: ['轻量级设计', '原生 TypeScript 支持', '组合式 API 风格', '代码更简洁'],
cons: ['Vue 3 专属', '生态系统相对年轻', '大型项目需自定义规范'],
codeExample: '// Pinia 示例代码',
learningCurve: 30,
bundleSize: '2KB',
typescript: true,
devtools: true,
ssr: true,
framework: 'Vue 3 Only'
}
]
const features = [
{ key: 'learningCurve', label: '学习曲线', icon: '📈' },
{ key: 'bundleSize', label: '包大小', icon: '📦' },
{ key: 'typescript', label: 'TypeScript', icon: '🔷' },
{ key: 'devtools', label: '开发工具', icon: '🛠️' },
{ key: 'ssr', label: 'SSR 支持', icon: '🚀' },
{ key: 'framework', label: '适用框架', icon: '🔧' }
]
const selectedLib = ref(null)
const selectedLibrary = computed(() => {
if (!selectedLib.value) return null
return libraries.find(lib => lib.id === selectedLib.value)
})
function selectLib(id) {
selectedLib.value = id
}
function getValue(lib, key) {
return lib[key]
}
function getCurveColor(value) {
if (value <= 30) return '#22c55e'
if (value <= 60) return '#f59e0b'
return '#ef4444'
}
function getCurveLabel(value) {
if (value <= 30) return '简单'
if (value <= 60) return '中等'
return '陡峭'
}
function getSizeClass(size) {
const num = parseInt(size)
if (num <= 2) return 'small'
if (num <= 5) return 'medium'
return 'large'
}
</script>
<style scoped>
.state-management-comparison {
padding: 1rem;
background: var(--vp-c-bg-soft);
border-radius: 8px;
}
.demo-header {
margin-bottom: 1.5rem;
text-align: center;
}
.demo-header h4 {
margin: 0 0 0.5rem;
color: var(--vp-c-text-1);
}
.hint {
margin: 0;
color: var(--vp-c-text-2);
font-size: 0.9rem;
}
.comparison-table-wrapper {
overflow-x: auto;
margin-bottom: 1.5rem;
}
.comparison-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.comparison-table th,
.comparison-table td {
padding: 0.75rem;
border: 1px solid var(--vp-c-divider);
text-align: left;
}
.comparison-table th {
background: var(--vp-c-bg);
font-weight: 600;
}
.feature-col {
width: 120px;
background: var(--vp-c-bg-soft);
}
.lib-col {
min-width: 120px;
cursor: pointer;
transition: background 0.2s;
}
.lib-col:hover,
.lib-col.selected {
background: rgba(102, 126, 234, 0.1);
}
.lib-header {
display: flex;
align-items: center;
gap: 0.5rem;
}
.lib-icon {
font-size: 1.2rem;
}
.lib-name {
font-weight: 500;
}
.feature-name {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
}
.feature-value {
text-align: center;
}
.curve-bar {
width: 100%;
height: 6px;
background: var(--vp-c-divider);
border-radius: 3px;
overflow: hidden;
margin-bottom: 0.25rem;
}
.curve-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s;
}
.curve-label {
font-size: 0.75rem;
color: var(--vp-c-text-2);
}
.size-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
}
.size-badge.small {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.size-badge.medium {
background: rgba(245, 158, 11, 0.1);
color: #f59e0b;
}
.size-badge.large {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.boolean-badge {
display: inline-block;
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
border-radius: 50%;
font-size: 0.8rem;
font-weight: 600;
}
.boolean-badge.yes {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.boolean-badge.no {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.text-value {
font-size: 0.85rem;
color: var(--vp-c-text-2);
}
.library-detail {
margin-top: 1.5rem;
padding: 1rem;
background: var(--vp-c-bg);
border-radius: 8px;
border: 1px solid var(--vp-c-divider);
}
.detail-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--vp-c-divider);
}
.detail-icon {
font-size: 2rem;
}
.detail-title {
flex: 1;
}
.detail-title h5 {
margin: 0 0 0.25rem;
font-size: 1.2rem;
}
.detail-tagline {
color: var(--vp-c-text-2);
font-size: 0.9rem;
}
.docs-link {
padding: 0.5rem 1rem;
background: var(--vp-c-brand);
color: white;
border-radius: 6px;
text-decoration: none;
font-size: 0.85rem;
transition: opacity 0.2s;
}
.docs-link:hover {
opacity: 0.9;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
.detail-section {
padding: 1rem;
background: var(--vp-c-bg-soft);
border-radius: 6px;
}
.detail-section h6 {
margin: 0 0 0.75rem;
font-size: 1rem;
color: var(--vp-c-text-1);
}
.detail-section ul {
margin: 0;
padding-left: 1.2rem;
}
.detail-section li {
margin: 0.5rem 0;
color: var(--vp-c-text-2);
font-size: 0.9rem;
}
.code-block {
background: var(--vp-c-bg);
border-radius: 6px;
padding: 1rem;
overflow-x: auto;
}
.code-block pre {
margin: 0;
font-size: 0.85rem;
line-height: 1.6;
}
.code-block code {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
color: var(--vp-c-text-1);
}
.decision-flow {
margin-top: 1.5rem;
padding: 1rem;
background: var(--vp-c-bg);
border-radius: 8px;
border: 1px solid var(--vp-c-divider);
}
.decision-flow h5 {
margin: 0 0 1rem;
text-align: center;
font-size: 1.1rem;
}
.flow-chart {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.flow-node {
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
text-align: center;
}
.flow-node.start {
background: var(--vp-c-brand);
color: white;
}
.flow-node.question {
background: var(--vp-c-bg-soft);
border: 2px solid var(--vp-c-divider);
}
.flow-node.result {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
border: 2px solid #22c55e;
}
.flow-arrow {
font-size: 1.2rem;
color: var(--vp-c-text-2);
}
@media (max-width: 768px) {
.comparison-table {
font-size: 0.8rem;
}
.comparison-table th,
.comparison-table td {
padding: 0.5rem;
}
.lib-icon {
font-size: 1rem;
}
.detail-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,546 @@
<template>
<div class="vuex-pinia-demo">
<div class="demo-header">
<h4>Vuex vs Pinia 深度对比</h4>
<p class="hint">体验 Vue 生态两种主流状态管理方案在语法类型支持和开发体验上的差异</p>
</div>
<div class="comparison-container">
<!-- Vuex 面板 -->
<div class="panel vuex-panel">
<div class="panel-header">
<div class="panel-title">
<span class="panel-icon">🌿</span>
<span>Vuex</span>
</div>
<span class="panel-badge legacy">经典</span>
</div>
<div class="panel-content">
<!-- Store 定义 -->
<div class="code-section">
<div class="code-header">
<span class="file-icon">📄</span>
<span class="file-name">store/index.js</span>
</div>
<div class="code-block" v-pre>
<pre><code>import { createStore } from 'vuex'
export default createStore({
// State
state: {
count: 0,
user: null
},
// Getters
getters: {
doubleCount: state => {
return (state?.count ?? 0) * 2
},
isLoggedIn: state => !!(state?.user)
},
// Mutations ()
mutations: {
INCREMENT(state) {
state.count = (state?.count ?? 0) + 1
},
SET_USER(state, user) {
state.user = user
}
},
// Actions ()
actions: {
incrementAsync({ commit }) {
setTimeout(() => {
commit('INCREMENT')
}, 1000)
},
async fetchUser({ commit }, userId) {
const response =
await fetch(\`/api/users/\${userId}\`)
const user = await response.json()
commit('SET_USER', user)
}
}
})</code></pre>
</div>
</div>
<!-- 组件中使用 -->
<div class="code-section">
<div class="code-header">
<span class="file-icon">📄</span>
<span class="file-name">Counter.vue</span>
</div>
<div class="code-block" v-pre>
<pre><code>&lt;template&gt;
&lt;div&gt;
&lt;p&gt;Count: {{ count }}&lt;/p&gt;
&lt;p&gt;Double: {{ doubleCount }}&lt;/p&gt;
&lt;button @click="increment"&gt;+&lt;/button&gt;
&lt;button @click="incrementAsync"&gt;+ (async)&lt;/button&gt;
&lt;/div&gt;
&lt;/template&gt;
&lt;script&gt;
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex'
export default {
computed: {
...mapState(['count']),
...mapGetters(['doubleCount'])
},
methods: {
...mapMutations(['INCREMENT']),
...mapActions(['incrementAsync']),
increment() {
this.INCREMENT()
}
}
}
&lt;/script&gt;</code></pre>
</div>
</div>
</div>
</div>
<!-- 中间对比区 -->
<div class="comparison-divider">
<div class="vs-badge">VS</div>
<div class="comparison-points">
<div class="point">
<span class="point-icon">📝</span>
<span class="point-text">Vuex 样板代码较多</span>
</div>
<div class="point">
<span class="point-icon">🔷</span>
<span class="point-text">TS 类型需额外定义</span>
</div>
<div class="point">
<span class="point-icon"></span>
<span class="point-text">选项式 API 风格</span>
</div>
</div>
</div>
<!-- Pinia 面板 -->
<div class="panel pinia-panel">
<div class="panel-header">
<div class="panel-title">
<span class="panel-icon">🍍</span>
<span>Pinia</span>
</div>
<span class="panel-badge modern">推荐</span>
</div>
<div class="panel-content">
<!-- Store 定义 -->
<div class="code-section">
<div class="code-header">
<span class="file-icon">📄</span>
<span class="file-name">stores/counter.js</span>
</div>
<div class="code-block" v-pre>
<pre><code>import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// 1: API ()
export const useCounterStore = defineStore('counter', () => {
// State
const count = ref(0)
const user = ref(null)
// Getters
const doubleCount = computed(() => (count.value ?? 0) * 2)
const isLoggedIn = computed(() => !!user.value)
// Actions
function increment() {
count.value = (count.value ?? 0) + 1
}
async function incrementAsync() {
await new Promise(r => setTimeout(r, 1000))
increment()
}
async function fetchUser(userId) {
const response = await fetch(\`/api/users/\${userId}\`)
user.value = await response.json()
}
return {
count, user,
doubleCount, isLoggedIn,
increment, incrementAsync, fetchUser
}
})
// 2: API
export const useCounterStoreOld = defineStore('counter', {
state: () => ({
count: 0,
user: null
}),
getters: {
doubleCount: (state) => (state?.count ?? 0) * 2
},
actions: {
increment() {
this.count = (this?.count ?? 0) + 1
}
}
})</code></pre>
</div>
</div>
<!-- 组件中使用 -->
<div class="code-section">
<div class="code-header">
<span class="file-icon">📄</span>
<span class="file-name">Counter.vue</span>
</div>
<div class="code-block" v-pre>
<pre><code>&lt;template&gt;
&lt;div&gt;
&lt;p&gt;Count: {{ counter.count }}&lt;/p&gt;
&lt;p&gt;Double: {{ counter.doubleCount }}&lt;/p&gt;
&lt;button @click="counter.increment()"&gt;+&lt;/button&gt;
&lt;button @click="counter.incrementAsync()"&gt;+ (async)&lt;/button&gt;
&lt;/div&gt;
&lt;/template&gt;
&lt;script setup&gt;
import { useCounterStore } from '@/stores/counter'
// store
const counter = useCounterStore()
//
// const { count, increment } = useCounterStore() //
// 使 storeToRefs
// import { storeToRefs } from 'pinia'
// const { count, doubleCount } = storeToRefs(counter)
// const { increment } = counter
&lt;/script&gt;</code></pre>
</div>
</div>
</div>
</div>
</div>
<!-- 特性对比表格 -->
<div class="features-comparison">
<h5>🔄 核心特性对比</h5>
<div class="features-table">
<div class="feature-row header">
<div class="feature-name">特性</div>
<div class="feature-vuex">Vuex</div>
<div class="feature-pinia">Pinia</div>
</div>
<div v-for="feature in comparisonFeatures" :key="feature.name" class="feature-row">
<div class="feature-name">{{ feature.name }}</div>
<div class="feature-vuex" :class="{ check: feature.vuex === '✓', cross: feature.vuex === '✗' }">{{ feature.vuex }}</div>
<div class="feature-pinia" :class="{ check: feature.pinia === '✓', cross: feature.pinia === '✗' }">{{ feature.pinia }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const comparisonFeatures = [
{ name: '组合式 API 支持', vuex: '✗', pinia: '✓' },
{ name: 'TypeScript 支持', vuex: '△', pinia: '✓' },
{ name: '无需 mutations', vuex: '✗', pinia: '✓' },
{ name: '自动模块化', vuex: '✗', pinia: '✓' },
{ name: '更轻量的体积', vuex: '✗', pinia: '✓' },
{ name: 'Vue 2 支持', vuex: '✓', pinia: '△' },
{ name: '开发工具支持', vuex: '✓', pinia: '✓' },
{ name: 'SSR 支持', vuex: '✓', pinia: '✓' }
]
</script>
<style scoped>
.vuex-pinia-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 20px;
background: var(--vp-c-bg-soft);
}
.demo-header {
margin-bottom: 20px;
}
.demo-header h4 {
margin: 0 0 8px 0;
color: var(--vp-c-text-1);
}
.hint {
margin: 0;
font-size: 14px;
color: var(--vp-c-text-2);
}
.comparison-container {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 16px;
margin-bottom: 24px;
}
@media (max-width: 968px) {
.comparison-container {
grid-template-columns: 1fr;
}
.comparison-divider {
flex-direction: row !important;
padding: 12px !important;
}
.comparison-points {
flex-direction: row !important;
flex-wrap: wrap;
}
}
.panel {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
overflow: hidden;
}
.vuex-panel {
border-color: #42b883;
}
.pinia-panel {
border-color: #ffd859;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--vp-c-bg-soft);
border-bottom: 1px solid var(--vp-c-divider);
}
.panel-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: var(--vp-c-text-1);
}
.panel-icon {
font-size: 24px;
}
.panel-badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
}
.panel-badge.legacy {
background: #e0f2fe;
color: #0369a1;
}
.panel-badge.modern {
background: #fef3c7;
color: #92400e;
}
.panel-content {
padding: 16px;
}
.code-section {
margin-bottom: 16px;
}
.code-section:last-child {
margin-bottom: 0;
}
.code-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
padding: 6px 10px;
background: var(--vp-c-bg-soft);
border-radius: 4px;
}
.file-icon {
font-size: 14px;
}
.file-name {
font-size: 12px;
color: var(--vp-c-text-2);
font-family: monospace;
}
.code-block {
background: #1e1e1e;
border-radius: 6px;
overflow: hidden;
}
.code-block pre {
margin: 0;
padding: 12px;
overflow-x: auto;
}
.code-block code {
font-family: 'Fira Code', 'Monaco', monospace;
font-size: 11px;
line-height: 1.5;
color: #d4d4d4;
}
.comparison-divider {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px 0;
}
.vs-badge {
width: 48px;
height: 48px;
background: linear-gradient(135deg, #42b883, #ffd859);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 16px;
color: white;
margin-bottom: 16px;
}
.comparison-points {
display: flex;
flex-direction: column;
gap: 8px;
}
.point {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
font-size: 12px;
}
.point-icon {
font-size: 14px;
}
.point-text {
color: var(--vp-c-text-2);
}
.features-comparison {
margin-top: 24px;
padding: 20px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
}
.features-comparison h5 {
margin: 0 0 16px 0;
color: var(--vp-c-text-1);
font-size: 16px;
}
.features-table {
display: flex;
flex-direction: column;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
overflow: hidden;
}
.feature-row {
display: grid;
grid-template-columns: 1fr 100px 100px;
border-bottom: 1px solid var(--vp-c-divider);
}
.feature-row:last-child {
border-bottom: none;
}
.feature-row.header {
background: var(--vp-c-bg-soft);
font-weight: 600;
}
.feature-name,
.feature-vuex,
.feature-pinia {
padding: 10px 12px;
font-size: 13px;
}
.feature-vuex,
.feature-pinia {
text-align: center;
border-left: 1px solid var(--vp-c-divider);
}
.feature-vuex.check,
.feature-pinia.check {
color: #22c55e;
font-weight: 600;
}
.feature-vuex.cross,
.feature-pinia.cross {
color: #ef4444;
}
.feature-vuex:not(.check):not(.cross),
.feature-pinia:not(.check):not(.cross) {
color: #f59e0b;
}
@media (max-width: 640px) {
.feature-row {
grid-template-columns: 1fr 60px 60px;
}
.feature-name,
.feature-vuex,
.feature-pinia {
padding: 8px;
font-size: 11px;
}
}
</style>
@@ -0,0 +1,823 @@
<template>
<div class="zustand-jotai-demo">
<div class="demo-header">
<h4>Zustand & Jotai轻量级状态管理</h4>
<p class="hint">探索现代 React 生态中最简洁的状态管理方案体验"钩子即状态"的开发模式</p>
</div>
<div class="demo-tabs">
<button
v-for="tab in tabs"
:key="tab.id"
class="tab-button"
:class="{ active: activeTab === tab.id }"
@click="activeTab = tab.id"
>
<span class="tab-icon">{{ tab.icon }}</span>
<span class="tab-name">{{ tab.name }}</span>
</button>
</div>
<!-- Zustand 演示 -->
<div v-if="activeTab === 'zustand'" class="tab-content">
<div class="split-view">
<!-- 左侧代码 -->
<div class="code-panel">
<div class="code-tabs">
<button
v-for="file in zustandFiles"
:key="file.name"
class="code-tab"
:class="{ active: activeZustandFile === file.name }"
@click="activeZustandFile = file.name"
>
{{ file.name }}
</button>
</div>
<div class="code-content">
<pre><code>{{ getZustandFileContent() }}</code></pre>
</div>
</div>
<!-- 右侧演示 -->
<div class="demo-panel">
<div class="panel-header">
<span class="header-icon">🐻</span>
<span class="header-title">Zustand Store Demo</span>
</div>
<div class="bear-counter">
<div class="bear-display">
<span v-for="n in Math.min(bears, 10)" :key="n" class="bear-icon">🐻</span>
<span v-if="bears > 10" class="more-bears">+{{ bears - 10 }}</span>
</div>
<div class="count-display">
<span class="count-number">{{ bears }}</span>
<span class="count-label">bears around here</span>
</div>
</div>
<div class="fish-tank">
<div class="tank-header">
<span>🐟 Fish Tank</span>
<span class="fish-count">{{ fishes }} fishes</span>
</div>
<div class="tank-content">
<div v-for="fish in Math.min(fishes, 8)" :key="fish" class="fish">
🐠
</div>
</div>
</div>
<div class="action-buttons">
<button class="action-btn primary" @click="addBear">
<span class="btn-icon"></span>
Add Bear
</button>
<button class="action-btn secondary" @click="addFish">
<span class="btn-icon">🐟</span>
Add Fish
</button>
<button class="action-btn danger" @click="eatFish">
<span class="btn-icon">🍽</span>
Eat Fish ({{ fishes }})
</button>
<button class="action-btn warning" @click="removeAllBears">
<span class="btn-icon">🗑</span>
Remove All
</button>
</div>
<div class="async-demo">
<div class="async-header">Async Action Demo</div>
<div class="async-controls">
<button class="async-btn" :disabled="loading" @click="fetchBears">
{{ loading ? '⏳ Fetching...' : '🌐 Fetch Bears from API' }}
</button>
</div>
<div v-if="apiResponse" class="api-response">
<pre>{{ apiResponse }}</pre>
</div>
</div>
</div>
</div>
</div>
<!-- Jotai 演示 -->
<div v-if="activeTab === 'jotai'" class="tab-content">
<!-- Jotai 内容类似结构 -->
<div class="split-view">
<div class="code-panel">
<!-- Jotai 代码示例 -->
<div class="code-tabs">
<button class="code-tab active">atoms/counter.js</button>
</div>
<div class="code-content">
<pre><code>import { atom } from 'jotai'
import { atomFamily } from 'jotai/utils'
//
const countAtom = atom(0)
// -
const doubleAtom = atom((get) => get(countAtom) * 2)
const isEvenAtom = atom((get) => get(countAtom) % 2 === 0)
//
const countAndDoubleAtom = atom(
(get) => ({
count: get(countAtom),
double: get(doubleAtom)
}),
(get, set, newCount) => {
set(countAtom, newCount)
}
)
// -
const todoAtomFamily = atomFamily((id) =>
atom({ id, text: '', completed: false })
)
//
const userAtom = atom(null)
const fetchUserAtom = atom(
(get) => get(userAtom),
async (get, set, userId) => {
const response = await fetch(\`/api/users/\${userId}\`)
const user = await response.json()
set(userAtom, user)
}
)
export {
countAtom,
doubleAtom,
isEvenAtom,
countAndDoubleAtom,
todoAtomFamily,
fetchUserAtom
}</code></pre>
</div>
</div>
<div class="demo-panel">
<div class="panel-header">
<span class="header-icon"></span>
<span class="header-title">Jotai Atom Demo</span>
</div>
<div class="atom-demo">
<div class="atom-visualization">
<div class="atom-core" :class="{ active: count > 0 }">
<div class="atom-nucleus">
<span class="nucleus-label">countAtom</span>
<span class="nucleus-value">{{ count }}</span>
</div>
<div class="electron-orbits">
<div class="orbit orbit-1"></div>
<div class="orbit orbit-2"></div>
<div class="orbit orbit-3"></div>
</div>
</div>
<div class="derived-atoms">
<div class="derived-atom" :class="{ active: double > 0 }">
<div class="atom-label">doubleAtom</div>
<div class="atom-value">{{ double }}</div>
<div class="atom-formula">count × 2</div>
</div>
<div class="derived-atom" :class="{ active: true }">
<div class="atom-label">isEvenAtom</div>
<div class="atom-value" :class="{ even: isEven }">{{ isEven ? 'YES' : 'NO' }}</div>
<div class="atom-formula">count % 2 === 0</div>
</div>
</div>
</div>
<div class="atom-controls">
<div class="control-group">
<button class="control-btn" @click="increment">
<span class="btn-icon"></span>
increment()
</button>
<button class="control-btn" @click="decrement">
<span class="btn-icon"></span>
decrement()
</button>
<button class="control-btn" @click="reset">
<span class="btn-icon">🔄</span>
reset()
</button>
</div>
<div class="control-group">
<button class="control-btn async" :disabled="loading" @click="fetchRandom">
<span class="btn-icon">{{ loading ? '⏳' : '🎲' }}</span>
{{ loading ? 'Fetching...' : 'Fetch Random (Async)' }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 底部说明 -->
<div class="info-section">
<div class="info-card">
<h5>🎯 核心概念</h5>
<ul>
<li><strong>Atom</strong>: 状态的基本单元可读写</li>
<li><strong>Derived Atom</strong>: 派生状态自动追踪依赖</li>
<li><strong>Atom Family</strong>: 动态创建原子集合</li>
</ul>
</div>
<div class="info-card">
<h5> Redux/MobX 对比</h5>
<ul>
<li>更细粒度的状态管理</li>
<li>天然支持 TypeScript</li>
<li>不需要 Provider 包裹</li>
<li> React Suspense 配合良好</li>
</ul>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
// Tab
const activeTab = ref('zustand')
const tabs = [
{ id: 'zustand', name: 'Zustand', icon: '🐻' },
{ id: 'jotai', name: 'Jotai', icon: '⚛️' }
]
// Zustand
const bears = ref(0)
const fishes = ref(5)
const loading = ref(false)
const apiResponse = ref('')
//
const double = computed(() => bears.value * 2)
const isEven = computed(() => bears.value % 2 === 0)
//
const increment = () => {
bears.value++
}
const decrement = () => {
if (bears.value > 0) bears.value--
}
const reset = () => {
bears.value = 0
fishes.value = 5
}
const addBear = () => {
bears.value++
}
const addFish = () => {
fishes.value++
}
const eatFish = () => {
if (fishes.value > 0) {
fishes.value--
bears.value += 0.1
}
}
const removeAllBears = () => {
bears.value = 0
}
const fetchBears = async () => {
loading.value = true
apiResponse.value = ''
await new Promise(resolve => setTimeout(resolve, 1500))
bears.value = Math.floor(Math.random() * 10) + 1
apiResponse.value = `{\n "status": "success",\n "data": {\n "bears": ${bears.value},\n "message": "Bears fetched successfully"\n }\n}`
loading.value = false
}
const fetchRandom = async () => {
loading.value = true
await new Promise(resolve => setTimeout(resolve, 1000))
bears.value = Math.floor(Math.random() * 100)
loading.value = false
}
//
const zustandFiles = [
{ name: 'store.js', content: `import { create } from 'zustand'
const useStore = create((set, get) => ({
// State
bears: 0,
fishes: 5,
// Computed (in component using selectors)
// const doubleBears = useStore(state => state.bears * 2)
// Actions
increasePopulation: () =>
set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
eatFish: () => set((state) => ({
fishes: state.fishes - 1,
bears: state.bears + 0.1
})),
// Async action
fetchBears: async () => {
try {
const response = await fetch('/api/bears')
const data = await response.json()
if (data && typeof data.count === 'number') {
set({ bears: data.count })
}
} catch (error) {
console.error('Failed to fetch bears:', error)
}
},
// Get current state
getState: () => get()
}))
export default useStore` },
{ name: 'Component.jsx', content: `import useStore from './store'
// Component using Zustand
function BearCounter() {
// Select only what you need - fine-grained updates
const bears = useStore((state) => state.bears)
const increase = useStore((state) => state.increasePopulation)
return (
<div>
<h1>{bears} bears around here...</h1>
<button onClick={increase}>Add bear</button>
</div>
)
}
// Async action usage
function BearFetcher() {
const fetchBears = useStore((state) => state.fetchBears)
const bears = useStore((state) => state.bears)
return (
<div>
<p>Bears: {bears}</p>
<button onClick={fetchBears}>
Fetch Bears
</button>
</div>
)
}
// Multiple state selections
function FishTank() {
const { fishes, bears, eatFish } = useStore(
(state) => ({
fishes: state.fishes,
bears: state.bears,
eatFish: state.eatFish
})
)
return (
<div>
<p>Fishes: {fishes}</p>
<p>Bears: {bears}</p>
<button onClick={eatFish}>Eat Fish</button>
</div>
)
}` }
]
const activeZustandFile = ref('store.js')
const getZustandFileContent = () => {
return zustandFiles.find(f => f.name === activeZustandFile.value)?.content || ''
}
</script>
<style scoped>
.zustand-jotai-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 20px;
background: var(--vp-c-bg-soft);
}
.demo-header {
margin-bottom: 20px;
}
.demo-header h4 {
margin: 0 0 8px 0;
color: var(--vp-c-text-1);
}
.hint {
margin: 0;
font-size: 14px;
color: var(--vp-c-text-2);
}
.demo-tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
border-bottom: 1px solid var(--vp-c-divider);
padding-bottom: 12px;
}
.tab-button {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
font-size: 14px;
color: var(--vp-c-text-2);
cursor: pointer;
transition: all 0.2s ease;
}
.tab-button:hover {
border-color: var(--vp-c-brand);
color: var(--vp-c-text-1);
}
.tab-button.active {
background: var(--vp-c-brand);
border-color: var(--vp-c-brand);
color: white;
}
.tab-icon {
font-size: 20px;
}
.tab-name {
font-weight: 500;
}
.split-view {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 968px) {
.split-view {
grid-template-columns: 1fr;
}
}
.code-panel {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
overflow: hidden;
}
.code-tabs {
display: flex;
background: var(--vp-c-bg-soft);
border-bottom: 1px solid var(--vp-c-divider);
}
.code-tab {
padding: 10px 16px;
background: transparent;
border: none;
font-size: 13px;
color: var(--vp-c-text-2);
cursor: pointer;
transition: all 0.2s ease;
}
.code-tab:hover {
color: var(--vp-c-text-1);
background: var(--vp-c-bg);
}
.code-tab.active {
color: var(--vp-c-brand);
background: var(--vp-c-bg);
border-bottom: 2px solid var(--vp-c-brand);
}
.code-content {
max-height: 600px;
overflow: auto;
}
.code-content pre {
margin: 0;
padding: 16px;
font-family: 'Fira Code', monospace;
font-size: 12px;
line-height: 1.6;
color: #d4d4d4;
background: #1e1e1e;
}
.demo-panel {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 20px;
}
.panel-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid var(--vp-c-divider);
}
.header-icon {
font-size: 24px;
}
.header-title {
font-weight: 600;
font-size: 16px;
color: var(--vp-c-text-1);
}
.bear-counter {
background: var(--vp-c-bg-soft);
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
}
.bear-display {
display: flex;
flex-wrap: wrap;
gap: 4px;
justify-content: center;
margin-bottom: 12px;
min-height: 40px;
}
.bear-icon {
font-size: 24px;
}
.more-bears {
font-size: 14px;
color: var(--vp-c-text-2);
align-self: center;
}
.count-display {
text-align: center;
}
.count-number {
font-size: 36px;
font-weight: 700;
color: var(--vp-c-brand);
}
.count-label {
display: block;
font-size: 14px;
color: var(--vp-c-text-2);
margin-top: 4px;
}
.fish-tank {
background: linear-gradient(180deg, #e0f2fe 0%, #bae6fd 100%);
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.tank-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-weight: 600;
color: #0369a1;
}
.tank-content {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
min-height: 50px;
}
.fish {
font-size: 28px;
animation: swim 2s ease-in-out infinite;
}
.fish:nth-child(2) { animation-delay: 0.3s; }
.fish:nth-child(3) { animation-delay: 0.6s; }
.fish:nth-child(4) { animation-delay: 0.9s; }
@keyframes swim {
0%, 100% { transform: translateY(0) rotate(0deg); }
25% { transform: translateY(-3px) rotate(-2deg); }
75% { transform: translateY(3px) rotate(2deg); }
}
.action-buttons {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.action-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.action-btn.primary {
background: var(--vp-c-brand);
color: white;
}
.action-btn.primary:hover {
background: var(--vp-c-brand-dark);
}
.action-btn.secondary {
background: #0ea5e9;
color: white;
}
.action-btn.secondary:hover {
background: #0284c7;
}
.action-btn.danger {
background: #ef4444;
color: white;
}
.action-btn.danger:hover {
background: #dc2626;
}
.action-btn.warning {
background: #f59e0b;
color: white;
}
.action-btn.warning:hover {
background: #d97706;
}
.action-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-icon {
font-size: 14px;
}
.async-demo {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 12px;
}
.async-header {
font-size: 12px;
font-weight: 600;
color: var(--vp-c-text-2);
margin-bottom: 8px;
}
.async-controls {
margin-bottom: 8px;
}
.async-btn {
width: 100%;
padding: 10px;
background: #8b5cf6;
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.async-btn:hover:not(:disabled) {
background: #7c3aed;
}
.async-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.api-response {
background: #1e1e1e;
border-radius: 6px;
padding: 10px;
overflow: auto;
}
.api-response pre {
margin: 0;
font-family: 'Fira Code', monospace;
font-size: 11px;
line-height: 1.5;
color: #d4d4d4;
}
.info-section {
margin-top: 20px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.info-card {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 16px;
}
.info-card h5 {
margin: 0 0 10px 0;
font-size: 14px;
color: var(--vp-c-text-1);
}
.info-card ul {
margin: 0;
padding-left: 18px;
}
.info-card li {
font-size: 12px;
color: var(--vp-c-text-2);
margin: 5px 0;
line-height: 1.5;
}
.info-card li strong {
color: var(--vp-c-text-1);
}
@media (max-width: 768px) {
.action-buttons {
grid-template-columns: 1fr;
}
.atom-core {
transform: scale(0.9);
}
}
</style>