refactor: 重构 api-intro、api-design、transistor-to-cpu 组件为紧凑布局

- 重构 api-intro 7 个 Vue 组件为更紧凑的左右布局
- 重构 api-design 相关组件
- 重构 transistor-to-cpu 相关组件
- 统一使用 demo-root -> demo-header -> demo-layout -> info-box 结构
- 扩写文章内容为 MIT 讲义风格
This commit is contained in:
sanbuphy
2026-02-23 01:50:43 +08:00
parent 2a0fdd3392
commit 1062e2e16f
68 changed files with 4455 additions and 3469 deletions
@@ -14,7 +14,9 @@
</div>
<div class="t-line">
<span class="t-ps">&gt; </span>
<span class="t-typing">{{ typing }}<span class="t-cur"></span></span>
<span class="t-typing"
>{{ typing }}<span class="t-cur"></span></span
>
</div>
</div>
</div>
@@ -23,18 +25,30 @@
v-for="op in ops"
:key="op.id"
:disabled="running || !op.ok()"
:class="['ar-btn', { 'ar-btn--on': active === op.id, 'ar-btn--dim': !op.ok() }]"
:class="[
'ar-btn',
{ 'ar-btn--on': active === op.id, 'ar-btn--dim': !op.ok() }
]"
@click="run(op)"
>
<code>{{ op.cmd }}</code>
</button>
<button class="ar-btn ar-btn--reset" :disabled="running" @click="reset">重置</button>
<button
class="ar-btn ar-btn--reset"
:disabled="running"
@click="reset"
>
重置
</button>
</div>
</div>
<div class="ar-right">
<div class="ar-flow">
<div class="flow-col flow-client" :class="{ 'flow-highlight': pulseArea === 'client' }">
<div
class="flow-col flow-client"
:class="{ 'flow-highlight': pulseArea === 'client' }"
>
<div class="flow-header">
<span class="flow-icon">💻</span>
<span class="flow-title">客户端</span>
@@ -43,7 +57,9 @@
<div class="flow-body">
<div v-if="requestData" class="req-preview">
<div class="req-line">
<span class="req-method" :class="requestData.method">{{ requestData.method }}</span>
<span class="req-method" :class="requestData.method">{{
requestData.method
}}</span>
<span class="req-url">{{ requestData.url }}</span>
</div>
<div v-if="requestData.body" class="req-body">
@@ -54,12 +70,18 @@
</div>
</div>
<div class="flow-arrow" :class="{ 'arrow-lit': pulseArea === 'request' }">
<div
class="flow-arrow"
:class="{ 'arrow-lit': pulseArea === 'request' }"
>
<code class="arrow-label">HTTP Request</code>
<span class="arrow-symbol"></span>
</div>
<div class="flow-col flow-server" :class="{ 'flow-highlight': pulseArea === 'server' }">
<div
class="flow-col flow-server"
:class="{ 'flow-highlight': pulseArea === 'server' }"
>
<div class="flow-header">
<span class="flow-icon">🖥</span>
<span class="flow-title">服务器</span>
@@ -74,12 +96,18 @@
</div>
</div>
<div class="flow-arrow" :class="{ 'arrow-lit': pulseArea === 'response' }">
<div
class="flow-arrow"
:class="{ 'arrow-lit': pulseArea === 'response' }"
>
<code class="arrow-label">HTTP Response</code>
<span class="arrow-symbol"></span>
</div>
<div class="flow-col flow-response" :class="{ 'flow-highlight': pulseArea === 'response' }">
<div
class="flow-col flow-response"
:class="{ 'flow-highlight': pulseArea === 'response' }"
>
<div class="flow-header">
<span class="flow-icon">📦</span>
<span class="flow-title">响应</span>
@@ -109,7 +137,9 @@
import { ref, nextTick } from 'vue'
const termEl = ref(null)
const lines = ref([{ kind: 'dim', text: '// 点击下方按钮,模拟不同的 API 请求' }])
const lines = ref([
{ kind: 'dim', text: '// 点击下方按钮,模拟不同的 API 请求' }
])
const typing = ref('')
const running = ref(false)
const active = ref(null)
@@ -120,7 +150,7 @@ const requestData = ref(null)
const serverStatus = ref(null)
const responseData = ref(null)
const sleep = ms => new Promise(r => setTimeout(r, ms))
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const ops = [
{
@@ -132,7 +162,7 @@ const ops = [
{ kind: 'grn', text: 'HTTP/1.1 200 OK' },
{ kind: 'dim', text: 'Content-Type: application/json' },
{ kind: 'dim', text: '' },
{ kind: 'grn', text: '{ "code": 0, "data": { "items": [...] } }' },
{ kind: 'grn', text: '{ "code": 0, "data": { "items": [...] } }' }
],
hint: 'GET 请求成功!状态码 200 表示请求正常。服务器返回了用户列表数据。',
do: async () => {
@@ -163,7 +193,10 @@ const ops = [
{ kind: 'grn', text: 'HTTP/1.1 201 Created' },
{ kind: 'dim', text: 'Location: /api/users/3' },
{ kind: 'dim', text: '' },
{ kind: 'grn', text: '{ "code": 0, "data": { "id": 3, "name": "王五" } }' },
{
kind: 'grn',
text: '{ "code": 0, "data": { "id": 3, "name": "王五" } }'
}
],
hint: 'POST 创建成功!状态码 201 表示资源已创建,响应头 Location 指向新资源地址。',
do: async () => {
@@ -199,7 +232,7 @@ const ops = [
{ kind: 'dim', text: '// 获取不存在的用户' },
{ kind: 'red', text: 'HTTP/1.1 404 Not Found' },
{ kind: 'dim', text: '' },
{ kind: 'red', text: '{ "code": 10002, "message": "用户不存在" }' },
{ kind: 'red', text: '{ "code": 10002, "message": "用户不存在" }' }
],
hint: '404 错误!请求的资源不存在。客户端应该检查请求的 ID 是否正确。',
do: async () => {
@@ -230,7 +263,7 @@ const ops = [
{ kind: 'red', text: 'HTTP/1.1 401 Unauthorized' },
{ kind: 'dim', text: 'WWW-Authenticate: Bearer' },
{ kind: 'dim', text: '' },
{ kind: 'red', text: '{ "code": 10018, "message": "请先登录" }' },
{ kind: 'red', text: '{ "code": 10018, "message": "请先登录" }' }
],
hint: '401 错误!需要身份认证。客户端应该引导用户登录后再重试。',
do: async () => {
@@ -255,7 +288,7 @@ const ops = [
body: '{\n "code": 10018,\n "message": "请先登录"\n}'
}
}
},
}
]
async function run(op) {
@@ -291,7 +324,9 @@ async function run(op) {
await sleep(120)
hint.value = op.hint
running.value = false
setTimeout(() => { pulseArea.value = null }, 1500)
setTimeout(() => {
pulseArea.value = null
}, 1500)
}
function scroll() {
@@ -342,11 +377,19 @@ function reset() {
}
@media (max-width: 768px) {
.ar-layout { flex-direction: column; }
.ar-right { width: 100%; border-left: none; border-top: 1px solid var(--vp-c-divider); }
.ar-layout {
flex-direction: column;
}
.ar-right {
width: 100%;
border-left: none;
border-top: 1px solid var(--vp-c-divider);
}
}
.ar-terminal { background: #141420; }
.ar-terminal {
background: #141420;
}
.term-bar {
display: flex;
align-items: center;
@@ -354,11 +397,26 @@ function reset() {
padding: 7px 12px;
background: #1e1e2e;
}
.dot { width: 11px; height: 11px; border-radius: 50%; }
.dot.r { background: #ff5f57; }
.dot.y { background: #febc2e; }
.dot.g { background: #28c840; }
.term-title { margin-left: 8px; font-size: 0.72rem; color: #666; font-family: monospace; }
.dot {
width: 11px;
height: 11px;
border-radius: 50%;
}
.dot.r {
background: #ff5f57;
}
.dot.y {
background: #febc2e;
}
.dot.g {
background: #28c840;
}
.term-title {
margin-left: 8px;
font-size: 0.72rem;
color: #666;
font-family: monospace;
}
.term-body {
min-height: 120px;
@@ -371,15 +429,41 @@ function reset() {
line-height: 1.65;
color: #cdd6f4;
}
.t-line { display: flex; min-width: min-content; }
.t-ps { color: #89b4fa; flex-shrink: 0; }
.t-cmd { color: #cdd6f4; }
.t-dim { color: #585b70; }
.t-grn { color: #a6e3a1; }
.t-red { color: #f38ba8; }
.t-typing { color: #cdd6f4; }
.t-cur { animation: blink 1s step-end infinite; }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
.t-line {
display: flex;
min-width: min-content;
}
.t-ps {
color: #89b4fa;
flex-shrink: 0;
}
.t-cmd {
color: #cdd6f4;
}
.t-dim {
color: #585b70;
}
.t-grn {
color: #a6e3a1;
}
.t-red {
color: #f38ba8;
}
.t-typing {
color: #cdd6f4;
}
.t-cur {
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
.ar-btns {
display: flex;
@@ -397,18 +481,38 @@ function reset() {
cursor: pointer;
transition: border-color 0.2s;
}
.ar-btn code { font-size: 0.7rem; color: #7f849c; font-family: monospace; white-space: nowrap; }
.ar-btn:hover:not(:disabled) { border-color: var(--vp-c-brand); }
.ar-btn--on { border-color: var(--vp-c-brand) !important; }
.ar-btn--on code { color: var(--vp-c-brand); }
.ar-btn--dim { opacity: 0.3; cursor: not-allowed; }
.ar-btn code {
font-size: 0.7rem;
color: #7f849c;
font-family: monospace;
white-space: nowrap;
}
.ar-btn:hover:not(:disabled) {
border-color: var(--vp-c-brand);
}
.ar-btn--on {
border-color: var(--vp-c-brand) !important;
}
.ar-btn--on code {
color: var(--vp-c-brand);
}
.ar-btn--dim {
opacity: 0.3;
cursor: not-allowed;
}
.ar-btn--reset {
background: transparent;
border-color: #313244;
margin-left: auto;
}
.ar-btn--reset code { display: none; }
.ar-btn--reset::after { content: '重置'; font-size: 0.7rem; color: #585b70; }
.ar-btn--reset code {
display: none;
}
.ar-btn--reset::after {
content: '重置';
font-size: 0.7rem;
color: #585b70;
}
.ar-flow {
display: flex;
@@ -426,15 +530,23 @@ function reset() {
display: flex;
flex-direction: column;
min-height: 60px;
transition: border-color 0.25s, box-shadow 0.25s;
transition:
border-color 0.25s,
box-shadow 0.25s;
}
.flow-col.flow-highlight {
border-color: var(--vp-c-brand);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--vp-c-brand) 14%, transparent);
}
.flow-client { border-left: 4px solid #89b4fa; }
.flow-server { border-left: 4px solid #f9e2af; }
.flow-response { border-left: 4px solid #a6e3a1; }
.flow-client {
border-left: 4px solid #89b4fa;
}
.flow-server {
border-left: 4px solid #f9e2af;
}
.flow-response {
border-left: 4px solid #a6e3a1;
}
.flow-header {
padding: 6px 8px;
@@ -444,9 +556,19 @@ function reset() {
align-items: center;
gap: 6px;
}
.flow-icon { font-size: 0.9rem; }
.flow-title { font-weight: 700; font-size: 0.8rem; color: var(--vp-c-text-1); }
.flow-desc { font-size: 0.7rem; color: var(--vp-c-text-3); margin-left: auto; }
.flow-icon {
font-size: 0.9rem;
}
.flow-title {
font-weight: 700;
font-size: 0.8rem;
color: var(--vp-c-text-1);
}
.flow-desc {
font-size: 0.7rem;
color: var(--vp-c-text-3);
margin-left: auto;
}
.flow-body {
padding: 8px 10px;
@@ -459,18 +581,36 @@ function reset() {
font-style: italic;
}
.req-preview, .res-preview { font-size: 0.72rem; }
.req-line { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.req-preview,
.res-preview {
font-size: 0.72rem;
}
.req-line {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.req-method {
padding: 2px 6px;
border-radius: 3px;
font-weight: 700;
font-size: 0.68rem;
}
.req-method.GET { background: #22c55e22; color: #22c55e; }
.req-method.POST { background: #3b82f622; color: #3b82f6; }
.req-url { font-family: monospace; color: var(--vp-c-text-1); }
.req-body pre, .res-body pre {
.req-method.GET {
background: #22c55e22;
color: #22c55e;
}
.req-method.POST {
background: #3b82f622;
color: #3b82f6;
}
.req-url {
font-family: monospace;
color: var(--vp-c-text-1);
}
.req-body pre,
.res-body pre {
margin: 0;
padding: 6px;
background: var(--vp-c-bg-soft);
@@ -486,8 +626,12 @@ function reset() {
gap: 8px;
font-size: 0.8rem;
}
.status-icon { font-size: 1rem; }
.status-text { color: var(--vp-c-text-2); }
.status-icon {
font-size: 1rem;
}
.status-text {
color: var(--vp-c-text-2);
}
.res-status {
display: inline-block;
@@ -497,8 +641,14 @@ function reset() {
font-size: 0.7rem;
margin-bottom: 6px;
}
.res-status.success { background: #22c55e22; color: #22c55e; }
.res-status.error { background: #ef444422; color: #ef4444; }
.res-status.success {
background: #22c55e22;
color: #22c55e;
}
.res-status.error {
background: #ef444422;
color: #ef4444;
}
.flow-arrow {
display: flex;
@@ -509,7 +659,9 @@ function reset() {
opacity: 0.3;
transition: opacity 0.3s;
}
.flow-arrow.arrow-lit { opacity: 1; }
.flow-arrow.arrow-lit {
opacity: 1;
}
.arrow-label {
font-size: 0.65rem;
font-family: monospace;
@@ -22,12 +22,17 @@
v-for="op in ops"
:key="op.id"
:disabled="running || !op.ok()"
:class="['av-btn', { 'av-btn--on': active === op.id, 'av-btn--dim': !op.ok() }]"
:class="[
'av-btn',
{ 'av-btn--on': active === op.id, 'av-btn--dim': !op.ok() }
]"
@click="run(op)"
>
<code>{{ op.cmd }}</code>
</button>
<button class="av-btn av-btn--reset" :disabled="running" @click="reset">重置</button>
<button class="av-btn av-btn--reset" :disabled="running" @click="reset">
重置
</button>
</div>
<div class="av-versions">
@@ -90,7 +95,7 @@ const active = ref(null)
const activeVersion = ref('')
const hint = ref('点击按钮,了解 API 版本控制的策略和最佳实践。')
const sleep = ms => new Promise(r => setTimeout(r, ms))
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const ops = [
{
@@ -108,10 +113,12 @@ const ops = [
{ kind: 'dim', text: '' },
{ kind: 'grn', text: '✅ 正确做法:' },
{ kind: 'grn', text: ' /v1/orders - 旧接口,继续服务旧 App' },
{ kind: 'grn', text: ' /v2/orders - 新接口,新功能在这里' },
{ kind: 'grn', text: ' /v2/orders - 新接口,新功能在这里' }
],
hint: '版本控制让新旧客户端都能正常工作。旧 App 用户可以慢慢升级,不会突然崩溃。',
do: () => { activeVersion.value = '' }
do: () => {
activeVersion.value = ''
}
},
{
id: 'url',
@@ -125,10 +132,12 @@ const ops = [
{ kind: 'grn', text: 'GET /v3/users' },
{ kind: 'dim', text: '' },
{ kind: 'dim', text: '优点:直观、易缓存、浏览器友好' },
{ kind: 'dim', text: '缺点:URL 变长' },
{ kind: 'dim', text: '缺点:URL 变长' }
],
hint: 'URL 路径版本是最常用的方式。GitHub、Twitter、Stripe 都用这种方式。',
do: () => { activeVersion.value = 'v1' }
do: () => {
activeVersion.value = 'v1'
}
},
{
id: 'header',
@@ -145,10 +154,12 @@ const ops = [
{ kind: 'grn', text: 'X-API-Version: 2' },
{ kind: 'dim', text: '' },
{ kind: 'dim', text: '优点:URL 干净' },
{ kind: 'dim', text: '缺点:不便调试、缓存复杂' },
{ kind: 'dim', text: '缺点:不便调试、缓存复杂' }
],
hint: 'Header 版本让 URL 更干净,但调试时需要额外设置 Header,不如 URL 版本直观。',
do: () => { activeVersion.value = 'v2' }
do: () => {
activeVersion.value = 'v2'
}
},
{
id: 'query',
@@ -161,10 +172,12 @@ const ops = [
{ kind: 'grn', text: 'GET /users?version=2' },
{ kind: 'dim', text: '' },
{ kind: 'dim', text: '优点:简单、向后兼容' },
{ kind: 'dim', text: '缺点:容易被忽略、不是 RESTful 标准' },
{ kind: 'dim', text: '缺点:容易被忽略、不是 RESTful 标准' }
],
hint: '查询参数版本简单但不够"正规"。适合内部 API 或快速迭代的项目。',
do: () => { activeVersion.value = '' }
do: () => {
activeVersion.value = ''
}
},
{
id: 'best',
@@ -177,11 +190,13 @@ const ops = [
{ kind: 'grn', text: '2. 新功能放新版本,旧版本保持稳定' },
{ kind: 'grn', text: '3. 设置废弃时间线(如 v1 将在 2025-06 废弃)' },
{ kind: 'grn', text: '4. 响应头标注当前版本和废弃信息' },
{ kind: 'grn', text: '5. 文档明确标注每个版本的变更' },
{ kind: 'grn', text: '5. 文档明确标注每个版本的变更' }
],
hint: '版本控制不是"以后再说"的事,从第一天就应该规划好。废弃旧版本要给用户足够的迁移时间。',
do: () => { activeVersion.value = 'v2' }
},
do: () => {
activeVersion.value = 'v2'
}
}
]
async function run(op) {
@@ -239,7 +254,9 @@ function reset() {
font-size: 0.85rem;
}
.av-terminal { background: #141420; }
.av-terminal {
background: #141420;
}
.term-bar {
display: flex;
align-items: center;
@@ -247,11 +264,26 @@ function reset() {
padding: 7px 12px;
background: #1e1e2e;
}
.dot { width: 11px; height: 11px; border-radius: 50%; }
.dot.r { background: #ff5f57; }
.dot.y { background: #febc2e; }
.dot.g { background: #28c840; }
.term-title { margin-left: 8px; font-size: 0.72rem; color: #666; font-family: monospace; }
.dot {
width: 11px;
height: 11px;
border-radius: 50%;
}
.dot.r {
background: #ff5f57;
}
.dot.y {
background: #febc2e;
}
.dot.g {
background: #28c840;
}
.term-title {
margin-left: 8px;
font-size: 0.72rem;
color: #666;
font-family: monospace;
}
.term-body {
min-height: 100px;
@@ -264,16 +296,44 @@ function reset() {
line-height: 1.6;
color: #cdd6f4;
}
.t-line { display: flex; min-width: min-content; }
.t-ps { color: #a6e3a1; flex-shrink: 0; }
.t-cmd { color: #cdd6f4; }
.t-dim { color: #585b70; }
.t-grn { color: #a6e3a1; }
.t-red { color: #f38ba8; }
.t-yel { color: #f9e2af; }
.t-typing { color: #cdd6f4; }
.t-cur { animation: blink 1s step-end infinite; }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
.t-line {
display: flex;
min-width: min-content;
}
.t-ps {
color: #a6e3a1;
flex-shrink: 0;
}
.t-cmd {
color: #cdd6f4;
}
.t-dim {
color: #585b70;
}
.t-grn {
color: #a6e3a1;
}
.t-red {
color: #f38ba8;
}
.t-yel {
color: #f9e2af;
}
.t-typing {
color: #cdd6f4;
}
.t-cur {
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
.av-btns {
display: flex;
@@ -291,18 +351,38 @@ function reset() {
cursor: pointer;
transition: border-color 0.2s;
}
.av-btn code { font-size: 0.68rem; color: #7f849c; font-family: monospace; white-space: nowrap; }
.av-btn:hover:not(:disabled) { border-color: var(--vp-c-brand); }
.av-btn--on { border-color: var(--vp-c-brand) !important; }
.av-btn--on code { color: var(--vp-c-brand); }
.av-btn--dim { opacity: 0.3; cursor: not-allowed; }
.av-btn code {
font-size: 0.68rem;
color: #7f849c;
font-family: monospace;
white-space: nowrap;
}
.av-btn:hover:not(:disabled) {
border-color: var(--vp-c-brand);
}
.av-btn--on {
border-color: var(--vp-c-brand) !important;
}
.av-btn--on code {
color: var(--vp-c-brand);
}
.av-btn--dim {
opacity: 0.3;
cursor: not-allowed;
}
.av-btn--reset {
background: transparent;
border-color: #313244;
margin-left: auto;
}
.av-btn--reset code { display: none; }
.av-btn--reset::after { content: '重置'; font-size: 0.7rem; color: #585b70; }
.av-btn--reset code {
display: none;
}
.av-btn--reset::after {
content: '重置';
font-size: 0.7rem;
color: #585b70;
}
.av-versions {
display: flex;
@@ -22,17 +22,20 @@
<div class="compare-row">
<div class="compare-col">
<div class="compare-title">单对象</div>
<pre class="code-sm">{
<pre class="code-sm">
{
"code": 0,
"data": {
"id": 123,
"name": "张三"
}
}</pre>
}</pre
>
</div>
<div class="compare-col">
<div class="compare-title">列表</div>
<pre class="code-sm">{
<pre class="code-sm">
{
"code": 0,
"data": {
"items": [...],
@@ -41,34 +44,27 @@
"total": 100
}
}
}</pre>
}</pre
>
</div>
</div>
<div class="note">列表数据包裹在 items 数组中分页信息放在 pagination 对象</div>
<div class="note">
列表数据包裹在 items 数组中分页信息放在 pagination 对象
</div>
</div>
<div v-if="active === 'naming'" class="section">
<h4>字段命名规范</h4>
<div class="rule-list">
<div
v-for="rule in namingRules"
:key="rule.name"
class="rule-item"
>
<div v-for="rule in namingRules" :key="rule.name" class="rule-item">
<div class="rule-header">
<span class="rule-icon">{{ rule.icon }}</span>
<span class="rule-name">{{ rule.name }}</span>
</div>
<div class="rule-examples">
<code class="good">{{ rule.good }}</code>
<span
v-if="rule.bad"
class="vs"
>vs</span>
<code
v-if="rule.bad"
class="bad"
>{{ rule.bad }}</code>
<span v-if="rule.bad" class="vs">vs</span>
<code v-if="rule.bad" class="bad">{{ rule.bad }}</code>
</div>
<div class="rule-desc">{{ rule.desc }}</div>
</div>
@@ -78,11 +74,13 @@
<div v-if="active === 'datetime'" class="section">
<h4>时间格式设计</h4>
<div class="time-example">
<pre class="code-block">{
<pre class="code-block">
{
"created_at": "2024-01-15T09:30:00.000Z",
"updated_at": "2024-01-15T10:00:00.000Z",
"expired_at": "2025-01-15T00:00:00.000Z"
}</pre>
}</pre
>
</div>
<div class="time-rules">
<div class="time-rule">
@@ -99,7 +97,9 @@
</div>
<div class="time-rule">
<span class="rule-label">命名</span>
<span class="rule-value">xxx_at 表示时间点xxx_duration 表示时长</span>
<span class="rule-value"
>xxx_at 表示时间点xxx_duration 表示时长</span
>
</div>
</div>
</div>
@@ -109,18 +109,22 @@
<div class="compare-row">
<div class="compare-col good-col">
<div class="compare-title"> 推荐</div>
<pre class="code-sm">{
<pre class="code-sm">
{
"name": "张三",
"nickname": null,
"avatar": null
}</pre>
}</pre
>
<div class="compare-desc">字段存在但无值时返回 null</div>
</div>
<div class="compare-col bad-col">
<div class="compare-title"> 不推荐</div>
<pre class="code-sm">{
<pre class="code-sm">
{
"name": "张三"
}</pre>
}</pre
>
<div class="compare-desc">省略字段前端需判断是否存在</div>
</div>
</div>
@@ -152,7 +156,9 @@
<div class="tips">
<span class="tips-icon">💡</span>
<span class="tips-text">参考 ISO 8601 时间标准字段命名保持 snake_case 风格</span>
<span class="tips-text"
>参考 ISO 8601 时间标准字段命名保持 snake_case 风格</span
>
</div>
</div>
</template>
@@ -172,11 +178,41 @@ const tabs = [
]
const namingRules = [
{ icon: '🔡', name: '使用 snake_case', good: 'created_at', bad: 'createdAt', desc: 'JSON 字段名统一用下划线' },
{ icon: '📖', name: '避免缩写', good: 'user_id', bad: 'uid', desc: '保持可读性' },
{ icon: '✅', name: '布尔值加前缀', good: 'is_active, has_permission', bad: 'active, permission', desc: '一眼识别布尔类型' },
{ icon: '📅', name: '时间带后缀', good: 'created_at, expired_at', bad: 'created, expired', desc: '明确是时间字段' },
{ icon: '🔢', name: '数量带后缀', good: 'total_count, page_size', bad: 'total, size', desc: '明确是数值类型' }
{
icon: '🔡',
name: '使用 snake_case',
good: 'created_at',
bad: 'createdAt',
desc: 'JSON 字段名统一用下划线'
},
{
icon: '📖',
name: '避免缩写',
good: 'user_id',
bad: 'uid',
desc: '保持可读性'
},
{
icon: '✅',
name: '布尔值加前缀',
good: 'is_active, has_permission',
bad: 'active, permission',
desc: '一眼识别布尔类型'
},
{
icon: '📅',
name: '时间带后缀',
good: 'created_at, expired_at',
bad: 'created, expired',
desc: '明确是时间字段'
},
{
icon: '🔢',
name: '数量带后缀',
good: 'total_count, page_size',
bad: 'total, size',
desc: '明确是数值类型'
}
]
const relations = [
@@ -220,7 +256,7 @@ const relations = [
]
const currentRelation = computed(() => {
return relations.find(r => r.id === rId.value) || relations[0]
return relations.find((r) => r.id === rId.value) || relations[0]
})
</script>
@@ -22,18 +22,25 @@
v-for="op in ops"
:key="op.id"
:disabled="running || !op.ok()"
:class="['eh-btn', { 'eh-btn--on': active === op.id, 'eh-btn--dim': !op.ok() }]"
:class="[
'eh-btn',
{ 'eh-btn--on': active === op.id, 'eh-btn--dim': !op.ok() }
]"
@click="run(op)"
>
<code>{{ op.cmd }}</code>
</button>
<button class="eh-btn eh-btn--reset" :disabled="running" @click="reset">重置</button>
<button class="eh-btn eh-btn--reset" :disabled="running" @click="reset">
重置
</button>
</div>
<div class="eh-response">
<div class="res-header">
<span class="res-label">响应结构</span>
<span class="res-status" :class="responseStatus">{{ responseStatus }}</span>
<span class="res-status" :class="responseStatus">{{
responseStatus
}}</span>
</div>
<div class="res-body">
<pre v-if="responseData">{{ responseData }}</pre>
@@ -57,7 +64,7 @@ const hint = ref('点击按钮,对比"好的"和"差的"错误响应设计。'
const responseData = ref('')
const responseStatus = ref('')
const sleep = ms => new Promise(r => setTimeout(r, ms))
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const ops = [
{
@@ -68,7 +75,7 @@ const ops = [
{ kind: 'dim', text: '// HTTP 200 但业务失败' },
{ kind: 'yel', text: 'HTTP/1.1 200 OK' },
{ kind: 'dim', text: '' },
{ kind: 'yel', text: '{ "error": "出错了" }' },
{ kind: 'yel', text: '{ "error": "出错了" }' }
],
hint: '问题:HTTP 状态码说"成功",但业务说"出错"。缓存层会缓存这个"成功"响应,监控系统也发现不了问题。',
do: () => {
@@ -86,7 +93,7 @@ const ops = [
{ kind: 'dim', text: '// 错误信息没有帮助' },
{ kind: 'red', text: 'HTTP/1.1 400 Bad Request' },
{ kind: 'dim', text: '' },
{ kind: 'red', text: '{ "message": "参数错误" }' },
{ kind: 'red', text: '{ "message": "参数错误" }' }
],
hint: '问题:客户端不知道哪个参数错了、为什么错。用户只能看到"参数错误",无法修正。',
do: () => {
@@ -104,9 +111,12 @@ const ops = [
{ kind: 'dim', text: '// 500 错误暴露堆栈' },
{ kind: 'red', text: 'HTTP/1.1 500 Internal Server Error' },
{ kind: 'dim', text: '' },
{ kind: 'red', text: '{ "error": "TypeError: Cannot read property..." }' },
{
kind: 'red',
text: '{ "error": "TypeError: Cannot read property..." }'
},
{ kind: 'red', text: '{ "stack": "at UserService.login..." }' },
{ kind: 'red', text: '{ "sql": "SELECT * FROM users WHERE..." }' },
{ kind: 'red', text: '{ "sql": "SELECT * FROM users WHERE..." }' }
],
hint: '危险!暴露了代码结构、数据库查询。攻击者可以利用这些信息进行攻击。',
do: () => {
@@ -126,7 +136,7 @@ const ops = [
{ kind: 'dim', text: '// HTTP 状态码准确表达错误类型' },
{ kind: 'grn', text: 'HTTP/1.1 404 Not Found' },
{ kind: 'dim', text: '' },
{ kind: 'grn', text: '{ "code": 10002, "message": "用户不存在" }' },
{ kind: 'grn', text: '{ "code": 10002, "message": "用户不存在" }' }
],
hint: '正确!404 表示资源不存在,客户端一看就知道问题所在。',
do: () => {
@@ -147,7 +157,7 @@ const ops = [
{ kind: 'grn', text: 'HTTP/1.1 422 Unprocessable Entity' },
{ kind: 'dim', text: '' },
{ kind: 'grn', text: '{ "code": 20003, "message": "密码强度不足" }' },
{ kind: 'grn', text: '{ "errors": [{ "field": "password", ... }] }' },
{ kind: 'grn', text: '{ "errors": [{ "field": "password", ... }] }' }
],
hint: '正确!提供了错误码、字段级别的错误详情,前端可以精确提示用户。',
do: () => {
@@ -175,7 +185,7 @@ const ops = [
{ kind: 'grn', text: 'HTTP/1.1 500 Internal Server Error' },
{ kind: 'dim', text: '' },
{ kind: 'grn', text: '{ "code": 10000, "message": "服务器错误" }' },
{ kind: 'grn', text: '{ "error_id": "err-a1b2c3d4" }' },
{ kind: 'grn', text: '{ "error_id": "err-a1b2c3d4" }' }
],
hint: '正确!只返回错误 ID,详细日志记录在服务器。用户反馈错误 ID,技术人员可以快速定位。',
do: () => {
@@ -188,7 +198,7 @@ const ops = [
"help_url": "https://docs.example.com/errors/10000"
}`
}
},
}
]
async function run(op) {
@@ -249,7 +259,9 @@ function reset() {
font-size: 0.85rem;
}
.eh-terminal { background: #141420; }
.eh-terminal {
background: #141420;
}
.term-bar {
display: flex;
align-items: center;
@@ -257,11 +269,26 @@ function reset() {
padding: 7px 12px;
background: #1e1e2e;
}
.dot { width: 11px; height: 11px; border-radius: 50%; }
.dot.r { background: #ff5f57; }
.dot.y { background: #febc2e; }
.dot.g { background: #28c840; }
.term-title { margin-left: 8px; font-size: 0.72rem; color: #666; font-family: monospace; }
.dot {
width: 11px;
height: 11px;
border-radius: 50%;
}
.dot.r {
background: #ff5f57;
}
.dot.y {
background: #febc2e;
}
.dot.g {
background: #28c840;
}
.term-title {
margin-left: 8px;
font-size: 0.72rem;
color: #666;
font-family: monospace;
}
.term-body {
min-height: 100px;
@@ -274,16 +301,44 @@ function reset() {
line-height: 1.6;
color: #cdd6f4;
}
.t-line { display: flex; min-width: min-content; }
.t-ps { color: #89b4fa; flex-shrink: 0; }
.t-cmd { color: #cdd6f4; }
.t-dim { color: #585b70; }
.t-grn { color: #a6e3a1; }
.t-red { color: #f38ba8; }
.t-yel { color: #f9e2af; }
.t-typing { color: #cdd6f4; }
.t-cur { animation: blink 1s step-end infinite; }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
.t-line {
display: flex;
min-width: min-content;
}
.t-ps {
color: #89b4fa;
flex-shrink: 0;
}
.t-cmd {
color: #cdd6f4;
}
.t-dim {
color: #585b70;
}
.t-grn {
color: #a6e3a1;
}
.t-red {
color: #f38ba8;
}
.t-yel {
color: #f9e2af;
}
.t-typing {
color: #cdd6f4;
}
.t-cur {
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
.eh-btns {
display: flex;
@@ -301,18 +356,38 @@ function reset() {
cursor: pointer;
transition: border-color 0.2s;
}
.eh-btn code { font-size: 0.68rem; color: #7f849c; font-family: monospace; white-space: nowrap; }
.eh-btn:hover:not(:disabled) { border-color: var(--vp-c-brand); }
.eh-btn--on { border-color: var(--vp-c-brand) !important; }
.eh-btn--on code { color: var(--vp-c-brand); }
.eh-btn--dim { opacity: 0.3; cursor: not-allowed; }
.eh-btn code {
font-size: 0.68rem;
color: #7f849c;
font-family: monospace;
white-space: nowrap;
}
.eh-btn:hover:not(:disabled) {
border-color: var(--vp-c-brand);
}
.eh-btn--on {
border-color: var(--vp-c-brand) !important;
}
.eh-btn--on code {
color: var(--vp-c-brand);
}
.eh-btn--dim {
opacity: 0.3;
cursor: not-allowed;
}
.eh-btn--reset {
background: transparent;
border-color: #313244;
margin-left: auto;
}
.eh-btn--reset code { display: none; }
.eh-btn--reset::after { content: '重置'; font-size: 0.7rem; color: #585b70; }
.eh-btn--reset code {
display: none;
}
.eh-btn--reset::after {
content: '重置';
font-size: 0.7rem;
color: #585b70;
}
.eh-response {
border-top: 1px solid var(--vp-c-divider);
@@ -326,7 +401,11 @@ function reset() {
border-bottom: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-alt);
}
.res-label { font-weight: 700; font-size: 0.8rem; color: var(--vp-c-text-1); }
.res-label {
font-weight: 700;
font-size: 0.8rem;
color: var(--vp-c-text-1);
}
.res-status {
font-family: monospace;
font-size: 0.72rem;
@@ -334,11 +413,26 @@ function reset() {
padding: 2px 8px;
border-radius: 4px;
}
.res-status\.200\ \(错误\) { background: #f9e2af22; color: #d97706; }
.res-status\.400 { background: #f59e0b22; color: #d97706; }
.res-status\.404 { background: #3b82f622; color: #3b82f6; }
.res-status\.422 { background: #8b5cf622; color: #8b5cf6; }
.res-status\.500 { background: #ef444422; color: #ef4444; }
.res-status\.200\ \(错误\) {
background: #f9e2af22;
color: #d97706;
}
.res-status\.400 {
background: #f59e0b22;
color: #d97706;
}
.res-status\.404 {
background: #3b82f622;
color: #3b82f6;
}
.res-status\.422 {
background: #8b5cf622;
color: #8b5cf6;
}
.res-status\.500 {
background: #ef444422;
color: #ef4444;
}
.res-body {
padding: 12px;
@@ -19,7 +19,8 @@
<div class="content">
<div v-if="active === 'validate'" class="section">
<h4>参数校验错误</h4>
<pre class="code-block">{
<pre class="code-block">
{
"code": 10001,
"message": "参数校验失败",
"data": {
@@ -36,7 +37,8 @@
}
]
}
}</pre>
}</pre
>
<div class="field-tips">
<div class="tip-row">
<code>field</code>
@@ -55,7 +57,8 @@
<div v-if="active === 'business'" class="section">
<h4>业务错误</h4>
<pre class="code-block">{
<pre class="code-block">
{
"code": 20001,
"message": "余额不足",
"data": {
@@ -64,7 +67,8 @@
"shortfall": 49.00,
"suggestion": "请充值后重试"
}
}</pre>
}</pre
>
<div class="business-tips">
<div class="b-tip"> 返回当前状态数据便于前端展示</div>
<div class="b-tip"> 提供 suggestion 给出解决建议</div>
@@ -75,11 +79,7 @@
<div v-if="active === 'layers'" class="section">
<h4>错误码分层设计</h4>
<div class="layer-list">
<div
v-for="layer in layers"
:key="layer.range"
class="layer-item"
>
<div v-for="layer in layers" :key="layer.range" class="layer-item">
<div class="layer-range">{{ layer.range }}</div>
<div class="layer-info">
<div class="layer-name">{{ layer.name }}</div>
@@ -88,7 +88,9 @@
<div class="layer-desc">{{ layer.desc }}</div>
</div>
</div>
<div class="layer-note">错误码从外到内系统 服务 业务 认证 参数</div>
<div class="layer-note">
错误码从外到内系统 服务 业务 认证 参数
</div>
</div>
<div v-if="active === 'http'" class="section">
@@ -164,7 +166,9 @@
<div class="tips">
<span class="tips-icon">💡</span>
<span class="tips-text">错误信息要"机器可读 + 人类友好"便于前端统一处理</span>
<span class="tips-text"
>错误信息要"机器可读 + 人类友好"便于前端统一处理</span
>
</div>
</div>
</template>
@@ -184,11 +188,36 @@ const tabs = [
]
const layers = [
{ range: '50001-59999', name: '系统层', example: '50001 数据库异常', desc: '基础设施问题' },
{ range: '40001-49999', name: '服务层', example: '40001 第三方服务超时', desc: '外部依赖问题' },
{ range: '30001-39999', name: '认证层', example: '30001 未登录', desc: '身份权限问题' },
{ range: '20001-29999', name: '业务层', example: '20001 余额不足', desc: '业务规则校验' },
{ range: '10001-19999', name: '参数层', example: '10001 参数缺失', desc: '客户端输入问题' }
{
range: '50001-59999',
name: '系统层',
example: '50001 数据库异常',
desc: '基础设施问题'
},
{
range: '40001-49999',
name: '服务层',
example: '40001 第三方服务超时',
desc: '外部依赖问题'
},
{
range: '30001-39999',
name: '认证层',
example: '30001 未登录',
desc: '身份权限问题'
},
{
range: '20001-29999',
name: '业务层',
example: '20001 余额不足',
desc: '业务规则校验'
},
{
range: '10001-19999',
name: '参数层',
example: '10001 参数缺失',
desc: '客户端输入问题'
}
]
const examples = [
@@ -234,7 +263,7 @@ const examples = [
]
const currentExample = computed(() => {
return examples.find(e => e.id === exId.value) || examples[0]
return examples.find((e) => e.id === exId.value) || examples[0]
})
</script>
@@ -434,7 +463,7 @@ const currentExample = computed(() => {
.http-compare {
flex-direction: column;
}
.http-arrow {
display: none;
}
@@ -29,7 +29,8 @@
{ "result": { "user": {...} } }
// C
{ "user": {...} }</pre>
{ "user": {...} }</pre
>
<div class="problem-desc">
前端需要针对每个接口单独处理代码冗余容易出错
</div>
@@ -42,7 +43,8 @@
"message": "success",
"data": { ... },
"request_id": "req-xxx"
}</pre>
}</pre
>
</div>
</div>
@@ -139,7 +141,8 @@
"total": 156,
"total_pages": 8,
"has_next": true
}</pre>
}</pre
>
</div>
</div>
</div>
@@ -147,7 +150,9 @@
<div class="tips">
<span class="tips-icon">💡</span>
<span class="tips-text">request_id 用于问题追踪建议使用 UUID v4 或雪花算法生成</span>
<span class="tips-text"
>request_id 用于问题追踪建议使用 UUID v4 或雪花算法生成</span
>
</div>
</div>
</template>
@@ -7,11 +7,11 @@
<span class="raf-icon">💻</span>
<span class="raf-title">Client (Browser/App)</span>
</div>
<div class="raf-controls">
<div class="raf-scenarios">
<button
v-for="s in scenarios"
<button
v-for="s in scenarios"
:key="s.id"
:class="['raf-chip', { active: currentScenario.id === s.id }]"
@click="selectScenario(s)"
@@ -24,15 +24,17 @@
<div class="raf-request-box">
<div class="raf-http-line">
<span :class="['raf-method', currentScenario.method]">{{ currentScenario.method }}</span>
<span :class="['raf-method', currentScenario.method]">{{
currentScenario.method
}}</span>
<span class="raf-url">{{ currentScenario.url }}</span>
</div>
<div v-if="currentScenario.body" class="raf-code-block">
{{ JSON.stringify(currentScenario.body, null, 2) }}
</div>
<button
class="raf-send-btn"
@click="sendRequest"
<button
class="raf-send-btn"
@click="sendRequest"
:disabled="processing"
>
{{ processing ? 'Sending...' : 'Send Request' }}
@@ -42,7 +44,9 @@
<div class="raf-response-box" v-if="response">
<div class="raf-status-line">
<span class="raf-label">Response Status:</span>
<span :class="['raf-status-badge', getStatusColor(response.status)]">
<span
:class="['raf-status-badge', getStatusColor(response.status)]"
>
{{ response.status }} {{ response.statusText }}
</span>
</div>
@@ -100,16 +104,46 @@ const logs = ref([])
const logsRef = ref(null)
const db = ref([
{ id: 1, name: "Alice", role: "admin" },
{ id: 2, name: "Bob", role: "user" }
{ id: 1, name: 'Alice', role: 'admin' },
{ id: 2, name: 'Bob', role: 'user' }
])
const scenarios = [
{ id: 'get-all', label: 'GET /users', method: 'GET', url: '/api/users', body: null },
{ id: 'get-one', label: 'GET /users/1', method: 'GET', url: '/api/users/1', body: null },
{ id: 'create', label: 'POST /users', method: 'POST', url: '/api/users', body: { name: "Charlie", role: "user" } },
{ id: 'not-found', label: 'GET /users/99', method: 'GET', url: '/api/users/99', body: null },
{ id: 'delete', label: 'DELETE /users/1', method: 'DELETE', url: '/api/users/1', body: null },
{
id: 'get-all',
label: 'GET /users',
method: 'GET',
url: '/api/users',
body: null
},
{
id: 'get-one',
label: 'GET /users/1',
method: 'GET',
url: '/api/users/1',
body: null
},
{
id: 'create',
label: 'POST /users',
method: 'POST',
url: '/api/users',
body: { name: 'Charlie', role: 'user' }
},
{
id: 'not-found',
label: 'GET /users/99',
method: 'GET',
url: '/api/users/99',
body: null
},
{
id: 'delete',
label: 'DELETE /users/1',
method: 'DELETE',
url: '/api/users/1',
body: null
}
]
const currentScenario = ref(scenarios[0])
@@ -137,43 +171,54 @@ function getStatusColor(status) {
async function sendRequest() {
processing.value = true
response.value = null
addLog(`Received ${currentScenario.value.method} ${currentScenario.value.url}`, 'info')
await new Promise(r => setTimeout(r, 600)) // Simulate network latency
addLog(
`Received ${currentScenario.value.method} ${currentScenario.value.url}`,
'info'
)
await new Promise((r) => setTimeout(r, 600)) // Simulate network latency
const { method, url, body } = currentScenario.value
// Router Logic Simulation
if (method === 'GET' && url === '/api/users') {
response.value = { status: 200, statusText: 'OK', body: db.value }
addLog('Matched route: GET /users -> listUsers()', 'success')
}
else if (method === 'GET' && url.match(/\/api\/users\/\d+/)) {
} else if (method === 'GET' && url.match(/\/api\/users\/\d+/)) {
const id = parseInt(url.split('/').pop())
const user = db.value.find(u => u.id === id)
const user = db.value.find((u) => u.id === id)
if (user) {
response.value = { status: 200, statusText: 'OK', body: user }
addLog(`Found user ${id}`, 'success')
} else {
response.value = { status: 404, statusText: 'Not Found', body: { error: "User not found" } }
response.value = {
status: 404,
statusText: 'Not Found',
body: { error: 'User not found' }
}
addLog(`User ${id} not found in DB`, 'error')
}
}
else if (method === 'POST' && url === '/api/users') {
const newUser = { id: Math.max(0, ...db.value.map(u => u.id)) + 1, ...body }
} else if (method === 'POST' && url === '/api/users') {
const newUser = {
id: Math.max(0, ...db.value.map((u) => u.id)) + 1,
...body
}
db.value.push(newUser)
response.value = { status: 201, statusText: 'Created', body: newUser }
addLog(`Created user ${newUser.id}`, 'success')
}
else if (method === 'DELETE' && url.match(/\/api\/users\/\d+/)) {
} else if (method === 'DELETE' && url.match(/\/api\/users\/\d+/)) {
const id = parseInt(url.split('/').pop())
const idx = db.value.findIndex(u => u.id === id)
const idx = db.value.findIndex((u) => u.id === id)
if (idx !== -1) {
db.value.splice(idx, 1)
response.value = { status: 204, statusText: 'No Content', body: null }
addLog(`Deleted user ${id}`, 'success')
} else {
response.value = { status: 404, statusText: 'Not Found', body: { error: "User not found" } }
response.value = {
status: 404,
statusText: 'Not Found',
body: { error: 'User not found' }
}
addLog(`User ${id} not found for deletion`, 'error')
}
}
@@ -198,7 +243,8 @@ async function sendRequest() {
min-height: 400px;
}
.raf-left, .raf-right {
.raf-left,
.raf-right {
flex: 1;
padding: 1.2rem;
display: flex;
@@ -259,7 +305,7 @@ async function sendRequest() {
padding: 1rem;
background: var(--vp-c-bg-soft);
margin-bottom: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.raf-http-line {
@@ -274,9 +320,15 @@ async function sendRequest() {
.raf-method {
font-weight: bold;
}
.raf-method.GET { color: #61affe; }
.raf-method.POST { color: #49cc90; }
.raf-method.DELETE { color: #f93e3e; }
.raf-method.GET {
color: #61affe;
}
.raf-method.POST {
color: #49cc90;
}
.raf-method.DELETE {
color: #f93e3e;
}
.raf-code-block {
background: var(--vp-c-bg);
@@ -317,8 +369,14 @@ async function sendRequest() {
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.raf-status-line {
@@ -334,9 +392,18 @@ async function sendRequest() {
font-weight: bold;
margin-left: 8px;
}
.status-success { background: #d1fae5; color: #065f46; }
.status-error { background: #fee2e2; color: #991b1b; }
.status-neutral { background: #f3f4f6; color: #374151; }
.status-success {
background: #d1fae5;
color: #065f46;
}
.status-error {
background: #fee2e2;
color: #991b1b;
}
.status-neutral {
background: #f3f4f6;
color: #374151;
}
.raf-db-view {
display: flex;
@@ -355,9 +422,17 @@ async function sendRequest() {
align-items: center;
}
.raf-db-id { color: var(--vp-c-text-3); font-family: monospace; }
.raf-db-name { font-weight: bold; }
.raf-db-role { color: var(--vp-c-brand); font-size: 0.9em; }
.raf-db-id {
color: var(--vp-c-text-3);
font-family: monospace;
}
.raf-db-name {
font-weight: bold;
}
.raf-db-role {
color: var(--vp-c-brand);
font-size: 0.9em;
}
.raf-logs {
height: 180px;
@@ -377,10 +452,19 @@ async function sendRequest() {
margin-bottom: 4px;
}
.raf-log-time { color: #6b7280; flex-shrink: 0; }
.info { color: #93c5fd; }
.success { color: #86efac; }
.error { color: #fca5a5; }
.raf-log-time {
color: #6b7280;
flex-shrink: 0;
}
.info {
color: #93c5fd;
}
.success {
color: #86efac;
}
.error {
color: #fca5a5;
}
.raf-section-title {
font-size: 12px;
@@ -391,7 +475,9 @@ async function sendRequest() {
color: var(--vp-c-text-3);
margin-top: 1.5rem;
}
.raf-section:first-child .raf-section-title { margin-top: 0; }
.raf-section:first-child .raf-section-title {
margin-top: 0;
}
.raf-empty {
color: var(--vp-c-text-3);
@@ -411,7 +497,12 @@ async function sendRequest() {
}
@media (max-width: 768px) {
.raf-layout { flex-direction: column; }
.raf-left { border-right: none; border-bottom: 1px solid var(--vp-c-divider); }
.raf-layout {
flex-direction: column;
}
.raf-left {
border-right: none;
border-bottom: 1px solid var(--vp-c-divider);
}
}
</style>
@@ -22,12 +22,17 @@
v-for="op in ops"
:key="op.id"
:disabled="running || !op.ok()"
:class="['ru-btn', { 'ru-btn--on': active === op.id, 'ru-btn--dim': !op.ok() }]"
:class="[
'ru-btn',
{ 'ru-btn--on': active === op.id, 'ru-btn--dim': !op.ok() }
]"
@click="run(op)"
>
<code>{{ op.cmd }}</code>
</button>
<button class="ru-btn ru-btn--reset" :disabled="running" @click="reset">重置</button>
<button class="ru-btn ru-btn--reset" :disabled="running" @click="reset">
重置
</button>
</div>
<div class="ru-compare">
@@ -37,7 +42,12 @@
<span class="compare-title">错误示例</span>
</div>
<div class="compare-body">
<div v-for="(item, i) in badExamples" :key="i" class="url-row" :class="{ highlight: item.active }">
<div
v-for="(item, i) in badExamples"
:key="i"
class="url-row"
:class="{ highlight: item.active }"
>
<code class="url-text">{{ item.url }}</code>
<span class="url-reason">{{ item.reason }}</span>
</div>
@@ -50,7 +60,12 @@
<span class="compare-title">正确示例</span>
</div>
<div class="compare-body">
<div v-for="(item, i) in goodExamples" :key="i" class="url-row" :class="{ highlight: item.active }">
<div
v-for="(item, i) in goodExamples"
:key="i"
class="url-row"
:class="{ highlight: item.active }"
>
<code class="url-text">{{ item.url }}</code>
<span class="url-reason">{{ item.reason }}</span>
</div>
@@ -66,7 +81,9 @@
import { ref, nextTick } from 'vue'
const termEl = ref(null)
const lines = ref([{ kind: 'dim', text: '# 对比 RESTful URL 的正确与错误写法' }])
const lines = ref([
{ kind: 'dim', text: '# 对比 RESTful URL 的正确与错误写法' }
])
const typing = ref('')
const running = ref(false)
const active = ref(null)
@@ -77,8 +94,16 @@ const badExamples = ref([
{ url: 'GET /user', reason: '单数形式', active: false },
{ url: 'GET /UserProfiles', reason: '大写字母', active: false },
{ url: 'GET /user_profiles', reason: '下划线连接', active: false },
{ url: 'GET /users/123/orders/456/items/789', reason: '层级过深', active: false },
{ url: 'GET /products/category/phone/price/5000', reason: '过滤条件放路径', active: false },
{
url: 'GET /users/123/orders/456/items/789',
reason: '层级过深',
active: false
},
{
url: 'GET /products/category/phone/price/5000',
reason: '过滤条件放路径',
active: false
}
])
const goodExamples = ref([
@@ -87,10 +112,14 @@ const goodExamples = ref([
{ url: 'GET /user-profiles', reason: '小写 + 连字符', active: false },
{ url: 'GET /user-profiles', reason: '连字符连接', active: false },
{ url: 'GET /users/123/orders', reason: '最多 3 层', active: false },
{ url: 'GET /products?category=phone&price_max=5000', reason: '过滤用查询参数', active: false },
{
url: 'GET /products?category=phone&price_max=5000',
reason: '过滤用查询参数',
active: false
}
])
const sleep = ms => new Promise(r => setTimeout(r, ms))
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const ops = [
{
@@ -104,7 +133,7 @@ const ops = [
{ kind: 'red', text: '❌ POST /createOrder' },
{ kind: 'grn', text: '✅ GET /users' },
{ kind: 'grn', text: '✅ GET /users/123' },
{ kind: 'grn', text: '✅ POST /orders' },
{ kind: 'grn', text: '✅ POST /orders' }
],
hint: 'URL 是资源的"地址"HTTP 方法已经表达了"操作"。不要在 URL 里重复说"做什么"。',
do: () => {
@@ -122,7 +151,7 @@ const ops = [
{ kind: 'red', text: '❌ GET /order' },
{ kind: 'grn', text: '✅ GET /users' },
{ kind: 'grn', text: '✅ GET /orders' },
{ kind: 'grn', text: '✅ GET /users/123 (获取单个)' },
{ kind: 'grn', text: '✅ GET /users/123 (获取单个)' }
],
hint: '统一用复数,避免 /user 和 /users 混用。获取单个资源时用 /users/123。',
do: () => {
@@ -139,7 +168,7 @@ const ops = [
{ kind: 'red', text: '❌ GET /UserProfiles' },
{ kind: 'red', text: '❌ GET /user_profiles' },
{ kind: 'grn', text: '✅ GET /user-profiles' },
{ kind: 'grn', text: '✅ GET /order-items' },
{ kind: 'grn', text: '✅ GET /order-items' }
],
hint: 'URL 大小写敏感,统一用小写 + 连字符(-)是最安全的做法。',
do: () => {
@@ -158,7 +187,7 @@ const ops = [
{ kind: 'red', text: '❌ /users/123/orders/456/items/789/status' },
{ kind: 'grn', text: '✅ /users/123/orders (用户订单)' },
{ kind: 'grn', text: '✅ /orders/456/items (订单商品)' },
{ kind: 'grn', text: '✅ /order-items/789 (直接访问)' },
{ kind: 'grn', text: '✅ /order-items/789 (直接访问)' }
],
hint: '超过 3 层考虑重构。可以用扁平化路径或查询参数替代深层嵌套。',
do: () => {
@@ -175,14 +204,14 @@ const ops = [
{ kind: 'red', text: '❌ /products/category/phone/price/5000' },
{ kind: 'grn', text: '✅ /products?category=phone&price_max=5000' },
{ kind: 'grn', text: '✅ /products?status=active&sort=created_desc' },
{ kind: 'grn', text: '✅ /products?category=phone,electronics' },
{ kind: 'grn', text: '✅ /products?category=phone,electronics' }
],
hint: '查询参数可以灵活组合,路径则固定不变。过滤、排序、分页都用查询参数。',
do: () => {
badExamples.value[5].active = true
goodExamples.value[5].active = true
}
},
}
]
async function run(op) {
@@ -192,8 +221,8 @@ async function run(op) {
hint.value = ''
typing.value = ''
badExamples.value.forEach(e => e.active = false)
goodExamples.value.forEach(e => e.active = false)
badExamples.value.forEach((e) => (e.active = false))
goodExamples.value.forEach((e) => (e.active = false))
for (const ch of op.cmd) {
typing.value += ch
@@ -225,8 +254,8 @@ function scroll() {
function reset() {
lines.value = [{ kind: 'dim', text: '# 对比 RESTful URL 的正确与错误写法' }]
badExamples.value.forEach(e => e.active = false)
goodExamples.value.forEach(e => e.active = false)
badExamples.value.forEach((e) => (e.active = false))
goodExamples.value.forEach((e) => (e.active = false))
active.value = null
hint.value = '点击命令按钮,查看不同场景下的 URL 设计对比。'
typing.value = ''
@@ -244,7 +273,9 @@ function reset() {
font-size: 0.85rem;
}
.ru-terminal { background: #141420; }
.ru-terminal {
background: #141420;
}
.term-bar {
display: flex;
align-items: center;
@@ -252,11 +283,26 @@ function reset() {
padding: 7px 12px;
background: #1e1e2e;
}
.dot { width: 11px; height: 11px; border-radius: 50%; }
.dot.r { background: #ff5f57; }
.dot.y { background: #febc2e; }
.dot.g { background: #28c840; }
.term-title { margin-left: 8px; font-size: 0.72rem; color: #666; font-family: monospace; }
.dot {
width: 11px;
height: 11px;
border-radius: 50%;
}
.dot.r {
background: #ff5f57;
}
.dot.y {
background: #febc2e;
}
.dot.g {
background: #28c840;
}
.term-title {
margin-left: 8px;
font-size: 0.72rem;
color: #666;
font-family: monospace;
}
.term-body {
min-height: 100px;
@@ -269,15 +315,41 @@ function reset() {
line-height: 1.6;
color: #cdd6f4;
}
.t-line { display: flex; min-width: min-content; }
.t-ps { color: #a6e3a1; flex-shrink: 0; }
.t-cmd { color: #cdd6f4; }
.t-dim { color: #585b70; }
.t-grn { color: #a6e3a1; }
.t-red { color: #f38ba8; }
.t-typing { color: #cdd6f4; }
.t-cur { animation: blink 1s step-end infinite; }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
.t-line {
display: flex;
min-width: min-content;
}
.t-ps {
color: #a6e3a1;
flex-shrink: 0;
}
.t-cmd {
color: #cdd6f4;
}
.t-dim {
color: #585b70;
}
.t-grn {
color: #a6e3a1;
}
.t-red {
color: #f38ba8;
}
.t-typing {
color: #cdd6f4;
}
.t-cur {
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
.ru-btns {
display: flex;
@@ -295,18 +367,38 @@ function reset() {
cursor: pointer;
transition: border-color 0.2s;
}
.ru-btn code { font-size: 0.68rem; color: #7f849c; font-family: monospace; white-space: nowrap; }
.ru-btn:hover:not(:disabled) { border-color: var(--vp-c-brand); }
.ru-btn--on { border-color: var(--vp-c-brand) !important; }
.ru-btn--on code { color: var(--vp-c-brand); }
.ru-btn--dim { opacity: 0.3; cursor: not-allowed; }
.ru-btn code {
font-size: 0.68rem;
color: #7f849c;
font-family: monospace;
white-space: nowrap;
}
.ru-btn:hover:not(:disabled) {
border-color: var(--vp-c-brand);
}
.ru-btn--on {
border-color: var(--vp-c-brand) !important;
}
.ru-btn--on code {
color: var(--vp-c-brand);
}
.ru-btn--dim {
opacity: 0.3;
cursor: not-allowed;
}
.ru-btn--reset {
background: transparent;
border-color: #313244;
margin-left: auto;
}
.ru-btn--reset code { display: none; }
.ru-btn--reset::after { content: '重置'; font-size: 0.7rem; color: #585b70; }
.ru-btn--reset code {
display: none;
}
.ru-btn--reset::after {
content: '重置';
font-size: 0.7rem;
color: #585b70;
}
.ru-compare {
display: grid;
@@ -332,8 +424,14 @@ function reset() {
gap: 6px;
margin-bottom: 10px;
}
.compare-icon { font-size: 1rem; }
.compare-title { font-weight: 700; font-size: 0.85rem; color: var(--vp-c-text-1); }
.compare-icon {
font-size: 1rem;
}
.compare-title {
font-weight: 700;
font-size: 0.85rem;
color: var(--vp-c-text-1);
}
.compare-body {
display: flex;
@@ -346,7 +444,9 @@ function reset() {
border-radius: 4px;
background: var(--vp-c-bg);
border: 1px solid transparent;
transition: border-color 0.2s, background 0.2s;
transition:
border-color 0.2s,
background 0.2s;
}
.url-row.highlight {
border-color: var(--vp-c-brand);
@@ -22,12 +22,17 @@
v-for="op in ops"
:key="op.id"
:disabled="running || !op.ok()"
:class="['sc-btn', { 'sc-btn--on': active === op.id, 'sc-btn--dim': !op.ok() }]"
:class="[
'sc-btn',
{ 'sc-btn--on': active === op.id, 'sc-btn--dim': !op.ok() }
]"
@click="run(op)"
>
<code>{{ op.cmd }}</code>
</button>
<button class="sc-btn sc-btn--reset" :disabled="running" @click="reset">重置</button>
<button class="sc-btn sc-btn--reset" :disabled="running" @click="reset">
重置
</button>
</div>
<div class="sc-codes">
@@ -37,7 +42,12 @@
<span class="section-title">2xx 成功</span>
</div>
<div class="section-body">
<div v-for="c in successCodes" :key="c.code" class="code-item" :class="{ active: activeCode === c.code }">
<div
v-for="c in successCodes"
:key="c.code"
class="code-item"
:class="{ active: activeCode === c.code }"
>
<span class="code-num">{{ c.code }}</span>
<span class="code-name">{{ c.name }}</span>
<span class="code-desc">{{ c.desc }}</span>
@@ -51,7 +61,12 @@
<span class="section-title">4xx 客户端错误</span>
</div>
<div class="section-body">
<div v-for="c in clientCodes" :key="c.code" class="code-item" :class="{ active: activeCode === c.code }">
<div
v-for="c in clientCodes"
:key="c.code"
class="code-item"
:class="{ active: activeCode === c.code }"
>
<span class="code-num">{{ c.code }}</span>
<span class="code-name">{{ c.name }}</span>
<span class="code-desc">{{ c.desc }}</span>
@@ -65,7 +80,12 @@
<span class="section-title">5xx 服务端错误</span>
</div>
<div class="section-body">
<div v-for="c in serverCodes" :key="c.code" class="code-item" :class="{ active: activeCode === c.code }">
<div
v-for="c in serverCodes"
:key="c.code"
class="code-item"
:class="{ active: activeCode === c.code }"
>
<span class="code-num">{{ c.code }}</span>
<span class="code-name">{{ c.name }}</span>
<span class="code-desc">{{ c.desc }}</span>
@@ -92,7 +112,7 @@ const hint = ref('点击命令按钮,了解常见的 HTTP 状态码。')
const successCodes = ref([
{ code: 200, name: 'OK', desc: '请求成功' },
{ code: 201, name: 'Created', desc: '创建成功' },
{ code: 204, name: 'No Content', desc: '成功但无返回内容' },
{ code: 204, name: 'No Content', desc: '成功但无返回内容' }
])
const clientCodes = ref([
@@ -101,16 +121,16 @@ const clientCodes = ref([
{ code: 403, name: 'Forbidden', desc: '无权限' },
{ code: 404, name: 'Not Found', desc: '资源不存在' },
{ code: 422, name: 'Unprocessable', desc: '语义错误' },
{ code: 429, name: 'Too Many', desc: '请求过多' },
{ code: 429, name: 'Too Many', desc: '请求过多' }
])
const serverCodes = ref([
{ code: 500, name: 'Server Error', desc: '服务器内部错误' },
{ code: 502, name: 'Bad Gateway', desc: '网关错误' },
{ code: 503, name: 'Unavailable', desc: '服务不可用' },
{ code: 503, name: 'Unavailable', desc: '服务不可用' }
])
const sleep = ms => new Promise(r => setTimeout(r, ms))
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const ops = [
{
@@ -122,10 +142,12 @@ const ops = [
{ kind: 'grn', text: 'HTTP/1.1 200 OK' },
{ kind: 'dim', text: 'Content-Type: application/json' },
{ kind: 'dim', text: '' },
{ kind: 'grn', text: '{ "code": 0, "data": { ... } }' },
{ kind: 'grn', text: '{ "code": 0, "data": { ... } }' }
],
hint: '200 表示请求成功处理。GET 查询、PUT/PATCH 更新成功时常用。',
do: () => { activeCode.value = 200 }
do: () => {
activeCode.value = 200
}
},
{
id: '201',
@@ -136,10 +158,12 @@ const ops = [
{ kind: 'grn', text: 'HTTP/1.1 201 Created' },
{ kind: 'dim', text: 'Location: /api/users/123' },
{ kind: 'dim', text: '' },
{ kind: 'grn', text: '{ "code": 0, "data": { "id": 123 } }' },
{ kind: 'grn', text: '{ "code": 0, "data": { "id": 123 } }' }
],
hint: '201 表示资源创建成功。响应头 Location 指向新资源的地址。',
do: () => { activeCode.value = 201 }
do: () => {
activeCode.value = 201
}
},
{
id: '400',
@@ -149,10 +173,12 @@ const ops = [
{ kind: 'dim', text: '// 客户端请求有问题' },
{ kind: 'red', text: 'HTTP/1.1 400 Bad Request' },
{ kind: 'dim', text: '' },
{ kind: 'red', text: '{ "code": 10001, "message": "参数格式错误" }' },
{ kind: 'red', text: '{ "code": 10001, "message": "参数格式错误" }' }
],
hint: '400 表示请求语法错误。比如 JSON 格式不对、缺少必填参数。',
do: () => { activeCode.value = 400 }
do: () => {
activeCode.value = 400
}
},
{
id: '401',
@@ -163,10 +189,12 @@ const ops = [
{ kind: 'red', text: 'HTTP/1.1 401 Unauthorized' },
{ kind: 'dim', text: 'WWW-Authenticate: Bearer' },
{ kind: 'dim', text: '' },
{ kind: 'red', text: '{ "code": 10018, "message": "请先登录" }' },
{ kind: 'red', text: '{ "code": 10018, "message": "请先登录" }' }
],
hint: '401 表示未认证。Token 过期、未登录时返回,客户端应引导用户登录。',
do: () => { activeCode.value = 401 }
do: () => {
activeCode.value = 401
}
},
{
id: '403',
@@ -176,10 +204,12 @@ const ops = [
{ kind: 'dim', text: '// 已登录但无权限' },
{ kind: 'red', text: 'HTTP/1.1 403 Forbidden' },
{ kind: 'dim', text: '' },
{ kind: 'red', text: '{ "code": 10021, "message": "需要管理员权限" }' },
{ kind: 'red', text: '{ "code": 10021, "message": "需要管理员权限" }' }
],
hint: '403 表示已认证但无权限。普通用户访问管理员接口时返回。',
do: () => { activeCode.value = 403 }
do: () => {
activeCode.value = 403
}
},
{
id: '404',
@@ -189,10 +219,12 @@ const ops = [
{ kind: 'dim', text: '// 资源不存在' },
{ kind: 'red', text: 'HTTP/1.1 404 Not Found' },
{ kind: 'dim', text: '' },
{ kind: 'red', text: '{ "code": 10002, "message": "用户不存在" }' },
{ kind: 'red', text: '{ "code": 10002, "message": "用户不存在" }' }
],
hint: '404 表示请求的资源不存在。URL 错误或资源已被删除。',
do: () => { activeCode.value = 404 }
do: () => {
activeCode.value = 404
}
},
{
id: '500',
@@ -202,11 +234,16 @@ const ops = [
{ kind: 'dim', text: '// 服务器内部错误' },
{ kind: 'red', text: 'HTTP/1.1 500 Internal Server Error' },
{ kind: 'dim', text: '' },
{ kind: 'red', text: '{ "code": 10000, "message": "服务器错误,请联系管理员" }' },
{
kind: 'red',
text: '{ "code": 10000, "message": "服务器错误,请联系管理员" }'
}
],
hint: '500 表示服务器内部错误。代码 bug、数据库连接失败等,不要暴露堆栈信息!',
do: () => { activeCode.value = 500 }
},
do: () => {
activeCode.value = 500
}
}
]
async function run(op) {
@@ -265,7 +302,9 @@ function reset() {
font-size: 0.85rem;
}
.sc-terminal { background: #141420; }
.sc-terminal {
background: #141420;
}
.term-bar {
display: flex;
align-items: center;
@@ -273,11 +312,26 @@ function reset() {
padding: 7px 12px;
background: #1e1e2e;
}
.dot { width: 11px; height: 11px; border-radius: 50%; }
.dot.r { background: #ff5f57; }
.dot.y { background: #febc2e; }
.dot.g { background: #28c840; }
.term-title { margin-left: 8px; font-size: 0.72rem; color: #666; font-family: monospace; }
.dot {
width: 11px;
height: 11px;
border-radius: 50%;
}
.dot.r {
background: #ff5f57;
}
.dot.y {
background: #febc2e;
}
.dot.g {
background: #28c840;
}
.term-title {
margin-left: 8px;
font-size: 0.72rem;
color: #666;
font-family: monospace;
}
.term-body {
min-height: 90px;
@@ -290,15 +344,41 @@ function reset() {
line-height: 1.6;
color: #cdd6f4;
}
.t-line { display: flex; min-width: min-content; }
.t-ps { color: #89b4fa; flex-shrink: 0; }
.t-cmd { color: #cdd6f4; }
.t-dim { color: #585b70; }
.t-grn { color: #a6e3a1; }
.t-red { color: #f38ba8; }
.t-typing { color: #cdd6f4; }
.t-cur { animation: blink 1s step-end infinite; }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
.t-line {
display: flex;
min-width: min-content;
}
.t-ps {
color: #89b4fa;
flex-shrink: 0;
}
.t-cmd {
color: #cdd6f4;
}
.t-dim {
color: #585b70;
}
.t-grn {
color: #a6e3a1;
}
.t-red {
color: #f38ba8;
}
.t-typing {
color: #cdd6f4;
}
.t-cur {
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
.sc-btns {
display: flex;
@@ -316,18 +396,38 @@ function reset() {
cursor: pointer;
transition: border-color 0.2s;
}
.sc-btn code { font-size: 0.68rem; color: #7f849c; font-family: monospace; white-space: nowrap; }
.sc-btn:hover:not(:disabled) { border-color: var(--vp-c-brand); }
.sc-btn--on { border-color: var(--vp-c-brand) !important; }
.sc-btn--on code { color: var(--vp-c-brand); }
.sc-btn--dim { opacity: 0.3; cursor: not-allowed; }
.sc-btn code {
font-size: 0.68rem;
color: #7f849c;
font-family: monospace;
white-space: nowrap;
}
.sc-btn:hover:not(:disabled) {
border-color: var(--vp-c-brand);
}
.sc-btn--on {
border-color: var(--vp-c-brand) !important;
}
.sc-btn--on code {
color: var(--vp-c-brand);
}
.sc-btn--dim {
opacity: 0.3;
cursor: not-allowed;
}
.sc-btn--reset {
background: transparent;
border-color: #313244;
margin-left: auto;
}
.sc-btn--reset code { display: none; }
.sc-btn--reset::after { content: '重置'; font-size: 0.7rem; color: #585b70; }
.sc-btn--reset code {
display: none;
}
.sc-btn--reset::after {
content: '重置';
font-size: 0.7rem;
color: #585b70;
}
.sc-codes {
display: grid;
@@ -351,12 +451,25 @@ function reset() {
font-weight: 700;
font-size: 0.8rem;
}
.section-header.success { background: color-mix(in srgb, #22c55e 8%, var(--vp-c-bg-alt)); color: #22c55e; }
.section-header.client { background: color-mix(in srgb, #f59e0b 8%, var(--vp-c-bg-alt)); color: #d97706; }
.section-header.server { background: color-mix(in srgb, #ef4444 8%, var(--vp-c-bg-alt)); color: #ef4444; }
.section-header.success {
background: color-mix(in srgb, #22c55e 8%, var(--vp-c-bg-alt));
color: #22c55e;
}
.section-header.client {
background: color-mix(in srgb, #f59e0b 8%, var(--vp-c-bg-alt));
color: #d97706;
}
.section-header.server {
background: color-mix(in srgb, #ef4444 8%, var(--vp-c-bg-alt));
color: #ef4444;
}
.section-icon { font-size: 0.9rem; }
.section-title { font-size: 0.75rem; }
.section-icon {
font-size: 0.9rem;
}
.section-title {
font-size: 0.75rem;
}
.section-body {
padding: 8px;
@@ -386,7 +499,9 @@ function reset() {
font-size: 0.75rem;
min-width: 28px;
}
.code-item.active .code-num { color: var(--vp-c-brand); }
.code-item.active .code-num {
color: var(--vp-c-brand);
}
.code-name {
font-size: 0.72rem;