first commit

This commit is contained in:
2026-07-02 23:51:05 +08:00
commit 438be1a9df
32 changed files with 8418 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
<template>
<div class="app-root" v-if="store.rootDir">
<TopBar />
<div class="app-body">
<aside class="side">
<ProjectTree />
</aside>
<main class="main">
<RequestPanel />
</main>
</div>
<StatusBar />
</div>
<EmptyState v-else />
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useWorkspaceStore } from '@/stores/workspace'
import TopBar from '@/components/TopBar.vue'
import ProjectTree from '@/components/ProjectTree.vue'
import RequestPanel from '@/components/RequestPanel.vue'
import StatusBar from '@/components/StatusBar.vue'
import EmptyState from '@/components/EmptyState.vue'
const store = useWorkspaceStore()
onMounted(async () => {
const root = await window.xs.workspace.get()
if (root) await store.init(root)
})
</script>
<style lang="scss" scoped>
.app-root {
height: 100vh;
display: flex;
flex-direction: column;
-webkit-app-region: drag;
}
.app-body {
flex: 1;
display: flex;
min-height: 0;
}
.side {
width: 280px;
border-right: 1px solid $border-soft;
background: $bg-base;
display: flex;
flex-direction: column;
-webkit-app-region: no-drag;
}
.main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: $bg-deep;
-webkit-app-region: no-drag;
}
</style>
+93
View File
@@ -0,0 +1,93 @@
<template>
<div class="ae">
<div class="type-row">
<span class="label">认证方式</span>
<div class="type-pills">
<button v-for="t in types" :key="t.value" class="type-pill"
:class="{ active: auth.type === t.value }"
@click="auth.type = t.value; $emit('change')">{{ t.label }}</button>
</div>
</div>
<div v-if="auth.type === 'bearer'" class="form">
<label>
<span>Token <code class="hint" v-pre>{{token}}</code> 可以引用环境变量</span>
<input class="tech-input" v-model="auth.token" @input="$emit('change')" placeholder="eyJhbGciOi..." />
</label>
</div>
<div v-else-if="auth.type === 'basic'" class="form grid">
<label>
<span>用户名</span>
<input class="tech-input" v-model="auth.username" @input="$emit('change')" />
</label>
<label>
<span>密码</span>
<input class="tech-input" type="password" v-model="auth.password" @input="$emit('change')" />
</label>
</div>
<div v-else-if="auth.type === 'apiKey'" class="form grid">
<label>
<span>Key </span>
<input class="tech-input" v-model="auth.apiKeyName" @input="$emit('change')" placeholder="X-API-Key" />
</label>
<label>
<span>Key </span>
<input class="tech-input" v-model="auth.token" @input="$emit('change')" />
</label>
<label class="full">
<span>位置</span>
<select v-model="auth.apiKeyIn" class="tech-input" @change="$emit('change')">
<option value="header">Header</option>
<option value="query">Query</option>
</select>
</label>
</div>
<div v-else class="muted form">当前接口无需认证</div>
</div>
</template>
<script setup lang="ts">
import type { AuthConfig } from '@/types'
defineProps<{ auth: AuthConfig }>()
defineEmits<{ (e: 'change'): void }>()
const types = [
{ value: 'none', label: 'No Auth' },
{ value: 'bearer', label: 'Bearer Token' },
{ value: 'basic', label: 'Basic Auth' },
{ value: 'apiKey', label: 'API Key' }
] as const
</script>
<style lang="scss" scoped>
.ae { display: flex; flex-direction: column; gap: 12px; }
.type-row { display: flex; align-items: center; gap: 12px; }
.label { color: $text-tertiary; font-size: $font-sm; }
.type-pills { display: flex; gap: 6px; }
.type-pill {
padding: 5px 12px;
border-radius: 14px;
background: transparent;
border: 1px solid $border-soft;
color: $text-secondary;
cursor: pointer;
font-size: $font-sm;
&:hover { border-color: $primary; color: $primary; }
&.active { background: $primary; border-color: $primary; color: #0a1428; font-weight: 600; }
}
.form { display: flex; flex-direction: column; gap: 8px; }
.form.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
.full { grid-column: 1 / -1; }
}
label { display: flex; flex-direction: column; gap: 4px; }
label span { color: $text-secondary; font-size: $font-sm; }
.hint { background: $bg-elevated; padding: 0 4px; border-radius: 3px; color: $primary; }
.tech-input { width: 100%; padding: 6px 10px; }
</style>
+275
View File
@@ -0,0 +1,275 @@
<template>
<div class="be">
<div class="types">
<button v-for="t in types" :key="t.value" class="type-pill"
:class="{ active: body.type === t.value }"
@click="setType(t.value)">{{ t.label }}</button>
</div>
<div v-if="body.type === 'none'" class="empty">
<div class="muted">此接口请求没有 Body点击上方切换以选择 Body 类型</div>
</div>
<template v-else-if="isTextBody">
<div class="mode-tabs">
<button class="mode-pill" :class="{ active: editorMode === 'json' }" @click="editorMode = 'json'">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"><path d="M8 4l-2 4 2 4M16 4l2 4-2 4M14 8h-4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
JSON 文本
</button>
<button class="mode-pill" :class="{ active: editorMode === 'table' }"
:disabled="!canSwitchTable"
:title="!canSwitchTable ? '当前 JSON 不是对象,无法切换到表单模式' : ''"
@click="switchToTable">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"><path d="M3 6h18M3 12h18M3 18h18" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>
表单 (树形)
</button>
</div>
<textarea
v-if="editorMode === 'json'"
v-model="body.content"
class="json"
:placeholder="textPlaceholder"
@input="onJsonChange"
/>
<TreeBody
v-else
:items="tableItems"
root-type="object"
root-add-label="字段"
root-label="字段名"
@change="onTableChange"
/>
</template>
<template v-else-if="isFormBody">
<ParamsTable
:items="formItems"
placeholder-key="参数名"
placeholder-value="参数值"
@change="$emit('change')"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { BodyConfig, ParamItem } from '@/types'
import ParamsTable from './ParamsTable.vue'
import TreeBody from './BodyTree.vue'
const props = defineProps<{ body: BodyConfig }>()
const emit = defineEmits<{ (e: 'change'): void }>()
const types = [
{ value: 'none', label: 'none' },
{ value: 'json', label: 'JSON' },
{ value: 'form-data', label: 'form-data' },
{ value: 'x-www-form-urlencoded', label: 'x-www-form-urlencoded' },
{ value: 'raw', label: 'Text' }
] as const
const isTextBody = computed(() => props.body.type === 'json' || props.body.type === 'raw')
const isFormBody = computed(() => props.body.type === 'form-data' || props.body.type === 'x-www-form-urlencoded')
const formItems = computed<ParamItem[]>(() => props.body.formItems ?? [])
const editorMode = ref<'json' | 'table'>('json')
const tableItems = ref<ParamItem[]>([])
const canSwitchTable = computed(() => {
const v = props.body.content?.trim() ?? ''
if (!v) return true
try {
const obj = JSON.parse(v)
return obj && typeof obj === 'object' && !Array.isArray(obj)
} catch {
return false
}
})
const switchToTable = () => {
const v = props.body.content?.trim() ?? ''
if (!v) {
tableItems.value = [{ key: '', value: '', type: 'string', enabled: true }]
editorMode.value = 'table'
return
}
try {
const obj = JSON.parse(v)
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return
tableItems.value = objectToRows(obj)
if (!tableItems.value.length) {
tableItems.value = [{ key: '', value: '', type: 'string', enabled: true }]
}
editorMode.value = 'table'
} catch {}
}
const onJsonChange = () => emit('change')
const onTableChange = () => {
const obj: Record<string, unknown> = {}
for (const it of tableItems.value) {
if (it.enabled === false) continue
if (!it.key) continue
obj[it.key] = buildValue(it)
}
props.body.content = JSON.stringify(obj, null, 2)
emit('change')
}
function buildValue(it: ParamItem): unknown {
const t = it.type || 'string'
const v = it.value ?? ''
switch (t) {
case 'string': return v
case 'number': return v === '' ? 0 : Number(v)
case 'boolean': return v === 'true'
case 'null': return null
case 'object': {
const out: Record<string, unknown> = {}
for (const c of it.children ?? []) {
if (c.enabled === false) continue
if (!c.key) continue
out[c.key] = buildValue(c)
}
return out
}
case 'array': {
const arr: unknown[] = []
for (const c of it.children ?? []) {
if (c.enabled === false) continue
arr.push(buildValue(c))
}
return arr
}
}
}
function objectToRows(obj: Record<string, unknown>, _key?: string): ParamItem[] {
const rows: ParamItem[] = []
for (const [k, val] of Object.entries(obj)) {
rows.push(valueToRow(val, k))
}
return rows
}
function valueToRow(v: unknown, k = ''): ParamItem {
const t = inferType(v)
if (t === 'object') {
return {
key: k, value: '', type: 'object',
children: objectToRows(v as Record<string, unknown>)
}
}
if (t === 'array') {
const children: ParamItem[] = []
;(v as unknown[]).forEach((cv) => {
const row = valueToRow(cv, '')
children.push(row)
})
return { key: k, value: '', type: 'array', children }
}
return { key: k, value: stringifyPrimitive(v), type: t }
}
function stringifyPrimitive(v: unknown): string {
if (v === null) return ''
return String(v)
}
function inferType(v: unknown): ParamItem['type'] {
if (v === null) return 'null'
if (typeof v === 'number') return 'number'
if (typeof v === 'boolean') return 'boolean'
if (Array.isArray(v)) return 'array'
if (typeof v === 'object') return 'object'
return 'string'
}
const textPlaceholder = computed(() =>
props.body.type === 'json' ? `{\n "key": "value"\n}` : '请输入...'
)
function setType(v: BodyConfig['type']) {
props.body.type = v
if ((v === 'form-data' || v === 'x-www-form-urlencoded') && !props.body.formItems) {
props.body.formItems = []
}
if (v === 'json' && !props.body.rawType) props.body.rawType = 'json'
emit('change')
}
watch(() => props.body.type, (t) => {
if (t !== 'json' && t !== 'raw') editorMode.value = 'json'
})
</script>
<style lang="scss" scoped>
.be { display: flex; flex-direction: column; gap: 10px; }
.types { display: flex; gap: 6px; flex-wrap: wrap; }
.type-pill {
padding: 4px 10px;
border-radius: 14px;
background: transparent;
border: 1px solid $border-soft;
color: $text-secondary;
cursor: pointer;
font-size: $font-sm;
&:hover { border-color: $primary; color: $primary; }
&.active {
background: $primary;
border-color: $primary;
color: #0a1428;
font-weight: 600;
}
}
.mode-tabs {
display: flex;
gap: 6px;
padding-bottom: 4px;
border-bottom: 1px dashed $border-soft;
}
.mode-pill {
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 10px;
border-radius: 6px 6px 0 0;
background: transparent;
border: 1px solid transparent;
color: $text-tertiary;
cursor: pointer;
font-size: $font-sm;
&:hover:not(:disabled) { color: $primary; }
&.active {
color: $primary;
border-color: $border-soft;
border-bottom-color: $bg-deep;
background: $bg-deep;
position: relative;
bottom: -1px;
}
&:disabled { opacity: .4; cursor: not-allowed; }
}
.empty {
padding: 16px;
border: 1px dashed $border;
border-radius: $radius;
text-align: center;
}
.json {
width: 100%;
min-height: 180px;
padding: 10px;
background: $bg-base;
border: 1px solid $border-soft;
border-radius: $radius;
outline: none;
font-family: 'Consolas','Menlo',monospace;
font-size: 12px;
color: #d5e6ff;
resize: vertical;
&:focus { border-color: $primary; box-shadow: 0 0 0 2px $primary-bg; }
}
</style>
+76
View File
@@ -0,0 +1,76 @@
<template>
<div class="te">
<div class="head-row" :style="{ paddingLeft: depth * 18 + 'px' }">
<div class="arrow"></div>
<div class="col-type">类型</div>
<div class="col-key">{{ rootType === 'array' ? '索引' : rootLabel }}</div>
<div class="col-val"></div>
<div class="col-act"></div>
</div>
<template v-for="(item, idx) in items" :key="idx">
<TreeRow
:item="item"
:depth="depth"
:is-array="rootType === 'array'"
:idx="idx"
@change="$emit('change')"
@remove="removeAt(idx)"
/>
</template>
<div class="add-bar" :style="{ paddingLeft: depth * 18 + 30 + 'px' }">
<button class="add" @click="addItem">+ 添加{{ rootAddLabel }}</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { ParamItem } from '@/types'
import TreeRow from './TreeRow.vue'
const props = defineProps<{
items: ParamItem[]
depth?: number
rootType: 'object' | 'array'
rootLabel?: string
rootAddLabel: string
}>()
defineEmits<{ (e: 'change'): void }>()
const depth = props.depth ?? 0
function removeAt(idx: number) {
props.items.splice(idx, 1)
}
function addItem() {
props.items.push({ key: '', value: '', type: 'string', enabled: true })
}
</script>
<style lang="scss" scoped>
.te { display: flex; flex-direction: column; gap: 4px; }
.head-row {
display: grid;
grid-template-columns: 16px 90px 1fr 1.4fr 32px;
gap: 6px;
font-size: $font-sm;
color: $text-tertiary;
padding: 4px;
border-bottom: 1px dashed $border-soft;
}
.arrow { width: 16px; }
.add-bar button.add {
width: 100%;
padding: 6px;
border: 1px dashed $border;
background: transparent;
border-radius: $radius;
color: $text-secondary;
cursor: pointer;
font-size: $font-sm;
&:hover { border-color: $primary; color: $primary; }
}
</style>
+136
View File
@@ -0,0 +1,136 @@
<template>
<div class="empty-state">
<div class="hero">
<div class="orb"></div>
<div class="logo">
<svg width="64" height="64" viewBox="0 0 64 64">
<defs>
<linearGradient id="lg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#4ea1ff" />
<stop offset="100%" stop-color="#22d3ee" />
</linearGradient>
</defs>
<rect x="2" y="2" width="60" height="60" rx="14" fill="#0f1c36" stroke="#3553a3" />
<path d="M16 32h32M32 16v32" stroke="url(#lg)" stroke-width="3" stroke-linecap="round" />
<circle cx="32" cy="32" r="6" fill="#7eb6ff" />
</svg>
</div>
<div class="title">XsinfoApi</div>
<div class="subtitle">私有化 · 版本化 · 集成于 Git API 调试工作台</div>
<div class="actions">
<button class="xs-btn primary" @click="open" :disabled="loading">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M3 7h6v2H3V7zm0 4h10v2H3v-2zm0 4h14v2H3v-2zM18 7l5 5-5 5v-3h-4v-4h4V7z" fill="currentColor"/></svg>
打开本地项目 (Git 仓库)
</button>
<button class="xs-btn ghost" @click="showTip = !showTip">使用说明</button>
</div>
<div class="features" v-show="showTip">
<div class="feature">
<div class="dot" style="background:#4ea1ff"></div>
<div><b>数据私有化</b><div class="muted">每个 API 是仓库中的一个 .json 文件 Git 提交PRcode review 自然流转</div></div>
</div>
<div class="feature">
<div class="dot" style="background:#22d3ee"></div>
<div><b>多环境一键切换</b><div class="muted">开发 / 测试 / 生产环境的 BaseURL 与变量集中管理自动注入到 URL</div></div>
</div>
<div class="feature">
<div class="dot" style="background:#8b5cf6"></div>
<div><b> APIFox 一样调试</b><div class="muted">左侧目录树右侧请求面板支持参数HeadersBodyAuth响应预览</div></div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useWorkspaceStore } from '@/stores/workspace'
const showTip = ref(true)
const loading = ref(false)
const store = useWorkspaceStore()
async function open() {
loading.value = true
try {
await store.pickWorkspace()
} finally {
loading.value = false
}
}
</script>
<style lang="scss" scoped>
.empty-state {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(800px 600px at 70% 30%, rgba(78,161,255,.12), transparent 70%),
radial-gradient(600px 500px at 20% 80%, rgba(34,211,238,.10), transparent 70%),
$bg-deep;
overflow: hidden;
position: relative;
}
.hero {
width: 520px;
text-align: center;
padding: 48px;
position: relative;
}
.orb {
position: absolute;
width: 360px;
height: 360px;
border-radius: 50%;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
background: radial-gradient(circle, rgba(78,161,255,.2), transparent 60%);
filter: blur(20px);
z-index: 0;
}
.logo { position: relative; display: inline-block; }
.title {
position: relative;
font-size: 32px;
font-weight: 700;
margin-top: 16px;
letter-spacing: 4px;
background: linear-gradient(90deg, #4ea1ff, #22d3ee);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
position: relative;
color: $text-secondary;
margin-top: 8px;
font-size: 14px;
}
.actions {
position: relative;
margin-top: 28px;
display: flex;
gap: 12px;
justify-content: center;
}
.features {
position: relative;
text-align: left;
margin-top: 36px;
background: rgba(15,28,54,.5);
border: 1px solid $border-soft;
border-radius: 12px;
padding: 18px;
}
.feature {
display: flex;
gap: 12px;
padding: 10px 6px;
border-bottom: 1px dashed $border-soft;
&:last-child { border-bottom: none; }
.dot { width: 8px; height: 8px; border-radius: 50%; margin-top: 6px; box-shadow: 0 0 10px currentColor; }
}
</style>
+195
View File
@@ -0,0 +1,195 @@
<template>
<aside class="env-panel">
<div class="header">
<div class="title">环境管理</div>
<button class="xs-btn ghost icon" @click="$emit('close')">×</button>
</div>
<div class="section">
<div class="section-title">当前环境</div>
<div class="env-list">
<div
v-for="e in store.env.envs"
:key="e.id"
class="env-card"
:class="{ active: store.env.activeEnv === e.id }"
@click="store.env.activeEnv = e.id; store.saveEnv()"
>
<div class="radio">
<span :class="{ on: store.env.activeEnv === e.id }"></span>
</div>
<div class="meta">
<div class="name">{{ e.name }}</div>
<div class="base">{{ e.baseUrl }}</div>
</div>
<button class="xs-btn ghost icon danger" @click.stop="removeEnv(e.id)" title="删除">×</button>
</div>
<button class="add-env" @click="addEnv">+ 新增环境</button>
</div>
</div>
<div v-if="current" class="section">
<div class="section-title">基本配置</div>
<label>
<span>环境名称</span>
<input v-model="current.name" class="tech-input" @blur="store.saveEnv()" />
</label>
<label>
<span>服务地址 (Base URL)</span>
<input v-model="current.baseUrl" class="tech-input" @blur="store.saveEnv()" placeholder="https://api.example.com" />
</label>
</div>
<div v-if="current" class="section">
<div class="section-title">环境变量 <span class="muted" v-html="varHint"></span></div>
<div class="var-table">
<div class="row head">
<div>变量名</div><div>变量值</div><div>说明</div><div></div>
</div>
<div v-for="(v, idx) in current.variables" :key="idx" class="row">
<input class="cell" v-model="v.key" placeholder="token" @blur="store.saveEnv()" />
<input class="cell" v-model="v.value" placeholder="xxxx" @blur="store.saveEnv()" />
<input class="cell" v-model="v.description" placeholder="可选" @blur="store.saveEnv()" />
<button class="xs-btn ghost icon danger" @click="current.variables.splice(idx, 1); store.saveEnv()"></button>
</div>
<button class="add-var" @click="current.variables.push({key:'',value:'',description:''}); store.saveEnv()">+ 添加变量</button>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useWorkspaceStore } from '@/stores/workspace'
const store = useWorkspaceStore()
const varHint = '可被 URL 中的 <code>{{变量名}}</code> 引用'
const current = computed(() => store.activeEnv)
function addEnv() {
const id = `env-${Date.now()}`
store.env.envs.push({ id, name: '新环境', baseUrl: '', variables: [] })
store.env.activeEnv = id
store.saveEnv()
}
function removeEnv(id: string) {
if (store.env.envs.length <= 1) {
alert('至少保留一个环境')
return
}
if (!confirm('确定删除该环境?')) return
store.env.envs = store.env.envs.filter(e => e.id !== id)
if (store.env.activeEnv === id) {
store.env.activeEnv = store.env.envs[0].id
}
store.saveEnv()
}
</script>
<style lang="scss" scoped>
.env-panel {
position: absolute;
top: 52px;
right: 16px;
width: 480px;
max-height: calc(100vh - 100px);
overflow-y: auto;
background: $bg-elevated;
border: 1px solid $border;
border-radius: $radius-lg;
box-shadow: $shadow-elevate;
z-index: 30;
padding: 4px 0;
}
.header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 16px;
border-bottom: 1px solid $border-soft;
.title { font-weight: 600; font-size: $font-md; }
.icon { font-size: 18px; line-height: 1; padding: 2px 8px; }
}
.section {
padding: 12px 16px;
border-bottom: 1px solid $border-soft;
&:last-child { border-bottom: none; }
}
.section-title {
font-size: $font-sm;
text-transform: uppercase;
letter-spacing: 1px;
color: $text-tertiary;
margin-bottom: 8px;
}
.env-list { display: flex; flex-direction: column; gap: 8px; }
.env-card {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px;
background: $bg-base;
border: 1px solid $border-soft;
border-radius: $radius;
cursor: pointer;
transition: all .18s ease;
&:hover { border-color: $primary; }
&.active {
border-color: $primary;
background: linear-gradient(90deg, $primary-bg, transparent);
box-shadow: 0 0 12px rgba(78,161,255,.12);
}
.radio {
width: 16px; height: 16px;
border: 1px solid $border;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
span {
width: 8px; height: 8px; border-radius: 50%;
background: transparent;
transition: all .18s ease;
}
span.on { background: $primary; box-shadow: 0 0 8px $primary; }
}
.meta { flex: 1; }
.name { font-weight: 600; }
.base { font-size: $font-xs; color: $text-tertiary; }
}
.add-env, .add-var {
padding: 8px 12px;
border: 1px dashed $border;
border-radius: $radius;
background: transparent;
color: $text-secondary;
cursor: pointer;
text-align: center;
font-size: $font-sm;
&:hover { border-color: $primary; color: $primary; }
}
label {
display: block;
margin-bottom: 10px;
span { display: block; font-size: $font-sm; color: $text-secondary; margin-bottom: 4px; }
.tech-input { width: 100%; }
}
.var-table {
display: flex; flex-direction: column; gap: 6px;
}
.row {
display: grid;
grid-template-columns: 130px 1fr 1fr 32px;
gap: 6px;
align-items: center;
&.head { color: $text-tertiary; font-size: $font-sm; padding: 0 2px; }
}
.cell {
padding: 4px 8px;
background: $bg-base;
border: 1px solid $border-soft;
border-radius: $radius-sm;
outline: none;
&:focus { border-color: $primary; }
}
.icon { padding: 4px 10px; }
</style>
+78
View File
@@ -0,0 +1,78 @@
<template>
<div class="pt">
<div class="row head">
<div class="col-en"></div>
<div class="col-key">{{ placeholderKey }}</div>
<div class="col-val">{{ placeholderValue }}</div>
<div class="col-desc">说明</div>
<div class="col-act"></div>
</div>
<div v-for="(it, idx) in items" :key="idx" class="row">
<input type="checkbox" v-model="it.enabled" @change="$emit('change')" />
<input class="cell" v-model="it.key" :placeholder="placeholderKey" @input="$emit('change')" />
<input class="cell" v-model="it.value" :placeholder="placeholderValue" @input="$emit('change')" />
<input class="cell" v-model="it.description" placeholder="可选" @input="$emit('change')" />
<button class="xs-btn ghost icon danger" @click="items.splice(idx, 1); $emit('change')">×</button>
</div>
<button class="add" @click="add">+ 添加</button>
</div>
</template>
<script setup lang="ts">
import type { ParamItem } from '@/types'
const props = defineProps<{
items: ParamItem[]
placeholderKey: string
placeholderValue: string
}>()
const emit = defineEmits<{ (e: 'change'): void }>()
function add() {
props.items.push({ key: '', value: '', description: '', enabled: true })
emit('change')
}
</script>
<style lang="scss" scoped>
.pt {
display: flex; flex-direction: column; gap: 6px;
}
.row {
display: grid;
grid-template-columns: 30px 1fr 1.2fr 1.4fr 32px;
gap: 6px;
align-items: center;
&.head {
color: $text-tertiary;
font-size: $font-sm;
padding: 0 2px;
}
}
.cell {
padding: 5px 10px;
background: rgba(15,28,54,.6);
border: 1px solid $border-soft;
border-radius: $radius;
outline: none;
font-size: $font-sm;
&:focus { border-color: $primary; background: rgba(15,28,54,.9); }
}
input[type='checkbox'] {
accent-color: $primary;
cursor: pointer;
}
.add {
width: 100%;
padding: 8px;
border: 1px dashed $border;
background: transparent;
border-radius: $radius;
color: $text-secondary;
cursor: pointer;
margin-top: 4px;
&:hover { border-color: $primary; color: $primary; }
}
.icon { padding: 4px 10px; }
</style>
+110
View File
@@ -0,0 +1,110 @@
<template>
<div class="tree-wrap">
<div class="search">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="1.5"/><path d="M20 20l-3-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
<input v-model="kw" placeholder="搜索接口 / 目录" />
</div>
<div class="tree-header">
<span class="title">接口管理</span>
<div class="actions">
<button class="xs-btn ghost icon" title="新增分组" @click="addTopGroup">+</button>
</div>
</div>
<div class="tree">
<TreeNode
v-for="n in projectTree"
:key="n.id"
:node="n"
:depth="0"
:keyword="kw"
:selected-file="req.activeFilePath"
@select-api="selectApi"
/>
<div v-if="!store.project.tree.length" class="empty">
<div class="muted">暂无接口</div>
<button class="xs-btn primary" @click="addTopGroup">新增分组</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useWorkspaceStore } from '@/stores/workspace'
import { useRequestStore } from '@/stores/request'
import TreeNode from './TreeNode.vue'
import type { ApiTreeNode } from '@/types'
const store = useWorkspaceStore()
const req = useRequestStore()
const kw = ref('')
const projectTree = computed(() => store.project.tree)
function selectApi(node: ApiTreeNode) {
if (node.type === 'api' && node.filePath) {
req.selectApi(node.filePath, node.label, node.method)
}
}
async function addTopGroup() {
const name = prompt('分组名称', '新分组')
if (!name) return
store.project.tree.push({
type: 'folder', id: `f-${Date.now()}`, label: name, children: []
})
await store.saveProject()
}
</script>
<style lang="scss" scoped>
.tree-wrap {
display: flex;
flex-direction: column;
height: 100%;
background: $bg-base;
}
.search {
display: flex; gap: 6px;
align-items: center;
padding: 8px 12px;
background: $bg-base;
border-bottom: 1px solid $border-soft;
svg { color: $text-tertiary; }
input {
flex: 1;
padding: 4px 0;
background: transparent;
border: none;
outline: none;
color: $text-primary;
font-size: $font-sm;
&::placeholder { color: $text-tertiary; }
}
}
.tree-header {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 14px 4px;
border-bottom: 1px solid $border-soft;
.title {
font-size: $font-xs;
color: $text-tertiary;
letter-spacing: 1px;
text-transform: uppercase;
}
}
.actions { display: flex; gap: 4px; }
.tree {
flex: 1;
overflow: auto;
padding: 6px 6px 16px;
}
.empty {
padding: 24px;
display: flex; flex-direction: column; gap: 12px; align-items: center;
}
</style>
+339
View File
@@ -0,0 +1,339 @@
<template>
<div v-if="!req.doc" class="placeholder">
<div class="ph-inner">
<div class="ring">
<svg width="56" height="56" viewBox="0 0 24 24" fill="none">
<path d="M4 7h16M4 12h16M4 17h10" stroke="url(#lg)" stroke-width="1.5" stroke-linecap="round"/>
<defs>
<linearGradient id="lg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#4ea1ff"/>
<stop offset="100%" stop-color="#22d3ee"/>
</linearGradient>
</defs>
</svg>
</div>
<h3>请选择左侧任一接口开始调试</h3>
<p class="muted">所有数据保存在 git 仓库的 <code>xsinfo-data/</code> 目录下</p>
</div>
</div>
<div v-else class="rp">
<!-- 请求头 -->
<div class="url-bar">
<select v-model="req.doc.method" class="method-sel" @change="markDirty">
<option v-for="m in methods" :key="m" :value="m">{{ m }}</option>
</select>
<input
class="url-input"
v-model="req.doc.url"
placeholder="/your/path/{id}"
@input="markDirty"
/>
<button class="xs-btn" @click="saveDoc" :class="{ dirty: req.dirty }">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2"/></svg>
保存
</button>
<button class="xs-btn primary send" :disabled="req.sending" @click="req.send">
<span v-if="req.sending">发送中</span>
<span v-else>
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M5 12l14-9-5 16-3-7-6 0z"/></svg>
发送
</span>
</button>
</div>
<div class="full-url" v-if="req.fullUrl">
<span class="muted">完整请求</span>
<code>{{ req.fullUrl }}</code>
</div>
<!-- Tabs -->
<div class="tabs">
<div class="tab" :class="{ active: req.activeTab === 'params' }" @click="req.activeTab='params'">Params <span class="badge">{{ enabledCount(req.doc.request?.query) }}</span></div>
<div class="tab" :class="{ active: req.activeTab === 'body' }" @click="req.activeTab='body'">Body <span class="badge" v-if="req.doc.request?.body?.type !== 'none'"></span></div>
<div class="tab" :class="{ active: req.activeTab === 'headers' }" @click="req.activeTab='headers'">Headers <span class="badge">{{ enabledCount(req.doc.request?.headers) }}</span></div>
<div class="tab" :class="{ active: req.activeTab === 'auth' }" @click="req.activeTab='auth'">Auth <span class="badge" v-if="req.doc.request?.auth?.type !== 'none'"></span></div>
</div>
<div class="tab-body">
<ParamsTable
v-if="req.activeTab === 'params'"
:items="req.ensureRequest().query"
placeholder-key="参数名"
placeholder-value="参数值"
@change="markDirty"
/>
<BodyEditor
v-else-if="req.activeTab === 'body'"
:body="req.ensureRequest().body"
@change="markDirty"
/>
<ParamsTable
v-else-if="req.activeTab === 'headers'"
:items="req.ensureRequest().headers"
placeholder-key="Header"
placeholder-value="Value"
@change="markDirty"
/>
<AuthEditor
v-else
:auth="req.ensureRequest().auth"
@change="markDirty"
/>
</div>
<!-- 响应 -->
<div class="response">
<div class="rs-header">
<span class="label">响应</span>
<template v-if="req.response">
<span class="status" :class="statusClass(req.response.status)">{{ req.response.status }} {{ req.response.statusText }}</span>
<span class="meta"> {{ req.response.durationMs }} ms</span>
<span class="meta">📦 {{ formatSize(req.response.size) }}</span>
<button class="xs-btn ghost" @click="showHeaders = !showHeaders">
{{ showHeaders ? '隐藏 Headers' : '查看 Headers' }}
</button>
</template>
</div>
<div class="rs-body" v-if="req.response">
<div v-if="showHeaders" class="rs-headers">
<div v-for="h in req.response.headers" :key="h.key" class="hdr">
<span class="key">{{ h.key }}:</span> <span>{{ h.value }}</span>
</div>
</div>
<pre v-if="!req.response.error" class="code"><code>{{ prettyBody }}</code></pre>
<div v-else class="error">
<div>🚨 请求失败</div>
<div class="muted">{{ req.response.error }}</div>
</div>
</div>
<div v-else class="rs-empty muted">
点击右上角发送按钮以发起请求响应将显示在这里
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRequestStore } from '@/stores/request'
import ParamsTable from './ParamsTable.vue'
import BodyEditor from './BodyEditor.vue'
import AuthEditor from './AuthEditor.vue'
import type { HttpMethod, ParamItem } from '@/types'
const req = useRequestStore()
const showHeaders = ref(false)
const methods: HttpMethod[] = ['GET','POST','PUT','DELETE','PATCH','HEAD','OPTIONS']
function enabledCount(items?: ParamItem[]) {
if (!items) return 0
return items.filter(i => i.enabled !== false && i.key).length
}
function markDirty() {
if (req.doc) req.dirty = true
}
async function saveDoc() {
await req.save()
}
function statusClass(s: number) {
if (s >= 200 && s < 300) return 'ok'
if (s >= 300 && s < 400) return 'redirect'
if (s >= 400) return 'err'
return 'unknown'
}
function formatSize(b: number) {
if (b < 1024) return `${b} B`
if (b < 1024*1024) return `${(b/1024).toFixed(1)} K`
return `${(b/1024/1024).toFixed(2)} M`
}
const prettyBody = computed(() => {
const r = req.response
if (!r) return ''
if (r.bodyType === 'json') {
try {
return JSON.stringify(JSON.parse(r.body), null, 2)
} catch { return r.body }
}
return r.body
})
</script>
<style lang="scss" scoped>
.placeholder {
height: 100%;
display: flex; align-items: center; justify-content: center;
.ph-inner { text-align: center; }
.ring {
width: 90px; height: 90px;
border-radius: 50%;
background: rgba(78,161,255,.06);
border: 1px dashed rgba(78,161,255,.4);
display: flex; align-items: center; justify-content: center;
margin: 0 auto 18px;
}
h3 { font-weight: 500; }
code {
background: $bg-elevated;
border: 1px solid $border-soft;
padding: 2px 6px;
border-radius: $radius-sm;
color: $primary;
}
}
.rp {
display: flex;
flex-direction: column;
height: 100%;
background: $bg-deep;
}
.url-bar {
display: flex;
gap: 8px;
padding: 12px 16px;
background: $bg-base;
border-bottom: 1px solid $border-soft;
}
.method-sel {
background: $bg-elevated;
border: 1px solid $border-soft;
border-radius: $radius;
padding: 6px 8px;
color: $primary;
font-weight: 700;
outline: none;
cursor: pointer;
&:focus { border-color: $primary; box-shadow: 0 0 0 2px $primary-bg; }
}
.url-input {
flex: 1;
padding: 6px 12px;
background: $bg-elevated;
border: 1px solid $border-soft;
border-radius: $radius;
outline: none;
&:focus { border-color: $primary; box-shadow: 0 0 0 2px $primary-bg; }
}
.send {
width: 110px;
justify-content: center;
}
.xs-btn.dirty { border-color: $warning; color: $warning; }
.full-url {
padding: 6px 16px 0;
font-size: $font-sm;
word-break: break-all;
code {
color: $primary;
background: rgba(78,161,255,.08);
padding: 1px 6px;
border-radius: $radius-sm;
}
}
.tabs {
display: flex;
border-bottom: 1px solid $border-soft;
padding: 0 16px;
background: $bg-base;
gap: 4px;
}
.tab {
padding: 10px 12px;
font-size: $font-sm;
cursor: pointer;
color: $text-secondary;
border-bottom: 2px solid transparent;
&:hover { color: $text-primary; }
&.active {
color: $primary;
border-bottom-color: $primary;
}
.badge {
background: $primary-bg;
color: $primary;
padding: 0 6px;
border-radius: 8px;
font-size: $font-xs;
margin-left: 4px;
}
}
.tab-body {
padding: 12px 16px;
max-height: 32vh;
overflow: auto;
}
.response {
flex: 1;
display: flex;
flex-direction: column;
border-top: 1px solid $border-soft;
background: $bg-base;
min-height: 0;
}
.rs-header {
display: flex; align-items: center; gap: 12px;
padding: 8px 16px;
border-bottom: 1px solid $border-soft;
.label {
font-size: $font-xs;
text-transform: uppercase;
color: $text-tertiary;
letter-spacing: 1px;
}
.status {
padding: 2px 8px;
border-radius: $radius-sm;
font-weight: 700;
font-size: $font-sm;
&.ok { background: rgba(52,211,153,.15); color: $success; }
&.redirect { background: rgba(78,161,255,.15); color: $primary; }
&.err { background: rgba(248,113,113,.15); color: $danger; }
&.unknown { background: $bg-elevated; color: $text-tertiary; }
}
.meta { color: $text-secondary; font-size: $font-sm; }
}
.rs-body {
flex: 1; min-height: 0;
display: flex; flex-direction: column;
overflow: auto;
}
.rs-headers {
background: $bg-elevated;
border-bottom: 1px solid $border-soft;
padding: 8px 16px;
font-family: 'Consolas', 'Menlo', monospace;
font-size: $font-sm;
.hdr { padding: 2px 0; }
.key { color: $primary; }
}
.code {
margin: 0;
padding: 14px 16px;
font-family: 'Consolas', 'Menlo', monospace;
font-size: 12px;
color: #d5e6ff;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
background: $bg-base;
}
.error { padding: 16px; color: $danger; }
.rs-empty {
padding: 28px;
text-align: center;
font-size: $font-sm;
}
</style>
+74
View File
@@ -0,0 +1,74 @@
<template>
<footer class="status">
<span class="left">
<span class="ok"></span>
<span class="muted"> 已加载 {{ apiCount }} 个接口 · 工作区{{ shortDir }}</span>
</span>
<span class="right">
<span class="muted">当前环境</span>
<span class="env-pill">
<span class="dot" :style="{ background: envColor }"></span>
{{ store.activeEnv?.name }}
</span>
<span class="muted ml">Base URL</span>
<code class="url">{{ store.activeEnv?.baseUrl }}</code>
</span>
</footer>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useWorkspaceStore } from '@/stores/workspace'
import type { ApiTreeNode } from '@/types'
const store = useWorkspaceStore()
function count(n: ApiTreeNode): number {
if (n.type === 'api') return 1
return (n.children ?? []).reduce((s, c) => s + count(c), 0)
}
const apiCount = computed(() => store.project.tree.reduce((s, c) => s + count(c), 0))
const shortDir = computed(() => {
if (!store.rootDir) return ''
const s = store.rootDir
if (s.length <= 50) return s
return s.substring(0, 25) + '...' + s.substring(s.length - 25)
})
const envColor = computed(() => {
const id = store.env.activeEnv
if (id === 'prod') return '#f87171'
if (id === 'dev') return '#34d399'
return '#22d3ee'
})
</script>
<style lang="scss" scoped>
.status {
height: 28px;
background: $bg-base;
border-top: 1px solid $border-soft;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
font-size: $font-xs;
color: $text-tertiary;
.left, .right { display: flex; align-items: center; gap: 6px; }
.ok { color: $success; text-shadow: 0 0 6px $success; }
.env-pill {
background: $bg-elevated;
border: 1px solid $border-soft;
border-radius: 10px;
padding: 1px 8px;
display: flex; gap: 4px; align-items: center;
color: $text-primary;
}
.dot { width: 6px; height: 6px; border-radius: 50%; box-shadow: 0 0 6px currentColor; }
.url { color: $primary; }
.ml { margin-left: 8px; }
}
</style>
+103
View File
@@ -0,0 +1,103 @@
<template>
<header class="topbar">
<div class="left">
<div class="brand">
<svg width="22" height="22" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="8" fill="#0a1428" stroke="#3553a3"/>
<path d="M8 16h16M16 8v16" stroke="#4ea1ff" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="16" cy="16" r="3" fill="#7eb6ff"/>
</svg>
<span class="brand-name">XsinfoApi</span>
</div>
<span class="sep">·</span>
<span class="project-name" @click="openSettings">
{{ store.project.name }}
<span class="muted">/{{ store.project.id }}</span>
</span>
</div>
<div class="right">
<button class="xs-btn ghost" @click="showEnv = !showEnv" :class="{ active: showEnv }">
<span class="env-dot" :style="{ background: envColor }"></span>
<span>{{ store.activeEnv?.name ?? '未选择环境' }}</span>
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M2 4l3 3 3-3" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"/></svg>
</button>
<button class="xs-btn ghost" @click="openDir" title="在工作区目录中打开">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z" stroke="currentColor" stroke-width="1.5"/></svg>
</button>
<button class="xs-btn ghost" @click="reset">
切换项目
</button>
</div>
<EnvPanel v-if="showEnv" @close="showEnv = false" />
</header>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useWorkspaceStore } from '@/stores/workspace'
import EnvPanel from './EnvPanel.vue'
const store = useWorkspaceStore()
const showEnv = ref(false)
const envColor = computed(() => {
const id = store.env.activeEnv
if (id === 'prod') return '#f87171'
if (id === 'dev') return '#34d399'
return '#22d3ee'
})
function openDir() {
if (store.rootDir) window.xs.shell.openDir(store.rootDir)
}
async function reset() {
if (confirm('确定要切换/重置项目吗?当前工作区数据不会丢失。')) {
await store.resetWorkspace()
}
}
function openSettings() {
showEnv.value = true
}
</script>
<style lang="scss" scoped>
.topbar {
height: 48px;
background: $bg-base;
border-bottom: 1px solid $border-soft;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
position: relative;
-webkit-app-region: drag;
&::after {
content: '';
position: absolute;
left: 0; right: 0; bottom: -1px;
height: 1px;
background: linear-gradient(90deg, transparent, $primary, transparent);
opacity: .35;
}
}
.left { display: flex; align-items: center; gap: 12px; }
.right { display: flex; align-items: center; gap: 8px; -webkit-app-region: no-drag; }
.brand { display: flex; gap: 8px; align-items: center; }
.brand-name { font-weight: 700; letter-spacing: 2px; }
.sep { color: $text-tertiary; }
.project-name { color: $text-primary; cursor: pointer; &:hover { color: $primary; } }
.env-dot {
width: 8px; height: 8px; border-radius: 50%;
box-shadow: 0 0 8px currentColor;
}
.xs-btn.active { background: $bg-hover; border-color: $primary; color: $primary; }
</style>
+182
View File
@@ -0,0 +1,182 @@
<template>
<div class="tn">
<div
v-if="node.type === 'folder'"
class="folder"
:style="{ paddingLeft: 8 + depth * 14 + 'px' }"
@click="open = !open"
>
<svg :class="{ open }" width="10" height="10" viewBox="0 0 10 10"><path d="M3 1l5 4-5 4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z" stroke="currentColor" stroke-width="1.5"/></svg>
<span class="label">{{ node.label }}</span>
<span class="count" v-if="node.children?.length">{{ countApis(node) }}</span>
<div class="row-actions">
<button class="xs-btn ghost icon" @click.stop="addApiHere" title="新增接口">+</button>
<button class="xs-btn ghost icon" @click.stop="addFolderHere" title="新增子分组"></button>
</div>
</div>
<div
v-else-if="node.type === 'api'"
class="api"
:class="{ active: node.filePath === selectedFile, hidden: !match }"
:style="{ paddingLeft: 8 + depth * 14 + 'px' }"
@click="onClick"
>
<span class="method" :class="'m-' + (node.method || 'GET').toLowerCase()">{{ node.method }}</span>
<span class="name">{{ node.label }}</span>
<span v-if="node.status === 'developing'" class="status dot" title="开发中"></span>
</div>
<template v-if="node.type === 'folder' && open">
<TreeNode
v-for="c in node.children"
:key="c.id"
:node="c"
:depth="depth + 1"
:keyword="keyword"
:selected-file="selectedFile"
@select-api="$emit('select-api', $event)"
/>
<div
v-if="!node.children?.length"
class="empty-folder"
:style="{ paddingLeft: 12 + (depth + 1) * 14 + 'px' }"
>
空白分组
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useWorkspaceStore } from '@/stores/workspace'
import type { ApiTreeNode } from '@/types'
const props = defineProps<{
node: ApiTreeNode
depth: number
keyword: string
selectedFile?: string | null
}>()
const emit = defineEmits<{
(e: 'select-api', n: ApiTreeNode): void
}>()
const workspaceStore = useWorkspaceStore()
const open = ref(props.depth < 2)
const match = computed(() => {
if (!props.keyword) return true
return props.node.label.toLowerCase().includes(props.keyword.toLowerCase())
})
function countApis(n: ApiTreeNode): number {
if (n.type === 'api') return 1
return (n.children ?? []).reduce((sum, c) => sum + countApis(c), 0)
}
async function addApiHere() {
if (!workspaceStore.rootDir) return
const name = prompt('接口名称', '新建接口')
if (!name) return
const created = await window.xs.api.create(workspaceStore.rootDir, '', {
name, method: 'GET', url: '/'
})
const apiNode: ApiTreeNode = {
type: 'api',
id: created.id,
label: name,
method: 'GET',
filePath: created.filePath
}
if (props.node.type === 'folder') {
props.node.children = props.node.children ?? []
props.node.children.push(apiNode)
await workspaceStore.saveProject()
}
}
async function addFolderHere() {
const name = prompt('分组名称', '新子分组')
if (!name) return
if (props.node.type === 'folder') {
props.node.children = props.node.children ?? []
props.node.children.push({
type: 'folder', id: `f-${Date.now()}`, label: name, children: []
})
await workspaceStore.saveProject()
}
}
function onClick() {
emit('select-api', props.node)
}
</script>
<style lang="scss" scoped>
.tn { user-select: none; }
.folder, .api {
display: flex; align-items: center; gap: 6px;
padding: 5px 8px;
font-size: $font-sm;
cursor: pointer;
border-radius: $radius-sm;
color: $text-secondary;
&:hover { background: $bg-hover; color: $text-primary; }
.row-actions { display: none; gap: 2px; margin-left: auto; }
&:hover .row-actions { display: flex; }
}
.folder {
svg:first-child { transition: transform .15s ease; color: $text-tertiary; }
svg.open { transform: rotate(90deg); }
svg:nth-child(2) { color: $primary; opacity: .8; }
.label { font-weight: 500; }
.count {
margin-left: 4px;
font-size: $font-xs;
color: $text-tertiary;
background: $bg-elevated;
padding: 0 5px;
border-radius: 8px;
}
}
.api {
&.hidden { opacity: .3; pointer-events: none; }
&.active {
background: linear-gradient(90deg, $primary-bg, transparent);
color: $text-primary;
border-left: 2px solid $primary;
.name { color: $text-primary; }
}
.method {
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
font-weight: 700;
color: #0a1428;
letter-spacing: .5px;
width: 42px;
text-align: center;
}
.m-get { background: $m-get; }
.m-post { background: $m-post; }
.m-put { background: $m-put; }
.m-delete { background: $m-delete; }
.m-patch { background: $m-patch; }
.m-head, .m-options { background: #6b85b3; color: #fff; }
.name { color: $text-primary; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.status { color: $primary; font-size: 10px; }
}
.empty-folder {
font-size: $font-xs;
color: $text-tertiary;
padding: 4px 0;
font-style: italic;
}
.icon { padding: 2px 6px; font-size: 12px; line-height: 1; }
</style>
+178
View File
@@ -0,0 +1,178 @@
<template>
<div>
<div class="row" :style="{ paddingLeft: depth * 18 + 'px' }">
<button
class="toggle"
@click="canHaveChildren && (item.collapsed = !item.collapsed)"
:class="{ empty: !canHaveChildren }"
:title="canHaveChildren ? '折叠 / 展开' : ''"
>{{ canHaveChildren ? (item.collapsed ? '▸' : '▾') : '•' }}</button>
<select class="cell type" v-model="item.type" @change="onTypeChange">
<option v-for="t in valueTypes" :key="t" :value="t">{{ t }}</option>
</select>
<input
v-if="!isArray"
class="cell key"
v-model="item.key"
:placeholder="'字段名'"
@input="$emit('change')"
/>
<span v-else class="idx">{{ idxLabel }}</span>
<input
v-if="isPrimitive"
class="cell val"
v-model="item.value"
:placeholder="valuePlaceholder"
@input="$emit('change')"
/>
<span v-else class="complex muted">
{{ item.type === 'object' ? `{ ${childCount} 字段` : `[ ${childCount}` }} }
</span>
<button class="xs-btn ghost icon danger" @click="$emit('remove')">×</button>
</div>
<BodyTree
v-if="canHaveChildren && !item.collapsed"
:items="item.children ?? (item.children = [])"
:depth="depth + 1"
:root-type="item.type === 'array' ? 'array' : 'object'"
:root-label="item.type === 'array' ? '索引' : '字段'"
:root-add-label="item.type === 'array' ? '元素' : '字段'"
@change="$emit('change')"
/>
<div
v-if="canHaveChildren && (!item.children || item.children.length === 0)"
class="empty-child"
:style="{ paddingLeft: (depth + 1) * 18 + 30 + 'px' }"
>
<span class="muted">{{ item.type === 'array' ? '数组' : '对象' }}</span>
<button class="xs-btn ghost" @click="addChild">+ 添加{{ item.type === 'array' ? '元素' : '字段' }}</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { ParamItem, ParamValueType } from '@/types'
import BodyTree from './BodyTree.vue'
const props = defineProps<{
item: ParamItem
depth: number
isArray?: boolean
idx?: number
}>()
const emit = defineEmits<{
(e: 'change'): void
(e: 'remove'): void
}>()
const valueTypes: ParamValueType[] = ['string', 'number', 'boolean', 'null', 'object', 'array']
const isArray = computed(() => props.isArray ?? false)
const canHaveChildren = computed(() =>
props.item.type === 'object' || props.item.type === 'array'
)
const isPrimitive = computed(() => !canHaveChildren.value)
const childCount = computed(() => props.item.children?.length ?? 0)
const idxLabel = computed(() => `[${props.idx ?? '?'}]`)
const valuePlaceholder = computed(() => {
switch (props.item.type) {
case 'number': return '例如 42'
case 'boolean': return 'true / false'
case 'null': return '(留空输出 null)'
default: return '值'
}
})
function onTypeChange() {
if (canHaveChildren.value) {
if (!props.item.children) props.item.children = []
if (props.item.children.length === 0) {
props.item.children.push({ key: '', value: '', type: 'string', enabled: true })
}
props.item.value = ''
} else {
props.item.children = undefined
}
emit('change')
}
function addChild() {
if (!props.item.children) props.item.children = []
props.item.children.push({ key: '', value: '', type: 'string', enabled: true })
emit('change')
}
</script>
<style lang="scss" scoped>
.row {
display: grid;
grid-template-columns: 18px 90px 1fr 1.4fr 32px;
gap: 6px;
align-items: center;
padding: 4px 4px;
}
.toggle {
width: 18px; height: 22px;
background: transparent;
border: 1px solid $border-soft;
border-radius: 4px;
color: $text-secondary;
cursor: pointer;
padding: 0;
font-size: 11px;
line-height: 1;
&:hover { color: $primary; border-color: $primary; }
&.empty { color: $text-tertiary; border-color: transparent; cursor: default; }
}
.cell {
padding: 5px 8px;
background: rgba(15,28,54,.6);
border: 1px solid $border-soft;
border-radius: $radius-sm;
outline: none;
font-size: $font-sm;
color: $text-primary;
&:focus { border-color: $primary; background: rgba(15,28,54,.9); }
}
select.cell.type {
cursor: pointer;
font-family: 'Consolas','Menlo',monospace;
text-transform: lowercase;
appearance: none;
padding-right: 18px;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'><path d='M2 4l3 3 3-3' stroke='%237eb6ff' stroke-width='1.2' fill='none' stroke-linecap='round'/></svg>");
background-repeat: no-repeat;
background-position: right 6px center;
}
.idx {
padding: 5px 8px;
font-family: 'Consolas','Menlo',monospace;
font-size: $font-sm;
color: $text-tertiary;
}
.complex {
font-size: $font-sm;
padding-left: 4px;
}
.empty-child {
display: flex; gap: 8px; align-items: center;
font-size: $font-sm;
padding: 4px 0;
}
.icon { padding: 4px 10px; }
</style>
+41
View File
@@ -0,0 +1,41 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
interface XsApi {
ping: () => Promise<string>
workspace: {
pick: () => Promise<string | null>
get: () => Promise<string | null>
clear: () => Promise<null>
init: (root: string) => Promise<true>
}
env: {
read: (root: string) => Promise<any>
write: (root: string, data: any) => Promise<true>
}
apiProject: {
read: (root: string) => Promise<any>
write: (root: string, data: any) => Promise<true>
}
api: {
read: (root: string, relativePath: string) => Promise<any>
write: (root: string, relativePath: string, data: any) => Promise<true>
create: (root: string, relativeDir: string, apiData: any) => Promise<{ id: string; filePath: string }>
}
shell: {
openDir: (dir: string) => Promise<true>
}
}
declare global {
interface Window {
xs: XsApi
}
}
export {}
+8
View File
@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import './styles/index.scss'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')
+191
View File
@@ -0,0 +1,191 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { ApiDoc, HttpMethod, RequestConfig } from '@/types'
import { useWorkspaceStore } from '@/stores/workspace'
export interface ResponseResult {
status: number
statusText: string
durationMs: number
size: number
headers: Array<{ key: string; value: string }>
body: string
bodyType: 'json' | 'text' | 'html'
error?: string
}
export const useRequestStore = defineStore('request', () => {
const workspaceStore = useWorkspaceStore()
const activeFilePath = ref<string | null>(null)
const activeTitle = ref('')
const activeMethod = ref<HttpMethod>('GET')
const doc = ref<ApiDoc | null>(null)
const dirty = ref(false)
const sending = ref(false)
const response = ref<ResponseResult | null>(null)
const activeTab = ref<'params' | 'body' | 'headers' | 'auth'>('params')
async function selectApi(filePath: string, label: string, method?: HttpMethod) {
activeFilePath.value = filePath
activeTitle.value = label
activeMethod.value = method ?? 'GET'
const data = await window.xs.api.read(workspaceStore.rootDir!, filePath)
if (data) {
doc.value = data
activeMethod.value = data.method ?? 'GET'
} else {
doc.value = null
}
dirty.value = false
response.value = null
}
async function save() {
if (!doc.value || !activeFilePath.value) return
await workspaceStore.writeApi(activeFilePath.value, doc.value)
dirty.value = false
}
const fullUrl = computed(() => {
if (!doc.value) return ''
const base = workspaceStore.activeEnv?.baseUrl ?? ''
const vars = workspaceStore.variableMap
const interpolate = (s: string) =>
s.replace(/\{\{(\w[\w.-]*)\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`)
return interpolate(base.replace(/\/$/, '') + interpolate(doc.value.url))
})
function buildFetchInput() {
if (!doc.value) return null
const req = doc.value.request ?? { headers: [], query: [], body: { type: 'none' }, auth: { type: 'none' } }
const headers: Record<string, string> = {}
req.headers.filter(h => h.enabled !== false && h.key).forEach(h => {
headers[h.key] = interpolate(h.value)
})
if (req.auth.type === 'bearer' && req.auth.token) {
headers['Authorization'] = `Bearer ${interpolate(req.auth.token)}`
}
if (req.auth.type === 'basic' && req.auth.username) {
headers['Authorization'] = 'Basic ' + btoa(`${interpolate(req.auth.username)}:${interpolate(req.auth.password ?? '')}`)
}
if (req.auth.type === 'apiKey' && req.auth.apiKeyName) {
if (req.auth.apiKeyIn === 'header') headers[req.auth.apiKeyName] = interpolate(req.auth.token ?? '')
}
const url = new URL(fullUrl.value || 'http://localhost/')
req.query.filter(q => q.enabled !== false && q.key).forEach(q => {
url.searchParams.set(q.key, interpolate(q.value))
})
if (req.auth.type === 'apiKey' && req.auth.apiKeyIn === 'query' && req.auth.apiKeyName) {
url.searchParams.set(req.auth.apiKeyName, interpolate(req.auth.token ?? ''))
}
const init: RequestInit = {
method: doc.value.method,
headers
}
if (!['GET', 'HEAD'].includes(doc.value.method)) {
if (req.body.type === 'json') {
if (!headers['Content-Type'] && !headers['content-type']) {
headers['Content-Type'] = 'application/json'
}
init.body = interpolate(req.body.content ?? '')
} else if (req.body.type === 'x-www-form-urlencoded') {
if (!headers['Content-Type']) headers['Content-Type'] = 'application/x-www-form-urlencoded'
const sp = new URLSearchParams()
req.body.formItems?.filter(i => i.enabled !== false && i.key).forEach(i => {
sp.append(i.key, interpolate(i.value))
})
init.body = sp.toString()
} else if (req.body.type === 'form-data') {
const fd = new FormData()
req.body.formItems?.filter(i => i.enabled !== false && i.key).forEach(i => {
fd.append(i.key, interpolate(i.value))
})
init.body = fd
} else if (req.body.type === 'raw') {
init.body = interpolate(req.body.content ?? '')
}
}
return { url: url.toString(), init, headers }
}
function interpolate(s: string): string {
if (!s) return s
const vars = workspaceStore.variableMap
return s.replace(/\{\{(\w[\w.-]*)\}\}/g, (_, k) => vars[k] ?? `{{${k}}}`)
}
async function send() {
if (!doc.value) return
const built = buildFetchInput()
if (!built) return
sending.value = true
const t0 = performance.now()
try {
const res = await fetch(built.url, built.init)
const text = await res.text()
const t1 = performance.now()
const headersArr: Array<{ key: string; value: string }> = []
res.headers.forEach((v, k) => headersArr.push({ key: k, value: v }))
let bodyType: ResponseResult['bodyType'] = 'text'
const ct = res.headers.get('content-type') || ''
if (ct.includes('json')) {
bodyType = 'json'
try { /* re-format */ } catch {}
} else if (ct.includes('html')) {
bodyType = 'html'
}
response.value = {
status: res.status,
statusText: res.statusText,
durationMs: Math.round(t1 - t0),
size: new Blob([text]).size,
headers: headersArr,
body: text,
bodyType
}
} catch (err: any) {
const t1 = performance.now()
response.value = {
status: 0,
statusText: 'Network Error',
durationMs: Math.round(t1 - t0),
size: 0,
headers: [],
body: '',
bodyType: 'text',
error: err?.message ?? String(err)
}
} finally {
sending.value = false
}
}
function ensureRequest(): RequestConfig {
const cur = doc.value ?? {
id: '', name: '', method: 'GET' as HttpMethod, url: '', group: ''
}
if (!doc.value) doc.value = cur
if (!cur.request) {
cur.request = { headers: [], query: [], body: { type: 'none' }, auth: { type: 'none' } }
}
return cur.request
}
return {
activeFilePath, activeTitle, activeMethod,
doc, dirty, sending, response, activeTab,
fullUrl, selectApi, save, send, ensureRequest
}
})
+264
View File
@@ -0,0 +1,264 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { EnvConfig, Environment, ProjectMeta, ApiDoc } from '@/types'
export const useWorkspaceStore = defineStore('workspace', () => {
const rootDir = ref<string | null>(null)
const env = ref<EnvConfig>({
activeEnv: 'dev',
envs: [
{ id: 'dev', name: '开发环境', baseUrl: 'http://localhost:8080', variables: [{ key: 'token', value: 'dev-token' }] },
{ id: 'prod', name: '生产环境', baseUrl: 'https://api.example.com', variables: [] }
]
})
const project = ref<ProjectMeta>({
id: 'default',
name: '示例项目',
description: '默认示例项目',
tree: []
})
const loading = ref(false)
const activeEnv = computed<Environment | null>(() => {
return env.value.envs.find(e => e.id === env.value.activeEnv) ?? null
})
const variableMap = computed<Record<string, string>>(() => {
const m: Record<string, string> = {}
activeEnv.value?.variables.forEach(v => { m[v.key] = v.value })
return m
})
async function pickWorkspace(): Promise<boolean> {
const dir = await window.xs.workspace.pick()
if (!dir) return false
rootDir.value = dir
await window.xs.workspace.init(dir)
await loadAll()
return true
}
async function loadAll() {
if (!rootDir.value) return
loading.value = true
try {
const e = await window.xs.env.read(rootDir.value)
if (e) env.value = e
const p = await window.xs.apiProject.read(rootDir.value)
if (p) {
project.value = p
} else {
// 没有则初始化一个示例项目
await seedSampleProject()
}
} finally {
loading.value = false
}
}
async function seedSampleProject() {
if (!rootDir.value) return
const sample: ProjectMeta = {
id: 'default',
name: '宠物商店 Demo',
description: 'XsinfoApi 内置示例项目 - 宠物商店接口集',
tree: [
{
type: 'folder', id: 'f1', label: '宠物商店',
children: [
{
type: 'folder', id: 'f2', label: '商店 API',
children: [
{
type: 'folder', id: 'f3', label: '宠物',
children: [
{
type: 'api',
id: 'a1',
label: '获取宠物',
method: 'GET',
status: 'developing',
filePath: 'apis/宠物商店/商店 API/宠物/a1.json'
},
{
type: 'api',
id: 'a2',
label: '更新宠物',
method: 'PUT',
filePath: 'apis/宠物商店/商店 API/宠物/a2.json'
},
{
type: 'api',
id: 'a3',
label: '删除宠物',
method: 'DELETE',
filePath: 'apis/宠物商店/商店 API/宠物/a3.json'
},
{
type: 'api',
id: 'a4',
label: '创建宠物',
method: 'POST',
filePath: 'apis/宠物商店/商店 API/宠物/a4.json'
},
{
type: 'api',
id: 'a5',
label: '上传宠物图片',
method: 'POST',
filePath: 'apis/宠物商店/商店 API/宠物/a5.json'
}
]
},
{
type: 'folder', id: 'f4', label: '商店',
children: [
{ type: 'api', id: 'a6', label: '获取库存', method: 'GET', filePath: 'apis/宠物商店/商店 API/商店/a6.json' }
]
},
{ type: 'folder', id: 'f5', label: '用户', children: [] }
]
},
{ type: 'folder', id: 'f6', label: '管理 API', children: [] }
]
},
{ type: 'folder', id: 'f0', label: '组件库', children: [] }
]
}
await window.xs.apiProject.write(rootDir.value, sample)
// 写入示例接口详情
const samples: Array<{ path: string; data: ApiDoc }> = [
{
path: 'apis/宠物商店/商店 API/宠物/a1.json',
data: {
id: 'a1', name: '获取宠物', group: '宠物商店/商店 API/宠物',
method: 'GET', url: '/pets/{id}', status: 'developing',
description: '根据唯一 ID 返回一只宠物',
request: {
headers: [],
query: [],
body: { type: 'none' },
auth: { type: 'bearer', token: '{{token}}' }
},
responseExample: JSON.stringify({
id: 100, name: 'Luna', status: 'available',
category: { id: 101, name: 'dog' },
tags: [{ id: 15, name: 'Dalmatian' }]
}, null, 2)
}
},
{
path: 'apis/宠物商店/商店 API/宠物/a2.json',
data: {
id: 'a2', name: '更新宠物', group: '宠物商店/商店 API/宠物',
method: 'PUT', url: '/pets/{id}', status: 'developing',
description: '更新宠物信息',
request: {
headers: [{ key: 'Content-Type', value: 'application/json', enabled: true }],
query: [{ key: 'id', value: '', description: '宠物 ID', required: true, enabled: true }],
body: { type: 'json', rawType: 'json', content: '{\n "id": 10,\n "name": "doggie",\n "category": { "id": 95, "name": "蓝子豪" },\n "photoUrls": ["https://loremflickr.com/400/400?lock=1637152641363274"],\n "status": "available"\n}' },
auth: { type: 'bearer', token: '{{token}}' }
},
responseExample: '{\n "id": 10\n}'
}
},
{
path: 'apis/宠物商店/商店 API/宠物/a3.json',
data: {
id: 'a3', name: '删除宠物', group: '宠物商店/商店 API/宠物',
method: 'DELETE', url: '/pets/{id}',
request: { headers: [], query: [], body: { type: 'none' }, auth: { type: 'bearer', token: '{{token}}' } },
responseExample: ''
}
},
{
path: 'apis/宠物商店/商店 API/宠物/a4.json',
data: {
id: 'a4', name: '创建宠物', group: '宠物商店/商店 API/宠物',
method: 'POST', url: '/pets',
request: {
headers: [{ key: 'Content-Type', value: 'application/json', enabled: true }],
query: [],
body: { type: 'json', rawType: 'json', content: '{\n "name": "MyPet",\n "photoUrls": ["https://example.com/x.jpg"]\n}' },
auth: { type: 'bearer', token: '{{token}}' }
},
responseExample: '{\n "id": 11\n}'
}
},
{
path: 'apis/宠物商店/商店 API/宠物/a5.json',
data: {
id: 'a5', name: '上传宠物图片', group: '宠物商店/商店 API/宠物',
method: 'POST', url: '/pets/{id}/uploadImage',
request: {
headers: [],
query: [{ key: 'id', value: '', description: '宠物 ID', required: true, enabled: true }],
body: { type: 'form-data', formItems: [
{ key: 'file', value: '', description: '图片文件', required: true, enabled: true }
] },
auth: { type: 'bearer', token: '{{token}}' }
},
responseExample: ''
}
},
{
path: 'apis/宠物商店/商店 API/商店/a6.json',
data: {
id: 'a6', name: '获取库存', group: '宠物商店/商店 API/商店',
method: 'GET', url: '/store/inventory',
request: { headers: [], query: [], body: { type: 'none' }, auth: { type: 'none' } },
responseExample: '{}'
}
}
]
for (const s of samples) {
await window.xs.api.write(rootDir.value, s.path, s.data)
}
project.value = sample
}
async function init(root: string) {
rootDir.value = root
await window.xs.workspace.init(root)
await loadAll()
}
async function saveEnv() {
if (!rootDir.value) return
await window.xs.env.write(rootDir.value, JSON.parse(JSON.stringify(env.value)))
}
async function saveProject() {
if (!rootDir.value) return
await window.xs.apiProject.write(rootDir.value, JSON.parse(JSON.stringify(project.value)))
}
async function readApi(filePath: string): Promise<ApiDoc | null> {
if (!rootDir.value) return null
const raw = await window.xs.api.read(rootDir.value, filePath)
return raw ?? null
}
async function writeApi(filePath: string, data: ApiDoc) {
if (!rootDir.value) return
await window.xs.api.write(rootDir.value, filePath, JSON.parse(JSON.stringify(data)))
}
async function resetWorkspace() {
rootDir.value = null
await window.xs.workspace.clear()
}
return {
rootDir, env, project, loading,
activeEnv, variableMap,
pickWorkspace, init, loadAll, saveEnv, saveProject,
readApi, writeApi, resetWorkspace
}
})
+166
View File
@@ -0,0 +1,166 @@
@use "./variables.scss" as *;
:root {
--bg-deep: #{$bg-deep};
--bg-base: #{$bg-base};
--bg-elevated: #{$bg-elevated};
--bg-overlay: #{$bg-overlay};
--bg-hover: #{$bg-hover};
--bg-active: #{$bg-active};
--border-soft: #{$border-soft};
--border: #{$border};
--border-strong: #{$border-strong};
--primary: #{$primary};
--primary-hover: #{$primary-hover};
--primary-active: #{$primary-active};
--primary-bg: #{$primary-bg};
--cyan: #{$cyan};
--violet: #{$violet};
--success: #{$success};
--warning: #{$warning};
--danger: #{$danger};
--m-get: #{$m-get};
--m-post: #{$m-post};
--m-put: #{$m-put};
--m-delete: #{$m-delete};
--m-patch: #{$m-patch};
--text-primary: #{$text-primary};
--text-secondary: #{$text-secondary};
--text-tertiary: #{$text-tertiary};
--text-disabled: #{$text-disabled};
--font-xs: #{$font-xs};
--font-sm: #{$font-sm};
--font-base: #{$font-base};
--font-md: #{$font-md};
--font-lg: #{$font-lg};
--radius-sm: #{$radius-sm};
--radius: #{$radius};
--radius-lg: #{$radius-lg};
--shadow-glow: #{$shadow-glow};
--shadow-card: #{$shadow-card};
--shadow-elevate: #{$shadow-elevate};
}
* {
box-sizing: border-box;
}
html, body, #app {
margin: 0;
padding: 0;
height: 100%;
background: $bg-deep;
color: $text-primary;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
font-size: $font-base;
line-height: 1.5;
overflow: hidden;
}
// 全局滚动条
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: $border;
border-radius: 4px;
&:hover { background: $border-strong; }
}
// 科技感背景:深蓝径向 + 网格线
body {
background:
radial-gradient(1200px 800px at 80% -10%, rgba(78,161,255,0.08), transparent 60%),
radial-gradient(900px 600px at -10% 110%, rgba(34,211,238,0.06), transparent 60%),
$bg-deep;
}
button, input, select, textarea {
font-family: inherit;
color: inherit;
}
a { color: $primary; text-decoration: none; }
input, textarea {
background: $bg-elevated;
border: 1px solid $border-soft;
border-radius: $radius-sm;
outline: none;
&:focus { border-color: $primary; box-shadow: 0 0 0 2px $primary-bg; }
}
// 通用按钮
.xs-btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 5px 12px;
background: $bg-elevated;
border: 1px solid $border;
color: $text-primary;
border-radius: $radius;
cursor: pointer;
font-size: $font-base;
transition: all .18s ease;
&:hover { background: $bg-hover; border-color: $border-strong; }
&.primary {
background: $primary;
border-color: $primary;
color: #0a1428;
font-weight: 600;
&:hover { background: $primary-hover; border-color: $primary-hover; box-shadow: $shadow-glow; }
&:active { background: $primary-active; }
}
&.ghost { background: transparent; }
&.danger { color: $danger; border-color: rgba(248,113,113,.4); &:hover { background: rgba(248,113,113,.1); } }
&:disabled { opacity: .5; cursor: not-allowed; }
}
.xs-input {
width: 100%;
padding: 6px 10px;
}
.xs-divider {
height: 1px;
background: $border-soft;
margin: 8px 0;
}
// 科技感输入框 + tag 输入域
.tech-input {
background: rgba(15,28,54,.6);
border: 1px solid $border-soft;
border-radius: $radius;
padding: 6px 10px;
outline: none;
transition: all .2s ease;
&:focus {
border-color: $primary;
background: rgba(15,28,54,.9);
box-shadow: 0 0 0 3px rgba(78,161,255,.12);
}
}
// 通用卡片
.tech-card {
background: linear-gradient(180deg, rgba(15,28,54,.65), rgba(15,28,54,.35));
border: 1px solid $border-soft;
border-radius: $radius;
}
// 提示文字
.muted { color: $text-tertiary; }
.secondary { color: $text-secondary; }
+69
View File
@@ -0,0 +1,69 @@
// 科技深蓝主题 token —— 用作 SCSS 变量与 CSS 变量两套
// 主色:电光蓝 cyan-blue,搭配深空蓝背景
// 基础色
$bg-deep: #050b1a; // 最底层
$bg-base: #0a1428; // 主背景
$bg-elevated: #0f1c36; // 卡片/浮层
$bg-overlay: #122244; // 菜单悬停
$bg-hover: #1a2b4f; // hover
$bg-active: #1d3361; // active
// 边框 / 分隔
$border-soft: #1d2f54;
$border: #243b6b;
$border-strong: #3553a3;
// 主色
$primary: #4ea1ff; // 电光蓝
$primary-hover: #6fb6ff;
$primary-active: #2d7ed8;
$primary-bg: rgba(78, 161, 255, 0.12);
// 辅助
$cyan: #22d3ee;
$violet: #8b5cf6;
$success: #34d399;
$warning: #fbbf24;
$danger: #f87171;
// HTTP method
$m-get: #34d399;
$m-post: #fbbf24;
$m-put: #4ea1ff;
$m-delete: #f87171;
$m-patch: #8b5cf6;
// 文本
$text-primary: #e6f0ff;
$text-secondary: #9bb4d8;
$text-tertiary: #6b85b3;
$text-disabled: #48588a;
// 字号
$font-xs: 11px;
$font-sm: 12px;
$font-base: 13px;
$font-md: 14px;
$font-lg: 16px;
$font-xl: 20px;
$font-2xl: 24px;
// 圆角
$radius-sm: 4px;
$radius: 6px;
$radius-lg: 10px;
$radius-xl: 14px;
// 阴影 / 发光
$shadow-glow: 0 0 18px rgba(78, 161, 255, 0.25);
$shadow-card: 0 4px 14px rgba(0, 0, 0, 0.4);
$shadow-elevate: 0 8px 32px rgba(0, 0, 0, 0.55);
// 间距
$gap-1: 4px;
$gap-2: 8px;
$gap-3: 12px;
$gap-4: 16px;
$gap-5: 20px;
$gap-6: 24px;
+85
View File
@@ -0,0 +1,85 @@
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS'
export interface ParamItem {
key: string
value: string
description?: string
required?: boolean
enabled?: boolean
type?: ParamValueType
collapsed?: boolean
children?: ParamItem[]
}
export type ParamValueType = 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array'
export interface BodyConfig {
type: 'none' | 'json' | 'form-data' | 'x-www-form-urlencoded' | 'raw'
rawType?: 'text' | 'json' | 'xml' | 'html'
content?: string
formItems?: ParamItem[]
}
export interface AuthConfig {
type: 'none' | 'bearer' | 'basic' | 'apiKey'
token?: string
username?: string
password?: string
apiKeyName?: string
apiKeyIn?: 'header' | 'query'
}
export interface RequestConfig {
headers: ParamItem[]
query: ParamItem[]
body: BodyConfig
auth: AuthConfig
}
export interface ApiDoc {
id: string
name: string
method: HttpMethod
url: string
description?: string
group: string // 目录路径, e.g. '宠物商店/商店 API/宠物'
filePath?: string // 实际 git 工作区内的相对路径 e.g. 'apis/宠物商店/商店 API/宠物/api-xxx.json'
status?: 'developing' | 'completed' | 'deprecated'
request?: RequestConfig
responseExample?: string
}
export interface EnvVariable {
key: string
value: string
description?: string
}
export interface Environment {
id: string
name: string
baseUrl: string
variables: EnvVariable[]
}
export interface EnvConfig {
activeEnv: string
envs: Environment[]
}
export interface ApiTreeNode {
type: 'folder' | 'api'
id: string
label: string
method?: HttpMethod
children?: ApiTreeNode[]
filePath?: string
status?: ApiDoc['status']
}
export interface ProjectMeta {
id: string
name: string
description?: string
tree: ApiTreeNode[]
}