448 lines
9.2 KiB
Vue
448 lines
9.2 KiB
Vue
|
|
<!--
|
|||
|
|
ApiConceptDemo.vue
|
|||
|
|
参考 ide-intro 的“虚拟 UI + 先玩再讲”风格。
|
|||
|
|
目标:用一个简单的“分类小游戏”讲清楚 API 到底要写清楚什么,
|
|||
|
|
以及哪些是“你不用管的内部细节”。
|
|||
|
|
-->
|
|||
|
|
<template>
|
|||
|
|
<div class="wrap">
|
|||
|
|
<div class="head">
|
|||
|
|
<div class="title">API 到底是什么?(先玩 10 秒)</div>
|
|||
|
|
<div class="sub">
|
|||
|
|
下面有一些卡片。请你把它们分成两类:<b>必须写清楚</b> vs
|
|||
|
|
<b>不用写给别人看</b>。
|
|||
|
|
</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>
|
|||
|
|
|
|||
|
|
<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>
|
|||
|
|
|
|||
|
|
<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'
|
|||
|
|
|
|||
|
|
const all = [
|
|||
|
|
// must
|
|||
|
|
{
|
|||
|
|
id: 'where',
|
|||
|
|
bucket: 'must',
|
|||
|
|
type: 'must',
|
|||
|
|
typeLabel: '必填脑子',
|
|||
|
|
text: '入口在哪(网址 / 函数名)'
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'input',
|
|||
|
|
bucket: 'must',
|
|||
|
|
type: 'must',
|
|||
|
|
typeLabel: '必填脑子',
|
|||
|
|
text: '要填什么(需要哪些信息)'
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'output',
|
|||
|
|
bucket: 'must',
|
|||
|
|
type: 'must',
|
|||
|
|
typeLabel: '必填脑子',
|
|||
|
|
text: '会拿到什么(成功的结果)'
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
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: '内部怎么做性能优化'
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
const must = ref([])
|
|||
|
|
const internal = ref([])
|
|||
|
|
const checked = ref(false)
|
|||
|
|
const score = reactive({ ok: 0, bad: 0 })
|
|||
|
|
|
|||
|
|
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
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.wrap {
|
|||
|
|
border: 1px solid var(--vp-c-divider);
|
|||
|
|
border-radius: 14px;
|
|||
|
|
background: var(--vp-c-bg-soft);
|
|||
|
|
padding: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.head {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 6px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.title {
|
|||
|
|
font-weight: 900;
|
|||
|
|
font-size: 16px;
|
|||
|
|
color: var(--vp-c-text-1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.sub {
|
|||
|
|
font-size: 13px;
|
|||
|
|
color: var(--vp-c-text-2);
|
|||
|
|
line-height: 1.6;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.arena {
|
|||
|
|
margin-top: 12px;
|
|||
|
|
display: grid;
|
|||
|
|
grid-template-columns: 1fr 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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card {
|
|||
|
|
border: 1px 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;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.card:hover {
|
|||
|
|
border-color: var(--vp-c-brand-1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.cardTop {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: flex-end;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.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);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.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;
|
|||
|
|
color: var(--vp-c-text-1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.binHint {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: var(--vp-c-text-2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.drop {
|
|||
|
|
margin-top: 10px;
|
|||
|
|
min-height: 120px;
|
|||
|
|
border: 1px dashed var(--vp-c-divider);
|
|||
|
|
border-radius: 12px;
|
|||
|
|
padding: 10px;
|
|||
|
|
display: flex;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
gap: 8px;
|
|||
|
|
align-items: flex-start;
|
|||
|
|
align-content: flex-start;
|
|||
|
|
background: var(--vp-c-bg-soft);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.empty {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: var(--vp-c-text-3);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chip {
|
|||
|
|
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-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);
|
|||
|
|
padding: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.resultTitle {
|
|||
|
|
font-weight: 900;
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</style>
|