eb454cbe73
部署到群晖 / deploy (push) Successful in 58s
对齐历史频道的交互:card-hd 整行可点,箭头 ▾ 旋转, 折叠时 card-hd border-bottom 去掉避免多余分隔线。 已完成卡片的分页器 done-pager 跟 card-bd 联动隐藏。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2127 lines
91 KiB
HTML
2127 lines
91 KiB
HTML
<!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; }
|
||
|
||
/* 队列卡片(已完成 / 当前 / 排队中) */
|
||
.qcard {
|
||
display: flex; align-items: flex-start; gap: 10px;
|
||
padding: 8px 10px; border-radius: 6px;
|
||
margin-bottom: 6px;
|
||
border: 1px solid transparent;
|
||
}
|
||
.qcard:last-child { margin-bottom: 0; }
|
||
.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_hash(32位字符串)</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">SOCKS5(Clash 默认 7891)</option>
|
||
<option value="http">HTTP(Clash 默认 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 => ({
|
||
'&':'&','<':'<','>':'>','"':'"',"'":'''
|
||
})[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>
|