feat(popup): 重做扩展云同步中心与项目文档
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
server/node_modules/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
700
PROJECT_ARCHITECTURE.md
Normal file
700
PROJECT_ARCHITECTURE.md
Normal file
@@ -0,0 +1,700 @@
|
||||
# 涩花塘磁力助手 - 项目架构与部署说明
|
||||
|
||||
> 更新时间:2026-03-14
|
||||
> 说明:本文档描述当前项目的整体架构、核心数据流、缓存策略、云同步设计、线上部署信息(**不包含任何敏感凭据**),以及当前已知问题与优化方向。
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目概览
|
||||
|
||||
本项目由两部分组成:
|
||||
|
||||
1. **Chrome 扩展端**
|
||||
负责在涩花塘论坛页面中:
|
||||
- 识别列表页 / 帖子页
|
||||
- 批量抓取帖子与磁力链接
|
||||
- 本地缓存帖子、范围快照、页快照、磁力结果
|
||||
- 展示搜索结果、缓存总览、收藏夹、云同步入口
|
||||
|
||||
2. **云端共享缓存服务**
|
||||
负责:
|
||||
- 共享线程缓存(shared thread cache)
|
||||
- 共享范围缓存(shared coverage cache)
|
||||
- 共享页缓存(shared page cache)
|
||||
- 覆盖规划(coverage planning)
|
||||
- 私有保险柜(vault)
|
||||
- 账号注册 / 登录 / 鉴权
|
||||
|
||||
---
|
||||
|
||||
## 2. 目录结构
|
||||
|
||||
### 扩展端根目录
|
||||
|
||||
- `manifest.json`:Chrome 扩展清单(MV3)
|
||||
- `content.js`:内容脚本,挂载 UI、执行抓取、合并缓存、结果展示
|
||||
- `background.js`:Service Worker,负责 IndexedDB、本地状态、云接口、跨页请求
|
||||
- `popup.html` / `popup.js`:扩展独立云同步中心 + 快捷复制入口
|
||||
|
||||
### 服务端目录
|
||||
|
||||
- `server/package.json`:服务端依赖与脚本
|
||||
- `server/src/index.js`:Fastify 入口
|
||||
- `server/src/config.js`:环境变量配置
|
||||
- `server/src/db.js`:MySQL 连接池
|
||||
- `server/src/redis.js`:Redis 连接与 JSON 缓存
|
||||
- `server/src/crypto.js`:AES-GCM 加密与哈希
|
||||
- `server/src/auth.js`:认证辅助逻辑
|
||||
- `server/src/routes/auth.js`:注册 / 登录 / me / logout
|
||||
- `server/src/routes/vault.js`:私有保险柜 push / pull
|
||||
- `server/src/routes/shared-cache.js`:共享缓存查找 / 写入 / coverage planning
|
||||
- `server/sql/001_init.sql`:数据库初始化表结构
|
||||
|
||||
---
|
||||
|
||||
## 3. 扩展端架构
|
||||
|
||||
### 3.1 页面层(content.js)
|
||||
|
||||
`content.js` 负责:
|
||||
|
||||
- 判断当前页面是:
|
||||
- 列表页(forumdisplay / forum-x-y.html / normalthread tbody)
|
||||
- 帖子页(thread-xxx / tid=xxx)
|
||||
- 创建浮动面板 UI
|
||||
- 管理:
|
||||
- 关键词搜索
|
||||
- 页码范围
|
||||
- 速度模式
|
||||
- 结果列表
|
||||
- 收藏页
|
||||
- 缓存页
|
||||
- 云同步页入口
|
||||
- 调用后台:
|
||||
- 读取 / 保存本地缓存
|
||||
- 读取 / 保存进度状态
|
||||
- 触发云同步相关动作
|
||||
|
||||
### 3.2 后台层(background.js)
|
||||
|
||||
`background.js` 是当前项目的核心调度层,负责:
|
||||
|
||||
- IndexedDB 缓存存取
|
||||
- 云缓存接口调用
|
||||
- 共享缓存 planning 调用
|
||||
- 上传抑制(hash 去重 + TTL 节流)
|
||||
- 本地历史缓存回填到云端
|
||||
- 私有保险柜加解密
|
||||
- 云账号登录状态维护
|
||||
|
||||
### 3.3 本地存储层
|
||||
|
||||
当前本地存储分三类:
|
||||
|
||||
1. **IndexedDB(结构化缓存)**
|
||||
- `threads`
|
||||
- `coverages`
|
||||
- `pageCoverages`
|
||||
|
||||
2. **chrome.storage.local(扩展私有存储)**
|
||||
- 收藏夹
|
||||
- 搜索历史
|
||||
- 云同步状态
|
||||
- 上传元数据去重表
|
||||
- 进度状态
|
||||
|
||||
3. **运行时内存态**
|
||||
- 当前抓取状态
|
||||
- session backup 临时状态
|
||||
|
||||
---
|
||||
|
||||
## 4. 服务端架构
|
||||
|
||||
### 4.1 服务端职责
|
||||
|
||||
云服务现在不仅是“存数据”,还承担:
|
||||
|
||||
- 共享缓存存储
|
||||
- 去重写入
|
||||
- coverage planning
|
||||
- Redis 缓存 planning 结果
|
||||
- 账号认证
|
||||
- 私有保险柜同步
|
||||
|
||||
### 4.2 路由职责
|
||||
|
||||
#### 认证
|
||||
- `POST /api/auth/register`
|
||||
- `POST /api/auth/login`
|
||||
- `GET /api/auth/me`
|
||||
- `POST /api/auth/logout`
|
||||
|
||||
#### 私有保险柜
|
||||
- `POST /api/vault/push`
|
||||
- `POST /api/vault/pull`
|
||||
|
||||
#### 共享缓存
|
||||
- `POST /api/shared-cache/threads/lookup`
|
||||
- `POST /api/shared-cache/threads/upsert`
|
||||
- `POST /api/shared-cache/coverages/lookup`
|
||||
- `POST /api/shared-cache/coverages/upsert`
|
||||
- `POST /api/shared-cache/pages/lookup`
|
||||
- `POST /api/shared-cache/pages/upsert`
|
||||
- `POST /api/shared-cache/coverages/plan`
|
||||
|
||||
#### 健康检查
|
||||
- `GET /health`
|
||||
- `GET /ready`
|
||||
|
||||
---
|
||||
|
||||
## 5. 本地缓存模型
|
||||
|
||||
### 5.1 `threads`
|
||||
存储唯一帖子级信息:
|
||||
|
||||
- `forumKey`
|
||||
- `threadKey`
|
||||
- `url`
|
||||
- `title`
|
||||
- `lastSeenAt`
|
||||
- `magnets`
|
||||
- `lastMagnetSyncAt`
|
||||
|
||||
特点:
|
||||
- 同一板块 + 同一帖子只保留一份
|
||||
- 线程磁链缓存优先从这里命中
|
||||
|
||||
### 5.2 `coverages`
|
||||
存储范围级快照:
|
||||
|
||||
- `forumKey`
|
||||
- `startPage`
|
||||
- `endPage`
|
||||
- `strategy`
|
||||
- `frontRefreshPages`
|
||||
- `threadKeys`
|
||||
|
||||
特点:
|
||||
- 表示某个页范围对应的帖子集合
|
||||
- 用于 exact coverage / shifted coverage / 历史 coverage 碎片复用
|
||||
|
||||
### 5.3 `pageCoverages`
|
||||
存储页级快照:
|
||||
|
||||
- `forumKey`
|
||||
- `page`
|
||||
- `threadKeys`
|
||||
|
||||
特点:
|
||||
- 用来组装连续页块
|
||||
- 当前大范围命中优化的重要基础
|
||||
|
||||
---
|
||||
|
||||
## 6. 云端缓存模型
|
||||
|
||||
### 6.1 共享线程缓存 `shared_thread_cache`
|
||||
- 唯一键:`(forum_key, thread_key)`
|
||||
- 数据库存储加密:AES-GCM
|
||||
- 用于跨用户复用帖子级磁链结果
|
||||
|
||||
### 6.2 共享范围缓存 `shared_coverage_cache`
|
||||
- 唯一键:`(forum_key, start_page, end_page, strategy)`
|
||||
- 用于整段范围的覆盖规划
|
||||
|
||||
### 6.3 共享页缓存 `shared_page_cache`
|
||||
- 唯一键:`(forum_key, page)`
|
||||
- 用于连续页块拼装
|
||||
|
||||
### 6.4 私有保险柜 `vault_items`
|
||||
- 用户级密文存储
|
||||
- 当前用于:
|
||||
- 收藏夹
|
||||
- 搜索历史
|
||||
- UI 设置
|
||||
- 进度状态
|
||||
|
||||
---
|
||||
|
||||
## 7. 抓取流程(当前实际运行路径)
|
||||
|
||||
### 7.1 点击“开始获取”后的流程
|
||||
|
||||
1. 读取页码范围 / 关键词 / 速度配置
|
||||
2. 构建 `searchContext`
|
||||
3. 显示:
|
||||
- `正在规划缓存:检查本地缓存 / 云端规划 / 复用块...`
|
||||
4. 调用 `getCachedCoveragePlan(...)`
|
||||
5. 依次判断:
|
||||
- exact coverage
|
||||
- assembled page coverage
|
||||
- 服务端 planning
|
||||
- cachedBlocks
|
||||
- shiftedCoverage
|
||||
6. 对缺口区间执行 live 抓取
|
||||
7. 结果与缓存合并后,再写回本地与云端
|
||||
|
||||
### 7.2 实际搜索执行路径
|
||||
|
||||
主要函数:
|
||||
|
||||
- `fetchFromPage(...)`
|
||||
- `fetchLivePageRange(...)`
|
||||
- `processCachedThreadBatch(...)`
|
||||
- `applyCachedMagnetHits(...)`
|
||||
|
||||
---
|
||||
|
||||
## 8. 去重与上传抑制策略
|
||||
|
||||
### 8.1 本地去重
|
||||
|
||||
- 线程去重:`normalizeCachedThreads()`
|
||||
- 磁链去重:`normalizeMagnetList()`
|
||||
- coverage 合并去重:`mergeCoverageThreads()`
|
||||
- coverage 保存前合并:`mergeCoverageThreadLists()`
|
||||
|
||||
### 8.2 云端去重
|
||||
|
||||
数据库唯一索引 + `ON DUPLICATE KEY UPDATE`
|
||||
|
||||
### 8.3 上传抑制
|
||||
|
||||
本地维护 upload meta:
|
||||
|
||||
- `threads`
|
||||
- `coverages`
|
||||
- `pages`
|
||||
|
||||
每类记录:
|
||||
- `payloadHash`
|
||||
- `lastUploadedAt`
|
||||
|
||||
TTL:
|
||||
|
||||
- 线程:10 分钟
|
||||
- 页:30 分钟
|
||||
- 范围:60 分钟
|
||||
|
||||
作用:
|
||||
- 降低重复上传
|
||||
- 降低重复加密
|
||||
- 降低数据库写入压力
|
||||
|
||||
---
|
||||
|
||||
## 9. 当前“智能更新”逻辑
|
||||
|
||||
### 已实现
|
||||
|
||||
1. front refresh(从第一页开始时可刷新前段)
|
||||
2. shiftedCoverage 复用
|
||||
3. page cache block 复用
|
||||
4. intersecting coverage fragments 复用
|
||||
5. 服务端 coverage planning
|
||||
6. Redis 缓存 planning 结果
|
||||
|
||||
### 当前仍不够理想的点
|
||||
|
||||
1. **thread cache 很多,但未完全反向沉淀成 page/coverage 索引**
|
||||
这会导致“明明有很多历史线程缓存,但大范围规划不够聪明”。
|
||||
|
||||
2. **coverage 合并仍然偏保守**
|
||||
少量前页更新后,虽然已经补了历史 coverage 合并,但仍建议继续增强“旧帖保留连续性”。
|
||||
|
||||
3. **前段刷新策略仍是经验型策略**
|
||||
当前已从“固定刷新前 20 页”改为“前面没有缓存覆盖才刷新”,但仍属于启发式策略。
|
||||
|
||||
4. **启动前规划仍然是同步等待**
|
||||
现在已经加了状态提示,但未来可以做:
|
||||
- 本地快速规划优先
|
||||
- 云端规划异步补充
|
||||
|
||||
---
|
||||
|
||||
## 10. 线上部署信息(不含敏感凭据)
|
||||
|
||||
### 服务器基础信息
|
||||
|
||||
- 公网 IP:`47.238.173.98`
|
||||
- 域名:`s.52oai.com`
|
||||
- 系统:CentOS 7(当前会话已确认)
|
||||
- 部署方式:Nginx + PM2 + Node.js + MySQL + Redis
|
||||
- 面板:宝塔面板(BT Panel)
|
||||
|
||||
### 当前服务端组成
|
||||
|
||||
- Nginx:反向代理入口
|
||||
- Node 服务:`magnet-cloud-cache`
|
||||
- PM2:进程守护
|
||||
- MySQL:主数据存储
|
||||
- Redis:coverage planning 结果缓存
|
||||
|
||||
### 线上访问入口
|
||||
|
||||
- `https://s.52oai.com/health`
|
||||
- `https://s.52oai.com/ready`
|
||||
|
||||
### Redis 状态
|
||||
|
||||
当前已验证:
|
||||
|
||||
- Redis 可连接
|
||||
- 可写入/读取 JSON
|
||||
- coverage planning key 已落库
|
||||
|
||||
---
|
||||
|
||||
## 11. 当前安全边界
|
||||
|
||||
### 已完成的安全改造
|
||||
|
||||
1. 云同步登录/注册 UI 已迁移到扩展独立页面
|
||||
2. 收藏/搜索历史已迁移到扩展私有存储
|
||||
3. session 进度备份已不再依赖页面 `sessionStorage`
|
||||
4. shared-cache 写接口已加写入令牌鉴权
|
||||
5. shared-cache 已避免旧写覆盖新写
|
||||
|
||||
### 仍建议后续加强
|
||||
|
||||
1. `vaultKeyBase64` 改为 session-only
|
||||
2. auth/vault/shared-cache 更细粒度限流
|
||||
3. 服务端请求体大小再收紧
|
||||
4. 共享缓存写审计日志
|
||||
|
||||
---
|
||||
|
||||
## 12. 当前最值得继续优化的方向
|
||||
|
||||
### P1 - 正确性优先
|
||||
|
||||
1. **thread cache 反向沉淀成 page/coverage 索引**
|
||||
这是目前提升“大范围缓存命中率”的最高价值项。
|
||||
|
||||
2. **coverage 合并策略继续增强**
|
||||
尤其是前段少量更新后,历史旧帖不应被挤出当前范围搜索。
|
||||
|
||||
3. **规划器结果可观测化**
|
||||
建议增加 debug summary:
|
||||
- 命中 exact coverage
|
||||
- 命中 page blocks 数
|
||||
- 命中 intersecting coverage 数
|
||||
- 实际缺口页数
|
||||
|
||||
### P2 - 性能优先
|
||||
|
||||
1. 本地快速规划优先,云端 planning 异步补充
|
||||
2. planning 结果本地短时缓存
|
||||
3. 回填队列分批化
|
||||
|
||||
---
|
||||
|
||||
## 12.1 当前抓取合并 / 去重 / 智能更新逻辑复盘
|
||||
|
||||
### 一、当前逻辑里已经做对的部分
|
||||
|
||||
1. **线程级去重已经明确**
|
||||
- 本地通过 `threadKey` 去重
|
||||
- 云端通过 `(forum_key, thread_key)` 唯一约束去重
|
||||
|
||||
2. **磁链级去重已经明确**
|
||||
- 本地通过 `normalizeMagnetList()` 去重
|
||||
- 云端共享缓存目前按线程保存,磁链集合在单线程内部去重
|
||||
|
||||
3. **coverage 规划已经从“只看精确范围”进化为多层来源**
|
||||
当前 `getCachedCoveragePlan()` 已经综合:
|
||||
- exact coverage
|
||||
- assembled page coverage
|
||||
- 服务端 planning
|
||||
- cachedBlocks(页块)
|
||||
- intersecting coverages(相交范围碎片)
|
||||
- shiftedCoverage
|
||||
|
||||
4. **上传抑制已经开始发挥作用**
|
||||
- thread/page/coverage 都有 upload meta
|
||||
- hash + TTL 可以减少重复上云
|
||||
|
||||
### 二、当前逻辑不够理想的核心点
|
||||
|
||||
#### 1. 线程缓存与页/范围索引仍然是“半脱节”
|
||||
|
||||
这是目前最关键的问题:
|
||||
|
||||
- `threads` 里可能已经积累了大量帖子与磁链
|
||||
- 但这些历史数据没有系统性地回灌成:
|
||||
- `pageCoverages`
|
||||
- `coverages`
|
||||
|
||||
结果就是:
|
||||
|
||||
> 看起来缓存很多,但真正能参与“大范围规划”的缓存块不够多。
|
||||
|
||||
#### 2. 智能更新仍然偏启发式,而不是差异驱动
|
||||
|
||||
当前 front refresh / shiftedCoverage 还是基于:
|
||||
|
||||
- 从第 1 页开始时,倾向刷新前段
|
||||
- 历史范围快照的最近性
|
||||
|
||||
但它缺少真正的:
|
||||
|
||||
- 基于“帖子是否变化”的差异判断
|
||||
- 基于线程游标 / 内容指纹 / 页指纹 的更新模型
|
||||
|
||||
#### 3. coverage 合并虽然已经补强,但仍然不够结构化
|
||||
|
||||
当前已经补了:
|
||||
|
||||
- 保存新 coverage 前,合并历史相交 coverage 的线程
|
||||
|
||||
但这个策略仍是:
|
||||
|
||||
- 按线程集合合并
|
||||
- 不是按“真实页结构”或“线程页位置偏移”合并
|
||||
|
||||
所以在论坛前段轻微更新时,仍可能出现:
|
||||
|
||||
- 范围快照连续性不足
|
||||
- 旧帖可见性波动
|
||||
|
||||
#### 4. 服务端 planning 已加入,但仍然是“辅助规划器”
|
||||
|
||||
现在服务端已经能返回:
|
||||
|
||||
- `exactCoverage`
|
||||
- `cachedBlocks`
|
||||
- `shiftedCoverage`
|
||||
|
||||
但扩展端仍保留了大量本地规划逻辑,导致:
|
||||
|
||||
- 本地规划与服务端规划并存
|
||||
- 逻辑复杂度上升
|
||||
- 调试成本高
|
||||
|
||||
### 三、当前最值得优先继续优化的技术方向
|
||||
|
||||
#### A. `threads -> pageCoverages / coverages` 反向沉淀
|
||||
|
||||
这是正确性和命中率的第一优先级优化。
|
||||
|
||||
目标:
|
||||
|
||||
- 当系统手里已经有大量 thread cache 时
|
||||
- 能定期 / 按需将其重建为:
|
||||
- page coverage block
|
||||
- coverage fragment
|
||||
|
||||
收益:
|
||||
|
||||
- “缓存明明很多却命中不上”的问题会明显缓解
|
||||
|
||||
#### B. 引入“线程或页面指纹”作为智能更新依据
|
||||
|
||||
例如:
|
||||
|
||||
- 页面线程列表 hash
|
||||
- 范围线程集合 hash
|
||||
- 线程集合版本号
|
||||
|
||||
这样 front refresh 就能从“经验刷新”变成:
|
||||
|
||||
- 先比对指纹
|
||||
- 只有前段真的变化才刷新
|
||||
|
||||
#### C. 最终把 planning 统一到服务端主导
|
||||
|
||||
建议目标不是“双规划器”,而是:
|
||||
|
||||
- 服务端负责 coverage planning
|
||||
- 扩展端只负责执行计划
|
||||
- 本地逻辑只保留兜底回退
|
||||
|
||||
这样架构会明显更稳。
|
||||
|
||||
### P3 - 运维优先
|
||||
|
||||
1. 共享缓存保留策略 / 归档策略
|
||||
2. 写入审计与异常监控
|
||||
3. 密钥轮换机制
|
||||
|
||||
---
|
||||
|
||||
## 13. 当前版本的整体评价
|
||||
|
||||
### 已经具备的能力
|
||||
|
||||
- 本地线程 / 范围 / 页缓存
|
||||
- 云端共享缓存
|
||||
- 私有保险柜
|
||||
- 上传抑制与 TTL 节流
|
||||
- coverage planning + Redis planning cache
|
||||
- 扩展端 UI / 云同步中心 / 收藏 / 搜索历史
|
||||
|
||||
### 当前最核心的短板
|
||||
|
||||
> 缓存数据已经很多,但“如何把这些缓存组织成最优复用计划”仍然不够强。
|
||||
|
||||
也就是说,当前系统最难的不是“存缓存”,而是:
|
||||
|
||||
> **把已有缓存规划成正确、完整、连续的搜索路径**
|
||||
|
||||
---
|
||||
|
||||
## 14. 文档使用建议
|
||||
|
||||
如果后续你继续迭代项目,建议把本文档当成:
|
||||
|
||||
1. **新人接手说明**
|
||||
2. **线上部署说明**
|
||||
3. **缓存/同步逻辑总览**
|
||||
4. **后续重构路线图**
|
||||
|
||||
---
|
||||
|
||||
## 15. 敏感信息说明
|
||||
|
||||
本文档**不记录**以下信息:
|
||||
|
||||
- SSH 密码
|
||||
- MySQL 密码
|
||||
- write token
|
||||
- 加密密钥
|
||||
- `.env` 明文内容
|
||||
|
||||
这些信息应只保留在安全的部署环境与秘密管理系统中。
|
||||
|
||||
---
|
||||
|
||||
## 16. Chrome 分发与更新能力边界
|
||||
|
||||
结合当前平台限制和架构评估,下面是**真实可行**与**不可行**的边界。
|
||||
|
||||
### 16.1 对普通 Chrome 用户,不可直接做到的事
|
||||
|
||||
以下能力在普通非企业托管 Chrome 中,**不现实或不受支持**:
|
||||
|
||||
1. **从网站自动把扩展装进 Chrome**
|
||||
2. **通过主页密码后,静默安装 CRX**
|
||||
3. **普通用户使用自托管 CRX 自动更新**
|
||||
|
||||
也就是说:
|
||||
|
||||
> `s.52oai.com` 可以做“分发引导页”,但不能真正实现“输密码后自动装到 Chrome 里”。
|
||||
|
||||
### 16.2 最现实的分发方式
|
||||
|
||||
对于普通用户,最推荐:
|
||||
|
||||
1. **发布到 Chrome Web Store(建议 unlisted)**
|
||||
2. `s.52oai.com` 做密码门禁页
|
||||
3. 门禁通过后展示:
|
||||
- CWS 安装链接
|
||||
- 安装说明
|
||||
- 使用说明
|
||||
|
||||
这样可以获得:
|
||||
|
||||
- 官方安装路径
|
||||
- Chrome 自动更新
|
||||
- 最低用户支持成本
|
||||
|
||||
### 16.3 自托管更新什么时候才成立
|
||||
|
||||
只有在以下场景,自托管更新才是现实方案:
|
||||
|
||||
- **企业托管 Chrome**
|
||||
- 管理员通过策略安装
|
||||
- 使用企业设备 / 组织统一管理浏览器
|
||||
|
||||
这不是普通公网用户场景。
|
||||
|
||||
### 16.4 当前项目建议的分发策略
|
||||
|
||||
如果未来要正式分发,推荐顺序:
|
||||
|
||||
1. **Chrome Web Store(最好 Unlisted)**
|
||||
2. `s.52oai.com` 做密码门禁与引导页
|
||||
3. 扩展内做“最低支持版本”检查
|
||||
4. 如果版本太低,则提示用户去商店更新
|
||||
|
||||
### 16.5 站点密码的真实作用
|
||||
|
||||
主页密码(例如 `123456`)只能充当:
|
||||
|
||||
- 引导门槛
|
||||
- 简单访问控制
|
||||
- 降低无关访问
|
||||
|
||||
但它**不能视为真正安全边界**。真正的权限控制仍然必须放在:
|
||||
|
||||
- 后端账号系统
|
||||
- API 鉴权
|
||||
- 服务端授权逻辑
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 16. Chrome 分发与更新能力边界(部署到主页的现实约束)
|
||||
|
||||
### 16.1 对普通 Chrome 用户,不能直接做到的事
|
||||
|
||||
对于**普通、非企业托管**的 Chrome 用户,当前不支持:
|
||||
|
||||
1. 从 `s.52oai.com` 网页直接自动安装扩展
|
||||
2. 从自有网站静默安装 CRX
|
||||
3. 对普通用户使用自托管 CRX 做自动更新
|
||||
|
||||
也就是说:
|
||||
|
||||
> 网站可以做“入口页 / 密码门禁页 / 引导页”,但不能真正代替 Chrome 官方安装渠道完成自动安装。
|
||||
|
||||
### 16.2 对普通用户最现实的方案
|
||||
|
||||
最可行方案是:
|
||||
|
||||
1. 发布到 **Chrome Web Store(建议 Unlisted)**
|
||||
2. `s.52oai.com` 作为密码门禁页
|
||||
3. 输入密码后展示:
|
||||
- Chrome Web Store 安装链接
|
||||
- 使用说明
|
||||
- 更新说明
|
||||
|
||||
这样可以获得:
|
||||
|
||||
- 标准安装体验
|
||||
- Chrome 自动更新
|
||||
- 最低的用户支持成本
|
||||
|
||||
### 16.3 自托管自动更新什么时候可行
|
||||
|
||||
只有在:
|
||||
|
||||
- 企业托管 Chrome
|
||||
- 管理员策略安装
|
||||
- 受管设备/受管浏览器环境
|
||||
|
||||
这种情况下,自托管 CRX + update manifest 才是现实路径。
|
||||
|
||||
### 16.4 当前项目推荐的分发模式
|
||||
|
||||
推荐顺序:
|
||||
|
||||
1. **Chrome Web Store(Unlisted)** 作为主分发渠道
|
||||
2. `s.52oai.com` 做密码门禁 + 安装引导页
|
||||
3. 扩展内调用服务端 `/ext/config`(后续可实现)检查最低支持版本
|
||||
4. 如果版本过低,提示用户跳转到商店升级
|
||||
|
||||
### 16.5 密码门禁的真实作用
|
||||
|
||||
密码门禁只能起到:
|
||||
|
||||
- 隐藏安装入口
|
||||
- 控制谁能看到链接
|
||||
|
||||
但它**不能视为真正的安全边界**。真正的权限控制必须放在:
|
||||
|
||||
- 后端账号体系
|
||||
- API token / 登录态
|
||||
- 服务器侧权限判断
|
||||
202
popup.html
202
popup.html
@@ -2,7 +2,7 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@500;600;700&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<title>磁力助手</title>
|
||||
<style>
|
||||
:root {
|
||||
--m-bg-deep: #0a0e14;
|
||||
@@ -10,124 +10,130 @@
|
||||
--m-bg-secondary: #1a1f2e;
|
||||
--m-bg-card: rgba(26, 31, 46, 0.92);
|
||||
--m-accent: #00d4aa;
|
||||
--m-accent-glow: rgba(0, 212, 170, 0.4);
|
||||
--m-accent-2: #00f5c4;
|
||||
--m-danger: #ef4444;
|
||||
--m-text-primary: #f0f4f8;
|
||||
--m-text-secondary: #8892a4;
|
||||
--m-text-muted: #5c6578;
|
||||
--m-border: rgba(255, 255, 255, 0.06);
|
||||
--m-border-accent: rgba(0, 212, 170, 0.3);
|
||||
--m-font-display: "Rajdhani", "Microsoft YaHei", sans-serif;
|
||||
--m-font-body: "Noto Sans SC", "Microsoft YaHei", sans-serif;
|
||||
--m-radius-md: 12px;
|
||||
--m-border: rgba(255,255,255,0.08);
|
||||
--m-radius: 12px;
|
||||
}
|
||||
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
width: 260px;
|
||||
padding: 20px;
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background: var(--m-bg-primary);
|
||||
font-family: var(--m-font-body);
|
||||
color: var(--m-text-primary);
|
||||
font: 13px/1.5 "Microsoft YaHei", sans-serif;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:16px; }
|
||||
.title { font-size: 18px; font-weight: 700; color: var(--m-accent); }
|
||||
.subtitle { font-size: 11px; color: var(--m-text-secondary); }
|
||||
.header-status { display:flex; flex-direction:column; align-items:flex-end; gap:6px; min-width:120px; }
|
||||
.header-status .status { margin-top:0; }
|
||||
.card {
|
||||
background: var(--m-bg-card);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.popup-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, rgba(0, 212, 170, 0.2) 0%, rgba(0, 212, 170, 0.1) 100%);
|
||||
border: 1px solid var(--m-border-accent);
|
||||
border-radius: 10px;
|
||||
color: var(--m-accent);
|
||||
}
|
||||
|
||||
.popup-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-family: var(--m-font-display);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
background: linear-gradient(135deg, var(--m-accent) 0%, #00f5c4 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.popup-desc {
|
||||
font-size: 12px;
|
||||
color: var(--m-text-secondary);
|
||||
margin: 0 0 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
button {
|
||||
.card-title { font-size: 13px; font-weight: 700; margin-bottom: 8px; }
|
||||
.meta { color: var(--m-text-secondary); font-size: 12px; }
|
||||
.row { display:flex; gap:8px; margin-top:10px; }
|
||||
.col { display:flex; flex-direction:column; gap:10px; }
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(135deg, var(--m-accent) 0%, #00f5c4 100%);
|
||||
color: var(--m-bg-deep);
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--m-radius);
|
||||
border: 1px solid var(--m-border);
|
||||
background: var(--m-bg-secondary);
|
||||
color: var(--m-text-primary);
|
||||
outline: none;
|
||||
}
|
||||
input:focus { border-color: var(--m-accent); }
|
||||
button {
|
||||
border: none;
|
||||
border-radius: var(--m-radius-md);
|
||||
border-radius: var(--m-radius);
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
font-family: var(--m-font-display);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 212, 170, 0.4);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.popup-footer {
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--m-border);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.popup-footer-text {
|
||||
font-size: 10px;
|
||||
color: var(--m-text-muted);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.primary { background: linear-gradient(135deg, var(--m-accent), var(--m-accent-2)); color: var(--m-bg-deep); }
|
||||
.secondary { background: var(--m-bg-secondary); color: var(--m-text-primary); border: 1px solid var(--m-border); }
|
||||
.danger { background: rgba(239,68,68,0.16); color: #ff8585; border: 1px solid rgba(239,68,68,0.25); }
|
||||
.status { display:flex; align-items:center; gap:8px; margin-top:8px; }
|
||||
.dot { width: 8px; height: 8px; border-radius:50%; background: var(--m-danger); box-shadow: 0 0 8px rgba(239,68,68,.35); }
|
||||
.dot.ok { background:#10b981; box-shadow:0 0 8px rgba(16,185,129,.35); }
|
||||
.small { font-size: 11px; color: var(--m-text-muted); }
|
||||
.hidden { display:none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="popup-header">
|
||||
<div class="popup-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" role="img" aria-label="Magnet">
|
||||
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
|
||||
</svg>
|
||||
<div class="header">
|
||||
<div>
|
||||
<div class="title">MAGNET CLOUD</div>
|
||||
<div class="subtitle">扩展独立云同步中心</div>
|
||||
</div>
|
||||
<div class="header-status">
|
||||
<div class="status"><span id="accountDot" class="dot"></span><span id="accountText" class="meta">账号状态加载中</span></div>
|
||||
<div class="status"><span id="serverDot" class="dot"></span><span id="serverText" class="meta">服务器状态加载中</span></div>
|
||||
<div id="syncEmail" class="small"></div>
|
||||
</div>
|
||||
<div class="popup-title">MAGNET</div>
|
||||
</div>
|
||||
|
||||
<p class="popup-desc">提取当前页面的磁力链接并复制到剪贴板</p>
|
||||
|
||||
<button id="copyBtn" type="button">复制当前页磁力链接</button>
|
||||
|
||||
<div class="popup-footer">
|
||||
<span class="popup-footer-text">仅支持涩花塘论坛页面</span>
|
||||
|
||||
<div class="card col">
|
||||
<div class="card-title">云端状态看板</div>
|
||||
<div id="cloudStatsSummary" class="meta">正在读取云端统计...</div>
|
||||
<div id="cloudStatsLatest" class="small"></div>
|
||||
<div id="cloudStatsTables" class="small"></div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card col">
|
||||
<div class="card-title">本地状态看板</div>
|
||||
<div id="localStatsSummary" class="meta">正在读取本地统计...</div>
|
||||
<div id="localStatsStorage" class="small"></div>
|
||||
<div id="localStatsMeta" class="small"></div>
|
||||
</div>
|
||||
|
||||
<div id="authLoggedOut" class="card col">
|
||||
<div class="card-title">登录 / 注册</div>
|
||||
<div class="meta">账号密码只会在扩展页面中输入,不再放在网页里。</div>
|
||||
<input id="emailInput" type="text" placeholder="邮箱">
|
||||
<input id="passwordInput" type="password" placeholder="密码,至少6位">
|
||||
<div class="row">
|
||||
<button id="loginBtn" class="primary" type="button">登录</button>
|
||||
<button id="registerBtn" class="secondary" type="button">注册</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="authLoggedIn" class="card col hidden">
|
||||
<div class="card-title">已登录</div>
|
||||
<div id="loggedInEmail" class="meta"></div>
|
||||
<div class="row">
|
||||
<button id="syncNowBtn" class="primary" type="button">立即同步</button>
|
||||
<button id="logoutBtn" class="danger" type="button">退出登录</button>
|
||||
</div>
|
||||
<div id="syncProgressText" class="small"></div>
|
||||
</div>
|
||||
|
||||
<div class="card col">
|
||||
<div class="card-title">下载器推荐</div>
|
||||
<div class="meta">如果点击磁力“下载”按钮没有反应,请先安装本地下载器。qBittorrent 更适合磁力资源,Motrix 更适合偏轻量的使用习惯。</div>
|
||||
<div class="row">
|
||||
<button id="downloadNode2Btn" class="primary" type="button">qB 节点2(推荐)</button>
|
||||
<button id="downloadOfficialBtn" class="secondary" type="button">qB 官方节点</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button id="downloadMotrixBtn" class="secondary" type="button">Motrix 下载</button>
|
||||
</div>
|
||||
<div class="small">安装完成后,请确保 qBittorrent 已关联 magnet 磁力链接协议。</div>
|
||||
</div>
|
||||
|
||||
<div id="message" class="small"></div>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
298
popup.js
298
popup.js
@@ -1,33 +1,277 @@
|
||||
document.getElementById('copyBtn').addEventListener('click', function() {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, function(tabs) {
|
||||
var activeTab = tabs && tabs[0];
|
||||
if (!activeTab || typeof activeTab.id !== 'number') {
|
||||
alert('未找到当前标签页');
|
||||
return;
|
||||
}
|
||||
|
||||
chrome.tabs.sendMessage(activeTab.id, { action: 'getMagnets' }, function(response) {
|
||||
function sendMessageToBackground(message) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
chrome.runtime.sendMessage(message, function(response) {
|
||||
if (chrome.runtime.lastError) {
|
||||
alert('当前页面不支持,请在涩花塘论坛页面使用');
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
return;
|
||||
}
|
||||
|
||||
var magnets = response && Array.isArray(response.magnets) ? response.magnets : [];
|
||||
magnets = Array.from(new Set(magnets));
|
||||
|
||||
if (magnets.length === 0) {
|
||||
alert('当前页面未找到磁力链接');
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(magnets.join('\n'))
|
||||
.then(function() {
|
||||
alert('已复制 ' + magnets.length + ' 个磁力链接!');
|
||||
})
|
||||
.catch(function(err) {
|
||||
var errorMsg = err && err.message ? err.message : '复制失败';
|
||||
alert('复制失败:' + errorMsg);
|
||||
});
|
||||
resolve(response || null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setMessage(text, isError) {
|
||||
var message = document.getElementById('message');
|
||||
if (!message) return;
|
||||
message.textContent = text || '';
|
||||
message.style.color = isError ? '#ff8585' : '#8892a4';
|
||||
}
|
||||
|
||||
var lastGoodCloudStats = null;
|
||||
var lastGoodSyncStatus = null;
|
||||
var syncProgressTimer = null;
|
||||
var lastServerHealthy = null;
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise(function(resolve) {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function setSyncProgress(text) {
|
||||
var el = document.getElementById('syncProgressText');
|
||||
if (!el) return;
|
||||
el.textContent = text || '';
|
||||
}
|
||||
|
||||
function startSyncProgress(prefix) {
|
||||
var startedAt = Date.now();
|
||||
var dots = 0;
|
||||
if (syncProgressTimer) {
|
||||
clearInterval(syncProgressTimer);
|
||||
}
|
||||
setSyncProgress((prefix || '正在同步') + ' 0s');
|
||||
syncProgressTimer = setInterval(function() {
|
||||
dots = (dots + 1) % 4;
|
||||
var suffix = new Array(dots + 1).join('.');
|
||||
var elapsed = Math.max(0, Math.floor((Date.now() - startedAt) / 1000));
|
||||
setSyncProgress((prefix || '正在同步') + suffix + ' ' + elapsed + 's');
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function stopSyncProgress(text) {
|
||||
if (syncProgressTimer) {
|
||||
clearInterval(syncProgressTimer);
|
||||
syncProgressTimer = null;
|
||||
}
|
||||
setSyncProgress(text || '');
|
||||
}
|
||||
|
||||
function updateStatus(status) {
|
||||
if (status && status.authenticated !== undefined) {
|
||||
lastGoodSyncStatus = status;
|
||||
}
|
||||
var accountDot = document.getElementById('accountDot');
|
||||
var accountText = document.getElementById('accountText');
|
||||
var email = document.getElementById('syncEmail');
|
||||
var loggedOut = document.getElementById('authLoggedOut');
|
||||
var loggedIn = document.getElementById('authLoggedIn');
|
||||
var loggedInEmail = document.getElementById('loggedInEmail');
|
||||
var healthy = !!(status && status.color === 'green');
|
||||
var authenticated = !!(status && status.authenticated);
|
||||
if (accountDot) accountDot.classList.toggle('ok', healthy);
|
||||
if (accountText) accountText.textContent = status && (status.accountText || status.text) ? (status.accountText || status.text) : '账号状态未知';
|
||||
if (email) email.textContent = status && status.email ? ('账号:' + status.email) : '';
|
||||
if (loggedOut) loggedOut.classList.toggle('hidden', authenticated);
|
||||
if (loggedIn) loggedIn.classList.toggle('hidden', !authenticated);
|
||||
if (loggedInEmail) loggedInEmail.textContent = status && status.email ? ('当前账号:' + status.email) : '当前账号:未知';
|
||||
}
|
||||
|
||||
function updateServerStatus(ok, text) {
|
||||
var serverDot = document.getElementById('serverDot');
|
||||
var serverText = document.getElementById('serverText');
|
||||
lastServerHealthy = !!ok;
|
||||
if (serverDot) serverDot.classList.toggle('ok', !!ok);
|
||||
if (serverText) serverText.textContent = text || (ok ? '服务器状态正常' : '服务器状态异常');
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '暂无';
|
||||
try {
|
||||
var date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
return date.toLocaleString('zh-CN');
|
||||
} catch (error) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCloudStats(stats) {
|
||||
var summary = document.getElementById('cloudStatsSummary');
|
||||
var latest = document.getElementById('cloudStatsLatest');
|
||||
var tables = document.getElementById('cloudStatsTables');
|
||||
if (!summary || !latest || !tables) return;
|
||||
if (!stats || !stats.ok) {
|
||||
if (lastGoodCloudStats && lastGoodCloudStats.ok) {
|
||||
updateServerStatus(true, '服务器状态正常(展示最近一次成功数据)');
|
||||
summary.textContent = '云端状态暂时刷新失败,显示最近一次成功数据';
|
||||
latest.textContent = '最近成功更新:线程 ' + formatDateTime(lastGoodCloudStats.latest.threads) + ' | 范围 ' + formatDateTime(lastGoodCloudStats.latest.coverages) + ' | 页缓存 ' + formatDateTime(lastGoodCloudStats.latest.pages);
|
||||
tables.textContent = '上次空间占用:' + (Array.isArray(lastGoodCloudStats.tables) ? lastGoodCloudStats.tables.map(function(table) {
|
||||
return table.tableName + ' ' + table.sizeMb + 'MB';
|
||||
}).join(' | ') : '暂无');
|
||||
return;
|
||||
}
|
||||
updateServerStatus(false, '服务器状态暂时不可读');
|
||||
summary.textContent = '暂时无法读取云端状态';
|
||||
latest.textContent = '';
|
||||
tables.textContent = '';
|
||||
return;
|
||||
}
|
||||
updateServerStatus(true, '服务器状态正常');
|
||||
lastGoodCloudStats = stats;
|
||||
summary.textContent = '帖子索引 ' + stats.counts.threads + ' 条(含磁力 ' + (stats.counts.magnetThreads || 0) + ' 条)/ 页缓存 ' + stats.counts.pages + ' 条 / 范围缓存 ' + stats.counts.coverages + ' 条 / 用户 ' + stats.counts.users + ' 个 / 保险柜 ' + stats.counts.vaultItems + ' 条';
|
||||
latest.textContent = '最近更新:线程 ' + formatDateTime(stats.latest.threads) + ' | 范围 ' + formatDateTime(stats.latest.coverages) + ' | 页缓存 ' + formatDateTime(stats.latest.pages);
|
||||
tables.textContent = '空间占用:' + (Array.isArray(stats.tables) ? stats.tables.map(function(table) {
|
||||
return table.tableName + ' ' + table.sizeMb + 'MB';
|
||||
}).join(' | ') : '暂无');
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
var value = Number(bytes || 0);
|
||||
if (!Number.isFinite(value) || value <= 0) return '0 B';
|
||||
if (value < 1024) return value + ' B';
|
||||
if (value < 1024 * 1024) return (value / 1024).toFixed(1) + ' KB';
|
||||
if (value < 1024 * 1024 * 1024) return (value / 1024 / 1024).toFixed(2) + ' MB';
|
||||
return (value / 1024 / 1024 / 1024).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
function renderLocalStats(stats) {
|
||||
var summary = document.getElementById('localStatsSummary');
|
||||
var storage = document.getElementById('localStatsStorage');
|
||||
var meta = document.getElementById('localStatsMeta');
|
||||
if (!summary || !storage || !meta) return;
|
||||
if (!stats || !stats.ok) {
|
||||
summary.textContent = '暂时无法读取本地状态';
|
||||
storage.textContent = '';
|
||||
meta.textContent = '';
|
||||
return;
|
||||
}
|
||||
summary.textContent = '帖子索引 ' + stats.counts.threads + ' 条(含磁力 ' + (stats.counts.magnetThreads || 0) + ' 条)/ 页缓存 ' + stats.counts.pages + ' 条 / 范围缓存 ' + stats.counts.coverages + ' 条 / 收藏 ' + stats.counts.favorites + ' 条 / 历史 ' + stats.counts.history + ' 条';
|
||||
storage.textContent = '本地存储占用:' + formatBytes(stats.storage.usage) + ' / ' + formatBytes(stats.storage.quota);
|
||||
meta.textContent = '上传抑制元数据:线程 ' + stats.counts.uploadMetaThreads + ' 条 | 范围 ' + stats.counts.uploadMetaCoverages + ' 条 | 页 ' + stats.counts.uploadMetaPages + ' 条';
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
var response = await sendMessageToBackground({ action: 'cloudGetSyncStatus' });
|
||||
if (response && response.ok !== false) {
|
||||
updateStatus(response || { color: 'red', text: '状态未知', authenticated: false });
|
||||
return;
|
||||
}
|
||||
if (lastGoodSyncStatus) {
|
||||
updateStatus(lastGoodSyncStatus);
|
||||
setMessage('云同步状态暂时刷新失败,显示最近一次成功状态', true);
|
||||
return;
|
||||
}
|
||||
updateStatus({ color: 'red', text: '云同步异常', authenticated: false, email: '' });
|
||||
} catch (error) {
|
||||
if (lastGoodSyncStatus) {
|
||||
updateStatus(lastGoodSyncStatus);
|
||||
setMessage('云同步状态暂时刷新失败,显示最近一次成功状态', true);
|
||||
return;
|
||||
}
|
||||
updateStatus({ color: 'red', text: '云同步异常', authenticated: false, email: '' });
|
||||
setMessage('状态获取失败:' + error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCloudStats() {
|
||||
var attempts = 0;
|
||||
var response = null;
|
||||
var error = null;
|
||||
for (attempts = 0; attempts < 3; attempts++) {
|
||||
try {
|
||||
response = await sendMessageToBackground({ action: 'cloudGetCacheStats' });
|
||||
if (response && response.ok) {
|
||||
renderCloudStats(response);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
await wait(500 * (attempts + 1));
|
||||
}
|
||||
renderCloudStats(null);
|
||||
if (error) {
|
||||
setMessage('云端状态暂时刷新失败,稍后会自动重试', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshLocalStats() {
|
||||
try {
|
||||
var response = await sendMessageToBackground({ action: 'localGetCacheStats' });
|
||||
renderLocalStats(response);
|
||||
} catch (error) {
|
||||
renderLocalStats(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAuth(action) {
|
||||
var emailInput = document.getElementById('emailInput');
|
||||
var passwordInput = document.getElementById('passwordInput');
|
||||
var email = emailInput ? emailInput.value.trim() : '';
|
||||
var password = passwordInput ? passwordInput.value : '';
|
||||
if (!email || !password) {
|
||||
setMessage('请输入邮箱和密码', true);
|
||||
return;
|
||||
}
|
||||
setMessage(action === 'cloudRegister' ? '正在注册...' : '正在登录...', false);
|
||||
try {
|
||||
var response = await sendMessageToBackground({ action: action, email: email, password: password });
|
||||
if (!response || !response.ok) {
|
||||
setMessage((action === 'cloudRegister' ? '注册失败:' : '登录失败:') + (response && response.error ? response.error : '未知错误'), true);
|
||||
return;
|
||||
}
|
||||
if (passwordInput) passwordInput.value = '';
|
||||
updateStatus(response.status || { color: 'green', text: '云同步正常', authenticated: true, email: email });
|
||||
setMessage(action === 'cloudRegister' ? '注册成功,云同步已开启' : '登录成功,云同步已开启', false);
|
||||
} catch (error) {
|
||||
setMessage((action === 'cloudRegister' ? '注册失败:' : '登录失败:') + error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncNow() {
|
||||
setMessage('', false);
|
||||
startSyncProgress('正在同步本地缓存到云端');
|
||||
try {
|
||||
await sendMessageToBackground({ action: 'cloudBackfillLocalCache', force: true });
|
||||
stopSyncProgress('已提交同步任务,正在刷新状态...');
|
||||
await wait(1200);
|
||||
startSyncProgress('正在刷新云端状态');
|
||||
await refreshStatus();
|
||||
await refreshCloudStats();
|
||||
await refreshLocalStats();
|
||||
stopSyncProgress('同步完成');
|
||||
setMessage('已触发云端回填', false);
|
||||
} catch (error) {
|
||||
stopSyncProgress('同步失败');
|
||||
setMessage('同步失败:' + error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await sendMessageToBackground({ action: 'cloudLogout' });
|
||||
await refreshStatus();
|
||||
setMessage('已退出登录', false);
|
||||
} catch (error) {
|
||||
setMessage('退出失败:' + error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function openExternalUrl(url) {
|
||||
chrome.tabs.create({ url: url });
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('loginBtn').addEventListener('click', function() { submitAuth('cloudLogin'); });
|
||||
document.getElementById('registerBtn').addEventListener('click', function() { submitAuth('cloudRegister'); });
|
||||
document.getElementById('syncNowBtn').addEventListener('click', syncNow);
|
||||
document.getElementById('logoutBtn').addEventListener('click', logout);
|
||||
document.getElementById('downloadNode2Btn').addEventListener('click', function() { openExternalUrl('http://7.haory.cn/x/x72/qBittorrent_5.1.4_x64_setup.exe'); });
|
||||
document.getElementById('downloadOfficialBtn').addEventListener('click', function() { openExternalUrl('https://sourceforge.net/projects/qbittorrent/files/qbittorrent-win32/qbittorrent-5.1.4/qbittorrent_5.1.4_x64_setup.exe/download'); });
|
||||
document.getElementById('downloadMotrixBtn').addEventListener('click', function() { openExternalUrl('http://7.haory.cn/x/x72/Motrix-Setup-1.8.19.exe'); });
|
||||
refreshStatus();
|
||||
refreshCloudStats();
|
||||
refreshLocalStats();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user