feat: import smsreceiver workerized code with full README
This commit is contained in:
95
pages/public/app.css
Normal file
95
pages/public/app.css
Normal file
@@ -0,0 +1,95 @@
|
||||
:root {
|
||||
--bg: #e8e8e8;
|
||||
--card: #fefefe;
|
||||
--ink: #1b1b1b;
|
||||
--primary: #6c7cff;
|
||||
--primary-dark: #4f5de1;
|
||||
--danger: #e34a4a;
|
||||
--border: #1b1b1b;
|
||||
--shadow: 4px 4px 0 #1b1b1b;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Pixel", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
main { max-width: 1200px; margin: 24px auto; padding: 0 16px; }
|
||||
|
||||
.pixel-card {
|
||||
background: var(--card);
|
||||
border: 2px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.header { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.title { font-size: 20px; font-weight: 700; }
|
||||
|
||||
.nav { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
padding: 6px 12px; border: 2px solid var(--border);
|
||||
background: #fff; cursor: pointer; text-decoration: none; color: var(--ink);
|
||||
box-shadow: 2px 2px 0 var(--border); border-radius: 6px; font-size: 14px;
|
||||
}
|
||||
.btn.active, .btn.primary { background: var(--primary); color: #fff; }
|
||||
.btn.danger { background: var(--danger); color: #fff; }
|
||||
.btn.small { padding: 4px 8px; font-size: 12px; }
|
||||
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
|
||||
.stat-card { text-align: center; }
|
||||
.stat-card .label { font-size: 12px; color: #555; }
|
||||
.stat-card .value { font-size: 28px; font-weight: 700; }
|
||||
|
||||
.filter .tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.tag {
|
||||
padding: 4px 10px; border: 2px solid var(--border); border-radius: 6px;
|
||||
background: #fff; cursor: pointer; box-shadow: 2px 2px 0 var(--border);
|
||||
}
|
||||
.tag.active { background: var(--primary); color: #fff; }
|
||||
|
||||
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
input {
|
||||
padding: 8px 10px; border: 2px solid var(--border); border-radius: 6px;
|
||||
box-shadow: 2px 2px 0 var(--border); outline: none; font-size: 14px;
|
||||
}
|
||||
|
||||
.table-wrap { overflow-x: auto; }
|
||||
.table-wrap table { width: 100%; border-collapse: collapse; min-width: 720px; }
|
||||
.table-wrap th, .table-wrap td { padding: 10px; border-bottom: 2px dashed #ccc; text-align: left; }
|
||||
.table-wrap th { background: #f3f3f3; }
|
||||
.ellipsis { max-width: 420px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.badge { padding: 2px 8px; border: 2px solid var(--border); border-radius: 6px; box-shadow: 2px 2px 0 var(--border); }
|
||||
.badge.ok { background: #9be38b; }
|
||||
.badge.err { background: #f08c8c; }
|
||||
|
||||
.pager { display: flex; gap: 10px; align-items: center; justify-content: center; margin: 16px 0; flex-wrap: wrap; }
|
||||
|
||||
.detail h3 { margin-top: 0; }
|
||||
.detail-row { display: grid; grid-template-columns: 140px 1fr; gap: 8px; padding: 6px 0; border-bottom: 1px dashed #ddd; }
|
||||
.detail-row .k { color: #666; }
|
||||
|
||||
.login-wrap { min-height: 80vh; display: flex; align-items: center; justify-content: center; padding: 0 12px; }
|
||||
.login-card { max-width: 420px; width: 100%; }
|
||||
.form-group { margin-bottom: 12px; }
|
||||
.form-group label { display: block; margin-bottom: 6px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
main { padding: 0 10px; }
|
||||
.header { padding: 12px; }
|
||||
.title { width: 100%; text-align: center; }
|
||||
.nav { width: 100%; justify-content: center; }
|
||||
.stats { grid-template-columns: 1fr; }
|
||||
.toolbar { flex-direction: column; align-items: stretch; }
|
||||
.filter .tags { max-height: 120px; overflow-y: auto; }
|
||||
.detail-row { grid-template-columns: 1fr; }
|
||||
.table-wrap table { min-width: 600px; }
|
||||
}
|
||||
319
pages/public/app.js
Normal file
319
pages/public/app.js
Normal file
@@ -0,0 +1,319 @@
|
||||
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()
|
||||
13
pages/public/index.html
Normal file
13
pages/public/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>SmsReceiver</title>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main id="app"></main>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user