4 Commits

Author SHA1 Message Date
musi
5cd21c570f Merge pull request #798 from SaseQ/main
Add ccr logo and badges (README.md edit)
2025-09-10 20:39:37 +08:00
SaseQ
c5e97709a5 Add ccr logo and badges (README.md edit) 2025-09-10 14:32:02 +02:00
musistudio
f7adb7b28e release v1.0.49 2025-09-09 22:43:01 +08:00
musistudio
7964fff175 release v1.0.48 2025-09-09 21:47:59 +08:00
21 changed files with 203 additions and 1185 deletions

View File

@@ -1,10 +1,13 @@
# Claude Code Router
![](blog/images/claude-code-router-img.png)
[![](https://img.shields.io/badge/%F0%9F%87%A8%F0%9F%87%B3-%E4%B8%AD%E6%96%87%E7%89%88-ff0000?style=flat)](README_zh.md)
[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?&logo=discord&logoColor=white)](https://discord.gg/rdftVMaUcS)
[![](https://img.shields.io/github/license/musistudio/claude-code-router)](https://github.com/musistudio/claude-code-router/blob/main/LICENSE)
<hr>
I am seeking funding support for this project to better sustain its development. If you have any ideas, feel free to reach out to me: [m@musiiot.top](mailto:m@musiiot.top)
[中文版](README_zh.md)
> A powerful tool to route Claude Code requests to different models and customize any request.
> Now you can use models such as `GLM-4.5`, `Kimi-K2`, `Qwen3-Coder-480B-A35B`, and `DeepSeek v3.1` for free through the [iFlow Platform](https://platform.iflow.cn/docs/api-mode).
@@ -13,6 +16,8 @@ I am seeking funding support for this project to better sustain its development.
![](blog/images/claude-code.png)
![](blog/images/roadmap.svg)
## ✨ Features
- **Model Routing**: Route requests to different models based on your needs (e.g., background tasks, thinking, long context).
@@ -572,6 +577,8 @@ A huge thank you to all our sponsors for their generous support!
- @\*更
- @\*.
- @F\*t
- @\*政
- @\*铭
- @\*叶
(If your name is masked, please contact me via my homepage email to update it with your GitHub username.)

View File

@@ -1,4 +1,10 @@
# Claude Code Router
![](blog/images/claude-code-router-img.png)
[![](https://img.shields.io/badge/%F0%9F%87%AC%F0%9F%87%A7-English-000aff?style=flat)](README.md)
[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?&logo=discord&logoColor=white)](https://discord.gg/rdftVMaUcS)
[![](https://img.shields.io/github/license/musistudio/claude-code-router)](https://github.com/musistudio/claude-code-router/blob/main/LICENSE)
<hr>
我正在为该项目寻求资金支持,以更好地维持其发展。如果您有任何想法,请随时与我联系: [m@musiiot.top](mailto:m@musiiot.top)
@@ -10,6 +16,9 @@
![](blog/images/claude-code.png)
![](blog/images/roadmap.svg)
## ✨ 功能
- **模型路由**: 根据您的需求将请求路由到不同的模型(例如,后台任务、思考、长上下文)。
@@ -541,6 +550,9 @@ jobs:
- @\*更
- @\*.
- @F\*t
- @\*政
- @\*铭
- @\*叶
(如果您的名字被屏蔽,请通过我的主页电子邮件与我联系,以便使用您的 GitHub 用户名进行更新。)

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

67
blog/images/roadmap.svg Normal file
View File

@@ -0,0 +1,67 @@
<svg viewBox="0 0 1200 420" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>
.road { stroke: #7aa2ff; stroke-width: 6; fill: none; filter: drop-shadow(0 6px 18px rgba(122,162,255,0.25)); }
.dash { stroke: rgba(122,162,255,0.25); stroke-width: 6; fill: none; stroke-dasharray: 2 18; }
.node { filter: drop-shadow(0 3px 10px rgba(126,240,193,0.35)); }
.node-circle { fill: #7ef0c1; }
.node-core { fill: #181b22; stroke: white; stroke-width: 1.5; }
.label-bg { fill: rgba(24,27,34,0.8); stroke: rgba(255,255,255,0.12); rx: 12; }
.label-text { fill: #e8ecf1; font-weight: 700; font-size: 14px; font-family: Arial, sans-serif; }
.label-sub { fill: #9aa6b2; font-weight: 500; font-size: 12px; font-family: Arial, sans-serif; }
.spark { fill: none; stroke: #ffd36e; stroke-width: 1.6; stroke-linecap: round; }
</style>
</defs>
<!-- Background road with dash -->
<path class="dash" d="M60,330 C320,260 460,100 720,160 C930,205 990,260 1140,260"/>
<!-- Main road -->
<path class="road" d="M60,330 C320,260 460,100 720,160 C930,205 990,260 1140,260"/>
<!-- New Documentation Node -->
<g class="node" transform="translate(200,280)">
<circle class="node-circle" r="10"/>
<circle class="node-core" r="6"/>
</g>
<!-- New Documentation Label -->
<g transform="translate(80,120)">
<rect class="label-bg" width="260" height="92"/>
<text class="label-text" x="16" y="34">New Documentation</text>
<text class="label-sub" x="16" y="58">Clear structure, examples &amp; best practices</text>
</g>
<!-- Plugin Marketplace Node -->
<g class="node" transform="translate(640,150)">
<circle class="node-circle" r="10"/>
<circle class="node-core" r="6"/>
</g>
<!-- Plugin Marketplace Label -->
<g transform="translate(560,20)">
<rect class="label-bg" width="320" height="100"/>
<text class="label-text" x="16" y="34">Plugin Marketplace</text>
<text class="label-sub" x="16" y="58">Community submissions, ratings &amp; version constraints</text>
</g>
<!-- One More Thing Node -->
<g class="node" transform="translate(1080,255)">
<circle class="node-circle" r="10"/>
<circle class="node-core" r="6"/>
</g>
<!-- One More Thing Label -->
<g transform="translate(940,300)">
<rect class="label-bg" width="250" height="86"/>
<text class="label-text" x="16" y="34">One More Thing</text>
<text class="label-sub" x="16" y="58">🚀 Confidential project · Revealing soon</text>
</g>
<!-- Spark decorations -->
<g transform="translate(1125,290)">
<path class="spark" d="M0 0 L8 0 M4 -4 L4 4"/>
<path class="spark" d="M14 -2 L22 -2 M18 -6 L18 2"/>
<path class="spark" d="M-10 6 L-2 6 M-6 2 L-6 10"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -1,6 +1,6 @@
{
"name": "@musistudio/claude-code-router",
"version": "1.0.47",
"version": "1.0.49",
"description": "Use Claude Code without an Anthropics account and route it to another LLM provider",
"bin": {
"ccr": "./dist/cli.js"
@@ -22,6 +22,7 @@
"@fastify/static": "^8.2.0",
"@musistudio/llms": "^1.0.32",
"dotenv": "^16.4.7",
"find-process": "^2.0.0",
"json5": "^2.2.3",
"openurl": "^1.1.1",
"rotating-file-stream": "^3.2.7",

48
pnpm-lock.yaml generated
View File

@@ -17,6 +17,9 @@ importers:
dotenv:
specifier: ^16.4.7
version: 16.6.1
find-process:
specifier: ^2.0.0
version: 2.0.0
json5:
specifier: ^2.2.3
version: 2.2.3
@@ -338,6 +341,10 @@ packages:
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -345,6 +352,10 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
commander@12.1.0:
resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
engines: {node: '>=18'}
content-disposition@0.5.4:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'}
@@ -460,6 +471,10 @@ packages:
resolution: {integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==}
engines: {node: '>=20'}
find-process@2.0.0:
resolution: {integrity: sha512-YUBQnteWGASJoEVVsOXy6XtKAY2O1FCsWnnvQ8y0YwgY1rZiKeVptnFvMu6RSELZAJOGklqseTnUGGs5D0bKmg==}
hasBin: true
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
@@ -524,6 +539,10 @@ packages:
resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==}
engines: {node: '>=18'}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
@@ -609,6 +628,10 @@ packages:
light-my-request@6.6.0:
resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
loglevel@1.9.2:
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
engines: {node: '>= 0.6.0'}
lru-cache@11.1.0:
resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==}
engines: {node: 20 || >=22}
@@ -865,6 +888,10 @@ packages:
resolution: {integrity: sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==}
engines: {node: '>=0.10.0'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
supports-preserve-symlinks-flag@1.0.0:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
@@ -1188,12 +1215,19 @@ snapshots:
buffer-equal-constant-time@1.0.1: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
commander@12.1.0: {}
content-disposition@0.5.4:
dependencies:
safe-buffer: 5.2.1
@@ -1351,6 +1385,12 @@ snapshots:
fast-querystring: 1.1.2
safe-regex2: 5.0.0
find-process@2.0.0:
dependencies:
chalk: 4.1.2
commander: 12.1.0
loglevel: 1.9.2
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
@@ -1458,6 +1498,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
has-flag@4.0.0: {}
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
@@ -1538,6 +1580,8 @@ snapshots:
process-warning: 4.0.1
set-cookie-parser: 2.7.1
loglevel@1.9.2: {}
lru-cache@11.1.0: {}
merge2@1.4.1: {}
@@ -1741,6 +1785,10 @@ snapshots:
strip-eof@1.0.0: {}
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
supports-preserve-symlinks-flag@1.0.0: {}
thread-stream@3.1.0:

View File

@@ -45,7 +45,8 @@ async function waitForService(
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
if (isServiceRunning()) {
const isRunning = await isServiceRunning()
if (isRunning) {
// Wait for an additional short period to ensure service is fully ready
await new Promise((resolve) => setTimeout(resolve, 500));
return true;
@@ -56,6 +57,7 @@ async function waitForService(
}
async function main() {
const isRunning = await isServiceRunning()
switch (command) {
case "start":
run();
@@ -108,7 +110,7 @@ async function main() {
});
break;
case "code":
if (!isServiceRunning()) {
if (!isRunning) {
console.log("Service not running, starting service...");
const cliPath = join(__dirname, "cli.js");
const startProcess = spawn("node", [cliPath, "start"], {
@@ -153,7 +155,7 @@ async function main() {
break;
case "ui":
// Check if service is running
if (!isServiceRunning()) {
if (!isRunning) {
console.log("Service not running, starting service...");
const cliPath = join(__dirname, "cli.js");
const startProcess = spawn("node", [cliPath, "start"], {

View File

@@ -51,7 +51,8 @@ interface RunOptions {
async function run(options: RunOptions = {}) {
// Check if service is already running
if (isServiceRunning()) {
const isRunning = await isServiceRunning()
if (isRunning) {
console.log("✅ Service is already running in the background.");
return;
}
@@ -244,7 +245,6 @@ async function run(options: RunOptions = {}) {
req,
config
});
console.log('result', toolResult)
toolMessages.push({
"tool_use_id": currentToolId,
"type": "tool_result",
@@ -295,14 +295,12 @@ async function run(options: RunOptions = {}) {
// 检查流是否仍然可写
if (!controller.desiredSize) {
console.log('Stream backpressure detected');
break;
}
controller.enqueue(value)
}catch (readError: any) {
if (readError.name === 'AbortError' || readError.code === 'ERR_STREAM_PREMATURE_CLOSE') {
console.log('Stream reading aborted due to client disconnect');
abortController.abort(); // 中止所有相关操作
break;
}
@@ -318,7 +316,6 @@ async function run(options: RunOptions = {}) {
// 处理流提前关闭的错误
if (error.code === 'ERR_STREAM_PREMATURE_CLOSE') {
console.log('Stream prematurely closed, aborting operations');
abortController.abort();
return undefined;
}
@@ -349,7 +346,7 @@ async function run(options: RunOptions = {}) {
}
} catch (readError: any) {
if (readError.name === 'AbortError' || readError.code === 'ERR_STREAM_PREMATURE_CLOSE') {
console.log('Background read stream closed prematurely');
console.error('Background read stream closed prematurely');
} else {
console.error('Error in background stream reading:', readError);
}
@@ -361,6 +358,13 @@ async function run(options: RunOptions = {}) {
return done(null, originalStream)
}
sessionUsageCache.put(req.sessionId, payload.usage);
if (typeof payload ==='object') {
if (payload.error) {
return done(payload.error, null)
} else {
return done(payload, null)
}
}
}
if (typeof payload ==='object' && payload.error) {
return done(payload.error, null)
@@ -368,7 +372,6 @@ async function run(options: RunOptions = {}) {
done(null, payload)
});
server.addHook("onSend", async (req, reply, payload) => {
console.log('主应用onSend')
event.emit('onSend', req, reply, payload);
return payload;
})

View File

@@ -5,8 +5,9 @@ import { join } from 'path';
export async function closeService() {
const PID_FILE = join(HOME_DIR, '.claude-code-router.pid');
const isRunning = await isServiceRunning()
if (!isServiceRunning()) {
if (!isRunning) {
console.log("No service is currently running.");
return;
}

View File

@@ -1,6 +1,16 @@
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { PID_FILE, REFERENCE_COUNT_FILE } from '../constants';
import { readConfigFile } from '.';
import find from 'find-process';
export async function isProcessRunning(pid: number): Promise<boolean> {
try {
const processes = await find('pid', pid);
return processes.length > 0;
} catch (error) {
return false;
}
}
export function incrementReferenceCount() {
let count = 0;
@@ -27,15 +37,14 @@ export function getReferenceCount(): number {
return parseInt(readFileSync(REFERENCE_COUNT_FILE, 'utf-8')) || 0;
}
export function isServiceRunning(): boolean {
export async function isServiceRunning(): Promise<boolean> {
if (!existsSync(PID_FILE)) {
return false;
}
try {
const pid = parseInt(readFileSync(PID_FILE, 'utf-8'));
process.kill(pid, 0);
return true;
return await isProcessRunning(pid);
} catch (e) {
// Process not running, clean up pid file
cleanupPidFile();
@@ -73,7 +82,7 @@ export function getServicePid(): number | null {
export async function getServiceInfo() {
const pid = getServicePid();
const running = isServiceRunning();
const running = await isServiceRunning();
const config = await readConfigFile();
const port = config.PORT || 3456;

View File

@@ -5,6 +5,7 @@ import {
} from "@anthropic-ai/sdk/resources/messages";
import { get_encoding } from "tiktoken";
import { sessionUsageCache, Usage } from "./cache";
import { readFile } from 'fs/promises'
const enc = get_encoding("cl100k_base");
@@ -147,6 +148,11 @@ export const router = async (req: any, _res: any, context: any) => {
}
const lastMessageUsage = sessionUsageCache.get(req.sessionId);
const { messages, system = [], tools }: MessageCreateParamsBase = req.body;
if (config.REWRITE_SYSTEM_PROMPT && system.length > 1 && system[1]?.text?.includes('<env>')) {
const prompt = await readFile(config.REWRITE_SYSTEM_PROMPT, 'utf-8');
system[1].text = `${prompt}<env>${system[1].text.split('<env>').pop()}`
}
try {
const tokenCount = calculateTokenCount(
messages as MessageParam[],

View File

@@ -16,7 +16,6 @@
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.11",
"class-variance-authority": "^0.7.1",

102
ui/pnpm-lock.yaml generated
View File

@@ -26,9 +26,6 @@ importers:
'@radix-ui/react-switch':
specifier: ^1.2.5
version: 1.2.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-tabs':
specifier: ^1.1.13
version: 1.1.13(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-tooltip':
specifier: ^1.2.7
version: 1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -520,19 +517,6 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-collection@1.1.7':
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-compose-refs@1.1.2':
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
peerDependencies:
@@ -564,15 +548,6 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-direction@1.1.1':
resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-dismissable-layer@1.1.10':
resolution: {integrity: sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==}
peerDependencies:
@@ -734,19 +709,6 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-roving-focus@1.1.11':
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.2.3':
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
peerDependencies:
@@ -769,19 +731,6 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-tabs@1.1.13':
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-tooltip@1.2.8':
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
peerDependencies:
@@ -2384,18 +2333,6 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.8)(react@19.1.0)':
dependencies:
react: 19.1.0
@@ -2430,12 +2367,6 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-direction@1.1.1(@types/react@19.1.8)(react@19.1.0)':
dependencies:
react: 19.1.0
optionalDependencies:
'@types/react': 19.1.8
'@radix-ui/react-dismissable-layer@1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.2
@@ -2593,23 +2524,6 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-slot@1.2.3(@types/react@19.1.8)(react@19.1.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
@@ -2632,22 +2546,6 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-tabs@1.1.13(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.3

View File

@@ -1,496 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { ArrowLeft, Send, Copy, Square, History, Maximize } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import MonacoEditor from '@monaco-editor/react';
import { RequestHistoryDrawer } from './RequestHistoryDrawer';
import { requestHistoryDB } from '@/lib/db';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
export function DebugPage() {
const navigate = useNavigate();
const location = useLocation();
const [requestData, setRequestData] = useState({
url: '',
method: 'POST',
headers: '{}',
body: '{}'
});
const [responseData, setResponseData] = useState({
status: 0,
responseTime: 0,
body: '',
headers: '{}'
});
const [isLoading, setIsLoading] = useState(false);
const [isHistoryDrawerOpen, setIsHistoryDrawerOpen] = useState(false);
const [fullscreenEditor, setFullscreenEditor] = useState<'headers' | 'body' | null>(null);
const headersEditorRef = useRef<any>(null);
const bodyEditorRef = useRef<any>(null);
// 切换全屏模式
const toggleFullscreen = (editorType: 'headers' | 'body') => {
const isEnteringFullscreen = fullscreenEditor !== editorType;
setFullscreenEditor(isEnteringFullscreen ? editorType : null);
// 延迟触发Monaco编辑器的重新布局等待DOM更新完成
setTimeout(() => {
if (headersEditorRef.current) {
headersEditorRef.current.layout();
}
if (bodyEditorRef.current) {
bodyEditorRef.current.layout();
}
}, 300);
};
// 从URL参数中解析日志数据
useEffect(() => {
const params = new URLSearchParams(location.search);
const logDataParam = params.get('logData');
if (logDataParam) {
try {
const parsedData = JSON.parse(decodeURIComponent(logDataParam));
// 解析URL - 支持多种字段名
const url = parsedData.url || parsedData.requestUrl || parsedData.endpoint || '';
// 解析Method - 支持多种字段名和大小写
const method = (parsedData.method || parsedData.requestMethod || 'POST').toUpperCase();
// 解析Headers - 支持多种格式
let headers: Record<string, string> = {};
if (parsedData.headers) {
if (typeof parsedData.headers === 'string') {
try {
headers = JSON.parse(parsedData.headers);
} catch {
// 如果是字符串格式,尝试解析为键值对
const headerLines = parsedData.headers.split('\n');
headerLines.forEach((line: string) => {
const [key, ...values] = line.split(':');
if (key && values.length > 0) {
headers[key.trim()] = values.join(':').trim();
}
});
}
} else {
headers = parsedData.headers;
}
}
// 解析Body - 支持多种格式和嵌套结构
let body: Record<string, unknown> = {};
let bodyData = null;
// 支持多种字段名和嵌套结构
if (parsedData.body) {
bodyData = parsedData.body;
} else if (parsedData.request && parsedData.request.body) {
bodyData = parsedData.request.body;
}
if (bodyData) {
if (typeof bodyData === 'string') {
try {
// 尝试解析为JSON对象
const parsed = JSON.parse(bodyData);
body = parsed;
} catch {
// 如果不是JSON检查是否是纯文本
const trimmed = bodyData.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
// 看起来像JSON但解析失败作为字符串保存
body = { raw: bodyData };
} else {
// 普通文本,直接保存
body = { content: bodyData };
}
}
} else if (typeof bodyData === 'object') {
// 已经是对象,直接使用
body = bodyData;
} else {
// 其他类型,转换为字符串
body = { content: String(bodyData) };
}
}
// 预填充请求表单
setRequestData({
url,
method,
headers: JSON.stringify(headers, null, 2),
body: JSON.stringify(body, null, 2)
});
console.log('Log data parsed successfully:', { url, method, headers, body });
} catch (error) {
console.error('Failed to parse log data:', error);
console.error('Raw log data:', logDataParam);
}
}
}, [location.search]);
// 发送请求
const sendRequest = async () => {
try {
setIsLoading(true);
const headers = JSON.parse(requestData.headers);
const body = JSON.parse(requestData.body);
const startTime = Date.now();
const response = await fetch(requestData.url, {
method: requestData.method,
headers: {
'Content-Type': 'application/json',
...headers
},
body: requestData.method !== 'GET' ? JSON.stringify(body) : undefined
});
const endTime = Date.now();
const responseTime = endTime - startTime;
const responseHeaders: Record<string, string> = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
const responseText = await response.text();
let responseBody = responseText;
// 尝试解析JSON响应
try {
const jsonResponse = JSON.parse(responseText);
responseBody = JSON.stringify(jsonResponse, null, 2);
} catch {
// 如果不是JSON保持原样
}
const responseHeadersString = JSON.stringify(responseHeaders, null, 2);
setResponseData({
status: response.status,
responseTime,
body: responseBody,
headers: responseHeadersString
});
// 保存到IndexedDB
await requestHistoryDB.saveRequest({
url: requestData.url,
method: requestData.method,
headers: requestData.headers,
body: requestData.body,
status: response.status,
responseTime,
responseBody,
responseHeaders: responseHeadersString
});
} catch (error) {
console.error('Request failed:', error);
setResponseData({
status: 0,
responseTime: 0,
body: `请求失败: ${error instanceof Error ? error.message : '未知错误'}`,
headers: '{}'
});
} finally {
setIsLoading(false);
}
};
// 从历史记录中选择请求
const handleSelectRequest = (request: import('@/lib/db').RequestHistoryItem) => {
setRequestData({
url: request.url,
method: request.method,
headers: request.headers,
body: request.body
});
setResponseData({
status: request.status,
responseTime: request.responseTime,
body: request.responseBody,
headers: request.responseHeaders
});
};
// 复制cURL命令
const copyCurl = () => {
try {
const headers = JSON.parse(requestData.headers);
const body = JSON.parse(requestData.body);
let curlCommand = `curl -X ${requestData.method} "${requestData.url}"`;
// 添加headers
Object.entries(headers).forEach(([key, value]) => {
curlCommand += ` \\\n -H "${key}: ${value}"`;
});
// 添加body
if (requestData.method !== 'GET' && Object.keys(body).length > 0) {
curlCommand += ` \\\n -d '${JSON.stringify(body)}'`;
}
navigator.clipboard.writeText(curlCommand);
alert('cURL命令已复制到剪贴板');
} catch (error) {
console.error('Failed to copy cURL:', error);
alert('复制cURL命令失败');
}
};
return (
<div className="h-screen bg-gray-50 font-sans">
{/* 头部 */}
<header className="flex h-16 items-center justify-between border-b bg-white px-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate('/dashboard')}>
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
<h1 className="text-xl font-semibold text-gray-800">HTTP </h1>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setIsHistoryDrawerOpen(true)}>
<History className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={copyCurl}>
<Copy className="h-4 w-4 mr-2" />
cURL
</Button>
</div>
</header>
{/* 主要内容 */}
<main className="flex h-[calc(100vh-4rem)] flex-col gap-4 p-4 overflow-hidden">
{/* 上部分:请求参数配置 - 上中下布局 */}
<div className="h-1/2 flex flex-col gap-4">
<div className="bg-white rounded-lg border p-4 flex-1 flex flex-col">
<h3 className="font-medium mb-4"></h3>
<div className="flex flex-col gap-4 flex-1">
{/* 上Method、URL和发送请求按钮配置 */}
<div className="flex gap-4 items-end">
<div className="w-32">
<label className="block text-sm font-medium mb-1">Method</label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
value={requestData.method}
onChange={(e) => setRequestData(prev => ({ ...prev, method: e.target.value }))}
>
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="DELETE">DELETE</option>
<option value="PATCH">PATCH</option>
</select>
</div>
<div className="flex-1">
<label className="block text-sm font-medium mb-1">URL</label>
<input
type="text"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
value={requestData.url}
onChange={(e) => setRequestData(prev => ({ ...prev, url: e.target.value }))}
placeholder="https://api.example.com/endpoint"
/>
</div>
<Button
variant={isLoading ? "destructive" : "default"}
onClick={isLoading ? () => {} : sendRequest}
disabled={isLoading || !requestData.url.trim()}
>
{isLoading ? (
<>
<Square className="h-4 w-4 mr-2" />
...
</>
) : (
<>
<Send className="h-4 w-4 mr-2" />
</>
)}
</Button>
</div>
{/* Headers和Body配置 - 使用tab布局 */}
<div className="flex-1">
<Tabs defaultValue="headers" className="h-full flex flex-col">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="headers">Headers</TabsTrigger>
<TabsTrigger value="body">Body</TabsTrigger>
</TabsList>
<TabsContent value="headers" className="flex-1 mt-2">
<div
className={`${fullscreenEditor === 'headers' ? '' : 'h-full'} flex flex-col ${
fullscreenEditor === 'headers' ? 'fixed bg-white w-[100vw] h-[100vh] z-[9999] top-0 left-0 p-4' : ''
}`}
>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium">Headers (JSON)</label>
<Button
variant="ghost"
size="sm"
onClick={() => toggleFullscreen('headers')}
>
<Maximize className="h-4 w-4 mr-1" />
{fullscreenEditor === 'headers' ? '退出全屏' : '全屏'}
</Button>
</div>
<div
id="fullscreen-headers"
className={`${fullscreenEditor === 'headers' ? 'h-full' : 'flex-1'} border border-gray-300 rounded-md overflow-hidden relative`}
>
<MonacoEditor
height="100%"
language="json"
value={requestData.headers}
onChange={(value) => setRequestData(prev => ({ ...prev, headers: value || '{}' }))}
onMount={(editor) => {
headersEditorRef.current = editor;
}}
options={{
minimap: { enabled: fullscreenEditor === 'headers' },
scrollBeyondLastLine: false,
fontSize: 14,
lineNumbers: 'on',
wordWrap: 'on',
automaticLayout: true,
formatOnPaste: true,
formatOnType: true,
}}
/>
</div>
</div>
</TabsContent>
<TabsContent value="body" className="flex-1 mt-2">
<div
className={`${fullscreenEditor === 'body' ? '' : 'h-full'} flex flex-col ${
fullscreenEditor === 'body' ? 'fixed bg-white w-[100vw] h-[100vh] z-[9999] top-0 left-0 p-4' : ''
}`}
>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium">Body (JSON)</label>
<Button
variant="ghost"
size="sm"
onClick={() => toggleFullscreen('body')}
>
<Maximize className="h-4 w-4 mr-1" />
{fullscreenEditor === 'body' ? '退出全屏' : '全屏'}
</Button>
</div>
<div
id="fullscreen-body"
className={`${fullscreenEditor === 'body' ? 'h-full' : 'flex-1'} border border-gray-300 rounded-md overflow-hidden relative`}
>
<MonacoEditor
height="100%"
language="json"
value={requestData.body}
onChange={(value) => setRequestData(prev => ({ ...prev, body: value || '{}' }))}
onMount={(editor) => {
bodyEditorRef.current = editor;
}}
options={{
minimap: { enabled: fullscreenEditor === 'body' },
scrollBeyondLastLine: false,
fontSize: 14,
lineNumbers: 'on',
wordWrap: 'on',
automaticLayout: true,
formatOnPaste: true,
formatOnType: true,
}}
/>
</div>
</div>
</TabsContent>
</Tabs>
</div>
</div>
</div>
</div>
{/* 下部分:响应信息查看 */}
<div className="h-1/2 flex flex-col gap-4">
<div className="flex-1 bg-white rounded-lg border p-4 flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium"></h3>
{responseData.status > 0 && (
<div className="flex items-center gap-4 text-sm">
<span className="flex items-center gap-1">
: <span className={`font-mono px-2 py-1 rounded ${
responseData.status >= 200 && responseData.status < 300
? 'bg-green-100 text-green-800'
: responseData.status >= 400
? 'bg-red-100 text-red-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{responseData.status}
</span>
</span>
<span>
: <span className="font-mono">{responseData.responseTime}ms</span>
</span>
</div>
)}
</div>
{responseData.body ? (
<div className="flex-1">
<Tabs defaultValue="body" className="h-full flex flex-col">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="body"></TabsTrigger>
<TabsTrigger value="headers"></TabsTrigger>
</TabsList>
<TabsContent value="body" className="flex-1 mt-2">
<div className="bg-gray-50 border rounded-md p-3 h-full overflow-auto">
<pre className="text-sm whitespace-pre-wrap">
{responseData.body}
</pre>
</div>
</TabsContent>
<TabsContent value="headers" className="flex-1 mt-2">
<div className="bg-gray-50 border rounded-md p-3 h-full overflow-auto">
<pre className="text-sm">
{responseData.headers}
</pre>
</div>
</TabsContent>
</Tabs>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-gray-500">
{isLoading ? '发送请求中...' : '发送请求后将在此显示响应'}
</div>
)}
</div>
</div>
</main>
{/* 请求历史抽屉 */}
<RequestHistoryDrawer
isOpen={isHistoryDrawerOpen}
onClose={() => setIsHistoryDrawerOpen(false)}
onSelectRequest={handleSelectRequest}
/>
</div>
);
}

View File

@@ -1,10 +1,9 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import Editor from '@monaco-editor/react';
import { Button } from '@/components/ui/button';
import { api } from '@/lib/api';
import { useTranslation } from 'react-i18next';
import { X, RefreshCw, Download, Trash2, ArrowLeft, File, Layers, Bug } from 'lucide-react';
import { X, RefreshCw, Download, Trash2, ArrowLeft, File, Layers } from 'lucide-react';
interface LogViewerProps {
open: boolean;
@@ -18,7 +17,6 @@ interface LogEntry {
message: string; // 现在这个字段直接包含原始JSON字符串
source?: string;
reqId?: string;
[key: string]: any; // 允许动态属性如msg、url、body等
}
interface LogFile {
@@ -52,7 +50,6 @@ interface GroupedLogsResponse {
export function LogViewer({ open, onOpenChange, showToast }: LogViewerProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [logs, setLogs] = useState<string[]>([]);
const [logFiles, setLogFiles] = useState<LogFile[]>([]);
const [selectedFile, setSelectedFile] = useState<LogFile | null>(null);
@@ -66,7 +63,6 @@ export function LogViewer({ open, onOpenChange, showToast }: LogViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const refreshInterval = useRef<NodeJS.Timeout | null>(null);
const workerRef = useRef<Worker | null>(null);
const editorRef = useRef<any>(null);
useEffect(() => {
if (open) {
@@ -511,183 +507,6 @@ export function LogViewer({ open, onOpenChange, showToast }: LogViewerProps) {
return logs.join('\n');
};
// 解析日志行获取final request的行号
const getFinalRequestLines = () => {
const lines: number[] = [];
if (groupByReqId && groupedLogs && selectedReqId && groupedLogs.groups[selectedReqId]) {
// 分组模式下,检查选中的请求日志
const requestLogs = groupedLogs.groups[selectedReqId];
requestLogs.forEach((log, index) => {
try {
// @ts-ignore
log = JSON.parse(log)
// 检查日志的msg字段是否等于"final request"
if (log.msg === "final request") {
lines.push(index + 1); // 行号从1开始
}
} catch (e) {
// 解析失败,跳过
}
});
} else {
// 非分组模式下,检查原始日志
logs.forEach((logLine, index) => {
try {
const log = JSON.parse(logLine);
// 检查日志的msg字段是否等于"final request"
if (log.msg === "final request") {
lines.push(index + 1); // 行号从1开始
}
} catch (e) {
// 解析失败,跳过
}
});
}
return lines;
};
// 处理调试按钮点击
const handleDebugClick = (lineNumber: number) => {
console.log('handleDebugClick called with lineNumber:', lineNumber);
console.log('Current state:', { groupByReqId, selectedReqId, logsLength: logs.length });
let logData = null;
if (groupByReqId && groupedLogs && selectedReqId && groupedLogs.groups[selectedReqId]) {
// 分组模式下获取日志数据
const requestLogs = groupedLogs.groups[selectedReqId];
console.log('Group mode - requestLogs length:', requestLogs.length);
logData = requestLogs[lineNumber - 1]; // 行号转换为数组索引
console.log('Group mode - logData:', logData);
} else {
// 非分组模式下获取日志数据
console.log('Non-group mode - logs length:', logs.length);
try {
const logLine = logs[lineNumber - 1];
console.log('Log line:', logLine);
logData = JSON.parse(logLine);
console.log('Parsed logData:', logData);
} catch (e) {
console.error('Failed to parse log data:', e);
}
}
if (logData) {
console.log('Navigating to debug page with logData:', logData);
// 导航到调试页面并传递日志数据作为URL参数
const logDataParam = encodeURIComponent(JSON.stringify(logData));
console.log('Encoded logDataParam length:', logDataParam.length);
navigate(`/debug?logData=${logDataParam}`);
} else {
console.error('No log data found for line:', lineNumber);
}
};
// 配置Monaco Editor
const configureEditor = (editor: any) => {
editorRef.current = editor;
// 启用glyph margin
editor.updateOptions({
glyphMargin: true,
});
// 存储当前的装饰ID
let currentDecorations: string[] = [];
// 添加glyph margin装饰
const updateDecorations = () => {
const finalRequestLines = getFinalRequestLines();
const decorations = finalRequestLines.map(lineNumber => ({
range: {
startLineNumber: lineNumber,
startColumn: 1,
endLineNumber: lineNumber,
endColumn: 1
},
options: {
glyphMarginClassName: 'debug-button-glyph',
glyphMarginHoverMessage: { value: '点击调试此请求' }
}
}));
// 使用deltaDecorations正确更新装饰清理旧的装饰
currentDecorations = editor.deltaDecorations(currentDecorations, decorations);
};
// 初始更新装饰
updateDecorations();
// 监听glyph margin点击 - 使用正确的事件监听方式
editor.onMouseDown((e: any) => {
console.log('Mouse down event:', e.target);
console.log('Event details:', {
type: e.target.type,
hasDetail: !!e.target.detail,
glyphMarginLane: e.target.detail?.glyphMarginLane,
offsetX: e.target.detail?.offsetX,
glyphMarginLeft: e.target.detail?.glyphMarginLeft,
glyphMarginWidth: e.target.detail?.glyphMarginWidth
});
// 检查是否点击在glyph margin区域
const isGlyphMarginClick = e.target.detail &&
e.target.detail.glyphMarginLane !== undefined &&
e.target.detail.offsetX !== undefined &&
e.target.detail.offsetX <= e.target.detail.glyphMarginLeft + e.target.detail.glyphMarginWidth;
console.log('Is glyph margin click:', isGlyphMarginClick);
if (e.target.position && isGlyphMarginClick) {
const finalRequestLines = getFinalRequestLines();
console.log('Final request lines:', finalRequestLines);
console.log('Clicked line number:', e.target.position.lineNumber);
if (finalRequestLines.includes(e.target.position.lineNumber)) {
console.log('Opening debug page for line:', e.target.position.lineNumber);
handleDebugClick(e.target.position.lineNumber);
}
}
});
// 尝试使用 onGlyphMarginClick 如果可用
if (typeof editor.onGlyphMarginClick === 'function') {
editor.onGlyphMarginClick((e: any) => {
console.log('Glyph margin click event:', e);
const finalRequestLines = getFinalRequestLines();
if (finalRequestLines.includes(e.target.position.lineNumber)) {
console.log('Opening debug page for line (glyph):', e.target.position.lineNumber);
handleDebugClick(e.target.position.lineNumber);
}
});
}
// 添加鼠标移动事件来检测悬停在调试按钮上
editor.onMouseMove((e: any) => {
if (e.target.position && (e.target.type === 4 || e.target.type === 'glyph-margin')) {
const finalRequestLines = getFinalRequestLines();
if (finalRequestLines.includes(e.target.position.lineNumber)) {
// 可以在这里添加悬停效果
editor.updateOptions({
glyphMargin: true,
});
}
}
});
// 当日志变化时更新装饰
const interval = setInterval(updateDecorations, 1000);
return () => {
clearInterval(interval);
// 清理装饰
if (editorRef.current) {
editorRef.current.deltaDecorations(currentDecorations, []);
}
};
};
if (!isVisible && !open) {
return null;
}
@@ -849,27 +668,23 @@ export function LogViewer({ open, onOpenChange, showToast }: LogViewerProps) {
</div>
) : (
// 显示日志内容
<div className="relative h-full">
<Editor
height="100%"
defaultLanguage="json"
value={formatLogsForEditor()}
theme="vs"
options={{
minimap: { enabled: true },
fontSize: 14,
scrollBeyondLastLine: false,
automaticLayout: true,
wordWrap: 'on',
readOnly: true,
lineNumbers: 'on',
folding: true,
renderWhitespace: 'all',
glyphMargin: true,
}}
onMount={configureEditor}
/>
</div>
<Editor
height="100%"
defaultLanguage="json"
value={formatLogsForEditor()}
theme="vs"
options={{
minimap: { enabled: true },
fontSize: 14,
scrollBeyondLastLine: false,
automaticLayout: true,
wordWrap: 'on',
readOnly: true,
lineNumbers: 'on',
folding: true,
renderWhitespace: 'all',
}}
/>
)}
</>
) : (

View File

@@ -1,169 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { History, Trash2, Clock, X } from 'lucide-react';
import { requestHistoryDB, type RequestHistoryItem } from '@/lib/db';
interface RequestHistoryDrawerProps {
isOpen: boolean;
onClose: () => void;
onSelectRequest: (request: RequestHistoryItem) => void;
}
export function RequestHistoryDrawer({ isOpen, onClose, onSelectRequest }: RequestHistoryDrawerProps) {
const [requests, setRequests] = useState<RequestHistoryItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (isOpen) {
loadRequests();
}
}, [isOpen]);
const loadRequests = async () => {
try {
setLoading(true);
const history = await requestHistoryDB.getRequests();
setRequests(history);
} catch (error) {
console.error('Failed to load request history:', error);
} finally {
setLoading(false);
}
};
const handleDelete = async (id: string, event: React.MouseEvent) => {
event.stopPropagation();
try {
await requestHistoryDB.deleteRequest(id);
setRequests(prev => prev.filter(req => req.id !== id));
} catch (error) {
console.error('Failed to delete request:', error);
}
};
const handleClearAll = async () => {
if (window.confirm('确定要清空所有请求历史吗?')) {
try {
await requestHistoryDB.clearAllRequests();
setRequests([]);
} catch (error) {
console.error('Failed to clear request history:', error);
}
}
};
const formatTime = (timestamp: string) => {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (minutes < 1440) return `${Math.floor(minutes / 60)}小时前`;
return date.toLocaleDateString();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50">
{/* 遮罩层 */}
<div
className="absolute inset-0 bg-black bg-opacity-50"
onClick={onClose}
/>
{/* 抽屉 */}
<div className="absolute right-0 top-0 h-full w-96 bg-white shadow-xl flex flex-col">
{/* 头部 */}
<div className="flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-2">
<History className="h-5 w-5" />
<h2 className="text-lg font-semibold"></h2>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleClearAll}
disabled={requests.length === 0}
>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* 内容 */}
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<div className="flex items-center justify-center h-32 text-gray-500">
...
</div>
) : requests.length > 0 ? (
<div className="space-y-2">
{requests.map((item) => (
<div
key={item.id}
className="p-3 bg-gray-50 rounded-lg border cursor-pointer hover:bg-gray-100 transition-colors"
onClick={() => {
onSelectRequest(item);
onClose();
}}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="font-mono text-xs bg-gray-200 px-2 py-1 rounded">
{item.method}
</span>
<span className="text-sm font-medium truncate flex-1">
{item.url}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={(e) => handleDelete(item.id, e)}
className="h-6 w-6 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="flex items-center justify-between text-xs text-gray-500">
<div className="flex items-center gap-2">
<span className={`font-mono px-1 rounded ${
item.status >= 200 && item.status < 300
? 'bg-green-100 text-green-800'
: item.status >= 400
? 'bg-red-100 text-red-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{item.status}
</span>
<span>{item.responseTime}ms</span>
</div>
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>{formatTime(item.timestamp)}</span>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center text-gray-500 py-8">
<History className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p></p>
<p className="text-sm mt-2"></p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,53 +0,0 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -154,24 +154,3 @@
scrollbar-color: oklch(0.708 0 0) oklch(0.269 0 0);
}
}
/* Monaco Editor 调试按钮样式 */
.debug-button-glyph {
background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="%23056bfe" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20v-9"/><path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z"/><path d="M14.12 3.88 16 2"/><path d="M21 21a4 4 0 0 0-3.81-4"/><path d="M21 5a4 4 0 0 1-3.55 3.97"/><path d="M22 13h-4"/><path d="M3 21a4 4 0 0 1 3.81-4"/><path d="M3 5a4 4 0 0 0 3.55 3.97"/><path d="M6 13H2"/><path d="m8 2 1.88 1.88"/><path d="M9 7.13V6a3 3 0 1 1 6 0v1.13"/></svg>') center center no-repeat;
background-size: 14px 14px;
cursor: pointer;
opacity: 0.8;
transition: opacity 0.2s ease;
}
.debug-button-glyph:hover {
opacity: 1;
}
/* 确保调试按钮在glyph margin中可见 */
.monaco-editor .margin-view-overlays .debug-button-glyph {
display: block !important;
width: 16px !important;
height: 16px !important;
margin: 2px 0;
}

View File

@@ -1,106 +0,0 @@
export interface RequestHistoryItem {
id: string;
url: string;
method: string;
headers: string;
body: string;
timestamp: string;
status: number;
responseTime: number;
responseBody: string;
responseHeaders: string;
}
class RequestHistoryDB {
private readonly DB_NAME = 'RequestHistoryDB';
private readonly STORE_NAME = 'requests';
private readonly VERSION = 1;
async openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.DB_NAME, this.VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(this.STORE_NAME)) {
const store = db.createObjectStore(this.STORE_NAME, { keyPath: 'id' });
store.createIndex('timestamp', 'timestamp', { unique: false });
store.createIndex('url', 'url', { unique: false });
store.createIndex('method', 'method', { unique: false });
}
};
});
}
async saveRequest(request: Omit<RequestHistoryItem, 'id' | 'timestamp'>): Promise<void> {
const db = await this.openDB();
const item: RequestHistoryItem = {
...request,
id: Date.now().toString(),
timestamp: new Date().toISOString(),
};
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.STORE_NAME], 'readwrite');
const store = transaction.objectStore(this.STORE_NAME);
const request = store.add(item);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async getRequests(limit: number = 50): Promise<RequestHistoryItem[]> {
const db = await this.openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.STORE_NAME], 'readonly');
const store = transaction.objectStore(this.STORE_NAME);
const index = store.index('timestamp');
const request = index.openCursor(null, 'prev');
const results: RequestHistoryItem[] = [];
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest).result;
if (cursor && results.length < limit) {
results.push(cursor.value);
cursor.continue();
} else {
resolve(results);
}
};
request.onerror = () => reject(request.error);
});
}
async deleteRequest(id: string): Promise<void> {
const db = await this.openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.STORE_NAME], 'readwrite');
const store = transaction.objectStore(this.STORE_NAME);
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async clearAllRequests(): Promise<void> {
const db = await this.openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.STORE_NAME], 'readwrite');
const store = transaction.objectStore(this.STORE_NAME);
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
}
export const requestHistoryDB = new RequestHistoryDB();

View File

@@ -1,7 +1,6 @@
import { createMemoryRouter, Navigate } from 'react-router-dom';
import App from './App';
import { Login } from '@/components/Login';
import { DebugPage } from '@/components/DebugPage';
import ProtectedRoute from '@/components/ProtectedRoute';
import PublicRoute from '@/components/PublicRoute';
@@ -18,10 +17,6 @@ export const router = createMemoryRouter([
path: '/dashboard',
element: <ProtectedRoute><App /></ProtectedRoute>,
},
{
path: '/debug',
element: <ProtectedRoute><DebugPage /></ProtectedRoute>,
},
], {
initialEntries: ['/dashboard']
});

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/i18n.ts","./src/main.tsx","./src/routes.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/configprovider.tsx","./src/components/debugpage.tsx","./src/components/jsoneditor.tsx","./src/components/logviewer.tsx","./src/components/login.tsx","./src/components/protectedroute.tsx","./src/components/providerlist.tsx","./src/components/providers.tsx","./src/components/publicroute.tsx","./src/components/requesthistorydrawer.tsx","./src/components/router.tsx","./src/components/settingsdialog.tsx","./src/components/statuslineconfigdialog.tsx","./src/components/statuslineimportexport.tsx","./src/components/transformerlist.tsx","./src/components/transformers.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/color-picker.tsx","./src/components/ui/combo-input.tsx","./src/components/ui/combobox.tsx","./src/components/ui/command.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/multi-combobox.tsx","./src/components/ui/popover.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/lib/api.ts","./src/lib/db.ts","./src/lib/utils.ts","./src/utils/statusline.ts"],"version":"5.8.3"}
{"root":["./src/app.tsx","./src/i18n.ts","./src/main.tsx","./src/routes.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/configprovider.tsx","./src/components/jsoneditor.tsx","./src/components/logviewer.tsx","./src/components/login.tsx","./src/components/protectedroute.tsx","./src/components/providerlist.tsx","./src/components/providers.tsx","./src/components/publicroute.tsx","./src/components/router.tsx","./src/components/settingsdialog.tsx","./src/components/statuslineconfigdialog.tsx","./src/components/statuslineimportexport.tsx","./src/components/transformerlist.tsx","./src/components/transformers.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/color-picker.tsx","./src/components/ui/combo-input.tsx","./src/components/ui/combobox.tsx","./src/components/ui/command.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/multi-combobox.tsx","./src/components/ui/popover.tsx","./src/components/ui/switch.tsx","./src/components/ui/toast.tsx","./src/lib/api.ts","./src/lib/utils.ts","./src/utils/statusline.ts"],"version":"5.8.3"}