├── .dockerignore ├── .env.example ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── activate_tool ├── README.md ├── activate.py └── activate_config.json.example ├── docker-compose.yml ├── frontend ├── .gitignore ├── README.md ├── eslint.config.js ├── index.html ├── package.json ├── pnpm-lock.yaml ├── public │ └── vite.svg ├── src │ ├── App.css │ ├── App.tsx │ ├── assets │ │ └── react.svg │ ├── components │ │ ├── Header │ │ │ ├── index.css │ │ │ └── index.tsx │ │ ├── SessionInitializer.tsx │ │ └── SessionManager.tsx │ ├── index.css │ ├── main.tsx │ ├── pages │ │ ├── AccountManager │ │ │ └── index.tsx │ │ ├── Activate │ │ │ ├── index.css │ │ │ └── index.tsx │ │ ├── History │ │ │ ├── index.css │ │ │ └── index.tsx │ │ ├── ProxyPool │ │ │ ├── index.css │ │ │ └── index.tsx │ │ └── Register │ │ │ ├── index.css │ │ │ └── index.tsx │ ├── services │ │ └── api.ts │ ├── utils │ │ ├── config.ts │ │ └── httpClient.ts │ └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── requirements.txt ├── run.py └── utils ├── __pycache__ ├── database.cpython-311.pyc ├── email_client.cpython-311.pyc ├── pikpak.cpython-311.pyc ├── pk_email.cpython-311.pyc └── session_manager.cpython-311.pyc ├── database.py ├── email_client.py ├── pikpak.py ├── pk_email.py └── session_manager.py /.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | .git 4 | .venv 5 | tests 6 | 7 | # Git repository files 8 | .git/ 9 | .gitignore 10 | 11 | # Node modules 12 | node_modules/ 13 | frontend/node_modules/ 14 | 15 | # Environment files (Ensure sensitive data isn't accidentally included) 16 | .env 17 | frontend/.env 18 | 19 | # Build artifacts (Frontend build happens inside container, but good practice) 20 | dist/ 21 | build/ 22 | frontend/dist/ 23 | frontend/build/ 24 | 25 | # Logs 26 | logs/ 27 | *.log 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | pnpm-debug.log* 32 | 33 | # OS generated files 34 | .DS_Store 35 | Thumbs.db 36 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/1653756334/PikPakInvitation/6e76029c8c940d56901986ca18ab74e12229d668/.env.example -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | account/* 3 | account copy/ 4 | final_review_gate.py 5 | accounts.db 6 | activate_tool/*.log 7 | activate_tool/activate_config.json 8 | .cursor 9 | apis.js -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine AS frontend-builder 2 | 3 | WORKDIR /app/frontend 4 | 5 | RUN npm install -g pnpm 6 | 7 | COPY frontend/package.json frontend/pnpm-lock.yaml ./ 8 | 9 | RUN pnpm install --frozen-lockfile 10 | 11 | COPY frontend/ ./ 12 | 13 | RUN pnpm build 14 | 15 | FROM python:3.10-slim AS final 16 | 17 | WORKDIR /app 18 | 19 | COPY requirements.txt ./ 20 | RUN pip install --no-cache-dir --upgrade pip && \ 21 | pip install --no-cache-dir -r requirements.txt 22 | 23 | RUN mkdir account 24 | 25 | RUN mkdir -p templates static 26 | 27 | # Create empty database file to ensure proper mounting 28 | RUN touch accounts.db 29 | 30 | COPY run.py ./ 31 | COPY utils/ ./utils/ 32 | 33 | COPY --from=frontend-builder /app/frontend/dist/index.html ./templates/ 34 | COPY --from=frontend-builder /app/frontend/dist/* ./static/ 35 | 36 | EXPOSE 5000 37 | 38 | CMD ["python", "run.py"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PikPak 自动邀请 2 | 3 | 一个帮助管理PikPak邀请的工具,包含前端界面和后端服务。 4 | 5 | **理论上输入账号后,一下都不用点,等着把列表里面账号注册完成就行** 6 | 7 | ## 预览 8 | [点击在线使用](https://pikpak.dddai.de/) 9 | 10 | ## 项目结构 11 | 12 | - `frontend/`: 前端代码,使用 pnpm 管理依赖 13 | - 后端: Python 实现的服务 14 | 15 | ## 环境变量 16 | (可选) MAIL_POINT_API_URL 使用:https://github.com/HChaoHui/msOauth2api 部署后获得 17 | 18 | ADMIN_SESSION_ID: 管理员密码 (记得修改) 19 | 20 | 如果不提供此环境变量,需要(邮箱,密码)支持imap登录 21 | 22 | ## 部署方式 23 | 24 | ### 前端部署 25 | 26 | ```bash 27 | # 进入前端目录 28 | cd frontend 29 | 30 | # 安装依赖 31 | pnpm install 32 | 33 | # 开发模式运行 34 | pnpm dev 35 | 36 | # 构建生产版本 37 | pnpm build 38 | ``` 39 | 40 | ### 后端部署 41 | 42 | #### 1. 环境变量 43 | 复制 .env.example 到 .env 44 | 45 | 修改环境变量的值 46 | 47 | ```bash 48 | MAIL_POINT_API_URL=https://your-endpoint.com 49 | ADMIN_SESSION_ID=your_admin_session_id 50 | ``` 51 | 52 | #### 2. 源码运行 53 | 54 | ```bash 55 | # 安装依赖 56 | pip install -r requirements.txt 57 | 58 | # 运行应用 59 | python run.py 60 | ``` 61 | 62 | ### Docker 部署 63 | 64 | 项目提供了 Dockerfile,可以一键构建包含前后端的完整应用。 65 | 66 | #### 运行 Docker 容器 67 | 68 | ```bash 69 | # 创建并运行容器 70 | docker run -d \ 71 | --name pikpak-auto \ 72 | -p 5000:5000 \ 73 | -e MAIL_POINT_API_URL=https://your-endpoint.com \ 74 | -e ADMIN_SESSION_ID=your_admin_session_id \ 75 | -v $(pwd)/account:/app/account \ 76 | vichus/pikpak-invitation:latest 77 | ``` 78 | 79 | 参数说明: 80 | - `-d`: 后台运行容器 81 | - `-p 5000:5000`: 将容器内的 5000 端口映射到主机的 5000 端口 82 | - `-e MAIL_POINT_API_URL=...`: 设置环境变量 83 | - `-v $(pwd)/account:/app/account`: 将本地 account 目录挂载到容器内,保存账号数据 84 | 85 | #### 4. 查看容器日志 86 | 87 | ```bash 88 | docker logs -f pikpak-auto 89 | ``` 90 | 91 | #### 5. 停止和重启容器 92 | 93 | ```bash 94 | # 停止容器 95 | docker stop pikpak-auto 96 | 97 | # 重启容器 98 | docker start pikpak-auto 99 | ``` 100 | 101 | ### Docker Compose 部署 102 | 103 | 如果你更喜欢使用 Docker Compose 进行部署,请按照以下步骤操作: 104 | 105 | #### 1. 启动服务 106 | 107 | 启动前记得修改 `docker-compose.yml` 的环境变量 108 | 109 | ```bash 110 | # 在项目根目录下启动服务 111 | docker-compose up -d 112 | ``` 113 | 114 | #### 2. 查看日志 115 | 116 | ```bash 117 | # 查看服务日志 118 | docker-compose logs -f 119 | ``` 120 | 121 | #### 3. 停止和重启服务 122 | 123 | ```bash 124 | # 停止服务 125 | docker-compose down 126 | 127 | # 重启服务 128 | docker-compose up -d 129 | ``` 130 | 131 | 鸣谢: 132 | 133 | [Pikpak-Auto-Invitation](https://github.com/Bear-biscuit/Pikpak-Auto-Invitation) 134 | 135 | [纸鸢地址发布页](https://kiteyuan.info/) 136 | 137 | [msOauth2api](https://github.com/HChaoHui/msOauth2api) 138 | -------------------------------------------------------------------------------- /activate_tool/README.md: -------------------------------------------------------------------------------- 1 | # PikPak 自动激活脚本使用说明 2 | 3 | ## 功能简介 4 | 5 | `activate.py` 是一个独立的自动激活脚本,用于: 6 | - 查询上次激活时间为前一天12点后且激活次数小于3的账号 7 | - 自动调用激活接口进行激活 8 | - 支持重试机制和日志记录 9 | - 每个账号激活后暂停10-30秒 10 | 11 | ## 配置文件 12 | 13 | 首先复制配置文件模板: 14 | ```bash 15 | cp activate_config.json.example activate_config.json 16 | ``` 17 | 18 | 然后编辑 `activate_config.json`: 19 | ```json 20 | { 21 | "activation_key": "your_actual_activation_key", 22 | "api_base_url": "http://localhost:5000", 23 | "session_id": "auto_activator", 24 | "db_path": "accounts.db", 25 | "min_sleep_seconds": 10, 26 | "max_sleep_seconds": 30, 27 | "max_retries": 3, 28 | "retry_delay_seconds": 5, 29 | "max_activation_count": 3 30 | } 31 | ``` 32 | 33 | ### 配置说明 34 | 35 | - `activation_key`: 激活密钥(必需), 在 https://kiteyuan.info/ 获取 36 | - `api_base_url`: API服务地址,默认本地5000端口 37 | - `session_id`: 会话ID,用于数据库操作权限。管理员会话可激活所有账号,普通会话只能激活自己的账号 38 | - `db_path`: 数据库文件路径 39 | - `min_sleep_seconds`: 每个账号激活后的最小暂停时间 40 | - `max_sleep_seconds`: 每个账号激活后的最大暂停时间 41 | - `max_retries`: 激活失败时的最大重试次数 42 | - `retry_delay_seconds`: 重试间隔时间 43 | - `max_activation_count`: 最大激活次数限制,默认为3 44 | 45 | ## 使用方法 46 | 47 | ### 1. 使用配置文件 48 | 49 | ```bash 50 | python activate.py 51 | ``` 52 | 53 | ### 2. 使用命令行参数 54 | 55 | ```bash 56 | python activate.py --key "your_activation_key" --max-activations 3 57 | ``` 58 | 59 | ### 3. 指定配置文件 60 | 61 | ```bash 62 | python activate.py --config custom_config.json 63 | ``` 64 | 65 | ### 命令行参数 66 | 67 | - `--key`, `-k`: 激活密钥 68 | - `--db`, `-d`: 数据库文件路径 69 | - `--url`, `-u`: API服务地址 70 | - `--session`, `-s`: 会话ID 71 | - `--config`, `-c`: 配置文件路径 72 | - `--max-activations`, `-m`: 最大激活次数限制 73 | 74 | ## 权限控制 75 | 76 | ### 管理员会话 77 | - 可以激活所有用户的符合条件账号 78 | - 日志中会显示每个账号的会话ID信息 79 | 80 | ### 普通用户会话 81 | - 使用普通的会话ID(不包含管理员关键字) 82 | - 只能激活自己会话ID下的账号 83 | - 提供了数据隔离和安全保护 84 | 85 | ## 运行逻辑 86 | 87 | 1. **权限检查**: 根据会话ID判断是管理员还是普通用户 88 | 2. **查询账号**: 查找上次激活时间为前一天12点后且激活次数小于3的账号 89 | 3. **逐个激活**: 不使用并发,按顺序激活每个账号 90 | 4. **暂停等待**: 每个账号激活后暂停10-30秒 91 | 5. **重试机制**: 激活失败时自动重试,最多3次 92 | 6. **日志记录**: 详细记录激活过程和统计信息 93 | 94 | ## 日志文件 95 | 96 | - `activate.log`: 详细的激活日志 97 | - `activation_stats.log`: 激活统计信息 98 | 99 | ## 定时运行 100 | 101 | ### 使用cron(Linux/Mac) 102 | 103 | 编辑crontab: 104 | ```bash 105 | crontab -e 106 | ``` 107 | 108 | 添加定时任务(每天早上8点运行): 109 | ```bash 110 | 0 8 * * * cd /path/to/your/project && python activate.py 111 | ``` 112 | 113 | ### 使用Windows任务计划程序 114 | 115 | 1. 打开"任务计划程序" 116 | 2. 创建基本任务 117 | 3. 设置触发器(例如每日) 118 | 4. 设置操作:启动程序 119 | - 程序:`python` 120 | - 参数:`activate.py` 121 | - 起始于:脚本所在目录 122 | 123 | ## 注意事项 124 | 125 | 1. 确保 `run.py` 服务正在运行 126 | 2. 激活密钥需要有效 127 | 3. 数据库文件路径正确 128 | 4. 网络连接正常 129 | 5. 会话ID需要有足够的权限 130 | 131 | ## 错误排查 132 | 133 | 1. **无激活密钥**: 检查配置文件或命令行参数 134 | 2. **连接失败**: 检查API服务是否运行 135 | 3. **数据库错误**: 检查数据库文件路径和权限 136 | 4. **权限不足**: 检查会话ID是否有效 137 | 138 | ## 示例输出 139 | 140 | ``` 141 | 2024-01-01 08:00:00 - INFO - ================================================== 142 | 2024-01-01 08:00:00 - INFO - 开始PikPak自动激活任务 143 | 2024-01-01 08:00:00 - INFO - ================================================== 144 | 2024-01-01 08:00:00 - INFO - 激活条件: 上次激活时间为前一天12点后 且 激活次数<3次 145 | 2024-01-01 08:00:00 - INFO - 配置信息: 暂停时间=10-30秒, 最大重试=3次 146 | 2024-01-01 08:00:00 - INFO - 找到 5 个符合条件的账号(激活次数<3且上次激活时间>前一天12点) 147 | 2024-01-01 08:00:00 - INFO - ------------------------------ 148 | 2024-01-01 08:00:00 - INFO - 处理第 1/5 个账号: user1 149 | 2024-01-01 08:00:00 - INFO - 邮箱: user1@example.com 150 | 2024-01-01 08:00:00 - INFO - 当前激活次数: 1 151 | 2024-01-01 08:00:00 - INFO - 上次激活时间: 2023-12-31 13:30:00 152 | 2024-01-01 08:00:00 - INFO - 正在激活账号: user1 153 | 2024-01-01 08:00:01 - INFO - ✓ 账号 user1 激活成功 (激活次数: 1 -> 2) 154 | 2024-01-01 08:00:01 - INFO - 暂停 15 秒后继续... 155 | 2024-01-01 08:00:16 - INFO - ================================================== 156 | 2024-01-01 08:00:16 - INFO - 激活任务完成 157 | 2024-01-01 08:00:16 - INFO - 总处理账号: 5 个 158 | 2024-01-01 08:00:16 - INFO - 激活成功: 5 个 159 | 2024-01-01 08:00:16 - INFO - 激活失败: 0 个 160 | 2024-01-01 08:00:16 - INFO - 跳过账号: 0 个 161 | 2024-01-01 08:00:16 - INFO - ================================================== 162 | ``` -------------------------------------------------------------------------------- /activate_tool/activate.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | PikPak 自动激活脚本 5 | 功能:查询激活时间超过一天的账号,并调用激活接口进行激活 6 | """ 7 | 8 | import time 9 | import json 10 | import random 11 | import logging 12 | import requests 13 | import sqlite3 14 | import os 15 | from datetime import datetime, timedelta 16 | from typing import List, Dict, Any, Optional 17 | 18 | # 配置日志 19 | logging.basicConfig( 20 | level=logging.INFO, 21 | format='%(asctime)s - %(levelname)s - %(message)s', 22 | handlers=[ 23 | logging.FileHandler('activate.log', encoding='utf-8'), 24 | logging.StreamHandler() 25 | ] 26 | ) 27 | logger = logging.getLogger(__name__) 28 | 29 | class PikPakActivator: 30 | """PikPak自动激活器""" 31 | 32 | def __init__(self, config: Optional[Dict] = None): 33 | """初始化激活器""" 34 | if not config: 35 | config = self.load_config() 36 | 37 | self.db_path = config.get('db_path', 'accounts.db') 38 | self.api_base_url = config.get('api_base_url', 'http://localhost:5000') 39 | self.activation_key = config.get('activation_key') 40 | self.session_id = config.get('session_id', 'auto_activator') 41 | self.min_sleep_seconds = config.get('min_sleep_seconds', 10) 42 | self.max_sleep_seconds = config.get('max_sleep_seconds', 30) 43 | self.max_retries = config.get('max_retries', 3) 44 | self.retry_delay_seconds = config.get('retry_delay_seconds', 5) 45 | self.max_activation_count = config.get('max_activation_count', 3) 46 | 47 | def load_config(self) -> Dict: 48 | """加载配置文件, 会被args覆盖""" 49 | config_file = 'activate_config.json' 50 | if os.path.exists(config_file): 51 | try: 52 | with open(config_file, 'r', encoding='utf-8') as f: 53 | config = json.load(f) 54 | logger.info(f"已加载配置文件: {config_file}") 55 | return config 56 | except Exception as e: 57 | logger.warning(f"加载配置文件失败: {e}") 58 | 59 | logger.info("使用默认配置") 60 | return {} 61 | 62 | def is_admin_session(self) -> bool: 63 | """通过API检查当前会话是否为管理员会话""" 64 | try: 65 | url = f"{self.api_base_url}/api/session/check_admin" 66 | headers = { 67 | 'Content-Type': 'application/json', 68 | 'X-Session-ID': self.session_id 69 | } 70 | 71 | data = { 72 | "session_id": self.session_id 73 | } 74 | 75 | response = requests.post(url, json=data, headers=headers, timeout=10) 76 | 77 | if response.status_code == 200: 78 | result = response.json() 79 | if result.get('status') == 'success': 80 | is_admin = result.get('is_admin', False) 81 | logger.info(f"API权限检查结果: {result.get('message', '')}") 82 | return is_admin 83 | else: 84 | logger.warning(f"API权限检查失败: {result.get('message', '未知错误')}") 85 | return False 86 | else: 87 | logger.warning(f"API权限检查请求失败: HTTP {response.status_code}") 88 | return False 89 | 90 | except Exception as e: 91 | logger.warning(f"调用API检查管理员权限时发生异常: {e}") 92 | # 检查失败直接按照非管理员处理 93 | return False 94 | 95 | def get_accounts_need_activation(self) -> List[Dict[str, Any]]: 96 | """获取需要激活的账号(上次激活时间为前一天12点后且激活次数小于3的)""" 97 | try: 98 | conn = sqlite3.connect(self.db_path) 99 | cursor = conn.cursor() 100 | 101 | # 检查会话ID是否为管理员 102 | is_admin = self.is_admin_session() 103 | 104 | # 计算前一天12点的时间戳 105 | yesterday = datetime.now() - timedelta(days=1) 106 | yesterday_noon = yesterday.replace(hour=12, minute=0, second=0, microsecond=0) 107 | check_timestamp = yesterday_noon.strftime('%Y-%m-%d %H:%M:%S') 108 | 109 | logger.info(f"会话ID: {self.session_id} ({'管理员' if is_admin else '普通用户'})") 110 | logger.info(f"查询条件: 上次激活时间晚于 {check_timestamp} 且激活次数小于{self.max_activation_count}次") 111 | 112 | # 根据会话权限构建不同的查询 113 | if is_admin: 114 | # 管理员可以激活所有符合条件的账号 115 | logger.info("管理员权限: 查询所有符合条件的账号") 116 | cursor.execute(''' 117 | SELECT id, email, name, activation_status, last_activation_time, account_data, session_id 118 | FROM accounts 119 | WHERE ( 120 | (activation_status IS NULL OR activation_status < ?) AND 121 | ( 122 | last_activation_time IS NULL OR 123 | last_activation_time > ? 124 | ) 125 | ) 126 | AND email IS NOT NULL 127 | AND email != '' 128 | ORDER BY session_id ASC, activation_status ASC, last_activation_time ASC, created_at ASC 129 | ''', (self.max_activation_count, check_timestamp)) 130 | else: 131 | # 普通用户只能激活自己会话的账号 132 | logger.info(f"普通用户权限: 只查询会话 {self.session_id} 的账号") 133 | cursor.execute(''' 134 | SELECT id, email, name, activation_status, last_activation_time, account_data, session_id 135 | FROM accounts 136 | WHERE session_id = ? AND ( 137 | (activation_status IS NULL OR activation_status < ?) AND 138 | ( 139 | last_activation_time IS NULL OR 140 | last_activation_time > ? 141 | ) 142 | ) 143 | AND email IS NOT NULL 144 | AND email != '' 145 | ORDER BY activation_status ASC, last_activation_time ASC, created_at ASC 146 | ''', (self.session_id, self.max_activation_count, check_timestamp)) 147 | 148 | rows = cursor.fetchall() 149 | accounts = [] 150 | 151 | for row in rows: 152 | account_data = json.loads(row[5]) if row[5] else {} 153 | account = { 154 | 'id': row[0], 155 | 'email': row[1], 156 | 'name': row[2], 157 | 'activation_status': row[3] if row[3] is not None else 0, 158 | 'last_activation_time': row[4], 159 | 'session_id': row[6] if len(row) > 6 else None, 160 | **account_data 161 | } 162 | accounts.append(account) 163 | 164 | conn.close() 165 | logger.info(f"找到 {len(accounts)} 个符合条件的账号(激活次数<{self.max_activation_count}且上次激活时间>前一天12点)") 166 | 167 | # 输出详细信息便于调试 168 | for account in accounts[:5]: # 只显示前5个账号的详细信息 169 | session_info = f", 会话: {account.get('session_id', '未知')}" if is_admin else "" 170 | logger.info(f" 账号: {account['name']}, 激活次数: {account['activation_status']}, 上次激活: {account['last_activation_time'] or '从未激活'}{session_info}") 171 | 172 | if len(accounts) > 5: 173 | logger.info(f" ... 还有 {len(accounts) - 5} 个账号") 174 | 175 | return accounts 176 | 177 | except Exception as e: 178 | logger.error(f"查询需要激活的账号失败: {e}") 179 | return [] 180 | 181 | def activate_account(self, account_name: str) -> bool: 182 | """激活单个账号(支持重试)""" 183 | for attempt in range(self.max_retries): 184 | try: 185 | if not self.activation_key: 186 | logger.error("未设置激活密钥") 187 | return False 188 | 189 | url = f"{self.api_base_url}/api/activate_account_with_names" 190 | headers = { 191 | 'Content-Type': 'application/json', 192 | 'X-Session-ID': self.session_id 193 | } 194 | 195 | data = { 196 | "key": self.activation_key, 197 | "names": [account_name], 198 | "all": False 199 | } 200 | 201 | if attempt > 0: 202 | logger.info(f"重试激活账号: {account_name} (第{attempt+1}次尝试)") 203 | else: 204 | logger.info(f"正在激活账号: {account_name}") 205 | 206 | response = requests.post(url, json=data, headers=headers, timeout=60) 207 | 208 | if response.status_code == 200: 209 | result = response.json() 210 | if result.get('status') == 'success': 211 | results = result.get('results', []) 212 | if results: 213 | first_result = results[0] 214 | if first_result.get('status') == 'success': 215 | logger.info(f"账号 {account_name} 激活成功") 216 | return True 217 | else: 218 | error_msg = first_result.get('message', '未知错误') 219 | if attempt < self.max_retries - 1: 220 | logger.warning(f"账号 {account_name} 激活失败: {error_msg},将重试") 221 | time.sleep(self.retry_delay_seconds) 222 | continue 223 | else: 224 | logger.warning(f"账号 {account_name} 激活失败: {error_msg}") 225 | return False 226 | else: 227 | if attempt < self.max_retries - 1: 228 | logger.warning(f"账号 {account_name} 激活响应为空,将重试") 229 | time.sleep(self.retry_delay_seconds) 230 | continue 231 | else: 232 | logger.warning(f"账号 {account_name} 激活响应为空") 233 | return False 234 | else: 235 | error_msg = result.get('message', '未知错误') 236 | if attempt < self.max_retries - 1: 237 | logger.warning(f"账号 {account_name} 激活失败: {error_msg},将重试") 238 | time.sleep(self.retry_delay_seconds) 239 | continue 240 | else: 241 | logger.warning(f"账号 {account_name} 激活失败: {error_msg}") 242 | return False 243 | else: 244 | if attempt < self.max_retries - 1: 245 | logger.warning(f"账号 {account_name} 激活请求失败: HTTP {response.status_code},将重试") 246 | time.sleep(self.retry_delay_seconds) 247 | continue 248 | else: 249 | logger.error(f"账号 {account_name} 激活请求失败: HTTP {response.status_code}") 250 | return False 251 | 252 | except Exception as e: 253 | if attempt < self.max_retries - 1: 254 | logger.warning(f"激活账号 {account_name} 时发生异常: {e},将重试") 255 | time.sleep(self.retry_delay_seconds) 256 | continue 257 | else: 258 | logger.error(f"激活账号 {account_name} 时发生异常: {e}") 259 | return False 260 | 261 | return False 262 | 263 | def run_activation(self): 264 | """运行激活任务""" 265 | logger.info("=" * 50) 266 | logger.info("开始PikPak自动激活任务") 267 | logger.info("=" * 50) 268 | logger.info(f"激活条件: 上次激活时间为前一天12点后 且 激活次数<{self.max_activation_count}次") 269 | logger.info(f"配置信息: 暂停时间={self.min_sleep_seconds}-{self.max_sleep_seconds}秒, 最大重试={self.max_retries}次") 270 | 271 | if not self.activation_key: 272 | logger.error("请设置激活密钥") 273 | return 274 | 275 | # 获取需要激活的账号 276 | accounts = self.get_accounts_need_activation() 277 | 278 | if not accounts: 279 | logger.info("没有符合条件的账号需要激活") 280 | return 281 | 282 | success_count = 0 283 | failed_count = 0 284 | skipped_count = 0 285 | 286 | for i, account in enumerate(accounts): 287 | account_name = account.get('name') or account.get('email', '').split('@')[0] 288 | activation_status = account.get('activation_status', 0) 289 | 290 | logger.info("-" * 30) 291 | logger.info(f"处理第 {i+1}/{len(accounts)} 个账号: {account_name}") 292 | logger.info(f"邮箱: {account.get('email')}") 293 | logger.info(f"当前激活次数: {activation_status}") 294 | logger.info(f"上次激活时间: {account.get('last_activation_time', '从未激活')}") 295 | 296 | # 再次检查激活次数限制(双重保险) 297 | if activation_status >= self.max_activation_count: 298 | logger.warning(f"账号 {account_name} 激活次数已达限制({activation_status}>={self.max_activation_count}),跳过") 299 | skipped_count += 1 300 | continue 301 | 302 | # 激活账号 303 | if self.activate_account(account_name): 304 | success_count += 1 305 | new_status = activation_status + 1 306 | logger.info(f"✓ 账号 {account_name} 激活成功 (激活次数: {activation_status} -> {new_status})") 307 | else: 308 | failed_count += 1 309 | logger.warning(f"✗ 账号 {account_name} 激活失败") 310 | 311 | # 如果不是最后一个账号,暂停随机时间 312 | if i < len(accounts) - 1: 313 | sleep_time = random.randint(self.min_sleep_seconds, self.max_sleep_seconds) 314 | logger.info(f"暂停 {sleep_time} 秒后继续...") 315 | time.sleep(sleep_time) 316 | 317 | logger.info("=" * 50) 318 | logger.info(f"激活任务完成") 319 | logger.info(f"总处理账号: {len(accounts)} 个") 320 | logger.info(f"激活成功: {success_count} 个") 321 | logger.info(f"激活失败: {failed_count} 个") 322 | logger.info(f"跳过账号: {skipped_count} 个") 323 | logger.info("=" * 50) 324 | 325 | # 记录统计信息到日志 326 | if success_count > 0 or failed_count > 0 or skipped_count > 0: 327 | with open('activation_stats.log', 'a', encoding='utf-8') as f: 328 | f.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - 处理:{len(accounts)}, 成功:{success_count}, 失败:{failed_count}, 跳过:{skipped_count}\n") 329 | 330 | def main(): 331 | """主函数""" 332 | import argparse 333 | 334 | parser = argparse.ArgumentParser(description='PikPak 自动激活脚本') 335 | parser.add_argument('--key', '-k', help='激活密钥') 336 | parser.add_argument('--db', '-d', help='数据库文件路径') 337 | parser.add_argument('--url', '-u', help='API服务地址') 338 | parser.add_argument('--session', '-s', help='会话ID') 339 | parser.add_argument('--config', '-c', help='配置文件路径') 340 | parser.add_argument('--max-activations', '-m', type=int, help='最大激活次数限制') 341 | 342 | args = parser.parse_args() 343 | 344 | # 加载配置 345 | config = {} 346 | if args.config and os.path.exists(args.config): 347 | with open(args.config, 'r', encoding='utf-8') as f: 348 | config = json.load(f) 349 | 350 | # 命令行参数覆盖配置文件 351 | if args.key: 352 | config['activation_key'] = args.key 353 | if args.db: 354 | config['db_path'] = args.db 355 | if args.url: 356 | config['api_base_url'] = args.url 357 | if args.session: 358 | config['session_id'] = args.session 359 | if getattr(args, 'max_activations', None): 360 | config['max_activation_count'] = args.max_activations 361 | 362 | # 创建激活器 363 | activator = PikPakActivator(config) 364 | 365 | # 运行激活任务 366 | activator.run_activation() 367 | 368 | if __name__ == "__main__": 369 | main() -------------------------------------------------------------------------------- /activate_tool/activate_config.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "activation_key": "Acce113945", 3 | "api_base_url": "http://localhost:5000", 4 | "session_id": "admin", 5 | "db_path": "../accounts.db", 6 | "min_sleep_seconds": 10, 7 | "max_sleep_seconds": 30, 8 | "max_retries": 3, 9 | "retry_delay_seconds": 5, 10 | "max_activation_count": 3 11 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | pikpak-auto: 5 | image: vichus/pikpak-invitation:latest 6 | container_name: pikpak-auto 7 | ports: 8 | - "5000:5000" 9 | environment: 10 | - MAIL_POINT_API_URL=https://your-endpoint.com 11 | - ADMIN_SESSION_ID=your_admin_session_id 12 | volumes: 13 | - ./account:/app/account 14 | restart: unless-stopped -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: 13 | 14 | ```js 15 | export default tseslint.config({ 16 | extends: [ 17 | // Remove ...tseslint.configs.recommended and replace with this 18 | ...tseslint.configs.recommendedTypeChecked, 19 | // Alternatively, use this for stricter rules 20 | ...tseslint.configs.strictTypeChecked, 21 | // Optionally, add this for stylistic rules 22 | ...tseslint.configs.stylisticTypeChecked, 23 | ], 24 | languageOptions: { 25 | // other options... 26 | parserOptions: { 27 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 28 | tsconfigRootDir: import.meta.dirname, 29 | }, 30 | }, 31 | }) 32 | ``` 33 | 34 | You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: 35 | 36 | ```js 37 | // eslint.config.js 38 | import reactX from 'eslint-plugin-react-x' 39 | import reactDom from 'eslint-plugin-react-dom' 40 | 41 | export default tseslint.config({ 42 | plugins: { 43 | // Add the react-x and react-dom plugins 44 | 'react-x': reactX, 45 | 'react-dom': reactDom, 46 | }, 47 | rules: { 48 | // other rules... 49 | // Enable its recommended typescript rules 50 | ...reactX.configs['recommended-typescript'].rules, 51 | ...reactDom.configs.recommended.rules, 52 | }, 53 | }) 54 | ``` 55 | -------------------------------------------------------------------------------- /frontend/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | PikPak 自助邀请助手 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@ant-design/icons": "^6.0.0", 14 | "@ant-design/v5-patch-for-react-19": "^1.0.3", 15 | "antd": "^5.24.9", 16 | "axios": "^1.9.0", 17 | "react": "^19.0.0", 18 | "react-dom": "^19.0.0", 19 | "react-router-dom": "^7.5.3" 20 | }, 21 | "devDependencies": { 22 | "@eslint/js": "^9.22.0", 23 | "@types/react": "^19.0.10", 24 | "@types/react-dom": "^19.0.4", 25 | "@vitejs/plugin-react": "^4.3.4", 26 | "eslint": "^9.22.0", 27 | "eslint-plugin-react-hooks": "^5.2.0", 28 | "eslint-plugin-react-refresh": "^0.4.19", 29 | "globals": "^16.0.0", 30 | "typescript": "~5.7.2", 31 | "typescript-eslint": "^8.26.1", 32 | "vite": "^6.3.1" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /frontend/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/App.css: -------------------------------------------------------------------------------- 1 | /* App.css */ 2 | * { 3 | margin: 0; 4 | padding: 0; 5 | box-sizing: border-box; 6 | } 7 | 8 | body { 9 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 10 | 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 11 | 'Noto Color Emoji'; 12 | background-color: #f0f2f5; 13 | } 14 | 15 | /* Remove old layout styles */ 16 | /* 17 | .app-container { 18 | display: flex; 19 | flex-direction: column; 20 | min-height: 100vh; 21 | } 22 | 23 | .content-container { 24 | flex: 1; 25 | padding: 20px; 26 | } 27 | 28 | @media (max-width: 768px) { 29 | .content-container { 30 | padding: 10px; 31 | } 32 | } 33 | */ 34 | 35 | /* Remove or comment out #root restrictions to allow full width */ 36 | /* 37 | #root { 38 | max-width: 1280px; 39 | margin: 0 auto; 40 | padding: 2rem; 41 | text-align: center; 42 | } 43 | */ 44 | 45 | /* Add styles for the logo in the sidebar */ 46 | .sidebar-logo { 47 | height: 32px; 48 | margin: 16px; 49 | background: rgba(255, 255, 255, 0.2); 50 | border-radius: 4px; 51 | text-align: center; 52 | line-height: 32px; 53 | color: white; 54 | font-weight: bold; 55 | overflow: hidden; 56 | white-space: nowrap; /* Prevent text wrap when collapsed */ 57 | } 58 | 59 | /* Add styles for fixed sidebar and scrollable content */ 60 | .ant-layout-sider-children { 61 | overflow-y: hidden !important; /* Prevent sidebar content from scrolling */ 62 | } 63 | 64 | .site-layout-background { 65 | overflow-y: auto; 66 | overflow-x: hidden; /* Hide horizontal scrollbar */ 67 | } 68 | 69 | /* Remove the overflow: hidden that's preventing scrolling */ 70 | /* html, body { 71 | overflow: hidden; 72 | } */ 73 | 74 | /* Responsive adjustments for mobile */ 75 | @media (max-width: 768px) { 76 | .ant-layout-sider { 77 | position: absolute !important; 78 | z-index: 100; 79 | } 80 | 81 | .ant-layout-content { 82 | margin-left: 0 !important; 83 | padding-left: 0 !important; 84 | } 85 | } 86 | 87 | /* Remove the rule for the now-deleted .site-layout element */ 88 | /* 89 | .site-layout { 90 | flex: 1; 91 | } 92 | */ 93 | 94 | /* Keep other potentially useful styles if needed, e.g., card, logo animation, etc. */ 95 | /* These might be template defaults or used elsewhere, review if needed */ 96 | .logo { 97 | height: 6em; 98 | padding: 1.5em; 99 | will-change: filter; 100 | transition: filter 300ms; 101 | } 102 | .logo:hover { 103 | filter: drop-shadow(0 0 2em #646cffaa); 104 | } 105 | .logo.react:hover { 106 | filter: drop-shadow(0 0 2em #61dafbaa); 107 | } 108 | 109 | @keyframes logo-spin { 110 | from { 111 | transform: rotate(0deg); 112 | } 113 | to { 114 | transform: rotate(360deg); 115 | } 116 | } 117 | 118 | @media (prefers-reduced-motion: no-preference) { 119 | a:nth-of-type(2) .logo { 120 | animation: logo-spin infinite 20s linear; 121 | } 122 | } 123 | 124 | .card { 125 | padding: 2em; 126 | } 127 | 128 | .read-the-docs { 129 | color: #888; 130 | } 131 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { BrowserRouter as Router, Routes, Route, Navigate, useLocation, Link, Outlet } from 'react-router-dom'; 3 | import { ConfigProvider, Layout, Menu, Button, Space, Typography } from 'antd'; 4 | import { 5 | UserAddOutlined, 6 | CheckCircleOutlined, 7 | HistoryOutlined, 8 | UserOutlined, 9 | SwapOutlined, 10 | GlobalOutlined, 11 | IdcardOutlined 12 | } from '@ant-design/icons'; 13 | import zhCN from 'antd/lib/locale/zh_CN'; 14 | import './App.css'; 15 | 16 | // 导入页面组件 (needed in MainLayout) 17 | import Register from './pages/Register'; 18 | import Activate from './pages/Activate'; 19 | import History from './pages/History'; 20 | import ProxyPool from './pages/ProxyPool'; 21 | import AccountManager from './pages/AccountManager'; 22 | 23 | // 导入会话管理组件 24 | import SessionManager from './components/SessionManager'; 25 | import SessionInitializer from './components/SessionInitializer'; 26 | 27 | const { Sider, Content } = Layout; 28 | const { Text } = Typography; 29 | 30 | // Define the new MainLayout component 31 | const MainLayout: React.FC = () => { 32 | const [collapsed, setCollapsed] = useState(false); // Move state here 33 | const [sessionId, setSessionId] = useState(''); 34 | const [isAdmin, setIsAdmin] = useState(false); 35 | const [showSessionManager, setShowSessionManager] = useState(false); 36 | const [showSessionInitializer, setShowSessionInitializer] = useState(false); 37 | 38 | const location = useLocation(); // Move hook call here 39 | const currentPath = location.pathname; 40 | 41 | // 检查会话状态 42 | useEffect(() => { 43 | const checkSession = async () => { 44 | const storedSessionId = localStorage.getItem('session_id'); 45 | 46 | if (!storedSessionId) { 47 | setShowSessionInitializer(true); 48 | return; 49 | } 50 | 51 | try { 52 | const response = await fetch('/api/session/validate', { 53 | method: 'POST', 54 | headers: { 55 | 'Content-Type': 'application/json', 56 | }, 57 | body: JSON.stringify({ session_id: storedSessionId }), 58 | }); 59 | 60 | const data = await response.json(); 61 | if (data.status === 'success' && data.is_valid) { 62 | setSessionId(storedSessionId); 63 | setIsAdmin(data.is_admin); 64 | } else { 65 | localStorage.removeItem('session_id'); 66 | setShowSessionInitializer(true); 67 | } 68 | } catch (error) { 69 | console.error('验证会话失败:', error); 70 | localStorage.removeItem('session_id'); 71 | setShowSessionInitializer(true); 72 | } 73 | }; 74 | 75 | checkSession(); 76 | }, []); 77 | 78 | const handleSessionCreated = (newSessionId: string, adminStatus: boolean) => { 79 | setSessionId(newSessionId); 80 | setIsAdmin(adminStatus); 81 | setShowSessionInitializer(false); 82 | }; 83 | 84 | const handleSessionChange = (newSessionId: string, adminStatus: boolean) => { 85 | setSessionId(newSessionId); 86 | setIsAdmin(adminStatus); 87 | // 刷新页面以重新加载数据 88 | window.location.reload(); 89 | }; 90 | 91 | // Move menu items definition here 92 | const items = [ 93 | { 94 | key: '/register', 95 | icon: , 96 | label: 账号注册, 97 | }, 98 | { 99 | key: '/activate', 100 | icon: , 101 | label: 账号激活, 102 | }, 103 | { 104 | key: '/history', 105 | icon: , 106 | label: 历史账号, 107 | }, 108 | { 109 | key: '/account-manager', 110 | icon: , 111 | label: 账号信息, 112 | }, 113 | // 只有管理员可以看到代理池管理 114 | ...(isAdmin ? [{ 115 | key: '/proxy-pool', 116 | icon: , 117 | label: 代理池管理, 118 | }] : []), 119 | ]; 120 | 121 | // Move the Layout JSX structure here 122 | return ( 123 | <> 124 | 125 | setCollapsed(value)} 129 | style={{ 130 | overflow: 'auto', 131 | height: '100vh', 132 | position: 'fixed', 133 | left: 0, 134 | top: 0, 135 | bottom: 0, 136 | zIndex: 10 137 | }} 138 | > 139 |
140 | {collapsed ? "P" : "PikPak 自动邀请"} 141 |
142 | 143 | 144 | {/* 会话信息和管理按钮 */} 145 | {sessionId && ( 146 |
157 | {!collapsed ? ( 158 | 159 | 166 | 会话: {sessionId.length > 12 ? sessionId.substring(0, 12) + '...' : sessionId} 167 | {isAdmin && (管理员)} 168 | 169 | 186 | 187 | ) : ( 188 |
189 | 195 | {sessionId.substring(0, 6)}... 196 | {isAdmin && } 197 | 198 |
215 | )} 216 |
217 | )} 218 | 219 | 224 | 225 |
235 | {/* Outlet用于渲染子路由 */} 236 | 237 |
238 |
239 |
240 | 241 | 242 | {/* 会话管理弹窗 */} 243 | setShowSessionManager(false)} 246 | onSessionChange={handleSessionChange} 247 | currentSessionId={sessionId} 248 | isAdmin={isAdmin} 249 | /> 250 | 251 | {/* 会话初始化弹窗 */} 252 | 256 | 257 | ); 258 | }; 259 | 260 | // Simplify the App component 261 | function App() { 262 | return ( 263 | 280 | 281 | 282 | }> 283 | } /> 284 | } /> 285 | } /> 286 | } /> 287 | } /> 288 | } /> 289 | 290 | 291 | 292 | 293 | ); 294 | } 295 | 296 | export default App; 297 | -------------------------------------------------------------------------------- /frontend/src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/components/Header/index.css: -------------------------------------------------------------------------------- 1 | /* Comment out or remove old container style 2 | .header-container { 3 | display: flex; 4 | align-items: center; 5 | padding: 0 24px; 6 | background-color: #001529; 7 | color: white; 8 | height: 64px; 9 | } 10 | */ 11 | 12 | /* Add new style for Layout.Header */ 13 | .header-layout { 14 | display: flex; /* Use flexbox for alignment */ 15 | align-items: center; /* Vertically center items */ 16 | padding: 0 30px; /* Adjust horizontal padding */ 17 | } 18 | 19 | .logo { 20 | font-size: 20px; 21 | font-weight: bold; 22 | margin-right: 30px; /* Adjust margin for spacing */ 23 | } 24 | 25 | .logo a { 26 | /* color: white; Remove this, Layout.Header theme handles it */ 27 | text-decoration: none; 28 | } 29 | 30 | .header-menu { 31 | flex: 1; /* Keep this to fill remaining space */ 32 | } -------------------------------------------------------------------------------- /frontend/src/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Menu, Layout } from 'antd'; 3 | import { Link, useLocation } from 'react-router-dom'; 4 | import './index.css'; 5 | 6 | const Header: React.FC = () => { 7 | const location = useLocation(); 8 | const currentPath = location.pathname; 9 | 10 | const items = [ 11 | { 12 | key: '/register', 13 | label: 账号注册, 14 | }, 15 | { 16 | key: '/activate', 17 | label: 账号激活, 18 | }, 19 | { 20 | key: '/history', 21 | label: 历史账号, 22 | }, 23 | ]; 24 | 25 | return ( 26 | 27 |
28 | PikPak 自动邀请 29 |
30 | 37 | 38 | ); 39 | }; 40 | 41 | export default Header; -------------------------------------------------------------------------------- /frontend/src/components/SessionInitializer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Modal, Input, Button, message, Space, Typography, Card, Alert, Tabs } from 'antd'; 3 | import { UserOutlined, KeyOutlined, PlusOutlined, LoginOutlined } from '@ant-design/icons'; 4 | 5 | const { Title, Text, Paragraph } = Typography; 6 | const { TabPane } = Tabs; 7 | 8 | interface SessionInitializerProps { 9 | visible: boolean; 10 | onSessionCreated: (sessionId: string, isAdmin: boolean) => void; 11 | } 12 | 13 | const SessionInitializer: React.FC = ({ 14 | visible, 15 | onSessionCreated 16 | }) => { 17 | const [activeTab, setActiveTab] = useState('create'); 18 | const [customSessionId, setCustomSessionId] = useState(''); // 用于自定义会话ID 19 | const [existingSessionId, setExistingSessionId] = useState(''); // 用于现有会话ID 20 | const [sessionLength, setSessionLength] = useState(12); 21 | const [loading, setLoading] = useState(false); 22 | 23 | const generateSessionId = async () => { 24 | setLoading(true); 25 | try { 26 | const response = await fetch('/api/session/generate', { 27 | method: 'POST', 28 | headers: { 29 | 'Content-Type': 'application/json', 30 | }, 31 | body: JSON.stringify({ length: sessionLength }), 32 | }); 33 | 34 | const data = await response.json(); 35 | if (data.status === 'success') { 36 | const newSessionId = data.session_id; 37 | 38 | // 保存到localStorage 39 | localStorage.setItem('session_id', newSessionId); 40 | 41 | message.success('会话创建成功!'); 42 | onSessionCreated(newSessionId, false); 43 | } else { 44 | message.error(data.message || '创建会话失败'); 45 | } 46 | } catch (error) { 47 | message.error('创建会话失败'); 48 | } finally { 49 | setLoading(false); 50 | } 51 | }; 52 | 53 | // 创建自定义会话ID 54 | const createCustomSession = async () => { 55 | if (!customSessionId.trim()) { 56 | message.error('请输入会话ID'); 57 | return; 58 | } 59 | 60 | const trimmedSessionId = customSessionId.trim(); 61 | 62 | // 验证会话ID格式 63 | if (trimmedSessionId.length < 6 || trimmedSessionId.length > 20) { 64 | message.error('会话ID长度必须在6-20位之间'); 65 | return; 66 | } 67 | 68 | if (!/^[a-zA-Z0-9]+$/.test(trimmedSessionId)) { 69 | message.error('会话ID只能包含字母和数字'); 70 | return; 71 | } 72 | 73 | setLoading(true); 74 | try { 75 | // 直接创建自定义会话ID 76 | const response = await fetch('/api/session/generate', { 77 | method: 'POST', 78 | headers: { 79 | 'Content-Type': 'application/json', 80 | }, 81 | body: JSON.stringify({ custom_id: trimmedSessionId }), 82 | }); 83 | 84 | const data = await response.json(); 85 | if (data.status === 'success') { 86 | localStorage.setItem('session_id', trimmedSessionId); 87 | message.success('自定义会话创建成功!'); 88 | onSessionCreated(trimmedSessionId, false); 89 | } else { 90 | message.error(data.message || '创建自定义会话失败'); 91 | } 92 | } catch (error) { 93 | message.error('创建自定义会话失败'); 94 | } finally { 95 | setLoading(false); 96 | } 97 | }; 98 | 99 | // 验证现有会话ID 100 | const validateExistingSession = async () => { 101 | if (!existingSessionId.trim()) { 102 | message.error('请输入会话ID'); 103 | return; 104 | } 105 | 106 | const trimmedSessionId = existingSessionId.trim(); 107 | 108 | // 验证会话ID格式 109 | if (trimmedSessionId.length < 6 || trimmedSessionId.length > 20) { 110 | message.error('会话ID长度必须在6-20位之间'); 111 | return; 112 | } 113 | 114 | if (!/^[a-zA-Z0-9]+$/.test(trimmedSessionId)) { 115 | message.error('会话ID只能包含字母和数字'); 116 | return; 117 | } 118 | 119 | setLoading(true); 120 | try { 121 | const response = await fetch('/api/session/validate', { 122 | method: 'POST', 123 | headers: { 124 | 'Content-Type': 'application/json', 125 | }, 126 | body: JSON.stringify({ session_id: trimmedSessionId }), 127 | }); 128 | 129 | const data = await response.json(); 130 | if (data.status === 'success' && data.is_valid) { 131 | // 保存到localStorage 132 | localStorage.setItem('session_id', trimmedSessionId); 133 | 134 | message.success(`会话验证成功${data.is_admin ? ' (管理员模式)' : ''}!`); 135 | onSessionCreated(trimmedSessionId, data.is_admin); 136 | } else { 137 | message.error(data.message || '会话ID无效'); 138 | } 139 | } catch (error) { 140 | message.error('验证会话ID失败'); 141 | } finally { 142 | setLoading(false); 143 | } 144 | }; 145 | 146 | return ( 147 | 150 | 151 | 欢迎使用 PikPak 自动邀请系统 152 | 153 | } 154 | open={visible} 155 | closable={false} 156 | maskClosable={false} 157 | footer={null} 158 | width={600} 159 | > 160 |
161 | 168 | 169 | 170 | 173 | 174 | 创建新会话 175 | 176 | } 177 | key="create" 178 | > 179 | 180 | 181 | 创建新会话 182 | 183 | 您可以自定义会话ID或让系统为您生成一个唯一的会话ID。 184 | 185 | 186 | {/* 自定义会话ID输入 */} 187 |
188 | 自定义会话ID: 189 | setCustomSessionId(e.target.value)} 193 | maxLength={20} 194 | prefix={} 195 | style={{ marginTop: 8 }} 196 | onPressEnter={() => { 197 | if (customSessionId.trim()) { 198 | createCustomSession(); 199 | } 200 | }} 201 | /> 202 | {customSessionId && ( 203 | 214 | )} 215 |
216 | 217 |
218 | 或者 219 |
220 | 221 | {/* 自动生成会话ID */} 222 |
223 | 自动生成会话ID: 224 | 225 | 长度: 226 | setSessionLength(Number(e.target.value))} 232 | style={{ width: 80 }} 233 | /> 234 | 235 | 236 | 237 | 247 |
248 |
249 |
250 |
251 | 252 | 255 | 256 | 使用现有会话 257 | 258 | } 259 | key="existing" 260 | > 261 | 262 | 263 | 使用现有会话 264 | 265 | 如果您已经有会话ID,请在下方输入以继续使用。 266 | 267 | 268 | setExistingSessionId(e.target.value)} 272 | maxLength={20} 273 | prefix={} 274 | size="large" 275 | onPressEnter={validateExistingSession} 276 | /> 277 | 278 | 288 | 289 | 290 | 291 |
292 | 293 | 297 |

