683 lines
16 KiB
Vue
683 lines
16 KiB
Vue
<template>
|
||
<div class="ar-root">
|
||
<div class="ar-layout">
|
||
<div class="ar-left">
|
||
<div class="ar-terminal">
|
||
<div class="term-bar">
|
||
<span class="dot r" /><span class="dot y" /><span class="dot g" />
|
||
<span class="term-title">API 请求演示</span>
|
||
</div>
|
||
<div ref="termEl" class="term-body">
|
||
<div v-for="(l, i) in lines" :key="i" class="t-line">
|
||
<span v-if="l.kind === 'cmd'" class="t-ps">> </span>
|
||
<span :class="'t-' + l.kind">{{ l.text }}</span>
|
||
</div>
|
||
<div class="t-line">
|
||
<span class="t-ps">> </span>
|
||
<span class="t-typing">{{ typing }}<span class="t-cur">▋</span></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="ar-btns">
|
||
<button
|
||
v-for="op in ops"
|
||
:key="op.id"
|
||
:disabled="running || !op.ok()"
|
||
:class="[
|
||
'ar-btn',
|
||
{ 'ar-btn--on': active === op.id, 'ar-btn--dim': !op.ok() }
|
||
]"
|
||
@click="run(op)"
|
||
>
|
||
<code>{{ op.cmd }}</code>
|
||
</button>
|
||
<button
|
||
class="ar-btn ar-btn--reset"
|
||
:disabled="running"
|
||
@click="reset"
|
||
>
|
||
重置
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ar-right">
|
||
<div class="ar-flow">
|
||
<div
|
||
class="flow-col flow-client"
|
||
:class="{ 'flow-highlight': pulseArea === 'client' }"
|
||
>
|
||
<div class="flow-header">
|
||
<span class="flow-icon">💻</span>
|
||
<span class="flow-title">客户端</span>
|
||
<span class="flow-desc">发起请求</span>
|
||
</div>
|
||
<div class="flow-body">
|
||
<div v-if="requestData" class="req-preview">
|
||
<div class="req-line">
|
||
<span class="req-method" :class="requestData.method">{{
|
||
requestData.method
|
||
}}</span>
|
||
<span class="req-url">{{ requestData.url }}</span>
|
||
</div>
|
||
<div v-if="requestData.body" class="req-body">
|
||
<pre>{{ requestData.body }}</pre>
|
||
</div>
|
||
</div>
|
||
<div v-else class="flow-empty">等待请求...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
class="flow-arrow"
|
||
:class="{ 'arrow-lit': pulseArea === 'request' }"
|
||
>
|
||
<code class="arrow-label">HTTP Request</code>
|
||
<span class="arrow-symbol">→</span>
|
||
</div>
|
||
|
||
<div
|
||
class="flow-col flow-server"
|
||
:class="{ 'flow-highlight': pulseArea === 'server' }"
|
||
>
|
||
<div class="flow-header">
|
||
<span class="flow-icon">🖥️</span>
|
||
<span class="flow-title">服务器</span>
|
||
<span class="flow-desc">处理请求</span>
|
||
</div>
|
||
<div class="flow-body">
|
||
<div v-if="serverStatus" class="server-status">
|
||
<span class="status-icon">{{ serverStatus.icon }}</span>
|
||
<span class="status-text">{{ serverStatus.text }}</span>
|
||
</div>
|
||
<div v-else class="flow-empty">等待中...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
class="flow-arrow"
|
||
:class="{ 'arrow-lit': pulseArea === 'response' }"
|
||
>
|
||
<code class="arrow-label">HTTP Response</code>
|
||
<span class="arrow-symbol">←</span>
|
||
</div>
|
||
|
||
<div
|
||
class="flow-col flow-response"
|
||
:class="{ 'flow-highlight': pulseArea === 'response' }"
|
||
>
|
||
<div class="flow-header">
|
||
<span class="flow-icon">📦</span>
|
||
<span class="flow-title">响应</span>
|
||
<span class="flow-desc">返回结果</span>
|
||
</div>
|
||
<div class="flow-body">
|
||
<div v-if="responseData" class="res-preview">
|
||
<div class="res-status" :class="responseData.statusClass">
|
||
{{ responseData.status }}
|
||
</div>
|
||
<div class="res-body">
|
||
<pre>{{ responseData.body }}</pre>
|
||
</div>
|
||
</div>
|
||
<div v-else class="flow-empty">等待响应...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="hint" class="ar-hint">💡 {{ hint }}</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, nextTick } from 'vue'
|
||
|
||
const termEl = ref(null)
|
||
const lines = ref([
|
||
{ kind: 'dim', text: '// 点击下方按钮,模拟不同的 API 请求' }
|
||
])
|
||
const typing = ref('')
|
||
const running = ref(false)
|
||
const active = ref(null)
|
||
const hint = ref('点击命令按钮,观察一次完整的 API 请求-响应流程。')
|
||
const pulseArea = ref(null)
|
||
|
||
const requestData = ref(null)
|
||
const serverStatus = ref(null)
|
||
const responseData = ref(null)
|
||
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||
|
||
const ops = [
|
||
{
|
||
id: 'get-users',
|
||
cmd: 'GET /api/users',
|
||
ok: () => true,
|
||
output: [
|
||
{ kind: 'dim', text: '// 获取用户列表' },
|
||
{ kind: 'grn', text: 'HTTP/1.1 200 OK' },
|
||
{ kind: 'dim', text: 'Content-Type: application/json' },
|
||
{ kind: 'dim', text: '' },
|
||
{ kind: 'grn', text: '{ "code": 0, "data": { "items": [...] } }' }
|
||
],
|
||
hint: 'GET 请求成功!状态码 200 表示请求正常。服务器返回了用户列表数据。',
|
||
do: async () => {
|
||
requestData.value = { method: 'GET', url: '/api/users' }
|
||
pulseArea.value = 'client'
|
||
await sleep(300)
|
||
pulseArea.value = 'request'
|
||
await sleep(300)
|
||
serverStatus.value = { icon: '⚡', text: '查询数据库...' }
|
||
pulseArea.value = 'server'
|
||
await sleep(500)
|
||
serverStatus.value = { icon: '✓', text: '处理完成' }
|
||
pulseArea.value = 'response'
|
||
await sleep(300)
|
||
responseData.value = {
|
||
status: '200 OK',
|
||
statusClass: 'success',
|
||
body: '{\n "code": 0,\n "data": {\n "items": [\n {"id": 1, "name": "张三"},\n {"id": 2, "name": "李四"}\n ]\n }\n}'
|
||
}
|
||
}
|
||
},
|
||
{
|
||
id: 'post-user',
|
||
cmd: 'POST /api/users',
|
||
ok: () => true,
|
||
output: [
|
||
{ kind: 'dim', text: '// 创建新用户' },
|
||
{ kind: 'grn', text: 'HTTP/1.1 201 Created' },
|
||
{ kind: 'dim', text: 'Location: /api/users/3' },
|
||
{ kind: 'dim', text: '' },
|
||
{
|
||
kind: 'grn',
|
||
text: '{ "code": 0, "data": { "id": 3, "name": "王五" } }'
|
||
}
|
||
],
|
||
hint: 'POST 创建成功!状态码 201 表示资源已创建,响应头 Location 指向新资源地址。',
|
||
do: async () => {
|
||
requestData.value = {
|
||
method: 'POST',
|
||
url: '/api/users',
|
||
body: '{\n "name": "王五",\n "email": "wangwu@example.com"\n}'
|
||
}
|
||
pulseArea.value = 'client'
|
||
await sleep(300)
|
||
pulseArea.value = 'request'
|
||
await sleep(300)
|
||
serverStatus.value = { icon: '⚡', text: '验证数据...' }
|
||
pulseArea.value = 'server'
|
||
await sleep(400)
|
||
serverStatus.value = { icon: '⚡', text: '写入数据库...' }
|
||
await sleep(400)
|
||
serverStatus.value = { icon: '✓', text: '创建成功' }
|
||
pulseArea.value = 'response'
|
||
await sleep(300)
|
||
responseData.value = {
|
||
status: '201 Created',
|
||
statusClass: 'success',
|
||
body: '{\n "code": 0,\n "data": {\n "id": 3,\n "name": "王五",\n "email": "wangwu@example.com"\n }\n}'
|
||
}
|
||
}
|
||
},
|
||
{
|
||
id: 'get-404',
|
||
cmd: 'GET /api/users/999',
|
||
ok: () => true,
|
||
output: [
|
||
{ kind: 'dim', text: '// 获取不存在的用户' },
|
||
{ kind: 'red', text: 'HTTP/1.1 404 Not Found' },
|
||
{ kind: 'dim', text: '' },
|
||
{ kind: 'red', text: '{ "code": 10002, "message": "用户不存在" }' }
|
||
],
|
||
hint: '404 错误!请求的资源不存在。客户端应该检查请求的 ID 是否正确。',
|
||
do: async () => {
|
||
requestData.value = { method: 'GET', url: '/api/users/999' }
|
||
pulseArea.value = 'client'
|
||
await sleep(300)
|
||
pulseArea.value = 'request'
|
||
await sleep(300)
|
||
serverStatus.value = { icon: '🔍', text: '查找用户...' }
|
||
pulseArea.value = 'server'
|
||
await sleep(500)
|
||
serverStatus.value = { icon: '✗', text: '未找到' }
|
||
pulseArea.value = 'response'
|
||
await sleep(300)
|
||
responseData.value = {
|
||
status: '404 Not Found',
|
||
statusClass: 'error',
|
||
body: '{\n "code": 10002,\n "message": "用户不存在"\n}'
|
||
}
|
||
}
|
||
},
|
||
{
|
||
id: 'post-401',
|
||
cmd: 'POST /api/orders (无Token)',
|
||
ok: () => true,
|
||
output: [
|
||
{ kind: 'dim', text: '// 未登录尝试下单' },
|
||
{ kind: 'red', text: 'HTTP/1.1 401 Unauthorized' },
|
||
{ kind: 'dim', text: 'WWW-Authenticate: Bearer' },
|
||
{ kind: 'dim', text: '' },
|
||
{ kind: 'red', text: '{ "code": 10018, "message": "请先登录" }' }
|
||
],
|
||
hint: '401 错误!需要身份认证。客户端应该引导用户登录后再重试。',
|
||
do: async () => {
|
||
requestData.value = {
|
||
method: 'POST',
|
||
url: '/api/orders',
|
||
body: '{\n "product_id": "P001",\n "quantity": 2\n}'
|
||
}
|
||
pulseArea.value = 'client'
|
||
await sleep(300)
|
||
pulseArea.value = 'request'
|
||
await sleep(300)
|
||
serverStatus.value = { icon: '🔐', text: '验证身份...' }
|
||
pulseArea.value = 'server'
|
||
await sleep(400)
|
||
serverStatus.value = { icon: '✗', text: '未授权' }
|
||
pulseArea.value = 'response'
|
||
await sleep(300)
|
||
responseData.value = {
|
||
status: '401 Unauthorized',
|
||
statusClass: 'error',
|
||
body: '{\n "code": 10018,\n "message": "请先登录"\n}'
|
||
}
|
||
}
|
||
}
|
||
]
|
||
|
||
async function run(op) {
|
||
if (running.value) return
|
||
running.value = true
|
||
active.value = op.id
|
||
hint.value = ''
|
||
typing.value = ''
|
||
pulseArea.value = null
|
||
requestData.value = null
|
||
serverStatus.value = null
|
||
responseData.value = null
|
||
|
||
for (const ch of op.cmd) {
|
||
typing.value += ch
|
||
await sleep(18)
|
||
}
|
||
await sleep(80)
|
||
lines.value.push({ kind: 'cmd', text: op.cmd })
|
||
typing.value = ''
|
||
await nextTick()
|
||
scroll()
|
||
await sleep(150)
|
||
|
||
for (const l of op.output) {
|
||
lines.value.push(l)
|
||
await nextTick()
|
||
scroll()
|
||
await sleep(50)
|
||
}
|
||
|
||
await op.do()
|
||
await sleep(120)
|
||
hint.value = op.hint
|
||
running.value = false
|
||
setTimeout(() => {
|
||
pulseArea.value = null
|
||
}, 1500)
|
||
}
|
||
|
||
function scroll() {
|
||
if (termEl.value) termEl.value.scrollTop = termEl.value.scrollHeight
|
||
}
|
||
|
||
function reset() {
|
||
lines.value = [{ kind: 'dim', text: '// 点击下方按钮,模拟不同的 API 请求' }]
|
||
active.value = null
|
||
pulseArea.value = null
|
||
hint.value = '点击命令按钮,观察一次完整的 API 请求-响应流程。'
|
||
typing.value = ''
|
||
running.value = false
|
||
requestData.value = null
|
||
serverStatus.value = null
|
||
responseData.value = null
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.ar-root {
|
||
border: 1px solid var(--vp-c-divider);
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
background: var(--vp-c-bg-soft);
|
||
margin: 1rem 0;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.ar-layout {
|
||
display: flex;
|
||
align-items: stretch;
|
||
gap: 0;
|
||
}
|
||
|
||
.ar-left {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.ar-right {
|
||
width: 280px;
|
||
flex-shrink: 0;
|
||
border-left: 1px solid var(--vp-c-divider);
|
||
background: var(--vp-c-bg);
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.ar-layout {
|
||
flex-direction: column;
|
||
}
|
||
.ar-right {
|
||
width: 100%;
|
||
border-left: none;
|
||
border-top: 1px solid var(--vp-c-divider);
|
||
}
|
||
}
|
||
|
||
.ar-terminal {
|
||
background: #141420;
|
||
}
|
||
.term-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 7px 12px;
|
||
background: #1e1e2e;
|
||
}
|
||
.dot {
|
||
width: 11px;
|
||
height: 11px;
|
||
border-radius: 50%;
|
||
}
|
||
.dot.r {
|
||
background: #ff5f57;
|
||
}
|
||
.dot.y {
|
||
background: #febc2e;
|
||
}
|
||
.dot.g {
|
||
background: #28c840;
|
||
}
|
||
.term-title {
|
||
margin-left: 8px;
|
||
font-size: 0.72rem;
|
||
color: #666;
|
||
font-family: monospace;
|
||
}
|
||
|
||
.term-body {
|
||
min-height: 120px;
|
||
max-height: 180px;
|
||
overflow-y: auto;
|
||
overflow-x: auto;
|
||
padding: 0.8rem 1rem;
|
||
font-family: 'Menlo', 'Monaco', monospace;
|
||
font-size: 0.76rem;
|
||
line-height: 1.65;
|
||
color: #cdd6f4;
|
||
}
|
||
.t-line {
|
||
display: flex;
|
||
min-width: min-content;
|
||
}
|
||
.t-ps {
|
||
color: #89b4fa;
|
||
flex-shrink: 0;
|
||
}
|
||
.t-cmd {
|
||
color: #cdd6f4;
|
||
}
|
||
.t-dim {
|
||
color: #585b70;
|
||
}
|
||
.t-grn {
|
||
color: #a6e3a1;
|
||
}
|
||
.t-red {
|
||
color: #f38ba8;
|
||
}
|
||
.t-typing {
|
||
color: #cdd6f4;
|
||
}
|
||
.t-cur {
|
||
animation: blink 1s step-end infinite;
|
||
}
|
||
@keyframes blink {
|
||
0%,
|
||
100% {
|
||
opacity: 1;
|
||
}
|
||
50% {
|
||
opacity: 0;
|
||
}
|
||
}
|
||
|
||
.ar-btns {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
padding: 8px 10px;
|
||
background: #0d0d1a;
|
||
border-top: 1px solid #2a2a3e;
|
||
}
|
||
.ar-btn {
|
||
background: #1e1e2e;
|
||
border: 1px solid #313244;
|
||
border-radius: 5px;
|
||
padding: 4px 9px;
|
||
cursor: pointer;
|
||
transition: border-color 0.2s;
|
||
}
|
||
.ar-btn code {
|
||
font-size: 0.7rem;
|
||
color: #7f849c;
|
||
font-family: monospace;
|
||
white-space: nowrap;
|
||
}
|
||
.ar-btn:hover:not(:disabled) {
|
||
border-color: var(--vp-c-brand);
|
||
}
|
||
.ar-btn--on {
|
||
border-color: var(--vp-c-brand) !important;
|
||
}
|
||
.ar-btn--on code {
|
||
color: var(--vp-c-brand);
|
||
}
|
||
.ar-btn--dim {
|
||
opacity: 0.3;
|
||
cursor: not-allowed;
|
||
}
|
||
.ar-btn--reset {
|
||
background: transparent;
|
||
border-color: #313244;
|
||
margin-left: auto;
|
||
}
|
||
.ar-btn--reset code {
|
||
display: none;
|
||
}
|
||
.ar-btn--reset::after {
|
||
content: '重置';
|
||
font-size: 0.7rem;
|
||
color: #585b70;
|
||
}
|
||
|
||
.ar-flow {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0;
|
||
padding: 10px 12px;
|
||
background: var(--vp-c-bg);
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
.flow-col {
|
||
border: 1.5px solid var(--vp-c-divider);
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 60px;
|
||
transition:
|
||
border-color 0.25s,
|
||
box-shadow 0.25s;
|
||
}
|
||
.flow-col.flow-highlight {
|
||
border-color: var(--vp-c-brand);
|
||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--vp-c-brand) 14%, transparent);
|
||
}
|
||
.flow-client {
|
||
border-left: 4px solid #89b4fa;
|
||
}
|
||
.flow-server {
|
||
border-left: 4px solid #f9e2af;
|
||
}
|
||
.flow-response {
|
||
border-left: 4px solid #a6e3a1;
|
||
}
|
||
|
||
.flow-header {
|
||
padding: 6px 8px;
|
||
background: var(--vp-c-bg-alt);
|
||
border-bottom: 1px solid var(--vp-c-divider);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.flow-icon {
|
||
font-size: 0.9rem;
|
||
}
|
||
.flow-title {
|
||
font-weight: 700;
|
||
font-size: 0.8rem;
|
||
color: var(--vp-c-text-1);
|
||
}
|
||
.flow-desc {
|
||
font-size: 0.7rem;
|
||
color: var(--vp-c-text-3);
|
||
margin-left: auto;
|
||
}
|
||
|
||
.flow-body {
|
||
padding: 8px 10px;
|
||
flex: 1;
|
||
min-height: 48px;
|
||
}
|
||
.flow-empty {
|
||
font-size: 0.75rem;
|
||
color: var(--vp-c-text-3);
|
||
font-style: italic;
|
||
}
|
||
|
||
.req-preview,
|
||
.res-preview {
|
||
font-size: 0.72rem;
|
||
}
|
||
.req-line {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-bottom: 6px;
|
||
}
|
||
.req-method {
|
||
padding: 2px 6px;
|
||
border-radius: 3px;
|
||
font-weight: 700;
|
||
font-size: 0.68rem;
|
||
}
|
||
.req-method.GET {
|
||
background: #22c55e22;
|
||
color: #22c55e;
|
||
}
|
||
.req-method.POST {
|
||
background: #3b82f622;
|
||
color: #3b82f6;
|
||
}
|
||
.req-url {
|
||
font-family: monospace;
|
||
color: var(--vp-c-text-1);
|
||
}
|
||
.req-body pre,
|
||
.res-body pre {
|
||
margin: 0;
|
||
padding: 6px;
|
||
background: var(--vp-c-bg-soft);
|
||
border-radius: 4px;
|
||
font-size: 0.65rem;
|
||
line-height: 1.4;
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.server-status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 0.8rem;
|
||
}
|
||
.status-icon {
|
||
font-size: 1rem;
|
||
}
|
||
.status-text {
|
||
color: var(--vp-c-text-2);
|
||
}
|
||
|
||
.res-status {
|
||
display: inline-block;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
font-weight: 700;
|
||
font-size: 0.7rem;
|
||
margin-bottom: 6px;
|
||
}
|
||
.res-status.success {
|
||
background: #22c55e22;
|
||
color: #22c55e;
|
||
}
|
||
.res-status.error {
|
||
background: #ef444422;
|
||
color: #ef4444;
|
||
}
|
||
|
||
.flow-arrow {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
padding: 4px 0;
|
||
opacity: 0.3;
|
||
transition: opacity 0.3s;
|
||
}
|
||
.flow-arrow.arrow-lit {
|
||
opacity: 1;
|
||
}
|
||
.arrow-label {
|
||
font-size: 0.65rem;
|
||
font-family: monospace;
|
||
color: var(--vp-c-brand);
|
||
white-space: nowrap;
|
||
}
|
||
.arrow-symbol {
|
||
font-size: 0.9rem;
|
||
color: var(--vp-c-brand);
|
||
}
|
||
|
||
.ar-hint {
|
||
padding: 10px 12px;
|
||
background: var(--vp-c-bg-alt);
|
||
border-top: 1px solid var(--vp-c-divider);
|
||
font-size: 0.82rem;
|
||
color: var(--vp-c-text-2);
|
||
line-height: 1.5;
|
||
}
|
||
</style>
|