加载中...
+diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..88e45a7bf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,118 @@ +# MediaCrawler 项目指南 + +## 项目概述 + +MediaCrawler 是一个小红书爬虫项目,支持爬取笔记数据并提供 Web 可视化界面。 + +## 技术栈 + +- **后端**: Python + FastAPI +- **前端**: 纯 HTML/CSS/JavaScript(无框架) +- **数据存储**: JSONL 文件 + +## 开发规范 + +### Git 分支与 PR 规范 + +#### 分支命名 + +- `feat/xxx` - 新功能开发 +- `fix/xxx` - Bug 修复 +- `refactor/xxx` - 代码重构 +- `docs/xxx` - 文档更新 + +#### PR 目标分支 + +**重要**: 所有 PR 必须提交到 `main` 分支,而非其他特性分支。 + +``` +正确: feature/xxx → main +错误: feature/xxx → feat/xxx +``` + +#### 远程仓库配置 + +项目有两个远程仓库: + +| 名称 | 地址 | 用途 | +|------|------|------| +| `origin` | git@github.com:NanmiCoder/MediaCrawler.git | 上游原始仓库 | +| `myfork` | git@github.com:J1anYi/MediaCrawler.git | 个人 Fork 仓库 | + +推送代码到 Fork 仓库: +```bash +git push myfork feat/xxx:feat/xxx +``` + +创建 PR 时,确保: +- **Base 仓库**: J1anYi/MediaCrawler +- **Base 分支**: main +- **Head 分支**: feat/xxx + +### 代码风格 + +#### Python + +- 使用 4 空格缩进 +- 遵循 PEP 8 规范 +- 函数必须有类型注解 +- 安全相关的输入验证必须完善 + +#### JavaScript + +- 使用 2 空格缩进 +- 使用 ES6+ 语法 +- 避免全局变量污染,使用 `window.xxx` 导出 + +#### CSS + +- 使用 BEM 命名规范 +- 响应式设计优先 + +### 安全规范 + +1. **路径遍历防护**: 所有文件路径相关的用户输入必须验证 +2. **输入验证**: 使用 FastAPI Query/Path 验证器 +3. **类型注解**: 函数必须有返回类型注解 + +## 目录结构 + +``` +MediaCrawler/ +├── api/ # FastAPI 后端 +│ └── routers/ +│ └── notes.py # 笔记 API +├── viewer/ # 前端可视化界面 +│ ├── index.html +│ └── static/ +│ ├── css/ +│ └── js/ +│ ├── app.js # 主应用逻辑 +│ ├── api.js # API 封装 +│ ├── modal.js # 模态框组件 +│ └── monitor.js # 监控组件 +├── data/ # 数据目录 +│ └── xhs/ +│ ├── jsonl/ # 笔记数据 +│ └── images/ # 图片资源 +└── docs/ # 文档 +``` + +## 开发流程 + +1. 从 `main` 分支创建特性分支 +2. 开发并测试 +3. 运行代码审查 +4. 推送到 Fork 仓库 +5. 创建 PR 到 `main` 分支 + +## 本地开发 + +启动开发服务器: +```bash +python api/main.py +``` + +访问: +- 可视化界面: http://localhost:8080/viewer/index.html +- API 文档: http://localhost:8080/docs diff --git a/api/main.py b/api/main.py index 23e90f26b..e7e4af433 100644 --- a/api/main.py +++ b/api/main.py @@ -30,7 +30,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse -from .routers import crawler_router, data_router, websocket_router +from .routers import crawler_router, data_router, websocket_router, notes_router app = FastAPI( title="MediaCrawler WebUI API", @@ -59,6 +59,7 @@ app.include_router(crawler_router, prefix="/api") app.include_router(data_router, prefix="/api") app.include_router(websocket_router, prefix="/api") +app.include_router(notes_router, prefix="/api") @app.get("/") @@ -182,6 +183,16 @@ async def get_config_options(): # Mount other static files (e.g., vite.svg) app.mount("/static", StaticFiles(directory=WEBUI_DIR), name="webui-static") +# Mount viewer static files +VIEWER_DIR = os.path.join(os.path.dirname(__file__), "..", "viewer", "static") +if os.path.exists(VIEWER_DIR): + app.mount("/viewer", StaticFiles(directory=VIEWER_DIR, html=True), name="viewer") + +# Mount images directory for note images +IMAGES_DIR = os.path.join(os.path.dirname(__file__), "..", "data", "xhs", "images") +if os.path.exists(IMAGES_DIR): + app.mount("/images", StaticFiles(directory=IMAGES_DIR), name="images") + if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8080) diff --git a/api/routers/__init__.py b/api/routers/__init__.py index 123cbc066..fb0fc92d4 100644 --- a/api/routers/__init__.py +++ b/api/routers/__init__.py @@ -19,5 +19,6 @@ from .crawler import router as crawler_router from .data import router as data_router from .websocket import router as websocket_router +from .notes import router as notes_router -__all__ = ["crawler_router", "data_router", "websocket_router"] +__all__ = ["crawler_router", "data_router", "websocket_router", "notes_router"] diff --git a/api/routers/notes.py b/api/routers/notes.py new file mode 100644 index 000000000..79eff1b94 --- /dev/null +++ b/api/routers/notes.py @@ -0,0 +1,292 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2025 relakkes@gmail.com +# +# This file is part of MediaCrawler project. +# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/routers/notes.py +# GitHub: https://github.com/NanmiCoder +# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 +# +# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: +# 1. 不得用于任何商业用途。 +# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 +# 3. 不得进行大规模爬取或对平台造成运营干扰。 +# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 +# 5. 不得用于任何非法或不当的用途。 +# +# 详细许可条款请参阅项目根目录下的LICENSE文件。 +# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 + +import os +import re +import json +import glob +from pathlib import Path +from typing import Optional, List, Dict, Any +from datetime import datetime + +from fastapi import APIRouter, HTTPException, Query + +router = APIRouter(prefix="/notes", tags=["notes"]) + +# Data directories +DATA_DIR = Path(__file__).parent.parent.parent / "data" +XHS_DATA_DIR = DATA_DIR / "xhs" +JSONL_DIR = XHS_DATA_DIR / "jsonl" +IMAGES_DIR = XHS_DATA_DIR / "images" + + +def parse_interaction_count(count_str: str) -> int: + """Parse Chinese interaction count format like '1.1万' to integer. + + Supports formats: + - Plain numbers: "1234", "1,234" + - Chinese units: "1.5万" (15,000), "2亿" (200,000,000) + - English units: "1.5w" (15,000), "2k" (2,000) + """ + if not count_str: + return 0 + count_str = str(count_str).strip() + try: + # Handle Chinese units + if "亿" in count_str: + num = float(count_str.replace("亿", "")) + return int(num * 100_000_000) + if "万" in count_str: + num = float(count_str.replace("万", "")) + return int(num * 10000) + # Handle English units + lower_str = count_str.lower() + if "w" in lower_str: + num = float(lower_str.replace("w", "")) + return int(num * 10000) + if "k" in lower_str: + num = float(lower_str.replace("k", "")) + return int(num * 1000) + # Plain number + clean_str = count_str.replace("+", "").replace(",", "") + return int(float(clean_str)) + except (ValueError, AttributeError, TypeError): + return 0 + + +# Valid note_id pattern: alphanumeric with underscores and hyphens +NOTE_ID_PATTERN = re.compile(r'^[a-zA-Z0-9_-]+$') + + +def validate_note_id(note_id: str) -> bool: + """Validate note_id to prevent path traversal attacks.""" + if not note_id or len(note_id) > 64: + return False + return bool(NOTE_ID_PATTERN.match(note_id)) + + +def get_local_image_count(note_id: str) -> int: + """Get the count of local images for a note. + + Args: + note_id: The note identifier (validated before use) + + Returns: + Number of image files found, or 0 if directory doesn't exist + """ + # Validate note_id to prevent path traversal + if not validate_note_id(note_id): + return 0 + + note_image_dir = IMAGES_DIR / note_id + + # Ensure path is within IMAGES_DIR (additional safety check) + try: + note_image_dir.resolve().relative_to(IMAGES_DIR.resolve()) + except (ValueError, OSError): + return 0 + + if not note_image_dir.exists(): + return 0 + + # Count image files using a single iteration + count = 0 + try: + for f in note_image_dir.iterdir(): + if f.suffix.lower() in ('.jpg', '.jpeg', '.webp', '.png'): + count += 1 + except OSError: + return 0 + return count + + +def read_jsonl_files() -> List[Dict[str, Any]]: + """Read all JSONL files and return list of notes. + + Returns: + List of note dictionaries sorted by time (newest first) + """ + notes: List[Dict[str, Any]] = [] + + if not JSONL_DIR.exists(): + return notes + + # Find all JSONL files + try: + jsonl_files = list(JSONL_DIR.glob("*.jsonl")) + except OSError: + return notes + + for jsonl_file in jsonl_files: + try: + with open(jsonl_file, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + note = json.loads(line) + # Add local image count + note["local_image_count"] = get_local_image_count(note.get("note_id", "")) + notes.append(note) + except json.JSONDecodeError: + # Skip malformed JSON lines + continue + except (OSError, IOError): + # Skip files that cannot be read + continue + + # Sort by time (newest first), handle None/0 values explicitly + notes.sort(key=lambda x: x.get("time") or 0, reverse=True) + + return notes + + +def format_note_for_response(note: Dict[str, Any]) -> Dict[str, Any]: + """Format a note for API response""" + note_id = note.get("note_id", "") + local_image_count = note.get("local_image_count", 0) + + return { + "note_id": note_id, + "type": note.get("type", "normal"), + "title": note.get("title", ""), + "desc": note.get("desc", ""), + "nickname": note.get("nickname", ""), + "avatar": note.get("avatar", ""), + "liked_count": note.get("liked_count", "0"), + "liked_count_num": parse_interaction_count(note.get("liked_count", "0")), + "collected_count": note.get("collected_count", "0"), + "collected_count_num": parse_interaction_count(note.get("collected_count", "0")), + "comment_count": note.get("comment_count", "0"), + "share_count": note.get("share_count", "0"), + "tag_list": note.get("tag_list", "").split(",") if note.get("tag_list") else [], + "source_keyword": note.get("source_keyword", ""), + "note_url": note.get("note_url", ""), + "time": note.get("time", 0), + "image_count": local_image_count, + "first_image_url": f"/images/{note_id}/0.jpg" if local_image_count > 0 else None, + "video_url": note.get("video_url", ""), + } + + +@router.get("") +async def list_notes( + keyword: Optional[str] = Query(None, max_length=50, description="Filter by source keyword"), + search: Optional[str] = Query(None, max_length=100, description="Search in title"), + offset: int = Query(0, ge=0, le=10000, description="Pagination offset"), + limit: int = Query(100, ge=1, le=500, description="Number of results") +) -> Dict[str, Any]: + """Get list of notes with optional filtering""" + notes = read_jsonl_files() + + # Filter by keyword (source_keyword) + if keyword: + keyword_lower = keyword.lower() + notes = [n for n in notes if keyword_lower in (n.get("source_keyword", "") or "").lower()] + + # Filter by search text (title) + if search: + search_lower = search.lower() + notes = [n for n in notes if search_lower in (n.get("title", "") or "").lower()] + + # Apply pagination + total = len(notes) + notes = notes[offset:offset + limit] + + # Format response + formatted_notes = [format_note_for_response(n) for n in notes] + + return { + "notes": formatted_notes, + "total": total, + "offset": offset, + "limit": limit + } + + +@router.get("/stats") +async def get_notes_stats() -> Dict[str, Any]: + """Get notes statistics""" + notes = read_jsonl_files() + + # Calculate statistics + total_notes = len(notes) + total_images = 0 + keywords_stats = {} + + for note in notes: + # Count images + total_images += note.get("local_image_count", 0) + + # Count by keyword + keyword = note.get("source_keyword", "unknown") + if keyword: + keywords_stats[keyword] = keywords_stats.get(keyword, 0) + 1 + + # Get recent notes (last 5) + recent_notes = [format_note_for_response(n) for n in notes[:5]] + + return { + "total_notes": total_notes, + "total_images": total_images, + "keywords_stats": keywords_stats, + "recent_notes": recent_notes, + "last_updated": datetime.now().isoformat() + } + + +@router.get("/{note_id}") +async def get_note_detail(note_id: str) -> Dict[str, Any]: + """Get single note detail by ID.""" + # Validate note_id to prevent path traversal + if not validate_note_id(note_id): + raise HTTPException(status_code=400, detail="Invalid note ID format") + + notes = read_jsonl_files() + + for note in notes: + if note.get("note_id") == note_id: + formatted = format_note_for_response(note) + + # Add all local image URLs + local_image_count = note.get("local_image_count", 0) + formatted["image_urls"] = [ + f"/images/{note_id}/{i}.jpg" + for i in range(local_image_count) + ] + + return formatted + + raise HTTPException(status_code=404, detail="Note not found") + + +@router.get("/keywords") +async def get_keywords() -> Dict[str, Any]: + """Get list of unique keywords""" + notes = read_jsonl_files() + + keywords = set() + for note in notes: + keyword = note.get("source_keyword") + if keyword: + keywords.add(keyword) + + return { + "keywords": sorted(list(keywords)) + } diff --git a/viewer/static/css/style.css b/viewer/static/css/style.css new file mode 100644 index 000000000..f1d718ade --- /dev/null +++ b/viewer/static/css/style.css @@ -0,0 +1,602 @@ +/* 小红书数据查看器样式 */ + +:root { + --primary-color: #ff2442; + --primary-light: #ff6b81; + --bg-color: #f5f5f5; + --card-bg: #ffffff; + --text-primary: #333333; + --text-secondary: #666666; + --text-muted: #999999; + --border-color: #eee; + --shadow: 0 2px 12px rgba(0, 0, 0, 0.1); + --shadow-hover: 0 4px 20px rgba(0, 0, 0, 0.15); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + background-color: var(--bg-color); + color: var(--text-primary); + min-height: 100vh; +} + +/* 头部导航 */ +.header { + position: sticky; + top: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 24px; + background: var(--card-bg); + border-bottom: 1px solid var(--border-color); + box-shadow: var(--shadow); +} + +.header-left { + flex: 0 0 auto; +} + +.logo { + font-size: 20px; + font-weight: 600; + color: var(--primary-color); +} + +.header-center { + flex: 1; + display: flex; + align-items: center; + gap: 12px; + max-width: 500px; + margin: 0 24px; +} + +.filter-select { + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: 8px; + font-size: 14px; + background: var(--card-bg); + cursor: pointer; + min-width: 140px; +} + +.search-input { + flex: 1; + padding: 8px 16px; + border: 1px solid var(--border-color); + border-radius: 20px; + font-size: 14px; + outline: none; + transition: border-color 0.2s; +} + +.search-input:focus { + border-color: var(--primary-color); +} + +.header-right { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 16px; +} + +.refresh-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 8px 16px; + background: var(--primary-color); + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 14px; + transition: background 0.2s; +} + +.refresh-btn:hover { + background: var(--primary-light); +} + +.refresh-btn.loading .refresh-icon { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.last-update { + font-size: 12px; + color: var(--text-muted); +} + +/* 监控面板 */ +.monitor-panel { + display: flex; + align-items: center; + gap: 24px; + padding: 12px 24px; + background: var(--card-bg); + border-bottom: 1px solid var(--border-color); +} + +.status-card { + display: flex; + align-items: center; +} + +.status-indicator { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: 20px; + background: #f0f0f0; +} + +.status-indicator.running { + background: #e8f5e9; +} + +.status-indicator.running .status-dot { + background: #4caf50; + animation: pulse 2s infinite; +} + +.status-indicator.stopped .status-dot { + background: #9e9e9e; +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: #ccc; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.status-text { + font-size: 14px; + font-weight: 500; +} + +.stats-cards { + display: flex; + gap: 16px; +} + +.stat-card { + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 20px; + background: linear-gradient(135deg, #fff5f5 0%, #fff 100%); + border-radius: 12px; + min-width: 80px; +} + +.stat-value { + font-size: 24px; + font-weight: 700; + color: var(--primary-color); +} + +.stat-label { + font-size: 12px; + color: var(--text-muted); +} + +.keywords-stats { + flex: 1; + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.keyword-tag { + padding: 4px 12px; + background: #f5f5f5; + border-radius: 16px; + font-size: 12px; + color: var(--text-secondary); +} + +.keyword-tag span { + font-weight: 600; + color: var(--primary-color); +} + +/* 主内容区 */ +.main-content { + padding: 24px; + min-height: calc(100vh - 200px); +} + +/* 瀑布流布局 */ +.note-grid { + column-count: 4; + column-gap: 16px; +} + +@media (max-width: 1400px) { + .note-grid { column-count: 3; } +} + +@media (max-width: 1000px) { + .note-grid { column-count: 2; } + .header-center { max-width: 300px; } +} + +@media (max-width: 700px) { + .note-grid { column-count: 1; } + .header { + flex-wrap: wrap; + gap: 12px; + } + .header-center { + order: 3; + width: 100%; + max-width: none; + margin: 0; + } + .monitor-panel { + flex-wrap: wrap; + } +} + +/* 笔记卡片 */ +.note-card { + break-inside: avoid; + margin-bottom: 16px; + background: var(--card-bg); + border-radius: 12px; + overflow: hidden; + box-shadow: var(--shadow); + cursor: pointer; + transition: transform 0.2s, box-shadow 0.2s; +} + +.note-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-hover); +} + +.note-card-image { + width: 100%; + aspect-ratio: 3/4; + object-fit: cover; + background: #f5f5f5; +} + +.note-card-image.lazy { + opacity: 0; + transition: opacity 0.3s; +} + +.note-card-image.loaded { + opacity: 1; +} + +.note-card-placeholder { + width: 100%; + aspect-ratio: 3/4; + background: linear-gradient(135deg, #f5f5f5 0%, #e0e0e0 100%); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-size: 48px; +} + +.note-card-content { + padding: 12px; +} + +.note-card-title { + font-size: 14px; + font-weight: 500; + line-height: 1.4; + color: var(--text-primary); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + margin-bottom: 8px; +} + +.note-card-meta { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 12px; + color: var(--text-muted); +} + +.note-card-author { + display: flex; + align-items: center; + gap: 4px; + overflow: hidden; +} + +.note-card-author img { + width: 20px; + height: 20px; + border-radius: 50%; + object-fit: cover; +} + +.note-card-author span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.note-card-stats { + display: flex; + gap: 8px; +} + +.note-card-stats span { + display: flex; + align-items: center; + gap: 2px; +} + +/* 空状态 */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 80px 20px; + text-align: center; +} + +.empty-icon { + font-size: 64px; + margin-bottom: 16px; +} + +.empty-text { + font-size: 18px; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.empty-hint { + font-size: 14px; + color: var(--text-muted); +} + +/* 加载状态 */ +.loading-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 80px 20px; +} + +.loading-spinner { + width: 40px; + height: 40px; + border: 3px solid var(--border-color); + border-top-color: var(--primary-color); + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 16px; +} + +/* 模态框 */ +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); +} + +.modal-content { + position: relative; + width: 90%; + max-width: 800px; + max-height: 90vh; + background: var(--card-bg); + border-radius: 16px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.modal-close { + position: absolute; + top: 12px; + right: 12px; + width: 32px; + height: 32px; + border: none; + background: rgba(0, 0, 0, 0.5); + color: white; + font-size: 24px; + border-radius: 50%; + cursor: pointer; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; +} + +.modal-body { + display: flex; + flex-direction: column; + overflow-y: auto; +} + +/* 图片画廊 */ +.image-gallery { + position: relative; + background: #000; +} + +.gallery-main { + position: relative; + width: 100%; + aspect-ratio: 4/3; +} + +.gallery-main img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.gallery-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 40px; + height: 40px; + border: none; + background: rgba(255, 255, 255, 0.8); + color: #333; + font-size: 24px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.gallery-prev { + left: 12px; +} + +.gallery-next { + right: 12px; +} + +.gallery-info { + padding: 8px; + text-align: center; + color: white; + font-size: 14px; + background: rgba(0, 0, 0, 0.5); +} + +/* 笔记内容 */ +.note-content { + padding: 20px; +} + +.note-content .note-title { + font-size: 18px; + font-weight: 600; + margin-bottom: 12px; + line-height: 1.4; +} + +.note-meta { + display: flex; + gap: 16px; + margin-bottom: 12px; + font-size: 14px; + color: var(--text-muted); +} + +.note-stats { + display: flex; + gap: 16px; + margin-bottom: 16px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border-color); +} + +.stat-item { + font-size: 14px; + color: var(--text-secondary); +} + +.note-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +} + +.note-tag { + padding: 4px 12px; + background: #fff5f5; + color: var(--primary-color); + border-radius: 16px; + font-size: 12px; + cursor: pointer; + transition: background 0.2s; +} + +.note-tag:hover { + background: #ffe0e0; +} + +.note-desc { + font-size: 14px; + line-height: 1.6; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-word; + margin-bottom: 16px; + max-height: 200px; + overflow-y: auto; +} + +.note-link { + display: inline-block; + color: var(--primary-color); + text-decoration: none; + font-size: 14px; +} + +.note-link:hover { + text-decoration: underline; +} + +/* 视频标识 */ +.video-badge { + position: absolute; + top: 8px; + right: 8px; + background: rgba(0, 0, 0, 0.7); + color: white; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; +} diff --git a/viewer/static/index.html b/viewer/static/index.html new file mode 100644 index 000000000..34e5d9a41 --- /dev/null +++ b/viewer/static/index.html @@ -0,0 +1,110 @@ + + +
+ + +加载中...
+