• 会话ID是您访问数据的唯一凭证,请务必保存好

298 |

• 如果丢失会话ID,将无法访问之前创建的账号数据

299 |
300 | } 301 | type="warning" 302 | showIcon 303 | style={{ marginTop: 24 }} 304 | /> 305 | 306 |
307 | ); 308 | }; 309 | 310 | export default SessionInitializer; -------------------------------------------------------------------------------- /frontend/src/components/SessionManager.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Modal, Input, Button, message, Space, Typography, Card, Divider, Alert } from 'antd'; 3 | import { UserOutlined, KeyOutlined, SwapOutlined, PlusOutlined } from '@ant-design/icons'; 4 | 5 | const { Title, Text } = Typography; 6 | 7 | interface SessionManagerProps { 8 | visible: boolean; 9 | onClose: () => void; 10 | onSessionChange: (sessionId: string, isAdmin: boolean) => void; 11 | currentSessionId?: string; 12 | isAdmin?: boolean; 13 | } 14 | 15 | const SessionManager: React.FC = ({ 16 | visible, 17 | onClose, 18 | onSessionChange, 19 | currentSessionId, 20 | isAdmin 21 | }) => { 22 | const [sessionId, setSessionId] = useState(''); 23 | const [sessionLength, setSessionLength] = useState(12); 24 | const [loading, setLoading] = useState(false); 25 | 26 | useEffect(() => { 27 | if (visible && currentSessionId) { 28 | setSessionId(currentSessionId); 29 | } 30 | }, [visible, currentSessionId]); 31 | 32 | const generateSessionId = async () => { 33 | setLoading(true); 34 | try { 35 | const response = await fetch('/api/session/generate', { 36 | method: 'POST', 37 | headers: { 38 | 'Content-Type': 'application/json', 39 | }, 40 | body: JSON.stringify({ length: sessionLength }), 41 | }); 42 | 43 | const data = await response.json(); 44 | if (data.status === 'success') { 45 | setSessionId(data.session_id); 46 | message.success('会话ID生成成功'); 47 | } else { 48 | message.error(data.message || '生成会话ID失败'); 49 | } 50 | } catch (error) { 51 | message.error('生成会话ID失败'); 52 | } finally { 53 | setLoading(false); 54 | } 55 | }; 56 | 57 | const validateAndSwitchSession = async () => { 58 | if (!sessionId.trim()) { 59 | message.error('请输入会话ID'); 60 | return; 61 | } 62 | 63 | setLoading(true); 64 | try { 65 | const response = await fetch('/api/session/validate', { 66 | method: 'POST', 67 | headers: { 68 | 'Content-Type': 'application/json', 69 | }, 70 | body: JSON.stringify({ session_id: sessionId.trim() }), 71 | }); 72 | 73 | const data = await response.json(); 74 | if (data.status === 'success' && data.is_valid) { 75 | // 保存到localStorage 76 | localStorage.setItem('session_id', sessionId.trim()); 77 | 78 | // 通知父组件会话已切换 79 | onSessionChange(sessionId.trim(), data.is_admin); 80 | 81 | message.success(`会话切换成功${data.is_admin ? ' (管理员模式)' : ''}`); 82 | onClose(); 83 | } else { 84 | message.error(data.message || '会话ID无效'); 85 | } 86 | } catch (error) { 87 | message.error('验证会话ID失败'); 88 | } finally { 89 | setLoading(false); 90 | } 91 | }; 92 | 93 | const handleCancel = () => { 94 | setSessionId(currentSessionId || ''); 95 | onClose(); 96 | }; 97 | 98 | return ( 99 | 102 | 103 | 会话管理 104 | 105 | } 106 | open={visible} 107 | onCancel={handleCancel} 108 | footer={null} 109 | width={500} 110 | destroyOnClose 111 | > 112 |
113 | {/* 当前会话信息 */} 114 | {currentSessionId && ( 115 | 116 | 117 | 当前会话 118 | 119 | 120 | {currentSessionId} 121 | {isAdmin && (管理员)} 122 | 123 | 124 | 125 | )} 126 | 127 | 134 | 135 | {/* 会话ID输入 */} 136 | 137 | 138 | <SwapOutlined /> 切换会话 139 | 140 | 141 | setSessionId(e.target.value)} 145 | maxLength={20} 146 | prefix={} 147 | onPressEnter={validateAndSwitchSession} 148 | /> 149 | 150 | 159 | 160 | 161 | 162 | 163 | {/* 生成新会话 */} 164 | 165 | 166 | <PlusOutlined /> 创建新会话 167 | 168 | 169 | 自动生成: 170 | 171 | 长度: 172 | setSessionLength(Number(e.target.value))} 178 | style={{ width: 80 }} 179 | /> 180 | 181 | 182 | 183 | 191 | 192 |
193 |
194 | ); 195 | }; 196 | 197 | export default SessionManager; -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #646cff; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | display: flex; 28 | place-items: center; 29 | min-width: 320px; 30 | min-height: 100vh; 31 | } 32 | 33 | h1 { 34 | font-size: 3.2em; 35 | line-height: 1.1; 36 | } 37 | 38 | /* Override Ant Design button hover styles */ 39 | .ant-btn-primary { 40 | transition: opacity 0.2s !important; 41 | box-shadow: none !important; 42 | } 43 | 44 | .ant-btn-primary:hover, 45 | .ant-btn-primary:focus, 46 | .ant-btn-primary:active { 47 | opacity: 0.9; 48 | box-shadow: none !important; 49 | transform: none !important; 50 | } 51 | 52 | button { 53 | border-radius: 8px; 54 | border: 1px solid transparent; 55 | padding: 0.6em 1.2em; 56 | font-size: 1em; 57 | font-weight: 500; 58 | font-family: inherit; 59 | background-color: #1a1a1a; 60 | cursor: pointer; 61 | transition: border-color 0.25s; 62 | } 63 | button:hover { 64 | border-color: #646cff; 65 | } 66 | button:focus, 67 | button:focus-visible { 68 | outline: 4px auto -webkit-focus-ring-color; 69 | } 70 | 71 | @media (prefers-color-scheme: light) { 72 | :root { 73 | color: #213547; 74 | background-color: #ffffff; 75 | } 76 | a:hover { 77 | color: #747bff; 78 | } 79 | button { 80 | background-color: #f9f9f9; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.tsx' 5 | import '@ant-design/v5-patch-for-react-19' 6 | 7 | createRoot(document.getElementById('root')!).render( 8 | 9 | 10 | , 11 | ) 12 | -------------------------------------------------------------------------------- /frontend/src/pages/AccountManager/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import { 3 | Table, 4 | Card, 5 | Button, 6 | Space, 7 | Modal, 8 | message, 9 | Tooltip, 10 | Badge, 11 | Typography, 12 | Tag, 13 | Drawer, 14 | Descriptions, 15 | Spin, 16 | Alert, 17 | Input, 18 | InputRef 19 | } from 'antd'; 20 | import { 21 | ReloadOutlined, 22 | KeyOutlined, 23 | TeamOutlined, 24 | CrownOutlined, 25 | SearchOutlined 26 | } from '@ant-design/icons'; 27 | import { fetchAccounts, getAccountVipInfo, getAccountInviteCode, getAccountInviteList } from '../../services/api'; 28 | 29 | const { Text, Title } = Typography; 30 | 31 | const AccountManager: React.FC = () => { 32 | const [accounts, setAccounts] = useState([]); 33 | const [filteredAccounts, setFilteredAccounts] = useState([]); 34 | const [loading, setLoading] = useState(false); 35 | const [vipModalVisible, setVipModalVisible] = useState(false); 36 | const [inviteCodeModalVisible, setInviteCodeModalVisible] = useState(false); 37 | const [inviteListDrawerVisible, setInviteListDrawerVisible] = useState(false); 38 | const [currentAccount, setCurrentAccount] = useState(null); 39 | const [vipInfo, setVipInfo] = useState(null); 40 | const [inviteCode, setInviteCode] = useState(''); 41 | const [inviteList, setInviteList] = useState([]); 42 | const [actionLoading, setActionLoading] = useState(false); 43 | const [inviteListLoading, setInviteListLoading] = useState(false); 44 | const [inviteListError, setInviteListError] = useState(null); 45 | const [_, setInviteListInfo] = useState(null); 46 | const [searchText, setSearchText] = useState(''); 47 | const searchInputRef = useRef(null); 48 | 49 | // 加载账号列表 50 | const loadAccounts = async () => { 51 | setLoading(true); 52 | try { 53 | const response = await fetchAccounts(); 54 | if (response.data.status === 'success') { 55 | const accountData = response.data.accounts || []; 56 | setAccounts(accountData); 57 | setFilteredAccounts(accountData); 58 | } else { 59 | message.error(response.data.message || '获取账号列表失败'); 60 | } 61 | } catch (error) { 62 | console.error('获取账号列表失败:', error); 63 | message.error('获取账号列表失败'); 64 | } finally { 65 | setLoading(false); 66 | } 67 | }; 68 | 69 | // 初始加载 70 | useEffect(() => { 71 | loadAccounts(); 72 | }, []); 73 | 74 | // 搜索过滤 75 | useEffect(() => { 76 | if (searchText) { 77 | const filtered = accounts.filter(account => 78 | account.email.toLowerCase().includes(searchText.toLowerCase()) 79 | ); 80 | setFilteredAccounts(filtered); 81 | } else { 82 | setFilteredAccounts(accounts); 83 | } 84 | }, [searchText, accounts]); 85 | 86 | // 查询VIP信息 87 | const handleViewVipInfo = async (account: any) => { 88 | setCurrentAccount(account); 89 | setVipInfo(null); 90 | setVipModalVisible(true); 91 | setActionLoading(true); 92 | 93 | try { 94 | const response = await getAccountVipInfo(account); 95 | if (response.data.status === 'success') { 96 | setVipInfo(response.data.data); 97 | } else { 98 | message.error(response.data.message || '获取VIP信息失败'); 99 | } 100 | } catch (error) { 101 | console.error('获取VIP信息失败:', error); 102 | message.error('获取VIP信息失败'); 103 | } finally { 104 | setActionLoading(false); 105 | } 106 | }; 107 | 108 | // 查看邀请码 109 | const handleViewInviteCode = async (account: any) => { 110 | setCurrentAccount(account); 111 | setInviteCode(''); 112 | setInviteCodeModalVisible(true); 113 | setActionLoading(true); 114 | 115 | try { 116 | const response = await getAccountInviteCode(account); 117 | if (response.data.status === 'success') { 118 | setInviteCode(response.data.data.code || ''); 119 | } else { 120 | message.error(response.data.message || '获取邀请码失败'); 121 | } 122 | } catch (error) { 123 | console.error('获取邀请码失败:', error); 124 | message.error('获取邀请码失败'); 125 | } finally { 126 | setActionLoading(false); 127 | } 128 | }; 129 | 130 | // 查看邀请记录 131 | const handleViewInviteList = async (account: any) => { 132 | setCurrentAccount(account); 133 | setInviteList([]); 134 | setInviteListInfo(null); 135 | setInviteListDrawerVisible(true); 136 | setActionLoading(true); 137 | setInviteListLoading(true); 138 | setInviteListError(null); 139 | 140 | try { 141 | const response = await getAccountInviteList(account); 142 | 143 | if (response.data.status === 'success') { 144 | const inviteData = response.data.data?.data || []; 145 | 146 | // 直接设置两个state 147 | setInviteList(inviteData); 148 | setInviteListInfo(response.data.data ? response.data : { data: { data: inviteData } }); 149 | 150 | if (inviteData.length === 0) { 151 | message.info('暂无邀请记录'); 152 | } 153 | } else { 154 | const errorMsg = response.data.message || '获取邀请记录失败'; 155 | message.error(errorMsg); 156 | setInviteListError(errorMsg); 157 | } 158 | } catch (error) { 159 | console.error('获取邀请记录失败:', error); 160 | message.error('获取邀请记录失败'); 161 | setInviteListError('获取邀请记录失败'); 162 | } finally { 163 | setActionLoading(false); 164 | setInviteListLoading(false); 165 | } 166 | }; 167 | 168 | // 复制邀请码到剪贴板 169 | const copyInviteCode = () => { 170 | if (inviteCode) { 171 | navigator.clipboard.writeText(inviteCode) 172 | .then(() => message.success('邀请码已复制到剪贴板')) 173 | .catch(() => message.error('复制失败,请手动复制')); 174 | } 175 | }; 176 | 177 | // 邀请记录抽屉内容 178 | const renderInviteList = () => { 179 | if (inviteListLoading) { 180 | return ; 181 | } 182 | 183 | if (inviteListError) { 184 | return ; 185 | } 186 | 187 | // 数据为空时显示提示 188 | if (!inviteList || inviteList.length === 0) { 189 | return ; 190 | } 191 | 192 | // 简化列定义,只保留最基本的列 193 | const columns = [ 194 | { 195 | title: '邮箱', 196 | dataIndex: 'invited_user', 197 | key: 'invited_user', 198 | }, 199 | { 200 | title: '邀请时间', 201 | dataIndex: 'time', 202 | key: 'time', 203 | render: (time: string) => time ? new Date(time).toLocaleString() : '-' 204 | }, 205 | { 206 | title: '奖励天数', 207 | dataIndex: 'reward_days', 208 | key: 'reward_days' 209 | }, 210 | { 211 | title: '状态', 212 | dataIndex: 'order_status', 213 | key: 'order_status', 214 | render: (status: string, record: any) => ( 215 | 216 | {status === 'present' ? '已生效' : (record.delay ? '延迟中' : status)} 217 | 218 | ) 219 | }, 220 | { 221 | title: '激活状态', 222 | dataIndex: 'activation_status', 223 | key: 'activation_status', 224 | render: (status: number) => ( 225 | 0 ? "success" : "default"} 227 | text={status > 0 ? `已激活(${status}次)` : "未激活"} 228 | /> 229 | ) 230 | } 231 | ]; 232 | 233 | // 添加调试信息 234 | return ( 235 | <> 236 |
237 | 242 |
243 | 244 | 251 | 252 | ); 253 | }; 254 | 255 | // 表格列定义 256 | const columns = [ 257 | { 258 | title: '邮箱', 259 | dataIndex: 'email', 260 | key: 'email', 261 | render: (text: string) => {text}, 262 | filterDropdown: () => ( 263 |
264 | setSearchText(e.target.value)} 269 | onPressEnter={() => searchInputRef.current?.blur()} 270 | style={{ width: 188, marginBottom: 8, display: 'block' }} 271 | /> 272 | 273 | 282 | 291 | 292 |
293 | ), 294 | filterIcon: (filtered: boolean) => ( 295 | 296 | ), 297 | }, 298 | { 299 | title: '创建时间', 300 | dataIndex: 'created_at', 301 | key: 'created_at', 302 | sorter: (a: any, b: any) => { 303 | const timeA = a.created_at ? new Date(a.created_at).getTime() : 0; 304 | const timeB = b.created_at ? new Date(b.created_at).getTime() : 0; 305 | return timeA - timeB; 306 | }, 307 | render: (time: string) => time ? new Date(time).toLocaleString() : '-' 308 | }, 309 | { 310 | title: '激活次数', 311 | dataIndex: 'activation_status', 312 | key: 'activation_status', 313 | sorter: (a: any, b: any) => (a.activation_status || 0) - (b.activation_status || 0), 314 | defaultSortOrder: 'descend' as const, 315 | render: (status: number) => status > 0 ? status : 0 316 | }, 317 | { 318 | title: '最后激活时间', 319 | dataIndex: 'last_activation_time', 320 | key: 'last_activation_time', 321 | sorter: (a: any, b: any) => { 322 | const timeA = a.last_activation_time ? new Date(a.last_activation_time).getTime() : 0; 323 | const timeB = b.last_activation_time ? new Date(b.last_activation_time).getTime() : 0; 324 | return timeA - timeB; 325 | }, 326 | render: (time: string) => time ? new Date(time).toLocaleString() : '未激活' 327 | }, 328 | { 329 | title: '状态', 330 | dataIndex: 'activation_status', 331 | key: 'status', 332 | render: (status: number) => ( 333 | 0 ? "success" : "default"} 335 | text={status > 0 ? `已激活` : "未激活"} 336 | /> 337 | ) 338 | }, 339 | { 340 | title: '操作', 341 | key: 'action', 342 | render: (_: any, record: any) => ( 343 | 344 | 345 | 392 | 393 | } 394 | > 395 |
418 | 419 | 420 | {/* VIP信息弹窗 */} 421 | VIP信息} 423 | open={vipModalVisible} 424 | onCancel={() => setVipModalVisible(false)} 425 | footer={[ 426 | 429 | ]} 430 | > 431 | {actionLoading ? ( 432 |
433 | 434 |
加载中...
435 |
436 | ) : ( 437 | vipInfo ? ( 438 | 439 | 440 | {currentAccount?.email} 441 | 442 | 443 | {vipInfo.data?.status === 'ok' ? ( 444 | 有效 445 | ) : ( 446 | 无效 447 | )} 448 | 449 | 450 | {vipInfo.data?.type === 'platinum' ? '白金会员' : 451 | vipInfo.data?.type === 'gold' ? '黄金会员' : 452 | vipInfo.data?.type === 'novip' ? '非会员' : vipInfo.data?.type} 453 | 454 | {vipInfo.data?.expire && ( 455 | 456 | {new Date(vipInfo.data.expire).toLocaleString()} 457 | 458 | )} 459 | 460 | ) : ( 461 | 462 | ) 463 | )} 464 |
465 | 466 | {/* 邀请码弹窗 */} 467 | 邀请码} 469 | open={inviteCodeModalVisible} 470 | onCancel={() => setInviteCodeModalVisible(false)} 471 | footer={[ 472 | , 475 | 478 | ]} 479 | > 480 | {actionLoading ? ( 481 |
482 | 483 |
加载中...
484 |
485 | ) : ( 486 | inviteCode ? ( 487 |
488 | {inviteCode} 489 | 这是账号 {currentAccount?.email} 的邀请码 490 |
491 | ) : ( 492 | 493 | ) 494 | )} 495 |
496 | 497 | {/* 邀请记录抽屉 */} 498 | 邀请记录} 500 | width={720} 501 | open={inviteListDrawerVisible} 502 | onClose={() => setInviteListDrawerVisible(false)} 503 | extra={ 504 | 512 | } 513 | > 514 | {renderInviteList()} 515 | 516 | 517 | ); 518 | }; 519 | 520 | export default AccountManager; -------------------------------------------------------------------------------- /frontend/src/pages/Activate/index.css: -------------------------------------------------------------------------------- 1 | .activate-container { 2 | max-width: 1400px; 3 | margin: 0 auto; 4 | padding: 20px; 5 | min-height: calc(100vh - 80px); 6 | overflow: visible; /* Allow proper scrolling */ 7 | } 8 | 9 | .activate-card { 10 | border-radius: 12px; 11 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); 12 | overflow: hidden; 13 | } 14 | 15 | .key-input-section { 16 | background: #fafafa; 17 | padding: 24px; 18 | border-radius: 8px; 19 | margin-bottom: 24px; 20 | } 21 | 22 | .filter-section { 23 | background: #f9f9f9; 24 | padding: 20px; 25 | border-radius: 8px; 26 | margin-bottom: 24px; 27 | border: 1px solid #e8e8e8; 28 | } 29 | 30 | .filter-section .ant-divider { 31 | margin: 0 0 16px 0; 32 | } 33 | 34 | .filter-section .ant-divider-inner-text { 35 | font-weight: 600; 36 | color: #1890ff; 37 | } 38 | 39 | .accounts-section { 40 | background: white; 41 | border-radius: 8px; 42 | } 43 | 44 | .accounts-section .ant-table { 45 | border-radius: 8px; 46 | overflow: hidden; 47 | } 48 | 49 | .accounts-section .ant-table-thead > tr > th { 50 | background: #fafafa; 51 | font-weight: 600; 52 | border-bottom: 2px solid #e8e8e8; 53 | } 54 | 55 | .accounts-section .ant-table-tbody > tr:hover > td { 56 | background: #f0f9ff; 57 | } 58 | 59 | .loading-section { 60 | text-align: center; 61 | padding: 60px 0; 62 | background: #fafafa; 63 | border-radius: 8px; 64 | } 65 | 66 | .loading-section .ant-spin { 67 | margin-bottom: 16px; 68 | } 69 | 70 | .results-section { 71 | background: white; 72 | padding: 24px; 73 | border-radius: 8px; 74 | } 75 | 76 | .results-section .ant-result { 77 | padding: 24px 0; 78 | } 79 | 80 | .results-section .ant-statistic { 81 | text-align: center; 82 | padding: 16px; 83 | background: #fafafa; 84 | border-radius: 8px; 85 | margin-bottom: 16px; 86 | } 87 | 88 | .results-section .ant-table { 89 | border-radius: 8px; 90 | overflow: hidden; 91 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 92 | } 93 | 94 | /* 响应式设计 */ 95 | @media (max-width: 768px) { 96 | .activate-container { 97 | padding: 10px; 98 | } 99 | 100 | .key-input-section, 101 | .filter-section { 102 | padding: 16px; 103 | } 104 | 105 | .filter-section .ant-row { 106 | flex-direction: column; 107 | } 108 | 109 | .filter-section .ant-col { 110 | margin-bottom: 12px; 111 | width: 100% !important; 112 | } 113 | } 114 | 115 | /* 自定义标签样式 */ 116 | .ant-tag { 117 | border-radius: 6px; 118 | font-weight: 500; 119 | } 120 | 121 | /* 自定义按钮样式 */ 122 | .ant-btn { 123 | border-radius: 6px; 124 | font-weight: 500; 125 | } 126 | 127 | .ant-btn:focus, 128 | .ant-btn:active { 129 | outline: none; 130 | box-shadow: none; 131 | border-color: transparent; 132 | } 133 | 134 | .ant-btn-primary { 135 | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 136 | border: none; 137 | } 138 | 139 | .ant-btn-primary:hover { 140 | background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%); 141 | transform: translateY(-1px); 142 | box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); 143 | } 144 | 145 | .ant-btn-primary:focus, 146 | .ant-btn-primary:active { 147 | background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); 148 | outline: none; 149 | box-shadow: none; 150 | border-color: transparent; 151 | } 152 | 153 | /* Ghost按钮样式 */ 154 | .ant-btn-primary.ant-btn-background-ghost { 155 | background: transparent; 156 | border-color: #667eea; 157 | color: #667eea; 158 | } 159 | 160 | .ant-btn-primary.ant-btn-background-ghost:hover { 161 | background: #667eea; 162 | border-color: #667eea; 163 | color: white; 164 | transform: translateY(-1px); 165 | box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); 166 | } 167 | 168 | .ant-btn-primary.ant-btn-background-ghost:focus, 169 | .ant-btn-primary.ant-btn-background-ghost:active { 170 | background: transparent; 171 | border-color: #667eea; 172 | color: #667eea; 173 | outline: none; 174 | box-shadow: none; 175 | } 176 | 177 | /* 表格行选择样式 */ 178 | .ant-table-tbody > tr.ant-table-row-selected > td { 179 | background: #e6f7ff; 180 | } 181 | 182 | .ant-table-tbody > tr.ant-table-row-selected:hover > td { 183 | background: #bae7ff; 184 | } 185 | 186 | /* 统计卡片动画 */ 187 | .ant-statistic { 188 | transition: all 0.3s ease; 189 | } 190 | 191 | .ant-statistic:hover { 192 | transform: translateY(-2px); 193 | } 194 | 195 | /* 筛选区域动画 */ 196 | .filter-section { 197 | transition: all 0.3s ease; 198 | } 199 | 200 | .filter-section:hover { 201 | box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); 202 | } 203 | 204 | /* 加载动画增强 */ 205 | .loading-section .ant-spin-dot { 206 | font-size: 24px; 207 | } 208 | 209 | /* 结果页面样式增强 */ 210 | .results-section .ant-result-icon { 211 | margin-bottom: 24px; 212 | } 213 | 214 | .results-section .ant-result-title { 215 | color: #1890ff; 216 | font-weight: 600; 217 | } 218 | 219 | .results-section .ant-result-subtitle { 220 | color: #666; 221 | font-size: 16px; 222 | } -------------------------------------------------------------------------------- /frontend/src/pages/History/index.css: -------------------------------------------------------------------------------- 1 | .history-container { 2 | max-width: 1000px; 3 | margin: 0 auto; 4 | overflow: visible; /* Allow proper scrolling */ 5 | } 6 | 7 | .history-card { 8 | width: 100%; 9 | 10 | } 11 | 12 | .history-card .ant-card-body { 13 | padding: 0; 14 | } 15 | 16 | .account-details { 17 | margin-top: 16px; 18 | } 19 | 20 | .token-container { 21 | max-width: 100%; 22 | overflow-x: auto; 23 | overflow-y: hidden; 24 | padding: 8px; 25 | background-color: #f5f5f5; 26 | border-radius: 4px; 27 | margin-top: 4px; 28 | word-break: break-all; 29 | white-space: normal; 30 | font-family: monospace; 31 | } -------------------------------------------------------------------------------- /frontend/src/pages/History/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Table, Card, Button, message, Modal, Typography, Tag, Space, Popconfirm } from 'antd'; 3 | import { ReloadOutlined, DeleteOutlined, InfoCircleOutlined } from '@ant-design/icons'; 4 | import { fetchAccounts as apiFetchAccounts, deleteAccount, deleteAccounts } from '../../services/api'; 5 | import './index.css'; 6 | 7 | const { Text, Paragraph } = Typography; 8 | 9 | interface AccountInfo { 10 | id?: number; 11 | name?: string; 12 | email?: string; 13 | password?: string; 14 | user_id?: string; 15 | device_id?: string; 16 | version?: string; 17 | access_token?: string; 18 | refresh_token?: string; 19 | filename: string; 20 | captcha_token?: string; 21 | timestamp?: number; 22 | invite_code?: string; // 新增邀请码字段 23 | activation_status?: number; // 激活状态:0=未激活,1+=激活次数 24 | last_activation_time?: string; // 最后激活时间 25 | session_id?: string; 26 | created_at?: string; 27 | updated_at?: string; 28 | } 29 | 30 | const History: React.FC = () => { 31 | const [accounts, setAccounts] = useState([]); 32 | const [loading, setLoading] = useState(false); 33 | const [visible, setVisible] = useState(false); 34 | const [currentAccount, setCurrentAccount] = useState(null); 35 | const [selectedRowKeys, setSelectedRowKeys] = useState([]); 36 | const [batchDeleteVisible, setBatchDeleteVisible] = useState(false); 37 | const [batchDeleteLoading, setBatchDeleteLoading] = useState(false); 38 | 39 | // 修改 fetchAccounts 函数以调用 API 40 | const fetchAccounts = async () => { 41 | setLoading(true); 42 | try { 43 | const response = await apiFetchAccounts(); // Call the imported API function 44 | if (response.data && response.data.status === 'success') { 45 | // Map the response to ensure consistency, though AccountInfo is now optional 46 | const fetchedAccounts = response.data.accounts.map((acc: any) => ({ 47 | ...acc, 48 | name: acc.name || acc.filename, // Use filename as name if name is missing 49 | })); 50 | setAccounts(fetchedAccounts); 51 | // 清空选择 52 | setSelectedRowKeys([]); 53 | } else { 54 | message.error(response.data.message || '获取账号列表失败'); 55 | } 56 | } catch (error: any) { 57 | console.error('获取账号错误:', error); 58 | message.error(`获取账号列表失败: ${error.message || '未知错误'}`); 59 | } 60 | setLoading(false); 61 | }; 62 | 63 | useEffect(() => { 64 | fetchAccounts(); 65 | }, []); 66 | 67 | const handleDelete = async (accountId: number | string) => { 68 | setLoading(true); 69 | try { 70 | // 调用删除账号API 71 | const response = await deleteAccount(accountId.toString()); 72 | 73 | if (response.data && response.data.status === 'success') { 74 | // 从状态中移除账号 75 | setAccounts(prevAccounts => prevAccounts.filter(acc => 76 | acc.id ? acc.id !== accountId : acc.filename !== accountId 77 | )); 78 | message.success(response.data.message || '账号已成功删除'); 79 | } else { 80 | // 显示API返回的错误消息 81 | message.error(response.data.message || '删除账号失败'); 82 | } 83 | } catch (error: any) { 84 | console.error('删除账号错误:', error); 85 | // 显示捕获到的错误消息 86 | message.error(`删除账号出错: ${error.message || '未知错误'}`); 87 | } finally { 88 | // 确保 loading 状态在所有情况下都设置为 false 89 | setLoading(false); 90 | } 91 | }; 92 | 93 | // 批量删除账号 94 | const handleBatchDelete = async () => { 95 | if (selectedRowKeys.length === 0) { 96 | message.warning('请至少选择一个账号'); 97 | return; 98 | } 99 | 100 | setBatchDeleteLoading(true); 101 | try { 102 | // 从选中的键中提取ID 103 | const accountIds = selectedRowKeys.map(key => key.toString()); 104 | 105 | // 调用批量删除API 106 | const response = await deleteAccounts(accountIds); 107 | 108 | if (response.data && (response.data.status === 'success' || response.data.status === 'partial')) { 109 | // 从状态中移除成功删除的账号 110 | if (response.data.results && response.data.results.success) { 111 | const successIds = response.data.results.success; 112 | setAccounts(prevAccounts => 113 | prevAccounts.filter(acc => !successIds.includes(acc.id?.toString())) 114 | ); 115 | } 116 | 117 | // 显示成功消息 118 | message.success(response.data.message || '账号已成功删除'); 119 | 120 | // 清空选择 121 | setSelectedRowKeys([]); 122 | } else { 123 | // 显示API返回的错误消息 124 | message.error(response.data.message || '批量删除账号失败'); 125 | } 126 | } catch (error: any) { 127 | console.error('批量删除账号错误:', error); 128 | message.error(`批量删除账号出错: ${error.message || '未知错误'}`); 129 | } finally { 130 | setBatchDeleteLoading(false); 131 | setBatchDeleteVisible(false); // 关闭确认对话框 132 | } 133 | }; 134 | 135 | const showAccountDetails = (account: AccountInfo) => { 136 | setCurrentAccount(account); 137 | setVisible(true); 138 | }; 139 | 140 | // 表格行选择配置 141 | const rowSelection = { 142 | selectedRowKeys, 143 | onChange: (newSelectedRowKeys: React.Key[]) => { 144 | setSelectedRowKeys(newSelectedRowKeys); 145 | } 146 | }; 147 | 148 | const columns = [ 149 | { 150 | title: '名称', 151 | dataIndex: 'name', 152 | key: 'name', 153 | }, 154 | { 155 | title: '邮箱', 156 | dataIndex: 'email', 157 | key: 'email', 158 | }, 159 | { 160 | title: '状态', 161 | key: 'status', 162 | render: (_: any, record: AccountInfo) => { 163 | const activationStatus = record.activation_status || 0; 164 | 165 | if (activationStatus === 0) { 166 | return 未激活; 167 | } else if (activationStatus === 1) { 168 | return 已激活 (1次); 169 | } else if (activationStatus > 1) { 170 | return 已激活 ({activationStatus}次); 171 | } else { 172 | // 兼容旧数据 173 | if (record.access_token) { 174 | return 已激活; 175 | } else if (record.email) { 176 | return 未激活; 177 | } else { 178 | return 信息不完整; 179 | } 180 | } 181 | }, 182 | }, 183 | { 184 | title: '邀请码', 185 | dataIndex: 'invite_code', 186 | key: 'invite_code', 187 | render: (invite_code?: string) => invite_code || '-', 188 | }, 189 | { 190 | title: '修改日期', 191 | dataIndex: 'timestamp', 192 | key: 'timestamp', 193 | render: (timestamp: number) => { 194 | // 这里需要类型转换 195 | return (new Date(timestamp*1)).toLocaleString(); 196 | }, 197 | }, 198 | { 199 | title: '操作', 200 | key: 'action', 201 | render: (_: any, record: AccountInfo) => { 202 | const isIncomplete = !record.email; // Consider incomplete if email is missing 203 | return ( 204 | 205 | 213 | handleDelete(record.id || record.filename)} 216 | okText="确定" 217 | cancelText="取消" 218 | > 219 | 222 | 223 | 224 | ); 225 | }, 226 | }, 227 | ]; 228 | 229 | return ( 230 |
231 | 236 | {selectedRowKeys.length > 0 && ( 237 | 244 | )} 245 | 253 | 254 | } 255 | > 256 |
record.id?.toString() || record.filename} 261 | loading={loading} 262 | pagination={{ pageSize: 10 }} 263 | /> 264 | 265 | 266 | {/* 账号详情模态框 */} 267 | setVisible(false)} 271 | footer={[ 272 | 275 | ]} 276 | width={700} 277 | > 278 | {currentAccount && ( 279 |
280 | 281 | 名称: {currentAccount.name || '未提供'} 282 | 283 | 284 | 邮箱: {currentAccount.email || '未提供'} 285 | 286 | 287 | 密码: {currentAccount.password || '未提供'} 288 | 289 | 290 | 用户ID: {currentAccount.user_id || '未提供'} 291 | 292 | 293 | 设备ID: {currentAccount.device_id || '未提供'} 294 | 295 | 296 | 版本: {currentAccount.version || '未提供'} 297 | 298 | 299 | Access Token: 300 |
301 | {currentAccount.access_token || '无'} 302 |
303 |
304 | 305 | Refresh Token: 306 |
307 | {currentAccount.refresh_token || '无'} 308 |
309 |
310 | 311 | 邀请码: {currentAccount.invite_code || '未提供'} 312 | 313 | 314 | 文件名: {currentAccount.filename} 315 | 316 |
317 | )} 318 |
319 | 320 | {/* 批量删除确认对话框 */} 321 | setBatchDeleteVisible(false)} 325 | footer={[ 326 | , 329 | 338 | ]} 339 | > 340 |

