first commit
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-electron
|
||||||
|
release
|
||||||
|
.vscode
|
||||||
|
*.log
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.local
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
registry=https://registry.npmmirror.com
|
||||||
|
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||||
|
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# XsinfoApi
|
||||||
|
|
||||||
|
私有化 · 版本化 · 集成于 Git 的 API 调试工具。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- Electron 30+
|
||||||
|
- Vue 3 + TypeScript 5
|
||||||
|
- Vite 5 + SCSS
|
||||||
|
- Pinia
|
||||||
|
|
||||||
|
## 核心特性
|
||||||
|
|
||||||
|
### 1. 数据私有化 & 版本化
|
||||||
|
|
||||||
|
每个 API 接口对应 git 仓库内的一个 `.json` 文件:
|
||||||
|
|
||||||
|
```
|
||||||
|
your-git-repo/
|
||||||
|
└── xsinfo-data/
|
||||||
|
├── env.json # 环境配置
|
||||||
|
├── project.json # 接口树结构
|
||||||
|
└── apis/
|
||||||
|
└── 宠物商店/
|
||||||
|
└── 商店 API/
|
||||||
|
└── 宠物/
|
||||||
|
├── a1.json # GET /pets/{id}
|
||||||
|
├── a2.json # PUT /pets/{id}
|
||||||
|
└── …
|
||||||
|
```
|
||||||
|
|
||||||
|
修改 API 即修改 JSON 文件,可与代码一同提交,享受 Git 版本控制 + Code Review。
|
||||||
|
|
||||||
|
### 2. 多环境管理
|
||||||
|
|
||||||
|
- 一键切换 `开发 / 测试 / 生产` 等多套环境
|
||||||
|
- 每套环境独立的 `Base URL` 与「环境变量」
|
||||||
|
- URL 与 Headers 中支持 `{{变量名}}` 自动注入
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 启动(vite + electron 同时启动)
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 打包 Win 安装包
|
||||||
|
npm run build:win
|
||||||
|
```
|
||||||
|
|
||||||
|
第一次启动会要求选择一个本地 Git 仓库目录,应用会自动在其中创建 `xsinfo-data/`。
|
||||||
|
|
||||||
|
## 主题
|
||||||
|
|
||||||
|
科技深蓝(深空蓝 + 电光蓝),参考 APIFox 但聚焦调试核心体验。
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron'
|
||||||
|
import { join, dirname } from 'node:path'
|
||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
|
||||||
|
let win: BrowserWindow | null = null
|
||||||
|
|
||||||
|
const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
win = new BrowserWindow({
|
||||||
|
width: 1440,
|
||||||
|
height: 900,
|
||||||
|
minWidth: 1100,
|
||||||
|
minHeight: 700,
|
||||||
|
title: 'XsinfoApi',
|
||||||
|
backgroundColor: '#0a1428',
|
||||||
|
titleBarStyle: 'hidden',
|
||||||
|
titleBarOverlay: {
|
||||||
|
color: '#0a1428',
|
||||||
|
symbolColor: '#7eb6ff',
|
||||||
|
height: 36
|
||||||
|
},
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, 'preload.js'),
|
||||||
|
sandbox: false,
|
||||||
|
contextIsolation: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (VITE_DEV_SERVER_URL) {
|
||||||
|
win.loadURL(VITE_DEV_SERVER_URL)
|
||||||
|
} else {
|
||||||
|
win.loadFile(join(__dirname, '../dist/index.html'))
|
||||||
|
}
|
||||||
|
|
||||||
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
shell.openExternal(url)
|
||||||
|
return { action: 'deny' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
createWindow()
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') app.quit()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============ 项目工作区 IPC(关联 git 下的 json) ============
|
||||||
|
|
||||||
|
const WORKSPACE_FILE = '.xsinfo-workspace.json'
|
||||||
|
|
||||||
|
interface WorkspaceMeta {
|
||||||
|
rootDir: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readWorkspaceMeta(): Promise<WorkspaceMeta> {
|
||||||
|
const metaPath = join(app.getPath('userData'), WORKSPACE_FILE)
|
||||||
|
if (!existsSync(metaPath)) return { rootDir: null }
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(metaPath, 'utf-8')
|
||||||
|
return JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
return { rootDir: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeWorkspaceMeta(meta: WorkspaceMeta): Promise<void> {
|
||||||
|
const metaPath = join(app.getPath('userData'), WORKSPACE_FILE)
|
||||||
|
await fs.writeFile(metaPath, JSON.stringify(meta, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择工作区目录(一个 git 项目)
|
||||||
|
ipcMain.handle('workspace:pick', async () => {
|
||||||
|
const res = await dialog.showOpenDialog({
|
||||||
|
title: '选择 API 项目工作区 (git 仓库)',
|
||||||
|
properties: ['openDirectory']
|
||||||
|
})
|
||||||
|
if (res.canceled || res.filePaths.length === 0) return null
|
||||||
|
const root = res.filePaths[0]
|
||||||
|
await writeWorkspaceMeta({ rootDir: root })
|
||||||
|
return root
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('workspace:get', async () => {
|
||||||
|
const meta = await readWorkspaceMeta()
|
||||||
|
return meta.rootDir
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('workspace:clear', async () => {
|
||||||
|
await writeWorkspaceMeta({ rootDir: null })
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
// 在工作区根下创建约定目录 xsinfo-data/{env.json, apis/...}
|
||||||
|
ipcMain.handle('workspace:init', async (_e, rootDir: string) => {
|
||||||
|
const dataDir = join(rootDir, 'xsinfo-data')
|
||||||
|
const apisDir = join(dataDir, 'apis')
|
||||||
|
await fs.mkdir(apisDir, { recursive: true })
|
||||||
|
|
||||||
|
const envFile = join(dataDir, 'env.json')
|
||||||
|
if (!existsSync(envFile)) {
|
||||||
|
const initial = {
|
||||||
|
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: [{ key: 'token', value: '' }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
await fs.writeFile(envFile, JSON.stringify(initial, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 读取 env.json
|
||||||
|
ipcMain.handle('env:read', async (_e, rootDir: string) => {
|
||||||
|
const file = join(rootDir, 'xsinfo-data', 'env.json')
|
||||||
|
if (!existsSync(file)) return null
|
||||||
|
const raw = await fs.readFile(file, 'utf-8')
|
||||||
|
return JSON.parse(raw)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('env:write', async (_e, rootDir: string, data: unknown) => {
|
||||||
|
const file = join(rootDir, 'xsinfo-data', 'env.json')
|
||||||
|
await fs.mkdir(join(rootDir, 'xsinfo-data'), { recursive: true })
|
||||||
|
await fs.writeFile(file, JSON.stringify(data, null, 2), 'utf-8')
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// API 列表 / 树 (apifox 风格:project.json + 各分组下子文件)
|
||||||
|
ipcMain.handle('api:project:read', async (_e, rootDir: string) => {
|
||||||
|
const file = join(rootDir, 'xsinfo-data', 'project.json')
|
||||||
|
if (!existsSync(file)) return null
|
||||||
|
const raw = await fs.readFile(file, 'utf-8')
|
||||||
|
return JSON.parse(raw)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('api:project:write', async (_e, rootDir: string, data: unknown) => {
|
||||||
|
const dir = join(rootDir, 'xsinfo-data', 'apis')
|
||||||
|
const file = join(rootDir, 'xsinfo-data', 'project.json')
|
||||||
|
await fs.mkdir(dir, { recursive: true })
|
||||||
|
await fs.writeFile(file, JSON.stringify(data, null, 2), 'utf-8')
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('api:read', async (_e, rootDir: string, relativePath: string) => {
|
||||||
|
if (relativePath.includes('..')) throw new Error('非法路径')
|
||||||
|
const file = join(rootDir, 'xsinfo-data', relativePath.replace(/\\/g, '/'))
|
||||||
|
if (!existsSync(file)) return null
|
||||||
|
const raw = await fs.readFile(file, 'utf-8')
|
||||||
|
return JSON.parse(raw)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('api:write', async (_e, rootDir: string, relativePath: string, data: unknown) => {
|
||||||
|
if (relativePath.includes('..')) throw new Error('非法路径')
|
||||||
|
const file = join(rootDir, 'xsinfo-data', relativePath.replace(/\\/g, '/'))
|
||||||
|
await fs.mkdir(dirname(file), { recursive: true })
|
||||||
|
await fs.writeFile(file, JSON.stringify(data, null, 2), 'utf-8')
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 在目录下新增空白接口文件
|
||||||
|
ipcMain.handle('api:create', async (_e, rootDir: string, relativeDir: string, apiData: any) => {
|
||||||
|
const dir = join(rootDir, 'xsinfo-data', 'apis', relativeDir.replace(/\\/g, '/'))
|
||||||
|
await fs.mkdir(dir, { recursive: true })
|
||||||
|
const id = `api-${Date.now()}`
|
||||||
|
const fileName = `${id}.json`
|
||||||
|
const file = join(dir, fileName)
|
||||||
|
const payload = {
|
||||||
|
id,
|
||||||
|
name: apiData.name || '新建接口',
|
||||||
|
method: apiData.method || 'GET',
|
||||||
|
url: apiData.url || '/',
|
||||||
|
...apiData
|
||||||
|
}
|
||||||
|
await fs.writeFile(file, JSON.stringify(payload, null, 2), 'utf-8')
|
||||||
|
return { id, filePath: `apis/${relativeDir}/${fileName}`.replace(/\\/g, '/') }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 在工作区打开系统资源管理器
|
||||||
|
ipcMain.handle('shell:openDir', async (_e, dir: string) => {
|
||||||
|
await shell.openPath(dir)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 健康检查
|
||||||
|
ipcMain.handle('app:ping', async () => 'pong')
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
ping: () => ipcRenderer.invoke('app:ping'),
|
||||||
|
workspace: {
|
||||||
|
pick: () => ipcRenderer.invoke('workspace:pick') as Promise<string | null>,
|
||||||
|
get: () => ipcRenderer.invoke('workspace:get') as Promise<string | null>,
|
||||||
|
clear: () => ipcRenderer.invoke('workspace:clear') as Promise<null>,
|
||||||
|
init: (root: string) => ipcRenderer.invoke('workspace:init', root) as Promise<true>
|
||||||
|
},
|
||||||
|
env: {
|
||||||
|
read: (root: string) => ipcRenderer.invoke('env:read', root) as Promise<any>,
|
||||||
|
write: (root: string, data: any) => ipcRenderer.invoke('env:write', root, data) as Promise<true>
|
||||||
|
},
|
||||||
|
apiProject: {
|
||||||
|
read: (root: string) => ipcRenderer.invoke('api:project:read', root) as Promise<any>,
|
||||||
|
write: (root: string, data: any) => ipcRenderer.invoke('api:project:write', root, data) as Promise<true>
|
||||||
|
},
|
||||||
|
api: {
|
||||||
|
read: (root: string, relativePath: string) =>
|
||||||
|
ipcRenderer.invoke('api:read', root, relativePath) as Promise<any>,
|
||||||
|
write: (root: string, relativePath: string, data: any) =>
|
||||||
|
ipcRenderer.invoke('api:write', root, relativePath, data) as Promise<true>,
|
||||||
|
create: (root: string, relativeDir: string, apiData: any) =>
|
||||||
|
ipcRenderer.invoke('api:create', root, relativeDir, apiData) as Promise<{ id: string; filePath: string }>
|
||||||
|
},
|
||||||
|
shell: {
|
||||||
|
openDir: (dir: string) => ipcRenderer.invoke('shell:openDir', dir) as Promise<true>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('xs', api)
|
||||||
|
|
||||||
|
export type XsApi = typeof api
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>XsinfoApi</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+5217
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "xsinfo-api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "XsinfoApi - 私有化 API 调试工具 (electron + vue3 + ts)",
|
||||||
|
"private": true,
|
||||||
|
"main": "dist-electron/main.js",
|
||||||
|
"author": "xsinfo",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build && electron-builder",
|
||||||
|
"build:win": "vue-tsc --noEmit && vite build && electron-builder --win",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"vue": "^3.4.27",
|
||||||
|
"vue-router": "^4.3.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.12.7",
|
||||||
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"electron": "^30.0.0",
|
||||||
|
"electron-builder": "^24.13.3",
|
||||||
|
"sass": "^1.77.0",
|
||||||
|
"typescript": "^5.4.5",
|
||||||
|
"vite": "^5.2.10",
|
||||||
|
"vite-plugin-electron": "^0.28.6",
|
||||||
|
"vite-plugin-electron-renderer": "^0.14.5",
|
||||||
|
"vue-tsc": "^2.0.14"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.xsinfo.api",
|
||||||
|
"productName": "XsinfoApi",
|
||||||
|
"files": [
|
||||||
|
"dist/**/*",
|
||||||
|
"dist-electron/**/*"
|
||||||
|
],
|
||||||
|
"directories": {
|
||||||
|
"buildResources": "build"
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": "nsis"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#3b82f6"/>
|
||||||
|
<stop offset="100%" stop-color="#06b6d4"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="2" y="2" width="28" height="28" rx="8" fill="#0a1428"/>
|
||||||
|
<path d="M8 16h16M16 8v16" stroke="url(#g)" stroke-width="2.5" stroke-linecap="round"/>
|
||||||
|
<circle cx="16" cy="16" r="3" fill="#7eb6ff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 467 B |
+62
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 提交、PR、code 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">左侧目录树、右侧请求面板,支持参数、Headers、Body、Auth、响应预览。</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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Vendored
+41
@@ -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 {}
|
||||||
@@ -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')
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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; }
|
||||||
@@ -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;
|
||||||
@@ -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[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.d.ts",
|
||||||
|
"src/**/*.tsx",
|
||||||
|
"src/**/*.vue",
|
||||||
|
"electron/**/*.ts"
|
||||||
|
],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import electron from 'vite-plugin-electron'
|
||||||
|
import renderer from 'vite-plugin-electron-renderer'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
electron([
|
||||||
|
{
|
||||||
|
entry: 'electron/main.ts',
|
||||||
|
onstart(options) {
|
||||||
|
options.startup()
|
||||||
|
},
|
||||||
|
vite: {
|
||||||
|
build: {
|
||||||
|
outDir: 'dist-electron',
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['electron']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
entry: 'electron/preload.ts',
|
||||||
|
onstart(options) {
|
||||||
|
options.reload()
|
||||||
|
},
|
||||||
|
vite: {
|
||||||
|
build: {
|
||||||
|
outDir: 'dist-electron',
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['electron']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
renderer()
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true
|
||||||
|
},
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
scss: {
|
||||||
|
additionalData: `@use "@/styles/variables.scss" as *;`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user