Files
CyberPigeon/internal/server/web/index.html
T

1835 lines
72 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SMS Forwarder</title>
<script>
// 在页面渲染前立即设置主题,避免闪烁
(function () {
const theme = localStorage.getItem('theme') || 'auto';
let actualTheme = theme;
if (theme === 'auto') {
actualTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', actualTheme);
})();
</script>
<script src="vue.global.js"></script>
<style>
:root {
/* 深色主题 */
--bg-primary: #000000;
--bg-secondary: #0a0a0a;
--bg-tertiary: #0f0f0f;
--border-primary: #1a1a1a;
--border-secondary: #333;
--text-primary: #ffffff;
--text-secondary: #ccc;
--text-tertiary: #666;
--accent-primary: #4fc3f7;
--accent-secondary: #81d4fa;
--status-success: #00e676;
--signal-excellent: #00e676;
--signal-good: #76ff03;
--signal-fair: #ffea00;
--signal-poor: #ff9800;
--signal-none: #757575;
--error-bg: #1a0000;
--error-border: #330000;
--error-text: #ff5252;
}
[data-theme="light"] {
/* 浅色主题 */
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--bg-tertiary: #eeeeee;
--border-primary: #e0e0e0;
--border-secondary: #bdbdbd;
--text-primary: #000000;
--text-secondary: #424242;
--text-tertiary: #9e9e9e;
--accent-primary: #0288d1;
--accent-secondary: #0277bd;
--status-success: #00c853;
--signal-excellent: #00c853;
--signal-good: #64dd17;
--signal-fair: #ffc107;
--signal-poor: #ff6f00;
--signal-none: #9e9e9e;
--error-bg: #ffebee;
--error-border: #ef5350;
--error-text: #c62828;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
[v-cloak] {
display: none !important;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
transition: background-color 0.3s ease, color 0.3s ease;
}
.app {
display: flex;
flex-direction: column;
height: 100vh;
}
/* 顶部栏 */
.topbar {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-primary);
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 100;
}
.topbar-left {
display: flex;
align-items: center;
gap: 24px;
}
.logo {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 1px;
}
.topbar-right {
display: flex;
align-items: center;
gap: 16px;
}
.status-dot {
width: 8px;
height: 8px;
background: var(--status-success);
display: inline-block;
margin-right: 6px;
}
.status-text {
font-size: 13px;
color: var(--text-tertiary);
}
.theme-toggle {
background: transparent;
border: 1px solid var(--border-secondary);
color: var(--text-tertiary);
padding: 6px 14px;
font-size: 12px;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.5px;
transition: all 0.2s;
}
.theme-toggle:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.modem-toggle {
background: transparent;
border: 1px solid var(--border-secondary);
color: var(--text-tertiary);
padding: 6px 14px;
font-size: 12px;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.5px;
transition: all 0.2s;
position: relative;
}
.modem-toggle:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.modem-toggle.active {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: var(--bg-primary);
}
/* TAB 标签页 */
.sidebar-tabs {
display: flex;
border-bottom: 1px solid var(--border-primary);
background: var(--bg-tertiary);
}
.sidebar-tab {
flex: 1;
padding: 12px 16px;
text-align: center;
font-size: 13px;
color: var(--text-tertiary);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sidebar-tab:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
.sidebar-tab.active {
color: var(--accent-primary);
border-bottom-color: var(--accent-primary);
background: var(--bg-secondary);
}
/* Modem 侧边栏 */
.modem-sidebar {
position: fixed;
top: 0;
right: 0;
width: 380px;
height: 100vh;
background: var(--bg-secondary);
border-left: 1px solid var(--border-primary);
transform: translateX(100%);
transition: transform 0.3s ease;
z-index: 200;
display: flex;
flex-direction: column;
}
.modem-sidebar.open {
transform: translateX(0);
}
.sidebar-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border-primary);
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-title {
font-size: 14px;
font-weight: 600;
color: var(--accent-primary);
text-transform: uppercase;
letter-spacing: 1px;
}
/* 推送通道编辑 */
.channel-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.channel-empty {
text-align: center;
padding: 40px 20px;
color: var(--text-tertiary);
}
.channel-empty-text {
font-size: 14px;
margin-bottom: 8px;
}
.channel-empty-hint {
font-size: 12px;
color: var(--text-tertiary);
opacity: 0.7;
}
.channel-card {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
padding: 16px;
margin-bottom: 12px;
}
.channel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-primary);
}
.channel-type {
font-size: 15px;
font-weight: 600;
color: var(--accent-primary);
text-transform: uppercase;
letter-spacing: 1px;
}
.channel-type-badge {
display: inline-block;
padding: 4px 8px;
background: var(--accent-primary);
color: white;
border-radius: 2px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.channel-enabled {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-secondary);
}
.channel-enabled input[type="checkbox"] {
cursor: pointer;
}
.channel-fields {
display: flex;
flex-direction: column;
gap: 12px;
}
.field-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.field-label {
font-size: 12px;
color: var(--text-tertiary);
}
.field-input {
background: var(--bg-primary);
border: 1px solid var(--border-primary);
color: var(--text-primary);
padding: 8px 12px;
font-size: 13px;
transition: border-color 0.2s;
}
.field-input:focus {
outline: none;
border-color: var(--accent-primary);
}
.field-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.save-channels-btn {
position: sticky;
bottom: 0;
background: var(--accent-primary);
color: white;
border: none;
padding: 12px;
width: 100%;
cursor: pointer;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
transition: opacity 0.2s;
}
.save-channels-btn:hover {
opacity: 0.9;
}
.save-channels-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.test-channel-btn {
background: transparent;
border: 1px solid var(--accent-primary);
color: var(--accent-primary);
padding: 4px 12px;
font-size: 12px;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
margin-left: 8px;
}
.test-channel-btn:hover {
background: var(--accent-primary);
color: white;
}
.test-channel-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.close-btn {
background: transparent;
border: none;
color: var(--text-tertiary);
font-size: 24px;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: var(--text-primary);
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
}
.tab-content {
flex: 1;
overflow-y: auto;
}
.modem-item {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
padding: 20px;
margin-bottom: 16px;
}
.modem-name {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 16px;
}
.modem-details {
display: flex;
flex-direction: column;
gap: 10px;
}
.detail-row {
display: flex;
justify-content: space-between;
font-size: 13px;
}
.detail-label {
color: var(--text-tertiary);
}
.detail-value {
color: var(--text-primary);
font-weight: 500;
}
.signal-row {
display: flex;
align-items: center;
gap: 12px;
padding-top: 10px;
margin-top: 10px;
border-top: 1px solid var(--border-primary);
}
.signal-bar {
flex: 1;
height: 4px;
background: var(--border-primary);
position: relative;
}
.signal-fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
transition: width 0.3s ease;
}
.signal-excellent {
background: var(--signal-excellent);
}
.signal-good {
background: var(--signal-good);
}
.signal-fair {
background: var(--signal-fair);
}
.signal-poor {
background: var(--signal-poor);
}
.signal-none {
background: var(--signal-none);
}
.signal-percent {
font-size: 13px;
font-weight: 600;
min-width: 40px;
text-align: right;
}
/* USSD 区域 */
.ussd-section {
background: transparent;
border: none;
border-top: 1px solid var(--border-primary);
padding-top: 20px;
margin-top: 20px;
}
.ussd-header {
font-size: 14px;
font-weight: 600;
color: var(--accent-primary);
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 1px;
}
.ussd-input-group {
display: flex;
gap: 10px;
}
.ussd-input {
flex: 1;
background: var(--bg-primary);
border: 1px solid var(--border-primary);
color: var(--text-primary);
padding: 8px 12px;
font-size: 13px;
}
.ussd-btn {
background: var(--accent-primary);
color: white;
border: none;
padding: 0 16px;
font-size: 13px;
cursor: pointer;
text-transform: uppercase;
white-space: nowrap;
flex-shrink: 0;
}
.ussd-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ussd-result {
margin-top: 12px;
padding: 10px;
background: var(--bg-primary);
border: 1px solid var(--border-primary);
font-size: 13px;
color: var(--text-secondary);
white-space: pre-wrap;
}
/* 主内容区 */
.main-content {
flex: 1;
overflow-y: auto;
padding: 0;
}
.messages-container {
max-width: 900px;
margin: 0 auto;
padding: 24px;
}
.messages-header {
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.messages-title {
font-size: 13px;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 1px;
}
.message-count {
font-size: 13px;
color: var(--text-tertiary);
}
.message-item {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-left: 2px solid var(--accent-primary);
padding: 24px;
margin-bottom: 1px;
transition: background 0.2s;
}
.message-item:hover {
background: var(--bg-tertiary);
}
.message-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
gap: 20px;
}
.message-sender {
flex: 1;
}
.sender-number {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.sender-device {
font-size: 12px;
color: var(--text-tertiary);
}
.message-timestamp {
font-size: 12px;
color: var(--text-tertiary);
white-space: nowrap;
}
.message-body {
font-size: 14px;
line-height: 1.7;
color: var(--text-secondary);
white-space: pre-wrap;
word-wrap: break-word;
}
.delete-btn {
background: transparent;
border: 1px solid var(--border-secondary);
color: var(--text-tertiary);
padding: 4px 12px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.delete-btn:hover {
border-color: #ff5252;
color: #ff5252;
}
/* 搜索框 */
.search-box {
margin-bottom: 16px;
}
.search-input {
width: 100%;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
color: var(--text-primary);
padding: 10px 16px;
font-size: 14px;
transition: border-color 0.2s;
}
.search-input:focus {
outline: none;
border-color: var(--accent-primary);
}
.search-input::placeholder {
color: var(--text-tertiary);
}
/* 确认对话框 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
padding: 32px;
max-width: 400px;
width: 90%;
}
.modal-title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 16px;
}
.modal-message {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 24px;
line-height: 1.6;
}
.modal-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.modal-btn {
padding: 8px 20px;
font-size: 13px;
cursor: pointer;
border: 1px solid var(--border-secondary);
background: transparent;
color: var(--text-primary);
transition: all 0.2s;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.modal-btn:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.modal-btn.danger {
border-color: #ff5252;
color: #ff5252;
}
.modal-btn.danger:hover {
background: #ff5252;
color: white;
}
/* 加载和空状态 */
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
color: var(--text-tertiary);
}
.spinner {
width: 32px;
height: 32px;
border: 2px solid var(--border-primary);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 16px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
font-size: 13px;
}
.empty-state {
text-align: center;
padding: 80px 20px;
color: var(--text-tertiary);
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.3;
}
.empty-text {
font-size: 14px;
}
.error-state {
background: var(--error-bg);
border: 1px solid var(--error-border);
color: var(--error-text);
padding: 20px;
margin: 20px;
text-align: center;
font-size: 13px;
}
/* 滚动条 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-secondary);
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
/* 响应式 */
@media (max-width: 768px) {
.topbar {
padding: 12px 16px;
}
.logo {
font-size: 16px;
}
.status-text {
display: none;
}
.modem-sidebar {
width: 100%;
}
.messages-container {
padding: 16px;
}
.message-item {
padding: 16px;
}
.message-header {
flex-direction: column;
gap: 8px;
}
}
</style>
</head>
<body>
<div id="app" v-cloak>
<div class="app">
<!-- 顶部栏 -->
<div class="topbar">
<div class="topbar-left">
<div class="logo">SMS FORWARDER</div>
</div>
<div class="topbar-right">
<div class="status-text" v-if="lastUpdate">
<span class="status-dot"></span>{{ lastUpdate }}
</div>
<button class="theme-toggle" @click="toggleTheme">
{{ themeLabel }}
</button>
<button class="modem-toggle" :class="{ active: sidebarOpen }" @click="toggleSidebar">
设置
</button>
</div>
</div>
<!-- 侧边栏 -->
<div class="modem-sidebar" :class="{ open: sidebarOpen }">
<div class="sidebar-header">
<div class="sidebar-title">设置</div>
<button class="close-btn" @click="closeSidebar">&times;</button>
</div>
<div class="sidebar-tabs">
<div class="sidebar-tab" :class="{ active: activeTab === 'devices' }"
@click="activeTab = 'devices'">
设备信息 ({{ modems.length }})
</div>
<div class="sidebar-tab" :class="{ active: activeTab === 'channels' }" @click="switchToChannels">
推送通道
</div>
</div>
<div class="sidebar-content">
<!-- 设备信息 -->
<div class="tab-content" v-show="activeTab === 'devices'">
<div v-if="modemsLoading" class="loading-state">
<div class="spinner"></div>
<div class="loading-text">加载中</div>
</div>
<div v-else-if="modemsError" class="error-state">{{ modemsError }}</div>
<div v-else-if="modems.length === 0" class="empty-state">
<div class="empty-text">暂无设备</div>
</div>
<div v-else>
<div v-for="modem in modems" :key="modem.imei" class="modem-item">
<div class="modem-name">{{ modem.model || modem.manufacturer || '未知设备' }}</div>
<div class="modem-details">
<div class="detail-row">
<span class="detail-label">IMEI</span>
<span class="detail-value">{{ modem.imei || '--' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">ICCID</span>
<span class="detail-value">{{ modem.iccid || '--' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">手机号</span>
<span class="detail-value">{{ modem.number || '--' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">运营商</span>
<span class="detail-value">{{ modem.operator_name || '--' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">制造商</span>
<span class="detail-value">{{ modem.manufacturer || '--' }}</span>
</div>
<div class="signal-row">
<span class="detail-label">信号</span>
<div class="signal-bar">
<div class="signal-fill" :class="getSignalClass(modem.signal_quality)"
:style="{ width: modem.signal_quality + '%' }">
</div>
</div>
<span class="signal-percent"
:style="{ color: getSignalColor(modem.signal_quality) }">
{{ modem.signal_quality }}%
</span>
</div>
<!-- USSD 执行 -->
<div class="ussd-section">
<div class="ussd-header">USSD 代码执行</div>
<div class="ussd-input-group">
<input class="ussd-input" v-model="ussdCode" placeholder="如 *#100#"
:disabled="ussdLoading" />
<button class="ussd-btn" @click="runUSSD" :disabled="ussdLoading">
{{ ussdLoading ? '执行中...' : '发送' }}
</button>
</div>
<div class="ussd-result" v-if="ussdResult">{{ ussdResult }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 推送通道 -->
<div class="tab-content" v-show="activeTab === 'channels'">
<div v-if="channelsLoading" class="loading-state">
<div class="spinner"></div>
<div class="loading-text">加载通道配置中...</div>
</div>
<div v-else-if="channelsError" class="error-state">{{ channelsError }}</div>
<div v-else>
<div
style="margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;">
<div style="color: var(--text-secondary); font-size: 14px;">测试所有启用通道</div>
<button class="save-channels-btn" @click="testAllChannels" :disabled="testingChannels"
style="padding: 8px 24px; width: auto; position: static;">
{{ testingChannels ? '测试中...' : '测试通道' }}
</button>
</div>
<div class="channel-list">
<!-- Email -->
<div class="channel-card">
<div class="channel-header">
<span class="channel-type-badge">邮件推送 (Email)</span>
<label class="channel-enabled">
<input type="checkbox" v-model="channelsList[0].enabled" />
启用
</label>
</div>
<div class="channel-fields">
<div class="field-group">
<label class="field-label">SMTP 服务器</label>
<input class="field-input" v-model="channelsList[0].host"
placeholder="smtp.gmail.com" />
</div>
<div class="field-group">
<label class="field-label">端口</label>
<input type="number" class="field-input"
v-model.number="channelsList[0].port" placeholder="587" />
</div>
<div class="field-group">
<label class="channel-enabled">
<input type="checkbox" v-model="channelsList[0].use_tls" />
使用 TLS 加密
</label>
</div>
<div class="field-group">
<label class="field-label">用户名</label>
<input class="field-input" v-model="channelsList[0].username"
placeholder="user@gmail.com" />
</div>
<div class="field-group">
<label class="field-label">密码</label>
<div style="position: relative;">
<input :type="showPasswords.email ? 'text' : 'password'"
class="field-input" v-model="channelsList[0].password"
placeholder="••••••" style="padding-right: 35px;" />
<span @click="showPasswords.email = !showPasswords.email"
style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); cursor: pointer; user-select: none; width: 20px; height: 20px;">
<svg v-if="showPasswords.email" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
style="width: 100%; height: 100%; opacity: 0.6;">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"
style="width: 100%; height: 100%; opacity: 0.6;">
<path
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24">
</path>
<line x1="1" y1="1" x2="23" y2="23"></line>
</svg>
</span>
</div>
</div>
<div class="field-group">
<label class="field-label">发件人</label>
<input class="field-input" v-model="channelsList[0].from"
placeholder="sender@gmail.com" />
</div>
<div class="field-group">
<label class="field-label">收件人 (多个用逗号分隔)</label>
<input class="field-input" :value="toStr(channelsList[0].to)"
@input="channelsList[0].to = fromStr($event.target.value)"
placeholder="a@gmail.com, b@gmail.com" />
</div>
</div>
</div>
<!-- Bark -->
<div class="channel-card">
<div class="channel-header">
<span class="channel-type-badge">Bark 推送</span>
<label class="channel-enabled">
<input type="checkbox" v-model="channelsList[1].enabled" />
启用
</label>
</div>
<div class="channel-fields">
<div class="field-group">
<label class="field-label">推送地址</label>
<input class="field-input" v-model="channelsList[1].endpoint"
placeholder="https://api.day.app/your_key" />
</div>
<div class="field-group">
<label class="field-label">标题 (可选)</label>
<input class="field-input" v-model="channelsList[1].title"
placeholder="SMS通知" />
</div>
</div>
</div>
<!-- Gotify -->
<div class="channel-card">
<div class="channel-header">
<span class="channel-type-badge">Gotify 推送</span>
<label class="channel-enabled">
<input type="checkbox" v-model="channelsList[2].enabled" />
启用
</label>
</div>
<div class="channel-fields">
<div class="field-group">
<label class="field-label">服务器地址</label>
<input class="field-input" v-model="channelsList[2].endpoint"
placeholder="https://gotify.example.com" />
</div>
<div class="field-group">
<label class="field-label">Token</label>
<div style="position: relative;">
<input :type="showPasswords.gotify ? 'text' : 'password'"
class="field-input" v-model="channelsList[2].token"
placeholder="应用令牌" style="padding-right: 35px;" />
<span @click="showPasswords.gotify = !showPasswords.gotify"
style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); cursor: pointer; user-select: none; width: 20px; height: 20px;">
<svg v-if="showPasswords.gotify" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
style="width: 100%; height: 100%; opacity: 0.6;">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"
style="width: 100%; height: 100%; opacity: 0.6;">
<path
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24">
</path>
<line x1="1" y1="1" x2="23" y2="23"></line>
</svg>
</span>
</div>
</div>
<div class="field-group">
<label class="field-label">优先级 (0-10)</label>
<input type="number" class="field-input"
v-model.number="channelsList[2].priority" placeholder="5" min="0"
max="10" />
</div>
</div>
</div>
<!-- ServerChan -->
<div class="channel-card">
<div class="channel-header">
<span class="channel-type-badge">Server酱3 推送</span>
<label class="channel-enabled">
<input type="checkbox" v-model="channelsList[3].enabled" />
启用
</label>
</div>
<div class="channel-fields">
<div class="field-group">
<label class="field-label">SendKey</label>
<div style="position: relative;">
<input :type="showPasswords.serverchan ? 'text' : 'password'"
class="field-input" v-model="channelsList[3].send_key"
placeholder="SCT..." style="padding-right: 35px;" />
<span @click="showPasswords.serverchan = !showPasswords.serverchan"
style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); cursor: pointer; user-select: none; width: 20px; height: 20px;">
<svg v-if="showPasswords.serverchan"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"
style="width: 100%; height: 100%; opacity: 0.6;">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"
style="width: 100%; height: 100%; opacity: 0.6;">
<path
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24">
</path>
<line x1="1" y1="1" x2="23" y2="23"></line>
</svg>
</span>
</div>
</div>
</div>
</div>
<!-- Webhook -->
<div class="channel-card">
<div class="channel-header">
<span class="channel-type-badge">Webhook 推送</span>
<label class="channel-enabled">
<input type="checkbox" v-model="channelsList[4].enabled" />
启用
</label>
</div>
<div class="channel-fields">
<div class="field-group">
<label class="field-label">URL</label>
<input class="field-input" v-model="channelsList[4].url"
placeholder="https://example.com/webhook" />
</div>
<div class="field-group">
<label class="field-label">请求方法</label>
<select class="field-input" v-model="channelsList[4].method">
<option value="POST">POST</option>
<option value="GET">GET</option>
</select>
</div>
<div class="field-group">
<label class="field-label">请求头 (JSON)</label>
<textarea class="field-input" v-model="channelsList[4].headers_json"
rows="3" placeholder='{"Authorization": "Bearer token"}'></textarea>
</div>
</div>
</div>
</div>
<button class="save-channels-btn" @click="saveChannels" :disabled="channelsSaving">
{{ channelsSaving ? '保存中...' : '保存配置' }}
</button>
</div>
</div>
</div>
</div>
<!-- 主内容区 - 短信列表 -->
<div class="main-content">
<div class="messages-container">
<div class="messages-header">
<div class="messages-title">消息</div>
<div class="message-count" v-if="!messagesLoading && !messagesError">
{{ filteredMessages.length }} / {{ messages.length }} 条
</div>
</div>
<!-- 搜索框 -->
<div class="search-box" v-if="!messagesLoading && !messagesError && messages.length > 0">
<input type="text" class="search-input" placeholder="搜索短信内容或发送号码..." v-model="searchQuery" />
</div>
<div v-if="messagesLoading" class="loading-state">
<div class="spinner"></div>
<div class="loading-text">加载中</div>
</div>
<div v-else-if="messagesError" class="error-state">{{ messagesError }}</div>
<div v-else-if="messages.length === 0" class="empty-state">
<div class="empty-icon">—</div>
<div class="empty-text">暂无短信记录</div>
</div>
<div v-else-if="filteredMessages.length === 0" class="empty-state">
<div class="empty-icon">🔍</div>
<div class="empty-text">没有找到匹配的短信</div>
</div>
<div v-else>
<div v-for="msg in filteredMessages" :key="msg.id" class="message-item">
<div class="message-header">
<div class="message-sender">
<div class="sender-number">{{ msg.number }}</div>
<div class="sender-device">{{ msg.modem }}</div>
</div>
<div style="display: flex; align-items: center; gap: 16px;">
<div class="message-timestamp">{{ formatTime(msg.timestamp) }}</div>
<button class="delete-btn" @click="confirmDelete(msg)">删除</button>
</div>
</div>
<div class="message-body">{{ msg.text || '(空消息)' }}</div>
</div>
<!-- 加载更多 -->
<div v-if="hasMoreMessages && !searchQuery.trim()" style="text-align: center; padding: 20px;">
<button @click="loadMoreMessages" :disabled="loadingMore"
style="padding: 10px 30px; background: transparent; border: 1px solid var(--border-secondary); color: var(--text-secondary); cursor: pointer; transition: all 0.2s;">
{{ loadingMore ? '加载中...' : '加载更多' }}
</button>
</div>
</div>
</div>
</div>
<!-- 删除确认对话框 -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="cancelDelete">
<div class="modal-content">
<div class="modal-title">确认删除</div>
<div class="modal-message">
确定要删除来自 <strong>{{ messageToDelete?.number }}</strong> 的短信吗?<br>
此操作不可撤销。
</div>
<div class="modal-actions">
<button class="modal-btn" @click="cancelDelete">取消</button>
<button class="modal-btn danger" @click="deleteMessage">删除</button>
</div>
</div>
</div>
</div>
</div>
<script>
const { createApp, ref, onMounted, onUnmounted, computed } = Vue;
createApp({
setup() {
const modems = ref([]);
const messages = ref([]);
const totalMessages = ref(0);
const messageOffset = ref(0);
const messageLimit = 50;
const hasMoreMessages = computed(() => messages.value.length < totalMessages.value);
const channelsList = ref([
{ type: 'email', enabled: false, port: 587, use_tls: true, to: [] },
{ type: 'bark', enabled: false },
{ type: 'gotify', enabled: false, priority: 5 },
{ type: 'serverchan', enabled: false },
{ type: 'webhook', enabled: false, method: 'POST' }
]);
const modemsLoading = ref(true);
const messagesLoading = ref(true);
const loadingMore = ref(false);
const channelsLoading = ref(false);
const modemsError = ref(null);
const messagesError = ref(null);
const channelsError = ref(null);
const lastUpdate = ref('');
const sidebarOpen = ref(false);
const activeTab = ref('devices'); // 'devices' or 'channels'
const theme = ref('auto'); // 'light', 'dark', 'auto'
const searchQuery = ref('');
const showDeleteModal = ref(false);
const messageToDelete = ref(null);
const channelsSaving = ref(false);
const testingChannels = ref(false);
const showPasswords = ref({
email: false,
gotify: false,
serverchan: false
});
// USSD
const ussdCode = ref('*#100#');
const ussdResult = ref('');
const ussdLoading = ref(false);
const selectedModem = ref('');
let refreshTimer = null;
let ws = null;
let reconnectTimer = null;
// 字符串数组互转
const toStr = (arr) => Array.isArray(arr) ? arr.join(', ') : '';
const fromStr = (str) => str.split(',').map(s => s.trim()).filter(s => s);
// 过滤后的消息列表
const filteredMessages = computed(() => {
if (!searchQuery.value.trim()) {
return messages.value;
}
const query = searchQuery.value.toLowerCase();
return messages.value.filter(msg => {
return (msg.text && msg.text.toLowerCase().includes(query)) ||
(msg.number && msg.number.includes(query)) ||
(msg.modem && msg.modem.toLowerCase().includes(query));
});
});
// 主题标签
const themeLabel = computed(() => {
const labels = { light: '浅色', dark: '深色', auto: '跟随系统' };
return labels[theme.value];
});
// 初始化主题
const initTheme = () => {
const savedTheme = localStorage.getItem('theme') || 'dark';
theme.value = savedTheme;
applyTheme(savedTheme);
};
// 应用主题
const applyTheme = (themeName) => {
const root = document.documentElement;
if (themeName === 'auto') {
// 检测系统主题偏好
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
root.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
} else {
root.setAttribute('data-theme', themeName);
}
};
// 切换主题
const toggleTheme = () => {
const themes = ['dark', 'light', 'auto'];
const currentIndex = themes.indexOf(theme.value);
const nextIndex = (currentIndex + 1) % themes.length;
theme.value = themes[nextIndex];
localStorage.setItem('theme', theme.value);
applyTheme(theme.value);
};
// 监听系统主题变化
const watchSystemTheme = () => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', (e) => {
if (theme.value === 'auto') {
applyTheme('auto');
}
});
};
const formatTime = (timestamp) => {
if (!timestamp) return '未知';
const date = new Date(timestamp);
const now = new Date();
const diff = now - date;
// 1分钟内
if (diff < 60000) return '刚刚';
// 1小时内
if (diff < 3600000) return Math.floor(diff / 60000) + ' 分钟前';
// 24小时内
if (diff < 86400000) return Math.floor(diff / 3600000) + ' 小时前';
// 7天内
if (diff < 604800000) return Math.floor(diff / 86400000) + ' 天前';
// 超过7天显示完整日期
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
const getSignalClass = (quality) => {
if (quality >= 80) return 'signal-excellent';
if (quality >= 60) return 'signal-good';
if (quality >= 40) return 'signal-fair';
if (quality > 0) return 'signal-poor';
return 'signal-none';
};
const getSignalColor = (quality) => {
if (quality >= 80) return 'var(--signal-excellent)';
if (quality >= 60) return 'var(--signal-good)';
if (quality >= 40) return 'var(--signal-fair)';
if (quality > 0) return 'var(--signal-poor)';
return 'var(--signal-none)';
};
// 获取通道类型的显示名称
const getChannelTypeName = (type) => {
const typeNames = {
'email': 'Email',
'bark': 'Bark',
'gotify': 'Gotify',
'serverchan': 'Server酱3',
'webhook': 'Webhook'
};
return typeNames[type] || type || '未知';
};
// 侧边栏控制
const toggleSidebar = () => {
sidebarOpen.value = !sidebarOpen.value;
};
const closeSidebar = () => {
sidebarOpen.value = false;
};
const switchToChannels = () => {
activeTab.value = 'channels';
if (!channelsLoading.value) {
loadChannels();
}
};
const loadModems = async () => {
try {
modemsError.value = null;
const response = await fetch('/api/modems');
if (!response.ok) throw new Error('加载失败');
modems.value = await response.json();
if (modems.value.length > 0 && !selectedModem.value) {
selectedModem.value = modems.value[0].imei;
}
updateLastUpdateTime();
} catch (error) {
modemsError.value = '加载设备信息失败: ' + error.message;
} finally {
modemsLoading.value = false;
}
};
const loadMessages = async (append = false) => {
try {
if (append) {
loadingMore.value = true;
} else {
messagesLoading.value = true;
messageOffset.value = 0;
messagesError.value = null;
}
const offset = append ? messageOffset.value : 0;
const response = await fetch(`/api/messages?limit=${messageLimit}&offset=${offset}`);
if (!response.ok) throw new Error('加载失败');
const data = await response.json();
// 兼容旧 API (返回数组) 和新 API (返回对象 {items, total})
let newMessages = [];
let total = 0;
if (Array.isArray(data)) {
newMessages = data;
total = data.length; // 旧 API 没有总数,假设只有这些
} else {
newMessages = data.items || [];
total = data.total || 0;
}
// 排序:最新的在前 (backend 已经按倒序返回,但为了保险可以再排一次,或者直接信任后端)
// 这里假设后端已经倒序,直接使用
if (append) {
messages.value = [...messages.value, ...newMessages];
} else {
messages.value = newMessages;
}
totalMessages.value = total;
messageOffset.value += newMessages.length;
} catch (error) {
if (!append) {
messagesError.value = '加载短信失败: ' + error.message;
} else {
alert('加载更多失败: ' + error.message);
}
} finally {
messagesLoading.value = false;
loadingMore.value = false;
}
};
const loadMoreMessages = () => {
if (!hasMoreMessages.value || loadingMore.value) return;
loadMessages(true);
};
const loadChannels = async () => {
try {
channelsLoading.value = true;
channelsError.value = null;
const response = await fetch('/api/channels');
if (!response.ok) throw new Error('加载失败');
const data = await response.json();
console.log('从服务器加载的通道配置:', data);
// 完全替换本地模板,确保所有字段都被正确覆盖
if (Array.isArray(data) && data.length > 0) {
data.forEach(ch => {
const idx = channelsList.value.findIndex(c => c.type === ch.type);
if (idx !== -1) {
// 确保 to 字段正确处理
if (ch.type === 'email' && !Array.isArray(ch.to)) {
ch.to = [];
}
channelsList.value[idx] = { ...ch };
console.log(`已合并通道: ${ch.type}`, channelsList.value[idx]);
}
});
}
} catch (error) {
channelsError.value = '加载通道配置失败: ' + error.message;
console.error('加载通道失败:', error);
} finally {
channelsLoading.value = false;
}
};
const saveChannels = async () => {
try {
channelsSaving.value = true;
const response = await fetch('/api/channels/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(channelsList.value)
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || '保存失败');
}
alert('配置已保存成功!');
} catch (error) {
alert('保存失败: ' + error.message);
} finally {
channelsSaving.value = false;
}
};
const testAllChannels = async () => {
const enabledChannels = channelsList.value.filter(ch => ch.enabled);
if (enabledChannels.length === 0) {
alert('❌ 没有启用的推送通道,请先启用至少一个通道');
return;
}
try {
testingChannels.value = true;
const response = await fetch('/api/channels/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(enabledChannels)
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || '测试失败');
}
const result = await response.json();
if (result.success) {
alert(`✅ 测试推送已发送到 ${enabledChannels.length} 个通道!请检查是否收到通知。`);
} else {
alert('❌ 测试失败: ' + (result.error || '未知错误'));
}
} catch (error) {
alert('❌ 测试失败: ' + error.message);
} finally {
testingChannels.value = false;
}
};
const runUSSD = async () => {
if (!selectedModem.value || !ussdCode.value) {
alert('请选择设备并输入 USSD 代码');
return;
}
try {
ussdLoading.value = true;
ussdResult.value = '';
const response = await fetch('/api/ussd', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
imei: selectedModem.value,
code: ussdCode.value
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '执行失败');
}
ussdResult.value = data.reply || 'USSD 请求已发送,但未收到即时回复(请留意短信通知)。';
} catch (error) {
alert('USSD 执行失败: ' + error.message);
} finally {
ussdLoading.value = false;
}
};
const updateLastUpdateTime = () => {
const now = new Date();
lastUpdate.value = '更新于 ' + now.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
};
const refresh = () => {
loadModems();
// 不自动刷新消息列表,以免干扰翻页(或者只刷新第一页)
// 选择:如果我们在第一页,可以尝试刷新最新的
if (messageOffset.value <= messageLimit) {
// 简单的做法:这里只刷新 modem,消息刷新由 websocket 或手动触发
}
};
// 确认删除
const confirmDelete = (msg) => {
messageToDelete.value = msg;
showDeleteModal.value = true;
};
// 取消删除
const cancelDelete = () => {
showDeleteModal.value = false;
messageToDelete.value = null;
};
// 删除短信
const deleteMessage = async () => {
if (!messageToDelete.value) return;
try {
const response = await fetch('/api/messages/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ id: messageToDelete.value.id })
});
if (!response.ok) {
const error = await response.text();
throw new Error(error || '删除失败');
}
// 从列表中移除
messages.value = messages.value.filter(m => m.id !== messageToDelete.value.id);
// 关闭对话框
cancelDelete();
} catch (error) {
alert('删除失败: ' + error.message);
}
};
// WebSocket 连接
const connectWebSocket = () => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket 已连接');
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'new_message') {
// 将新消息插入到列表顶部
messages.value.unshift(data.message);
totalMessages.value++;
messageOffset.value++; // 偏移量增加
updateLastUpdateTime();
}
} catch (error) {
console.error('处理 WebSocket 消息失败:', error);
}
};
ws.onerror = (error) => {
console.error('WebSocket 错误:', error);
};
ws.onclose = () => {
console.log('WebSocket 已断开,5秒后重连...');
reconnectTimer = setTimeout(connectWebSocket, 5000);
};
};
onMounted(() => {
initTheme();
watchSystemTheme();
loadModems();
loadMessages(); // 初始加载
connectWebSocket();
refreshTimer = setInterval(refresh, 30000);
});
onUnmounted(() => {
if (refreshTimer) {
clearInterval(refreshTimer);
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
}
if (ws) {
ws.close();
}
});
return {
modems,
messages,
channelsList,
modemsLoading,
messagesLoading,
loadingMore,
hasMoreMessages,
channelsLoading,
modemsError,
messagesError,
channelsError,
lastUpdate,
sidebarOpen,
activeTab,
theme,
themeLabel,
searchQuery,
filteredMessages,
showDeleteModal,
messageToDelete,
channelsSaving,
testingChannels,
showPasswords,
ussdCode,
ussdResult,
ussdLoading,
selectedModem,
formatTime,
getSignalClass,
getSignalColor,
toStr,
fromStr,
toggleSidebar,
closeSidebar,
switchToChannels,
toggleTheme,
confirmDelete,
cancelDelete,
deleteMessage,
saveChannels,
testAllChannels,
runUSSD,
loadMoreMessages
};
}
}).mount('#app');
</script>
</body>
</html>