Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9aba339d40 | |||
| ee96cc0f46 | |||
| 482908e655 | |||
| 93ef7d54f5 | |||
| 7cb60ae8a8 | |||
| 79966d2130 | |||
| 53d3ab7769 | |||
| 1cb70dabe0 | |||
| 383c8ce17c | |||
| 6fd4239ab4 | |||
| 49fb1bd55a | |||
| 3c1f8e4c5a | |||
| e40b15da33 | |||
| ac7c491e6c | |||
| 7b338ad086 |
@@ -7,17 +7,72 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
# 清除可能存在的代理环境变量,防止 Docker 通过代理拉取镜像
|
||||||
|
env:
|
||||||
|
HTTP_PROXY: ""
|
||||||
|
HTTPS_PROXY: ""
|
||||||
|
http_proxy: ""
|
||||||
|
https_proxy: ""
|
||||||
|
NO_PROXY: "*"
|
||||||
|
no_proxy: "*"
|
||||||
steps:
|
steps:
|
||||||
- name: 安装 Docker CLI
|
- name: 安装 Docker CLI
|
||||||
run: |
|
run: |
|
||||||
|
# === 彻底清零 Docker 代理,避免 daemon 拉 daocloud 时走 socks5 被 reset ===
|
||||||
|
# 局域网 socks5 代理(192.168.1.119:1080)只供应用容器内部使用,
|
||||||
|
# 这里把 daemon 自己的代理一律置空,让它直连国内 docker mirror。
|
||||||
|
# 1) systemd drop-in:dockerd 启动时强制覆盖环境变量为空
|
||||||
|
mkdir -p /etc/systemd/system/docker.service.d
|
||||||
|
cat > /etc/systemd/system/docker.service.d/no-proxy.conf <<'EOF'
|
||||||
|
[Service]
|
||||||
|
Environment="HTTP_PROXY="
|
||||||
|
Environment="HTTPS_PROXY="
|
||||||
|
Environment="NO_PROXY=*"
|
||||||
|
EOF
|
||||||
|
# 2) daemon.json 显式声明空 proxies(双保险,覆盖任何外部继承)
|
||||||
|
mkdir -p /etc/docker
|
||||||
|
cat > /etc/docker/daemon.json <<'EOF'
|
||||||
|
{
|
||||||
|
"proxies": {
|
||||||
|
"http-proxy": "",
|
||||||
|
"https-proxy": "",
|
||||||
|
"no-proxy": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
# 3) docker 客户端配置:影响 docker build/buildx 自身走的代理
|
||||||
|
mkdir -p /root/.docker
|
||||||
|
cat > /root/.docker/config.json <<'EOF'
|
||||||
|
{
|
||||||
|
"proxies": {
|
||||||
|
"default": {
|
||||||
|
"httpProxy": "",
|
||||||
|
"httpsProxy": "",
|
||||||
|
"noProxy": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
# 替换 Debian 源为清华镜像,避免国内连 deb.debian.org 超时
|
# 替换 Debian 源为清华镜像,避免国内连 deb.debian.org 超时
|
||||||
sed -i 's|deb.debian.org|mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null || true
|
sed -i 's|deb.debian.org|mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null || true
|
||||||
sed -i 's|security.debian.org|mirrors.tuna.tsinghua.edu.cn/debian-security|g' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null || true
|
sed -i 's|security.debian.org|mirrors.tuna.tsinghua.edu.cn/debian-security|g' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null || true
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
apt-get install -y -qq docker.io
|
# 顺手装 git:拉取代码那步要用,ubuntu-latest 默认不带
|
||||||
|
apt-get install -y -qq docker.io git ca-certificates
|
||||||
|
# 装完之后让 daemon 重新读上面三处配置;不同启动方式都试一遍
|
||||||
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
|
systemctl restart docker 2>/dev/null || service docker restart 2>/dev/null || true
|
||||||
|
# 给 daemon 一点时间就绪,否则紧接着 docker build 会连不上 socket
|
||||||
|
sleep 3
|
||||||
|
|
||||||
- name: 拉取代码
|
- name: 拉取代码
|
||||||
uses: actions/checkout@v4
|
# 直接走宿主 IP+端口,避免 act job 容器解析不了 git.ymxixi.space 内网域名
|
||||||
|
run: |
|
||||||
|
git init
|
||||||
|
git remote add origin http://192.168.1.66:3000/adminym/telegram-downloader.git
|
||||||
|
# 优先按本次触发的 commit SHA 拉,确保部署的是 push 上来的那次
|
||||||
|
git fetch --depth 1 origin "$GITHUB_SHA"
|
||||||
|
git checkout FETCH_HEAD
|
||||||
|
|
||||||
- name: 构建镜像
|
- name: 构建镜像
|
||||||
run: docker build -t telegram-downloader:latest .
|
run: docker build -t telegram-downloader:latest .
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ FROM python:3.11.9-alpine AS runtime-image
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 设置时区为东八区(北京时间),让日志和数据库 download_time 字段使用本地时间
|
||||||
|
RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.ustc.edu.cn|g' /etc/apk/repositories \
|
||||||
|
&& apk add --no-cache tzdata \
|
||||||
|
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||||
|
&& echo "Asia/Shanghai" > /etc/timezone
|
||||||
|
|
||||||
# 从上面自己编译的阶段复制依赖(支持任意架构)
|
# 从上面自己编译的阶段复制依赖(支持任意架构)
|
||||||
COPY --from=compile-image /usr/local/lib/python3.11/site-packages \
|
COPY --from=compile-image /usr/local/lib/python3.11/site-packages \
|
||||||
/usr/local/lib/python3.11/site-packages
|
/usr/local/lib/python3.11/site-packages
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ services:
|
|||||||
# ports:
|
# ports:
|
||||||
# - "5000:5000"
|
# - "5000:5000"
|
||||||
|
|
||||||
|
# 时区设置(让日志、数据库 download_time 显示北京时间)
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
# 配置、session、进度数据统一持久化到 ./appdata 目录
|
# 配置、session、进度数据统一持久化到 ./appdata 目录
|
||||||
- "./appdata:/app/appdata"
|
- "./appdata:/app/appdata"
|
||||||
|
|||||||
+111
-47
@@ -423,7 +423,15 @@ async def download_media(
|
|||||||
task_start_time: float = time.time()
|
task_start_time: float = time.time()
|
||||||
media_size = 0
|
media_size = 0
|
||||||
_media = None
|
_media = None
|
||||||
message = await fetch_message(client, message)
|
# 关键修复:fetch_message 是发起任何下载前的网络调用,连接异常时不能让异常一路冒泡,
|
||||||
|
# 否则 worker 只会打日志,状态永远停在 Downloading,UI 卡死。
|
||||||
|
try:
|
||||||
|
message = await fetch_message(client, message)
|
||||||
|
except Exception as fetch_err:
|
||||||
|
logger.warning(
|
||||||
|
f"Message[{getattr(message, 'id', '?')}] 拉取消息失败(可能连接断开): {fetch_err}"
|
||||||
|
)
|
||||||
|
return DownloadStatus.FailedDownload, None
|
||||||
try:
|
try:
|
||||||
for _type in media_types:
|
for _type in media_types:
|
||||||
_media = getattr(message, _type, None)
|
_media = getattr(message, _type, None)
|
||||||
@@ -581,18 +589,23 @@ def _check_config() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def worker(client: pyrogram.client.Client):
|
async def worker(client: pyrogram.client.Client):
|
||||||
"""Work for download task"""
|
"""下载任务消费者协程"""
|
||||||
while app.is_running:
|
while app.is_running:
|
||||||
|
message = None
|
||||||
|
node = None
|
||||||
try:
|
try:
|
||||||
item = await queue.get()
|
item = await queue.get()
|
||||||
message = item[0]
|
message = item[0]
|
||||||
node: TaskNode = item[1]
|
node = item[1]
|
||||||
|
|
||||||
if node.is_stop_transmission:
|
if node.is_stop_transmission:
|
||||||
|
# 主动中止:把队列里残留的下载中状态清掉,避免 UI 一直显示 Downloading
|
||||||
|
_release_stuck_task(node, message, DownloadStatus.SkipDownload)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if is_message_skipped(str(node.chat_id), message.id):
|
if is_message_skipped(str(node.chat_id), message.id):
|
||||||
skip_message(str(node.chat_id), message.id)
|
skip_message(str(node.chat_id), message.id)
|
||||||
|
_release_stuck_task(node, message, DownloadStatus.SkipDownload)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if node.client:
|
if node.client:
|
||||||
@@ -600,7 +613,39 @@ async def worker(client: pyrogram.client.Client):
|
|||||||
else:
|
else:
|
||||||
await download_task(client, message, node)
|
await download_task(client, message, node)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"{e}")
|
logger.exception(f"worker 捕获到未处理异常: {e}")
|
||||||
|
# 关键修复:worker 吞异常时必须把状态推进,否则 finish_task 永远追不上 total_task
|
||||||
|
if node is not None and message is not None:
|
||||||
|
_release_stuck_task(node, message, DownloadStatus.FailedDownload)
|
||||||
|
|
||||||
|
|
||||||
|
def _release_stuck_task(
|
||||||
|
node: "TaskNode",
|
||||||
|
message: "pyrogram.types.Message",
|
||||||
|
status: "DownloadStatus",
|
||||||
|
):
|
||||||
|
"""将异常/中断的任务从"下载中"状态释放,避免任务队列永久卡死。
|
||||||
|
|
||||||
|
做三件事:
|
||||||
|
1) 标记 download_status,让 UI 不再显示"下载中"。
|
||||||
|
2) 推进 finish_task,让 _wait_node_finish 能正常退出。
|
||||||
|
3) 清理 _download_result 残留条目,避免速度/列表脏数据。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
msg_id = getattr(message, "id", None)
|
||||||
|
if msg_id is None:
|
||||||
|
return
|
||||||
|
node.download_status[msg_id] = status
|
||||||
|
# finish_task 通过 app.set_download_id 推进;bot 模式下原逻辑也不走 set_download_id,
|
||||||
|
# 这里保持一致:非 bot 时才推进,避免重复计数。
|
||||||
|
if not node.bot:
|
||||||
|
try:
|
||||||
|
app.set_download_id(node, msg_id, status)
|
||||||
|
except Exception as inner:
|
||||||
|
logger.warning(f"释放卡死任务时 set_download_id 失败 msg_id={msg_id}: {inner}")
|
||||||
|
remove_download_entry(node.chat_id, msg_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"释放卡死任务清理失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
async def download_chat_task(
|
async def download_chat_task(
|
||||||
@@ -657,58 +702,77 @@ async def download_chat_task(
|
|||||||
for message in skipped_messages:
|
for message in skipped_messages:
|
||||||
await add_download_task(message, node)
|
await add_download_task(message, node)
|
||||||
|
|
||||||
async for message in messages_iter: # type: ignore
|
# 关键修复:消息迭代和缓存写入必须包进 try/finally,
|
||||||
# Update checking progress for each message
|
# 否则中途抛 Connection lost 时 total_task 不会被回写,_wait_node_finish 误判频道已完成,
|
||||||
increment_task_stat("checked_messages")
|
# 后续 worker 还在跑就被切到下一个频道,UI 永远显示"下载中"。
|
||||||
|
iter_completed = False
|
||||||
meta_data = MetaData()
|
try:
|
||||||
|
async for message in messages_iter: # type: ignore
|
||||||
|
# Update checking progress for each message
|
||||||
|
increment_task_stat("checked_messages")
|
||||||
|
|
||||||
caption = message.caption
|
meta_data = MetaData()
|
||||||
if caption:
|
|
||||||
caption = validate_title(caption)
|
|
||||||
app.set_caption_name(node.chat_id, message.media_group_id, caption)
|
|
||||||
app.set_caption_entities(
|
|
||||||
node.chat_id, message.media_group_id, message.caption_entities
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
caption = app.get_caption_name(node.chat_id, message.media_group_id)
|
|
||||||
set_meta_data(meta_data, message, caption)
|
|
||||||
|
|
||||||
if app.need_skip_message(chat_download_config, message.id):
|
caption = message.caption
|
||||||
continue
|
if caption:
|
||||||
|
caption = validate_title(caption)
|
||||||
if app.exec_filter(chat_download_config, meta_data):
|
app.set_caption_name(node.chat_id, message.media_group_id, caption)
|
||||||
# 改动点 B:通过 filter 的消息计数,作为 X/N 的分母实时递增
|
app.set_caption_entities(
|
||||||
increment_task_stat("qualified_files")
|
node.chat_id, message.media_group_id, message.caption_entities
|
||||||
await add_download_task(message, node)
|
|
||||||
else:
|
|
||||||
node.download_status[message.id] = DownloadStatus.SkipDownload
|
|
||||||
increment_task_stat("skipped_files")
|
|
||||||
if message.media_group_id:
|
|
||||||
await upload_telegram_chat(
|
|
||||||
client,
|
|
||||||
node.upload_user,
|
|
||||||
app,
|
|
||||||
node,
|
|
||||||
message,
|
|
||||||
DownloadStatus.SkipDownload,
|
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
caption = app.get_caption_name(node.chat_id, message.media_group_id)
|
||||||
|
set_meta_data(meta_data, message, caption)
|
||||||
|
|
||||||
chat_download_config.need_check = True
|
if app.need_skip_message(chat_download_config, message.id):
|
||||||
chat_download_config.total_task = node.total_task
|
continue
|
||||||
node.is_running = True
|
|
||||||
|
if app.exec_filter(chat_download_config, meta_data):
|
||||||
|
# 改动点 B:通过 filter 的消息计数,作为 X/N 的分母实时递增
|
||||||
|
increment_task_stat("qualified_files")
|
||||||
|
await add_download_task(message, node)
|
||||||
|
else:
|
||||||
|
node.download_status[message.id] = DownloadStatus.SkipDownload
|
||||||
|
increment_task_stat("skipped_files")
|
||||||
|
if message.media_group_id:
|
||||||
|
await upload_telegram_chat(
|
||||||
|
client,
|
||||||
|
node.upload_user,
|
||||||
|
app,
|
||||||
|
node,
|
||||||
|
message,
|
||||||
|
DownloadStatus.SkipDownload,
|
||||||
|
)
|
||||||
|
iter_completed = True
|
||||||
|
finally:
|
||||||
|
# 不论遍历是否正常结束,都必须把 node.total_task 同步给 chat_download_config,
|
||||||
|
# 否则 _wait_node_finish 会用 total_task=0 的旧值立即返回,跳过对 worker 的等待。
|
||||||
|
chat_download_config.need_check = True
|
||||||
|
chat_download_config.total_task = node.total_task
|
||||||
|
node.is_running = True
|
||||||
|
|
||||||
# 改动点 C:遍历正常完成,把实际 qualified_files 写入缓存并覆盖 estimated_total
|
# 改动点 C:遍历正常完成,把实际 qualified_files 写入缓存并覆盖 estimated_total
|
||||||
# 仅在正常完成时写入(异常/中断时不执行,避免脏缓存)
|
# 仅在正常完成时写入(异常/中断时不执行,避免脏缓存)
|
||||||
actual_total = get_task_progress().get("qualified_files", 0)
|
if iter_completed:
|
||||||
db.save_scan_cache(str(node.chat_id), filter_key, actual_total)
|
actual_total = get_task_progress().get("qualified_files", 0)
|
||||||
update_task_progress(estimated_total=actual_total, is_checking=False)
|
db.save_scan_cache(str(node.chat_id), filter_key, actual_total)
|
||||||
|
update_task_progress(estimated_total=actual_total, is_checking=False)
|
||||||
|
else:
|
||||||
|
# 异常中断时也要把 is_checking 关掉,避免 UI 一直显示"扫描中"
|
||||||
|
update_task_progress(is_checking=False)
|
||||||
|
|
||||||
|
|
||||||
async def _wait_node_finish(node: TaskNode):
|
async def _wait_node_finish(chat_config, timeout: int = 3600):
|
||||||
"""等待单个频道的所有下载任务完成(含重试中的任务)"""
|
"""等待单个频道的所有下载任务完成(含重试中的任务),超时后强制跳过避免队列卡死"""
|
||||||
|
deadline = asyncio.get_event_loop().time() + timeout
|
||||||
while True:
|
while True:
|
||||||
if node.need_check and node.finish_task >= node.total_task:
|
if chat_config.need_check and chat_config.finish_task >= chat_config.total_task:
|
||||||
|
break
|
||||||
|
if asyncio.get_event_loop().time() > deadline:
|
||||||
|
logger.warning(
|
||||||
|
f"等待频道任务完成超时({timeout}s),强制跳过,"
|
||||||
|
f"finish={chat_config.finish_task} total={chat_config.total_task}"
|
||||||
|
)
|
||||||
break
|
break
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
@@ -725,7 +789,7 @@ async def download_all_chat(client: pyrogram.Client):
|
|||||||
finally:
|
finally:
|
||||||
value.need_check = True
|
value.need_check = True
|
||||||
# 等当前频道所有下载任务跑完,再拍快照;否则 completed_files 还是 0
|
# 等当前频道所有下载任务跑完,再拍快照;否则 completed_files 还是 0
|
||||||
await _wait_node_finish(value.node)
|
await _wait_node_finish(value)
|
||||||
snapshot_current_chat()
|
snapshot_current_chat()
|
||||||
reset_task_progress()
|
reset_task_progress()
|
||||||
# 所有频道都已在循环内快照并重置,此处仅确保最终状态干净
|
# 所有频道都已在循环内快照并重置,此处仅确保最终状态干净
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ def get_download_result() -> dict:
|
|||||||
|
|
||||||
def get_total_download_speed() -> int:
|
def get_total_download_speed() -> int:
|
||||||
"""get total download speed"""
|
"""get total download speed"""
|
||||||
|
# 超过 2 秒没有新数据,视为速度为 0(防止旧速度值残留显示)
|
||||||
|
if time.time() - _last_download_time > 2.0:
|
||||||
|
return 0
|
||||||
return _total_download_speed
|
return _total_download_speed
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+165
-18
@@ -214,13 +214,13 @@
|
|||||||
.qbanner-summary { font-size: 11px; }
|
.qbanner-summary { font-size: 11px; }
|
||||||
|
|
||||||
/* 队列卡片(已完成 / 当前 / 排队中) */
|
/* 队列卡片(已完成 / 当前 / 排队中) */
|
||||||
|
#qbanner-list { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
.qcard {
|
.qcard {
|
||||||
display: flex; align-items: flex-start; gap: 10px;
|
display: flex; align-items: flex-start; gap: 10px;
|
||||||
padding: 8px 10px; border-radius: 6px;
|
padding: 8px 10px; border-radius: 6px;
|
||||||
margin-bottom: 6px;
|
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
|
flex: 1 1 220px; min-width: 200px; max-width: 380px;
|
||||||
}
|
}
|
||||||
.qcard:last-child { margin-bottom: 0; }
|
|
||||||
.qcard-icon { font-size: 14px; line-height: 1.5; flex-shrink: 0; }
|
.qcard-icon { font-size: 14px; line-height: 1.5; flex-shrink: 0; }
|
||||||
.qcard-main { flex: 1; min-width: 0; }
|
.qcard-main { flex: 1; min-width: 0; }
|
||||||
.qcard-name { font-weight: 600; font-size: 13px; color: var(--text);
|
.qcard-name { font-weight: 600; font-size: 13px; color: var(--text);
|
||||||
@@ -298,6 +298,8 @@
|
|||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.dl-pct { font-size: 12px; font-weight: 700; color: var(--accent); flex-shrink: 0; }
|
.dl-pct { font-size: 12px; font-weight: 700; color: var(--accent); flex-shrink: 0; }
|
||||||
|
/* 总大小:跟速度同级别的次要信息,靠灰度区分 */
|
||||||
|
.dl-size { font-size: 11px; color: var(--muted); flex-shrink: 0; }
|
||||||
.dl-spd { font-size: 11px; color: var(--muted); 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 { height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; }
|
||||||
.prog-fill {
|
.prog-fill {
|
||||||
@@ -377,6 +379,43 @@
|
|||||||
background: var(--orange); color: #0d1117; font-size: 10px; font-weight: 700;
|
background: var(--orange); color: #0d1117; font-size: 10px; font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ════ 文件类型多选下拉 ════ */
|
||||||
|
.fb-field-ext { flex-shrink: 0; border-right: 1px solid var(--border); padding: 0 12px; }
|
||||||
|
.ext-pop {
|
||||||
|
display: none;
|
||||||
|
position: absolute; top: calc(100% - 4px); left: 8px; z-index: 320;
|
||||||
|
width: 320px; max-height: 360px; overflow: auto;
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 8px; box-shadow: 0 6px 18px rgba(0,0,0,.45);
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.ext-pop.on { display: block; }
|
||||||
|
.ext-grp-title {
|
||||||
|
font-size: 11px; color: var(--muted); font-weight: 600;
|
||||||
|
margin: 8px 0 5px; letter-spacing: .5px;
|
||||||
|
}
|
||||||
|
.ext-grp-title:first-child { margin-top: 0; }
|
||||||
|
.ext-grp { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||||
|
.ext-chip {
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
padding: 3px 9px; border-radius: 12px;
|
||||||
|
border: 1px solid var(--border); background: transparent;
|
||||||
|
color: var(--muted); font-size: 11px; cursor: pointer;
|
||||||
|
transition: all .12s; user-select: none;
|
||||||
|
}
|
||||||
|
.ext-chip:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.ext-chip.on { border-color: var(--orange); color: var(--orange); background: rgba(210,153,34,.12); }
|
||||||
|
.ext-pop-foot {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--border);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.ext-pop-foot a {
|
||||||
|
color: var(--muted); cursor: pointer; text-decoration: none;
|
||||||
|
}
|
||||||
|
.ext-pop-foot a:hover { color: var(--accent); }
|
||||||
|
.ext-pop-foot a.danger:hover { color: var(--red); }
|
||||||
|
|
||||||
/* ════ 过滤器弹窗 ════ */
|
/* ════ 过滤器弹窗 ════ */
|
||||||
.filter-mask {
|
.filter-mask {
|
||||||
display: none; position: fixed; inset: 0;
|
display: none; position: fixed; inset: 0;
|
||||||
@@ -968,6 +1007,15 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 文件类型 -->
|
||||||
|
<div class="fb-field fb-field-ext">
|
||||||
|
<div class="fb-label">文件类型</div>
|
||||||
|
<button class="btn-filter" id="ext-open-btn" onclick="toggleExtPop(event)">
|
||||||
|
🗂 全部 <span class="filter-badge" id="ext-badge" style="display:none">0</span>
|
||||||
|
</button>
|
||||||
|
<div class="ext-pop" id="ext-pop" onclick="event.stopPropagation()"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 保存路径 -->
|
<!-- 保存路径 -->
|
||||||
<div class="fb-field fb-field-path">
|
<div class="fb-field fb-field-path">
|
||||||
<div class="fb-label">
|
<div class="fb-label">
|
||||||
@@ -1126,25 +1174,117 @@
|
|||||||
return d.toLocaleDateString('zh-CN');
|
return d.toLocaleDateString('zh-CN');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 文件类型多选 ──
|
||||||
|
// 分组定义;扩展名要全小写(底层 file_extension 字段保证小写)
|
||||||
|
const EXT_GROUPS = [
|
||||||
|
{ name: '🎬 视频', items: ['mp4','mov','mkv','avi','webm','flv','wmv'] },
|
||||||
|
{ name: '🎵 音频', items: ['mp3','flac','wav','m4a','aac','ogg','opus'] },
|
||||||
|
{ name: '🖼 图片', items: ['jpg','png','gif','webp','heic','bmp'] },
|
||||||
|
{ name: '📄 文档', items: ['pdf','zip','rar','7z','txt','doc','docx','xlsx','pptx','epub'] },
|
||||||
|
];
|
||||||
|
let selectedExtensions = []; // 当前主表单选中的扩展名列表
|
||||||
|
|
||||||
|
function renderExtPop() {
|
||||||
|
const pop = document.getElementById('ext-pop');
|
||||||
|
if (!pop) return;
|
||||||
|
const groups = EXT_GROUPS.map(g => {
|
||||||
|
const chips = g.items.map(ext => {
|
||||||
|
const on = selectedExtensions.includes(ext) ? ' on' : '';
|
||||||
|
return `<span class="ext-chip${on}" onclick="toggleExt('${ext}')">${ext}</span>`;
|
||||||
|
}).join('');
|
||||||
|
return `<div class="ext-grp-title">${g.name}</div><div class="ext-grp">${chips}</div>`;
|
||||||
|
}).join('');
|
||||||
|
pop.innerHTML = groups + `
|
||||||
|
<div class="ext-pop-foot">
|
||||||
|
<a onclick="clearExtensions()" class="danger">清空</a>
|
||||||
|
<a onclick="closeExtPop()">完成</a>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExtPop(ev) {
|
||||||
|
if (ev) ev.stopPropagation();
|
||||||
|
const pop = document.getElementById('ext-pop');
|
||||||
|
const open = !pop.classList.contains('on');
|
||||||
|
if (open) {
|
||||||
|
renderExtPop();
|
||||||
|
pop.classList.add('on');
|
||||||
|
// 点击外部关闭:注册一次性 listener
|
||||||
|
setTimeout(() => document.addEventListener('click', closeExtPopOnOutside), 0);
|
||||||
|
} else {
|
||||||
|
closeExtPop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function closeExtPop() {
|
||||||
|
document.getElementById('ext-pop').classList.remove('on');
|
||||||
|
document.removeEventListener('click', closeExtPopOnOutside);
|
||||||
|
}
|
||||||
|
function closeExtPopOnOutside() { closeExtPop(); }
|
||||||
|
|
||||||
|
function toggleExt(ext) {
|
||||||
|
const i = selectedExtensions.indexOf(ext);
|
||||||
|
if (i >= 0) selectedExtensions.splice(i, 1);
|
||||||
|
else selectedExtensions.push(ext);
|
||||||
|
renderExtPop();
|
||||||
|
updateExtBtn();
|
||||||
|
}
|
||||||
|
function clearExtensions() {
|
||||||
|
selectedExtensions = [];
|
||||||
|
renderExtPop();
|
||||||
|
updateExtBtn();
|
||||||
|
}
|
||||||
|
function updateExtBtn() {
|
||||||
|
const btn = document.getElementById('ext-open-btn');
|
||||||
|
const badge = document.getElementById('ext-badge');
|
||||||
|
if (!btn) return;
|
||||||
|
const n = selectedExtensions.length;
|
||||||
|
if (n > 0) {
|
||||||
|
btn.className = 'btn-filter active';
|
||||||
|
btn.firstChild.textContent = '🗂 已选 ';
|
||||||
|
badge.style.display = '';
|
||||||
|
badge.textContent = n;
|
||||||
|
} else {
|
||||||
|
btn.className = 'btn-filter';
|
||||||
|
btn.firstChild.textContent = '🗂 全部 ';
|
||||||
|
badge.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function buildExtensionFilter(exts) {
|
||||||
|
if (!exts || !exts.length) return '';
|
||||||
|
if (exts.length === 1) return `file_extension == '${exts[0]}'`;
|
||||||
|
return `file_extension == r'(${exts.join('|')})'`;
|
||||||
|
}
|
||||||
|
// 从表达式里提取已经写在 file_extension == r'(...)' / 'xxx' 里的扩展名列表
|
||||||
|
function parseExtensionsFromFilter(filter) {
|
||||||
|
if (!filter) return [];
|
||||||
|
const reg = filter.match(/file_extension\s*==\s*r'\(([^)]+)\)'/i);
|
||||||
|
if (reg) return reg[1].split('|').map(s => s.trim().toLowerCase()).filter(Boolean);
|
||||||
|
const single = filter.match(/file_extension\s*==\s*'([^']+)'/i);
|
||||||
|
if (single) return [single[1].trim().toLowerCase()];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
// ── 加载配置 ──
|
// ── 加载配置 ──
|
||||||
function parseFilterDisplay(filter) {
|
function parseFilterDisplay(filter) {
|
||||||
if (!filter) return '';
|
if (!filter) return '';
|
||||||
const startM = filter.match(/message_date\s*>=\s*(\d{4}-\d{2}-\d{2})/);
|
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 endM = filter.match(/message_date\s*<=\s*(\d{4}-\d{2}-\d{2})/);
|
||||||
|
const exts = parseExtensionsFromFilter(filter);
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (startM || endM) {
|
if (startM || endM) {
|
||||||
if (startM && endM) parts.push('📅 ' + startM[1] + ' 至 ' + endM[1]);
|
if (startM && endM) parts.push('📅 ' + startM[1] + ' 至 ' + endM[1]);
|
||||||
else if (startM) parts.push('📅 ' + startM[1] + ' 起');
|
else if (startM) parts.push('📅 ' + startM[1] + ' 起');
|
||||||
else parts.push('📅 至 ' + endM[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);
|
|
||||||
}
|
}
|
||||||
|
if (exts.length) parts.push('🗂 ' + exts.join(', '));
|
||||||
|
// 检查是否还有日期/扩展名之外的自定义条件
|
||||||
|
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(/file_extension\s*==\s*r'\([^)]+\)'/gi, '')
|
||||||
|
.replace(/file_extension\s*==\s*'[^']+'/gi, '')
|
||||||
|
.replace(/\band\b/gi, '').trim();
|
||||||
|
if (rest) parts.push('🔍 ' + rest);
|
||||||
|
if (!parts.length) parts.push('🔍 ' + filter);
|
||||||
return parts.join(' · ');
|
return parts.join(' · ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1310,19 +1450,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildFilterFromForm(startDate, endDate) {
|
function buildFilterFromForm(startDate, endDate) {
|
||||||
|
// 高级过滤器表达式存在时优先用它,文件类型选择被忽略(与日期同样的策略)
|
||||||
const custom = filterExpression || '';
|
const custom = filterExpression || '';
|
||||||
if (custom) return custom;
|
if (custom) return custom;
|
||||||
if (!startDate) return '';
|
const segs = [];
|
||||||
return 'message_date >= ' + startDate + ' 00:00:00' +
|
if (startDate) {
|
||||||
(endDate ? ' and message_date <= ' + endDate + ' 23:59:59' : '');
|
segs.push('message_date >= ' + startDate + ' 00:00:00');
|
||||||
|
if (endDate) segs.push('message_date <= ' + endDate + ' 23:59:59');
|
||||||
|
}
|
||||||
|
const extSeg = buildExtensionFilter(selectedExtensions);
|
||||||
|
if (extSeg) segs.push(extSeg);
|
||||||
|
return segs.join(' and ');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 把队列里的起止日期格式化成 "📅 2025-11-20 至 2026-04-23";无日期则 fallback 到原 filter
|
// 把队列里的起止日期/扩展名格式化展示;优先用解析 filter(能覆盖日期+文件类型)
|
||||||
function formatQueueDateRange(startDate, endDate, fallbackFilter) {
|
function formatQueueDateRange(startDate, endDate, fallbackFilter) {
|
||||||
if (startDate) {
|
const parsed = parseFilterDisplay(fallbackFilter || '');
|
||||||
return '📅 ' + startDate + ' 至 ' + (endDate || startDate);
|
if (parsed) return parsed;
|
||||||
}
|
if (startDate) return '📅 ' + startDate + ' 至 ' + (endDate || startDate);
|
||||||
return fallbackFilter || '无过滤条件';
|
return '无过滤条件';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderQueue() {
|
function renderQueue() {
|
||||||
@@ -1641,6 +1787,7 @@
|
|||||||
<div class="dl-item">
|
<div class="dl-item">
|
||||||
<div class="dl-top">
|
<div class="dl-top">
|
||||||
<div class="dl-name" title="${item.filename}">${item.filename}</div>
|
<div class="dl-name" title="${item.filename}">${item.filename}</div>
|
||||||
|
<span class="dl-size">${item.total_size||''}</span>
|
||||||
<span class="dl-pct">${item.download_progress}%</span>
|
<span class="dl-pct">${item.download_progress}%</span>
|
||||||
<span class="dl-spd">${item.download_speed||''}</span>
|
<span class="dl-spd">${item.download_speed||''}</span>
|
||||||
<div class="dl-ctrl">
|
<div class="dl-ctrl">
|
||||||
|
|||||||
Reference in New Issue
Block a user