Skip to content

Commit b1b7fe7

Browse files
committed
feat: External API, console refresh button, README update
- Add External API with API Key auth for AI tools / automation - GET /api/external/status (server TPS, memory, CPU, players) - GET /api/external/players (online player list) - POST /api/external/command (execute MC commands) - Add console log refresh button (reconnect Socket.IO without clearing logs) - Add EXTERNAL_API_KEY to config and .env.example - Update README with External API documentation and usage examples
1 parent 4f750f1 commit b1b7fe7

7 files changed

Lines changed: 182 additions & 2 deletions

File tree

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
- **公告系统** — 创建/编辑/删除公告,一键推送至游戏内
1111
- **插件管理** — 查看已安装插件列表及状态
1212
- **Uptime 监控** — 集成 Uptime Kuma,展示服务器延迟与可用率图表
13+
- **External API** — API Key 认证的外部接口,供 AI 工具或自动化脚本调用
1314
- **响应式设计** — 桌面端侧边栏 + 移动端底部导航栏,完美适配各种设备
1415
- **暗色/亮色主题** — 支持一键切换,带圆形扩散过渡动画
1516

@@ -114,6 +115,32 @@ status-interval: 5000
114115
| `UPTIME_URL` | `http://localhost:3001` | Uptime Kuma 地址(可选) |
115116
| `UPTIME_API_KEY` | — | Uptime Kuma API 密钥(可选) |
116117
| `UPTIME_MONITOR_IDS` | `3,6` | 监控项 ID(可选) |
118+
| `EXTERNAL_API_KEY` | — | 外部 API 密钥,用于 AI 工具/自动化接入(可选) |
119+
120+
## External API
121+
122+
设置 `EXTERNAL_API_KEY` 环境变量后,可通过以下接口实现外部自动化控制:
123+
124+
**认证方式:** 所有请求需携带 Header `Authorization: Bearer <你的API_KEY>`
125+
126+
| 方法 | 路径 | 说明 | 请求体 |
127+
|------|------|------|--------|
128+
| GET | `/api/external/status` | 获取服务器状态(TPS、内存、CPU、玩家、MSPT) | — |
129+
| GET | `/api/external/players` | 获取在线玩家列表 | — |
130+
| POST | `/api/external/command` | 执行 MC 服务器命令 | `{"command": "say Hello"}` |
131+
132+
**示例:**
133+
134+
```bash
135+
# 查询服务器状态
136+
curl -H "Authorization: Bearer YOUR_API_KEY" http://localhost:3000/api/external/status
137+
138+
# 执行服务器命令
139+
curl -X POST -H "Authorization: Bearer YOUR_API_KEY" \
140+
-H "Content-Type: application/json" \
141+
-d '{"command":"say Hello from API"}' \
142+
http://localhost:3000/api/external/command
143+
```
117144

118145
## 项目结构
119146

backend/.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,6 @@ ADMIN_PASSWORD=admin123
1111
UPTIME_URL=http://localhost:3001
1212
UPTIME_API_KEY=
1313
UPTIME_MONITOR_IDS=3,6
14+
15+
# External API for AI tools / automation (optional)
16+
EXTERNAL_API_KEY=

backend/src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { authMiddleware } from './middleware/auth.js';
1515
import { bridgeService } from './services/bridge.js';
1616
import consoleRouter from './routes/console.js';
1717
import pluginsRouter from './routes/plugins.js';
18+
import externalRouter from './routes/external.js';
1819

1920
const app = express();
2021

@@ -30,6 +31,7 @@ app.get('/api/health', (_req, res) => {
3031
});
3132

3233
app.use('/api/auth', authRouter);
34+
app.use('/api/external', externalRouter);
3335

