mirror of
https://github.com/Zhuym07/Tsumugiboshi.git
synced 2025-05-20 09:17:28 +08:00
921 lines
38 KiB
HTML
921 lines
38 KiB
HTML
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>TsumugiBoshi|纺星</title>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||
|
||
|
||
<!-- PWA 支持 -->
|
||
<link rel="manifest" href="manifest.json">
|
||
<meta name="theme-color" content="#1976d2">
|
||
<link rel="apple-touch-icon" href="icons/icon-192x192.png">
|
||
|
||
<!-- OpenGraph 标签 -->
|
||
<meta property="og:title" content="TsumugiBoshi|纺星">
|
||
<meta property="og:description" content="神秘API调试工具">
|
||
<meta property="og:image" content="https://s2.loli.net/2025/01/29/1KPvmysVrAdLW3C.png" />
|
||
<meta name="twitter:card" content="summary_large_image" />
|
||
<meta name="twitter:image" content="https://s2.loli.net/2025/01/29/1KPvmysVrAdLW3C.png" />
|
||
<meta property="og:type" content="website">
|
||
|
||
<title>TsumugiBoshi|纺星</title>
|
||
<!--
|
||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||
<script src="https://unpkg.com/react@17/umd/react.production.min.js"></script>
|
||
<script src="https://unpkg.com/react-dom@17/umd/react-dom.production.min.js"></script>
|
||
<script src="https://unpkg.com/@babel/standalone@7.21.5/babel.min.js"></script>
|
||
<script src="https://unpkg.com/@mui/material@5.14.0/umd/material-ui.production.min.js"></script>
|
||
//-->
|
||
|
||
<!-- 使用本地js代替CDN -->
|
||
|
||
|
||
|
||
|
||
<!-- Material Icons -->
|
||
<link rel="stylesheet" href="https://fonts.loli.net/icon?family=Material+Icons">
|
||
|
||
<!-- React 依赖 -->
|
||
<script src="js\react.production.min.js"></script>
|
||
<script src="js\react-dom.production.min.js"></script>
|
||
|
||
<!-- Babel -->
|
||
<script src="js\babel.min.js"></script>
|
||
|
||
<!-- Material UI 组件库 -->
|
||
<script src="js\material-ui.production.min.js"></script>
|
||
|
||
<link rel="shortcut icon" href="favicon.png">
|
||
<style>
|
||
body {
|
||
margin: 0;
|
||
font-family: Arial, sans-serif;
|
||
min-height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
#root {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
pre {
|
||
background: #f5f5f5;
|
||
padding: 10px;
|
||
border-radius: 4px;
|
||
font-size: 14px;
|
||
overflow-x: auto;
|
||
}
|
||
.api-description {
|
||
margin: 8px 0 16px !important;
|
||
color: #666;
|
||
white-space: pre-line;
|
||
line-height: 1.5;
|
||
background-color: #f8f9fa;
|
||
padding: 12px;
|
||
border-radius: 4px;
|
||
border-left: 4px solid #1976d2;
|
||
font-size: 0.9rem;
|
||
}
|
||
.selected-backend { font-weight: bold; color: #1976d2; }
|
||
.scrollable-tabs {
|
||
max-width: calc(100% - 48px); /* 为菜单按钮留出空间 */
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
}
|
||
.tabs-container {
|
||
display: flex;
|
||
align-items: center;
|
||
position: relative;
|
||
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
||
}
|
||
.menu-button {
|
||
position: absolute !important;
|
||
right: 0;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
}
|
||
footer {
|
||
margin-top: auto;
|
||
padding: 16px;
|
||
background-color: #f5f5f5;
|
||
text-align: center;
|
||
font-size: 0.875rem;
|
||
color: #666;
|
||
}
|
||
.backend-selector {
|
||
margin-top: 2rem;
|
||
padding: 1.5rem;
|
||
background-color: #f8f9fa;
|
||
border-radius: 4px;
|
||
}
|
||
@media (max-width: 600px) {
|
||
.MuiContainer-root {
|
||
padding: 8px !important;
|
||
}
|
||
.MuiPaper-root {
|
||
padding: 12px !important;
|
||
}
|
||
.MuiTypography-h4 {
|
||
font-size: 1.5rem !important;
|
||
}
|
||
.MuiTypography-h6 {
|
||
font-size: 1.1rem !important;
|
||
}
|
||
.MuiTab-root {
|
||
min-height: 48px !important;
|
||
padding: 6px 12px !important;
|
||
font-size: 0.8rem !important;
|
||
}
|
||
.backend-selector {
|
||
margin-top: 1rem;
|
||
padding: 1rem;
|
||
}
|
||
h4.MuiTypography-root {
|
||
font-size: 1.5rem;
|
||
}
|
||
.scrollable-tabs::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
.api-description {
|
||
font-size: 0.875rem;
|
||
padding: 8px;
|
||
}
|
||
}
|
||
/* 移动端优化 */
|
||
@media (max-width: 600px) {
|
||
.MuiContainer-root {
|
||
padding: 8px !important;
|
||
}
|
||
.MuiPaper-root {
|
||
padding: 8px !important;
|
||
}
|
||
.MuiTypography-h4 {
|
||
font-size: 1.25rem !important;
|
||
margin-bottom: 0.5rem !important;
|
||
}
|
||
.MuiTypography-h6 {
|
||
font-size: 1rem !important;
|
||
}
|
||
.MuiTab-root {
|
||
min-width: auto !important;
|
||
padding: 6px 8px !important;
|
||
font-size: 0.75rem !important;
|
||
}
|
||
.MuiFormControl-root {
|
||
margin-bottom: 8px !important;
|
||
}
|
||
pre {
|
||
font-size: 0.75rem !important;
|
||
padding: 8px !important;
|
||
}
|
||
.api-description {
|
||
font-size: 0.75rem !important;
|
||
padding: 8px !important;
|
||
margin: 4px 0 8px !important;
|
||
}
|
||
}
|
||
/* 新增日志卡片样式 */
|
||
.log-card {
|
||
margin-top: 1rem;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.log-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 0.5rem 1rem;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.log-content {
|
||
padding: 1rem;
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.log-item {
|
||
border-left: 3px solid #1976d2;
|
||
margin-bottom: 0.5rem;
|
||
padding: 0.5rem;
|
||
background: #f8f9fa;
|
||
}
|
||
|
||
/* 优化响应式布局 */
|
||
@media (max-width: 600px) {
|
||
body {
|
||
font-size: 14px;
|
||
}
|
||
|
||
.MuiContainer-root {
|
||
padding: 0.5rem !important;
|
||
}
|
||
|
||
.MuiPaper-root {
|
||
padding: 0.75rem !important;
|
||
}
|
||
|
||
/* 标题样式 */
|
||
.MuiTypography-h4 {
|
||
font-size: 1.5rem !important;
|
||
line-height: 1.3;
|
||
}
|
||
|
||
.MuiTypography-h6 {
|
||
font-size: 1.1rem !重要;
|
||
}
|
||
|
||
/* 表单样式 */
|
||
.MuiFormControl-root {
|
||
margin-bottom: 0.75rem !important;
|
||
}
|
||
|
||
/* 标签页样式 */
|
||
.MuiTab-root {
|
||
font-size: 0.85rem !important;
|
||
min-width: unset !important;
|
||
padding: 0.5rem 0.75rem !important;
|
||
}
|
||
|
||
/* 输入框样式 */
|
||
.MuiInputBase-root {
|
||
font-size: 0.9rem !important;
|
||
}
|
||
|
||
/* 其他元素缩放 */
|
||
.api-description {
|
||
font-size: 0.85rem !important;
|
||
padding: 0.75rem !important;
|
||
}
|
||
|
||
pre {
|
||
font-size: 0.8rem !important;
|
||
}
|
||
|
||
.backend-selector {
|
||
margin-top: 0.75rem;
|
||
padding: 0.75rem;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body></body>
|
||
<div id="root"></div>
|
||
<footer>
|
||
<Typography variant="body2" color="textSecondary">
|
||
© 2025 Tsumugiboshi. All rights reserved.
|
||
<br />
|
||
</Typography>
|
||
</footer>
|
||
|
||
<script type="text/babel">
|
||
// 从全局对象获取Material UI组件
|
||
const {
|
||
Container,
|
||
Tabs,
|
||
Tab,
|
||
TextField,
|
||
Button,
|
||
Paper,
|
||
Typography,
|
||
Grid,
|
||
Box,
|
||
createTheme,
|
||
ThemeProvider,
|
||
Select,
|
||
MenuItem,
|
||
InputLabel,
|
||
FormControl,
|
||
Dialog,
|
||
DialogTitle,
|
||
DialogContent,
|
||
DialogActions,
|
||
Snackbar,
|
||
Alert,
|
||
Menu,
|
||
IconButton,
|
||
Link,
|
||
Collapse
|
||
} = MaterialUI;
|
||
|
||
// 创建主题
|
||
const theme = createTheme();
|
||
|
||
// 预设的后端地址
|
||
const PRESET_BACKENDS = [
|
||
{ label: "localhost", value: "http://127.0.0.1:8080" },
|
||
{ label: "测试环境", value: "http://test-api.example.com" },
|
||
{ label: "生产环境", value: "http://api.example.com" }
|
||
];
|
||
|
||
// 通用表单组件
|
||
const DynamicForm = React.forwardRef(({ formConfig, onSubmit }, ref) => {
|
||
const [formData, setFormData] = React.useState({});
|
||
|
||
// 暴露更新表单数据的方法给父组件
|
||
React.useImperativeHandle(ref, () => ({
|
||
updateField: (fieldId, value) => {
|
||
handleChange(fieldId, value);
|
||
}
|
||
}));
|
||
|
||
const handleChange = (fieldId, value) => {
|
||
const keys = fieldId.split('.');
|
||
setFormData(prev => {
|
||
let newData = {...prev};
|
||
let current = newData;
|
||
for (let i = 0; i < keys.length - 1; i++) {
|
||
current[keys[i]] = current[keys[i]] || {};
|
||
current = current[keys[i]];
|
||
}
|
||
current[keys[keys.length - 1]] = value;
|
||
return newData;
|
||
});
|
||
};
|
||
|
||
const handleSubmit = async () => {
|
||
if (formConfig.confirmBeforeSubmit) {
|
||
const confirmCount = formConfig.confirmCount || 1;
|
||
const messages = formConfig.confirmMessages || Array(confirmCount).fill("确认提交数据?");
|
||
|
||
for (let i = 0; i < confirmCount; i++) {
|
||
let confirmMessage = messages[i] || "确认提交数据?";
|
||
if (confirmMessage) {
|
||
confirmMessage = confirmMessage.replace(/\{([^}]+)\}/g, (_, key) => {
|
||
return key === 'rawData' ? JSON.stringify(formData, null, 2) :
|
||
key.split('.').reduce((obj, k) => obj && obj[k], formData) || '';
|
||
});
|
||
}
|
||
|
||
if (!confirm(confirmMessage)) {
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
onSubmit(formData);
|
||
};
|
||
|
||
return (
|
||
<div style={{ padding: '1rem' }}>
|
||
<Typography variant="body1" className="api-description">
|
||
{formConfig.tab.description}
|
||
</Typography>
|
||
<Grid container spacing={2}>
|
||
{formConfig.fields.map(field => (
|
||
<Grid item xs={12} key={field.id}>
|
||
{field.type === 'select' ? (
|
||
<FormControl fullWidth>
|
||
<InputLabel>{field.label}</InputLabel>
|
||
<Select
|
||
value={field.id.split('.').reduce((obj, key) => obj && obj[key], formData) ?? ''}
|
||
onChange={(e) => handleChange(field.id, e.target.value)}
|
||
label={field.label}
|
||
>
|
||
{field.options.map(opt => (
|
||
<MenuItem key={opt.value} value={opt.value}>
|
||
{opt.label}
|
||
</MenuItem>
|
||
))}
|
||
</Select>
|
||
</FormControl>
|
||
) : (
|
||
<TextField
|
||
fullWidth
|
||
label={field.label}
|
||
type={field.type}
|
||
value={field.id.split('.').reduce((obj, key) => obj && obj[key], formData) || ''}
|
||
onChange={(e) => handleChange(field.id, e.target.value)}
|
||
placeholder={field.placeholder}
|
||
/>
|
||
)}
|
||
</Grid>
|
||
))}
|
||
<Grid item xs={12}>
|
||
<Button
|
||
variant="contained"
|
||
color="primary"
|
||
onClick={handleSubmit}
|
||
>
|
||
提交
|
||
</Button>
|
||
</Grid>
|
||
</Grid>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
// 在 App 组件外部添加地址格式化函数
|
||
const formatApiUrl = (url) => {
|
||
if (!url) return '';
|
||
|
||
// 移除末尾的斜杠
|
||
url = url.replace(/\/+$/, '');
|
||
|
||
// 如果不包含协议,添加 http://
|
||
if (!/^https?:\/\//i.test(url)) {
|
||
url = 'http://' + url;
|
||
}
|
||
|
||
try {
|
||
// 使用 URL API 解析和规范化地址
|
||
const urlObj = new URL(url);
|
||
return urlObj.toString().replace(/\/+$/, '');
|
||
} catch (e) {
|
||
return url;
|
||
}
|
||
};
|
||
|
||
function App() {
|
||
const [tabValue, setTabValue] = React.useState(0);
|
||
const [qrInput, setQrInput] = React.useState('');
|
||
const [userId, setUserId] = React.useState(getCookie('userId') || '');
|
||
const [response, setResponse] = React.useState('');
|
||
const [musicData, setMusicData] = React.useState({
|
||
userId: parseInt(userId) || 0,
|
||
music: {
|
||
musicId: 0,
|
||
level: 0,
|
||
playCount: 0,
|
||
//achievement: 0,
|
||
comboStatus: 0,
|
||
syncStatus: 0,
|
||
deluxscoreMax: 0,
|
||
scoreRank: 0
|
||
}
|
||
});
|
||
const [apiBase, setApiBase] = React.useState(
|
||
localStorage.getItem('apiBase') || PRESET_BACKENDS[0].value
|
||
);
|
||
const [backends, setBackends] = React.useState([
|
||
...PRESET_BACKENDS,
|
||
...JSON.parse(localStorage.getItem('customBackends') || '[]')
|
||
]);
|
||
const [isDialogOpen, setIsDialogOpen] = React.useState(false);
|
||
const [newBackend, setNewBackend] = React.useState({ label: '', value: '', formattedUrl: '' });
|
||
const [snackbarOpen, setSnackbarOpen] = React.useState(false);
|
||
const [snackbarMessage, setSnackbarMessage] = React.useState('');
|
||
const [menuAnchorEl, setMenuAnchorEl] = React.useState(null);
|
||
const [logs, setLogs] = React.useState(
|
||
JSON.parse(localStorage.getItem('apiLogs') || '[]')
|
||
);
|
||
const [isLogExpanded, setIsLogExpanded] = React.useState(false);
|
||
const formRefs = React.useRef({});
|
||
|
||
// Cookie操作函数
|
||
function setCookie(name, value, days) {
|
||
const d = new Date();
|
||
d.setTime(d.getTime() + (days * 24 * 60 * 60 * 1000));
|
||
document.cookie = `${name}=${value};expires=${d.toUTCString()};path=/`;
|
||
}
|
||
|
||
function getCookie(name) {
|
||
return document.cookie.split('; ')
|
||
.find(row => row.startsWith(name + '='))
|
||
?.split('=')[1];
|
||
}
|
||
|
||
React.useEffect(() => {
|
||
if (userId) setCookie('userId', userId, 7);
|
||
}, [userId]);
|
||
|
||
const saveLog = (endpoint, request, response) => {
|
||
const newLog = {
|
||
timestamp: Date.now(),
|
||
endpoint,
|
||
request,
|
||
response,
|
||
date: new Date().toLocaleString()
|
||
};
|
||
|
||
const updatedLogs = [newLog, ...logs].slice(0, 50); // 只保留最近50条记录
|
||
setLogs(updatedLogs);
|
||
localStorage.setItem('apiLogs', JSON.stringify(updatedLogs));
|
||
};
|
||
|
||
const handleApiCall = async (endpoint, method = "GET", formData = null, config) => {
|
||
try {
|
||
const options = {
|
||
method,
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include" // 跨域凭证
|
||
};
|
||
|
||
let url = `${apiBase}${endpoint}`;
|
||
|
||
// GET请求参数处理
|
||
if (method === "GET" && config?.queryParams) {
|
||
const params = new URLSearchParams();
|
||
config.queryParams.forEach(param => {
|
||
if (formData?.[param]) params.append(param, formData[param]);
|
||
});
|
||
url += `?${params.toString()}`;
|
||
}
|
||
|
||
// POST请求体处理
|
||
if (method === "POST" && config?.requestFormat === "json") {
|
||
options.body = JSON.stringify(formData);
|
||
}
|
||
|
||
const res = await fetch(url, options);
|
||
const rawText = await res.text(); // 先获取原始文本
|
||
|
||
// 尝试解析JSON(兼容非JSON响应)
|
||
let data;
|
||
try {
|
||
data = JSON.parse(rawText);
|
||
} catch {
|
||
data = { _raw: rawText }; // 解析失败时保留原始响应
|
||
}
|
||
|
||
setResponse(JSON.stringify(data, null, 2)); // 显示原始内容
|
||
|
||
// 保存日志(包含状态码)
|
||
saveLog(endpoint, { ...formData, _status: res.status }, data);
|
||
|
||
// 登录处理逻辑
|
||
if (config?.endpoint === "/qr" && data.userId) {
|
||
setUserId(data.userId.toString());
|
||
setCookie("userId", data.userId.toString(), 7);
|
||
|
||
Object.entries(formConfigs).forEach(([key, formConfig]) => {
|
||
const userIdField = formConfig.fields.find(
|
||
field => field.id.toLowerCase().includes("userid")
|
||
);
|
||
if (userIdField && formRefs.current[key]) {
|
||
formRefs.current[key].updateField(userIdField.id, data.userId.toString());
|
||
}
|
||
});
|
||
}
|
||
|
||
// 显示服务器返回的原始提示(兼容非JSON响应)
|
||
setSnackbarMessage(data.info || data._raw || "请求完成");
|
||
setSnackbarOpen(true);
|
||
|
||
} catch (error) {
|
||
setResponse(`Error: ${error.message}`);
|
||
setSnackbarMessage(`网络错误: ${error.message}`);
|
||
setSnackbarOpen(true);
|
||
}
|
||
};
|
||
|
||
const handleMusicChange = (e) => {
|
||
const { name, value } = e.target;
|
||
setMusicData(prev => ({
|
||
...prev,
|
||
music: {
|
||
...prev.music,
|
||
[name]: parseInt(value) || 0
|
||
}
|
||
}));
|
||
};
|
||
|
||
const handleApiBaseChange = (event) => {
|
||
const value = event.target.value;
|
||
setApiBase(value);
|
||
localStorage.setItem('apiBase', value);
|
||
};
|
||
|
||
const handleAddBackend = () => {
|
||
setIsDialogOpen(true);
|
||
};
|
||
|
||
const handleDialogClose = () => {
|
||
setIsDialogOpen(false);
|
||
setNewBackend({ label: '', value: '', formattedUrl: '' });
|
||
};
|
||
|
||
const handleSaveBackend = () => {
|
||
if (!newBackend.label || !newBackend.value) {
|
||
setSnackbarMessage('后端名称和地址不能为空');
|
||
setSnackbarOpen(true);
|
||
return;
|
||
}
|
||
|
||
const updatedBackends = [...backends, newBackend];
|
||
setBackends(updatedBackends);
|
||
localStorage.setItem('customBackends', JSON.stringify(updatedBackends.filter(b => !PRESET_BACKENDS.includes(b))));
|
||
setIsDialogOpen(false);
|
||
setSnackbarMessage('自定义后端已保存');
|
||
setSnackbarOpen(true);
|
||
};
|
||
|
||
const handleSnackbarClose = () => {
|
||
setSnackbarOpen(false);
|
||
};
|
||
|
||
const handleDeleteBackend = (backendValue) => {
|
||
const updatedBackends = backends.filter(b => b.value !== backendValue);
|
||
setBackends(updatedBackends);
|
||
|
||
// 如果删除的是当前选中的后端,切换到第一个默认后端
|
||
if (apiBase === backendValue) {
|
||
setApiBase(PRESET_BACKENDS[0].value);
|
||
localStorage.setItem('apiBase', PRESET_BACKENDS[0].value);
|
||
}
|
||
|
||
// 只保存自定义后端
|
||
localStorage.setItem('customBackends',
|
||
JSON.stringify(updatedBackends.filter(b =>
|
||
!PRESET_BACKENDS.some(preset => preset.value === b.value)
|
||
))
|
||
);
|
||
|
||
setSnackbarMessage('后端已删除');
|
||
setSnackbarOpen(true);
|
||
};
|
||
|
||
const handleMenuClick = (event) => {
|
||
setMenuAnchorEl(event.currentTarget);
|
||
};
|
||
|
||
const handleMenuClose = () => {
|
||
setMenuAnchorEl(null);
|
||
};
|
||
|
||
const handleMenuSelect = (index) => {
|
||
setTabValue(index);
|
||
handleMenuClose();
|
||
};
|
||
|
||
const handleNewBackendChange = (e) => {
|
||
const value = e.target.value;
|
||
const formattedUrl = formatApiUrl(value);
|
||
setNewBackend(prev => ({...prev, value: value, formattedUrl: formattedUrl}));
|
||
};
|
||
|
||
// 加载表单配置
|
||
const [formConfigs, setFormConfigs] = React.useState(null);
|
||
|
||
React.useEffect(() => {
|
||
fetch('config/label.json')
|
||
.then(res => res.json())
|
||
.then(config => setFormConfigs(config.forms));
|
||
}, []);
|
||
|
||
return (
|
||
<ThemeProvider theme={theme}>
|
||
<Container maxWidth="md" style={{ marginTop: '1rem', marginBottom: '2rem' }}>
|
||
<Typography variant="h4" gutterBottom>
|
||
💫TsumugiBoshi
|
||
</Typography>
|
||
<Typography variant="subtitle1" gutterBottom>
|
||
神秘API调试工具
|
||
</Typography>
|
||
|
||
{/* API表单部分 */}
|
||
<Paper elevation={3} style={{ padding: '1rem', marginBottom: '1rem' }}>
|
||
<div className="tabs-container">
|
||
<Box className="scrollable-tabs">
|
||
<Tabs
|
||
value={tabValue}
|
||
onChange={(e, v) => setTabValue(v)}
|
||
indicatorColor="primary"
|
||
textColor="primary"
|
||
variant="scrollable"
|
||
scrollButtons="auto"
|
||
allowScrollButtonsMobile
|
||
>
|
||
{formConfigs && Object.values(formConfigs)
|
||
.sort((a, b) => a.tab.index - b.tab.index)
|
||
.map(config => (
|
||
<Tab
|
||
key={config.tab.index}
|
||
label={config.tab.label}
|
||
style={{ minWidth: 'max-content' }}
|
||
/>
|
||
))}
|
||
</Tabs>
|
||
</Box>
|
||
<IconButton
|
||
className="menu-button"
|
||
onClick={handleMenuClick}
|
||
size="small"
|
||
>
|
||
<span className="material-icons">menu</span>
|
||
</IconButton>
|
||
<Menu
|
||
anchorEl={menuAnchorEl}
|
||
open={Boolean(menuAnchorEl)}
|
||
onClose={handleMenuClose}
|
||
>
|
||
{formConfigs && Object.values(formConfigs)
|
||
.sort((a, b) => a.tab.index - b.tab.index)
|
||
.map(config => (
|
||
<MenuItem
|
||
key={config.tab.index}
|
||
onClick={() => handleMenuSelect(config.tab.index)}
|
||
selected={tabValue === config.tab.index}
|
||
>
|
||
{config.tab.label}
|
||
</MenuItem>
|
||
))}
|
||
</Menu>
|
||
</div>
|
||
|
||
{formConfigs && Object.entries(formConfigs).map(([key, config]) => (
|
||
tabValue === config.tab.index && (
|
||
<DynamicForm
|
||
key={key}
|
||
ref={el => formRefs.current[key] = el}
|
||
formConfig={config}
|
||
onSubmit={(data) => handleApiCall(
|
||
config.endpoint,
|
||
config.method,
|
||
data,
|
||
config
|
||
)}
|
||
/>
|
||
)
|
||
))}
|
||
</Paper>
|
||
|
||
{response && (
|
||
<Paper elevation={3} style={{ padding: '1rem', marginBottom: '1rem' }}>
|
||
<Typography variant="h6" gutterBottom>响应结果:</Typography>
|
||
<pre style={{
|
||
whiteSpace: 'pre-wrap',
|
||
wordWrap: 'break-word',
|
||
backgroundColor: '#f5f5f5',
|
||
padding: '1rem',
|
||
borderRadius: '4px',
|
||
fontSize: '0.9rem'
|
||
}}>
|
||
{response}
|
||
</pre>
|
||
</Paper>
|
||
)}
|
||
|
||
{/* 移动后端选择器到底部 */}
|
||
<Paper elevation={3} className="backend-selector">
|
||
<Typography variant="h6" gutterBottom>
|
||
⚙️ 后端设置
|
||
</Typography>
|
||
<FormControl fullWidth>
|
||
<InputLabel>选择后端地址</InputLabel>
|
||
<Select
|
||
value={apiBase}
|
||
onChange={handleApiBaseChange}
|
||
label="选择后端地址"
|
||
>
|
||
{/* 默认后端 */}
|
||
{PRESET_BACKENDS.map((backend) => (
|
||
<MenuItem key={backend.value} value={backend.value}>
|
||
{backend.label} - {backend.value}
|
||
</MenuItem>
|
||
))}
|
||
|
||
{/* 分割线 */}
|
||
{backends.some(b => !PRESET_BACKENDS.some(preset => preset.value === b.value)) && (
|
||
<MenuItem disabled>
|
||
<hr style={{ width: '100%', margin: '4px 0' }} />
|
||
</MenuItem>
|
||
)}
|
||
|
||
{/* 自定义后端 */}
|
||
{backends
|
||
.filter(b => !PRESET_BACKENDS.some(preset => preset.value === b.value))
|
||
.map((backend) => (
|
||
<MenuItem key={backend.value} value={backend.value}>
|
||
<Box display="flex" justifyContent="space-between" width="100%">
|
||
<span>{backend.label} - {backend.value}</span>
|
||
<Button
|
||
size="small"
|
||
color="error"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleDeleteBackend(backend.value);
|
||
}}
|
||
>
|
||
删除
|
||
</Button>
|
||
</Box>
|
||
</MenuItem>
|
||
))}
|
||
</Select>
|
||
|
||
<Button
|
||
variant="outlined"
|
||
color="primary"
|
||
style={{ marginTop: '1rem' }}
|
||
onClick={handleAddBackend}
|
||
>
|
||
添加自定义后端
|
||
</Button>
|
||
|
||
{/* 新增认证提示区域 */}
|
||
<Box display="flex" justifyContent="space-between" alignItems="center" mt={2}>
|
||
<Typography variant="body2" color="textSecondary">
|
||
⚠️部分后端地址受认证保护 使用前可能需要身份认证
|
||
</Typography>
|
||
<Button
|
||
variant="text"
|
||
color="primary"
|
||
onClick={() => window.open(apiBase, '_blank')}
|
||
>
|
||
认证
|
||
</Button>
|
||
</Box>
|
||
</FormControl>
|
||
</Paper>
|
||
|
||
{/* 日志卡片 */}
|
||
<Paper elevation={3} className="log-card">
|
||
<div
|
||
className="log-header"
|
||
onClick={() => setIsLogExpanded(!isLogExpanded)}
|
||
>
|
||
<Typography variant="h6">
|
||
📝 API调用日志
|
||
</Typography>
|
||
<IconButton size="small">
|
||
<span className="material-icons">
|
||
{isLogExpanded ? 'expand_less' : 'expand_more'}
|
||
</span>
|
||
</IconButton>
|
||
</div>
|
||
<Collapse in={isLogExpanded}>
|
||
<div className="log-content">
|
||
{logs.map((log, index) => (
|
||
<div key={index} className="log-item">
|
||
<Typography variant="subtitle2" gutterBottom>
|
||
{log.date} - {log.endpoint}
|
||
</Typography>
|
||
<Typography variant="body2" component="pre" style={{margin: 0}}>
|
||
请求: {JSON.stringify(log.request, null, 2)}
|
||
</Typography>
|
||
<Typography variant="body2" component="pre" style={{margin: 0}}>
|
||
响应: {JSON.stringify(log.response, null, 2)}
|
||
</Typography>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Collapse>
|
||
</Paper>
|
||
</Container>
|
||
|
||
{/* 添加自定义后端的对话框 */}
|
||
<Dialog open={isDialogOpen} onClose={handleDialogClose}>
|
||
<DialogTitle>添加自定义后端</DialogTitle>
|
||
<DialogContent>
|
||
<TextField
|
||
autoFocus
|
||
margin="dense"
|
||
label="后端名称"
|
||
fullWidth
|
||
value={newBackend.label}
|
||
onChange={(e) => setNewBackend(prev => ({...prev, label: e.target.value}))}
|
||
/>
|
||
<TextField
|
||
margin="dense"
|
||
label="后端地址"
|
||
fullWidth
|
||
value={newBackend.value}
|
||
onChange={handleNewBackendChange}
|
||
placeholder="example.com:2333"
|
||
helperText={
|
||
<div style={{fontSize: '0.75rem', color: '#666'}}>
|
||
支持的格式:<br/>
|
||
- example.com<br/>
|
||
- example.com:2333<br/>
|
||
- http(s)://example.com<br/>
|
||
{newBackend.value && (
|
||
<>
|
||
<br/>
|
||
将被格式化为: <span style={{color: '#1976d2'}}>{newBackend.formattedUrl}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
}
|
||
/>
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<Button onClick={handleDialogClose}>取消</Button>
|
||
<Button
|
||
onClick={() => handleSaveBackend()}
|
||
color="primary"
|
||
disabled={!newBackend.label || !newBackend.value}
|
||
>
|
||
保存
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
|
||
{/* 提示消息 */}
|
||
<Snackbar
|
||
open={snackbarOpen}
|
||
autoHideDuration={3000}
|
||
onClose={handleSnackbarClose}
|
||
>
|
||
<Alert onClose={handleSnackbarClose} severity="success">
|
||
{snackbarMessage}
|
||
</Alert>
|
||
</Snackbar>
|
||
</ThemeProvider>
|
||
);
|
||
}
|
||
|
||
ReactDOM.render(<App />, document.getElementById('root'));
|
||
</script>
|
||
</body>
|
||
</html> |