chore: save local history restorations from accidental git restore
This commit is contained in:
@@ -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">UTC(Z 后缀)或明确偏移量</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 非 0,message 说明原因'
|
||||
},
|
||||
{
|
||||
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>
|
||||
Reference in New Issue
Block a user