Initial commit: API Debug Tool

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jason
2026-03-12 04:32:35 +08:00
commit 96bdc292bb
42 changed files with 8577 additions and 0 deletions

24
client/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

16
client/README.md Normal file
View File

@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

29
client/eslint.config.js Normal file
View File

@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

13
client/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2930
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
client/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^5.6.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"vite": "^7.3.1"
}
}

1
client/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
client/src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

46
client/src/App.jsx Normal file
View File

@@ -0,0 +1,46 @@
import React, { useState } from 'react';
import Sidebar from './components/Sidebar';
import TenantManager from './components/TenantManager';
import EndpointManager from './components/EndpointManager';
import DebugTabs from './components/DebugTabs';
import Auth from './components/Auth';
import Settings from './components/Settings';
import './index.css';
function App() {
const [activeTab, setActiveTab] = useState('debug');
const [showSettings, setShowSettings] = useState(false);
const [user, setUser] = useState(() => {
const saved = localStorage.getItem('api_debug_user');
return saved ? JSON.parse(saved) : null;
});
const handleLogout = () => {
localStorage.removeItem('api_debug_token');
localStorage.removeItem('api_debug_user');
setUser(null);
};
if (!user) {
return <Auth onLogin={setUser} />;
}
return (
<div className="app-container">
<Sidebar
currentTab={activeTab}
setTab={setActiveTab}
onLogout={handleLogout}
onSettings={() => setShowSettings(true)}
/>
<main className="main-content" style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: activeTab === 'tenants' ? 'flex' : 'none', flexDirection: 'column', flex: 1, minHeight: 0 }}><TenantManager /></div>
<div style={{ display: activeTab === 'endpoints' ? 'flex' : 'none', flexDirection: 'column', flex: 1, minHeight: 0 }}><EndpointManager /></div>
<div style={{ display: activeTab === 'debug' ? 'flex' : 'none', flexDirection: 'column', flex: 1, minHeight: 0 }}><DebugTabs /></div>
</main>
{showSettings && <Settings onClose={() => setShowSettings(false)} />}
</div>
);
}
export default App;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,730 @@
import React, { useState, useEffect, useMemo } from 'react';
import toast from '../utils/toast';
import { authFetch } from '../utils/api';
const TENANT_API = 'http://localhost:5001/api/tenants';
const ENDPOINT_API = 'http://localhost:5001/api/endpoints';
const DEBUG_API = 'http://localhost:5001/api/debug/execute-pp';
const HISTORY_API = 'http://localhost:5001/api/debug/history';
const ApiDebugger = ({ onTitleChange, storageKey = 'tab_default' }) => {
const [tenants, setTenants] = useState([]);
const [endpoints, setEndpoints] = useState([]);
const [filteredEndpoints, setFilteredEndpoints] = useState([]);
// Form state
const [selectedTenantId, setSelectedTenantId] = useState(localStorage.getItem(`${storageKey}_tenantId`) || '');
const [selectedEndpointId, setSelectedEndpointId] = useState(localStorage.getItem(`${storageKey}_endpointId`) || '');
const [url, setUrl] = useState('');
const [method, setMethod] = useState('GET');
const [body, setBody] = useState('');
const [queryParams, setQueryParams] = useState('');
const [bodyError, setBodyError] = useState('');
const [queryParamsError, setQueryParamsError] = useState('');
const [debugTitle, setDebugTitle] = useState('未命名调试');
// Response state
const [response, setResponse] = useState(null);
const [loading, setLoading] = useState(false);
// Search state
const [searchQuery, setSearchQuery] = useState('');
const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
const [matchCount, setMatchCount] = useState(0);
// UX state
const [copyResText, setCopyResText] = useState('复制结果');
const [copyCurlText, setCopyCurlText] = useState('复制 CURL');
// History state
const [isHistoryModalOpen, setIsHistoryModalOpen] = useState(false);
const [historyRecords, setHistoryRecords] = useState([]);
const [selectedHistory, setSelectedHistory] = useState(null);
const [historySearchQuery, setHistorySearchQuery] = useState('');
// History response search state
const [historyResSearchQuery, setHistoryResSearchQuery] = useState('');
const [historyResMatchIndex, setHistoryResMatchIndex] = useState(0);
const [historyResMatchCount, setHistoryResMatchCount] = useState(0);
useEffect(() => {
const fetchInitialData = async () => {
try {
const [tenantsRes, endpointsRes] = await Promise.all([
authFetch(TENANT_API),
authFetch(ENDPOINT_API)
]);
const tenantsData = await tenantsRes.json();
const endpointsData = await endpointsRes.json();
setTenants(tenantsData);
setEndpoints(endpointsData);
// Load initial selection from localStorage
const lastTenantId = localStorage.getItem(`${storageKey}_tenantId`);
const lastEndpointId = localStorage.getItem(`${storageKey}_endpointId`);
if (lastTenantId && tenantsData.some(t => t.id === Number(lastTenantId))) {
const initialTenantId = Number(lastTenantId);
setSelectedTenantId(initialTenantId);
const initialTenant = tenantsData.find(t => t.id === initialTenantId);
// Filter endpoints for initialized tenant
const applicableEndpoints = endpointsData.filter(ep => ep.category === initialTenant.type);
setFilteredEndpoints(applicableEndpoints);
if (lastEndpointId && applicableEndpoints.some(e => e.id === Number(lastEndpointId))) {
const initialEndpointId = Number(lastEndpointId);
setSelectedEndpointId(initialEndpointId);
const initialEndpoint = applicableEndpoints.find(e => e.id === initialEndpointId);
setUrl(initialEndpoint.url);
setMethod(initialEndpoint.method);
// Automatically set body and queryParams if present
if (initialEndpoint.body) {
setBody(typeof initialEndpoint.body === 'object' ? JSON.stringify(initialEndpoint.body, null, 2) : initialEndpoint.body);
} else {
setBody('');
}
if (initialEndpoint.query_params) {
setQueryParams(typeof initialEndpoint.query_params === 'object' ? JSON.stringify(initialEndpoint.query_params, null, 2) : initialEndpoint.query_params);
} else {
setQueryParams('');
}
}
}
} catch (error) {
console.error('Failed to fetch initial data:', error);
toast.error('加载初始数据失败');
}
};
fetchInitialData();
}, []);
useEffect(() => {
const tenant = tenants.find(t => t.id === parseInt(selectedTenantId));
const endpoint = endpoints.find(e => e.id === parseInt(selectedEndpointId));
if (tenant && endpoint && endpoint.category === tenant.type) {
setDebugTitle(`${tenant.name} + ${endpoint.name}`);
// setRequestBody(endpoint.body || ''); // This is now handled by handleEndpointChange or initial load
} else if (tenant && endpoint) {
// If they don't match, gracefully handle or clear body
setDebugTitle(`${tenant.name} + ${endpoint.name} (类型不匹配)`);
// setRequestBody(endpoint.body || ''); // This is now handled by handleEndpointChange or initial load
} else {
setDebugTitle('未命名调试');
}
}, [selectedTenantId, selectedEndpointId, tenants, endpoints]);
// Save strictly to local storage when selections change
useEffect(() => {
if (selectedTenantId) localStorage.setItem(`${storageKey}_tenantId`, selectedTenantId);
if (selectedEndpointId) localStorage.setItem(`${storageKey}_endpointId`, selectedEndpointId);
}, [selectedTenantId, selectedEndpointId]);
// Notify parent of title changes (for tab label)
useEffect(() => {
onTitleChange?.(debugTitle);
}, [debugTitle]);
// Update match count when search query or response changes
useEffect(() => {
setCurrentMatchIndex(0);
if (response && searchQuery) {
const displayData = response?.data !== undefined ? response.data : response;
const text = JSON.stringify(displayData, null, 2);
const safeQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(safeQuery, 'gi');
const matches = text.match(regex);
setMatchCount(matches ? matches.length : 0);
} else {
setMatchCount(0);
}
}, [searchQuery, response]);
const handleFormatJson = (jsonString, setter, errorSetter) => {
if (!jsonString.trim()) {
errorSetter('');
return;
}
try {
const parsed = JSON.parse(jsonString);
setter(JSON.stringify(parsed, null, 2));
errorSetter('');
} catch (err) {
errorSetter('JSON 格式不正确');
}
};
const handleSend = async () => {
if (!selectedTenantId || !selectedEndpointId) {
toast.error('请先选择租户和接口');
return;
}
let bodyStr = body.trim();
let queryParamsStr = queryParams.trim();
// Validate Body JSON
if (bodyStr) {
try {
JSON.parse(bodyStr);
setBodyError('');
} catch (err) {
setBodyError('Body 参数 JSON 格式不正确,请修改后再发送');
return;
}
}
// Validate Query Params JSON
if (queryParamsStr) {
try {
JSON.parse(queryParamsStr);
setQueryParamsError('');
} catch (err) {
setQueryParamsError('Query Params JSON 格式不正确,请修改后再发送');
return;
}
}
setLoading(true);
setResponse(null);
setSearchQuery(''); // Clear search on new request
try {
let parsedBody = {};
if (bodyStr) parsedBody = JSON.parse(bodyStr);
let parsedQueryParams = {};
if (queryParamsStr) parsedQueryParams = JSON.parse(queryParamsStr);
const payload = {
tenantId: selectedTenantId,
endpointId: selectedEndpointId,
url,
method,
body: parsedBody,
queryParams: parsedQueryParams
};
const res = await authFetch(DEBUG_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
setResponse(data);
} catch (err) {
setResponse({ error: err.message });
} finally {
setLoading(false);
}
};
const handleSearchKeyDown = (e) => {
if (e.key === 'Enter' && matchCount > 0) {
e.preventDefault();
const nextIndex = (currentMatchIndex + 1) % matchCount;
setCurrentMatchIndex(nextIndex);
const el = document.getElementById(`search-match-${nextIndex}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
};
const handleCopyResponse = () => {
const displayData = response?.data !== undefined ? response.data : response;
navigator.clipboard.writeText(JSON.stringify(displayData, null, 2));
setCopyResText('✅ 已复制');
setTimeout(() => setCopyResText('复制结果'), 2000);
};
const handleCopyCurl = () => {
if (response && response.curlCmd) {
navigator.clipboard.writeText(response.curlCmd);
setCopyCurlText('✅ 已复制');
setTimeout(() => setCopyCurlText('复制完整 CURL'), 2000);
}
};
const handleExport = () => {
const displayData = response?.data !== undefined ? response.data : response;
const blob = new Blob([JSON.stringify(displayData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${debugTitle}.json`;
a.click();
};
const loadHistory = async () => {
try {
const res = await authFetch(HISTORY_API);
const data = await res.json();
// 只保留当前租户 + 接口匹配的记录
const filtered = data.filter(r =>
String(r.tenant_id) === String(selectedTenantId) &&
String(r.endpoint_id) === String(selectedEndpointId)
);
setHistoryRecords(filtered);
setSelectedHistory(filtered.length > 0 ? filtered[0] : null);
setIsHistoryModalOpen(true);
} catch (err) {
console.error('加载历史记录失败:', err);
toast.error('加载历史记录失败');
}
};
const highlightText = (text, highlight) => {
if (!highlight.trim() || matchCount === 0) {
return <span>{text}</span>;
}
const safeQuery = highlight.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${safeQuery})`, 'gi');
const parts = text.split(regex);
let matchIndexTracker = -1;
return (
<span>
{parts.map((part, i) => {
if (regex.test(part)) {
matchIndexTracker++;
const isCurrent = matchIndexTracker === currentMatchIndex;
return (
<span
key={i}
id={`search-match-${matchIndexTracker}`}
style={{
backgroundColor: isCurrent ? '#ff9800' : 'yellow',
color: isCurrent ? '#fff' : '#000',
fontWeight: isCurrent ? 'bold' : 'normal',
padding: '0 2px',
borderRadius: '2px',
boxShadow: isCurrent ? '0 0 0 2px rgba(255, 152, 0, 0.5)' : 'none',
transition: 'all 0.2s'
}}
>
{part}
</span>
);
} else {
return <span key={i}>{part}</span>;
}
})}
</span>
);
};
const filteredHistory = historyRecords.filter(record => {
if (!historySearchQuery) return true;
const searchLower = historySearchQuery.toLowerCase();
const title = `${record.tenant_name} + ${record.endpoint_name}`.toLowerCase();
const dateStr = new Date(record.created_at).toLocaleString().toLowerCase();
return title.includes(searchLower) || dateStr.includes(searchLower);
});
// Update match count for history response search
useEffect(() => {
setHistoryResMatchIndex(0);
if (selectedHistory && selectedHistory.response_data && historyResSearchQuery) {
try {
const text = JSON.stringify(JSON.parse(selectedHistory.response_data), null, 2);
const safeQuery = historyResSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(safeQuery, 'gi');
const matches = text.match(regex);
setHistoryResMatchCount(matches ? matches.length : 0);
} catch (e) {
setHistoryResMatchCount(0);
}
} else {
setHistoryResMatchCount(0);
}
}, [historyResSearchQuery, selectedHistory]);
const handleHistorySearchKeyDown = (e) => {
if (e.key === 'Enter' && historyResMatchCount > 0) {
e.preventDefault();
const nextIndex = (historyResMatchIndex + 1) % historyResMatchCount;
setHistoryResMatchIndex(nextIndex);
const el = document.getElementById(`history-search-match-${nextIndex}`);
if (el) {
// Ensure the pre container is scrollable and we scroll within it
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
};
const highlightHistoryText = (text, highlight) => {
if (!highlight.trim() || historyResMatchCount === 0) {
return <span>{text}</span>;
}
const safeQuery = highlight.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${safeQuery})`, 'gi');
const parts = text.split(regex);
let matchIndexTracker = -1;
return (
<span>
{parts.map((part, i) => {
if (regex.test(part)) {
matchIndexTracker++;
const isCurrent = matchIndexTracker === historyResMatchIndex;
return (
<span
key={i}
id={`history-search-match-${matchIndexTracker}`}
style={{
backgroundColor: isCurrent ? '#ff9800' : 'yellow',
color: isCurrent ? '#fff' : '#000',
fontWeight: isCurrent ? 'bold' : 'normal',
padding: '0 2px',
borderRadius: '2px',
boxShadow: isCurrent ? '0 0 0 2px rgba(255, 152, 0, 0.5)' : 'none',
transition: 'all 0.2s'
}}
>
{part}
</span>
);
} else {
return <span key={i}>{part}</span>;
}
})}
</span>
);
};
const handleTenantChange = (e) => {
const tenantId = Number(e.target.value);
setSelectedTenantId(tenantId);
localStorage.setItem(`${storageKey}_tenantId`, tenantId);
// Filter endpoints
const tenant = tenants.find(t => t.id === tenantId);
if (tenant) {
const applicableEndpoints = endpoints.filter(ep => ep.category === tenant.type);
setFilteredEndpoints(applicableEndpoints);
} else {
setFilteredEndpoints([]);
}
// Reset endpoint selection when tenant changes
setSelectedEndpointId('');
localStorage.removeItem(`${storageKey}_endpointId`);
setUrl('');
setMethod('GET');
setBody('');
setQueryParams('');
setBodyError('');
setQueryParamsError('');
};
const handleEndpointChange = (e) => {
const endpointId = Number(e.target.value);
setSelectedEndpointId(endpointId);
localStorage.setItem(`${storageKey}_endpointId`, endpointId);
const endpoint = endpoints.find(ep => ep.id === endpointId);
if (endpoint) {
setUrl(endpoint.url || '');
setMethod(endpoint.method || 'GET');
if (endpoint.body) {
setBody(typeof endpoint.body === 'object' ? JSON.stringify(endpoint.body, null, 2) : endpoint.body);
} else {
setBody('');
}
if (endpoint.query_params) {
setQueryParams(typeof endpoint.query_params === 'object' ? JSON.stringify(endpoint.query_params, null, 2) : endpoint.query_params);
} else {
setQueryParams('');
}
setBodyError('');
setQueryParamsError('');
}
};
return (
<div style={{ width: '100%', maxWidth: '1600px', margin: '0 auto', height: '100%', display: 'flex', flexDirection: 'column' }}>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<h1>{debugTitle}</h1>
</header>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(350px, 1fr) 2.5fr', gap: '1.5rem', flex: 1, minHeight: 0, paddingBottom: '2rem' }}>
{/* 左侧:参数配置区域 */}
<div className="card" style={{ display: 'flex', flexDirection: 'column', gap: '1rem', height: '100%' }}>
<div className="form-group">
<label>选择租户</label>
<select value={selectedTenantId} onChange={handleTenantChange}>
<option value="">-- 请选择租户 --</option>
{tenants.map(t => <option key={t.id} value={t.id}>{t.name} - ({t.type})</option>)}
</select>
</div>
<div className="form-group">
<label>选择接口</label>
<select value={selectedEndpointId} onChange={handleEndpointChange}>
<option value="">-- 请选择接口 --</option>
{filteredEndpoints
.map(ep => <option key={ep.id} value={ep.id}>[{ep.method}] {ep.name}</option>)
}
</select>
</div>
<div className="form-group">
<label>接口地址 (URL)</label>
<input value={endpoints.find(e => e.id === parseInt(selectedEndpointId))?.url || ''} disabled style={{ opacity: 0.7 }} />
</div>
{tenants.find(t => t.id === Number(selectedTenantId))?.type !== '标品PP' && false && (
<div className="form-group" style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: '150px', marginBottom: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<label style={{ margin: 0 }}>Query Param (JSON)</label>
{queryParamsError && <span style={{ color: '#ef4444', fontSize: '0.75rem' }}>{queryParamsError}</span>}
</div>
<textarea
style={{ flex: 1, fontFamily: 'monospace', fontSize: '0.875rem', borderColor: queryParamsError ? '#ef4444' : 'var(--border)', outlineColor: queryParamsError ? '#ef4444' : undefined }}
value={queryParams}
onChange={e => {
setQueryParams(e.target.value);
if (queryParamsError) setQueryParamsError('');
}}
onBlur={() => handleFormatJson(queryParams, setQueryParams, setQueryParamsError)}
placeholder='{ "entCode": "xxx" }'
/>
</div>
)}
<div className="form-group" style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: '150px', marginBottom: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<label style={{ margin: 0 }}>Body 参数 (JSON)</label>
{bodyError && <span style={{ color: '#ef4444', fontSize: '0.75rem' }}>{bodyError}</span>}
</div>
<textarea
style={{ flex: 1, fontFamily: 'monospace', fontSize: '0.875rem', borderColor: bodyError ? '#ef4444' : 'var(--border)', outlineColor: bodyError ? '#ef4444' : undefined }}
value={body}
onChange={e => {
setBody(e.target.value);
if (bodyError) setBodyError('');
}}
onBlur={() => handleFormatJson(body, setBody, setBodyError)}
placeholder='{ "key": "value" }'
/>
</div>
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem' }}>
<button className={`btn-primary ${loading ? 'opacity-50' : ''}`} style={{ flex: 1, justifyContent: 'center' }} onClick={handleSend} disabled={loading}>
<span style={{ display: 'flex', alignItems: 'center' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg></span>
{loading ? '请求中...' : '发送请求'}
</button>
</div>
</div>
{/* 右侧:结果展示区域 */}
<div className="card" style={{ display: 'flex', flexDirection: 'column', gap: '1rem', overflow: 'hidden' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '1rem' }}>
{/* 搜索框放大 */}
<div style={{ position: 'relative', flex: 1, maxWidth: '500px' }}>
<input
type="text"
placeholder="搜索关键字 (按回车键可跳转下一个)..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onKeyDown={handleSearchKeyDown}
style={{ padding: '0.5rem 1rem', width: '100%', fontSize: '0.875rem' }}
/>
{matchCount > 0 && (
<div style={{ position: 'absolute', right: '1rem', top: '50%', transform: 'translateY(-50%)', fontSize: '0.75rem', color: 'var(--text-muted)' }}>
{currentMatchIndex + 1} / {matchCount}
</div>
)}
</div>
{/* 复制结果和导出按钮移至右上角高位 */}
<div style={{ display: 'flex', gap: '0.75rem', flexShrink: 0 }}>
{response && (
<>
<button className="btn-secondary" onClick={handleCopyCurl} disabled={!response?.curlCmd} style={{ fontSize: '0.875rem', backgroundColor: '#10b981', color: 'white', border: 'none' }}>
<span style={{ display: 'flex', alignItems: 'center' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg></span>
{copyCurlText}
</button>
<button className="btn-secondary" onClick={handleCopyResponse} style={{ fontSize: '0.875rem' }}>
<span style={{ display: 'flex', alignItems: 'center' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg></span>
{copyResText}
</button>
<button className="btn-secondary" onClick={handleExport} style={{ fontSize: '0.875rem' }}>
<span style={{ display: 'flex', alignItems: 'center' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg></span>
导出 JSON
</button>
</>
)}
<button className="btn-secondary" onClick={loadHistory} style={{ fontSize: '0.875rem' }} disabled={!selectedTenantId || !selectedEndpointId} title={!selectedTenantId || !selectedEndpointId ? '请先选择租户和接口' : '查看当前接口的请求记录'}>
<span style={{ display: 'flex', alignItems: 'center' }}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg></span>
请求记录
</button>
</div>
</div>
<div style={{
flex: 1,
background: '#020617',
borderRadius: '0.5rem',
padding: '1.5rem',
overflow: 'auto',
fontFamily: 'monospace',
fontSize: '0.875rem',
whiteSpace: 'pre-wrap',
color: '#34d399',
position: 'relative',
border: '1px solid #1e293b'
}}>
{response ? (
<div style={{ wordBreak: 'break-all' }}>
{highlightText(JSON.stringify(response?.data !== undefined ? response.data : response, null, 2), searchQuery)}
</div>
) : <div style={{ color: 'var(--text-muted)', textAlign: 'center', marginTop: '20%' }}>暂无响应数据</div>}
</div>
</div>
</div>
{/* 历史记录弹窗 */}
{isHistoryModalOpen && (
<div className="modal-overlay" onClick={(e) => {
if (e.target === e.currentTarget) {
setIsHistoryModalOpen(false);
setSelectedHistory(null);
setHistorySearchQuery('');
setHistoryResSearchQuery('');
}
}}>
{/* 增加弹窗宽度从 1000px 到 1400px高度适配 */}
<div className="modal" style={{ maxWidth: '1400px', width: '95%', maxHeight: '95vh', display: 'flex', flexDirection: 'column' }}>
<h2 style={{ marginBottom: '1.5rem', fontWeight: 'bold' }}>请求历史记录</h2>
<div style={{ display: 'flex', gap: '1.5rem', flex: 1, minHeight: 0, height: '75vh' }}>
<div style={{ flex: '0 0 350px', overflowY: 'hidden', borderRight: '1px solid var(--border)', paddingRight: '1rem', display: 'flex', flexDirection: 'column' }}>
<div style={{ marginBottom: '1rem' }}>
<input
type="text"
placeholder="搜索租户、接口、日期..."
value={historySearchQuery}
onChange={e => setHistorySearchQuery(e.target.value)}
style={{ fontSize: '0.875rem' }}
/>
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
{filteredHistory.length === 0 ? <p className="text-muted" style={{ padding: '1rem' }}>暂无历史记录</p> : null}
{filteredHistory.map(record => (
<div
key={record.id}
onClick={() => setSelectedHistory(record)}
style={{
padding: '1rem',
borderBottom: '1px solid var(--border)',
cursor: 'pointer',
backgroundColor: selectedHistory?.id === record.id ? 'rgba(99, 102, 241, 0.1)' : 'transparent',
borderLeft: selectedHistory?.id === record.id ? '3px solid var(--primary)' : '3px solid transparent',
borderRadius: '0.25rem',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '0.5rem'
}}
>
<div style={{ minWidth: 0, overflow: 'hidden' }}>
<div style={{ fontWeight: '600', fontSize: '0.875rem', color: selectedHistory?.id === record.id ? 'var(--text)' : 'inherit', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{record.tenant_name} + {record.endpoint_name}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '0.5rem' }}>
{new Date(record.created_at).toLocaleString()}
</div>
</div>
<button
className="btn-primary"
style={{ padding: '0.4rem 0.6rem', fontSize: '0.75rem', backgroundColor: '#10b981', color: 'white', flexShrink: 0 }}
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(record.curl_cmd);
const btn = e.target;
const originText = btn.innerText;
btn.innerText = '✅ 已复制';
btn.style.backgroundColor = '#059669';
setTimeout(() => {
btn.innerText = originText;
btn.style.backgroundColor = '#10b981';
}, 2000);
}}
>
复制 CURL
</button>
</div>
))}
</div>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden' }}>
{selectedHistory ? (
<div className="form-group" style={{ flex: 1, display: 'flex', flexDirection: 'column', margin: 0, minHeight: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem', flexShrink: 0 }}>
<label style={{ margin: 0 }}>返回结果</label>
{/* 新增:历史结果搜索和复制区 */}
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<div style={{ position: 'relative', width: '300px' }}>
<input
type="text"
placeholder="搜索结果关键字 (回车下一个)..."
value={historyResSearchQuery}
onChange={e => setHistoryResSearchQuery(e.target.value)}
onKeyDown={handleHistorySearchKeyDown}
style={{ padding: '0.4rem 0.8rem', width: '100%', fontSize: '0.75rem' }}
/>
{historyResMatchCount > 0 && (
<div style={{ position: 'absolute', right: '0.8rem', top: '50%', transform: 'translateY(-50%)', fontSize: '0.7rem', color: 'var(--text-muted)' }}>
{historyResMatchIndex + 1} / {historyResMatchCount}
</div>
)}
</div>
<button
className="btn-secondary"
style={{ padding: '0.4rem 0.8rem', fontSize: '0.75rem' }}
onClick={(e) => {
const resText = selectedHistory.response_data ? JSON.stringify(JSON.parse(selectedHistory.response_data), null, 2) : '';
navigator.clipboard.writeText(resText);
const btn = e.target;
const originText = btn.innerText;
btn.innerText = '✅ 已复制';
setTimeout(() => btn.innerText = originText, 2000);
}}
>
复制结果
</button>
</div>
</div>
<div style={{ flex: 1, backgroundColor: '#020617', borderRadius: '0.5rem', border: '1px solid #1e293b', overflow: 'hidden', display: 'flex' }}>
<pre style={{ flex: 1, margin: 0, padding: '1rem', color: '#34d399', overflow: 'auto', fontSize: '0.875rem' }}>
{selectedHistory.response_data ? highlightHistoryText(JSON.stringify(JSON.parse(selectedHistory.response_data), null, 2), historyResSearchQuery) : '无结果'}
</pre>
</div>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--text-muted)' }}>
请在左侧选择一条历史记录查看详情
</div>
)}
</div>
</div>
<div className="modal-actions" style={{ marginTop: '1.5rem', display: 'flex', justifyContent: 'flex-end', flexShrink: 0 }}>
<button className="btn-secondary" onClick={() => { setIsHistoryModalOpen(false); setSelectedHistory(null); setHistorySearchQuery(''); setHistoryResSearchQuery(''); }}>关闭弹窗</button>
</div>
</div>
</div>
)}
</div>
);
};
export default ApiDebugger;

View File

@@ -0,0 +1,131 @@
import React, { useState } from 'react';
import toast from '../utils/toast';
function Auth({ onLogin }) {
const [isLogin, setIsLogin] = useState(true);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
if (!username || !password) {
toast.error('请输入用户名和密码');
return;
}
setLoading(true);
const endpoint = isLogin ? '/api/auth/login' : '/api/auth/register';
try {
const response = await fetch(`http://localhost:5001${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (!response.ok) {
toast.error(data.error || '请求失败');
} else {
if (isLogin) {
toast.success('登录成功');
localStorage.setItem('api_debug_token', data.token);
localStorage.setItem('api_debug_user', JSON.stringify(data.user));
onLogin(data.user);
} else {
toast.success('注册成功,请登录');
setIsLogin(true); // switch to login mode
setPassword('');
}
}
} catch (error) {
console.error('Auth error:', error);
toast.error('网络错误,请稍后再试');
} finally {
setLoading(false);
}
};
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
width: '100vw',
background: 'linear-gradient(135deg, #f0f7fa 0%, #e0eaf5 100%)',
}}>
<div style={{
background: 'rgba(255, 255, 255, 0.8)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
padding: '3rem',
borderRadius: '1rem',
boxShadow: '0 20px 40px -15px rgba(0, 50, 100, 0.1), 0 0 20px rgba(255, 255, 255, 0.5) inset',
border: '1px solid rgba(255, 255, 255, 0.6)',
width: '100%',
maxWidth: '400px',
textAlign: 'center'
}}>
<h1 style={{
color: 'var(--primary-dark)',
marginBottom: '0.5rem',
fontSize: '1.75rem',
fontWeight: 700
}}>API Debug</h1>
<p style={{ color: 'var(--text-muted)', marginBottom: '2rem' }}>
{isLogin ? '登录以继续调试' : '创建您的账号'}
</p>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div className="form-group" style={{ textAlign: 'left' }}>
<label>用户名</label>
<input
type="text"
placeholder="请输入用户名"
value={username}
onChange={e => setUsername(e.target.value)}
required
/>
</div>
<div className="form-group" style={{ textAlign: 'left' }}>
<label>密码</label>
<input
type="password"
placeholder="请输入密码"
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
</div>
<button
type="submit"
className="btn btn-primary"
style={{ marginTop: '1rem', padding: '0.75rem', fontSize: '1rem', fontWeight: 600 }}
disabled={loading}
>
{loading ? '处理中...' : (isLogin ? '登录' : '注册')}
</button>
<div style={{ marginTop: '1rem', fontSize: '0.9rem', color: 'var(--text-muted)' }}>
{isLogin ? '还没有账号?' : '已有账号?'}
<span
style={{ color: 'var(--primary)', cursor: 'pointer', marginLeft: '0.5rem', fontWeight: 500 }}
onClick={() => setIsLogin(!isLogin)}
>
{isLogin ? '立即注册' : '返回登录'}
</span>
</div>
</form>
</div>
</div>
);
}
export default Auth;

View File

@@ -0,0 +1,190 @@
import React, { useState, useRef, useEffect } from 'react';
import ApiDebugger from './ApiDebugger';
const getUsername = () => {
try {
const user = JSON.parse(localStorage.getItem('api_debug_user'));
return user?.username || 'default';
} catch { return 'default'; }
};
const getStorageKey = () => `debug_tabs_state_${getUsername()}`;
const loadState = () => {
try {
const saved = JSON.parse(localStorage.getItem(getStorageKey()));
if (saved && Array.isArray(saved.tabs) && saved.tabs.length > 0) return saved;
} catch {}
return null;
};
const DebugTabs = () => {
const username = getUsername();
const storageKey = `debug_tabs_state_${username}`;
const tabStoragePrefix = `tab_${username}`;
const saved = loadState();
const nextId = useRef(saved ? Math.max(...saved.tabs.map(t => t.id)) + 1 : 2);
const [tabs, setTabs] = useState(saved?.tabs || [{ id: 1 }]);
const [activeTabId, setActiveTabId] = useState(saved?.activeTabId || 1);
const [tabTitles, setTabTitles] = useState(saved?.tabTitles || { 1: '新建调试' });
useEffect(() => {
localStorage.setItem(storageKey, JSON.stringify({ tabs, activeTabId, tabTitles }));
}, [tabs, activeTabId, tabTitles]);
const addTab = () => {
const id = nextId.current++;
setTabs(prev => [...prev, { id }]);
setTabTitles(prev => ({ ...prev, [id]: '新建调试' }));
setActiveTabId(id);
};
const closeTab = (id, e) => {
e.stopPropagation();
if (tabs.length === 1) return;
const idx = tabs.findIndex(t => t.id === id);
const remaining = tabs.filter(t => t.id !== id);
setTabs(remaining);
setTabTitles(prev => { const next = { ...prev }; delete next[id]; return next; });
if (activeTabId === id) setActiveTabId(remaining[Math.max(0, idx - 1)].id);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', width: '100%' }}>
{/* 标签栏 */}
<div style={{
display: 'flex',
alignItems: 'flex-end',
borderBottom: '2px solid var(--border)',
marginBottom: '1.25rem',
overflowX: 'auto',
flexShrink: 0,
gap: '4px',
paddingLeft: '2px',
}}>
{tabs.map(tab => {
const isActive = activeTabId === tab.id;
return (
<div
key={tab.id}
onClick={() => setActiveTabId(tab.id)}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.55rem 1rem 0.55rem 1.1rem',
cursor: 'pointer',
borderRadius: '6px 6px 0 0',
border: isActive
? '2px solid var(--border)'
: '2px solid transparent',
borderBottom: isActive ? '2px solid var(--card-bg, #ffffff)' : '2px solid transparent',
marginBottom: isActive ? '-2px' : '0',
background: isActive
? 'var(--card-bg, #ffffff)'
: 'rgba(0,0,0,0.04)',
color: isActive ? 'var(--text, #0f172a)' : 'var(--text-muted, #64748b)',
fontSize: '0.85rem',
whiteSpace: 'nowrap',
maxWidth: '220px',
minWidth: '110px',
userSelect: 'none',
transition: 'background 0.15s, color 0.15s',
position: 'relative',
}}
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'rgba(0,0,0,0.07)'; }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'rgba(0,0,0,0.04)'; }}
>
{/* 活动标签左侧色条 */}
{isActive && (
<span style={{
position: 'absolute',
left: 0, top: '20%', height: '60%',
width: '3px',
background: 'var(--primary, #6366f1)',
borderRadius: '0 2px 2px 0',
}} />
)}
<span style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
flex: 1,
fontWeight: isActive ? 600 : 400,
fontSize: isActive ? '0.875rem' : '0.85rem',
}}>
{tabTitles[tab.id] || '新建调试'}
</span>
{tabs.length > 1 && (
<span
onClick={(e) => closeTab(tab.id, e)}
title="关闭标签"
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '16px',
height: '16px',
borderRadius: '50%',
fontSize: '0.75rem',
flexShrink: 0,
opacity: 0.45,
cursor: 'pointer',
transition: 'opacity 0.15s, background 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.background = 'rgba(239,68,68,0.2)'; }}
onMouseLeave={e => { e.currentTarget.style.opacity = '0.45'; e.currentTarget.style.background = 'transparent'; }}
>
×
</span>
)}
</div>
);
})}
{/* 新增标签按钮 */}
<button
onClick={addTab}
title="新建标签"
style={{
alignSelf: 'center',
marginBottom: '2px',
padding: '0.25rem 0.6rem',
background: 'none',
border: '1px dashed var(--border)',
borderRadius: '4px',
cursor: 'pointer',
color: 'var(--text-muted)',
fontSize: '1rem',
lineHeight: 1,
flexShrink: 0,
transition: 'border-color 0.15s, color 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--primary)'; e.currentTarget.style.color = 'var(--primary)'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border)'; e.currentTarget.style.color = 'var(--text-muted)'; }}
>
+
</button>
</div>
{/* 标签内容区保持全部挂载display 切换保留状态 */}
{tabs.map(tab => (
<div
key={tab.id}
style={{
display: activeTabId === tab.id ? 'flex' : 'none',
flexDirection: 'column',
flex: 1,
minHeight: 0,
}}
>
<ApiDebugger
storageKey={`${tabStoragePrefix}_${tab.id}`}
onTitleChange={(title) => setTabTitles(prev => ({ ...prev, [tab.id]: title }))}
/>
</div>
))}
</div>
);
};
export default DebugTabs;

View File

@@ -0,0 +1,432 @@
import React, { useState, useEffect, useMemo } from 'react';
import toast from '../utils/toast';
import { authFetch } from '../utils/api';
const API_BASE = 'http://localhost:5001/api/endpoints';
const SETTINGS_API = 'http://localhost:5001/api/settings';
const CATEGORY_COLORS = {
'旗舰版PP': '#3b82f6', '旗舰版ATS': '#6366f1',
'标品PP': '#10b981', '标品ATS': '#8b5cf6', '国际版ATS': '#d946ef',
};
const METHOD_COLORS = { GET: '#10b981', POST: '#3b82f6', PUT: '#f59e0b', DELETE: '#ef4444', PATCH: '#f97316' };
const categoryColor = (cat) => CATEGORY_COLORS[cat] || '#64748b';
const methodColor = (m) => METHOD_COLORS[m] || '#64748b';
const EMPTY_FORM = { name: '', url: '', method: 'GET', body: '', category: '旗舰版PP', module: '', api_code: '', user_name: '' };
// ─── 主组件 ───────────────────────────────────────────────────────────────
const EndpointManager = () => {
const [endpoints, setEndpoints] = useState([]);
const [settings, setSettings] = useState({ categories: [], modules: [], defaultCategories: [], defaultModules: [] });
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [expandedCats, setExpandedCats] = useState(new Set());
const [expandedMods, setExpandedMods] = useState(new Set());
const [hoveredRow, setHoveredRow] = useState(null);
// 右侧面板:{ endpoint: null|object, isNew: bool }
const [panel, setPanel] = useState(null);
const [deleteModal, setDeleteModal] = useState({ isOpen: false, endpointId: null });
const fetchAll = async () => {
setLoading(true);
try {
const [epRes, stRes] = await Promise.all([authFetch(API_BASE), authFetch(SETTINGS_API)]);
setEndpoints(await epRes.json());
setSettings(await stRes.json());
} catch (err) { console.error(err); }
finally { setLoading(false); }
};
useEffect(() => { fetchAll(); }, []);
const treeData = useMemo(() => {
const lower = search.toLowerCase();
const filtered = search
? endpoints.filter(ep => ep.name.toLowerCase().includes(lower) || ep.url?.toLowerCase().includes(lower))
: endpoints;
const tree = {};
settings.categories.forEach(cat => { tree[cat] = {}; });
filtered.forEach(ep => {
const cat = ep.category || '未分类';
const mod = ep.module || '未分组';
if (!tree[cat]) tree[cat] = {};
if (!tree[cat][mod]) tree[cat][mod] = [];
tree[cat][mod].push(ep);
});
return tree;
}, [endpoints, settings.categories, search]);
useEffect(() => {
if (search) {
setExpandedCats(new Set(Object.keys(treeData)));
const mods = new Set();
Object.entries(treeData).forEach(([cat, modules]) =>
Object.keys(modules).forEach(mod => mods.add(`${cat}::${mod}`))
);
setExpandedMods(mods);
}
}, [search]);
const toggleCat = (cat) => setExpandedCats(prev => {
const next = new Set(prev);
next.has(cat) ? next.delete(cat) : next.add(cat);
return next;
});
const toggleMod = (cat, mod) => {
const key = `${cat}::${mod}`;
setExpandedMods(prev => {
const next = new Set(prev);
next.has(key) ? next.delete(key) : next.add(key);
return next;
});
};
const openNew = () => setPanel({ endpoint: null, isNew: true });
const openEdit = (ep) => setPanel({ endpoint: ep, isNew: false });
const openCopy = (ep) => setPanel({ endpoint: { ...ep, id: undefined, name: `${ep.name} (Copy)` }, isNew: true });
const confirmDelete = async () => {
if (!deleteModal.endpointId) return;
try {
await authFetch(`${API_BASE}/${deleteModal.endpointId}`, { method: 'DELETE' });
toast.success('删除成功');
if (panel?.endpoint?.id === deleteModal.endpointId) setPanel(null);
fetchAll();
} catch { toast.error('删除失败'); }
finally { setDeleteModal({ isOpen: false, endpointId: null }); }
};
const catCount = (cat) => Object.values(treeData[cat] || {}).reduce((s, arr) => s + arr.length, 0);
const selectedId = panel?.endpoint?.id;
return (
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* 顶部标题栏 */}
<header style={{ flexShrink: 0 }}>
<h1>接口管理</h1>
<button className="btn-primary" onClick={openNew}>+ 新增接口</button>
</header>
{/* 左右主体 */}
<div style={{ display: 'flex', gap: '1rem', flex: 1, minHeight: 0 }}>
{/* 左侧树 */}
<div className="card" style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', padding: '1rem', overflow: 'hidden' }}>
<div style={{ marginBottom: '0.75rem', display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<input
type="text"
placeholder="搜索接口名称或 URL..."
value={search}
onChange={e => setSearch(e.target.value)}
style={{ flex: 1, fontSize: '0.875rem' }}
/>
<button
className="btn-secondary"
style={{ fontSize: '0.75rem', padding: '0.35rem 0.65rem', flexShrink: 0 }}
onClick={() => {
const allCats = Object.keys(treeData);
const allExpanded = allCats.every(c => expandedCats.has(c));
if (allExpanded) {
setExpandedCats(new Set());
setExpandedMods(new Set());
} else {
setExpandedCats(new Set(allCats));
const mods = new Set();
Object.entries(treeData).forEach(([cat, modules]) =>
Object.keys(modules).forEach(mod => mods.add(`${cat}::${mod}`))
);
setExpandedMods(mods);
}
}}
>
{Object.keys(treeData).every(c => expandedCats.has(c)) ? '全部收起' : '全部展开'}
</button>
</div>
<div style={{ overflowY: 'auto', flex: 1 }}>
{loading ? (
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}>加载中...</div>
) : (
Object.entries(treeData).map(([cat, modules]) => {
const isCatOpen = expandedCats.has(cat);
const count = catCount(cat);
return (
<div key={cat} style={{ marginBottom: '2px' }}>
{/* 一级:分类 */}
<div
onClick={() => toggleCat(cat)}
onMouseEnter={() => setHoveredRow(`cat::${cat}`)}
onMouseLeave={() => setHoveredRow(null)}
style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.65rem 0.75rem', borderRadius: '6px', cursor: 'pointer',
background: isCatOpen ? 'rgba(37,99,235,0.06)' : 'transparent',
borderLeft: `3px solid ${categoryColor(cat)}`,
userSelect: 'none',
}}
>
<span style={{ fontSize: '0.65rem', color: 'var(--text-muted)', display: 'inline-block', transform: isCatOpen ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s', flexShrink: 0 }}></span>
<span style={{ fontWeight: 700, fontSize: '1.05rem', color: 'var(--text)' }}>{cat}</span>
<span style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '0.5rem', flexShrink: 0 }}>
{hoveredRow === `cat::${cat}` && isCatOpen && Object.keys(modules).length > 0 && (
<span
onClick={e => {
e.stopPropagation();
const modKeys = Object.keys(modules).map(m => `${cat}::${m}`);
const allOpen = modKeys.every(k => expandedMods.has(k));
setExpandedMods(prev => {
const next = new Set(prev);
if (allOpen) modKeys.forEach(k => next.delete(k));
else modKeys.forEach(k => next.add(k));
return next;
});
}}
style={{ fontSize: '0.72rem', color: 'var(--primary)', cursor: 'pointer', padding: '1px 6px', borderRadius: '3px', background: 'rgba(37,99,235,0.1)', whiteSpace: 'nowrap' }}
>
{Object.keys(modules).every(m => expandedMods.has(`${cat}::${m}`)) ? '收起全部' : '展开全部'}
</span>
)}
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{count}</span>
</span>
</div>
{/* 二级:业务模块 */}
{isCatOpen && Object.entries(modules).map(([mod, eps]) => {
const modKey = `${cat}::${mod}`;
const isModOpen = expandedMods.has(modKey);
return (
<div key={mod} style={{ marginLeft: '1rem' }}>
<div
onClick={() => toggleMod(cat, mod)}
onMouseEnter={() => setHoveredRow(`mod::${cat}::${mod}`)}
onMouseLeave={() => setHoveredRow(null)}
style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.5rem 0.75rem', borderRadius: '5px', cursor: 'pointer',
userSelect: 'none',
background: isModOpen ? 'rgba(0,0,0,0.03)' : 'transparent',
}}
>
<span style={{ fontSize: '0.6rem', color: 'var(--text-muted)', display: 'inline-block', transform: isModOpen ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s', flexShrink: 0 }}></span>
<span style={{ fontSize: '0.95rem', color: 'var(--text-muted)', fontWeight: 600 }}>{mod}</span>
<span style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '0.5rem', flexShrink: 0 }}>
{hoveredRow === `mod::${cat}::${mod}` && (
<span
onClick={e => { e.stopPropagation(); toggleMod(cat, mod); }}
style={{ fontSize: '0.72rem', color: 'var(--text-muted)', cursor: 'pointer', padding: '1px 6px', borderRadius: '3px', background: 'rgba(0,0,0,0.07)', whiteSpace: 'nowrap' }}
>
{isModOpen ? '收起' : '展开'}
</span>
)}
<span style={{ fontSize: '0.78rem', color: 'var(--text-muted)', opacity: 0.6 }}>{eps.length}</span>
</span>
</div>
{/* 三级:接口 */}
{isModOpen && (
<div style={{ marginLeft: '0.75rem', borderLeft: '2px solid var(--border)', paddingLeft: '0.6rem', marginBottom: '4px' }}>
{eps.map(ep => {
const isSelected = selectedId === ep.id;
return (
<div
key={ep.id}
onClick={() => openEdit(ep)}
style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.45rem 0.5rem', borderRadius: '4px', marginBottom: '1px',
cursor: 'pointer', userSelect: 'none',
background: isSelected ? 'rgba(37,99,235,0.1)' : 'transparent',
borderLeft: isSelected ? `2px solid var(--primary)` : '2px solid transparent',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'rgba(0,0,0,0.04)'; e.currentTarget.querySelector('.ep-actions').style.opacity = '1'; }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent'; e.currentTarget.querySelector('.ep-actions').style.opacity = '0'; }}
>
<span style={{ fontSize: '0.72rem', fontWeight: 700, color: methodColor(ep.method), background: `${methodColor(ep.method)}18`, padding: '2px 6px', borderRadius: '3px', flexShrink: 0 }}>
{ep.method}
</span>
<span style={{ fontSize: '0.925rem', color: isSelected ? 'var(--primary)' : 'var(--text)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isSelected ? 600 : 400 }}>
{ep.name}
</span>
<div className="ep-actions" style={{ display: 'flex', gap: '0.3rem', flexShrink: 0, opacity: isSelected ? 1 : 0, transition: 'opacity 0.15s' }}>
<button className="btn-secondary" style={{ padding: '1px 8px', fontSize: '0.72rem' }} onClick={e => { e.stopPropagation(); openEdit(ep); }}>编辑</button>
<button className="btn-secondary" style={{ padding: '1px 8px', fontSize: '0.72rem' }} onClick={e => { e.stopPropagation(); openCopy(ep); }}>复制</button>
<button className="btn-secondary" style={{ padding: '1px 8px', fontSize: '0.72rem', color: 'var(--danger)', borderColor: 'rgba(239,68,68,0.25)' }} onClick={e => { e.stopPropagation(); setDeleteModal({ isOpen: true, endpointId: ep.id }); }}>删除</button>
</div>
</div>
);
})}
</div>
)}
</div>
);
})}
{isCatOpen && Object.keys(modules).length === 0 && (
<div style={{ marginLeft: '2rem', padding: '0.35rem 0.75rem', color: 'var(--text-muted)', fontSize: '0.8rem' }}>暂无接口</div>
)}
</div>
);
})
)}
</div>
</div>
{/* 右侧表单 */}
<div className="card" style={{ flex: 1, minWidth: 0, padding: '2rem', overflowY: 'auto', display: 'flex', flexDirection: 'column' }}>
{panel ? (
<EndpointForm
key={panel.endpoint?.id ?? 'new'}
endpoint={panel.endpoint}
settings={settings}
onDelete={panel.endpoint?.id ? () => setDeleteModal({ isOpen: true, endpointId: panel.endpoint.id }) : null}
onCopy={panel.endpoint?.id ? () => openCopy(panel.endpoint) : null}
onSuccess={() => { fetchAll(); }}
/>
) : (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', flex: 1, color: 'var(--text-muted)', gap: '0.75rem' }}>
<span style={{ fontSize: '2.5rem', opacity: 0.3 }}>🔗</span>
<p style={{ fontSize: '0.9rem' }}>从左侧选择接口进行编辑或点击新增接口</p>
</div>
)}
</div>
</div>
{/* 删除确认 */}
{deleteModal.isOpen && (
<div className="modal-overlay" onClick={() => setDeleteModal({ isOpen: false, endpointId: null })}>
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: '400px', textAlign: 'center' }}>
<h3 style={{ color: 'var(--text)', marginBottom: '1rem' }}>确认删除</h3>
<p style={{ color: 'var(--text-muted)', marginBottom: '2rem' }}>确定要删除该接口吗此操作不可逆</p>
<div style={{ display: 'flex', gap: '1rem' }}>
<button className="btn-secondary" onClick={() => setDeleteModal({ isOpen: false, endpointId: null })} style={{ flex: 1 }}>取消</button>
<button className="btn-primary" onClick={confirmDelete} style={{ flex: 1, backgroundColor: 'var(--danger)' }}>确认删除</button>
</div>
</div>
</div>
)}
</div>
);
};
// ─── 右侧接口表单(内联,非弹窗) ──────────────────────────────────────────
const EndpointForm = ({ endpoint, settings, onDelete, onCopy, onSuccess }) => {
const isEdit = !!endpoint?.id;
const [formData, setFormData] = useState(endpoint ? {
name: endpoint.name || '', url: endpoint.url || '', method: endpoint.method || 'GET',
body: endpoint.body || '', category: endpoint.category || '旗舰版PP',
module: endpoint.module || '', api_code: endpoint.api_code || '', user_name: endpoint.user_name || '',
} : { ...EMPTY_FORM });
const [jsonError, setJsonError] = useState('');
const [saving, setSaving] = useState(false);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
if (name === 'body') setJsonError('');
};
const handleFormatJson = () => {
if (!formData.body.trim()) { setJsonError(''); return; }
try {
setFormData(prev => ({ ...prev, body: JSON.stringify(JSON.parse(prev.body), null, 2) }));
setJsonError('');
} catch { setJsonError('JSON 格式不正确'); }
};
const handleSubmit = async (e) => {
e.preventDefault();
setSaving(true);
try {
const res = await authFetch(isEdit ? `${API_BASE}/${endpoint.id}` : API_BASE, {
method: isEdit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
if (!res.ok) throw new Error((await res.json()).error || '保存失败');
toast.success('保存成功');
onSuccess();
} catch (err) { toast.error(err.message); }
finally { setSaving(false); }
};
const categories = settings?.categories || ['旗舰版PP', '旗舰版ATS', '标品ATS', '标品PP', '国际版ATS'];
const modules = settings?.modules || [];
return (
<div>
{/* 右侧表单标题 + 操作 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem' }}>
<h2 style={{ margin: 0, fontSize: '1.1rem', fontWeight: 700, color: 'var(--text)' }}>
{isEdit ? '编辑接口' : '新增接口'}
</h2>
{isEdit && (
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button className="btn-secondary" style={{ fontSize: '0.8rem', padding: '0.3rem 0.75rem' }} onClick={onCopy}>复制</button>
<button className="btn-secondary" style={{ fontSize: '0.8rem', padding: '0.3rem 0.75rem', color: 'var(--danger)', borderColor: 'rgba(239,68,68,0.25)' }} onClick={onDelete}>删除</button>
</div>
)}
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>接口名称</label>
<input name="name" value={formData.name} onChange={handleChange} required placeholder="例如:查询部门列表" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div className="form-group">
<label>所属分类</label>
<select name="category" value={formData.category} onChange={handleChange}>
{categories.map(c => <option key={c}>{c}</option>)}
</select>
</div>
<div className="form-group">
<label>业务模块</label>
<select name="module" value={formData.module} onChange={handleChange}>
<option value="">-- 请选择 --</option>
{modules.map(m => <option key={m}>{m}</option>)}
</select>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: '1rem' }}>
<div className="form-group">
<label>请求方式</label>
<select name="method" value={formData.method} onChange={handleChange}>
{['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].map(m => <option key={m}>{m}</option>)}
</select>
</div>
<div className="form-group">
<label>接口地址 (URL)</label>
<input name="url" value={formData.url} onChange={handleChange} required placeholder="https://api.example.com/v1/..." />
</div>
</div>
{formData.category === '标品PP' && (
<>
<div className="form-group">
<label>接口编码 (apiCode)</label>
<input name="api_code" value={formData.api_code} onChange={handleChange} placeholder="从「外部接口设置」中获取" />
</div>
<div className="form-group">
<label>用户名 (userName非必填)</label>
<input name="user_name" value={formData.user_name} onChange={handleChange} placeholder="需要操作人归属时填写例如xiao@qq.com" />
</div>
</>
)}
<div className="form-group">
<label>Body 参数 (JSON)</label>
<textarea name="body" value={formData.body} onChange={handleChange} onBlur={handleFormatJson} rows="8"
style={{ fontFamily: 'monospace', borderColor: jsonError ? 'var(--danger)' : 'var(--border)' }}
placeholder='{ "key": "value" }' />
{jsonError && <div style={{ color: 'var(--danger)', fontSize: '0.875rem', marginTop: '0.5rem' }}>{jsonError}</div>}
</div>
<div style={{ marginTop: '1.5rem' }}>
<button type="submit" className="btn-primary" style={{ width: '100%', padding: '0.65rem' }} disabled={!!jsonError || saving}>
{saving ? '保存中...' : (isEdit ? '保存修改' : '创建接口')}
</button>
</div>
</form>
</div>
);
};
export default EndpointManager;

View File

@@ -0,0 +1,185 @@
import React, { useState, useEffect } from 'react';
import toast from '../utils/toast';
import { authFetch } from '../utils/api';
const SETTINGS_API = 'http://localhost:5001/api/settings';
const Settings = ({ onClose }) => {
const [settings, setSettings] = useState(null);
const [loading, setLoading] = useState(true);
const [catInput, setCatInput] = useState('');
const [modInput, setModInput] = useState('');
const [saving, setSaving] = useState('');
useEffect(() => {
authFetch(SETTINGS_API)
.then(r => r.json())
.then(data => { setSettings(data); setLoading(false); })
.catch(() => setLoading(false));
}, []);
const saveCategories = async (items) => {
setSaving('categories');
try {
await authFetch(`${SETTINGS_API}/categories`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
});
setSettings(prev => ({ ...prev, categories: items }));
toast.success('分类已保存');
} catch { toast.error('保存失败'); }
finally { setSaving(''); }
};
const saveModules = async (items) => {
setSaving('modules');
try {
await authFetch(`${SETTINGS_API}/modules`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
});
setSettings(prev => ({ ...prev, modules: items }));
toast.success('业务模块已保存');
} catch { toast.error('保存失败'); }
finally { setSaving(''); }
};
const addCategory = () => {
const val = catInput.trim();
if (!val) return;
if (settings.categories.includes(val)) { toast.error('分类已存在'); return; }
const next = [...settings.categories, val];
saveCategories(next);
setCatInput('');
};
const removeCategory = (item) => {
if (settings.defaultCategories.includes(item)) return;
saveCategories(settings.categories.filter(c => c !== item));
};
const addModule = () => {
const val = modInput.trim();
if (!val) return;
if (settings.modules.includes(val)) { toast.error('业务模块已存在'); return; }
const next = [...settings.modules, val];
saveModules(next);
setModInput('');
};
const removeModule = (item) => {
if (settings.defaultModules.includes(item)) return;
saveModules(settings.modules.filter(m => m !== item));
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: '36rem', maxHeight: '80vh', overflowY: 'auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h2 style={{ margin: 0 }}> 设置</h2>
<button className="btn-secondary" onClick={onClose} style={{ padding: '0.25rem 0.6rem', fontSize: '0.8rem' }}>关闭</button>
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}>加载中...</div>
) : (
<>
{/* 分类管理 */}
<Section title="接口 / 租户分类">
<ItemList
items={settings.categories}
defaults={settings.defaultCategories}
onRemove={removeCategory}
/>
<AddInput
value={catInput}
onChange={setCatInput}
onAdd={addCategory}
placeholder="输入新分类名称..."
loading={saving === 'categories'}
/>
</Section>
<div style={{ height: '1px', background: 'var(--border)', margin: '1.5rem 0' }} />
{/* 业务模块管理 */}
<Section title="业务模块">
<ItemList
items={settings.modules}
defaults={settings.defaultModules}
onRemove={removeModule}
/>
<AddInput
value={modInput}
onChange={setModInput}
onAdd={addModule}
placeholder="输入新业务模块名称..."
loading={saving === 'modules'}
/>
</Section>
</>
)}
</div>
</div>
);
};
const Section = ({ title, children }) => (
<div>
<h3 style={{ fontSize: '0.95rem', fontWeight: 600, color: 'var(--text-main)', marginBottom: '0.75rem', marginTop: 0 }}>{title}</h3>
{children}
</div>
);
const ItemList = ({ items, defaults, onRemove }) => (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '0.75rem' }}>
{items.map(item => {
const isDefault = defaults.includes(item);
return (
<span
key={item}
style={{
display: 'inline-flex', alignItems: 'center', gap: '0.3rem',
padding: '0.3rem 0.65rem',
background: isDefault ? 'rgba(99,102,241,0.1)' : 'rgba(255,255,255,0.06)',
border: `1px solid ${isDefault ? 'rgba(99,102,241,0.3)' : 'var(--border)'}`,
borderRadius: '999px',
fontSize: '0.8rem',
color: isDefault ? 'var(--primary)' : 'var(--text-main)',
}}
>
{item}
{isDefault ? (
<span style={{ fontSize: '0.65rem', opacity: 0.5, marginLeft: '2px' }} title="默认项,不可删除">🔒</span>
) : (
<span
onClick={() => onRemove(item)}
style={{ cursor: 'pointer', opacity: 0.5, fontSize: '0.8rem', lineHeight: 1 }}
onMouseEnter={e => e.currentTarget.style.opacity = '1'}
onMouseLeave={e => e.currentTarget.style.opacity = '0.5'}
>×</span>
)}
</span>
);
})}
</div>
);
const AddInput = ({ value, onChange, onAdd, placeholder, loading }) => (
<div style={{ display: 'flex', gap: '0.5rem' }}>
<input
value={value}
onChange={e => onChange(e.target.value)}
onKeyDown={e => e.key === 'Enter' && onAdd()}
placeholder={placeholder}
style={{ flex: 1, fontSize: '0.875rem' }}
/>
<button className="btn-primary" onClick={onAdd} disabled={loading || !value.trim()} style={{ padding: '0.5rem 1rem', fontSize: '0.875rem', flexShrink: 0 }}>
{loading ? '保存中...' : '+ 添加'}
</button>
</div>
);
export default Settings;

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { FiLogOut, FiSettings } from 'react-icons/fi';
const Sidebar = ({ currentTab, setTab, onLogout, onSettings }) => {
const user = JSON.parse(localStorage.getItem('api_debug_user') || '{}');
return (
<div className="sidebar">
<div className="sidebar-brand">API Debug</div>
<nav>
<div className={`nav-item ${currentTab === 'debug' ? 'active' : ''}`} onClick={() => setTab('debug')}>
<span></span> 接口调试
</div>
<div className={`nav-item ${currentTab === 'endpoints' ? 'active' : ''}`} onClick={() => setTab('endpoints')}>
<span>🔗</span> 接口管理
</div>
<div className={`nav-item ${currentTab === 'tenants' ? 'active' : ''}`} onClick={() => setTab('tenants')}>
<span>🏠</span> 租户管理
</div>
</nav>
<div style={{ marginTop: 'auto', paddingTop: '2rem' }}>
<div className="nav-item" onClick={onSettings} style={{ marginBottom: '0.5rem' }}>
<FiSettings />
<span>设置</span>
</div>
<div style={{ padding: '0 1rem', marginBottom: '0.75rem', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
当前用户: <strong style={{ color: 'var(--primary-dark)' }}>{user?.username || 'admin'}</strong>
</div>
<div className="nav-item" onClick={onLogout} style={{ color: 'var(--error)' }}>
<FiLogOut />
<span>退出登录</span>
</div>
</div>
</div>
);
};
export default Sidebar;

View File

@@ -0,0 +1,369 @@
import React, { useState, useEffect, useMemo } from 'react';
import toast from '../utils/toast';
import { authFetch } from '../utils/api';
const API_BASE = 'http://localhost:5001/api/tenants';
const TYPE_COLORS = {
'旗舰版PP': '#3b82f6', '旗舰版ATS': '#6366f1',
'标品PP': '#10b981', '标品ATS': '#8b5cf6', '国际版ATS': '#d946ef',
};
const TYPE_ORDER = ['旗舰版PP', '旗舰版ATS', '标品ATS', '标品PP', '国际版ATS'];
const typeColor = (t) => TYPE_COLORS[t] || '#64748b';
const EMPTY_FORM = {
name: '', type: '旗舰版PP', appKey: '', appSecret: '',
apiKey: '', entCode: '', buId: '', entId: '', privateKey: '', publicKey: '',
};
const TenantManager = () => {
const [tenants, setTenants] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [expandedTypes, setExpandedTypes] = useState(new Set());
const [hoveredRow, setHoveredRow] = useState(null);
const [panel, setPanel] = useState(null);
const [deleteModal, setDeleteModal] = useState({ isOpen: false, tenantId: null });
const fetchTenants = async () => {
setLoading(true);
try {
const res = await authFetch(`${API_BASE}?search=`);
setTenants(await res.json());
} catch (err) { console.error(err); }
finally { setLoading(false); }
};
useEffect(() => { fetchTenants(); }, []);
// 构建树:{ [type]: [tenants] }
const treeData = useMemo(() => {
const lower = search.toLowerCase();
const filtered = search
? tenants.filter(t => t.name.toLowerCase().includes(lower))
: tenants;
const tree = {};
TYPE_ORDER.forEach(t => { tree[t] = []; });
filtered.forEach(t => {
const type = t.type || '其他';
if (!tree[type]) tree[type] = [];
tree[type].push(t);
});
return tree;
}, [tenants, search]);
useEffect(() => {
if (search) setExpandedTypes(new Set(Object.keys(treeData).filter(t => treeData[t].length > 0)));
}, [search]);
const toggleType = (type) => setExpandedTypes(prev => {
const next = new Set(prev);
next.has(type) ? next.delete(type) : next.add(type);
return next;
});
const openNew = () => setPanel({ tenant: null });
const openEdit = (t) => setPanel({ tenant: t });
const openCopy = (t) => setPanel({ tenant: { ...t, id: undefined, name: `${t.name} (Copy)` } });
const confirmDelete = async () => {
if (!deleteModal.tenantId) return;
try {
await authFetch(`${API_BASE}/${deleteModal.tenantId}`, { method: 'DELETE' });
toast.success('删除成功');
if (panel?.tenant?.id === deleteModal.tenantId) setPanel(null);
fetchTenants();
} catch { toast.error('删除失败'); }
finally { setDeleteModal({ isOpen: false, tenantId: null }); }
};
const allExpanded = Object.keys(treeData).every(t => expandedTypes.has(t));
const selectedId = panel?.tenant?.id;
return (
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', height: '100%' }}>
<header style={{ flexShrink: 0 }}>
<h1>租户管理</h1>
<button className="btn-primary" onClick={openNew}>+ 新增租户</button>
</header>
<div style={{ display: 'flex', gap: '1rem', flex: 1, minHeight: 0 }}>
{/* 左侧树 */}
<div className="card" style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', padding: '1rem', overflow: 'hidden' }}>
<div style={{ marginBottom: '0.75rem', display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<input
type="text"
placeholder="搜索租户名称..."
value={search}
onChange={e => setSearch(e.target.value)}
style={{ flex: 1, fontSize: '0.875rem' }}
/>
<button
className="btn-secondary"
style={{ fontSize: '0.75rem', padding: '0.35rem 0.65rem', flexShrink: 0 }}
onClick={() => {
if (allExpanded) setExpandedTypes(new Set());
else setExpandedTypes(new Set(Object.keys(treeData)));
}}
>
{allExpanded ? '全部收起' : '全部展开'}
</button>
</div>
<div style={{ overflowY: 'auto', flex: 1 }}>
{loading ? (
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-muted)' }}>加载中...</div>
) : (
Object.entries(treeData).map(([type, list]) => {
const isOpen = expandedTypes.has(type);
return (
<div key={type} style={{ marginBottom: '2px' }}>
{/* 一级:类型 */}
<div
onClick={() => toggleType(type)}
onMouseEnter={() => setHoveredRow(`type::${type}`)}
onMouseLeave={() => setHoveredRow(null)}
style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.65rem 0.75rem', borderRadius: '6px', cursor: 'pointer',
background: isOpen ? `${typeColor(type)}0d` : 'transparent',
borderLeft: `3px solid ${typeColor(type)}`,
userSelect: 'none',
}}
>
<span style={{ fontSize: '0.65rem', color: 'var(--text-muted)', display: 'inline-block', transform: isOpen ? 'rotate(90deg)' : 'none', transition: 'transform 0.15s', flexShrink: 0 }}></span>
<span style={{ fontWeight: 700, fontSize: '1.05rem', color: 'var(--text)' }}>{type}</span>
<span style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '0.5rem', flexShrink: 0 }}>
{hoveredRow === `type::${type}` && isOpen && list.length > 0 && (
<span
onClick={e => { e.stopPropagation(); toggleType(type); }}
style={{ fontSize: '0.72rem', color: typeColor(type), cursor: 'pointer', padding: '1px 6px', borderRadius: '3px', background: `${typeColor(type)}18`, whiteSpace: 'nowrap' }}
>
收起
</span>
)}
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>{list.length}</span>
</span>
</div>
{/* 二级:租户列表 */}
{isOpen && (
<div style={{ marginLeft: '1rem', borderLeft: '2px solid var(--border)', paddingLeft: '0.6rem', marginTop: '2px', marginBottom: '4px' }}>
{list.length === 0 ? (
<div style={{ padding: '0.35rem 0.75rem', color: 'var(--text-muted)', fontSize: '0.8rem' }}>暂无租户</div>
) : list.map(t => {
const isSelected = selectedId === t.id;
return (
<div
key={t.id}
onClick={() => openEdit(t)}
style={{
display: 'flex', alignItems: 'center', gap: '0.5rem',
padding: '0.5rem 0.6rem', borderRadius: '4px', marginBottom: '1px',
cursor: 'pointer', userSelect: 'none',
background: isSelected ? `${typeColor(type)}12` : 'transparent',
borderLeft: isSelected ? `2px solid ${typeColor(type)}` : '2px solid transparent',
}}
onMouseEnter={e => {
if (!isSelected) e.currentTarget.style.background = 'rgba(0,0,0,0.04)';
e.currentTarget.querySelector('.t-actions').style.opacity = '1';
}}
onMouseLeave={e => {
if (!isSelected) e.currentTarget.style.background = 'transparent';
e.currentTarget.querySelector('.t-actions').style.opacity = '0';
}}
>
<span style={{ fontSize: '0.925rem', color: isSelected ? typeColor(type) : 'var(--text)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: isSelected ? 700 : 400 }}>
{t.name}
</span>
<div className="t-actions" style={{ display: 'flex', gap: '0.3rem', flexShrink: 0, opacity: isSelected ? 1 : 0, transition: 'opacity 0.15s' }}>
<button className="btn-secondary" style={{ padding: '1px 8px', fontSize: '0.72rem' }} onClick={e => { e.stopPropagation(); openEdit(t); }}>编辑</button>
<button className="btn-secondary" style={{ padding: '1px 8px', fontSize: '0.72rem' }} onClick={e => { e.stopPropagation(); openCopy(t); }}>复制</button>
<button className="btn-secondary" style={{ padding: '1px 8px', fontSize: '0.72rem', color: 'var(--danger)', borderColor: 'rgba(239,68,68,0.25)' }} onClick={e => { e.stopPropagation(); setDeleteModal({ isOpen: true, tenantId: t.id }); }}>删除</button>
</div>
</div>
);
})}
</div>
)}
</div>
);
})
)}
</div>
</div>
{/* 右侧表单 */}
<div className="card" style={{ flex: 1, minWidth: 0, padding: '2rem', overflowY: 'auto' }}>
{panel ? (
<TenantForm
key={panel.tenant?.id ?? 'new'}
tenant={panel.tenant}
onDelete={panel.tenant?.id ? () => setDeleteModal({ isOpen: true, tenantId: panel.tenant.id }) : null}
onCopy={panel.tenant?.id ? () => openCopy(panel.tenant) : null}
onSuccess={() => fetchTenants()}
/>
) : (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--text-muted)', gap: '0.75rem' }}>
<span style={{ fontSize: '2.5rem', opacity: 0.3 }}>🏠</span>
<p style={{ fontSize: '0.9rem' }}>从左侧选择租户进行编辑或点击新增租户</p>
</div>
)}
</div>
</div>
{deleteModal.isOpen && (
<div className="modal-overlay" onClick={() => setDeleteModal({ isOpen: false, tenantId: null })}>
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: '400px', textAlign: 'center' }}>
<h3 style={{ color: 'var(--text)', marginBottom: '1rem' }}>确认删除</h3>
<p style={{ color: 'var(--text-muted)', marginBottom: '2rem' }}>确定要删除该租户吗此操作不可逆</p>
<div style={{ display: 'flex', gap: '1rem' }}>
<button className="btn-secondary" onClick={() => setDeleteModal({ isOpen: false, tenantId: null })} style={{ flex: 1 }}>取消</button>
<button className="btn-primary" onClick={confirmDelete} style={{ flex: 1, backgroundColor: 'var(--danger)' }}>确认删除</button>
</div>
</div>
</div>
)}
</div>
);
};
// ─── 右侧租户表单(内联) ──────────────────────────────────────────────────
const TenantForm = ({ tenant, onDelete, onCopy, onSuccess }) => {
const isEdit = !!tenant?.id;
const [formData, setFormData] = useState(tenant ? {
name: tenant.name || '', type: tenant.type || '旗舰版PP',
appKey: tenant.app_key || '', appSecret: tenant.app_secret || '',
apiKey: tenant.api_key || '', entCode: tenant.ent_code || '',
buId: tenant.bu_id || '', entId: tenant.ent_id || '',
privateKey: tenant.private_key || '', publicKey: tenant.public_key || '',
} : { ...EMPTY_FORM });
const [saving, setSaving] = useState(false);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => {
const next = { ...prev, [name]: value };
if (name === 'apiKey' && prev.type === '标品PP') next.entCode = value;
return next;
});
};
const handleSubmit = async (e) => {
e.preventDefault();
setSaving(true);
try {
const res = await authFetch(isEdit ? `${API_BASE}/${tenant.id}` : API_BASE, {
method: isEdit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
if (!res.ok) throw new Error((await res.json()).error || '保存失败');
toast.success('保存成功');
onSuccess();
} catch (err) { toast.error(err.message); }
finally { setSaving(false); }
};
const isTypePP_ATS = ['旗舰版PP', '旗舰版ATS'].includes(formData.type);
const isTypeStdATS_Intl = ['标品ATS', '国际版ATS'].includes(formData.type);
const isTypeStdPP = formData.type === '标品PP';
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem' }}>
<h2 style={{ margin: 0, fontSize: '1.1rem', fontWeight: 700, color: 'var(--text)' }}>
{isEdit ? '编辑租户' : '新增租户'}
</h2>
{isEdit && (
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button className="btn-secondary" style={{ fontSize: '0.8rem', padding: '0.3rem 0.75rem' }} onClick={onCopy}>复制</button>
<button className="btn-secondary" style={{ fontSize: '0.8rem', padding: '0.3rem 0.75rem', color: 'var(--danger)', borderColor: 'rgba(239,68,68,0.25)' }} onClick={onDelete}>删除</button>
</div>
)}
</div>
<form onSubmit={handleSubmit}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div className="form-group">
<label>租户名称</label>
<input name="name" value={formData.name} onChange={handleChange} required placeholder="例如895" />
</div>
<div className="form-group">
<label>租户类型</label>
<select name="type" value={formData.type} onChange={handleChange}>
<option>旗舰版PP</option>
<option>旗舰版ATS</option>
<option>标品ATS</option>
<option>标品PP</option>
<option>国际版ATS</option>
</select>
</div>
</div>
{isTypePP_ATS && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div className="form-group">
<label>AppKey</label>
<input name="appKey" value={formData.appKey} onChange={handleChange} required />
</div>
<div className="form-group">
<label>AppSecret</label>
<input name="appSecret" value={formData.appSecret} onChange={handleChange} required />
</div>
</div>
)}
{isTypeStdATS_Intl && (
<div className="form-group">
<label>API Key</label>
<input name="apiKey" value={formData.apiKey} onChange={handleChange} required />
</div>
)}
{isTypeStdPP && (
<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div className="form-group">
<label>API Key</label>
<input name="apiKey" value={formData.apiKey} onChange={handleChange} required />
</div>
<div className="form-group">
<label>EntCode</label>
<input name="entCode" value={formData.entCode} onChange={handleChange} />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div className="form-group">
<label>BuId</label>
<input name="buId" value={formData.buId} onChange={handleChange} />
</div>
<div className="form-group">
<label>EntId</label>
<input name="entId" value={formData.entId} onChange={handleChange} />
</div>
</div>
<div className="form-group">
<label>PrivateKey</label>
<textarea name="privateKey" value={formData.privateKey} onChange={handleChange} rows="4" style={{ fontFamily: 'monospace', fontSize: '0.8rem' }} />
</div>
<div className="form-group">
<label>PublicKey</label>
<textarea name="publicKey" value={formData.publicKey} onChange={handleChange} rows="3" style={{ fontFamily: 'monospace', fontSize: '0.8rem' }} />
</div>
</>
)}
<div style={{ marginTop: '1.5rem' }}>
<button type="submit" className="btn-primary" style={{ width: '100%', padding: '0.65rem' }} disabled={saving}>
{saving ? '保存中...' : (isEdit ? '保存修改' : '创建租户')}
</button>
</div>
</form>
</div>
);
};
export default TenantManager;

376
client/src/index.css Normal file
View File

@@ -0,0 +1,376 @@
:root {
--primary: #2563eb;
--primary-hover: #1d4ed8;
--bg: #f8fafc;
--panel-bg: rgba(255, 255, 255, 0.85);
--card-bg: #ffffff;
--text: #0f172a;
--text-muted: #64748b;
--border: #e2e8f0;
--accent: #10b981;
--danger: #ef4444;
--glass: blur(16px) saturate(180%);
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: var(--bg);
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
color: var(--text);
min-height: 100vh;
overflow: hidden;
}
.app-container {
display: flex;
height: 100vh;
width: 100vw;
}
.sidebar {
width: 240px;
background: var(--panel-bg);
backdrop-filter: var(--glass);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 2rem 1rem;
}
.sidebar-brand {
font-size: 1.5rem;
font-weight: 800;
margin-bottom: 3rem;
text-align: center;
background: linear-gradient(to right, #2563eb, #3b82f6);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.025em;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
color: var(--text-muted);
text-decoration: none;
cursor: pointer;
margin-bottom: 0.5rem;
transition: all 0.2s;
font-weight: 500;
border-bottom: 1px solid var(--border);
}
.nav-item:hover {
background: rgba(37, 99, 235, 0.08);
color: var(--primary);
}
.nav-item.active {
background: var(--primary);
color: white;
box-shadow: var(--shadow-md);
border-bottom-color: transparent;
}
.main-content {
flex: 1;
overflow: hidden;
padding: 2rem;
background: transparent;
display: flex;
flex-direction: column;
}
.container {
max-width: 1000px;
margin: 0 auto;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
h1 {
font-size: 1.875rem;
font-weight: 800;
color: var(--text);
letter-spacing: -0.025em;
}
button {
cursor: pointer;
border: none;
border-radius: 0.5rem;
padding: 0.625rem 1.25rem;
font-weight: 600;
transition: all 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn-primary {
background-color: var(--primary);
color: white;
}
.btn-primary:hover {
background-color: var(--primary-hover);
transform: translateY(-1px);
}
.btn-secondary {
background-color: var(--card-bg);
color: var(--text);
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
}
.btn-secondary:hover {
background-color: #f1f5f9;
border-color: #cbd5e1;
}
.card {
background: var(--panel-bg);
backdrop-filter: var(--glass);
border: 1px solid var(--border);
border-radius: 1rem;
padding: 1.5rem;
box-shadow: var(--shadow-md);
}
.search-bar {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
input,
textarea {
background-color: var(--card-bg);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 0.625rem 1rem;
color: var(--text);
outline: none;
width: 100%;
}
select {
background-color: var(--card-bg);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 0.625rem 1rem;
color: var(--text);
outline: none;
width: 100%;
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 1rem center;
background-size: 1em;
padding-right: 2.5rem;
cursor: pointer;
transition: border-color 0.2s;
}
select:hover {
border-color: var(--primary);
}
input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
.tenant-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.tenant-card {
position: relative;
transition: transform 0.2s;
}
.tenant-card:hover {
transform: translateY(-4px);
border-color: var(--primary);
}
.tenant-type {
display: inline-block;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 9999px;
background-color: rgba(99, 102, 241, 0.1);
color: #a5b4fc;
margin-bottom: 1rem;
}
.tenant-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.tenant-actions {
display: flex;
gap: 0.5rem;
margin-top: 1.5rem;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(4px);
display: flex;
justify-content: center;
align-items: center;
z-index: 50;
}
.modal {
background: var(--card-bg);
border-radius: 1rem;
padding: 2rem;
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
border: 1px solid var(--border);
box-shadow: var(--shadow-lg);
}
/* Data Tables */
.table-container {
width: 100%;
overflow-x: auto;
border-radius: 0.75rem;
border: 1px solid var(--border);
background: var(--card-bg);
box-shadow: var(--shadow-sm);
}
.data-table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 0.875rem;
}
.data-table th {
background-color: #f8fafc;
padding: 1rem;
font-weight: 600;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.data-table td {
padding: 1rem;
border-bottom: 1px solid var(--border);
color: var(--text);
vertical-align: middle;
}
.data-table tbody tr:hover {
background-color: #f1f5f9;
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
/* Toast Notifications */
.toast-container {
position: fixed;
top: 1.5rem;
right: 1.5rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 0.75rem;
pointer-events: none;
}
.toast {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1rem 1.25rem;
box-shadow: var(--shadow-lg);
color: var(--text);
font-weight: 500;
display: flex;
align-items: center;
gap: 0.75rem;
pointer-events: auto;
min-width: 250px;
animation: toast-slide-in 0.3s cubic-bezier(0.16, 1, 0.3, 1);
transition: opacity 0.3s, transform 0.3s;
}
.toast.toast-success {
border-left: 4px solid #10b981;
}
.toast.toast-error {
border-left: 4px solid #ef4444;
}
.toast.toast-info {
border-left: 4px solid #3b82f6;
}
.toast.toast-leaving {
opacity: 0;
transform: translateX(100%);
}
@keyframes toast-slide-in {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
margin-bottom: 0.5rem;
color: var(--text-muted);
}

10
client/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

10
client/src/utils/api.js Normal file
View File

@@ -0,0 +1,10 @@
export const authFetch = (url, options = {}) => {
const token = localStorage.getItem('api_debug_token');
return fetch(url, {
...options,
headers: {
...(options.headers || {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
};

53
client/src/utils/toast.js Normal file
View File

@@ -0,0 +1,53 @@
let toastContainer = null;
const createToastContainer = () => {
if (toastContainer) return;
toastContainer = document.createElement('div');
toastContainer.className = 'toast-container';
document.body.appendChild(toastContainer);
};
// Available types: 'success', 'error', 'info'
export const toast = (message, type = 'success', duration = 3000) => {
createToastContainer();
const toastEl = document.createElement('div');
toastEl.className = `toast toast-${type}`;
// Add icon based on type
const iconWrapper = document.createElement('span');
iconWrapper.style.display = 'flex';
iconWrapper.style.alignItems = 'center';
if (type === 'success') {
iconWrapper.innerHTML = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>`;
} else if (type === 'error') {
iconWrapper.innerHTML = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>`;
} else {
iconWrapper.innerHTML = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>`;
}
const textWrapper = document.createElement('span');
textWrapper.textContent = message;
toastEl.appendChild(iconWrapper);
toastEl.appendChild(textWrapper);
toastContainer.appendChild(toastEl);
// Remove logic
setTimeout(() => {
toastEl.classList.add('toast-leaving');
toastEl.addEventListener('transitionend', () => {
if (toastContainer.contains(toastEl)) {
toastContainer.removeChild(toastEl);
}
});
}, duration);
};
export default {
success: (msg, dur) => toast(msg, 'success', dur),
error: (msg, dur) => toast(msg, 'error', dur),
info: (msg, dur) => toast(msg, 'info', dur)
};

7
client/vite.config.js Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})