From ff7d7b1fa42230bccd9a143a22697f46687575a1 Mon Sep 17 00:00:00 2001 From: YuHoYe Date: Sun, 8 Feb 2026 23:06:18 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(DailySummary):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=B4=BB=E5=8A=A8=E6=80=BB=E7=BB=93=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 定时发送每日/每周/每月活动总结通知,支持自定义报告模块和历史记录查看。 --- package.v2.json | 12 + plugins.v2/dailysummary/__init__.py | 805 ++++++++++++++++++++++++++++ 2 files changed, 817 insertions(+) create mode 100644 plugins.v2/dailysummary/__init__.py diff --git a/package.v2.json b/package.v2.json index 776dfee..2c7120a 100644 --- a/package.v2.json +++ b/package.v2.json @@ -613,5 +613,17 @@ "v1.4.2": "适配MoviePilot v2.8.8+", "v1.4.1": "MoviePilot V2 版本登录壁纸本地化插件" } + }, + "DailySummary": { + "name": "活动总结", + "description": "定时发送每日/每周/每月活动总结通知,支持自定义报告模块、历史记录查看", + "labels": "通知", + "version": "2.0.0", + "icon": "Bark_A.png", + "author": "yuhoye", + "level": 1, + "history": { + "v2.0.0": "首次发布:可配置报告模块、历史记录页面、下载器通用化、存储路径可配置" + } } } diff --git a/plugins.v2/dailysummary/__init__.py b/plugins.v2/dailysummary/__init__.py new file mode 100644 index 0000000..5144a66 --- /dev/null +++ b/plugins.v2/dailysummary/__init__.py @@ -0,0 +1,805 @@ +"""MoviePilot 活动总结插件 — 定时发送每日/每周/每月活动总结通知""" + +import os +from collections import OrderedDict +from dataclasses import dataclass +from typing import Any, List, Dict, Tuple, Optional +from datetime import datetime, timedelta + +import pytz +from apscheduler.triggers.cron import CronTrigger + +from app.log import logger +from app.plugins import _PluginBase +from app.schemas import NotificationType +from app.core.event import eventmanager, Event +from app.schemas.types import EventType +from app.core.config import settings +from app.db.transferhistory_oper import TransferHistoryOper +from app.db.subscribe_oper import SubscribeOper +from app.db.plugindata_oper import PluginDataOper +from app.db.models.siteuserdata import SiteUserData +from app.db import ScopedSession + + +# ─── 模块注册表:key → 中文名,各报告按需选取 ─── + +MODULES = OrderedDict([ + ("download", "下载记录"), + ("transfer", "入库记录"), + ("signin", "签到状态"), + ("brush", "刷流统计"), + ("downloader", "下载器概览"), + ("site_delta", "站点增量"), + ("site_current", "站点快照"), + ("subscribe", "订阅进度"), + ("storage", "存储空间"), +]) + +MODULE_OPTIONS = [{"title": name, "value": key} for key, name in MODULES.items()] + +# 各报告类型的默认模块 +DEFAULT_DAILY_MODULES = ["download", "transfer", "signin", "brush", "downloader", "site_delta"] +DEFAULT_WEEKLY_MODULES = ["download", "transfer", "subscribe", "site_delta", "brush"] +DEFAULT_MONTHLY_MODULES = [ + "download", "transfer", "subscribe", "site_current", + "site_delta", "storage", "brush", "downloader", +] + +WEEKDAY_NAMES = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] + +# 历史记录上限 +MAX_HISTORY = 100 + + +@dataclass +class TimeRange: + """报告的时间范围""" + start: datetime + end: datetime + start_str: str # "YYYY-MM-DD HH:MM:SS" — 用于数据库查询 + start_date: str # "YYYY-MM-DD" + end_date: str # "YYYY-MM-DD" + report_type: str # "daily" / "weekly" / "monthly" + prefix: str # "今日" / "本周" / "本月" + + +class DailySummary(_PluginBase): + plugin_name = "活动总结" + plugin_desc = "定时发送每日/每周/每月活动总结通知,支持自定义报告模块、历史记录查看" + plugin_icon = "Bark_A.png" + plugin_version = "2.0.0" + plugin_author = "yuhoye" + author_url = "https://github.com/yuhoye" + plugin_config_prefix = "dailysummary_" + plugin_order = 30 + auth_level = 1 + + # ─── 配置字段 ─── + + _enabled: bool = False + _notify: bool = True + _daily_cron: str = "0 23 * * *" + _weekly_cron: str = "0 23 * * 1" + _monthly_cron: str = "0 23 1 * *" + _onlyonce: bool = False + _test_type: str = "daily" + + _daily_modules: list = None + _weekly_modules: list = None + _monthly_modules: list = None + + _signin_plugin_id: str = "AutoSignIn" + _brush_plugin_ids: str = "BrushFlow" + _storage_paths: str = "" + + def init_plugin(self, config: dict = None): + if config: + self._enabled = config.get("enabled", False) + self._notify = config.get("notify", True) + self._daily_cron = config.get("daily_cron", "0 23 * * *") + self._weekly_cron = config.get("weekly_cron", "0 23 * * 1") + self._monthly_cron = config.get("monthly_cron", "0 23 1 * *") + self._onlyonce = config.get("onlyonce", False) + self._test_type = config.get("test_type", "daily") + self._daily_modules = config.get("daily_modules") or DEFAULT_DAILY_MODULES + self._weekly_modules = config.get("weekly_modules") or DEFAULT_WEEKLY_MODULES + self._monthly_modules = config.get("monthly_modules") or DEFAULT_MONTHLY_MODULES + self._signin_plugin_id = config.get("signin_plugin_id", "AutoSignIn") + self._brush_plugin_ids = config.get("brush_plugin_ids", "BrushFlow") + self._storage_paths = config.get("storage_paths", "") + else: + self._daily_modules = DEFAULT_DAILY_MODULES + self._weekly_modules = DEFAULT_WEEKLY_MODULES + self._monthly_modules = DEFAULT_MONTHLY_MODULES + + if self._onlyonce: + self._onlyonce = False + self._save_config() + from apscheduler.schedulers.background import BackgroundScheduler + scheduler = BackgroundScheduler(timezone=settings.TZ) + test_func = { + "daily": self.send_daily, + "weekly": self.send_weekly, + "monthly": self.send_monthly, + }.get(self._test_type, self.send_daily) + scheduler.add_job( + func=test_func, + trigger="date", + run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), + name="立即测试", + ) + scheduler.start() + + def _save_config(self): + self.update_config({ + "enabled": self._enabled, + "notify": self._notify, + "daily_cron": self._daily_cron, + "weekly_cron": self._weekly_cron, + "monthly_cron": self._monthly_cron, + "onlyonce": False, + "test_type": self._test_type, + "daily_modules": self._daily_modules, + "weekly_modules": self._weekly_modules, + "monthly_modules": self._monthly_modules, + "signin_plugin_id": self._signin_plugin_id, + "brush_plugin_ids": self._brush_plugin_ids, + "storage_paths": self._storage_paths, + }) + + def get_state(self) -> bool: + return self._enabled + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + return [ + {"cmd": "/daily_summary", "event": EventType.PluginAction, "desc": "发送每日总结", "category": "工具", "data": {"action": "daily_summary"}}, + {"cmd": "/weekly_summary", "event": EventType.PluginAction, "desc": "发送每周总结", "category": "工具", "data": {"action": "weekly_summary"}}, + {"cmd": "/monthly_summary", "event": EventType.PluginAction, "desc": "发送每月总结", "category": "工具", "data": {"action": "monthly_summary"}}, + ] + + def get_api(self) -> List[Dict[str, Any]]: + return [{ + "path": "/clear_history", + "endpoint": self._api_clear_history, + "methods": ["POST"], + "summary": "清除历史记录", + }] + + def _api_clear_history(self) -> dict: + self.save_data("history", []) + logger.info("[DailySummary] 历史记录已清除") + return {"success": True} + + def get_service(self) -> List[Dict[str, Any]]: + if not self._enabled: + return [] + services = [] + if self._daily_cron: + services.append({ + "id": "DailySummary_daily", + "name": "每日总结", + "trigger": CronTrigger.from_crontab(self._daily_cron), + "func": self.send_daily, + "kwargs": {}, + }) + if self._weekly_cron: + services.append({ + "id": "DailySummary_weekly", + "name": "每周总结", + "trigger": CronTrigger.from_crontab(self._weekly_cron), + "func": self.send_weekly, + "kwargs": {}, + }) + if self._monthly_cron: + services.append({ + "id": "DailySummary_monthly", + "name": "每月总结", + "trigger": CronTrigger.from_crontab(self._monthly_cron), + "func": self.send_monthly, + "kwargs": {}, + }) + return services + + # ─── 配置表单:三 Tab 布局 ─── + + def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: + test_options = [ + {"title": "每日总结", "value": "daily"}, + {"title": "每周总结", "value": "weekly"}, + {"title": "每月总结", "value": "monthly"}, + ] + return [ + { + "component": "VForm", + "content": [ + { + "component": "VTabs", + "props": {"model": "_tab", "style": "margin-top: -18px; margin-bottom: 12px;"}, + "content": [ + {"component": "VTab", "props": {"value": "basic"}, "text": "基本设置"}, + {"component": "VTab", "props": {"value": "modules"}, "text": "报告内容"}, + {"component": "VTab", "props": {"value": "advanced"}, "text": "高级设置"}, + ], + }, + { + "component": "VWindow", + "props": {"model": "_tab"}, + "content": [ + # ── Tab 1: 基本设置 ── + { + "component": "VWindowItem", + "props": {"value": "basic"}, + "content": [ + { + "component": "VRow", + "content": [ + {"component": "VCol", "props": {"cols": 12, "md": 4}, + "content": [{"component": "VSwitch", "props": {"model": "enabled", "label": "启用插件"}}]}, + {"component": "VCol", "props": {"cols": 12, "md": 4}, + "content": [{"component": "VSwitch", "props": {"model": "notify", "label": "发送通知"}}]}, + {"component": "VCol", "props": {"cols": 12, "md": 4}, + "content": [{"component": "VSwitch", "props": {"model": "onlyonce", "label": "立即测试一次"}}]}, + ], + }, + { + "component": "VRow", + "content": [ + {"component": "VCol", "props": {"cols": 12, "md": 3}, + "content": [{"component": "VTextField", "props": {"model": "daily_cron", "label": "每日 Cron"}}]}, + {"component": "VCol", "props": {"cols": 12, "md": 3}, + "content": [{"component": "VTextField", "props": {"model": "weekly_cron", "label": "每周 Cron"}}]}, + {"component": "VCol", "props": {"cols": 12, "md": 3}, + "content": [{"component": "VTextField", "props": {"model": "monthly_cron", "label": "每月 Cron"}}]}, + {"component": "VCol", "props": {"cols": 12, "md": 3}, + "content": [{"component": "VSelect", "props": {"model": "test_type", "label": "测试类型", "items": test_options}}]}, + ], + }, + ], + }, + # ── Tab 2: 报告内容 ── + { + "component": "VWindowItem", + "props": {"value": "modules"}, + "content": [ + { + "component": "VRow", + "content": [ + {"component": "VCol", "props": {"cols": 12}, + "content": [{"component": "VAlert", "props": {"type": "info", "variant": "tonal", "text": "选择各报告中包含的信息模块,模块按选择顺序显示在报告中"}}]}, + ], + }, + { + "component": "VRow", + "content": [ + {"component": "VCol", "props": {"cols": 12, "md": 4}, + "content": [{"component": "VSelect", "props": { + "model": "daily_modules", "label": "日报模块", + "items": MODULE_OPTIONS, "multiple": True, "chips": True, "closable-chips": True, + }}]}, + {"component": "VCol", "props": {"cols": 12, "md": 4}, + "content": [{"component": "VSelect", "props": { + "model": "weekly_modules", "label": "周报模块", + "items": MODULE_OPTIONS, "multiple": True, "chips": True, "closable-chips": True, + }}]}, + {"component": "VCol", "props": {"cols": 12, "md": 4}, + "content": [{"component": "VSelect", "props": { + "model": "monthly_modules", "label": "月报模块", + "items": MODULE_OPTIONS, "multiple": True, "chips": True, "closable-chips": True, + }}]}, + ], + }, + ], + }, + # ── Tab 3: 高级设置 ── + { + "component": "VWindowItem", + "props": {"value": "advanced"}, + "content": [ + { + "component": "VRow", + "content": [ + {"component": "VCol", "props": {"cols": 12}, + "content": [{"component": "VAlert", "props": {"type": "info", "variant": "tonal", "text": "以下为高级配置,一般无需修改"}}]}, + ], + }, + { + "component": "VRow", + "content": [ + {"component": "VCol", "props": {"cols": 12, "md": 4}, + "content": [{"component": "VTextField", "props": {"model": "signin_plugin_id", "label": "签到插件 ID", "placeholder": "AutoSignIn"}}]}, + {"component": "VCol", "props": {"cols": 12, "md": 4}, + "content": [{"component": "VTextField", "props": {"model": "brush_plugin_ids", "label": "刷流插件 ID", "placeholder": "BrushFlow", "hint": "多个用逗号分隔"}}]}, + {"component": "VCol", "props": {"cols": 12, "md": 4}, + "content": [{"component": "VTextField", "props": {"model": "storage_paths", "label": "存储监控路径", "placeholder": "留空自动检测", "hint": "格式: /media:媒体盘,/downloads:下载盘"}}]}, + ], + }, + ], + }, + ], + }, + ], + } + ], { + "enabled": False, + "notify": True, + "daily_cron": "0 23 * * *", + "weekly_cron": "0 23 * * 1", + "monthly_cron": "0 23 1 * *", + "onlyonce": False, + "test_type": "daily", + "daily_modules": DEFAULT_DAILY_MODULES, + "weekly_modules": DEFAULT_WEEKLY_MODULES, + "monthly_modules": DEFAULT_MONTHLY_MODULES, + "signin_plugin_id": "AutoSignIn", + "brush_plugin_ids": "BrushFlow", + "storage_paths": "", + } + + # ─── 历史记录页面 ─── + + def get_page(self) -> Optional[List[dict]]: + history = self.get_data("history") or [] + + daily_count = sum(1 for r in history if r.get("type") == "daily") + weekly_count = sum(1 for r in history if r.get("type") == "weekly") + monthly_count = sum(1 for r in history if r.get("type") == "monthly") + + return [ + # 统计卡片 + { + "component": "VRow", + "content": [ + self._stat_card("📊 日报", f"{daily_count} 份"), + self._stat_card("📈 周报", f"{weekly_count} 份"), + self._stat_card("📅 月报", f"{monthly_count} 份"), + ], + }, + # 历史记录表格 + { + "component": "VRow", + "content": [ + { + "component": "VCol", + "props": {"cols": 12}, + "content": [ + { + "component": "VDataTableVirtual", + "props": { + "headers": [ + {"title": "时间", "key": "time", "sortable": True, "width": "160px"}, + {"title": "类型", "key": "type_label", "sortable": True, "width": "80px"}, + {"title": "标题", "key": "title", "sortable": False}, + {"title": "预览", "key": "preview", "sortable": False}, + ], + "items": [ + { + "time": r.get("time", ""), + "type_label": {"daily": "日报", "weekly": "周报", "monthly": "月报"}.get(r.get("type"), ""), + "title": r.get("title", ""), + "preview": (r.get("text", "")[:80] + "...") if len(r.get("text", "")) > 80 else r.get("text", ""), + } + for r in reversed(history) + ], + "height": 400, + "fixed-header": True, + "density": "compact", + "hover": True, + }, + } + ], + } + ], + }, + ] + + @staticmethod + def _stat_card(title: str, value: str) -> dict: + return { + "component": "VCol", + "props": {"cols": 12, "md": 4}, + "content": [{ + "component": "VCard", + "props": {"variant": "tonal"}, + "content": [{ + "component": "VCardText", + "props": {"class": "text-center"}, + "content": [ + {"component": "div", "props": {"class": "text-subtitle-2"}, "text": title}, + {"component": "div", "props": {"class": "text-h5 mt-1"}, "text": value}, + ], + }], + }], + } + + def stop_service(self): + pass + + # ─── 命令处理 ─── + + @eventmanager.register(EventType.PluginAction) + def handle_command(self, event: Event = None): + if not event: + return + action = (event.event_data or {}).get("action", "") + handler = { + "daily_summary": self.send_daily, + "weekly_summary": self.send_weekly, + "monthly_summary": self.send_monthly, + }.get(action) + if handler: + handler() + + # ─── 统一报告引擎 ─── + + def send_daily(self): + header, text = self._build_report("daily") + self._send(report_type="daily", title=header, text=text) + + def send_weekly(self): + header, text = self._build_report("weekly") + self._send(report_type="weekly", title=header, text=text) + + def send_monthly(self): + header, text = self._build_report("monthly") + self._send(report_type="monthly", title=header, text=text) + + def _build_report(self, report_type: str) -> Tuple[str, str]: + logger.info(f"[DailySummary] 开始生成 {report_type} 总结") + tr = self._calc_time_range(report_type) + modules = { + "daily": self._daily_modules, + "weekly": self._weekly_modules, + "monthly": self._monthly_modules, + }.get(report_type, self._daily_modules) + + sections = [] + for mod in modules: + result = self._run_section(mod, tr) + if result: + sections.append(result) + + header = self._make_header(report_type, tr) + text = header + "\n\n" + "\n\n".join(sections) if sections else header + "\n\n无数据" + return header, text + + def _calc_time_range(self, report_type: str) -> TimeRange: + tz = pytz.timezone(settings.TZ) + now = datetime.now(tz) + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + + if report_type == "daily": + start = today_start + prefix = "今日" + elif report_type == "weekly": + start = today_start - timedelta(days=now.weekday()) + prefix = "本周" + else: + start = today_start.replace(day=1) + prefix = "本月" + + return TimeRange( + start=start, + end=now, + start_str=start.strftime("%Y-%m-%d 00:00:00"), + start_date=start.strftime("%Y-%m-%d"), + end_date=today_start.strftime("%Y-%m-%d"), + report_type=report_type, + prefix=prefix, + ) + + def _make_header(self, report_type: str, tr: TimeRange) -> str: + now = tr.end + if report_type == "daily": + return f"📊 每日总结 ({now.strftime('%m-%d')} {WEEKDAY_NAMES[now.weekday()]})" + elif report_type == "weekly": + return f"📈 周报 ({tr.start.strftime('%m-%d')} ~ {now.strftime('%m-%d')})" + else: + return f"📅 月报 ({now.strftime('%Y年%m月')})" + + def _run_section(self, module: str, tr: TimeRange) -> Optional[str]: + handler = { + "download": self._section_download, + "transfer": self._section_transfer, + "signin": self._section_signin, + "brush": self._section_brush, + "downloader": self._section_downloader, + "site_delta": self._section_site_delta, + "site_current": self._section_site_current, + "subscribe": self._section_subscribe, + "storage": self._section_storage, + }.get(module) + if not handler: + return None + try: + return handler(tr) + except Exception as e: + logger.error(f"[DailySummary] 模块 {module} 执行失败: {e}") + return f"【{MODULES.get(module, module)}】数据读取失败" + + # ─── 各模块实现 ─── + + def _section_download(self, tr: TimeRange) -> Optional[str]: + downloads = self._get_downloads(tr.start_str) + if not downloads: + return f"【{tr.prefix}下载】无" + + # 日报:详细列表;周报/月报:分类汇总 + if tr.report_type == "daily": + lines = [f"【{tr.prefix}下载 {len(downloads)} 部】"] + for d in downloads: + ep = f" {d.seasons}{d.episodes}" if d.episodes else (f" {d.seasons}" if d.seasons else "") + site = f" - {d.torrent_site}" if d.torrent_site else "" + lines.append(f" • {d.title}{ep}{site}") + return "\n".join(lines) + + type_count = {} + for d in downloads: + cat = d.media_category or d.type or "其他" + type_count[cat] = type_count.get(cat, 0) + 1 + type_summary = " | ".join(f"{k} {v}" for k, v in sorted(type_count.items(), key=lambda x: -x[1])) + return f"【{tr.prefix}下载】共 {len(downloads)} 部\n {type_summary}" + + def _section_transfer(self, tr: TimeRange) -> Optional[str]: + transfers = self._get_transfers(tr.start_str) + success = [t for t in transfers if t.status] + if not success: + return f"【{tr.prefix}入库】无" + + # 日报:详细列表;周报/月报:分类汇总 + if tr.report_type == "daily": + lines = [f"【{tr.prefix}入库 {len(success)} 部】"] + for t in success: + ep = f" {t.seasons}{t.episodes}" if t.episodes else (f" {t.seasons}" if t.seasons else "") + cat = f" → {t.category}" if t.category else "" + lines.append(f" • {t.title}{ep}{cat}") + return "\n".join(lines) + + cat_count = {} + for t in success: + cat = t.category or t.type or "其他" + cat_count[cat] = cat_count.get(cat, 0) + 1 + cat_summary = " | ".join(f"{k} {v}" for k, v in sorted(cat_count.items(), key=lambda x: -x[1])) + return f"【{tr.prefix}入库】共 {len(success)} 部\n {cat_summary}" + + def _section_signin(self, tr: TimeRange) -> str: + pdo = PluginDataOper() + plugin_id = self._signin_plugin_id or "AutoSignIn" + now = tr.end + key = f"{now.month}月{now.day}日" + data = pdo.get_data(plugin_id, key) + if not data: + return "【签到】今日无签到记录" + + signin_records = [r for r in data if "模拟登录" not in r.get("status", "")] + total = len(signin_records) + success = sum(1 for r in signin_records if "成功" in r.get("status", "")) + failed = [r for r in signin_records if "成功" not in r.get("status", "")] + + if success == total: + return f"【签到】✅ 全部成功 ({success}/{total})" + + fail_sites = ", ".join(r["site"] for r in failed) + return f"【签到】⚠️ {success}/{total} 成功\n 失败: {fail_sites}" + + def _section_brush(self, tr: TimeRange) -> str: + pdo = PluginDataOper() + plugin_ids = [pid.strip() for pid in (self._brush_plugin_ids or "BrushFlow").split(",") if pid.strip()] + + total_uploaded = 0 + total_downloaded = 0 + total_active = 0 + total_deleted = 0 + total_count = 0 + + for pid in plugin_ids: + stat = pdo.get_data(pid, "statistic") + if not stat: + continue + total_uploaded += stat.get("uploaded", 0) + stat.get("active_uploaded", 0) + total_downloaded += stat.get("downloaded", 0) + total_active += stat.get("active", 0) + total_deleted += stat.get("deleted", 0) + total_count += stat.get("count", 0) + + return ( + f"【刷流】总种: {total_count} | 活跃: {total_active} | 已删: {total_deleted}\n" + f" 总↑ {_human_size(total_uploaded)} | 总↓ {_human_size(total_downloaded)}" + ) + + def _section_downloader(self, tr: TimeRange) -> Optional[str]: + """通过 DownloaderHelper 获取所有已配置下载器的概览""" + try: + from app.helper.downloader import DownloaderHelper + except ImportError: + logger.warning("[DailySummary] DownloaderHelper 不可用") + return None + + services = DownloaderHelper().get_services() + if not services: + return None + + lines = ["【下载器概览】"] + for name, svc in services.items(): + if not svc or not svc.instance: + continue + inst = svc.instance + completed = inst.get_completed_torrents() or [] + downloading = inst.get_downloading_torrents() or [] + total = len(completed) + len(downloading) + ti = inst.transfer_info() + up_speed = ti.get("up_info_speed", 0) if ti else 0 + dl_speed = ti.get("dl_info_speed", 0) if ti else 0 + lines.append(f" {name}: 种子 {total} | ↑{_human_size(up_speed)}/s | ↓{_human_size(dl_speed)}/s") + + return "\n".join(lines) if len(lines) > 1 else None + + def _section_site_delta(self, tr: TimeRange) -> Optional[str]: + with ScopedSession() as db: + start_data = SiteUserData.get_by_date(db, tr.start_date) + end_data = SiteUserData.get_by_date(db, tr.end_date) + + if not start_data or not end_data: + return None + + start_map = {d.domain: d for d in start_data} + end_map = {d.domain: d for d in end_data} + + label = {"daily": "站点增量", "weekly": "站点周增量", "monthly": "站点月增量"}.get(tr.report_type, "站点增量") + lines = [f"【{label}】", " 站点 ↑上传 ↓下载 魔力变化"] + + has_data = False + for domain, end in sorted(end_map.items(), key=lambda x: (x[1].upload or 0), reverse=True): + start = start_map.get(domain) + if not start: + continue + up_delta = (end.upload or 0) - (start.upload or 0) + down_delta = (end.download or 0) - (start.download or 0) + bonus_delta = (end.bonus or 0) - (start.bonus or 0) + + no_change = up_delta == 0 and down_delta == 0 and bonus_delta == 0 + data_anomaly = up_delta < 0 or down_delta < 0 + if no_change or data_anomaly: + continue + + has_data = True + name = (end.name or domain)[:6].ljust(6) + bonus_str = f"+{bonus_delta:.0f}" if bonus_delta >= 0 else f"{bonus_delta:.0f}" + lines.append(f" {name} {_human_size(up_delta):>10} {_human_size(down_delta):>10} {bonus_str:>8}") + + return "\n".join(lines) if has_data else None + + def _section_site_current(self, tr: TimeRange) -> Optional[str]: + with ScopedSession() as db: + data = SiteUserData.get_latest(db) + + if not data: + return None + + lines = ["【站点数据】", " 站点 总↑ 总↓ 分享率 魔力"] + + for d in sorted(data, key=lambda x: (x.upload or 0), reverse=True): + if not d.upload and not d.download: + continue + name = (d.name or d.domain)[:6].ljust(6) + ratio = f"{d.ratio:.2f}" if d.ratio else "∞" + bonus = f"{d.bonus:.0f}" if d.bonus else "0" + lines.append(f" {name} {_human_size(d.upload or 0):>10} {_human_size(d.download or 0):>10} {ratio:>6} {bonus:>8}") + + return "\n".join(lines) if len(lines) > 2 else None + + def _section_subscribe(self, tr: TimeRange) -> str: + subs = SubscribeOper().list(state="R") or [] + if not subs: + return "【订阅进度】无活跃订阅" + + lines = [f"【订阅进度】{len(subs)} 部进行中"] + for s in subs: + total = s.total_episode or 0 + lack = s.lack_episode or 0 + done = total - lack + season = f" S{s.season}" if s.season else "" + progress = f" {done}/{total}" if total > 0 else "" + lines.append(f" • {s.name}{season}{progress}") + return "\n".join(lines) + + def _section_storage(self, tr: TimeRange) -> Optional[str]: + volumes = self._parse_storage_paths() + if not volumes: + return None + + lines = ["【存储空间】"] + has_data = False + for path, label in volumes: + if not os.path.exists(path): + continue + stat = os.statvfs(path) + total = stat.f_blocks * stat.f_frsize + used = (stat.f_blocks - stat.f_bfree) * stat.f_frsize + if total == 0: + continue + has_data = True + pct = used / total * 100 + lines.append(f" {label}: 已用 {_human_size(used)} / {_human_size(total)} ({pct:.0f}%)") + return "\n".join(lines) if has_data else None + + def _parse_storage_paths(self) -> List[Tuple[str, str]]: + """解析用户配置的存储路径,或自动检测 MP 的 LIBRARY_PATH / DOWNLOAD_PATH""" + if self._storage_paths: + result = [] + for item in self._storage_paths.split(","): + item = item.strip() + if ":" in item: + path, label = item.split(":", 1) + result.append((path.strip(), label.strip())) + elif item: + result.append((item, item)) + return result + + # 自动检测 + paths = [] + if hasattr(settings, "LIBRARY_PATH") and settings.LIBRARY_PATH: + paths.append((settings.LIBRARY_PATH, "媒体库")) + if hasattr(settings, "DOWNLOAD_PATH") and settings.DOWNLOAD_PATH: + paths.append((settings.DOWNLOAD_PATH, "下载目录")) + return paths + + # ─── 数据查询 ─── + + def _get_downloads(self, since: str) -> list: + try: + from app.db.models.downloadhistory import DownloadHistory + with ScopedSession() as db: + return db.query(DownloadHistory).filter( + DownloadHistory.date > since + ).order_by(DownloadHistory.date.desc()).all() + except Exception as e: + logger.error(f"[DailySummary] 查询下载记录失败: {e}") + return [] + + def _get_transfers(self, since: str) -> list: + try: + return TransferHistoryOper().list_by_date(since) or [] + except Exception as e: + logger.error(f"[DailySummary] 查询入库记录失败: {e}") + return [] + + # ─── 发送通知 + 保存历史 ─── + + def _send(self, report_type: str, title: str, text: str): + logger.info(f"[DailySummary] {title}\n{text}") + + if self._notify: + self.post_message(mtype=NotificationType.Plugin, title=title, text=text) + + # 保存历史记录 + tz = pytz.timezone(settings.TZ) + now = datetime.now(tz) + record = { + "time": now.strftime("%Y-%m-%d %H:%M"), + "type": report_type, + "title": title, + "text": text, + } + history = self.get_data("history") or [] + history.append(record) + # 保留最近 MAX_HISTORY 条 + if len(history) > MAX_HISTORY: + history = history[-MAX_HISTORY:] + self.save_data("history", history) + + +# ─── 工具函数 ─── + +def _human_size(size_bytes: float) -> str: + if size_bytes is None or size_bytes == 0: + return "0 B" + negative = size_bytes < 0 + size_bytes = abs(size_bytes) + for unit in ["B", "KB", "MB", "GB", "TB", "PB"]: + if size_bytes < 1024: + formatted = f"{size_bytes:.1f} {unit}" if size_bytes != int(size_bytes) else f"{int(size_bytes)} {unit}" + return f"-{formatted}" if negative else formatted + size_bytes /= 1024 + return f"{size_bytes:.1f} EB" From 82c825e3496f7f72486bea81fbd825d216a94532 Mon Sep 17 00:00:00 2001 From: YuHoYe Date: Mon, 9 Feb 2026 00:41:43 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(DailySummary):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5=E9=9D=A2=E4=B8=8D=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 参考 BrushFlow 的 get_page 结构,将所有内容放在单个 VRow 内 - 无数据时返回「暂无数据」提示 - 表格 height 改为 '30rem'(字符串) - 统计卡片对齐官方组件风格 --- plugins.v2/dailysummary/__init__.py | 114 ++++++++++++++++------------ 1 file changed, 65 insertions(+), 49 deletions(-) diff --git a/plugins.v2/dailysummary/__init__.py b/plugins.v2/dailysummary/__init__.py index 5144a66..1133833 100644 --- a/plugins.v2/dailysummary/__init__.py +++ b/plugins.v2/dailysummary/__init__.py @@ -339,75 +339,91 @@ class DailySummary(_PluginBase): # ─── 历史记录页面 ─── - def get_page(self) -> Optional[List[dict]]: + def get_page(self) -> List[dict]: history = self.get_data("history") or [] + if not history: + return [ + { + 'component': 'div', + 'text': '暂无数据', + 'props': { + 'class': 'text-center', + } + } + ] + daily_count = sum(1 for r in history if r.get("type") == "daily") weekly_count = sum(1 for r in history if r.get("type") == "weekly") monthly_count = sum(1 for r in history if r.get("type") == "monthly") + # 表格数据 + items = [ + { + 'time': r.get('time', ''), + 'type_label': {'daily': '日报', 'weekly': '周报', 'monthly': '月报'}.get(r.get('type'), ''), + 'title': r.get('title', ''), + 'preview': (r.get('text', '')[:80] + '...') if len(r.get('text', '')) > 80 else r.get('text', ''), + } + for r in reversed(history) + ] + return [ - # 统计卡片 { - "component": "VRow", - "content": [ - self._stat_card("📊 日报", f"{daily_count} 份"), - self._stat_card("📈 周报", f"{weekly_count} 份"), - self._stat_card("📅 月报", f"{monthly_count} 份"), - ], - }, - # 历史记录表格 - { - "component": "VRow", - "content": [ + 'component': 'VRow', + 'content': [ + # 统计卡片 + self._stat_card('📊 日报', f'{daily_count} 份'), + self._stat_card('📈 周报', f'{weekly_count} 份'), + self._stat_card('📅 月报', f'{monthly_count} 份'), + # 历史记录表格 { - "component": "VCol", - "props": {"cols": 12}, - "content": [ + 'component': 'VCol', + 'props': {'cols': 12, 'class': 'd-none d-sm-block'}, + 'content': [ { - "component": "VDataTableVirtual", - "props": { - "headers": [ - {"title": "时间", "key": "time", "sortable": True, "width": "160px"}, - {"title": "类型", "key": "type_label", "sortable": True, "width": "80px"}, - {"title": "标题", "key": "title", "sortable": False}, - {"title": "预览", "key": "preview", "sortable": False}, + 'component': 'VDataTableVirtual', + 'props': { + 'class': 'text-sm', + 'headers': [ + {'title': '时间', 'key': 'time', 'sortable': True}, + {'title': '类型', 'key': 'type_label', 'sortable': True}, + {'title': '标题', 'key': 'title', 'sortable': False}, + {'title': '预览', 'key': 'preview', 'sortable': False}, ], - "items": [ - { - "time": r.get("time", ""), - "type_label": {"daily": "日报", "weekly": "周报", "monthly": "月报"}.get(r.get("type"), ""), - "title": r.get("title", ""), - "preview": (r.get("text", "")[:80] + "...") if len(r.get("text", "")) > 80 else r.get("text", ""), - } - for r in reversed(history) - ], - "height": 400, - "fixed-header": True, - "density": "compact", - "hover": True, + 'items': items, + 'height': '30rem', + 'density': 'compact', + 'fixed-header': True, + 'hide-no-data': True, + 'hover': True, }, } ], - } + }, ], - }, + } ] @staticmethod def _stat_card(title: str, value: str) -> dict: return { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [{ - "component": "VCard", - "props": {"variant": "tonal"}, - "content": [{ - "component": "VCardText", - "props": {"class": "text-center"}, - "content": [ - {"component": "div", "props": {"class": "text-subtitle-2"}, "text": title}, - {"component": "div", "props": {"class": "text-h5 mt-1"}, "text": value}, + 'component': 'VCol', + 'props': {'cols': 6, 'md': 4}, + 'content': [{ + 'component': 'VCard', + 'props': {'variant': 'tonal'}, + 'content': [{ + 'component': 'VCardText', + 'props': {'class': 'd-flex align-center'}, + 'content': [ + { + 'component': 'div', + 'content': [ + {'component': 'span', 'props': {'class': 'text-subtitle-2'}, 'text': title}, + {'component': 'div', 'props': {'class': 'text-h6'}, 'text': value}, + ], + }, ], }], }],