Files

2154 lines
86 KiB
HTML
Raw Permalink 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>CyberPigeon</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<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="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<style>
:root {
/* 深色主题 */
--bg-primary: #09090b;
--bg-secondary: #18181b;
--bg-tertiary: #27272a;
--border-primary: #27272a;
--border-secondary: #3f3f46;
--text-primary: #fafafa;
--text-secondary: #a1a1aa;
--text-tertiary: #71717a;
--accent-primary: #3b82f6;
--accent-secondary: #60a5fa;
--accent-hover: #2563eb;
--status-success: #10b981;
--signal-excellent: #10b981;
--signal-good: #84cc16;
--signal-fair: #eab308;
--signal-poor: #f97316;
--signal-none: #52525b;
--error-bg: #450a0a;
--error-border: #7f1d1d;
--error-text: #f87171;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
--shadow-subtle: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-card: 0 4px 12px rgba(0, 0, 0, 0.2);
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
[data-theme="light"] {
/* 浅色主题 */
--bg-primary: #f8fafc;
--bg-secondary: #ffffff;
--bg-tertiary: #f1f5f9;
--border-primary: #e2e8f0;
--border-secondary: #cbd5e1;
--text-primary: #0f172a;
--text-secondary: #475569;
--text-tertiary: #94a3b8;
--accent-primary: #2563eb;
--accent-secondary: #3b82f6;
--accent-hover: #1d4ed8;
--status-success: #10b981;
--signal-excellent: #10b981;
--signal-good: #84cc16;
--signal-fair: #eab308;
--signal-poor: #f97316;
--signal-none: #cbd5e1;
--error-bg: #fef2f2;
--error-border: #fca5a5;
--error-text: #ef4444;
--shadow-subtle: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-card: 0 4px 12px rgba(0, 0, 0, 0.05);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
[v-cloak] {
display: none !important;
}
body {
font-family: var(--font-family);
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
transition: background-color 0.3s ease, color 0.3s ease;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.app {
display: flex;
flex-direction: column;
height: 100vh;
}
/* 顶部栏 */
.topbar {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-primary);
padding: 16px 32px;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 100;
box-shadow: var(--shadow-subtle);
}
.topbar-left {
display: flex;
align-items: center;
gap: 24px;
}
.logo {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 8px;
}
.logo::before {
content: '';
display: inline-block;
width: 20px;
height: 20px;
background: var(--accent-primary);
border-radius: 6px;
}
.topbar-right {
display: flex;
align-items: center;
gap: 16px;
}
.status-dot {
width: 8px;
height: 8px;
background: var(--status-success);
border-radius: 50%;
display: inline-block;
margin-right: 8px;
box-shadow: 0 0 8px var(--status-success);
animation: pulse 2s ease-in-out infinite;
}
.status-dot.disconnected {
background: var(--error-text);
box-shadow: 0 0 8px var(--error-text);
animation: none;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.status-text {
font-size: 13px;
font-weight: 500;
color: var(--text-tertiary);
display: flex;
align-items: center;
}
button {
font-family: var(--font-family);
}
.theme-toggle, .modem-toggle {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
color: var(--text-secondary);
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border-radius: var(--radius-sm);
}
.theme-toggle:hover, .modem-toggle:hover {
border-color: var(--border-secondary);
color: var(--text-primary);
background: var(--bg-primary);
}
.modem-toggle.active {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: #ffffff;
}
/* TAB 标签页 */
.sidebar-tabs {
display: flex;
border-bottom: 1px solid var(--border-primary);
background: var(--bg-secondary);
padding: 0 8px;
gap: 4px;
}
.sidebar-tab {
flex: 1;
padding: 14px 16px;
text-align: center;
font-size: 13px;
font-weight: 500;
color: var(--text-tertiary);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s ease;
margin-bottom: -1px;
}
.sidebar-tab:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.sidebar-tab.active {
color: var(--accent-primary);
border-bottom-color: var(--accent-primary);
}
/* Modem 侧边栏 */
.modem-sidebar {
position: fixed;
top: 0;
right: 0;
width: 400px;
max-width: 100%;
height: 100vh;
background: var(--bg-secondary);
border-left: 1px solid var(--border-primary);
box-shadow: var(--shadow-card);
transform: translateX(100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 200;
display: flex;
flex-direction: column;
}
.modem-sidebar.open {
transform: translateX(0);
}
.sidebar-header {
padding: 24px;
border-bottom: 1px solid var(--border-primary);
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
/* 推送通道编辑 */
.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: 13px;
color: var(--text-tertiary);
opacity: 0.8;
}
.channel-card {
background: var(--bg-primary);
border: 1px solid var(--border-primary);
padding: 20px;
border-radius: var(--radius-md);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.channel-card:hover {
border-color: var(--border-secondary);
box-shadow: var(--shadow-sm);
}
.channel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border-primary);
}
.channel-type-badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
background: rgba(59, 130, 246, 0.1);
color: var(--accent-primary);
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: var(--radius-full);
font-size: 12px;
font-weight: 600;
}
.channel-enabled {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
cursor: pointer;
}
.channel-enabled input[type="checkbox"] {
cursor: pointer;
width: 16px;
height: 16px;
accent-color: var(--accent-primary);
}
.channel-fields {
display: flex;
flex-direction: column;
gap: 16px;
}
.field-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.field-input {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
color: var(--text-primary);
padding: 10px 14px;
font-size: 14px;
font-family: var(--font-family);
transition: all 0.2s ease;
border-radius: var(--radius-sm);
}
.field-input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
.field-input:disabled {
opacity: 0.6;
cursor: not-allowed;
background: var(--bg-secondary);
}
select.field-input {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2371717a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 16px;
padding-right: 40px;
}
.save-channels-btn {
position: sticky;
bottom: 20px;
background: var(--accent-primary);
color: #ffffff;
border: none;
padding: 14px;
width: 100%;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.2s ease;
border-radius: var(--radius-sm);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
margin-top: 10px;
}
.save-channels-btn:hover {
background: var(--accent-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
.save-channels-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.close-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
color: var(--text-secondary);
font-size: 20px;
cursor: pointer;
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.close-btn:hover {
color: var(--text-primary);
background: var(--border-primary);
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
}
.tab-content {
flex: 1;
overflow-y: auto;
}
.modem-item {
background: var(--bg-primary);
border: 1px solid var(--border-primary);
padding: 24px;
margin-bottom: 16px;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
border-radius: var(--radius-lg);
position: relative;
overflow: hidden;
}
.modem-item:hover {
transform: translateY(-2px);
border-color: var(--border-secondary);
}
.modem-name {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 20px;
}
.modem-details {
display: flex;
flex-direction: column;
gap: 12px;
}
.detail-row {
display: flex;
justify-content: space-between;
font-size: 13px;
align-items: center;
}
.detail-label {
color: var(--text-tertiary);
font-weight: 500;
}
.detail-value {
color: var(--text-primary);
font-weight: 600;
background: var(--bg-tertiary);
padding: 4px 8px;
border-radius: var(--radius-sm);
}
.signal-row {
display: flex;
align-items: center;
gap: 14px;
padding-top: 16px;
margin-top: 8px;
border-top: 1px dashed var(--border-primary);
}
.signal-bar {
flex: 1;
height: 6px;
background: var(--bg-tertiary);
position: relative;
border-radius: 3px;
overflow: hidden;
border: 1px solid var(--border-primary);
}
.signal-fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
.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: 700;
min-width: 44px;
text-align: right;
}
/* USSD 区域 */
.ussd-section {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
padding: 16px;
margin-top: 20px;
border-radius: var(--radius-sm);
}
.ussd-header {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 12px;
}
.ussd-input-group {
display: flex;
gap: 8px;
}
.ussd-input {
flex: 1;
min-width: 0;
background: var(--bg-primary);
border: 1px solid var(--border-primary);
color: var(--text-primary);
padding: 10px 12px;
font-size: 14px;
font-family: var(--font-family);
border-radius: var(--radius-sm);
transition: border-color 0.2s ease;
}
.ussd-input:focus {
outline: none;
border-color: var(--accent-primary);
}
.ussd-btn {
background: var(--text-primary);
color: var(--bg-primary);
border: none;
padding: 0 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
border-radius: var(--radius-sm);
transition: opacity 0.2s ease;
}
.ussd-btn:hover {
opacity: 0.9;
}
.ussd-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ussd-result {
margin-top: 12px;
padding: 12px;
background: var(--bg-primary);
border: 1px solid var(--border-primary);
font-size: 13px;
color: var(--text-secondary);
font-family: monospace;
white-space: pre-wrap;
border-radius: var(--radius-sm);
}
.ussd-error {
color: var(--error-text);
font-size: 12px;
margin-top: 6px;
font-weight: 500;
}
/* 主内容区 */
.main-content {
flex: 1;
overflow-y: auto;
padding: 24px;
background: var(--bg-primary);
}
.messages-container {
max-width: 900px;
margin: 0 auto;
}
.messages-header {
margin-bottom: 32px;
display: flex;
justify-content: space-between;
align-items: center;
}
.messages-title {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
}
.message-count {
font-size: 14px;
font-weight: 500;
color: var(--text-tertiary);
background: var(--bg-secondary);
padding: 6px 12px;
border-radius: var(--radius-full);
border: 1px solid var(--border-primary);
}
.message-item {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
padding: 24px;
margin-bottom: 16px;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
border-radius: var(--radius-lg);
position: relative;
overflow: hidden;
}
.message-item::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: var(--accent-primary);
opacity: 0.8;
}
.message-item:hover {
background: var(--bg-primary);
box-shadow: var(--shadow-sm);
border-color: var(--border-secondary);
transform: translateY(-1px);
}
.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: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 8px;
}
.sender-device {
font-size: 13px;
color: var(--text-tertiary);
background: var(--bg-tertiary);
padding: 2px 8px;
border-radius: var(--radius-sm);
display: inline-block;
}
.message-timestamp {
font-size: 13px;
color: var(--text-tertiary);
font-weight: 500;
white-space: nowrap;
}
.message-body {
font-size: 15px;
line-height: 1.6;
color: var(--text-secondary);
white-space: pre-wrap;
word-wrap: break-word;
background: var(--bg-tertiary);
padding: 16px;
border-radius: var(--radius-md);
border: 1px solid var(--border-primary);
}
.delete-btn {
background: transparent;
border: 1px solid var(--border-primary);
color: var(--text-tertiary);
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border-radius: var(--radius-sm);
}
.delete-btn:hover {
border-color: var(--error-border);
color: var(--error-text);
background: var(--error-bg);
}
/* 搜索框 */
.search-box {
margin-bottom: 24px;
position: relative;
}
.search-input {
width: 100%;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
color: var(--text-primary);
padding: 14px 16px 14px 44px;
font-size: 15px;
font-family: var(--font-family);
transition: all 0.2s ease;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.search-input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
background: var(--bg-primary);
}
.search-input::placeholder {
color: var(--text-tertiary);
}
.search-box::before {
content: "🔍";
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
font-size: 16px;
opacity: 0.5;
pointer-events: none;
}
/* 确认对话框 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-content {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
padding: 32px;
max-width: 420px;
width: 90%;
border-radius: var(--radius-lg);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.modal-title {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 16px;
}
.modal-message {
font-size: 15px;
color: var(--text-secondary);
margin-bottom: 32px;
line-height: 1.6;
}
.modal-message strong {
color: var(--text-primary);
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 4px;
}
.modal-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.modal-btn {
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--border-primary);
background: var(--bg-tertiary);
color: var(--text-primary);
transition: all 0.2s ease;
border-radius: var(--radius-sm);
}
.modal-btn:hover {
border-color: var(--border-secondary);
background: var(--bg-secondary);
}
.modal-btn.danger {
background: var(--error-text);
color: white;
border-color: var(--error-text);
}
.modal-btn.danger:hover {
opacity: 0.9;
transform: translateY(-1px);
}
/* 加载和空状态 */
.loading-state, .empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100px 20px;
color: var(--text-tertiary);
background: var(--bg-secondary);
border-radius: var(--radius-lg);
border: 1px dashed var(--border-secondary);
margin-bottom: 24px;
}
.spinner {
width: 36px;
height: 36px;
border: 3px solid var(--border-primary);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 0.8s cubic-bezier(0.6, 0.2, 0.4, 0.8) infinite;
margin-bottom: 20px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
font-size: 14px;
font-weight: 500;
letter-spacing: 0.5px;
}
.empty-icon {
font-size: 48px;
margin-bottom: 20px;
opacity: 0.5;
filter: grayscale(1);
}
.empty-text {
font-size: 15px;
font-weight: 500;
}
.error-state {
background: var(--error-bg);
border: 1px solid var(--error-border);
color: var(--error-text);
padding: 24px;
margin-bottom: 24px;
text-align: center;
font-size: 14px;
font-weight: 500;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.error-state::before {
content: '⚠️';
font-size: 18px;
}
/* 滚动条 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-secondary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
/* Toast 通知 */
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 2000;
display: flex;
flex-direction: column;
gap: 12px;
pointer-events: none;
}
.toast {
padding: 14px 20px;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 500;
color: white;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.2), 0 4px 6px -2px rgba(0, 0, 0, 0.1);
pointer-events: auto;
animation: toastIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
max-width: 380px;
word-break: break-word;
line-height: 1.5;
display: flex;
align-items: center;
gap: 12px;
}
.toast.toast-out {
animation: toastOut 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.toast.success { background: var(--status-success); color: #fff; }
.toast.error { background: var(--error-text); color: #fff;}
.toast.info { background: var(--accent-primary); color: #fff;}
.toast.warning { background: var(--signal-fair); color: #000;}
@keyframes toastIn {
from { opacity: 0; transform: translateY(20px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes toastOut {
from { opacity: 1; transform: scale(1); }
to { opacity: 0; transform: scale(0.95); margin-top: -60px; }
}
/* 侧边栏遮罩 */
.sidebar-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(2px);
z-index: 150;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
}
.sidebar-overlay.visible {
opacity: 1;
visibility: visible;
}
/* 通道添加按钮 */
.add-channel-btn {
background: var(--bg-tertiary);
border: 1px dashed var(--border-secondary);
color: var(--text-primary);
padding: 14px;
width: 100%;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
border-radius: var(--radius-md);
margin-bottom: 16px;
}
.add-channel-btn:hover:not(:disabled) {
border-color: var(--accent-primary);
color: var(--accent-primary);
background: rgba(59, 130, 246, 0.05);
}
.add-channel-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 通道删除按钮 */
.remove-channel-btn {
background: transparent;
border: none;
color: var(--text-tertiary);
cursor: pointer;
font-size: 20px;
padding: 4px 8px;
transition: all 0.2s ease;
border-radius: var(--radius-sm);
line-height: 1;
}
.remove-channel-btn:hover {
color: var(--error-text);
background: var(--error-bg);
}
/* 密码可见切换按钮调整 */
.pwd-toggle-btn {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
user-select: none;
width: 20px;
height: 20px;
color: var(--text-tertiary);
transition: color 0.2s;
}
.pwd-toggle-btn:hover {
color: var(--text-primary);
}
/* 响应式 */
@media (max-width: 768px) {
.topbar {
padding: 12px 16px;
}
.logo {
font-size: 16px;
}
.status-text {
display: none;
}
.modem-sidebar {
width: 100%;
}
.messages-container {
padding: 0;
}
.message-item {
padding: 16px;
border-radius: var(--radius-md);
}
.message-header {
flex-direction: column;
gap: 12px;
}
.toast-container {
bottom: 16px;
right: 16px;
left: 16px;
}
.toast {
max-width: 100%;
}
}
</style>
</head>
<body>
<div id="app" v-cloak>
<div class="app">
<!-- 顶部栏 -->
<div class="topbar">
<div class="topbar-left">
<div class="logo">CYBERPIGEON</div>
</div>
<div class="topbar-right">
<div class="status-text" v-if="lastUpdate || !wsConnected">
<span class="status-dot" :class="{ disconnected: !wsConnected }"></span>{{ wsConnected ? lastUpdate : '已断开,重连中...' }}
</div>
<button class="theme-toggle" @click="toggleTheme">
{{ themeLabel }}
</button>
<button class="modem-toggle" :class="{ active: sidebarOpen }" @click="toggleSidebar">
设置
</button>
</div>
</div>
<!-- 侧边栏遮罩 -->
<div class="sidebar-overlay" :class="{ visible: sidebarOpen }" @click="closeSidebar"></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="getUssdState(modem.imei).code" placeholder="如 *#100#"
:disabled="getUssdState(modem.imei).loading" @input="validateUSSD(modem.imei)" />
<button class="ussd-btn" @click="runUSSD(modem)" :disabled="getUssdState(modem.imei).loading || getUssdState(modem.imei).error">
{{ getUssdState(modem.imei).loading ? '执行中...' : '发送' }}
</button>
</div>
<div v-if="getUssdState(modem.imei).error" class="ussd-error">{{ getUssdState(modem.imei).error }}</div>
<div class="ussd-result" v-if="getUssdState(modem.imei).result">{{ getUssdState(modem.imei).result }}</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">
<div v-for="(ch, index) in channelsList" :key="index" class="channel-card">
<div class="channel-header">
<span class="channel-type-badge">{{ getChannelLabel(ch.type) }}</span>
<div style="display: flex; align-items: center; gap: 8px;">
<label class="channel-enabled">
<input type="checkbox" v-model="ch.enabled" />
启用
</label>
<button v-if="canRemoveChannel(ch.type)" class="remove-channel-btn"
@click="removeChannel(index)" title="删除此通道">×</button>
</div>
</div>
<div class="channel-fields">
<!-- Email fields -->
<template v-if="ch.type === 'email'">
<div class="field-group">
<label class="field-label">SMTP 服务器</label>
<input class="field-input" v-model="ch.host"
placeholder="smtp.gmail.com" />
</div>
<div class="field-group">
<label class="field-label">端口</label>
<input type="number" class="field-input"
v-model.number="ch.port" placeholder="587" />
</div>
<div class="field-group">
<label class="channel-enabled">
<input type="checkbox" v-model="ch.use_tls" />
使用 TLS 加密
</label>
</div>
<div class="field-group">
<label class="field-label">用户名</label>
<input class="field-input" v-model="ch.username"
placeholder="user@gmail.com" />
</div>
<div class="field-group">
<label class="field-label">密码</label>
<password-input v-model="ch.password" placeholder="••••••"></password-input>
</div>
<div class="field-group">
<label class="field-label">发件人</label>
<input class="field-input" v-model="ch.from"
placeholder="sender@gmail.com" />
</div>
<div class="field-group">
<label class="field-label">收件人 (多个用逗号分隔)</label>
<input class="field-input" :value="toStr(ch.to)"
@input="ch.to = fromStr($event.target.value)"
placeholder="a@gmail.com, b@gmail.com" />
</div>
</template>
<!-- Bark fields -->
<template v-if="ch.type === 'bark'">
<div class="field-group">
<label class="field-label">推送地址</label>
<input class="field-input" v-model="ch.endpoint"
placeholder="https://api.day.app/your_key" />
</div>
<div class="field-group">
<label class="field-label">标题 (可选)</label>
<input class="field-input" v-model="ch.title"
placeholder="SMS通知" />
</div>
</template>
<!-- Gotify fields -->
<template v-if="ch.type === 'gotify'">
<div class="field-group">
<label class="field-label">服务器地址</label>
<input class="field-input" v-model="ch.endpoint"
placeholder="https://gotify.example.com" />
</div>
<div class="field-group">
<label class="field-label">Token</label>
<password-input v-model="ch.token" placeholder="应用令牌"></password-input>
</div>
<div class="field-group">
<label class="field-label">优先级 (0-10)</label>
<input type="number" class="field-input"
v-model.number="ch.priority" placeholder="5" min="0"
max="10" />
</div>
</template>
<!-- ServerChan fields -->
<template v-if="ch.type === 'serverchan'">
<div class="field-group">
<label class="field-label">SendKey</label>
<password-input v-model="ch.send_key" placeholder="SCT..."></password-input>
</div>
</template>
<!-- Webhook fields -->
<template v-if="ch.type === 'webhook'">
<div class="field-group">
<label class="field-label">URL</label>
<input class="field-input" v-model="ch.url"
placeholder="https://example.com/webhook" />
</div>
<div class="field-group">
<label class="field-label">请求方法</label>
<select class="field-input" v-model="ch.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="ch.headers_json"
rows="3" placeholder='{"Authorization": "Bearer token"}'></textarea>
</div>
</template>
<!-- WeCom fields -->
<template v-if="ch.type === 'wecom'">
<div class="field-group">
<label class="field-label">Corp ID (企业ID)</label>
<input class="field-input" v-model="ch.corp_id"
placeholder="ww…" />
</div>
<div class="field-group">
<label class="field-label">Corp Secret (应用密钥)</label>
<password-input v-model="ch.corp_secret" placeholder="应用 Secret"></password-input>
</div>
<div class="field-group">
<label class="field-label">Agent ID (应用ID)</label>
<input type="number" class="field-input" v-model.number="ch.agent_id"
placeholder="1000002" />
</div>
<div class="field-group">
<label class="field-label">接收人 (可选)</label>
<input class="field-input" v-model="ch.to_user"
placeholder="UserID1|UserID2,留空则推送给所有人" />
</div>
</template>
<!-- Feishu fields -->
<template v-if="ch.type === 'feishu'">
<div class="field-group">
<label class="field-label">App ID</label>
<input class="field-input" v-model="ch.app_id"
placeholder="cli_xxx" />
</div>
<div class="field-group">
<label class="field-label">App Secret</label>
<password-input v-model="ch.app_secret" placeholder="飞书应用 Secret"></password-input>
</div>
<div class="field-group">
<label class="field-label">接收者 ID</label>
<input class="field-input" v-model="ch.receive_id"
placeholder="ou_xxx 或 oc_xxx" />
</div>
<div class="field-group">
<label class="field-label">接收者 ID 类型</label>
<select class="field-input" v-model="ch.receive_id_type">
<option value="open_id">open_id</option>
<option value="user_id">user_id</option>
<option value="union_id">union_id</option>
<option value="email">email</option>
<option value="chat_id">chat_id</option>
</select>
</div>
<div class="field-group">
<label class="field-label">标题 (可选)</label>
<input class="field-input" v-model="ch.title"
placeholder="短信通知" />
</div>
</template>
<!-- DingTalk fields -->
<template v-if="ch.type === 'dingtalk'">
<div class="field-group">
<label class="field-label">Webhook URL</label>
<input class="field-input" v-model="ch.webhook_url"
placeholder="https://oapi.dingtalk.com/robot/send?access_token=xxx" />
</div>
<div class="field-group">
<label class="field-label">签名密钥 (可选)</label>
<password-input v-model="ch.sign_secret" placeholder="SECxxx,不填则不加签"></password-input>
</div>
<div class="field-group">
<label class="field-label">标题 (可选)</label>
<input class="field-input" v-model="ch.title"
placeholder="短信通知" />
</div>
</template>
<!-- Telegram fields -->
<template v-if="ch.type === 'telegram'">
<div class="field-group">
<label class="field-label">Bot Token</label>
<password-input v-model="ch.bot_token" placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"></password-input>
</div>
<div class="field-group">
<label class="field-label">Chat ID</label>
<input class="field-input" v-model="ch.chat_id"
placeholder="目标接收用户或群组的 ID" />
</div>
<div class="field-group">
<label class="field-label">API URL (自定义代理,可选)</label>
<input class="field-input" v-model="ch.api_url"
placeholder="https://api.telegram.org" />
</div>
</template>
</div>
</div>
</div>
<!-- 添加通道按钮 -->
<div style="margin-top: 8px; margin-bottom: 16px;">
<select class="field-input" v-model="newChannelType" style="margin-bottom: 8px; width: 100%;">
<option value="" disabled>选择通道类型...</option>
<option value="email">Email 邮件推送</option>
<option value="bark">Bark 推送</option>
<option value="gotify">Gotify 推送</option>
<option value="serverchan">Server酱3 推送</option>
<option value="webhook">Webhook 推送</option>
<option value="wecom">企业微信推送</option>
<option value="feishu">飞书推送</option>
<option value="dingtalk">钉钉推送</option>
<option value="telegram">Telegram 推送</option>
</select>
<button class="add-channel-btn" @click="addChannel" :disabled="!newChannelType">
+ 添加推送通道
</button>
</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; border-radius: 4px;">
{{ 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>
<!-- Toast 通知 -->
<div class="toast-container">
<div v-for="toast in toasts" :key="toast.id"
class="toast" :class="[toast.type, toast.leaving ? 'toast-out' : '']">
{{ toast.message }}
</div>
</div>
</div>
</div>
<script>
const { createApp, ref, onMounted, onUnmounted, computed } = Vue;
const PasswordInput = {
props: ['modelValue', 'placeholder'],
emits: ['update:modelValue'],
setup() {
const showPwd = ref(false);
return { showPwd };
},
template: `
<div style="position: relative;">
<input :type="showPwd ? 'text' : 'password'"
class="field-input" :value="modelValue" @input="$emit('update:modelValue', $event.target.value)"
:placeholder="placeholder" style="padding-right: 35px;" />
<span @click="showPwd = !showPwd" class="pwd-toggle-btn">
<svg v-if="showPwd" 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>
`
};
const app = 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([]);
// ── UI 状态 ──
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');
const theme = ref('auto');
const searchQuery = ref('');
const showDeleteModal = ref(false);
const messageToDelete = ref(null);
const channelsSaving = ref(false);
const testingChannels = ref(false);
const wsConnected = ref(false);
const newChannelType = ref('');
// ── USSD(按 IMEI 独立管理每台设备的状态)──
const ussdStates = ref({});
const getUssdState = (imei) => {
if (!ussdStates.value[imei]) {
ussdStates.value[imei] = { code: '*#100#', result: '', loading: false, error: '' };
}
return ussdStates.value[imei];
};
// ── Toast 通知 ──
const toasts = ref([]);
let toastId = 0;
const showToast = (message, type = 'info', duration = 3000) => {
const id = ++toastId;
toasts.value.push({ id, message, type, leaving: false });
setTimeout(() => {
const t = toasts.value.find(t => t.id === id);
if (t) t.leaving = true;
setTimeout(() => {
toasts.value = toasts.value.filter(t => t.id !== id);
}, 300);
}, duration);
};
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 =>
(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 saved = localStorage.getItem('theme') || 'dark';
theme.value = saved;
applyTheme(saved);
};
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 idx = (themes.indexOf(theme.value) + 1) % themes.length;
theme.value = themes[idx];
localStorage.setItem('theme', theme.value);
applyTheme(theme.value);
};
const watchSystemTheme = () => {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (theme.value === 'auto') applyTheme('auto');
});
};
// ── 格式化 ──
const formatTime = (timestamp) => {
if (!timestamp) return '未知';
const date = new Date(timestamp);
const diff = Date.now() - date.getTime();
if (diff < 60000) return '刚刚';
if (diff < 3600000) return Math.floor(diff / 60000) + ' 分钟前';
if (diff < 86400000) return Math.floor(diff / 3600000) + ' 小时前';
if (diff < 604800000) return Math.floor(diff / 86400000) + ' 天前';
return date.toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit'
});
};
const getSignalClass = (q) => {
if (q >= 80) return 'signal-excellent';
if (q >= 60) return 'signal-good';
if (q >= 40) return 'signal-fair';
if (q > 0) return 'signal-poor';
return 'signal-none';
};
const getSignalColor = (q) => {
if (q >= 80) return 'var(--signal-excellent)';
if (q >= 60) return 'var(--signal-good)';
if (q >= 40) return 'var(--signal-fair)';
if (q > 0) return 'var(--signal-poor)';
return 'var(--signal-none)';
};
// ── 通道管理 ──
const channelLabels = {
email: '邮件推送 (Email)',
bark: 'Bark 推送',
gotify: 'Gotify 推送',
serverchan: 'Server酱3 推送',
webhook: 'Webhook 推送',
wecom: '企业微信推送',
feishu: '飞书推送',
dingtalk: '钉钉推送',
telegram: 'Telegram 推送'
};
const getChannelLabel = (type) => channelLabels[type] || type || '未知';
const channelDefaults = {
email: { type: 'email', enabled: false, port: 587, use_tls: true, to: [] },
bark: { type: 'bark', enabled: false },
gotify: { type: 'gotify', enabled: false, priority: 5 },
serverchan: { type: 'serverchan', enabled: false },
webhook: { type: 'webhook', enabled: false, method: 'POST', headers_json: '' },
wecom: { type: 'wecom', enabled: false },
feishu: { type: 'feishu', enabled: false, receive_id_type: 'open_id' },
dingtalk: { type: 'dingtalk', enabled: false },
telegram: { type: 'telegram', enabled: false, api_url: 'https://api.telegram.org' }
};
const addChannel = () => {
const type = newChannelType.value;
if (!type || !channelDefaults[type]) return;
// 深拷贝以避免多个同类型通道共享引用(如 email 的 to 数组)
channelsList.value.push(JSON.parse(JSON.stringify(channelDefaults[type])));
newChannelType.value = '';
showToast(`已添加 ${getChannelLabel(type)} 通道`, 'success');
};
const removeChannel = (index) => {
const ch = channelsList.value[index];
channelsList.value.splice(index, 1);
showToast(`已移除 ${getChannelLabel(ch.type)} 通道`, 'info');
};
// 判断是否可以删除该通道(同类型有多个时才允许删除)
const canRemoveChannel = (type) => {
return channelsList.value.filter(c => c.type === type).length > 1;
};
// 清洗通道数据,去掉前端内部字段
const cleanChannelsForSave = () => {
return channelsList.value.map(ch => {
const copy = { ...ch };
if (copy.type === 'webhook') {
try {
copy.headers = copy.headers_json ? JSON.parse(copy.headers_json) : {};
} catch (e) {
copy.headers = {};
}
delete copy.headers_json;
}
return copy;
});
};
// ── USSD 验证 ──
const validateUSSD = (imei) => {
const state = getUssdState(imei);
const code = state.code;
if (!code) {
state.error = '';
return;
}
for (const c of code) {
if (c !== '*' && c !== '#' && (c < '0' || c > '9')) {
state.error = '仅允许数字、* 和 # 字符';
return;
}
}
state.error = '';
};
// ── 侧边栏 ──
const toggleSidebar = () => {
sidebarOpen.value = !sidebarOpen.value;
};
const closeSidebar = () => {
sidebarOpen.value = false;
};
const switchToChannels = () => {
activeTab.value = 'channels';
if (!channelsLoading.value) loadChannels();
};
// ── API 请求 ──
const loadModems = async () => {
try {
modemsError.value = null;
const res = await fetch('/api/modems');
if (!res.ok) throw new Error('加载失败');
modems.value = await res.json();
updateLastUpdateTime();
} catch (e) {
modemsError.value = '加载设备信息失败: ' + e.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 res = await fetch(`/api/messages?limit=${messageLimit}&offset=${offset}`);
if (!res.ok) throw new Error('加载失败');
const data = await res.json();
let newMsgs = [];
let total = 0;
if (Array.isArray(data)) {
newMsgs = data;
total = data.length;
} else {
newMsgs = data.items || [];
total = data.total || 0;
}
if (append) {
messages.value = [...messages.value, ...newMsgs];
} else {
messages.value = newMsgs;
}
totalMessages.value = total;
messageOffset.value += newMsgs.length;
} catch (e) {
if (!append) {
messagesError.value = '加载短信失败: ' + e.message;
} else {
showToast('加载更多失败: ' + e.message, 'error');
}
} 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 res = await fetch('/api/channels');
if (!res.ok) throw new Error('加载失败');
const data = await res.json();
if (Array.isArray(data) && data.length > 0) {
// 后端返回完整通道列表(含多个同类型),直接使用
channelsList.value = data.map(ch => {
if (ch.type === 'email' && !Array.isArray(ch.to)) {
ch.to = [];
}
if (ch.type === 'webhook') {
ch.headers_json = ch.headers ? JSON.stringify(ch.headers, null, 2) : '';
}
return ch;
});
}
} catch (e) {
channelsError.value = '加载通道配置失败: ' + e.message;
} finally {
channelsLoading.value = false;
}
};
const saveChannels = async () => {
try {
channelsSaving.value = true;
const res = await fetch('/api/channels/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cleanChannelsForSave())
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || '保存失败');
}
showToast('配置已保存成功', 'success');
} catch (e) {
showToast('保存失败: ' + e.message, 'error', 5000);
} finally {
channelsSaving.value = false;
}
};
const testAllChannels = async () => {
const enabled = cleanChannelsForSave().filter(ch => ch.enabled);
if (enabled.length === 0) {
showToast('没有启用的推送通道,请先启用至少一个通道', 'warning');
return;
}
try {
testingChannels.value = true;
const res = await fetch('/api/channels/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(enabled)
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || '测试失败');
}
const result = await res.json();
if (result.success) {
showToast(`测试推送已发送到 ${enabled.length} 个通道,请检查是否收到通知`, 'success', 5000);
} else {
showToast('测试失败: ' + (result.error || '未知错误'), 'error', 5000);
}
} catch (e) {
showToast('测试失败: ' + e.message, 'error', 5000);
} finally {
testingChannels.value = false;
}
};
const runUSSD = async (modem) => {
if (!modem || !modem.imei) return;
const state = getUssdState(modem.imei);
if (!state.code) {
showToast('请输入 USSD 代码', 'warning');
return;
}
if (state.error) return;
try {
state.loading = true;
state.result = '';
const res = await fetch('/api/ussd', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imei: modem.imei, code: state.code })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || '执行失败');
state.result = data.reply || 'USSD 请求已发送,但未收到即时回复(请留意短信通知)。';
} catch (e) {
showToast('USSD 执行失败: ' + e.message, 'error');
} finally {
state.loading = false;
}
};
const updateLastUpdateTime = () => {
lastUpdate.value = '更新于 ' + new Date().toLocaleTimeString('zh-CN', {
hour: '2-digit', minute: '2-digit'
});
};
const refresh = () => { loadModems(); };
// ── 删除 ──
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 res = await fetch('/api/messages/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: messageToDelete.value.id })
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || '删除失败');
}
messages.value = messages.value.filter(m => m.id !== messageToDelete.value.id);
totalMessages.value = Math.max(0, totalMessages.value - 1);
cancelDelete();
showToast('短信已删除', 'success');
} catch (e) {
showToast('删除失败: ' + e.message, 'error');
}
};
// ── WebSocket ──
const connectWebSocket = () => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
ws = new WebSocket(wsUrl);
ws.onopen = () => {
wsConnected.value = true;
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 (e) {
console.error('处理 WebSocket 消息失败:', e);
}
};
ws.onerror = () => {
wsConnected.value = false;
};
ws.onclose = () => {
wsConnected.value = false;
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.onclose = null; // 防止 close 触发重连
ws.close();
ws = null;
}
});
return {
modems, messages, channelsList,
modemsLoading, messagesLoading, loadingMore, hasMoreMessages,
channelsLoading, modemsError, messagesError, channelsError,
lastUpdate, sidebarOpen, activeTab, theme, themeLabel,
searchQuery, filteredMessages,
showDeleteModal, messageToDelete,
channelsSaving, testingChannels,
wsConnected, newChannelType, toasts,
ussdStates, getUssdState,
formatTime, getSignalClass, getSignalColor,
toStr, fromStr,
getChannelLabel, addChannel, removeChannel, canRemoveChannel,
validateUSSD, showToast,
toggleSidebar, closeSidebar, switchToChannels, toggleTheme,
confirmDelete, cancelDelete, deleteMessage,
saveChannels, testAllChannels, runUSSD, loadMoreMessages
};
}
})
app.component('password-input', PasswordInput);
app.mount('#app');
</script>
</body>
</html>