确定要删除选中的 {selectedRowKeys.length} 个账号吗?此操作不可撤销。

341 |
342 | 343 | ); 344 | }; 345 | 346 | export default History; -------------------------------------------------------------------------------- /frontend/src/pages/ProxyPool/index.css: -------------------------------------------------------------------------------- 1 | .proxy-pool-container { 2 | padding: 24px; 3 | background: #f5f5f5; 4 | min-height: calc(100vh - 48px); /* Subtract padding from viewport height */ 5 | overflow: visible; /* Allow proper scrolling */ 6 | } 7 | 8 | .proxy-pool-container .ant-card { 9 | border-radius: 8px; 10 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 11 | } 12 | 13 | .proxy-pool-container .ant-table-thead > tr > th { 14 | background: #fafafa; 15 | font-weight: 600; 16 | } 17 | 18 | .proxy-pool-container .ant-table-tbody > tr:hover > td { 19 | background: #f5f5f5; 20 | } 21 | 22 | .proxy-pool-container .ant-tag { 23 | border-radius: 4px; 24 | font-size: 12px; 25 | } 26 | 27 | .proxy-pool-container .ant-btn-sm { 28 | height: 24px; 29 | padding: 0 7px; 30 | font-size: 12px; 31 | } 32 | 33 | .proxy-pool-container .ant-modal-body { 34 | padding: 24px; 35 | } 36 | 37 | .proxy-pool-container .ant-progress { 38 | margin-bottom: 8px; 39 | } 40 | 41 | .proxy-pool-container .ant-alert { 42 | border-radius: 6px; 43 | } 44 | 45 | .proxy-pool-container .ant-typography { 46 | margin-bottom: 0; 47 | } 48 | 49 | .proxy-pool-container .ant-space-item { 50 | display: flex; 51 | align-items: center; 52 | } 53 | 54 | /* 响应式设计 */ 55 | @media (max-width: 768px) { 56 | .proxy-pool-container { 57 | padding: 16px; 58 | } 59 | 60 | .proxy-pool-container .ant-table { 61 | font-size: 12px; 62 | } 63 | 64 | .proxy-pool-container .ant-btn { 65 | padding: 4px 8px; 66 | font-size: 12px; 67 | } 68 | } -------------------------------------------------------------------------------- /frontend/src/pages/ProxyPool/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { 3 | Card, 4 | Table, 5 | Button, 6 | Input, 7 | message, 8 | Modal, 9 | Space, 10 | Tag, 11 | Popconfirm, 12 | Typography, 13 | Alert, 14 | Tooltip, 15 | Progress 16 | } from 'antd'; 17 | import { 18 | PlusOutlined, 19 | DeleteOutlined, 20 | ReloadOutlined, 21 | ExperimentOutlined, 22 | CheckCircleOutlined, 23 | CloseCircleOutlined, 24 | } from '@ant-design/icons'; 25 | import { HttpClient } from '../../utils/httpClient'; 26 | import './index.css'; 27 | 28 | const { Text, Title } = Typography; 29 | const { TextArea } = Input; 30 | 31 | interface ProxyInfo { 32 | id: number; 33 | proxy_url: string; 34 | protocol: string; 35 | host: string; 36 | port: number; 37 | username?: string; 38 | password?: string; 39 | is_active: boolean; 40 | last_checked?: string; 41 | response_time?: number; 42 | success_count: number; 43 | fail_count: number; 44 | created_at: string; 45 | updated_at: string; 46 | } 47 | 48 | const ProxyPool: React.FC = () => { 49 | const [proxies, setProxies] = useState([]); 50 | const [loading, setLoading] = useState(false); 51 | const [addModalVisible, setAddModalVisible] = useState(false); 52 | const [batchAddModalVisible, setBatchAddModalVisible] = useState(false); 53 | const [newProxyUrl, setNewProxyUrl] = useState(''); 54 | const [batchProxyUrls, setBatchProxyUrls] = useState(''); 55 | const [testingAll, setTestingAll] = useState(false); 56 | const [testProgress, setTestProgress] = useState(0); 57 | const [testingProxies, setTestingProxies] = useState>(new Set()); 58 | 59 | useEffect(() => { 60 | fetchProxies(); 61 | }, []); 62 | 63 | const fetchProxies = async () => { 64 | setLoading(true); 65 | try { 66 | const response = await HttpClient.get('/api/proxy/list'); 67 | const data = await response.json(); 68 | 69 | if (data.status === 'success') { 70 | setProxies(data.proxies); 71 | } else { 72 | message.error(data.message || '获取代理列表失败'); 73 | } 74 | } catch (error) { 75 | message.error('获取代理列表失败'); 76 | } finally { 77 | setLoading(false); 78 | } 79 | }; 80 | 81 | const addProxy = async () => { 82 | if (!newProxyUrl.trim()) { 83 | message.error('请输入代理URL'); 84 | return; 85 | } 86 | 87 | try { 88 | const response = await HttpClient.post('/api/proxy/add', { 89 | proxy_url: newProxyUrl.trim() 90 | }); 91 | const data = await response.json(); 92 | 93 | if (data.status === 'success') { 94 | message.success('代理添加成功'); 95 | setNewProxyUrl(''); 96 | setAddModalVisible(false); 97 | fetchProxies(); 98 | } else { 99 | message.error(data.message || '添加代理失败'); 100 | } 101 | } catch (error) { 102 | message.error('添加代理失败'); 103 | } 104 | }; 105 | 106 | const batchAddProxies = async () => { 107 | const urls = batchProxyUrls.split('\n').filter(url => url.trim()); 108 | if (urls.length === 0) { 109 | message.error('请输入代理URL'); 110 | return; 111 | } 112 | 113 | let successCount = 0; 114 | let failCount = 0; 115 | 116 | for (const url of urls) { 117 | try { 118 | const response = await HttpClient.post('/api/proxy/add', { 119 | proxy_url: url.trim() 120 | }); 121 | const data = await response.json(); 122 | 123 | if (data.status === 'success') { 124 | successCount++; 125 | } else { 126 | failCount++; 127 | } 128 | } catch (error) { 129 | failCount++; 130 | } 131 | } 132 | 133 | message.success(`批量添加完成: 成功 ${successCount} 个,失败 ${failCount} 个`); 134 | setBatchProxyUrls(''); 135 | setBatchAddModalVisible(false); 136 | fetchProxies(); 137 | }; 138 | 139 | const removeProxy = async (proxyId: number) => { 140 | try { 141 | const response = await HttpClient.post('/api/proxy/remove', { 142 | proxy_id: proxyId 143 | }); 144 | const data = await response.json(); 145 | 146 | if (data.status === 'success') { 147 | message.success('代理删除成功'); 148 | fetchProxies(); 149 | } else { 150 | message.error(data.message || '删除代理失败'); 151 | } 152 | } catch (error) { 153 | message.error('删除代理失败'); 154 | } 155 | }; 156 | 157 | const testProxy = async (proxyId: number, proxyUrl: string) => { 158 | // 添加到测试中的代理集合 159 | setTestingProxies(prev => new Set(prev).add(proxyId)); 160 | 161 | try { 162 | const response = await HttpClient.post('/api/proxy/test', { 163 | proxy_url: proxyUrl 164 | }); 165 | const data = await response.json(); 166 | 167 | if (data.status === 'success') { 168 | const result = data.test_result; 169 | if (result.success) { 170 | message.success(`代理测试成功,响应时间: ${result.response_time?.toFixed(2)}s`); 171 | } else { 172 | message.error(`代理测试失败: ${result.error}`); 173 | } 174 | fetchProxies(); // 刷新列表以显示最新状态 175 | } else { 176 | message.error(data.message || '测试代理失败'); 177 | } 178 | } catch (error) { 179 | message.error('测试代理失败'); 180 | } finally { 181 | // 从测试中的代理集合中移除 182 | setTestingProxies(prev => { 183 | const newSet = new Set(prev); 184 | newSet.delete(proxyId); 185 | return newSet; 186 | }); 187 | } 188 | }; 189 | 190 | const testAllProxies = async () => { 191 | setTestingAll(true); 192 | setTestProgress(0); 193 | 194 | try { 195 | const response = await HttpClient.post('/api/proxy/test-all'); 196 | const data = await response.json(); 197 | 198 | if (data.status === 'success') { 199 | const results = data.results; 200 | message.success(`批量测试完成: ${results.success}/${results.total} 成功`); 201 | fetchProxies(); 202 | } else { 203 | message.error(data.message || '批量测试失败'); 204 | } 205 | } catch (error) { 206 | message.error('批量测试失败'); 207 | } finally { 208 | setTestingAll(false); 209 | setTestProgress(0); 210 | } 211 | }; 212 | 213 | const columns = [ 214 | { 215 | title: 'ID', 216 | dataIndex: 'id', 217 | key: 'id', 218 | width: 60, 219 | }, 220 | { 221 | title: '代理地址', 222 | dataIndex: 'proxy_url', 223 | key: 'proxy_url', 224 | ellipsis: true, 225 | render: (text: string) => ( 226 | 227 | 228 | {text.length > 40 ? `${text.substring(0, 40)}...` : text} 229 | 230 | 231 | ), 232 | }, 233 | { 234 | title: '协议', 235 | dataIndex: 'protocol', 236 | key: 'protocol', 237 | width: 80, 238 | render: (protocol: string) => ( 239 | 240 | {protocol.toUpperCase()} 241 | 242 | ), 243 | }, 244 | { 245 | title: '状态', 246 | dataIndex: 'is_active', 247 | key: 'is_active', 248 | width: 80, 249 | render: (isActive: boolean) => ( 250 | : }> 251 | {isActive ? '活跃' : '不活跃'} 252 | 253 | ), 254 | }, 255 | { 256 | title: '响应时间', 257 | dataIndex: 'response_time', 258 | key: 'response_time', 259 | width: 100, 260 | render: (time: number) => ( 261 | time ? ( 262 | 263 | {time.toFixed(2)}s 264 | 265 | ) : '-' 266 | ), 267 | }, 268 | { 269 | title: '成功/失败', 270 | key: 'stats', 271 | width: 100, 272 | render: (record: ProxyInfo) => ( 273 | 274 | 成功: {record.success_count} 275 | 失败: {record.fail_count} 276 | 277 | ), 278 | }, 279 | { 280 | title: '最后检查', 281 | dataIndex: 'last_checked', 282 | key: 'last_checked', 283 | width: 120, 284 | render: (time: string) => ( 285 | time ? ( 286 | 287 | {new Date(time).toLocaleString()} 288 | 289 | ) : '-' 290 | ), 291 | }, 292 | { 293 | title: '操作', 294 | key: 'actions', 295 | width: 120, 296 | render: (record: ProxyInfo) => ( 297 | 298 | 346 | 352 | 359 | 366 | 367 | 368 | 369 | {testingAll && ( 370 |
371 | 372 | 正在测试代理... 373 |
374 | )} 375 | 376 |
`共 ${total} 个代理`, 386 | }} 387 | scroll={{ x: 800 }} 388 | /> 389 | 390 | 391 | {/* 添加单个代理弹窗 */} 392 | { 397 | setAddModalVisible(false); 398 | setNewProxyUrl(''); 399 | }} 400 | okText="添加" 401 | cancelText="取消" 402 | > 403 |
404 | 代理URL格式示例: 405 |
406 |
• http://127.0.0.1:7890
407 |
• https://user:pass@proxy.example.com:8080
408 |
• socks5://user:pass@proxy.example.com:1080
409 |
410 |
411 | setNewProxyUrl(e.target.value)} 415 | onPressEnter={addProxy} 416 | /> 417 |
418 | 419 | {/* 批量添加代理弹窗 */} 420 | { 425 | setBatchAddModalVisible(false); 426 | setBatchProxyUrls(''); 427 | }} 428 | okText="批量添加" 429 | cancelText="取消" 430 | width={600} 431 | > 432 |
433 | 每行一个代理URL: 434 |
435 |