diff --git a/backend/example/config.example.json b/backend/example/config.example.json index 89f163f..cf308e8 100644 --- a/backend/example/config.example.json +++ b/backend/example/config.example.json @@ -17,5 +17,11 @@ "enable": true, "ttl_seconds": 60, "srv_ttl_second": 600 + }, + "appImage": { + "width": 700, + "height": 389, + "ttlSeconds": 300, + "tempDir": "./data/appImage-temp" } -} \ No newline at end of file +} diff --git a/backend/routes/appImage.js b/backend/routes/appImage.js new file mode 100644 index 0000000..fd45676 --- /dev/null +++ b/backend/routes/appImage.js @@ -0,0 +1,75 @@ +const express = require('express'); + +const router = express.Router(); +const logger = require('../utils/logger'); +const { queryServerStatus } = require('../services/queryService'); +const { generateAppImage } = require('../services/appImageRenderer'); +const { default: parseHost } = require('../utils/parsehost'); +const { logQuery } = require('../services/analyticsService'); + +router.get('/', async (req, res) => { + const clientIP = req.ip === '::1' ? '127.0.0.1' : req.ip.replace(/^::ffff:/, ''); + const { ip, port, host, stype, icon, srv, lang, dark } = req.query; + const isInternalRequest = req.headers['x-internal-request'] === 'true'; + + let referrer = req.headers.referer || null; + if (referrer) { + try { + referrer = new URL(referrer).hostname; + } catch { + referrer = null; + } + } + + const preHost = parseHost(ip, port, host); + if (!preHost.success) { + return res.status(400).json({ + status: 'error', + message: preHost.message || preHost.error + }); + } + + const fullAddress = preHost.port ? `${preHost.ip}:${preHost.port}` : preHost.ip; + const displayHost = typeof host === 'string' && host.trim() ? host.trim() : fullAddress; + logger.debug('[APP_IMAGE]', `${clientIP} 查询 ${fullAddress}`); + res.setHeader('Content-Type', 'image/png'); + + try { + const serverData = await queryServerStatus(preHost.ip, preHost.port, icon, stype, Boolean(srv === 'true'), isInternalRequest); + const imageBuffer = await generateAppImage({ ...serverData, displayHost }, { lang, imageUrl: icon, dark }); + + logQuery({ + endpoint: '/api/app_img', + ip: preHost.ip, + port: preHost.port, + clientIp: clientIP, + success: true, + serverType: serverData.type, + referrer, + from_cache: serverData.cached + }); + + return res.send(imageBuffer); + } catch (error) { + logger.warn('[APP_IMAGE]', `实时查服失败: ${fullAddress}`, error.message); + const imageBuffer = await generateAppImage({ + status: 'offline', + host: fullAddress, + displayHost + }, { lang, imageUrl: icon, dark }); + + logQuery({ + endpoint: '/api/app_img', + ip: preHost.ip, + port: preHost.port, + clientIp: clientIP, + success: false, + serverType: null, + referrer + }); + + return res.send(imageBuffer); + } +}); + +module.exports = router; diff --git a/backend/routes/screenshot.js b/backend/routes/screenshot.js new file mode 100644 index 0000000..06b4a37 --- /dev/null +++ b/backend/routes/screenshot.js @@ -0,0 +1,21 @@ +const express = require('express'); + +const router = express.Router(); +const { cleanupExpiredFiles, readTemporaryImage } = require('../services/appImageStorage'); + +router.get('/', (req, res) => { + cleanupExpiredFiles(); + + const result = readTemporaryImage(req.query.file); + if (!result) { + return res.status(404).json({ + status: 'error', + message: '图片不存在或已过期' + }); + } + + res.setHeader('Content-Type', 'image/png'); + return res.send(result.buffer); +}); + +module.exports = router; diff --git a/backend/routes/sync_appImage.js b/backend/routes/sync_appImage.js new file mode 100644 index 0000000..551514b --- /dev/null +++ b/backend/routes/sync_appImage.js @@ -0,0 +1,88 @@ +const express = require('express'); + +const router = express.Router(); +const logger = require('../utils/logger'); +const { queryServerStatus } = require('../services/queryService'); +const { generateAppImage } = require('../services/appImageRenderer'); +const { cleanupExpiredFiles, saveTemporaryImage } = require('../services/appImageStorage'); +const { default: parseHost } = require('../utils/parsehost'); +const { logQuery } = require('../services/analyticsService'); + +async function handleSync(req, res) { + const clientIP = req.ip === '::1' ? '127.0.0.1' : req.ip.replace(/^::ffff:/, ''); + const params = req.method === 'POST' ? { ...req.query, ...req.body } : req.query; + const { ip, port, host, stype, icon, srv, lang, dark } = params; + const isInternalRequest = req.headers['x-internal-request'] === 'true'; + + let referrer = req.headers.referer || null; + if (referrer) { + try { + referrer = new URL(referrer).hostname; + } catch { + referrer = null; + } + } + + const preHost = parseHost(ip, port, host); + if (!preHost.success) { + return res.status(400).json({ + status: 'error', + message: preHost.message || preHost.error + }); + } + + const fullAddress = preHost.port ? `${preHost.ip}:${preHost.port}` : preHost.ip; + const displayHost = typeof host === 'string' && host.trim() ? host.trim() : fullAddress; + logger.debug('[SYNC_APP_IMAGE]', `${clientIP} 同步 ${fullAddress}`); + + try { + cleanupExpiredFiles(); + + const serverData = await queryServerStatus(preHost.ip, preHost.port, icon, stype, Boolean(srv === 'true'), isInternalRequest); + const responseData = { ...serverData, displayHost }; + delete responseData.cached; + + const imageBuffer = await generateAppImage(responseData, { lang, imageUrl: icon, dark }); + const stored = saveTemporaryImage(imageBuffer, responseData); + const screenshotUrl = `${req.protocol}://${req.get('host')}/api/screenshot?file=${stored.file}`; + + logQuery({ + endpoint: '/api/sync_app_img', + ip: preHost.ip, + port: preHost.port, + clientIp: clientIP, + success: true, + serverType: responseData.type, + referrer, + from_cache: serverData.cached + }); + + return res.json({ + serverData: responseData, + screenshotUrl, + expireAt: stored.expireAt + }); + } catch (error) { + logger.warn('[SYNC_APP_IMAGE]', `生成失败: ${fullAddress}`, error.message); + + logQuery({ + endpoint: '/api/sync_app_img', + ip: preHost.ip, + port: preHost.port, + clientIp: clientIP, + success: false, + serverType: null, + referrer + }); + + return res.status(500).json({ + status: 'error', + message: '生成 appImage 失败' + }); + } +} + +router.get('/', handleSync); +router.post('/', handleSync); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index 6ed6665..74ab7c6 100644 --- a/backend/server.js +++ b/backend/server.js @@ -53,6 +53,9 @@ analyticsService.init(); const cacheService = require('./services/cacheService'); cacheService.init(); +const appImageStorage = require('./services/appImageStorage'); +appImageStorage.ensureTempDir(); +appImageStorage.cleanupExpiredFiles(); @@ -60,6 +63,9 @@ cacheService.init(); // 导入所有路由模块 const statusRoute = require('./routes/status'); const statusImageRoute = require('./routes/status_img'); // 导入新的图片路由 +const appImageRoute = require('./routes/appImage'); +const syncAppImageRoute = require('./routes/sync_appImage'); +const screenshotRoute = require('./routes/screenshot'); const configRoute = require('./routes/config'); const AuthRoute = require('./routes/auth'); const adminRoutes = require('./routes/admin'); @@ -75,6 +81,9 @@ app.use(express.json()); // --- API 路由 --- app.use('/api/status', statusRoute); app.use('/api/status_img', statusImageRoute); // 挂载新的图片路由 +app.use('/api/app_img', appImageRoute); +app.use('/api/sync_app_img', syncAppImageRoute); +app.use('/api/screenshot', screenshotRoute); app.use('/api/config', configRoute); app.use('/api/login', AuthRoute); app.use('/api/admin', adminRoutes); // 挂载 admin 路由 @@ -93,4 +102,4 @@ app.listen(PORT, (err) => { } else { logger.info('[SERVER]', `后端服务器正在 http://localhost:${PORT} 上运行`); } -}); \ No newline at end of file +}); diff --git a/backend/services/appImageRenderer.js b/backend/services/appImageRenderer.js new file mode 100644 index 0000000..b08e506 --- /dev/null +++ b/backend/services/appImageRenderer.js @@ -0,0 +1,608 @@ +const { createCanvas, loadImage } = require('canvas'); + +const frontConfig = require('../config/front.json'); +const { getAppImageConfig } = require('./appImageStorage'); +const zhCN = require('../../front/src/locales/zh-CN.json'); +const enUS = require('../../front/src/locales/en.json'); + +const FONT_FAMILY = 'sans-serif'; +const MC_CODE = '\u00A7'; +const LIGHT_COLORS = { + card: '#ffffff', + text: '#333333', + muted: '#6c757d', + primary: '#007bff', + border: '#e9ecef', + success: '#28a745', + successBg: '#e9f7ec', + shadow: 'rgba(0, 0, 0, 0.08)', + iconBg: '#dbe2ee', + iconText: '#8fa0ba', + obfuscated: '#c5ccd7' +}; +const DARK_COLORS = { + card: '#424242', + text: '#e0e0e0', + muted: '#9ca2a8', + primary: '#3694ff', + border: '#333333', + success: '#3ddc84', + successBg: 'rgba(61, 220, 132, 0.15)', + shadow: 'rgba(0, 0, 0, 0.2)', + iconBg: '#2f3945', + iconText: '#90a4c0', + obfuscated: '#97a0af' +}; +const LABELS = { + 'zh-CN': zhCN.comp.serverDis, + zh: zhCN.comp.serverDis, + 'en-US': enUS.comp.serverDis, + en: enUS.comp.serverDis +}; +const LEGACY_COLORS = { + '0': '#000000', '1': '#0000AA', '2': '#00AA00', '3': '#00AAAA', + '4': '#AA0000', '5': '#AA00AA', '6': '#FFAA00', '7': '#AAAAAA', + '8': '#555555', '9': '#5555FF', a: '#55FF55', b: '#55FFFF', + c: '#FF5555', d: '#FF55FF', e: '#FFFF55', f: '#FFFFFF' +}; +const JSON_COLORS = { + black: '#000000', + dark_blue: '#0000AA', + dark_green: '#00AA00', + dark_aqua: '#00AAAA', + dark_red: '#AA0000', + dark_purple: '#AA00AA', + gold: '#FFAA00', + gray: '#AAAAAA', + dark_gray: '#555555', + blue: '#5555FF', + green: '#55FF55', + aqua: '#55FFFF', + red: '#FF5555', + light_purple: '#FF55FF', + yellow: '#FFFF55', + white: '#FFFFFF' +}; +const OBFUSCATED = ["░", "▒", "▓", "█", "▌", "▐"]; + +function getLabels(lang) { + return LABELS[lang] || LABELS['zh-CN']; +} + +function isDarkModeEnabled(value) { + if (typeof value === 'boolean') return value; + if (typeof value !== 'string') return false; + const normalized = value.trim().toLowerCase(); + return normalized === 'true' || normalized === '1' || normalized === 'dark'; +} + +function getThemeColors(options = {}) { + return isDarkModeEnabled(options.dark) ? DARK_COLORS : LIGHT_COLORS; +} + +function drawRoundRect(ctx, x, y, width, height, radius) { + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.arcTo(x + width, y, x + width, y + radius, radius); + ctx.lineTo(x + width, y + height - radius); + ctx.arcTo(x + width, y + height, x + width - radius, y + height, radius); + ctx.lineTo(x + radius, y + height); + ctx.arcTo(x, y + height, x, y + height - radius, radius); + ctx.lineTo(x, y + radius); + ctx.arcTo(x, y, x + radius, y, radius); + ctx.closePath(); +} + +function fillRoundRect(ctx, x, y, width, height, radius, color) { + drawRoundRect(ctx, x, y, width, height, radius); + ctx.fillStyle = color; + ctx.fill(); +} + +function strokeLine(ctx, x1, y1, x2, y2, color) { + ctx.beginPath(); + ctx.strokeStyle = color; + ctx.lineWidth = 1; + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); +} + +function sanitizeText(value) { + if (value === undefined || value === null) return ''; + return String(value).replace(/\u00A7[0-9a-fk-or]/gi, '').trim(); +} + +function parseLegacySegments(text, colors) { + const source = String(text || ''); + const segments = []; + let state = { + color: colors.text, + bold: false, + italic: false, + underline: false, + strike: false, + obfuscated: false + }; + let buffer = ''; + + const flush = () => { + if (!buffer) return; + segments.push({ ...state, text: buffer }); + buffer = ''; + }; + + for (let i = 0; i < source.length; i++) { + const char = source[i]; + if (char === MC_CODE && i + 1 < source.length) { + flush(); + const code = source[++i].toLowerCase(); + if (LEGACY_COLORS[code]) { + state = { + color: LEGACY_COLORS[code], + bold: false, + italic: false, + underline: false, + strike: false, + obfuscated: false + }; + continue; + } + if (code === 'l') state.bold = true; + if (code === 'o') state.italic = true; + if (code === 'n') state.underline = true; + if (code === 'm') state.strike = true; + if (code === 'k') state.obfuscated = true; + if (code === 'r') { + state = { + color: colors.text, + bold: false, + italic: false, + underline: false, + strike: false, + obfuscated: false + }; + } + continue; + } + buffer += char; + } + + flush(); + return segments; +} + +function parseJsonSegments(node, colors, inherited = {}) { + if (node === null || node === undefined) return []; + if (typeof node === 'string') return parseLegacySegments(node, colors); + + const style = { + color: inherited.color || colors.text, + bold: inherited.bold || false, + italic: inherited.italic || false, + underline: inherited.underline || false, + strike: inherited.strike || false, + obfuscated: inherited.obfuscated || false + }; + + if (node.color) { + style.color = node.color.startsWith && node.color.startsWith('#') + ? node.color + : (JSON_COLORS[node.color] || style.color); + } + if (node.bold !== undefined) style.bold = Boolean(node.bold); + if (node.italic !== undefined) style.italic = Boolean(node.italic); + if (node.underlined !== undefined) style.underline = Boolean(node.underlined); + if (node.strikethrough !== undefined) style.strike = Boolean(node.strikethrough); + if (node.obfuscated !== undefined) style.obfuscated = Boolean(node.obfuscated); + + let segments = []; + if (typeof node.text === 'string' && node.text) { + segments.push({ ...style, text: node.text }); + } + if (Array.isArray(node.extra)) { + node.extra.forEach(item => { + segments = segments.concat(parseJsonSegments(item, colors, style)); + }); + } + + return segments; +} + +function getMotdSegments(motd, colors) { + if (!motd) return []; + if (typeof motd === 'object') return parseJsonSegments(motd, colors); + return parseLegacySegments(motd, colors); +} + +function layoutMotdLines(ctx, motd, maxWidth, maxLines, colors) { + const segments = getMotdSegments(motd, colors); + const lines = [[]]; + let lineIndex = 0; + let lineWidth = 0; + + const nextLine = () => { + if (lineIndex >= maxLines - 1) { + return false; + } + lineIndex += 1; + lines[lineIndex] = []; + lineWidth = 0; + return true; + }; + + for (const segment of segments) { + const chars = segment.obfuscated + ? Array.from({ length: segment.text.length }, (_, index) => OBFUSCATED[index % OBFUSCATED.length]) + : segment.text.split(''); + + for (const char of chars) { + ctx.font = `${segment.italic ? 'italic ' : ''}${segment.bold ? '700' : '600'} 18px "${FONT_FAMILY}"`; + const charWidth = ctx.measureText(char).width; + + if (char === '\n') { + if (!nextLine()) return lines; + continue; + } + + if (lineWidth + charWidth > maxWidth && lineWidth > 0) { + if (!nextLine()) return lines; + } + + lines[lineIndex].push({ + char, + width: charWidth, + color: segment.obfuscated ? colors.obfuscated : (segment.color || colors.text), + bold: segment.bold, + italic: segment.italic + }); + lineWidth += charWidth; + } + } + + return lines; +} + +function drawMotd(ctx, motd, x, centerY, maxWidth, maxLines, colors) { + const lines = layoutMotdLines(ctx, motd, maxWidth, maxLines, colors); + const lineHeight = 28; + const totalHeight = (lines.length - 1) * lineHeight; + let cursorY = centerY - totalHeight / 2 + 7; + + lines.forEach(line => { + let cursorX = x; + line.forEach(token => { + ctx.font = `${token.italic ? 'italic ' : ''}${token.bold ? '700' : '600'} 18px "${FONT_FAMILY}"`; + ctx.fillStyle = token.color; + ctx.fillText(token.char, cursorX, cursorY); + cursorX += token.width; + }); + cursorY += lineHeight; + }); +} + +function wrapPlainText(ctx, text, maxWidth, maxLines) { + const source = sanitizeText(text) || '-'; + const lines = []; + let current = ''; + let lastBreakIndex = -1; + + const pushLine = (line) => { + lines.push(line); + return lines.length >= maxLines; + }; + + const resetBreakIndex = () => { + lastBreakIndex = -1; + for (let i = current.length - 1; i >= 0; i--) { + if (/[.\-_/:\s]/.test(current[i])) { + lastBreakIndex = i; + break; + } + } + }; + + for (const char of source) { + if (char === '\n') { + if (pushLine(current || '-')) break; + current = ''; + lastBreakIndex = -1; + continue; + } + + current += char; + if (/[.\-_/:\s]/.test(char)) { + lastBreakIndex = current.length - 1; + } + + if (ctx.measureText(current).width <= maxWidth) { + continue; + } + + let line = current.slice(0, -1); + let remainder = char; + + if (lastBreakIndex >= 0) { + line = current.slice(0, lastBreakIndex + 1).trimEnd(); + remainder = current.slice(lastBreakIndex + 1).trimStart(); + } + + if (!line) { + line = current.slice(0, -1); + remainder = char; + } + + if (pushLine(line || remainder.slice(0, 1))) break; + current = line ? remainder : remainder.slice(1); + resetBreakIndex(); + } + + if (current && lines.length < maxLines) lines.push(current); + return lines.slice(0, maxLines); +} + +function truncateLinesToWidth(ctx, lines, maxWidth) { + if (!lines.length) return lines; + const result = [...lines]; + const lastIndex = result.length - 1; + let lastLine = result[lastIndex]; + if (ctx.measureText(lastLine).width <= maxWidth) return result; + + while (lastLine.length > 1 && ctx.measureText(`${lastLine}...`).width > maxWidth) { + lastLine = lastLine.slice(0, -1); + } + result[lastIndex] = `${lastLine}...`; + return result; +} + +function fitTextBlock(ctx, text, preferredWidth, maxWidth, maxLines) { + let width = preferredWidth; + let lines = wrapPlainText(ctx, text, width, maxLines); + + while (lines.length === maxLines && ctx.measureText(lines[lines.length - 1]).width >= width && width < maxWidth) { + width += 8; + lines = wrapPlainText(ctx, text, width, maxLines); + } + + const fullTextWidth = ctx.measureText(sanitizeText(text)).width; + if (fullTextWidth <= width) { + return { width, lines: [sanitizeText(text)] }; + } + + const candidate = wrapPlainText(ctx, text, width, maxLines); + const normalized = candidate.length > maxLines ? candidate.slice(0, maxLines) : candidate; + const truncated = truncateLinesToWidth(ctx, normalized, width); + return { width, lines: truncated }; +} + +function splitHostLines(value) { + const text = sanitizeText(value); + const colonIndex = text.lastIndexOf(':'); + if (colonIndex > 0 && colonIndex < text.length - 1) { + return [text.slice(0, colonIndex), text.slice(colonIndex)]; + } + return [text]; +} + +function fitHostBlock(ctx, value, preferredWidth, maxWidth, maxLines) { + const splitLines = splitHostLines(value); + let width = preferredWidth; + + while (width <= maxWidth) { + if (splitLines.length <= maxLines && splitLines.every(line => ctx.measureText(line).width <= width)) { + return { width, lines: splitLines }; + } + + const wrapped = wrapPlainText(ctx, value, width, maxLines); + const wrappedText = sanitizeText(wrapped.join('')); + if (wrappedText === sanitizeText(value) && wrapped.every(line => ctx.measureText(line).width <= width)) { + return { width, lines: wrapped }; + } + + width += 8; + } + + const wrapped = wrapPlainText(ctx, value, maxWidth, maxLines); + return { + width: maxWidth, + lines: truncateLinesToWidth(ctx, wrapped, maxWidth) + }; +} + +function drawInfoCell(ctx, label, value, x, y, width, primary, colors, options = {}) { + ctx.textAlign = 'left'; + ctx.fillStyle = colors.muted; + ctx.font = `400 15px "${FONT_FAMILY}"`; + ctx.fillText(label, x, y); + + ctx.fillStyle = primary ? colors.primary : colors.text; + const baseFontSize = options.fontSize || 18; + const lineHeight = options.lineHeight || 22; + ctx.font = `700 ${baseFontSize}px "${FONT_FAMILY}"`; + const fit = options.strictWidth + ? { + width, + lines: truncateLinesToWidth(ctx, wrapPlainText(ctx, value, width, options.maxLines || 2), width) + } + : (options.hostWrap + ? fitHostBlock(ctx, value, width, options.maxWidth || width, options.maxLines || 2) + : fitTextBlock(ctx, value, width, options.maxWidth || width, options.maxLines || 2)); + let lines = fit.lines; + + if (options.forceWrap && lines.length === 1 && ctx.measureText(lines[0]).width > fit.width) { + lines = truncateLinesToWidth(ctx, wrapPlainText(ctx, value, fit.width, options.maxLines || 2), fit.width); + } + + lines.forEach((line, index) => ctx.fillText(line, x, y + 28 + index * lineHeight)); +} + +async function drawIcon(ctx, iconUrl, x, y, size, colors) { + if (iconUrl) { + try { + const image = await loadImage(iconUrl); + ctx.save(); + drawRoundRect(ctx, x, y, size, size, 8); + ctx.clip(); + ctx.drawImage(image, x, y, size, size); + ctx.restore(); + return; + } catch {} + } + + fillRoundRect(ctx, x, y, size, size, 24, colors.iconBg); + ctx.fillStyle = colors.iconText; + ctx.textAlign = 'center'; + ctx.font = `700 40px "${FONT_FAMILY}"`; + ctx.fillText('MC', x + size / 2, y + size / 2 + 13); +} + +function getStatusBadgeWidth(ctx, text) { + const badgeText = String(text || 'ONLINE').toUpperCase(); + ctx.font = `700 16px "${FONT_FAMILY}"`; + return Math.ceil(ctx.measureText(badgeText).width) + 36; +} + +function drawStatusBadge(ctx, text, x, y, colors) { + const badgeText = String(text || 'ONLINE').toUpperCase(); + const width = getStatusBadgeWidth(ctx, badgeText); + fillRoundRect(ctx, x, y, width, 32, 16, colors.successBg); + ctx.beginPath(); + ctx.fillStyle = colors.success; + ctx.arc(x + 14, y + 16, 4, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = colors.success; + ctx.textAlign = 'left'; + ctx.fillText(badgeText, x + 22, y + 21); +} + +function drawOffline(ctx, width, height, colors) { + fillRoundRect(ctx, 0, 0, width, height, 12, colors.card); + const slogan = frontConfig.failureState?.defaultSlogan || '服务器未响应'; + const subtext = frontConfig.failureState?.subtext || '服务器未响应或不存在'; + + ctx.textAlign = 'center'; + ctx.fillStyle = colors.text; + ctx.font = `600 26px "${FONT_FAMILY}"`; + wrapPlainText(ctx, slogan, 440, 2).forEach((line, index) => ctx.fillText(line, width / 2, 160 + index * 34)); + ctx.fillStyle = colors.muted; + ctx.font = `400 16px "${FONT_FAMILY}"`; + wrapPlainText(ctx, subtext, 440, 2).forEach((line, index) => ctx.fillText(line, width / 2, 220 + index * 24)); +} + +async function drawOnline(ctx, width, height, serverData, options) { + const labels = getLabels(options.lang); + const colors = getThemeColors(options); + const iconColumnWidth = 120; + const contentX = iconColumnWidth + 24; + const headerCenterY = 40; + const displayHost = sanitizeText(serverData.displayHost || serverData.host); + const iconUrl = serverData.icon || options.imageUrl; + + fillRoundRect(ctx, 0, 0, width, height, 12, colors.card); + strokeLine(ctx, iconColumnWidth, 0, iconColumnWidth, height, colors.border); + strokeLine(ctx, iconColumnWidth, 76, width, 76, colors.border); + strokeLine(ctx, iconColumnWidth, 298, width, 298, colors.border); + + await drawIcon(ctx, iconUrl, 24, 159, 72, colors); + + ctx.textAlign = 'left'; + drawMotd(ctx, serverData.motd || serverData.pureMotd || serverData.host, contentX, headerCenterY, 408, 2, colors); + + const badgeWidth = getStatusBadgeWidth(ctx, serverData.type || serverData.status || 'ONLINE'); + drawStatusBadge(ctx, serverData.type || serverData.status || 'ONLINE', width - 24 - badgeWidth, 24, colors); + + const columns = [contentX, contentX + 202, contentX + 392]; + const rows = [122, 214]; + const rightColumnWidth = Math.max(120, width - columns[2] - 24); + const players = serverData.players || {}; + const cells = [ + { label: labels.host, value: displayHost, primary: false, width: 202, maxWidth: 242, fontSize: 18, lineHeight: 22, maxLines: 2, forceWrap: true, hostWrap: true, strictWidth: true }, + { label: labels.version, value: sanitizeText(serverData.version), primary: true, maxWidth: 160 }, + { label: labels.protocol, value: sanitizeText(serverData.protocol), primary: false, width: rightColumnWidth, maxWidth: rightColumnWidth }, + { label: labels.gamemode, value: sanitizeText(serverData.gamemode), primary: false, maxWidth: 160 }, + { label: labels.delay, value: `${sanitizeText(serverData.delay)}ms`, primary: false, maxWidth: 160 }, + { label: labels.levelname, value: sanitizeText(serverData.levelname), primary: false, width: rightColumnWidth, maxWidth: rightColumnWidth, fontSize: 16, lineHeight: 20, maxLines: 2, strictWidth: true } + ].filter(item => item.value && item.value !== 'ms'); + + if (players.sample) { + cells.push({ + label: labels.onlineList, + value: sanitizeText(players.sample), + primary: false, + width: 170, + fontSize: 15, + lineHeight: 18, + maxLines: 2 + }); + } + + cells.forEach((cell, index) => { + const col = index % 3; + const row = Math.floor(index / 3); + drawInfoCell( + ctx, + cell.label, + cell.value, + columns[col], + rows[row], + cell.width || 150, + cell.primary, + colors, + { + fontSize: cell.fontSize, + lineHeight: cell.lineHeight, + maxLines: cell.maxLines, + maxWidth: cell.maxWidth, + forceWrap: cell.forceWrap, + hostWrap: cell.hostWrap, + strictWidth: cell.strictWidth + } + ); + }); + + const online = Number(players.online) || 0; + const max = Number(players.max) || 0; + const ratio = max > 0 ? Math.min(1, online / max) : 0; + + ctx.fillStyle = colors.text; + ctx.font = `400 16px "${FONT_FAMILY}"`; + const prefix = `${labels.players}: `; + ctx.fillText(prefix, contentX, 340); + const prefixWidth = ctx.measureText(prefix).width; + ctx.font = `700 18px "${FONT_FAMILY}"`; + ctx.fillText(`${online}`, contentX + prefixWidth, 340); + const onlineWidth = ctx.measureText(`${online}`).width; + ctx.font = `400 16px "${FONT_FAMILY}"`; + ctx.fillText(` / ${max}`, contentX + prefixWidth + onlineWidth, 340); + + const barWidth = width - iconColumnWidth - 48; + fillRoundRect(ctx, contentX, 357, barWidth, 8, 4, colors.border); + fillRoundRect(ctx, contentX, 357, Math.max(0, Math.min(barWidth, barWidth * ratio)), 8, 4, colors.success); +} + +async function generateAppImage(serverData, options = {}) { + const { width, height } = getAppImageConfig(); + const canvas = createCanvas(width, height); + const ctx = canvas.getContext('2d'); + const colors = getThemeColors(options); + + ctx.clearRect(0, 0, width, height); + ctx.shadowColor = colors.shadow; + ctx.shadowBlur = 24; + ctx.shadowOffsetY = 6; + fillRoundRect(ctx, 0, 0, width, height, 12, colors.card); + ctx.shadowColor = 'transparent'; + + if (serverData.status === 'offline' || !serverData.version) { + drawOffline(ctx, width, height, colors); + } else { + await drawOnline(ctx, width, height, serverData, options); + } + + return canvas.toBuffer('image/png'); +} + +module.exports = { + generateAppImage +}; diff --git a/backend/services/appImageStorage.js b/backend/services/appImageStorage.js new file mode 100644 index 0000000..f0db5fc --- /dev/null +++ b/backend/services/appImageStorage.js @@ -0,0 +1,168 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const config = require('../config/config.json'); +const logger = require('../utils/logger'); + +const DEFAULT_TEMP_DIR = './data/appImage-temp'; +const DEFAULT_TTL_SECONDS = 300; + +function ensureDirectoryExists(dirPath) { + const resolvedPath = path.resolve(dirPath); + const parsed = path.parse(resolvedPath); + const segments = resolvedPath + .slice(parsed.root.length) + .split(path.sep) + .filter(Boolean); + + let currentPath = parsed.root; + if (!fs.existsSync(currentPath)) { + fs.mkdirSync(currentPath); + } + + segments.forEach(segment => { + currentPath = path.join(currentPath, segment); + if (!fs.existsSync(currentPath)) { + try { + fs.mkdirSync(currentPath); + } catch (error) { + if (error.code !== 'EEXIST') { + throw error; + } + } + } + }); +} + +function getAppImageConfig() { + const appImageConfig = config.appImage || {}; + const tempDir = path.isAbsolute(appImageConfig.tempDir || '') + ? appImageConfig.tempDir + : path.join(__dirname, '..', appImageConfig.tempDir || DEFAULT_TEMP_DIR); + + return { + width: appImageConfig.width || 700, + height: appImageConfig.height || 1000, + ttlSeconds: appImageConfig.ttlSeconds || DEFAULT_TTL_SECONDS, + tempDir + }; +} + +function ensureTempDir() { + const { tempDir } = getAppImageConfig(); + ensureDirectoryExists(tempDir); + return tempDir; +} + +function isValidFileName(fileName) { + return typeof fileName === 'string' && /^[a-f0-9]{24}\.png$/i.test(fileName); +} + +function getFilePaths(fileName) { + const tempDir = ensureTempDir(); + return { + imagePath: path.join(tempDir, fileName), + metadataPath: path.join(tempDir, fileName.replace(/\.png$/i, '.json')) + }; +} + +function safeUnlink(filePath) { + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } catch (error) { + logger.warn('[APP_IMAGE]', `删除临时文件失败: ${filePath}`, error.message); + } +} + +function deleteStoredImage(fileName) { + if (!isValidFileName(fileName)) { + return; + } + + const { imagePath, metadataPath } = getFilePaths(fileName); + safeUnlink(imagePath); + safeUnlink(metadataPath); +} + +function cleanupExpiredFiles() { + const tempDir = ensureTempDir(); + const now = Date.now(); + const files = fs.readdirSync(tempDir); + + files + .filter(file => file.endsWith('.json')) + .forEach(file => { + const metadataPath = path.join(tempDir, file); + try { + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); + if (!metadata.expireAt || metadata.expireAt <= now) { + deleteStoredImage(metadata.file || file.replace(/\.json$/i, '.png')); + } + } catch (error) { + logger.warn('[APP_IMAGE]', `清理损坏元数据失败: ${file}`, error.message); + deleteStoredImage(file.replace(/\.json$/i, '.png')); + } + }); +} + +function saveTemporaryImage(buffer, serverData) { + cleanupExpiredFiles(); + + const file = `${crypto.randomBytes(12).toString('hex')}.png`; + const { ttlSeconds } = getAppImageConfig(); + const createdAt = Date.now(); + const expireAt = createdAt + ttlSeconds * 1000; + const { imagePath, metadataPath } = getFilePaths(file); + + fs.writeFileSync(imagePath, buffer); + fs.writeFileSync(metadataPath, JSON.stringify({ + file, + createdAt, + expireAt, + serverData + }, null, 2)); + + return { file, createdAt, expireAt }; +} + +function readTemporaryImage(fileName) { + cleanupExpiredFiles(); + + if (!isValidFileName(fileName)) { + return null; + } + + const { imagePath, metadataPath } = getFilePaths(fileName); + if (!fs.existsSync(imagePath) || !fs.existsSync(metadataPath)) { + return null; + } + + try { + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); + if (!metadata.expireAt || metadata.expireAt <= Date.now()) { + deleteStoredImage(fileName); + return null; + } + + return { + metadata, + buffer: fs.readFileSync(imagePath) + }; + } catch (error) { + logger.warn('[APP_IMAGE]', `读取临时图片失败: ${fileName}`, error.message); + deleteStoredImage(fileName); + return null; + } +} + +module.exports = { + cleanupExpiredFiles, + deleteStoredImage, + ensureTempDir, + getAppImageConfig, + readTemporaryImage, + saveTemporaryImage +}; diff --git a/front/src/components/ImageLinkGenerator.vue b/front/src/components/ImageLinkGenerator.vue index b53487a..7088ddb 100644 --- a/front/src/components/ImageLinkGenerator.vue +++ b/front/src/components/ImageLinkGenerator.vue @@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'; // [新增] 引入 useConfig Hook import { useConfig } from '../composables/useConfig'; -const { t } = useI18n(); +const { t, locale } = useI18n(); // [新增] 获取全局配置 const config = useConfig(); // 获取 t 函数和当前的 locale @@ -31,7 +31,10 @@ const imageUrl = ref(''); const selectedTheme = ref(''); // [新增] 从全局配置中安全地获取可用主题列表 -const availableThemes = computed(() => config.value?.image_generator?.themes || []); +const availableThemes = computed(() => { + const themes = config.value?.image_generator?.themes || []; + return [...themes, { name: t('comp.imgG.previewCard'), value: 'app_card' }]; +}); // [新增] 监听配置加载,设置默认主题 watch(config, (newConfig) => { @@ -54,8 +57,12 @@ watch(() => [props.address, props.port, props.serverType, props.isSrv, selectedT if (props.serverType) params.append('stype', props.serverType); if (props.isSrv) params.append('srv', String(props.isSrv)); // [新增] 将选中的主题作为 template 参数添加到 URL 中 + if (selectedTheme.value === 'app_card') { + params.append('lang', locale.value || 'zh-CN'); + imageUrl.value = `/api/app_img?${params.toString()}`; + return; + } params.append('theme', selectedTheme.value); - imageUrl.value = `/api/status_img?${params.toString()}`; }, { immediate: true, deep: true }); @@ -281,4 +288,4 @@ select.form-input { transform: rotate(360deg); } } - \ No newline at end of file + diff --git a/front/src/components/docs/ApiTester.vue b/front/src/components/docs/ApiTester.vue index 78d24b3..f127a7f 100644 --- a/front/src/components/docs/ApiTester.vue +++ b/front/src/components/docs/ApiTester.vue @@ -12,6 +12,10 @@ const testStype = ref('auto'); const testSrv = ref(false); const testTheme = ref('simple'); const testIcon = ref(''); +const testLang = ref('zh-CN'); +const testDark = ref(false); +const testJsonEndpoint = ref('/api/status'); +const testImageEndpoint = ref('/api/status_img'); const jsonResponse = ref(null); const isJsonLoading = ref(false); @@ -25,7 +29,11 @@ const generatedJsonUrl = computed(() => { if (testStype.value !== 'auto') params.append('stype', testStype.value); if (testSrv.value) params.append('srv', 'true'); if (testIcon.value) params.append('icon', testIcon.value); - return `/api/status?${params.toString()}`; + if (testJsonEndpoint.value === '/api/sync_app_img') { + if (testLang.value) params.append('lang', testLang.value); + if (testDark.value) params.append('dark', 'true'); + } + return `${testJsonEndpoint.value}?${params.toString()}`; }); const generatedImageUrl = computed(() => { @@ -34,9 +42,15 @@ const generatedImageUrl = computed(() => { if (testPort.value) params.append('port', testPort.value); if (testStype.value !== 'auto') params.append('stype', testStype.value); if (testSrv.value) params.append('srv', 'true'); - if (testTheme.value) params.append('theme', testTheme.value); if (testIcon.value) params.append('icon', testIcon.value); - return `/api/status_img?${params.toString()}`; + if (testImageEndpoint.value === '/api/status_img' && testTheme.value) { + params.append('theme', testTheme.value); + } + if (testImageEndpoint.value === '/api/app_img') { + if (testLang.value) params.append('lang', testLang.value); + if (testDark.value) params.append('dark', 'true'); + } + return `${testImageEndpoint.value}?${params.toString()}`; }); // --- Methods --- @@ -69,8 +83,18 @@ const sendJsonRequest = async () => { +
+
+
@@ -80,6 +104,11 @@ const sendJsonRequest = async () => {
+
+ + +
+

{{ t('comp.apiTester.darkHint') }}