feat: import smsreceiver workerized code with full README

This commit is contained in:
OpenClaw Agent
2026-03-23 04:08:27 +08:00
commit 56179e6a75
21 changed files with 2725 additions and 0 deletions

95
pages/public/app.css Normal file
View 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
View 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
View 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>