first update

This commit is contained in:
Zhuym 2025-01-28 15:22:18 +08:00
parent 0d8a82fc33
commit dd468817b4
13 changed files with 886 additions and 710 deletions

BIN
._index.html Normal file

Binary file not shown.

BIN
._src Normal file

Binary file not shown.

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
# 忽略UserDataDir文件夹
UserDataDir

16
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,16 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Chrome",
"type": "chrome",
"request": "launch",
"file": "${workspaceFolder}/index.html",
"runtimeArgs": [
"--disable-web-security",
"--user-data-dir=${workspaceFolder}/UserDataDir"
],
"webRoot": "${workspaceFolder}"
}
]
}

View File

@ -1,103 +0,0 @@
{
"title": "API调试工具",
"theme": "light",
"apis": [
{
"name": "二维码扫描",
"type": "qr",
"endpoint": "/qr",
"method": "GET",
"params": [
{
"key": "qrcode",
"label": "二维码内容",
"type": "qr",
"required": true
}
],
"responseFields": ["status", "timestamp", "info", "apiName", "date", "userId"]
},
{
"name": "发送6倍券",
"type": "action",
"endpoint": "/ticket",
"method": "GET",
"params": [
{
"key": "userid",
"label": "用户ID",
"type": "text",
"required": true,
"source": "userId"
}
],
"responseFields": ["status", "timestamp", "info", "apiName", "date", "userId"]
},
{
"name": "保存99公里",
"type": "action",
"endpoint": "/mapstock",
"method": "GET",
"params": [
{
"key": "userid",
"label": "用户ID",
"type": "text",
"required": true,
"source": "userId"
}
],
"responseFields": ["status", "timestamp", "info", "apiName", "date", "userId"]
},
{
"name": "解锁DX曲目",
"type": "action",
"endpoint": "/unlock",
"method": "GET",
"params": [
{
"key": "userid",
"label": "用户ID",
"type": "text",
"required": true,
"source": "userId"
}
],
"responseFields": ["status", "timestamp", "info", "apiName", "date", "userId"]
},
{
"name": "更新音乐数据",
"type": "form",
"endpoint": "/music",
"method": "POST",
"params": [
{
"key": "userId",
"label": "用户ID",
"type": "text",
"required": true,
"source": "userId"
},
{
"key": "musicId",
"label": "音乐ID",
"type": "number",
"required": true
},
{
"key": "level",
"label": "难度",
"type": "number",
"required": true
},
{
"key": "playCount",
"label": "游玩次数",
"type": "number",
"required": true
}
],
"responseFields": ["status", "timestamp", "info", "apiName", "date", "userId"]
}
]
}

161
config/label.json Normal file
View File

