Files
tg-spam-guard/web/static/index.html
T

560 lines
25 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 lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🛡️ TG Spam Guard - 收割机</title>
<link rel="stylesheet" href="https://unpkg.com/element-ui@2.15.14/lib/theme-chalk/index.css">
<style>
* { box-sizing: border-box; }
body { margin: 0; background: #eef2f7; color: #303133; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
#app { padding: 12px 12px 72px; height: 100vh; overflow: hidden; max-width: 1680px; margin: 0 auto; }
.topbar {
height: 54px;
background: linear-gradient(135deg, #2563eb, #7c3aed);
border-radius: 12px;
padding: 0 18px;
margin-bottom: 10px;
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 6px 18px rgba(37, 99, 235, 0.18);
}
.topbar h1 { margin: 0; font-size: 18px; font-weight: 700; }
.status { display: flex; align-items: center; gap: 8px; font-size: 13px; opacity: 0.95; }
.sse-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.sse-dot.on { background: #22c55e; box-shadow: 0 0 0 4px rgba(34,197,94,.2); }
.sse-dot.off { background: #ef4444; box-shadow: 0 0 0 4px rgba(239,68,68,.2); }
.stats-row { margin-bottom: 10px; }
.mini-stat .el-card__body { padding: 10px 14px; display: flex; align-items: center; justify-content: space-between; }
.mini-stat .label { font-size: 12px; color: #6b7280; }
.mini-stat .num { font-size: 20px; font-weight: 800; line-height: 1; }
.mini-stat.blue .num { color: #2563eb; }
.mini-stat.red .num { color: #ef4444; }
.mini-stat.orange .num { color: #f59e0b; }
.mini-stat.green .num { color: #16a34a; }
.main-grid { height: calc(100vh - 54px - 10px - 54px - 10px - 212px - 84px); min-height: 360px; margin-bottom: 10px; }
.panel { height: 100%; }
.panel .el-card__body { height: 100%; padding: 12px; display: flex; flex-direction: column; }
.panel-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
.panel-header h3 { margin: 0; font-size: 15px; color: #111827; white-space: nowrap; }
.panel-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.send-box { display: flex; gap: 8px; margin-bottom: 10px; }
.fixed-send-box {
position: fixed;
left: 50%;
bottom: 12px;
transform: translateX(-50%);
width: min(760px, calc(100vw - 24px));
display: flex;
gap: 8px;
padding: 10px;
background: rgba(255,255,255,.96);
border: 1px solid #e5e7eb;
border-radius: 14px;
box-shadow: 0 10px 30px rgba(15,23,42,.16);
z-index: 50;
backdrop-filter: blur(8px);
}
.scroll-area { flex: 1; overflow-y: auto; padding-right: 4px; }
.empty-text { text-align: center; padding: 50px 0; color: #9ca3af; }
.empty-text .icon { font-size: 36px; display: block; margin-bottom: 8px; }
.chat-item { padding: 8px 10px; border-bottom: 1px solid #edf2f7; border-radius: 8px; transition: background .15s; }
.chat-item:hover { background: #f8fafc; }
.chat-item.spam { background: #fff1f2; border-left: 3px solid #ef4444; }
.chat-item.deleted { background: #f8fafc; border-left: 3px solid #94a3b8; opacity: .72; }
.chat-item.deleted .text { color: #94a3b8; text-decoration: line-through; }
.chat-item .meta { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; margin-bottom: 4px; }
.chat-item .meta-right { display: flex; align-items: center; justify-content: flex-end; gap: 6px; flex-shrink: 0; flex-wrap: wrap; }
.chat-item .user { font-weight: 700; color: #2563eb; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.chat-item .time { flex-shrink: 0; font-size: 11px; color: #9ca3af; white-space: nowrap; }
.chat-item .text { font-size: 13px; color: #374151; line-height: 1.55; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; }
.reason-tag { display: inline-block; max-width: 100%; margin-top: 4px; }
.reason-tag .el-tag { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; }
.log-window {
flex: 1;
background: #0b1020;
color: #cbd5e1;
border: 1px solid #1e293b;
border-radius: 8px;
padding: 4px 6px;
overflow: auto;
font-family: Consolas, Monaco, 'Courier New', monospace;
font-size: 11px;
line-height: 1.25;
}
.log-line {
display: grid;
grid-template-columns: 68px 46px minmax(0, 1fr);
gap: 6px;
align-items: baseline;
min-height: 16px;
padding: 1px 2px;
white-space: nowrap;
border-bottom: 1px solid rgba(148,163,184,.08);
}
.log-line:hover { background: rgba(148,163,184,.08); }
.log-time { color: #64748b; }
.log-level { font-weight: 700; text-align: center; border-radius: 3px; padding: 0 3px; }
.log-msg { overflow: hidden; text-overflow: ellipsis; }
.log-line.INFO .log-level { color: #93c5fd; background: rgba(59,130,246,.12); }
.log-line.WARNING .log-level { color: #fbbf24; background: rgba(245,158,11,.12); }
.log-line.ERROR .log-level { color: #f87171; background: rgba(239,68,68,.14); }
.log-line.INFO .log-msg { color: #cbd5e1; }
.log-line.WARNING .log-msg { color: #fde68a; }
.log-line.ERROR .log-msg { color: #fecaca; }
.ban-section { height: 212px; }
.ban-section .el-card__body { height: 100%; padding: 12px; display: flex; flex-direction: column; }
.ban-list { flex: 1; overflow-y: auto; }
.ban-item { padding: 8px 10px; border-bottom: 1px solid #edf2f7; display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.ban-info { flex: 1; min-width: 0; }
.ban-info .user { font-weight: 700; color: #ef4444; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ban-info .reason { font-size: 12px; color: #6b7280; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ban-info .time { font-size: 11px; color: #9ca3af; margin-top: 2px; }
.el-card { border: none; border-radius: 12px; }
.el-button--mini, .el-button--small { border-radius: 8px; }
.confirm-dialog { border-radius: 14px; max-width: calc(100vw - 32px); }
.confirm-dialog .el-message-box__content { word-break: break-word; }
.chat-actions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.chat-actions .el-button--text { padding: 0; }
.chat-id-pill { color: #94a3b8; font-size: 11px; }
.spam-tooltip { max-width: min(720px, calc(100vw - 48px)); word-break: break-word; line-height: 1.45; }
@media (max-width: 900px) { .spam-tooltip { max-width: calc(100vw - 32px); } }
@media (max-width: 1200px) and (min-width: 901px) {
.main-grid { height: calc(100vh - 54px - 10px - 54px - 10px - 220px - 84px); }
.chat-item .meta { flex-direction: column; gap: 4px; }
.chat-item .meta-right { justify-content: flex-start; }
.log-line { grid-template-columns: 62px 42px minmax(0, 1fr); }
}
@media (max-width: 900px) {
#app { height: auto; min-height: 100vh; overflow: auto; padding: 8px 8px 104px; }
.topbar { height: auto; min-height: 48px; padding: 10px 12px; margin-bottom: 8px; align-items: flex-start; gap: 8px; }
.topbar h1 { font-size: 16px; line-height: 1.25; }
.status { font-size: 12px; white-space: nowrap; margin-top: 2px; }
.stats-row { margin-bottom: 4px; }
.stats-row .el-col { margin-bottom: 8px; }
.mini-stat .el-card__body { padding: 9px 10px; }
.mini-stat .label { font-size: 11px; }
.mini-stat .num { font-size: 18px; }
.main-grid { height: auto; min-height: 0; margin-bottom: 8px; }
.main-grid > .el-col { height: auto !important; }
.panel { height: auto; min-height: 0; margin-bottom: 10px; }
.panel .el-card__body { padding: 10px; }
.panel-header { align-items: flex-start; margin-bottom: 8px; }
.panel-header h3 { font-size: 14px; }
.panel-actions { gap: 6px; }
.scroll-area { height: 40vh; min-height: 280px; flex: none; }
.chat-item { padding: 8px; }
.chat-item .meta { flex-direction: column; gap: 5px; }
.chat-item .meta-right { justify-content: space-between; align-items: flex-start; width: 100%; gap: 6px; }
.chat-actions { justify-content: flex-end; }
.chat-item .text { margin-top: 2px; padding: 6px 8px; background: #f8fafc; border-radius: 8px; }
.chat-item .user { max-width: 100%; white-space: normal; word-break: break-all; }
.chat-item .time { white-space: normal; }
.log-window { height: 34vh; min-height: 250px; flex: none; font-size: 10px; }
.log-line { grid-template-columns: 54px 38px minmax(0, 1fr); gap: 4px; min-height: 18px; }
.ban-section { height: 34vh; min-height: 280px; }
.ban-section .el-card__body { padding: 10px; }
.ban-item { align-items: flex-start; gap: 8px; }
.ban-info .reason { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.fixed-send-box { bottom: 8px; width: calc(100vw - 16px); padding: 8px; border-radius: 12px; }
.fixed-send-box .el-button { padding-left: 12px; padding-right: 12px; }
}
@media (max-width: 420px) {
#app { padding-left: 6px; padding-right: 6px; }
.topbar { border-radius: 10px; }
.panel .el-card__body, .ban-section .el-card__body { padding: 8px; }
.scroll-area { height: 38vh; min-height: 260px; }
.log-window { height: 32vh; min-height: 240px; }
.log-msg { white-space: normal; }
.log-line { align-items: start; }
.fixed-send-box { width: calc(100vw - 12px); }
.confirm-dialog { width: calc(100vw - 28px) !important; }
.confirm-dialog .el-message-box__btns { display: flex; gap: 8px; }
.confirm-dialog .el-message-box__btns button { flex: 1; margin-left: 0; }
}
</style>
</head>
<body>
<div id="app">
<div class="topbar">
<h1>🛡️ TG Spam Guard</h1>
<div class="status">
<span class="sse-dot" :class="sseConnected ? 'on' : 'off'"></span>
{{ sseConnected ? '实时连接' : '连接中...' }}
<el-button size="mini" type="primary" plain :loading="restartingBot" @click="restartBot">重启Bot</el-button>
<el-button size="mini" type="warning" plain :loading="restarting" @click="restartService">重启服务</el-button>
</div>
</div>
<el-row class="stats-row" :gutter="10">
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="mini-stat blue"><span class="label">今日封禁</span><span class="num">{{ stats.today_bans || 0 }}</span></el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="mini-stat red"><span class="label">累计封禁</span><span class="num">{{ stats.total_bans || 0 }}</span></el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="mini-stat orange"><span class="label">当前封禁</span><span class="num">{{ stats.active_bans || 0 }}</span></el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never" class="mini-stat green"><span class="label">聊天记录</span><span class="num">{{ chatLogs.length || 0 }}</span></el-card>
</el-col>
</el-row>
<el-row class="main-grid" :gutter="10">
<el-col :xs="24" :md="12" style="height:100%;">
<el-card shadow="hover" class="panel">
<div class="panel-header">
<h3>💬 聊天记录</h3>
<el-tag size="mini" type="info">{{ chatLogs.length }} 条</el-tag>
</div>
<div class="scroll-area" ref="chatScroll">
<div v-if="chatLogs.length === 0" class="empty-text"><span class="icon">💬</span>等待消息...</div>
<div v-for="(item, i) in chatLogs" :key="i" class="chat-item" :class="{spam: item.spam, deleted: item.deleted}">
<div class="meta">
<span class="user">@{{ item.username || item.first_name || 'unknown' }} <small>({{ item.user_id }})</small></span>
<span class="meta-right">
<span class="time"><span class="chat-id-pill">#{{ item.msg_id || '-' }}</span> · {{ formatTime(item.time) }}</span>
<span class="chat-actions">
<el-tag v-if="item.deleted" size="mini" type="info">手动删除</el-tag>
<el-button v-else type="text" size="mini" icon="el-icon-delete" :loading="item._deleting" @click="deleteChatMessage(item, false)">删除</el-button>
<el-button v-if="!item.deleted && item.user_id" type="text" size="mini" icon="el-icon-remove-outline" :loading="item._deleting" @click="deleteChatMessage(item, true)">删封</el-button>
</span>
</span>
</div>
<div class="text">{{ item.text }}</div>
<el-tooltip v-if="item.spam" effect="dark" placement="top-start" popper-class="spam-tooltip" :content="item.reason || item.spam_reason || ''">
<span class="reason-tag"><el-tag size="mini" type="danger">垃圾:{{ item.reason || item.spam_reason }}</el-tag></span>
</el-tooltip>
<el-tag v-if="item.deleted" size="mini" type="info">已手动删除 · {{ formatTime(item.deleted_at) }}</el-tag>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :md="12" style="height:100%;">
<el-card shadow="hover" class="panel">
<div class="panel-header">
<h3>📜 实时日志</h3>
<div class="panel-actions">
<el-tag size="mini" type="info">{{ logs.length }} 条</el-tag>
<el-button size="mini" icon="el-icon-document-copy" @click="copyLogs">复制</el-button>
<el-button size="mini" icon="el-icon-refresh" @click="loadLogs">刷新</el-button>
</div>
</div>
<div class="log-window" ref="logScroll">
<div v-if="logs.length === 0" class="log-line">等待日志...</div>
<div v-for="(item, i) in logs" :key="i" class="log-line" :class="item.level" :title="compactLog(item.message)">
<span class="log-time">{{ formatTime(item.time) }}</span>
<span class="log-level">{{ item.level }}</span>
<span class="log-msg">{{ compactLog(item.message) }}</span>
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-card shadow="hover" class="ban-section">
<div class="panel-header">
<h3>🚫 封禁记录</h3>
<div class="panel-actions">
<el-tag size="mini" type="danger">{{ bans.length }} 条</el-tag>
<el-button size="mini" icon="el-icon-refresh" @click="loadBans">刷新</el-button>
</div>
</div>
<div class="ban-list">
<div v-if="bans.length === 0" class="empty-text" style="padding:35px 0;"><span class="icon"></span>暂无封禁记录</div>
<div v-for="(item, i) in bans" :key="i" class="ban-item">
<div class="ban-info">
<div class="user">@{{ item.username || item.first_name || 'unknown' }} ({{ item.user_id }})</div>
<div class="reason">📝 {{ item.reason }}</div>
<div class="time">⏰ {{ formatTime(item.time) }}</div>
</div>
<el-button v-if="item.auto_unban !== false" size="mini" type="danger" :loading="item._loading" @click="unbanUser(item)">解封</el-button>
<el-tag v-else size="mini" type="success">已解封</el-tag>
</div>
</div>
</el-card>
<div class="fixed-send-box">
<el-input v-model="newMessage" placeholder="发送消息到 TG 群" size="small" clearable @keyup.enter.native="sendMessage"></el-input>
<el-button type="primary" size="small" :loading="sending" @click="sendMessage">发送</el-button>
</div>
</div>
<script src="https://unpkg.com/vue@2.7.16/dist/vue.min.js"></script>
<script src="https://unpkg.com/element-ui@2.15.14/lib/index.js"></script>
<script>
new Vue({
el: '#app',
data() {
return {
sseConnected: false,
chatLogs: [],
bans: [],
logs: [],
newMessage: '',
sending: false,
restarting: false,
restartingBot: false,
stats: { today_bans: 0, total_bans: 0, active_bans: 0 },
}
},
mounted() {
this.loadChatHistory()
this.loadBans()
this.loadStats()
this.loadLogs()
this.connectSSE()
setInterval(() => this.loadStats(), 30000)
},
beforeDestroy() {
if (this._es) this._es.close()
},
methods: {
connectSSE() {
if (this._es) this._es.close()
const es = new EventSource('/api/stream')
this._es = es
es.addEventListener('connected', () => { this.sseConnected = true })
es.addEventListener('chat', (e) => {
const data = JSON.parse(e.data)
this.chatLogs.unshift(data)
if (this.chatLogs.length > 120) this.chatLogs.pop()
})
es.addEventListener('spam', (e) => {
const data = JSON.parse(e.data)
const item = this.chatLogs.find(x => x.msg_id === data.msg_id)
if (item) Object.assign(item, data)
})
es.addEventListener('log', (e) => {
const data = JSON.parse(e.data)
this.logs.push(data)
if (this.logs.length > 300) this.logs.shift()
this.scrollLogBottom()
})
es.addEventListener('chat_delete', (e) => {
const data = JSON.parse(e.data)
const item = this.chatLogs.find(m => m.id === data.id)
if (item) {
this.$set(item, 'deleted', true)
this.$set(item, 'manually_deleted', true)
this.$set(item, 'deleted_at', data.deleted_at)
}
this.loadStats()
})
es.addEventListener('ban', (e) => {
const data = JSON.parse(e.data)
data._loading = false
this.bans.unshift(data)
if (this.bans.length > 80) this.bans.pop()
this.loadStats()
this.$message({ type: 'error', message: `🚫 封禁 @${data.username} - ${data.reason}`, duration: 5000 })
})
es.addEventListener('unban', (e) => {
const data = JSON.parse(e.data)
const ban = this.bans.find(b => b.user_id === data.user_id)
if (ban) ban.auto_unban = false
this.loadStats()
this.$message({ type: 'success', message: `✅ 已解封 ${data.username || data.user_id}`, duration: 3000 })
})
es.onerror = () => {
this.sseConnected = false
setTimeout(() => this.connectSSE(), 3000)
}
},
async loadChatHistory() {
try {
const data = await (await fetch('/api/chat')).json()
this.chatLogs = data.map(m => ({ ...m, _deleting: false }))
} catch (e) {}
},
async loadBans() {
try {
const data = await (await fetch('/api/bans')).json()
this.bans = data.map(b => ({ ...b, _loading: false }))
} catch (e) {}
},
async loadStats() {
try { this.stats = await (await fetch('/api/stats')).json() } catch (e) {}
},
async restartBot() {
this.$confirm('确认只重启 Telegram BotWeb 管理页不会重启。', '重启 TG Bot', {
confirmButtonText: '确认重启',
cancelButtonText: '取消',
type: 'warning',
customClass: 'confirm-dialog',
dangerouslyUseHTMLString: false,
distinguishCancelAndClose: true
}).then(async () => {
this.restartingBot = true
try {
const data = await (await fetch('/api/restart-bot', { method: 'POST' })).json()
if (data.ok) {
this.$message.success('✅ TG Bot 正在重启')
setTimeout(() => { this.restartingBot = false; this.loadLogs() }, 5000)
} else {
this.$message.error(`❌ 重启失败: ${data.error || '未知错误'}`)
this.restartingBot = false
}
} catch (e) {
this.$message.error('❌ 网络错误')
this.restartingBot = false
}
}).catch(() => {})
},
async restartService() {
this.$confirm('确认重启 TG Spam Guard 服务?重启期间 Web 和 Bot 会短暂不可用。', '重启服务', {
confirmButtonText: '确认重启',
cancelButtonText: '取消',
type: 'warning',
customClass: 'confirm-dialog',
dangerouslyUseHTMLString: false,
distinguishCancelAndClose: true
}).then(async () => {
this.restarting = true
try {
const data = await (await fetch('/api/restart', { method: 'POST' })).json()
if (data.ok) {
this.$message.success('✅ 服务正在重启,请稍等刷新')
setTimeout(() => location.reload(), 8000)
} else {
this.$message.error(`❌ 重启失败: ${data.error || '未知错误'}`)
this.restarting = false
}
} catch (e) {
this.$message.warning('服务可能已开始重启,请稍等刷新')
setTimeout(() => location.reload(), 8000)
}
}).catch(() => {})
},
async loadLogs() {
try {
this.logs = await (await fetch('/api/logs')).json()
this.scrollLogBottom()
} catch (e) {}
},
async sendMessage() {
const text = this.newMessage.trim()
if (!text) return
this.sending = true
try {
const r = await fetch('/api/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
})
const data = await r.json()
if (data.ok) {
this.newMessage = ''
this.$message.success('✅ 已发送')
} else {
this.$message.error(`❌ 发送失败: ${data.error}`)
}
} catch (e) {
this.$message.error('❌ 网络错误')
}
this.sending = false
},
async deleteChatMessage(item, banUser = false) {
this.$confirm(banUser ? '先删除 Telegram 群内消息,再封禁该用户,并标记数据库。继续吗?' : '先删除 Telegram 群内消息,再把数据库记录标记为手动删除。继续吗?', banUser ? '删除并封禁' : '删除聊天记录', {
confirmButtonText: banUser ? '删封' : '删除',
cancelButtonText: '取消',
type: 'warning',
customClass: 'confirm-dialog',
dangerouslyUseHTMLString: false,
distinguishCancelAndClose: true
}).then(async () => {
this.$set(item, '_deleting', true)
try {
const data = await (await fetch(`/api/messages/${item.id}/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ban_user: banUser })
})).json()
if (data.ok) {
this.$set(item, 'deleted', true)
this.$set(item, 'manually_deleted', true)
this.$set(item, 'deleted_at', data.deleted_at || new Date().toISOString())
this.$message.success(data.banned ? '✅ 已删除并封禁' : '✅ 已标记为手动删除')
this.loadStats()
} else {
this.$message.error(`❌ 删除失败: ${data.error}`)
}
} catch (e) {
this.$message.error('❌ 网络错误')
}
this.$set(item, '_deleting', false)
}).catch(() => {})
},
async copyLogs() {
const text = this.logs.map(l => `${this.formatTime(l.time)} ${l.level || ''} ${this.compactLog(l.message)}`).join('\n')
if (!text) return this.$message.warning('暂无日志可复制')
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
} else {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.focus()
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
this.$message.success(`✅ 已复制 ${this.logs.length} 条日志`)
} catch (e) {
this.$message.error('❌ 复制失败')
}
},
async unbanUser(item) {
this.$set(item, '_loading', true)
try {
const data = await (await fetch(`/api/unban/${item.user_id}`, { method: 'POST' })).json()
if (data.ok) {
item.auto_unban = false
this.$message.success(`✅ 已解封 @${item.username}`)
this.loadStats()
} else {
this.$message.error(`❌ 解封失败: ${data.error}`)
}
} catch (e) {
this.$message.error('❌ 网络错误')
}
this.$set(item, '_loading', false)
},
scrollLogBottom() {
this.$nextTick(() => {
if (this.$refs.logScroll) this.$refs.logScroll.scrollTop = this.$refs.logScroll.scrollHeight
})
},
formatTime(iso) {
if (!iso) return ''
const d = new Date(iso)
return isNaN(d.getTime()) ? iso : d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
},
compactLog(msg) {
if (!msg) return ''
return String(msg).replace(/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\s+\[[A-Z]+\]\s+/, '')
}
}
})
</script>
</body>
</html>