chore: save local history restorations from accidental git restore

This commit is contained in:
sanbuphy
2026-02-23 01:40:56 +08:00
parent 780be69b99
commit 2a0fdd3392
27 changed files with 5971 additions and 2743 deletions
@@ -0,0 +1,479 @@
<template>
<div class="demo">
<div class="header">
<span class="icon">🎨</span>
<span class="title">四种 API 风格对比</span>
</div>
<div class="tabs">
<button
v-for="style in styles"
:key="style.id"
:class="['tab', { active: active === style.id }]"
@click="active = style.id"
>
{{ style.icon }} {{ style.name }}
</button>
</div>
<div class="content">
<div class="style-header">
<h4>{{ currentStyle.name }}</h4>
<span class="badge">{{ currentStyle.badge }}</span>
</div>
<p class="desc">{{ currentStyle.desc }}</p>
<div class="example-section">
<div class="example-label">示例获取用户信息</div>
<pre class="code-block"><code>{{ currentStyle.example }}</code></pre>
</div>
<div class="features">
<div class="features-title">核心特点</div>
<div class="features-grid">
<div
v-for="(f, i) in currentStyle.features"
:key="i"
class="feature-item"
>
<span class="check"></span>
<span>{{ f }}</span>
</div>
</div>
</div>
<div class="meta">
<div class="meta-row">
<span class="meta-label">适用场景</span>
<span class="meta-value">{{ currentStyle.scenarios }}</span>
</div>
<div class="meta-row">
<span class="meta-label">官方地址</span>
<a :href="currentStyle.official" target="_blank" class="meta-link">{{
currentStyle.official
}}</a>
</div>
</div>
</div>
<div class="compare-section">
<div class="compare-title">📊 风格速览对比</div>
<div class="compare-table">
<div class="compare-row head">
<div class="cell">特性</div>
<div class="cell">RPC</div>
<div class="cell highlight">REST</div>
<div class="cell">GraphQL</div>
<div class="cell">gRPC</div>
</div>
<div class="compare-row">
<div class="cell">核心理念</div>
<div class="cell">面向过程</div>
<div class="cell highlight">面向资源</div>
<div class="cell">面向数据</div>
<div class="cell">面向方法</div>
</div>
<div class="compare-row">
<div class="cell">URL 风格</div>
<div class="cell">动词为主</div>
<div class="cell highlight">名词为主</div>
<div class="cell">单一端点</div>
<div class="cell">不依赖URL</div>
</div>
<div class="compare-row">
<div class="cell">学习曲线</div>
<div class="cell low"></div>
<div class="cell"></div>
<div class="cell"></div>
<div class="cell high"></div>
</div>
<div class="compare-row">
<div class="cell">性能</div>
<div class="cell">一般</div>
<div class="cell">一般</div>
<div class="cell">较好</div>
<div class="cell best">优秀</div>
</div>
<div class="compare-row">
<div class="cell">使用占比</div>
<div class="cell">~30%</div>
<div class="cell highlight">~50%</div>
<div class="cell">~15%</div>
<div class="cell">~5%</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const active = ref('rest')
const styles = [
{
id: 'rpc',
icon: '📞',
name: 'RPC',
badge: '最传统',
desc: 'Remote Procedure Call,远程过程调用。像调用本地方法一样调用远程服务,面向过程,简单直接。超过 50% 的内部 API 采用这种风格。',
example: `GET /getUserInfo?id=123
POST /createUser
POST /deleteOrder
GET /queryUserList`,
features: [
'URL 命名往往是动词',
'HTTP 方法基本只用 GET/POST',
'设计简单,几乎无约束',
'需要详细文档说明'
],
scenarios: '内部 API、性能敏感场景、难以抽象为资源的业务',
official: '无官方规范(概念性风格)'
},
{
id: 'rest',
icon: '🌐',
name: 'REST',
badge: '最常用',
desc: 'Representational State Transfer,表述性状态转移。由 Roy Fielding 于 2000 年在其博士论文中提出。面向资源,用 URL 标识资源,用 HTTP 方法操作资源。',
example: `GET /users # 获取用户列表
GET /users/123 # 获取单个用户
POST /users # 创建用户
PUT /users/123 # 全量更新
PATCH /users/123 # 部分更新
DELETE /users/123 # 删除用户`,
features: [
'URL 是名词,不是动词',
'使用 HTTP 方法表达操作',
'无状态,请求包含所有信息',
'可缓存,支持分层系统'
],
scenarios: '公开 API、CRUD 操作、资源边界清晰的业务',
official: 'https://restfulapi.net/'
},
{
id: 'graphql',
icon: '📊',
name: 'GraphQL',
badge: '最灵活',
desc: '由 Facebook 于 2015 年开源。一种查询语言,客户端可以精确指定需要的数据字段,避免过度获取或获取不足。',
example: `query {
user(id: "123") {
name
email
orders {
id
total
}
}
}`,
features: [
'单一端点(/graphql',
'客户端决定返回字段',
'Schema 即文档',
'一次请求获取多资源'
],
scenarios: '客户端需求多变、数据关系复杂、移动端 App',
official: 'https://graphql.org/'
},
{
id: 'grpc',
icon: '⚡',
name: 'gRPC',
badge: '最高效',
desc: '由 Google 于 2016 年开源。高性能 RPC 框架,使用 Protocol Buffers 序列化,基于 HTTP/2,支持双向流通信。',
example: `service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc CreateUser(CreateUserRequest) returns (User);
}
message User {
string id = 1;
string name = 2;
}`,
features: [
'二进制传输,性能极高',
'强类型,代码自动生成',
'基于 HTTP/2,双向流',
'浏览器支持差'
],
scenarios: '微服务内部通信、高性能场景、强类型需求',
official: 'https://grpc.io/'
}
]
const currentStyle = computed(() => {
return styles.find((s) => s.id === active.value) || styles[1]
})
</script>
<style scoped>
.demo {
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg-soft);
margin: 24px 0;
overflow: hidden;
}
.header {
padding: 14px 20px;
background: var(--vp-c-bg);
border-bottom: 1px solid var(--vp-c-divider);
display: flex;
align-items: center;
gap: 10px;
}
.icon {
font-size: 20px;
}
.title {
font-weight: 600;
font-size: 15px;
}
.tabs {
display: flex;
gap: 6px;
padding: 12px 16px;
background: var(--vp-c-bg);
border-bottom: 1px solid var(--vp-c-divider);
overflow-x: auto;
}
.tab {
padding: 8px 14px;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg-soft);
font-size: 13px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
}
.tab:hover {
border-color: var(--vp-c-brand);
}
.tab.active {
background: var(--vp-c-brand);
border-color: var(--vp-c-brand);
color: white;
}
.content {
padding: 20px;
}
.style-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.style-header h4 {
margin: 0;
font-size: 18px;
}
.badge {
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
background: color-mix(in srgb, var(--vp-c-brand) 15%, transparent);
color: var(--vp-c-brand);
}
.desc {
font-size: 14px;
color: var(--vp-c-text-2);
line-height: 1.6;
margin: 0 0 16px 0;
}
.example-section {
margin-bottom: 16px;
}
.example-label {
font-size: 12px;
color: var(--vp-c-text-3);
margin-bottom: 8px;
}
.code-block {
background: #1e293b;
color: #e2e8f0;
padding: 14px;
border-radius: 8px;
font-family: 'Menlo', 'Monaco', monospace;
font-size: 12px;
line-height: 1.6;
overflow-x: auto;
margin: 0;
}
.features {
margin-bottom: 16px;
}
.features-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 10px;
}
.features-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
@media (max-width: 640px) {
.features-grid {
grid-template-columns: 1fr;
}
}
.feature-item {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 13px;
color: var(--vp-c-text-2);
}
.check {
color: var(--vp-c-brand);
font-weight: bold;
}
.meta {
padding-top: 14px;
border-top: 1px solid var(--vp-c-divider);
}
.meta-row {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 8px;
font-size: 13px;
}
.meta-label {
color: var(--vp-c-text-3);
min-width: 70px;
flex-shrink: 0;
}
.meta-value {
color: var(--vp-c-text-2);
}
.meta-link {
color: var(--vp-c-brand);
text-decoration: none;
word-break: break-all;
}
.meta-link:hover {
text-decoration: underline;
}
.compare-section {
background: var(--vp-c-bg);
border-top: 1px solid var(--vp-c-divider);
padding: 16px 20px;
}
.compare-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
}
.compare-table {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
overflow: hidden;
}
.compare-row {
display: grid;
grid-template-columns: 1fr repeat(4, 1fr);
}
.compare-row:nth-child(odd) {
background: var(--vp-c-bg-soft);
}
.compare-row:nth-child(even) {
background: var(--vp-c-bg);
}
.compare-row.head {
background: var(--vp-c-bg-alt);
}
.cell {
padding: 10px 8px;
font-size: 12px;
color: var(--vp-c-text-2);
text-align: center;
border-right: 1px solid var(--vp-c-divider);
}
.cell:last-child {
border-right: none;
}
.head .cell {
font-weight: 600;
color: var(--vp-c-text-1);
}
.cell:first-child {
text-align: left;
font-weight: 500;
color: var(--vp-c-text-1);
padding-left: 12px;
}
.cell.highlight {
background: color-mix(in srgb, var(--vp-c-brand) 10%, transparent);
color: var(--vp-c-brand);
font-weight: 600;
}
.cell.low {
color: #22c55e;
}
.cell.high {
color: #f59e0b;
}
.cell.best {
color: #22c55e;
font-weight: 600;
}
@media (max-width: 640px) {
.compare-row {
grid-template-columns: 70px repeat(4, 1fr);
}
.cell {
padding: 8px 4px;
font-size: 11px;
}
}
</style>
@@ -0,0 +1,533 @@
<template>
<div class="demo">
<div class="header">
<span class="icon">📦</span>
<span class="title">data 字段设计规范</span>
</div>
<div class="tabs">
<button
v-for="tab in tabs"
:key="tab.id"
:class="['tab', { active: active === tab.id }]"
@click="active = tab.id"
>
{{ tab.icon }} {{ tab.name }}
</button>
</div>
<div class="content">
<div v-if="active === 'structure'" class="section">
<h4>单对象 vs 列表</h4>
<div class="compare-row">
<div class="compare-col">
<div class="compare-title">单对象</div>
<pre class="code-sm">{
"code": 0,
"data": {
"id": 123,
"name": "张三"
}
}</pre>
</div>
<div class="compare-col">
<div class="compare-title">列表</div>
<pre class="code-sm">{
"code": 0,
"data": {
"items": [...],
"pagination": {
"page": 1,
"total": 100
}
}
}</pre>
</div>
</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 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>
</div>
<div class="rule-desc">{{ rule.desc }}</div>
</div>
</div>
</div>
<div v-if="active === 'datetime'" class="section">
<h4>时间格式设计</h4>
<div class="time-example">
<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>
</div>
<div class="time-rules">
<div class="time-rule">
<span class="rule-label">格式</span>
<span class="rule-value">ISO 8601</span>
</div>
<div class="time-rule">
<span class="rule-label">时区</span>
<span class="rule-value">UTCZ 后缀或明确偏移量</span>
</div>
<div class="time-rule">
<span class="rule-label">精度</span>
<span class="rule-value">毫秒 .000Z</span>
</div>
<div class="time-rule">
<span class="rule-label">命名</span>
<span class="rule-value">xxx_at 表示时间点xxx_duration 表示时长</span>
</div>
</div>
</div>
<div v-if="active === 'null'" class="section">
<h4>空值处理</h4>
<div class="compare-row">
<div class="compare-col good-col">
<div class="compare-title"> 推荐</div>
<pre class="code-sm">{
"name": "张三",
"nickname": null,
"avatar": null
}</pre>
<div class="compare-desc">字段存在但无值时返回 null</div>
</div>
<div class="compare-col bad-col">
<div class="compare-title"> 不推荐</div>
<pre class="code-sm">{
"name": "张三"
}</pre>
<div class="compare-desc">省略字段前端需判断是否存在</div>
</div>
</div>
<div class="null-tips">
<div class="tip-item">空数组返回 <code>[]</code></div>
<div class="tip-item">空对象返回 <code>{}</code></div>
<div class="tip-item">前端可统一处理无需判断字段是否存在</div>
</div>
</div>
<div v-if="active === 'relation'" class="section">
<h4>关联数据设计</h4>
<div class="relation-tabs">
<button
v-for="r in relations"
:key="r.id"
:class="['r-tab', { active: rId === r.id }]"
@click="rId = r.id"
>
{{ r.name }}
</button>
</div>
<div class="relation-content">
<div class="relation-desc">{{ currentRelation.desc }}</div>
<pre class="code-block"><code>{{ currentRelation.code }}</code></pre>
</div>
</div>
</div>
<div class="tips">
<span class="tips-icon">💡</span>
<span class="tips-text">参考 ISO 8601 时间标准字段命名保持 snake_case 风格</span>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const active = ref('structure')
const rId = ref('embed')
const tabs = [
{ id: 'structure', icon: '📐', name: '结构设计' },
{ id: 'naming', icon: '📝', name: '命名规范' },
{ id: 'datetime', icon: '🕐', name: '时间格式' },
{ id: 'null', icon: '∅', name: '空值处理' },
{ id: 'relation', icon: '🔗', name: '关联数据' }
]
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: '明确是数值类型' }
]
const relations = [
{
id: 'embed',
name: '内嵌',
desc: '适合数据量小、频繁访问的关联数据',
code: `{
"id": 123,
"name": "张三",
"department": {
"id": 1,
"name": "技术部"
}
}`
},
{
id: 'foreign',
name: '外键',
desc: '适合数据量大、按需加载的关联数据',
code: `{
"id": 123,
"name": "张三",
"department_id": 1
}`
},
{
id: 'expand',
name: 'expand 参数',
desc: 'Stripe 风格,客户端按需展开',
code: `// GET /users/123?expand=department
{
"id": 123,
"name": "张三",
"department": {
"id": 1,
"name": "技术部"
}
}`
}
]
const currentRelation = computed(() => {
return relations.find(r => r.id === rId.value) || relations[0]
})
</script>
<style scoped>
.demo {
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg-soft);
margin: 24px 0;
overflow: hidden;
}
.header {
padding: 14px 20px;
background: var(--vp-c-bg);
border-bottom: 1px solid var(--vp-c-divider);
display: flex;
align-items: center;
gap: 10px;
}
.icon {
font-size: 20px;
}
.title {
font-weight: 600;
font-size: 15px;
}
.tabs {
display: flex;
gap: 4px;
padding: 10px 12px;
background: var(--vp-c-bg);
border-bottom: 1px solid var(--vp-c-divider);
overflow-x: auto;
}
.tab {
padding: 6px 12px;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg-soft);
font-size: 12px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
}
.tab:hover {
border-color: var(--vp-c-brand);
}
.tab.active {
background: var(--vp-c-brand);
border-color: var(--vp-c-brand);
color: white;
}
.content {
padding: 16px;
}
.section h4 {
margin: 0 0 12px 0;
font-size: 14px;
}
.compare-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 640px) {
.compare-row {
grid-template-columns: 1fr;
}
}
.compare-col {
padding: 12px;
border-radius: 8px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
}
.compare-col.good-col {
border-color: color-mix(in srgb, #22c55e 30%, transparent);
background: color-mix(in srgb, #22c55e 5%, var(--vp-c-bg));
}
.compare-col.bad-col {
border-color: color-mix(in srgb, #ef4444 30%, transparent);
background: color-mix(in srgb, #ef4444 5%, var(--vp-c-bg));
}
.compare-title {
font-size: 12px;
font-weight: 600;
margin-bottom: 8px;
}
.compare-desc {
font-size: 11px;
color: var(--vp-c-text-3);
margin-top: 8px;
}
.code-sm {
background: #1e293b;
color: #e2e8f0;
padding: 10px;
border-radius: 6px;
font-family: 'Menlo', monospace;
font-size: 11px;
line-height: 1.5;
overflow-x: auto;
margin: 0;
}
.code-block {
background: #1e293b;
color: #e2e8f0;
padding: 12px;
border-radius: 6px;
font-family: 'Menlo', monospace;
font-size: 11px;
line-height: 1.5;
overflow-x: auto;
margin: 0;
}
.note {
font-size: 12px;
color: var(--vp-c-text-2);
margin-top: 12px;
padding: 8px 12px;
background: var(--vp-c-bg);
border-radius: 6px;
}
.rule-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.rule-item {
padding: 12px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
}
.rule-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.rule-icon {
font-size: 16px;
}
.rule-name {
font-size: 13px;
font-weight: 600;
}
.rule-examples {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.rule-examples code {
padding: 3px 8px;
border-radius: 4px;
font-size: 12px;
}
.rule-examples .good {
background: color-mix(in srgb, #22c55e 15%, transparent);
color: #16a34a;
}
.rule-examples .bad {
background: color-mix(in srgb, #ef4444 15%, transparent);
color: #dc2626;
text-decoration: line-through;
}
.rule-examples .vs {
font-size: 10px;
color: var(--vp-c-text-3);
}
.rule-desc {
font-size: 11px;
color: var(--vp-c-text-3);
}
.time-example {
margin-bottom: 12px;
}
.time-rules {
display: flex;
flex-direction: column;
gap: 6px;
}
.time-rule {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: var(--vp-c-bg);
border-radius: 6px;
}
.rule-label {
font-size: 12px;
font-weight: 600;
color: var(--vp-c-text-2);
min-width: 40px;
}
.rule-value {
font-size: 12px;
color: var(--vp-c-text-1);
}
.null-tips {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.tip-item {
font-size: 12px;
color: var(--vp-c-text-2);
padding: 6px 10px;
background: var(--vp-c-bg);
border-radius: 4px;
}
.tip-item code {
background: var(--vp-c-bg-soft);
padding: 1px 5px;
border-radius: 3px;
font-size: 11px;
}
.relation-tabs {
display: flex;
gap: 4px;
margin-bottom: 12px;
}
.r-tab {
padding: 6px 12px;
border: 1px solid var(--vp-c-divider);
border-radius: 4px;
background: var(--vp-c-bg);
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.r-tab:hover {
border-color: var(--vp-c-brand);
}
.r-tab.active {
background: var(--vp-c-brand);
border-color: var(--vp-c-brand);
color: white;
}
.relation-desc {
font-size: 12px;
color: var(--vp-c-text-2);
margin-bottom: 10px;
}
.tips {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: var(--vp-c-bg);
border-top: 1px solid var(--vp-c-divider);
}
.tips-icon {
font-size: 14px;
}
.tips-text {
font-size: 12px;
color: var(--vp-c-text-2);
}
</style>
@@ -0,0 +1,575 @@
<template>
<div class="demo">
<div class="header">
<span class="icon"></span>
<span class="title">错误响应设计进阶</span>
</div>
<div class="tabs">
<button
v-for="tab in tabs"
:key="tab.id"
:class="['tab', { active: active === tab.id }]"
@click="active = tab.id"
>
{{ tab.icon }} {{ tab.name }}
</button>
</div>
<div class="content">
<div v-if="active === 'validate'" class="section">
<h4>参数校验错误</h4>
<pre class="code-block">{
"code": 10001,
"message": "参数校验失败",
"data": {
"errors": [
{
"field": "email",
"message": "邮箱格式不正确",
"value": "invalid-email"
},
{
"field": "password",
"message": "密码长度至少 8 位",
"value": "123"
}
]
}
}</pre>
<div class="field-tips">
<div class="tip-row">
<code>field</code>
<span>出错字段名前端可定位表单</span>
</div>
<div class="tip-row">
<code>message</code>
<span>用户友好的错误描述</span>
</div>
<div class="tip-row">
<code>value</code>
<span>客户端提交的值可选</span>
</div>
</div>
</div>
<div v-if="active === 'business'" class="section">
<h4>业务错误</h4>
<pre class="code-block">{
"code": 20001,
"message": "余额不足",
"data": {
"current_balance": 50.00,
"required_amount": 99.00,
"shortfall": 49.00,
"suggestion": "请充值后重试"
}
}</pre>
<div class="business-tips">
<div class="b-tip"> 返回当前状态数据便于前端展示</div>
<div class="b-tip"> 提供 suggestion 给出解决建议</div>
<div class="b-tip"> 数据结构化前端可灵活展示</div>
</div>
</div>
<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 class="layer-range">{{ layer.range }}</div>
<div class="layer-info">
<div class="layer-name">{{ layer.name }}</div>
<div class="layer-example">示例{{ layer.example }}</div>
</div>
<div class="layer-desc">{{ layer.desc }}</div>
</div>
</div>
<div class="layer-note">错误码从外到内系统 服务 业务 认证 参数</div>
</div>
<div v-if="active === 'http'" class="section">
<h4>HTTP 状态码 vs 业务状态码</h4>
<div class="http-compare">
<div class="http-col">
<div class="http-title">HTTP 状态码</div>
<div class="http-desc">传输层状态</div>
<div class="http-codes">
<div class="http-code">
<span class="code-num">2xx</span>
<span>请求成功</span>
</div>
<div class="http-code">
<span class="code-num">4xx</span>
<span>客户端错误</span>
</div>
<div class="http-code">
<span class="code-num">5xx</span>
<span>服务端错误</span>
</div>
</div>
</div>
<div class="http-arrow"></div>
<div class="http-col">
<div class="http-title">业务状态码</div>
<div class="http-desc">业务层状态</div>
<div class="http-codes">
<div class="http-code">
<span class="code-num">0</span>
<span>业务成功</span>
</div>
<div class="http-code">
<span class="code-num">1xxxx</span>
<span>参数错误</span>
</div>
<div class="http-code">
<span class="code-num">2xxxx</span>
<span>业务错误</span>
</div>
</div>
</div>
</div>
<div class="http-note">HTTP 200 + 业务错误码 是业界主流做法</div>
</div>
<div v-if="active === 'examples'" class="section">
<h4>常见错误码示例</h4>
<div class="ex-tabs">
<button
v-for="ex in examples"
:key="ex.id"
:class="['ex-tab', { active: exId === ex.id }]"
@click="exId = ex.id"
>
{{ ex.name }}
</button>
</div>
<div class="ex-content">
<div class="ex-list">
<div
v-for="item in currentExample.items"
:key="item.code"
class="ex-row"
>
<code class="ex-code">{{ item.code }}</code>
<span class="ex-msg">{{ item.message }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="tips">
<span class="tips-icon">💡</span>
<span class="tips-text">错误信息要"机器可读 + 人类友好"便于前端统一处理</span>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const active = ref('validate')
const exId = ref('param')
const tabs = [
{ id: 'validate', icon: '🔍', name: '参数校验' },
{ id: 'business', icon: '💼', name: '业务错误' },
{ id: 'layers', icon: '📊', name: '分层设计' },
{ id: 'http', icon: '🌐', name: 'HTTP对比' },
{ id: 'examples', icon: '📋', name: '常见示例' }
]
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: '客户端输入问题' }
]
const examples = [
{
id: 'param',
name: '参数层',
items: [
{ code: 10001, message: '缺少必填参数' },
{ code: 10002, message: '参数格式错误' },
{ code: 10003, message: '参数长度超限' },
{ code: 10004, message: '参数值非法' }
]
},
{
id: 'auth',
name: '认证层',
items: [
{ code: 30001, message: '未登录' },
{ code: 30002, message: '登录已过期' },
{ code: 30003, message: '无权限访问' },
{ code: 30004, message: '账号已被禁用' }
]
},
{
id: 'biz',
name: '业务层',
items: [
{ code: 20001, message: '余额不足' },
{ code: 20002, message: '商品已下架' },
{ code: 20003, message: '订单已取消' },
{ code: 20004, message: '库存不足' }
]
},
{
id: 'sys',
name: '系统层',
items: [
{ code: 50001, message: '数据库异常' },
{ code: 50002, message: '缓存服务异常' },
{ code: 50003, message: '系统繁忙,请稍后重试' }
]
}
]
const currentExample = computed(() => {
return examples.find(e => e.id === exId.value) || examples[0]
})
</script>
<style scoped>
.demo {
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg-soft);
margin: 24px 0;
overflow: hidden;
}
.header {
padding: 14px 20px;
background: var(--vp-c-bg);
border-bottom: 1px solid var(--vp-c-divider);
display: flex;
align-items: center;
gap: 10px;
}
.icon {
font-size: 20px;
}
.title {
font-weight: 600;
font-size: 15px;
}
.tabs {
display: flex;
gap: 4px;
padding: 10px 12px;
background: var(--vp-c-bg);
border-bottom: 1px solid var(--vp-c-divider);
overflow-x: auto;
}
.tab {
padding: 6px 12px;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg-soft);
font-size: 12px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
}
.tab:hover {
border-color: var(--vp-c-brand);
}
.tab.active {
background: var(--vp-c-brand);
border-color: var(--vp-c-brand);
color: white;
}
.content {
padding: 16px;
}
.section h4 {
margin: 0 0 12px 0;
font-size: 14px;
}
.code-block {
background: #1e293b;
color: #e2e8f0;
padding: 12px;
border-radius: 6px;
font-family: 'Menlo', monospace;
font-size: 11px;
line-height: 1.5;
overflow-x: auto;
margin: 0;
}
.field-tips {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.tip-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
background: var(--vp-c-bg);
border-radius: 6px;
}
.tip-row code {
background: var(--vp-c-bg-soft);
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
color: var(--vp-c-brand);
min-width: 70px;
}
.tip-row span {
font-size: 12px;
color: var(--vp-c-text-2);
}
.business-tips {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.b-tip {
font-size: 12px;
color: var(--vp-c-text-2);
padding: 6px 10px;
background: var(--vp-c-bg);
border-radius: 4px;
}
.layer-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.layer-item {
display: grid;
grid-template-columns: 100px 1fr auto;
gap: 12px;
align-items: center;
padding: 10px 12px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
}
@media (max-width: 640px) {
.layer-item {
grid-template-columns: 1fr;
gap: 6px;
}
}
.layer-range {
font-family: monospace;
font-size: 12px;
font-weight: 600;
color: var(--vp-c-brand);
background: var(--vp-c-bg-soft);
padding: 4px 8px;
border-radius: 4px;
text-align: center;
}
.layer-name {
font-size: 13px;
font-weight: 600;
}
.layer-example {
font-size: 11px;
color: var(--vp-c-text-3);
}
.layer-desc {
font-size: 11px;
color: var(--vp-c-text-2);
background: color-mix(in srgb, var(--vp-c-brand) 10%, transparent);
padding: 4px 8px;
border-radius: 4px;
}
.layer-note {
margin-top: 12px;
font-size: 12px;
color: var(--vp-c-text-2);
padding: 8px 12px;
background: var(--vp-c-bg);
border-radius: 6px;
}
.http-compare {
display: flex;
align-items: stretch;
gap: 12px;
}
@media (max-width: 640px) {
.http-compare {
flex-direction: column;
}
.http-arrow {
display: none;
}
}
.http-col {
flex: 1;
padding: 12px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
}
.http-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 4px;
}
.http-desc {
font-size: 11px;
color: var(--vp-c-text-3);
margin-bottom: 10px;
}
.http-arrow {
display: flex;
align-items: center;
font-size: 20px;
color: var(--vp-c-text-3);
}
.http-codes {
display: flex;
flex-direction: column;
gap: 6px;
}
.http-code {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.code-num {
font-family: monospace;
font-weight: 600;
color: var(--vp-c-brand);
min-width: 50px;
}
.http-note {
margin-top: 12px;
font-size: 12px;
color: var(--vp-c-text-2);
padding: 8px 12px;
background: color-mix(in srgb, #22c55e 10%, var(--vp-c-bg));
border-radius: 6px;
}
.ex-tabs {
display: flex;
gap: 4px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.ex-tab {
padding: 5px 10px;
border: 1px solid var(--vp-c-divider);
border-radius: 4px;
background: var(--vp-c-bg);
font-size: 11px;
cursor: pointer;
transition: all 0.2s;
}
.ex-tab:hover {
border-color: var(--vp-c-brand);
}
.ex-tab.active {
background: var(--vp-c-brand);
border-color: var(--vp-c-brand);
color: white;
}
.ex-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.ex-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: var(--vp-c-bg);
border-radius: 6px;
}
.ex-code {
font-family: monospace;
font-size: 12px;
font-weight: 600;
color: var(--vp-c-brand);
background: var(--vp-c-bg-soft);
padding: 2px 8px;
border-radius: 4px;
min-width: 50px;
text-align: center;
}
.ex-msg {
font-size: 12px;
color: var(--vp-c-text-2);
}
.tips {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: var(--vp-c-bg);
border-top: 1px solid var(--vp-c-divider);
}
.tips-icon {
font-size: 14px;
}
.tips-text {
font-size: 12px;
color: var(--vp-c-text-2);
}
</style>
@@ -0,0 +1,603 @@
<template>
<div class="demo">
<div class="header">
<span class="icon">📋</span>
<span class="title">API 响应结构设计</span>
</div>
<div class="tabs">
<button
v-for="tab in tabs"
:key="tab.id"
:class="['tab', { active: active === tab.id }]"
@click="active = tab.id"
>
{{ tab.icon }} {{ tab.name }}
</button>
</div>
<div class="content">
<div v-if="active === 'why'" class="section">
<h4>为什么要统一响应格式</h4>
<div class="problem-box">
<div class="problem-title"> 问题不同接口返回格式不一致</div>
<pre class="code-sm">
// 接口 A
{ "data": { "user": {...} } }
// 接口 B
{ "result": { "user": {...} } }
// 接口 C
{ "user": {...} }</pre>
<div class="problem-desc">
前端需要针对每个接口单独处理代码冗余容易出错
</div>
</div>
<div class="solution-box">
<div class="solution-title"> 解决统一响应格式</div>
<pre class="code-sm">
{
"code": 0,
"message": "success",
"data": { ... },
"request_id": "req-xxx"
}</pre>
</div>
</div>
<div v-if="active === 'fields'" class="section">
<h4>响应字段说明</h4>
<div class="field-list">
<div v-for="field in fields" :key="field.name" class="field-item">
<div class="field-header">
<code class="field-name">{{ field.name }}</code>
<span class="field-type">{{ field.type }}</span>
<span v-if="field.required" class="field-required">必填</span>
</div>
<div class="field-desc">{{ field.desc }}</div>
</div>
</div>
</div>
<div v-if="active === 'codes'" class="section">
<h4>业务状态码设计</h4>
<div class="code-ranges">
<div class="range-item">
<span class="range-num">0</span>
<span class="range-label">成功</span>
</div>
<div class="range-item">
<span class="range-num">1xxxx</span>
<span class="range-label">客户端错误</span>
</div>
<div class="range-item">
<span class="range-num">2xxxx</span>
<span class="range-label">业务错误</span>
</div>
<div class="range-item">
<span class="range-num">3xxxx</span>
<span class="range-label">认证/权限错误</span>
</div>
<div class="range-item">
<span class="range-num">5xxxx</span>
<span class="range-label">系统错误</span>
</div>
</div>
<div class="code-examples">
<div v-for="code in codeExamples" :key="code.code" class="code-row">
<code class="code-value">{{ code.code }}</code>
<span class="code-msg">{{ code.message }}</span>
</div>
</div>
</div>
<div v-if="active === 'examples'" class="section">
<h4>不同场景响应示例</h4>
<div class="example-tabs">
<button
v-for="ex in examples"
:key="ex.id"
:class="['ex-tab', { active: exId === ex.id }]"
@click="exId = ex.id"
>
{{ ex.name }}
</button>
</div>
<div class="example-content">
<pre class="code-block"><code>{{ currentExample.code }}</code></pre>
<div class="example-note">{{ currentExample.note }}</div>
</div>
</div>
<div v-if="active === 'pagination'" class="section">
<h4>分页参数设计</h4>
<div class="pg-row">
<div class="pg-col">
<div class="pg-title">请求参数</div>
<div class="pg-params">
<div class="pg-item">
<code>page</code>
<span>页码 1 开始</span>
</div>
<div class="pg-item">
<code>page_size</code>
<span>每页数量默认 20</span>
</div>
<div class="pg-item">
<code>sort</code>
<span>排序 created_desc</span>
</div>
</div>
</div>
<div class="pg-col">
<div class="pg-title">响应格式</div>
<pre class="code-sm">
"pagination": {
"page": 1,
"page_size": 20,
"total": 156,
"total_pages": 8,
"has_next": true
}</pre>
</div>
</div>
</div>
</div>
<div class="tips">
<span class="tips-icon">💡</span>
<span class="tips-text">request_id 用于问题追踪建议使用 UUID v4 或雪花算法生成</span>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const active = ref('why')
const exId = ref('success')
const tabs = [
{ id: 'why', icon: '❓', name: '为什么统一' },
{ id: 'fields', icon: '📝', name: '字段说明' },
{ id: 'codes', icon: '🔢', name: '状态码' },
{ id: 'examples', icon: '📄', name: '示例' },
{ id: 'pagination', icon: '📑', name: '分页' }
]
const fields = [
{
name: 'code',
type: 'number',
required: true,
desc: '业务状态码,0 表示成功'
},
{ name: 'message', type: 'string', required: true, desc: '状态描述信息' },
{
name: 'data',
type: 'any',
required: false,
desc: '业务数据,失败时可为 null'
},
{
name: 'request_id',
type: 'string',
required: true,
desc: '请求唯一标识,用于追踪'
},
{
name: 'timestamp',
type: 'string',
required: false,
desc: '响应时间戳,ISO 8601 格式'
}
]
const codeExamples = [
{ code: 0, message: 'success - 成功' },
{ code: 10001, message: '参数错误:缺少必填字段' },
{ code: 10002, message: '资源不存在' },
{ code: 20001, message: '余额不足' },
{ code: 30001, message: '未登录' },
{ code: 50001, message: '系统繁忙,请稍后重试' }
]
const examples = [
{
id: 'success',
name: '成功-单对象',
code: `{
"code": 0,
"message": "success",
"data": {
"id": 123,
"name": "张三",
"email": "zhangsan@example.com"
},
"request_id": "req-abc123"
}`,
note: '成功响应:data 包含具体业务数据'
},
{
id: 'list',
name: '成功-列表',
code: `{
"code": 0,
"message": "success",
"data": {
"items": [
{ "id": 1, "name": "商品A" },
{ "id": 2, "name": "商品B" }
],
"pagination": {
"page": 1,
"page_size": 20,
"total": 156
}
},
"request_id": "req-def456"
}`,
note: '列表响应:items 数组 + pagination 分页信息'
},
{
id: 'error',
name: '业务错误',
code: `{
"code": 20001,
"message": "余额不足,当前余额 50.00 元",
"data": null,
"request_id": "req-ghi789"
}`,
note: '业务错误:code 非 0message 说明原因'
},
{
id: 'validate',
name: '参数校验',
code: `{
"code": 10001,
"message": "参数校验失败",
"data": {
"errors": [
{ "field": "email", "message": "邮箱格式不正确" },
{ "field": "password", "message": "密码长度至少 8 位" }
]
},
"request_id": "req-jkl012"
}`,
note: '参数错误:data.errors 列出所有错误字段'
}
]
const currentExample = computed(() => {
return examples.find((e) => e.id === exId.value) || examples[0]
})
</script>
<style scoped>
.demo {
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: var(--vp-c-bg-soft);
margin: 24px 0;
overflow: hidden;
}
.header {
padding: 14px 20px;
background: var(--vp-c-bg);
border-bottom: 1px solid var(--vp-c-divider);
display: flex;
align-items: center;
gap: 10px;
}
.icon {
font-size: 20px;
}
.title {
font-weight: 600;
font-size: 15px;
}
.tabs {
display: flex;
gap: 4px;
padding: 10px 12px;
background: var(--vp-c-bg);
border-bottom: 1px solid var(--vp-c-divider);
overflow-x: auto;
}
.tab {
padding: 6px 12px;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg-soft);
font-size: 12px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
}
.tab:hover {
border-color: var(--vp-c-brand);
}
.tab.active {
background: var(--vp-c-brand);
border-color: var(--vp-c-brand);
color: white;
}
.content {
padding: 16px;
}
.section h4 {
margin: 0 0 12px 0;
font-size: 14px;
}
.problem-box,
.solution-box {
margin-bottom: 12px;
padding: 12px;
border-radius: 8px;
}
.problem-box {
background: color-mix(in srgb, #ef4444 8%, var(--vp-c-bg));
border: 1px solid color-mix(in srgb, #ef4444 20%, transparent);
}
.solution-box {
background: color-mix(in srgb, #22c55e 8%, var(--vp-c-bg));
border: 1px solid color-mix(in srgb, #22c55e 20%, transparent);
}
.problem-title,
.solution-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
}
.problem-desc {
font-size: 12px;
color: var(--vp-c-text-3);
margin-top: 8px;
}
.code-sm {
background: #1e293b;
color: #e2e8f0;
padding: 10px;
border-radius: 6px;
font-family: 'Menlo', monospace;
font-size: 11px;
line-height: 1.5;
overflow-x: auto;
margin: 0;
}
.field-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.field-item {
padding: 10px 12px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
}
.field-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.field-name {
font-size: 13px;
font-weight: 600;
color: var(--vp-c-brand);
}
.field-type {
font-size: 11px;
color: var(--vp-c-text-3);
background: var(--vp-c-bg-soft);
padding: 2px 6px;
border-radius: 4px;
}
.field-required {
font-size: 10px;
color: #f59e0b;
background: color-mix(in srgb, #f59e0b 15%, transparent);
padding: 1px 5px;
border-radius: 3px;
}
.field-desc {
font-size: 12px;
color: var(--vp-c-text-2);
}
.code-ranges {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.range-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
}
.range-num {
font-family: monospace;
font-size: 12px;
font-weight: 600;
color: var(--vp-c-brand);
}
.range-label {
font-size: 11px;
color: var(--vp-c-text-2);
}
.code-examples {
display: flex;
flex-direction: column;
gap: 4px;
}
.code-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: var(--vp-c-bg);
border-radius: 4px;
}
.code-value {
font-family: monospace;
font-size: 12px;
font-weight: 600;
color: var(--vp-c-brand);
min-width: 50px;
}
.code-msg {
font-size: 12px;
color: var(--vp-c-text-2);
}
.example-tabs {
display: flex;
gap: 4px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.ex-tab {
padding: 5px 10px;
border: 1px solid var(--vp-c-divider);
border-radius: 4px;
background: var(--vp-c-bg);
font-size: 11px;
cursor: pointer;
transition: all 0.2s;
}
.ex-tab:hover {
border-color: var(--vp-c-brand);
}
.ex-tab.active {
background: var(--vp-c-brand);
border-color: var(--vp-c-brand);
color: white;
}
.code-block {
background: #1e293b;
color: #e2e8f0;
padding: 12px;
border-radius: 6px;
font-family: 'Menlo', monospace;
font-size: 11px;
line-height: 1.5;
overflow-x: auto;
margin: 0;
}
.example-note {
font-size: 11px;
color: var(--vp-c-text-3);
margin-top: 8px;
padding-left: 4px;
}
.pg-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 640px) {
.pg-row {
grid-template-columns: 1fr;
}
}
.pg-col {
padding: 12px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
}
.pg-title {
font-size: 12px;
font-weight: 600;
margin-bottom: 10px;
}
.pg-params {
display: flex;
flex-direction: column;
gap: 6px;
}
.pg-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
}
.pg-item code {
background: var(--vp-c-bg-soft);
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
color: var(--vp-c-brand);
}
.pg-item span {
color: var(--vp-c-text-2);
}
.tips {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: var(--vp-c-bg);
border-top: 1px solid var(--vp-c-divider);
}
.tips-icon {
font-size: 14px;
}
.tips-text {
font-size: 12px;
color: var(--vp-c-text-2);
}
</style>
@@ -0,0 +1,417 @@
<template>
<div class="raf-root">
<div class="raf-layout">
<!-- Left: Client Side -->
<div class="raf-left">
<div class="raf-header">
<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"
:key="s.id"
:class="['raf-chip', { active: currentScenario.id === s.id }]"
@click="selectScenario(s)"
:disabled="processing"
>
{{ s.label }}
</button>
</div>
</div>
<div class="raf-request-box">
<div class="raf-http-line">
<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"
:disabled="processing"
>
{{ processing ? 'Sending...' : 'Send Request' }}
</button>
</div>
<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)]">
{{ response.status }} {{ response.statusText }}
</span>
</div>
<div class="raf-code-block response-body">
{{ JSON.stringify(response.body, null, 2) }}
</div>
</div>
</div>
<!-- Right: Server Side -->
<div class="raf-right">
<div class="raf-header server-header">
<span class="raf-icon"></span>
<span class="raf-title">Server (API)</span>
</div>
<div class="raf-server-state">
<!-- Database View -->
<div class="raf-section">
<div class="raf-section-title">📦 Database (Users Resource)</div>
<div class="raf-db-view">
<transition-group name="list">
<div v-for="user in db" :key="user.id" class="raf-db-item">
<span class="raf-db-id">ID: {{ user.id }}</span>
<span class="raf-db-name">{{ user.name }}</span>
<span class="raf-db-role">({{ user.role }})</span>
</div>
</transition-group>
<div v-if="db.length === 0" class="raf-empty">No users found</div>
</div>
</div>
<!-- Logs -->
<div class="raf-section">
<div class="raf-section-title">📜 Server Logs</div>
<div class="raf-logs" ref="logsRef">
<div v-for="(log, i) in logs" :key="i" class="raf-log-line">
<span class="raf-log-time">[{{ log.time }}]</span>
<span :class="log.type">{{ log.msg }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, nextTick } from 'vue'
const processing = ref(false)
const response = ref(null)
const logs = ref([])
const logsRef = ref(null)
const db = ref([
{ 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 },
]
const currentScenario = ref(scenarios[0])
function selectScenario(s) {
currentScenario.value = s
response.value = null
}
function addLog(msg, type = 'info') {
const now = new Date()
const time = `${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}`
logs.value.push({ time, msg, type })
nextTick(() => {
if (logsRef.value) logsRef.value.scrollTop = logsRef.value.scrollHeight
})
}
function getStatusColor(status) {
if (status >= 200 && status < 300) return 'status-success'
if (status >= 400 && status < 500) return 'status-error'
return 'status-neutral'
}
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
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+/)) {
const id = parseInt(url.split('/').pop())
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" } }
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 }
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+/)) {
const id = parseInt(url.split('/').pop())
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" } }
addLog(`User ${id} not found for deletion`, 'error')
}
}
processing.value = false
}
</script>
<style scoped>
.raf-root {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
overflow: hidden;
background: var(--vp-c-bg-soft);
margin: 1rem 0;
font-family: var(--vp-font-family-mono);
font-size: 13px;
}
.raf-layout {
display: flex;
min-height: 400px;
}
.raf-left, .raf-right {
flex: 1;
padding: 1.2rem;
display: flex;
flex-direction: column;
}
.raf-left {
border-right: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
}
.raf-right {
background: var(--vp-c-bg-alt);
}
.raf-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 1rem;
font-weight: 600;
font-size: 1.1em;
color: var(--vp-c-text-1);
}
.raf-scenarios {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 1.5rem;
}
.raf-chip {
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
color: var(--vp-c-text-2);
}
.raf-chip:hover {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
}
.raf-chip.active {
background: var(--vp-c-brand);
color: white;
border-color: var(--vp-c-brand);
}
.raf-request-box {
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
padding: 1rem;
background: var(--vp-c-bg-soft);
margin-bottom: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.raf-http-line {
display: flex;
gap: 10px;
font-family: monospace;
margin-bottom: 8px;
align-items: center;
font-size: 1.1em;
}
.raf-method {
font-weight: bold;
}
.raf-method.GET { color: #61affe; }
.raf-method.POST { color: #49cc90; }
.raf-method.DELETE { color: #f93e3e; }
.raf-code-block {
background: var(--vp-c-bg);
padding: 10px;
border-radius: 4px;
font-size: 12px;
white-space: pre;
overflow-x: auto;
border: 1px solid var(--vp-c-divider);
color: var(--vp-c-text-2);
}
.raf-send-btn {
margin-top: 10px;
width: 100%;
padding: 10px;
background: var(--vp-c-brand);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: opacity 0.2s;
}
.raf-send-btn:hover {
opacity: 0.9;
}
.raf-send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.raf-response-box {
margin-top: auto;
border-top: 1px solid var(--vp-c-divider);
padding-top: 1rem;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.raf-status-line {
margin-bottom: 8px;
display: flex;
align-items: center;
}
.raf-status-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
margin-left: 8px;
}
.status-success { background: #d1fae5; color: #065f46; }
.status-error { background: #fee2e2; color: #991b1b; }
.status-neutral { background: #f3f4f6; color: #374151; }
.raf-db-view {
display: flex;
flex-direction: column;
gap: 6px;
}
.raf-db-item {
display: flex;
gap: 10px;
padding: 8px 12px;
background: var(--vp-c-bg);
border-radius: 6px;
border: 1px solid var(--vp-c-divider);
font-size: 12px;
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-logs {
height: 180px;
overflow-y: auto;
background: #1e1e1e;
color: #d4d4d4;
padding: 12px;
border-radius: 6px;
font-family: monospace;
font-size: 11px;
line-height: 1.5;
}
.raf-log-line {
display: flex;
gap: 8px;
margin-bottom: 4px;
}
.raf-log-time { color: #6b7280; flex-shrink: 0; }
.info { color: #93c5fd; }
.success { color: #86efac; }
.error { color: #fca5a5; }
.raf-section-title {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: bold;
margin-bottom: 8px;
color: var(--vp-c-text-3);
margin-top: 1.5rem;
}
.raf-section:first-child .raf-section-title { margin-top: 0; }
.raf-empty {
color: var(--vp-c-text-3);
font-style: italic;
padding: 10px;
text-align: center;
}
.list-enter-active,
.list-leave-active {
transition: all 0.3s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(20px);
}
@media (max-width: 768px) {
.raf-layout { flex-direction: column; }
.raf-left { border-right: none; border-bottom: 1px solid var(--vp-c-divider); }
}
</style>