feat: 添加多个附录交互式组件和文档更新
- 添加浏览器前端组件:无障碍访问、国际化、实时通信 - 添加 Transformer 注意力机制系列组件 - 更新 Canvas、数据追踪等现有组件 - 修复 ESLint 变量名冲突问题 - 完善相关附录文档
This commit is contained in:
@@ -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><div></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><input></code>、<code><button></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);
|
||||
}
|
||||
|
||||
/* 如果是 RTL,Flex 的 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>
|
||||
+465
@@ -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>
|
||||
|
||||
<!-- 实验室 2:Intl 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>
|
||||
Reference in New Issue
Block a user