<!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="/image/og-image.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 !important; } /* 表单样式 */ .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) { 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() { 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: '' }); 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' } }; 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') { if (config.requestFormat === 'json') { options.body = JSON.stringify(formData); } } const res = await fetch(url, options); const data = await res.json(); setResponse(JSON.stringify(data, null, 2)); // 保存日志 saveLog(endpoint, formData, data); // 处理登录响应 if (config.endpoint === '/qr' && data.userId) { setUserId(data.userId.toString()); setCookie('userId', data.userId.toString(), 7); // 遍历所有表单配置,更新包含userid/userId字段的表单 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()); } }); } // 根据状态设置提示消息 setSnackbarMessage(data.info || '操作成功'); 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: '' }); }; 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 [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> </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={(e) => setNewBackend(prev => ({...prev, value: e.target.value}))} /> </DialogContent> <DialogActions> <Button onClick={handleDialogClose}>取消</Button> <Button onClick={handleSaveBackend} color="primary">保存</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>