@ -0,0 +1,161 @@
{
"forms": {
"login": {
"tab": {
"index": 0,
"label": "🪪登录",
"description": "上传登录二维码解码后的字符串来获取 userID"
},
"endpoint": "/qr",
"method": "GET",
"fields": [
{
"id": "qrcode",
"label": "二维码内容",
"type": "text",
"required": true
}
],
"confirmBeforeSubmit": false
},
"ticket": {
"tab": {
"index": 1,
"label": "🎟发6倍票",
"description": "发送「6倍功能票」到账户"
},
"endpoint": "/ticket",
"method": "GET",
"fields": [
{
"id": "userid",
"label": "🪪UserID",
"type": "text",
"required": true
}
],
"confirmBeforeSubmit": true
},
"mapstock": {
"tab": {
"index": 2,
"label": "🗺存入Stock",
"description": "保存 99km Stocks 到账户"
},
"endpoint": "/mapstock",
"method": "GET",
"fields": [
{
"id": "userid",
"label": "🪪UserID",
"type": "text",
"required": true
}
],
"confirmBeforeSubmit": true
},
"unlock": {
"tab": {
"index": 3,
"label": "🔓解锁紫铺",
"description": "解锁所有 DX Master 谱面"
},
"endpoint": "/unlock",
"method": "GET",
"fields": [
{
"id": "userid",
"label": "🪪UserID",
"type": "text",
"required": true
}
],
"confirmBeforeSubmit": true
},
"music": {
"tab": {
"index": 4,
"label": "✏️修改成绩",
"description": "⚠警告\n这将[覆写]账户中的成绩数据"
},
"endpoint": "/music",
"method": "POST",
"fields": [
{
"id": "userId",
"label": "🪪UserID",
"type": "number",
"required": true
},
{
"id": "music.achievement",
"label": "💯分数",
"type": "number",
"placeholder": "1010000",
"required": true
},
{
"id": "music.level",
"label": "🧩难度",
"type": "select",
"options": [
{"value": 0, "label": "0 (🟩Bas)"},
{"value": 1, "label": "1 (🟨Adv)"},
{"value": 2, "label": "2 (🟥Exp)"},
{"value": 3, "label": "3 (🟪Mas)"},
{"value": 4, "label": "4 (⬜ReM)"}
],
"required": true
},
{
"id": "music.comboStatus",
"label": "Full Combo 状态",
"type": "select",
"options": [
{"value": 0, "label": "0 (⚪未FC)"},
{"value": 1, "label": "1 (🟢已FC)"}
],
"required": true
},
{
"id": "music.syncStatus",
"label": "Full Sync 状态",
"type": "select",
"options": [
{"value": 0, "label": "0 (⚪未Sync)"},
{"value": 1, "label": "1 (🔵已Sync)"}
],
"required": true
},
{
"id": "music.musicId",
"label": "🎵musicID",
"type": "number",
"placeholder": "11529",
"required": true
},
{
"id": "music.playCount",
"label": "🎰游玩次数",
"type": "number",
"placeholder": "1",
"required": true
},
{
"id": "music.deluxscoreMax",
"label": "🔢deluxe分数",
"type": "number",
"required": true
},
{
"id": "music.scoreRank",
"label": "scoreRank",
"type": "number",
"required": true
}
],
"confirmBeforeSubmit": true,
"confirmTemplate": "即将发送的数据:\nUserID: {userId}\n歌曲ID: {music.musicId}\nachievement: {music.achievement}\n难度: {music.level}\nFC状态: {music.comboStatus}\nsyncStatus: {music.syncStatus}\nFS状态:{music.syncStatus}\n\n原始请求:\n{rawData}"
}
}
}

View File

