Tsumugiboshi/index.html

921 lines
38 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
<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>