docs(api-intro): rewrite API introduction with interactive examples and clearer explanations

- Restructure content with more engaging metaphors and practical examples
- Add simplified interactive components to demonstrate key concepts
- Improve readability with better organization and visual aids
- Update terminology to be more beginner-friendly
- Include real-world API usage scenarios
This commit is contained in:
sanbuphy
2026-01-20 08:51:04 +08:00
parent 6806f05deb
commit 389c9126a1
9 changed files with 2008 additions and 2820 deletions
@@ -1,447 +1,222 @@
<!--
ApiConceptDemo.vue
参考 ide-intro 虚拟 UI + 先玩再讲风格
目标用一个简单的分类小游戏讲清楚 API 到底要写清楚什么
以及哪些是你不用管的内部细节
目标互动演示 API 必须写清楚的 4 个要点
-->
<template>
<div class="wrap">
<div class="head">
<div class="title">API 到底是什么先玩 10 </div>
<div class="sub">
下面有一些卡片请你把它们分成两类<b>必须写清楚</b> vs
<b>不用写给别人看</b>
<div class="demo">
<div class="title">📋 API 必须写清楚的 4 件事</div>
<p class="subtitle">点每一项看看是什么意思</p>
<div class="cards">
<div
v-for="item in items"
:key="item.id"
:class="['card', { active: selectedId === item.id }]"
@click="select(item.id)"
>
<div class="card-icon">{{ item.icon }}</div>
<div class="card-title">{{ item.title }}</div>
<div class="card-hint">{{ item.hint }}</div>
</div>
</div>
<div class="arena">
<div class="cards">
<div class="cardsTitle">卡片池</div>
<div class="cardGrid">
<button
v-for="c in pool"
:key="c.id"
class="card"
@click="pick(c.id)"
>
<div class="cardTop">
<span class="tag" :class="c.type">{{ c.typeLabel }}</span>
</div>
<div class="cardText">{{ c.text }}</div>
</button>
</div>
<div class="muted">
提示API
的核心就是怎么用所以入口要填什么会返回什么失败怎么说
</div>
<div class="detail" v-if="selected">
<div class="detail-header">
<span class="detail-icon">{{ selected.icon }}</span>
<span class="detail-title">{{ selected.title }}</span>
</div>
<div class="bins">
<div class="bin must">
<div class="binHead">
<div class="binTitle">必须写清楚</div>
<div class="binHint">不写清楚就会用错</div>
</div>
<div class="drop">
<div v-if="must.length === 0" class="empty">点卡片把它放进来</div>
<button v-for="id in must" :key="id" class="chip" @click="undo(id)">
{{ byId(id).text }}
<span class="x">×</span>
</button>
</div>
<div class="detail-body">
<div class="detail-desc">{{ selected.desc }}</div>
<div class="detail-example">
<strong>例子</strong>
<code>{{ selected.example }}</code>
</div>
<div class="bin internal">
<div class="binHead">
<div class="binTitle">不用写给别人看</div>
<div class="binHint">这是内部实现换了也不影响怎么用</div>
</div>
<div class="drop">
<div v-if="internal.length === 0" class="empty">
点卡片把它放进来
</div>
<button
v-for="id in internal"
:key="id"
class="chip"
@click="undo(id)"
>
{{ byId(id).text }}
<span class="x">×</span>
</button>
</div>
</div>
</div>
</div>
<div class="bar">
<button class="btn" @click="check">检查一下</button>
<button class="ghost" @click="reset">重来</button>
<div class="score">
<span
>正确<b>{{ score.ok }}</b></span
>
<span
>错误<b>{{ score.bad }}</b></span
>
</div>
</div>
<div class="result" v-if="checked">
<div class="resultTitle">结论新手版一句话</div>
<div class="resultText">
API =
你告诉别人这个按钮怎么按<b>入口在哪</b><b>要填什么</b><b>会得到什么</b><b>失败怎么说</b>
</div>
</div>
</div>
</template>
<script setup>
import { computed, reactive, ref } from 'vue'
import { ref, computed } from 'vue'
const all = [
// must
const selectedId = ref('entry')
const items = [
{
id: 'where',
bucket: 'must',
type: 'must',
typeLabel: '必填脑子',
text: '入口在哪(网址 / 函数名'
id: 'entry',
icon: '📍',
title: '入口在哪',
hint: '网址 / 函数名',
desc: '你要调用的"按钮"在哪里。是 HTTP 网址,还是代码里的函数名',
example: 'GET /api/users/{id}'
},
{
id: 'input',
bucket: 'must',
type: 'must',
typeLabel: '必填脑子',
text: '要填什么(需要哪些信息)'
id: 'params',
icon: '📝',
title: '要填什么',
hint: '需要哪些参数',
desc: '调用这个 API 时,你需要提供哪些信息?哪些是必填的,哪些是可选的?',
example: 'id(必填)、page(可选)'
},
{
id: 'output',
bucket: 'must',
type: 'must',
typeLabel: '必填脑子',
text: '会拿到什么(成功的结果)'
id: 'response',
icon: '',
title: '会得到什么',
hint: '返回什么数据',
desc: '成功的时候,API 会返回什么数据?有哪些字段,分别代表什么意思?',
example: '{ id, name, email }'
},
{
id: 'error',
bucket: 'must',
type: 'must',
typeLabel: '必填脑子',
text: '失败怎么说(提示/原因)'
},
// internal
{
id: 'db',
bucket: 'internal',
type: 'internal',
typeLabel: '内部',
text: '数据库表怎么设计'
},
{
id: 'topo',
bucket: 'internal',
type: 'internal',
typeLabel: '内部',
text: '服务有几个机器/怎么部署'
},
{
id: 'perf',
bucket: 'internal',
type: 'internal',
typeLabel: '内部',
text: '内部怎么做性能优化'
icon: '⚠️',
title: '失败怎么说',
hint: '错误提示',
desc: '调用失败的时候会返回什么错误信息?你应该怎么处理这些错误?',
example: '401 没权限、404 找不到、429 太频繁'
}
]
const must = ref([])
const internal = ref([])
const checked = ref(false)
const score = reactive({ ok: 0, bad: 0 })
const selected = computed(() => items.find(i => i.id === selectedId.value))
const used = computed(() => new Set([...must.value, ...internal.value]))
const pool = computed(() => all.filter((c) => !used.value.has(c.id)))
function byId(id) {
return all.find((x) => x.id === id) || { id, text: id }
}
function pick(id) {
// 简化交互:按顺序放进“必须写清楚”,再点一次放进“内部”
// 这样新手不需要理解拖拽,也不需要想“放哪儿”:玩起来更顺。
// 如果想放“内部”,可以先把它放错,再点 chip 撤回重新放。
if (must.value.length <= internal.value.length) {
must.value.push(id)
} else {
internal.value.push(id)
}
checked.value = false
}
function undo(id) {
must.value = must.value.filter((x) => x !== id)
internal.value = internal.value.filter((x) => x !== id)
checked.value = false
}
function reset() {
must.value = []
internal.value = []
checked.value = false
score.ok = 0
score.bad = 0
}
function check() {
let ok = 0
let bad = 0
for (const id of must.value) {
byId(id).bucket === 'must' ? (ok += 1) : (bad += 1)
}
for (const id of internal.value) {
byId(id).bucket === 'internal' ? (ok += 1) : (bad += 1)
}
score.ok = ok
score.bad = bad
checked.value = true
function select(id) {
selectedId.value = id
}
</script>
<style scoped>
.wrap {
.demo {
border: 1px solid var(--vp-c-divider);
border-radius: 14px;
border-radius: 12px;
padding: 20px;
background: var(--vp-c-bg-soft);
padding: 16px;
}
.head {
display: flex;
flex-direction: column;
gap: 6px;
margin: 16px 0;
}
.title {
font-weight: 900;
font-size: 16px;
font-size: 18px;
font-weight: bold;
margin-bottom: 8px;
color: var(--vp-c-text-1);
}
.sub {
font-size: 13px;
.subtitle {
color: var(--vp-c-text-2);
line-height: 1.6;
margin-bottom: 20px;
}
.arena {
margin-top: 12px;
.cards {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.cards,
.bin {
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg);
padding: 12px;
}
.cardsTitle {
font-weight: 900;
font-size: 13px;
color: var(--vp-c-text-1);
}
.cardGrid {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-bottom: 20px;
}
.card {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
color: var(--vp-c-text-1);
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 12px;
padding: 10px 12px;
text-align: left;
padding: 16px;
cursor: pointer;
transition: all 0.2s;
}
.card:hover {
border-color: var(--vp-c-brand-1);
transform: translateY(-2px);
}
.cardTop {
display: flex;
justify-content: flex-end;
.card.active {
border-color: var(--vp-c-brand-1);
background: #f0f9ff;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.tag {
font-size: 12px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
color: var(--vp-c-text-2);
.card-icon {
font-size: 32px;
margin-bottom: 8px;
}
.tag.must {
border-color: color-mix(in srgb, #22c55e 40%, var(--vp-c-divider));
background: color-mix(in srgb, #22c55e 12%, var(--vp-c-bg));
color: #166534;
}
.tag.internal {
border-color: color-mix(in srgb, #f59e0b 45%, var(--vp-c-divider));
background: color-mix(in srgb, #f59e0b 14%, var(--vp-c-bg));
color: #92400e;
}
.cardText {
margin-top: 8px;
font-size: 13px;
font-weight: 900;
line-height: 1.4;
}
.bins {
display: grid;
gap: 12px;
}
.binHead {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: baseline;
}
.binTitle {
font-weight: 900;
font-size: 13px;
.card-title {
font-weight: bold;
font-size: 16px;
margin-bottom: 4px;
color: var(--vp-c-text-1);
}
.binHint {
font-size: 12px;
.card-hint {
font-size: 13px;
color: var(--vp-c-text-2);
}
.drop {
margin-top: 10px;
min-height: 120px;
border: 1px dashed var(--vp-c-divider);
.detail {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-brand-1);
border-radius: 12px;
padding: 10px;
padding: 20px;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.detail-header {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
align-content: flex-start;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.detail-icon {
font-size: 28px;
}
.detail-title {
font-size: 20px;
font-weight: bold;
color: var(--vp-c-text-1);
}
.detail-body {
line-height: 1.8;
}
.detail-desc {
font-size: 15px;
color: var(--vp-c-text-1);
margin-bottom: 16px;
}
.detail-example {
background: var(--vp-c-bg-soft);
padding: 16px;
border-radius: 8px;
border-left: 4px solid var(--vp-c-brand-1);
}
.empty {
font-size: 12px;
color: var(--vp-c-text-3);
}
.chip {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
.detail-example strong {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: var(--vp-c-text-1);
border-radius: 999px;
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
display: inline-flex;
gap: 8px;
align-items: center;
}
.x {
font-weight: 900;
color: var(--vp-c-text-3);
}
.bar {
margin-top: 12px;
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.btn {
border: 1px solid var(--vp-c-brand-1);
background: var(--vp-c-brand-1);
color: #fff;
border-radius: 10px;
padding: 8px 12px;
font-weight: 900;
cursor: pointer;
}
.ghost {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
color: var(--vp-c-text-1);
border-radius: 10px;
padding: 8px 12px;
font-weight: 900;
cursor: pointer;
}
.score {
margin-left: auto;
display: flex;
gap: 12px;
color: var(--vp-c-text-2);
font-size: 12px;
}
.result {
margin-top: 12px;
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg);
.detail-example code {
display: block;
background: #1e293b;
color: #e2e8f0;
padding: 12px;
}
.resultTitle {
font-weight: 900;
border-radius: 6px;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 13px;
color: var(--vp-c-text-1);
}
.resultText {
margin-top: 8px;
font-size: 12px;
color: var(--vp-c-text-2);
line-height: 1.6;
}
.muted {
margin-top: 10px;
font-size: 12px;
color: var(--vp-c-text-2);
line-height: 1.6;
}
@media (max-width: 720px) {
.arena {
grid-template-columns: 1fr;
}
.cardGrid {
grid-template-columns: 1fr;
}
.score {
margin-left: 0;
}
white-space: pre-wrap;
}
</style>
@@ -1,396 +1,194 @@
<!--
ApiDocumentDemo.vue
参考 ide-intro 虚拟界面 + 点击探索风格
目标让新手学会看 API 文档的 3 个重点入口在哪 / 要填什么 / 会得到什么
ApiDocumentDemo.vue - 简化版
目标用简单的示例展示如何阅读 API 文档
-->
<template>
<div class="wrap">
<div class="head">
<div class="title">怎么读 API 文档像找按钮一样找</div>
<div class="sub">
任务请在下面的假文档依次点出<b>入口</b><b>要填什么</b><b>会得到什么</b>
<div class="demo">
<div class="title">📖 怎么读 API 文档</div>
<p class="subtitle">找到这 3 个信息就够了</p>
<div class="doc-example">
<div class="doc-header">API 文档示例</div>
<div class="doc-body">
<div class="section">
<div class="section-title">📍 1 入口在哪</div>
<div class="section-content">
<code>GET /api/users/{id}</code>
<p class="hint">这就是你要调用的"按钮"</p>
</div>
</div>
<div class="section">
<div class="section-title">📝 2 要填什么</div>
<div class="section-content">
<div class="param">
<span class="param-name">id</span>
<span class="param-desc">用户编号必填</span>
</div>
<p class="hint">你需要提供这个参数</p>
</div>
</div>
<div class="section">
<div class="section-title"> 3 会得到什么</div>
<div class="section-content">
<pre><code>{
"id": "123",
"name": "张三",
"email": "zhang@example.com"
}</code></pre>
<p class="hint">成功时返回的数据格式</p>
</div>
</div>
</div>
</div>
<div class="game">
<div class="doc">
<div class="docBar">
<span class="dot red" />
<span class="dot yellow" />
<span class="dot green" />
<span class="docTitle">API 文档示例</span>
</div>
<div class="docBody">
<button
class="block"
:class="{ hit: hits.entry }"
@click="hit('entry')"
>
<div class="blockK">入口</div>
<div class="blockV">GET /v1/users/{id}</div>
<div class="blockHint">你要按哪个按钮</div>
</button>
<button
class="block"
:class="{ hit: hits.input }"
@click="hit('input')"
>
<div class="blockK">要填什么</div>
<div class="blockV">id用户编号</div>
<div class="blockHint">你要告诉它什么</div>
</button>
<button
class="block"
:class="{ hit: hits.output }"
@click="hit('output')"
>
<div class="blockK">会得到什么</div>
<div class="blockV">{ id, name }</div>
<div class="blockHint">成功时给你的结果</div>
</button>
<button
class="block gray"
:class="{ hit: hits.fail }"
@click="hit('fail')"
>
<div class="blockK">失败会怎样常见</div>
<div class="blockV">没钥匙 / 找不到 / 太频繁</div>
<div class="blockHint">你要能看懂失败原因</div>
</button>
</div>
</div>
<div class="side">
<div class="task">
<div class="taskTitle">你要找的 3 个重点</div>
<div class="taskList">
<div :class="['taskItem', hits.entry && 'done']">
入口在哪入口
</div>
<div :class="['taskItem', hits.input && 'done']">
要填什么要填什么
</div>
<div :class="['taskItem', hits.output && 'done']">
会得到什么会得到什么
</div>
</div>
<div class="muted">你只要先会这三件事就能开始用 API </div>
</div>
<div class="explain" v-if="last">
<div class="explainTitle">你刚刚点的是{{ labelOf(last) }}</div>
<div class="explainText">{{ explainOf(last) }}</div>
</div>
<div class="actions">
<button class="btn" @click="autoWin">一键帮我找对</button>
<button class="ghost" @click="reset">重置</button>
</div>
<div class="win" v-if="won">
<div class="winTitle">完成</div>
<div class="winText">
你已经会读 80% API 文档了入口 / 要填 / 会得到
</div>
</div>
</div>
<div class="tips">
<p><strong>💡 小贴士</strong></p>
<ul>
<li>先确认这个 API 是不是你需要的</li>
<li>再看要填什么参数必填 vs 可选</li>
<li>最后看返回什么失败会怎样</li>
</ul>
</div>
</div>
</template>
<script setup>
import { computed, reactive, ref } from 'vue'
const hits = reactive({
entry: false,
input: false,
output: false,
fail: false
})
const last = ref('')
const won = computed(() => hits.entry && hits.input && hits.output)
function hit(key) {
hits[key] = true
last.value = key
}
function reset() {
hits.entry = false
hits.input = false
hits.output = false
hits.fail = false
last.value = ''
}
function autoWin() {
hits.entry = true
hits.input = true
hits.output = true
last.value = 'output'
}
function labelOf(key) {
if (key === 'entry') return '入口'
if (key === 'input') return '要填什么'
if (key === 'output') return '会得到什么'
if (key === 'fail') return '失败会怎样'
return key
}
function explainOf(key) {
if (key === 'entry') {
return '入口就是“按钮名字”。你要按哪个按钮,先找到它。'
}
if (key === 'input') {
return '要填什么 = 你需要提供的信息。比如 id、页码、搜索词。'
}
if (key === 'output') {
return '会得到什么 = 成功时返回的数据。你要关心字段有什么、有没有可能为空。'
}
if (key === 'fail') {
return '失败会怎样 = 你要能看懂失败原因,好给用户提示/重试。'
}
return ''
}
// 无需脚本逻辑
</script>
<style scoped>
.wrap {
.demo {
border: 1px solid var(--vp-c-divider);
border-radius: 14px;
border-radius: 12px;
padding: 20px;
background: var(--vp-c-bg-soft);
padding: 16px;
}
.head {
display: flex;
flex-direction: column;
gap: 6px;
margin: 16px 0;
}
.title {
font-weight: 900;
font-size: 16px;
font-size: 18px;
font-weight: bold;
margin-bottom: 8px;
color: var(--vp-c-text-1);
}
.sub {
font-size: 13px;
.subtitle {
color: var(--vp-c-text-2);
line-height: 1.6;
margin-bottom: 16px;
}
.game {
margin-top: 12px;
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 12px;
}
.doc {
border: 1px solid var(--vp-c-divider);
border-radius: 14px;
.doc-example {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 12px;
overflow: hidden;
margin-bottom: 16px;
}
.docBar {
display: flex;
gap: 6px;
align-items: center;
padding: 10px 12px;
border-bottom: 1px solid var(--vp-c-divider);
.doc-header {
background: var(--vp-c-bg-soft);
}
.dot {
width: 10px;
height: 10px;
border-radius: 999px;
}
.dot.red {
background: #ef4444;
}
.dot.yellow {
background: #f59e0b;
}
.dot.green {
background: #22c55e;
}
.docTitle {
margin-left: 6px;
font-size: 12px;
font-weight: 900;
color: var(--vp-c-text-2);
}
.docBody {
padding: 12px;
display: grid;
gap: 10px;
}
.block {
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg-soft);
padding: 10px 12px;
text-align: left;
cursor: pointer;
color: var(--vp-c-text-1);
}
.block:hover {
border-color: var(--vp-c-brand-1);
}
.block.gray {
background: var(--vp-c-bg);
}
.block.hit {
border-color: #22c55e;
box-shadow: 0 0 0 3px color-mix(in srgb, #22c55e 18%, transparent);
}
.blockK {
font-size: 12px;
color: var(--vp-c-text-2);
font-weight: 900;
}
.blockV {
margin-top: 6px;
padding: 12px 16px;
font-weight: bold;
font-size: 14px;
font-weight: 900;
border-bottom: 1px solid var(--vp-c-divider);
}
.blockHint {
margin-top: 6px;
font-size: 12px;
color: var(--vp-c-text-2);
.doc-body {
padding: 16px;
}
.side {
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg);
.section {
margin-bottom: 16px;
padding: 12px;
display: grid;
gap: 12px;
}
.taskTitle {
font-weight: 900;
font-size: 13px;
color: var(--vp-c-text-1);
}
.taskList {
margin-top: 10px;
display: grid;
gap: 8px;
}
.taskItem {
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg-soft);
padding: 8px 10px;
font-size: 12px;
border-radius: 8px;
}
.section:last-child {
margin-bottom: 0;
}
.section-title {
font-weight: bold;
font-size: 14px;
margin-bottom: 8px;
color: var(--vp-c-text-1);
font-weight: 800;
}
.taskItem.done {
border-color: #22c55e;
background: color-mix(in srgb, #22c55e 12%, var(--vp-c-bg));
.section-content {
margin-left: 0;
}
.muted {
margin-top: 10px;
font-size: 12px;
color: var(--vp-c-text-2);
line-height: 1.6;
}
.explain {
border: 1px dashed var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg-soft);
padding: 10px 12px;
}
.explainTitle {
font-weight: 900;
code {
background: #1e293b;
color: #e2e8f0;
padding: 4px 8px;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 13px;
color: var(--vp-c-text-1);
}
.explainText {
.hint {
margin-top: 8px;
font-size: 12px;
color: var(--vp-c-text-2);
line-height: 1.6;
font-style: italic;
}
.actions {
.param {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.btn {
border: 1px solid var(--vp-c-brand-1);
background: var(--vp-c-brand-1);
color: #fff;
border-radius: 10px;
padding: 8px 12px;
font-weight: 900;
cursor: pointer;
}
.ghost {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
color: var(--vp-c-text-1);
border-radius: 10px;
padding: 8px 12px;
font-weight: 900;
cursor: pointer;
}
.win {
border: 1px solid #22c55e;
border-radius: 12px;
background: color-mix(in srgb, #22c55e 12%, var(--vp-c-bg));
padding: 10px 12px;
}
.winTitle {
font-weight: 900;
color: #166534;
font-size: 13px;
}
.winText {
margin-top: 8px;
.param-name {
background: #dbeafe;
color: #1e40af;
padding: 4px 8px;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
color: #166534;
line-height: 1.6;
font-weight: bold;
}
@media (max-width: 720px) {
.game {
grid-template-columns: 1fr;
}
.param-desc {
font-size: 13px;
color: var(--vp-c-text-1);
}
pre {
background: #1e293b;
border-radius: 6px;
padding: 12px;
overflow-x: auto;
margin: 8px 0;
}
pre code {
background: transparent;
padding: 0;
color: #e2e8f0;
font-size: 12px;
line-height: 1.5;
}
.tips {
background: var(--vp-c-bg);
padding: 16px;
border-radius: 8px;
font-size: 14px;
line-height: 1.6;
color: var(--vp-c-text-2);
}
.tips ul {
margin: 8px 0 0 20px;
}
.tips li {
margin: 4px 0;
}
</style>
@@ -1,438 +1,337 @@
<!--
ApiMethodDemo.vue
参考 ide-intro 虚拟环境演示风格
目标 GET/POST/DELETE 讲成//三个按钮并用可视化列表展示效果
注意这是选读内容但组件本身要足够好玩足够直观
目标清晰展示各种 HTTP 方法的含义和使用场景
-->
<template>
<div class="wrap">
<div class="head">
<div class="title">三个按钮GET/ POST/ DELETE</div>
<div class="sub">你不用记英文先玩点按钮看看列表怎么变</div>
</div>
<div class="demo">
<div class="title">🔍 HTTP 方法GETPOSTPUTDELETE 是什么</div>
<p class="subtitle">把它们想象成对数据的"操作方式"</p>
<div class="board">
<div class="left">
<div class="panelTitle">小列表服务器里有的东西</div>
<div class="list">
<div v-if="items.length === 0" class="empty">空的</div>
<div v-for="it in items" :key="it.id" class="row">
<div class="pillId">#{{ it.id }}</div>
<div class="name">{{ it.name }}</div>
<div class="methods-grid">
<div class="method-card get">
<div class="method-badge">GET</div>
<div class="method-title">📖 获取查询</div>
<div class="method-desc">
<p><strong>只看不改</strong> - 从服务器获取数据</p>
<div class="method-examples">
<div class="example-item"> 查询用户信息</div>
<div class="example-item"> 搜索商品</div>
<div class="example-item"> 获取文章列表</div>
</div>
</div>
<div class="mini">
<span class="miniK">提示</span>
<span class="miniV">你可以一直点GET列表不会变</span>
<div class="method-tip">
💡 可以安全重试不会改变服务器数据
</div>
</div>
<div class="right">
<div class="panelTitle">按按钮模拟 API</div>
<div class="btnRow">
<button class="btn get" :disabled="busy" @click="getList">
GET
</button>
<button class="btn post" :disabled="busy" @click="addOne">
POST
</button>
<button class="btn del" :disabled="busy" @click="removeOne">
DELETE
</button>
</div>
<div class="inputs">
<label class="field">
<span class="k">要加什么</span>
<input v-model="newName" class="input" placeholder="随便写个名字" />
</label>
</div>
<div class="result">
<div class="resultTitle">返回结果</div>
<div v-if="!last" class="muted">点一个按钮试试</div>
<div v-else class="resBox" :class="{ ok: last.ok, bad: !last.ok }">
<div class="badge">{{ last.ok ? '成功' : '失败' }}</div>
<div class="text">{{ last.text }}</div>
<div class="method-card post">
<div class="method-badge">POST</div>
<div class="method-title"> 创建新增</div>
<div class="method-desc">
<p><strong>新建数据</strong> - 在服务器创建新资源</p>
<div class="method-examples">
<div class="example-item"> 创建新用户</div>
<div class="example-item"> 提交订单</div>
<div class="example-item"> 发表评论</div>
</div>
</div>
<div class="method-tip">
不能随意重试可能会重复创建
</div>
</div>
<div class="foot">
<div class="stat">
<span class="statK">你点了</span>
<span class="statV">{{ clicks }}</span>
<span class="statK"></span>
<div class="method-card put">
<div class="method-badge">PUT</div>
<div class="method-title"> 更新替换</div>
<div class="method-desc">
<p><strong>整体替换</strong> - 用新数据完全替换旧数据</p>
<div class="method-examples">
<div class="example-item"> 修改用户全部信息</div>
<div class="example-item"> 更换文章全部内容</div>
</div>
<button class="ghost" :disabled="busy" @click="reset">重置</button>
</div>
<div class="method-tip">
会覆盖整个资源需要提供完整数据
</div>
</div>
<div class="method-card patch">
<div class="method-badge">PATCH</div>
<div class="method-title">🔧 修改部分</div>
<div class="method-desc">
<p><strong>局部更新</strong> - 只修改资源的部分字段</p>
<div class="method-examples">
<div class="example-item"> 只修改用户昵称</div>
<div class="example-item"> 更新文章标题</div>
</div>
</div>
<div class="method-tip">
💡 只传需要修改的字段更灵活
</div>
</div>
<div class="method-card delete">
<div class="method-badge">DELETE</div>
<div class="method-title">🗑 删除</div>
<div class="method-desc">
<p><strong>移除数据</strong> - 从服务器删除资源</p>
<div class="method-examples">
<div class="example-item"> 删除用户</div>
<div class="example-item"> 取消订单</div>
<div class="example-item"> 删除评论</div>
</div>
</div>
<div class="method-tip">
操作不可逆删除后无法恢复
</div>
</div>
</div>
<div class="one">
<div class="oneTitle">一句话总结</div>
<div class="oneText">
GET 通常只是拿数据POST/DELETE 改数据所以网络抖动时重试 POST
要更小心
</div>
<div class="comparison-table">
<div class="table-title">📊 快速对比</div>
<table>
<thead>
<tr>
<th>方法</th>
<th>操作</th>
<th>是否会改数据</th>
<th>能否重试</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="badge get">GET</span></td>
<td>查询</td>
<td> </td>
<td> 可以</td>
</tr>
<tr>
<td><span class="badge post">POST</span></td>
<td>创建</td>
<td> </td>
<td> 不建议</td>
</tr>
<tr>
<td><span class="badge put">PUT</span></td>
<td>替换</td>
<td> </td>
<td> 不建议</td>
</tr>
<tr>
<td><span class="badge patch">PATCH</span></td>
<td>修改</td>
<td> </td>
<td> 不建议</td>
</tr>
<tr>
<td><span class="badge delete">DELETE</span></td>
<td>删除</td>
<td> </td>
<td> 不建议</td>
</tr>
</tbody>
</table>
</div>
<div class="tips">
<p><strong>💡 新手建议</strong></p>
<ul>
<li>先学会 <strong>GET</strong>查询 <strong>POST</strong>创建就够用了</li>
<li>PUT/PATCH/DELETE 可以慢慢学理解原理更重要</li>
<li>实际开发中GET POST 占了 80% 的使用场景</li>
</ul>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const busy = ref(false)
const clicks = ref(0)
const seq = ref(3)
const items = ref([
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' },
{ id: 3, name: 'Cookie' }
])
const newName = ref('Donut')
const last = ref(null)
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms))
}
async function getList() {
clicks.value += 1
busy.value = true
await sleep(220)
last.value = {
ok: true,
text: `拿到了 ${items.value.length} 条数据(列表不变)`
}
busy.value = false
}
async function addOne() {
clicks.value += 1
busy.value = true
await sleep(260)
const name = String(newName.value || '').trim()
if (!name) {
last.value = { ok: false, text: '你还没写“要加什么”' }
busy.value = false
return
}
seq.value += 1
items.value = [...items.value, { id: seq.value, name }]
last.value = { ok: true, text: `已添加:#${seq.value} ${name}` }
busy.value = false
}
async function removeOne() {
clicks.value += 1
busy.value = true
await sleep(240)
if (items.value.length === 0) {
last.value = { ok: false, text: '已经空了,删不了' }
busy.value = false
return
}
const removed = items.value[items.value.length - 1]
items.value = items.value.slice(0, -1)
last.value = { ok: true, text: `已删除:#${removed.id} ${removed.name}` }
busy.value = false
}
function reset() {
seq.value = 3
items.value = [
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' },
{ id: 3, name: 'Cookie' }
]
newName.value = 'Donut'
last.value = null
clicks.value = 0
}
// 无需脚本逻辑
</script>
<style scoped>
.wrap {
.demo {
border: 1px solid var(--vp-c-divider);
border-radius: 14px;
border-radius: 12px;
padding: 20px;
background: var(--vp-c-bg-soft);
padding: 16px;
}
.head {
display: flex;
flex-direction: column;
gap: 6px;
margin: 16px 0;
}
.title {
font-weight: 900;
font-size: 18px;
font-weight: bold;
margin-bottom: 8px;
color: var(--vp-c-text-1);
}
.subtitle {
color: var(--vp-c-text-2);
margin-bottom: 24px;
}
.methods-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.method-card {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 12px;
padding: 16px;
position: relative;
}
.method-card.get { border-color: #3b82f6; }
.method-card.post { border-color: #22c55e; }
.method-card.put { border-color: #f59e0b; }
.method-card.patch { border-color: #8b5cf6; }
.method-card.delete { border-color: #ef4444; }
.method-badge {
position: absolute;
top: 12px;
right: 12px;
padding: 4px 12px;
border-radius: 6px;
font-weight: bold;
font-size: 12px;
color: white;
}
.method-card.get .method-badge { background: #3b82f6; }
.method-card.post .method-badge { background: #22c55e; }
.method-card.put .method-badge { background: #f59e0b; }
.method-card.patch .method-badge { background: #8b5cf6; }
.method-card.delete .method-badge { background: #ef4444; }
.method-title {
font-size: 16px;
font-weight: bold;
margin-bottom: 12px;
padding-right: 60px;
color: var(--vp-c-text-1);
}
.sub {
font-size: 13px;
color: var(--vp-c-text-2);
.method-desc p {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
}
.board {
margin-top: 12px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.left,
.right {
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg);
.method-examples {
background: var(--vp-c-bg-soft);
padding: 12px;
border-radius: 8px;
margin-bottom: 12px;
}
.panelTitle {
font-weight: 900;
.example-item {
font-size: 13px;
padding: 4px 0;
color: var(--vp-c-text-1);
}
.list {
margin-top: 10px;
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg-soft);
padding: 10px;
display: grid;
gap: 8px;
min-height: 160px;
}
.empty {
font-size: 12px;
color: var(--vp-c-text-3);
}
.row {
display: flex;
gap: 10px;
align-items: center;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
border-radius: 12px;
padding: 8px 10px;
}
.pillId {
font-size: 12px;
font-weight: 900;
color: var(--vp-c-text-2);
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
border-radius: 999px;
padding: 2px 8px;
}
.name {
font-size: 13px;
font-weight: 900;
color: var(--vp-c-text-1);
}
.mini {
margin-top: 10px;
font-size: 12px;
color: var(--vp-c-text-2);
}
.miniK {
font-weight: 900;
}
.btnRow {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.btn {
border-radius: 12px;
.method-tip {
padding: 10px 12px;
font-weight: 900;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
border-radius: 8px;
font-size: 12px;
line-height: 1.5;
}
.method-card.get .method-tip {
background: #eff6ff;
color: #1e40af;
}
.method-card.post .method-tip,
.method-card.put .method-tip,
.method-card.patch .method-tip,
.method-card.delete .method-tip {
background: #fef2f2;
color: #991b1b;
}
.comparison-table {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
.table-title {
font-size: 16px;
font-weight: bold;
margin-bottom: 16px;
color: var(--vp-c-text-1);
}
.btn.get {
border-color: color-mix(in srgb, #60a5fa 45%, var(--vp-c-divider));
}
.btn.post {
border-color: color-mix(in srgb, #22c55e 45%, var(--vp-c-divider));
}
.btn.del {
border-color: color-mix(in srgb, #ef4444 45%, var(--vp-c-divider));
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.inputs {
margin-top: 12px;
}
.field {
display: grid;
grid-template-columns: 72px 1fr;
gap: 10px;
align-items: center;
}
.k {
font-size: 12px;
color: var(--vp-c-text-2);
font-weight: 900;
}
.input {
table {
width: 100%;
border: 1px solid var(--vp-c-divider);
border-collapse: collapse;
}
thead {
background: var(--vp-c-bg-soft);
border-radius: 10px;
padding: 8px 10px;
}
th {
padding: 12px;
text-align: left;
font-weight: bold;
font-size: 13px;
color: var(--vp-c-text-1);
font-size: 13px;
border-bottom: 2px solid var(--vp-c-divider);
}
.result {
margin-top: 12px;
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg-soft);
padding: 10px 12px;
}
.resultTitle {
font-weight: 900;
td {
padding: 12px;
font-size: 13px;
border-bottom: 1px solid var(--vp-c-divider);
color: var(--vp-c-text-1);
}
.muted {
margin-top: 8px;
font-size: 12px;
color: var(--vp-c-text-2);
}
.resBox {
margin-top: 8px;
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg);
padding: 10px 12px;
}
.resBox.ok {
border-color: color-mix(in srgb, #22c55e 45%, var(--vp-c-divider));
}
.resBox.bad {
border-color: color-mix(in srgb, #ef4444 45%, var(--vp-c-divider));
tr:last-child td {
border-bottom: none;
}
.badge {
display: inline-block;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
border-radius: 999px;
padding: 2px 10px;
padding: 4px 10px;
border-radius: 6px;
font-weight: bold;
font-size: 12px;
font-weight: 900;
color: var(--vp-c-text-1);
color: white;
}
.text {
margin-top: 8px;
font-size: 13px;
font-weight: 900;
color: var(--vp-c-text-1);
}
.badge.get { background: #3b82f6; }
.badge.post { background: #22c55e; }
.badge.put { background: #f59e0b; }
.badge.patch { background: #8b5cf6; }
.badge.delete { background: #ef4444; }
.foot {
margin-top: 12px;
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
}
.stat {
font-size: 12px;
.tips {
background: var(--vp-c-bg);
padding: 16px;
border-radius: 8px;
font-size: 14px;
line-height: 1.8;
color: var(--vp-c-text-2);
}
.statV {
font-weight: 900;
color: var(--vp-c-text-1);
margin: 0 6px;
.tips p {
margin-bottom: 8px;
}
.ghost {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
color: var(--vp-c-text-1);
border-radius: 10px;
padding: 8px 12px;
font-weight: 900;
cursor: pointer;
.tips ul {
margin: 0;
padding-left: 20px;
}
.ghost:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.one {
margin-top: 12px;
border: 1px dashed var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg);
padding: 10px 12px;
}
.oneTitle {
font-weight: 900;
font-size: 13px;
color: var(--vp-c-text-1);
}
.oneText {
margin-top: 8px;
font-size: 12px;
color: var(--vp-c-text-2);
line-height: 1.6;
}
@media (max-width: 720px) {
.board {
grid-template-columns: 1fr;
}
.btnRow {
grid-template-columns: 1fr;
}
.tips li {
margin: 4px 0;
}
</style>
@@ -1,484 +1,306 @@
<!--
ApiPlayground.vue
参考 ide-intro 虚拟工具风格做一个极简 Postman
目标可玩可视化少字段
- 选操作//
- 填一点id name
- 钥匙开关
- 点发送 -> 看结果
ApiPlayground.vue - 简化版
目标用最简单的演示展示 API 调用的各种情况
-->
<template>
<div class="wrap">
<div class="head">
<div class="title">练习场一个迷你 Postman</div>
<div class="sub">
你不用懂代码把它当成按钮调试器按对了拿结果按错了看失败原因
</div>
</div>
<div class="demo">
<div class="title">🎮 练习场试试调用 API</div>
<p class="subtitle">体验一下成功和失败的情况</p>
<div class="app">
<div class="topbar">
<div class="brand">API Console</div>
<div class="toggles">
<button :class="['toggle', { on: keyOn }]" @click="keyOn = !keyOn">
钥匙{{ keyOn ? '' : '没有' }}
<div class="playground">
<div class="controls">
<div class="control-group">
<label>🔑 钥匙API Key</label>
<button
:class="['toggle', { active: hasKey }]"
@click="hasKey = !hasKey"
>
{{ hasKey ? '✅ 有钥匙' : '❌ 没有钥匙' }}
</button>
<button class="toggle" @click="reset">重置</button>
</div>
<div class="control-group">
<label>📍 用户 ID</label>
<input
v-model="userId"
class="input"
placeholder="例如:u_123"
/>
</div>
<button class="call-btn" :disabled="calling" @click="callApi">
{{ calling ? '调用中...' : '🚀 调用 API' }}
</button>
</div>
<div class="result-area">
<div v-if="!result" class="placeholder">
还没有结果点一下"调用 API"试试
</div>
<div v-else class="result" :class="result.type">
<div class="result-header">
{{ result.type === 'success' ? '✅ 成功' : '❌ 失败' }}
</div>
<div class="result-body">{{ result.message }}</div>
</div>
</div>
<div class="main">
<div class="left">
<div class="panelTitle"> 选操作</div>
<div class="ops">
<button
v-for="o in ops"
:key="o.id"
:class="['op', { active: opId === o.id }]"
@click="setOp(o.id)"
>
<div class="opTitle">{{ o.label }}</div>
<div class="opHint">{{ o.hint }}</div>
</button>
</div>
</div>
<div class="mid">
<div class="panelTitle"> 填一点</div>
<div class="form">
<label class="row" v-if="opId !== 'list'">
<span class="k">id</span>
<input v-model="idText" class="input" placeholder="例如:u_123" />
</label>
<label class="row" v-if="opId === 'create'">
<span class="k">name</span>
<input
v-model="nameText"
class="input"
placeholder="例如:Alice"
/>
</label>
<div class="tip">
玩法
<span class="mono">钥匙=没有</span> 再发送一次或者连续点 4
次触发太频繁
</div>
</div>
<button class="send" :disabled="busy" @click="send">
{{ busy ? '发送中' : ' 发送模拟' }}
</button>
<details class="details">
<summary>选看这一次你相当于发了什么</summary>
<pre class="code"><code>{{ pseudo }}</code></pre>
</details>
</div>
<div class="right">
<div class="panelTitle"> 返回结果</div>
<div v-if="!res" class="muted">还没有结果发送</div>
<div v-else class="resBox" :class="{ ok: res.ok, bad: !res.ok }">
<div class="badge">{{ res.ok ? '成功' : '失败' }}</div>
<div class="resText">{{ res.text }}</div>
<pre v-if="res.data" class="code"><code>{{ res.data }}</code></pre>
</div>
</div>
<div class="tips">
<p><strong>💡 玩法建议</strong></p>
<ul>
<li>试试把"钥匙"改成"没有钥匙"看看会发生什么</li>
<li>试试把 ID 改成 <code>u_404</code>看看会怎样</li>
<li>连续快速点击看看"限流"提示</li>
</ul>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { ref } from 'vue'
const ops = [
{ id: 'list', label: '拿列表(GET', hint: '不改数据' },
{ id: 'get', label: '拿一个(GET', hint: '需要 id' },
{ id: 'create', label: '加一个(POST', hint: '需要 name' },
{ id: 'delete', label: '删一个(DELETE', hint: '需要 id' }
]
const hasKey = ref(true)
const userId = ref('u_123')
const calling = ref(false)
const result = ref(null)
const callCount = ref([])
const now = ref(Date.now())
const opId = ref('list')
const keyOn = ref(true)
const idText = ref('u_123')
const nameText = ref('Alice')
const busy = ref(false)
const res = ref(null)
const callTimes = ref([])
function callApi() {
calling.value = true
result.value = null
const pseudo = computed(() => {
const key = keyOn.value ? "X-Api-Key: '****'" : '(没有钥匙)'
if (opId.value === 'list') return `GET /v1/users\n${key}`
if (opId.value === 'get')
return `GET /v1/users/${idText.value || '{id}'}\n${key}`
if (opId.value === 'create')
return `POST /v1/users\n${key}\nBody: { name: '${nameText.value || ''}' }`
return `DELETE /v1/users/${idText.value || '{id}'}\n${key}`
})
// 模拟限流
const currentTime = Date.now()
callCount.value = callCount.value.filter(t => currentTime - t < 2000)
callCount.value.push(currentTime)
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms))
}
function setOp(id) {
opId.value = id
res.value = null
}
function reset() {
opId.value = 'list'
keyOn.value = true
idText.value = 'u_123'
nameText.value = 'Alice'
res.value = null
callTimes.value = []
}
async function send() {
if (busy.value) return
busy.value = true
res.value = null
const now = Date.now()
callTimes.value = callTimes.value.filter((t) => now - t < 1200)
callTimes.value.push(now)
if (callTimes.value.length >= 4) {
await sleep(180)
res.value = { ok: false, text: '太频繁了,请慢一点(模拟)' }
busy.value = false
if (callCount.value.length >= 4) {
setTimeout(() => {
result.value = {
type: 'error',
message: '太频繁了!请慢一点再试(限流)'
}
calling.value = false
}, 300)
return
}
await sleep(300)
if (!keyOn.value) {
res.value = { ok: false, text: '没有钥匙(没权限,模拟)' }
busy.value = false
return
}
if (opId.value === 'list') {
res.value = {
ok: true,
text: '拿到列表(模拟)',
data: JSON.stringify(
{
data: [
{ id: 'u_123', name: 'Alice' },
{ id: 'u_124', name: 'Bob' }
]
},
null,
2
)
setTimeout(() => {
// 检查钥匙
if (!hasKey.value) {
result.value = {
type: 'error',
message: '没有钥匙!你没有权限调用这个 API(401 未授权)'
}
calling.value = false
return
}
busy.value = false
return
}
if (opId.value === 'get') {
const id = String(idText.value || '').trim()
// 检查用户 ID
const id = userId.value.trim()
if (!id) {
res.value = { ok: false, text: '你还没填 id' }
busy.value = false
result.value = {
type: 'error',
message: '你还没填用户 ID'
}
calling.value = false
return
}
if (id === 'u_404') {
res.value = { ok: false, text: '找不到这个 id(模拟)' }
busy.value = false
result.value = {
type: 'error',
message: '找不到这个用户!ID 不存在(404)'
}
calling.value = false
return
}
res.value = {
ok: true,
text: `拿到用户 ${id}(模拟)`,
data: JSON.stringify({ id, name: 'Alice' }, null, 2)
}
busy.value = false
return
}
if (opId.value === 'create') {
const name = String(nameText.value || '').trim()
if (!name) {
res.value = { ok: false, text: '你还没填 name' }
busy.value = false
return
// 成功
result.value = {
type: 'success',
message: `成功获取用户信息:\nID: ${id}\n姓名: 张三\n邮箱: zhang@example.com`
}
res.value = {
ok: true,
text: `创建成功(模拟)`,
data: JSON.stringify({ id: 'u_789', name }, null, 2)
}
busy.value = false
return
}
// delete
const id = String(idText.value || '').trim()
if (!id) {
res.value = { ok: false, text: '你还没填 id' }
busy.value = false
return
}
res.value = { ok: true, text: `删除成功:${id}(模拟)` }
busy.value = false
calling.value = false
}, 800)
}
</script>
<style scoped>
.wrap {
.demo {
border: 1px solid var(--vp-c-divider);
border-radius: 14px;
border-radius: 12px;
padding: 20px;
background: var(--vp-c-bg-soft);
padding: 16px;
}
.head {
display: flex;
flex-direction: column;
gap: 6px;
margin: 16px 0;
}
.title {
font-weight: 900;
font-size: 16px;
font-size: 18px;
font-weight: bold;
margin-bottom: 8px;
color: var(--vp-c-text-1);
}
.sub {
font-size: 13px;
.subtitle {
color: var(--vp-c-text-2);
line-height: 1.6;
margin-bottom: 16px;
}
.app {
margin-top: 12px;
border: 1px solid var(--vp-c-divider);
border-radius: 14px;
.playground {
background: var(--vp-c-bg);
overflow: hidden;
border: 2px solid var(--vp-c-divider);
border-radius: 12px;
padding: 20px;
}
.topbar {
.controls {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 20px;
}
.control-group {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
padding: 10px 12px;
border-bottom: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
}
.brand {
font-weight: 900;
font-size: 13px;
color: var(--vp-c-text-1);
}
.toggles {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.toggle {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
color: var(--vp-c-text-1);
border-radius: 999px;
padding: 6px 10px;
font-weight: 900;
font-size: 12px;
cursor: pointer;
}
.toggle.on {
border-color: var(--vp-c-brand-1);
}
.main {
padding: 12px;
display: grid;
grid-template-columns: 0.9fr 1fr 1.1fr;
gap: 12px;
}
.left,
.mid,
.right {
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg);
padding: 12px;
.control-group label {
font-weight: bold;
font-size: 14px;
min-width: 120px;
}
.panelTitle {
font-weight: 900;
font-size: 13px;
color: var(--vp-c-text-1);
}
.ops {
margin-top: 10px;
display: grid;
gap: 10px;
}
.op {
border: 1px solid var(--vp-c-divider);
.toggle {
padding: 8px 16px;
border: 2px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
color: var(--vp-c-text-1);
border-radius: 12px;
padding: 10px 12px;
text-align: left;
border-radius: 8px;
cursor: pointer;
}
.op.active {
border-color: var(--vp-c-brand-1);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--vp-c-brand-1) 18%, transparent);
}
.opTitle {
font-weight: 900;
font-size: 13px;
font-weight: bold;
transition: all 0.2s;
}
.opHint {
margin-top: 6px;
font-size: 12px;
color: var(--vp-c-text-2);
}
.form {
margin-top: 10px;
display: grid;
gap: 10px;
}
.row {
display: grid;
grid-template-columns: 56px 1fr;
gap: 10px;
align-items: center;
}
.k {
font-size: 12px;
color: var(--vp-c-text-2);
font-weight: 900;
.toggle.active {
border-color: #22c55e;
background: #dcfce7;
color: #166534;
}
.input {
width: 100%;
flex: 1;
padding: 8px 12px;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
font-size: 14px;
background: var(--vp-c-bg-soft);
border-radius: 10px;
padding: 8px 10px;
color: var(--vp-c-text-1);
font-size: 13px;
}
.send {
margin-top: 12px;
.call-btn {
width: 100%;
border: 1px solid var(--vp-c-brand-1);
padding: 12px 20px;
background: var(--vp-c-brand-1);
color: #fff;
border-radius: 12px;
padding: 10px 12px;
font-weight: 900;
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
}
.send:disabled {
.call-btn:hover:not(:disabled) {
opacity: 0.9;
}
.call-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.tip {
font-size: 12px;
.result-area {
min-height: 120px;
margin-bottom: 20px;
}
.placeholder {
padding: 20px;
text-align: center;
color: var(--vp-c-text-2);
font-style: italic;
}
.result {
border: 2px solid;
border-radius: 8px;
overflow: hidden;
}
.result.success {
border-color: #22c55e;
background: #f0fdf4;
}
.result.error {
border-color: #ef4444;
background: #fef2f2;
}
.result-header {
padding: 12px 16px;
font-weight: bold;
font-size: 14px;
}
.result.success .result-header {
background: #dcfce7;
color: #166534;
}
.result.error .result-header {
background: #fee2e2;
color: #991b1b;
}
.result-body {
padding: 12px 16px;
font-size: 13px;
white-space: pre-line;
line-height: 1.6;
}
.mono {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
}
.details {
margin-top: 12px;
border: 1px dashed var(--vp-c-divider);
border-radius: 12px;
.tips {
background: var(--vp-c-bg-soft);
padding: 10px 12px;
}
.details summary {
cursor: pointer;
font-weight: 900;
padding: 16px;
border-radius: 8px;
font-size: 13px;
color: var(--vp-c-text-1);
line-height: 1.6;
}
.muted {
margin-top: 10px;
.tips p {
margin-bottom: 8px;
}
.tips ul {
margin: 0;
padding-left: 20px;
}
.tips li {
margin: 4px 0;
}
.tips code {
background: #1e293b;
color: #e2e8f0;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
color: var(--vp-c-text-2);
}
.resBox {
margin-top: 10px;
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg-soft);
padding: 10px 12px;
}
.resBox.ok {
border-color: color-mix(in srgb, #22c55e 45%, var(--vp-c-divider));
}
.resBox.bad {
border-color: color-mix(in srgb, #ef4444 45%, var(--vp-c-divider));
}
.badge {
display: inline-block;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
border-radius: 999px;
padding: 2px 10px;
font-size: 12px;
font-weight: 900;
}
.resText {
margin-top: 8px;
font-size: 13px;
font-weight: 900;
color: var(--vp-c-text-1);
}
.code {
margin: 8px 0 0;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
color: var(--vp-c-text-1);
}
@media (max-width: 720px) {
.main {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,755 +1,132 @@
<!--
ApiQuickStartDemo.vue
参考 ide-intro 虚拟 UI + 先玩再讲风格
目标0 基础读者能立刻理解 API = 按钮/入口
选择按钮 -> 填一点 -> 点一下 -> 看结果成功/失败
ApiQuickStartDemo.vue - 简化版
目标用最简单的交互展示 API 调用流程
-->
<template>
<div class="machine">
<div class="top">
<div>
<div class="title">先玩一下 API 当成一个按钮机</div>
<div class="sub">你只管选按钮填信息点一下别背术语</div>
</div>
<div class="legend">
<span class="dot in" /> 你填的信息
<span class="dot mid" /> 按钮API <span class="dot out" /> 返回结果
<div class="demo">
<div class="title">🎮 试试看调用一次 API</div>
<p class="subtitle">点一下按钮看看会发生什么</p>
<div class="box">
<button class="call-btn" :disabled="calling" @click="callApi">
{{ calling ? '调用中...' : '🔘 点我调用 API' }}
</button>
<div class="result" v-if="result">
<div class="success" v-if="result.success">
成功API 返回了{{ result.data }}
</div>
<div class="error" v-else>
失败了{{ result.error }}
</div>
</div>
</div>
<div class="screen">
<div class="left">
<div class="panelTitle"> 选一个按钮</div>
<div class="buttons">
<button
v-for="b in buttons"
:key="b.id"
:class="['bigBtn', { active: currentId === b.id }]"
@click="select(b.id)"
>
<div class="icon">{{ b.icon }}</div>
<div class="label">{{ b.label }}</div>
<div class="hint">{{ b.hint }}</div>
</button>
</div>
</div>
<div class="middle">
<div class="panelTitle"> 填一点信息</div>
<div class="form" v-if="currentId === 'date'">
<label class="row">
<span class="k">日期</span>
<input
v-model="form.dateText"
class="input"
placeholder="2026-01-19"
/>
</label>
<div class="row">
<span class="k">格式</span>
<div class="chips">
<button
v-for="f in formats"
:key="f"
:class="['chip', { active: form.dateFormat === f }]"
@click="form.dateFormat = f"
>
{{ f }}
</button>
</div>
</div>
<div class="tip">玩法把日期写错试试比如 2026-99-99</div>
</div>
<div class="form" v-else-if="currentId === 'ai'">
<label class="row">
<span class="k">问题</span>
<textarea
v-model="form.question"
class="textarea"
placeholder="例如:用一句话解释什么是 API"
/>
</label>
<div class="tip">玩法清空问题再点一下看看会发生什么</div>
</div>
<div class="form" v-else>
<div class="row">
<span class="k">选择</span>
<div class="chips">
<button
:class="['chip', { active: form.loginOk }]"
@click="form.loginOk = true"
>
同意
</button>
<button
:class="['chip', { active: !form.loginOk }]"
@click="form.loginOk = false"
>
取消
</button>
</div>
</div>
<div class="tip">玩法取消再点一下</div>
</div>
<div class="callBar">
<button class="callBtn" :disabled="busy" @click="call">
{{ busy ? '执行中' : ' 点一下调用' }}
</button>
<button class="ghost" :disabled="busy" @click="resetScore">
清零计分
</button>
</div>
<div class="score">
<div class="scoreItem">
<div class="scoreK">成功</div>
<div class="scoreV">{{ score.ok }}</div>
</div>
<div class="scoreItem">
<div class="scoreK">失败</div>
<div class="scoreV">{{ score.bad }}</div>
</div>
<div class="scoreItem">
<div class="scoreK">连续成功</div>
<div class="scoreV">{{ score.streak }}</div>
</div>
</div>
</div>
<div class="right">
<div class="panelTitle"> 看结果</div>
<div class="flow">
<div class="node">
<div class="nodeTop">
<span class="dot in" />
<span class="nodeTitle">你填的信息</span>
</div>
<div class="nodeBody">{{ requestPreview }}</div>
</div>
<div class="arrow" :class="{ go: animating }"></div>
<div class="node">
<div class="nodeTop">
<span class="dot mid" />
<span class="nodeTitle">按钮API</span>
</div>
<div class="nodeBody">{{ currentHow }}</div>
</div>
<div class="arrow" :class="{ go: animating }"></div>
<div class="node">
<div class="nodeTop">
<span class="dot out" />
<span class="nodeTitle">返回结果</span>
</div>
<div class="nodeBody">
<div v-if="!result" class="muted">
还没有结果点一下调用试试
</div>
<div
v-else
class="resultBox"
:class="{ ok: result.ok, bad: !result.ok }"
>
<div class="badge">{{ result.ok ? '成功' : '失败' }}</div>
<div class="resultText">{{ result.text }}</div>
<pre
v-if="result.debug"
class="code"
><code>{{ result.debug }}</code></pre>
</div>
</div>
</div>
</div>
<details class="details">
<summary>选看你不用管的细节</summary>
<ul class="list">
<li v-for="x in currentDetails" :key="x">{{ x }}</li>
</ul>
</details>
</div>
<div class="explain">
<p><strong>你看</strong>你只需要点一下按钮调用 API就会得到结果</p>
<p>这就是 API 的本质<strong>按约定把请求交给对方对方按约定把结果给你</strong></p>
</div>
</div>
</template>
<script setup>
import { computed, reactive, ref } from 'vue'
import { ref } from 'vue'
const buttons = [
{
id: 'date',
icon: '🗓️',
label: '日期格式化',
hint: '最简单:填日期 -> 得到结果',
how: '调用一个日期格式化函数(本地)',
details: ['内部怎么处理时区', '怎么优化性能', '怎么兼容不同语言环境']
},
{
id: 'ai',
icon: '🤖',
label: '问 AI(模拟)',
hint: '写一句话 -> 得到回答',
how: '调用一个“问答入口”(可能是 SDK 或 HTTP',
details: ['模型怎么训练', '服务端怎么排队', '怎么限流/重试']
},
{
id: 'login',
icon: '🔑',
label: '一键登录(模拟)',
hint: '点同意/取消 -> 得到结果',
how: '按登录流程走一遍(这里用模拟)',
details: ['它怎么做安全校验', '登录凭证怎么生成', '怎么风控']
}
]
const formats = ['YYYY-MM-DD', 'YYYY/MM/DD', 'MM-DD']
const currentId = ref('date')
const busy = ref(false)
const animating = ref(false)
const form = reactive({
dateText: '2026-01-19',
dateFormat: 'YYYY-MM-DD',
question: '用一句话解释什么是 API',
loginOk: true
})
const score = reactive({ ok: 0, bad: 0, streak: 0 })
const calling = ref(false)
const result = ref(null)
const callTimes = ref([]) // for "too frequent"
const current = computed(
() => buttons.find((b) => b.id === currentId.value) || buttons[0]
)
const currentHow = computed(() => current.value.how)
const currentDetails = computed(() => current.value.details || [])
const requestPreview = computed(() => {
if (currentId.value === 'date') {
return `日期=${form.dateText || '(空)'};格式=${form.dateFormat}`
}
if (currentId.value === 'ai') {
return `问题=${(form.question || '').trim() || '(空)'}`
}
return `选择=${form.loginOk ? '同意' : '取消'}`
})
function select(id) {
currentId.value = id
function callApi() {
calling.value = true
result.value = null
}
function resetScore() {
score.ok = 0
score.bad = 0
score.streak = 0
}
function pad2(n) {
return String(n).padStart(2, '0')
}
function fmtDate(d, fmt) {
const y = d.getFullYear()
const m = pad2(d.getMonth() + 1)
const day = pad2(d.getDate())
if (fmt === 'YYYY/MM/DD') return `${y}/${m}/${day}`
if (fmt === 'MM-DD') return `${m}-${day}`
return `${y}-${m}-${day}`
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms))
}
function record(ok) {
if (ok) {
score.ok += 1
score.streak += 1
} else {
score.bad += 1
score.streak = 0
}
}
async function call() {
if (busy.value) return
busy.value = true
animating.value = true
// simulate "too frequent"
const now = Date.now()
callTimes.value = callTimes.value.filter((t) => now - t < 1200)
callTimes.value.push(now)
if (callTimes.value.length >= 4) {
await sleep(220)
// 模拟 API 调用
setTimeout(() => {
result.value = {
ok: false,
text: '太频繁了,请慢一点再试(模拟)',
debug: '现实里:有些 API 会限制你“点太快”。'
success: true,
data: 'Hello from API!'
}
record(false)
animating.value = false
busy.value = false
return
}
await sleep(380)
if (currentId.value === 'date') {
const raw = String(form.dateText || '').trim()
const d = new Date(raw)
if (Number.isNaN(d.getTime())) {
result.value = {
ok: false,
text: '日期写错了(我看不懂)',
debug: `输入:${raw}`
}
record(false)
animating.value = false
busy.value = false
return
}
const out = fmtDate(d, form.dateFormat)
result.value = {
ok: true,
text: `结果:${out}`,
debug: `你填的:${raw}\n你选的格式:${form.dateFormat}\n它给你的:${out}`
}
record(true)
animating.value = false
busy.value = false
return
}
if (currentId.value === 'ai') {
const q = String(form.question || '').trim()
if (!q) {
result.value = { ok: false, text: '你还没写问题', debug: '' }
record(false)
animating.value = false
busy.value = false
return
}
result.value = {
ok: true,
text: '回答:API 就是“别的软件给你用的按钮/入口”。',
debug: `你的问题:${q}\n回答:API 就是“别的软件给你用的按钮/入口”。`
}
record(true)
animating.value = false
busy.value = false
return
}
// login
if (!form.loginOk) {
result.value = { ok: false, text: '用户取消了登录(模拟)', debug: '' }
record(false)
animating.value = false
busy.value = false
return
}
result.value = {
ok: true,
text: '登录成功:拿到用户信息(模拟)',
debug: '用户:Alice\n状态:成功'
}
record(true)
animating.value = false
busy.value = false
calling.value = false
}, 800)
}
</script>
<style scoped>
.machine {
.demo {
border: 1px solid var(--vp-c-divider);
border-radius: 14px;
border-radius: 12px;
padding: 20px;
background: var(--vp-c-bg-soft);
padding: 16px;
}
.top {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
margin: 16px 0;
}
.title {
font-weight: 900;
font-size: 16px;
color: var(--vp-c-text-1);
}
.sub {
margin-top: 6px;
font-size: 13px;
color: var(--vp-c-text-2);
}
.legend {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
font-size: 12px;
color: var(--vp-c-text-2);
}
.dot {
width: 10px;
height: 10px;
border-radius: 999px;
display: inline-block;
border: 1px solid var(--vp-c-divider);
}
.dot.in {
background: color-mix(in srgb, #60a5fa 40%, var(--vp-c-bg));
}
.dot.mid {
background: color-mix(in srgb, var(--vp-c-brand-1) 35%, var(--vp-c-bg));
}
.dot.out {
background: color-mix(in srgb, #22c55e 30%, var(--vp-c-bg));
}
.screen {
margin-top: 12px;
display: grid;
grid-template-columns: 1.05fr 1fr 1.2fr;
gap: 12px;
}
.panelTitle {
font-weight: 800;
font-size: 13px;
color: var(--vp-c-text-1);
}
.left,
.middle,
.right {
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg);
padding: 12px;
}
.buttons {
margin-top: 10px;
display: grid;
gap: 10px;
}
.bigBtn {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
color: var(--vp-c-text-1);
border-radius: 12px;
padding: 12px;
text-align: left;
cursor: pointer;
}
.bigBtn:hover {
border-color: var(--vp-c-brand-1);
}
.bigBtn.active {
border-color: var(--vp-c-brand-1);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--vp-c-brand-1) 18%, transparent);
}
.icon {
font-size: 18px;
font-weight: bold;
margin-bottom: 8px;
color: var(--vp-c-text-1);
}
.label {
margin-top: 6px;
font-weight: 900;
font-size: 13px;
}
.hint {
margin-top: 6px;
font-size: 12px;
.subtitle {
color: var(--vp-c-text-2);
line-height: 1.5;
margin-bottom: 16px;
}
.form {
margin-top: 10px;
display: grid;
gap: 10px;
}
.row {
display: grid;
grid-template-columns: 56px 1fr;
gap: 10px;
align-items: start;
}
.k {
font-size: 12px;
color: var(--vp-c-text-3);
padding-top: 8px;
}
.input {
width: 100%;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
border-radius: 10px;
padding: 8px 10px;
color: var(--vp-c-text-1);
font-size: 13px;
}
.textarea {
width: 100%;
min-height: 78px;
resize: vertical;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
border-radius: 10px;
padding: 8px 10px;
color: var(--vp-c-text-1);
font-size: 13px;
line-height: 1.5;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
border: 1px solid var(--vp-c-divider);
.box {
background: var(--vp-c-bg);
color: var(--vp-c-text-1);
padding: 6px 10px;
border-radius: 999px;
font-size: 13px;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 20px;
text-align: center;
}
.chip.active {
border-color: var(--vp-c-brand-1);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--vp-c-brand-1) 18%, transparent);
}
.tip {
margin-top: 4px;
font-size: 12px;
color: var(--vp-c-text-2);
line-height: 1.6;
}
.callBar {
margin-top: 12px;
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.callBtn {
flex: 1;
min-width: 200px;
border: 1px solid var(--vp-c-brand-1);
.call-btn {
background: var(--vp-c-brand-1);
color: #fff;
border-radius: 12px;
padding: 10px 12px;
font-weight: 900;
color: white;
border: none;
padding: 12px 24px;
font-size: 16px;
font-weight: bold;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.ghost {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
color: var(--vp-c-text-1);
border-radius: 12px;
padding: 10px 12px;
font-weight: 900;
cursor: pointer;
.call-btn:hover:not(:disabled) {
opacity: 0.9;
transform: scale(1.05);
}
.callBtn:disabled,
.ghost:disabled {
.call-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.score {
margin-top: 12px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
.result {
margin-top: 16px;
padding: 12px;
border-radius: 8px;
font-size: 14px;
}
.scoreItem {
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg-soft);
padding: 10px 12px;
.success {
background: #dcfce7;
color: #166534;
border: 1px solid #86efac;
}
.scoreK {
font-size: 12px;
color: var(--vp-c-text-2);
.error {
background: #fee2e2;
color: #991b1b;
border: 1px solid #fca5a5;
}
.scoreV {
margin-top: 4px;
font-size: 18px;
font-weight: 900;
color: var(--vp-c-text-1);
}
.flow {
margin-top: 10px;
display: grid;
gap: 10px;
}
.node {
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg-soft);
overflow: hidden;
}
.nodeTop {
display: flex;
gap: 8px;
align-items: center;
padding: 10px 12px;
border-bottom: 1px solid var(--vp-c-divider);
}
.nodeTitle {
font-weight: 900;
font-size: 12px;
color: var(--vp-c-text-1);
}
.nodeBody {
padding: 10px 12px;
font-size: 12px;
color: var(--vp-c-text-1);
line-height: 1.6;
}
.arrow {
text-align: center;
color: var(--vp-c-text-3);
font-weight: 900;
font-size: 16px;
transition: transform 180ms ease;
}
.arrow.go {
transform: translateX(6px);
color: var(--vp-c-brand-1);
}
.muted {
font-size: 12px;
color: var(--vp-c-text-2);
line-height: 1.6;
}
.resultBox {
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
.explain {
margin-top: 16px;
padding: 12px;
background: var(--vp-c-bg);
padding: 10px 12px;
}
.resultBox.ok {
border-color: color-mix(in srgb, #22c55e 45%, var(--vp-c-divider));
}
.resultBox.bad {
border-color: color-mix(in srgb, #ef4444 45%, var(--vp-c-divider));
}
.badge {
display: inline-block;
font-size: 12px;
padding: 2px 10px;
border-radius: 999px;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
font-weight: 900;
}
.resultText {
margin-top: 8px;
font-size: 13px;
font-weight: 900;
color: var(--vp-c-text-1);
}
.code {
margin: 8px 0 0;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
color: var(--vp-c-text-1);
}
.details {
margin-top: 12px;
border: 1px dashed var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg);
padding: 10px 12px;
}
.details summary {
cursor: pointer;
color: var(--vp-c-text-1);
font-weight: 900;
font-size: 13px;
}
.list {
margin: 10px 0 0;
padding-left: 16px;
color: var(--vp-c-text-1);
font-size: 12px;
border-radius: 8px;
font-size: 14px;
line-height: 1.6;
}
@media (max-width: 720px) {
.top {
flex-direction: column;
}
.screen {
grid-template-columns: 1fr;
}
.score {
grid-template-columns: 1fr;
}
color: var(--vp-c-text-2);
}
</style>
@@ -1,60 +1,172 @@
<!--
RealWorldApiDemo.vue
参考 ide-intro 虚拟 UI + 一眼看懂的风格
目标解释为什么 SDK 的调用也叫 API
- 左边SDK 按钮你调用函数
- 右边HTTP 按钮你发请求
两个按钮得到的结果一样只是包装方式不同
目标展示真实场景中调用 AI 服务的两种方式
-->
<template>
<div class="wrap">
<div class="head">
<div class="title">SDK vs HTTP其实都是按按钮拿结果</div>
<div class="sub">
你可以把它理解成<b>同一个按钮</b>只是有两种按法
<div class="demo">
<div class="title">🤖 真实场景 AI 帮你写产品文案</div>
<p class="subtitle">体验两种调用方式的区别</p>
<div class="scenario">
<div class="scenario-header">
<span class="scenario-icon">📝</span>
<span class="scenario-title">你的需求</span>
</div>
<div class="scenario-body">
我想让 AI 帮智能手表写一段吸引人的产品文案
</div>
</div>
<div class="grid">
<div class="card">
<div class="cardTitle">方式 ASDK 按钮调用函数</div>
<div class="cardBody">
<button class="call sdk" :disabled="busy" @click="call('sdk')">
{{ busy ? '执行中' : '按一下SDK' }}
</button>
<div class="muted">你写的通常是<b>client.users.get(...)</b></div>
<details class="details">
<summary>选看长什么样</summary>
<pre class="code"><code>{{ sdkCode }}</code></pre>
</details>
</div>
<div class="modes">
<div class="mode-tabs">
<button
:class="['tab', { active: mode === 'http' }]"
@click="mode = 'http'"
>
🌐 HTTP API外卖模式
</button>
<button
:class="['tab', { active: mode === 'sdk' }]"
@click="mode = 'sdk'"
>
📦 SDK堂食模式
</button>
</div>
<div class="card">
<div class="cardTitle">方式 BHTTP 按钮发请求</div>
<div class="cardBody">
<button class="call http" :disabled="busy" @click="call('http')">
{{ busy ? '执行中' : '按一下HTTP' }}
</button>
<div class="muted">你写的通常是<b>fetch(url, headers)</b></div>
<details class="details">
<summary>选看长什么样</summary>
<pre class="code"><code>{{ httpCode }}</code></pre>
</details>
</div>
</div>
<div class="mode-content">
<!-- HTTP 模式 -->
<div v-if="mode === 'http'" class="mode-details">
<div class="steps">
<div class="step" :class="{ active: currentStep >= 1 }">
<div class="step-number">1</div>
<div class="step-content">
<div class="step-title">找到网址打开外卖 APP</div>
<div class="step-code">https://api.openai.com/v1/chat/completions</div>
</div>
</div>
<div class="card">
<div class="cardTitle">结果两边一样</div>
<div class="cardBody">
<div v-if="!res" class="muted">先按一下左边或右边的按钮</div>
<div v-else class="resBox">
<div class="badge">返回</div>
<div class="resText">{{ res }}</div>
<div class="step" :class="{ active: currentStep >= 2 }">
<div class="step-number">2</div>
<div class="step-content">
<div class="step-title">准备订单填写信息</div>
<div class="step-code">
Authorization: Bearer 你的API密钥<br>
Content-Type: application/json
</div>
</div>
</div>
<div class="step" :class="{ active: currentStep >= 3 }">
<div class="step-number">3</div>
<div class="step-content">
<div class="step-title">下单发送请求</div>
<div class="step-code">
{<br>
&nbsp;&nbsp;"model": "gpt-4",<br>
&nbsp;&nbsp;"messages": [<br>
&nbsp;&nbsp;&nbsp;&nbsp;{ "role": "system", "content": "你是营销文案专家" },<br>
&nbsp;&nbsp;&nbsp;&nbsp;{ "role": "user", "content": "写智能手表文案" }<br>
&nbsp;&nbsp;]<br>
}
</div>
</div>
</div>
<div class="step" :class="{ active: currentStep >= 4 }">
<div class="step-number">4</div>
<div class="step-content">
<div class="step-title">等待配送解析响应</div>
<div class="step-code">
response.choices[0].message.content<br>
<span class="step-hint"> 需要自己处理解析错误</span>
</div>
</div>
</div>
</div>
<div class="tip">
结论<b>SDK 函数入口也叫 API</b
>因为它也是对外公开的怎么用
<div class="summary">
<p><strong>💡 HTTP API 特点</strong></p>
<ul>
<li> 灵活任何语言都能用</li>
<li> 复杂要手动处理很多细节</li>
<li> 容易出错鉴权数据格式错误处理都要自己写</li>
</ul>
</div>
</div>
<!-- SDK 模式 -->
<div v-else class="mode-details">
<div class="steps">
<div class="step" :class="{ active: currentStep >= 1 }">
<div class="step-number">1</div>
<div class="step-content">
<div class="step-title">走进餐厅安装 SDK</div>
<div class="step-code">import OpenAI from 'openai'</div>
</div>
</div>
<div class="step" :class="{ active: currentStep >= 2 }">
<div class="step-number">2</div>
<div class="step-content">
<div class="step-title">找服务员初始化客户端</div>
<div class="step-code">
const client = new OpenAI({<br>
&nbsp;&nbsp;apiKey: '你的密钥'<br>
})
</div>
</div>
</div>
<div class="step" :class="{ active: currentStep >= 3 }">
<div class="step-number">3</div>
<div class="step-content">
<div class="step-title">直接点菜调用函数</div>
<div class="step-code">
const response = await client.chat.completions.create({<br>
&nbsp;&nbsp;model: 'gpt-4',<br>
&nbsp;&nbsp;messages: [<br>
&nbsp;&nbsp;&nbsp;&nbsp;{ role: 'system', content: '你是营销文案专家' },<br>
&nbsp;&nbsp;&nbsp;&nbsp;{ role: 'user', content: '写智能手表文案' }<br>
&nbsp;&nbsp;]<br>
})
</div>
</div>
</div>
<div class="step" :class="{ active: currentStep >= 4 }">
<div class="step-number">4</div>
<div class="step-content">
<div class="step-title">享用美食直接使用</div>
<div class="step-code">
console.log(response.choices[0].message.content)<br>
<span class="step-hint"> SDK 帮你处理好了所有细节</span>
</div>
</div>
</div>
</div>
<div class="summary">
<p><strong>💡 SDK 特点</strong></p>
<ul>
<li> 简单只管调用函数</li>
<li> 省心SDK 自动处理鉴权错误数据格式</li>
<li> 限制通常只能在特定语言使用</li>
</ul>
</div>
</div>
</div>
<div class="action">
<button class="run-btn" :disabled="running" @click="runDemo">
{{ running ? '调用中...' : '🚀 开始调用 AI' }}
</button>
<div class="result" v-if="result">
<div class="result-header">
{{ mode === 'http' ? '🌐 HTTP API 返回' : '📦 SDK 返回' }}
</div>
<div class="result-body">
"这款智能手表,是你的贴身健康管家。全天候心率监测,运动模式自动识别..."
</div>
</div>
</div>
@@ -63,189 +175,272 @@
</template>
<script setup>
import { computed, ref } from 'vue'
import { ref } from 'vue'
const busy = ref(false)
const res = ref('')
const mode = ref('http')
const currentStep = ref(0)
const running = ref(false)
const result = ref(null)
const sdkCode = computed(
() => `import { AcmeClient } from 'acme-sdk'
async function runDemo() {
running.value = true
result.value = null
currentStep.value = 0
const client = new AcmeClient({ apiKey: '****' })
const user = await client.users.get({ id: 'u_123' })
console.log(user)`
)
// 模拟逐步执行
for (let i = 1; i <= 4; i++) {
await new Promise(resolve => setTimeout(resolve, 600))
currentStep.value = i
}
const httpCode = computed(
() => `const res = await fetch('https://api.acme.com/v1/users/u_123', {
headers: { 'X-Api-Key': '****' }
})
const user = await res.json()
console.log(user)`
)
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms))
}
async function call(which) {
if (busy.value) return
busy.value = true
res.value = ''
await sleep(280)
res.value =
which === 'sdk'
? '用户:{ id: "u_123", name: "Alice" }(模拟)'
: '用户:{ id: "u_123", name: "Alice" }(模拟)'
busy.value = false
await new Promise(resolve => setTimeout(resolve, 400))
result.value = true
running.value = false
}
</script>
<style scoped>
.wrap {
.demo {
border: 1px solid var(--vp-c-divider);
border-radius: 14px;
border-radius: 12px;
padding: 20px;
background: var(--vp-c-bg-soft);
padding: 16px;
}
.head {
display: flex;
flex-direction: column;
gap: 6px;
margin: 16px 0;
}
.title {
font-weight: 900;
font-size: 16px;
font-size: 18px;
font-weight: bold;
margin-bottom: 8px;
color: var(--vp-c-text-1);
}
.sub {
font-size: 13px;
.subtitle {
color: var(--vp-c-text-2);
line-height: 1.6;
margin-bottom: 20px;
}
.grid {
margin-top: 12px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.card {
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
.scenario {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-brand-1);
border-radius: 12px;
padding: 16px;
margin-bottom: 20px;
}
.scenario-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.scenario-icon {
font-size: 24px;
}
.scenario-title {
font-size: 16px;
font-weight: bold;
color: var(--vp-c-text-1);
}
.scenario-body {
font-size: 15px;
line-height: 1.6;
color: var(--vp-c-text-1);
padding-left: 32px;
}
.modes {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 12px;
overflow: hidden;
}
.cardTitle {
padding: 10px 12px;
border-bottom: 1px solid var(--vp-c-divider);
font-weight: 900;
font-size: 13px;
color: var(--vp-c-text-1);
.mode-tabs {
display: flex;
border-bottom: 2px solid var(--vp-c-divider);
}
.cardBody {
padding: 12px;
.tab {
flex: 1;
padding: 14px 20px;
border: none;
background: var(--vp-c-bg-soft);
font-size: 15px;
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
border-bottom: 3px solid transparent;
}
.tab:hover {
background: var(--vp-c-bg);
}
.tab.active {
background: var(--vp-c-bg);
border-bottom-color: var(--vp-c-brand-1);
}
.mode-content {
padding: 20px;
}
.steps {
display: flex;
flex-direction: column;
gap: 10px;
gap: 16px;
margin-bottom: 20px;
}
.call {
width: 100%;
border-radius: 12px;
padding: 12px 12px;
font-weight: 900;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
.step {
display: flex;
gap: 16px;
opacity: 0.4;
transition: opacity 0.3s;
}
.step.active {
opacity: 1;
}
.step-number {
flex-shrink: 0;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--vp-c-bg-soft);
border: 2px solid var(--vp-c-divider);
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 14px;
color: var(--vp-c-text-2);
}
.step.active .step-number {
background: var(--vp-c-brand-1);
border-color: var(--vp-c-brand-1);
color: white;
}
.step-content {
flex: 1;
}
.step-title {
font-weight: bold;
font-size: 14px;
margin-bottom: 6px;
color: var(--vp-c-text-1);
}
.call.sdk {
border-color: color-mix(in srgb, #22c55e 45%, var(--vp-c-divider));
.step-code {
background: #1e293b;
color: #e2e8f0;
padding: 12px;
border-radius: 8px;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
line-height: 1.6;
position: relative;
}
.call.http {
border-color: color-mix(in srgb, #60a5fa 45%, var(--vp-c-divider));
.step-hint {
display: block;
margin-top: 8px;
font-size: 11px;
padding: 6px 8px;
border-radius: 4px;
}
.call:disabled {
.step.active .step-hint {
background: rgba(255, 255, 255, 0.1);
}
.summary {
background: var(--vp-c-bg-soft);
padding: 16px;
border-radius: 8px;
font-size: 14px;
line-height: 1.8;
}
.summary p {
margin-bottom: 8px;
}
.summary ul {
margin: 0;
padding-left: 20px;
}
.summary li {
margin: 4px 0;
}
.action {
padding: 20px;
border-top: 2px solid var(--vp-c-divider);
}
.run-btn {
width: 100%;
padding: 14px 24px;
background: var(--vp-c-brand-1);
color: white;
border: none;
border-radius: 10px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
}
.run-btn:hover:not(:disabled) {
opacity: 0.9;
transform: scale(1.02);
}
.run-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.muted {
font-size: 12px;
color: var(--vp-c-text-2);
line-height: 1.6;
.result {
margin-top: 16px;
animation: fadeIn 0.3s ease;
}
.details {
border: 1px dashed var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg-soft);
padding: 10px 12px;
}
.details summary {
cursor: pointer;
font-weight: 900;
font-size: 13px;
color: var(--vp-c-text-1);
}
.code {
margin: 10px 0 0;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
color: var(--vp-c-text-1);
}
.resBox {
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg-soft);
padding: 10px 12px;
}
.badge {
display: inline-block;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
border-radius: 999px;
padding: 2px 10px;
font-size: 12px;
font-weight: 900;
}
.resText {
margin-top: 8px;
font-size: 13px;
font-weight: 900;
color: var(--vp-c-text-1);
}
.tip {
font-size: 12px;
color: var(--vp-c-text-2);
line-height: 1.6;
}
@media (max-width: 720px) {
.grid {
grid-template-columns: 1fr;
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.result-header {
font-weight: bold;
font-size: 14px;
margin-bottom: 8px;
color: var(--vp-c-text-1);
}
.result-body {
background: #f0fdf4;
border: 2px solid #86efac;
border-radius: 8px;
padding: 16px;
font-size: 14px;
line-height: 1.6;
color: #166534;
}
</style>
@@ -1,403 +1,207 @@
<!--
RequestResponseFlow.vue
参考 ide-intro 可视化演示风格更像一个小动画而不是表单
目标让新手理解一次 API 调用你发过去 -> 对方处理 -> 回给你
RequestResponseFlow.vue - 简化版
目标用简单的动画展示请求-响应流程
-->
<template>
<div class="wrap">
<div class="head">
<div class="title">一次 API 调用会发生什么</div>
<div class="sub">点一下发送看小纸飞机飞过去再飞回来</div>
</div>
<div class="demo">
<div class="title">🔄 一次 API 调用的流程</div>
<p class="subtitle">点一下按钮看请求怎么飞过去再飞回来</p>
<div class="controls">
<div class="ctrl">
<div class="ctrlK">地址</div>
<div class="ctrlV">
<button :class="['pill', { active: addrOk }]" @click="addrOk = true">
正确
</button>
<button
:class="['pill', { active: !addrOk }]"
@click="addrOk = false"
>
错误
</button>
</div>
</div>
<div class="ctrl">
<div class="ctrlK">钥匙</div>
<div class="ctrlV">
<button :class="['pill', { active: keyOk }]" @click="keyOk = true">
</button>
<button :class="['pill', { active: !keyOk }]" @click="keyOk = false">
没有
</button>
</div>
</div>
<button class="send" :disabled="busy" @click="send">
{{ busy ? '飞行中' : '发送一次模拟' }}
</button>
</div>
<div class="stage">
<div class="side">
<div class="flow-container">
<div class="side you">
<div class="window">
<div class="bar">
<span class="dot red" />
<span class="dot yellow" />
<span class="dot green" />
<span class="barText">你这边你的程序</span>
</div>
<div class="body">
<div class="bubble mine">
我想按按钮拿结果
<div class="small">
地址{{ addrOk ? '正确' : '错误' }}钥匙{{
keyOk ? '有' : '没有'
}}
</div>
</div>
<div class="window-header">👤 你这边</div>
<div class="window-body">
<div class="message">我想调用 API</div>
</div>
</div>
</div>
<div class="mid">
<div class="line" />
<div class="plane" :class="{ go: flying }"></div>
<div class="line" />
<div class="middle">
<div class="arrow" :class="{ animating: isAnimating }"></div>
<button class="send-btn" :disabled="isAnimating" @click="send">
{{ isAnimating ? '发送中...' : '🚀 发送请求' }}
</button>
</div>
<div class="side">
<div class="side server">
<div class="window">
<div class="bar">
<span class="dot red" />
<span class="dot yellow" />
<span class="dot green" />
<span class="barText">对方那边提供能力</span>
</div>
<div class="body">
<div class="bubble theirs">
{{ serverText }}
<div class="small">它按规则检查地址/钥匙/参数</div>
</div>
<div class="window-header">🖥 对方服务器</div>
<div class="window-body">
<div class="message">{{ serverMessage }}</div>
</div>
</div>
</div>
</div>
<div class="result">
<div class="resultTitle">返回结果</div>
<div v-if="!response" class="muted">还没有结果点一下发送一次</div>
<div
v-else
class="resBox"
:class="{ ok: response.ok, bad: !response.ok }"
>
<div class="badge">{{ response.ok ? '成功' : '失败' }}</div>
<div class="resText">{{ response.text }}</div>
</div>
<div class="tip">
玩法地址改成错误或者把钥匙改成没有再发送一次
<div class="result" v-if="result">
<div class="result-box" :class="result.type">
{{ result.text }}
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { ref } from 'vue'
const addrOk = ref(true)
const keyOk = ref(true)
const busy = ref(false)
const flying = ref(false)
const response = ref(null)
const isAnimating = ref(false)
const serverMessage = ref('等待请求...')
const result = ref(null)
const serverText = computed(() => {
if (!addrOk.value) return '我找不到这个按钮(地址错了)'
if (!keyOk.value) return '你没有钥匙,我不能给你结果'
return '收到!我去处理一下…'
})
function send() {
isAnimating.value = true
serverMessage.value = '收到请求,处理中...'
result.value = null
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms))
}
async function send() {
if (busy.value) return
busy.value = true
flying.value = true
response.value = null
await sleep(600)
flying.value = false
await sleep(260)
if (!addrOk.value) {
response.value = { ok: false, text: '失败:地址不对(找不到这个按钮)' }
} else if (!keyOk.value) {
response.value = { ok: false, text: '失败:没有钥匙(没权限)' }
} else {
response.value = { ok: true, text: '成功:拿到结果(模拟)' }
}
busy.value = false
// 模拟请求流程
setTimeout(() => {
serverMessage.value = '处理完成!'
result.value = {
type: 'success',
text: '✅ 请求成功!服务器返回了数据'
}
isAnimating.value = false
}, 1500)
}
</script>
<style scoped>
.wrap {
.demo {
border: 1px solid var(--vp-c-divider);
border-radius: 14px;
border-radius: 12px;
padding: 20px;
background: var(--vp-c-bg-soft);
padding: 16px;
}
.head {
display: flex;
flex-direction: column;
gap: 6px;
margin: 16px 0;
}
.title {
font-weight: 900;
font-size: 16px;
font-size: 18px;
font-weight: bold;
margin-bottom: 8px;
color: var(--vp-c-text-1);
}
.sub {
font-size: 13px;
.subtitle {
color: var(--vp-c-text-2);
margin-bottom: 20px;
}
.controls {
margin-top: 12px;
.flow-container {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: flex-end;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.ctrl {
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
.side {
flex: 1;
}
.window {
background: var(--vp-c-bg);
padding: 10px 12px;
}
.ctrlK {
font-size: 12px;
color: var(--vp-c-text-2);
font-weight: 900;
}
.ctrlV {
margin-top: 8px;
display: flex;
gap: 8px;
}
.pill {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
color: var(--vp-c-text-1);
border-radius: 999px;
padding: 6px 10px;
font-weight: 900;
font-size: 13px;
cursor: pointer;
}
.pill.active {
border-color: var(--vp-c-brand-1);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--vp-c-brand-1) 18%, transparent);
}
.send {
border: 1px solid var(--vp-c-brand-1);
background: var(--vp-c-brand-1);
color: #fff;
border: 2px solid var(--vp-c-divider);
border-radius: 12px;
padding: 10px 12px;
font-weight: 900;
cursor: pointer;
overflow: hidden;
}
.send:disabled {
.window-header {
background: var(--vp-c-bg-soft);
padding: 12px;
font-weight: bold;
font-size: 14px;
border-bottom: 1px solid var(--vp-c-divider);
text-align: center;
}
.window-body {
padding: 20px;
min-height: 80px;
display: flex;
align-items: center;
justify-content: center;
}
.message {
font-size: 14px;
color: var(--vp-c-text-1);
text-align: center;
}
.middle {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.arrow {
font-size: 32px;
color: var(--vp-c-brand-1);
transition: transform 0.3s;
}
.arrow.animating {
animation: pulse 0.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.2); }
}
.send-btn {
background: var(--vp-c-brand-1);
color: white;
border: none;
padding: 10px 20px;
font-size: 14px;
font-weight: bold;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.send-btn:hover:not(:disabled) {
opacity: 0.9;
}
.send-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.stage {
margin-top: 12px;
display: grid;
grid-template-columns: 1fr 120px 1fr;
gap: 12px;
align-items: center;
}
.window {
border: 1px solid var(--vp-c-divider);
border-radius: 14px;
background: var(--vp-c-bg);
overflow: hidden;
}
.bar {
display: flex;
gap: 6px;
align-items: center;
padding: 10px 12px;
border-bottom: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
}
.dot {
width: 10px;
height: 10px;
border-radius: 999px;
}
.dot.red {
background: #ef4444;
}
.dot.yellow {
background: #f59e0b;
}
.dot.green {
background: #22c55e;
}
.barText {
margin-left: 6px;
font-size: 12px;
font-weight: 900;
color: var(--vp-c-text-2);
}
.body {
padding: 12px;
min-height: 92px;
}
.bubble {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-soft);
border-radius: 12px;
padding: 10px 12px;
font-size: 13px;
font-weight: 800;
color: var(--vp-c-text-1);
}
.bubble.mine {
border-color: color-mix(in srgb, #60a5fa 40%, var(--vp-c-divider));
}
.bubble.theirs {
border-color: color-mix(in srgb, #a78bfa 45%, var(--vp-c-divider));
}
.small {
margin-top: 6px;
font-size: 12px;
color: var(--vp-c-text-2);
font-weight: 600;
}
.mid {
display: grid;
justify-items: center;
gap: 10px;
}
.line {
width: 100%;
height: 2px;
background: var(--vp-c-divider);
border-radius: 99px;
}
.plane {
font-size: 18px;
color: var(--vp-c-text-3);
transition:
transform 600ms ease,
color 200ms ease;
}
.plane.go {
transform: translateX(28px);
color: var(--vp-c-brand-1);
}
.result {
margin-top: 12px;
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg);
padding: 12px;
margin-top: 16px;
}
.resultTitle {
font-weight: 900;
font-size: 13px;
color: var(--vp-c-text-1);
.result-box {
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
}
.muted {
margin-top: 10px;
font-size: 12px;
color: var(--vp-c-text-2);
.result-box.success {
background: #dcfce7;
color: #166534;
border: 1px solid #86efac;
}
.resBox {
margin-top: 10px;
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg-soft);
padding: 10px 12px;
}
.resBox.ok {
border-color: color-mix(in srgb, #22c55e 45%, var(--vp-c-divider));
}
.resBox.bad {
border-color: color-mix(in srgb, #ef4444 45%, var(--vp-c-divider));
}
.badge {
display: inline-block;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
border-radius: 999px;
padding: 2px 10px;
font-size: 12px;
font-weight: 900;
color: var(--vp-c-text-1);
}
.resText {
margin-top: 8px;
font-size: 13px;
font-weight: 900;
color: var(--vp-c-text-1);
}
.tip {
margin-top: 10px;
font-size: 12px;
color: var(--vp-c-text-2);
line-height: 1.6;
.result-box.error {
background: #fee2e2;
color: #991b1b;
border: 1px solid #fca5a5;
}
@media (max-width: 720px) {
.stage {
grid-template-columns: 1fr;
.flow-container {
flex-direction: column;
}
.mid {
display: none;
.middle {
flex-direction: row;
}
}
</style>