feat(docs): add performance overview demo component and update content structure
- Create PerformanceOverviewDemo.vue with interactive performance dimension visualization - Update config.mjs to support new component registration - Add new frontend evolution components to theme/index.js - Consolidate stage-0 intro pages into index.md across all locales - Enhance LLM intro documentation with tokenization details
This commit is contained in:
+490
@@ -0,0 +1,490 @@
|
||||
<!--
|
||||
ImperativeVsDeclarativeDemo.vue
|
||||
命令式 vs 声明式编程对比演示
|
||||
|
||||
用途:
|
||||
通过并排的交互式计数器,直观展示 Imperative(jQuery)和 Declarative(Vue)
|
||||
在代码量和心智负担上的差异。
|
||||
|
||||
交互功能:
|
||||
- 两个可交互的计数器。
|
||||
- 切换展示背后的代码实现。
|
||||
- 高亮显示 jQuery 需要手动更新的多个 DOM 节点 vs Vue 的自动绑定。
|
||||
-->
|
||||
<template>
|
||||
<div class="imperative-declarative-demo">
|
||||
<div class="demo-header">
|
||||
<div class="toggle-group">
|
||||
<button
|
||||
v-for="view in views"
|
||||
:key="view.id"
|
||||
:class="['toggle-btn', { active: currentView === view.id }]"
|
||||
@click="currentView = view.id"
|
||||
>
|
||||
{{ view.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="comparison-container">
|
||||
<!-- Imperative Side (jQuery) -->
|
||||
<div class="side imperative-side">
|
||||
<div class="side-header">
|
||||
<span class="badge imperative">jQuery / Imperative</span>
|
||||
<h4>"Tell me HOW"</h4>
|
||||
</div>
|
||||
|
||||
<div class="demo-area">
|
||||
<!-- The UI -->
|
||||
<div class="counter-ui">
|
||||
<div class="display-value" id="jq-display">{{ jqCount }}</div>
|
||||
<div class="meters">
|
||||
<div class="meter-label">Progress:</div>
|
||||
<div class="meter-bar">
|
||||
<div
|
||||
class="meter-fill"
|
||||
id="jq-meter"
|
||||
:style="{ width: jqProgress + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="status-text" id="jq-status">
|
||||
{{ jqStatus }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button class="btn-decrement" @click="updateJq(-1)">-</button>
|
||||
<button class="btn-increment" @click="updateJq(1)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The Code -->
|
||||
<div v-show="currentView === 'code'" class="code-panel">
|
||||
<div class="code-block imperative-code">
|
||||
<pre><code>function updateCounter(change) {
|
||||
// 1. Get current value
|
||||
var count = parseInt($('#counter').text());
|
||||
|
||||
// 2. Calculate new value
|
||||
var newCount = count + change;
|
||||
|
||||
// 3. Update DOM element 1
|
||||
$('#counter').text(newCount);
|
||||
|
||||
// 4. Update DOM element 2
|
||||
var progress = (newCount / 10) * 100;
|
||||
$('#progress-bar').css('width', progress + '%');
|
||||
|
||||
// 5. Update DOM element 3
|
||||
if (newCount > 5) {
|
||||
$('#status').text('High!').addClass('warning');
|
||||
} else {
|
||||
$('#status').text('Normal').removeClass('warning');
|
||||
}
|
||||
|
||||
// 6. Update DOM element 4...
|
||||
// Oops! Forgot to update the color indicator!
|
||||
}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pain-points" v-if="showAnalysis">
|
||||
<div class="pain-point">
|
||||
<span class="icon">⚠️</span>
|
||||
<span>需要手动操作多个 DOM 元素</span>
|
||||
</div>
|
||||
<div class="pain-point">
|
||||
<span class="icon">🐛</span>
|
||||
<span>容易遗漏更新,导致界面不一致</span>
|
||||
</div>
|
||||
<div class="pain-point">
|
||||
<span class="icon">🍝</span>
|
||||
<span>逻辑分散,代码难以维护</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VS Divider -->
|
||||
<div class="vs-divider">
|
||||
<div class="vs-badge">VS</div>
|
||||
</div>
|
||||
|
||||
<!-- Declarative Side (Vue) -->
|
||||
<div class="side declarative-side">
|
||||
<div class="side-header">
|
||||
<span class="badge declarative">Vue / Declarative</span>
|
||||
<h4>"Tell me WHAT"</h4>
|
||||
</div>
|
||||
|
||||
<div class="demo-area">
|
||||
<!-- The UI -->
|
||||
<div class="counter-ui">
|
||||
<div class="display-value">{{ vueCount }}</div>
|
||||
<div class="meters">
|
||||
<div class="meter-label">Progress:</div>
|
||||
<div class="meter-bar">
|
||||
<div
|
||||
class="meter-fill"
|
||||
:style="{ width: vueProgress + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="status-text" :class="{ warning: vueCount > 5 }">
|
||||
{{ vueStatus }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button class="btn-decrement" @click="vueCount--">-</button>
|
||||
<button class="btn-increment" @click="vueCount++">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The Code -->
|
||||
<div v-show="currentView === 'code'" class="code-panel">
|
||||
<div class="code-block declarative-code">
|
||||
<pre><code>export default {
|
||||
data() {
|
||||
return {
|
||||
count: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// Automatically updates when count changes
|
||||
progress() {
|
||||
return (this.count / 10) * 100;
|
||||
},
|
||||
status() {
|
||||
return this.count > 5 ? 'High!' : 'Normal';
|
||||
},
|
||||
isWarning() {
|
||||
return this.count > 5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Template - just declare what the UI should look like
|
||||
<template>
|
||||
<div class="status" :class="{ warning: isWarning }">
|
||||
{{ status }}
|
||||
</div>
|
||||
</template></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="benefits" v-if="showAnalysis">
|
||||
<div class="benefit">
|
||||
<span class="icon">✅</span>
|
||||
<span>只关注数据,不用手动操作 DOM</span>
|
||||
</div>
|
||||
<div class="benefit">
|
||||
<span class="icon">🔄</span>
|
||||
<span>数据变化自动同步到所有相关视图</span>
|
||||
</div>
|
||||
<div class="benefit">
|
||||
<span class="icon">🧩</span>
|
||||
<span>代码结构清晰,易于维护</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-controls">
|
||||
<button class="toggle-btn" @click="showAnalysis = !showAnalysis">
|
||||
{{ showAnalysis ? '隐藏' : '显示' }}对比分析
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const currentView = ref('ui')
|
||||
const showAnalysis = ref(false)
|
||||
const jqCount = ref(0)
|
||||
const vueCount = ref(0)
|
||||
|
||||
const views = [
|
||||
{ id: 'ui', label: '仅显示界面' },
|
||||
{ id: 'code', label: '显示代码' }
|
||||
]
|
||||
|
||||
const jqProgress = computed(() => Math.min((jqCount.value / 10) * 100, 100))
|
||||
const vueProgress = computed(() => Math.min((vueCount.value / 10) * 100, 100))
|
||||
|
||||
const jqStatus = computed(() => (jqCount.value > 5 ? 'High!' : 'Normal'))
|
||||
const vueStatus = computed(() => (vueCount.value > 5 ? 'High!' : 'Normal'))
|
||||
|
||||
function updateJq(change) {
|
||||
jqCount.value += change
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.imperative-declarative-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.toggle-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-2);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
background-color: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.comparison-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 1.5rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.side-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge.imperative {
|
||||
background-color: rgba(7, 105, 173, 0.2);
|
||||
color: #0769ad;
|
||||
}
|
||||
|
||||
.badge.declarative {
|
||||
background-color: rgba(66, 184, 131, 0.2);
|
||||
color: #2c8a5e;
|
||||
}
|
||||
|
||||
.side-header h4 {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 1rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.demo-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.counter-ui {
|
||||
background-color: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.display-value {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.meters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.meter-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.meter-bar {
|
||||
height: 8px;
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meter-fill {
|
||||
height: 100%;
|
||||
background-color: var(--vp-c-brand);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.status-text.warning {
|
||||
color: #f87171;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.controls button:hover:not(:disabled) {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.controls button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.code-panel {
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-block pre {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.imperative-code {
|
||||
background-color: #1e1e2e;
|
||||
color: #a6accd;
|
||||
}
|
||||
|
||||
.imperative-code code {
|
||||
font-family: 'Fira Code', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
.declarative-code {
|
||||
background-color: #1e1e2e;
|
||||
color: #a6accd;
|
||||
}
|
||||
|
||||
.declarative-code code {
|
||||
font-family: 'Fira Code', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
.vs-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vs-badge {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #0769ad, #42b883);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.pain-points,
|
||||
.benefits {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pain-point,
|
||||
.benefit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.pain-point {
|
||||
background-color: rgba(248, 113, 113, 0.1);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.benefit {
|
||||
background-color: rgba(74, 222, 128, 0.1);
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.demo-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.comparison-container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.vs-divider {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,804 @@
|
||||
<!--
|
||||
JQueryVsStateDemo.vue
|
||||
jQuery vs 数据驱动对比演示 - 重构版
|
||||
|
||||
用途:
|
||||
用"餐厅服务员"的比喻,让零基础用户理解命令式 vs 声明式的区别。
|
||||
通过并排的交互式计数器,直观展示两种编程范式的差异。
|
||||
-->
|
||||
<template>
|
||||
<div class="jquery-state-demo">
|
||||
<div class="scenario-intro">
|
||||
<div class="emoji-scene">🍽️ 👨🍳 📝</div>
|
||||
<h4>餐厅服务员模拟器</h4>
|
||||
<p>想象一下你在餐厅当服务员,有两种工作方式,你会选哪种?</p>
|
||||
</div>
|
||||
|
||||
<div class="comparison-container">
|
||||
<!-- 左边:jQuery 模式 -->
|
||||
<div class="side-panel jquery-panel">
|
||||
<div class="panel-header">
|
||||
<div class="mode-badge jquery">
|
||||
<span class="badge-icon">🏃</span>
|
||||
<span class="badge-text">跑腿王模式</span>
|
||||
</div>
|
||||
<div class="mode-subtitle">命令式(jQuery)</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario-visual">
|
||||
<div class="visual-label">后厨 → 吧台 → 收银台</div>
|
||||
<div class="runner-path">
|
||||
<div class="station kitchen" :class="{ active: jqActiveStation === 'kitchen' }">
|
||||
<span class="station-icon">🍳</span>
|
||||
<span class="station-name">后厨</span>
|
||||
</div>
|
||||
<div class="path-arrow" :class="{ active: jqActiveStation === 'bar' }">→</div>
|
||||
<div class="station bar" :class="{ active: jqActiveStation === 'bar' }">
|
||||
<span class="station-icon">🥤</span>
|
||||
<span class="station-name">吧台</span>
|
||||
</div>
|
||||
<div class="path-arrow" :class="{ active: jqActiveStation === 'cashier' }">→</div>
|
||||
<div class="station cashier" :class="{ active: jqActiveStation === 'cashier' }">
|
||||
<span class="station-icon">💰</span>
|
||||
<span class="station-name">收银</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-counter">
|
||||
<div class="counter-display">
|
||||
<div class="display-label">当前计数</div>
|
||||
<div class="display-value">{{ jqCount }}</div>
|
||||
</div>
|
||||
|
||||
<div class="counter-controls">
|
||||
<button class="ctrl-btn decrement" @click="updateJq(-1)" :disabled="jqCount <= 0">
|
||||
<span class="btn-icon">➖</span>
|
||||
<span class="btn-label">减 1</span>
|
||||
</button>
|
||||
<button class="ctrl-btn increment" @click="updateJq(1)">
|
||||
<span class="btn-icon">➕</span>
|
||||
<span class="btn-label">加 1</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="status-bars">
|
||||
<div class="status-item">
|
||||
<span class="status-label">进度条</span>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: jqProgress + '%' }"></div>
|
||||
</div>
|
||||
<span class="status-value">{{ jqProgress }}%</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">状态</span>
|
||||
<span class="status-badge" :class="jqStatusClass">{{ jqStatus }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-snippet">
|
||||
<div class="snippet-header">
|
||||
<span class="snippet-title">💻 代码实现</span>
|
||||
<span class="snippet-lang">jQuery</span>
|
||||
</div>
|
||||
<pre class="snippet-code"><code>// 需要手动更新每个元素
|
||||
function updateCounter(change) {
|
||||
var count = parseInt($('#counter').text());
|
||||
var newCount = count + change;
|
||||
|
||||
// 更新计数显示
|
||||
$('#counter').text(newCount);
|
||||
|
||||
// 更新进度条
|
||||
var progress = (newCount / 10) * 100;
|
||||
$('#progress').css('width', progress + '%');
|
||||
|
||||
// 更新状态文字
|
||||
if (newCount > 5) {
|
||||
$('#status').text('高!').addClass('warning');
|
||||
} else {
|
||||
$('#status').text('正常').removeClass('warning');
|
||||
}
|
||||
|
||||
// 如果忘了更新某个地方...
|
||||
// 界面就会不一致!😱
|
||||
}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="pain-points">
|
||||
<div class="pain-title">😫 痛点</div>
|
||||
<ul class="pain-list">
|
||||
<li>每次都要亲自跑三个地方更新</li>
|
||||
<li>漏改一个地方,界面就不一致</li>
|
||||
<li>代码分散,难以维护</li>
|
||||
<li>累得半死,还容易出错</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VS 标识 -->
|
||||
<div class="vs-divider">
|
||||
<div class="vs-badge">VS</div>
|
||||
</div>
|
||||
|
||||
<!-- 右边:Vue 模式 -->
|
||||
<div class="side-panel vue-panel">
|
||||
<div class="panel-header">
|
||||
<div class="mode-badge vue">
|
||||
<span class="badge-icon">👔</span>
|
||||
<span class="badge-text">指挥家模式</span>
|
||||
</div>
|
||||
<div class="mode-subtitle">声明式(Vue)</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario-visual">
|
||||
<div class="visual-label">我只管改单子,其他自动同步!</div>
|
||||
<div class="conductor-scene">
|
||||
<div class="conductor">🎩</div>
|
||||
<div class="orchestra">
|
||||
<div class="musician" :class="{ playing: vueCount > 0 }">
|
||||
<span class="musician-icon">🎸</span>
|
||||
<span class="musician-role">计数</span>
|
||||
</div>
|
||||
<div class="musician" :class="{ playing: vueProgress > 0 }">
|
||||
<span class="musician-icon">📊</span>
|
||||
<span class="musician-role">进度</span>
|
||||
</div>
|
||||
<div class="musician" :class="{ playing: vueCount > 5 }">
|
||||
<span class="musician-icon">🚦</span>
|
||||
<span class="musician-role">状态</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-counter">
|
||||
<div class="counter-display">
|
||||
<div class="display-label">当前计数</div>
|
||||
<div class="display-value">{{ vueCount }}</div>
|
||||
</div>
|
||||
|
||||
<div class="counter-controls">
|
||||
<button class="ctrl-btn decrement" @click="vueCount--" :disabled="vueCount <= 0">
|
||||
<span class="btn-icon">➖</span>
|
||||
<span class="btn-label">减 1</span>
|
||||
</button>
|
||||
<button class="ctrl-btn increment" @click="vueCount++">
|
||||
<span class="btn-icon">➕</span>
|
||||
<span class="btn-label">加 1</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="status-bars">
|
||||
<div class="status-item">
|
||||
<span class="status-label">进度条</span>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill vue" :style="{ width: vueProgress + '%' }"></div>
|
||||
</div>
|
||||
<span class="status-value">{{ vueProgress }}%</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">状态</span>
|
||||
<span class="status-badge" :class="vueStatusClass">{{ vueStatus }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-snippet">
|
||||
<div class="snippet-header">
|
||||
<span class="snippet-title">💻 代码实现</span>
|
||||
<span class="snippet-lang">Vue</span>
|
||||
</div>
|
||||
<pre class="snippet-code"><code>// 只需要定义数据和规则
|
||||
data() {
|
||||
return {
|
||||
count: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
// 进度自动计算
|
||||
progress() {
|
||||
return (this.count / 10) * 100;
|
||||
},
|
||||
// 状态自动判断
|
||||
status() {
|
||||
return this.count > 5 ? '高!' : '正常';
|
||||
},
|
||||
isWarning() {
|
||||
return this.count > 5;
|
||||
}
|
||||
}
|
||||
|
||||
// 模板里只需要声明关系
|
||||
<template>
|
||||
<div class="status" :class="{ warning: isWarning }">
|
||||
{{ status }}
|
||||
</div>
|
||||
</template></code></pre>
|
||||
</div>
|
||||
|
||||
<div class="benefits">
|
||||
<div class="benefit-title">✨ 优势</div>
|
||||
<ul class="benefit-list">
|
||||
<li>只需改数据,不用手动更新每个地方</li>
|
||||
<li>界面自动同步,永远保持一致</li>
|
||||
<li>代码结构清晰,容易维护</li>
|
||||
<li>轻松优雅,不易出错</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
// jQuery 模式的状态
|
||||
const jqCount = ref(0)
|
||||
const jqActiveStation = ref('')
|
||||
|
||||
// Vue 模式的状态
|
||||
const vueCount = ref(0)
|
||||
|
||||
// jQuery 计算属性
|
||||
const jqProgress = computed(() => Math.min((jqCount.value / 10) * 100, 100))
|
||||
|
||||
const jqStatus = computed(() => {
|
||||
if (jqCount.value > 5) return '高!'
|
||||
if (jqCount.value > 0) return '正常'
|
||||
return '初始'
|
||||
})
|
||||
|
||||
const jqStatusClass = computed(() => {
|
||||
if (jqCount.value > 5) return 'warning'
|
||||
if (jqCount.value > 0) return 'normal'
|
||||
return 'initial'
|
||||
})
|
||||
|
||||
// Vue 计算属性
|
||||
const vueProgress = computed(() => Math.min((vueCount.value / 10) * 100, 100))
|
||||
|
||||
const vueStatus = computed(() => {
|
||||
if (vueCount.value > 5) return '高!'
|
||||
if (vueCount.value > 0) return '正常'
|
||||
return '初始'
|
||||
})
|
||||
|
||||
const vueStatusClass = computed(() => {
|
||||
if (vueCount.value > 5) return 'warning'
|
||||
if (vueCount.value > 0) return 'normal'
|
||||
return 'initial'
|
||||
})
|
||||
|
||||
// jQuery 更新函数(模拟需要手动更新多个地方)
|
||||
const updateJq = async (change) => {
|
||||
const newCount = jqCount.value + change
|
||||
if (newCount < 0) return
|
||||
|
||||
// 模拟需要跑三个地方更新
|
||||
// 第一站:后厨(计数)
|
||||
jqActiveStation.value = 'kitchen'
|
||||
await sleep(300)
|
||||
jqCount.value = newCount
|
||||
|
||||
// 第二站:吧台(进度条)
|
||||
jqActiveStation.value = 'bar'
|
||||
await sleep(300)
|
||||
|
||||
// 第三站:收银台(状态)
|
||||
jqActiveStation.value = 'cashier'
|
||||
await sleep(300)
|
||||
|
||||
jqActiveStation.value = ''
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.jquery-state-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--vp-c-bg-soft) 0%, var(--vp-c-bg) 100%);
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.scenario-intro {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, rgba(255, 183, 77, 0.2), rgba(255, 138, 101, 0.2));
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.emoji-scene {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
animation: bounce 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
.scenario-intro h4 {
|
||||
margin: 0.5rem 0;
|
||||
color: var(--vp-c-text-1);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.scenario-intro p {
|
||||
margin: 0;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.comparison-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: var(--vp-c-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.jquery-panel {
|
||||
border-color: #ff7043;
|
||||
}
|
||||
|
||||
.vue-panel {
|
||||
border-color: #42b883;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.mode-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 9999px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mode-badge.jquery {
|
||||
background: linear-gradient(135deg, #ff7043, #f4511e);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mode-badge.vue {
|
||||
background: linear-gradient(135deg, #42b883, #35495e);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.mode-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.scenario-visual {
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.visual-label {
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.runner-path {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.station {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
background: #f5f5f5;
|
||||
transition: all 0.3s;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.station.active {
|
||||
background: #ff7043;
|
||||
color: white;
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(255, 112, 67, 0.4);
|
||||
}
|
||||
|
||||
.station-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.station-name {
|
||||
font-size: 0.625rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.path-arrow {
|
||||
font-size: 1.5rem;
|
||||
color: #ccc;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.path-arrow.active {
|
||||
color: #ff7043;
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.conductor-scene {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.conductor {
|
||||
font-size: 3rem;
|
||||
animation: conduct 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes conduct {
|
||||
0%, 100% { transform: rotate(-10deg); }
|
||||
50% { transform: rotate(10deg); }
|
||||
}
|
||||
|
||||
.orchestra {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.musician {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.musician.playing {
|
||||
background: #42b883;
|
||||
color: white;
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(66, 184, 131, 0.4);
|
||||
}
|
||||
|
||||
.musician-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.musician-role {
|
||||
font-size: 0.625rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.demo-counter {
|
||||
padding: 1rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.counter-display {
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.display-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.display-value {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-brand);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.counter-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.ctrl-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.ctrl-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.jquery-panel .ctrl-btn.decrement {
|
||||
background: #ffccbc;
|
||||
color: #bf360c;
|
||||
}
|
||||
|
||||
.jquery-panel .ctrl-btn.increment {
|
||||
background: #ff7043;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.vue-panel .ctrl-btn.decrement {
|
||||
background: #c8e6c9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.vue-panel .ctrl-btn.increment {
|
||||
background: #42b883;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ctrl-btn:not(:disabled):hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.status-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.jquery-panel .progress-fill {
|
||||
background: linear-gradient(90deg, #ff7043, #f4511e);
|
||||
}
|
||||
|
||||
.vue-panel .progress-fill {
|
||||
background: linear-gradient(90deg, #42b883, #35495e);
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
min-width: 35px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge.initial {
|
||||
background: #f5f5f5;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.status-badge.normal {
|
||||
background: #c8e6c9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.status-badge.warning {
|
||||
background: #ffccbc;
|
||||
color: #bf360c;
|
||||
}
|
||||
|
||||
.code-snippet {
|
||||
margin: 1rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.snippet-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.snippet-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.snippet-lang {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.jquery-panel .snippet-lang {
|
||||
background: #ff7043;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.vue-panel .snippet-lang {
|
||||
background: #42b883;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.snippet-code {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
background: #1e1e2e;
|
||||
color: #a6accd;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.pain-points,
|
||||
.benefits {
|
||||
margin: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.pain-points {
|
||||
background: #fff3e0;
|
||||
border-left: 4px solid #ff7043;
|
||||
}
|
||||
|
||||
.benefits {
|
||||
background: #e8f5e9;
|
||||
border-left: 4px solid #42b883;
|
||||
}
|
||||
|
||||
.pain-title,
|
||||
.benefit-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.pain-title {
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.benefit-title {
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.pain-list,
|
||||
.benefit-list {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.pain-list li {
|
||||
color: #bf360c;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.benefit-list li {
|
||||
color: #1b5e20;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.vs-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vs-badge {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.comparison-container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.vs-divider {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.vs-badge {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.runner-path,
|
||||
.orchestra {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.counter-controls {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,532 @@
|
||||
<!--
|
||||
ResponsiveGridDemo.vue
|
||||
响应式布局演示 - 重构版
|
||||
|
||||
用途:
|
||||
用"智能变形相框"的比喻,让零基础用户理解响应式设计。
|
||||
通过可拖动的滑块,直观展示同一套代码如何适配不同屏幕尺寸。
|
||||
-->
|
||||
<template>
|
||||
<div class="responsive-demo">
|
||||
<div class="scenario-intro">
|
||||
<div class="emoji-scene">🖼️ 📱 💻</div>
|
||||
<h4>智能变形相框</h4>
|
||||
<p>想象你有一张照片,它会自动调整大小和布局,在任何相框里都好看!</p>
|
||||
</div>
|
||||
|
||||
<div class="device-presets">
|
||||
<button
|
||||
v-for="device in devices"
|
||||
:key="device.id"
|
||||
:class="['device-btn', { active: screenWidth === device.width }]"
|
||||
@click="setDevice(device.width)"
|
||||
>
|
||||
<span class="device-icon">{{ device.icon }}</span>
|
||||
<span class="device-name">{{ device.name }}</span>
|
||||
<span class="device-size">{{ device.width }}px</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="slider-control">
|
||||
<div class="slider-labels">
|
||||
<span>📱 手机</span>
|
||||
<span>
|
||||
<input
|
||||
type="range"
|
||||
v-model="screenWidth"
|
||||
:min="320"
|
||||
:max="1400"
|
||||
step="10"
|
||||
class="width-slider"
|
||||
/>
|
||||
</span>
|
||||
<span>💻 电脑</span>
|
||||
</div>
|
||||
<div class="current-width">
|
||||
当前宽度: <strong>{{ screenWidth }}px</strong> - {{ currentBreakpoint.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="viewport-preview">
|
||||
<div class="viewport-header">
|
||||
<span class="viewport-device">{{ currentDevice }}</span>
|
||||
<span class="viewport-dots">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="viewport-content" :style="{ width: screenWidth + 'px' }">
|
||||
<div class="demo-grid" :class="gridClass">
|
||||
<div
|
||||
v-for="(item, index) in gridItems"
|
||||
:key="index"
|
||||
class="grid-item"
|
||||
:style="{ animationDelay: (index * 0.1) + 's' }"
|
||||
>
|
||||
<div class="item-icon">{{ item.icon }}</div>
|
||||
<div class="item-title">{{ item.title }}</div>
|
||||
<div class="item-desc">{{ item.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="breakpoint-indicator">
|
||||
<div class="bp-track">
|
||||
<div
|
||||
v-for="bp in breakpoints"
|
||||
:key="bp.name"
|
||||
class="bp-point"
|
||||
:class="{ active: screenWidth >= bp.min && screenWidth < (bp.max || 9999) }"
|
||||
:style="{ left: ((bp.min - 320) / (1400 - 320)) * 100 + '%' }"
|
||||
>
|
||||
<span class="bp-label">{{ bp.name }}</span>
|
||||
<span class="bp-range">{{ bp.min }}px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="code-preview">
|
||||
<div class="code-header">
|
||||
<span class="code-title">💻 响应式 CSS 代码</span>
|
||||
<span class="code-lang">CSS</span>
|
||||
</div>
|
||||
<pre class="code-block"><code>/* 默认:手机端(单列) */
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* 平板端:双列(640px 以上) */
|
||||
@media (min-width: 640px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* 电脑端:三列(1024px 以上) */
|
||||
@media (min-width: 1024px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="tips-box">
|
||||
<div class="tips-icon">🎯</div>
|
||||
<div class="tips-content">
|
||||
<strong>关键点:</strong>
|
||||
响应式布局通过 CSS 媒体查询(Media Query)自动适配不同屏幕。
|
||||
就像智能相框,无论大屏小屏,内容都能完美展示!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const screenWidth = ref(375)
|
||||
|
||||
const devices = [
|
||||
{ id: 'iphone-se', name: 'iPhone SE', width: 375, icon: '📱' },
|
||||
{ id: 'iphone-14', name: 'iPhone 14', width: 390, icon: '📱' },
|
||||
{ id: 'ipad-mini', name: 'iPad Mini', width: 768, icon: '📲' },
|
||||
{ id: 'macbook', name: 'MacBook', width: 1280, icon: '💻' },
|
||||
{ id: 'desktop', name: '桌面显示器', width: 1400, icon: '🖥️' }
|
||||
]
|
||||
|
||||
const breakpoints = [
|
||||
{ name: 'sm', min: 320, max: 640 },
|
||||
{ name: 'md', min: 640, max: 1024 },
|
||||
{ name: 'lg', min: 1024, max: 9999 }
|
||||
]
|
||||
|
||||
const currentBreakpoint = computed(() => {
|
||||
if (screenWidth.value < 640) return { name: '手机端(小屏)', cols: 1 }
|
||||
if (screenWidth.value < 1024) return { name: '平板端(中屏)', cols: 2 }
|
||||
return { name: '电脑端(大屏)', cols: 3 }
|
||||
})
|
||||
|
||||
const currentDevice = computed(() => {
|
||||
const device = devices.find(d => d.width === screenWidth.value)
|
||||
return device ? device.name : `${screenWidth.value}px 视口`
|
||||
})
|
||||
|
||||
const gridClass = computed(() => {
|
||||
if (screenWidth.value < 640) return 'grid-cols-1'
|
||||
if (screenWidth.value < 1024) return 'grid-cols-2'
|
||||
return 'grid-cols-3'
|
||||
})
|
||||
|
||||
const setDevice = (width) => {
|
||||
screenWidth.value = width
|
||||
}
|
||||
|
||||
const gridItems = [
|
||||
{ icon: '📷', title: '摄影', description: '捕捉精彩瞬间' },
|
||||
{ icon: '🎨', title: '设计', description: '创造视觉美感' },
|
||||
{ icon: '💻', title: '编程', description: '构建数字世界' },
|
||||
{ icon: '🎵', title: '音乐', description: '谱写动人旋律' },
|
||||
{ icon: '📚', title: '阅读', description: '探索知识海洋' },
|
||||
{ icon: '✈️', title: '旅行', description: '发现世界之美' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.responsive-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--vp-c-bg-soft) 0%, var(--vp-c-bg) 100%);
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.scenario-intro {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, rgba(77, 208, 225, 0.2), rgba(126, 87, 194, 0.2));
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.emoji-scene {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
.scenario-intro h4 {
|
||||
margin: 0.5rem 0;
|
||||
color: var(--vp-c-text-1);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.scenario-intro p {
|
||||
margin: 0;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.device-presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.device-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.device-btn:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.device-btn.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: var(--vp-c-brand-soft);
|
||||
}
|
||||
|
||||
.device-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.device-size {
|
||||
font-size: 0.625rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.slider-control {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.slider-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.width-slider {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: linear-gradient(90deg, #4dd0e1, #7e57c2);
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.width-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: white;
|
||||
border: 3px solid #7e57c2;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.current-width {
|
||||
text-align: center;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.viewport-preview {
|
||||
border: 2px solid var(--vp-c-brand);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.viewport-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--vp-c-brand-soft);
|
||||
border-bottom: 1px solid var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.viewport-device {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-brand-dark);
|
||||
}
|
||||
|
||||
.viewport-dots {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.viewport-dots span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.viewport-content {
|
||||
margin: 0 auto;
|
||||
transition: width 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.grid-cols-1 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.grid-cols-2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.grid-cols-3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
color: white;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
animation: fadeInUp 0.5s ease both;
|
||||
}
|
||||
|
||||
.grid-item:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.item-desc {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.breakpoint-indicator {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.bp-track {
|
||||
position: relative;
|
||||
height: 40px;
|
||||
background: linear-gradient(90deg, #4dd0e1 0%, #7e57c2 50%, #ab47bc 100%);
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.bp-point {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.bp-point.active .bp-label {
|
||||
background: white;
|
||||
color: #333;
|
||||
font-weight: 700;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.bp-label {
|
||||
padding: 4px 8px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.bp-range {
|
||||
font-size: 0.625rem;
|
||||
color: white;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.code-preview {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.code-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.code-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.code-lang {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
background: #1e1e2e;
|
||||
color: #a6accd;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tips-box {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, #e3f2fd, #f3e5f5);
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #2196f3;
|
||||
}
|
||||
|
||||
.tips-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.tips-content {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
color: #444;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.scene-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.device-presets {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bp-track {
|
||||
height: 60px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,717 @@
|
||||
<!--
|
||||
SliceRequestDemo.vue
|
||||
切图时代请求次数演示 - 重构版
|
||||
|
||||
用途:
|
||||
用外卖点餐的比喻,让零基础用户理解 HTTP 请求的概念。
|
||||
通过可视化的外卖小哥动画,展示切图时代 vs 雪碧图的性能差异。
|
||||
-->
|
||||
<template>
|
||||
<div class="slice-demo">
|
||||
<div class="scenario-intro">
|
||||
<div class="emoji-scene">🍕 📱 🛵</div>
|
||||
<h4>外卖点餐模拟器</h4>
|
||||
<p>想象一下你在点披萨外卖。每次下单,外卖小哥就要跑一趟。</p>
|
||||
</div>
|
||||
|
||||
<div class="mode-tabs">
|
||||
<button
|
||||
v-for="mode in modes"
|
||||
:key="mode.id"
|
||||
:class="['mode-tab', { active: currentMode === mode.id }]"
|
||||
@click="switchMode(mode.id)"
|
||||
>
|
||||
<span class="tab-icon">{{ mode.icon }}</span>
|
||||
<span class="tab-label">{{ mode.label }}</span>
|
||||
<span class="tab-desc">{{ mode.desc }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="restaurant-scene">
|
||||
<div class="scene-header">
|
||||
<div class="restaurant-info">
|
||||
<span class="restaurant-emoji">🏪</span>
|
||||
<span class="restaurant-name">前端披萨店</span>
|
||||
</div>
|
||||
<div class="delivery-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-label">外卖小哥跑了:</span>
|
||||
<span class="stat-value deliveries">{{ deliveryCount }}</span>
|
||||
<span class="stat-unit">趟</span>
|
||||
</div>
|
||||
<div class="stat time-stat">
|
||||
<span class="stat-label">总耗时:</span>
|
||||
<span class="stat-value time">{{ totalTime }}</span>
|
||||
<span class="stat-unit">秒</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scene-body">
|
||||
<div class="kitchen-area">
|
||||
<div class="kitchen-label">🍳 后厨(服务器)</div>
|
||||
<div class="food-items">
|
||||
<div
|
||||
v-for="(item, index) in foodItems"
|
||||
:key="index"
|
||||
class="food-item"
|
||||
:class="{ preparing: item.status === 'preparing', ready: item.status === 'ready' }"
|
||||
>
|
||||
<span class="food-emoji">{{ item.emoji }}</span>
|
||||
<span class="food-name">{{ item.name }}</span>
|
||||
<span class="food-status">{{ getStatusText(item.status) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="delivery-lane">
|
||||
<div class="lane-label">🛵 配送路线(网络)</div>
|
||||
<div class="delivery-runway">
|
||||
<div
|
||||
v-for="(rider, index) in activeRiders"
|
||||
:key="rider.id"
|
||||
class="rider"
|
||||
:style="{ left: rider.position + '%' }"
|
||||
>
|
||||
<div class="rider-emoji">{{ rider.mode === 'sprite' ? '🚚' : '🛵' }}</div>
|
||||
<div class="rider-package">
|
||||
<span v-for="emoji in rider.packages" :key="emoji">{{ emoji }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="activeRiders.length === 0" class="empty-lane">
|
||||
等待下单...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="customer-area">
|
||||
<div class="customer-label">🏠 你家(浏览器)</div>
|
||||
<div class="received-items">
|
||||
<div v-if="receivedItems.length === 0" class="empty-plate">
|
||||
🍽️ 等待美食送达...
|
||||
</div>
|
||||
<div v-else class="food-on-table">
|
||||
<div
|
||||
v-for="(item, index) in receivedItems"
|
||||
:key="index"
|
||||
class="received-item"
|
||||
:class="{ fresh: item.isNew }"
|
||||
>
|
||||
<span class="item-emoji">{{ item.emoji }}</span>
|
||||
<span class="item-name">{{ item.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control-panel">
|
||||
<button class="order-btn" @click="placeOrder" :disabled="isOrdering">
|
||||
<span class="btn-icon">{{ isOrdering ? '⏳' : '🛒' }}</span>
|
||||
<span class="btn-text">{{ isOrdering ? '配 送 中...' : '下 单 点 餐' }}</span>
|
||||
</button>
|
||||
<button class="reset-btn" @click="resetScene">
|
||||
<span class="btn-icon">🔄</span>
|
||||
<span class="btn-text">重新开始</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="explanation-box">
|
||||
<div class="explanation-icon">💡</div>
|
||||
<div class="explanation-content">
|
||||
<strong>{{ currentMode === 'slice' ? '切图时代' : '雪碧图时代' }}:</strong>
|
||||
{{ currentExplanation }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const currentMode = ref('slice')
|
||||
const isOrdering = ref(false)
|
||||
const deliveryCount = ref(0)
|
||||
const totalTime = ref(0)
|
||||
const activeRiders = ref([])
|
||||
const receivedItems = ref([])
|
||||
|
||||
const modes = [
|
||||
{
|
||||
id: 'slice',
|
||||
label: '切图时代',
|
||||
icon: '🛵',
|
||||
desc: '每次只送一道菜'
|
||||
},
|
||||
{
|
||||
id: 'sprite',
|
||||
label: '雪碧图时代',
|
||||
icon: '🚚',
|
||||
desc: '一次送完整桌菜'
|
||||
}
|
||||
]
|
||||
|
||||
const foodItems = [
|
||||
{ emoji: '🍕', name: '披萨底', status: 'ready' },
|
||||
{ emoji: '🧀', name: '芝士', status: 'ready' },
|
||||
{ emoji: '🍄', name: '蘑菇', status: 'ready' },
|
||||
{ emoji: '🥓', name: '培根', status: 'ready' },
|
||||
{ emoji: '🫑', name: '青椒', status: 'ready' },
|
||||
{ emoji: '🍅', name: '番茄酱', status: 'ready' }
|
||||
]
|
||||
|
||||
const currentExplanation = computed(() => {
|
||||
return currentMode.value === 'slice'
|
||||
? '每张小图都单独发一个 HTTP 请求。就像点外卖时,每道菜都单独叫一个外卖小哥,跑 6 趟才能送齐!'
|
||||
: '把所有小图合并成一张大图。就像把一桌菜装进一个保温箱,一个外卖小哥一趟就全送来了!'
|
||||
})
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const map = { ready: '✓ 就绪', preparing: '⏳ 制作中', delivering: '🛵 配送中' }
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
let riderIdCounter = 0
|
||||
|
||||
const switchMode = (mode) => {
|
||||
currentMode.value = mode
|
||||
resetScene()
|
||||
}
|
||||
|
||||
const resetScene = () => {
|
||||
isOrdering.value = false
|
||||
deliveryCount.value = 0
|
||||
totalTime.value = 0
|
||||
activeRiders.value = []
|
||||
receivedItems.value = []
|
||||
riderIdCounter = 0
|
||||
}
|
||||
|
||||
const placeOrder = async () => {
|
||||
if (isOrdering.value) return
|
||||
isOrdering.value = true
|
||||
receivedItems.value = []
|
||||
|
||||
const items = [...foodItems]
|
||||
|
||||
if (currentMode.value === 'slice') {
|
||||
// 切图模式:每个食材单独配送
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
deliveryCount.value++
|
||||
|
||||
// 创建骑手
|
||||
const rider = {
|
||||
id: riderIdCounter++,
|
||||
position: 0,
|
||||
mode: 'slice',
|
||||
packages: [item.emoji]
|
||||
}
|
||||
activeRiders.value = [rider]
|
||||
|
||||
// 动画:去程 - 使用响应式方式更新
|
||||
await animateRiderReactive(rider, 100, 800)
|
||||
|
||||
// 送达
|
||||
receivedItems.value.push({ ...item, isNew: true })
|
||||
setTimeout(() => { if (receivedItems.value[i]) receivedItems.value[i].isNew = false }, 500)
|
||||
|
||||
// 动画:返程 - 使用响应式方式更新
|
||||
await animateRiderReactive(rider, 0, 600)
|
||||
|
||||
totalTime.value += 1.4
|
||||
activeRiders.value = []
|
||||
}
|
||||
} else {
|
||||
// 雪碧图模式:一次送全部
|
||||
deliveryCount.value = 1
|
||||
|
||||
const rider = {
|
||||
id: riderIdCounter++,
|
||||
position: 0,
|
||||
mode: 'sprite',
|
||||
packages: items.map(i => i.emoji)
|
||||
}
|
||||
activeRiders.value = [rider]
|
||||
|
||||
// 动画:去程
|
||||
await animateRider(rider, 100, 1500)
|
||||
|
||||
// 全部送达
|
||||
items.forEach((item, idx) => {
|
||||
setTimeout(() => {
|
||||
receivedItems.value.push({ ...item, isNew: true })
|
||||
setTimeout(() => {
|
||||
const found = receivedItems.value.find(r => r.name === item.name && r.isNew)
|
||||
if (found) found.isNew = false
|
||||
}, 500)
|
||||
}, idx * 100)
|
||||
})
|
||||
|
||||
totalTime.value = 2.5
|
||||
|
||||
// 动画:返程
|
||||
await animateRider(rider, 0, 1000)
|
||||
activeRiders.value = []
|
||||
}
|
||||
|
||||
isOrdering.value = false
|
||||
}
|
||||
|
||||
// 响应式动画函数 - 使用 Vue 的响希方式更新位置
|
||||
const animateRiderReactive = (rider, targetPosition, duration) => {
|
||||
return new Promise(resolve => {
|
||||
const startPosition = rider.position
|
||||
const startTime = performance.now()
|
||||
let isActive = true
|
||||
|
||||
const animate = (currentTime) => {
|
||||
if (!isActive) return
|
||||
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
|
||||
// 缓动函数
|
||||
const easeProgress = 1 - Math.pow(1 - progress, 3)
|
||||
|
||||
// 使用 Vue 的方式触发更新 - 直接修改对象属性
|
||||
const newPosition = startPosition + (targetPosition - startPosition) * easeProgress
|
||||
|
||||
// 通过强制触发 Vue 响应的方式更新
|
||||
rider.position = newPosition
|
||||
|
||||
// 手动触发 Vue 的更新(通过操作数组)
|
||||
const riders = activeRiders.value
|
||||
const index = riders.indexOf(rider)
|
||||
if (index !== -1) {
|
||||
// 通过替换对象强制触发响应
|
||||
riders[index] = { ...rider, position: newPosition }
|
||||
}
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate)
|
||||
} else {
|
||||
isActive = false
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(animate)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slice-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--vp-c-bg-soft) 0%, var(--vp-c-bg) 100%);
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.scenario-intro {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, rgba(255, 183, 77, 0.2), rgba(255, 138, 101, 0.2));
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.emoji-scene {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
animation: bounce 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
.scenario-intro h4 {
|
||||
margin: 0.5rem 0;
|
||||
color: var(--vp-c-text-1);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.scenario-intro p {
|
||||
margin: 0;
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.mode-tabs {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.mode-tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 1rem;
|
||||
border: 2px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.mode-tab:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.mode-tab.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: linear-gradient(135deg, var(--vp-c-brand-soft), var(--vp-c-bg));
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.tab-desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.restaurant-scene {
|
||||
background: linear-gradient(180deg, #e3f2fd 0%, #f5f5f5 100%);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.scene-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.restaurant-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.restaurant-emoji {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.restaurant-name {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.delivery-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.stat-value.deliveries {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.stat-value.time {
|
||||
color: #4ecdc4;
|
||||
}
|
||||
|
||||
.scene-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.5fr 1fr;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
.kitchen-area,
|
||||
.delivery-lane,
|
||||
.customer-area {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.kitchen-label,
|
||||
.lane-label,
|
||||
.customer-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
margin-bottom: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.food-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.food-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.food-item.preparing {
|
||||
background: #fff3e0;
|
||||
}
|
||||
|
||||
.food-item.delivering {
|
||||
background: #e3f2fd;
|
||||
}
|
||||
|
||||
.food-item.ready {
|
||||
background: #e8f5e9;
|
||||
}
|
||||
|
||||
.food-emoji {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.food-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.food-status {
|
||||
font-size: 0.625rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.delivery-runway {
|
||||
position: relative;
|
||||
height: 120px;
|
||||
background: linear-gradient(90deg, #e8eaf6 0%, #c5cae9 50%, #e8eaf6 100%);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.delivery-runway::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
#9fa8da 0px,
|
||||
#9fa8da 20px,
|
||||
transparent 20px,
|
||||
transparent 40px
|
||||
);
|
||||
}
|
||||
|
||||
.rider {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
transition: left 0.1s linear;
|
||||
}
|
||||
|
||||
.rider-emoji {
|
||||
font-size: 2rem;
|
||||
animation: rider-bounce 0.5s infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes rider-bounce {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(-3px); }
|
||||
}
|
||||
|
||||
.rider-package {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-top: 2px;
|
||||
padding: 2px 4px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.empty-lane {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #9fa8da;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.received-items {
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.empty-plate {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 150px;
|
||||
color: #999;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.food-on-table {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.received-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.received-item.fresh {
|
||||
animation: item-arrive 0.5s ease;
|
||||
background: #e8f5e9;
|
||||
}
|
||||
|
||||
@keyframes item-arrive {
|
||||
0% { transform: scale(0.5); opacity: 0; }
|
||||
50% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.item-emoji {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 0.625rem;
|
||||
color: #666;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.order-btn,
|
||||
.reset-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.order-btn {
|
||||
background: linear-gradient(135deg, #ff6b6b, #ee5a5a);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.order-btn:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(255, 107, 107, 0.4);
|
||||
}
|
||||
|
||||
.order-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.reset-btn:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.explanation-box {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, #e3f2fd, #f3e5f5);
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #2196f3;
|
||||
}
|
||||
|
||||
.explanation-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.explanation-content {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
color: #444;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.scene-body {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.delivery-stats {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.mode-tabs {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user