Initial commit: API Debug Tool
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
client/node_modules/
|
||||
server/node_modules/
|
||||
|
||||
# Environment variables (contains secrets)
|
||||
.env
|
||||
server/.env
|
||||
|
||||
# Build output
|
||||
client/dist/
|
||||
client/build/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Claude Code settings
|
||||
.claude/
|
||||
24
client/.gitignore
vendored
Normal file
24
client/.gitignore
vendored
Normal 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
16
client/README.md
Normal 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
29
client/eslint.config.js
Normal 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
13
client/index.html
Normal 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
2930
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
client/package.json
Normal file
28
client/package.json
Normal 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
1
client/public/vite.svg
Normal 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
42
client/src/App.css
Normal 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
46
client/src/App.jsx
Normal 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;
|
||||
1
client/src/assets/react.svg
Normal file
1
client/src/assets/react.svg
Normal 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 |
730
client/src/components/ApiDebugger.jsx
Normal file
730
client/src/components/ApiDebugger.jsx
Normal 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;
|
||||
131
client/src/components/Auth.jsx
Normal file
131
client/src/components/Auth.jsx
Normal 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;
|
||||
190
client/src/components/DebugTabs.jsx
Normal file
190
client/src/components/DebugTabs.jsx
Normal 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;
|
||||
432
client/src/components/EndpointManager.jsx
Normal file
432
client/src/components/EndpointManager.jsx
Normal 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;
|
||||
185
client/src/components/Settings.jsx
Normal file
185
client/src/components/Settings.jsx
Normal 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;
|
||||
39
client/src/components/Sidebar.jsx
Normal file
39
client/src/components/Sidebar.jsx
Normal 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;
|
||||
369
client/src/components/TenantManager.jsx
Normal file
369
client/src/components/TenantManager.jsx
Normal 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
376
client/src/index.css
Normal 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
10
client/src/main.jsx
Normal 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
10
client/src/utils/api.js
Normal 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
53
client/src/utils/toast.js
Normal 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
7
client/vite.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
330
package-lock.json
generated
Normal file
330
package-lock.json
generated
Normal file
@@ -0,0 +1,330 @@
|
||||
{
|
||||
"name": "api-debug",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "api-debug",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "4.1.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.8.3",
|
||||
"supports-color": "8.1.1",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/concurrently.js",
|
||||
"concurrently": "dist/bin/concurrently.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tree-kill": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
package.json
Normal file
17
package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "api-debug",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"cd server && npm run dev\" \"cd client && npm run dev\"",
|
||||
"install-all": "npm install && cd server && npm install && cd ../client && npm install"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
}
|
||||
}
|
||||
5
server/.env.example
Normal file
5
server/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
PORT=5001
|
||||
DB_HOST=localhost
|
||||
DB_USER=root
|
||||
DB_PASSWORD=your_password_here
|
||||
DB_NAME=api_debug
|
||||
14
server/db.js
Normal file
14
server/db.js
Normal file
@@ -0,0 +1,14 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
require('dotenv').config();
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'api_debug',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0
|
||||
});
|
||||
|
||||
module.exports = pool;
|
||||
30
server/index.js
Normal file
30
server/index.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const bodyParser = require('body-parser');
|
||||
require('dotenv').config();
|
||||
|
||||
const tenantRoutes = require('./routes/tenants');
|
||||
const endpointRoutes = require('./routes/endpoints');
|
||||
const debugRoutes = require('./routes/debug');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const settingsRoutes = require('./routes/settings');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 5001;
|
||||
|
||||
app.use(cors());
|
||||
app.use(bodyParser.json());
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/tenants', tenantRoutes);
|
||||
app.use('/api/endpoints', endpointRoutes);
|
||||
app.use('/api/debug', debugRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.send('API Debug Tool Backend is running...');
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
process.stdout.write(`Server is running on port ${PORT}\n`);
|
||||
});
|
||||
20
server/init.sql
Normal file
20
server/init.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
CREATE DATABASE IF NOT EXISTS api_debug;
|
||||
|
||||
USE api_debug;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id VARCHAR(50) DEFAULT 'admin', -- 暂时默认为 admin,后续可扩展用户系统
|
||||
name VARCHAR(100) NOT NULL,
|
||||
type ENUM('旗舰版PP', '旗舰版ATS', '标品ATS', '标品PP', '国际版ATS') NOT NULL,
|
||||
app_key VARCHAR(100),
|
||||
app_secret VARCHAR(100),
|
||||
api_key VARCHAR(100),
|
||||
ent_code VARCHAR(100),
|
||||
bu_id VARCHAR(100),
|
||||
ent_id VARCHAR(100),
|
||||
private_key TEXT,
|
||||
public_key TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
);
|
||||
17
server/middleware/auth.js
Normal file
17
server/middleware/auth.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_api_debug_key';
|
||||
|
||||
module.exports = (req, res, next) => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) return res.status(401).json({ error: '未登录,请先登录' });
|
||||
|
||||
try {
|
||||
req.user = jwt.verify(token, JWT_SECRET);
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Token 无效或已过期,请重新登录' });
|
||||
}
|
||||
};
|
||||
44
server/migrate.js
Normal file
44
server/migrate.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const pool = require('./db');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
async function migrate() {
|
||||
try {
|
||||
console.log('Starting migration...');
|
||||
|
||||
// 1. Create users table
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
console.log('users table ensured.');
|
||||
|
||||
// 2. Add default admin user if no users exist
|
||||
const [users] = await pool.query('SELECT * FROM users');
|
||||
if (users.length === 0) {
|
||||
const hashedPassword = await bcrypt.hash('123456', 10);
|
||||
await pool.query('INSERT INTO users (username, password) VALUES (?, ?)', ['admin', hashedPassword]);
|
||||
console.log('Default admin user created.');
|
||||
}
|
||||
|
||||
// 3. Add category column to endpoints if it doesn't exist
|
||||
const [columns] = await pool.query(`SHOW COLUMNS FROM endpoints LIKE 'category'`);
|
||||
if (columns.length === 0) {
|
||||
await pool.query(`ALTER TABLE endpoints ADD COLUMN category VARCHAR(50) DEFAULT '旗舰版PP' AFTER name`);
|
||||
console.log('Added category column to endpoints table.');
|
||||
} else {
|
||||
console.log('category column already exists in endpoints.');
|
||||
}
|
||||
|
||||
console.log('Migration completed successfully.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Migration failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
migrate();
|
||||
1603
server/package-lock.json
generated
Normal file
1603
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
server/package.json
Normal file
28
server/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"axios": "^1.13.6",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"body-parser": "^2.2.2",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"mysql2": "^3.19.0"
|
||||
},
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "nodemon index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.14"
|
||||
}
|
||||
}
|
||||
86
server/routes/auth.js
Normal file
86
server/routes/auth.js
Normal file
@@ -0,0 +1,86 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const db = require('../db');
|
||||
|
||||
// JWT Secret from env or default
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_api_debug_key';
|
||||
|
||||
// 用户登录
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: '请输入用户名和密码' });
|
||||
}
|
||||
|
||||
const [users] = await db.query('SELECT * FROM users WHERE username = ?', [username]);
|
||||
if (users.length === 0) {
|
||||
return res.status(401).json({ error: '用户名或密码错误' });
|
||||
}
|
||||
|
||||
const user = users[0];
|
||||
const isMatch = await bcrypt.compare(password, user.password);
|
||||
|
||||
if (!isMatch) {
|
||||
return res.status(401).json({ error: '用户名或密码错误' });
|
||||
}
|
||||
|
||||
// 签发 Token
|
||||
const token = jwt.sign(
|
||||
{ id: user.id, username: user.username },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '7d' } // 7天有效
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
token,
|
||||
user: { id: user.id, username: user.username }
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
res.status(500).json({ error: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
// 用户注册 (仅供演示,生产环境应限制注册)
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: '请输入用户名和设置密码' });
|
||||
}
|
||||
|
||||
// 检查用户是否已存在
|
||||
const [existing] = await db.query('SELECT id FROM users WHERE username = ?', [username]);
|
||||
if (existing.length > 0) {
|
||||
return res.status(400).json({ error: '该用户名已被使用' });
|
||||
}
|
||||
|
||||
// 密码加密
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
const hashedPassword = await bcrypt.hash(password, salt);
|
||||
|
||||
// 插入数据库
|
||||
const [result] = await db.query(
|
||||
'INSERT INTO users (username, password) VALUES (?, ?)',
|
||||
[username, hashedPassword]
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: '注册成功,请去登录'
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Registration error:', err);
|
||||
res.status(500).json({ error: '服务器内部错误' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
214
server/routes/debug.js
Normal file
214
server/routes/debug.js
Normal file
@@ -0,0 +1,214 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../db');
|
||||
const axios = require('axios');
|
||||
const crypto = require('crypto');
|
||||
const auth = require('../middleware/auth');
|
||||
|
||||
router.use(auth);
|
||||
|
||||
/**
|
||||
* 将私钥(裸 base64 PKCS8 DER 或 PEM)加载为 KeyObject
|
||||
*/
|
||||
function loadPrivateKey(raw) {
|
||||
if (!raw) return null;
|
||||
if (raw.includes('-----BEGIN')) {
|
||||
return crypto.createPrivateKey(raw);
|
||||
}
|
||||
const derBuf = Buffer.from(raw.trim(), 'base64');
|
||||
return crypto.createPrivateKey({ key: derBuf, format: 'der', type: 'pkcs8' });
|
||||
}
|
||||
|
||||
// 获取 DingTalk Access Token
|
||||
async function getAccessToken(appKey, appSecret) {
|
||||
// 1. 检查缓存
|
||||
const [rows] = await db.query(
|
||||
'SELECT * FROM token_cache WHERE app_key = ? AND expires_at > NOW()',
|
||||
[appKey]
|
||||
);
|
||||
|
||||
if (rows.length > 0) {
|
||||
return rows[0].access_token;
|
||||
}
|
||||
|
||||
// 2. 调用钉钉接口获取
|
||||
try {
|
||||
const response = await axios.post('https://api.dingtalk.com/v1.0/oauth2/accessToken', {
|
||||
appKey,
|
||||
appSecret
|
||||
});
|
||||
|
||||
const { accessToken, expireIn } = response.data;
|
||||
|
||||
// 计算过期时间 (提前 5 分钟过期以保证安全)
|
||||
const expiresAt = new Date(Date.now() + (expireIn - 300) * 1000);
|
||||
|
||||
// 3. 更新缓存
|
||||
await db.query(
|
||||
'INSERT INTO token_cache (app_key, access_token, expires_at) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE access_token = ?, expires_at = ?',
|
||||
[appKey, accessToken, expiresAt, accessToken, expiresAt]
|
||||
);
|
||||
|
||||
return accessToken;
|
||||
} catch (err) {
|
||||
console.error('获取钉钉 Token 失败:', err.response ? err.response.data : err.message);
|
||||
throw new Error('获取 AccessToken 失败: ' + (err.response?.data?.message || err.message));
|
||||
}
|
||||
}
|
||||
|
||||
// 旗舰版PP 接口调试转发
|
||||
router.post('/execute-pp', async (req, res) => {
|
||||
try {
|
||||
const { tenantId, endpointId, body, queryParams } = req.body;
|
||||
|
||||
if (!tenantId || !endpointId) {
|
||||
return res.status(400).json({ error: 'tenantId and endpointId are required' });
|
||||
}
|
||||
|
||||
// 1. 获取租户和接口信息(验证归属当前用户)
|
||||
const [[tenantInfo]] = await db.query('SELECT * FROM tenants WHERE id = ? AND user_id = ?', [tenantId, req.user.username]);
|
||||
const [[endpointInfo]] = await db.query('SELECT * FROM endpoints WHERE id = ? AND user_id = ?', [endpointId, req.user.username]);
|
||||
|
||||
if (!tenantInfo || !endpointInfo) {
|
||||
return res.status(404).json({ error: '租户或接口信息不存在' });
|
||||
}
|
||||
|
||||
// The endpoint URL is an absolute URL (e.g., https://api.dingtalk.com/...)
|
||||
if (!endpointInfo.url) {
|
||||
return res.status(400).json({ error: 'Endpoint url is missing' });
|
||||
}
|
||||
let finalUrl = endpointInfo.url;
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
};
|
||||
|
||||
let requestConfig = {
|
||||
method: endpointInfo.method.toLowerCase(),
|
||||
url: finalUrl,
|
||||
headers,
|
||||
timeout: 15000 // 15s timeout
|
||||
};
|
||||
|
||||
// Standard PP Logic
|
||||
if (tenantInfo.type === '标品PP') {
|
||||
// 1. Basic Auth using API Key
|
||||
if (tenantInfo.api_key) {
|
||||
const encodedKey = Buffer.from(`${tenantInfo.api_key}:`).toString('base64');
|
||||
headers['Authorization'] = `Basic ${encodedKey}`;
|
||||
}
|
||||
|
||||
// 2. Prepare Query Parameters for Signature
|
||||
const mergedParams = { ...(queryParams || {}) };
|
||||
|
||||
// Auto inject missing parameters
|
||||
if (!mergedParams.timestamp) mergedParams.timestamp = Date.now().toString();
|
||||
if (!mergedParams.nonce) {
|
||||
// 文档要求:字母数字,最多 8 字符
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
mergedParams.nonce = Array.from({ length: 8 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||
}
|
||||
if (!mergedParams.entCode && tenantInfo.ent_code) mergedParams.entCode = tenantInfo.ent_code;
|
||||
if (!mergedParams.apiCode && endpointInfo.api_code) mergedParams.apiCode = endpointInfo.api_code;
|
||||
// userName 为非必填,有值时加入签名(文档示例:...&userName=xiao@qq.com)
|
||||
if (!mergedParams.userName && endpointInfo.user_name) mergedParams.userName = endpointInfo.user_name;
|
||||
|
||||
// 3. RSA-MD5 Signature Generation
|
||||
if (tenantInfo.private_key) {
|
||||
// Collect keys and sort alphabetically
|
||||
const keys = Object.keys(mergedParams).sort();
|
||||
const signParts = keys.map(k => `${k}=${mergedParams[k]}`);
|
||||
const signString = signParts.join('&');
|
||||
|
||||
try {
|
||||
const privateKey = loadPrivateKey(tenantInfo.private_key);
|
||||
const signGenerator = crypto.createSign('RSA-MD5');
|
||||
signGenerator.update(signString, 'utf8');
|
||||
const signatureBase64 = signGenerator.sign(privateKey, 'base64');
|
||||
// Add signature to params (axios params will URL-encode it automatically)
|
||||
mergedParams.sign = signatureBase64;
|
||||
} catch (signErr) {
|
||||
console.error('Failed to generate signature via RSA-MD5', signErr);
|
||||
// Continue anyway, it might fail on server but we let them debug it
|
||||
}
|
||||
}
|
||||
|
||||
requestConfig.params = mergedParams;
|
||||
} else if (tenantInfo.type === '旗舰版PP') {
|
||||
// DingTalk specific logic
|
||||
// 2. 获取 Token
|
||||
const token = (await getAccessToken(tenantInfo.app_key, tenantInfo.app_secret)).trim();
|
||||
headers['x-acs-dingtalk-access-token'] = token;
|
||||
} else {
|
||||
return res.status(400).json({ error: `不支持的租户类型: ${tenantInfo.type}` });
|
||||
}
|
||||
|
||||
// Apply Data Body
|
||||
if (requestConfig.method !== 'get' && body && Object.keys(body).length > 0) {
|
||||
requestConfig.data = body;
|
||||
}
|
||||
|
||||
// 仅供复制 CURL 使用 (近似的命令)
|
||||
let curlCmd = `curl -X ${endpointInfo.method.toUpperCase()} '${finalUrl}${requestConfig.params ? '?' + new URLSearchParams(requestConfig.params).toString() : ''}'`;
|
||||
Object.entries(headers).forEach(([k, v]) => {
|
||||
curlCmd += ` \\\n -H '${k}: ${v}'`;
|
||||
});
|
||||
if (requestConfig.data) {
|
||||
// 格式化输出 JSON
|
||||
curlCmd += ` \\\n -d '${JSON.stringify(requestConfig.data, null, 2)}'`;
|
||||
}
|
||||
|
||||
console.log('--- [TRACE] 发送请求详情 ---');
|
||||
console.log('URL:', requestConfig.url);
|
||||
console.log('Headers:', JSON.stringify(requestConfig.headers, null, 2));
|
||||
if (requestConfig.params) console.log('Query Params:', JSON.stringify(requestConfig.params, null, 2));
|
||||
if (requestConfig.data) console.log('Payload:', JSON.stringify(requestConfig.data, null, 2));
|
||||
console.log('等效 CURL 命令:\n', curlCmd);
|
||||
|
||||
// 3. 执行转发请求
|
||||
const response = await axios(requestConfig);
|
||||
|
||||
// 4. 保存历史记录
|
||||
await db.query(
|
||||
'INSERT INTO debug_history (tenant_id, endpoint_id, url, curl_cmd, response_data) VALUES (?, ?, ?, ?, ?)',
|
||||
[tenantInfo.id, endpointInfo.id, finalUrl, curlCmd, JSON.stringify(response.data)]
|
||||
);
|
||||
|
||||
console.log('--- [TRACE] 接口响应成功并已保存历史 ---');
|
||||
res.json({
|
||||
curlCmd: curlCmd,
|
||||
data: response.data
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMsg = err.response ? err.response.data : err.message;
|
||||
console.error('--- 调试请求失败 ---');
|
||||
console.error('错误信息:', err.response ? JSON.stringify(errorMsg, null, 2) : errorMsg);
|
||||
|
||||
// 失败也保存历史以便排查 (可选,这里为保持简单先不存,或可根据需求扩展)
|
||||
|
||||
res.status(err.response ? err.response.status : 500).json({
|
||||
error: errorMsg
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 获取调试历史
|
||||
router.get('/history', async (req, res) => {
|
||||
try {
|
||||
const [rows] = await db.query(
|
||||
`SELECT dh.*, t.name as tenant_name, e.name as endpoint_name
|
||||
FROM debug_history dh
|
||||
LEFT JOIN tenants t ON dh.tenant_id = t.id
|
||||
LEFT JOIN endpoints e ON dh.endpoint_id = e.id
|
||||
WHERE t.user_id = ?
|
||||
ORDER BY dh.created_at DESC LIMIT 50`,
|
||||
[req.user.username]
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
68
server/routes/endpoints.js
Normal file
68
server/routes/endpoints.js
Normal file
@@ -0,0 +1,68 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../db');
|
||||
const auth = require('../middleware/auth');
|
||||
|
||||
router.use(auth);
|
||||
|
||||
// 获取所有接口 (含搜索)
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { search } = req.query;
|
||||
let query = 'SELECT * FROM endpoints WHERE user_id = ?';
|
||||
let params = [req.user.username];
|
||||
|
||||
if (search) {
|
||||
query += ' AND name LIKE ?';
|
||||
params.push(`%${search}%`);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC';
|
||||
const [rows] = await db.query(query, params);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 新增接口
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { name, url, method, body, category, api_code, user_name, module } = req.body;
|
||||
const [result] = await db.query(
|
||||
'INSERT INTO endpoints (user_id, name, url, method, body, category, api_code, user_name, module) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[req.user.username, name, url, method, body || null, category || '旗舰版PP', api_code || null, user_name || null, module || null]
|
||||
);
|
||||
res.json({ id: result.insertId, name, url, method, body, category: category || '旗舰版PP', api_code: api_code || null, user_name: user_name || null, module: module || null });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新接口
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, url, method, body, category, api_code, user_name, module } = req.body;
|
||||
await db.query(
|
||||
'UPDATE endpoints SET name = ?, url = ?, method = ?, body = ?, category = ?, api_code = ?, user_name = ?, module = ? WHERE id = ? AND user_id = ?',
|
||||
[name, url, method, body || null, category || '旗舰版PP', api_code || null, user_name || null, module || null, id, req.user.username]
|
||||
);
|
||||
res.json({ message: 'Updated successfully' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除接口
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await db.query('DELETE FROM endpoints WHERE id = ? AND user_id = ?', [id, req.user.username]);
|
||||
res.json({ message: 'Deleted successfully' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
71
server/routes/settings.js
Normal file
71
server/routes/settings.js
Normal file
@@ -0,0 +1,71 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../db');
|
||||
const auth = require('../middleware/auth');
|
||||
|
||||
router.use(auth);
|
||||
|
||||
const DEFAULT_CATEGORIES = ['旗舰版PP', '旗舰版ATS', '标品ATS', '标品PP', '国际版ATS'];
|
||||
const DEFAULT_MODULES = ['组织接口', '职位职务接口', '人事接口', '假勤接口', '薪酬接口', '绩效接口', '自定义分组接口', 'BI 报表接口'];
|
||||
|
||||
async function getSetting(key, defaults) {
|
||||
const [[row]] = await db.query('SELECT value FROM settings WHERE `key` = ?', [key]);
|
||||
if (!row) return defaults;
|
||||
try {
|
||||
const extra = JSON.parse(row.value);
|
||||
// 合并:默认值在前,自定义值去重追加
|
||||
return [...defaults, ...extra.filter(v => !defaults.includes(v))];
|
||||
} catch { return defaults; }
|
||||
}
|
||||
|
||||
// 获取所有设置
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const [categories, modules] = await Promise.all([
|
||||
getSetting('categories', DEFAULT_CATEGORIES),
|
||||
getSetting('modules', DEFAULT_MODULES),
|
||||
]);
|
||||
res.json({
|
||||
categories,
|
||||
modules,
|
||||
defaultCategories: DEFAULT_CATEGORIES,
|
||||
defaultModules: DEFAULT_MODULES,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新分类(仅保存自定义项,默认项不入库)
|
||||
router.put('/categories', async (req, res) => {
|
||||
try {
|
||||
const { items } = req.body; // 完整列表(含默认+自定义)
|
||||
const extra = items.filter(v => !DEFAULT_CATEGORIES.includes(v));
|
||||
await db.query(
|
||||
'INSERT INTO settings (`key`, value) VALUES (?, ?) ON DUPLICATE KEY UPDATE value = ?',
|
||||
['categories', JSON.stringify(extra), JSON.stringify(extra)]
|
||||
);
|
||||
res.json({ message: 'OK' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新业务模块
|
||||
router.put('/modules', async (req, res) => {
|
||||
try {
|
||||
const { items } = req.body;
|
||||
const extra = items.filter(v => !DEFAULT_MODULES.includes(v));
|
||||
await db.query(
|
||||
'INSERT INTO settings (`key`, value) VALUES (?, ?) ON DUPLICATE KEY UPDATE value = ?',
|
||||
['modules', JSON.stringify(extra), JSON.stringify(extra)]
|
||||
);
|
||||
res.json({ message: 'OK' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.DEFAULT_CATEGORIES = DEFAULT_CATEGORIES;
|
||||
module.exports.DEFAULT_MODULES = DEFAULT_MODULES;
|
||||
144
server/routes/tenants.js
Normal file
144
server/routes/tenants.js
Normal file
@@ -0,0 +1,144 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../db');
|
||||
const auth = require('../middleware/auth');
|
||||
|
||||
router.use(auth);
|
||||
|
||||
// 获取所有租户 (含搜索)
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { search } = req.query;
|
||||
let query = 'SELECT * FROM tenants WHERE user_id = ?';
|
||||
let params = [req.user.username];
|
||||
|
||||
if (search) {
|
||||
query += ' AND name LIKE ?';
|
||||
params.push(`%${search}%`);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC';
|
||||
const [rows] = await db.query(query, params);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 新增租户
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const tenant = req.body;
|
||||
|
||||
// 特殊逻辑:标品PP下 entCode 默认等于 apiKey
|
||||
if (tenant.type === '标品PP' && !tenant.entCode) {
|
||||
tenant.entCode = tenant.apiKey;
|
||||
}
|
||||
|
||||
const [result] = await db.query(
|
||||
'INSERT INTO tenants (user_id, name, type, app_key, app_secret, api_key, ent_code, bu_id, ent_id, private_key, public_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
req.user.username,
|
||||
tenant.name,
|
||||
tenant.type,
|
||||
tenant.appKey || null,
|
||||
tenant.appSecret || null,
|
||||
tenant.apiKey || null,
|
||||
tenant.entCode || null,
|
||||
tenant.buId || null,
|
||||
tenant.entId || null,
|
||||
tenant.privateKey || null,
|
||||
tenant.publicKey || null
|
||||
]
|
||||
);
|
||||
res.json({ id: result.insertId, ...tenant });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新租户
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const tenant = req.body;
|
||||
|
||||
await db.query(
|
||||
`UPDATE tenants SET
|
||||
name = ?,
|
||||
type = ?,
|
||||
app_key = ?,
|
||||
app_secret = ?,
|
||||
api_key = ?,
|
||||
ent_code = ?,
|
||||
bu_id = ?,
|
||||
ent_id = ?,
|
||||
private_key = ?,
|
||||
public_key = ?
|
||||
WHERE id = ? AND user_id = ?`,
|
||||
[
|
||||
tenant.name,
|
||||
tenant.type,
|
||||
tenant.appKey || null,
|
||||
tenant.appSecret || null,
|
||||
tenant.apiKey || null,
|
||||
tenant.entCode || null,
|
||||
tenant.buId || null,
|
||||
tenant.entId || null,
|
||||
tenant.privateKey || null,
|
||||
tenant.publicKey || null,
|
||||
id,
|
||||
req.user.username
|
||||
]
|
||||
);
|
||||
res.json({ message: 'Updated successfully' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除租户
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
await db.query('DELETE FROM tenants WHERE id = ? AND user_id = ?', [id, req.user.username]);
|
||||
res.json({ message: 'Deleted successfully' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 复制租户
|
||||
router.post('/copy/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const [rows] = await db.query('SELECT * FROM tenants WHERE id = ? AND user_id = ?', [id, req.user.username]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Tenant not found' });
|
||||
}
|
||||
|
||||
const tenant = rows[0];
|
||||
const [result] = await db.query(
|
||||
'INSERT INTO tenants (user_id, name, type, app_key, app_secret, api_key, ent_code, bu_id, ent_id, private_key, public_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
req.user.username,
|
||||
`${tenant.name} (Copy)`,
|
||||
tenant.type,
|
||||
tenant.app_key,
|
||||
tenant.app_secret,
|
||||
tenant.api_key,
|
||||
tenant.ent_code,
|
||||
tenant.bu_id,
|
||||
tenant.ent_id,
|
||||
tenant.private_key,
|
||||
tenant.public_key
|
||||
]
|
||||
);
|
||||
res.json({ id: result.insertId, message: 'Copied successfully' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
20
server/test-rsa.js
Normal file
20
server/test-rsa.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const crypto = require('crypto');
|
||||
const { generateKeyPairSync } = crypto;
|
||||
|
||||
const { privateKey, publicKey } = generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: {
|
||||
type: 'spki',
|
||||
format: 'pem'
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem'
|
||||
}
|
||||
});
|
||||
|
||||
const data = "apiCode=0001&entCode=1&nonce=999×tamp=1565244098737";
|
||||
const sign = crypto.createSign('RSA-MD5');
|
||||
sign.update(data);
|
||||
const signature = sign.sign(privateKey, 'base64');
|
||||
console.log('Signature:', signature);
|
||||
16
server/test_debug.js
Normal file
16
server/test_debug.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const axios = require('axios');
|
||||
|
||||
async function test() {
|
||||
try {
|
||||
const res = await axios.post('http://localhost:5001/api/debug/execute-pp', {
|
||||
tenantId: 1, // Assume 1 is a valid tenant
|
||||
endpointId: 1, // Assume 1 is a valid endpoint
|
||||
body: { "test": "data" },
|
||||
queryParams: { "hello": "world" }
|
||||
});
|
||||
console.log("Success:", res.data);
|
||||
} catch (e) {
|
||||
console.error("Error:", e.response ? e.response.data : e.message);
|
||||
}
|
||||
}
|
||||
test();
|
||||
145
server/tmp_test/test_standard_pp.js
Normal file
145
server/tmp_test/test_standard_pp.js
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 标品PP 真实接口调试脚本(从 DB 读取完整数据)
|
||||
* 使用租户 id=5 (名称:895) + 接口 id=4 (标品部门数据)
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const axios = require('axios');
|
||||
const db = require('../db');
|
||||
|
||||
// ─── 工具函数 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 将私钥(多种格式)解析为 Node.js crypto 可用的 KeyObject
|
||||
* 支持:
|
||||
* - 裸 base64 PKCS8 DER(Java 导出格式)
|
||||
* - PEM 格式
|
||||
*/
|
||||
function loadPrivateKey(raw) {
|
||||
if (raw.includes('-----BEGIN')) {
|
||||
return crypto.createPrivateKey(raw);
|
||||
}
|
||||
const derBuf = Buffer.from(raw.trim(), 'base64');
|
||||
return crypto.createPrivateKey({ key: derBuf, format: 'der', type: 'pkcs8' });
|
||||
}
|
||||
|
||||
function generateNonce(length = 8) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
return Array.from({ length }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||
}
|
||||
|
||||
function generateSign(params, privateKey) {
|
||||
const sortedKeys = Object.keys(params).sort();
|
||||
const signString = sortedKeys.map(k => `${k}=${params[k]}`).join('&');
|
||||
console.log('[签名] 待签名字符串:', signString);
|
||||
|
||||
const signer = crypto.createSign('RSA-MD5');
|
||||
signer.update(signString, 'utf8');
|
||||
const signature = signer.sign(privateKey, 'base64');
|
||||
console.log('[签名] Base64 (前30):', signature.substring(0, 30) + '...');
|
||||
return signature;
|
||||
}
|
||||
|
||||
// ─── 主流程 ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
console.log('='.repeat(60));
|
||||
console.log('标品PP 真实接口调试:标品部门数据');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
// 从 DB 读取完整数据
|
||||
const [[tenant]] = await db.query('SELECT * FROM tenants WHERE id = ?', [5]);
|
||||
const [[endpoint]] = await db.query('SELECT * FROM endpoints WHERE id = ?', [4]);
|
||||
|
||||
if (!tenant || !endpoint) {
|
||||
console.error('❌ 租户或接口不存在');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\n[租户] 名称:', tenant.name, '| 类型:', tenant.type);
|
||||
console.log('[接口] 名称:', endpoint.name, '| URL:', endpoint.url);
|
||||
console.log('[私钥] 长度:', tenant.private_key?.length, '字符');
|
||||
|
||||
// 1. 加载私钥
|
||||
let privateKey;
|
||||
try {
|
||||
privateKey = loadPrivateKey(tenant.private_key);
|
||||
console.log('✅ 私钥加载成功(类型:', privateKey.asymmetricKeyType, ')');
|
||||
} catch (e) {
|
||||
console.error('❌ 私钥加载失败:', e.message);
|
||||
// 尝试输出私钥前后片段以诊断
|
||||
console.log(' 私钥开头:', tenant.private_key.substring(0, 50));
|
||||
console.log(' 私钥结尾:', tenant.private_key.slice(-50));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. 构建 query 参数(不含 sign)
|
||||
const params = {
|
||||
entCode: tenant.ent_code,
|
||||
apiCode: endpoint.api_code,
|
||||
timestamp: Date.now().toString(),
|
||||
nonce: generateNonce(8),
|
||||
};
|
||||
|
||||
console.log('\n[参数] 基础参数:', JSON.stringify(params));
|
||||
|
||||
// 3. 生成签名
|
||||
let signature;
|
||||
try {
|
||||
signature = generateSign(params, privateKey);
|
||||
params.sign = signature;
|
||||
console.log('✅ 签名生成成功');
|
||||
} catch (e) {
|
||||
console.error('❌ 签名失败:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. 构建请求
|
||||
const encodedKey = Buffer.from(`${tenant.api_key}:`).toString('base64');
|
||||
const headers = {
|
||||
'Authorization': `Basic ${encodedKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const body = JSON.parse(endpoint.body || '{}');
|
||||
|
||||
// 5. CURL 命令
|
||||
const urlObj = new URL(endpoint.url);
|
||||
Object.entries(params).forEach(([k, v]) => urlObj.searchParams.set(k, v));
|
||||
let curlCmd = `curl -X ${endpoint.method} '${urlObj.toString()}'`;
|
||||
Object.entries(headers).forEach(([k, v]) => {
|
||||
curlCmd += ` \\\n -H '${k}: ${v}'`;
|
||||
});
|
||||
curlCmd += ` \\\n -d '${JSON.stringify(body)}'`;
|
||||
console.log('\n[CURL]\n' + curlCmd);
|
||||
|
||||
// 6. 发送请求
|
||||
console.log('\n[请求] 发送中...');
|
||||
try {
|
||||
const response = await axios({
|
||||
method: endpoint.method.toLowerCase(),
|
||||
url: endpoint.url,
|
||||
headers,
|
||||
params,
|
||||
data: body,
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
console.log('\n✅ 请求成功!');
|
||||
console.log('[响应] 状态:', response.status);
|
||||
console.log('[响应] 数据:');
|
||||
console.log(JSON.stringify(response.data, null, 2).substring(0, 1000));
|
||||
} catch (err) {
|
||||
console.error('\n❌ 请求失败!');
|
||||
if (err.response) {
|
||||
console.error('[错误] HTTP 状态:', err.response.status);
|
||||
console.error('[错误] 响应:', JSON.stringify(err.response.data, null, 2));
|
||||
} else {
|
||||
console.error('[错误]', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
20
server/update_endpoints_table.js
Normal file
20
server/update_endpoints_table.js
Normal file
@@ -0,0 +1,20 @@
|
||||
const { pool } = require('./db');
|
||||
|
||||
async function migrate() {
|
||||
try {
|
||||
console.log('Checking endpoints table for query_params column...');
|
||||
const [columns] = await pool.query(`SHOW COLUMNS FROM endpoints LIKE 'query_params'`);
|
||||
if (columns.length === 0) {
|
||||
console.log('Adding query_params column...');
|
||||
await pool.query(`ALTER TABLE endpoints ADD COLUMN query_params JSON`);
|
||||
console.log('query_params column added successfully.');
|
||||
} else {
|
||||
console.log('query_params column already exists.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Migration failed:', err);
|
||||
} finally {
|
||||
process.exit();
|
||||
}
|
||||
}
|
||||
migrate();
|
||||
Reference in New Issue
Block a user