Files
telegram-downloader/module/templates/index.html
T
yuming 6f87cb5a77
部署到群晖 / deploy (push) Successful in 1m11s
feat: 任务队列改为横向卡片布局,超出自动换行
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 12:01:22 +08:00

2127 lines
91 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Telegram 下载器</title>
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #0d1117;
--surface: #161b22;
--surface2: #1c2128;
--border: #30363d;
--accent: #58a6ff;
--green: #3fb950;
--orange: #d29922;
--red: #f85149;
--text: #e6edf3;
--muted: #8b949e;
--hdr-h: 52px;
--bar-h: 68px;
}
html, body { height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg); color: var(--text);
font-size: 14px; line-height: 1.5;
}
/* ════ HEADER ════ */
.hdr {
position: fixed; top: 0; left: 0; right: 0;
height: var(--hdr-h);
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex; align-items: center;
padding: 0 16px; gap: 12px; z-index: 200;
}
.hdr-brand {
font-size: 15px; font-weight: 700; color: var(--accent);
white-space: nowrap; letter-spacing: -.3px;
}
.hdr-brand small { font-weight: 400; font-size: 11px; color: var(--muted); margin-left: 4px; }
.hdr-divider { width: 1px; height: 20px; background: var(--border); flex-shrink: 0; }
.hdr-stats { display: flex; gap: 20px; flex: 1; justify-content: center; }
.hstat { text-align: center; }
.hstat-v { font-size: 14px; font-weight: 700; color: var(--green); line-height: 1.1; }
.hstat-v.s { color: var(--accent); }
.hstat-v.o { color: var(--orange); }
.hstat-l { font-size: 10px; color: var(--muted); margin-top: 1px; }
.hdr-right { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.state-pill {
display: flex; align-items: center; gap: 5px;
padding: 3px 8px; border-radius: 20px;
border: 1px solid var(--border); font-size: 12px; color: var(--muted);
}
.state-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--green); }
.state-dot.off { background: var(--orange); }
.btn-pause {
padding: 5px 14px; border-radius: 6px; font-size: 12px; font-weight: 600;
border: 1px solid var(--border); background: transparent;
color: var(--text); cursor: pointer; transition: all .15s;
}
.btn-pause:hover { border-color: var(--accent); color: var(--accent); }
.btn-pause.resuming { border-color: var(--green); color: var(--green); }
.btn-pause.resuming:hover { background: rgba(63,185,80,.08); }
/* ════ FORM BAR ════ */
.formbar {
position: fixed;
top: var(--hdr-h); left: 0; right: 0;
height: var(--bar-h);
background: var(--surface2);
border-bottom: 1px solid var(--border);
display: flex; align-items: center;
padding: 0 16px; gap: 0;
z-index: 150;
overflow: visible;
}
.fb-field {
position: relative;
display: flex; flex-direction: column; justify-content: center;
padding: 0 12px;
height: 100%;
}
.fb-field-channel { flex: 2; min-width: 200px; border-right: 1px solid var(--border); }
.fb-field-date { width: 148px; border-right: 1px solid var(--border); }
.fb-field-path { flex: 1.5; min-width: 180px; border-right: 1px solid var(--border); }
.fb-field-submit { flex-shrink: 0; padding: 0 14px; }
.fb-label {
font-size: 10px; font-weight: 600;
color: var(--muted); text-transform: uppercase;
letter-spacing: .5px; margin-bottom: 5px;
white-space: nowrap;
display: flex; align-items: center; gap: 6px;
}
.fb-input-row { display: flex; gap: 6px; }
.fb-input-row .field-input { flex: 1; min-width: 0; }
.field-input {
width: 100%; padding: 6px 10px;
background: var(--bg); border: 1px solid var(--border);
border-radius: 6px; color: var(--text); font-size: 13px;
transition: border-color .15s;
}
.field-input:focus { outline: none; border-color: var(--accent); }
.field-input::placeholder { color: #555; }
/* 验证结果下拉 */
.val-drop {
display: none;
position: absolute; top: calc(100% + 2px); left: 12px;
width: 280px; z-index: 400;
background: var(--surface2); border: 1px solid var(--border);
border-radius: 8px; padding: 10px 12px;
box-shadow: 0 8px 24px rgba(0,0,0,.45);
gap: 10px; align-items: center;
}
.val-drop.ok { display: flex; border-color: rgba(63,185,80,.3); }
.val-drop.err { display: flex; border-color: rgba(248,81,73,.3); }
.val-icon { font-size: 18px; flex-shrink: 0; }
.val-info b { display: block; font-size: 13px; }
.val-drop.ok .val-info b { color: var(--green); }
.val-drop.err .val-info b { color: var(--red); }
.val-info span { font-size: 11px; color: var(--muted); }
/* 保存路径区 */
.path-row { display: flex; align-items: center; gap: 6px; min-width: 0; }
.path-display {
flex: 1; font-size: 12px; color: var(--green);
font-family: monospace; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; min-width: 0;
}
.path-edit-drop {
display: none;
position: absolute; top: calc(100% + 2px); right: 0; left: 0;
z-index: 400;
background: var(--surface2); border: 1px solid var(--border);
border-radius: 8px; padding: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,.45);
}
.path-edit-drop.on { display: block; }
.path-edit-drop .field-input { margin-bottom: 8px; }
.path-edit-btns { display: flex; gap: 6px; }
/* 按钮 */
.btn {
padding: 6px 13px; border-radius: 6px; font-size: 13px;
font-weight: 500; cursor: pointer; border: none;
display: inline-flex; align-items: center; gap: 5px;
transition: opacity .15s; white-space: nowrap;
}
.btn:disabled { opacity: .4; cursor: not-allowed; }
.btn-primary { background: var(--accent); color: #0d1117; }
.btn-primary:hover:not(:disabled) { opacity: .85; }
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--muted); }
.btn-outline:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.btn-submit {
padding: 9px 20px; border-radius: 7px;
background: var(--accent); color: #0d1117;
font-size: 13px; font-weight: 700;
border: none; cursor: pointer;
display: inline-flex; align-items: center; gap: 6px;
transition: opacity .15s; white-space: nowrap;
}
.btn-submit:hover:not(:disabled) { opacity: .88; }
.btn-submit:disabled { opacity: .4; cursor: not-allowed; }
/* ════ MAIN SCROLL ════ */
.main-scroll {
position: fixed;
top: calc(var(--hdr-h) + var(--bar-h));
left: 0; right: 0; bottom: 0;
overflow-y: auto; overflow-x: hidden;
padding: 16px;
background: var(--bg);
display: flex; flex-direction: column; gap: 14px;
}
/* 卡片 */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
}
.card-hd {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; border-bottom: 1px solid var(--border);
font-size: 13px; font-weight: 600;
}
.card-hd-badge { font-size: 11px; font-weight: 400; color: var(--muted); }
.card-bd { padding: 12px 14px; }
/* 可折叠的 card-hd */
.card-hd.collapsible { cursor: pointer; user-select: none; }
.card-hd.collapsed { border-bottom: none; }
.card-hd-left { display: flex; align-items: center; gap: 6px; }
/* 任务队列总览 */
.task-banner {
display: none; padding: 10px 14px;
border-radius: 8px;
background: var(--surface); border: 1px solid var(--border);
}
.task-banner.show { display: block; }
.qbanner-head {
display: flex; align-items: center; justify-content: space-between;
font-size: 12px; color: var(--muted); margin-bottom: 8px;
}
.qbanner-title { font-weight: 600; color: var(--text); font-size: 13px; }
.qbanner-summary { font-size: 11px; }
/* 队列卡片(已完成 / 当前 / 排队中) */
#qbanner-list { display: flex; flex-wrap: wrap; gap: 8px; }
.qcard {
display: flex; align-items: flex-start; gap: 10px;
padding: 8px 10px; border-radius: 6px;
border: 1px solid transparent;
flex: 1 1 220px; min-width: 200px; max-width: 380px;
}
.qcard-icon { font-size: 14px; line-height: 1.5; flex-shrink: 0; }
.qcard-main { flex: 1; min-width: 0; }
.qcard-name { font-weight: 600; font-size: 13px; color: var(--text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.qcard-state { font-size: 11px; color: var(--muted); margin-top: 2px; }
.qcard-prog {
height: 4px; background: rgba(255,255,255,.12);
border-radius: 2px; overflow: hidden; margin-top: 6px;
}
.qcard-prog-fill { height: 100%; width: 0%; background: #4caf50; transition: width .3s; }
.qcard-active { background: rgba(74,144,226,.08); border-color: rgba(74,144,226,.25); }
.qcard-active .qcard-name { color: var(--accent); }
.qcard-done { background: rgba(76,175,80,.07); opacity: .85; }
.qcard-pending { background: rgba(128,128,128,.06); opacity: .6; }
/* 历史频道卡片 */
.hist-head {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; cursor: pointer; user-select: none;
}
.hist-head-left { display: flex; align-items: center; gap: 6px;
font-size: 13px; font-weight: 600; color: var(--text); }
.hist-arrow { font-size: 10px; color: var(--muted); transition: transform .2s; }
.hist-arrow.closed { transform: rotate(-90deg); }
.btn-clear { background: none; border: none; cursor: pointer;
font-size: 11px; color: var(--muted); padding: 2px 6px;
border-radius: 4px; }
.btn-clear:hover { color: var(--red); background: rgba(248,81,73,.1); }
.hist-body { padding: 0 8px 10px; }
.hist-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
align-content: start;
align-items: start;
gap: 8px;
height: 46px;
min-height: 46px;
overflow-y: auto;
resize: vertical;
box-sizing: border-box;
}
.hist-item {
display: flex; align-items: center; gap: 8px;
padding: 5px 10px; border-radius: 7px;
cursor: pointer; border: 1px solid transparent;
transition: all .12s; background: var(--bg);
}
.hist-item:hover { background: rgba(88,166,255,.07); border-color: rgba(88,166,255,.2); }
.hist-item.sel { background: rgba(88,166,255,.12); border-color: var(--accent); }
.hist-emoji { font-size: 16px; flex-shrink: 0; }
.hist-info { flex: 1; min-width: 0; }
.hist-name { font-size: 12px; font-weight: 500;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hist-meta { font-size: 10px; color: var(--muted); margin-top: 1px; }
.hist-del {
width: 20px; height: 20px; border: none; background: none;
color: var(--muted); cursor: pointer; border-radius: 4px;
display: flex; align-items: center; justify-content: center;
opacity: 0; transition: all .12s; font-size: 11px; flex-shrink: 0;
}
.hist-item:hover .hist-del { opacity: 1; }
.hist-del:hover { background: rgba(248,81,73,.15); color: var(--red); }
.hist-empty { text-align: center; padding: 14px 8px; color: var(--muted); font-size: 12px; }
/* 下载项 */
.dl-item {
padding: 9px 0;
border-bottom: 1px solid var(--border);
}
.dl-item:last-child { border-bottom: none; }
.dl-top { display: flex; align-items: center; gap: 10px; margin-bottom: 5px; }
.dl-name {
flex: 1; min-width: 0; font-size: 12px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.dl-pct { font-size: 12px; font-weight: 700; color: var(--accent); flex-shrink: 0; }
.dl-spd { font-size: 11px; color: var(--muted); flex-shrink: 0; }
.prog { height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; }
.prog-fill {
height: 100%; border-radius: 2px;
background: linear-gradient(90deg, var(--accent), var(--green));
transition: width .4s ease;
}
.prog-fill.done { background: var(--green); }
/* 下载项控制按钮 */
.dl-ctrl { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
.dl-btn {
width: 24px; height: 24px; border: none; background: none; cursor: pointer;
color: var(--muted); border-radius: 4px; display: flex; align-items: center; justify-content: center;
font-size: 12px; flex-shrink: 0; transition: all .12s;
}
.dl-btn:hover { background: rgba(88,166,255,.12); color: var(--accent); }
.dl-btn.dl-skip:hover { background: rgba(248,81,73,.12); color: var(--red); }
.dl-btn.dl-paused { color: var(--green); }
/* 已完成项 */
.done-item {
display: flex; align-items: center; gap: 10px;
padding: 7px 0; border-bottom: 1px solid var(--border);
font-size: 12px;
}
.done-item:last-child { border-bottom: none; }
.done-tick { color: var(--green); font-size: 11px; flex-shrink: 0; }
.done-tick.skipped { color: var(--orange); }
.done-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.done-size { color: var(--muted); flex-shrink: 0; font-size: 11px; }
.done-skip-tag { font-size: 10px; color: var(--orange); flex-shrink: 0; padding: 1px 5px; border: 1px solid rgba(210,153,34,.4); border-radius: 3px; }
.done-undo {
font-size: 11px; color: var(--muted); cursor: pointer; padding: 2px 7px;
border: 1px solid var(--border); border-radius: 4px; background: none;
transition: all .12s; flex-shrink: 0; white-space: nowrap;
}
.done-undo:hover { color: var(--accent); border-color: var(--accent); }
/* 分页控件 */
.done-pager {
display: flex; align-items: center; justify-content: space-between;
padding: 6px 0 2px; font-size: 12px; color: var(--muted);
}
.done-pager-info { flex-shrink: 0; }
.done-pager-btns { display: flex; gap: 4px; }
.pg-btn {
padding: 2px 10px; border-radius: 5px; border: 1px solid var(--border);
background: none; color: var(--muted); font-size: 12px; cursor: pointer;
transition: all .12s;
}
.pg-btn:hover:not(:disabled) { color: var(--accent); border-color: var(--accent); }
.pg-btn:disabled { opacity: .35; cursor: default; }
/* 列表卡片内容区可滚动 */
#dl-list, #done-list {
height: 260px;
min-height: 60px;
overflow-y: auto;
resize: vertical;
box-sizing: border-box;
}
/* ════ 过滤器按钮 ════ */
.fb-field-filter { flex-shrink: 0; border-right: 1px solid var(--border); }
.btn-filter {
display: inline-flex; align-items: center; gap: 5px;
padding: 5px 11px; border-radius: 6px; font-size: 12px; font-weight: 500;
background: transparent; border: 1px solid var(--border);
color: var(--muted); cursor: pointer; transition: all .15s; white-space: nowrap;
}
.btn-filter:hover { border-color: var(--accent); color: var(--accent); }
.btn-filter.active { border-color: var(--orange); color: var(--orange); background: rgba(210,153,34,.08); }
.filter-badge {
display: inline-flex; align-items: center; justify-content: center;
width: 16px; height: 16px; border-radius: 50%;
background: var(--orange); color: #0d1117; font-size: 10px; font-weight: 700;
}
/* ════ 过滤器弹窗 ════ */
.filter-mask {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,.7); z-index: 450;
align-items: center; justify-content: center; padding: 16px;
}
.filter-mask.on { display: flex; }
.filter-box {
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; width: 100%; max-width: 680px;
max-height: 88vh; display: flex; flex-direction: column;
}
.filter-head {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; border-bottom: 1px solid var(--border);
}
.filter-head-title { font-size: 15px; font-weight: 700; }
.filter-mode-tabs { display: flex; gap: 2px; background: var(--bg); border-radius: 7px; padding: 2px; }
.filter-tab {
padding: 4px 12px; border-radius: 5px; font-size: 12px;
cursor: pointer; border: none; background: transparent; color: var(--muted);
transition: all .15s;
}
.filter-tab.on { background: var(--surface2); color: var(--text); }
.filter-body { padding: 16px 20px; overflow-y: auto; flex: 1; }
/* 规则行 */
.filter-rules { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
.filter-rule {
display: flex; align-items: center; gap: 7px;
background: var(--bg); border: 1px solid var(--border);
border-radius: 8px; padding: 8px 10px;
}
.filter-rule select, .filter-rule input {
background: var(--surface2); border: 1px solid var(--border);
border-radius: 5px; color: var(--text); font-size: 12px;
padding: 5px 8px; outline: none; transition: border-color .15s;
}
.filter-rule select:focus, .filter-rule input:focus { border-color: var(--accent); }
.rule-field { flex: 1.4; min-width: 0; }
.rule-op { flex: 1; min-width: 0; }
.rule-val { flex: 1.6; min-width: 0; }
.rule-unit { width: 68px; flex-shrink: 0; }
.rule-negate {
display: flex; align-items: center; gap: 4px;
font-size: 11px; color: var(--muted); flex-shrink: 0; white-space: nowrap; cursor: pointer;
}
.rule-negate input { width: 13px; height: 13px; cursor: pointer; accent-color: var(--orange); }
.rule-del {
width: 24px; height: 24px; border: none; background: none; cursor: pointer;
color: var(--muted); border-radius: 4px; display: flex; align-items: center; justify-content: center;
font-size: 13px; flex-shrink: 0; transition: all .12s;
}
.rule-del:hover { background: rgba(248,81,73,.15); color: var(--red); }
.btn-add-rule {
display: inline-flex; align-items: center; gap: 5px;
padding: 6px 14px; border-radius: 6px; font-size: 12px;
background: transparent; border: 1px dashed var(--border);
color: var(--muted); cursor: pointer; transition: all .15s;
}
.btn-add-rule:hover { border-color: var(--accent); color: var(--accent); }
/* 预览区 */
.filter-preview {
margin-top: 12px; padding: 10px 12px; border-radius: 7px;
background: var(--bg); border: 1px solid var(--border);
}
.filter-preview-label { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 5px; }
.filter-preview-expr {
font-family: monospace; font-size: 12px; color: var(--green);
word-break: break-all; min-height: 18px;
}
.filter-preview-expr.empty { color: var(--muted); font-style: italic; }
.filter-val-tip {
display: flex; align-items: center; gap: 6px; margin-top: 6px;
font-size: 11px; padding: 5px 8px; border-radius: 5px;
}
.filter-val-tip.ok { background: rgba(63,185,80,.07); color: var(--green); }
.filter-val-tip.err { background: rgba(248,81,73,.07); color: var(--red); }
/* 手动模式 */
.filter-manual { display: none; }
.filter-manual.on { display: block; }
.filter-visual { display: block; }
.filter-visual.hide { display: none; }
.filter-manual textarea {
width: 100%; height: 80px; resize: vertical;
background: var(--bg); border: 1px solid var(--border);
border-radius: 6px; color: var(--text); font-family: monospace; font-size: 13px;
padding: 8px 10px; outline: none; transition: border-color .15s;
}
.filter-manual textarea:focus { border-color: var(--accent); }
.filter-manual .hint { font-size: 11px; color: var(--muted); margin-top: 5px; }
.filter-foot {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 20px; border-top: 1px solid var(--border); gap: 8px;
}
/* 空状态 */
.empty { text-align: center; padding: 28px 16px; color: var(--muted); font-size: 13px; }
.empty svg { display: block; margin: 0 auto 10px; opacity: .25; }
/* Toast */
.toasts { position: fixed; top: 60px; right: 14px; display: flex; flex-direction: column; gap: 7px; z-index: 999; pointer-events: none; }
.toast { padding: 9px 16px; border-radius: 7px; font-size: 13px; animation: tin .2s ease; }
.toast-ok { background: rgba(63,185,80,.92); color: #0d1117; }
.toast-err { background: rgba(248,81,73,.92); color: #fff; }
@keyframes tin { from { opacity:0; transform:translateX(16px); } to { opacity:1; transform:none; } }
/* Spinner */
.spin {
display: inline-block; width: 11px; height: 11px;
border: 2px solid rgba(255,255,255,.3); border-radius: 50%;
border-top-color: currentColor; animation: sp 1s linear infinite;
}
@keyframes sp { to { transform: rotate(360deg); } }
/* ════ 设置向导 ════ */
.wizard-mask {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,.82); z-index: 500;
align-items: center; justify-content: center; padding: 16px;
}
.wizard-mask.on { display: flex; }
.wizard-box {
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; width: 100%; max-width: 580px;
max-height: 90vh; overflow-y: auto;
display: flex; flex-direction: column;
}
.wiz-steps {
display: flex; align-items: center;
padding: 20px 24px 0; gap: 0;
}
.ws {
display: flex; align-items: center; gap: 7px;
flex: 1; position: relative;
}
.ws:not(:last-child)::after {
content: ''; position: absolute;
left: 26px; right: 0; top: 13px;
height: 1px; background: var(--border); z-index: 0;
}
.ws.done::after, .ws.active::after { background: var(--accent); }
.ws-num {
width: 26px; height: 26px; border-radius: 50%;
border: 2px solid var(--border);
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 700; color: var(--muted);
background: var(--surface); flex-shrink: 0; position: relative; z-index: 1;
transition: all .2s;
}
.ws.active .ws-num { border-color: var(--accent); color: var(--accent); }
.ws.done .ws-num { border-color: var(--green); background: var(--green); color: #0d1117; }
.ws-lbl { font-size: 11px; color: var(--muted); white-space: nowrap; }
.ws.active .ws-lbl { color: var(--accent); }
.ws.done .ws-lbl { color: var(--green); }
@media (max-width: 440px) { .ws-lbl { display: none; } }
.wiz-body { padding: 22px 24px; flex: 1; }
.wiz-title { font-size: 17px; font-weight: 700; margin-bottom: 4px; }
.wiz-sub { font-size: 12px; color: var(--muted); margin-bottom: 18px; }
.guide-list { display: flex; flex-direction: column; gap: 12px; margin-bottom: 18px; }
.guide-item { display: flex; gap: 12px; align-items: flex-start; }
.guide-n {
width: 24px; height: 24px; flex-shrink: 0; border-radius: 50%;
background: rgba(88,166,255,.12); border: 1px solid rgba(88,166,255,.25);
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 700; color: var(--accent);
}
.guide-c b { font-size: 13px; display: block; margin-bottom: 2px; }
.guide-c p { font-size: 12px; color: var(--muted); line-height: 1.6; }
.guide-c a { color: var(--accent); }
.guide-c code {
background: rgba(88,166,255,.1); border: 1px solid rgba(88,166,255,.2);
border-radius: 3px; padding: 0 5px; font-size: 11px; color: var(--accent);
}
.info-tip {
padding: 10px 12px; border-radius: 7px;
font-size: 12px; line-height: 1.6; margin-bottom: 14px;
background: rgba(88,166,255,.07); border: 1px solid rgba(88,166,255,.18); color: var(--muted);
}
.info-warn {
padding: 10px 12px; border-radius: 7px; font-size: 12px; line-height: 1.6; margin-bottom: 14px;
background: rgba(210,153,34,.08); border: 1px solid rgba(210,153,34,.25); color: #d29922;
}
.info-ok {
padding: 10px 12px; border-radius: 7px; font-size: 12px; line-height: 1.6; margin-bottom: 14px;
background: rgba(63,185,80,.08); border: 1px solid rgba(63,185,80,.25); color: var(--green);
}
.toggle-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.tog { position: relative; width: 38px; height: 20px; }
.tog input { opacity: 0; width: 0; height: 0; }
.tog-track {
position: absolute; inset: 0; background: var(--border);
border-radius: 10px; cursor: pointer; transition: background .2s;
}
.tog-track::after {
content: ''; position: absolute; width: 14px; height: 14px;
border-radius: 50%; background: #fff; top: 3px; left: 3px; transition: transform .2s;
}
.tog input:checked + .tog-track { background: var(--accent); }
.tog input:checked + .tog-track::after { transform: translateX(18px); }
.proxy-fields { display: none; margin-bottom: 6px; }
.proxy-fields.on { display: block; }
.proxy-2col { display: grid; grid-template-columns: 1fr 90px; gap: 8px; }
.wiz-field { margin-bottom: 10px; }
.wiz-field-label { display: block; font-size: 11px; color: var(--muted); margin-bottom: 4px; }
.proxy-tbl { font-size: 11px; color: var(--muted); border-collapse: collapse; width: 100%; margin-top: 8px; }
.proxy-tbl th { text-align: left; padding: 2px 6px; color: var(--text); font-weight: 500; }
.proxy-tbl td { padding: 2px 6px; text-align: center; }
.summary-box { background: var(--bg); border: 1px solid var(--border); border-radius: 7px; overflow: hidden; margin-bottom: 16px; }
.sum-row { display: flex; justify-content: space-between; padding: 9px 12px; font-size: 12px; border-bottom: 1px solid var(--border); }
.sum-row:last-child { border-bottom: none; }
.sum-k { color: var(--muted); }
.sum-v { color: var(--green); font-family: monospace; }
.login-box { background: rgba(210,153,34,.07); border: 1px solid rgba(210,153,34,.25); border-radius: 7px; padding: 12px 14px; margin-bottom: 14px; font-size: 12px; }
.login-box-title { color: #d29922; font-weight: 600; margin-bottom: 6px; }
.login-box ol { color: var(--muted); padding-left: 16px; line-height: 1.9; }
.login-box code { color: var(--accent); }
.wiz-foot {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 24px; border-top: 1px solid var(--border); gap: 10px;
}
/* ════ 下载队列 ════ */
.submit-row { display: flex; gap: 6px; align-items: stretch; }
.btn-add-queue {
padding: 9px 14px; border-radius: 7px;
background: transparent; border: 1px solid var(--border);
color: var(--muted); font-size: 13px; font-weight: 600;
cursor: pointer; transition: all .15s; white-space: nowrap;
}
.btn-add-queue:hover { border-color: var(--accent); color: var(--accent); }
.queue-item {
display: flex; align-items: center; gap: 10px;
padding: 9px 2px; border-bottom: 1px solid var(--border);
}
.queue-item:last-child { border-bottom: none; }
.queue-num {
width: 22px; height: 22px; border-radius: 50%;
background: rgba(88,166,255,.12); border: 1px solid rgba(88,166,255,.25);
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 700; color: var(--accent); flex-shrink: 0;
}
.queue-info { flex: 1; min-width: 0; }
.queue-name { font-size: 13px; font-weight: 500;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.queue-filter { font-size: 11px; color: var(--muted); margin-top: 2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.queue-del {
width: 26px; height: 26px; border: none; background: none;
color: var(--muted); cursor: pointer; border-radius: 4px;
display: flex; align-items: center; justify-content: center;
font-size: 13px; flex-shrink: 0; transition: all .12s;
}
.queue-del:hover { background: rgba(248,81,73,.15); color: var(--red); }
/* 响应式:手机适配(≤ 768px) */
@media (max-width: 768px) {
/* 顶部导航变两行,把 --hdr-h 调大供 formbar margin 跟随 */
:root { --hdr-h: 88px; }
/* 页面滚动交给整页而非固定容器,避免 fixed 高度硬编码 bug */
html, body { height: auto; }
/* 顶部导航:允许换行,分两行显示,防止文字被挤压竖排 */
.hdr {
height: auto;
min-height: var(--hdr-h);
flex-wrap: wrap;
padding: 6px 10px;
gap: 8px;
align-items: center;
}
.hdr-brand { font-size: 13px; flex-shrink: 0; }
.hdr-brand small { display: none; }
.hdr-divider { display: none; }
.hdr-right { margin-left: auto; gap: 6px; flex-shrink: 0; }
.state-pill { padding: 3px 8px; font-size: 11px; white-space: nowrap; flex-shrink: 0; }
.btn-pause { padding: 5px 12px; font-size: 11px; white-space: nowrap; flex-shrink: 0; }
/* 统计数字独占第二行(order: 5 让它换到 brand/right 之后) */
.hdr-stats {
order: 5;
flex-basis: 100%;
width: 100%;
gap: 0;
padding-top: 6px;
border-top: 1px solid var(--border);
justify-content: space-around;
}
.hstat { text-align: center; white-space: nowrap; }
.hstat-v { font-size: 13px; white-space: nowrap; }
.hstat-l { font-size: 10px; white-space: nowrap; margin-top: 1px; }
/* 表单栏:取消固定定位,改为跟内容一起滚;字段全占满宽度 */
.formbar {
position: static;
height: auto;
flex-wrap: wrap;
padding: 10px 12px;
gap: 10px;
margin-top: var(--hdr-h);
}
.fb-field { padding: 0; width: 100%; border-right: none !important; min-width: 0; }
.fb-field-submit { padding: 0; }
.submit-row { flex-direction: column; gap: 8px; }
.btn-add-queue { width: 100%; padding: 11px; font-size: 13px; }
.btn-submit { width: 100%; justify-content: center; padding: 12px; font-size: 14px; }
.queue-filter { font-size: 10px; }
/* 验证下拉宽度自适应,避免溢出 */
.val-drop { width: auto; left: 12px; right: 12px; }
/* 主内容区:取消固定,走正常流 */
.main-scroll {
position: static;
padding: 12px;
gap: 10px;
}
/* 卡片内边距缩小 */
.card-hd { padding: 8px 12px; }
.card-bd { padding: 10px 12px; }
/* 历史频道网格一行两列,更紧凑 */
.hist-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
/* 下载项操作按钮加大便于点按 */
.dl-btn { width: 28px; height: 28px; font-size: 13px; }
/* 列表高度减小 */
#dl-list, #done-list { height: 220px; }
/* 过滤弹窗:占满屏幕、规则行竖排堆叠 */
.filter-mask { padding: 8px; align-items: flex-start; padding-top: 8vh; }
.filter-box { max-width: 100%; max-height: 85vh; }
.filter-head { padding: 12px 14px; }
.filter-head-title { font-size: 14px; }
.filter-body { padding: 12px 14px; }
.filter-foot { padding: 10px 14px; flex-wrap: wrap; }
.filter-rule { flex-wrap: wrap; gap: 6px; padding: 8px; }
.rule-field, .rule-op, .rule-val, .rule-unit { flex: 1 1 calc(50% - 3px); min-width: 0; }
.rule-negate { flex: 1 1 auto; }
.rule-del { margin-left: auto; }
/* 向导弹窗:占满屏幕、步骤圆点紧凑 */
.wizard-mask { padding: 8px; align-items: flex-start; padding-top: 5vh; }
.wizard-box { max-width: 100%; max-height: 90vh; }
.wiz-steps { padding: 14px 14px 0; }
.wiz-body { padding: 14px; }
.wiz-foot { padding: 12px 14px; gap: 8px; }
.wiz-title { font-size: 15px; }
.ws-num { width: 22px; height: 22px; font-size: 10px; }
/* Toast 通知全宽居中(避开变高的 hdr) */
.toasts { top: calc(var(--hdr-h) + 6px); right: 8px; left: 8px; }
.toast { font-size: 12px; }
}
</style>
</head>
<body>
<!-- ════ 过滤器弹窗 ════ -->
<div class="filter-mask" id="filter-mask">
<div class="filter-box">
<div class="filter-head">
<div class="filter-head-title">⚙ 过滤条件配置</div>
<div class="filter-mode-tabs">
<button class="filter-tab on" id="ftab-visual" onclick="switchFilterTab('visual')">可视化</button>
<button class="filter-tab" id="ftab-manual" onclick="switchFilterTab('manual')">手动输入</button>
</div>
</div>
<div class="filter-body">
<!-- 可视化模式 -->
<div class="filter-visual" id="fv-visual">
<div class="filter-rules" id="filter-rules"></div>
<button class="btn-add-rule" onclick="addFilterRule()"> 添加条件</button>
<div style="margin-top:10px;font-size:11px;color:var(--muted);">
多个条件之间为 <b style="color:var(--text)">AND(且)</b> 关系;勾选「排除」则对该条件取反(NOT)
</div>
</div>
<!-- 手动输入模式 -->
<div class="filter-manual" id="fv-manual">
<textarea id="filter-manual-input" placeholder="如:message_date >= 2024-01-01 00:00:00 and media_file_size >= 10485760" oninput="onManualFilterInput()"></textarea>
<div class="hint">
可用字段:message_date、media_file_size、media_width、media_height、media_duration、media_file_name、message_caption<br>
操作符:>=、<=、>、<、==、!=、matches(正则)、contains(包含)、not(取反)<br>
示例:<code style="color:var(--accent)">not media_file_name matches '.*\.txt$'</code>
</div>
</div>
<!-- 预览 -->
<div class="filter-preview">
<div class="filter-preview-label">生成的过滤表达式</div>
<div class="filter-preview-expr empty" id="filter-expr-preview">(无过滤条件)</div>
<div id="filter-val-tip" style="display:none" class="filter-val-tip"></div>
</div>
</div>
<div class="filter-foot">
<button class="btn btn-outline btn-sm" onclick="closeFilterModal()">取消</button>
<button class="btn btn-outline btn-sm" id="filter-validate-btn" onclick="validateFilterExpr()"><span id="fvbtn-txt">校验语法</span></button>
<div style="flex:1"></div>
<button class="btn btn-outline btn-sm" onclick="clearFilter()" style="color:var(--red)">清除过滤</button>
<button class="btn btn-primary btn-sm" onclick="applyFilter()">应用</button>
</div>
</div>
</div>
<!-- ════ 设置向导 ════ -->
<div class="wizard-mask" id="wiz-mask">
<div class="wizard-box">
<div class="wiz-steps" id="wiz-steps">
<div class="ws active" id="ws-0"><div class="ws-num">1</div><span class="ws-lbl">API 凭证</span></div>
<div class="ws" id="ws-1"><div class="ws-num">2</div><span class="ws-lbl">网络 & 路径</span></div>
<div class="ws" id="ws-2"><div class="ws-num">3</div><span class="ws-lbl">确认</span></div>
</div>
<!-- Step 0 -->
<div class="wiz-body" id="wp-0">
<div class="wiz-title">获取 Telegram API 凭证</div>
<div class="wiz-sub">连接 Telegram 需要你自己的 API 密钥,申请免费,约 1 分钟</div>
<div class="guide-list">
<div class="guide-item">
<div class="guide-n">1</div>
<div class="guide-c">
<b>打开官方开发者平台</b>
<p>访问 <a href="https://my.telegram.org/apps" target="_blank">my.telegram.org/apps</a>,用 Telegram 账号登录。<br>⚠️ 验证码发到 <b>Telegram App 内收件箱</b>,不是短信</p>
</div>
</div>
<div class="guide-item">
<div class="guide-n">2</div>
<div class="guide-c">
<b>创建应用</b>
<p>填写任意名称,Platform 选 <code>Desktop</code>,点击「Create application」</p>
</div>
</div>
<div class="guide-item">
<div class="guide-n">3</div>
<div class="guide-c">
<b>复制密钥到下方</b>
<p>页面会显示 <code>App api_id</code><code>App api_hash</code></p>
</div>
</div>
</div>
<div class="wiz-field">
<label class="wiz-field-label">api_id(纯数字)</label>
<input class="field-input" type="number" id="w-api-id" placeholder="如:12345678">
</div>
<div class="wiz-field">
<label class="wiz-field-label">api_hash32位字符串)</label>
<input class="field-input" type="text" id="w-api-hash" placeholder="从 my.telegram.org 复制">
</div>
<div class="info-tip">💡 遇到「too many tries」提示,等待 1~2 小时后重试</div>
</div>
<!-- Step 1 -->
<div class="wiz-body" id="wp-1" style="display:none">
<div class="wiz-title">网络代理 & 保存路径</div>
<div class="wiz-sub">国内需要代理才能连接 Telegram</div>
<div class="toggle-row">
<span style="font-size:13px;">启用代理(国内用户必选)</span>
<label class="tog"><input type="checkbox" id="w-proxy-on" onchange="toggleProxyFields()"><div class="tog-track"></div></label>
</div>
<div class="proxy-fields" id="w-proxy-fields">
<div class="wiz-field">
<label class="wiz-field-label">代理类型</label>
<select class="field-input" id="w-proxy-scheme">
<option value="socks5">SOCKS5Clash 默认 7891</option>
<option value="http">HTTPClash 默认 7890</option>
</select>
</div>
<div class="proxy-2col">
<div class="wiz-field" style="margin-bottom:0">
<label class="wiz-field-label">地址</label>
<input class="field-input" type="text" id="w-proxy-host" value="127.0.0.1">
</div>
<div class="wiz-field" style="margin-bottom:0">
<label class="wiz-field-label">端口</label>
<input class="field-input" type="number" id="w-proxy-port" value="7891">
</div>
</div>
<table class="proxy-tbl">
<tr><th>代理软件</th><th>SOCKS5</th><th>HTTP</th></tr>
<tr><td>Clash</td><td>7891</td><td>7890</td></tr>
<tr><td>V2Ray</td><td>10808</td><td>10809</td></tr>
<tr><td>Shadowsocks</td><td>1080</td><td></td></tr>
</table>
<div style="margin-top:10px;">
<button class="btn btn-outline btn-sm" onclick="testWizProxy()" id="w-proxy-test-btn">🔌 测试代理</button>
<span id="w-proxy-test-result" style="font-size:11px;margin-left:8px;"></span>
</div>
</div>
<div class="wiz-field" style="margin-top:12px">
<label class="wiz-field-label">媒体文件保存路径</label>
<input class="field-input" type="text" id="w-save-path" placeholder="/Users/yourname/Downloads/tg">
<div style="font-size:11px;color:var(--muted);margin-top:4px;">留空使用当前配置,建议填写绝对路径</div>
</div>
</div>
<!-- Step 2 -->
<div class="wiz-body" id="wp-2" style="display:none">
<div style="text-align:center;font-size:48px;margin-bottom:12px;">🎉</div>
<div class="wiz-title" style="text-align:center;">配置确认</div>
<div class="wiz-sub" style="text-align:center;margin-bottom:16px;">点击「保存并启动」完成设置</div>
<div class="summary-box" id="w-summary"></div>
<div class="login-box" id="w-login-notice" style="display:none">
<div class="login-box-title">⚠️ 首次运行需在终端完成账号验证</div>
<ol>
<li>保存后程序自动重启</li>
<li>切换到<b>终端窗口</b></li>
<li>输入手机号(如 <code>+8613800138000</code></li>
<li>输入 Telegram App 内收到的验证码</li>
<li>验证成功后刷新此页面</li>
</ol>
</div>
<div class="info-ok" id="w-session-ok" style="display:none">✅ 已有登录记录,保存后直接可用</div>
</div>
<div class="wiz-foot">
<button class="btn btn-outline btn-sm" id="w-back" onclick="wizBack()" style="display:none">← 上一步</button>
<div style="flex:1"></div>
<button class="btn btn-primary" id="w-next" onclick="wizNext()">下一步 →</button>
</div>
</div>
</div>
<!-- ════ HEADER ════ -->
<header class="hdr">
<div class="hdr-brand">TG 下载器 <small>v<span id="app-ver"></span></small></div>
<div class="hdr-divider"></div>
<div class="hdr-stats">
<div class="hstat"><div class="hstat-v s" id="h-speed">0 B/s</div><div class="hstat-l">速度</div></div>
<div class="hstat"><div class="hstat-v" id="h-dl">0</div><div class="hstat-l">下载中</div></div>
<div class="hstat"><div class="hstat-v" id="h-done">0</div><div class="hstat-l">已完成</div></div>
<div class="hstat"><div class="hstat-v o" id="h-skip">0</div><div class="hstat-l">已跳过</div></div>
</div>
<div class="hdr-right">
<div class="state-pill"><div class="state-dot" id="state-dot"></div><span id="state-txt">连接中</span></div>
<a href="/settings" class="btn-pause" style="text-decoration:none;" title="设置"></a>
<button class="btn-pause" id="btn-pause" onclick="toggleDownload()">⏸ 暂停</button>
</div>
</header>
<!-- ════ FORM BAR ════ -->
<div class="formbar">
<!-- 频道 -->
<div class="fb-field fb-field-channel">
<div class="fb-label">频道</div>
<div class="fb-input-row">
<input class="field-input" id="channel-input" placeholder="t.me/xxx 或 @username">
<button class="btn btn-outline btn-sm" id="validate-btn" onclick="validateChannel()">验证</button>
</div>
<div id="val-result" class="val-drop">
<div class="val-icon" id="val-icon"></div>
<div class="val-info"><b id="val-name"></b><span id="val-type"></span></div>
</div>
</div>
<!-- 开始日期 -->
<div class="fb-field fb-field-date">
<div class="fb-label">开始日期</div>
<input class="field-input" type="date" id="start-date">
</div>
<!-- 结束日期 -->
<div class="fb-field fb-field-date">
<div class="fb-label">结束日期(可选)</div>
<input class="field-input" type="date" id="end-date">
</div>
<!-- 过滤器 -->
<div class="fb-field fb-field-filter" style="padding:0 12px;">
<div class="fb-label">过滤条件</div>
<button class="btn-filter" id="filter-open-btn" onclick="openFilterModal()">
⚙ 过滤器 <span class="filter-badge" id="filter-badge" style="display:none">0</span>
</button>
</div>
<!-- 保存路径 -->
<div class="fb-field fb-field-path">
<div class="fb-label">
保存路径
<button class="btn btn-outline btn-sm" id="edit-path-btn" onclick="togglePathEdit()"
style="padding:1px 7px;font-size:10px;margin-left:2px;">编辑</button>
</div>
<div class="path-row">
<span class="path-display" id="cfg-path" title="">加载中…</span>
</div>
<div class="path-edit-drop" id="path-edit-area">
<input class="field-input" id="path-input" type="text" placeholder="输入绝对路径">
<div class="path-edit-btns">
<button class="btn btn-primary btn-sm" style="flex:1" onclick="savePath()">保存</button>
<button class="btn btn-outline btn-sm" style="flex:1" onclick="togglePathEdit()">取消</button>
</div>
</div>
</div>
<!-- 提交 -->
<div class="fb-field fb-field-submit">
<div class="submit-row">
<button class="btn-add-queue" id="add-queue-btn" onclick="addToQueue()">
加入队列
</button>
<button class="btn-submit" id="save-btn" onclick="saveAndRestart()" style="flex:1">
🔄 保存并重启下载
</button>
</div>
</div>
</div>
<div class="toasts" id="toasts"></div>
<!-- ════ MAIN CONTENT ════ -->
<div class="main-scroll">
<!-- 任务队列总览 -->
<div class="task-banner" id="task-banner">
<div class="qbanner-head">
<span class="qbanner-title">任务队列</span>
<span class="qbanner-summary" id="qbanner-summary"></span>
</div>
<div id="qbanner-list"></div>
</div>
<!-- 下载队列(待处理) -->
<div class="card" id="queue-card" style="display:none">
<div class="card-hd">
<span>
待处理队列
<span class="card-hd-badge" id="queue-count" style="margin-left:6px">0 个</span>
</span>
<button class="btn-clear" onclick="clearQueue()">清空</button>
</div>
<div class="card-bd" id="queue-list"></div>
</div>
<!-- 历史频道 -->
<div class="card">
<div class="hist-head" onclick="toggleHist()">
<div class="hist-head-left">
<span class="hist-arrow" id="hist-arrow"></span>
历史频道
</div>
<button class="btn-clear" id="hist-clear-btn" style="display:none"
onclick="event.stopPropagation();clearHistory()">清空</button>
</div>
<div id="hist-body" class="hist-body">
<div class="hist-grid" id="hist-list">
<div class="hist-empty">暂无历史记录</div>
</div>
</div>
</div>
<!-- 正在下载 -->
<div class="card">
<div class="card-hd collapsible" id="dl-head" onclick="toggleDl()">
<div class="card-hd-left">
<span class="hist-arrow" id="dl-arrow"></span>
<span>正在下载</span>
</div>
<span class="card-hd-badge" id="dl-badge">0 个文件</span>
</div>
<div class="card-bd" id="dl-list">
<div class="empty">
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
暂无下载任务
</div>
</div>
</div>
<!-- 已完成 -->
<div class="card">
<div class="card-hd collapsible" id="done-head" onclick="toggleDone()">
<div class="card-hd-left">
<span class="hist-arrow" id="done-arrow"></span>
<span>已完成</span>
</div>
<span class="card-hd-badge" id="done-badge">0 个文件</span>
</div>
<div class="card-bd" id="done-list">
<div class="empty">暂无已完成文件</div>
</div>
<div class="done-pager" id="done-pager" style="display:none">
<span class="done-pager-info" id="done-pager-info"></span>
<div class="done-pager-btns">
<button class="pg-btn" id="pg-prev" onclick="donePageGo(-1)"> 上一页</button>
<button class="pg-btn" id="pg-next" onclick="donePageGo(1)">下一页 </button>
</div>
</div>
</div>
</div>
<script>
let dlState = '{{ download_state }}';
let validatedChannel = null;
let histOpen = true;
let dlOpen = true;
let doneOpen = true;
let setupStatus = {};
let wizStep = 0;
let donePage = 1;
const donePageSize = 50;
// ── Toast ──
function toast(msg, type = 'ok') {
const el = document.createElement('div');
el.className = 'toast toast-' + type;
el.textContent = msg;
document.getElementById('toasts').appendChild(el);
setTimeout(() => el.remove(), 3200);
}
// ── 工具 ──
function parseChannelId(s) {
s = (s || '').trim();
const m = s.match(/t\.me\/([a-zA-Z0-9_]+)/);
if (m) return m[1];
return s.startsWith('@') ? s.slice(1) : s;
}
const TICON = { CHANNEL:'📺', SUPERGROUP:'👥', GROUP:'👥', PRIVATE:'👤', BOT:'🤖' };
const TNAME = { CHANNEL:'频道', SUPERGROUP:'超级群组', GROUP:'群组', PRIVATE:'私聊', BOT:'机器人' };
function fmtTime(iso) {
if (!iso) return '';
const d = new Date(iso), diff = Date.now() - d;
if (diff < 60000) return '刚刚';
if (diff < 3600000) return Math.floor(diff/60000) + '分钟前';
if (diff < 86400000) return Math.floor(diff/3600000) + '小时前';
return d.toLocaleDateString('zh-CN');
}
// ── 加载配置 ──
function parseFilterDisplay(filter) {
if (!filter) return '';
const startM = filter.match(/message_date\s*>=\s*(\d{4}-\d{2}-\d{2})/);
const endM = filter.match(/message_date\s*<=\s*(\d{4}-\d{2}-\d{2})/);
const parts = [];
if (startM || endM) {
if (startM && endM) parts.push('📅 ' + startM[1] + ' 至 ' + endM[1]);
else if (startM) parts.push('📅 ' + startM[1] + ' 起');
else parts.push('📅 至 ' + endM[1]);
// 检查是否还有日期之外的自定义条件
const rest = filter
.replace(/message_date\s*>=\s*\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}/gi, '')
.replace(/message_date\s*<=\s*\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}/gi, '')
.replace(/\band\b/gi, '').trim();
if (rest) parts.push('🔍 ' + rest);
} else {
parts.push('🔍 ' + filter);
}
return parts.join(' · ');
}
function loadConfig() {
fetch('/api/get_config').then(r => r.json()).then(d => {
const p = d.save_path || '默认路径';
const el = document.getElementById('cfg-path');
el.textContent = p; el.title = p;
document.getElementById('path-input').value = d.save_path || '';
}).catch(() => {});
}
function togglePathEdit() {
const area = document.getElementById('path-edit-area');
const btn = document.getElementById('edit-path-btn');
const open = !area.classList.contains('on');
area.classList.toggle('on', open);
btn.textContent = open ? '收起' : '编辑';
if (open) document.getElementById('path-input').focus();
}
function savePath() {
const val = document.getElementById('path-input').value.trim();
if (!val) { toast('路径不能为空', 'err'); return; }
fetch('/api/save_path', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ save_path: val }) })
.then(r => r.json()).then(d => {
if (d.success) {
const el = document.getElementById('cfg-path');
el.textContent = val; el.title = val;
togglePathEdit(); toast('保存路径已更新');
} else toast('保存失败:' + (d.error||''), 'err');
}).catch(e => toast('保存失败:' + e.message, 'err'));
}
// ── 历史频道 ──
function toggleHist() {
histOpen = !histOpen;
document.getElementById('hist-body').style.display = histOpen ? '' : 'none';
document.getElementById('hist-arrow').className = 'hist-arrow' + (histOpen ? '' : ' closed');
}
// ── 正在下载 / 已完成 折叠 ──
function toggleDl() {
dlOpen = !dlOpen;
document.getElementById('dl-list').style.display = dlOpen ? '' : 'none';
document.getElementById('dl-arrow').className = 'hist-arrow' + (dlOpen ? '' : ' closed');
document.getElementById('dl-head').classList.toggle('collapsed', !dlOpen);
}
function toggleDone() {
doneOpen = !doneOpen;
document.getElementById('done-list').style.display = doneOpen ? '' : 'none';
document.getElementById('done-arrow').className = 'hist-arrow' + (doneOpen ? '' : ' closed');
document.getElementById('done-head').classList.toggle('collapsed', !doneOpen);
// 分页器跟 done-list 联动,仅在展开且本来就显示时才恢复
const pager = document.getElementById('done-pager');
if (!doneOpen) {
pager.dataset.hiddenByToggle = pager.style.display !== 'none' ? '1' : '';
pager.style.display = 'none';
} else if (pager.dataset.hiddenByToggle === '1') {
pager.style.display = '';
pager.dataset.hiddenByToggle = '';
}
}
function loadHistory() {
fetch('/api/channel_history').then(r => r.json()).then(d => {
const list = document.getElementById('hist-list');
const clrBtn = document.getElementById('hist-clear-btn');
if (!d.success || !d.history?.length) {
list.innerHTML = '<div class="hist-empty">暂无历史记录</div>';
clrBtn.style.display = 'none'; return;
}
clrBtn.style.display = '';
list.innerHTML = d.history.map(item => `
<div class="hist-item" id="hi-${item.chat_id}"
onclick="selHistory('${item.chat_id}','${(item.chat_title||'').replace(/'/g,"\\'")}','${item.chat_type||''}')">
<div class="hist-emoji">${TICON[item.chat_type]||'📁'}</div>
<div class="hist-info">
<div class="hist-name">${item.chat_title||item.chat_id}</div>
<div class="hist-meta">@${item.chat_id} · ${fmtTime(item.last_used)}</div>
</div>
<button class="hist-del" onclick="event.stopPropagation();delHistory('${item.chat_id}')">✕</button>
</div>`).join('');
}).catch(() => {});
}
function selHistory(chatId, chatTitle, chatType) {
document.getElementById('channel-input').value = chatId;
validatedChannel = { valid:true, chat_id:chatId, chat_title:chatTitle||chatId, chat_type:chatType };
showVal(validatedChannel);
document.querySelectorAll('.hist-item').forEach(e => e.classList.remove('sel'));
const el = document.getElementById('hi-' + chatId);
if (el) el.classList.add('sel');
toast('已选择: ' + (chatTitle||chatId));
}
function delHistory(chatId) {
fetch('/api/channel_history/' + encodeURIComponent(chatId), { method:'DELETE' })
.then(r => r.json()).then(d => {
if (d.success) { loadHistory(); toast('已删除'); }
else toast('删除失败', 'err');
});
}
function clearHistory() {
if (!confirm('确定清空所有历史记录?')) return;
fetch('/api/channel_history/clear', { method:'POST' })
.then(r => r.json()).then(d => {
if (d.success) { loadHistory(); toast('已清空'); }
});
}
// ── 频道验证 ──
function showVal(result) {
const box = document.getElementById('val-result');
const icon = document.getElementById('val-icon');
const name = document.getElementById('val-name');
const type = document.getElementById('val-type');
if (result.valid) {
box.className = 'val-drop ok';
icon.textContent = TICON[result.chat_type] || '✅';
name.textContent = result.chat_title;
type.textContent = TNAME[result.chat_type] || result.chat_type || '';
} else {
box.className = 'val-drop err';
icon.textContent = '❌';
name.textContent = '验证失败';
type.textContent = result.error || '无法访问该频道';
}
}
function validateChannel() {
const channelId = parseChannelId(document.getElementById('channel-input').value);
if (!channelId) { toast('请输入频道', 'err'); return; }
const btn = document.getElementById('validate-btn');
btn.disabled = true; btn.innerHTML = '<span class="spin"></span>';
document.getElementById('val-result').className = 'val-drop';
validatedChannel = null;
fetch('/api/validate_chat', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ chat_id: channelId }) })
.then(r => r.json()).then(d => {
showVal(d);
if (d.valid) { validatedChannel = d; toast('验证成功: ' + d.chat_title); }
else toast('验证失败: ' + (d.error||''), 'err');
}).catch(e => { showVal({ valid:false, error: e.message }); toast('验证失败', 'err'); })
.finally(() => { btn.disabled = false; btn.textContent = '验证'; });
}
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('channel-input').addEventListener('input', () => {
validatedChannel = null;
document.getElementById('val-result').className = 'val-drop';
document.querySelectorAll('.hist-item').forEach(e => e.classList.remove('sel'));
});
});
// ── 下载队列 ──
let downloadQueue = [];
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
})[c]);
}
function buildFilterFromForm(startDate, endDate) {
const custom = filterExpression || '';
if (custom) return custom;
if (!startDate) return '';
return 'message_date >= ' + startDate + ' 00:00:00' +
(endDate ? ' and message_date <= ' + endDate + ' 23:59:59' : '');
}
// 把队列里的起止日期格式化成 "📅 2025-11-20 至 2026-04-23";无日期则 fallback 到原 filter
function formatQueueDateRange(startDate, endDate, fallbackFilter) {
if (startDate) {
return '📅 ' + startDate + ' 至 ' + (endDate || startDate);
}
return fallbackFilter || '无过滤条件';
}
function renderQueue() {
const card = document.getElementById('queue-card');
const list = document.getElementById('queue-list');
const count = document.getElementById('queue-count');
if (!downloadQueue.length) { card.style.display = 'none'; return; }
card.style.display = '';
count.textContent = downloadQueue.length + ' 个';
list.innerHTML = downloadQueue.map((q, i) => {
const dateStr = formatQueueDateRange(q.start_date, q.end_date, q.download_filter);
return `
<div class="queue-item">
<div class="queue-num">${i + 1}</div>
<div class="queue-info">
<div class="queue-name">${escapeHtml(q.chat_title || q.chat_id)}</div>
<div class="queue-filter" title="${escapeHtml(q.download_filter || '')}">${escapeHtml(dateStr)}</div>
</div>
<button class="queue-del" onclick="removeFromQueue(${i})" title="移除">✕</button>
</div>
`;
}).join('');
}
function addToQueue() {
const channelId = parseChannelId(document.getElementById('channel-input').value);
const startDate = document.getElementById('start-date').value;
const endDate = document.getElementById('end-date').value;
if (!channelId) { toast('请输入有效的频道', 'err'); return; }
if (!startDate) { toast('请选择开始日期', 'err'); return; }
if (downloadQueue.some(q => q.chat_id === channelId)) {
toast('该频道已在队列中', 'err'); return;
}
const doAdd = (chatTitle, chatType) => {
downloadQueue.push({
chat_id: channelId,
chat_title: chatTitle || channelId,
chat_type: chatType || '',
download_filter: buildFilterFromForm(startDate, endDate),
start_date: startDate,
end_date: endDate,
});
renderQueue();
// 清空表单以便继续添加下一个频道
document.getElementById('channel-input').value = '';
validatedChannel = null;
const valBox = document.getElementById('val-result');
if (valBox) valBox.className = 'val-drop';
toast('已加入队列: ' + (chatTitle || channelId));
};
const btn = document.getElementById('add-queue-btn');
if (validatedChannel && validatedChannel.chat_id === channelId) {
doAdd(validatedChannel.chat_title, validatedChannel.chat_type);
} else {
btn.disabled = true;
fetch('/api/validate_chat', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ chat_id: channelId }) })
.then(r => r.json()).then(d => {
btn.disabled = false;
if (d.valid) { showVal(d); validatedChannel = d; doAdd(d.chat_title, d.chat_type); }
else { showVal(d); toast('频道验证失败', 'err'); }
}).catch(e => { btn.disabled = false; toast('验证失败: ' + e.message, 'err'); });
}
}
function removeFromQueue(idx) {
downloadQueue.splice(idx, 1);
renderQueue();
}
function clearQueue() {
if (!downloadQueue.length) return;
if (!confirm('确认清空队列里的 ' + downloadQueue.length + ' 个频道?')) return;
downloadQueue = [];
renderQueue();
}
// ── 保存并重启(队列 + 表单一起提交) ──
function saveAndRestart() {
const channelId = parseChannelId(document.getElementById('channel-input').value);
const startDate = document.getElementById('start-date').value;
const endDate = document.getElementById('end-date').value;
const hasForm = !!channelId;
const hasQueue = downloadQueue.length > 0;
if (!hasForm && !hasQueue) { toast('请输入频道或先加入队列', 'err'); return; }
if (hasForm && !startDate) { toast('请选择开始日期', 'err'); return; }
const btn = document.getElementById('save-btn');
btn.disabled = true; btn.innerHTML = '<span class="spin"></span> 重启中…';
const submitAll = (formItem) => {
const items = downloadQueue.slice();
if (formItem && !items.some(q => q.chat_id === formItem.chat_id)) items.push(formItem);
fetch('/api/save_and_restart_multi', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ items })
}).then(r => r.json()).then(d => {
if (d.success) {
toast('✅ ' + d.message + '5秒后刷新…');
setTimeout(() => location.reload(), 5000);
} else {
toast('失败: ' + (d.error||''), 'err');
btn.disabled = false; btn.textContent = '🔄 保存并重启下载';
}
}).catch(e => {
toast('操作失败: ' + e.message, 'err');
btn.disabled = false; btn.textContent = '🔄 保存并重启下载';
});
};
if (!hasForm) { submitAll(null); return; }
const buildFormItem = (chatTitle, chatType) => ({
chat_id: channelId,
chat_title: chatTitle || channelId,
chat_type: chatType || '',
download_filter: buildFilterFromForm(startDate, endDate),
start_date: startDate,
end_date: endDate,
});
if (validatedChannel && validatedChannel.chat_id === channelId) {
submitAll(buildFormItem(validatedChannel.chat_title, validatedChannel.chat_type));
} else {
fetch('/api/validate_chat', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ chat_id: channelId }) })
.then(r => r.json()).then(d => {
if (d.valid) { showVal(d); validatedChannel = d; submitAll(buildFormItem(d.chat_title, d.chat_type)); }
else { showVal(d); toast('频道验证失败', 'err'); btn.disabled = false; btn.textContent = '🔄 保存并重启下载'; }
}).catch(e => { toast('验证失败: ' + e.message, 'err'); btn.disabled = false; btn.textContent = '🔄 保存并重启下载'; });
}
}
// ── 暂停/继续 ──
function applyState(state) {
dlState = state;
const dot = document.getElementById('state-dot');
const txt = document.getElementById('state-txt');
const btn = document.getElementById('btn-pause');
if (state === 'pause') {
dot.className = 'state-dot'; txt.textContent = '下载中';
btn.className = 'btn-pause'; btn.textContent = '⏸ 暂停';
} else {
dot.className = 'state-dot off'; txt.textContent = '已暂停';
btn.className = 'btn-pause resuming'; btn.textContent = '▶ 继续';
}
}
function toggleDownload() {
const btn = document.getElementById('btn-pause');
btn.disabled = true;
fetch('/set_download_state?state=' + dlState, { method:'POST', credentials:'include' })
.then(r => { if (r.redirected || r.status === 401) { location.href='/login'; throw 0; } return r.text(); })
.then(resp => {
applyState(resp);
if (resp === 'pause') toast('已恢复下载');
else { toast('已暂停下载'); document.getElementById('h-speed').textContent = '0 B/s'; }
}).catch(() => {}).finally(() => { btn.disabled = false; });
}
// ── 任务队列总览渲染 ──
function renderTaskQueue(p) {
const banner = document.getElementById('task-banner');
const list = document.getElementById('qbanner-list');
const summary = document.getElementById('qbanner-summary');
const queue = Array.isArray(p.task_queue) ? p.task_queue : [];
const completed = Array.isArray(p.completed_chats) ? p.completed_chats : [];
const currentChat = p.current_chat || '';
// 若没有队列也没有当前任务,隐藏整个 banner
if (!queue.length && !currentChat) {
banner.classList.remove('show');
list.innerHTML = '';
summary.textContent = '';
return;
}
banner.classList.add('show');
// completed 按 chat_id 建索引
const completedMap = {};
completed.forEach(c => { completedMap[c.chat_id] = c; });
// 兜底:队列为空但有 current(多频道队列元信息还没写入时),用 current 顶一下
const effectiveQueue = queue.length
? queue
: [{ chat_id: currentChat, chat_title: p.current_chat_title || currentChat }];
const paused = dlState === 'continue';
let doneCount = 0, runningCount = 0;
list.innerHTML = effectiveQueue.map(q => {
if (completedMap[q.chat_id]) {
doneCount++;
return renderDoneCard(q, completedMap[q.chat_id]);
} else if (q.chat_id === currentChat) {
runningCount++;
return renderActiveCard(q, p, paused);
} else {
return renderPendingCard(q);
}
}).join('');
summary.textContent = `${doneCount}/${effectiveQueue.length} 完成`;
}
function renderDoneCard(q, c) {
const title = escapeHtml(q.chat_title || q.chat_id);
const skipStr = (c.existing_skip && c.existing_skip > 0)
? `跳过${c.skip||0},其中本次跳过${c.existing_skip}`
: `跳过${c.skip||0}`;
const totalStr = (c.total && c.total > 0)
? `${c.done||0} / ${c.total} 完成`
: `${c.done||0} 个完成`;
const filterLine = renderFilterLine(q.download_filter);
return `
<div class="qcard qcard-done">
<div class="qcard-icon">✅</div>
<div class="qcard-main">
<div class="qcard-name">${title}</div>
${filterLine}
<div class="qcard-state">${totalStr}${skipStr}</div>
</div>
</div>`;
}
function renderFilterLine(filter) {
const txt = parseFilterDisplay(filter || '');
if (!txt) return '';
return `<div class="qcard-state" style="color:var(--muted);">${escapeHtml(txt)}</div>`;
}
function renderActiveCard(q, p, paused) {
const title = escapeHtml(q.chat_title || p.current_chat_title || q.chat_id);
const dl = p.downloading_files||0, done = p.completed_files||0, skip = p.skipped_files||0;
const qual = p.qualified_files||0, est = p.estimated_total||0;
const existingSkip = p.existing_skipped || 0;
const rawTotal = est || qual;
const realTotal = Math.max(0, rawTotal - existingSkip);
const doneStr = rawTotal ? `${done} / ${realTotal}` : `${done}`;
const skipStr = existingSkip > 0 ? `跳过${skip},其中本次跳过${existingSkip}` : `跳过${skip}`;
let st = '';
if (p.is_checking && paused) st = `⏸ 已暂停 · 扫描中… (${skipStr})`;
else if (p.is_checking && dl>0) st = `🔍 扫描+下载中 (${dl}个,${doneStr}${skipStr})`;
else if (p.is_checking) st = `🔍 扫描中… (${doneStr}${skipStr})`;
else if (paused) st = `⏸ 已暂停 (${doneStr}${skipStr})`;
else if (dl>0) st = `🚀 下载中 (${dl}个,${doneStr}${skipStr})`;
else if (done>0||skip>0) st = `✅ 完成 (${doneStr}${skipStr})`;
else st = '⏳ 等待开始';
let progBar = '';
if (realTotal > 0) {
const pct = Math.min(100, Math.round(done * 100 / realTotal));
const suffix = (!est && p.is_checking) ? '(扫描中…)' : '';
progBar = `
<div class="qcard-prog"><div class="qcard-prog-fill" style="width:${pct}%"></div></div>
<div class="qcard-state" style="margin-top:3px;">${done} / ${realTotal}${suffix}</div>`;
}
const filterLine = renderFilterLine(q.download_filter);
return `
<div class="qcard qcard-active">
<div class="qcard-icon">🚀</div>
<div class="qcard-main">
<div class="qcard-name">${title}</div>
${filterLine}
<div class="qcard-state">${st}</div>
${progBar}
</div>
</div>`;
}
function renderPendingCard(q) {
const title = escapeHtml(q.chat_title || q.chat_id);
const filterLine = renderFilterLine(q.download_filter);
return `
<div class="qcard qcard-pending">
<div class="qcard-icon">⏳</div>
<div class="qcard-main">
<div class="qcard-name">${title}</div>
${filterLine}
<div class="qcard-state">排队中…</div>
</div>
</div>`;
}
// ── 轮询 ──
function poll() {
fetch('/get_download_status').then(r => r.json()).then(d => {
document.getElementById('h-speed').textContent = d.download_speed || '0 B/s';
applyState(d.state);
}).catch(() => {});
fetch('/api/task_progress').then(r => r.json()).then(p => {
// h-skip 要跨频道累加:本次运行里已完成频道的 skip 汇总 + 当前频道实时的 skipped_files
// 否则切频道时 reset_task_progress 会把计数清零,视觉上会「跳过数突然归零」
const completed = Array.isArray(p.completed_chats) ? p.completed_chats : [];
const completedSkip = completed.reduce((s, c) => s + (c.skip || 0), 0);
document.getElementById('h-skip').textContent = completedSkip + (p.skipped_files || 0);
renderTaskQueue(p);
}).catch(() => {});
fetch('/get_download_list?already_down=false').then(r => r.json()).then(data => {
const active = data.filter(d => d.download_progress < 100);
document.getElementById('h-dl').textContent = active.length;
document.getElementById('h-done').textContent = data.filter(d => d.download_progress >= 100).length;
document.getElementById('dl-badge').textContent = active.length + ' 个文件';
const list = document.getElementById('dl-list');
if (!active.length) {
list.innerHTML = '<div class="empty"><svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>暂无下载任务</div>';
return;
}
list.innerHTML = active.map(item => `
<div class="dl-item">
<div class="dl-top">
<div class="dl-name" title="${item.filename}">${item.filename}</div>
<span class="dl-pct">${item.download_progress}%</span>
<span class="dl-spd">${item.download_speed||''}</span>
<div class="dl-ctrl">
<button class="dl-btn ${item.paused?'dl-paused':''}"
onclick="pauseItem('${item.chat}','${item.id}',this)"
title="${item.paused?'继续下载':'暂停下载'}">${item.paused?'▶':'⏸'}</button>
<button class="dl-btn dl-skip"
onclick="skipItem('${item.chat}','${item.id}','${(item.filename||'').replace(/'/g,'')}',this)"
title="跳过此文件">✕</button>
</div>
</div>
<div class="prog"><div class="prog-fill ${item.download_progress>=100?'done':''}" style="width:${item.download_progress}%"></div></div>
</div>`).join('');
}).catch(() => {});
loadDonePage();
}
function loadDonePage() {
fetch(`/get_download_list?already_down=true&page=${donePage}&page_size=${donePageSize}`)
.then(r => r.json()).then(resp => {
const { items, total, page, total_pages } = resp;
document.getElementById('done-badge').textContent = total + ' 个文件';
const list = document.getElementById('done-list');
if (!items.length) {
list.innerHTML = '<div class="empty">暂无已完成文件</div>';
document.getElementById('done-pager').style.display = 'none';
return;
}
list.innerHTML = items.map(item => {
const isSkip = item.status === 'skip';
return `<div class="done-item">
<span class="done-tick ${isSkip?'skipped':''}">${isSkip?'⊘':'✓'}</span>
<span class="done-name" title="${item.filename||''}">${item.filename||'(未知文件)'}</span>
${isSkip?'<span class="done-skip-tag">跳过</span>':''}
<span class="done-size">${isSkip?'':item.total_size||''}</span>
${isSkip?`<button class="done-undo" onclick="undoSkip('${item.chat_id||item.chat}','${item.id}',this)">撤销</button>`:''}
</div>`;
}).join('');
const pager = document.getElementById('done-pager');
if (total_pages <= 1) {
pager.style.display = 'none';
} else {
// 卡片被折叠时不显示分页器,但记下"原本该显示"的状态,展开后由 toggleDone 恢复
if (doneOpen) {
pager.style.display = 'flex';
} else {
pager.style.display = 'none';
pager.dataset.hiddenByToggle = '1';
}
document.getElementById('done-pager-info').textContent =
`${page} / ${total_pages} 页,共 ${total}`;
document.getElementById('pg-prev').disabled = page <= 1;
document.getElementById('pg-next').disabled = page >= total_pages;
}
}).catch(() => {});
}
function donePageGo(delta) {
donePage = Math.max(1, donePage + delta);
loadDonePage();
}
// ── 初始化 ──
applyState(dlState);
document.getElementById('start-date').value = '2024-01-01';
fetch('/get_app_version').then(r => r.text()).then(v => document.getElementById('app-ver').textContent = v).catch(() => {});
loadConfig();
loadHistory();
poll();
setInterval(poll, 2000);
checkSetup();
// ════════════════════════════
// 设置向导
// ════════════════════════════
function checkSetup() {
fetch('/api/setup_status').then(r => r.json()).then(s => {
setupStatus = s;
if (!s.has_api_credentials) {
if (s.proxy?.hostname) {
document.getElementById('w-proxy-on').checked = true;
document.getElementById('w-proxy-host').value = s.proxy.hostname || '127.0.0.1';
document.getElementById('w-proxy-port').value = s.proxy.port || 7891;
if (s.proxy.scheme === 'http') document.getElementById('w-proxy-scheme').value = 'http';
toggleProxyFields();
}
if (s.save_path) document.getElementById('w-save-path').value = s.save_path;
document.getElementById('wiz-mask').classList.add('on');
wizGoto(0);
}
}).catch(() => {});
}
function wizGoto(step) {
wizStep = step;
for (let i = 0; i < 3; i++) {
document.getElementById('wp-' + i).style.display = i === step ? '' : 'none';
const ws = document.getElementById('ws-' + i);
ws.className = 'ws' + (i < step ? ' done' : i === step ? ' active' : '');
ws.querySelector('.ws-num').textContent = i < step ? '✓' : (i + 1);
}
document.getElementById('w-back').style.display = step === 0 ? 'none' : '';
document.getElementById('w-next').textContent = step === 2 ? '💾 保存并启动' : '下一步 →';
if (step === 2) buildWizSummary();
}
function wizBack() { if (wizStep > 0) wizGoto(wizStep - 1); }
function wizNext() {
if (wizStep === 0) {
const id = document.getElementById('w-api-id').value.trim();
const hash = document.getElementById('w-api-hash').value.trim();
if (!id || isNaN(+id) || +id === 0) { toast('请输入有效的 api_id', 'err'); return; }
if (!hash || hash.length < 8) { toast('请输入有效的 api_hash', 'err'); return; }
wizGoto(1);
} else if (wizStep === 1) {
const on = document.getElementById('w-proxy-on').checked;
if (on && !document.getElementById('w-proxy-host').value.trim()) { toast('请填写代理地址', 'err'); return; }
wizGoto(2);
} else {
doWizSave();
}
}
function toggleProxyFields() {
const on = document.getElementById('w-proxy-on').checked;
document.getElementById('w-proxy-fields').className = 'proxy-fields' + (on ? ' on' : '');
}
function buildWizSummary() {
const id = document.getElementById('w-api-id').value.trim();
const hash = document.getElementById('w-api-hash').value.trim();
const on = document.getElementById('w-proxy-on').checked;
const host = document.getElementById('w-proxy-host').value.trim();
const port = document.getElementById('w-proxy-port').value.trim();
const scheme = document.getElementById('w-proxy-scheme').value;
const path = document.getElementById('w-save-path').value.trim();
const masked = hash.length > 8 ? hash.slice(0,4) + '****' + hash.slice(-4) : '****';
document.getElementById('w-summary').innerHTML = `
<div class="sum-row"><span class="sum-k">api_id</span><span class="sum-v">${id}</span></div>
<div class="sum-row"><span class="sum-k">api_hash</span><span class="sum-v">${masked}</span></div>
<div class="sum-row"><span class="sum-k">代理</span><span class="sum-v">${on ? scheme+'://'+host+':'+port : '未启用'}</span></div>
<div class="sum-row"><span class="sum-k">保存路径</span><span class="sum-v">${path||'使用当前配置'}</span></div>`;
if (setupStatus.has_session) {
document.getElementById('w-session-ok').style.display = '';
document.getElementById('w-login-notice').style.display = 'none';
} else {
document.getElementById('w-session-ok').style.display = 'none';
document.getElementById('w-login-notice').style.display = '';
}
}
// ════════════════════════════
// 代理测试(向导内)
// ════════════════════════════
function testWizProxy() {
const btn = document.getElementById('w-proxy-test-btn');
const result = document.getElementById('w-proxy-test-result');
btn.disabled = true; btn.innerHTML = '<span class="spin"></span> 测试中…';
result.textContent = '';
const scheme = document.getElementById('w-proxy-scheme').value;
const host = document.getElementById('w-proxy-host').value.trim();
const port = +document.getElementById('w-proxy-port').value || 0;
fetch('/api/test_proxy', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ scheme, hostname: host, port })
}).then(r => r.json()).then(d => {
if (d.success) {
result.style.color = 'var(--green)';
result.textContent = '✓ ' + d.message;
} else {
result.style.color = 'var(--red)';
result.textContent = '✗ ' + d.error;
}
}).catch(e => {
result.style.color = 'var(--red)';
result.textContent = '✗ 请求失败:' + e.message;
}).finally(() => {
btn.disabled = false; btn.textContent = '🔌 测试代理';
});
}
// ════════════════════════════
// 过滤器弹窗
// ════════════════════════════
let filterRules = []; // [{field, op, value, unit, negate}]
let filterExpression = ''; // 最终生成/手动输入的表达式
let filterMode = 'visual'; // 'visual' | 'manual'
const F_FIELDS = [
{ value: 'message_date', label: '消息日期', type: 'date' },
{ value: 'media_file_size',label: '文件大小', type: 'size' },
{ value: 'media_width', label: '视频宽度', type: 'number', unit: 'px' },
{ value: 'media_height', label: '视频高度', type: 'number', unit: 'px' },
{ value: 'media_duration', label: '视频时长', type: 'duration' },
{ value: 'media_file_name',label: '文件名', type: 'string' },
{ value: 'message_caption',label: '消息说明', type: 'string' },
];
const F_OPS = {
date: [['>=','在此之后'],['<=','在此之前'],['>','晚于'],['<','早于']],
size: [['>=','大于等于'],['<=','小于等于'],['>','大于'],['<','小于']],
number: [['>=','≥'],['<=','≤'],['>','>'],['<','<'],['==','等于']],
duration: [['>=','大于等于'],['<=','小于等于'],['>','大于'],['<','小于']],
string: [['contains','包含'],['matches','正则匹配'],['==','等于'],['!=','不等于']],
};
function getFieldType(fieldVal) {
return (F_FIELDS.find(f => f.value === fieldVal) || {}).type || 'string';
}
function ruleToExpr(r) {
if (!r.field || !r.op || r.value === '') return null;
const ftype = getFieldType(r.field);
let val = r.value;
let expr = '';
if (ftype === 'date') {
expr = `${r.field} ${r.op} ${val} 00:00:00`;
} else if (ftype === 'size') {
const bytes = sizeToBytes(val, r.unit || 'MB');
expr = `${r.field} ${r.op} ${bytes}`;
} else if (ftype === 'duration') {
const secs = durationToSeconds(val, r.unit || 'sec');
expr = `${r.field} ${r.op} ${secs}`;
} else if (ftype === 'string') {
if (r.op === 'contains' || r.op === 'matches') {
expr = `${r.field} ${r.op} '${val}'`;
} else {
expr = `${r.field} ${r.op} '${val}'`;
}
} else {
expr = `${r.field} ${r.op} ${val}`;
}
return r.negate ? `not (${expr})` : expr;
}
function sizeToBytes(val, unit) {
const n = parseFloat(val) || 0;
const m = { 'B':1, 'KB':1024, 'MB':1024**2, 'GB':1024**3 };
return Math.round(n * (m[unit] || 1024**2));
}
function durationToSeconds(val, unit) {
const n = parseFloat(val) || 0;
return unit === 'min' ? Math.round(n * 60) : Math.round(n);
}
function buildExprFromRules() {
return filterRules.map(ruleToExpr).filter(Boolean).join(' and ');
}
function renderRules() {
const container = document.getElementById('filter-rules');
if (!filterRules.length) {
container.innerHTML = '<div style="text-align:center;padding:14px 0;color:var(--muted);font-size:12px;">暂无条件,点击下方「添加条件」</div>';
updateFilterPreview('');
return;
}
container.innerHTML = filterRules.map((r, i) => {
const ftype = getFieldType(r.field);
const ops = F_OPS[ftype] || F_OPS.string;
const fieldOpts = F_FIELDS.map(f => `<option value="${f.value}"${f.value===r.field?' selected':''}>${f.label}</option>`).join('');
const opOpts = ops.map(([v,l]) => `<option value="${v}"${v===r.op?' selected':''}>${l}</option>`).join('');
let valInput = '';
if (ftype === 'date') {
valInput = `<input class="rule-val" type="date" value="${r.value||''}" oninput="updateRule(${i},'value',this.value)">`;
} else if (ftype === 'size') {
const unitOpts = ['B','KB','MB','GB'].map(u=>`<option${u===(r.unit||'MB')?' selected':''}>${u}</option>`).join('');
valInput = `<input class="rule-val" type="number" min="0" placeholder="数值" value="${r.value||''}" oninput="updateRule(${i},'value',this.value)" style="flex:1.2">
<select class="rule-unit" onchange="updateRule(${i},'unit',this.value)">${unitOpts}</select>`;
} else if (ftype === 'duration') {
const unitOpts = ['秒','分钟'].map((u,ui)=>`<option value="${ui?'min':'sec'}"${(r.unit||'sec')===(ui?'min':'sec')?' selected':''}>${u}</option>`).join('');
valInput = `<input class="rule-val" type="number" min="0" placeholder="数值" value="${r.value||''}" oninput="updateRule(${i},'value',this.value)" style="flex:1.2">
<select class="rule-unit" onchange="updateRule(${i},'unit',this.value)">${unitOpts}</select>`;
} else {
valInput = `<input class="rule-val" type="text" placeholder="${ftype==='string'?'输入文本或正则':'数值'}" value="${r.value||''}" oninput="updateRule(${i},'value',this.value)">`;
}
return `<div class="filter-rule">
<select class="rule-field" onchange="updateRule(${i},'field',this.value)">${fieldOpts}</select>
<select class="rule-op" onchange="updateRule(${i},'op',this.value)">${opOpts}</select>
${valInput}
<label class="rule-negate"><input type="checkbox" ${r.negate?'checked':''} onchange="updateRule(${i},'negate',this.checked)">排除</label>
<button class="rule-del" onclick="deleteRule(${i})">✕</button>
</div>`;
}).join('');
updateFilterPreview(buildExprFromRules());
}
function updateRule(idx, key, val) {
if (key === 'field') {
// 切换字段时重置op和value
const ftype = getFieldType(val);
filterRules[idx] = { ...filterRules[idx], field: val, op: (F_OPS[ftype]||F_OPS.string)[0][0], value: '', unit: '' };
} else {
filterRules[idx][key] = val;
}
renderRules();
}
function addFilterRule() {
filterRules.push({ field: 'message_date', op: '>=', value: '', unit: '', negate: false });
renderRules();
}
function deleteRule(idx) {
filterRules.splice(idx, 1);
renderRules();
}
function updateFilterPreview(expr) {
const el = document.getElementById('filter-expr-preview');
const tip = document.getElementById('filter-val-tip');
if (!expr) {
el.textContent = '(无过滤条件)';
el.className = 'filter-preview-expr empty';
tip.style.display = 'none';
} else {
el.textContent = expr;
el.className = 'filter-preview-expr';
tip.style.display = 'none';
}
}
function switchFilterTab(mode) {
filterMode = mode;
document.getElementById('ftab-visual').className = 'filter-tab' + (mode==='visual'?' on':'');
document.getElementById('ftab-manual').className = 'filter-tab' + (mode==='manual'?' on':'');
document.getElementById('fv-visual').className = 'filter-visual' + (mode==='visual'?'':' hide');
document.getElementById('fv-manual').className = 'filter-manual' + (mode==='manual'?' on':'');
if (mode === 'manual') {
const expr = buildExprFromRules() || filterExpression;
document.getElementById('filter-manual-input').value = expr;
updateFilterPreview(expr);
}
}
function onManualFilterInput() {
const expr = document.getElementById('filter-manual-input').value.trim();
updateFilterPreview(expr);
}
function validateFilterExpr() {
const expr = filterMode === 'manual'
? document.getElementById('filter-manual-input').value.trim()
: buildExprFromRules();
if (!expr) { toast('没有要校验的表达式', 'err'); return; }
const btn = document.getElementById('filter-validate-btn');
const txt = document.getElementById('fvbtn-txt');
btn.disabled = true; txt.innerHTML = '<span class="spin"></span>';
fetch('/api/validate_filter', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ filter: expr })
}).then(r => r.json()).then(d => {
const tip = document.getElementById('filter-val-tip');
tip.style.display = 'flex';
if (d.valid) {
tip.className = 'filter-val-tip ok';
tip.textContent = '✓ 语法正确';
} else {
tip.className = 'filter-val-tip err';
tip.textContent = '✗ ' + (d.error || '语法错误');
}
}).catch(e => toast('校验失败:' + e.message, 'err'))
.finally(() => { btn.disabled = false; txt.textContent = '校验语法'; });
}
function openFilterModal() {
renderRules();
if (filterExpression && filterMode === 'manual') {
document.getElementById('filter-manual-input').value = filterExpression;
}
document.getElementById('filter-mask').classList.add('on');
}
function closeFilterModal() {
document.getElementById('filter-mask').classList.remove('on');
document.getElementById('filter-val-tip').style.display = 'none';
}
function clearFilter() {
filterRules = [];
filterExpression = '';
document.getElementById('filter-manual-input').value = '';
renderRules();
updateFilterBtn(0);
}
function applyFilter() {
let expr = '';
if (filterMode === 'manual') {
expr = document.getElementById('filter-manual-input').value.trim();
} else {
expr = buildExprFromRules();
}
filterExpression = expr;
updateFilterBtn(filterMode === 'manual' ? (expr ? 1 : 0) : filterRules.length);
closeFilterModal();
toast(expr ? `已应用 ${filterMode==='manual'?'自定义':filterRules.length+' 条'}过滤条件` : '已清除过滤条件');
}
function updateFilterBtn(count) {
const btn = document.getElementById('filter-open-btn');
const badge = document.getElementById('filter-badge');
if (count > 0) {
btn.className = 'btn-filter active';
badge.style.display = '';
badge.textContent = count;
} else {
btn.className = 'btn-filter';
badge.style.display = 'none';
}
}
// ── 单条下载控制 ──
function pauseItem(chat, id, btn) {
const isPaused = btn.classList.contains('dl-paused');
const action = isPaused ? 'resume' : 'pause';
fetch('/api/message_control', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({chat_id: chat, message_id: +id, action})
}).then(r => r.json()).then(d => {
if (d.success) {
btn.classList.toggle('dl-paused', !isPaused);
btn.textContent = isPaused ? '⏸' : '▶';
btn.title = isPaused ? '暂停下载' : '继续下载';
} else toast(d.error || '操作失败', 'err');
}).catch(() => toast('请求失败', 'err'));
}
function skipItem(chat, id, fileName, btn) {
btn.disabled = true;
fetch('/api/message_control', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({chat_id: chat, message_id: +id, action: 'skip', file_name: fileName})
}).then(r => r.json()).then(d => {
if (!d.success) { toast(d.error || '跳过失败', 'err'); btn.disabled = false; }
}).catch(() => { toast('请求失败', 'err'); btn.disabled = false; });
}
function undoSkip(chatId, id, btn) {
btn.disabled = true;
fetch('/api/undo_skip', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({chat_id: chatId, message_id: +id})
}).then(r => r.json()).then(d => {
if (d.success) toast('已撤销跳过,下次运行将重新下载');
else { toast(d.error || '撤销失败', 'err'); btn.disabled = false; }
}).catch(() => { toast('请求失败', 'err'); btn.disabled = false; });
}
function doWizSave() {
const btn = document.getElementById('w-next');
btn.disabled = true; btn.innerHTML = '<span class="spin"></span> 保存中…';
fetch('/api/save_initial_config', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({
api_id: +document.getElementById('w-api-id').value.trim(),
api_hash: document.getElementById('w-api-hash').value.trim(),
proxy_enabled: document.getElementById('w-proxy-on').checked,
proxy_scheme: document.getElementById('w-proxy-scheme').value,
proxy_hostname: document.getElementById('w-proxy-host').value.trim(),
proxy_port: +document.getElementById('w-proxy-port').value.trim() || 7891,
save_path: document.getElementById('w-save-path').value.trim(),
})
}).then(r => r.json()).then(d => {
if (d.success) {
document.getElementById('wiz-mask').classList.remove('on');
toast('✅ 配置已保存,重启中…');
setTimeout(() => location.reload(), 6000);
} else { toast('保存失败:' + (d.error||''), 'err'); btn.disabled = false; btn.textContent = '💾 保存并启动'; }
}).catch(e => { toast('保存失败:' + e.message, 'err'); btn.disabled = false; btn.textContent = '💾 保存并启动'; });
}
</script>
</body>
</html>