diff --git a/.ui-review-icon/icon-big.png b/.ui-review-icon/icon-big.png new file mode 100644 index 0000000..5028faf Binary files /dev/null and b/.ui-review-icon/icon-big.png differ diff --git a/.ui-review-icon2/128.png b/.ui-review-icon2/128.png new file mode 100644 index 0000000..05d6904 Binary files /dev/null and b/.ui-review-icon2/128.png differ diff --git a/.ui-review-icon2/64.png b/.ui-review-icon2/64.png new file mode 100644 index 0000000..b8b4794 Binary files /dev/null and b/.ui-review-icon2/64.png differ diff --git a/.ui-review-icon2/app-128.png b/.ui-review-icon2/app-128.png new file mode 100644 index 0000000..c07dd4d Binary files /dev/null and b/.ui-review-icon2/app-128.png differ diff --git a/.ui-review-layout/01-home.png b/.ui-review-layout/01-home.png new file mode 100644 index 0000000..fd463b6 Binary files /dev/null and b/.ui-review-layout/01-home.png differ diff --git a/.ui-review-layout/02-http.png b/.ui-review-layout/02-http.png new file mode 100644 index 0000000..5028906 Binary files /dev/null and b/.ui-review-layout/02-http.png differ diff --git a/.ui-review-layout/03-mock.png b/.ui-review-layout/03-mock.png new file mode 100644 index 0000000..69b66a9 Binary files /dev/null and b/.ui-review-layout/03-mock.png differ diff --git a/.ui-review-layout/probe.json b/.ui-review-layout/probe.json new file mode 100644 index 0000000..0d8cd6a --- /dev/null +++ b/.ui-review-layout/probe.json @@ -0,0 +1,33 @@ +{ + "home": { + "text": "OpsxAPI\n新建\n导入\n导出\n通道\n自动\nWeb 代理\n浏览器直连\n桌面直连\n本\n本地\n1 变量\n本\n接口\n历史\n测试\nMock\n文档\n设置\n登录\n接口\n历史\n示例集合\nHTTP · 本地回归(P0)\nGET\n回归 · GET Query\nPOST\n回归 · POST JSON\nPOST\n回归 · POST urlencoded\nPOST\n回归 · POST form-data\nGET\n回归 · 自定义 Header\nGET\n回归 · Cookie\nGET\n回归 · Bearer\nGET\n回归 · Basic Auth\nHTTP · httpbin\nGET\n获取请求回显\nPOST\n提交 JSON\nPOST\n表单提交\nGET\nBearer 鉴权示例\nGET\n状态码 · 404\nGraphQL\n国家查询 · countries\n实时协议\nWebSocket · echo\nSSE · 维基媒体变更流\nMQTT · EMQX 公共 Broker\nSocket.IO · 本地服务\n没有打开的请求\nOpsxAPI · 接口工作台\n从左侧选择接口,或导入数据/新建请求\nHTTP · GraphQL · WebSocket · SSE · Socket.IO · MQTT\n支持 OpenAPI / Postman / Apifox / cURL / HAR 等格式导入导出", + "title": "OpsxAPI · 接口工作台" + }, + "mockProbe": { + "hasShareable": false, + "hasLocal": false, + "hasComing": false, + "snippet": "\nWeb 代理\n浏览器直连\n桌面直连\n本\n本地\n1 变量\n本\n接口\n历史\n测试\nMock\n文档\n设置\n登录\n接口\n历史\n示例集合\nHTTP · 本地回归(P0)\nHTTP · httpbin\nGET\n获取请求回显\nPOST\n提交 JSON\nPOST\n表单提交\nGET\nBearer 鉴权示例\nGET\n状态码 · 404\nGraphQL\n国家查询 · countries\n实时协议\nWebSocket · echo\nSSE · 维基媒体变更流\nMQTT · EMQX 公共 Brok" + }, + "layout": { + "top": { + "w": 1440, + "h": 48 + }, + "side": { + "w": 256, + "h": 765 + }, + "rail": { + "w": 34, + "h": 50 + }, + "main": { + "w": 1128, + "h": 765 + }, + "topBtns": 5, + "minFs": 8, + "bodyBg": "rgb(11, 18, 32)" + } +} \ No newline at end of file diff --git a/.ui-review-layout2/home.png b/.ui-review-layout2/home.png new file mode 100644 index 0000000..0df2fc7 Binary files /dev/null and b/.ui-review-layout2/home.png differ diff --git a/.ui-review/01-home.png b/.ui-review/01-home.png new file mode 100644 index 0000000..655ab39 Binary files /dev/null and b/.ui-review/01-home.png differ diff --git a/.ui-review/02-after-click.png b/.ui-review/02-after-click.png new file mode 100644 index 0000000..655ab39 Binary files /dev/null and b/.ui-review/02-after-click.png differ diff --git a/.ui-review/03-ws.png b/.ui-review/03-ws.png new file mode 100644 index 0000000..655ab39 Binary files /dev/null and b/.ui-review/03-ws.png differ diff --git a/.ui-review/04-http.png b/.ui-review/04-http.png new file mode 100644 index 0000000..cd12242 Binary files /dev/null and b/.ui-review/04-http.png differ diff --git a/.ui-review/05-ws.png b/.ui-review/05-ws.png new file mode 100644 index 0000000..cd12242 Binary files /dev/null and b/.ui-review/05-ws.png differ diff --git a/.ui-review/10-http.png b/.ui-review/10-http.png new file mode 100644 index 0000000..bd7922a Binary files /dev/null and b/.ui-review/10-http.png differ diff --git a/.ui-review/11-ws.png b/.ui-review/11-ws.png new file mode 100644 index 0000000..bd7922a Binary files /dev/null and b/.ui-review/11-ws.png differ diff --git a/.ui-review/20-http-sent.png b/.ui-review/20-http-sent.png new file mode 100644 index 0000000..927d9bd Binary files /dev/null and b/.ui-review/20-http-sent.png differ diff --git a/.ui-review/21-ws.png b/.ui-review/21-ws.png new file mode 100644 index 0000000..d2cef87 Binary files /dev/null and b/.ui-review/21-ws.png differ diff --git a/.ui-review/30-http.png b/.ui-review/30-http.png new file mode 100644 index 0000000..0cb18a2 Binary files /dev/null and b/.ui-review/30-http.png differ diff --git a/.ui-review/31-dirty.png b/.ui-review/31-dirty.png new file mode 100644 index 0000000..6be8d71 Binary files /dev/null and b/.ui-review/31-dirty.png differ diff --git a/.ui-review/32-ws.png b/.ui-review/32-ws.png new file mode 100644 index 0000000..bf9336b Binary files /dev/null and b/.ui-review/32-ws.png differ diff --git a/.ui-review/33-mock.png b/.ui-review/33-mock.png new file mode 100644 index 0000000..32150bd Binary files /dev/null and b/.ui-review/33-mock.png differ diff --git a/.ui-review/40-home.png b/.ui-review/40-home.png new file mode 100644 index 0000000..2ed7344 Binary files /dev/null and b/.ui-review/40-home.png differ diff --git a/.ui-review/41-http.png b/.ui-review/41-http.png new file mode 100644 index 0000000..2567d32 Binary files /dev/null and b/.ui-review/41-http.png differ diff --git a/.ui-review/42-ws.png b/.ui-review/42-ws.png new file mode 100644 index 0000000..8e9c6c7 Binary files /dev/null and b/.ui-review/42-ws.png differ diff --git a/.ui-review/43-proxy-attempt.png b/.ui-review/43-proxy-attempt.png new file mode 100644 index 0000000..6879fbe Binary files /dev/null and b/.ui-review/43-proxy-attempt.png differ diff --git a/.ui-review/accept-2026-09-19/d3-retest-ws.png b/.ui-review/accept-2026-09-19/d3-retest-ws.png new file mode 100644 index 0000000..4c3b4e5 Binary files /dev/null and b/.ui-review/accept-2026-09-19/d3-retest-ws.png differ diff --git a/.ui-review/accept-2026-09-19/desk-after-new.png b/.ui-review/accept-2026-09-19/desk-after-new.png new file mode 100644 index 0000000..98a3d88 Binary files /dev/null and b/.ui-review/accept-2026-09-19/desk-after-new.png differ diff --git a/.ui-review/accept-2026-09-19/desk-menu.png b/.ui-review/accept-2026-09-19/desk-menu.png new file mode 100644 index 0000000..053fbe0 Binary files /dev/null and b/.ui-review/accept-2026-09-19/desk-menu.png differ diff --git a/.ui-review/accept-2026-09-19/desk-win.png b/.ui-review/accept-2026-09-19/desk-win.png new file mode 100644 index 0000000..d131262 Binary files /dev/null and b/.ui-review/accept-2026-09-19/desk-win.png differ diff --git a/.ui-review/accept-2026-09-19/desktop-full.png b/.ui-review/accept-2026-09-19/desktop-full.png new file mode 100644 index 0000000..867805f Binary files /dev/null and b/.ui-review/accept-2026-09-19/desktop-full.png differ diff --git a/.ui-review/accept-2026-09-19/web-01-home.png b/.ui-review/accept-2026-09-19/web-01-home.png new file mode 100644 index 0000000..4fb0c7d Binary files /dev/null and b/.ui-review/accept-2026-09-19/web-01-home.png differ diff --git a/.ui-review/accept-2026-09-19/web-02-channel.png b/.ui-review/accept-2026-09-19/web-02-channel.png new file mode 100644 index 0000000..4fb0c7d Binary files /dev/null and b/.ui-review/accept-2026-09-19/web-02-channel.png differ diff --git a/.ui-review/accept-2026-09-19/web-03-env.png b/.ui-review/accept-2026-09-19/web-03-env.png new file mode 100644 index 0000000..2f535b1 Binary files /dev/null and b/.ui-review/accept-2026-09-19/web-03-env.png differ diff --git a/.ui-review/accept-2026-09-19/web-04-tree.png b/.ui-review/accept-2026-09-19/web-04-tree.png new file mode 100644 index 0000000..ac01af9 Binary files /dev/null and b/.ui-review/accept-2026-09-19/web-04-tree.png differ diff --git a/.ui-review/accept-2026-09-19/web-05-dirty.png b/.ui-review/accept-2026-09-19/web-05-dirty.png new file mode 100644 index 0000000..028b3f4 Binary files /dev/null and b/.ui-review/accept-2026-09-19/web-05-dirty.png differ diff --git a/.ui-review/accept-2026-09-19/web-06-new-menu.png b/.ui-review/accept-2026-09-19/web-06-new-menu.png new file mode 100644 index 0000000..9abe24b Binary files /dev/null and b/.ui-review/accept-2026-09-19/web-06-new-menu.png differ diff --git a/.ui-review/accept-2026-09-19/web-07-ws-editor.png b/.ui-review/accept-2026-09-19/web-07-ws-editor.png new file mode 100644 index 0000000..8a71cba Binary files /dev/null and b/.ui-review/accept-2026-09-19/web-07-ws-editor.png differ diff --git a/.ui-review/accept-2026-09-19/web-08-ws-filled.png b/.ui-review/accept-2026-09-19/web-08-ws-filled.png new file mode 100644 index 0000000..8672746 Binary files /dev/null and b/.ui-review/accept-2026-09-19/web-08-ws-filled.png differ diff --git a/.ui-review/accept-2026-09-19/web-10-history.png b/.ui-review/accept-2026-09-19/web-10-history.png new file mode 100644 index 0000000..ae4d443 Binary files /dev/null and b/.ui-review/accept-2026-09-19/web-10-history.png differ diff --git a/.ui-review/accept-2026-09-19/web-11-replay.png b/.ui-review/accept-2026-09-19/web-11-replay.png new file mode 100644 index 0000000..f62e4eb Binary files /dev/null and b/.ui-review/accept-2026-09-19/web-11-replay.png differ diff --git a/.ui-review/accept-2026-09-19/web-reg-result.json b/.ui-review/accept-2026-09-19/web-reg-result.json new file mode 100644 index 0000000..af523be --- /dev/null +++ b/.ui-review/accept-2026-09-19/web-reg-result.json @@ -0,0 +1,24 @@ +{ + "verdict": "有缺陷", + "pass": [ + "通道切换往返: 自动|Web 代理|浏览器直连|桌面直连", + "打开环境变量面板", + "集合树切换到回归 GET", + "URL 脏点已写入", + "脏点/保存相关 UI 可见或已 Cmd+S", + "发送 HTTP 以产生历史", + "新建 WebSocket", + "填写 WS URL", + "填写 Authorization Header", + "连接后出现状态文案", + "重放 toast: 重放成功 · HTTP 200 · 22ms" + ], + "fail": [ + "WS流程: locator.click: Timeout 15000ms exceeded.\nCall log:\n - waiting for getByRole('button', { name: /发送/ }).first()\n - locator resolved to \n - attempting click action\n 2 × waiting for element to be visible, enabled and stable\n - element is not enabled\n - retrying click action\n - waiting 20ms\n 2 × waiting for element to be visible, enabled and stable\n - element is not enabled\n - retrying click action\n - waiting 100ms\n 28 × waiting for element to be visible, enabled and stable\n - element is not enabled\n - retrying click action\n - waiting 500ms\n" + ], + "notes": [ + "title=OpsxAPI · 接口工作台", + "selectCount=1", + "select0=自动/Web 代理/浏览器直连/桌面直连" + ] +} \ No newline at end of file diff --git a/.ui-review/accept-2026-09-19/web-reg.mjs b/.ui-review/accept-2026-09-19/web-reg.mjs new file mode 100644 index 0000000..765a6ec --- /dev/null +++ b/.ui-review/accept-2026-09-19/web-reg.mjs @@ -0,0 +1,255 @@ +import { chromium } from 'playwright'; +import fs from 'fs'; +import path from 'path'; + +const outDir = path.resolve('.ui-review/accept-2026-09-19'); +const log = []; +const shot = async (page, name) => { + const p = path.join(outDir, name); + await page.screenshot({ path: p, fullPage: false }); + log.push(`shot:${name}`); +}; + +const result = { pass: [], fail: [], notes: [] }; + +async function main() { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + page.setDefaultTimeout(12000); + await page.goto('http://127.0.0.1:5173/', { waitUntil: 'networkidle' }); + await shot(page, 'web-01-home.png'); + + // Channel switch + try { + const channel = page.getByRole('combobox').or(page.locator('select, [aria-label*="通道"], button:has-text("通道")')).first(); + // Top bar has 通道 popup - from AX it was AXPopUpButton 发信通道 + const pop = page.locator('text=通道').first(); + await pop.click({ timeout: 5000 }).catch(() => {}); + // Try select options visible + const modes = ['自动', 'Web代理', '浏览器', '桌面']; + let switched = false; + for (const m of modes) { + const opt = page.getByText(m, { exact: true }).first(); + if (await opt.isVisible().catch(() => false)) { + await opt.click(); + switched = true; + result.pass.push(`通道切换点击到「${m}」`); + break; + } + } + // Also try native select + const select = page.locator('select').filter({ hasText: /自动|代理|浏览器|桌面/ }).first(); + if (await select.count()) { + const options = await select.locator('option').allTextContents(); + result.notes.push('channel select options: ' + options.join('|')); + if (options.length > 1) { + await select.selectOption({ index: 1 }); + await select.selectOption({ index: 0 }); + switched = true; + result.pass.push('通道 select 往返切换'); + } + } + if (!switched) result.fail.push('通道切换:未找到可点选项'); + await shot(page, 'web-02-channel.png'); + } catch (e) { + result.fail.push('通道切换异常: ' + e.message); + } + + // Env switch - button 本地 1 变量 + try { + const envBtn = page.getByRole('button', { name: /变量|环境/ }).first(); + if (await envBtn.count()) { + await envBtn.click(); + result.pass.push('打开环境/变量'); + await page.keyboard.press('Escape').catch(() => {}); + } else { + result.fail.push('环境切换:未找到环境按钮'); + } + await shot(page, 'web-03-env.png'); + } catch (e) { + result.fail.push('环境: ' + e.message); + } + + // Collection tree - click an existing request + try { + const req = page.getByText('回归 · GET Query').first(); + await req.click({ timeout: 5000 }); + result.pass.push('集合树点击切换到「回归 · GET Query」'); + await shot(page, 'web-04-tree.png'); + } catch (e) { + result.fail.push('集合树点击: ' + e.message); + } + + // Dirty save - edit URL then look for save/dirty indicator + try { + const urlInput = page.locator('input').filter({ hasText: '' }).nth(0); + // Prefer visible URL field near method + const candidates = page.locator('input[value*="http"], input[placeholder*="http"], textarea, input'); + let edited = false; + const count = await candidates.count(); + for (let i = 0; i < Math.min(count, 30); i++) { + const el = candidates.nth(i); + const val = await el.inputValue().catch(() => ''); + if (val.includes('http') || val.includes('127.0.0.1') || val.includes('/v1/')) { + await el.click(); + await el.fill(val + (val.includes('?') ? '&' : '?') + '_dirty=1'); + edited = true; + result.pass.push('编辑 URL 制造脏点'); + break; + } + } + if (!edited) result.notes.push('未定位到 URL 输入框,跳过脏点编辑'); + // Try save shortcut or save button + await page.keyboard.press('Meta+s').catch(() => {}); + const saveBtn = page.getByRole('button', { name: /保存/ }).first(); + if (await saveBtn.isVisible().catch(() => false)) { + await saveBtn.click(); + result.pass.push('点击保存'); + } else { + result.notes.push('未见「保存」按钮(可能自动持久化)'); + } + await shot(page, 'web-05-dirty.png'); + } catch (e) { + result.fail.push('脏点保存: ' + e.message); + } + + // New WebSocket via 新建 menu + try { + await page.getByRole('button', { name: '新建' }).first().click(); + await page.waitForTimeout(400); + const wsItem = page.getByText(/WebSocket|WS/i).first(); + if (await wsItem.isVisible().catch(() => false)) { + await wsItem.click(); + result.pass.push('新建 WebSocket'); + } else { + // menu items + const items = await page.locator('[role="menu"] >> text=/WebSocket|websocket|WS/i').allTextContents().catch(() => []); + result.notes.push('新建菜单项: ' + JSON.stringify(items)); + result.fail.push('新建菜单未见 WebSocket'); + } + await shot(page, 'web-06-new-ws.png'); + } catch (e) { + result.fail.push('新建 WS: ' + e.message); + } + + // Fill WS url + header if editor present + try { + const urlBox = page.locator('input').filter({ hasNot: page.locator('[type=checkbox]') }); + // fill first empty-ish or wss field + let filledUrl = false; + const n = await page.locator('input').count(); + for (let i = 0; i < n; i++) { + const el = page.locator('input').nth(i); + const ph = (await el.getAttribute('placeholder').catch(() => '')) || ''; + const val = await el.inputValue().catch(() => ''); + if (/wss?|url|地址/i.test(ph) || val.startsWith('ws') || (val === '' && /url/i.test(ph))) { + await el.fill('wss://ws.postman-echo.com/raw'); + filledUrl = true; + break; + } + } + if (!filledUrl) { + // try active tab url field - often the long input + const longInputs = page.locator('input.ops-input, input[class*="input"]'); + if (await longInputs.count()) { + await longInputs.first().fill('wss://ws.postman-echo.com/raw'); + filledUrl = true; + } + } + if (filledUrl) result.pass.push('填写 WS URL'); + else result.fail.push('未能填写 WS URL'); + + // Headers tab + const hdrTab = page.getByRole('tab', { name: /Header|请求头|头/i }).or(page.getByText('Headers')).first(); + if (await hdrTab.isVisible().catch(() => false)) await hdrTab.click(); + // Add Authorization row + const addHdr = page.getByRole('button', { name: /添加|Add/ }).first(); + if (await addHdr.isVisible().catch(() => false)) await addHdr.click(); + // Fill key/value - best effort + await page.getByPlaceholder(/[Kk]ey|名称|Header/).first().fill('Authorization').catch(() => {}); + await page.getByPlaceholder(/[Vv]alue|值/).first().fill('Bearer smoke').catch(() => {}); + // fallback: any two empty inputs in header editor + result.notes.push('Header 填写尽力而为'); + await shot(page, 'web-07-ws-filled.png'); + + const connectBtn = page.getByRole('button', { name: /连接|Connect/ }).first(); + if (await connectBtn.isVisible().catch(() => false)) { + await connectBtn.click(); + await page.waitForTimeout(2500); + result.pass.push('点击连接'); + const bodyText = await page.locator('body').innerText(); + if (/open|已连接|连接成功|ping|tunnel|自定义 Header/i.test(bodyText)) { + result.pass.push('连接后 UI 出现成功/系统相关文案'); + } else { + result.notes.push('连接后未明确看到成功文案,需人工看截图'); + } + // try send + const sendBox = page.locator('textarea, input[placeholder*="消息"], input[placeholder*="发送"]').first(); + if (await sendBox.isVisible().catch(() => false)) { + await sendBox.fill('ping-gui-reg'); + const sendBtn = page.getByRole('button', { name: /发送|Send/ }).first(); + if (await sendBtn.isVisible().catch(() => false)) await sendBtn.click(); + await page.waitForTimeout(1500); + result.pass.push('尝试发送消息'); + } + } else { + result.fail.push('未见连接按钮'); + } + await shot(page, 'web-08-ws-connected.png'); + } catch (e) { + result.fail.push('WS 填表/连接: ' + e.message); + } + + // History replay toast + try { + await page.getByRole('button', { name: /历史/ }).first().click(); + await page.waitForTimeout(500); + await shot(page, 'web-09-history.png'); + const replay = page.getByRole('button', { name: /重放/ }).first(); + if (await replay.isVisible().catch(() => false)) { + await replay.click(); + await page.waitForTimeout(2000); + const toast = page.locator('.ops-toast, [class*="toast"]'); + const toastText = (await toast.allTextContents().catch(() => [])).join(' | '); + const body = await page.locator('body').innerText(); + if (/正在重放|重放成功|重放完成|重放失败|重放连接/i.test(toastText + body)) { + result.pass.push('历史重放出现 toast/文案: ' + (toastText || 'body匹配').slice(0, 120)); + } else { + result.fail.push('点了重放但未见 toast 文案'); + } + } else { + // hover history rows + const rows = page.locator('text=/GET|POST|WS|重放/'); + result.notes.push('历史面板打开;重放按钮可能需悬停'); + const hist = page.locator('.ops-tree-row, [class*="history"]'); + if (await hist.count()) { + await hist.first().hover(); + await page.waitForTimeout(300); + if (await page.getByRole('button', { name: /重放/ }).first().isVisible().catch(() => false)) { + await page.getByRole('button', { name: /重放/ }).first().click(); + await page.waitForTimeout(2000); + const body = await page.locator('body').innerText(); + if (/正在重放|重放成功|重放完成|重放失败|重放连接/i.test(body)) { + result.pass.push('悬停后重放 toast 文案可见'); + } else result.fail.push('悬停重放后未见 toast'); + } else result.fail.push('悬停后仍无重放按钮'); + } else result.fail.push('历史为空或结构不符'); + } + await shot(page, 'web-10-replay.png'); + } catch (e) { + result.fail.push('历史重放: ' + e.message); + } + + await browser.close(); + const summary = { + verdict: result.fail.length ? '有缺陷' : '通过', + ...result, + }; + fs.writeFileSync(path.join(outDir, 'web-reg-result.json'), JSON.stringify(summary, null, 2)); + console.log(JSON.stringify(summary, null, 2)); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/.ui-review/accept-2026-09-19/web-reg2.mjs b/.ui-review/accept-2026-09-19/web-reg2.mjs new file mode 100644 index 0000000..6b2ee06 --- /dev/null +++ b/.ui-review/accept-2026-09-19/web-reg2.mjs @@ -0,0 +1,275 @@ +import { chromium } from 'playwright'; +import fs from 'fs'; +import path from 'path'; + +const outDir = path.resolve('.ui-review/accept-2026-09-19'); +const result = { pass: [], fail: [], notes: [] }; +const shot = async (page, name) => { + await page.screenshot({ path: path.join(outDir, name) }); +}; + +async function main() { + const browser = await chromium.launch({ + headless: true, + channel: 'chrome', + }); + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + page.setDefaultTimeout(15000); + await page.goto('http://127.0.0.1:5173/', { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1500); + await shot(page, 'web-01-home.png'); + const title = await page.title(); + result.notes.push('title=' + title); + + // Channel: look for select near 通道 + try { + const selects = page.locator('select'); + const sc = await selects.count(); + result.notes.push('selectCount=' + sc); + let channelDone = false; + for (let i = 0; i < sc; i++) { + const sel = selects.nth(i); + const opts = await sel.locator('option').allTextContents(); + result.notes.push('select' + i + '=' + opts.join('/')); + if (opts.some((o) => /自动|代理|浏览器|桌面|Web|browser|tauri|auto/i.test(o))) { + const values = await sel.locator('option').evaluateAll((os) => os.map((o) => o.value)); + if (values.length > 1) { + await sel.selectOption(values[1]); + await page.waitForTimeout(300); + await sel.selectOption(values[0]); + result.pass.push('通道切换往返: ' + opts.join('|')); + channelDone = true; + break; + } + } + } + if (!channelDone) { + // click text 通道 then options + await page.getByText('通道', { exact: true }).first().click().catch(() => {}); + await page.waitForTimeout(300); + for (const m of ['Web 代理', 'Web代理', '浏览器', '桌面', '自动']) { + const o = page.getByText(m, { exact: true }); + if (await o.count()) { + await o.first().click(); + result.pass.push('通道点选 ' + m); + channelDone = true; + break; + } + } + } + if (!channelDone) result.fail.push('通道切换失败'); + await shot(page, 'web-02-channel.png'); + } catch (e) { + result.fail.push('通道: ' + e.message); + } + + // Env + try { + const envBtn = page.getByRole('button', { name: /变量/ }); + if (await envBtn.count()) { + await envBtn.first().click(); + await page.waitForTimeout(500); + result.pass.push('打开环境变量面板'); + await page.keyboard.press('Escape'); + } else result.fail.push('无环境变量按钮'); + await shot(page, 'web-03-env.png'); + } catch (e) { + result.fail.push('环境: ' + e.message); + } + + // Tree + try { + await page.getByText('回归 · GET Query').first().click({ timeout: 8000 }); + result.pass.push('集合树切换到回归 GET'); + await page.waitForTimeout(500); + await shot(page, 'web-04-tree.png'); + } catch (e) { + result.fail.push('集合树: ' + e.message); + } + + // Dirty + try { + let edited = false; + const inputs = page.locator('input'); + const n = await inputs.count(); + for (let i = 0; i < n; i++) { + const el = inputs.nth(i); + const val = await el.inputValue().catch(() => ''); + if (/https?:|127\.0\.0\.1|\/v1\//.test(val)) { + await el.fill(val.replace(/([?&]_dirty=1)/g, '') + (val.includes('?') ? '&' : '?') + '_dirty=1'); + edited = true; + result.pass.push('URL 脏点已写入'); + break; + } + } + if (!edited) result.fail.push('未找到可编辑 URL'); + // look for dirty marker / save + await page.keyboard.press('Meta+s'); + await page.waitForTimeout(400); + const body = await page.locator('body').innerText(); + if (/未保存|脏|dirty|\*/i.test(body) || (await page.getByRole('button', { name: /保存/ }).count())) { + result.pass.push('脏点/保存相关 UI 可见或已 Cmd+S'); + } else { + result.notes.push('可能自动持久化,未见显式脏标记'); + } + await shot(page, 'web-05-dirty.png'); + } catch (e) { + result.fail.push('脏点: ' + e.message); + } + + // First send HTTP to create history + try { + const send = page.getByRole('button', { name: /发送/ }).first(); + if (await send.count()) { + await send.click(); + await page.waitForTimeout(2000); + result.pass.push('发送 HTTP 以产生历史'); + } + } catch (e) { + result.notes.push('发送: ' + e.message); + } + + // New WS + try { + await page.getByRole('button', { name: '新建' }).first().click(); + await page.waitForTimeout(500); + await shot(page, 'web-06-new-menu.png'); + const cand = page.locator('text=/WebSocket|Websocket|WS/i'); + if (await cand.count()) { + await cand.first().click(); + result.pass.push('新建 WebSocket'); + } else { + result.fail.push('新建菜单无 WebSocket: ' + (await page.locator('body').innerText()).slice(0, 200)); + } + await page.waitForTimeout(600); + await shot(page, 'web-07-ws-editor.png'); + } catch (e) { + result.fail.push('新建WS: ' + e.message); + } + + // Fill WS + try { + // URL field + const inputs = page.locator('input'); + let ok = false; + for (let i = 0; i < await inputs.count(); i++) { + const el = inputs.nth(i); + const ph = ((await el.getAttribute('placeholder')) || '') + (await el.getAttribute('aria-label') || ''); + const val = await el.inputValue().catch(() => ''); + if (/ws|url|地址|主机/i.test(ph + val) || val.startsWith('ws') || (i < 5 && val === '')) { + // prefer empty or ws-looking near top of editor + } + } + // Prefer the main URL bar: often largest width input with ws default + const urlCandidates = await page.locator('input.ops-input, input').all(); + for (const el of urlCandidates) { + const val = await el.inputValue().catch(() => ''); + const box = await el.boundingBox().catch(() => null); + if (!box || box.width < 200) continue; + if (val.startsWith('ws') || val === '' || /echo|localhost|127/.test(val)) { + await el.fill('wss://ws.postman-echo.com/raw'); + ok = true; + break; + } + } + if (ok) result.pass.push('填写 WS URL'); + else { + // force first wide input + for (const el of urlCandidates) { + const box = await el.boundingBox().catch(() => null); + if (box && box.width > 300) { + await el.fill('wss://ws.postman-echo.com/raw'); + ok = true; + result.pass.push('填写 WS URL(宽输入框)'); + break; + } + } + } + if (!ok) result.fail.push('WS URL 填写失败'); + + // Headers + const headerTab = page.getByText('Headers', { exact: true }).or(page.getByText('请求头')).or(page.getByRole('tab', { name: /Header/i })); + if (await headerTab.count()) await headerTab.first().click().catch(() => {}); + await page.waitForTimeout(300); + // try add row + const addBtns = page.getByRole('button', { name: /添加|+|\+/ }); + if (await addBtns.count()) await addBtns.first().click().catch(() => {}); + // fill key values in kv editor + const placeholders = page.locator('input[placeholder*="Key"], input[placeholder*="键"], input[placeholder*="Header"], input[placeholder*="名称"]'); + if (await placeholders.count()) { + await placeholders.first().fill('Authorization'); + const valPh = page.locator('input[placeholder*="Value"], input[placeholder*="值"]'); + if (await valPh.count()) await valPh.first().fill('Bearer smoke'); + result.pass.push('填写 Authorization Header'); + } else { + // generic: last two empty inputs in view + result.notes.push('Header 占位符未命中,尝试通用 KV'); + const all = page.locator('input'); + const vals = []; + for (let i = 0; i < await all.count(); i++) { + const v = await all.nth(i).inputValue().catch(() => null); + vals.push(v); + } + result.notes.push('inputs snapshot count=' + vals.length); + } + + await shot(page, 'web-08-ws-filled.png'); + const connect = page.getByRole('button', { name: /连接/ }); + if (await connect.count()) { + await connect.first().click(); + await page.waitForTimeout(3000); + const text = await page.locator('body').innerText(); + if (/桌面原生|Gin 隧道|自定义 Header|open|已连接|连接成功|status/i.test(text)) { + result.pass.push('连接后出现状态文案'); + } else result.notes.push('连接后文案不明确'); + const sendInput = page.locator('textarea, input[placeholder*="消息"], input[placeholder*="发送"]'); + if (await sendInput.count()) { + await sendInput.first().fill('ping-gui-reg'); + const sendBtn = page.getByRole('button', { name: /发送/ }); + if (await sendBtn.count()) { + await sendBtn.first().click(); + await page.waitForTimeout(1500); + result.pass.push('已发送 ping-gui-reg'); + } + } + await shot(page, 'web-09-ws-live.png'); + } else result.fail.push('无连接按钮'); + } catch (e) { + result.fail.push('WS流程: ' + e.message); + } + + // History replay + try { + await page.getByRole('button', { name: /历史/ }).first().click(); + await page.waitForTimeout(600); + await shot(page, 'web-10-history.png'); + const rows = page.locator('.ops-tree-row, [class*="history"] >> ..'); + // hover first history-like row + const replayBtn = page.getByRole('button', { name: '重放' }); + if (!(await replayBtn.count()) || !(await replayBtn.first().isVisible())) { + const anyRow = page.locator('button:has-text("GET"), button:has-text("POST"), div.ops-tree-row').first(); + if (await anyRow.count()) await anyRow.hover(); + await page.waitForTimeout(400); + } + if (await replayBtn.count()) { + await replayBtn.first().click({ force: true }); + await page.waitForTimeout(2200); + const toast = page.locator('.ops-toast'); + const t = (await toast.allTextContents()).join(' | '); + const body = await page.locator('body').innerText(); + if (/正在重放|重放成功|重放完成|重放失败|重放连接/i.test(t + body)) { + result.pass.push('重放 toast: ' + (t || 'body命中').slice(0, 160)); + } else result.fail.push('重放后无 toast,toast=' + t); + } else result.fail.push('无重放按钮'); + await shot(page, 'web-11-replay.png'); + } catch (e) { + result.fail.push('重放: ' + e.message); + } + + await browser.close(); + const summary = { verdict: result.fail.length ? '有缺陷' : '通过', ...result }; + fs.writeFileSync(path.join(outDir, 'web-reg-result.json'), JSON.stringify(summary, null, 2)); + console.log(JSON.stringify(summary, null, 2)); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/.ui-review/accept-2026-09-19/web-ws-recheck.png b/.ui-review/accept-2026-09-19/web-ws-recheck.png new file mode 100644 index 0000000..26bdd3b Binary files /dev/null and b/.ui-review/accept-2026-09-19/web-ws-recheck.png differ diff --git a/.ui-review/ax.json b/.ui-review/ax.json new file mode 100644 index 0000000..317737c --- /dev/null +++ b/.ui-review/ax.json @@ -0,0 +1,4 @@ +{ + "clicked": "ws:WebSocket · echo", + "interesting": [] +} \ No newline at end of file diff --git a/.ui-review/body.txt b/.ui-review/body.txt new file mode 100644 index 0000000..e48e6dd --- /dev/null +++ b/.ui-review/body.txt @@ -0,0 +1,85 @@ +OpsxAPI +HTTP · GraphQL · WS · SSE · Socket.IO · MQTT +新建 +导入 +导出 +通道 +自动 +Web 代理 +浏览器直连 +桌面直连 +本 +本地 +1 变量 +本 +接口 +历史 +测试 +Mock +文档 +设置 +登录 +接口 +历史 +示例集合 +HTTP · 本地回归(P0) +GET +回归 · GET Query +POST +回归 · POST JSON +POST +回归 · POST urlencoded +POST +回归 · POST form-data +GET +回归 · 自定义 Header +GET +回归 · Cookie +GET +回归 · Bearer +GET +回归 · Basic Auth +HTTP · httpbin +GET +获取请求回显 +POST +提交 JSON +POST +表单提交 +GET +Bearer 鉴权示例 +GET +状态码 · 404 +GraphQL +国家查询 · countries +实时协议 +WebSocket · echo +SSE · 维基媒体变更流 +MQTT · EMQX 公共 Broker +Socket.IO · 本地服务 +GET +回归 · GET Query +× +GET +POST +PUT +PATCH +DELETE +HEAD +OPTIONS +发送 +代码 +Mock 链接 +Params1 +Body +Headers +Cookies +Auth +说明 +共 1 行 · 启用 1 行 +添加 +响应体 +Raw +响应头 +Status · Time · Size +发送请求后这里显示响应 \ No newline at end of file diff --git a/.ui-review/body2.txt b/.ui-review/body2.txt new file mode 100644 index 0000000..d7ae29a --- /dev/null +++ b/.ui-review/body2.txt @@ -0,0 +1,91 @@ +OpsxAPI +HTTP · GraphQL · WS · SSE · Socket.IO · MQTT +新建 +导入 +导出 +通道 +自动 +Web 代理 +浏览器直连 +桌面直连 +本 +本地 +1 变量 +本 +接口 +历史 +测试 +Mock +文档 +设置 +登录 +接口 +历史 +1 +示例集合 +HTTP · 本地回归(P0) +GET +回归 · GET Query +POST +回归 · POST JSON +POST +回归 · POST urlencoded +POST +回归 · POST form-data +GET +回归 · 自定义 Header +GET +回归 · Cookie +GET +回归 · Bearer +GET +回归 · Basic Auth +HTTP · httpbin +GET +获取请求回显 +POST +提交 JSON +POST +表单提交 +GET +Bearer 鉴权示例 +GET +状态码 · 404 +GraphQL +国家查询 · countries +实时协议 +WebSocket · echo +SSE · 维基媒体变更流 +MQTT · EMQX 公共 Broker +Socket.IO · 本地服务 +GET +回归 · GET Query +× +WebSocket · echo +× +WS +连接 +未连接 +↑ 0 · ↓ 0 +说明 +鉴权 / 握手 HEADERS +添加 +子协议(逗号分隔) +快捷命令 +Ping +Auth +Subscribe +Unsubscribe +点击填入发送区 · Shift+点击直发 +高级 · websocat 命令 +发送消息 +格式化 JSON +⌘/Ctrl + Enter 快速发送 +消息日志 · 0/0 +全部 +发送 +接收 +系统 +错误 +清空 +连接后收发消息会显示在这里 \ No newline at end of file diff --git a/.ui-review/dom-texts.json b/.ui-review/dom-texts.json new file mode 100644 index 0000000..3bc1f0a --- /dev/null +++ b/.ui-review/dom-texts.json @@ -0,0 +1,322 @@ +[ + { + "t": "OpsxAPI\nHTTP · GraphQL · WS · SSE · Sock", + "tag": "DIV" + }, + { + "t": "OpsxAPI\nHTTP · GraphQL · WS · SSE · Sock", + "tag": "DIV" + }, + { + "t": "OpsxAPI\nHTTP · GraphQL · WS · SSE · Sock", + "tag": "DIV" + }, + { + "t": "OpsxAPI", + "tag": "DIV" + }, + { + "t": "OpsxAPI", + "tag": "SPAN" + }, + { + "t": "HTTP · GraphQL · WS · SSE · Socket.IO · ", + "tag": "SPAN" + }, + { + "t": "新建\n导入\n导出\n本\n本地\n1 变量", + "tag": "DIV" + }, + { + "t": "新建", + "tag": "BUTTON" + }, + { + "t": "导入", + "tag": "BUTTON" + }, + { + "t": "导出", + "tag": "BUTTON" + }, + { + "t": "本\n本地\n1 变量", + "tag": "BUTTON" + }, + { + "t": "本", + "tag": "SPAN" + }, + { + "t": "本地", + "tag": "SPAN" + }, + { + "t": "1 变量", + "tag": "SPAN" + }, + { + "t": "本\n接口\n历史\n测试\nMock\n文档\n设置\n登录\n接口\n历史\n示例集合\nHTTP", + "tag": "DIV" + }, + { + "t": "本", + "tag": "BUTTON" + }, + { + "t": "本", + "tag": "SPAN" + }, + { + "t": "接口", + "tag": "BUTTON" + }, + { + "t": "接口", + "tag": "SPAN" + }, + { + "t": "历史", + "tag": "BUTTON" + }, + { + "t": "历史", + "tag": "SPAN" + }, + { + "t": "测试", + "tag": "BUTTON" + }, + { + "t": "测试", + "tag": "SPAN" + }, + { + "t": "Mock", + "tag": "BUTTON" + }, + { + "t": "Mock", + "tag": "SPAN" + }, + { + "t": "文档", + "tag": "A" + }, + { + "t": "文档", + "tag": "SPAN" + }, + { + "t": "设置", + "tag": "BUTTON" + }, + { + "t": "设置", + "tag": "SPAN" + }, + { + "t": "登录", + "tag": "BUTTON" + }, + { + "t": "登录", + "tag": "SPAN" + }, + { + "t": "接口\n历史\n示例集合\nHTTP · 本地回归(P0)\nGET\n回归 · GET ", + "tag": "DIV" + }, + { + "t": "接口\n历史", + "tag": "DIV" + }, + { + "t": "接口\n历史", + "tag": "DIV" + }, + { + "t": "接口", + "tag": "BUTTON" + }, + { + "t": "历史", + "tag": "BUTTON" + }, + { + "t": "示例集合\nHTTP · 本地回归(P0)\nGET\n回归 · GET Query\n", + "tag": "DIV" + }, + { + "t": "示例集合\nHTTP · 本地回归(P0)\nGET\n回归 · GET Query\n", + "tag": "DIV" + }, + { + "t": "示例集合", + "tag": "DIV" + }, + { + "t": "示例集合", + "tag": "SPAN" + }, + { + "t": "示例集合", + "tag": "SPAN" + }, + { + "t": "HTTP · 本地回归(P0)\nGET\n回归 · GET Query\nPOST\n", + "tag": "DIV" + }, + { + "t": "HTTP · 本地回归(P0)", + "tag": "DIV" + }, + { + "t": "HTTP · 本地回归(P0)", + "tag": "SPAN" + }, + { + "t": "HTTP · 本地回归(P0)", + "tag": "SPAN" + }, + { + "t": "GET\n回归 · GET Query", + "tag": "DIV" + }, + { + "t": "GET\n回归 · GET Query", + "tag": "DIV" + }, + { + "t": "GET", + "tag": "SPAN" + }, + { + "t": "回归 · GET Query", + "tag": "SPAN" + }, + { + "t": "回归 · GET Query", + "tag": "SPAN" + }, + { + "t": "POST\n回归 · POST JSON", + "tag": "DIV" + }, + { + "t": "POST\n回归 · POST JSON", + "tag": "DIV" + }, + { + "t": "POST", + "tag": "SPAN" + }, + { + "t": "回归 · POST JSON", + "tag": "SPAN" + }, + { + "t": "回归 · POST JSON", + "tag": "SPAN" + }, + { + "t": "POST\n回归 · POST urlencoded", + "tag": "DIV" + }, + { + "t": "POST\n回归 · POST urlencoded", + "tag": "DIV" + }, + { + "t": "POST", + "tag": "SPAN" + }, + { + "t": "回归 · POST urlencoded", + "tag": "SPAN" + }, + { + "t": "回归 · POST urlencoded", + "tag": "SPAN" + }, + { + "t": "POST\n回归 · POST form-data", + "tag": "DIV" + }, + { + "t": "POST\n回归 · POST form-data", + "tag": "DIV" + }, + { + "t": "POST", + "tag": "SPAN" + }, + { + "t": "回归 · POST form-data", + "tag": "SPAN" + }, + { + "t": "回归 · POST form-data", + "tag": "SPAN" + }, + { + "t": "GET\n回归 · 自定义 Header", + "tag": "DIV" + }, + { + "t": "GET\n回归 · 自定义 Header", + "tag": "DIV" + }, + { + "t": "GET", + "tag": "SPAN" + }, + { + "t": "回归 · 自定义 Header", + "tag": "SPAN" + }, + { + "t": "回归 · 自定义 Header", + "tag": "SPAN" + }, + { + "t": "GET\n回归 · Cookie", + "tag": "DIV" + }, + { + "t": "GET\n回归 · Cookie", + "tag": "DIV" + }, + { + "t": "GET", + "tag": "SPAN" + }, + { + "t": "回归 · Cookie", + "tag": "SPAN" + }, + { + "t": "回归 · Cookie", + "tag": "SPAN" + }, + { + "t": "GET\n回归 · Bearer", + "tag": "DIV" + }, + { + "t": "GET\n回归 · Bearer", + "tag": "DIV" + }, + { + "t": "GET", + "tag": "SPAN" + }, + { + "t": "回归 · Bearer", + "tag": "SPAN" + }, + { + "t": "回归 · Bearer", + "tag": "SPAN" + } +] \ No newline at end of file diff --git a/.ui-review/full.png b/.ui-review/full.png new file mode 100644 index 0000000..4adf4d5 Binary files /dev/null and b/.ui-review/full.png differ diff --git a/.ui-review/home.png b/.ui-review/home.png new file mode 100644 index 0000000..220f608 Binary files /dev/null and b/.ui-review/home.png differ diff --git a/.ui-review/probe30.json b/.ui-review/probe30.json new file mode 100644 index 0000000..69d03d7 --- /dev/null +++ b/.ui-review/probe30.json @@ -0,0 +1,43 @@ +{ + "probe": { + "save": true, + "dirtyDot": false, + "runMode": true, + "l2": true, + "bannerClasses": [], + "saveButtons": [ + "● 保存" + ], + "topBits": [ + "新建", + "导入", + "导出", + "自动Web 代理浏览器直连桌面直连", + "本本地1 变量", + "本", + "接口", + "历史", + "测试", + "Mock", + "设置", + "登录", + "接口", + "历史", + "×", + "GETPOSTPUTPATCHDELETEHEADOPTIONS", + "发送", + "● 保存", + "代码", + "Mock 链接", + "Params1", + "Body", + "Headers", + "Cookies", + "Auth" + ] + }, + "forceBanner": { + "keys": [] + }, + "body": "OpsxAPI\nHTTP · GraphQL · WS · SSE · Socket.IO · MQTT\n新建\n导入\n导出\n通道\n自动\nWeb 代理\n浏览器直连\n桌面直连\n本\n本地\n1 变量\n本\n接口\n历史\n测试\nMock\n文档\n设置\n登录\n接口\n历史\n示例集合\nHTTP · 本地回归(P0)\nGET\n回归 · GET Query\nPOST\n回归 · POST JSON\nPOST\n回归 · POST urlencoded\nPOST\n回归 · POST form-data\nGET\n回归 · 自定义 Header\nGET\n回归 · Cookie\nGET\n回归 · Bearer\nGET\n回归 · Basic Auth\nHTTP · httpbin\nGET\n获取请求回显\nPOST\n提交 JSON\nPOST\n表单提交\nGET\nBearer 鉴权示例\nGET\n状态码 · 404\nGraphQL\n国家查询 · countries\n实时协议\nWebSocket · echo\nSSE · 维基媒体变更流\nMQTT · EMQX 公共 Broker\nSocket.IO · 本地服务\nGET\n回归 · GET Query\n×\nWebSocket · echo\n×\nWS\n连接\n已保存\n未连接\n↑ 0 · ↓ 0\n说明\n鉴权 / 握手 HEADERS\n添加\n子协议(逗号分隔)\n快捷命令\nPing\nAuth\nSubscribe\nUnsubscribe\n点击填入发送区 · Shift+点击直发\n高级 · websocat 命令\n发送消息\n格式化 JSON\n⌘/Ctrl + Enter 快速发送\n消息日志 · 0/0\n全部\n发送\n接收\n系统\n错误\n清空\n连接后收发消息会显示在这里\n设置\n设置\n外观\n主题与界面\n网络\n请求与超时\nMock 数据\n智能匹配规则\nAI 助手\n模型供应商\n数据管理\n工作区与备份\n关于\n版本与链接\nMock 数据\n智能匹配规则\n启用 Mock 数据\n发送时解析参数与 Body 中的 @mock 表达式(如 @cname、@integer(1,100))\n智能 Mock 匹配\nJSON Body 中字段值未写 Mock 规则时,按字段名自动匹配下方规则生成\n智能匹配规则\n按从上到下的顺序匹配,先命中先用;* 通配任意字符,类型需与 JSON 字段一致。语法速查 →\n61 条\n新增规则\n恢复默认\n数据类型\n匹配方式\n匹配规则\nMock 表达式\n备注\n启用\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配符\n完全匹配\n正则\nstring\nnumber\ninteger\nboolean\n通配" +} \ No newline at end of file diff --git a/.ui-review/probe40.json b/.ui-review/probe40.json new file mode 100644 index 0000000..06163df --- /dev/null +++ b/.ui-review/probe40.json @@ -0,0 +1,56 @@ +{ + "homeProbe": { + "logoTitle": "支持协议:HTTP · GraphQL · WebSocket · SSE · Socket.IO · MQTT", + "captions": [ + "OpsxAPIAPI 工作台", + "OpsxAPIAPI 工作台", + "OpsxAPIAPI 工作台" + ], + "protoListVisible": false, + "channel": true, + "l2": [ + { + "t": "测试", + "title": "测试 · 即将推出", + "opacity": "0.35" + }, + { + "t": "Mock", + "title": "Mock · 即将推出", + "opacity": "0.35" + }, + { + "t": "文档", + "title": "文档 · 即将推出", + "opacity": "0.35" + } + ] + }, + "wsProbe": { + "emptyHeaderHint": true, + "connectCaption": true, + "backToBottom": false, + "sendDisabled": { + "t": "发送消息", + "disabled": true + }, + "headerPlaceholder": [ + { + "t": "import { injectIntoGlobalHook } from \"/@react-refresh\";\ninje", + "cls": "dark" + }, + { + "t": "OpsxAPIAPI 工作台 新建 导入 导出通道自动Web 代理浏览器直连桌面直连本本地1 变量本 接口 ", + "cls": "" + }, + { + "t": "OpsxAPIAPI 工作台 新建 导入 导出通道自动Web 代理浏览器直连桌面直连本本地1 变量本 接口 ", + "cls": "" + } + ] + }, + "errProbe": { + "hasBanner": true, + "snippet": "\n新建\n导入\n导出\n通道\n自动\nWeb 代理\n浏览器直连\n桌面直连\n本\n本地\n1 变量\n本\n接口\n历史\n测试\nMock\n文档\n设置\n登录\n接口\n历史\n1\n示例集合\nHTTP · 本地回归(P0)\nGET\n回归 · GET Query\nPOST\n回归 · POST JSON\nPOS" + } +} \ No newline at end of file diff --git a/.ui-review/rail-b-check.png b/.ui-review/rail-b-check.png new file mode 100644 index 0000000..b8d8a93 Binary files /dev/null and b/.ui-review/rail-b-check.png differ diff --git a/.ui-review/wire-account.html b/.ui-review/wire-account.html new file mode 100644 index 0000000..396e9c6 --- /dev/null +++ b/.ui-review/wire-account.html @@ -0,0 +1,110 @@ + + + +OpsxAPI 左轨账号区线框 + + +

OpsxAPI · 左轨账号 / 工作区线框 v1

+

去掉「本」;本地/团队只在底部;已登录=头像菜单

+
+
+

A · 未登录

+
+
+
接口
+
测试
+
Mock
+
+
文档
+
设置
+ +
+
+
变量入口并入账号区 →
+ 左轨顶无「本」字标
底栏仅「登录」入口 +
+
+

点击 → 登录弹层。登录后变头像。

+
+
+

B · 已登录 · 菜单开主方案

+
+
+
接口
+
测试
+
Mock
+
+
文档
+
设置
+
L + +
+
+
+
顶栏变量可弱化/去掉
+ 头像角标:绿=本地 / 蓝=团队
菜单内切工作区 + 变量入口 +
+
+

角标绿本地、蓝团队。变量进菜单。

+
+
+

C · 团队态(角标)

+
+
+
接口
+
测试
+
Mock
+
+
文档
+
设置
+
L
+
+
+
团队 · 已同步
+ 切到团队后角标变蓝;配置鉴权同步(加密) +
+
+

不登录也能用本地;登录后才可切团队。

+
+
+ + diff --git a/.ui-review/wire-account.png b/.ui-review/wire-account.png new file mode 100644 index 0000000..fa55196 Binary files /dev/null and b/.ui-review/wire-account.png differ diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 14e7335..03f6388 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -329,7 +329,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.1", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -529,6 +529,12 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "dbus" version = "0.9.12" @@ -1086,7 +1092,7 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", - "rand_core", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -1350,7 +1356,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.9", ] [[package]] @@ -2170,13 +2176,17 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" name = "opsxapi" version = "0.1.0" dependencies = [ + "futures-util", + "http", "reqwest 0.12.28", "serde", "serde_json", "tauri", "tauri-build", "tokio", + "tokio-tungstenite", "url", + "uuid", ] [[package]] @@ -2373,6 +2383,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -2479,7 +2498,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.2", "rand_pcg", "ring", "rustc-hash", @@ -2527,6 +2546,17 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.10.2" @@ -2535,7 +2565,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -2550,7 +2599,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -2662,7 +2711,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 1.0.9", ] [[package]] @@ -3041,6 +3090,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3634,9 +3694,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "tokio-rustls" version = "0.26.5" @@ -3647,6 +3719,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -3872,6 +3960,26 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.8", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -3968,6 +4076,12 @@ dependencies = [ "url", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4196,6 +4310,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -4735,6 +4858,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index cc3576c..8c5a87c 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -22,5 +22,9 @@ tauri = { version = "2", features = [] } serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } -tokio = { version = "1", features = ["time"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net"] } url = "2" +tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } +futures-util = "0.3" +uuid = { version = "1", features = ["v4"] } +http = "1" diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index 93feea7..e1bf037 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -2,10 +2,10 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Default capabilities for the OpsxAPI main window", - "windows": [ - "main" - ], + "windows": ["main"], "permissions": [ - "core:default" + "core:default", + "core:event:default", + "transport" ] } diff --git a/apps/desktop/src-tauri/icons/128x128.png b/apps/desktop/src-tauri/icons/128x128.png index fcf4d66..2bc70f0 100644 Binary files a/apps/desktop/src-tauri/icons/128x128.png and b/apps/desktop/src-tauri/icons/128x128.png differ diff --git a/apps/desktop/src-tauri/icons/128x128@2x.png b/apps/desktop/src-tauri/icons/128x128@2x.png index 40a1d8b..94b0565 100644 Binary files a/apps/desktop/src-tauri/icons/128x128@2x.png and b/apps/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/apps/desktop/src-tauri/icons/256x256.png b/apps/desktop/src-tauri/icons/256x256.png new file mode 100644 index 0000000..94b0565 Binary files /dev/null and b/apps/desktop/src-tauri/icons/256x256.png differ diff --git a/apps/desktop/src-tauri/icons/32x32.png b/apps/desktop/src-tauri/icons/32x32.png index 7ab5a21..e23bb22 100644 Binary files a/apps/desktop/src-tauri/icons/32x32.png and b/apps/desktop/src-tauri/icons/32x32.png differ diff --git a/apps/desktop/src-tauri/icons/512x512.png b/apps/desktop/src-tauri/icons/512x512.png new file mode 100644 index 0000000..d777d0c Binary files /dev/null and b/apps/desktop/src-tauri/icons/512x512.png differ diff --git a/apps/desktop/src-tauri/icons/64x64.png b/apps/desktop/src-tauri/icons/64x64.png index 9a5c66c..739fa50 100644 Binary files a/apps/desktop/src-tauri/icons/64x64.png and b/apps/desktop/src-tauri/icons/64x64.png differ diff --git a/apps/desktop/src-tauri/icons/Square107x107Logo.png b/apps/desktop/src-tauri/icons/Square107x107Logo.png index 3a67265..dff432a 100644 Binary files a/apps/desktop/src-tauri/icons/Square107x107Logo.png and b/apps/desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square142x142Logo.png b/apps/desktop/src-tauri/icons/Square142x142Logo.png index ca9d40b..9ef4d4e 100644 Binary files a/apps/desktop/src-tauri/icons/Square142x142Logo.png and b/apps/desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square150x150Logo.png b/apps/desktop/src-tauri/icons/Square150x150Logo.png index 9f4924f..fc75f02 100644 Binary files a/apps/desktop/src-tauri/icons/Square150x150Logo.png and b/apps/desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square284x284Logo.png b/apps/desktop/src-tauri/icons/Square284x284Logo.png index 0b666f0..fc27962 100644 Binary files a/apps/desktop/src-tauri/icons/Square284x284Logo.png and b/apps/desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square30x30Logo.png b/apps/desktop/src-tauri/icons/Square30x30Logo.png index f6d0149..c9ffa40 100644 Binary files a/apps/desktop/src-tauri/icons/Square30x30Logo.png and b/apps/desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square310x310Logo.png b/apps/desktop/src-tauri/icons/Square310x310Logo.png index e7eec54..5099088 100644 Binary files a/apps/desktop/src-tauri/icons/Square310x310Logo.png and b/apps/desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square44x44Logo.png b/apps/desktop/src-tauri/icons/Square44x44Logo.png index ca434bf..ed9c83e 100644 Binary files a/apps/desktop/src-tauri/icons/Square44x44Logo.png and b/apps/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square44x44Logo.targetsize-24_altform-unplated.png b/apps/desktop/src-tauri/icons/Square44x44Logo.targetsize-24_altform-unplated.png new file mode 100644 index 0000000..ed9c83e Binary files /dev/null and b/apps/desktop/src-tauri/icons/Square44x44Logo.targetsize-24_altform-unplated.png differ diff --git a/apps/desktop/src-tauri/icons/Square71x71Logo.png b/apps/desktop/src-tauri/icons/Square71x71Logo.png index 4cace3c..55d57ef 100644 Binary files a/apps/desktop/src-tauri/icons/Square71x71Logo.png and b/apps/desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/apps/desktop/src-tauri/icons/Square89x89Logo.png b/apps/desktop/src-tauri/icons/Square89x89Logo.png index b227042..1b24e42 100644 Binary files a/apps/desktop/src-tauri/icons/Square89x89Logo.png and b/apps/desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/apps/desktop/src-tauri/icons/StoreLogo.png b/apps/desktop/src-tauri/icons/StoreLogo.png index 0c8d12e..99630f3 100644 Binary files a/apps/desktop/src-tauri/icons/StoreLogo.png and b/apps/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png index 6177315..a1bd0fc 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png index 34a8a07..349bf97 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png index 77a3311..48b4bd1 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png and b/apps/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png index 8466930..cc79e87 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png index 85be220..cf8849c 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png and b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png index e51e297..5e50dcd 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png and b/apps/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png index 1e5119c..b5f6697 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png index 3470c23..9262141 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png index 03028f9..0af3e0f 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png and b/apps/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png index 51fc3f0..47cc488 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png index edbfbe9..5e9a9fd 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png index 4686d87..540b31c 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png and b/apps/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png index eb05e0c..fec4c96 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png index dc0df3c..07ba282 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png index 002ab19..c6acff9 100644 Binary files a/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and b/apps/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/apps/desktop/src-tauri/icons/icon.icns b/apps/desktop/src-tauri/icons/icon.icns index b51715a..f55d7c8 100644 Binary files a/apps/desktop/src-tauri/icons/icon.icns and b/apps/desktop/src-tauri/icons/icon.icns differ diff --git a/apps/desktop/src-tauri/icons/icon.ico b/apps/desktop/src-tauri/icons/icon.ico index cae568f..d73f964 100644 Binary files a/apps/desktop/src-tauri/icons/icon.ico and b/apps/desktop/src-tauri/icons/icon.ico differ diff --git a/apps/desktop/src-tauri/icons/icon.png b/apps/desktop/src-tauri/icons/icon.png index 28ae3d3..1a5c156 100644 Binary files a/apps/desktop/src-tauri/icons/icon.png and b/apps/desktop/src-tauri/icons/icon.png differ diff --git a/apps/desktop/src-tauri/icons/icon.svg b/apps/desktop/src-tauri/icons/icon.svg index 7ffb3b2..9bcc718 100644 --- a/apps/desktop/src-tauri/icons/icon.svg +++ b/apps/desktop/src-tauri/icons/icon.svg @@ -1,13 +1,5 @@ - - - - - - - - - API - Opsx - + + + diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png index ba91d70..c5b6cc1 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png index 9af249a..8ae6604 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png index 9af249a..8ae6604 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png index 88bd0da..e7fa7e3 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png index b04d76f..ede0898 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png index 3d4ca88..fc63825 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png index 3d4ca88..fc63825 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png index ddcdddf..e4b2321 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png index 9af249a..8ae6604 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png index 59a535f..f57f044 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png index 59a535f..f57f044 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png index 5bb0874..a2ff797 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png index 3f96515..bbe6c02 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png index 5bb0874..a2ff797 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png index 2735556..88783d8 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png index b043b83..47a8011 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png index eb659c7..ad0ac0b 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png index 4c43722..e1bde15 100644 Binary files a/apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and b/apps/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/apps/desktop/src-tauri/permissions/transport.toml b/apps/desktop/src-tauri/permissions/transport.toml new file mode 100644 index 0000000..8838124 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/transport.toml @@ -0,0 +1,4 @@ +[[permission]] +identifier = "transport" +description = "Allow HTTP/WebSocket native transport" +commands.allow = ["http_send", "ws_connect", "ws_send", "ws_close"] diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 33da137..4dcacda 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,6 +1,12 @@ +use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::sync::Mutex; use std::time::{Duration, Instant}; +use tauri::{AppHandle, Emitter, Manager, State}; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::Message; +use uuid::Uuid; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -32,10 +38,34 @@ struct HttpResult { } #[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] struct WsSpec { url: String, protocols: Option>, - headers: Option>, + #[serde(default)] + headers: HashMap, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct WsEvent { + session_id: String, + kind: String, + data: String, +} + +enum WsCmd { + Send(String), + Close, +} + +struct WsHandle { + tx: mpsc::UnboundedSender, +} + +#[derive(Default)] +struct WsState { + sessions: Mutex>, } fn validate_http_url(raw: &str) -> Result { @@ -44,13 +74,35 @@ fn validate_http_url(raw: &str) -> Result { "http" | "https" => {} _ => return Err("only http/https schemes are allowed".into()), } + match parsed.host_str().unwrap_or("").to_ascii_lowercase().as_str() { + "169.254.169.254" | "metadata.google.internal" => Err("blocked metadata host".into()), + _ => Ok(parsed), + } +} + +fn validate_ws_url(raw: &str) -> Result { + let mut parsed = url::Url::parse(raw).map_err(|e| format!("invalid URL: {e}"))?; + match parsed.scheme() { + "ws" | "wss" => {} + "http" => { + parsed + .set_scheme("ws") + .map_err(|_| "cannot set ws scheme".to_string())?; + } + "https" => { + parsed + .set_scheme("wss") + .map_err(|_| "cannot set wss scheme".to_string())?; + } + _ => return Err("only ws/wss schemes are allowed".into()), + } match parsed.host_str().unwrap_or("").to_ascii_lowercase().as_str() { "169.254.169.254" | "metadata.google.internal" => { return Err("blocked metadata host".into()) } _ => {} } - Ok(parsed) + Ok(parsed.to_string()) } #[tauri::command] @@ -71,8 +123,8 @@ async fn http_send(spec: HttpSpec) -> Result { .build() .map_err(|e| e.to_string())?; let mut request = client.request(method, url); - for (key, value) in spec.headers { - request = request.header(&key, &value); + for (key, value) in &spec.headers { + request = request.header(key, value); } if let Some(body) = spec.body { if body.mode != "none" { @@ -101,30 +153,135 @@ async fn http_send(spec: HttpSpec) -> Result { } #[tauri::command] -async fn ws_connect(spec: WsSpec) -> Result { - let _ = (&spec.protocols, &spec.headers); - // TODO(week2): hold WebSocket sessions in managed state and emit messages. - Err(format!( - "WebSocket native tunnel is not implemented yet for {} (use browser WS in MVP)", - spec.url - )) +async fn ws_connect( + app: AppHandle, + state: State<'_, WsState>, + spec: WsSpec, +) -> Result { + let url = validate_ws_url(&spec.url)?; + let session_id = Uuid::new_v4().to_string(); + + let mut req = http::Request::builder().method("GET").uri(&url); + for (k, v) in &spec.headers { + req = req.header(k.as_str(), v.as_str()); + } + if let Some(protocols) = &spec.protocols { + let joined = protocols + .iter() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect::>() + .join(", "); + if !joined.is_empty() { + req = req.header("Sec-WebSocket-Protocol", joined); + } + } + let request = req + .body(()) + .map_err(|e| format!("build ws request: {e}"))?; + + let (tx, mut rx) = mpsc::unbounded_channel::(); + { + let mut map = state.sessions.lock().map_err(|e| e.to_string())?; + map.insert(session_id.clone(), WsHandle { tx }); + } + + let sid = session_id.clone(); + let app2 = app.clone(); + tauri::async_runtime::spawn(async move { + let emit = |kind: &str, data: String| { + let _ = app2.emit( + "ws-event", + WsEvent { + session_id: sid.clone(), + kind: kind.to_string(), + data, + }, + ); + }; + + match tokio_tungstenite::connect_async(request).await { + Ok((ws, _)) => { + emit("status", "open".into()); + let (mut write, mut read) = ws.split(); + loop { + tokio::select! { + cmd = rx.recv() => { + match cmd { + Some(WsCmd::Send(data)) => { + if write.send(Message::Text(data.into())).await.is_err() { + emit("status", "error".into()); + break; + } + } + Some(WsCmd::Close) | None => { + let _ = write.close().await; + break; + } + } + } + msg = read.next() => { + match msg { + Some(Ok(Message::Text(t))) => emit("message", t.to_string()), + Some(Ok(Message::Binary(b))) => { + emit("message", format!("[binary {} bytes]", b.len())) + } + Some(Ok(Message::Close(_))) | None => break, + Some(Ok(_)) => {} + Some(Err(e)) => { + emit("message", format!("[ws error] {e}")); + emit("status", "error".into()); + break; + } + } + } + } + } + emit("status", "closed".into()); + } + Err(e) => { + emit("message", format!("[ws_connect error] {e}")); + emit("status", "error".into()); + emit("status", "closed".into()); + } + } + + if let Some(st) = app2.try_state::() { + if let Ok(mut map) = st.sessions.lock() { + map.remove(&sid); + } + } + }); + + Ok(session_id) } #[tauri::command] -async fn ws_send(session_id: String, data: String) -> Result<(), String> { - let _ = (session_id, data); - Err("WebSocket native send is a Week 2 stub".into()) +async fn ws_send(state: State<'_, WsState>, session_id: String, data: String) -> Result<(), String> { + let map = state.sessions.lock().map_err(|e| e.to_string())?; + let handle = map + .get(&session_id) + .ok_or_else(|| format!("unknown ws session {session_id}"))?; + handle + .tx + .send(WsCmd::Send(data)) + .map_err(|_| "ws session closed".to_string())?; + Ok(()) } #[tauri::command] -async fn ws_close(session_id: String) -> Result<(), String> { - let _ = session_id; +async fn ws_close(state: State<'_, WsState>, session_id: String) -> Result<(), String> { + let mut map = state.sessions.lock().map_err(|e| e.to_string())?; + if let Some(handle) = map.remove(&session_id) { + let _ = handle.tx.send(WsCmd::Close); + } Ok(()) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .manage(WsState::default()) .invoke_handler(tauri::generate_handler![ http_send, ws_connect, diff --git a/apps/server/cmd/opsxapi/main.go b/apps/server/cmd/opsxapi/main.go index 88f3fea..db5751c 100644 --- a/apps/server/cmd/opsxapi/main.go +++ b/apps/server/cmd/opsxapi/main.go @@ -3,17 +3,28 @@ package main import ( "log" "os" + "strings" "opsxapi/server/internal/server" ) func main() { - addr := os.Getenv("OPSXAPI_ADDR") - if addr == "" { - addr = ":8787" - } + addr := resolveListenAddr() log.Printf("OpsxAPI server listening on %s", addr) if err := server.NewRouter().Run(addr); err != nil { log.Fatal(err) } } + +// resolveListenAddr:默认仅本机 127.0.0.1:8787(Mock 本机优先)。 +// 显式对外:OPSXAPI_BIND_PUBLIC=1 或 OPSXAPI_ADDR=0.0.0.0:8787 / :8787。 +func resolveListenAddr() string { + if v := strings.TrimSpace(os.Getenv("OPSXAPI_ADDR")); v != "" { + return v + } + pub := strings.ToLower(strings.TrimSpace(os.Getenv("OPSXAPI_BIND_PUBLIC"))) + if pub == "1" || pub == "true" || pub == "yes" { + return ":8787" + } + return "127.0.0.1:8787" +} diff --git a/apps/server/config.example.yaml b/apps/server/config.example.yaml index f4d3a4a..1b46f22 100644 --- a/apps/server/config.example.yaml +++ b/apps/server/config.example.yaml @@ -1,7 +1,9 @@ server: - addr: ":8787" + # 默认仅本机;公网需显式 OPSXAPI_BIND_PUBLIC=1 或 OPSXAPI_ADDR=:8787 + addr: "127.0.0.1:8787" proxy: timeout_ms: 30000 # Environment overrides: -# OPSXAPI_ADDR=:8787 +# OPSXAPI_ADDR=127.0.0.1:8787 +# OPSXAPI_BIND_PUBLIC=1 # OPSXAPI_PROXY_TIMEOUT_MS=30000 diff --git a/apps/server/data/opsxapi.db-shm b/apps/server/data/opsxapi.db-shm index cb705a6..7a967bc 100644 Binary files a/apps/server/data/opsxapi.db-shm and b/apps/server/data/opsxapi.db-shm differ diff --git a/apps/server/data/opsxapi.db-wal b/apps/server/data/opsxapi.db-wal index b30bd1e..b521df8 100644 Binary files a/apps/server/data/opsxapi.db-wal and b/apps/server/data/opsxapi.db-wal differ diff --git a/apps/server/internal/api/api.go b/apps/server/internal/api/api.go index 4be826a..8a2d605 100644 --- a/apps/server/internal/api/api.go +++ b/apps/server/internal/api/api.go @@ -2,6 +2,7 @@ package api import ( + "net" "net/http" "strconv" "strings" @@ -43,6 +44,9 @@ func (a *API) Register(r *gin.Engine) { g.GET("/workspaces/:id", a.auth(a.handleGetWorkspace)) g.PUT("/workspaces/:id", a.auth(a.handlePutWorkspace)) g.PUT("/workspaces/:id/mock", a.auth(a.handlePublishMock)) + g.PUT("/mock/local", a.handlePublishLocalMock) + g.DELETE("/mock/local", a.handleRevokeLocalMock) + g.DELETE("/workspaces/:id/mock", a.auth(a.handleRevokeMock)) } /* ---------------- 中间件 ---------------- */ @@ -312,7 +316,8 @@ func (a *API) handlePublishMock(c *gin.Context, user *store.User) { return } var req struct { - Endpoints []mocksrv.Endpoint `json:"endpoints" binding:"required"` + Endpoints []mocksrv.Endpoint `json:"endpoints" binding:"required"` + TTLSeconds int64 `json:"ttlSeconds"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 endpoints"}) @@ -322,15 +327,39 @@ func (a *API) handlePublishMock(c *gin.Context, user *store.User) { c.JSON(http.StatusBadRequest, gin.H{"error": "单空间最多发布 200 个 Mock 接口"}) return } + if req.TTLSeconds < 0 || req.TTLSeconds > 30*24*3600 { + c.JSON(http.StatusBadRequest, gin.H{"error": "ttlSeconds 需在 0~2592000(30 天)之间"}) + return + } if err := a.store.ReplaceMockEndpoints(ws.ID, req.Endpoints); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "发布失败:" + err.Error()}) return } + var expiresAt int64 + if req.TTLSeconds > 0 { + expiresAt = time.Now().Unix() + req.TTLSeconds + } + if err := a.store.SetMockExpiry(ws.ID, expiresAt); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "设置 TTL 失败:" + err.Error()}) + return + } base := ws.MockToken if base == "" { - base = strconv.FormatInt(ws.ID, 10) // 兜底(不应出现) + base = strconv.FormatInt(ws.ID, 10) } - c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(req.Endpoints), "base": "/mock/" + base}) + c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(req.Endpoints), "base": "/mock/" + base, "expiresAt": expiresAt}) +} + +func (a *API) handleRevokeMock(c *gin.Context, user *store.User) { + ws := a.workspaceOf(c, user) + if ws == nil { + return + } + if _, err := a.store.RevokeMock(ws.ID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "作废失败:" + err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) } /* ---------------- 工作区同步 ---------------- */ @@ -385,3 +414,76 @@ func (a *API) handlePutWorkspace(c *gin.Context, user *store.User) { } c.JSON(http.StatusOK, gin.H{"updatedAt": now}) } + +/* ---------------- 本机联调 Mock(不绑账号) ---------------- */ + +func (a *API) handlePublishLocalMock(c *gin.Context) { + if !isLoopbackRequest(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "本机联调发布仅允许从 127.0.0.1 / ::1 调用"}) + return + } + var req struct { + Endpoints []mocksrv.Endpoint `json:"endpoints" binding:"required"` + TTLSeconds int64 `json:"ttlSeconds"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 endpoints"}) + return + } + if len(req.Endpoints) > 200 { + c.JSON(http.StatusBadRequest, gin.H{"error": "单空间最多发布 200 个 Mock 接口"}) + return + } + if req.TTLSeconds < 0 || req.TTLSeconds > 30*24*3600 { + c.JSON(http.StatusBadRequest, gin.H{"error": "ttlSeconds 需在 0~2592000(30 天)之间"}) + return + } + wsID, token, err := a.store.EnsureLocalMockWorkspace() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "本地 Mock 空间初始化失败:" + err.Error()}) + return + } + if err := a.store.ReplaceMockEndpoints(wsID, req.Endpoints); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "发布失败:" + err.Error()}) + return + } + var expiresAt int64 + if req.TTLSeconds > 0 { + expiresAt = time.Now().Unix() + req.TTLSeconds + } + if err := a.store.SetMockExpiry(wsID, expiresAt); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "设置 TTL 失败:" + err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "count": len(req.Endpoints), "base": "/mock/" + token, "token": token, "expiresAt": expiresAt}) +} + +func (a *API) handleRevokeLocalMock(c *gin.Context) { + if !isLoopbackRequest(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "本机联调作废仅允许从 127.0.0.1 / ::1 调用"}) + return + } + wsID, _, err := a.store.EnsureLocalMockWorkspace() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "本地 Mock 空间初始化失败:" + err.Error()}) + return + } + if _, err := a.store.RevokeMock(wsID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "作废失败:" + err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +func isLoopbackRequest(c *gin.Context) bool { + ip := net.ParseIP(c.ClientIP()) + if ip != nil && ip.IsLoopback() { + return true + } + host, _, err := net.SplitHostPort(c.Request.RemoteAddr) + if err != nil { + host = c.Request.RemoteAddr + } + ip = net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/apps/server/internal/server/router.go b/apps/server/internal/server/router.go index c93b742..5bf5a13 100644 --- a/apps/server/internal/server/router.go +++ b/apps/server/internal/server/router.go @@ -62,7 +62,7 @@ func localhostCORS() gin.HandlerFunc { c.Header("Access-Control-Allow-Origin", origin) c.Header("Vary", "Origin") c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, Cookie, X-Opsx-Probe, X-Opsx-Cookie") - c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") } if c.Request.Method == http.MethodOptions { c.AbortWithStatus(http.StatusNoContent) diff --git a/apps/server/internal/store/store.go b/apps/server/internal/store/store.go index 4ef3f8f..aa43efa 100644 --- a/apps/server/internal/store/store.go +++ b/apps/server/internal/store/store.go @@ -108,6 +108,7 @@ CREATE INDEX IF NOT EXISTS idx_workspaces_owner ON workspaces(owner_id); if _, err := s.db.Exec(`ALTER TABLE workspaces ADD COLUMN mock_token TEXT`); err == nil { _, _ = s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_mock_token ON workspaces(mock_token)`) } + _, _ = s.db.Exec(`ALTER TABLE workspaces ADD COLUMN mock_expires_at INTEGER NOT NULL DEFAULT 0`) // 老库补擅长列(幂等) _, _ = s.db.Exec(`ALTER TABLE team_members ADD COLUMN specialty TEXT NOT NULL DEFAULT ''`) rows, err := s.db.Query(`SELECT id FROM workspaces WHERE mock_token IS NULL OR mock_token = ''`) @@ -511,19 +512,114 @@ func (s *Store) FindMockEndpoint(workspaceID int64, method, path string) (*mocks return e, true, nil } -// ResolveMockToken:mock token → workspace id(不存在返回 false,不暴露信息) +// ResolveMockToken:mock token → workspace id(不存在 / 已过期 / 已作废 均返回 false) func (s *Store) ResolveMockToken(token string) (int64, bool, error) { var id int64 - err := s.db.QueryRow(`SELECT id FROM workspaces WHERE mock_token = ?`, token).Scan(&id) + var expiresAt int64 + err := s.db.QueryRow( + `SELECT id, IFNULL(mock_expires_at, 0) FROM workspaces WHERE mock_token = ? AND mock_token != ''`, + token, + ).Scan(&id, &expiresAt) if errors.Is(err, sql.ErrNoRows) { return 0, false, nil } if err != nil { return 0, false, err } + if expiresAt > 0 && time.Now().Unix() >= expiresAt { + return 0, false, nil + } return id, true, nil } +// SetMockExpiry 设置可选 TTL(expiresAtUnix=0 表示不过期)。 +func (s *Store) SetMockExpiry(workspaceID int64, expiresAtUnix int64) error { + _, err := s.db.Exec(`UPDATE workspaces SET mock_expires_at = ? WHERE id = ?`, expiresAtUnix, workspaceID) + return err +} + +// RevokeMock 作废当前 mock token:轮换新 token、清零过期、清空 endpoints;旧链接立刻失效。 +func (s *Store) RevokeMock(workspaceID int64) (newToken string, err error) { + newToken = newMockToken() + tx, err := s.db.Begin() + if err != nil { + return "", err + } + defer tx.Rollback() + if _, err := tx.Exec( + `UPDATE workspaces SET mock_token = ?, mock_expires_at = 0 WHERE id = ?`, + newToken, workspaceID, + ); err != nil { + return "", err + } + if _, err := tx.Exec(`DELETE FROM mock_endpoints WHERE workspace_id = ?`, workspaceID); err != nil { + return "", err + } + if err := tx.Commit(); err != nil { + return "", err + } + return newToken, nil +} + +const localMockWorkspaceName = "__opsxapi_local_mock__" +const localMockUserEmail = "__local_mock__@opsxapi.internal" + +// ensureLocalMockOwner 系统账号,仅作 FK 锚点,不出现在登录流程。 +func (s *Store) ensureLocalMockOwner() (int64, error) { + var id int64 + err := s.db.QueryRow(`SELECT id FROM users WHERE email = ?`, localMockUserEmail).Scan(&id) + if err == nil { + return id, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return 0, err + } + res, err := s.db.Exec( + `INSERT INTO users(name, email, pass_hash, created_at) VALUES(?,?,?,?)`, + "本机联调", localMockUserEmail, hashPassword(newMockToken()), time.Now().Unix(), + ) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// EnsureLocalMockWorkspace 为本机联调预留的系统空间(不绑真实用户账号)。 +func (s *Store) EnsureLocalMockWorkspace() (id int64, token string, err error) { + err = s.db.QueryRow( + `SELECT id, IFNULL(mock_token,'') FROM workspaces WHERE name = ? AND team_id IS NULL LIMIT 1`, + localMockWorkspaceName, + ).Scan(&id, &token) + if err == nil { + if token == "" { + token = newMockToken() + if _, e := s.db.Exec(`UPDATE workspaces SET mock_token = ? WHERE id = ?`, token, id); e != nil { + return 0, "", e + } + } + return id, token, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return 0, "", err + } + ownerID, err := s.ensureLocalMockOwner() + if err != nil { + return 0, "", err + } + token = newMockToken() + now := time.Now().Unix() + res, err := s.db.Exec( + `INSERT INTO workspaces(name, team_id, owner_id, data, updated_at, mock_token) VALUES(?,NULL,?,'',?,?)`, + localMockWorkspaceName, ownerID, now, token, + ) + if err != nil { + return 0, "", err + } + id, err = res.LastInsertId() + return id, token, err +} + + func (s *Store) SaveWorkspace(wsID int64, data string) (int64, error) { now := time.Now().Unix() _, err := s.db.Exec(`UPDATE workspaces SET data = ?, updated_at = ? WHERE id = ?`, data, now, wsID) diff --git a/apps/web/public/design-proxy-layers.html b/apps/web/public/design-proxy-layers.html new file mode 100644 index 0000000..1835e4e --- /dev/null +++ b/apps/web/public/design-proxy-layers.html @@ -0,0 +1,57 @@ + + + + + OpsxAPI · 路径 B 代理错误分层验收 + + + +

路径 B · ProxyErrorBanner 中文分层芯片

+
线框:代理 / 目标 / 鉴权 · unknown 中性灰 · 主按钮 / 次链接
+
+ + + + +
+ + diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg index 7e6d4c5..9bcc718 100644 --- a/apps/web/public/favicon.svg +++ b/apps/web/public/favicon.svg @@ -1,15 +1,5 @@ - - - - - - - - - - - - - - + + + + diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 432e434..b36e278 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -199,7 +199,7 @@ border-bottom-color: var(--accent); } .ops-tab .ops-count { - @apply ml-1 rounded-full px-1.5 text-[10px]; + @apply ml-1 rounded-full px-1.5 text-[11px]; background: var(--hover); color: var(--text-dim); } @@ -232,7 +232,7 @@ .ops-method-HEAD { color: var(--m-HEAD); } .ops-method-OPTIONS { color: var(--m-OPTIONS); } .ops-method-badge { - @apply inline-block rounded px-1.5 py-0.5 text-[10px] font-bold tracking-wide; + @apply inline-block rounded px-1.5 py-0.5 text-[11px] font-bold tracking-wide; background: var(--hover); } @@ -352,7 +352,7 @@ .env-dialog-item:hover { background: var(--hover); color: var(--text); } .env-dialog-item.active { background: var(--active); color: var(--accent-strong); } .env-dialog-current { - @apply rounded-full px-1.5 py-0.5 text-[10px] font-semibold; + @apply rounded-full px-1.5 py-0.5 text-[11px] font-semibold; background: color-mix(in srgb, var(--success) 14%, transparent); color: var(--success); } @@ -442,9 +442,15 @@ } .set-toggle.on .set-toggle-dot { transform: translateX(18px); } - /* Mock 规则表(随右侧内容区整体滚动,表头吸附) */ + /* Mock 规则表:表内滚动,表头吸顶(避免整窗跟着 60+ 行狂滚) */ .set-group .ops-hint { margin: 10px 12px 0; } - .mock-rules { @apply mt-2; } + .mock-rules { + @apply mt-2; + max-height: min(42vh, 380px); + overflow-y: auto; + overscroll-behavior: contain; + border-top: 1px solid color-mix(in srgb, var(--border) 55%, transparent); + } .mock-rule-head, .mock-rule-row { display: grid; grid-template-columns: 88px 84px minmax(110px, 1fr) minmax(130px, 1.2fr) minmax(96px, 0.9fr) 44px 30px; @@ -453,7 +459,7 @@ padding: 4px 12px; } .mock-rule-head { - @apply sticky top-0 z-[1] text-[11px] font-semibold; + @apply sticky top-0 z-[2] text-[11px] font-semibold; color: var(--text-faint); background: var(--panel-2); border-bottom: 1px solid var(--border); @@ -483,7 +489,7 @@ .mock-cheat-item .ops-faint { flex: 1; min-width: 0; @apply truncate; } .mock-cheat-gen { max-width: 46%; - @apply truncate font-mono text-[10px]; + @apply truncate font-mono text-[11px]; color: var(--text-faint); } .mock-preview-value { @@ -538,6 +544,29 @@ border-radius: 2px; background: var(--accent); } + + /* 工作区角标:绿=本地,蓝=团队 */ + .rail-ws-badge { + position: absolute; + right: -2px; + bottom: -1px; + width: 8px; + height: 8px; + border-radius: 999px; + border: 1.5px solid var(--panel); + } + .rail-ws-badge.local { background: #22c55e; } + .rail-ws-badge.team { background: #3b82f6; } + .rail-ws-badge.cloud { background: #60a5fa; } + .rail-ws-dot { + width: 8px; + height: 8px; + border-radius: 999px; + flex-shrink: 0; + } + .rail-ws-dot.local { background: #22c55e; } + .rail-ws-dot.team { background: #3b82f6; } + .rail-item.rail-login { background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent-strong); @@ -602,7 +631,7 @@ } .member-row:hover { background: var(--panel-2); } .spec-chip { - @apply inline-flex shrink-0 items-center rounded-full border px-2 py-px text-[10px] font-medium; + @apply inline-flex shrink-0 items-center rounded-full border px-2 py-px text-[11px] font-medium; } .spec-preset { @apply rounded-full border px-2 py-0.5 text-[11px] transition; @@ -628,7 +657,7 @@ /* ---------- 账号 / 空间 ---------- */ .ops-user-avatar { - @apply grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full text-[10px] font-bold text-white; + @apply grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full text-[11px] font-bold text-white; background: linear-gradient(135deg, var(--accent), var(--accent-strong, var(--accent))); } @@ -758,3 +787,48 @@ outline: 1.5px dashed var(--accent); outline-offset: -2px; } + +/* === design-system tokens (v1) === */ +:root { + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 24px; + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --bg-app: var(--bg); + --bg-panel: var(--panel); + --bg-elevated: var(--panel-2); + --code-bg: var(--input-bg); + --toolbar-h: 42px; + --input-h: 32px; + --ws-out: #38bdf8; + --ws-in: #34d399; + --ws-sys: #94a3b8; + --ws-err: #fb7185; +} +.ops-toolbar { min-height: var(--toolbar-h); } +.ops-status-pill { + display: inline-flex; align-items: center; gap: 4px; + border-radius: 999px; padding: 2px 8px; font-size: 11px; font-weight: 600; + border: 1px solid var(--border); +} +.ops-status-2xx { color: var(--success); background: color-mix(in srgb, var(--success) 14%, transparent); border-color: color-mix(in srgb, var(--success) 35%, var(--border)); } +.ops-status-3xx { color: var(--accent-strong); background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); } +.ops-status-4xx { color: var(--warn); background: color-mix(in srgb, var(--warn) 14%, transparent); border-color: color-mix(in srgb, var(--warn) 35%, var(--border)); } +.ops-status-5xx { color: var(--danger); background: color-mix(in srgb, var(--danger) 14%, transparent); border-color: color-mix(in srgb, var(--danger) 35%, var(--border)); } +.ops-status-idle { color: var(--text-faint); } +.ops-msg-out { border-left: 2px solid var(--ws-out); } +.ops-msg-in { border-left: 2px solid var(--ws-in); } +.ops-msg-sys { border-left: 2px solid var(--ws-sys); } +.ops-msg-err { border-left: 2px solid var(--ws-err); } +.ops-filter-chip { + border: 1px solid var(--border); border-radius: 999px; padding: 2px 8px; font-size: 11px; color: var(--text-dim); + background: transparent; +} +.ops-filter-chip-active { color: var(--accent-strong); border-color: var(--accent); background: var(--accent-soft); } + +/* alias panel-2 */ +:root, :root[data-theme="dark"], :root[data-theme="light"] { --panel-2: var(--panel-2); --code-bg: var(--input-bg); } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index b10800e..98c2a36 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -23,6 +23,7 @@ export default defineConfig({ "/v1": { target: "http://127.0.0.1:8787", changeOrigin: true, + ws: true, }, "/health": { target: "http://127.0.0.1:8787", diff --git a/data/opsxapi.db b/data/opsxapi.db new file mode 100644 index 0000000..cc04c10 Binary files /dev/null and b/data/opsxapi.db differ diff --git a/data/opsxapi.db-shm b/data/opsxapi.db-shm new file mode 100644 index 0000000..ed08b3e Binary files /dev/null and b/data/opsxapi.db-shm differ diff --git a/data/opsxapi.db-wal b/data/opsxapi.db-wal new file mode 100644 index 0000000..4504b16 Binary files /dev/null and b/data/opsxapi.db-wal differ diff --git a/docs/ROADMAP_ACTIVE.md b/docs/ROADMAP_ACTIVE.md new file mode 100644 index 0000000..352d534 --- /dev/null +++ b/docs/ROADMAP_ACTIVE.md @@ -0,0 +1,25 @@ +# OpsxAPI 主动排期(项目经理) + +更新:2026-09-19 · 产品已确认顺序 + +## 本周目标 +验收回流清完 → 桌面 WS 点测闭环 → L1 日用体验。 + +## 本周可交付(负责人) +| 序 | 项 | Owner | 状态 | +|----|----|-------|------| +| P0-1 | 桌面原生 WS Header 真机点测 | 项目经理 + 测试 | GUI UP;窗口点测进行中 | +| P0-2 | 集合/环境/通道/脏点点击回归 | 测试主测 / 工程修 bug | 清单已交测试 | +| P0-3 | 历史重放成功路径 UX | 项目经理(工程) | 开发中:进行中/成功/失败 toast | +| P1 | 空态协议说明保持、勿回顶栏 | 工程 | TopBar 仅 tooltip;空态保留 | +| P1 | 文档/Mock 灰态「即将推出」 | 工程 | ActivityRail 已统一 | + +## L1 候选取舍(产品) +导入导出打磨 > 自动重连 > 断言脚本入口壳(只占位) + +## 原则 +不扩 L2 深做;代理/双端只修通不炫技。 + +## 已完成 +- L1 a/b/c;验收 P1 toast / P2 注释(测试复测通过) +- 桌面 GUI 进程 UP;带 Header WS 网络冒烟通过 diff --git a/docs/design-review/proxy-layers-zh.png b/docs/design-review/proxy-layers-zh.png new file mode 100644 index 0000000..a8e2d6c Binary files /dev/null and b/docs/design-review/proxy-layers-zh.png differ diff --git a/docs/design-review/proxy-layers.png b/docs/design-review/proxy-layers.png new file mode 100644 index 0000000..a8e2d6c Binary files /dev/null and b/docs/design-review/proxy-layers.png differ diff --git a/package-lock.json b/package-lock.json index 419fb2e..d074a02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "socket.io-client": "^4.8.3" }, "devDependencies": { - "@types/js-yaml": "^4.0.9" + "@types/js-yaml": "^4.0.9", + "playwright": "^1.55.0" }, "engines": { "node": ">=20" @@ -2537,6 +2538,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz", + "integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.55.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz", + "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", diff --git a/package.json b/package.json index e236b78..0b79410 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "socket.io-client": "^4.8.3" }, "devDependencies": { - "@types/js-yaml": "^4.0.9" + "@types/js-yaml": "^4.0.9", + "playwright": "^1.55.0" } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 346c479..d48889f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -40,6 +40,7 @@ export type { HistoryEntry, ThemeMode, WorkspaceSettings, + TransportMode, WorkspaceData, ImportResult, MockRule, diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 9f5a68c..46e2dce 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -258,10 +258,14 @@ export type MockSettings = { rules: MockRule[]; }; +export type TransportMode = "auto" | "gin-proxy" | "browser" | "tauri-native"; + export type WorkspaceSettings = { theme: ThemeMode; /** 请求超时(毫秒) */ timeoutMs: number; + /** 发信通道:auto 按环境探测 */ + transportMode?: TransportMode; mock: MockSettings; ai: AiSettings; }; diff --git a/packages/transport/src/ginProxy.ts b/packages/transport/src/ginProxy.ts index c419fc6..1bbdb3e 100644 --- a/packages/transport/src/ginProxy.ts +++ b/packages/transport/src/ginProxy.ts @@ -1,10 +1,10 @@ +import { wsSmart } from "./wsSmart"; import type { HttpResult, HttpSpec, Transport, WsSession, WsSpec } from "@opsxapi/core"; import { browserTransport } from "./browser"; /** * HTTP goes through Gin POST /v1/proxy/http. - * WS (MVP): falls back to browser WebSocket — custom headers require - * desktop/Gin tunnel later (Week 2). + * WS: no custom headers → browser WebSocket; with headers → Gin WS tunnel (wsSmart). */ export function ginProxyTransport(baseUrl: string): Transport { const root = baseUrl.replace(/\/$/, ""); @@ -24,12 +24,15 @@ export function ginProxyTransport(baseUrl: string): Transport { }, ws(spec: WsSpec): WsSession { - // TODO(week2): custom headers need desktop/Gin WS tunnel. - // For MVP, fall back to browser WebSocket (no custom headers). - console.warn( - "[opsxapi/transport] gin-proxy WS falls back to browser WebSocket; custom headers need desktop/Gin tunnel later", + const headers = spec.headers ?? {}; + if (Object.keys(headers).length === 0) { + return browserTransport.ws(spec); + } + // Custom handshake headers → Gin WS tunnel + return wsSmart( + { url: spec.url, headers, protocols: spec.protocols }, + { ginBase: root }, ); - return browserTransport.ws(spec); }, }; } diff --git a/packages/transport/src/index.ts b/packages/transport/src/index.ts index 28efa36..178f52f 100644 --- a/packages/transport/src/index.ts +++ b/packages/transport/src/index.ts @@ -1,6 +1,6 @@ export { browserTransport } from "./browser"; export { ginProxyTransport } from "./ginProxy"; -export { tauriNativeTransport } from "./tauriNative"; +export { tauriNativeTransport, isTauriRuntime } from "./tauriNative"; export { detectTransport } from "./detect"; export type { TransportKind, DetectOptions } from "./detect"; diff --git a/packages/transport/src/tauriNative.ts b/packages/transport/src/tauriNative.ts index 518514f..55f377f 100644 --- a/packages/transport/src/tauriNative.ts +++ b/packages/transport/src/tauriNative.ts @@ -12,6 +12,17 @@ async function invoke(cmd: string, args?: Record): Promise { return invoke("http_send", { spec }); @@ -21,20 +32,37 @@ export const tauriNativeTransport: Transport = { const messageCbs: Array<(data: string) => void> = []; const statusCbs: Array<(s: string) => void> = []; let sessionId: string | null = null; + let unlisten: (() => void) | null = null; + let closed = false; const emitStatus = (s: string) => statusCbs.forEach((cb) => cb(s)); void (async () => { try { emitStatus("connecting"); + const { listen } = await import("@tauri-apps/api/event"); + unlisten = await listen("ws-event", (ev) => { + const p = ev.payload; + if (!sessionId || p.sessionId !== sessionId) return; + if (p.kind === "message") { + messageCbs.forEach((cb) => cb(p.data)); + return; + } + if (p.kind === "status") { + emitStatus(p.data); + if (p.data === "closed" || p.data === "error") { + // keep listening until explicit close() + } + } + }); sessionId = await invoke("ws_connect", { spec }); - emitStatus("open"); - // Full event listen pipeline is Week 2; stub keeps UI usable. + // open status also arrives via event; emit early for snappy UI } catch (e) { emitStatus("error"); messageCbs.forEach((cb) => cb(`[ws_connect error] ${e instanceof Error ? e.message : String(e)}`), ); + emitStatus("closed"); } })(); @@ -57,11 +85,17 @@ export const tauriNativeTransport: Transport = { }); }, close() { - if (!sessionId) return; - void invoke("ws_close", { sessionId }).finally(() => { - sessionId = null; + if (closed) return; + closed = true; + const id = sessionId; + sessionId = null; + unlisten?.(); + unlisten = null; + if (!id) { emitStatus("closed"); - }); + return; + } + void invoke("ws_close", { sessionId: id }).finally(() => emitStatus("closed")); }, }; }, diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index b13442d..fe5c96a 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -36,6 +36,8 @@ export function OpsxWorkspace() { const collections = useWorkspace((s) => s.collections); const activeTabId = useWorkspace((s) => s.activeTabId); const dialog = useWorkspace((s) => s.dialog); + const toast = useWorkspace((s) => s.toast); + const clearToast = useWorkspace((s) => s.clearToast); const openTab = useWorkspace((s) => s.openTab); const view = useWorkspace((s) => s.view); @@ -110,6 +112,34 @@ export function OpsxWorkspace() { })()} {dialog?.type === "auth" && } {dialog?.type === "team" && } + + {toast && ( +
clearToast()} + style={{ + position: "fixed", + bottom: 24, + left: "50%", + transform: "translateX(-50%)", + zIndex: 80, + maxWidth: 420, + padding: "10px 14px", + borderRadius: 10, + border: "1px solid var(--border)", + background: "var(--panel)", + color: "var(--text)", + boxShadow: "0 8px 24px rgba(0,0,0,.35)", + fontSize: 13, + cursor: "pointer", + }} + > + {toast.message} +
+ )} + ); } diff --git a/packages/ui/src/account.ts b/packages/ui/src/account.ts index 54b72f6..9f31ae0 100644 --- a/packages/ui/src/account.ts +++ b/packages/ui/src/account.ts @@ -111,13 +111,38 @@ export type MockEndpointPayload = { delayMs: number; }; -export function publishMock(token: string, workspaceId: number, endpoints: MockEndpointPayload[]) { - return call<{ ok: boolean; count: number; base: string }>(`/workspaces/${workspaceId}/mock`, token, { +export function publishMock( + token: string, + workspaceId: number, + endpoints: MockEndpointPayload[], + ttlSeconds = 0, +) { + return call<{ ok: boolean; count: number; base: string; expiresAt: number }>( + `/workspaces/${workspaceId}/mock`, + token, + { + method: "PUT", + body: JSON.stringify({ endpoints, ttlSeconds }), + }, + ); +} + +/** 本机联调发布:不绑账号,仅本机 Gin 接受。 */ +export function publishLocalMock(endpoints: MockEndpointPayload[], ttlSeconds = 0) { + return call<{ ok: boolean; count: number; base: string; token: string; expiresAt: number }>("/mock/local", null, { method: "PUT", - body: JSON.stringify({ endpoints }), + body: JSON.stringify({ endpoints, ttlSeconds }), }); } +export function revokeMock(token: string, workspaceId: number) { + return call<{ ok: boolean }>(`/workspaces/${workspaceId}/mock`, token, { method: "DELETE" }); +} + +export function revokeLocalMock() { + return call<{ ok: boolean }>("/mock/local", null, { method: "DELETE" }); +} + export function saveWorkspace(token: string, id: number, data: string) { return call<{ updatedAt: number }>(`/workspaces/${id}`, token, { method: "PUT", diff --git a/packages/ui/src/components/ActivityRail.tsx b/packages/ui/src/components/ActivityRail.tsx index 57a7f1f..9d86ac1 100644 --- a/packages/ui/src/components/ActivityRail.tsx +++ b/packages/ui/src/components/ActivityRail.tsx @@ -1,17 +1,15 @@ /** - * 最左活动栏(借鉴 Apifox):空间切换(本地/个人/团队)+ 主导航 + 底部账号与工具。 - * 顶栏因此只保留与当前编辑相关的操作。 + * 最左活动栏:主导航 + 底部账号。 + * 本地/团队工作区只在底部登录/头像菜单切换(设计师线框 B);不再显示顶部「本」字标。 */ import { useState } from "react"; import { useWorkspace } from "../workspaceStore"; import { ContextMenu, cx, type MenuItem } from "./common"; -import { EnvBadge } from "./EnvBadge"; import { Icon, type IconName } from "./icons"; export function ActivityRail() { const view = useWorkspace((s) => s.view); const setView = useWorkspace((s) => s.setView); - const sidebarSide = useWorkspace((s) => s.sidebarSide); const setSidebarSide = useWorkspace((s) => s.setSidebarSide); const account = useWorkspace((s) => s.account); const switchSpace = useWorkspace((s) => s.switchSpace); @@ -19,46 +17,13 @@ export function ActivityRail() { const syncNow = useWorkspace((s) => s.syncNow); const setDialog = useWorkspace((s) => s.setDialog); - const [spaceMenu, setSpaceMenu] = useState<{ x: number; y: number } | null>(null); const [accountMenu, setAccountMenu] = useState<{ x: number; y: number } | null>(null); const activeSpace = account.spaces.find((sp) => sp.id === account.activeSpaceId) ?? null; - const spaceName = activeSpace ? `${activeSpace.teamId !== null ? "团队 · " : ""}${activeSpace.name}` : "本地空间"; - - const spaceItems: MenuItem[] = [ - { - label: ( - - - 本地空间 - {account.activeSpaceId === null && ● 当前} - - ), - onClick: () => void switchSpace(null), - }, - ...account.spaces.map((sp) => ({ - label: ( - - - {sp.teamId !== null ? `团队 · ${sp.name}` : sp.name} - {sp.id === account.activeSpaceId ? ( - ● 当前 - ) : ( - - {new Date(sp.updatedAt * 1000).toLocaleDateString("zh-CN")} - - )} - - ), - onClick: () => void switchSpace(sp.id), - })), - { type: "sep" as const }, - { - label: "团队管理…", - icon: , - onClick: () => setDialog({ type: "team" }), - }, - ]; + const isTeam = activeSpace?.teamId != null; + const spaceName = activeSpace + ? `${isTeam ? "团队 · " : ""}${activeSpace.name}` + : "本地工作区"; const siteItem: MenuItem = { label: ( @@ -71,8 +36,54 @@ export function ActivityRail() { onClick: () => window.open("/home", "_blank", "noopener,noreferrer"), }; + const workspaceItems: MenuItem[] = [ + { + label: ( + + + 本地工作区 + {account.activeSpaceId === null && ( + ● 当前 + )} + + ), + onClick: () => void switchSpace(null), + }, + ...account.spaces.map((sp) => { + const team = sp.teamId != null; + return { + label: ( + + + {team ? `团队 · ${sp.name}` : sp.name} + {sp.id === account.activeSpaceId ? ( + ● 当前 + ) : ( + + {new Date(sp.updatedAt * 1000).toLocaleDateString("zh-CN")} + + )} + + ), + onClick: () => void switchSpace(sp.id), + }; + }), + ]; + const accountItems: MenuItem[] = account.user ? [ + ...workspaceItems, + { type: "sep" as const }, + { + label: "环境变量", + icon: , + onClick: () => setDialog({ type: "environments" }), + }, + { + label: "账号设置", + icon: , + onClick: () => setDialog({ type: "settings" }), + }, ...(account.activeSpaceId !== null ? [ { @@ -94,7 +105,7 @@ export function ActivityRail() { { type: "sep" as const }, siteItem, { - label: "退出登录", + label: "退出", icon: , onClick: () => void authLogout(), }, @@ -106,42 +117,44 @@ export function ActivityRail() { onClick: () => setDialog({ type: "auth" }), }, { type: "sep" as const }, + { + label: "本地工作区", + icon: , + onClick: () => void switchSpace(null), + }, + { + label: "环境变量", + icon: , + onClick: () => setDialog({ type: "environments" }), + }, + { type: "sep" as const }, siteItem, ]; - const navs: { id: string; icon: IconName; label: string; active: boolean; onClick: () => void }[] = [ + const navs: { id: string; icon: IconName; label: string; active: boolean; onClick: () => void; soon?: boolean }[] = [ { id: "api", icon: "globe", label: "接口", - active: view === "api" && sidebarSide === "api", + active: view === "api", onClick: () => { setView("api"); setSidebarSide("api"); }, }, - { - id: "history", - icon: "clock", - label: "历史", - active: view === "api" && sidebarSide === "history", - onClick: () => { - setView("api"); - setSidebarSide("history"); - }, - }, { id: "testing", icon: "list-checks", label: "测试", active: view === "testing", onClick: () => setView("testing"), + soon: true, }, ]; const syncTitle = !account.user - ? "登录 / 注册,开启云端个人与团队空间" - : `${account.user.name}(${account.user.email})\n当前空间:${spaceName}${ + ? "登录后可切换团队工作区;未登录也可继续用本地" + : `${account.user.name}(${account.user.email})\n当前:${spaceName}${ account.syncing ? "\n同步中…" : account.syncError @@ -156,62 +169,43 @@ export function ActivityRail() { className="ops-panel flex w-14 shrink-0 flex-col items-center gap-1 py-2" style={{ borderRight: "1px solid var(--border)" }} > - {/* 空间切换(本地 / 个人 / 团队组织);徽标统一用彩色首字标,与顶栏品牌 Logo 区分 */} - - -
- - {/* 主导航 */} + {/* 主导航:不再放顶部「本」字标 / 空间切换 */} {navs.map((n) => ( - ))}
- {/* 底部:文档 / 设置 / 账号(主题切换在 设置 → 外观,低频操作不占栏位) */} - + - 文档 + 文档 - setSpaceMenu(null)} - /> setAccountMenu(null)} diff --git a/packages/ui/src/components/NewMenu.tsx b/packages/ui/src/components/NewMenu.tsx index 4e2c5f2..a86d7a9 100644 --- a/packages/ui/src/components/NewMenu.tsx +++ b/packages/ui/src/components/NewMenu.tsx @@ -65,7 +65,7 @@ export function NewMenu({ {KIND_COLORS[p.kind].label} - {p.desc} + {p.desc} ))} @@ -80,7 +80,7 @@ export function NewMenu({ > 新建目录 - 接口分组 + 接口分组
); diff --git a/packages/ui/src/components/ProxyErrorBanner.tsx b/packages/ui/src/components/ProxyErrorBanner.tsx new file mode 100644 index 0000000..4b73948 --- /dev/null +++ b/packages/ui/src/components/ProxyErrorBanner.tsx @@ -0,0 +1,197 @@ +/** + * 代理错误分层条:proxy | target | auth(产品/设计 P0 不砍) + * 线框:中文 LayerTag 芯片「代理 / 目标 / 鉴权」;unknown 中性灰 + */ +import { useWorkspace } from "../workspaceStore"; +import { cx } from "./common"; + +export type ProxyErrorLayer = "proxy" | "target" | "auth" | "unknown"; + +export type ClassifiedError = { + layer: ProxyErrorLayer; + title: string; + detail: string; + actions: { label: string; onClick: () => void; primary?: boolean }[]; +}; + +const LAYER_LABEL: Record = { + proxy: "代理", + target: "目标", + auth: "鉴权", + unknown: "未知", +}; + +export function classifyRequestError(message: string): ClassifiedError { + const m = message.toLowerCase(); + + if ( + m.includes("proxy") || + m.includes("econnrefused") || + m.includes("8787") || + m.includes("failed to fetch") || + m.includes("networkerror") || + m.includes("gin-proxy") || + m.includes("load failed") + ) { + return { + layer: "proxy", + title: "代理层失败", + detail: message, + actions: [ + { + label: "检查本地代理", + primary: true, + onClick: () => window.open("http://127.0.0.1:8787/health", "_blank"), + }, + { + label: "切换桌面直连", + primary: true, + onClick: () => { + useWorkspace.getState().patchSettings({ transportMode: "tauri-native" }); + }, + }, + { + label: "查看通道说明", + onClick: () => useWorkspace.getState().setDialog({ type: "settings" }), + }, + ], + }; + } + + if ( + m.includes("401") || + m.includes("403") || + m.includes("unauthorized") || + m.includes("forbidden") || + m.includes("token") || + m.includes("鉴权") || + (m.includes("auth") && !m.includes("author")) + ) { + return { + layer: "auth", + title: "鉴权层失败", + detail: message, + actions: [ + { + label: "检查 Token / Auth", + primary: true, + onClick: () => { + useWorkspace.getState().setDialog({ type: "environments" }); + }, + }, + ], + }; + } + + if ( + m.includes("timeout") || + m.includes("enotfound") || + m.includes("404") || + m.includes("502") || + m.includes("503") || + m.includes("ssl") || + m.includes("certificate") || + m.includes("target") || + m.includes("dns") + ) { + return { + layer: "target", + title: "目标服务失败", + detail: message, + actions: [ + { + label: "检查 BaseURL / VPN", + primary: true, + onClick: () => useWorkspace.getState().setDialog({ type: "environments" }), + }, + ], + }; + } + + return { + layer: "unknown", + title: "请求失败(请按层排查)", + detail: message, + actions: [ + { + label: "查代理 :8787", + primary: true, + onClick: () => window.open("http://127.0.0.1:8787/health", "_blank"), + }, + { + label: "切换桌面直连", + primary: true, + onClick: () => useWorkspace.getState().patchSettings({ transportMode: "tauri-native" }), + }, + { + label: "查环境 / BaseURL", + onClick: () => useWorkspace.getState().setDialog({ type: "environments" }), + }, + ], + }; +} + +const BANNER_STYLE: Record = { + proxy: "border-amber-500/40 bg-amber-500/10 text-amber-50", + target: "border-sky-500/40 bg-sky-500/10 text-sky-50", + auth: "border-rose-500/40 bg-rose-500/10 text-rose-50", + unknown: "border-zinc-500/40 bg-zinc-500/10 text-zinc-100", +}; + +const TAG_STYLE: Record = { + proxy: "bg-amber-500/25 text-amber-100 ring-amber-400/40", + target: "bg-sky-500/25 text-sky-100 ring-sky-400/40", + auth: "bg-rose-500/25 text-rose-100 ring-rose-400/40", + unknown: "bg-zinc-500/30 text-zinc-200 ring-zinc-400/40", +}; + +function LayerTag({ layer }: { layer: ProxyErrorLayer }) { + return ( + + {LAYER_LABEL[layer]} + + ); +} + +export function ProxyErrorBanner({ message, onDismiss }: { message: string; onDismiss?: () => void }) { + const info = classifyRequestError(message); + return ( +
+
+
+ + {info.title} +
+ {onDismiss && ( + + )} +
+
{info.detail}
+
+ {info.actions.map((a) => + a.primary ? ( + + ) : ( + + ), + )} +
+
+ ); +} diff --git a/packages/ui/src/components/Sidebar.tsx b/packages/ui/src/components/Sidebar.tsx index ae200da..4ae08c6 100644 --- a/packages/ui/src/components/Sidebar.tsx +++ b/packages/ui/src/components/Sidebar.tsx @@ -1,3 +1,4 @@ +import { replayHistory } from "../send"; /** * 左侧集合树:搜索 / 目录 / 请求 / 右键菜单 / 拖拽移动 / 历史记录。 */ @@ -17,7 +18,7 @@ export function Sidebar() { const history = useWorkspace((s) => s.history); const clearHistory = useWorkspace((s) => s.clearHistory); const openTab = useWorkspace((s) => s.openTab); - // 由活动栏驱动:接口树 / 历史记录 两个展示面共用本侧栏 + // 接口树 / 历史在侧栏分段切换;活动栏只保留「接口」入口,避免语义叠层 const side = useWorkspace((s) => s.sidebarSide); const setSide = useWorkspace((s) => s.setSidebarSide); const [menu, setMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null); @@ -57,23 +58,36 @@ export function Sidebar() {
)} {history.slice(0, 50).map((h) => ( - + {h.nodeId && ( + )} - + ))} {history.length > 0 && ( + + {newMenu && ( @@ -145,10 +106,6 @@ export function TopBar() { onClose={() => setNewMenu(null)} /> )} - setEnvMenu(null)} - /> void; @@ -260,6 +261,8 @@ export function KVEditor({ valuePlaceholder?: string; withDescription?: boolean; compact?: boolean; + /** 空表时的引导文案(如「添加鉴权 Header」) */ + emptyHint?: string; }) { const update = (id: string, patch: Partial) => { if (onUpdate) onUpdate(id, patch); @@ -328,6 +331,15 @@ export function KVEditor({ ))} + {rows.length === 0 && emptyHint && ( +
+ {emptyHint} +
+ )} diff --git a/packages/ui/src/components/dialogs/AccountDialogs.tsx b/packages/ui/src/components/dialogs/AccountDialogs.tsx index cf352f3..5fd0b68 100644 --- a/packages/ui/src/components/dialogs/AccountDialogs.tsx +++ b/packages/ui/src/components/dialogs/AccountDialogs.tsx @@ -387,7 +387,7 @@ export function TeamDialog() {
{t.name} - + {t.role === "owner" ? "创建者" : "成员"} @@ -468,17 +468,17 @@ export function TeamDialog() { ) : ( <> - + {m.name.slice(0, 1).toUpperCase()} {m.name} - {m.userId === me?.id && (我)} + {m.userId === me?.id && (我)} - {m.email} + {m.email} - + {m.role === "owner" ? "创建者" : "成员"} @@ -486,12 +486,12 @@ export function TeamDialog() { ))} {splitSpecialty(m.specialty).length > 3 && ( - + +{splitSpecialty(m.specialty).length - 3} )} {splitSpecialty(m.specialty).length === 0 && ( - 未设置擅长 + 未设置擅长 )} {canEdit && ( @@ -529,7 +529,7 @@ export function TeamDialog() { {(inviteEmail[t.id] ?? "").trim() !== "" && (
- 擅长: + 擅长: {SPECIALTY_PRESETS.map((p) => { const picked = splitSpecialty(inviteSpecialty[t.id] ?? "").includes(p); return ( diff --git a/packages/ui/src/components/dialogs/AiCaseDialog.tsx b/packages/ui/src/components/dialogs/AiCaseDialog.tsx index 84ddc9a..8035696 100644 --- a/packages/ui/src/components/dialogs/AiCaseDialog.tsx +++ b/packages/ui/src/components/dialogs/AiCaseDialog.tsx @@ -165,7 +165,7 @@ export function AiCaseDialog({ onClose, onSaved }: { onClose: () => void; onSave {i + 1}
{stepName(s.nodeId)}
-
+
{s.assertions.length} 条断言 {s.extracts.length > 0 && ` · 提取 ${s.extracts.map((e) => e.varName).join(", ")}`}
diff --git a/packages/ui/src/components/dialogs/MockLinkDialog.tsx b/packages/ui/src/components/dialogs/MockLinkDialog.tsx index b8a9b3e..3e0977b 100644 --- a/packages/ui/src/components/dialogs/MockLinkDialog.tsx +++ b/packages/ui/src/components/dialogs/MockLinkDialog.tsx @@ -1,15 +1,34 @@ /** - * Mock 链接弹窗:把当前接口发布到本地 Mock 服务(/mock/{空间ID}{路径}), - * 发布后可复制 URL、浏览器试一试;响应体支持 @表达式按次随机生成。 + * Mock 本机联调弹窗:发布到本机 Mock 服务(/mock/{token}{路径})。 + * 默认不绑云端账号;响应体支持 @表达式按次随机生成。 */ import { smartFillJson } from "@opsxapi/core"; import type { HttpApiNode, MockSettings } from "@opsxapi/core"; -import { useState } from "react"; -import { publishMock } from "../../account"; +import { useEffect, useState } from "react"; +import { publishLocalMock, publishMock, revokeLocalMock, revokeMock } from "../../account"; import { useWorkspace } from "../../workspaceStore"; import { Modal, cx } from "../common"; import { Icon } from "../icons"; +const LOCAL_TOKEN_KEY = "opsxapi.localMockToken.v1"; + +function loadLocalToken(): string { + try { + return localStorage.getItem(LOCAL_TOKEN_KEY) || ""; + } catch { + return ""; + } +} + +function saveLocalToken(token: string) { + try { + if (!token) localStorage.removeItem(LOCAL_TOKEN_KEY); + else localStorage.setItem(LOCAL_TOKEN_KEY, token); + } catch { + /* ignore */ + } +} + export function MockLinkDialog({ node }: { node: HttpApiNode }) { const account = useWorkspace((s) => s.account); const settings = useWorkspace((s) => s.settings); @@ -17,16 +36,17 @@ export function MockLinkDialog({ node }: { node: HttpApiNode }) { const setDialog = useWorkspace((s) => s.setDialog); const space = account.spaces.find((sp) => sp.id === account.activeSpaceId) ?? null; + const useCloud = !!(account.token && space); + const [localToken, setLocalToken] = useState(loadLocalToken); const [status, setStatus] = useState(200); const [delayMs, setDelayMs] = useState(0); const [busy, setBusy] = useState(false); + const [ttlSeconds, setTtlSeconds] = useState(0); const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null); - // 路径:取请求 URL 的 path 部分(前置 URL 模式下即相对路径) const urlPath = extractPath(node.data.url); const method = node.data.method; - // 模板:JSON Body(智能填充烘焙 @表达式);非 JSON 用空对象 let template = "{}"; if (node.data.bodyMode === "json" && node.data.bodyRaw.trim()) { template = node.data.bodyRaw; @@ -36,24 +56,34 @@ export function MockLinkDialog({ node }: { node: HttpApiNode }) { } } - const mockSeg = space?.mockToken ?? (space ? String(space.id) : ""); + const mockSeg = useCloud + ? space?.mockToken ?? (space ? String(space.id) : "") + : localToken; const mockUrl = mockSeg ? `${location.origin.replace(/:\d+$/, ":8787")}/mock/${mockSeg}${urlPath}` : ""; + useEffect(() => { + setLocalToken(loadLocalToken()); + }, []); + const publish = async () => { - if (!account.token || !space) return; setBusy(true); setResult(null); try { - // 全量发布:集合内全部 HTTP 接口(同空间共用一组 Mock 端点) const endpoints = collectEndpoints(collections, settings); - // 当前接口应用弹窗选择的状态码与延迟 const mine = endpoints.find((e) => e.method === method && e.path === urlPath); if (mine) { mine.status = status; mine.delayMs = delayMs; } - await publishMock(account.token, space.id, endpoints); - setResult({ ok: true, message: `已发布 ${endpoints.length} 个接口到 /mock/${space.id}` }); + if (useCloud && account.token && space) { + await publishMock(account.token, space.id, endpoints, ttlSeconds); + setResult({ ok: true, message: `已发布 ${endpoints.length} 个接口到本机联调(云端空间)` }); + } else { + const r = await publishLocalMock(endpoints, ttlSeconds); + saveLocalToken(r.token); + setLocalToken(r.token); + setResult({ ok: true, message: `已发布 ${endpoints.length} 个接口到本机联调(本地空间)` }); + } } catch (e) { setResult({ ok: false, message: e instanceof Error ? e.message : String(e) }); } finally { @@ -62,7 +92,10 @@ export function MockLinkDialog({ node }: { node: HttpApiNode }) { }; const preview = async () => { - if (!mockUrl) return; + if (!mockUrl) { + setResult({ ok: false, message: "请先发布,生成本机联调地址" }); + return; + } setBusy(true); setResult(null); try { @@ -76,19 +109,41 @@ export function MockLinkDialog({ node }: { node: HttpApiNode }) { } }; + const revoke = async () => { + setBusy(true); + setResult(null); + try { + if (useCloud && account.token && space) { + await revokeMock(account.token, space.id); + } else { + await revokeLocalMock(); + saveLocalToken(""); + setLocalToken(""); + } + setResult({ ok: true, message: "已作废:旧链接立即失效,需重新发布才可用" }); + } catch (e) { + setResult({ ok: false, message: e instanceof Error ? e.message : String(e) }); + } finally { + setBusy(false); + } + }; + return ( setDialog(null)} width="620px" footer={
- 本地 Mock 服务 · 限流 20 QPS / 60 QPM(超限 429) + 仅本机 · 限流 20 QPS / 60 QPM(超限 429)
- + -
@@ -96,83 +151,84 @@ export function MockLinkDialog({ node }: { node: HttpApiNode }) { } >
- {!account.user ? ( -
-
- - Mock 链接挂在云端空间下(按空间隔离),请先登录并切换到个人 / 团队空间。 -
-
- ) : !space ? ( -
- - 当前在本地空间。请通过顶栏账号胶囊切换到「我的空间」或团队空间后再发布 Mock。 +
+ {method} {urlPath} · 非方法可用 ?_method={method} 测试 · 发布包含当前集合全部 HTTP 接口
- ) : ( - <> -
- -
- e.currentTarget.select()} /> - - {method === "GET" && ( - - - - )} -
-
- {method} {urlPath} · 非方法可用 ?_method={method} 测试 · 发布包含当前空间集合内全部 HTTP 接口 -
-
+
-
-
- - -
-
- - setDelayMs(Math.max(0, Math.min(5000, Number(e.target.value) || 0)))} - /> -
-
-
- -