3436
app.use('/api', (req, res, next) => {
3537
// Skip auth for health and auth routes

backend/src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ export const config = {
1515
apiKey: process.env.UPTIME_API_KEY || '',
1616
monitorIds: (process.env.UPTIME_MONITOR_IDS || '3,6').split(','),
1717
},
18+
externalApiKey: process.env.EXTERNAL_API_KEY || '',
1819
};

backend/src/routes/external.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* External API — API Key authenticated endpoints for AI tools / automation.
3+
*
4+
* Auth: Header `Authorization: Bearer <EXTERNAL_API_KEY>`
5+
*
6+
* Endpoints:
7+
* GET /api/external/status — server status (TPS, memory, CPU, players, mspt)
8+
* POST /api/external/command — execute MC command { "command": "say hello" }
9+
* GET /api/external/players — online player list
10+
*/
11+
12+
import { Router, Request, Response, NextFunction } from 'express';
13+
import { config } from '../config.js';
14+
import { bridgeService } from '../services/bridge.js';
15+
import { getCachedStatus } from '../services/status.js';
16+
17+
const router = Router();
18+
19+
// API Key auth middleware
20+
function apiKeyAuth(req: Request, res: Response, next: NextFunction): void {
21+
if (!config.externalApiKey) {
22+
res.status(503).json({ error: 'External API is not configured. Set EXTERNAL_API_KEY env var.' });
23+
return;
24+
}
25+
26+
const auth = req.headers.authorization;
27+
if (!auth || !auth.startsWith('Bearer ')) {
28+
res.status(401).json({ error: 'Missing Authorization header. Use: Bearer <API_KEY>' });
29+
return;
30+
}
31+
32+
const key = auth.slice(7);
33+
if (key !== config.externalApiKey) {
34+
res.status(403).json({ error: 'Invalid API key' });
35+
return;
36+
}
37+
38+
next();
39+
}
40+
41+
router.use(apiKeyAuth);
42+
43+
// GET /api/external/status
44+
router.get('/status', (_req: Request, res: Response) => {
45+
const status = getCachedStatus();
46+
res.json({
47+
bridge: bridgeService.getStatus(),
48+
tps: status.tps ?? null,
49+
memory: status.memory ?? null,
50+
cpu: status.cpu ?? null,
51+
players: status.players ?? null,
52+
mspt: status.mspt ?? null,
53+
});
54+
});
55+
56+
// POST /api/external/command
57+
router.post('/command', async (req: Request, res: Response) => {
58+
const { command } = req.body as { command?: string };
59+
60+
if (!command || typeof command !== 'string') {
61+
res.status(400).json({ error: 'Missing "command" field' });
62+
return;
63+
}
64+
65+
if (bridgeService.getStatus() !== 'connected') {
66+
res.status(503).json({ error: 'Bridge not connected to MC server' });
67+
return;
68+
}
69+
70+
try {
71+
const result = await bridgeService.sendCommand(command);
72+
res.json({ success: true, result: result.result });
73+
} catch {
74+
res.status(500).json({ error: 'Command execution failed' });
75+
}
76+
});
77+
78+
// GET /api/external/players
79+
router.get('/players', (_req: Request, res: Response) => {
80+
const status = getCachedStatus();
81+
if (!status.players) {
82+
res.status(503).json({ error: 'Player data not available' });
83+
return;
84+
}
85+
res.json({
86+
online: status.players.online,
87+
max: status.players.max,
88+
list: status.players.list,
89+
});
90+
});
91+
92+
export default router;

frontend/src/stores/logs.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,9 @@ export const useLogStore = defineStore('logs', () => {
4949
initialized.value = true
5050
}
5151

52-
return { lines, initialized, addLine, setHistory, addCommandResponse, markInitialized }
52+
function markUninitialized() {
53+
initialized.value = false
54+
}
55+
56+
return { lines, initialized, addLine, setHistory, addCommandResponse, markInitialized, markUninitialized }
5357
})

frontend/src/views/ConsolePage.vue

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@ import { ref, onMounted, onUnmounted, watch } from 'vue'
33
import { useMessage } from 'naive-ui'
44
import XTerminal from '../components/XTerminal.vue'
55
import { useLogStore } from '../stores/logs'
6+
import { useSocket } from '../composables/useSocket'
67
import { http } from '../api/http'
78
89
const message = useMessage()
910
const logStore = useLogStore()
11+
const { disconnect, connect } = useSocket()
1012
1113
const terminalRef = ref<InstanceType<typeof XTerminal>>()
1214
const commandInput = ref('')
1315
const sending = ref(false)
16+
const refreshing = ref(false)
1417
1518
// Load command history from localStorage
1619
const savedHistory = localStorage.getItem('mc_command_history')
@@ -43,6 +46,18 @@ async function loadServerCommands() {
4346
}
4447
}
4548
49+
function handleRefresh() {
50+
refreshing.value = true
51+
disconnect()
52+
logStore.markUninitialized()
53+
setTimeout(() => {
54+
connect()
55+
logStore.markInitialized()
56+
refreshing.value = false
57+
message.success('日志已刷新')
58+
}, 500)
59+
}
60+
4661
onMounted(() => {
4762
renderedCount = 0
4863
renderPendingLines()
@@ -192,6 +207,13 @@ function handleInputEnter(e: KeyboardEvent) {
192207
<XTerminal ref="terminalRef" />
193208
</div>
194209
<div class="input-bar">
210+
<button class="refresh-btn" :disabled="refreshing" @click="handleRefresh" title="刷新日志">
211+
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" :class="{ spinning: refreshing }">
212+
<polyline points="23 4 23 10 17 10"></polyline>
213+
<polyline points="1 20 1 14 7 14"></polyline>
214+
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
215+
</svg>
216+
</button>
195217
<input
196218
ref="inputRef"
197219
v-model="commandInput"
@@ -232,6 +254,36 @@ function handleInputEnter(e: KeyboardEvent) {
232254
flex-shrink: 0;
233255
}
234256
257+
.refresh-btn {
258+
flex-shrink: 0;
259+
width: 42px;
260+
height: 42px;
261+
border: 1px solid var(--command-input-border, #3a3a3c);
262+
border-radius: 8px;
263+
background: var(--command-input-bg, #1e1e2e);
264+
color: var(--command-input-color, #cdd6f4);
265+
cursor: pointer;
266+
display: flex;
267+
align-items: center;
268+
justify-content: center;
269+
transition: border-color 0.2s, background 0.2s;
270+
}
271+
.refresh-btn:hover {
272+
border-color: #f472b6;
273+
background: rgba(244, 114, 182, 0.1);
274+
}
275+
.refresh-btn:disabled {
276+
opacity: 0.5;
277+
cursor: not-allowed;
278+
}
279+
.refresh-btn .spinning {
280+
animation: spin 0.6s linear infinite;
281+
}
282+
@keyframes spin {
283+
from { transform: rotate(0deg); }
284+
to { transform: rotate(360deg); }
285+
}
286+
235287
.command-input {
236288
flex: 1;
237289
min-width: 0;
@@ -245,7 +297,6 @@ function handleInputEnter(e: KeyboardEvent) {
245297
font-size: 15px;
246298
outline: none;
247299
transition: border-color 0.2s, background 0.2s, color 0.2s;
248-
/* Allow text to scroll horizontally when input is long */
249300
overflow-x: auto;
250301
white-space: nowrap;
251302
}

0 commit comments

Comments
 (0)