Files
v6ole 4c53474ffe Add FastLink_Lite custom rules enhancement
- Add custom rules editor to LUCI web interface
- Support 32 rule types (DOMAIN, IP-CIDR, GEOSITE, etc.)
- Rule CRUD, sorting, batch import, templates
- netflow-rules script for mihomo config injection
- Standalone IPK repackaging support
2026-06-19 00:41:24 +08:00

2764 lines
118 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.
<%+header%>
<style>
:root {
--nf-primary: #4f8ef7;
--nf-primary-dark: #3a6fd8;
--nf-success: #4caf50;
--nf-danger: #f44336;
--nf-warning: #ff9800;
--nf-bg: #1a1d23;
--nf-card: #22262e;
--nf-card-hover: #2a2f38;
--nf-border: #333842;
--nf-text: #e0e0e0;
--nf-text-dim: #8a94a6;
--nf-radius: 12px;
}
#netflow-app {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
color: var(--nf-text);
}
.nf-header {
text-align: center;
margin-bottom: 24px;
}
.nf-header h2 {
font-size: 28px;
font-weight: 700;
margin: 0 0 6px 0;
color: var(--nf-primary);
}
.nf-header p {
color: var(--nf-text-dim);
margin: 0;
font-size: 14px;
}
#nf-loading-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
gap: 16px;
}
.nf-loading-spinner {
width: 36px;
height: 36px;
border: 3px solid var(--nf-border);
border-top-color: var(--nf-primary);
border-radius: 50%;
animation: nf-spin 0.8s linear infinite;
}
#nf-loading-screen span {
color: var(--nf-text-dim);
font-size: 14px;
}
#nf-dashboard-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
opacity: 0;
transition: opacity 0.25s ease;
}
#nf-dashboard-section.nf-fade-in { opacity: 1; }
.nf-col-left, .nf-col-right {
display: flex;
flex-direction: column;
gap: 0;
}
#nf-login-section {
max-width: 420px;
margin: 0 auto;
opacity: 0;
transition: opacity 0.25s ease;
}
#nf-login-section.nf-fade-in { opacity: 1; }
.nf-card {
background: var(--nf-card);
border: 1px solid var(--nf-border);
border-radius: var(--nf-radius);
padding: 20px;
margin-bottom: 16px;
}
.nf-card-title {
font-size: 15px;
font-weight: 600;
margin: 0 0 14px 0;
display: flex;
align-items: center;
gap: 8px;
}
.nf-form-group { margin-bottom: 16px; }
.nf-form-group label {
display: block;
font-size: 13px;
color: var(--nf-text-dim);
margin-bottom: 6px;
font-weight: 500;
}
.nf-input {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--nf-border);
border-radius: 8px;
background: var(--nf-bg);
color: var(--nf-text);
font-size: 14px;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
}
.nf-input:focus { border-color: var(--nf-primary); }
.nf-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
color: #fff;
min-width: 90px;
}
.nf-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.nf-btn-primary { background: var(--nf-primary); }
.nf-btn-primary:hover:not(:disabled) { background: var(--nf-primary-dark); }
.nf-btn-success { background: var(--nf-success); }
.nf-btn-success:hover:not(:disabled) { background: #43a047; }
.nf-btn-danger { background: var(--nf-danger); }
.nf-btn-danger:hover:not(:disabled) { background: #e53935; }
.nf-btn-warning { background: var(--nf-warning); }
.nf-btn-warning:hover:not(:disabled) { background: #f57c00; }
.nf-btn-sm { padding: 6px 14px; font-size: 12px; min-width: auto; }
.nf-btn-outline {
background: var(--nf-bg); border: 1px solid var(--nf-border); color: var(--nf-text);
flex: 0 0 auto;
}
.nf-btn-outline:hover:not(:disabled) { background: var(--nf-card-hover); border-color: var(--nf-primary); color: var(--nf-primary); }
.nf-btn-block { width: 100%; display: flex; }
.nf-btn:active:not(:disabled) { transform: scale(0.97); }
.nf-status-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
border-radius: var(--nf-radius);
margin-bottom: 16px;
transition: all 0.3s ease;
background: var(--nf-card);
border: 1px solid var(--nf-border);
color: var(--nf-text);
}
.nf-status-bar.running {
background: linear-gradient(135deg, var(--nf-card), rgba(76, 175, 80, 0.15));
border-color: rgba(76, 175, 80, 0.4);
}
.nf-status-bar.stopped {
background: linear-gradient(135deg, var(--nf-card), rgba(244, 67, 54, 0.15));
border-color: rgba(244, 67, 54, 0.4);
}
.nf-status-bar.starting {
background: linear-gradient(135deg, var(--nf-card), rgba(79, 142, 247, 0.15));
border-color: rgba(79, 142, 247, 0.4);
}
.nf-status-dot {
width: 12px; height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.nf-status-dot.on { background: var(--nf-success); box-shadow: 0 0 8px rgba(76,175,80,0.5); }
.nf-status-dot.off { background: var(--nf-danger); }
.nf-status-dot.pending { background: var(--nf-primary); animation: nf-pulse 1s ease-in-out infinite; }
.nf-status-text { flex: 1; font-weight: 600; font-size: 15px; }
.nf-status-info { color: var(--nf-text-dim); font-size: 13px; }
.nf-toggle-wrap {
display: flex;
gap: 10px;
margin-bottom: 16px;
}
.nf-toggle-wrap .nf-btn { flex: 1; padding: 14px; font-size: 16px; }
.nf-node-list {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
max-height: 800px;
overflow-y: auto;
padding-right: 4px;
}
.nf-node-item {
display: flex;
align-items: center;
padding: 10px 14px;
background: var(--nf-bg);
border: 2px solid var(--nf-border);
border-radius: 12px;
cursor: pointer;
transition: all 0.15s;
gap: 10px;
position: relative;
min-width: 0;
}
.nf-node-item:hover { background: var(--nf-card-hover); border-color: var(--nf-primary); }
.nf-node-item.active { border-color: var(--nf-primary); background: rgba(79, 142, 247, 0.1); }
.nf-node-item.switching { opacity: 0.6; pointer-events: none; }
.nf-node-item.preselected { border-color: var(--nf-warning); border-style: dashed; background: rgba(255, 152, 0, 0.06); }
.nf-node-item:active { transform: scale(0.99); }
.nf-node-active-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--nf-primary); box-shadow: 0 0 6px rgba(79, 142, 247, 0.6);
flex-shrink: 0;
}
.nf-node-preselect-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--nf-warning); box-shadow: 0 0 6px rgba(255, 152, 0, 0.5);
flex-shrink: 0;
}
.nf-node-name {
font-size: 13px; overflow: hidden;
white-space: nowrap; text-overflow: ellipsis; position: relative;
}
.nf-node-item:hover .nf-node-name { text-overflow: clip; }
.nf-node-name > span {
display: inline-block; position: relative; transition: none;
}
.nf-node-item:hover .nf-node-name > span {
animation: nf-scroll-name 3s linear 0.3s infinite alternate;
}
@keyframes nf-scroll-name {
0%, 15% { transform: translateX(0); }
85%, 100% { transform: translateX(var(--nf-scroll-x, 0px)); }
}
.nf-node-info { flex: 1; min-width: 0; }
.nf-node-type {
font-size: 10px; color: var(--nf-text-dim); margin-top: 2px; opacity: 0.7;
}
.nf-node-delay { font-size: 12px; color: var(--nf-text-dim); min-width: 50px; text-align: right; }
.nf-node-delay.fast { color: var(--nf-success); }
.nf-node-delay.medium { color: var(--nf-warning); }
.nf-node-delay.slow { color: var(--nf-danger); }
.nf-node-delay.testing { color: var(--nf-primary); animation: nf-pulse 1s ease-in-out infinite; }
.nf-group-tabs { display: flex; gap: 6px; margin-bottom: 12px; flex-wrap: wrap; }
.nf-group-tab {
padding: 6px 14px; border-radius: 6px; font-size: 13px; cursor: pointer;
background: var(--nf-bg); border: 1px solid var(--nf-border);
color: var(--nf-text-dim); transition: all 0.15s;
display: inline-flex; align-items: center; gap: 6px;
}
.nf-group-tab:hover { border-color: var(--nf-primary); color: var(--nf-text); }
.nf-group-tab.active { background: var(--nf-primary); border-color: var(--nf-primary); color: #fff; }
.nf-group-tab.loading { opacity: 0.7; pointer-events: none; }
.nf-group-tab:active { transform: scale(0.96); }
.nf-group-tab.nf-compact {
padding: 5px 12px;
font-size: 12px;
opacity: 0.92;
}
.nf-group-tab.nf-compact.active {
border-color: var(--nf-success);
color: var(--nf-success);
background: rgba(52, 199, 89, 0.1);
}
.nf-group-tab .nf-tab-spinner {
display: none; width: 12px; height: 12px;
border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff;
border-radius: 50%; animation: nf-spin 0.6s linear infinite;
}
.nf-group-tab.loading .nf-tab-spinner { display: inline-block; }
.nf-group-auto-badge {
font-size: 10px; padding: 1px 6px; border-radius: 4px;
background: rgba(255, 152, 0, 0.22); color: var(--nf-warning); font-weight: 600;
line-height: 1.2;
}
.nf-auto-group-hint {
font-size: 12px; color: var(--nf-text-dim); padding: 8px 10px; margin-bottom: 10px;
background: rgba(255, 152, 0, 0.08); border: 1px solid rgba(255, 152, 0, 0.22);
border-radius: 8px; line-height: 1.45; grid-column: 1 / -1;
}
.nf-node-item.nf-node-readonly {
cursor: default;
opacity: 0.96;
}
.nf-node-item.nf-node-readonly:hover {
border-color: var(--nf-border);
background: var(--nf-bg);
}
.nf-node-item.nf-node-readonly:active { transform: none; }
.nf-user-info { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 14px; }
.nf-info-item { padding: 10px; background: var(--nf-bg); border-radius: 8px; }
.nf-info-label { font-size: 12px; color: var(--nf-text-dim); margin-bottom: 4px; }
.nf-info-value { font-size: 14px; font-weight: 600; }
.nf-log-area {
background: var(--nf-bg);
border: 1px solid var(--nf-border);
border-radius: 8px;
padding: 10px;
font-family: "Consolas", "Monaco", monospace;
font-size: 11px;
color: var(--nf-text-dim);
min-height: 200px;
max-height: 500px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
line-height: 1.6;
}
.nf-log-line-info { color: var(--nf-success); }
.nf-log-line-warn { color: var(--nf-warning); }
.nf-log-line-error { color: var(--nf-danger); }
.nf-stats-panel {
grid-column: 1 / 3;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-top: 4px;
}
.nf-stat-item {
background: var(--nf-card);
border: 1px solid var(--nf-border);
border-radius: var(--nf-radius);
padding: 16px;
text-align: center;
}
.nf-stat-label {
font-size: 12px;
color: var(--nf-text-dim);
margin-bottom: 6px;
}
.nf-stat-value {
font-size: 16px;
font-weight: 700;
color: var(--nf-success);
}
.nf-stat-value.inactive { color: var(--nf-text-dim); }
.nf-toast {
position: fixed; top: 20px; right: 20px;
padding: 12px 20px; border-radius: 8px; color: #fff;
font-size: 14px; z-index: 9999; opacity: 0;
transform: translateY(-10px); transition: all 0.3s; max-width: 350px;
}
.nf-toast.show { opacity: 1; transform: translateY(0); }
.nf-toast.success { background: var(--nf-success); }
.nf-toast.error { background: var(--nf-danger); }
.nf-toast.info { background: var(--nf-primary); }
.nf-hidden { display: none !important; }
.nf-loading {
display: inline-block; width: 16px; height: 16px;
border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff;
border-radius: 50%; animation: nf-spin 0.6s linear infinite; margin-right: 6px;
}
@keyframes nf-spin { to { transform: rotate(360deg); } }
@keyframes nf-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
@keyframes nf-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
.nf-skeleton {
background: linear-gradient(90deg, var(--nf-bg) 25%, var(--nf-card-hover) 50%, var(--nf-bg) 75%);
background-size: 200% 100%; animation: nf-shimmer 1.5s ease-in-out infinite;
border-radius: 6px; height: 40px; margin-bottom: 8px;
}
.nf-actions-row { display: flex; gap: 8px; flex-wrap: wrap; }
.nf-core-info { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 14px; }
.nf-core-info .nf-info-item { padding: 10px; background: var(--nf-bg); border-radius: 8px; }
.nf-select {
width: 100%; padding: 8px 12px; border: 1px solid var(--nf-border);
border-radius: 8px; background: var(--nf-bg); color: var(--nf-text);
font-size: 13px; outline: none; cursor: pointer; box-sizing: border-box;
-webkit-appearance: none; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238a94a6' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 10px center;
}
.nf-select:focus { border-color: var(--nf-primary); }
.nf-select option { background: var(--nf-card); color: var(--nf-text); }
.nf-progress-wrap { margin-top: 12px; }
.nf-progress-bar-outer {
width: 100%; height: 8px; background: var(--nf-bg); border-radius: 4px; overflow: hidden;
}
.nf-progress-bar-inner {
height: 100%; background: var(--nf-primary); border-radius: 4px;
transition: width 0.3s ease; width: 0%;
}
.nf-progress-bar-inner.done { background: var(--nf-success); }
.nf-progress-bar-inner.error { background: var(--nf-danger); }
.nf-progress-text { font-size: 12px; color: var(--nf-text-dim); margin-top: 6px; }
.nf-core-update-banner {
margin: 0 16px 12px 16px;
padding: 10px 12px;
border-radius: 8px;
background: rgba(255, 152, 0, 0.12);
border: 1px solid rgba(255, 152, 0, 0.35);
font-size: 13px;
color: var(--nf-text);
line-height: 1.45;
}
.nf-core-update-banner.nf-hidden { display: none !important; }
.nf-core-banner-main { display: flex; align-items: flex-start; gap: 2px; flex-wrap: wrap; }
.nf-core-banner-dot {
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
background: var(--nf-warning); margin-top: 5px; flex-shrink: 0;
}
.nf-core-banner-sub { margin-top: 6px; font-size: 12px; color: var(--nf-text-dim); }
.nf-mode-desc {
font-size: 12px; color: var(--nf-text-dim); margin-top: 8px; transition: opacity 0.2s;
}
.nf-mode-desc.switching { opacity: 0.5; }
.nf-combined-card {
background: var(--nf-card);
border: 1px solid var(--nf-border);
border-radius: var(--nf-radius);
margin-bottom: 16px;
overflow: hidden;
}
.nf-combined-card .nf-toggle-row {
display: flex;
border-bottom: 1px solid var(--nf-border);
}
.nf-combined-card .nf-toggle-row .nf-tun-toggle {
flex: 1; margin: 0; border: none; border-radius: 0;
}
.nf-combined-card .nf-toggle-row .nf-tun-toggle + .nf-tun-toggle {
border-left: 1px solid var(--nf-border);
}
.nf-combined-card .nf-run-mode-section {
padding: 14px 18px;
}
.nf-proxy-mode-bar {
display: flex;
border-bottom: 1px solid var(--nf-border);
}
.nf-proxy-mode-bar .nf-proxy-mode-tab {
flex: 1; text-align: center; padding: 12px 0; font-size: 13px; font-weight: 500;
cursor: pointer; transition: all 0.15s; color: var(--nf-text-dim); background: transparent;
border: none; outline: none;
}
.nf-proxy-mode-bar .nf-proxy-mode-tab + .nf-proxy-mode-tab {
border-left: 1px solid var(--nf-border);
}
.nf-proxy-mode-bar .nf-proxy-mode-tab:hover:not(.active) { background: var(--nf-card-hover); color: var(--nf-text); }
.nf-proxy-mode-bar .nf-proxy-mode-tab.active { background: var(--nf-primary); color: #fff; }
.nf-proxy-mode-bar .nf-proxy-mode-tab.loading { opacity: 0.7; pointer-events: none; }
.nf-combined-card .nf-nodes-inner { padding: 14px 18px 18px; }
.nf-tun-toggle {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 18px; background: var(--nf-card); border: 1px solid var(--nf-border);
border-radius: var(--nf-radius); margin-bottom: 16px; transition: all 0.2s ease;
}
.nf-tun-label { font-size: 14px; font-weight: 600; color: var(--nf-text); }
.nf-tun-desc { font-size: 12px; color: var(--nf-text-dim); margin-top: 2px; }
.nf-lan-proxy-hint { margin-top: 10px; padding: 10px 12px; border-radius: 8px; background: rgba(79, 142, 247, 0.08); border: 1px solid rgba(79, 142, 247, 0.15); }
.nf-lan-proxy-hint-inner { font-size: 12px; color: var(--nf-text-dim); line-height: 1.55; }
.nf-lan-proxy-hint code { font-size: 12px; color: var(--nf-primary); padding: 1px 6px; background: var(--nf-bg); border-radius: 4px; word-break: break-all; }
.nf-switch {
position: relative; width: 48px; height: 26px; background: var(--nf-border);
border-radius: 13px; cursor: pointer; transition: background 0.3s ease; flex-shrink: 0;
}
.nf-switch.on { background: var(--nf-primary); }
.nf-switch.disabled { opacity: 0.5; pointer-events: none; }
.nf-switch::after {
content: ''; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px;
background: #fff; border-radius: 50%; transition: transform 0.3s ease;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.nf-switch.on::after { transform: translateX(22px); }
@media (max-width: 900px) {
#nf-dashboard-section{ grid-template-columns: 1fr; }
.nf-stats-panel { grid-template-columns: repeat(2, 1fr); }
.nf-log-area { min-height: 150px; max-height: 300px; }
}
#netflow-app.nf-light {
--nf-bg: #f0f2f5; --nf-card: #ffffff; --nf-card-hover: #f5f5f5;
--nf-border: #d9dde3; --nf-text: #1a1a2e; --nf-text-dim: #666e80;
}
#netflow-app.nf-light .nf-node-type { color: #888; }
#netflow-app.nf-light .nf-node-item.active { background: rgba(79, 142, 247, 0.06); }
#netflow-app.nf-light .nf-node-item.preselected { background: rgba(255, 152, 0, 0.04); }
#netflow-app.nf-light .nf-status-bar { background: #fff; border-color: #e0e0e0; color: #1a1a2e; }
#netflow-app.nf-light .nf-status-bar.running { background: linear-gradient(135deg, #fff, rgba(76,175,80,0.08)); border-color: rgba(76,175,80,0.3); }
#netflow-app.nf-light .nf-status-bar.stopped { background: linear-gradient(135deg, #fff, rgba(244,67,54,0.08)); border-color: rgba(244,67,54,0.3); }
#netflow-app.nf-light .nf-status-bar.starting { background: linear-gradient(135deg, #fff, rgba(79,142,247,0.08)); border-color: rgba(79,142,247,0.3); }
#netflow-app.nf-light .nf-toast.success { background: #388e3c; }
#netflow-app.nf-light .nf-toast.error { background: #d32f2f; }
#netflow-app.nf-light .nf-toast.info { background: #1976d2; }
#netflow-app.nf-light .nf-group-tab.active { color: #fff; }
#netflow-app.nf-light .nf-group-tab.nf-compact {
background: #f5f7fa; border-color: #c8cdd5; color: #444;
}
#netflow-app.nf-light .nf-group-tab.nf-compact.active {
background: rgba(40, 167, 69, 0.12); border-color: #28a745; color: #1e7e34;
}
#netflow-app.nf-light .nf-mode-desc { color: #555; }
#netflow-app.nf-light .nf-lan-proxy-hint { background: rgba(25, 118, 210, 0.06); border-color: rgba(25, 118, 210, 0.2); }
#netflow-app.nf-light .nf-lan-proxy-hint code { background: #f0f2f5; color: #1565c0; }
#netflow-app.nf-light .nf-stat-item { background: #fff; }
#netflow-app.nf-light .nf-select { background-color: #fff; }
#netflow-app.nf-light .nf-select option { background: #fff; color: #1a1a2e; }
#netflow-app.nf-light .nf-btn-outline { background: #fff; }
@media (prefers-color-scheme: light) {
:root {
--nf-bg: #f0f2f5; --nf-card: #ffffff; --nf-card-hover: #f5f5f5;
--nf-border: #d9dde3; --nf-text: #1a1a2e; --nf-text-dim: #666e80;
}
}
</style>
<div id="netflow-app">
<div class="nf-header">
<h2>FastLink_Lite</h2>
<p id="nf-subtitle">Proxy Client</p>
</div>
<div id="nf-loading-screen">
<div class="nf-loading-spinner"></div>
<span>加载中...</span>
</div>
<div id="nf-login-section" class="nf-hidden">
<div class="nf-card">
<div class="nf-card-title">&#x1f511; 账户登录</div>
<div class="nf-form-group">
<label>邮箱</label>
<input type="email" id="nf-email" class="nf-input" placeholder="请输入邮箱">
</div>
<div class="nf-form-group">
<label>密码</label>
<input type="password" id="nf-password" class="nf-input" placeholder="请输入密码">
</div>
<button class="nf-btn nf-btn-primary nf-btn-block" id="nf-login-btn" onclick="nfLogin()">登 录</button>
</div>
</div>
<div id="nf-dashboard-section" class="nf-hidden">
<div class="nf-stats-panel" id="nf-stats-panel">
<div class="nf-stat-item">
<div class="nf-stat-label">上传</div>
<div class="nf-stat-value inactive" id="nf-stat-up-speed">- -</div>
</div>
<div class="nf-stat-item">
<div class="nf-stat-label">下载</div>
<div class="nf-stat-value inactive" id="nf-stat-down-speed">- -</div>
</div>
<div class="nf-stat-item">
<div class="nf-stat-label">上传总量</div>
<div class="nf-stat-value inactive" id="nf-stat-up-total">- -</div>
</div>
<div class="nf-stat-item">
<div class="nf-stat-label">下载总量</div>
<div class="nf-stat-value inactive" id="nf-stat-down-total">- -</div>
</div>
<div class="nf-stat-item">
<div class="nf-stat-label">活动连接</div>
<div class="nf-stat-value inactive" id="nf-stat-connections">- -</div>
</div>
<div class="nf-stat-item">
<div class="nf-stat-label">内存占用</div>
<div class="nf-stat-value inactive" id="nf-stat-memory">- -</div>
</div>
<div class="nf-stat-item">
<div class="nf-stat-label">CPU 占用</div>
<div class="nf-stat-value inactive" id="nf-stat-cpu">- -</div>
</div>
<div class="nf-stat-item">
<div class="nf-stat-label">系统负载</div>
<div class="nf-stat-value inactive" id="nf-stat-load">- -</div>
</div>
</div>
<div class="nf-col-left">
<div id="nf-proxy-status-bar" class="nf-status-bar stopped">
<div id="nf-status-dot" class="nf-status-dot off"></div>
<span id="nf-status-text" class="nf-status-text">代理未运行</span>
<span id="nf-status-info" class="nf-status-info"></span>
</div>
<div class="nf-toggle-wrap">
<button class="nf-btn nf-btn-success" id="nf-start-btn" onclick="nfStartProxy()">启用代理</button>
<button class="nf-btn nf-btn-danger" id="nf-stop-btn" onclick="nfStopProxy()">停止代理</button>
</div>
<!-- LAN Proxy Info Bar -->
<div id="nf-lan-info-bar" class="nf-hidden" style="grid-column:1/-1;display:flex;align-items:center;gap:10px;padding:8px 16px;background:var(--nf-card);border:1px solid var(--nf-border);border-radius:8px;margin-bottom:4px;font-size:13px;">
<span style="color:var(--nf-text-dim);white-space:nowrap;">&#x1f310; 局域网代理</span>
<code id="nf-lan-url" style="flex:1;font-size:13px;color:var(--nf-primary);padding:2px 8px;background:var(--nf-bg);border-radius:4px;cursor:pointer;user-select:all;" onclick="nfCopyLanUrl()" title="点击复制"></code>
<button class="nf-btn nf-btn-sm nf-btn-outline" onclick="nfCopyLanUrl()" style="flex-shrink:0;">复制</button>
</div> <div class="nf-combined-card" id="nf-settings-card">
<div class="nf-toggle-row">
<div class="nf-tun-toggle" id="nf-lan-proxy-toggle">
<div>
<div class="nf-tun-label">局域网代理</div>
<div class="nf-tun-desc" id="nf-lan-proxy-desc">开启后局域网设备流量走代理</div>
<div class="nf-lan-proxy-hint nf-hidden" id="nf-lan-proxy-hint">
<div class="nf-lan-proxy-hint-inner">
局域网地址 <code id="nf-lan-router-ip"></code>,端口 <code id="nf-lan-mixed-port">7890</code>
</div></div>
</div>
<div class="nf-switch" id="nf-lan-proxy-switch" onclick="nfSwitchLANProxy()"></div>
</div>
<div class="nf-tun-toggle" id="nf-ipv6-proxy-toggle">
<div>
<div class="nf-tun-label">IPv6 代理</div>
<div class="nf-tun-desc" id="nf-ipv6-proxy-desc">开启后 IPv6 流量也走代理(需节点支持)</div>
</div>
<div class="nf-switch" id="nf-ipv6-proxy-switch" onclick="nfSwitchIPv6Proxy()"></div>
</div>
</div>
<div class="nf-run-mode-section" id="nf-run-mode-card">
<div style="display:flex; align-items:center; gap:10px; flex-wrap:wrap;">
<span style="font-size:13px; font-weight:500; color:var(--nf-text-dim); white-space:nowrap;">&#x26A1; 运行模式</span>
<div class="nf-group-tabs" id="nf-run-mode-tabs" style="margin-bottom:0;">
<div class="nf-group-tab" id="nf-rm-redir-host" onclick="nfSwitchRunMode('redir-host')">增强</div>
<div class="nf-group-tab" id="nf-rm-tun" onclick="nfSwitchRunMode('tun')">TUN</div>
<div class="nf-group-tab" id="nf-rm-mixed" onclick="nfSwitchRunMode('mixed')">混合</div>
<button type="button" class="nf-group-tab nf-compact" id="nf-rm-compat" onclick="nfSwitchRunMode('fake-ip')">标准兼容</button>
</div>
</div>
<div class="nf-mode-desc" id="nf-run-mode-desc"></div>
</div>
</div>
<div class="nf-col-right">
<div class="nf-card" id="nf-core-card">
<div class="nf-card-title" style="justify-content:space-between;">
<span>&#x2699; 核心管理</span>
<div style="display:flex;gap:4px;">
<button class="nf-btn nf-btn-sm nf-btn-outline" onclick="nfViewConfig()" title="查看运行时配置">配置</button>
<button class="nf-btn nf-btn-sm nf-btn-outline" onclick="nfShowProviders()" title="查看 Provider">Provider</button>
<button class="nf-btn nf-btn-sm nf-btn-primary" id="nf-core-check-btn" onclick="nfCheckCoreUpdate()">检查更新</button>
</div>
</div>
<div id="nf-core-update-banner" class="nf-core-update-banner nf-hidden" role="status"></div>
<div class="nf-core-info">
<div class="nf-info-item">
<div class="nf-info-label">核心状态</div>
<div class="nf-info-value" id="nf-core-status">检测中...</div>
</div>
<div class="nf-info-item">
<div class="nf-info-label">设备架构</div>
<div class="nf-info-value" id="nf-core-arch">-</div>
</div>
<div class="nf-info-item">
<div class="nf-info-label">当前版本</div>
<div class="nf-info-value" id="nf-core-version">-</div>
</div>
<div class="nf-info-item">
<div class="nf-info-label">最新版本</div>
<div class="nf-info-value" id="nf-core-latest">-</div>
</div>
</div>
<div class="nf-form-group" style="margin-bottom:10px;">
<label>下载源</label>
<select class="nf-select" id="nf-core-source">
<option value="github">GitHub 官方</option>
</select>
</div>
<div style="display:flex;gap:8px;margin-bottom:10px;">
<button class="nf-btn nf-btn-primary" style="flex:1;" id="nf-core-download-btn" onclick="nfDownloadCore()">下载 / 更新核心</button>
<button class="nf-btn nf-btn-outline" id="nf-core-upload-btn" onclick="document.getElementById('nf-core-file-input').click()">上传核心</button>
<input type="file" id="nf-core-file-input" style="display:none;" accept=".gz,application/gzip,application/octet-stream" onchange="nfUploadCore(this)">
</div>
<div class="nf-progress-wrap nf-hidden" id="nf-core-progress-wrap">
<div class="nf-progress-bar-outer">
<div class="nf-progress-bar-inner" id="nf-core-progress-bar"></div>
</div>
<div class="nf-progress-text" id="nf-core-progress-text"></div>
</div>
</div>
<div class="nf-card">
<div class="nf-card-title">&#x1f464; 账户信息</div>
<div class="nf-user-info">
<div class="nf-info-item">
<div class="nf-info-label">邮箱</div>
<div class="nf-info-value" id="nf-user-email">-</div>
</div>
<div class="nf-info-item">
<div class="nf-info-label">到期时间</div>
<div class="nf-info-value" id="nf-user-expire">-</div>
</div>
<div class="nf-info-item">
<div class="nf-info-label">已用流量</div>
<div class="nf-info-value" id="nf-user-used">-</div>
</div>
<div class="nf-info-item">
<div class="nf-info-label">总流量</div>
<div class="nf-info-value" id="nf-user-total">-</div>
</div>
</div>
<div id="nf-last-sub-time" style="font-size:12px;color:var(--nf-text-dim);margin-bottom:10px;padding:0 2px;"></div>
<div class="nf-actions-row">
<button class="nf-btn nf-btn-primary nf-btn-sm" id="nf-sub-btn" onclick="nfSubscribe()">更新订阅</button>
<button class="nf-btn nf-btn-primary nf-btn-sm" id="nf-geo-btn" onclick="nfUpdateGeo()" style="background:var(--nf-warning);">更新规则库</button>
<button class="nf-btn nf-btn-primary nf-btn-sm nf-hidden" id="nf-website-btn" onclick="nfOpenWebsite()" style="background:#6366f1;">官方网站</button>
<button class="nf-btn nf-btn-outline nf-btn-sm" onclick="nfBackupConfig()">备份</button>
<button class="nf-btn nf-btn-outline nf-btn-sm" onclick="nfListBackups()">恢复</button>
<button class="nf-btn nf-btn-danger nf-btn-sm" onclick="nfLogout()">退出登录</button>
</div>
</div>
<!-- DNS 配置卡片 -->
<div class="nf-card" id="nf-dns-card">
<div class="nf-card-title" style="justify-content:space-between;">
<span>&#x1f310; DNS 配置</span>
<button class="nf-btn nf-btn-sm nf-btn-outline" onclick="nfDnsToggle()" id="nf-dns-expand-btn">展开</button>
</div>
<div id="nf-dns-body" class="nf-hidden">
<div class="nf-form-group">
<label>上游 DNS(逗号分隔)</label>
<input type="text" id="nf-dns-nameserver" class="nf-input" placeholder="tls://1.1.1.1, https://dns.google/dns-query">
</div>
<div class="nf-form-group">
<label>Fallback DNS</label>
<input type="text" id="nf-dns-fallback" class="nf-input" placeholder="tls://8.8.8.8">
</div>
<div class="nf-form-group">
<label>Fake IP 范围</label>
<input type="text" id="nf-dns-fakeip" class="nf-input" placeholder="198.18.0.1/16">
</div>
<div class="nf-form-group">
<label>自定义 Hosts(每行一条: IP 域名)</label>
<textarea id="nf-dns-hosts" class="nf-input" rows="3" placeholder="10.0.0.1 my.local" style="font-family:monospace;font-size:12px;"></textarea>
</div>
<button class="nf-btn nf-btn-sm nf-btn-success nf-btn-block" onclick="nfDnsSave()">保存 DNS 配置</button>
</div>
</div>
<div class="nf-card" id="nf-rules-card">
<div class="nf-card-title" style="justify-content:space-between;">
<span>&#x1f4dd; 自定义规则</span>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
<button class="nf-btn nf-btn-sm nf-btn-outline" onclick="nfShowImport()" title="批量导入">导入</button>
<button class="nf-btn nf-btn-sm nf-btn-outline" onclick="nfShowTemplates()" title="模板">模板</button>
<button class="nf-btn nf-btn-sm nf-btn-primary" id="nf-rule-submit-btn" onclick="nfAddOrUpdateRule()">添加规则</button>
<button class="nf-btn nf-btn-sm nf-btn-success" onclick="nfApplyRules()">应用</button>
</div>
</div>
<input type="hidden" id="nf-rule-edit-id" value="">
<div class="nf-form-group">
<label>规则类型</label>
<select class="nf-select" id="nf-rule-type">
<option value="DOMAIN-SUFFIX">DOMAIN-SUFFIX (域名后缀)</option>
<option value="DOMAIN">DOMAIN (精确域名)</option>
<option value="DOMAIN-KEYWORD">DOMAIN-KEYWORD (关键词)</option>
<option value="DOMAIN-REGEX">DOMAIN-REGEX (正则)</option>
<option value="IP-CIDR">IP-CIDR (IP 段)</option>
<option value="IP-CIDR6">IP-CIDR6 (IPv6)</option>
<option value="GEOSITE">GEOSITE (地理站点)</option>
<option value="GEOIP">GEOIP (地理 IP)</option>
<option value="SRC-IP-CIDR">SRC-IP-CIDR (源IP)</option>
<option value="DST-PORT">DST-PORT (目标端口)</option>
<option value="SRC-PORT">SRC-PORT (源端口)</option>
<option value="PROCESS-NAME">PROCESS-NAME (进程名)</option>
<option value="RULE-SET">RULE-SET (规则集)</option>
<option value="IP-SUFFIX">IP-SUFFIX (IP 后缀)</option>
<option value="SRC-IP-SUFFIX">SRC-IP-SUFFIX (源IP后缀)</option>
<option value="IP-ASN">IP-ASN (ASN)</option>
<option value="SRC-IP-ASN">SRC-IP-ASN (源ASN)</option>
<option value="SRC-GEOIP">SRC-GEOIP (源GEOIP)</option>
<option value="PROCESS-PATH">PROCESS-PATH (进程路径)</option>
<option value="PROCESS-NAME-REGEX">PROCESS-NAME-REGEX (进程正则)</option>
<option value="PROCESS-PATH-REGEX">PROCESS-PATH-REGEX (路径正则)</option>
<option value="NETWORK">NETWORK (网络类型)</option>
<option value="UID">UID (用户ID)</option>
<option value="IN-PORT">IN-PORT (入站端口)</option>
<option value="IN-TYPE">IN-TYPE (入站类型)</option>
<option value="IN-USER">IN-USER (入站用户)</option>
<option value="IN-NAME">IN-NAME (入站名称)</option>
<option value="DSCP">DSCP</option>
<option value="SUB-RULE">SUB-RULE (子规则)</option>
<option value="AND">AND (逻辑与)</option>
<option value="OR">OR (逻辑或)</option>
<option value="NOT">NOT (逻辑非)</option>
<option value="MATCH">MATCH (全匹配)</option>
</select>
</div>
<div class="nf-form-group">
<label>匹配值</label>
<input type="text" id="nf-rule-value" class="nf-input" placeholder="例如 google.com 或 1.2.3.0/24">
</div>
<div class="nf-form-group">
<label>目标策略组</label>
<div style="display:flex;gap:6px;">
<input type="text" id="nf-rule-policy" class="nf-input" placeholder="例如 Proxy / DIRECT / REJECT" list="nf-policy-datalist" style="flex:1;">
<datalist id="nf-policy-datalist">
<option value="DIRECT">
<option value="REJECT">
<option value="REJECT-DROP">
<option value="PASS">
</datalist>
</div>
</div>
<div style="display:flex;gap:6px;margin-bottom:8px;" id="nf-rule-edit-actions" class="nf-hidden">
<button class="nf-btn nf-btn-sm nf-btn-outline" onclick="nfCancelEdit()">取消编辑</button>
</div>
<!-- 批量导入框 -->
<div id="nf-import-box" class="nf-hidden" style="margin-bottom:8px;">
<div class="nf-form-group">
<label>每行一条规则,格式:类型,匹配值,策略组 或 匹配值,策略组(默认DOMAIN-SUFFIX)</label>
<textarea id="nf-import-text" class="nf-input" rows="6" placeholder="DOMAIN-SUFFIX,google.com,Proxy&#10;youtube.com,Proxy&#10;DOMAIN-KEYWORD,baidu,DIRECT" style="font-family:monospace;font-size:12px;"></textarea>
</div>
<div style="display:flex;gap:6px;">
<button class="nf-btn nf-btn-sm nf-btn-primary" onclick="nfDoImport()">确认导入</button>
<button class="nf-btn nf-btn-sm nf-btn-outline" onclick="nfHideImport()">取消</button>
</div>
</div>
<!-- 模板选择框 -->
<div id="nf-template-box" class="nf-hidden" style="margin-bottom:8px;">
<div id="nf-template-list" style="max-height:200px;overflow-y:auto;"></div>
</div>
<!-- 规则列表 -->
<div id="nf-rules-list" style="max-height:260px;overflow-y:auto;margin-top:8px;">
<div style="text-align:center;color:var(--nf-text-dim);padding:10px;font-size:13px;">暂无自定义规则</div>
</div>
</div>
<!-- 流媒体解锁检测 -->
<div class="nf-card" id="nf-unlock-card">
<div class="nf-card-title" style="justify-content:space-between;">
<span>&#x1f3ac; 流媒体解锁</span>
<button class="nf-btn nf-btn-sm nf-btn-primary" id="nf-unlock-btn" onclick="nfCheckMediaUnlock()">检测全部</button>
</div>
<div id="nf-unlock-list" style="font-size:12px;max-height:300px;overflow-y:auto;">
<div style="text-align:center;color:var(--nf-text-dim);padding:10px;">点击"检测全部"开始</div>
</div>
</div>
<div class="nf-card" style="flex:1;display:flex;flex-direction:column;">
<div class="nf-card-title" style="justify-content:space-between;">
<span>&#x1f4cb; 实时日志</span>
<div style="display:flex;gap:6px;">
<button class="nf-btn nf-btn-sm nf-btn-primary" onclick="nfToggleLogPause()" id="nf-log-pause-btn">暂停</button>
<button class="nf-btn nf-btn-sm nf-btn-primary" onclick="nfClearLog()">清除</button>
</div>
</div>
<div style="display:flex;gap:6px;margin-bottom:8px;">
<div class="nf-group-tabs" id="nf-log-level-tabs" style="margin-bottom:0;">
<div class="nf-group-tab active" onclick="nfSetLogLevel('ALL')">全部</div>
<div class="nf-group-tab" onclick="nfSetLogLevel('INFO')">INFO</div>
<div class="nf-group-tab" onclick="nfSetLogLevel('WARN')">WARN</div>
<div class="nf-group-tab" onclick="nfSetLogLevel('ERROR')">ERROR</div>
<div class="nf-group-tab" onclick="nfSetLogLevel('DEBUG')">DEBUG</div>
</div>
<input type="text" id="nf-log-search" class="nf-input" style="flex:1;padding:4px 8px;font-size:11px;" placeholder="搜索日志..." oninput="nfRenderLogFilter()">
</div>
<div class="nf-log-area" id="nf-log-area" style="flex:1;">暂无日志</div>
</div>
</div>
<!-- Tab content wrappers -->
</div>
</div>
</div>
<div class="nf-combined-card" id="nf-connections-card" style="margin-top:0;grid-column:1/-1;">
<div class="nf-card-title" style="justify-content:space-between;padding:14px 18px 0;">
<span>&#x1f517; 活跃连接</span>
<div style="display:flex;gap:6px;">
<button class="nf-btn nf-btn-sm nf-btn-primary" onclick="nfRefreshConnections()">刷新</button>
<button class="nf-btn nf-btn-sm nf-btn-danger" onclick="nfCloseAllConnections()">关闭全部</button>
</div>
</div>
<div style="padding:10px 18px;">
<div style="display:flex;gap:6px;margin-bottom:8px;">
<input type="text" id="nf-conn-search" class="nf-input" style="flex:1;padding:6px 10px;font-size:12px;" placeholder="搜索主机/规则...">
<select class="nf-select" id="nf-conn-sort" style="width:auto;padding:4px 8px;font-size:12px;" onchange="nfRenderConnections()">
<option value="default">默认</option>
<option value="dl">下载速度</option>
<option value="ul">上传速度</option>
</select>
</div>
<div style="display:flex;gap:8px;font-size:12px;color:var(--nf-text-dim);margin-bottom:6px;" id="nf-conn-total"></div>
<div id="nf-conn-list" style="max-height:280px;overflow-y:auto;font-size:12px;">
<div style="text-align:center;color:var(--nf-text-dim);padding:20px;">点击刷新获取连接数据</div>
</div>
</div>
</div>
</div>
<div id="nf-toast" class="nf-toast"></div>
</div>
<script>
(function() {
var apiUrl = '<%=luci.dispatcher.build_url("admin", "services", "netflow", "api")%>';
var nodesData = null;
var currentGroup = '';
var refreshTimer = null;
var nodesRefreshTimer = null;
var logTimer = null;
var statsTimer = null;
var proxyRunning = false;
var currentMode = 'rule';
var tunEnabled = false;
var cachedGroups = null;
var delayResults = {};
var toastTimer = null;
var btnDebounce = {};
var lanProxyEnabled = true;
var ipv6ProxyEnabled = false;
var coreProgressTimer = null;
var websiteURL = '';
function apiCall(action, params, callback) {
var xhr = new XMLHttpRequest();
var body = 'action=' + encodeURIComponent(action);
if (params) {
for (var k in params) {
if (!Object.prototype.hasOwnProperty.call(params, k)) continue;
var pv = params[k];
if (pv === undefined || pv === null) continue;
body += '&' + encodeURIComponent(k) + '=' + encodeURIComponent(String(pv));
}
}
xhr.open('POST', apiUrl, true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
var result = null;
try { result = JSON.parse(xhr.responseText); } catch(e) { result = {status:'error', message: xhr.responseText || '网络错误'}; }
if (callback) callback(result);
}
};
xhr.send(body);
}
function debounce(key, fn, delay) {
if (btnDebounce[key]) return;
btnDebounce[key] = true;
fn();
setTimeout(function() { delete btnDebounce[key]; }, delay || 500);
}
function showToast(msg, type) {
var t = document.getElementById('nf-toast');
t.textContent = msg;
t.className = 'nf-toast ' + (type || 'info') + ' show';
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(function() { t.className = 'nf-toast'; }, 3000);
}
function formatBytes(bytes) {
if (bytes === undefined || bytes === null || bytes < 0) return '0 B';
if (bytes === 0) return '0 B';
var units = ['B', 'KB', 'MB', 'GB', 'TB'];
var i = Math.floor(Math.log(bytes) / Math.log(1024));
if (i >= units.length) i = units.length - 1;
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + units[i];
}
function formatSpeed(bytesPerSec) {
if (!bytesPerSec || bytesPerSec <= 0) return '0 B/s';
var units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
var i = Math.floor(Math.log(bytesPerSec) / Math.log(1024));
if (i >= units.length) i = units.length - 1;
return (bytesPerSec / Math.pow(1024, i)).toFixed(1) + ' ' + units[i];
}
function formatTimestamp(ts) {
if (!ts || ts <= 0) return '永久';
var d = new Date(ts * 1000);
return d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0');
}
function setBtnLoading(btn, loading) {
if (loading) {
btn.disabled = true;
btn._origText = btn.textContent;
btn.innerHTML = '<span class="nf-loading"></span>处理中...';
} else {
btn.disabled = false;
btn.textContent = btn._origText || btn.textContent;
}
}
function startLogPolling(fast) {
stopLogPolling();
var interval = fast ? 1000 : 3000;
refreshLog();
logTimer = setInterval(refreshLog, interval);
}
function stopLogPolling() {
if (logTimer) { clearInterval(logTimer); logTimer = null; }
}
function startStatsPolling() {
stopStatsPolling();
refreshStats();
statsTimer = setInterval(refreshStats, 3000);
}
function stopStatsPolling() {
if (statsTimer) { clearInterval(statsTimer); statsTimer = null; }
}
/* --- Actions --- */
window.nfLogin = function() {
var email = document.getElementById('nf-email').value.trim();
var password = document.getElementById('nf-password').value;
if (!email || !password) { showToast('请输入邮箱和密码', 'error'); return; }
var btn = document.getElementById('nf-login-btn');
if (btn.disabled) return;
setBtnLoading(btn, true);
apiCall('login', {email: email, password: password}, function(res) {
setBtnLoading(btn, false);
if (res.status === 'success') {
showToast('登录成功', 'success');
showDashboard({email: res.email || email, website_url: res.website_url});
setTimeout(function() { refreshDashboardStatus(); }, 5000);
} else {
showToast(res.message || '登录失败', 'error');
}
});
};
window.nfLogout = function() {
apiCall('logout', null, function() {
showToast('已退出', 'info');
showLogin();
});
};
window.nfSubscribe = function() {
debounce('subscribe', function() {
var btn = document.getElementById('nf-sub-btn');
setBtnLoading(btn, true);
startLogPolling(true);
apiCall('subscribe', null, function(res) {
setBtnLoading(btn, false);
startLogPolling(false);
if (res.status === 'success') {
showToast('订阅获取成功', 'success');
var subTimeEl = document.getElementById('nf-last-sub-time');
if (subTimeEl) {
var now = new Date();
var pad = function(n) { return n < 10 ? '0' + n : n; };
subTimeEl.textContent = '上次订阅更新: ' + now.getFullYear() + '-' + pad(now.getMonth()+1) + '-' + pad(now.getDate()) + ' ' + pad(now.getHours()) + ':' + pad(now.getMinutes()) + ':' + pad(now.getSeconds());
}
refreshNodes();
} else {
showToast(res.message || '订阅更新失败', 'error');
}
});
});
};
window.nfStartProxy = function() {
debounce('start', function() {
var btn = document.getElementById('nf-start-btn');
setBtnLoading(btn, true);
var bar = document.getElementById('nf-proxy-status-bar');
var dot = document.getElementById('nf-status-dot');
var text = document.getElementById('nf-status-text');
bar.className = 'nf-status-bar starting';
dot.className = 'nf-status-dot pending';
text.textContent = '正在启动...';
startLogPolling(true);
apiCall('proxy_start', null, function(res) {
setBtnLoading(btn, false);
if (res.status === 'success') {
showToast('代理启动中,等待就绪...', 'info');
text.textContent = '正在等待就绪...';
var pollCount = 0;
var maxPolls = 20;
var pollInterval = setInterval(function() {
pollCount++;
apiCall('nodes', null, function(nodeRes) {
if (nodeRes.status === 'success' && nodeRes.source === 'api' && nodeRes.data && nodeRes.data.proxies) {
clearInterval(pollInterval);
showToast('代理已启动', 'success');
proxyRunning = true;
refreshDashboardStatus();
refreshNodes();
startLogPolling(false);
startStatsPolling();
} else if (pollCount >= maxPolls) {
clearInterval(pollInterval);
showToast('代理启动超时,请检查日志', 'error');
startLogPolling(false);
refreshDashboardStatus();
refreshNodes();
}
});
}, 2000);
} else {
showToast(res.message || '启动失败', 'error');
startLogPolling(false);
refreshDashboardStatus();
}
});
}, 2000);
};
window.nfStopProxy = function() {
debounce('stop', function() {
var btn = document.getElementById('nf-stop-btn');
setBtnLoading(btn, true);
var bar = document.getElementById('nf-proxy-status-bar');
var dot = document.getElementById('nf-status-dot');
var text = document.getElementById('nf-status-text');
bar.className = 'nf-status-bar starting';
dot.className = 'nf-status-dot pending';
text.textContent = '正在停止...';
startLogPolling(true);
apiCall('proxy_stop', null, function(res) {
setBtnLoading(btn, false);
showToast('代理已停止', 'info');
proxyRunning = false;
delayResults = {};
startLogPolling(false);
stopStatsPolling();
resetStatsUI();
refreshDashboardStatus();
refreshNodes();
});
}, 2000);
};
window.nfSelectNode = function(group, node, el) {
if (cachedGroups) {
for (var gi = 0; gi < cachedGroups.length; gi++) {
if (cachedGroups[gi].name !== group) continue;
if (!isManualSelectorGroup(cachedGroups[gi].type)) {
showToast('该组为「' + policyGroupHint(cachedGroups[gi].type) + '」类型,由核心自动调度,无法手动固定', 'info');
return;
}
break;
}
}
var items = document.querySelectorAll('#nf-node-list .nf-node-item');
for (var i = 0; i < items.length; i++) {
items[i].classList.remove('active', 'preselected');
}
if (el) el.classList.add(proxyRunning ? 'active' : 'preselected', 'switching');
apiCall('select_node', {group: group, node: node}, function(res) {
if (el) el.classList.remove('switching');
if (res.status === 'success') {
showToast('已切换到: ' + node, 'success');
// 与核心同步「当前节点」:原先只改点击项 DOMcachedGroups/nodesData 仍是旧的 now
// 切换分组或状态栏会错,必须整表按 group.now 重绘(与刷新页面效果一致)
if (cachedGroups) {
for (var gii = 0; gii < cachedGroups.length; gii++) {
if (cachedGroups[gii].name === group) {
cachedGroups[gii].now = node;
break;
}
}
}
if (nodesData && nodesData[group]) {
nodesData[group].now = node;
}
if (cachedGroups) {
var fgOk = filterGroupsByMode(cachedGroups);
renderNodeList(fgOk);
updateStatusBarNode(fgOk);
}
setTimeout(function() { refreshNodes(); }, 200);
} else {
showToast(res.message || '切换失败', 'error');
refreshNodes();
}
});
};
function updateNodeDots() {
var items = document.querySelectorAll('#nf-node-list .nf-node-item');
for (var i = 0; i < items.length; i++) {
var dot = items[i].querySelector('.nf-node-active-dot, .nf-node-preselect-dot');
if (items[i].classList.contains('active')) {
if (!dot || dot.className !== 'nf-node-active-dot') {
if (dot) dot.remove();
var d = document.createElement('span'); d.className = 'nf-node-active-dot';
items[i].insertBefore(d, items[i].firstChild);
}
} else if (items[i].classList.contains('preselected')) {
if (!dot || dot.className !== 'nf-node-preselect-dot') {
if (dot) dot.remove();
var d = document.createElement('span'); d.className = 'nf-node-preselect-dot';
items[i].insertBefore(d, items[i].firstChild);
}
} else {
if (dot) dot.remove();
}
}
}
var modeDescs = {
'rule': '规则模式:根据规则分流,国内直连、国外代理',
'global': '全局模式:所有流量都通过代理',
'direct': '直连模式:所有流量都直连,不走代理'
};
window.nfSwitchMode = function(mode) {
if (mode === currentMode) return;
debounce('mode_' + mode, function() {
var prevMode = currentMode;
updateModeUI(mode);
setModeLoading(mode, true);
var descEl = document.getElementById('nf-mode-desc');
if (descEl) descEl.classList.add('switching');
apiCall('switch_mode', {mode: mode}, function(res) {
setModeLoading(mode, false);
if (descEl) descEl.classList.remove('switching');
if (res.status === 'success') {
currentMode = mode;
delayResults = {};
showToast(res.message || '模式已切换', 'success');
var nodesInner = document.querySelector('#nf-nodes-card .nf-nodes-inner');
var statusInfo = document.getElementById('nf-status-info');
if (mode === 'direct') {
if (nodesInner) nodesInner.classList.add('nf-hidden');
if (statusInfo && proxyRunning) statusInfo.textContent = '直连模式';
} else {
if (nodesInner) nodesInner.classList.remove('nf-hidden');
if (cachedGroups) {
var groups = filterGroupsByMode(cachedGroups);
var gNames = groups.map(function(g) { return g.name; });
if (!currentGroup || gNames.indexOf(currentGroup) < 0) currentGroup = gNames[0] || '';
renderGroupTabs(groups);
renderNodeList(groups);
updateStatusBarNode(groups);
} else { refreshNodes(); }
}
} else {
updateModeUI(prevMode);
showToast(res.message || '切换失败', 'error');
}
});
}, 300);
};
function setModeLoading(mode, loading) {
var el = document.getElementById('nf-mode-' + mode);
if (el) { if (loading) el.classList.add('loading'); else el.classList.remove('loading'); }
}
function updateModeUI(mode) {
var modes = ['rule', 'global', 'direct'];
for (var i = 0; i < modes.length; i++) {
var el = document.getElementById('nf-mode-' + modes[i]);
if (el) { if (modes[i] === mode) el.classList.add('active'); else el.classList.remove('active'); }
}
var desc = document.getElementById('nf-mode-desc');
if (desc) desc.textContent = modeDescs[mode] || '';
}
window.nfTestDelay = function() {
if (!proxyRunning || !currentGroup) { showToast('请先启动代理', 'error'); return; }
debounce('delay', function() {
var btn = document.getElementById('nf-delay-btn');
setBtnLoading(btn, true);
var delayEls = document.querySelectorAll('#nf-node-list .nf-node-delay');
for (var i = 0; i < delayEls.length; i++) { delayEls[i].className = 'nf-node-delay testing'; delayEls[i].textContent = '测速中'; }
apiCall('test_delay', {group: currentGroup}, function(res) {
setBtnLoading(btn, false);
if (res.status === 'success' && res.data) {
delayResults = res.data;
applyDelayResults();
showToast('测速完成', 'success');
} else {
showToast(res.message || '测速失败', 'error');
var els = document.querySelectorAll('#nf-node-list .nf-node-delay');
for (var j = 0; j < els.length; j++) { els[j].className = 'nf-node-delay'; els[j].textContent = '-'; }
}
});
}, 3000);
};
function applyDelayResults() {
var items = document.querySelectorAll('#nf-node-list .nf-node-item');
for (var i = 0; i < items.length; i++) {
var name = items[i].getAttribute('data-node');
var delayEl = items[i].querySelector('.nf-node-delay');
if (!delayEl || !name) continue;
var delay = delayResults[name];
if (delay && delay > 0) {
delayEl.textContent = delay + 'ms';
if (delay < 200) delayEl.className = 'nf-node-delay fast';
else if (delay < 500) delayEl.className = 'nf-node-delay medium';
else delayEl.className = 'nf-node-delay slow';
} else if (delay === 0 || (delayResults.hasOwnProperty && delayResults.hasOwnProperty(name))) {
delayEl.textContent = '超时'; delayEl.className = 'nf-node-delay slow';
} else { delayEl.textContent = '-'; delayEl.className = 'nf-node-delay'; }
}
}
window.nfUpdateGeo = function() {
debounce('geo', function() {
var btn = document.getElementById('nf-geo-btn');
setBtnLoading(btn, true);
startLogPolling(true);
apiCall('update_geodata', null, function(res) {
setBtnLoading(btn, false);
startLogPolling(false);
if (res.status === 'success') showToast(res.message || 'GeoData 已更新', 'success');
else showToast(res.message || '更新失败', 'error');
});
});
};
window.nfClearLog = function() {
apiCall('clear_log', null, function() {
document.getElementById('nf-log-area').innerHTML = '<span style="color:var(--nf-text-dim)">日志已清除</span>';
showToast('日志已清除', 'info');
});
};
window.nfSwitchTun = function() {
var sw = document.getElementById('nf-tun-switch');
if (sw) sw.classList.add('disabled');
apiCall('switch_tun', null, function(res) {
if (sw) sw.classList.remove('disabled');
if (res.status === 'success') {
tunEnabled = (res.tun_enabled === '1');
updateTunUI();
showToast(res.message, res.need_restart ? 'info' : 'success');
} else { showToast(res.message || '切换失败', 'error'); }
});
};
var currentRunMode = 'fake-ip';
var runModeDescs = {
'fake-ip': '标准兼容:透明重定向 + Fake-IP DNS,接近多数 OpenClash 默认 Redir 行为(非独立「第四档运行模式」)。',
'redir-host': '增强:DNS 为 redir-host(真实 IP),对应 OpenClash「增强」;仍走 Redir 透明代理。',
'tun': 'TUN:虚拟网卡 + DNS 使用 redir-host,纯 TUN、不设 Redir 端口与透明重定向规则。',
'mixed': '混合:TUN + Fake-IP DNS + Redir 端口,并启用透明重定向(与 OpenClash「混合」常见组合一致)。'
};
window.nfSwitchRunMode = function(mode) {
if (mode === currentRunMode) return;
var tabs = document.querySelectorAll('#nf-run-mode-tabs .nf-group-tab');
for (var i = 0; i < tabs.length; i++) tabs[i].classList.add('disabled');
apiCall('switch_run_mode', {run_mode: mode}, function(res) {
for (var i = 0; i < tabs.length; i++) tabs[i].classList.remove('disabled');
if (res.status === 'success') {
currentRunMode = mode;
updateRunModeUI();
showToast(res.message || '运行模式已切换', 'success');
} else {
showToast(res.message || '切换失败', 'error');
}
});
};
function updateRunModeUI() {
var mainIds = {'redir-host': 'nf-rm-redir-host', 'tun': 'nf-rm-tun', 'mixed': 'nf-rm-mixed'};
for (var m in mainIds) {
var el = document.getElementById(mainIds[m]);
if (el) { if (m === currentRunMode) el.classList.add('active'); else el.classList.remove('active'); }
}
var compat = document.getElementById('nf-rm-compat');
if (compat) {
if (currentRunMode === 'fake-ip') compat.classList.add('active');
else compat.classList.remove('active');
}
var desc = document.getElementById('nf-run-mode-desc');
if (desc) desc.textContent = runModeDescs[currentRunMode] || '';
}
window.nfSwitchGroup = function(name) {
currentGroup = name;
delayResults = {};
if (cachedGroups) {
var groups = filterGroupsByMode(cachedGroups);
renderGroupTabs(groups);
renderNodeList(groups);
} else { refreshNodes(); }
};
/* --- Page State --- */
function showLogin() {
document.getElementById('nf-loading-screen').classList.add('nf-hidden');
document.getElementById('nf-dashboard-section').classList.add('nf-hidden');
document.getElementById('nf-dashboard-section').classList.remove('nf-fade-in');
var loginEl = document.getElementById('nf-login-section');
loginEl.classList.remove('nf-hidden');
setTimeout(function() { loginEl.classList.add('nf-fade-in'); }, 10);
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
if (nodesRefreshTimer) { clearInterval(nodesRefreshTimer); nodesRefreshTimer = null; }
stopLogPolling();
stopStatsPolling();
}
/** 首次进入控制台:无字段则显示占位符 */
function applyAccountPanelFull(data) {
if (!data) return;
document.getElementById('nf-user-email').textContent = data.email || '-';
document.getElementById('nf-user-expire').textContent = (data.expired_at !== undefined && data.expired_at !== null) ? formatTimestamp(data.expired_at) : '-';
document.getElementById('nf-user-used').textContent = (data.used_up !== undefined || data.used_down !== undefined) ? formatBytes((data.used_up || 0) + (data.used_down || 0)) : '-';
document.getElementById('nf-user-total').textContent = (data.transfer_enable !== undefined && data.transfer_enable !== null) ? formatBytes(data.transfer_enable) : '-';
var subTimeEl = document.getElementById('nf-last-sub-time');
if (subTimeEl) subTimeEl.textContent = data.last_subscribe ? '上次订阅更新: ' + data.last_subscribe : '';
}
/**
* 轮询 dashboard_status 时合并账户区:只更新响应里出现的字段,避免一次 API 失败把已有数据刷成「-」
* (此前 refreshDashboardStatus 从不写账户区,导致首次请求若缺流量/到期则永远不变)
*/
function mergeAccountPanelFromStatus(res) {
if (!res || res.status !== 'logged_in') return;
if (res.email) {
document.getElementById('nf-user-email').textContent = res.email;
}
if (Object.prototype.hasOwnProperty.call(res, 'expired_at')) {
document.getElementById('nf-user-expire').textContent = (res.expired_at !== undefined && res.expired_at !== null) ? formatTimestamp(res.expired_at) : '-';
}
if (Object.prototype.hasOwnProperty.call(res, 'used_up') || Object.prototype.hasOwnProperty.call(res, 'used_down')) {
document.getElementById('nf-user-used').textContent = formatBytes((res.used_up || 0) + (res.used_down || 0));
}
if (Object.prototype.hasOwnProperty.call(res, 'transfer_enable')) {
document.getElementById('nf-user-total').textContent = (res.transfer_enable !== undefined && res.transfer_enable !== null) ? formatBytes(res.transfer_enable) : '-';
}
if (res.last_subscribe) {
var subTimeEl = document.getElementById('nf-last-sub-time');
if (subTimeEl) subTimeEl.textContent = '上次订阅更新: ' + res.last_subscribe;
}
}
function showDashboard(data) {
document.getElementById('nf-loading-screen').classList.add('nf-hidden');
document.getElementById('nf-login-section').classList.add('nf-hidden');
document.getElementById('nf-login-section').classList.remove('nf-fade-in');
var dashEl = document.getElementById('nf-dashboard-section');
dashEl.classList.remove('nf-hidden');
setTimeout(function() { dashEl.classList.add('nf-fade-in'); }, 10);
if (data) {
applyAccountPanelFull(data);
updateWebsiteButton(data.website_url);
}
refreshDashboardStatus();
refreshNodes();
startLogPolling(false);
loadCoreStatus();
nfRefreshRules();
if (!refreshTimer) {
refreshTimer = setInterval(function() {
refreshDashboardStatus();
}, 10000);
}
if (!nodesRefreshTimer) {
nodesRefreshTimer = setInterval(function() {
if (proxyRunning && currentMode !== 'direct') { refreshNodes(); }
}, 30000);
}
}
function refreshDashboardStatus() {
apiCall('dashboard_status', null, function(res) {
if (res.status === 'not_logged_in') {
showLogin();
return;
}
var bar = document.getElementById('nf-proxy-status-bar');
var dot = document.getElementById('nf-status-dot');
var text = document.getElementById('nf-status-text');
var info = document.getElementById('nf-status-info');
var delayBtn = document.getElementById('nf-delay-btn');
mergeAccountPanelFromStatus(res);
if (res.running) {
proxyRunning = true;
bar.className = 'nf-status-bar running';
dot.className = 'nf-status-dot on';
text.textContent = '代理运行中';
if (currentMode === 'direct') {
info.textContent = '直连模式';
} else {
var barNode = res.current_node || '';
if (cachedGroups && currentGroup) {
var fg = filterGroupsByMode(cachedGroups);
for (var bi = 0; bi < fg.length; bi++) {
if (fg[bi].name === currentGroup) {
barNode = fg[bi].now || barNode;
break;
}
}
}
info.textContent = barNode;
}
document.getElementById('nf-start-btn').classList.add('nf-hidden');
document.getElementById('nf-stop-btn').classList.remove('nf-hidden');
if (delayBtn) delayBtn.style.display = '';
if (!statsTimer) startStatsPolling();
} else {
proxyRunning = false;
bar.className = 'nf-status-bar stopped';
dot.className = 'nf-status-dot off';
text.textContent = '代理未运行';
info.textContent = res.mihomo_found ? '' : '未找到 Mihomo';
document.getElementById('nf-start-btn').classList.remove('nf-hidden');
document.getElementById('nf-stop-btn').classList.add('nf-hidden');
if (delayBtn) delayBtn.style.display = 'none';
stopStatsPolling();
resetStatsUI();
if (res.has_subscription) {
var nodeListEl = document.getElementById('nf-node-list');
if (nodeListEl && nodeListEl.textContent.indexOf('请先获取订阅') >= 0) {
refreshNodes();
}
}
}
if (res.mode) {
var changed = (currentMode !== res.mode);
currentMode = res.mode;
updateModeUI(res.mode);
if (changed && cachedGroups) {
var groups = filterGroupsByMode(cachedGroups);
var gNames = groups.map(function(g) { return g.name; });
if (!currentGroup || gNames.indexOf(currentGroup) < 0) currentGroup = gNames[0] || '';
renderGroupTabs(groups);
renderNodeList(groups);
updateStatusBarNode(groups);
} else if (changed) { refreshNodes(); }
}
tunEnabled = (res.tun_enabled === '1');
updateTunUI();
if (res.run_mode) {
currentRunMode = res.run_mode;
updateRunModeUI();
}
if (res.lan_proxy !== undefined) {
lanProxyEnabled = (res.lan_proxy === '1');
updateLANProxyUI();
}
if (res.ipv6_proxy !== undefined) {
ipv6ProxyEnabled = (res.ipv6_proxy === '1');
updateIPv6ProxyUI();
}
mergeLANProxyHints(res);
applyCoreUpdateHint(res);
});
}
function nfEscapeHtml(s) {
if (s === undefined || s === null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/** 参考 OpenClash:在面板与核心卡片上提示「远端有新版本」(数据来自后端缓存 + 语义化版本比较) */
function applyCoreUpdateHint(res) {
if (!res) return;
var bar = document.getElementById('nf-core-update-banner');
var latestEl = document.getElementById('nf-core-latest');
var lat = res.core_latest || res.latest;
if (latestEl && lat) latestEl.textContent = lat;
if (!bar) return;
var has = !!(res.core_has_update || res.has_update);
var stale = !!res.core_update_cache_stale;
if (!has) {
bar.classList.add('nf-hidden');
bar.innerHTML = '';
return;
}
bar.classList.remove('nf-hidden');
var ver = nfEscapeHtml(lat || '未知');
var sub = stale ? '<div class="nf-core-banner-sub">远端版本信息可能已过期,服务将自动重新检测。</div>' : '';
bar.innerHTML = '<div class="nf-core-banner-main"><span class="nf-core-banner-dot" aria-hidden="true"></span><div>核心有新版本 <strong>' + ver + '</strong>,请在下方选择下载源后点击「下载 / 更新核心」。</div></div>' + sub;
}
function mergeLANProxyHints(res) {
var hint = document.getElementById('nf-lan-proxy-hint');
var ipEl = document.getElementById('nf-lan-router-ip');
var portEl = document.getElementById('nf-lan-mixed-port');
if (!hint) return;
if (ipEl) ipEl.textContent = res.lan_router_ip || '—';
if (portEl) portEl.textContent = res.lan_mixed_port || '7890';
if (res.lan_proxy === '1') {
hint.classList.remove('nf-hidden');
} else {
hint.classList.add('nf-hidden');
}
// Update LAN info bar (visible in all tabs)
var infoBar = document.getElementById('nf-lan-info-bar');
var urlEl = document.getElementById('nf-lan-url');
if (infoBar && urlEl) {
var ip = res.lan_router_ip || '—';
var port = res.lan_mixed_port || '7890';
if (res.lan_proxy === '1' && ip !== '—') {
urlEl.textContent = 'http://' + ip + ':' + port;
infoBar.classList.remove('nf-hidden');
} else if (res.lan_proxy === '1') {
urlEl.textContent = '端口 ' + port + '(等待 IP...';
infoBar.classList.remove('nf-hidden');
} else {
infoBar.classList.add('nf-hidden');
}
}
}
window.nfCopyLanUrl = function() {
var el = document.getElementById('nf-lan-url');
if (!el || el.textContent === '—') return;
var textarea = document.createElement('textarea');
textarea.value = el.textContent;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
showToast('已复制: ' + el.textContent, 'success');
};
function isGlobalGroup(name) { return name === 'GLOBAL' || name === 'global'; }
/** 仅 Selector(select) 可手动选节点;URLTest/Fallback/LoadBalance 由核心自动改「当前」 */
function isManualSelectorGroup(t) { return t === 'Selector'; }
function policyGroupHint(t) {
if (t === 'URLTest') return '自动测速';
if (t === 'Fallback') return '自动故障转移';
if (t === 'LoadBalance') return '负载均衡';
return '自动';
}
function filterGroupsByMode(groups) {
var filtered = [];
for (var i = 0; i < groups.length; i++) {
if (currentMode === 'global') { if (isGlobalGroup(groups[i].name)) filtered.push(groups[i]); }
else { if (!isGlobalGroup(groups[i].name)) filtered.push(groups[i]); }
}
return filtered;
}
function isPolicyProxyGroup(p) {
if (!p || !p.type) return false;
return p.type === 'Selector' || p.type === 'URLTest' || p.type === 'Fallback' || p.type === 'LoadBalance';
}
/** 按订阅 proxy-groups 顺序(后端 proxy_group_order)组 Tab,避免 Go JSON map 字典序导致 A-Z */
function buildPolicyGroupsList(nodesData, order) {
var allGroups = [];
var seen = {};
if (order && order.length) {
for (var oi = 0; oi < order.length; oi++) {
var name = order[oi];
var p = nodesData[name];
if (!p || !isPolicyProxyGroup(p)) continue;
allGroups.push({name: name, type: p.type, now: p.now, all: p.all || []});
seen[name] = true;
}
}
var rest = [];
for (var name in nodesData) {
if (seen[name]) continue;
var p = nodesData[name];
if (!isPolicyProxyGroup(p)) continue;
rest.push({name: name, type: p.type, now: p.now, all: p.all || []});
}
rest.sort(function(a, b) { return a.name.localeCompare(b.name); });
return allGroups.concat(rest);
}
function refreshNodes() {
var nodesInner = document.querySelector('#nf-nodes-card .nf-nodes-inner');
if (currentMode === 'direct') {
if (nodesInner) nodesInner.classList.add('nf-hidden');
return;
}
if (nodesInner) nodesInner.classList.remove('nf-hidden');
apiCall('nodes', null, function(res) {
if (res.status !== 'success') {
document.getElementById('nf-node-list').innerHTML = '<div style="text-align:center;color:var(--nf-text-dim);padding:20px;grid-column:1/-1;">' + escapeHtml(res.message || '暂无节点,请先获取订阅') + '</div>';
document.getElementById('nf-group-tabs').innerHTML = '';
return;
}
if (res.source === 'offline') {
document.getElementById('nf-node-list').innerHTML = '<div style="text-align:center;color:var(--nf-text-dim);padding:20px;grid-column:1/-1;">代理未运行,已有订阅数据。请启动代理后查看节点。</div>';
document.getElementById('nf-group-tabs').innerHTML = '';
return;
}
if (res.source === 'api' && res.data && res.data.proxies) {
nodesData = res.data.proxies;
var order = res.data.proxy_group_order;
var allGroups = buildPolicyGroupsList(nodesData, order);
if (allGroups.length === 0) {
document.getElementById('nf-node-list').innerHTML = '<div style="text-align:center;color:var(--nf-text-dim);padding:20px;grid-column:1/-1;">未找到代理分组,请检查订阅内容</div>';
document.getElementById('nf-group-tabs').innerHTML = '';
return;
}
cachedGroups = allGroups;
var groups = filterGroupsByMode(allGroups);
if (groups.length === 0) {
document.getElementById('nf-node-list').innerHTML = '<div style="text-align:center;color:var(--nf-text-dim);padding:20px;grid-column:1/-1;">当前模式无可用分组</div>';
document.getElementById('nf-group-tabs').innerHTML = '';
return;
}
var gNames = groups.map(function(g) { return g.name; });
if (!currentGroup || gNames.indexOf(currentGroup) < 0) currentGroup = gNames[0];
renderGroupTabs(groups);
renderNodeList(groups);
updateStatusBarNode(groups);
}
});
}
function updateStatusBarNode(groups) {
if (!proxyRunning) return;
var info = document.getElementById('nf-status-info');
if (!info) return;
if (currentMode === 'direct') {
info.textContent = '直连模式';
return;
}
var targetGroup = null;
for (var i = 0; i < groups.length; i++) {
if (groups[i].name === currentGroup) { targetGroup = groups[i]; break; }
}
if (!targetGroup && groups.length > 0) targetGroup = groups[0];
if (targetGroup && targetGroup.now) info.textContent = targetGroup.now;
}
function renderGroupTabs(groups) {
var container = document.getElementById('nf-group-tabs');
var html = '';
for (var i = 0; i < groups.length; i++) {
var g = groups[i];
var cls = g.name === currentGroup ? 'nf-group-tab active' : 'nf-group-tab';
var badge = '';
if (!isManualSelectorGroup(g.type)) {
badge = '<span class="nf-group-auto-badge">' + escapeHtml(policyGroupHint(g.type)) + '</span>';
}
html += '<div class="' + cls + '" onclick="nfSwitchGroup(\'' + escapeJs(g.name) + '\')">' + escapeHtml(g.name) + badge + '</div>';
}
container.innerHTML = html;
}
function renderNodeList(groups) {
var container = document.getElementById('nf-node-list');
var group = null;
for (var i = 0; i < groups.length; i++) { if (groups[i].name === currentGroup) { group = groups[i]; break; } }
if (!group) { container.innerHTML = ''; return; }
var manual = isManualSelectorGroup(group.type);
var html = '';
if (!manual) {
html += '<div class="nf-auto-group-hint">此策略组类型为「' + escapeHtml(policyGroupHint(group.type)) + '」,当前出口由 Mihomo 按延迟/优先级自动切换,与 OpenClash 一致;若需固定线路,请在订阅中把该组改为 <b>select</b>,或在其下的 <b>Selector</b> 子策略里选择节点。</div>';
}
var nodes = group.all;
for (var j = 0; j < nodes.length; j++) {
var nodeName = nodes[j];
var isActive = proxyRunning && nodeName === group.now;
var isPreselected = !proxyRunning && nodeName === group.now;
var nodeInfo = nodesData[nodeName];
var nodeType = nodeInfo ? nodeInfo.type : '';
var delay = nodeInfo && nodeInfo.history && nodeInfo.history.length > 0 ? nodeInfo.history[nodeInfo.history.length - 1].delay : 0;
var savedDelay = delayResults[nodeName];
if (savedDelay && savedDelay > 0) delay = savedDelay;
var delayClass = '', delayText = '-';
if (delay > 0) {
delayText = delay + 'ms';
if (delay < 200) delayClass = 'fast';
else if (delay < 500) delayClass = 'medium';
else delayClass = 'slow';
}
var itemClass = 'nf-node-item';
if (!manual) itemClass += ' nf-node-readonly';
if (isActive) itemClass += ' active';
else if (isPreselected) itemClass += ' preselected';
var clickPart = manual
? ' onclick="nfSelectNode(\'' + escapeJs(currentGroup) + '\',\'' + escapeJs(nodeName) + '\',this)"'
: '';
html += '<div class="' + itemClass + '" data-node="' + escapeHtml(nodeName) + '"' + clickPart + '>';
if (isActive) html += '<span class="nf-node-active-dot"></span>';
else if (isPreselected) html += '<span class="nf-node-preselect-dot"></span>';
html += '<div class="nf-node-info"><div class="nf-node-name"><span>' + escapeHtml(nodeName) + '</span></div>';
if (nodeType && nodeType !== 'Selector' && nodeType !== 'URLTest') {
html += '<div class="nf-node-type">' + escapeHtml(nodeType) + '</div>';
}
html += '</div>';
html += '<span class="nf-node-delay ' + delayClass + '">' + delayText + '</span>';
html += '</div>';
}
container.innerHTML = html || '<div style="text-align:center;color:var(--nf-text-dim);padding:20px;grid-column:1/-1;">暂无节点</div>';
setTimeout(function() {
var names = container.querySelectorAll('.nf-node-name');
for (var ni = 0; ni < names.length; ni++) {
var s = names[ni].querySelector('span');
if (s && s.scrollWidth > names[ni].clientWidth) {
s.style.setProperty('--nf-scroll-x', -(s.scrollWidth - names[ni].clientWidth) + 'px');
}
}
}, 50);
}
function refreshLog() {
apiCall('log', null, function(res) {
if (res.status === 'success' && res.log) {
var area = document.getElementById('nf-log-area');
var raw = res.log.replace(/\\n/g, '\n');
var lines = raw.split('\n');
var html = '';
for (var i = 0; i < lines.length; i++) {
var line = escapeHtml(lines[i]);
if (!line) continue;
if (line.indexOf('[ERROR]') >= 0) html += '<div class="nf-log-line-error">' + line + '</div>';
else if (line.indexOf('[WARN]') >= 0) html += '<div class="nf-log-line-warn">' + line + '</div>';
else if (line.indexOf('[INFO]') >= 0) html += '<div class="nf-log-line-info">' + line + '</div>';
else html += '<div>' + line + '</div>';
}
area.innerHTML = html || '<span style="color:var(--nf-text-dim)">暂无日志</span>';
area.scrollTop = area.scrollHeight;
}
});
}
function refreshStats() {
if (!proxyRunning) return;
apiCall('sys_status', null, function(res) {
if (res.status === 'success') {
setStatValue('nf-stat-up-speed', formatSpeed(res.up_speed));
setStatValue('nf-stat-down-speed', formatSpeed(res.down_speed));
setStatValue('nf-stat-up-total', formatBytes(res.up_total));
setStatValue('nf-stat-down-total', formatBytes(res.down_total));
setStatValue('nf-stat-connections', String(res.connections || 0));
setStatValue('nf-stat-memory', (res.memory || '0') + '%');
setStatValue('nf-stat-cpu', (res.cpu || '0') + '%');
setStatValue('nf-stat-load', res.load || '0');
}
});
}
function setStatValue(id, val) {
var el = document.getElementById(id);
if (el) { el.textContent = val; el.className = 'nf-stat-value'; }
}
function resetStatsUI() {
var ids = ['nf-stat-up-speed','nf-stat-down-speed','nf-stat-up-total','nf-stat-down-total',
'nf-stat-connections','nf-stat-memory','nf-stat-cpu','nf-stat-load'];
for (var i = 0; i < ids.length; i++) {
var el = document.getElementById(ids[i]);
if (el) { el.textContent = '- -'; el.className = 'nf-stat-value inactive'; }
}
}
function updateTunUI() {
var sw = document.getElementById('nf-tun-switch');
var desc = document.getElementById('nf-tun-desc');
if (sw) { if (tunEnabled) sw.classList.add('on'); else sw.classList.remove('on'); }
if (desc) desc.textContent = tunEnabled ? '当前: TUN 模式 (虚拟网卡)' : '当前: 重定向模式 (redir)';
}
/* --- Core Management --- */
window.nfCheckCoreUpdate = function() {
debounce('core_check', function() {
var btn = document.getElementById('nf-core-check-btn');
setBtnLoading(btn, true);
apiCall('core_check_update', null, function(res) {
setBtnLoading(btn, false);
if (res.status === 'success') {
document.getElementById('nf-core-latest').textContent = res.latest || '-';
if (res.current) document.getElementById('nf-core-version').textContent = res.current;
if (res.has_update) {
showToast('有新版本可用: ' + res.latest, 'info');
} else if (res.current) {
showToast('已是最新版本', 'success');
}
loadCoreStatus();
} else {
showToast(res.message || '检查更新失败', 'error');
}
});
});
};
function loadCoreStatus() {
apiCall('core_status', null, function(res) {
if (res.status !== 'success') return;
var statusEl = document.getElementById('nf-core-status');
var archEl = document.getElementById('nf-core-arch');
var versionEl = document.getElementById('nf-core-version');
if (archEl) archEl.textContent = res.device_arch || '-';
if (res.installed) {
if (statusEl) { statusEl.textContent = '已安装'; statusEl.style.color = 'var(--nf-success)'; }
if (versionEl) versionEl.textContent = res.version || '-';
} else {
if (statusEl) { statusEl.textContent = '未安装'; statusEl.style.color = 'var(--nf-danger)'; }
if (versionEl) versionEl.textContent = '-';
}
var select = document.getElementById('nf-core-source');
if (select && res.sources && res.sources.length > 0) {
select.innerHTML = '';
for (var i = 0; i < res.sources.length; i++) {
var opt = document.createElement('option');
opt.value = res.sources[i].id;
opt.textContent = res.sources[i].name;
select.appendChild(opt);
}
}
applyCoreUpdateHint(res);
});
}
window.nfDownloadCore = function() {
debounce('core_dl', function() {
var source = document.getElementById('nf-core-source').value;
var btn = document.getElementById('nf-core-download-btn');
setBtnLoading(btn, true);
apiCall('core_download', {source: source}, function(res) {
if (res.status === 'success') {
showToast('开始下载核心...', 'info');
document.getElementById('nf-core-progress-wrap').classList.remove('nf-hidden');
startCoreProgressPolling();
} else {
setBtnLoading(btn, false);
showToast(res.message || '下载失败', 'error');
}
});
}, 2000);
};
function startCoreProgressPolling() {
if (coreProgressTimer) clearInterval(coreProgressTimer);
coreProgressTimer = setInterval(pollCoreProgress, 1000);
}
function pollCoreProgress() {
apiCall('core_progress', null, function(res) {
if (res.status !== 'success') return;
var bar = document.getElementById('nf-core-progress-bar');
var text = document.getElementById('nf-core-progress-text');
var btn = document.getElementById('nf-core-download-btn');
bar.style.width = res.download_percent + '%';
bar.className = 'nf-progress-bar-inner';
text.textContent = res.download_message || '';
if (res.download_status === 'done') {
bar.classList.add('done');
setBtnLoading(btn, false);
clearInterval(coreProgressTimer);
coreProgressTimer = null;
showToast(res.download_message || '核心安装成功', 'success');
setTimeout(function() {
loadCoreStatus();
document.getElementById('nf-core-progress-wrap').classList.add('nf-hidden');
}, 3000);
} else if (res.download_status === 'error') {
bar.classList.add('error');
setBtnLoading(btn, false);
clearInterval(coreProgressTimer);
coreProgressTimer = null;
showToast(res.download_message || '下载失败', 'error');
}
});
}
/* --- Core Upload --- */
window.nfUploadCore = function(input) {
if (!input.files || !input.files[0]) return;
var file = input.files[0];
if (file.size < 1024) {
showToast('文件太小,不是有效的核心文件', 'error');
input.value = '';
return;
}
if (file.size > 100 * 1024 * 1024) {
showToast('文件超过 100MB,请检查是否选错文件', 'error');
input.value = '';
return;
}
var btn = document.getElementById('nf-core-upload-btn');
var origText = btn.textContent;
btn.textContent = '上传中...';
btn.disabled = true;
var formData = new FormData();
formData.append('corefile', file);
var token = document.querySelector('input[name="token"]');
if (token) formData.append('token', token.value);
var xhr = new XMLHttpRequest();
xhr.open('POST', '<%=luci.dispatcher.build_url("admin","services","netflow","upload_core")%>', true);
xhr.onload = function() {
btn.textContent = origText;
btn.disabled = false;
input.value = '';
try {
var res = JSON.parse(xhr.responseText);
if (res.status === 'success') {
showToast(res.message || '核心上传安装成功', 'success');
loadCoreStatus();
} else {
showToast(res.message || '上传失败', 'error');
}
} catch(e) {
showToast('上传失败:服务器响应异常', 'error');
}
};
xhr.onerror = function() {
btn.textContent = origText;
btn.disabled = false;
input.value = '';
showToast('上传失败:网络错误', 'error');
};
xhr.send(formData);
};
/* --- LAN Proxy --- */
window.nfSwitchLANProxy = function() {
var sw = document.getElementById('nf-lan-proxy-switch');
if (sw) sw.classList.add('disabled');
apiCall('switch_lan_proxy', null, function(res) {
if (sw) sw.classList.remove('disabled');
if (res.status === 'success') {
lanProxyEnabled = (res.lan_proxy === '1');
updateLANProxyUI();
refreshDashboardStatus();
showToast(res.message, 'success');
} else {
showToast(res.message || '切换失败', 'error');
}
});
};
function updateLANProxyUI() {
var sw = document.getElementById('nf-lan-proxy-switch');
var desc = document.getElementById('nf-lan-proxy-desc');
var hint = document.getElementById('nf-lan-proxy-hint');
if (sw) { if (lanProxyEnabled) sw.classList.add('on'); else sw.classList.remove('on'); }
if (desc) desc.textContent = lanProxyEnabled ? '已开启,局域网设备流量走代理' : '已关闭,仅路由器本机走代理';
if (hint && !lanProxyEnabled) hint.classList.add('nf-hidden');
}
/* --- IPv6 Proxy --- */
window.nfSwitchIPv6Proxy = function() {
var sw = document.getElementById('nf-ipv6-proxy-switch');
if (sw) sw.classList.add('disabled');
apiCall('switch_ipv6_proxy', null, function(res) {
if (sw) sw.classList.remove('disabled');
if (res.status === 'success') {
ipv6ProxyEnabled = (res.ipv6_proxy === '1');
updateIPv6ProxyUI();
showToast(res.message, 'success');
} else {
showToast(res.message || '切换失败', 'error');
}
});
};
function updateIPv6ProxyUI() {
var sw = document.getElementById('nf-ipv6-proxy-switch');
var desc = document.getElementById('nf-ipv6-proxy-desc');
if (sw) { if (ipv6ProxyEnabled) sw.classList.add('on'); else sw.classList.remove('on'); }
if (desc) desc.textContent = ipv6ProxyEnabled ? '已开启,IPv6 流量走代理' : '已关闭,仅代理 IPv4 流量';
}
var _checkRetry = 0;
var _checkMaxRetry = 5;
var _checkRetryDelay = 3000;
window.nfOpenWebsite = function() {
if (websiteURL) window.open(websiteURL, '_blank');
};
function updateWebsiteButton(url) {
websiteURL = url || '';
var btn = document.getElementById('nf-website-btn');
if (!btn) return;
if (websiteURL) {
btn.classList.remove('nf-hidden');
} else {
btn.classList.add('nf-hidden');
}
}
function checkStatus() {
apiCall('v2board_status', null, function(res) {
if (res.status === 'logged_in') {
_checkRetry = 0;
showDashboard(res);
} else if (res.status === 'error' && res.message && res.message.indexOf('后端') >= 0 && _checkRetry < _checkMaxRetry) {
_checkRetry++;
var loadingText = document.querySelector('#nf-loading-screen span');
if (loadingText) loadingText.textContent = '正在等待后端启动... (' + _checkRetry + '/' + _checkMaxRetry + ')';
setTimeout(checkStatus, _checkRetryDelay);
} else {
_checkRetry = 0;
showLogin();
if (res.email) document.getElementById('nf-email').value = res.email;
if (res.status === 'error' && res.message) showToast(res.message, 'error');
}
});
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escapeJs(str) {
if (!str) return '';
return str.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
}
document.getElementById('nf-password').addEventListener('keypress', function(e) {
if (e.key === 'Enter') nfLogin();
});
(function detectTheme() {
var app = document.getElementById('netflow-app');
if (!app) return;
try {
var body = document.body;
var bg = window.getComputedStyle(body).backgroundColor;
var m = bg.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (m) {
var brightness = (parseInt(m[1]) * 299 + parseInt(m[2]) * 587 + parseInt(m[3]) * 114) / 1000;
if (brightness > 128) app.classList.add('nf-light');
} else if (bg === 'transparent' || bg === '') {
var parent = app.parentElement;
while (parent && parent !== document.documentElement) {
var pbg = window.getComputedStyle(parent).backgroundColor;
var pm = pbg.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (pm) {
var pb = (parseInt(pm[1]) * 299 + parseInt(pm[2]) * 587 + parseInt(pm[3]) * 114) / 1000;
if (pb > 128) app.classList.add('nf-light');
break;
}
parent = parent.parentElement;
}
}
} catch(e) {}
})();
/* --- Custom Rules --- */
function nfRefreshRules() {
apiCall('rule_list', null, function(res) {
var list = document.getElementById('nf-rules-list');
if (!list) return;
if (res.status !== 'success' || !res.rules || res.rules.length === 0) {
list.innerHTML = '<div style="text-align:center;color:var(--nf-text-dim);padding:10px;font-size:13px;">暂无自定义规则</div>';
nfRefreshPolicyDatalist();
return;
}
var html = '';
for (var i = 0; i < res.rules.length; i++) {
var r = res.rules[i];
var isFirst = (i === 0);
var isLast = (i === res.rules.length - 1);
var upDisabled = isFirst ? ' disabled' : '';
var downDisabled = isLast ? ' disabled' : '';
html += '<div class="nf-rule-row" style="display:flex;align-items:center;gap:6px;padding:6px 8px;background:var(--nf-bg);border-radius:6px;margin-bottom:5px;font-size:12px;cursor:pointer;" onclick="nfEditRule(\'' + escapeJs(r.id) + '\',\'' + escapeJs(r.type) + '\',\'' + escapeJs(r.value) + '\',\'' + escapeJs(r.policy) + '\')">';
html += '<span style="display:flex;flex-direction:column;gap:1px;flex-shrink:0;">';
html += '<button class="nf-btn nf-btn-sm nf-btn-outline" style="padding:1px 4px;font-size:9px;min-width:auto;line-height:1;" onclick="event.stopPropagation();nfMoveRule(\'' + escapeJs(r.id) + '\',\'up\')"' + upDisabled + '>▲</button>';
html += '<button class="nf-btn nf-btn-sm nf-btn-outline" style="padding:1px 4px;font-size:9px;min-width:auto;line-height:1;" onclick="event.stopPropagation();nfMoveRule(\'' + escapeJs(r.id) + '\',\'down\')"' + downDisabled + '>▼</button>';
html += '</span>';
html += '<span style="background:var(--nf-primary);color:#fff;padding:2px 6px;border-radius:4px;font-size:10px;font-weight:600;flex-shrink:0;" title="' + escapeHtml(r.type) + '">' + escapeHtml(r.type) + '</span>';
html += '<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--nf-text);">' + escapeHtml(r.value) + '</span>';
html += '<span style="color:var(--nf-text-dim);flex-shrink:0;">&rarr; ' + escapeHtml(r.policy) + '</span>';
html += '<button class="nf-btn nf-btn-sm nf-btn-danger" style="padding:3px 8px;font-size:10px;" onclick="event.stopPropagation();nfDeleteRule(\'' + escapeJs(r.id) + '\')">删除</button>';
html += '</div>';
}
list.innerHTML = html;
nfRefreshPolicyDatalist();
});
}
function nfRefreshPolicyDatalist() {
var dl = document.getElementById('nf-policy-datalist');
if (!dl) return;
// Keep builtins: DIRECT, REJECT, REJECT-DROP, PASS
dl.innerHTML = '<option value="DIRECT"><option value="REJECT"><option value="REJECT-DROP"><option value="PASS">';
// Add groups from cached nodes data
if (cachedGroups) {
for (var i = 0; i < cachedGroups.length; i++) {
var name = cachedGroups[i].name;
if (name !== 'DIRECT' && name !== 'REJECT' && name !== 'REJECT-DROP' && name !== 'PASS') {
dl.innerHTML += '<option value="' + escapeHtml(name) + '">';
}
}
}
// Also add groups from nodesData proxynames
if (nodesData) {
for (var key in nodesData) {
if (!Object.prototype.hasOwnProperty.call(nodesData, key)) continue;
if (key !== 'DIRECT' && key !== 'REJECT' && key !== 'REJECT-DROP' && key !== 'PASS') {
dl.innerHTML += '<option value="' + escapeHtml(key) + '">';
}
}
}
}
window.nfEditRule = function(id, type, value, policy) {
document.getElementById('nf-rule-edit-id').value = id;
document.getElementById('nf-rule-type').value = type;
document.getElementById('nf-rule-value').value = value;
document.getElementById('nf-rule-policy').value = policy;
document.getElementById('nf-rule-submit-btn').textContent = '更新规则';
document.getElementById('nf-rule-submit-btn').className = 'nf-btn nf-btn-sm nf-btn-warning';
document.getElementById('nf-rule-edit-actions').classList.remove('nf-hidden');
};
window.nfCancelEdit = function() {
document.getElementById('nf-rule-edit-id').value = '';
document.getElementById('nf-rule-type').value = 'DOMAIN-SUFFIX';
document.getElementById('nf-rule-value').value = '';
document.getElementById('nf-rule-policy').value = '';
document.getElementById('nf-rule-submit-btn').textContent = '添加规则';
document.getElementById('nf-rule-submit-btn').className = 'nf-btn nf-btn-sm nf-btn-primary';
document.getElementById('nf-rule-edit-actions').classList.add('nf-hidden');
};
window.nfAddOrUpdateRule = function() {
var editId = document.getElementById('nf-rule-edit-id').value;
var type = document.getElementById('nf-rule-type').value;
var value = document.getElementById('nf-rule-value').value.trim();
var policy = document.getElementById('nf-rule-policy').value.trim();
if (!value || !policy) { showToast('请填写匹配值和目标策略组', 'error'); return; }
if (editId) {
apiCall('rule_update', {id: editId, type: type, value: value, policy: policy}, function(res) {
if (res.status === 'success') {
showToast('规则已更新', 'success');
nfCancelEdit();
nfRefreshRules();
} else {
showToast(res.message || '更新失败', 'error');
}
});
} else {
apiCall('rule_add', {type: type, value: value, policy: policy}, function(res) {
if (res.status === 'success') {
showToast('规则已添加', 'success');
document.getElementById('nf-rule-value').value = '';
nfRefreshRules();
} else {
showToast(res.message || '添加失败', 'error');
}
});
}
};
window.nfMoveRule = function(id, dir) {
apiCall('rule_sort', {id: id, dir: dir}, function(res) {
if (res.status === 'success') {
nfRefreshRules();
} else {
showToast(res.message || '排序失败', 'error');
}
});
};
window.nfDeleteRule = function(id) {
if (!confirm('确认删除此规则?')) return;
apiCall('rule_delete', {id: id}, function(res) {
if (res.status === 'success') {
showToast('规则已删除', 'success');
// If editing this rule, cancel edit
if (document.getElementById('nf-rule-edit-id').value === id) nfCancelEdit();
nfRefreshRules();
} else {
showToast(res.message || '删除失败', 'error');
}
});
};
/* --- Batch Import --- */
window.nfShowImport = function() {
document.getElementById('nf-import-box').classList.remove('nf-hidden');
document.getElementById('nf-template-box').classList.add('nf-hidden');
};
window.nfHideImport = function() {
document.getElementById('nf-import-box').classList.add('nf-hidden');
};
window.nfDoImport = function() {
var text = document.getElementById('nf-import-text').value.trim();
if (!text) { showToast('请输入规则内容', 'error'); return; }
apiCall('rule_import', {text: text}, function(res) {
if (res.status === 'success') {
showToast(res.message, 'success');
document.getElementById('nf-import-text').value = '';
nfHideImport();
nfRefreshRules();
} else {
showToast(res.message || '导入失败', 'error');
}
});
};
/* --- Templates --- */
window.nfShowTemplates = function() {
var box = document.getElementById('nf-template-box');
var list = document.getElementById('nf-template-list');
box.classList.remove('nf-hidden');
document.getElementById('nf-import-box').classList.add('nf-hidden');
apiCall('rule_templates', null, function(res) {
if (res.status !== 'success' || !res.templates) return;
var html = '';
for (var i = 0; i < res.templates.length; i++) {
var tmpl = res.templates[i];
html += '<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 10px;background:var(--nf-bg);border-radius:6px;margin-bottom:6px;">';
html += '<div style="flex:1;min-width:0;">';
html += '<div style="font-size:13px;font-weight:500;">' + escapeHtml(tmpl.name) + '</div>';
html += '<div style="font-size:11px;color:var(--nf-text-dim);margin-top:2px;">' + tmpl.rules.length + ' 条规则</div>';
html += '</div>';
html += '<button class="nf-btn nf-btn-sm nf-btn-success" onclick="nfApplyTemplate(' + i + ')">添加</button>';
html += '</div>';
}
list.innerHTML = html;
// Cache templates
window._ruleTemplates = res.templates;
});
};
window.nfApplyTemplate = function(idx) {
var tmpl = window._ruleTemplates && window._ruleTemplates[idx];
if (!tmpl) return;
var count = 0;
function addNext(i) {
if (i >= tmpl.rules.length) {
showToast('已添加 ' + count + ' 条模板规则', 'success');
nfRefreshRules();
document.getElementById('nf-template-box').classList.add('nf-hidden');
return;
}
var r = tmpl.rules[i];
apiCall('rule_add', {type: r.type, value: r.value, policy: r.policy}, function(res) {
if (res.status === 'success') count++;
addNext(i + 1);
});
}
addNext(0);
};
window.nfApplyRules = function() {
apiCall('rule_apply', null, function(res) {
if (res.status === 'success') {
showToast('规则已应用并热重载', 'success');
} else {
showToast(res.message || '应用失败', 'error');
}
});
};
/* --- 连接管理 --- */
var connCache = null;
window.nfRefreshConnections = function() {
if (!proxyRunning) { showToast('请先启动代理', 'error'); return; }
apiCall('connections', null, function(res) {
if (res.status === 'error') { showToast(res.message, 'error'); return; }
connCache = res;
nfRenderConnections();
});
};
function nfRenderConnections() {
var list = document.getElementById('nf-conn-list');
var total = document.getElementById('nf-conn-total');
var parent = list ? list.parentElement : null;
if (!connCache || !connCache.connections) {
list.innerHTML = '<div style="text-align:center;color:var(--nf-text-dim);padding:20px;">暂无活跃连接</div>';
if (total) total.innerHTML = '';
return;
}
var cons = connCache.connections;
var searchText = (document.getElementById('nf-conn-search').value || '').toLowerCase();
var sortBy = document.getElementById('nf-conn-sort').value;
if (searchText) {
cons = cons.filter(function(c) {
var host = (c.metadata && c.metadata.host || '').toLowerCase();
var dest = (c.metadata && c.metadata.destinationIP || '').toLowerCase();
var rule = (c.rule || '').toLowerCase();
return host.indexOf(searchText) >= 0 || dest.indexOf(searchText) >= 0 || rule.indexOf(searchText) >= 0;
});
}
if (sortBy === 'dl') cons.sort(function(a,b) { return (b.download||0) - (a.download||0); });
else if (sortBy === 'ul') cons.sort(function(a,b) { return (b.upload||0) - (a.upload||0); });
if (total) total.innerHTML = '<b>' + cons.length + '</b> 条连接 &nbsp;|&nbsp; 下载 ' + formatBytes(connCache.downloadTotal||0) + ' &nbsp;|&nbsp; 上传 ' + formatBytes(connCache.uploadTotal||0);
if (cons.length === 0) {
list.innerHTML = '<div style="text-align:center;color:var(--nf-text-dim);padding:20px;">无匹配连接</div>';
return;
}
var hdr = '<div style="display:grid;grid-template-columns:2fr 1.5fr 0.7fr 0.7fr 0.6fr 40px;gap:8px;padding:6px 8px;font-size:11px;font-weight:600;color:var(--nf-text-dim);border-bottom:2px solid var(--nf-border);margin-bottom:4px;">'
+ '<span>主机</span><span>规则/链</span><span style="text-align:right">下载</span><span style="text-align:right">上传</span><span style="text-align:right">类型</span><span></span></div>';
var html = '';
for (var i = 0; i < cons.length; i++) {
var c = cons[i];
var meta = c.metadata || {};
var host = meta.host || meta.destinationIP || '-';
var dst = meta.destinationIP || '';
if (dst && host !== dst) host += ' (' + dst + ')';
var rule = c.rule || '-';
var chain = (c.chains || []).join(' > ') || '-';
var info = rule;
if (chain !== rule) info = chain + ' / ' + rule;
var dl = formatSpeed(c.download || 0);
var ul = formatSpeed(c.upload || 0);
var ntype = (meta.network || '').toUpperCase();
html += '<div style="display:grid;grid-template-columns:2fr 1.5fr 0.7fr 0.7fr 0.6fr 40px;gap:8px;padding:4px 8px;font-size:11px;border-bottom:1px solid var(--nf-border);align-items:center;">';
html += '<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="' + escapeHtml(host) + '">' + escapeHtml(host) + '</span>';
html += '<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--nf-text-dim);" title="' + escapeHtml(info) + '">' + escapeHtml(info) + '</span>';
html += '<span style="text-align:right;color:var(--nf-success);">' + dl + '</span>';
html += '<span style="text-align:right;color:var(--nf-danger);">' + ul + '</span>';
html += '<span style="text-align:right;color:var(--nf-text-dim);">' + escapeHtml(ntype) + '</span>';
html += '<button class="nf-btn nf-btn-sm nf-btn-danger" style="padding:1px 5px;font-size:10px;min-width:auto;height:20px;line-height:1;" onclick="nfCloseConnection(\'' + escapeJs(c.id) + '\')" title="关闭">X</button>';
html += '</div>';
}
list.innerHTML = hdr + html;
}
window.nfCloseConnection = function(id) {
apiCall('close_connection', {id: id}, function() {
if (connCache && connCache.connections) {
connCache.connections = connCache.connections.filter(function(c) { return c.id !== id; });
}
nfRenderConnections();
});
};
window.nfCloseAllConnections = function() {
if (!confirm('确认关闭所有连接?')) return;
apiCall('close_all_connections', null, function() {
connCache = {connections: [], downloadTotal: 0, uploadTotal: 0};
nfRenderConnections();
showToast('所有连接已关闭', 'success');
});
};
/* --- 日志过滤 --- */
var logPaused = false;
var logLevelFilter = 'ALL';
var logRawCache = '';
window.nfToggleLogPause = function() {
logPaused = !logPaused;
var btn = document.getElementById('nf-log-pause-btn');
if (logPaused) {
btn.textContent = '继续';
btn.className = 'nf-btn nf-btn-sm nf-btn-warning';
stopLogPolling();
} else {
btn.textContent = '暂停';
btn.className = 'nf-btn nf-btn-sm nf-btn-primary';
startLogPolling(false);
}
};
window.nfSetLogLevel = function(level) {
logLevelFilter = level;
var tabs = document.querySelectorAll('#nf-log-level-tabs .nf-group-tab');
for (var i = 0; i < tabs.length; i++) {
if (tabs[i].textContent === level) tabs[i].classList.add('active');
else tabs[i].classList.remove('active');
}
nfRenderLogFilter();
};
function nfRenderLogFilter() {
var area = document.getElementById('nf-log-area');
if (!area) return;
var searchText = document.getElementById('nf-log-search') ? document.getElementById('nf-log-search').value.toLowerCase() : '';
var lines = logRawCache.split('\n');
var html = '';
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (!line) continue;
// Level filter
if (logLevelFilter !== 'ALL') {
if (line.indexOf('[' + logLevelFilter + ']') < 0) continue;
}
// Search filter
if (searchText) {
if (line.toLowerCase().indexOf(searchText) < 0) continue;
}
var escaped = escapeHtml(line);
if (line.indexOf('[ERROR]') >= 0) html += '<div class="nf-log-line-error">' + escaped + '</div>';
else if (line.indexOf('[WARN]') >= 0) html += '<div class="nf-log-line-warn">' + escaped + '</div>';
else if (line.indexOf('[INFO]') >= 0) html += '<div class="nf-log-line-info">' + escaped + '</div>';
else html += '<div>' + escaped + '</div>';
}
area.innerHTML = html || '<span style="color:var(--nf-text-dim)">暂无匹配日志</span>';
area.scrollTop = area.scrollHeight;
}
// Override refreshLog to cache log for filtering
var _origRefreshLog = refreshLog;
refreshLog = function() {
if (logPaused) return;
apiCall('log', null, function(res) {
if (res.status === 'success' && res.log) {
logRawCache = res.log.replace(/\\n/g, '\n');
nfRenderLogFilter();
}
});
};
// Override nfClearLog to reset cache
var _origClearLog = nfClearLog;
window.nfClearLog = function() {
apiCall('clear_log', null, function() {
logRawCache = '';
document.getElementById('nf-log-area').innerHTML = '<span style="color:var(--nf-text-dim)">日志已清除</span>';
showToast('日志已清除', 'info');
});
};
/* --- DNS 配置 --- */
var dnsExpanded = false;
window.nfDnsToggle = function() {
dnsExpanded = !dnsExpanded;
var body = document.getElementById('nf-dns-body');
var btn = document.getElementById('nf-dns-expand-btn');
if (dnsExpanded) {
body.classList.remove('nf-hidden');
btn.textContent = '收起';
nfDnsLoad();
} else {
body.classList.add('nf-hidden');
btn.textContent = '展开';
}
};
function nfDnsLoad() {
apiCall('dns_config', {dns_action: 'get'}, function(res) {
if (!res || !res.dns) {
// Try loading from UCI directly
var ns = (res && res['dns.nameserver']) || '';
var fb = (res && res['dns.fallback']) || '';
if (typeof ns === 'object') ns = (ns.default || []).join(',');
if (typeof fb === 'object') fb = (fb.default || []).join(',');
document.getElementById('nf-dns-nameserver').value = Array.isArray(ns) ? ns.join(',') : (ns || '');
document.getElementById('nf-dns-fallback').value = Array.isArray(fb) ? fb.join(',') : (fb || '');
}
});
}
window.nfDnsSave = function() {
var ns = document.getElementById('nf-dns-nameserver').value.trim();
var fb = document.getElementById('nf-dns-fallback').value.trim();
var fip = document.getElementById('nf-dns-fakeip').value.trim();
var hosts = document.getElementById('nf-dns-hosts').value.trim();
apiCall('dns_config', {
dns_action: 'save',
nameserver: ns,
fallback: fb,
fake_ip_range: fip,
hosts: hosts
}, function(res) {
if (res.status === 'success') {
showToast('DNS 配置已保存,应用规则后生效', 'success');
} else {
showToast(res.message || '保存失败', 'error');
}
});
};
/* --- 流媒体解锁检测 --- */
var mediaServices = [];
window.nfCheckMediaUnlock = function() {
var btn = document.getElementById('nf-unlock-btn');
setBtnLoading(btn, true);
document.getElementById('nf-unlock-list').innerHTML = '<div style="text-align:center;color:var(--nf-text-dim);padding:20px;"><div class="nf-loading"></div> 检测中...</div>';
// Get service list
apiCall('media_unlock', {}, function(res) {
if (res.status !== 'success' || !res.services) {
setBtnLoading(btn, false);
document.getElementById('nf-unlock-list').innerHTML = '<div style="text-align:center;color:var(--nf-danger);padding:10px;">获取服务列表失败</div>';
return;
}
mediaServices = res.services.map(function(s, i) {
return {name: s.name, url: s.url, key: s.key, idx: i, status: 'pending'};
});
nfRenderUnlock();
nfRunUnlockTests(0);
});
};
function nfRunUnlockTests(i) {
if (i >= mediaServices.length) {
setBtnLoading(document.getElementById('nf-unlock-btn'), false);
nfRenderUnlock();
return;
}
var svc = mediaServices[i];
svc.status = 'testing';
nfRenderUnlock();
apiCall('media_unlock', {name: svc.name, url: svc.url}, function(res) {
if (res.status === 'success' && res.result) {
svc.status = res.result.unlocked ? 'yes' : 'no';
svc.http_code = res.result.http_code;
} else {
svc.status = 'error';
}
nfRenderUnlock();
nfRunUnlockTests(i + 1);
});
}
function nfRenderUnlock() {
var list = document.getElementById('nf-unlock-list');
var html = '';
for (var i = 0; i < mediaServices.length; i++) {
var s = mediaServices[i];
var icon = '', color = '';
if (s.status === 'yes') { icon = '✓'; color = 'var(--nf-success)'; }
else if (s.status === 'no') { icon = '✗'; color = 'var(--nf-danger)'; }
else if (s.status === 'testing') { icon = '⏳'; color = 'var(--nf-warning)'; }
else if (s.status === 'error') { icon = '!'; color = 'var(--nf-text-dim)'; }
else { icon = '·'; color = 'var(--nf-text-dim)'; }
html += '<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--nf-border);">';
html += '<span style="color:' + color + ';font-size:16px;width:20px;text-align:center;">' + icon + '</span>';
html += '<span style="flex:1;">' + escapeHtml(s.name) + '</span>';
if (s.http_code) html += '<span style="font-size:10px;color:var(--nf-text-dim);">HTTP ' + s.http_code + '</span>';
html += '</div>';
}
list.innerHTML = html;
}
/* --- 运行时配置查看器 --- */
window.nfViewConfig = function() {
apiCall('config_viewer', null, function(res) {
if (res.status === 'error') { showToast(res.message, 'error'); return; }
var w = window.open('', '_blank', 'width=800,height=600');
w.document.write('<pre style="font-size:12px;font-family:monospace;white-space:pre-wrap;word-break:break-all;">' + escapeHtml(JSON.stringify(res, null, 2)) + '</pre>');
});
};
/* --- Provider 管理 --- */
window.nfShowProviders = function() {
if (!confirm('刷新代理/规则 Provider 列表?')) return;
apiCall('providers', null, function(res) {
var msg = '';
if (res.providers) {
var keys = Object.keys(res.providers);
msg += '代理 Provider: ' + keys.length + ' 个\n';
for (var i = 0; i < keys.length; i++) {
var p = res.providers[keys[i]];
msg += ' ' + keys[i] + ' (' + (p.vehicleType || '') + ', 更新: ' + (p.updatedAt || '-') + ')\n';
}
}
if (res.rule_providers) {
var rkeys = Object.keys(res.rule_providers);
msg += '\n规则 Provider: ' + rkeys.length + ' 个\n';
for (var i = 0; i < rkeys.length; i++) {
msg += ' ' + rkeys[i] + '\n';
}
}
if (!msg) msg = '无 Provider 信息';
alert(msg);
});
};
window.nfUpdateProvider = function(type, name) {
apiCall('update_provider', {type: type, name: name}, function(res) {
if (res.status === 'error') showToast(res.message, 'error');
else showToast(name + ' 更新已触发', 'success');
});
};
/* --- 配置备份 --- */
window.nfBackupConfig = function() {
apiCall('config_backup', {backup_action: 'export'}, function(res) {
if (res.status === 'success') showToast(res.message, 'success');
else showToast(res.message || '备份失败', 'error');
});
};
window.nfListBackups = function() {
apiCall('config_backup', {backup_action: 'list'}, function(res) {
if (res.status !== 'success' || !res.backups || res.backups.length === 0) {
showToast('暂无备份', 'info');
return;
}
var msg = '备份列表:\n';
for (var i = 0; i < res.backups.length; i++) {
msg += ' ' + res.backups[i].name + '\n';
}
msg += '\n输入名称恢复:';
var name = prompt(msg);
if (name) {
apiCall('config_backup', {backup_action: 'restore', file: name + '.tar.gz'}, function(r) {
if (r.status === 'success') showToast(r.message, 'success');
else showToast(r.message || '恢复失败', 'error');
});
}
});
};
checkStatus();
})();
</script>
<%+footer%>