feat: 添加多个附录交互式组件和文档更新

- 添加浏览器前端组件:无障碍访问、国际化、实时通信
- 添加 Transformer 注意力机制系列组件
- 更新 Canvas、数据追踪等现有组件
- 修复 ESLint 变量名冲突问题
- 完善相关附录文档
This commit is contained in:
sanbuphy
2026-02-24 08:34:53 +08:00
parent 94f9db0834
commit 260d17ee8b
42 changed files with 5290 additions and 12173 deletions
@@ -0,0 +1,246 @@
<template>
<div class="demo-wrapper">
<div class="demo-header">Accessibility (a11y) / 读屏机眼里的你</div>
<div class="split-pane">
<!-- 糟糕的做法 -->
<div class="pane bad-pane">
<h4 class="pane-title label-bad"> 野路子开发全是 DIV</h4>
<div class="component-card">
<!-- 这里全是用 div 伪造的组件 -->
<div
class="fake-btn"
@mouseenter="speakBad('提交')"
@mouseleave="stopSpeak"
@keydown.enter="showError"
>
提交
</div>
<div
class="fake-icon"
@mouseenter="speakBad('叉叉图')"
@mouseleave="stopSpeak"
>
</div>
</div>
<div class="reader-box">
<div class="reader-header">🎧 读屏机播报内容</div>
<div class="reader-text" :class="{ empty: !currentBadSpeech }">
{{ currentBadSpeech || '(只有字面,不知用途,键盘 Enter 无效)' }}
</div>
</div>
</div>
<!-- 优秀做法 -->
<div class="pane good-pane">
<h4 class="pane-title label-good"> 专业前端语义化 + ARIA</h4>
<div class="component-card">
<!-- 使用真正的按钮和 ARIA -->
<button
class="real-btn"
@mouseenter="speakGood('提交按钮。按下以发送表单。')"
@mouseleave="stopSpeak"
@click="triggerAction"
>
提交
</button>
<button
class="real-icon-btn"
aria-label="关闭窗口"
@mouseenter="speakGood('关闭窗口,按钮。')"
@mouseleave="stopSpeak"
>
<span aria-hidden="true"></span>
</button>
</div>
<div class="reader-box">
<div class="reader-header">🎧 读屏机播报内容</div>
<div class="reader-text active">
{{ currentGoodSpeech || '(悬停查看播报,支持 Tab 和 Enter 交互)' }}
</div>
</div>
</div>
</div>
<div class="status-msg">
💡 提示将鼠标悬停在上方按钮上模拟视障用户读屏机听到的内容<br/>
可以尝试用键盘 Tab 键选中并按 Enter只有右侧的按钮会响应
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const currentBadSpeech = ref('')
const currentGoodSpeech = ref('')
const speakBad = (text) => {
currentBadSpeech.value = `文本:"${text}"`
}
const speakGood = (text) => {
currentGoodSpeech.value = `🗣️ ${text}`
}
const stopSpeak = () => {
currentBadSpeech.value = ''
currentGoodSpeech.value = ''
}
const showError = () => {
alert('假按钮的 @keydown.enter 事件不会天生自带!')
}
const triggerAction = () => {
alert('真按钮成功触发!')
}
</script>
<style scoped>
.demo-wrapper {
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
padding: 1.5rem;
margin: 1.5rem 0;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
}
.demo-header {
font-weight: bold;
font-size: 0.9rem;
color: var(--vp-c-text-2);
margin-bottom: 1rem;
}
.split-pane {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
}
.pane {
flex: 1;
min-width: 250px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
}
.pane-title {
font-size: 0.9rem;
font-weight: bold;
margin-bottom: 1rem;
text-align: center;
}
.label-bad { color: var(--vp-c-danger, #e74c3c); }
.label-good { color: var(--vp-c-brand-1, #10b981); }
.component-card {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
flex-grow: 1;
padding: 2rem 0;
}
/* 假按钮完全不响应 tab 且无默认高亮样式 */
.fake-btn, .real-btn {
padding: 0.6rem 1.2rem;
border-radius: 4px;
font-weight: bold;
text-align: center;
user-select: none;
}
.fake-btn {
background: #e2e8f0;
color: #475569;
cursor: pointer;
/* 缺少 focus 可见轮廓 */
outline: none;
}
.real-btn {
background: var(--vp-c-brand);
color: white;
border: none;
cursor: pointer;
}
.real-btn:focus-visible {
outline: 3px solid var(--vp-c-brand-soft);
outline-offset: 2px;
}
.fake-icon, .real-icon-btn {
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 1.2rem;
}
.fake-icon {
background: #f1f5f9;
cursor: pointer;
}
.real-icon-btn {
background: #f1f5f9;
border: none;
cursor: pointer;
color: var(--vp-c-text-1);
}
.real-icon-btn:focus-visible {
outline: 3px solid var(--vp-c-brand-soft);
}
.reader-box {
background: #1e293b;
border-radius: 6px;
padding: 0.8rem;
margin-top: auto;
min-height: 80px;
display: flex;
flex-direction: column;
}
.reader-header {
font-size: 0.75rem;
color: #94a3b8;
margin-bottom: 0.4rem;
}
.reader-text {
color: white;
font-family: monospace;
font-size: 0.85rem;
font-weight: bold;
line-height: 1.4;
}
.reader-text.empty {
color: #64748b;
font-weight: normal;
}
.reader-text.active {
color: #34d399; /* emerald-400 */
}
.status-msg {
margin-top: 1rem;
font-size: 0.8rem;
color: var(--vp-c-text-2);
line-height: 1.5;
background: var(--vp-c-bg);
padding: 0.8rem;
border-radius: 6px;
border-left: 4px solid var(--vp-c-brand);
}
</style>
@@ -0,0 +1,309 @@
<template>
<div class="demo-wrapper">
<div class="demo-header">
<span class="icon">🔍</span>
<span>无障碍对象模型 (AOM) 视角对比演示</span>
</div>
<div class="intro-text">
请尝试使用<strong>纯键盘Tab 键与 Enter </strong>分别操作下方两个面板中的元素并观察右侧屏幕阅读器捕获到的 AOM 层解析结果
</div>
<div class="comparison-container">
<!-- 案例 A仅仅是看起来像按钮 -->
<div class="case-panel bad-case">
<h3 class="case-title"> 案例 A纯粹的视觉欺骗</h3>
<p class="case-desc">使用 <code>&lt;div&gt;</code> 结合 CSS 绘制在渲染树上很完美但在 AOM 树中缺失语义</p>
<div class="interactive-area">
<div class="label">操作确认</div>
<!-- 伪造的 input -->
<div
class="fake-input"
@click="simulateFocus('bad', '文本:请输入验证码')"
>
请输入验证码
</div>
<!-- 伪造的 button -->
<div
class="fake-button"
@mouseenter="simulateFocus('bad', '文本:确认提交')"
@mouseleave="clearFocus('bad')"
@click="handleClick('bad')"
>
确认提交
</div>
</div>
<div class="aom-monitor">
<div class="monitor-header">💻 屏幕阅读器解析 (AOM)</div>
<div class="monitor-screen" :class="{ 'has-content': badCaseOutput }">
{{ badCaseOutput || '(视障用户无法通过 Tab 键选中此区域的任何元素)' }}
</div>
</div>
</div>
<!-- 案例 B语义化与 ARIA 规范 -->
<div class="case-panel good-case">
<h3 class="case-title"> 案例 B语义化 + ARIA 护航</h3>
<p class="case-desc">使用 <code>&lt;input&gt;</code><code>&lt;button&gt;</code> 等原生标签补充 <code>aria-label</code> AOM 树中拥有完整交互属性</p>
<div class="interactive-area">
<label for="a11y-input" class="label">操作确认</label>
<input
id="a11y-input"
type="text"
placeholder="请输入验证码"
@focus="simulateFocus('good', '输入框:操作确认,请输入验证码')"
@blur="clearFocus('good')"
@mouseenter="simulateFocus('good', '输入框:操作确认,请输入验证码')"
@mouseleave="clearFocus('good')"
/>
<button
type="button"
class="real-button"
aria-label="提交确认验证码"
@focus="simulateFocus('good', '按钮:提交确认验证码。按下回车键激活。')"
@blur="clearFocus('good')"
@mouseenter="simulateFocus('good', '按钮:提交确认验证码。')"
@mouseleave="clearFocus('good')"
@click="handleClick('good')"
>
确认提交
</button>
</div>
<div class="aom-monitor">
<div class="monitor-header">💻 屏幕阅读器解析 (AOM)</div>
<div class="monitor-screen" :class="{ 'has-content': goodCaseOutput }">
{{ goodCaseOutput || '(鼠标悬停或按 Tab 键切入以查看解析)' }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const badCaseOutput = ref('')
const goodCaseOutput = ref('')
let timerBad = null
let timerGood = null
const simulateFocus = (type, text) => {
if (type === 'bad') {
if (timerBad) clearTimeout(timerBad)
badCaseOutput.value = text
} else {
if (timerGood) clearTimeout(timerGood)
goodCaseOutput.value = '🗣️ 正在朗读:' + text
}
}
const clearFocus = (type) => {
if (type === 'bad') {
timerBad = setTimeout(() => { badCaseOutput.value = '' }, 400)
} else {
timerGood = setTimeout(() => { goodCaseOutput.value = '' }, 400)
}
}
const handleClick = (type) => {
if (type === 'bad') {
alert('【系统提示】普通 div 虽然能绑定点击事件,但键盘用户无法使用 Tab 聚焦它,也无法用 Enter 键触发它。这对肢体障碍人士是灾难。')
} else {
alert('【系统提示】原生 button 点击触发成功!无论你是用鼠标点击,还是用键盘 Enter 键,都能完美触发。')
}
}
</script>
<style scoped>
.demo-wrapper {
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
padding: 1.8rem;
margin: 2rem 0;
font-family: var(--vp-font-family-base);
}
.demo-header {
display: flex;
align-items: center;
gap: 0.8rem;
font-size: 1.25rem;
font-weight: 700;
color: var(--vp-c-text-1);
margin-bottom: 1rem;
border-bottom: 2px solid var(--vp-c-divider);
padding-bottom: 0.8rem;
}
.intro-text {
font-size: 0.95rem;
color: var(--vp-c-text-2);
margin-bottom: 1.8rem;
line-height: 1.6;
}
.comparison-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
@media (min-width: 768px) {
.comparison-container {
flex-direction: row;
}
.case-panel {
flex: 1;
min-width: 0;
}
}
.case-panel {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 1.5rem;
display: flex;
flex-direction: column;
}
.bad-case {
border-top: 4px solid var(--vp-c-danger-1);
}
.good-case {
border-top: 4px solid var(--vp-c-brand-1);
}
.case-title {
margin: 0 0 0.8rem 0;
font-size: 1.1rem;
font-weight: 700;
color: var(--vp-c-text-1);
}
.case-desc {
font-size: 0.85rem;
color: var(--vp-c-text-2);
margin-bottom: 1.5rem;
line-height: 1.5;
min-height: 2.5rem;
}
.case-desc code {
background: var(--vp-c-bg-alt);
padding: 0.1rem 0.3rem;
border-radius: 4px;
color: var(--vp-c-text-1);
}
.interactive-area {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 2rem;
padding: 1.5rem;
background: var(--vp-c-bg-alt);
border-radius: 6px;
border: 1px dashed var(--vp-c-divider);
}
.label {
font-size: 0.9rem;
font-weight: 600;
color: var(--vp-c-text-1);
}
/* 伪造元素的样式 */
.fake-input {
background: #fff;
border: 1px solid #ccc;
padding: 0.6rem 0.8rem;
font-size: 0.9rem;
color: #888;
cursor: text;
border-radius: 4px;
}
.fake-button {
background: var(--vp-c-brand-soft);
color: var(--vp-c-brand-1);
padding: 0.6rem 1.2rem;
text-align: center;
font-weight: 600;
border-radius: 4px;
cursor: pointer;
border: 1px solid var(--vp-c-brand-soft);
}
/* 注意:这里故意不写 :focus 样式,以反映一般野路子开发的现状 */
/* 真实原生元素的样式 */
#a11y-input {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
padding: 0.6rem 0.8rem;
font-size: 0.9rem;
color: var(--vp-c-text-1);
border-radius: 4px;
transition: all 0.2s;
}
#a11y-input:focus {
outline: none;
border-color: var(--vp-c-brand-1);
box-shadow: 0 0 0 2px var(--vp-c-brand-soft);
}
.real-button {
background: var(--vp-c-brand-1);
color: #fff;
padding: 0.6rem 1.2rem;
text-align: center;
font-weight: 600;
border-radius: 4px;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.real-button:hover {
background: var(--vp-c-brand-2);
}
.real-button:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--vp-c-brand-soft);
}
/* 屏幕阅读器模拟面板 */
.aom-monitor {
margin-top: auto;
background: #1e293b;
border-radius: 6px;
padding: 1rem;
border-left: 4px solid #475569;
}
.monitor-header {
font-size: 0.8rem;
color: #94a3b8;
margin-bottom: 0.6rem;
font-weight: 600;
}
.monitor-screen {
font-family: "Courier New", Courier, monospace;
font-size: 0.9rem;
color: #64748b;
min-height: 2.5rem;
line-height: 1.4;
}
.monitor-screen.has-content {
color: #34d399; /* 绿色亮起,表示正确读出语义 */
font-weight: bold;
}
.dark .fake-input { background: #333; border-color: #555; }
</style>
@@ -0,0 +1,303 @@
<template>
<div class="demo-wrapper" :dir="layoutDirection">
<div class="demo-header" dir="ltr">i18n / 布局与本地化 API 演示</div>
<!-- 控制面板 -->
<div class="controls" dir="ltr">
<div class="lang-selector">
<label>选择环境 (Locale)</label>
<select v-model="currentLocale">
<option value="zh-CN">🇨🇳 简体中文 (zh-CN)</option>
<option value="en-US">🇺🇸 English (en-US)</option>
<option value="de-DE">🇩🇪 Deutsch (de-DE) [测试超长文本]</option>
<option value="ar-SA">🇸🇦 العربية (ar-SA) [测试从右到左]</option>
</select>
</div>
</div>
<!-- 演示应用 -->
<div class="app-ui">
<!-- 头部导航栏 -->
<nav class="app-nav">
<div class="nav-brand">
<span class="logo"></span>
<span>{{ t('app_name') }}</span>
</div>
<div class="nav-links">
<a href="#">{{ t('home') }}</a>
<a href="#">{{ t('profile') }}</a>
</div>
</nav>
<!-- 主要内容区 -->
<main class="app-body">
<div class="card">
<h2 class="card-title">{{ t('payment_title') }}</h2>
<p class="card-desc">{{ t('payment_desc') }}</p>
<div class="data-row">
<span class="label">{{ t('date_label') }}</span>
<span class="value date-val">{{ formattedDate }}</span>
</div>
<div class="data-row">
<span class="label">{{ t('amount_label') }}</span>
<span class="value amount-val">{{ formattedAmount }}</span>
</div>
<div class="actions">
<!-- 演示按钮超长溢出防护 -->
<button class="btn btn-primary">
{{ t('confirm_btn') }}
</button>
<button class="btn btn-ghost">
{{ t('cancel_btn') }} <span dir="ltr"></span>
</button>
</div>
</div>
</main>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const currentLocale = ref('zh-CN')
// 极其简易的字典
const dictionary = {
'zh-CN': {
app_name: '随星流界',
home: '首页',
profile: '我的',
payment_title: '账单详情',
payment_desc: '请在到期前完成支付以避免服务中断。',
date_label: '出账日期',
amount_label: '应付总额',
confirm_btn: '立即确认支付',
cancel_btn: '返回上一页'
},
'en-US': {
app_name: 'Easy Vibe',
home: 'Home',
profile: 'Profile',
payment_title: 'Invoice Details',
payment_desc: 'Please complete your payment before the due date to avoid service interruption.',
date_label: 'Issued Date',
amount_label: 'Total Due',
confirm_btn: 'Confirm Payment',
cancel_btn: 'Go Back'
},
'de-DE': {
app_name: 'Einfache Stimmung',
home: 'Startseite',
profile: 'Profil',
payment_title: 'Rechnungsdetails',
payment_desc: 'Bitte schließen Sie Ihre Zahlung vor dem Fälligkeitsdatum ab, um eine Unterbrechung des Dienstes zu vermeiden.',
date_label: 'Ausstellungsdatum',
amount_label: 'Fälliger Gesamtbetrag',
confirm_btn: 'Zahlungsvorgang jetzt bestätigen', // 超长按钮文本
cancel_btn: 'Zurück zur vorherigen Seite'
},
'ar-SA': {
app_name: 'إيزي فايب',
home: 'الرئيسية',
profile: 'الملف الشخصي',
payment_title: 'تفاصيل الفاتورة',
payment_desc: 'يرجى إتمام عملية الدفع قبل تاريخ الاستحقاق لتجنب انقطاع الخدمة.',
date_label: 'تاريخ الإصدار',
amount_label: 'الإجمالي المستحق',
confirm_btn: 'تأكيد الدفع',
cancel_btn: 'العودة'
}
}
// 模拟的原始数据
const rawDate = new Date()
const rawAmount = 14590.5
const t = (key) => dictionary[currentLocale.value][key]
// 核心特性:自动计算布局方向
const layoutDirection = computed(() => {
return currentLocale.value === 'ar-SA' ? 'rtl' : 'ltr'
})
// 核心特性:使用浏览器原生 Intl API 进行本地化格式,告别手写正则
const formattedDate = computed(() => {
return new Intl.DateTimeFormat(currentLocale.value, {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'short'
}).format(rawDate)
})
const formattedAmount = computed(() => {
let currency = 'CNY'
if (currentLocale.value === 'en-US') currency = 'USD'
if (currentLocale.value === 'de-DE') currency = 'EUR'
if (currentLocale.value === 'ar-SA') currency = 'SAR'
return new Intl.NumberFormat(currentLocale.value, {
style: 'currency',
currency: currency
}).format(rawAmount)
})
</script>
<style scoped>
.demo-wrapper {
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
padding: 1.5rem;
margin: 1.5rem 0;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
}
.demo-header {
font-weight: bold;
font-size: 0.9rem;
color: var(--vp-c-text-2);
margin-bottom: 1rem;
}
.controls {
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--vp-c-bg);
border-radius: 8px;
border-left: 4px solid var(--vp-c-brand);
}
.lang-selector label {
font-weight: 600;
margin-right: 0.5rem;
color: var(--vp-c-text-1);
}
select {
padding: 0.3rem 0.5rem;
border-radius: 4px;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-alt);
color: var(--vp-c-text-1);
}
/* 内部 APP 模拟容器 */
.app-ui {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
}
/* 如果是 RTLFlex 的 start 自动会贴到右边! */
.app-nav {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
background: #1e293b;
color: white;
}
.nav-brand {
font-weight: 800;
font-size: 1.1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-links {
display: flex;
gap: 1.5rem;
}
.nav-links a { color: #cbd5e1; text-decoration: none; font-size: 0.9rem; font-weight: 600; }
.nav-links a:hover { color: white; }
.app-body {
padding: 2rem 1.5rem;
background: #f8fafc;
}
.card {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);
max-width: 500px;
margin: 0 auto;
}
.card-title {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
color: #0f172a;
border: none;
}
.card-desc {
color: #64748b;
font-size: 0.95rem;
margin-bottom: 1.5rem;
line-height: 1.5;
}
.data-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 0;
border-bottom: 1px solid #e2e8f0;
}
.data-row:last-of-type { border-bottom: none; margin-bottom: 1rem; }
.label { color: #64748b; font-weight: 600; font-size: 0.9rem; }
.value { font-weight: bold; color: #0f172a; }
.date-val { font-size: 0.9rem; }
.amount-val { font-size: 1.25rem; color: #10b981; }
.actions {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
flex-wrap: wrap; /* 关键:德文过长时允许换行,保护布局 */
}
.btn {
padding: 0.75rem 1.25rem;
border-radius: 6px;
font-weight: bold;
font-size: 0.9rem;
cursor: pointer;
border: none;
min-width: fit-content;
flex: 1; /* 按钮自动填满空间 */
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn-primary { background: var(--vp-c-brand); color: white; }
.btn-primary:hover { opacity: 0.9; }
.btn-ghost { background: #f1f5f9; color: #475569; }
.btn-ghost:hover { background: #e2e8f0; }
/* 暗黑模式适配 */
.dark .app-body { background: var(--vp-c-bg-alt); }
.dark .card { background: var(--vp-c-bg); border: 1px solid var(--vp-c-divider); }
.dark .card-title { color: var(--vp-c-text-1); }
.dark .value { color: var(--vp-c-text-1); }
.dark .amount-val { color: var(--vp-c-brand-1); }
.dark .btn-ghost { background: var(--vp-c-bg-soft); color: var(--vp-c-text-2); }
.dark .data-row { border-color: var(--vp-c-divider); }
</style>
@@ -0,0 +1,465 @@
<template>
<div class="demo-wrapper">
<div class="demo-header" dir="ltr">
<span class="icon">🌍</span>
<span>浏览器原生的本地化转换 (i18n) 演示</span>
</div>
<div class="intro-text" dir="ltr">
请在下方切换用户的本地化偏好环境代号体验浏览器引擎在不修改任何底层数据逻辑的前提下是如何同时处理**语言字典****弹性换行****排版反转 (RTL)** 以及**原生数据格式转换**
</div>
<!-- 顶层控制面板 -->
<div class="controls-panel" dir="ltr">
<label for="env-selector" class="control-label">🌐 模拟操作系统/浏览器偏好环境</label>
<select id="env-selector" v-model="currentLocale" class="env-select">
<option value="zh-CN">🇨🇳 zh-CN (简体中文)</option>
<option value="en-US">🇺🇸 en-US (美国英语)</option>
<option value="de-DE">🇩🇪 de-DE (德国德语) - 关注文字长度爆增</option>
<option value="ar-SA">🇸🇦 ar-SA (沙特阿拉伯语) - 关注 RTL 排版全量反转</option>
</select>
</div>
<div class="lab-container">
<!-- 实验室 1排版与字典 -->
<div class="lab-section">
<h3 class="lab-title" dir="ltr">实战区 1依赖 Flex 面向字典与排版进行重构</h3>
<p class="lab-desc" dir="ltr">
由于我们在 CSS 中使用了弹性的 Flex 布局并且没有写死 `margin-left` 而是用了 `gap` `justify-content`当切换到阿拉伯语时`dir="rtl"` 属性会指挥浏览器**完美镜像反转**整个布局当切换到德语时超长的按钮文字会自动引发弹性换行而不会溢出
</p>
<!-- 核心演示区域响应 RTL -->
<div class="lab-window" :dir="layoutDirection">
<header class="app-nav">
<div class="logo-area">
<span class="logo"></span>
<span class="appName">{{ dictionary[currentLocale].appName }}</span>
</div>
<div class="links-area">
<a href="#">{{ dictionary[currentLocale].navIndex }}</a>
<a href="#">{{ dictionary[currentLocale].navMe }}</a>
</div>
</header>
<main class="app-content">
<div class="alert-box">
{{ dictionary[currentLocale].alertDesc }}
</div>
<div class="action-bar">
<button class="btn btn-primary">{{ dictionary[currentLocale].btnPay }}</button>
<button class="btn btn-ghost">{{ dictionary[currentLocale].btnBack }} <span dir="ltr"></span></button>
</div>
</main>
</div>
</div>
<!-- 实验室 2Intl API 底层转换 -->
<div class="lab-section rtl-ignore-section">
<h3 class="lab-title" dir="ltr">实战区 2使用 Intl 引擎接管数据呈现</h3>
<p class="lab-desc" dir="ltr">
彻底抛弃正则表达式的截取与拼接看看原生的 <code>Intl.NumberFormat</code> <code>Intl.DateTimeFormat</code> 是如何根据我们上方选择的环境代号将下方固定不变的底层二进制数据无缝格式化的
</p>
<div class="data-compare-window" dir="ltr">
<!-- 金钱数据对比 -->
<div class="data-row">
<div class="raw-data">
<span class="data-label">底层内存数值 (Float):</span>
<code class="data-code">1459800.5</code>
</div>
<div class="data-arrow">
引擎介入<br/>
</div>
<div class="intl-data">
<span class="data-label">DOM 最终呈现:</span>
<span class="formatted-val highlight-money">{{ formattedAmount }}</span>
</div>
</div>
<!-- 日期数据对比 -->
<div class="data-row">
<div class="raw-data">
<span class="data-label">底层内存数值 (Timestamp):</span>
<code class="data-code">1757430000000</code>
</div>
<div class="data-arrow">
引擎介入<br/>
</div>
<div class="intl-data">
<span class="data-label">DOM 最终呈现:</span>
<span class="formatted-val highlight-date">{{ formattedDate }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const currentLocale = ref('zh-CN')
// 极其简易的本地化字典
const dictionary = {
'zh-CN': {
appName: '企业云服务',
navIndex: '控制台首页',
navMe: '账户设置',
alertDesc: '您有一个待支付的云服务器实例账单,请在 24 小时内完成续费操作以免停机。',
btnPay: '立即确认并支付款项',
btnBack: '取消并返回'
},
'en-US': {
appName: 'Enterprise Cloud',
navIndex: 'Dashboard',
navMe: 'Account',
alertDesc: 'You have a pending cloud server instance bill. Please renew within 24 hours to avoid suspension.',
btnPay: 'Confirm & Proceed to Pay',
btnBack: 'Cancel'
},
'de-DE': {
appName: 'Unternehmenscloud',
navIndex: 'Startseite',
navMe: 'Kontoeinstellungen',
alertDesc: 'Sie haben eine ausstehende Rechnung für Ihre Cloud-Server-Instanz. Bitte verlängern Sie innerhalb von 24 Stunden, um eine Aussetzung zu vermeiden.',
btnPay: 'Bestätigen und sofortigen Zahlungsvorgang abschließen', // 故意设置的德语超长合成词
btnBack: 'Abbrechen'
},
'ar-SA': {
appName: 'سحابة المؤسسة',
navIndex: 'لوحة القيادة',
navMe: 'إعدادات الحساب',
alertDesc: 'لديك فاتورة معلقة لمثيل خادم السحابة الخاص بك. يرجى التجديد خلال 24 ساعة لتجنب التعليق.',
btnPay: 'تأكيد والمتابعة للدفع',
btnBack: 'إلغاء والعودة'
}
}
// 固定的底层原始数据
const RAW_TIMESTAMP = 1757430000000 // 模拟某个固定时间 2025-09-09(近似)
const RAW_MONEY = 1459800.5
// 计算布局方向 (核心知识点:处理 RTL)
const layoutDirection = computed(() => {
return currentLocale.value === 'ar-SA' ? 'rtl' : 'ltr'
})
// 原生 Intl 货币格式化
const formattedAmount = computed(() => {
let currency = 'CNY'
if (currentLocale.value === 'en-US') currency = 'USD'
if (currentLocale.value === 'de-DE') currency = 'EUR'
if (currentLocale.value === 'ar-SA') currency = 'SAR'
return new Intl.NumberFormat(currentLocale.value, {
style: 'currency',
currency: currency,
minimumFractionDigits: 2
}).format(RAW_MONEY)
})
// 原生 Intl 日期格式化
const formattedDate = computed(() => {
return new Intl.DateTimeFormat(currentLocale.value, {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long'
}).format(new Date(RAW_TIMESTAMP))
})
</script>
<style scoped>
.demo-wrapper {
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
padding: 1.8rem;
margin: 2rem 0;
font-family: var(--vp-font-family-base);
}
.demo-header {
display: flex;
align-items: center;
gap: 0.8rem;
font-size: 1.3rem;
font-weight: 700;
color: var(--vp-c-text-1);
margin-bottom: 1rem;
border-bottom: 2px solid var(--vp-c-divider);
padding-bottom: 0.8rem;
}
.intro-text {
font-size: 0.95rem;
color: var(--vp-c-text-2);
margin-bottom: 1.5rem;
line-height: 1.6;
}
.controls-panel {
background: var(--vp-c-brand-soft);
border: 1px solid var(--vp-c-brand-1);
border-radius: 8px;
padding: 1rem 1.5rem;
margin-bottom: 2rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
@media (min-width: 640px) {
.controls-panel {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
.control-label {
font-weight: 700;
color: var(--vp-c-brand-1);
font-size: 1rem;
}
.env-select {
padding: 0.4rem 1rem;
border-radius: 6px;
border: 2px solid var(--vp-c-brand-1);
background: var(--vp-c-bg);
color: var(--vp-c-text-1);
font-weight: 600;
cursor: pointer;
min-width: 280px;
}
/* 两个实战区的公共样式 */
.lab-container {
display: flex;
flex-direction: column;
gap: 2.5rem;
}
.lab-title {
font-size: 1.15rem;
font-weight: 700;
color: var(--vp-c-text-1);
margin: 0 0 0.8rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.lab-title::before {
content: '';
display: inline-block;
width: 4px;
height: 18px;
background: var(--vp-c-brand-1);
border-radius: 2px;
}
.lab-desc {
font-size: 0.9rem;
color: var(--vp-c-text-2);
line-height: 1.5;
margin-bottom: 1.2rem;
}
.lab-desc code {
color: var(--vp-c-brand-1);
background: var(--vp-c-bg-alt);
padding: 0.1rem 0.3rem;
border-radius: 4px;
}
/* 实验室 1 的内部排版沙盒 */
.lab-window {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 6px 12px rgba(0,0,0,0.08);
background: var(--vp-c-bg);
transition: all 0.3s ease;
}
.app-nav {
display: flex;
justify-content: space-between;
align-items: center;
background: #1e293b;
color: white;
padding: 1rem 1.5rem;
}
.logo-area {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 700;
font-size: 1.1rem;
}
.logo { color: #38bdf8; font-size: 1.3rem; }
.links-area {
display: flex;
gap: 1.5rem;
}
.links-area a {
color: #94a3b8;
text-decoration: none;
font-weight: 600;
font-size: 0.9rem;
transition: 0.2s;
}
.links-area a:hover { color: white; }
.app-content {
padding: 2rem;
background: #f8fafc;
}
.alert-box {
background: #fffbeb;
border-left: 4px solid #f59e0b;
padding: 1.2rem;
color: #b45309;
border-radius: 0 6px 6px 0;
margin-bottom: 1.5rem;
font-size: 0.95rem;
line-height: 1.5;
}
/* RTL 环境下,警告框的彩色边框需要自动镜像到了右侧!这通过 CSS 的逻辑属性来实现最佳!但我们还是直接利用 dir 的流式特性。我们把 border-left 改为 border-inline-start */
[dir="rtl"] .alert-box {
border-left: none;
border-right: 4px solid #f59e0b;
border-radius: 6px 0 0 6px;
}
.action-bar {
display: flex;
gap: 1rem;
flex-wrap: wrap; /* 核心知识点:弹性拉伸保护 */
}
.btn {
padding: 0.7rem 1.2rem;
font-size: 0.9rem;
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
flex: 1; /* 弹性填满剩余空间,对抗超长德语 */
min-width: 150px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn-primary { background: #2563eb; color: white; }
.btn-primary:hover { background: #1d4ed8; }
.btn-ghost { background: #e2e8f0; color: #475569; border: 1px solid #cbd5e1;}
.btn-ghost:hover { background: #cbd5e1; }
.dark .app-content { background: var(--vp-c-bg-alt); }
.dark .alert-box { background: rgba(245, 158, 11, 0.1); color: #fcd34d; }
.dark .btn-ghost { background: var(--vp-c-bg-soft); color: var(--vp-c-text-1); border-color: var(--vp-c-divider); }
.dark .btn-ghost:hover { background: var(--vp-c-divider); }
/* 实验室 2 的转换展示面板 */
.data-compare-window {
display: flex;
flex-direction: column;
gap: 1.2rem;
padding: 1.5rem;
border-radius: 8px;
border: 1px dashed var(--vp-c-divider);
background: var(--vp-c-bg-soft);
}
.data-row {
display: flex;
flex-direction: column;
align-items: stretch;
background: var(--vp-c-bg);
padding: 1.2rem;
border-radius: 6px;
border: 1px solid var(--vp-c-divider);
box-shadow: 0 2px 4px rgba(0,0,0,0.03);
}
@media (min-width: 768px) {
.data-row {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
.raw-data, .intl-data {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.intl-data {
text-align: left;
}
@media (min-width: 768px) {
.intl-data { text-align: right; }
}
.data-label {
font-size: 0.75rem;
font-weight: 700;
color: var(--vp-c-text-3);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.data-code {
font-family: monospace;
background: #1e293b;
color: #a7f3d0;
padding: 0.5rem 0.8rem;
border-radius: 6px;
font-size: 1.05rem;
font-weight: bold;
word-break: break-all;
}
.data-arrow {
color: var(--vp-c-brand-1);
font-weight: 700;
font-size: 0.9rem;
text-align: center;
padding: 1rem;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
@media (max-width: 767px) {
.data-arrow { padding: 0.5rem; }
}
.formatted-val {
font-size: 1.2rem;
font-weight: 800;
letter-spacing: -0.5px;
}
.highlight-money { color: #f59e0b; } /* 显眼的金色/橙色代表金钱 */
.highlight-date { color: #3b82f6; } /* 蓝色代表日期体系 */
.dark .data-code { background: #000; color: #10b981; border: 1px solid #333; }
</style>
@@ -0,0 +1,249 @@
<template>
<div class="demo-wrapper">
<div class="demo-header">Polling / 短轮询交互演示</div>
<div class="network-stage">
<!-- 客户端 -->
<div class="node client">
<div class="node-icon">💻</div>
<div class="node-label">Client</div>
</div>
<!-- 通信链路 -->
<div class="channel">
<div class="message req" :class="{ 'moving-right': isRequesting }">
<span v-if="isRequesting">"有新消息吗?" </span>
</div>
<div class="message res" :class="{ 'moving-left': isResponding }">
<span v-if="isResponding"> "{{ serverResponse }}"</span>
</div>
</div>
<!-- 服务端 -->
<div class="node server">
<div class="node-icon">🖧</div>
<div class="node-label">Server (无状态)</div>
<button class="action-btn" @click="triggerNewMessage" :disabled="hasNewMessage">
制造新消息
</button>
</div>
</div>
<div class="status-panel">
<div class="status-controls">
<button
class="toggle-btn"
:class="{ active: isPolling }"
@click="togglePolling"
>
{{ isPolling ? '⏹ 停止轮询' : '▶ 开始定时轮询 (1s)' }}
</button>
</div>
<div class="log-box">
<div v-for="(log, idx) in logs" :key="idx" class="log-line">
{{ log }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onUnmounted } from 'vue'
const isPolling = ref(false)
const isRequesting = ref(false)
const isResponding = ref(false)
const serverResponse = ref('')
const hasNewMessage = ref(false)
const logs = ref([])
let timer = null
const addLog = (msg) => {
logs.value.unshift(`[${new Date().toLocaleTimeString()}] ${msg}`)
if (logs.value.length > 5) logs.value.pop()
}
const triggerNewMessage = () => {
hasNewMessage.value = true
addLog('服务端:偷偷准备了一条新消息 🤫')
}
const performPoll = () => {
if (isRequesting.value || isResponding.value) return
// 发起请求
isRequesting.value = true
addLog('客户端:发起 HTTP GET 请求...')
setTimeout(() => {
isRequesting.value = false
// 服务端处理并响应
if (hasNewMessage.value) {
serverResponse.value = '有啦!这是刚收到的弹幕'
hasNewMessage.value = false
} else {
serverResponse.value = '没有'
}
isResponding.value = true
addLog(`服务端:响应 "${serverResponse.value}",然后关闭连接。`)
setTimeout(() => {
isResponding.value = false
}, 600)
}, 600)
}
const togglePolling = () => {
if (isPolling.value) {
clearInterval(timer)
isPolling.value = false
addLog('停止定时器。')
} else {
isPolling.value = true
addLog('启动 setInterval() 狂轰乱炸模式。')
performPoll()
timer = setInterval(performPoll, 2500) // 放慢演示速度
}
}
onUnmounted(() => {
if (timer) clearInterval(timer)
})
</script>
<style scoped>
.demo-wrapper {
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
padding: 1.5rem;
margin: 1.5rem 0;
}
.demo-header {
font-weight: bold;
font-size: 0.9rem;
color: var(--vp-c-text-2);
margin-bottom: 1rem;
}
.network-stage {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2rem 1rem;
background: var(--vp-c-bg);
border-radius: 8px;
border: 1px dashed var(--vp-c-divider);
}
.node {
display: flex;
flex-direction: column;
align-items: center;
width: 120px;
}
.node-icon {
font-size: 3rem;
margin-bottom: 0.5rem;
}
.node-label {
font-weight: 600;
font-size: 0.85rem;
color: var(--vp-c-text-1);
}
.action-btn {
margin-top: 0.5rem;
padding: 0.3rem 0.6rem;
font-size: 0.75rem;
background: var(--vp-c-brand);
color: white;
border-radius: 4px;
opacity: 0.9;
}
.action-btn:disabled {
background: var(--vp-c-text-3);
cursor: not-allowed;
}
.channel {
flex-grow: 1;
position: relative;
height: 60px;
border-top: 2px solid transparent;
display: flex;
flex-direction: column;
justify-content: center;
}
.message {
position: absolute;
font-size: 0.75rem;
font-weight: bold;
padding: 0.2rem 0.5rem;
border-radius: 4px;
white-space: nowrap;
opacity: 0;
transition: all 0.6s linear;
}
.message.req {
color: var(--vp-c-brand);
background: var(--vp-c-brand-soft);
top: 0;
left: 0;
}
.message.res {
color: var(--vp-c-warning-1, #d97706);
background: var(--vp-c-warning-soft, rgba(217, 119, 6, 0.1));
bottom: 0;
right: 0;
}
.moving-right {
opacity: 1;
transform: translateX(100px);
}
.moving-left {
opacity: 1;
transform: translateX(-100px);
}
.status-panel {
margin-top: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.toggle-btn {
padding: 0.5rem 1rem;
border: 2px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg);
font-weight: bold;
transition: 0.2s;
}
.toggle-btn.active {
border-color: var(--vp-c-danger, #e74c3c);
color: var(--vp-c-danger, #e74c3c);
background: rgba(231, 76, 60, 0.1);
}
.log-box {
background: #1e293b;
color: #a7f3d0;
padding: 0.8rem;
border-radius: 6px;
font-family: monospace;
font-size: 0.8rem;
min-height: 100px;
}
</style>
@@ -0,0 +1,257 @@
<template>
<div class="demo-wrapper">
<div class="demo-header">Server-Sent Events / 单向流推送演示</div>
<div class="network-stage">
<!-- 客户端 -->
<div class="node client">
<div class="node-icon">📱</div>
<div class="node-label">Client</div>
</div>
<!-- 通信链路带动画的管道 -->
<div class="channel">
<div class="pipe" v-show="isConnected">
<div class="pipe-flow"></div>
</div>
<div
v-for="msg in activeMessages"
:key="msg.id"
class="message-chunk"
>
{{ msg.text }}
</div>
</div>
<!-- 服务端 -->
<div class="node server">
<div class="node-icon"></div>
<div class="node-label">Server (流管道)</div>
<button
v-if="isConnected"
class="action-btn"
@click="pushEvent"
>
推送大盘数据 👇
</button>
</div>
</div>
<div class="status-panel">
<div class="status-controls">
<button
class="toggle-btn"
:class="{ active: isConnected }"
@click="toggleConnection"
>
{{ isConnected ? '⏹ 断开 SSE 连接' : '▶ 建立 SSE 流连接' }}
</button>
</div>
<div class="log-box">
<div v-for="(log, idx) in logs" :key="idx" class="log-line">
{{ log }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onUnmounted } from 'vue'
const isConnected = ref(false)
const activeMessages = ref([])
const logs = ref([])
let msgId = 0
const addLog = (msg) => {
logs.value.unshift(`[${new Date().toLocaleTimeString()}] ${msg}`)
if (logs.value.length > 5) logs.value.pop()
}
const toggleConnection = () => {
if (isConnected.value) {
isConnected.value = false
addLog('客户端:主动断开连接 (Connection: close)')
activeMessages.value = []
} else {
isConnected.value = true
addLog('客户端:发起 HTTP Get, Accept: text/event-stream')
setTimeout(() => {
addLog('服务端:保持连接不断开,随时准备单向下发数据。')
}, 600)
}
}
const pushEvent = () => {
const stockPrices = ['上证指数 3012.3', '茅台 ¥1750', '宁德时代涨停', '中石油跌 -1%']
const randomMsg = stockPrices[Math.floor(Math.random() * stockPrices.length)]
const msgObj = { id: msgId++, text: randomMsg }
activeMessages.value.push(msgObj)
addLog(`服务端:向管道喷射数据 "data: ${randomMsg}\\n\\n"`)
// 模拟动画结束移除
setTimeout(() => {
activeMessages.value = activeMessages.value.filter(m => m.id !== msgObj.id)
addLog(`客户端:触发 onmessage 事件,拿到数据:${randomMsg}`)
}, 1200)
}
onUnmounted(() => {
isConnected.value = false
})
</script>
<style scoped>
.demo-wrapper {
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
padding: 1.5rem;
margin: 1.5rem 0;
}
.demo-header {
font-weight: bold;
font-size: 0.9rem;
color: var(--vp-c-text-2);
margin-bottom: 1rem;
}
.network-stage {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2rem 1rem;
background: var(--vp-c-bg);
border-radius: 8px;
border: 1px dashed var(--vp-c-brand-soft);
position: relative;
}
.node {
display: flex;
flex-direction: column;
align-items: center;
width: 120px;
z-index: 2;
}
.node-icon {
font-size: 3rem;
margin-bottom: 0.5rem;
}
.node-label {
font-weight: 600;
font-size: 0.85rem;
color: var(--vp-c-text-1);
}
.action-btn {
margin-top: 0.5rem;
padding: 0.3rem 0.6rem;
font-size: 0.75rem;
background: var(--vp-c-brand);
color: white;
border-radius: 4px;
opacity: 0.9;
cursor: pointer;
transition: 0.2s;
}
.action-btn:hover {
opacity: 1;
}
.channel {
flex-grow: 1;
position: relative;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
.pipe {
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 8px;
background: var(--vp-c-brand-soft);
transform: translateY(-50%);
border-radius: 4px;
overflow: hidden;
}
.pipe-flow {
width: 100%;
height: 100%;
background: repeating-linear-gradient(
-45deg,
var(--vp-c-brand) 0,
var(--vp-c-brand) 10px,
transparent 10px,
transparent 20px
);
animation: flow 1s linear infinite;
}
@keyframes flow {
from { background-position: 0 0; }
to { background-position: -28px 0; }
}
.message-chunk {
position: absolute;
font-size: 0.75rem;
font-weight: bold;
padding: 0.2rem 0.5rem;
color: white;
background: var(--vp-c-brand-1);
border-radius: 4px;
white-space: nowrap;
animation: moveLeft 1.2s linear forwards;
z-index: 5;
}
@keyframes moveLeft {
0% { right: 0; opacity: 1; transform: scale(1); }
90% { right: 90%; opacity: 1; transform: scale(1.1); }
100% { right: 100%; opacity: 0; transform: scale(0.5); }
}
.status-panel {
margin-top: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.toggle-btn {
padding: 0.5rem 1rem;
border: 2px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg);
font-weight: bold;
transition: 0.2s;
}
.toggle-btn.active {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
background: rgba(59, 130, 246, 0.1);
}
.log-box {
background: #1e293b;
color: #a7f3d0;
padding: 0.8rem;
border-radius: 6px;
font-family: monospace;
font-size: 0.8rem;
min-height: 100px;
}
</style>
@@ -0,0 +1,308 @@
<template>
<div class="demo-wrapper">
<div class="demo-header">WebSocket / 全双工通信演示</div>
<div class="network-stage">
<!-- 客户端 -->
<div class="node client">
<div class="node-icon">🎮</div>
<div class="node-label">Player 1</div>
<button
v-if="isConnected"
class="action-btn client-btn"
@click="sendMessage('client')"
>
发招升龙拳👊
</button>
</div>
<!-- WebSocket 通信链路包含左右两个方向的车道 -->
<div class="channel">
<div class="ws-pipe" v-show="isConnected">
<div class="line top-line"></div>
<div class="line bottom-line"></div>
</div>
<!-- 流动的数据包 -->
<div
v-for="msg in activeMessages"
:key="msg.id"
class="ws-packet"
:class="msg.sender"
>
{{ msg.text }}
</div>
</div>
<!-- 服务端 -->
<div class="node server">
<div class="node-icon">🖥</div>
<div class="node-label">Game Server</div>
<button
v-if="isConnected"
class="action-btn server-btn"
@click="sendMessage('server')"
>
群发敌军出动🛸
</button>
</div>
</div>
<div class="status-panel">
<div class="status-controls">
<button
class="toggle-btn"
:class="{ active: isConnected }"
@click="toggleConnection"
>
{{ isConnected ? '⏹ 挥泪握手告别' : '⚡ Upgrade: websocket 协议质变' }}
</button>
</div>
<div class="log-box">
<div v-for="(log, idx) in logs" :key="idx" class="log-line">
{{ log }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onUnmounted } from 'vue'
const isConnected = ref(false)
const activeMessages = ref([])
const logs = ref([])
let msgId = 0
const addLog = (msg) => {
logs.value.unshift(`[${new Date().toLocaleTimeString()}] ${msg}`)
if (logs.value.length > 5) logs.value.pop()
}
const toggleConnection = () => {
if (isConnected.value) {
isConnected.value = false
addLog('断开 WebSockets 连接 (TCP 四次挥手).')
activeMessages.value = []
} else {
addLog('客户端发 HTTP 请求:Upgrade: websocket, Connection: Upgrade')
setTimeout(() => {
addLog('服务端响应:101 Switching Protocols。神级链路建立完成!')
isConnected.value = true
}, 600)
}
}
const sendMessage = (sender) => {
const text = sender === 'client' ? '【二进制帧】走位左移' : '【JSON帧】Boss 释放技能'
const msgObj = { id: msgId++, text, sender }
activeMessages.value.push(msgObj)
if (sender === 'client') {
addLog(`客户端:瞬间送出 0101 极简格式数据包`)
} else {
addLog(`服务端:瞬间下发最新全局状态帧`)
}
// 模拟极快传输
setTimeout(() => {
activeMessages.value = activeMessages.value.filter(m => m.id !== msgObj.id)
if (sender === 'client') addLog('服务端:光速收到玩家操作响应。')
else addLog('客户端:光速渲染 Boss 动画!')
}, 800)
}
onUnmounted(() => {
isConnected.value = false
})
</script>
<style scoped>
.demo-wrapper {
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
padding: 1.5rem;
margin: 1.5rem 0;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
}
.demo-header {
font-weight: bold;
font-size: 0.9rem;
color: var(--vp-c-text-2);
margin-bottom: 1rem;
}
.network-stage {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2rem 1rem;
background: var(--vp-c-bg);
border-radius: 8px;
border: 1px dashed var(--vp-c-divider);
position: relative;
}
.node {
display: flex;
flex-direction: column;
align-items: center;
width: 130px;
z-index: 2;
}
.node-icon {
font-size: 3rem;
margin-bottom: 0.5rem;
}
.node-label {
font-weight: 600;
font-size: 0.85rem;
color: var(--vp-c-text-1);
}
.action-btn {
margin-top: 0.5rem;
padding: 0.4rem 0.6rem;
font-size: 0.75rem;
font-weight: bold;
border-radius: 6px;
cursor: pointer;
transition: 0.2s;
color: white;
}
.client-btn {
background: #3b82f6; /* Blue for client sending */
}
.client-btn:hover { background: #2563eb; }
.server-btn {
background: #eab308; /* Yellow for server sending */
}
.server-btn:hover { background: #ca8a04; }
.channel {
flex-grow: 1;
position: relative;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
.ws-pipe {
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 40px;
transform: translateY(-50%);
display: flex;
flex-direction: column;
justify-content: space-between;
}
.line {
height: 4px;
width: 100%;
background: repeating-linear-gradient(90deg, #10b981 0px, #10b981 10px, transparent 10px, transparent 20px);
}
.top-line {
animation: slideRight 1s linear infinite;
}
.bottom-line {
animation: slideLeft 1s linear infinite;
}
@keyframes slideRight {
from { background-position: 0 0; }
to { background-position: 20px 0; }
}
@keyframes slideLeft {
from { background-position: 0 0; }
to { background-position: -20px 0; }
}
.ws-packet {
position: absolute;
font-size: 0.7rem;
font-weight: bold;
padding: 0.3rem 0.6rem;
color: white;
border-radius: 20px;
white-space: nowrap;
animation-duration: 0.8s;
animation-timing-function: ease-in-out;
animation-fill-mode: forwards;
z-index: 5;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}
.ws-packet.client {
background: #3b82f6;
top: 0;
left: 0;
animation-name: shootRight;
}
.ws-packet.server {
background: #eab308;
bottom: 0;
right: 0;
animation-name: shootLeft;
}
@keyframes shootRight {
0% { left: 0; opacity: 1; transform: scale(0.8); }
50% { transform: scale(1.1); }
100% { left: 85%; opacity: 0; transform: scale(0.5); }
}
@keyframes shootLeft {
0% { right: 0; opacity: 1; transform: scale(0.8); }
50% { transform: scale(1.1); }
100% { right: 85%; opacity: 0; transform: scale(0.5); }
}
.status-panel {
margin-top: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.toggle-btn {
padding: 0.6rem 1.2rem;
border: 2px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg);
font-weight: bold;
font-size: 0.9rem;
transition: 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.toggle-btn:hover {
transform: translateY(-2px);
}
.toggle-btn.active {
border-color: #10b981;
color: #10b981;
background: rgba(16, 185, 129, 0.1);
}
.log-box {
background: #1e293b;
color: #6ee7b7; /* lighter green for WS logs */
padding: 0.8rem;
border-radius: 6px;
font-family: monospace;
font-size: 0.8rem;
min-height: 100px;
}
</style>