2154 lines
86 KiB
HTML
2154 lines
86 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>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">×</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> |