from pathlib import Path import gzip import re assets = Path('/app/themes/2024/assets') def rewrite_gzip(path: Path, content: str) -> None: path.write_text(content, encoding='utf-8') with gzip.open(path.with_suffix(path.suffix + '.gz'), 'wb', compresslevel=9) as gz: gz.write(content.encode('utf-8')) def patch_chunk_size() -> None: changed = [] for path in assets.glob('SendFileView-*.js'): text = path.read_text(encoding='utf-8') if 'chunk/upload/init/' not in text and 'initChunkUpload' not in text: continue old = text for source, target in [ ('re=5*1024*1024,ft=', 're=50*1024*1024,ft='), ('const i=5*1024*1024,d=Math.ceil(o.size/i),k=await ae.post("chunk/upload/init/"', 'const i=50*1024*1024,d=Math.ceil(o.size/i),k=await ae.post("chunk/upload/init/"'), ('const i=5*1024*1024,d=Math.ceil(o.size/i),S=await ae.post("chunk/upload/init/"', 'const i=50*1024*1024,d=Math.ceil(o.size/i),S=await ae.post("chunk/upload/init/"'), ('// 默认切片大小为5MB\n const chunkSize = 5 * 1024 * 1024', '// 默认切片大小为50MB\n const chunkSize = 50 * 1024 * 1024'), ]: text = text.replace(source, target) if text == old: idx = text.find('chunk/upload/init/') if idx < 0: idx = text.find('initChunkUpload') start = max(0, idx - 700) end = min(len(text), idx + 300) window = text[start:end] patched = re.sub(r'5\s*\*\s*1024\s*\*\s*1024', '50*1024*1024', window, count=1) if patched != window: text = text[:start] + patched + text[end:] if text != old: rewrite_gzip(path, text) changed.append(path.name) schema = Path('/app/apps/base/schemas.py') if schema.exists(): text = schema.read_text(encoding='utf-8') old = text text = text.replace('chunk_size: int = 5 * 1024 * 1024', 'chunk_size: int = 50 * 1024 * 1024') text = re.sub(r'chunk_size:\s*int\s*=\s*5\s*\*\s*1024\s*\*\s*1024', 'chunk_size: int = 50 * 1024 * 1024', text) if text != old: schema.write_text(text, encoding='utf-8') print('chunk patch:', changed or 'no frontend marker changed') def patch_models_and_migration() -> None: models = Path('/app/apps/base/models.py') text = models.read_text(encoding='utf-8') if 'class TransferStats(models.Model):' not in text: insert = ''' class TransferStats(models.Model): id = fields.IntField(pk=True) action = fields.CharField(max_length=16, index=True) file_code_id = fields.IntField(null=True, index=True) code = fields.CharField(max_length=255, null=True, index=True) name = fields.CharField(max_length=512, default="") size = fields.BigIntField(default=0) ip = fields.CharField(max_length=64, default="") file_path = fields.CharField(max_length=1024, default="") expired_at = fields.DatetimeField(null=True) deleted_at = fields.DatetimeField(null=True, index=True) meta = fields.TextField(null=True) created_at = fields.DatetimeField(auto_now_add=True, index=True) class Meta: table = "transfer_stats" ''' text = text.replace('\n\nclass KeyValue(Model):', insert + '\n\nclass KeyValue(Model):') models.write_text(text, encoding='utf-8') migration = Path('/app/apps/base/migrations/migrations_099_transfer_stats.py') migration.write_text('\n'.join([ 'from tortoise import Tortoise', '', '', 'async def migrate():', ' conn = Tortoise.get_connection("default")', ' await conn.execute_script("""', ' CREATE TABLE IF NOT EXISTS transfer_stats (', ' id INTEGER PRIMARY KEY AUTOINCREMENT,', ' action VARCHAR(16) NOT NULL,', ' file_code_id INT NULL,', ' code VARCHAR(255) NULL,', " name VARCHAR(512) NOT NULL DEFAULT '',", ' size BIGINT NOT NULL DEFAULT 0,', " ip VARCHAR(64) NOT NULL DEFAULT '',", ' created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP', ' );', ' CREATE INDEX IF NOT EXISTS idx_transfer_stats_action_created ON transfer_stats(action, created_at);', ' CREATE INDEX IF NOT EXISTS idx_transfer_stats_file_code_id ON transfer_stats(file_code_id);', ' CREATE INDEX IF NOT EXISTS idx_transfer_stats_code ON transfer_stats(code);', ' CREATE INDEX IF NOT EXISTS idx_transfer_stats_deleted_at ON transfer_stats(deleted_at);', ' """)', ]) + '\n', encoding='utf-8') # Keep existing SQLite databases compatible when the table already exists. import sqlite3 db = Path('/app/data/filecodebox.db') if db.exists(): con = sqlite3.connect(db) cols = {row[1] for row in con.execute('PRAGMA table_info(transfer_stats)').fetchall()} for col, ddl in { 'file_path': "ALTER TABLE transfer_stats ADD COLUMN file_path VARCHAR(1024) NOT NULL DEFAULT ''", 'expired_at': 'ALTER TABLE transfer_stats ADD COLUMN expired_at TIMESTAMP NULL', 'deleted_at': 'ALTER TABLE transfer_stats ADD COLUMN deleted_at TIMESTAMP NULL', 'meta': 'ALTER TABLE transfer_stats ADD COLUMN meta TEXT NULL', }.items(): if col not in cols: con.execute(ddl) con.execute('CREATE INDEX IF NOT EXISTS idx_transfer_stats_deleted_at ON transfer_stats(deleted_at)') con.commit(); con.close() def patch_base_views() -> None: views = Path('/app/apps/base/views.py') text = views.read_text(encoding='utf-8') text = text.replace('from apps.base.models import FileCodes, UploadChunk, PresignUploadSession', 'from apps.base.models import FileCodes, UploadChunk, PresignUploadSession, TransferStats') helper = ''' async def record_transfer_stat(action: str, file_code: FileCodes, size: Optional[int] = None, ip: str = "", deleted_at=None, meta: str = "") -> None: async def _write(): name = f"{file_code.prefix or ''}{file_code.suffix or ''}" or file_code.uuid_file_name or file_code.code await TransferStats.create( action=action, file_code_id=file_code.id, code=file_code.code, name=name, size=int(size if size is not None else (file_code.size or 0)), ip=ip or "", file_path=file_code.file_path or "", expired_at=file_code.expired_at, deleted_at=deleted_at, meta=meta or "", ) try: await _write() except Exception: for ddl in [ "ALTER TABLE transfer_stats ADD COLUMN file_path VARCHAR(1024) NOT NULL DEFAULT ''", "ALTER TABLE transfer_stats ADD COLUMN expired_at TIMESTAMP NULL", "ALTER TABLE transfer_stats ADD COLUMN deleted_at TIMESTAMP NULL", "ALTER TABLE transfer_stats ADD COLUMN meta TEXT NULL", "CREATE INDEX IF NOT EXISTS idx_transfer_stats_deleted_at ON transfer_stats(deleted_at)", ]: try: await TransferStats._meta.db.execute_script(ddl) except Exception: pass try: await _write() except Exception: pass ''' if 'async def record_transfer_stat(' not in text: text = text.replace('async def create_file_code(code, **kwargs):\n return await FileCodes.create(code=code, **kwargs)\n', 'async def create_file_code(code, **kwargs):\n return await FileCodes.create(code=code, **kwargs)\n' + helper + '\n') replacements = [ (''' await FileCodes.create(\n code=code,\n prefix=prefix,''', ''' file_code = await FileCodes.create(\n code=code,\n prefix=prefix,'''), (''' )\n return code\n''', ''' )\n await record_transfer_stat("upload", file_code, file_size)\n return code\n''', 1), (''' await create_file_code(\n code=code,\n text=text,''', ''' file_code = await create_file_code(\n code=code,\n text=text,'''), (''' prefix="Text",\n )\n ip_limit["upload"].add_ip(ip)''', ''' prefix="Text",\n )\n await record_transfer_stat("upload", file_code, text_size, ip)\n ip_limit["upload"].add_ip(ip)'''), (''' await create_file_code(\n code=code,\n prefix=prefix,''', ''' file_code = await create_file_code(\n code=code,\n prefix=prefix,'''), (''' used_count=used_count,\n )\n ip_limit["upload"].add_ip(ip)\n return APIResponse(detail={"code": code, "name": file.filename})''', ''' used_count=used_count,\n )\n await record_transfer_stat("upload", file_code, file_size, ip)\n ip_limit["upload"].add_ip(ip)\n return APIResponse(detail={"code": code, "name": file.filename})'''), (''' await update_file_usage(file_code)\n return await file_storage.get_file_response(file_code)''', ''' await update_file_usage(file_code)\n await record_transfer_stat("download", file_code, file_code.size, ip)\n return await file_storage.get_file_response(file_code)'''), (''' await update_file_usage(file_code)\n return APIResponse(\n detail={''', ''' await update_file_usage(file_code)\n await record_transfer_stat("download", file_code, file_code.size, ip)\n return APIResponse(\n detail={'''), (''' await FileCodes.create(\n code=code,\n file_hash=file_hash,''', ''' file_code = await FileCodes.create(\n code=code,\n file_hash=file_hash,'''), (''' )\n # 清理临时文件\n await storage.clean_chunks(upload_id, save_path)''', ''' )\n await record_transfer_stat("upload", file_code, chunk_info.file_size, ip)\n # 清理临时文件\n await storage.clean_chunks(upload_id, save_path)'''), ] for item in replacements: source, target = item[0], item[1] count = item[2] if len(item) > 2 else -1 if source in text and target not in text: text = text.replace(source, target, count) for source, target in [ (''' await session.delete()\n ip_limit["upload"].add_ip(ip)\n return APIResponse(detail={"code": code, "name": session.file_name})''', ''' file_code = await FileCodes.filter(code=code).first()\n if file_code:\n await record_transfer_stat("upload", file_code, file_size, ip)\n await session.delete()\n ip_limit["upload"].add_ip(ip)\n return APIResponse(detail={"code": code, "name": session.file_name})'''), (''' await session.delete()\n ip_limit["upload"].add_ip(ip)\n return APIResponse(detail={"code": code, "name": session.file_name})''', ''' file_code = await FileCodes.filter(code=code).first()\n if file_code:\n await record_transfer_stat("upload", file_code, session.file_size, ip)\n await session.delete()\n ip_limit["upload"].add_ip(ip)\n return APIResponse(detail={"code": code, "name": session.file_name})'''), ]: if source in text: text = text.replace(source, target, 1) views.write_text(text, encoding='utf-8') def patch_admin_views() -> None: admin = Path('/app/apps/admin/views.py') text = admin.read_text(encoding='utf-8') text = text.replace('from apps.base.models import FileCodes, KeyValue', 'from apps.base.models import FileCodes, KeyValue, TransferStats') analytics = r''' @admin_api.get("/analytics") async def analytics(start: str = "", end: str = ""): try: conn = TransferStats._meta.db await conn.execute_script(""" ALTER TABLE transfer_stats ADD COLUMN file_path VARCHAR(1024) NOT NULL DEFAULT ''; """) except Exception: pass for ddl in [ "ALTER TABLE transfer_stats ADD COLUMN expired_at TIMESTAMP NULL", "ALTER TABLE transfer_stats ADD COLUMN deleted_at TIMESTAMP NULL", "ALTER TABLE transfer_stats ADD COLUMN meta TEXT NULL", "CREATE INDEX IF NOT EXISTS idx_transfer_stats_deleted_at ON transfer_stats(deleted_at)", ]: try: await TransferStats._meta.db.execute_script(ddl) except Exception: pass now = await get_now() def parse_day(value, fallback): if not value: return fallback try: return datetime.datetime.strptime(value[:10], "%Y-%m-%d") except Exception: return fallback end_day = parse_day(end, now.replace(hour=0, minute=0, second=0, microsecond=0)) start_day = parse_day(start, end_day - datetime.timedelta(days=29)) end_exclusive = end_day + datetime.timedelta(days=1) stats = await TransferStats.filter(created_at__gte=start_day, created_at__lt=end_exclusive) files = await FileCodes.all() file_map = {item.code: item for item in files} total_downloads = sum(1 for item in stats if item.action == "download") total_uploads = sum(1 for item in stats if item.action == "upload") download_traffic = sum(item.size or 0 for item in stats if item.action == "download") upload_traffic = sum(item.size or 0 for item in stats if item.action == "upload") downloaded_files = len({item.code for item in stats if item.action == "download" and item.code}) historical_codes = {item.code for item in stats if item.code} day_map = {} cursor = start_day while cursor < end_exclusive: key = cursor.strftime("%Y-%m-%d") day_map[key] = {"date": key, "downloads": 0, "uploads": 0, "downloadTraffic": 0, "uploadTraffic": 0} cursor += datetime.timedelta(days=1) file_stats = {} for item in stats: key = item.created_at.strftime("%Y-%m-%d") row = day_map.get(key) if row: if item.action == "download": row["downloads"] += 1 row["downloadTraffic"] += item.size or 0 elif item.action == "upload": row["uploads"] += 1 row["uploadTraffic"] += item.size or 0 if item.code and item.action in {"upload", "download", "delete"}: bucket = file_stats.setdefault(item.code, {"downloads": 0, "downloadTraffic": 0, "uploads": 0, "uploadTraffic": 0, "name": item.name or item.code, "size": 0, "file_path": "", "uploaded_at": None, "expired_at": None, "deleted_at": None, "deleted": False}) bucket["name"] = bucket["name"] or item.name or item.code bucket["size"] = item.size or bucket["size"] bucket["file_path"] = getattr(item, "file_path", "") or bucket["file_path"] if getattr(item, "expired_at", None): bucket["expired_at"] = item.expired_at if item.action == "upload": bucket["uploads"] += 1 bucket["uploadTraffic"] += item.size or 0 bucket["uploaded_at"] = bucket["uploaded_at"] or item.created_at elif item.action == "download": bucket["downloads"] += 1 bucket["downloadTraffic"] += item.size or 0 elif item.action == "delete": bucket["deleted"] = True bucket["deleted_at"] = getattr(item, "deleted_at", None) or item.created_at for file_item in files: current_name = f"{file_item.prefix or ''}{file_item.suffix or ''}" or file_item.uuid_file_name or file_item.code bucket = file_stats.setdefault(file_item.code, {"downloads": 0, "downloadTraffic": 0, "uploads": 0, "uploadTraffic": 0, "name": current_name, "size": int(file_item.size or 0), "file_path": file_item.file_path or "", "uploaded_at": file_item.created_at, "expired_at": file_item.expired_at, "deleted_at": None, "deleted": False}) bucket["name"] = bucket.get("name") or current_name bucket["size"] = bucket.get("size") or int(file_item.size or 0) bucket["file_path"] = bucket.get("file_path") or file_item.file_path or "" bucket["uploaded_at"] = bucket.get("uploaded_at") or file_item.created_at bucket["expired_at"] = bucket.get("expired_at") or file_item.expired_at historical_codes.add(file_item.code) # Current files are always part of history metadata, even if upload events were missed before instrumentation. all_history_files = sorted(file_stats.items(), key=lambda kv: (kv[1].get("deleted_at") or kv[1].get("uploaded_at") or datetime.datetime.min.replace(tzinfo=datetime.timezone.utc), kv[1]["downloads"], kv[1]["downloadTraffic"]), reverse=True) top_files = sorted([kv for kv in all_history_files if kv[1]["downloads"] > 0], key=lambda kv: (kv[1]["downloads"], kv[1]["downloadTraffic"]), reverse=True)[:5] def dt_value(value): return value.isoformat() if value else "" def file_history_row(code, data): file_item = file_map.get(code) current_name = f"{file_item.prefix or ''}{file_item.suffix or ''}" if file_item else "" uploaded_at = data.get("uploaded_at") or (file_item.created_at if file_item else None) expired_at = data.get("expired_at") or (file_item.expired_at if file_item else None) return { "code": code, "name": data["name"] or current_name or code, "size": str(data["size"] or (file_item.size if file_item else 0) or 0), "download_count": data["downloads"], "used_count": data["downloads"], "download_traffic": str(data["downloadTraffic"]), "upload_count": data.get("uploads", 0), "upload_traffic": str(data.get("uploadTraffic", 0)), "current": bool(file_item), "deleted": bool(data.get("deleted")) and not bool(file_item), "file_path": data.get("file_path") or (file_item.file_path if file_item else ""), "created_at": dt_value(uploaded_at), "uploaded_at": dt_value(uploaded_at), "expired_at": dt_value(expired_at), "deleted_at": dt_value(data.get("deleted_at")), } return APIResponse(detail={ "range": {"start": start_day.strftime("%Y-%m-%d"), "end": end_day.strftime("%Y-%m-%d")}, "totals": { "totalDownloads": total_downloads, "downloadedFiles": downloaded_files, "downloadTraffic": str(download_traffic), "historicalFiles": len(historical_codes), "currentFiles": len(files), "totalUploads": total_uploads, "uploadTraffic": str(upload_traffic), }, "daily": list(day_map.values()), "topFiles": [file_history_row(code, data) for code, data in top_files], "historyFiles": [file_history_row(code, data) for code, data in all_history_files], }) ''' text = re.sub(r'\n@admin_api\.get\("/analytics"\)\nasync def analytics\(start: str = "", end: str = ""\):.*?\n\n@admin_api\.delete\("/file/delete"\)', '\n' + analytics + '\n\n@admin_api.delete("/file/delete")', text, flags=re.S) if '@admin_api.get("/analytics")' not in text: text = text.replace('\n\n@admin_api.delete("/file/delete")', '\n' + analytics + '\n\n@admin_api.delete("/file/delete")') admin.write_text(text, encoding='utf-8') def patch_admin_services() -> None: services = Path('/app/apps/admin/services.py') text = services.read_text(encoding='utf-8') text = text.replace('from apps.base.models import FileCodes, KeyValue, file_codes_pydantic', 'from apps.base.models import FileCodes, KeyValue, TransferStats, file_codes_pydantic') if 'import json' not in text: text = 'import json\n' + text if 'import datetime' not in text and 'from datetime import datetime' not in text: text = 'import datetime\n' + text marker = ' item = await file_codes_pydantic.from_tortoise_orm(file_code)\n data = item.model_dump()\n' replacement = ''' item = await file_codes_pydantic.from_tortoise_orm(file_code) data = item.model_dump() download_events = await TransferStats.filter(action="download", file_code_id=file_code.id) download_count = len(download_events) download_traffic = sum(event.size or 0 for event in download_events) ''' if marker in text and 'download_events = await TransferStats.filter(action="download", file_code_id=file_code.id)' not in text: text = text.replace(marker, replacement, 1) data_marker = ''' "usedCount": file_code.used_count, "used_count": file_code.used_count, "createdAt": file_code.created_at,''' data_repl = ''' "usedCount": file_code.used_count, "used_count": file_code.used_count, "downloadCount": download_count, "download_count": download_count, "downloadTraffic": str(download_traffic), "download_traffic": str(download_traffic), "createdAt": file_code.created_at,''' if data_marker in text and '"downloadCount": download_count' not in text: text = text.replace(data_marker, data_repl, 1) old_delete = ' async def _delete_file_code(self, file_code: FileCodes):\n if file_code.text is None:\n await self.file_storage.delete_file(file_code)\n await KeyValue.filter(key=self._file_metadata_key(file_code.id)).delete()\n await file_code.delete()\n' new_delete = ' async def _delete_file_code(self, file_code: FileCodes):\n try:\n name = f"{file_code.prefix or \'\'}{file_code.suffix or \'\'}" or file_code.uuid_file_name or file_code.code\n await TransferStats.create(\n action="delete",\n file_code_id=file_code.id,\n code=file_code.code,\n name=name,\n size=int(file_code.size or 0),\n ip="admin-delete",\n file_path=file_code.file_path or "",\n expired_at=file_code.expired_at,\n deleted_at=datetime.now(),\n meta=json.dumps({\n "prefix": file_code.prefix or "",\n "suffix": file_code.suffix or "",\n "uuid_file_name": file_code.uuid_file_name or "",\n "file_hash": file_code.file_hash or "",\n "is_chunked": bool(file_code.is_chunked),\n "upload_id": file_code.upload_id or "",\n "used_count": file_code.used_count,\n "expired_count": file_code.expired_count,\n "created_at": str(file_code.created_at),\n "expired_at": str(file_code.expired_at) if file_code.expired_at else "",\n }, ensure_ascii=False),\n )\n except Exception:\n pass\n if file_code.text is None:\n await self.file_storage.delete_file(file_code)\n await KeyValue.filter(key=self._file_metadata_key(file_code.id)).delete()\n await file_code.delete()\n' if old_delete in text and 'action="delete"' not in text: text = text.replace(old_delete, new_delete, 1) text = text.replace('deleted_at=datetime.datetime.now(datetime.timezone.utc),', 'deleted_at=datetime.now(),') if 'meta=json.dumps(' in text and 'import json' not in text: text = 'import json\n' + text # Ensure every lifecycle row has a visible, meaningful timestamp. text = text.replace(''' "timestamp": None, } ) elif file_code.expired_at is not None:''', ''' "timestamp": file_code.created_at, } ) elif file_code.expired_at is not None:''') text = text.replace(''' "timestamp": None, "value": remaining_downloads, } ) else:''', ''' "timestamp": file_code.created_at, "value": remaining_downloads, } ) else:''') text = text.replace(''' "timestamp": None, "value": None, } ) timeline.append(''', ''' "timestamp": file_code.created_at, "value": None, } ) timeline.append(''') text = text.replace(''' "timestamp": None, "value": file_code.used_count, } )''', ''' "timestamp": now if file_code.used_count > 0 else file_code.created_at, "value": file_code.used_count, } )''') services.write_text(text, encoding='utf-8') def patch_official_admin_assets() -> None: changed = [] for path in assets.glob('index-*.js'): text = path.read_text(encoding='utf-8') old = text replacements = { 'monitor:"Keep monitoring"': 'monitor:""', 'nextAction:"Next Action"': 'nextAction:""', 'neutral:"Watch"': 'neutral:""', 'extend24h:"Extend 24h"': 'extend24h:""', 'extend7d:"Extend 7d"': 'extend7d:""', 'makePermanent:"Make Permanent"': 'makePermanent:""', 'monitor:"保持观察"': 'monitor:""', 'nextAction:"下一步"': 'nextAction:""', 'neutral:"观察"': 'neutral:""', } for source, target in replacements.items(): text = text.replace(source, target) if text != old: rewrite_gzip(path, text) changed.append(path.name) for path in assets.glob('FileManageView-*.js'): text = path.read_text(encoding='utf-8') old = text text = text.replace('"mt-4 flex items-center justify-between px-6 py-4 border-t"', '"mt-4 flex items-center justify-between gap-3 px-6 py-4 border-t fcbx-native-pagination"') text = text.replace('"flex items-center space-x-2"', '"flex items-center space-x-2 fcbx-native-pagination-buttons"') text = text.replace('"flex items-center space-x-1"', '"flex items-center space-x-1 fcbx-native-pagination-pages"') if text != old: rewrite_gzip(path, text) changed.append(path.name) print('official admin asset patch:', changed or 'no official asset marker changed') def write_frontend_enhancement() -> None: js = r'''(() => { const css = `:root{--fcbx-radius:16px;--fcbx-border:#e5e7eb;--fcbx-surface:rgba(255,255,255,.94);--fcbx-surface-2:rgba(249,250,251,.96);--fcbx-text:#111827;--fcbx-muted:#6b7280;--fcbx-card-shadow:0 10px 15px -3px rgba(0,0,0,.10),0 4px 6px -4px rgba(0,0,0,.10);--fcbx-hover-shadow:0 18px 28px -12px rgba(15,23,42,.22),0 8px 14px -10px rgba(15,23,42,.18)} html.dark,html.dark body,body.dark{--fcbx-border:#374151;--fcbx-surface:rgba(31,41,55,.86);--fcbx-surface-2:rgba(17,24,39,.92);--fcbx-text:#f9fafb;--fcbx-muted:#9ca3af;--fcbx-card-shadow:0 16px 28px -14px rgba(0,0,0,.62),0 8px 18px -12px rgba(0,0,0,.55);--fcbx-hover-shadow:0 22px 34px -16px rgba(0,0,0,.70),0 12px 22px -16px rgba(0,0,0,.58)} body.fcbx-admin #app .rounded-md,body.fcbx-admin #app .rounded-lg,body.fcbx-admin #app .rounded-xl,body.fcbx-admin #app .rounded-2xl,body.fcbx-admin #app section[class*="rounded"],body.fcbx-admin #app div[class*="rounded"]{border-radius:16px!important} body.fcbx-admin #app button,body.fcbx-admin #app input:not([type="checkbox"]),body.fcbx-admin #app textarea,body.fcbx-admin #app select{border-radius:16px!important} body.fcbx-admin #app table{border-radius:0!important;overflow:visible!important} body.fcbx-admin main section[class*="rounded"],body.fcbx-admin main div[class*="rounded"][class*="border"],body.fcbx-admin main div[class*="shadow"]{box-shadow:var(--fcbx-card-shadow)!important;border-color:var(--fcbx-border)!important} body.fcbx-admin main section[class*="rounded"]:hover,body.fcbx-admin main div[class*="rounded"][class*="border"]:hover{box-shadow:var(--fcbx-hover-shadow)!important} body.fcbx-admin main .grid{gap:1.5rem!important}.fcbx{margin:24px 0 0;color:var(--fcbx-text)}.fcbx,.fcbx *{box-sizing:border-box}.fcbx-panel,.fcbx-card{background:linear-gradient(180deg,var(--fcbx-surface),var(--fcbx-surface-2));border:1px solid var(--fcbx-border);border-radius:16px;box-shadow:var(--fcbx-card-shadow);transition:box-shadow .18s ease,transform .18s ease}.fcbx-panel:hover,.fcbx-card:hover{box-shadow:var(--fcbx-hover-shadow);transform:translateY(-1px)}.fcbx-title{font-size:18px;font-weight:800;margin:0 0 16px}.fcbx-muted{color:var(--fcbx-muted);font-size:13px}.fcbx-dashboard-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:24px;margin:24px 0}.fcbx-card{padding:20px}.fcbx-card-label{font-size:13px;color:var(--fcbx-muted);font-weight:700}.fcbx-card-value{font-size:28px;line-height:1.25;font-weight:900;margin-top:8px}.fcbx-panel{padding:20px;margin-top:24px}.fcbx-bar{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:16px}.fcbx-bar select,.fcbx-official-pager select,.fcbx-official-pager input,.fcbx-page-input{height:40px;border:1px solid var(--fcbx-border);padding:0 12px;background:var(--fcbx-surface);color:var(--fcbx-text);border-radius:16px}.fcbx-btn,.fcbx-official-pager button{height:40px;border:1px solid #4f46e5;background:#4f46e5;color:#fff;padding:0 14px;font-weight:800;border-radius:16px;cursor:pointer}.fcbx-btn:disabled,.fcbx-official-pager button:disabled{opacity:.45;cursor:not-allowed;background:var(--fcbx-surface-2);color:var(--fcbx-muted);border-color:var(--fcbx-border)}.fcbx-chart-wrap{position:relative;overflow:hidden;touch-action:none}.fcbx-chart{width:100%;height:260px;display:block;touch-action:none;cursor:grab}.fcbx-chart:active{cursor:grabbing}.fcbx-chart-controls{display:flex;gap:10px;align-items:center;justify-content:flex-end;flex-wrap:wrap;margin:-6px 0 12px}.fcbx-chart-window{margin-right:auto}.fcbx-tooltip{position:absolute;z-index:3;min-width:130px;max-width:min(240px,calc(100vw - 48px));padding:9px 11px;border:1px solid var(--fcbx-border);border-radius:12px;background:var(--fcbx-surface);box-shadow:var(--fcbx-card-shadow);font-size:12px;pointer-events:none;transform:translate(-50%,-112%);transition:left .08s linear,top .08s linear,opacity .12s ease}.fcbx-history-deleted{opacity:.58;filter:grayscale(.35)}.fcbx-history-deleted td{color:var(--fcbx-muted)!important}.fcbx-filter-toggle{display:inline-flex!important;align-items:center;gap:6px}.fcbx-filter-body-collapsed{display:none!important}.fcbx-filter-card{position:relative}.fcbx-table-wrap{overflow:auto;border:1px solid var(--fcbx-border);border-radius:12px}.fcbx-table-wrap table{width:100%;border-collapse:collapse}.fcbx-table-wrap th,.fcbx-table-wrap td{padding:12px 14px;border-bottom:1px solid var(--fcbx-border);text-align:left;white-space:nowrap}.fcbx-table-wrap th{font-size:12px;text-transform:uppercase;color:var(--fcbx-muted);font-weight:800}.fcbx-empty{text-align:center;color:var(--fcbx-muted);padding:24px!important}.fcbx-status-pill{display:inline-flex;align-items:center;padding:4px 10px;border-radius:999px;font-size:12px;font-weight:800}.fcbx-status-current{background:#dcfce7;color:#166534}.fcbx-status-expired{background:#fef3c7;color:#92400e}.fcbx-status-deleted{background:#fee2e2;color:#991b1b}.fcbx-native-pagination-hidden{display:none!important}.fcbx-official-pager{display:flex!important;visibility:visible!important;opacity:1!important;align-items:center!important;justify-content:flex-end!important;gap:10px!important;flex-wrap:wrap!important;margin:14px 0 0!important;padding:14px!important;border-top:1px solid var(--fcbx-border)!important;background:var(--fcbx-surface-2)!important;border-radius:0 0 16px 16px!important}.fcbx-official-pager .fcbx-muted{margin-right:auto!important} @media(max-width:768px){body.fcbx-admin main .grid{gap:1rem!important}.fcbx-dashboard-grid{grid-template-columns:1fr;gap:16px;margin:16px 0}.fcbx{margin-top:16px}.fcbx-panel,.fcbx-card{padding:16px}.fcbx-chart{height:220px}.fcbx-official-pager{justify-content:stretch!important}.fcbx-official-pager>*{flex:1 1 auto!important}.fcbx-official-pager .fcbx-muted{flex:1 1 100%!important}.fcbx-official-pager input{width:76px!important;flex:0 0 76px!important}.fcbx-table-wrap table,.fcbx-table-wrap thead,.fcbx-table-wrap tbody,.fcbx-table-wrap tr,.fcbx-table-wrap th,.fcbx-table-wrap td{display:block}.fcbx-table-wrap thead{display:none}.fcbx-table-wrap tr{padding:10px 0;border-bottom:1px solid var(--fcbx-border)}.fcbx-table-wrap td{border:0;white-space:normal;display:flex;justify-content:space-between;gap:12px}.fcbx-table-wrap td:before{content:attr(data-label);color:var(--fcbx-muted);font-weight:800}}`; const $ = (s, r=document) => r.querySelector(s); const $$ = (s, r=document) => [...r.querySelectorAll(s)]; const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const token = () => localStorage.getItem('admin-token') || localStorage.getItem('token') || sessionStorage.getItem('admin-token') || ''; const today = () => new Date().toISOString().slice(0,10); const daysAgo = n => { const d=new Date(); d.setDate(d.getDate()-n); return d.toISOString().slice(0,10); }; const fmtBytes = n => { n=Number(n||0); const u=['B','KB','MB','GB','TB']; let i=0; while(n>=1024&&i v ? new Date(v).toLocaleString() : '-'; const ensureStyle = () => { let style=$('#fcbx-style'); if(!style){style=document.createElement('style');style.id='fcbx-style';document.head.appendChild(style)} style.textContent=css; }; const updateMode = () => { const login=location.hash.includes('/login') || location.hash.includes('/admin/login'); document.body.classList.toggle('fcbx-admin', location.hash.includes('/admin') && !login); }; let cache = {key:'', data:null, at:0, promise:null}; const api = async (url) => { const t=token(); const res=await fetch(url,{headers:t?{Authorization:`Bearer ${t}`}:{}}); if(!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }; const getAnalytics = async (start=daysAgo(120), end=today(), force=false) => { const key=`${start}|${end}`; const now=Date.now(); if(!force && cache.key===key && cache.data && now-cache.at<60000) return cache.data; if(!force && cache.key===key && cache.promise) return cache.promise; cache.key=key; cache.promise=api(`/admin/analytics?start=${start}&end=${end}`).then(r=>{cache.data=r.detail||r;cache.at=Date.now();cache.promise=null;return cache.data}).catch(e=>{cache.promise=null;throw e}); return cache.promise; }; const closeMobileDrawer = () => { if(innerWidth>=1024)return; const aside=$('aside'); if(!aside)return; aside.classList.remove('translate-x-0'); aside.classList.add('-translate-x-full'); }; const bindDrawer = () => { if(window.__fcbxDrawerBound)return; window.__fcbxDrawerBound=true; document.addEventListener('click',e=>{if(e.target?.closest?.('aside a, aside nav a')&&innerWidth<1024){setTimeout(closeMobileDrawer,0);setTimeout(closeMobileDrawer,160);setTimeout(closeMobileDrawer,420)}},true); window.addEventListener('hashchange',()=>{setTimeout(closeMobileDrawer,0);setTimeout(closeMobileDrawer,180)}); }; const dateSelect = (id, value=today()) => { const d=new Date(value); const y=d.getFullYear(), m=d.getMonth()+1, day=d.getDate(); const years=[]; for(let i=y-5;i<=y+1;i++) years.push(i); return ``; }; const readDate = id => `${$(`#${id}-y`)?.value}-${String($(`#${id}-m`)?.value||1).padStart(2,'0')}-${String($(`#${id}-d`)?.value||1).padStart(2,'0')}`; const chartStates = new WeakMap(); const drawChart = (canvas, rows=[]) => { if(!canvas) return; const wrap=canvas.closest('.fcbx-chart-wrap')||canvas.parentElement; const total=rows.length; let st=chartStates.get(canvas); if(!st){const win=Math.min(total||1, innerWidth<640?35:70); st={start:Math.max(0,total-win), end:total, drag:null, pinch:null, hover:null}; chartStates.set(canvas,st)} st.end=Math.min(total,Math.max(st.end,1)); st.start=Math.max(0,Math.min(st.start,Math.max(0,st.end-1))); if(st.end-st.start<2){st.start=Math.max(0,st.end-2)} const visible=rows.slice(st.start,st.end); const ctx=canvas.getContext('2d'), dpr=devicePixelRatio||1, rect=canvas.getBoundingClientRect(); if(!rect.width||!rect.height)return; canvas.width=rect.width*dpr; canvas.height=rect.height*dpr; ctx.setTransform(dpr,0,0,dpr,0,0); ctx.clearRect(0,0,rect.width,rect.height); const padL=38,padR=18,padT=24,padB=34,w=Math.max(1,rect.width-padL-padR),h=Math.max(1,rect.height-padT-padB); const vals=visible.map(r=>Number(r.downloads||0)); const max=Math.max(1,...vals); const border=getComputedStyle(document.body).getPropertyValue('--fcbx-border')||'#ddd', muted=getComputedStyle(document.body).getPropertyValue('--fcbx-muted')||'#777'; ctx.strokeStyle=border; ctx.lineWidth=1; ctx.fillStyle=muted; ctx.font='12px system-ui'; for(let i=0;i<4;i++){const y=padT+h*i/3;ctx.beginPath();ctx.moveTo(padL,y);ctx.lineTo(padL+w,y);ctx.stroke();ctx.fillText(String(Math.round(max*(1-i/3))),4,y+4)} const pts=visible.map((r,i)=>({x:padL+(visible.length<2?0:i*w/(visible.length-1)), y:padT+h-(Number(r.downloads||0)/max)*h, r})); ctx.strokeStyle='#4f46e5'; ctx.lineWidth=3; ctx.lineJoin='round'; ctx.lineCap='round'; ctx.beginPath(); pts.forEach((p,i)=>i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y)); ctx.stroke(); ctx.fillStyle='#4f46e5'; pts.forEach(p=>{ctx.beginPath();ctx.arc(p.x,p.y,3,0,Math.PI*2);ctx.fill()}); if(pts.length){ctx.fillStyle=muted; ctx.fillText(String(visible[0].date||visible[0].day||''),padL,rect.height-8); const last=String(visible.at(-1).date||visible.at(-1).day||''); ctx.fillText(last,Math.max(padL,rect.width-padR-ctx.measureText(last).width),rect.height-8)} const label=wrap?.querySelector('.fcbx-chart-window'); if(label) label.textContent = total ? `${st.start+1}-${st.end} / ${total}` : '无数据'; let tip=wrap?.querySelector('.fcbx-tooltip'); if(wrap&&!tip){tip=document.createElement('div');tip.className='fcbx-tooltip';tip.style.opacity='0';wrap.appendChild(tip)} if(tip&&st.hover&&pts.length){const near=pts.reduce((a,b)=>Math.abs(b.x-st.hover.x)${esc(near.r.date||near.r.day||'-')}
下载:${Number(near.r.downloads||0)}
上传:${Number(near.r.uploads||0)}`; const left=Math.min(rect.width-80,Math.max(80,near.x)); const top=Math.max(42,near.y); tip.style.left=left+'px'; tip.style.top=top+'px'; tip.style.opacity='1'; ctx.fillStyle='#fff'; ctx.strokeStyle='#4f46e5'; ctx.lineWidth=3; ctx.beginPath();ctx.arc(near.x,near.y,6,0,Math.PI*2);ctx.fill();ctx.stroke();} else if(tip){tip.style.opacity='0'} }; const bindChart = (canvas, rows=[]) => { if(!canvas||canvas.dataset.fcbxChartBound==='1') return; canvas.dataset.fcbxChartBound='1'; const zoom=(factor,center=.5)=>{const st=chartStates.get(canvas); if(!st)return; const total=rows.length,min=Math.min(total,7),cur=st.end-st.start,next=Math.max(min,Math.min(total,Math.round(cur*factor))); const anchor=st.start+cur*center; st.start=Math.round(anchor-next*center); st.start=Math.max(0,Math.min(st.start,total-next)); st.end=st.start+next; drawChart(canvas,rows)}; const pan=(dx)=>{const st=chartStates.get(canvas); if(!st)return; const rect=canvas.getBoundingClientRect(), cur=st.end-st.start, step=Math.round(-dx/Math.max(1,rect.width)*cur*1.15); if(!step)return; st.start=Math.max(0,Math.min(st.start+step,rows.length-cur)); st.end=st.start+cur; drawChart(canvas,rows)}; canvas.addEventListener('wheel',e=>{e.preventDefault(); const r=canvas.getBoundingClientRect(); zoom(e.deltaY>0?1.18:.84,(e.clientX-r.left)/r.width)},{passive:false}); canvas.addEventListener('pointerdown',e=>{e.preventDefault(); canvas.setPointerCapture?.(e.pointerId); const st=chartStates.get(canvas); st.drag={x:e.clientX,last:e.clientX};},{passive:false}); canvas.addEventListener('pointermove',e=>{const st=chartStates.get(canvas); const r=canvas.getBoundingClientRect(); st.hover={x:e.clientX-r.left}; if(st.drag){pan(e.clientX-st.drag.last); st.drag.last=e.clientX}else drawChart(canvas,rows)},{passive:false}); canvas.addEventListener('pointerup',e=>{const st=chartStates.get(canvas); if(st)st.drag=null},{passive:false}); canvas.addEventListener('pointerleave',()=>{const st=chartStates.get(canvas); if(st){st.hover=null;st.drag=null;drawChart(canvas,rows)}}); canvas.addEventListener('dblclick',e=>{e.preventDefault(); const st=chartStates.get(canvas); const win=Math.min(rows.length||1, innerWidth<640?35:70); st.start=Math.max(0,rows.length-win); st.end=rows.length; drawChart(canvas,rows)},{passive:false}); canvas.closest('.fcbx-panel')?.querySelector('[data-chart-zoom-in]')?.addEventListener('click',()=>zoom(.75)); canvas.closest('.fcbx-panel')?.querySelector('[data-chart-zoom-out]')?.addEventListener('click',()=>zoom(1.33)); canvas.closest('.fcbx-panel')?.querySelector('[data-chart-reset]')?.addEventListener('click',()=>{const st=chartStates.get(canvas); const win=Math.min(rows.length||1, innerWidth<640?35:70); st.start=Math.max(0,rows.length-win); st.end=rows.length; drawChart(canvas,rows)}); }; const pagerHtml = id => `
`; const attachPager = (id, rows, render, empty) => { let page=1; const paint=()=>{const size=Number($(`#${id}-size`)?.value||10), pages=Math.max(1,Math.ceil(rows.length/size)); page=Math.min(Math.max(1,page),pages); const slice=rows.slice((page-1)*size,page*size); render(slice, rows.length?null:empty); const p=$(`#${id}-pager`); if(!p)return; $('.fcbx-muted',p).textContent=`第 ${page} / ${pages} 页,共 ${rows.length} 条`; $(`#${id}-page`).value=page; $(`#${id}-prev`).disabled=page<=1; $(`#${id}-next`).disabled=page>=pages; }; $(`#${id}-size`).onchange=()=>{page=1;paint()}; $(`#${id}-prev`).onclick=()=>{page--;paint()}; $(`#${id}-next`).onclick=()=>{page++;paint()}; $(`#${id}-go`).onclick=()=>{page=Number($(`#${id}-page`).value)||1;paint()}; paint(); }; const renderDashboard = async () => { if(!location.hash.includes('/admin/dashboard')){ $('#fcbx-dashboard')?.remove(); return; } const main=$('main')||document.body; let box=$('#fcbx-dashboard'); if(!box){box=document.createElement('section');box.id='fcbx-dashboard';box.className='fcbx';main.appendChild(box)} box.innerHTML='
加载统计中...
'; try{const data=await getAnalytics(); const t=data.totals||{}; box.innerHTML=`
${dateSelect('fcbx-start',data.range?.start||daysAgo(120))}${dateSelect('fcbx-end',data.range?.end||today())}
总文件数
${t.currentFiles??0}
历史文件
${t.historicalFiles??0}
下载次数
${t.totalDownloads??0}
下载流量
${fmtBytes(t.downloadTraffic)}