@ -16,14 +16,81 @@
<script src="https://unpkg.com/@mui/material@5.14.0/umd/material-ui.production.min.js"></script> <script src="https://unpkg.com/@mui/material@5.14.0/umd/material-ui.production.min.js"></script>
<style> <style>
body { margin: 20px; font-family: Arial, sans-serif; } body {
margin: 0;
font-family: Arial, sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
}
#root {
flex: 1;
}
pre { background: #f5f5f5; padding: 10px; border-radius: 4px; } pre { background: #f5f5f5; padding: 10px; border-radius: 4px; }
.api-description { margin-bottom: 16px; color: #666; } .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;
}
.selected-backend { font-weight: bold; color: #1976d2; } .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;
}
@media (max-width: 600px) {
.MuiContainer-root {
padding: 12px !important;
}
.MuiPaper-root {
padding: 12px !important;
}
h4.MuiTypography-root {
font-size: 1.5rem;
}
.scrollable-tabs::-webkit-scrollbar {
display: none;
}
.api-description {
font-size: 0.875rem;
padding: 8px;
}
}
</style> </style>
</head> </head>
<body> <body></body>
<div id="root"></div> <div id="root"></div>
<footer>
<Typography variant="body2" color="textSecondary">
© 2023 Tsumugiboshi. All rights reserved.
<br />
</Typography>
</footer>
<script type="text/babel"> <script type="text/babel">
// 从全局对象获取Material UI组件 // 从全局对象获取Material UI组件
@ -48,7 +115,10 @@
DialogContent, DialogContent,
DialogActions, DialogActions,
Snackbar, Snackbar,
Alert Alert,
Menu,
IconButton,
Link
} = MaterialUI; } = MaterialUI;
// 创建主题 // 创建主题
@ -61,6 +131,87 @@
{ label: "生产环境", value: "http://api.example.com" } { label: "生产环境", value: "http://api.example.com" }
]; ];
// 通用表单组件
const DynamicForm = ({ formConfig, onSubmit }) => {
const [formData, setFormData] = React.useState({});
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) {
let confirmMessage = formConfig.confirmTemplate || "确认提交数据?";
if (formConfig.confirmTemplate) {
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>
);
};
function App() { function App() {
const [tabValue, setTabValue] = React.useState(0); const [tabValue, setTabValue] = React.useState(0);
const [qrInput, setQrInput] = React.useState(''); const [qrInput, setQrInput] = React.useState('');
@ -90,6 +241,7 @@
const [newBackend, setNewBackend] = React.useState({ label: '', value: '' }); const [newBackend, setNewBackend] = React.useState({ label: '', value: '' });
const [snackbarOpen, setSnackbarOpen] = React.useState(false); const [snackbarOpen, setSnackbarOpen] = React.useState(false);
const [snackbarMessage, setSnackbarMessage] = React.useState(''); const [snackbarMessage, setSnackbarMessage] = React.useState('');
const [menuAnchorEl, setMenuAnchorEl] = React.useState(null);
// Cookie操作函数 // Cookie操作函数
function setCookie(name, value, days) { function setCookie(name, value, days) {
@ -169,9 +321,52 @@
setSnackbarOpen(false); 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 [formConfigs, setFormConfigs] = React.useState(null);
React.useEffect(() => {
fetch('config/label.json')
.then(res => res.json())
.then(config => setFormConfigs(config.forms));
}, []);
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<Container maxWidth="md" style={{ marginTop: '2rem', marginBottom: '2rem' }}> <Container maxWidth="md" style={{ marginTop: '1rem', marginBottom: '2rem' }}>
{/* 后端地址选择器 */} {/* 后端地址选择器 */}
<FormControl fullWidth style={{ marginBottom: '2rem' }}> <FormControl fullWidth style={{ marginBottom: '2rem' }}>
<InputLabel>选择后端地址</InputLabel> <InputLabel>选择后端地址</InputLabel>
@ -180,9 +375,38 @@
onChange={handleApiBaseChange} onChange={handleApiBaseChange}
label="选择后端地址" label="选择后端地址"
> >
{backends.map((backend) => ( {/* 默认后端 */}
{PRESET_BACKENDS.map((backend) => (
<MenuItem key={backend.value} value={backend.value}> <MenuItem key={backend.value} value={backend.value}>
{backend.label} - {backend.value} {apiBase === 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> </MenuItem>
))} ))}
</Select> </Select>
@ -235,178 +459,67 @@
</Typography> </Typography>
<Paper elevation={3} style={{ padding: '1rem', marginBottom: '1rem' }}> <Paper elevation={3} style={{ padding: '1rem', marginBottom: '1rem' }}>
<div className="tabs-container">
<Box className="scrollable-tabs">
<Tabs <Tabs
value={tabValue} value={tabValue}
onChange={(e, v) => setTabValue(v)} onChange={(e, v) => setTabValue(v)}
indicatorColor="primary" indicatorColor="primary"
textColor="primary" textColor="primary"
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
> >
<Tab label="🪪登录" /> {formConfigs && Object.values(formConfigs)
<Tab label="🎟发6倍票" /> .sort((a, b) => a.tab.index - b.tab.index)
<Tab label="🗺存入Stock" /> .map(config => (
<Tab label="🔓解锁紫铺" /> <Tab
<Tab label="✏️修改成绩" /> key={config.tab.index}
label={config.tab.label}
style={{ minWidth: 'max-content' }}
/>
))}
</Tabs> </Tabs>
</Box>
{/* 二维码转UID表单 */} <IconButton
{tabValue === 0 && ( className="menu-button"
<div style={{ padding: '1rem' }}> onClick={handleMenuClick}
<Typography variant="body1" className="api-description"> size="small"
上传登录二维码解码后的字符串来获取 userID。
</Typography>
<TextField
fullWidth
label="二维码内容"
value={qrInput}
onChange={(e) => setQrInput(e.target.value)}
style={{ marginBottom: '1rem' }}
/>
<Button
variant="contained"
color="primary"
onClick={() => handleApiCall(`/qr?qrcode=${encodeURIComponent(qrInput)}`)}
> >
获取UserID <span className="material-icons">menu</span>
</Button> </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> </div>
)}
{/* 通用GET请求表单 */} {formConfigs && Object.entries(formConfigs).map(([key, config]) => (
{[1, 2, 3].includes(tabValue) && ( tabValue === config.tab.index && (
<div style={{ padding: '1rem' }}> <DynamicForm
<Typography variant="body1" className="api-description"> key={key}
{tabValue === 1 && "发送「6倍功能票」到账户。"} formConfig={config}
{tabValue === 2 && "保存 99 公里 Stock 到账户。"} onSubmit={(data) => handleApiCall(
{tabValue === 3 && "解锁所有 DX Master 谱面。"} config.endpoint,
</Typography> config.method,
<TextField config.method === 'POST' ? data : null
label="UserID"
value={userId}
onChange={(e) => setUserId(e.target.value)}
style={{ marginBottom: '1rem' }}
fullWidth
/>
<Button
variant="contained"
color="primary"
onClick={() => {
const endpoints = ['/ticket', '/mapstock', '/unlock'];
handleApiCall(`${endpoints[tabValue-1]}?userid=${userId}`);
}}
>
发送请求
</Button>
</div>
)} )}
{/* 音乐数据POST表单 */}
{tabValue === 4 && (
<div style={{ padding: '1rem' }}>
<Typography variant="body1" className="api-description">
*覆写* 账户中的成绩数据
</Typography>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
fullWidth
label="🪪UserID"
type="number"
value={musicData.userId}
onChange={(e) => setMusicData({...musicData, userId: e.target.value})}
/> />
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="💯分数"
type="number"
value={musicData.music.achievement}
onChange={(e) => setMusicData({...musicData, music: {...musicData.music, achievement: e.target.value}})}
placeholder="1010000"
InputProps={{
style: { color: 'grey' }
}}
/>
</Grid>
<Grid item xs={12}>
<FormControl fullWidth>
<InputLabel id="level-select-label">🧩难度</InputLabel>
<Select
labelId="level-select-label"
id="level-select"
value={musicData.music.level}
onChange={(e) => setMusicData({...musicData, music: {...musicData.music, level: e.target.value}})}
>
<MenuItem value={0}>0 (🟩Bas)</MenuItem>
<MenuItem value={1}>1 (🟨Adv)</MenuItem>
<MenuItem value={2}>2 (🟥Exp)</MenuItem>
<MenuItem value={3}>3 (🟪Mas)</MenuItem>
<MenuItem value={4}>4 (⬜ReM)</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12}>
<FormControl fullWidth>
<InputLabel id="comboStatus-select-label">comboStatus</InputLabel>
<Select
labelId="comboStatus-select-label"
id="comboStatus-select"
value={musicData.music.comboStatus}
onChange={(e) => setMusicData({...musicData, music: {...musicData.music, comboStatus: e.target.value}})}
>
<MenuItem value={0}>0 (⚪未达成)</MenuItem>
<MenuItem value={1}>1 (🟢已达成)</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12}>
<FormControl fullWidth>
<InputLabel id="syncStatus-select-label">syncStatus</InputLabel>
<Select
labelId="syncStatus-select-label"
id="syncStatus-select"
value={musicData.music.syncStatus}
onChange={(e) => setMusicData({...musicData, music: {...musicData.music, syncStatus: e.target.value}})}
>
<MenuItem value={0}>0 (未同步)</MenuItem>
<MenuItem value={1}>1 (🔵已同步)</MenuItem>
</Select>
</FormControl>
</Grid>
{Object.entries(musicData.music).map(([key, val]) => (
key !== 'level' && key !== 'achievement' && key !== 'comboStatus' && key !== 'syncStatus' && (
<Grid item xs={6} sm={4} key={key}>
<TextField
fullWidth
label={key}
type="number"
name={key}
value={val}
onChange={handleMusicChange}
/>
</Grid>
) )
))} ))}
<Grid item xs={12}>
<Button
variant="contained"
color="primary"
onClick={async () => {
const dataToSend = JSON.stringify(musicData, null, 2);
const confirmationMessage = `即将发送的数据:\nUserID: ${musicData.userId}\n歌曲ID: ${musicData.music.musicId}\nachievement: ${musicData.music.achievement}\nlevel: ${musicData.music.level}\ncomboStatus: ${musicData.music.comboStatus}\nsyncStatus: ${musicData.music.syncStatus}\n\n原始请求:\n${dataToSend}`;
alert(confirmationMessage); // 显示即将发送的数据
const confirmation = window.confirm("确认提交数据?"); // 弹出确认对话框
if (confirmation) {
handleApiCall('/music', 'POST', musicData); // 如果用户确认,发送请求
}
}}
>
提交音乐数据
</Button>
</Grid>
</Grid>
</div>
)}
</Paper> </Paper>
{response && ( {response && (

View File

@ -1,431 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>API调试工具</title>
<!-- Material Icons -->
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<!-- React 依赖 -->
<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>
<!-- Babel -->
<script src="https://unpkg.com/@babel/standalone@7.21.5/babel.min.js"></script>
<!-- Material UI 组件库 -->
<script src="https://unpkg.com/@mui/material@5.14.0/umd/material-ui.production.min.js"></script>
<style>
body { margin: 20px; font-family: Arial, sans-serif; }
pre { background: #f5f5f5; padding: 10px; border-radius: 4px; }
.api-description { margin-bottom: 16px; color: #666; }
.selected-backend { font-weight: bold; color: #1976d2; }
</style>
</head>
<body>
<div id="root"></div>
<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
} = MaterialUI;
// 创建主题
const theme = createTheme();
// 预设的后端地址
const PRESET_BACKENDS = [
{ label: "开发环境", value: "http://dev-api.example.com" },
{ label: "测试环境", value: "http://test-api.example.com" },
{ label: "生产环境", value: "http://api.example.com" }
];
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,
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: '' });
const [snackbarOpen, setSnackbarOpen] = React.useState(false);
const [snackbarMessage, setSnackbarMessage] = React.useState('');
// 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 handleApiCall = async (endpoint, method = 'GET', body = null) => {
try {
const options = {
method,
headers: { 'Content-Type': 'application/json' }
};
if (body) options.body = JSON.stringify(body);
const res = await fetch(`${apiBase}${endpoint}`, options);
const data = await res.json();
setResponse(JSON.stringify(data, null, 2));
//记录日志
const log = {
timestamp: new Date().toLocaleString(),
apiName: data.apiName || endpoint,
status: data.status || 'Unknown',
response: data,
expanded: false
};
setLogs(prevLogs => [log, ...prevLogs]);
//处理userID
if (data.userId) {
setUserId(data.userId);
localStorage.setItem('userId', data.userId);
}
//显示成功toast
if (data.status === "200 OK") {
const date = new Date(data.timestamp * 1000).toLocaleString();
setSnackbarMessage(`成功: ${data.apiName} (${date})`);
setSnackbarOpen(true);
}
} catch (error) {
setResponse(`Error: ${error.message}`);
}
};
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: '' });
};
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 toggleLogExpanded = (index) => {
setLogs(prevLogs => {
const newLogs = [...prevLogs];
newLogs[index].expanded = !newLogs[index].expanded;
return newLogs;
});
};
0
return (
<ThemeProvider theme={theme}>
<Container maxWidth="md" style={{ marginTop: '2rem', marginBottom: '2rem' }}>
{/* 后端地址选择器 */}
<FormControl fullWidth style={{ marginBottom: '2rem' }}>
<InputLabel>选择后端地址</InputLabel>
<Select
value={apiBase}
onChange={handleApiBaseChange}
label="选择后端地址"
>
{backends.map((backend) => (
<MenuItem key={backend.value} value={backend.value}>
{backend.label} {apiBase === backend.value && '◉'}
</MenuItem>
))}
</Select>
<Button
variant="outlined"
color="primary"
style={{ marginTop: '1rem' }}
onClick={handleAddBackend}
>
添加自定义后端
</Button>
</FormControl>
{/* 添加自定义后端对话框 */}
<Dialog open={isDialogOpen} onClose={handleDialogClose}>
<DialogTitle>添加自定义后端</DialogTitle>
<DialogContent>
<TextField
fullWidth
label="后端名称"
value={newBackend.label}
onChange={(e) => setNewBackend({ ...newBackend, label: e.target.value })}
style={{ marginBottom: '1rem' }}
/>
<TextField
fullWidth
label="后端地址"
value={newBackend.value}
onChange={(e) => setNewBackend({ ...newBackend, value: e.target.value })}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleDialogClose}>取消</Button>
<Button onClick={handleSaveBackend} color="primary">保存</Button>
</DialogActions>
</Dialog>
{/* Snackbar 提示 */}
<Snackbar
open={snackbarOpen}
autoHideDuration={3000}
onClose={handleSnackbarClose}
>
<Alert onClose={handleSnackbarClose} severity="success">
{snackbarMessage}
</Alert>
</Snackbar>
<Typography variant="h4" gutterBottom>
API调试工具
</Typography>
<Paper elevation={3} style={{ padding: '1rem', marginBottom: '1rem' }}>
<Tabs
value={tabValue}
onChange={(e, v) => setTabValue(v)}
indicatorColor="primary"
textColor="primary"
>
<Tab label="二维码转UID" />
<Tab label="6倍票券" />
<Tab label="地图库存" />
<Tab label="解锁DX" />
<Tab label="音乐数据" />
</Tabs>
{/* 二维码转UID表单 */}
{tabValue === 0 && (
<div style={{ padding: '1rem' }}>
<Typography variant="body1" className="api-description">
通过上传用户登录二维码解码后的字符串来获取 userID。
</Typography>
<TextField
fullWidth
label="二维码内容"
value={qrInput}
onChange={(e) => setQrInput(e.target.value)}
style={{ marginBottom: '1rem' }}
/>
<Button
variant="contained"
color="primary"
onClick={() => handleApiCall(`/qr?qrcode=${encodeURIComponent(qrInput)}`)}
>
获取UserID
</Button>
</div>
)}
{/* 通用GET请求表单 */}
{[1, 2, 3].includes(tabValue) && (
<div style={{ padding: '1rem' }}>
<Typography variant="body1" className="api-description">
{tabValue === 1 && "使用用户的 userID 发送「6倍チケット」到账户。"}
{tabValue === 2 && "使用用户的 userID 保存 99 公里地图库存到账户。"}
{tabValue === 3 && "使用用户的 userID 解锁所有 DX Master 谱面。"}
</Typography>
<TextField
label="UserID"
value={userId}
onChange={(e) => setUserId(e.target.value)}
style={{ marginBottom: '1rem' }}
fullWidth
/>
<Button
variant="contained"
color="primary"
onClick={() => {
const endpoints = ['/ticket', '/mapstock', '/unlock'];
handleApiCall(`${endpoints[tabValue-1]}?userid=${userId}`);
}}
>
发送请求
</Button>
</div>
)}
{/* 音乐数据POST表单 */}
{tabValue === 4 && (
<div style={{ padding: '1rem' }}>
<Typography variant="body1" className="api-description">
覆写账户中的音乐数据
</Typography>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
fullWidth
label="UserID"
type="number"
value={musicData.userId}
onChange={(e) => setMusicData({...musicData, userId: e.target.value})}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="achievement"
type="number"
value={musicData.music.achievement}
onChange={(e) => setMusicData({...musicData, music: {...musicData.music, achievement: e.target.value}})}
placeholder="1010000"
InputProps={{
style: { color: 'grey' }
}}
/>
</Grid>
<Grid item xs={12}>
<FormControl fullWidth>
<InputLabel id="level-select-label">level</InputLabel>
<Select
labelId="level-select-label"
id="level-select"
value={musicData.music.level}
onChange={(e) => setMusicData({...musicData, music: {...musicData.music, level: e.target.value}})}
>
<MenuItem value={0}>0 (BAS)</MenuItem>
<MenuItem value={1}>1 (ADV)</MenuItem>
<MenuItem value={2}>2 (EXP)</MenuItem>
<MenuItem value={3}>3 (MAS)</MenuItem>
<MenuItem value={4}>4 (ReM)</MenuItem>
</Select>
</FormControl>
</Grid>
{Object.entries(musicData.music).map(([key, val]) => (
key !== 'level' && key !== 'achievement' && (
<Grid item xs={6} sm={4} key={key}>
<TextField
fullWidth
label={key}
type="number"
name={key}
value={val}
onChange={handleMusicChange}
/>
</Grid>
)
))}
<Grid item xs={12}>
<Button
variant="contained"
color="primary"
onClick={() => handleApiCall('/music', 'POST', musicData)}
>
提交音乐数据
</Button>
</Grid>
</Grid>
</div>
)}
</Paper>
{response && (
<Paper elevation={3} style={{ padding: '1rem' }}>
<Typography variant="h6" gutterBottom>响应结果:</Typography>
<pre style={{
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
backgroundColor: '#f5f5f5',
padding: '1rem',
borderRadius: '4px'
}}>
{response}
</pre>
</Paper>
)}
</Container>
</ThemeProvider>
);
}
ReactDOM.render(<App />, document.getElementById('root'));
</script>
</body>
</html>

228
src/components/ApiTabs.js Normal file
View File

@ -0,0 +1,228 @@
import React from 'react';
import {
Tabs,
Tab,
TextField,
Button,
Paper,
Typography,
Grid,
Box,
Select,
MenuItem,
InputLabel,
FormControl,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Snackbar,
Alert,
Radio,
RadioGroup,
FormControlLabel
} from '@mui/material';
import { setCookie } from '../utils/cookies';
function ApiTabs({
tabValue,
setTabValue,
qrInput,
setQrInput,
userId,
setUserId,
response,
setResponse,
musicData,
setMusicData,
apiBase,
setApiBase,
backends,
setBackends,
isDialogOpen,
setIsDialogOpen,
newBackend,
setNewBackend,
snackbarOpen,
setSnackbarOpen,
snackbarMessage,
setSnackbarMessage
}) {
const handleTabChange = (event, newValue) => {
setTabValue(newValue);
};
const handleUserIdChange = (event) => {
const value = event.target.value;
setUserId(value);
setCookie('userId', value);
setMusicData(prev => ({ ...prev, userId: parseInt(value) || 0 }));
};
const handleBackendChange = (event) => {
const value = event.target.value;
setApiBase(value);
localStorage.setItem('apiBase', value);
};
const handleAddBackend = () => {
if (!newBackend.label || !newBackend.value) {
setSnackbarMessage('请填写完整的后端信息');
setSnackbarOpen(true);
return;
}
const updatedBackends = [...backends, newBackend];
setBackends(updatedBackends);
localStorage.setItem('customBackends', JSON.stringify(
updatedBackends.slice(PRESET_BACKENDS.length)
));
setIsDialogOpen(false);
setNewBackend({ label: '', value: '' });
};
return (
<>
<Paper elevation={3} sx={{ p: 3, mb: 4 }}>
<Typography variant="h4" gutterBottom>
API调试工具
</Typography>
<div className="tabs-container">
<div className="scrollable-tabs">
<Tabs
value={tabValue}
onChange={handleTabChange}
variant="scrollable"
scrollButtons="auto"
>
<Tab label="二维码解析" />
<Tab label="音乐数据" />
</Tabs>
</div>
</div>
<Box sx={{ mt: 3 }}>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="用户ID"
value={userId}
onChange={handleUserIdChange}
variant="outlined"
/>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>后端环境</InputLabel>
<Select
value={apiBase}
onChange={handleBackendChange}
label="后端环境"
>
{backends.map((backend, index) => (
<MenuItem key={index} value={backend.value}>
{backend.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
</Grid>
{tabValue === 0 && (
<Box sx={{ mt: 3 }}>
<TextField
fullWidth
multiline
rows={4}
label="二维码内容"
value={qrInput}
onChange={(e) => setQrInput(e.target.value)}
variant="outlined"
/>
</Box>
)}
{tabValue === 1 && (
<Box sx={{ mt: 3 }}>
<Grid container spacing={2}>
{Object.entries(musicData.music).map(([key, value]) => (
<Grid item xs={12} sm={6} md={4} key={key}>
<TextField
fullWidth
label={key}
type="number"
value={value}
onChange={(e) => {
const newValue = parseInt(e.target.value) || 0;
setMusicData(prev => ({
...prev,
music: {
...prev.music,
[key]: newValue
}
}));
}}
variant="outlined"
/>
</Grid>
))}
</Grid>
</Box>
)}
{response && (
<Box sx={{ mt: 3 }}>
<Typography variant="h6" gutterBottom>
响应结果
</Typography>
<pre>{JSON.stringify(response, null, 2)}</pre>
</Box>
)}
</Box>
</Paper>
<Dialog open={isDialogOpen} onClose={() => setIsDialogOpen(false)}>
<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={(e) => setNewBackend(prev => ({ ...prev, value: e.target.value }))}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsDialogOpen(false)}>取消</Button>
<Button onClick={handleAddBackend}>添加</Button>
</DialogActions>
</Dialog>
<Snackbar
open={snackbarOpen}
autoHideDuration={6000}
onClose={() => setSnackbarOpen(false)}
>
<Alert
onClose={() => setSnackbarOpen(false)}
severity="error"
sx={{ width: '100%' }}
>
{snackbarMessage}
</Alert>
</Snackbar>
</>
);
}
export default ApiTabs;

79
src/components/App.js Normal file
View File

@ -0,0 +1,79 @@
import React, { useState } from 'react';
import { Container, Typography } from '@mui/material';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import ApiTabs from './ApiTabs';
import { PRESET_BACKENDS } from '../constants';
import { getCookie, setCookie } from '../utils/cookies';
const theme = createTheme();
function App() {
const [tabValue, setTabValue] = useState(0);
const [qrInput, setQrInput] = useState('');
const [userId, setUserId] = useState(getCookie('userId') || '');
const [response, setResponse] = useState('');
const [musicData, setMusicData] = useState({
userId: parseInt(userId) || 0,
music: {
musicId: 0,
level: 0,
achievement: 0,
playCount: 0,
comboStatus: 0,
syncStatus: 0,
deluxscoreMax: 0,
scoreRank: 0
}
});
const [apiBase, setApiBase] = useState(
localStorage.getItem('apiBase') || PRESET_BACKENDS[0].value
);
const [backends, setBackends] = useState([
...PRESET_BACKENDS,
...JSON.parse(localStorage.getItem('customBackends') || '[]')
]);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [newBackend, setNewBackend] = useState({ label: '', value: '' });
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
return (
<ThemeProvider theme={theme}>
<div id="root">
<Container maxWidth="md" sx={{ mt: 4, mb: 4 }}>
<ApiTabs
tabValue={tabValue}
setTabValue={setTabValue}
qrInput={qrInput}
setQrInput={setQrInput}
userId={userId}
setUserId={setUserId}
response={response}
setResponse={setResponse}
musicData={musicData}
setMusicData={setMusicData}
apiBase={apiBase}
setApiBase={setApiBase}
backends={backends}
setBackends={setBackends}
isDialogOpen={isDialogOpen}
setIsDialogOpen={setIsDialogOpen}
newBackend={newBackend}
setNewBackend={setNewBackend}
snackbarOpen={snackbarOpen}
setSnackbarOpen={setSnackbarOpen}
snackbarMessage={snackbarMessage}
setSnackbarMessage={setSnackbarMessage}
/>
</Container>
</div>
<footer>
<Typography variant="body2" color="textSecondary">
© 2023 Tsumugiboshi. All rights reserved.
</Typography>
</footer>
</ThemeProvider>
);
}
export default App;

5
src/constants/index.js Normal file
View File

@ -0,0 +1,5 @@
export const PRESET_BACKENDS = [
{ label: "开发环境", value: "http://dev-api.example.com" },
{ label: "测试环境", value: "http://test-api.example.com" },
{ label: "生产环境", value: "http://api.example.com" }
];

85
src/styles/main.css Normal file
View File

@ -0,0 +1,85 @@
body {
margin: 0;
font-family: Arial, sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
}
#root {
flex: 1;
}
pre {
background: #f5f5f5;
padding: 10px;
border-radius: 4px;
}
.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;
}
.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;
}
@media (max-width: 600px) {
.MuiContainer-root {
padding: 12px !important;
}
.MuiPaper-root {
padding: 12px !important;
}
h4.MuiTypography-root {
font-size: 1.5rem;
}
.scrollable-tabs::-webkit-scrollbar {
display: none;
}
.api-description {
font-size: 0.875rem;
padding: 8px;
}
}

21
src/utils/cookies.js Normal file
View File

@ -0,0 +1,21 @@
export function setCookie(name, value, days = 30) {
const d = new Date();
d.setTime(d.getTime() + (days * 24 * 60 * 60 * 1000));
const expires = `expires=${d.toUTCString()}`;
document.cookie = `${name}=${value};${expires};path=/`;
}
export function getCookie(name) {
const nameEQ = `${name}=`;
const ca = document.cookie.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === ' ') c = c.substring(1, c.length);
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
}
return null;
}
export function deleteCookie(name) {
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:01 GMT;path=/`;
}