320 lines
10 KiB
JavaScript
320 lines
10 KiB
JavaScript
const API_BASE = window.API_BASE || 'https://sms-api.ouai.nyc.mn'
|
|
|
|
const app = document.getElementById('app')
|
|
|
|
const state = {
|
|
view: 'login',
|
|
messages: [],
|
|
logs: [],
|
|
page: 1,
|
|
total: 0,
|
|
logsPage: 1,
|
|
logsTotal: 0,
|
|
currentMessage: null,
|
|
fromNumbers: [],
|
|
selectedFrom: '',
|
|
search: '',
|
|
stats: { total: 0, today: 0, failed: 0 },
|
|
startTs: 0,
|
|
endTs: 0,
|
|
}
|
|
|
|
function monthRange(offset = 0) {
|
|
const now = new Date()
|
|
const y = now.getFullYear()
|
|
const m = now.getMonth() + offset
|
|
const start = new Date(y, m, 1, 0, 0, 0)
|
|
const end = new Date(y, m + 1, 0, 23, 59, 59)
|
|
return [start.getTime(), end.getTime()]
|
|
}
|
|
|
|
function initDefaultRange() {
|
|
const [s, e] = monthRange(0)
|
|
state.startTs = s
|
|
state.endTs = e
|
|
}
|
|
|
|
async function api(path, options = {}) {
|
|
const res = await fetch(`${API_BASE}${path}`, {
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
|
|
...options,
|
|
})
|
|
return res.json()
|
|
}
|
|
|
|
function setView(view) {
|
|
state.view = view
|
|
render()
|
|
}
|
|
|
|
function nav(active) {
|
|
return `
|
|
<div class="header pixel-card">
|
|
<div class="title">📱 短信转发接收端</div>
|
|
<div class="nav">
|
|
<a class="btn ${active==='list'?'active':''}" data-view="list">短信列表</a>
|
|
<a class="btn ${active==='logs'?'active':''}" data-view="logs">接收日志</a>
|
|
<a class="btn ${active==='stats'?'active':''}" data-view="stats">统计信息</a>
|
|
<a class="btn danger" id="logoutBtn">退出</a>
|
|
</div>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
function loginView() {
|
|
return `
|
|
<div class="login-wrap">
|
|
<div class="login-card pixel-card">
|
|
<h1>📱 短信转发接收端</h1>
|
|
<div class="form-group">
|
|
<label>用户名</label>
|
|
<input id="username" placeholder="请输入用户名" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>密码</label>
|
|
<input id="password" type="password" placeholder="请输入密码" />
|
|
</div>
|
|
<button class="btn primary" id="loginBtn">登录</button>
|
|
</div>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
function statsCards() {
|
|
const s = state.stats
|
|
return `
|
|
<div class="stats">
|
|
<div class="stat-card pixel-card"><div class="label">总短信</div><div class="value">${s.total}</div></div>
|
|
<div class="stat-card pixel-card"><div class="label">今日</div><div class="value">${s.today}</div></div>
|
|
<div class="stat-card pixel-card"><div class="label">异常</div><div class="value">${s.failed}</div></div>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
function fromFilter() {
|
|
const tags = state.fromNumbers.map(n => `
|
|
<span class="tag ${state.selectedFrom===n?'active':''}" data-from="${n}">${n}</span>
|
|
`).join('')
|
|
return `
|
|
<div class="filter pixel-card">
|
|
<div class="label">发送方筛选</div>
|
|
<div class="tags">
|
|
<span class="tag ${state.selectedFrom===''?'active':''}" data-from="">全部</span>
|
|
${tags}
|
|
</div>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
function timeFilter() {
|
|
return `
|
|
<div class="toolbar pixel-card">
|
|
<span class="label">时间筛选</span>
|
|
<button class="btn" id="thisMonthBtn">本月</button>
|
|
<button class="btn" id="lastMonthBtn">上月</button>
|
|
<button class="btn" id="allBtn">全部</button>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
function toolbar() {
|
|
return `
|
|
<div class="toolbar pixel-card">
|
|
<input id="search" placeholder="搜索内容/号码" value="${state.search}" />
|
|
<button class="btn primary" id="searchBtn">搜索</button>
|
|
<button class="btn" id="refreshBtn">刷新</button>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
function listView() {
|
|
const rows = state.messages.map(m => `
|
|
<tr>
|
|
<td>${m.id}</td>
|
|
<td>${m.from_number}</td>
|
|
<td class="ellipsis">${m.content}</td>
|
|
<td>${new Date(m.timestamp).toLocaleString()}</td>
|
|
<td><a class="btn small" data-id="${m.id}" data-action="detail">详情</a></td>
|
|
</tr>
|
|
`).join('')
|
|
|
|
return `
|
|
${nav('list')}
|
|
${statsCards()}
|
|
${fromFilter()}
|
|
${timeFilter()}
|
|
${toolbar()}
|
|
<div class="table-wrap pixel-card">
|
|
<table>
|
|
<thead><tr><th>ID</th><th>号码</th><th>内容</th><th>时间</th><th></th></tr></thead>
|
|
<tbody>${rows || ''}</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="pager">
|
|
<button class="btn" id="prevPage">上一页</button>
|
|
<span>第 ${state.page} 页</span>
|
|
<button class="btn" id="nextPage">下一页</button>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
function detailView() {
|
|
const m = state.currentMessage
|
|
if (!m) return ''
|
|
return `
|
|
${nav('list')}
|
|
<div class="detail pixel-card">
|
|
<h3>📱 短信详情</h3>
|
|
<div class="detail-row"><div class="k">ID</div><div class="v">${m.id}</div></div>
|
|
<div class="detail-row"><div class="k">发送方号码</div><div class="v">${m.from_number}</div></div>
|
|
<div class="detail-row"><div class="k">短信内容</div><div class="v">${m.content}</div></div>
|
|
<div class="detail-row"><div class="k">原始时间戳</div><div class="v">${m.timestamp}</div></div>
|
|
<div class="detail-row"><div class="k">入库时间</div><div class="v">${new Date(m.created_at || m.timestamp).toLocaleString()}</div></div>
|
|
<div class="detail-row"><div class="k">签名验证</div><div class="v">${m.sign_verified ? '已验证' : '未验证'}</div></div>
|
|
<div class="detail-row"><div class="k">设备信息</div><div class="v">${m.device_info || '-'}</div></div>
|
|
<div class="detail-row"><div class="k">SIM 卡信息</div><div class="v">${m.sim_info || '-'}</div></div>
|
|
<div class="detail-row"><div class="k">IP 地址</div><div class="v">${m.ip_address || '-'}</div></div>
|
|
<button class="btn" id="backBtn">返回列表</button>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
function logsView() {
|
|
const rows = state.logs.map(l => `
|
|
<tr>
|
|
<td>${l.id}</td>
|
|
<td>${l.from_number}</td>
|
|
<td><span class="badge ${l.status==='success'?'ok':'err'}">${l.status}</span></td>
|
|
<td>${l.error_message || ''}</td>
|
|
<td>${new Date(l.timestamp).toLocaleString()}</td>
|
|
</tr>
|
|
`).join('')
|
|
|
|
return `
|
|
${nav('logs')}
|
|
<div class="table-wrap pixel-card">
|
|
<table>
|
|
<thead><tr><th>ID</th><th>号码</th><th>状态</th><th>错误</th><th>时间</th></tr></thead>
|
|
<tbody>${rows || ''}</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="pager">
|
|
<button class="btn" id="prevLogs">上一页</button>
|
|
<span>第 ${state.logsPage} 页</span>
|
|
<button class="btn" id="nextLogs">下一页</button>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
function statsView() {
|
|
return `
|
|
${nav('stats')}
|
|
${statsCards()}
|
|
<div class="detail pixel-card">
|
|
<h3>统计概览</h3>
|
|
<div class="detail-row"><div class="k">总短信</div><div class="v">${state.stats.total}</div></div>
|
|
<div class="detail-row"><div class="k">今日</div><div class="v">${state.stats.today}</div></div>
|
|
<div class="detail-row"><div class="k">异常</div><div class="v">${state.stats.failed}</div></div>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
async function loadStats() {
|
|
const data = await api('/api/stats')
|
|
if (data.success) state.stats = data.data
|
|
}
|
|
|
|
async function loadMessages() {
|
|
const data = await api(`/api/messages?page=${state.page}&limit=20&from=${encodeURIComponent(state.selectedFrom)}&q=${encodeURIComponent(state.search)}&start_ts=${state.startTs}&end_ts=${state.endTs}`)
|
|
if (!data.success) return
|
|
state.messages = data.data
|
|
state.total = data.total
|
|
state.fromNumbers = data.from_numbers || []
|
|
}
|
|
|
|
async function loadLogs() {
|
|
const data = await api(`/api/logs?page=${state.logsPage}&limit=20`)
|
|
if (!data.success) return
|
|
state.logs = data.data
|
|
state.logsTotal = data.total
|
|
}
|
|
|
|
async function render() {
|
|
if (state.view === 'login') {
|
|
app.innerHTML = loginView()
|
|
document.getElementById('loginBtn').onclick = async () => {
|
|
const username = document.getElementById('username').value
|
|
const password = document.getElementById('password').value
|
|
const res = await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) })
|
|
if (res.success) {
|
|
initDefaultRange()
|
|
state.view = 'list'
|
|
await loadStats()
|
|
await loadMessages()
|
|
render()
|
|
} else alert(res.error || '登录失败')
|
|
}
|
|
return
|
|
}
|
|
|
|
if (state.view === 'list') {
|
|
await loadStats()
|
|
await loadMessages()
|
|
app.innerHTML = listView()
|
|
bindCommon()
|
|
document.querySelectorAll('.tag').forEach(tag => {
|
|
tag.onclick = () => { state.selectedFrom = tag.dataset.from || ''; state.page = 1; render(); }
|
|
})
|
|
document.getElementById('searchBtn').onclick = () => { state.search = document.getElementById('search').value; state.page = 1; render(); }
|
|
document.getElementById('refreshBtn').onclick = () => render()
|
|
document.getElementById('prevPage').onclick = () => { if (state.page>1) { state.page--; render(); } }
|
|
document.getElementById('nextPage').onclick = () => { state.page++; render(); }
|
|
document.getElementById('thisMonthBtn').onclick = () => { const [s,e] = monthRange(0); state.startTs=s; state.endTs=e; state.page=1; render(); }
|
|
document.getElementById('lastMonthBtn').onclick = () => { const [s,e] = monthRange(-1); state.startTs=s; state.endTs=e; state.page=1; render(); }
|
|
document.getElementById('allBtn').onclick = () => { state.startTs=0; state.endTs=0; state.page=1; render(); }
|
|
document.querySelectorAll('[data-action="detail"]').forEach(btn => {
|
|
btn.onclick = async () => {
|
|
const id = btn.dataset.id
|
|
const data = await api(`/api/messages/${id}`)
|
|
if (data.success) { state.currentMessage = data.data; state.view = 'detail'; render(); }
|
|
}
|
|
})
|
|
return
|
|
}
|
|
|
|
if (state.view === 'detail') {
|
|
app.innerHTML = detailView()
|
|
bindCommon()
|
|
document.getElementById('backBtn').onclick = () => { state.view = 'list'; render(); }
|
|
return
|
|
}
|
|
|
|
if (state.view === 'logs') {
|
|
await loadLogs()
|
|
app.innerHTML = logsView()
|
|
bindCommon()
|
|
document.getElementById('prevLogs').onclick = () => { if (state.logsPage>1) { state.logsPage--; render(); } }
|
|
document.getElementById('nextLogs').onclick = () => { state.logsPage++; render(); }
|
|
return
|
|
}
|
|
|
|
if (state.view === 'stats') {
|
|
await loadStats()
|
|
app.innerHTML = statsView()
|
|
bindCommon()
|
|
return
|
|
}
|
|
}
|
|
|
|
function bindCommon() {
|
|
document.querySelectorAll('[data-view]').forEach(a => {
|
|
a.onclick = () => setView(a.dataset.view)
|
|
})
|
|
const logout = document.getElementById('logoutBtn')
|
|
if (logout) logout.onclick = async () => { await api('/api/auth/logout', { method: 'POST' }); state.view='login'; render(); }
|
|
}
|
|
|
|
initDefaultRange()
|
|
render()
|