Tsumugiboshi/index.html

653 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<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="icons/og-image.png">
<meta property="og:url" content="https://your-domain.com">
<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>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
font-size: 16px;
}
#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) {
body {
font-size: 16px;
}
.MuiContainer-root {
padding: 12px !important;
}
.MuiPaper-root {
padding: 16px !important;
margin-bottom: 16px !important;
}
.MuiTypography-h4 {
font-size: 1.75rem !important;
margin-bottom: 0.5rem !important;
}
.MuiTypography-h6 {
font-size: 1.25rem !important;
}
.MuiTab-root {
min-height: 56px !important;
padding: 8px 16px !important;
font-size: 1rem !important;
}
.MuiInputBase-input {
font-size: 1rem !important;
padding: 14px !important;
}
.MuiButton-root {
padding: 12px 24px !important;
font-size: 1rem !important;
}
.backend-selector {
margin-top: 1.5rem;
padding: 1.25rem;
}
pre {
font-size: 0.9rem;
padding: 12px;
margin: 8px 0;
}
.api-description {
font-size: 0.95rem;
padding: 12px;
margin: 12px 0 !important;
}
/* 增强触摸区域 */
.MuiIconButton-root {
padding: 12px !important;
}
/* 改善表单间距 */
.MuiGrid-container {
gap: 16px;
}
/* 增加选择器高度 */
.MuiSelect-select {
padding: 14px !important;
}
/* 调整按钮大小和间距 */
.MuiButton-contained {
margin-top: 16px;
width: 100%;
height: 48px;
}
}
/* 添加暗色模式支持 */
@media (prefers-color-scheme: dark) {
body {
background-color: #121212;
color: #fff;
}
.MuiPaper-root {
background-color: #1e1e1e !important;
}
pre {
background-color: #2d2d2d;
color: #e0e0e0;
}
.api-description {
background-color: #2d2d2d;
border-left-color: #90caf9;
color: #e0e0e0;
}
footer {
background-color: #1e1e1e;
color: #888;
}
}
</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
} = 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 = ({ 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() {
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);
// 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));
} 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 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('后端URL已删除');
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}
formConfig={config}
onSubmit={(data) => handleApiCall(
config.endpoint,
config.method,
config.method === 'POST' ? data : null
)}
/>
)
))}
</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>
</Container>
</ThemeProvider>
);
}
ReactDOM.render(<App />, document.getElementById('root'));
</script>
</body>
</html>