下载趋势

热门下载 Top 5

${(data.topFiles||[]).map(f=>``).join('')||''}
文件下载流量状态
${esc(f.name)}${f.download_count||0}${fmtBytes(f.download_traffic)}${f.current?'现存':'历史'}
暂无下载记录
`; $('#fcbx-refresh').onclick=async()=>{cache.data=null; await renderDashboard()}; drawChart($('#fcbx-chart'),data.daily||[]); bindChart($('#fcbx-chart'),data.daily||[]); }catch(e){box.innerHTML=`
统计加载失败:${esc(e.message)}
`} }; const renderHistory = async () => { if(!location.hash.includes('/admin/files')){ $('#fcbx-file-history')?.remove(); return; } const card=findFilesTableCard(); const host=card?.parentElement || $('main') || document.body; let box=$('#fcbx-file-history'); if(!box){box=document.createElement('section');box.id='fcbx-file-history';box.className='fcbx';box.innerHTML=`

历史文件

文件大小取件码上传日期过期时间删除时间下载流量状态
加载中...
${pagerHtml('fcbx-history')}
`; host.appendChild(box)} try{const data=await getAnalytics(); const rows=data.historyFiles||[]; const row=f=>{const expired=!f.deleted&&f.expired_at&&new Date(f.expired_at)${esc(f.name)}${fmtBytes(f.size)}${esc(f.code)}${fmtDate(f.uploaded_at||f.created_at)}${fmtDate(f.expired_at)}${fmtDate(f.deleted_at)}${f.download_count??0}${fmtBytes(f.download_traffic)}${status}`}; attachPager('fcbx-history',rows,(slice,empty)=>{$('#fcbx-history-body').innerHTML=empty||slice.map(row).join('')},'暂无历史文件'); }catch(e){$('#fcbx-history-body').innerHTML=`历史文件加载失败:${esc(e.message)}`} }; const findFilesTableCard = () => { const table=$$('main table').find(t=>/Retrieve Code|取件码/.test($$('th',t).map(th=>th.textContent).join('|'))); return table?.closest('section,div[class*="rounded"]')||null; }; const mountFilesPager = () => { if(!location.hash.includes('/admin/files'))return; const native=$('.fcbx-native-pagination'); const card=findFilesTableCard(); if(!native&&!card)return; native?.classList.add('fcbx-native-pagination-hidden'); let pager=$('#fcbx-allfiles-pager'); if(!pager){pager=document.createElement('div');pager.id='fcbx-allfiles-pager';pager.className='fcbx-official-pager'; native?native.after(pager):card.appendChild(pager)} const text=native?.textContent?.replace(/\s+/g,' ')||card?.textContent||''; const m=text.match(/显示第\s*(\d+)\s*到\s*(\d+)\s*条,共\s*(\d+)\s*条/); const total=Number(m?.[3]||card?.querySelectorAll('tbody tr')?.length||0); const params=new URLSearchParams((location.hash.split('?')[1]||'')); const size=Number(params.get('size')||10), page=Number(params.get('page')||1), pages=Math.max(1,Math.ceil(total/size)); pager.innerHTML=`第 ${Math.min(page,pages)} / ${pages} 页,共 ${total} 条`; const setHash=patch=>{const [path,q='']=location.hash.split('?');const p=new URLSearchParams(q);Object.entries(patch).forEach(([k,v])=>p.set(k,String(v)));location.hash=`${path||'#/admin/files'}?${p.toString()}`}; $('#fcbx-allfiles-size').onchange=e=>setHash({page:1,size:e.target.value}); $('#fcbx-allfiles-prev').onclick=()=>setHash({page:Math.max(1,page-1),size}); $('#fcbx-allfiles-next').onclick=()=>setHash({page:Math.min(pages,page+1),size}); $('#fcbx-allfiles-go').onclick=()=>setHash({page:Math.min(pages,Math.max(1,Number($('#fcbx-allfiles-page').value)||1)),size}); }; const preCollapseFilesFilter = () => { if(!location.hash.includes('/admin/files'))return; const cards=$$('main section, main div[class*="rounded"]'); const card=cards.find(el=>!el.querySelector('table') && (el.querySelector('input')||el.querySelector('select')) && /搜索|筛选|Search|Filter|文件名|取件码/.test(el.innerText||'')); if(!card||card.dataset.fcbxFilterReady==='1')return; card.dataset.fcbxFilterReady='1'; card.classList.add('fcbx-filter-card'); const kids=[...card.children]; const body=kids.length>1?kids.slice(1):kids; body.forEach(el=>el.classList.add('fcbx-filter-body-collapsed')); const btn=document.createElement('button'); btn.type='button'; btn.className='fcbx-btn fcbx-filter-toggle'; btn.textContent='展开筛选'; btn.onclick=()=>{const collapsed=body.some(el=>el.classList.contains('fcbx-filter-body-collapsed')); body.forEach(el=>el.classList.toggle('fcbx-filter-body-collapsed',!collapsed)); btn.textContent=collapsed?'收起筛选':'展开筛选'}; (card.firstElementChild||card).appendChild(btn); }; let scheduled=false,lastRun=0; const run=()=>{ if(scheduled)return; scheduled=true; requestAnimationFrame(()=>{scheduled=false; const now=Date.now(); ensureStyle(); updateMode(); bindDrawer(); preCollapseFilesFilter(); mountFilesPager(); if(now-lastRun<500)return; lastRun=now; renderDashboard(); renderHistory(); }); }; window.addEventListener('hashchange',run); window.addEventListener('resize',run); document.addEventListener('DOMContentLoaded',run); new MutationObserver(run).observe(document.body,{childList:true,subtree:true}); run(); })();''' rewrite_gzip(assets / 'fcb-analytics.js', js) index = Path('/app/themes/2024/index.html') text = index.read_text(encoding='utf-8') if '/assets/fcb-analytics.js' not in text: text = text.replace('', '\n') else: text = re.sub(r'', '', text) index.write_text(text, encoding='utf-8') patch_chunk_size() patch_models_and_migration() patch_base_views() patch_admin_views() patch_admin_services() patch_official_admin_assets() write_frontend_enhancement() print('filecodebox liquid analytics patch done')