├── .dockerignore ├── .github └── workflows │ └── sync_alist_files.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── RELEASE.md ├── VERSION ├── action.txt ├── alist-sync-ql.py ├── alist-sync-web.py ├── alist_sync.py ├── docker-compose.yml ├── requirements.txt ├── static ├── css │ └── all.min.css ├── images │ ├── favicon.ico │ ├── logo.png │ ├── 令牌.png │ ├── 数据同步.png │ ├── 文件同步.png │ ├── 文件移动.png │ └── 查看任务进度.png ├── json │ └── run.json ├── layui │ ├── css │ │ ├── layui.css │ │ └── modules │ │ │ ├── code.css │ │ │ ├── laydate │ │ │ └── default │ │ │ │ ├── font.css │ │ │ │ └── laydate.css │ │ │ └── layer │ │ │ └── default │ │ │ ├── icon-ext.png │ │ │ ├── icon.png │ │ │ ├── layer.css │ │ │ ├── loading-0.gif │ │ │ ├── loading-1.gif │ │ │ └── loading-2.gif │ ├── font │ │ ├── iconfont.eot │ │ ├── iconfont.svg │ │ ├── iconfont.ttf │ │ ├── iconfont.woff │ │ └── iconfont.woff2 │ └── layui.js ├── layui_exts │ └── cron │ │ ├── cron.css │ │ └── cron.js └── webfonts │ ├── fa-brands-400.ttf │ ├── fa-brands-400.woff2 │ ├── fa-regular-400.ttf │ ├── fa-regular-400.woff2 │ ├── fa-solid-900.ttf │ └── fa-solid-900.woff2 ├── templates ├── index.html └── login.html └── xiaojin.txt /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .github 3 | .gitignore 4 | __pycache__ 5 | *.pyc 6 | *.pyo 7 | *.pyd 8 | .Python 9 | env 10 | pip-log.txt 11 | pip-delete-this-directory.txt 12 | .tox 13 | .coverage 14 | .coverage.* 15 | .cache 16 | nosetests.xml 17 | coverage.xml 18 | *.cover 19 | *.log 20 | .pytest_cache 21 | .env 22 | .venv 23 | .DS_Store -------------------------------------------------------------------------------- /.github/workflows/sync_alist_files.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - 'v*' 8 | paths: 9 | - 'action.txt' 10 | 11 | # 添加权限配置 12 | permissions: 13 | contents: write 14 | packages: write 15 | issues: write 16 | pull-requests: write 17 | 18 | env: 19 | DOCKER_IMAGE: ${{ github.repository }} # 请替换成你的 Docker Hub 用户名和镜像名 20 | PLATFORMS: linux/386,linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/s390x 21 | 22 | jobs: 23 | alist-sync-docker: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v4 28 | 29 | - name: Docker meta 30 | id: meta 31 | uses: docker/metadata-action@v5 32 | with: 33 | images: ${{ env.DOCKER_IMAGE }} 34 | tags: | 35 | type=raw,value=latest,enable={{is_default_branch}} 36 | type=ref,event=branch 37 | type=ref,event=pr 38 | type=semver,pattern={{version}} 39 | 40 | - name: Set up QEMU 41 | uses: docker/setup-qemu-action@v3 42 | 43 | - name: Set up Docker Buildx 44 | uses: docker/setup-buildx-action@v3 45 | 46 | - name: Login to Docker Hub 47 | if: github.event_name != 'pull_request' 48 | uses: docker/login-action@v3 49 | with: 50 | username: ${{ secrets.DOCKERHUB_USERNAME }} 51 | password: ${{ secrets.DOCKERHUB_TOKEN }} 52 | 53 | - name: Get version from tag 54 | if: startsWith(github.ref, 'refs/tags/') 55 | id: get_version 56 | run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 57 | 58 | - name: Build and push 59 | uses: docker/build-push-action@v5 60 | with: 61 | context: . 62 | platforms: ${{ env.PLATFORMS }} 63 | push: ${{ github.event_name != 'pull_request' }} 64 | tags: ${{ steps.meta.outputs.tags }} 65 | labels: ${{ steps.meta.outputs.labels }} 66 | cache-from: type=gha 67 | cache-to: type=gha,mode=max 68 | provenance: false 69 | 70 | - name: Update Docker Hub Description 71 | if: github.event_name != 'pull_request' 72 | uses: peter-evans/dockerhub-description@v3 73 | with: 74 | username: ${{ secrets.DOCKERHUB_USERNAME }} 75 | password: ${{ secrets.DOCKERHUB_TOKEN }} 76 | repository: ${{ env.DOCKER_IMAGE }} 77 | readme-filepath: ./README.md 78 | short-description: "Alist-Sync - A tool for syncing files between Alist storages" 79 | 80 | # 以下是新增的 Release 相关步骤 81 | - name: Create Source Archive 82 | if: startsWith(github.ref, 'refs/tags/') 83 | run: | 84 | # 创建临时目录 85 | TEMP_DIR="/tmp/alist-sync-release" 86 | mkdir -p "$TEMP_DIR" 87 | 88 | # 定义要包含的文件列表 89 | INCLUDE_FILES=( 90 | "alist-sync-web.py" 91 | "alist_sync.py" 92 | "requirements.txt" 93 | "Dockerfile" 94 | "docker-compose.yml" 95 | "README.md" 96 | "static" 97 | "templates" 98 | ) 99 | 100 | # 定义平台列表 101 | PLATFORMS=( 102 | "linux-386" 103 | "linux-amd64" 104 | "linux-arm-v6" 105 | "linux-arm-v7" 106 | "linux-arm64" 107 | "linux-ppc64le" 108 | "linux-s390x" 109 | ) 110 | 111 | # 为每个平台创建一个包 112 | for platform in "${PLATFORMS[@]}"; do 113 | # 创建平台特定的临时目录 114 | PLATFORM_DIR="$TEMP_DIR/$platform" 115 | mkdir -p "$PLATFORM_DIR" 116 | 117 | # 复制文件到平台目录 118 | for item in "${INCLUDE_FILES[@]}"; do 119 | if [ -e "$item" ]; then 120 | cp -r "$item" "$PLATFORM_DIR/" 121 | fi 122 | done 123 | 124 | # 在平台目录中创建压缩包 125 | cd "$PLATFORM_DIR" 126 | 127 | # 创建 ZIP 文件 128 | zip -r "alist-sync-${{ github.ref_name }}-${platform}.zip" ./* 129 | 130 | # 创建 TAR.GZ 文件 131 | tar --exclude='.[^/]*' -czf "alist-sync-${{ github.ref_name }}-${platform}.tar.gz" ./* 132 | 133 | # 移动压缩包到工作目录 134 | mv "alist-sync-${{ github.ref_name }}-${platform}.zip" "$GITHUB_WORKSPACE/" 135 | mv "alist-sync-${{ github.ref_name }}-${platform}.tar.gz" "$GITHUB_WORKSPACE/" 136 | done 137 | 138 | - name: Generate Release Body 139 | if: startsWith(github.ref, 'refs/tags/') 140 | id: release_body 141 | run: | 142 | # 将 release 内容保存到输出变量 143 | CONTENT=$(cat RELEASE.md) 144 | echo "RELEASE_BODY<> "$GITHUB_OUTPUT" 145 | echo "$CONTENT" >> "$GITHUB_OUTPUT" 146 | echo "EOF" >> "$GITHUB_OUTPUT" 147 | 148 | - name: Create Release 149 | if: startsWith(github.ref, 'refs/tags/') 150 | id: create_release 151 | uses: softprops/action-gh-release@v1 152 | with: 153 | tag_name: ${{ github.ref_name }} 154 | name: Release ${{ github.ref_name }} 155 | draft: false 156 | prerelease: false 157 | body: ${{ steps.release_body.outputs.RELEASE_BODY }} 158 | files: | 159 | alist-sync-${{ github.ref_name }}-linux-386.zip 160 | alist-sync-${{ github.ref_name }}-linux-386.tar.gz 161 | alist-sync-${{ github.ref_name }}-linux-amd64.zip 162 | alist-sync-${{ github.ref_name }}-linux-amd64.tar.gz 163 | alist-sync-${{ github.ref_name }}-linux-arm-v6.zip 164 | alist-sync-${{ github.ref_name }}-linux-arm-v6.tar.gz 165 | alist-sync-${{ github.ref_name }}-linux-arm-v7.zip 166 | alist-sync-${{ github.ref_name }}-linux-arm-v7.tar.gz 167 | alist-sync-${{ github.ref_name }}-linux-arm64.zip 168 | alist-sync-${{ github.ref_name }}-linux-arm64.tar.gz 169 | alist-sync-${{ github.ref_name }}-linux-ppc64le.zip 170 | alist-sync-${{ github.ref_name }}-linux-ppc64le.tar.gz 171 | alist-sync-${{ github.ref_name }}-linux-s390x.zip 172 | alist-sync-${{ github.ref_name }}-linux-s390x.tar.gz 173 | 174 | # 新增推送文件到 Gitee 的步骤 175 | - name: Sync Github Repos To Gitee # 名字随便起 176 | uses: Yikun/hub-mirror-action@master # 使用Yikun/hub-mirror-action 177 | with: 178 | src: github/xjxjin # 源端账户名(github) 179 | dst: gitee/xjxjin # 目的端账户名(gitee) 180 | dst_key: ${{ secrets.GITEE_PRIVATE_KEY }} # SSH密钥对中的私钥 181 | dst_token: ${{ secrets.GITEE_TOKEN }} # Gitee账户的私人令牌 182 | account_type: user # 账户类型 183 | clone_style: "https" # 使用https方式进行clone,也可以使用ssh 184 | debug: true # 启用后会显示所有执行命令 185 | force_update: true # 启用后,强制同步,即强制覆盖目的端仓库 186 | static_list: "${{ github.event.repository.name }}" # 静态同步列表,在此填写需要同步的仓库名称,可填写多个 187 | timeout: '600s' # git超时设置,超时后会自动重试git操作 188 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | data 2 | __pycache__ 3 | .vscode 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 使用 Python Alpine 作为构建镜像 2 | FROM python:3.10-alpine AS builder 3 | 4 | # 设置工作目录 5 | WORKDIR /app 6 | 7 | # 安装构建依赖 8 | RUN apk add --no-cache \ 9 | gcc \ 10 | musl-dev \ 11 | python3-dev \ 12 | libffi-dev \ 13 | openssl-dev 14 | 15 | # 复制依赖文件 16 | COPY requirements.txt . 17 | 18 | # 安装依赖到指定目录 19 | RUN pip install --no-cache-dir -r requirements.txt --target=/install 20 | 21 | # 使用更小的运行时镜像 22 | FROM python:3.10-alpine 23 | 24 | # 设置工作目录 25 | WORKDIR /app 26 | 27 | # 复制安装的依赖 28 | COPY --from=builder /install /usr/local/lib/python3.10/site-packages 29 | 30 | # 只复制必要的项目文件 31 | COPY alist-sync-web.py alist_sync.py VERSION ./ 32 | COPY static ./static 33 | COPY templates ./templates 34 | 35 | # 创建必要的目录 36 | RUN mkdir -p /app/data/config /app/data/log && \ 37 | # 设置权限 38 | chmod -R 755 /app/data && \ 39 | # 清理不必要的文件 40 | find /usr/local/lib/python3.10/site-packages -name "*.pyc" -delete && \ 41 | find /usr/local/lib/python3.10/site-packages -name "__pycache__" -exec rm -r {} + && \ 42 | # 删除测试文件和文档 43 | find /usr/local/lib/python3.10/site-packages -name "tests" -type d -exec rm -r {} + && \ 44 | find /usr/local/lib/python3.10/site-packages -name "*.txt" -delete && \ 45 | find /usr/local/lib/python3.10/site-packages -name "*.md" -delete 46 | 47 | # 设置环境变量 48 | ENV PYTHONUNBUFFERED=1 \ 49 | PYTHONDONTWRITEBYTECODE=1 \ 50 | PATH="/app:$PATH" 51 | 52 | # 暴露端口 53 | EXPOSE 52441 54 | 55 | # 设置启动命令 56 | CMD ["python", "alist-sync-web.py"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 木兰宽松许可证,第2版 2 | 3 | 木兰宽松许可证,第2版 4 | 5 | 2020年1月 http://license.coscl.org.cn/MulanPSL2 6 | 7 | 您对“软件”的复制、使用、修改及分发受木兰宽松许可证,第2版(“本许可证”)的如下条款的约束: 8 | 9 | 0. 定义 10 | 11 | “软件” 是指由“贡献”构成的许可在“本许可证”下的程序和相关文档的集合。 12 | 13 | “贡献” 是指由任一“贡献者”许可在“本许可证”下的受版权法保护的作品。 14 | 15 | “贡献者” 是指将受版权法保护的作品许可在“本许可证”下的自然人或“法人实体”。 16 | 17 | “法人实体” 是指提交贡献的机构及其“关联实体”。 18 | 19 | “关联实体” 是指,对“本许可证”下的行为方而言,控制、受控制或与其共同受控制的机构,此处的控制是 20 | 指有受控方或共同受控方至少50%直接或间接的投票权、资金或其他有价证券。 21 | 22 | 1. 授予版权许可 23 | 24 | 每个“贡献者”根据“本许可证”授予您永久性的、全球性的、免费的、非独占的、不可撤销的版权许可,您可 25 | 以复制、使用、修改、分发其“贡献”,不论修改与否。 26 | 27 | 2. 授予专利许可 28 | 29 | 每个“贡献者”根据“本许可证”授予您永久性的、全球性的、免费的、非独占的、不可撤销的(根据本条规定 30 | 撤销除外)专利许可,供您制造、委托制造、使用、许诺销售、销售、进口其“贡献”或以其他方式转移其“贡 31 | 献”。前述专利许可仅限于“贡献者”现在或将来拥有或控制的其“贡献”本身或其“贡献”与许可“贡献”时的“软 32 | 件”结合而将必然会侵犯的专利权利要求,不包括对“贡献”的修改或包含“贡献”的其他结合。如果您或您的“ 33 | 关联实体”直接或间接地,就“软件”或其中的“贡献”对任何人发起专利侵权诉讼(包括反诉或交叉诉讼)或 34 | 其他专利维权行动,指控其侵犯专利权,则“本许可证”授予您对“软件”的专利许可自您提起诉讼或发起维权 35 | 行动之日终止。 36 | 37 | 3. 无商标许可 38 | 39 | “本许可证”不提供对“贡献者”的商品名称、商标、服务标志或产品名称的商标许可,但您为满足第4条规定 40 | 的声明义务而必须使用除外。 41 | 42 | 4. 分发限制 43 | 44 | 您可以在任何媒介中将“软件”以源程序形式或可执行形式重新分发,不论修改与否,但您必须向接收者提供“ 45 | 本许可证”的副本,并保留“软件”中的版权、商标、专利及免责声明。 46 | 47 | 5. 免责声明与责任限制 48 | 49 | “软件”及其中的“贡献”在提供时不带任何明示或默示的担保。在任何情况下,“贡献者”或版权所有者不对 50 | 任何人因使用“软件”或其中的“贡献”而引发的任何直接或间接损失承担责任,不论因何种原因导致或者基于 51 | 何种法律理论,即使其曾被建议有此种损失的可能性。 52 | 53 | 6. 语言 54 | 55 | “本许可证”以中英文双语表述,中英文版本具有同等法律效力。如果中英文版本存在任何冲突不一致,以中文 56 | 版为准。 57 | 58 | 条款结束 59 | 60 | 如何将木兰宽松许可证,第2版,应用到您的软件 61 | 62 | 如果您希望将木兰宽松许可证,第2版,应用到您的新软件,为了方便接收者查阅,建议您完成如下三步: 63 | 64 | 1, 请您补充如下声明中的空白,包括软件名、软件的首次发表年份以及您作为版权人的名字; 65 | 66 | 2, 请您在软件包的一级目录下创建以“LICENSE”为名的文件,将整个许可证文本放入该文件中; 67 | 68 | 3, 请将如下声明文本放入每个源文件的头部注释中。 69 | 70 | Copyright (c) [Year] [name of copyright holder] 71 | [Software Name] is licensed under Mulan PSL v2. 72 | You can use this software according to the terms and conditions of the Mulan 73 | PSL v2. 74 | You may obtain a copy of Mulan PSL v2 at: 75 | http://license.coscl.org.cn/MulanPSL2 76 | THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY 77 | KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO 78 | NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. 79 | See the Mulan PSL v2 for more details. 80 | 81 | Mulan Permissive Software License,Version 2 82 | 83 | Mulan Permissive Software License,Version 2 (Mulan PSL v2) 84 | 85 | January 2020 http://license.coscl.org.cn/MulanPSL2 86 | 87 | Your reproduction, use, modification and distribution of the Software shall 88 | be subject to Mulan PSL v2 (this License) with the following terms and 89 | conditions: 90 | 91 | 0. Definition 92 | 93 | Software means the program and related documents which are licensed under 94 | this License and comprise all Contribution(s). 95 | 96 | Contribution means the copyrightable work licensed by a particular 97 | Contributor under this License. 98 | 99 | Contributor means the Individual or Legal Entity who licenses its 100 | copyrightable work under this License. 101 | 102 | Legal Entity means the entity making a Contribution and all its 103 | Affiliates. 104 | 105 | Affiliates means entities that control, are controlled by, or are under 106 | common control with the acting entity under this License, ‘control’ means 107 | direct or indirect ownership of at least fifty percent (50%) of the voting 108 | power, capital or other securities of controlled or commonly controlled 109 | entity. 110 | 111 | 1. Grant of Copyright License 112 | 113 | Subject to the terms and conditions of this License, each Contributor hereby 114 | grants to you a perpetual, worldwide, royalty-free, non-exclusive, 115 | irrevocable copyright license to reproduce, use, modify, or distribute its 116 | Contribution, with modification or not. 117 | 118 | 2. Grant of Patent License 119 | 120 | Subject to the terms and conditions of this License, each Contributor hereby 121 | grants to you a perpetual, worldwide, royalty-free, non-exclusive, 122 | irrevocable (except for revocation under this Section) patent license to 123 | make, have made, use, offer for sale, sell, import or otherwise transfer its 124 | Contribution, where such patent license is only limited to the patent claims 125 | owned or controlled by such Contributor now or in future which will be 126 | necessarily infringed by its Contribution alone, or by combination of the 127 | Contribution with the Software to which the Contribution was contributed. 128 | The patent license shall not apply to any modification of the Contribution, 129 | and any other combination which includes the Contribution. If you or your 130 | Affiliates directly or indirectly institute patent litigation (including a 131 | cross claim or counterclaim in a litigation) or other patent enforcement 132 | activities against any individual or entity by alleging that the Software or 133 | any Contribution in it infringes patents, then any patent license granted to 134 | you under this License for the Software shall terminate as of the date such 135 | litigation or activity is filed or taken. 136 | 137 | 3. No Trademark License 138 | 139 | No trademark license is granted to use the trade names, trademarks, service 140 | marks, or product names of Contributor, except as required to fulfill notice 141 | requirements in section 4. 142 | 143 | 4. Distribution Restriction 144 | 145 | You may distribute the Software in any medium with or without modification, 146 | whether in source or executable forms, provided that you provide recipients 147 | with a copy of this License and retain copyright, patent, trademark and 148 | disclaimer statements in the Software. 149 | 150 | 5. Disclaimer of Warranty and Limitation of Liability 151 | 152 | THE SOFTWARE AND CONTRIBUTION IN IT ARE PROVIDED WITHOUT WARRANTIES OF ANY 153 | KIND, EITHER EXPRESS OR IMPLIED. IN NO EVENT SHALL ANY CONTRIBUTOR OR 154 | COPYRIGHT HOLDER BE LIABLE TO YOU FOR ANY DAMAGES, INCLUDING, BUT NOT 155 | LIMITED TO ANY DIRECT, OR INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING 156 | FROM YOUR USE OR INABILITY TO USE THE SOFTWARE OR THE CONTRIBUTION IN IT, NO 157 | MATTER HOW IT’S CAUSED OR BASED ON WHICH LEGAL THEORY, EVEN IF ADVISED OF 158 | THE POSSIBILITY OF SUCH DAMAGES. 159 | 160 | 6. Language 161 | 162 | THIS LICENSE IS WRITTEN IN BOTH CHINESE AND ENGLISH, AND THE CHINESE VERSION 163 | AND ENGLISH VERSION SHALL HAVE THE SAME LEGAL EFFECT. IN THE CASE OF 164 | DIVERGENCE BETWEEN THE CHINESE AND ENGLISH VERSIONS, THE CHINESE VERSION 165 | SHALL PREVAIL. 166 | 167 | END OF THE TERMS AND CONDITIONS 168 | 169 | How to Apply the Mulan Permissive Software License,Version 2 170 | (Mulan PSL v2) to Your Software 171 | 172 | To apply the Mulan PSL v2 to your work, for easy identification by 173 | recipients, you are suggested to complete following three steps: 174 | 175 | i. Fill in the blanks in following statement, including insert your software 176 | name, the year of the first publication of your software, and your name 177 | identified as the copyright owner; 178 | 179 | ii. Create a file named "LICENSE" which contains the whole context of this 180 | License in the first directory of your software package; 181 | 182 | iii. Attach the statement to the appropriate annotated syntax at the 183 | beginning of each source file. 184 | 185 | Copyright (c) [Year] [name of copyright holder] 186 | [Software Name] is licensed under Mulan PSL v2. 187 | You can use this software according to the terms and conditions of the Mulan 188 | PSL v2. 189 | You may obtain a copy of Mulan PSL v2 at: 190 | http://license.coscl.org.cn/MulanPSL2 191 | THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY 192 | KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO 193 | NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. 194 | See the Mulan PSL v2 for more details. 195 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Star History 2 | 3 | [![Star History Chart](https://api.star-history.com/svg?repos=xjxjin/alist-sync&type=Date)](https://star-history.com/#xjxjin/alist-sync&Date) 4 | # Alist-Sync 5 | 6 | 一个基于 Web 界面的 Alist 存储同步工具,支持多任务管理、定时同步、差异处理等功能。 7 | 8 |
9 | 10 | [![github tag][gitHub-tag-image]][github-url] [![docker pulls][docker-pulls-image]][docker-url] [![docker image size][docker-image-size-image]][docker-url] 11 | **如果好用,请Star!非常感谢!** [GitHub](https://github.com/xjxjin/alist-sync) [Gitee](https://gitee.com/xjxjin/alist-sync) [DockerHub](https://hub.docker.com/r/xjxjin/alist-sync) 12 | --- 13 | 14 | [gitHub-tag-image]: https://img.shields.io/github/v/tag/xjxjin/alist-sync 15 | [docker-pulls-image]: https://img.shields.io/docker/pulls/xjxjin/alist-sync 16 | [docker-image-size-image]: https://img.shields.io/docker/image-size/xjxjin/alist-sync 17 | [github-url]: https://github.com/xjxjin/alist-sync 18 | [docker-url]: https://hub.docker.com/r/xjxjin/alist-sync 19 |
20 | 21 | 22 | 23 | ## 功能特点 24 | 25 | - 📱 美观的 Web 管理界面 26 | - 🔄 支持多任务管理 27 | - ⏰ 支持 Cron 定时任务 28 | - 📂 支持数据同步和文件同步两种模式 29 | - 🗑️ 支持多种差异处理策略(保留/移动到回收站/删除) 30 | - 📝 详细的同步日志记录 31 | - 🔒 支持用户认证和密码管理 32 | - 🐳 支持 Docker 部署 33 | - 🐉 支持青龙面板定时任务 34 | 35 | 36 | 37 | 38 | 39 | ## 快速开始 40 | 41 | ### Docker 部署(推荐) 42 | 43 | 1. 创建必要的目录: 44 | 45 | ```bash 46 | mkdir -p /DATA/AppData/alist-sync-web/data 47 | ``` 48 | 49 | 2. 创建 docker-compose.yml: 50 | 51 | ```bash 52 | version: '3' 53 | 54 | services: 55 | alist-sync-web: 56 | image: xjxjin/alist-sync:latest 57 | container_name: alist-sync 58 | restart: unless-stopped 59 | ports: 60 | - "52441:52441" 61 | volumes: 62 | - /DATA/AppData/alist-sync/data:/app/data 63 | environment: 64 | - TZ=Asia/Shanghai 65 | ``` 66 | 67 | 3. 启动服务: 68 | 69 | ```bash 70 | docker-compose up -d 71 | ``` 72 | 73 | 4. 访问 Web 界面: 74 | 75 | http://localhost:52441 76 | 77 | 默认登录账号: 78 | - 用户名:admin 79 | - 密码:admin 80 | 81 | ## 使用说明 82 | 83 | ### 1. 基础配置 84 | 85 | 首次使用需要配置 Alist 的基本连接信息: 86 | - 服务地址:Alist 服务的访问地址 87 | - 用户名:Alist 管理员账号 88 | - 密码:Alist 管理员密码 89 | - 令牌:Alist 令牌 90 | 91 | ### 2. 同步任务配置 92 | 93 | 支持两种同步模式: 94 | 95 | #### 数据同步模式 96 | - 各个网盘之间同目录数据备份 97 | - 选择源存储器和目标存储器 98 | - 配置同步目录 99 | - 支持排除目录 100 | - 支持多目标存储同步 101 | - 参照最后图片 102 | 103 | #### 文件同步模式 104 | - 需要填写全路径 105 | - 手动配置源路径和目标路径 106 | - 支持多个路径对 107 | - 支持排除目录 108 | - 参照最后图片 109 | 110 | #### 文件移动模式 111 | - 需要填写全路径 112 | - 手动配置源路径和目标路径 113 | - 支持多个路径对 114 | - 支持排除目录 115 | - 注:文件移动实现方式是先复制到目标路径,然后在下次自动执行任务时,判断目标路径是否已存在文件,如果存在则删除源路径文件 116 | 117 | 118 | ### 3. 差异处理策略 119 | 120 | 提供三种差异处理方式: 121 | - 不处理:保留目标目录中的差异文件 122 | - 移动到回收站:将差异文件移动到目标存储的回收站(trash) 123 | - 删除:直接删除目标目录中的差异文件 124 | - 移动/删除 在有的存储源会失效欢迎提交Issue,我反馈到 Alist 作者 125 | 126 | ### 4. 定时任务 127 | 128 | - 支持 Cron 表达式配置定时任务 129 | - 可查看未来 5 次执行时间 130 | - 支持立即执行功能 131 | 132 | ### 5. 日志查看 133 | 134 | - 支持查看当前日志 135 | - 支持查看历史日志 136 | - 日志自动按天切割 137 | 138 | ## 配置文件说明 139 | 140 | 所有配置文件存储在 `data/config` 目录: 141 | - `alist_sync_base_config.json`:基础连接配置 142 | - `alist_sync_sync_config.json`:同步任务配置 143 | - `alist_sync_users_config.json`:用户认证配置 144 | 145 | 日志文件存储在 `data/log` 目录: 146 | - `alist_sync.log`:当前日志 147 | - `alist_sync.log.YYYY-MM-DD`:历史日志 148 | 149 | ## 注意事项 150 | 151 | 1. 首次使用请修改默认登录密码 152 | 2. 建议定期备份配置文件 153 | 3. 请确保 Alist 服务正常可访问 154 | 4. 建议先测试连接再保存配置 155 | 5. 可以通过日志查看同步执行情况 156 | 157 | ## 青龙使用 158 | 159 |
160 | 点击这里展开/折叠内容 161 | 162 | ### 参数 163 | 164 | ```bash 165 | BASE_URL: 服务器基础URL(结尾不带/) 166 | USERNAME: 用户名 167 | PASSWORD: 密码 168 | TOKEN: 令牌 169 | DIR_PAIRS: 源目录和目标目录的配对(源目录和目标目录的配对,用分号隔开,冒号分隔) 170 | CRON_SCHEDULE: 调度日期,参考cron语法 "分 时 日 月 周" 非必填,不填为一次调度 171 | --以下参数用于目标目录有,但源目录不存在的文件处理,可选参数-- 172 | SYNC_DELETE_ACTION: 同步删除动作,可选值为move,delete。 173 | 当SYNC_DELETE_ACTION设置为move时,文件将移动到trash目录下;比如存储器目录为 /dav/quark,则源目录多余的文件将会移动到/dav/quark/trash 目录下 174 | EXCLUDE_DIRS: 排除目录 175 | MOVE_FILE: 是否移动文件,会删除源目录,且与SYNC_DELETE_ACTION 不能同时生效 176 | REGEX_PATTERNS: 用于匹配文件名的正则表达式 177 | 178 | ``` 179 | 180 | 国内执行 181 | 182 | ```bash 183 | ql raw https://gitee.com/xjxjin/alist-sync/raw/main/alist-sync-ql.py 184 | ``` 185 | 国际执行 186 | 187 | ```bash 188 | ql raw https://github.com/xjxjin/alist-sync/raw/main/alist-sync-ql.py 189 | ``` 190 | 191 |
192 | 193 | ## 更新记录 194 | ### v1.1.5 195 | - 2025-03-15 196 | - 修复正则表达式为空报错问题 197 | 198 | ### v1.1.4 199 | - 2025-02-21 200 | - 修复正则表达式为空报错问题 201 | 202 | ### v1.1.3 203 | - 2025-02-18 204 | - 新增正则表达式功能 205 | - 优化版本号展示 206 | 207 | ### v1.1.2 208 | - 2025-02-08 209 | - 优化文件移动模式下保留源目录 210 | 211 | ### v1.1.1 212 | - 2025-02-06 213 | - 修复 docker 镜像打包文件缺失 214 | 215 | ### v1.1.0 216 | - 2025-02-06 217 | - 新增文件移动功能,由【[kuke2733](https://github.com/kuke2733)】小哥提供 218 | - 新增版本号展示 219 | - 执行前会重新执行失败任务 220 | - 执行中排除已创建任务文件 221 | - 修复排除目录会在目标目录创建 bug 222 | 223 | ### v1.0.8 224 | - 2025-01-09 225 | - 修复源目录不存在 bug 226 | - 修复删除模式下目标目录为空判断报错异常 227 | - 修复页面刷新任务展示异常 228 | 229 | ### v1.0.7 230 | - 2025-01-08 231 | - 新增令牌验证 232 | - 新增导入导出配置文件功能 233 | - 修复登录后无法显示存储器下拉列表 234 | - 修改配置文件以 alist_sync开头 235 | 236 | ### v1.0.6 237 | - 2025-01-07 238 | - 在删除模式下,修复源目录为空,目标目录多余文件不能正确删除问题 239 | - 简单适配移动端 UI 240 | 241 | ### v1.0.5 242 | - 2025-01-05 243 | - 初始UI版本发布 244 | - 支持基础的同步功能 245 | - 支持 Web 界面管理 246 | 247 | 248 | ### 2024-12-16更新 249 | - 当源文件和目标文件大小不一致时,如果目标文件修改时间晚于源文件,则跳过覆盖 250 | 251 | ### 2024-11-13更新 252 | 253 | - 修复删除目标目录多余文件重复删除问题 254 | - 优化移动目标目录多余文件到存储器根目录 255 | - 优化设置多目录,一个目录失败导致所有目录失败问题 256 | 257 | 258 | ### 2024-09-06更新 259 | - 新增参数,处理目标目录有多的文件或者文件夹,但是源目录没有的处理方式,功能由【[RWDai](https://github.com/RWDai)】小哥提供 260 | - none 什么也不做 261 | - move 移动到目标目录下的trash目录 262 | - delete 真实删除 263 | 264 | ### 2024-06-29更新 265 | - 新增DIR_PAIRS参数个数,最多到50,参数内容和之前一致(源目录和目标目录的配对(源目录和目标目录的配对,用分号隔开,冒号分隔)),参数格式为 266 | - ```bash 267 | DIR_PAIRS 268 | DIR_PAIRS1 269 | DIR_PAIRS2 270 | DIR_PAIRS3 271 | ..... 272 | DIR_PAIRS50 273 | ``` 274 | 275 | ### 2024-05-23更新 276 | - 新增青龙调度 277 | 278 | ### 2024-05-13更新 279 | - 1.新增文件存在判断逻辑 280 | - 文件名称 281 | - 文件大小 282 | - 2.CRON_SCHEDULE 变更为参数可选 283 | - 当参数不传变更为一次调度,可以配合青龙远程调度 284 | 285 | 286 | ## 问题反馈 287 | 288 | 如果您在使用过程中遇到任何问题,请提交 Issue。 289 | 290 | 291 | ## 警告 292 | * **在两个目录相互备份的情况下使用删除功能时请格外谨慎。可能导致文件永久丢失,后果自负。** 293 | 294 | 295 | 296 | ## Tips 297 | - 前端页面均为 AI 生成,使用过程中可能有小瑕疵,欢迎前端大神提交代码修复 298 | - 初次使用,保存基础配置后,可以点击添加任务,刷新源存储器和目标存储器下拉列表 299 | - 如果忘记密码,请删除data/config/alist_sync_users_config.json 文件,会默认变成 admin/admin 300 | - 令牌从 Alist 的 管理-设置-其他 获取,获取后不要重置令牌 301 | - 有其他新增功能欢迎提交 Issue。 302 | - 文件同步填写全目录,参照最后面图片 303 | - 如果无法获取docker镜像,可以参考以下脚本换源,国内执行如下代码 304 | ```bash 305 | bash <(curl -sSL https://gitee.com/xjxjin/scripts/raw/main/check_docker_registry.sh) 306 | ``` 307 | - 国际执行如下代码 308 | ```bash 309 | bash <(curl -sSL https://github.com/xjxjin/scripts/raw/main/check_docker_registry.sh) 310 | ``` 311 | 312 | 313 | ## License 314 | 315 | MIT License 316 | 317 | 318 | ## 数据同步 319 | 数据同步 320 | 321 | ## 文件同步 322 | 文件同步 323 | 324 | ## 文件移动 325 | 文件移动 326 | 327 | ## 令牌获取 328 | 令牌获取 329 | 330 | ## 查看任务进度 331 | 查看任务进度 332 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | ### v1.1.5 2 | - 2025-03-15 3 | - 修复正则表达式为空报错问题 -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 1.1.5 -------------------------------------------------------------------------------- /action.txt: -------------------------------------------------------------------------------- 1 | 触发action 2 | 1 3 | 2 4 | -------------------------------------------------------------------------------- /alist-sync-ql.py: -------------------------------------------------------------------------------- 1 | import http.client 2 | import json 3 | import re 4 | from datetime import datetime, timedelta 5 | import os 6 | import logging 7 | from typing import List, Dict, Optional, Union 8 | from logging.handlers import TimedRotatingFileHandler 9 | from typing import List, Tuple, Pattern 10 | 11 | 12 | def setup_logger(): 13 | """配置日志记录器""" 14 | # 获取当前文件所在目录 15 | current_dir = os.path.dirname(os.path.abspath(__file__)) 16 | # 创建日志目录 17 | log_dir = os.path.join(current_dir, 'data/log') 18 | os.makedirs(log_dir, exist_ok=True) 19 | 20 | # 设置日志文件路径 21 | log_file = os.path.join(log_dir, 'alist_sync.log') 22 | 23 | # 创建 TimedRotatingFileHandler 24 | file_handler = TimedRotatingFileHandler( 25 | filename=log_file, 26 | when='midnight', 27 | interval=1, 28 | backupCount=7, 29 | encoding='utf-8' 30 | ) 31 | 32 | # 创建控制台处理器 33 | console_handler = logging.StreamHandler() 34 | 35 | # 设置日志格式 36 | formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') 37 | file_handler.setFormatter(formatter) 38 | console_handler.setFormatter(formatter) 39 | 40 | # 配置根日志记录器 41 | logger = logging.getLogger() 42 | logger.setLevel(logging.INFO) 43 | 44 | # 避免重复添加处理器 45 | if not logger.handlers: 46 | logger.addHandler(file_handler) 47 | logger.addHandler(console_handler) 48 | 49 | return logger 50 | 51 | 52 | # 初始化日志记录器 53 | logger = setup_logger() 54 | 55 | 56 | def parse_time_and_adjust_utc(date_str: str) -> datetime: 57 | """ 58 | 解析时间字符串,如果是UTC格式(包含'Z')则加8小时 59 | """ 60 | iso_8601_pattern = r'(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d+)?([+-]\d{2}:\d{2}|Z)?' 61 | match_iso = re.match(iso_8601_pattern, date_str) 62 | if match_iso: 63 | year, month, day, hour, minute, second, microsecond, timezone = match_iso.groups() 64 | if microsecond: 65 | microsecond = int(float(microsecond) * 1000000) 66 | else: 67 | microsecond = 0 68 | dt = datetime(int(year), int(month), int(day), int(hour), int(minute), int(second), microsecond) 69 | if timezone == "Z": 70 | dt = dt + timedelta(hours=8) # UTC时间加8小时 71 | elif timezone: 72 | # 处理其他时区偏移量 73 | sign = 1 if timezone[0] == "+" else -1 74 | hours = int(timezone[1:3]) 75 | minutes = int(timezone[4:6]) 76 | offset = timedelta(hours=sign * hours, minutes=sign * minutes) 77 | dt = dt - offset 78 | return dt 79 | return None 80 | 81 | 82 | class AlistSync: 83 | def __init__(self, base_url: str, username: str = None, password: str = None, token: str = None, 84 | sync_delete_action: str = "none", exclude_list: List[str] = None, move_file_action: bool = False, 85 | regex_patterns_list=None, regex_pattern=None, 86 | task_list: List[str] = None): 87 | """初始化AlistSync类""" 88 | if regex_patterns_list is None: 89 | regex_patterns_list = [] 90 | self.base_url = base_url 91 | self.username = username 92 | self.password = password 93 | self.token = token # 添加token属性 94 | self.sync_delete_action = sync_delete_action.lower() 95 | self.sync_delete = self.sync_delete_action in ["move", "delete"] 96 | self.connection = self._create_connection() 97 | self.task_list = task_list 98 | self.exclude_list = exclude_list 99 | self.move_file_action = move_file_action 100 | self.regex_patterns_list = regex_patterns_list 101 | self.regex_pattern = regex_pattern 102 | 103 | def _create_connection(self) -> Union[http.client.HTTPConnection, http.client.HTTPSConnection]: 104 | """创建HTTP(S)连接""" 105 | try: 106 | match = re.match(r"(?:http[s]?://)?([^:/]+)(?::(\d+))?", self.base_url) 107 | if not match: 108 | raise ValueError("Invalid base URL format") 109 | 110 | host = match.group(1) 111 | port_part = match.group(2) 112 | port = int(port_part) if port_part else (443 if self.base_url.startswith("https://") else 80) 113 | 114 | logger.info(f"创建连接 - 主机: {host}, 端口: {port}") 115 | return (http.client.HTTPSConnection(host, port) 116 | if self.base_url.startswith("https://") 117 | else http.client.HTTPConnection(host, port)) 118 | except Exception as e: 119 | logger.error(f"创建连接失败: {str(e)}") 120 | raise 121 | 122 | def _make_request(self, method: str, path: str, headers: Dict = None, 123 | payload: str = None) -> Optional[Dict]: 124 | """发送HTTP请求并返回JSON响应""" 125 | try: 126 | logger.debug(f"发送请求 - 方法: {method}, 路径: {path}") 127 | self.connection.request(method, path, body=payload, headers=headers) 128 | response = self.connection.getresponse() 129 | result = json.loads(response.read().decode("utf-8")) 130 | logger.debug(f"请求响应: {result}") 131 | return result 132 | except Exception as e: 133 | logger.error(f"请求失败 - 方法: {method}, 路径: {path}, 错误: {str(e)}") 134 | return None 135 | 136 | def login(self) -> bool: 137 | """登录并获取token""" 138 | # 如果已有token,直接返回True 139 | if self.token and self.get_setting(): 140 | return True 141 | 142 | # 否则使用用户名密码登录 143 | if not self.username or not self.password: 144 | logger.error("token或用户名密码不正确") 145 | return False 146 | 147 | payload = json.dumps({"username": self.username, "password": self.password}) 148 | headers = { 149 | "User-Agent": "Apifox/1.0.0 (https://apifox.com)", 150 | "Content-Type": "application/json" 151 | } 152 | response = self._make_request("POST", "/api/auth/login", headers, payload) 153 | if response and response.get("data", {}).get("token"): 154 | self.token = response["data"]["token"] 155 | logger.info("令牌验证成功") 156 | return True 157 | logger.error("获取token失败") 158 | return False 159 | 160 | def get_setting(self) -> bool: 161 | """验证令牌正确性""" 162 | headers = { 163 | "User-Agent": "Apifox/1.0.0 (https://apifox.com)", 164 | "Content-Type": "application/json", 165 | "Authorization": self.token 166 | } 167 | response = self._make_request("GET", "/api/admin/setting/list", headers) 168 | if response and response.get("data", {}): 169 | token_value = None 170 | for item in response["data"]: 171 | if item["key"] == "token": 172 | token_value = item["value"] 173 | logger.info("令牌验证成功") 174 | break 175 | if self.token == token_value: 176 | logger.info("令牌验证成功") 177 | return True 178 | logger.info("令牌验证失败") 179 | return False 180 | 181 | def _directory_operation(self, operation: str, **kwargs) -> Optional[Dict]: 182 | """执行目录操作""" 183 | if not self.token: 184 | if not self.login(): 185 | return None 186 | 187 | headers = { 188 | "Authorization": self.token, 189 | "User-Agent": "Apifox/1.0.0 (https://apifox.com)", 190 | "Content-Type": "application/json" 191 | } 192 | payload = json.dumps(kwargs) 193 | path = f"/api/fs/{operation}" 194 | return self._make_request("POST", path, headers, payload) 195 | 196 | def _task_operation(self, method: str, operation: str, **kwargs) -> Optional[Dict]: 197 | """执行任务操作""" 198 | if not self.token: 199 | if not self.login(): 200 | return None 201 | 202 | headers = { 203 | "Authorization": self.token, 204 | "User-Agent": "Apifox/1.0.0 (https://apifox.com)", 205 | "Content-Type": "application/json" 206 | } 207 | payload = json.dumps(kwargs) 208 | path = f"/api/admin/task/{operation}" 209 | return self._make_request(method, path, headers, payload) 210 | 211 | def get_copy_task_undone(self): 212 | """获取未完成的复制任务""" 213 | response = self._task_operation("GET", "copy/undone") 214 | name_list = [] 215 | if response and response.get("data", []): 216 | for item in response["data"]: 217 | name = item["name"] 218 | try: 219 | result = name.replace("](", "") 220 | name_list.append(result) 221 | except IndexError: 222 | print(f"字符串 '{name}' 未找到匹配的切分字符串。") 223 | self.task_list = name_list 224 | return True 225 | # return name_list 226 | 227 | def get_copy_task_retry_failed(self) -> List[Dict]: 228 | """获取已完成的复制任务""" 229 | response = self._task_operation("POST", "copy/retry_failed") 230 | return response.get("data", []) if response else [] 231 | 232 | def get_copy_task_done(self) -> List[Dict]: 233 | """获取已完成的复制任务""" 234 | response = self._task_operation("GET", "copy/done") 235 | return response.get("data", []) if response else [] 236 | 237 | def get_directory_contents(self, directory_path: str) -> List[Dict]: 238 | """获取目录内容""" 239 | response = self._directory_operation("list", path=directory_path) 240 | return response.get("data", {}).get("content", []) if response else [] 241 | 242 | def create_directory(self, directory_path: str) -> bool: 243 | """创建目录""" 244 | response = self._directory_operation("mkdir", path=directory_path) 245 | if response: 246 | logger.info(f"文件夹【{directory_path}】创建成功") 247 | return True 248 | logger.error("文件夹创建失败") 249 | return False 250 | 251 | def remove_empty_directory(self, directory_path: str) -> bool: 252 | """删除空文件夹""" 253 | response = self._directory_operation("remove_empty_directory", src_dir=directory_path) 254 | if response: 255 | logger.info(f"删除空文件夹【{directory_path}】成功") 256 | return True 257 | logger.error("删除空文件夹失败") 258 | return False 259 | 260 | # 递归删除空文件夹 261 | def _remove_empty_folders(self, base_dir: str, src_dir: str): 262 | if base_dir in src_dir and self.is_path_exists(src_dir): 263 | src_contents = self.get_directory_contents(src_dir) 264 | if src_contents: 265 | for item in src_contents: 266 | if item.get('is_dir', False): 267 | name = item.get('name', '未知项目') 268 | src_path = f"{src_dir}/{name}" 269 | self._remove_empty_folders(base_dir, src_path) 270 | else: 271 | # 文件移动的情况下删除空文件夹 272 | if base_dir != src_dir: 273 | # 找到最后一个 / 的索引 274 | last_slash_index = src_dir.rfind('/') 275 | # 分割字符串 276 | remove_dir = src_dir[:last_slash_index] 277 | remove_names = src_dir[last_slash_index + 1:] 278 | self._directory_operation("remove", dir=remove_dir, names=[remove_names]) 279 | logger.info(f"删除空文件夹【{src_dir}】成功") 280 | self._remove_empty_folders(base_dir, remove_dir) 281 | 282 | def _copy_item(self, src_dir: str, dst_dir: str, item_name: str) -> bool: 283 | """复制文件或目录""" 284 | response = self._directory_operation("copy", 285 | src_dir=src_dir, 286 | dst_dir=dst_dir, 287 | names=[item_name]) 288 | if response: 289 | logger.info(f"文件【{item_name}】复制成功") 290 | return True 291 | logger.error("文件复制失败") 292 | return False 293 | 294 | def _move_item(self, src_dir: str, dst_dir: str, item_name: str) -> bool: 295 | """移动文件或目录""" 296 | response = self._directory_operation("move", 297 | src_dir=src_dir, 298 | dst_dir=dst_dir, 299 | names=[item_name]) 300 | if response: 301 | logger.info(f"文件从【{src_dir}/{item_name}】移动到【{dst_dir}/{item_name}】移动成功") 302 | return True 303 | logger.error("文件移动失败") 304 | return False 305 | 306 | def is_path_exists(self, path: str) -> bool: 307 | """检查路径是否存在""" 308 | response = self._directory_operation("get", path=path) 309 | return bool(response and response.get("message") == "success") 310 | 311 | def get_storage_list(self) -> List[str]: 312 | """获取存储列表""" 313 | if not self.token: 314 | if not self.login(): 315 | return [] 316 | 317 | headers = { 318 | "Authorization": self.token, 319 | "User-Agent": "Apifox/1.0.0 (https://apifox.com)", 320 | "Content-Type": "application/json" 321 | } 322 | response = self._make_request("GET", "/api/admin/storage/list", headers) 323 | if response: 324 | storage_list = response["data"]["content"] 325 | return [item["mount_path"] for item in storage_list] 326 | logger.error("获取存储列表失败") 327 | return [] 328 | 329 | def check_regex(self, path: str) -> bool: 330 | if self.regex_patterns_list: 331 | for regex in self.regex_patterns_list: 332 | if regex.match(path): 333 | return True 334 | if self.regex_pattern and self.regex_pattern.match(path): 335 | return True 336 | return False 337 | 338 | def sync_directories(self, src_dir: str, dst_dir: str) -> bool: 339 | """同步两个目录""" 340 | try: 341 | 342 | # 重试已失败任务 343 | self.get_copy_task_retry_failed() 344 | # 获取正在运行任务 345 | self.get_copy_task_undone() 346 | 347 | logger.info(f"开始同步目录 - 源目录: {src_dir}, 目标目录: {dst_dir}") 348 | if not self.is_path_exists(src_dir): 349 | logger.error(f"源目录【{src_dir}】不存在,停止同步") 350 | return False 351 | result = self._recursive_copy(src_dir, dst_dir) 352 | # 递归删除空文件夹 353 | if self.move_file_action: 354 | self._remove_empty_folders(src_dir, src_dir) 355 | 356 | logger.info(f"目录同步完成 - 源目录: {src_dir}, 目标目录: {dst_dir}, 结果: {'成功' if result else '失败'}") 357 | return result 358 | except Exception as e: 359 | logger.error(f"同步目录失败: {str(e)}") 360 | return False 361 | 362 | def _recursive_copy(self, src_dir: str, dst_dir: str) -> bool: 363 | """递归复制目录内容""" 364 | try: 365 | if src_dir in self.exclude_list: 366 | logger.info(f"排除目录: {src_dir}, 跳过同步") 367 | return True 368 | else: 369 | logger.info(f"开始递归复制 - 源目录: {src_dir}, 目标目录: {dst_dir}") 370 | src_contents = self.get_directory_contents(src_dir) 371 | if not src_contents: 372 | logger.info(f"源目录为空或获取内容失败: {src_dir}") 373 | # return True 374 | 375 | if self.sync_delete: 376 | self._handle_sync_delete(src_dir, dst_dir, src_contents) 377 | if src_contents: 378 | for item in src_contents: 379 | if not self._copy_item_with_check(src_dir, dst_dir, item): 380 | logger.error(f"复制项目失败: {item.get('name', '未知项目')}") 381 | return False 382 | logger.info(f"递归复制完成 - 源目录: {src_dir}, 目标目录: {dst_dir}") 383 | return True 384 | except Exception as e: 385 | logger.error(f"递归复制失败: {str(e)}") 386 | return False 387 | 388 | def _handle_sync_delete(self, src_dir: str, dst_dir: str, src_contents: List[Dict]): 389 | """处理同步删除逻辑""" 390 | try: 391 | dst_contents = self.get_directory_contents(dst_dir) 392 | src_names = {} 393 | if src_contents: 394 | src_names = {item["name"] for item in src_contents} 395 | 396 | dst_names = {} 397 | if dst_contents: 398 | dst_names = {item["name"] for item in dst_contents} 399 | 400 | if src_names: 401 | to_delete = set(dst_names) - set(src_names) 402 | else: 403 | to_delete = dst_names 404 | 405 | if not to_delete: 406 | logger.info("没有需要删除的项目") 407 | return 408 | 409 | for name in to_delete: 410 | if self.sync_delete_action == "move": 411 | logger.info(f"处理同步移动 - 目录: {dst_dir}") 412 | logger.info(f"处理移动项目: {name}") 413 | trash_dir = self._get_trash_dir(dst_dir) 414 | if trash_dir: 415 | if not self.is_path_exists(trash_dir): 416 | logger.info(f"创建回收站目录: {trash_dir}") 417 | self.create_directory(trash_dir) 418 | logger.info(f"移动到回收站: {name}") 419 | self._move_item(dst_dir, trash_dir, name) 420 | else: # delete 421 | logger.info(f"处理同步删除 - 目录: {dst_dir}") 422 | logger.info(f"处理删除项目: {name}") 423 | logger.info(f"直接删除项目: {name}") 424 | self._directory_operation("remove", dir=dst_dir, names=[name]) 425 | except Exception as e: 426 | logger.error(f"处理同步删除失败: {str(e)}") 427 | 428 | def _get_trash_dir(self, dst_dir: str) -> Optional[str]: 429 | """获取回收站目录路径""" 430 | storage_list = self.get_storage_list() 431 | for mount_path in storage_list: 432 | if dst_dir.startswith(mount_path): 433 | return f"{mount_path}/trash{dst_dir[len(mount_path):]}" 434 | return None 435 | 436 | def close(self): 437 | """关闭连接""" 438 | try: 439 | if hasattr(self, 'connection') and self.connection: 440 | self.connection.close() 441 | logger.debug("连接已关闭") 442 | except Exception as e: 443 | logger.error(f"关闭连接时发生错误: {str(e)}") 444 | 445 | def get_file_info(self, path: str) -> Optional[Dict]: 446 | """获取文件信息,包括大小和修改时间""" 447 | response = self._directory_operation("get", path=path) 448 | if response and response.get("message") == "success": 449 | return response.get("data", {}) 450 | return None 451 | 452 | def _copy_item_with_check(self, src_dir: str, dst_dir: str, item: Dict) -> bool: 453 | """复制项目并进行检查""" 454 | try: 455 | item_name = item.get('name') 456 | if not item_name: 457 | logger.error("项目名称为空") 458 | return False 459 | 460 | logger.info(f"处理项目: {item_name}") 461 | if src_dir in self.exclude_list: 462 | logger.info(f"排除目录: {src_dir}, 跳过同步") 463 | return True 464 | 465 | # 处理文件 466 | src_path = f"{src_dir}/{item_name}".replace('//', '/') 467 | dst_path = f"{dst_dir}/{item_name}".replace('//', '/') 468 | 469 | # 如果是目录,递归处理 470 | if item.get('is_dir', False): 471 | 472 | # 确保目标子目录存在 473 | if not self.is_path_exists(dst_path): 474 | logger.info(f"创建目标子目录: {dst_path}") 475 | if not self.create_directory(dst_path): 476 | return False 477 | else: 478 | logger.info(f"文件夹【{dst_path}】已存在,跳过创建") 479 | 480 | # 递归复制子目录 481 | return self._recursive_copy(src_path, dst_path) 482 | else: 483 | 484 | # 判断正则表达式,如果符合正则表达式跳过复制 485 | if(self.regex_patterns_list or self.regex_pattern): 486 | if not self.check_regex(item_name): 487 | logger.info(f"不符合正则表达式: {src_path}, 跳过同步") 488 | return True 489 | 490 | # 检查是否在未完成的任务列表中,如果存在,则跳过 491 | for task_item in self.task_list: 492 | if src_dir in task_item and dst_dir in task_item and src_path in task_item: 493 | logger.info(f"文件【{item_name}】在未完成的任务列表中,跳过复制") 494 | return True 495 | # 检查目标文件是否存在 496 | if not self.is_path_exists(dst_path): 497 | logger.info(f"复制文件: {item_name}") 498 | return self._copy_item(src_dir, dst_dir, item_name) 499 | else: 500 | # 获取源文件和目标文件信息 501 | src_size = item.get("size") 502 | dst_info = self.get_file_info(dst_path) 503 | 504 | if not dst_info: 505 | logger.error(f"获取目标文件信息失败: {dst_path}") 506 | return False 507 | 508 | dst_size = dst_info.get("size") 509 | 510 | # 比较文件大小 511 | if src_size == dst_size: 512 | logger.info(f"文件【{item_name}】已存在且大小相同,跳过复制") 513 | if self.move_file_action: 514 | if not self._directory_operation("remove", dir=src_dir, names=[item_name]): 515 | logger.error(f"删除源文件失败: {src_path}") 516 | return False 517 | else: 518 | logger.info(f"删除源文件成功: {src_path}") 519 | return True 520 | else: 521 | return True 522 | else: 523 | # 比较修改时间 524 | src_modified = parse_time_and_adjust_utc(item.get("modified")) 525 | dst_modified = parse_time_and_adjust_utc(dst_info.get("modified")) 526 | 527 | if src_modified and dst_modified and dst_modified > src_modified: 528 | logger.info(f"文件【{item_name}】目标文件修改时间晚于源文件,跳过复制") 529 | if self.move_file_action: 530 | if not self._directory_operation("remove", dir=src_dir, names=[item_name]): 531 | logger.error(f"删除源文件失败: {src_dir}") 532 | return False 533 | else: 534 | logger.error(f"删除源文件: {src_dir}") 535 | return True 536 | else: 537 | return True 538 | else: 539 | logger.info(f"文件【{item_name}】存在变更,删除并重新复制") 540 | # 删除旧文件 541 | if not self._directory_operation("remove", dir=dst_dir, names=[item_name]): 542 | logger.error(f"删除目标文件失败: {dst_path}") 543 | return False 544 | # 复制新文件 545 | return self._copy_item(src_dir, dst_dir, item_name) 546 | except Exception as e: 547 | logger.error(f"复制项目时发生错误: {str(e)}") 548 | return False 549 | 550 | 551 | def get_dir_pairs_from_env() -> List[str]: 552 | """从环境变量获取目录对列表""" 553 | dir_pairs_list = [] 554 | 555 | # 获取主DIR_PAIRS 556 | if dir_pairs := os.environ.get("DIR_PAIRS"): 557 | dir_pairs_list.extend(dir_pairs.split(";")) 558 | 559 | # 获取DIR_PAIRS1到DIR_PAIRS50 560 | for i in range(1, 51): 561 | if dir_pairs := os.environ.get(f"DIR_PAIRS{i}"): 562 | dir_pairs_list.extend(dir_pairs.split(";")) 563 | 564 | return dir_pairs_list 565 | 566 | 567 | def main(dir_pairs: str = None, sync_del_action: str = None, exclude_dirs: str = None, move_file: bool = False, 568 | regex_patterns: str = None, ): 569 | """主函数,用于命令行执行""" 570 | code_souce() 571 | xiaojin() 572 | 573 | logger.info("开始执行同步任务") 574 | # 从环境变量获取配置 0 575 | base_url = os.environ.get("BASE_URL") 576 | username = os.environ.get("USERNAME") 577 | password = os.environ.get("PASSWORD") 578 | token = os.environ.get("TOKEN") # 添加token环境变量 579 | 580 | # 是否删除目标目录多余文件 581 | if sync_del_action: 582 | sync_delete_action = sync_del_action 583 | else: 584 | sync_delete_action = os.environ.get("SYNC_DELETE_ACTION", "none") 585 | 586 | # 是否删除源目录 587 | if move_file: 588 | move_file_action = move_file 589 | else: 590 | move_file_action = os.environ.get("MOVE_FILE", "false").lower() == "true" 591 | 592 | # 删除源目录和删除多余目标目录无法同时生效 593 | if move_file_action: 594 | sync_delete_action = "none" 595 | 596 | # 排除目录 597 | if exclude_dirs: 598 | exclude_list = exclude_dirs.split(",") 599 | else: 600 | exclude_dirs = os.environ.get("EXCLUDE_DIRS", "") 601 | exclude_list = exclude_dirs.split(",") 602 | 603 | # 正则表达式 604 | if not regex_patterns: 605 | regex_patterns = os.environ.get("REGEX_PATTERNS", None) 606 | # regex_patterns_list = regex_patterns.split(" ") 607 | # else: 608 | # regex_patterns = os.environ.get("REGEX_PATTERNS", None) 609 | # regex_patterns_list = regex_patterns.split(" ") 610 | 611 | # 初始化一个空列表,用于存储编译后的正则表达式对象 612 | regex_and_replace_list: List[Pattern[str]] = [] 613 | # if regex_patterns_list: 614 | # for pattern_replacement in regex_patterns_list: 615 | # try: 616 | # compiled_pattern = re.compile(pattern_replacement) 617 | # regex_and_replace_list.append(compiled_pattern) 618 | # except re.error as e: 619 | # print(f"正则表达式 {pattern_replacement} 编译失败:{e}") 620 | regex_pattern = None 621 | try: 622 | if regex_patterns: 623 | regex_pattern = re.compile(regex_patterns) 624 | # regex_and_replace_list.append(compiled_pattern) 625 | except re.error as e: 626 | print(f"正则表达式 {regex_patterns} 编译失败:{e}") 627 | 628 | if not base_url: 629 | logger.error("服务地址(BASE_URL)环境变量未设置") 630 | return 631 | 632 | # 修改验证逻辑 633 | if not token and not (username and password): 634 | logger.error("需要设置令牌(TOKEN)或者同时设置用户名(USERNAME)和密码(PASSWORD)") 635 | return 636 | 637 | logger.info( 638 | f"配置信息 - URL: {base_url}, 用户名: {username}, 删除动作: {sync_delete_action}, 删除源目录: {move_file_action}") 639 | 640 | # 创建AlistSync实例时添加token参数 641 | alist_sync = AlistSync(base_url, username, password, token, sync_delete_action, exclude_list, move_file_action, 642 | regex_and_replace_list, regex_pattern) 643 | # 验证 token 是否正确 644 | if not alist_sync.login(): 645 | logger.error("令牌或用户名密码不正确") 646 | return False 647 | try: 648 | # 获取同步目录对 649 | dir_pairs_list = [] 650 | if dir_pairs: 651 | dir_pairs_list.extend(dir_pairs.split(";")) 652 | else: 653 | dir_pairs_list = get_dir_pairs_from_env() 654 | 655 | logger.info(f"") 656 | logger.info(f"") 657 | num = 1 658 | for pair in dir_pairs_list: 659 | logger.info(f"No.{num:02d}【{pair}】") 660 | num += 1 661 | 662 | # 执行同步 663 | i = 1 664 | for pair in dir_pairs_list: 665 | src_dir, dst_dir = pair.split(":") 666 | logger.info(f"") 667 | logger.info(f"") 668 | logger.info(f"") 669 | logger.info(f"") 670 | logger.info(f"") 671 | logger.info(f"第 [{i:02d}] 个 同步目录【{src_dir.strip()}】---->【 {dst_dir.strip()}】") 672 | logger.info(f"") 673 | logger.info(f"") 674 | i += 1 675 | alist_sync.sync_directories(src_dir.strip(), dst_dir.strip()) 676 | 677 | logger.info("所有同步任务执行完成") 678 | except Exception as e: 679 | logger.error(f"执行同步任务时发生错误: {str(e)}") 680 | finally: 681 | alist_sync.close() 682 | logger.info("关闭连接,任务结束") 683 | 684 | 685 | def code_souce(): 686 | logger.info("如果好用,请Star!非常感谢! https://gitee.com/xjxjin/alist-sync") 687 | logger.info("如果好用,请Star!非常感谢! https://github.com/xjxjin/alist-sync") 688 | logger.info("如果好用,请Star!非常感谢! https://hub.docker.com/r/xjxjin/alist-sync") 689 | 690 | 691 | def xiaojin(): 692 | pt = """ 693 | 694 | .. 695 | .... 696 | .:----=: 697 | ...:::---==-:::-+. 698 | ..:=====-=+=-::::::== .:-. 699 | .-==*=-:::::::::::::::=*-: .:-=++. 700 | .-==++++-::::::::::::::-++:-==:. .=-=::=-. 701 | ....:::-=-::-++-:::::::::::::::--:::::==: -:.:=..+: 702 | ==-------::::-==-:::::::::::::::::::::::-+-. .=: .:=-.. 703 | ==-::::+-:::::==-:::::::::::::::::::::::::=+.:+- :-: 704 | :--==+*::::::-=-::::::::::::::::::::::::::-*+: .+. 705 | ..-*:::::::==::::::::::::::::::::::::::-+. -+. 706 | -*:::::::-=-:::::::--:::::::::::::::=-. +- 707 | :*::::::::-=::::::-=:::::=:::::::::-: .*. 708 | .+=:::::::::::::::-::::-*-::......:: -- 709 | :+::-:::::::::::::::::*=:-::...... -. 710 | :-:-===-:::::::::::.:+==--:...... .+. 711 | .==:...-+#+::....... . ....... .=- 712 | -*.....::............::-. ...=- 713 | .==-:.. :=-::::::=. ..:+- 714 | .:--===---=-:::-:::--:. ..:+: 715 | =--+=:+*+:. ...... ..-+. 716 | .#. .+#- .:. .::=: 717 | -=:.-: ..::-. 718 | .-=. xjxjin ...:-: 719 | ... ...:- 720 | 721 | 722 | 723 | """ 724 | logger.info(pt) 725 | 726 | 727 | if __name__ == '__main__': 728 | main() 729 | -------------------------------------------------------------------------------- /alist-sync-web.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, render_template, request, jsonify, session, redirect, url_for 2 | import logging 3 | import os 4 | import json 5 | import hashlib 6 | import croniter 7 | import datetime 8 | import time 9 | from functools import wraps 10 | import importlib.util 11 | import sys 12 | from typing import Dict, List, Optional, Any 13 | from apscheduler.schedulers.background import BackgroundScheduler 14 | from apscheduler.triggers.cron import CronTrigger 15 | from logging.handlers import TimedRotatingFileHandler 16 | import shutil 17 | import http.client 18 | import urllib.parse 19 | import re 20 | import socket 21 | 22 | 23 | # 替换 passlib 的密码哈希功能 24 | def hash_password(password: str) -> str: 25 | """使用 SHA-256 哈希密码""" 26 | return hashlib.sha256(password.encode()).hexdigest() 27 | 28 | 29 | def verify_password(password: str, hash: str) -> bool: 30 | """验证密码哈希""" 31 | return hash_password(password) == hash 32 | 33 | 34 | # 创建一个全局的调度器 35 | scheduler = BackgroundScheduler() 36 | scheduler.start() 37 | 38 | 39 | def import_from_file(module_name: str, file_path: str) -> Any: 40 | """动态导入模块""" 41 | spec = importlib.util.spec_from_file_location(module_name, file_path) 42 | module = importlib.util.module_from_spec(spec) 43 | sys.modules[module_name] = module 44 | spec.loader.exec_module(module) 45 | return module 46 | 47 | 48 | # 导入AlistSync类 49 | try: 50 | current_dir = os.path.dirname(os.path.abspath(__file__)) 51 | alist_sync = import_from_file('alist_sync', os.path.join(current_dir, 'alist_sync.py')) 52 | AlistSync = alist_sync.AlistSync 53 | except Exception as e: 54 | print(f"导入alist_sync.py失败: {e}") 55 | print(f"当前目录: {current_dir}") 56 | print(f"尝试导入的文件路径: {os.path.join(current_dir, 'alist_sync.py')}") 57 | raise 58 | 59 | app = Flask(__name__) 60 | app.secret_key = os.urandom(24) # 用于session加密 61 | 62 | # 设置日志记录器 63 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') 64 | logger = logging.getLogger(__name__) 65 | 66 | # 假设配置数据存储在当前目录下的config_data目录中,你可以根据实际需求修改 67 | STORAGE_DIR = os.path.join(app.root_path, 'data/config') 68 | if not os.path.exists(STORAGE_DIR): 69 | os.makedirs(STORAGE_DIR) 70 | 71 | # 用户配置文件路径 72 | USER_CONFIG_FILE = os.path.join(os.path.dirname(__file__), STORAGE_DIR, 'alist_sync_users_config.json') 73 | 74 | # 确保配置目录存在 75 | os.makedirs(os.path.dirname(USER_CONFIG_FILE), exist_ok=True) 76 | 77 | # 如果用户配置文件不存在,创建默认配置 78 | if not os.path.exists(USER_CONFIG_FILE): 79 | default_config = { 80 | "users": [ 81 | { 82 | "username": "admin", 83 | "password": hash_password("admin") # 使用新的哈希函数 84 | } 85 | ] 86 | } 87 | with open(USER_CONFIG_FILE, 'w', encoding='utf-8') as f: 88 | json.dump(default_config, f, indent=2, ensure_ascii=False) 89 | 90 | # 添加版本配置文件路径常量 91 | VERSION_CONFIG_FILE = os.path.join(os.path.dirname(__file__), STORAGE_DIR, 'alist_sync_version.json') 92 | 93 | # 确保配置目录存在 94 | os.makedirs(os.path.dirname(VERSION_CONFIG_FILE), exist_ok=True) 95 | 96 | # 如果版本配置文件不存在,创建默认配置 97 | if not os.path.exists(VERSION_CONFIG_FILE): 98 | default_version_config = { 99 | "latest_version": "", 100 | "update_time": "", 101 | "source": "github" 102 | } 103 | with open(VERSION_CONFIG_FILE, 'w', encoding='utf-8') as f: 104 | json.dump(default_version_config, f, indent=2, ensure_ascii=False) 105 | 106 | 107 | def load_users(): 108 | """加载用户配置""" 109 | try: 110 | with open(USER_CONFIG_FILE, 'r', encoding='utf-8') as f: 111 | return json.load(f) 112 | except Exception as e: 113 | print(f"加载用户配置失败: {e}") 114 | return {"users": []} 115 | 116 | 117 | def save_users(config): 118 | """保存用户配置""" 119 | try: 120 | with open(USER_CONFIG_FILE, 'w', encoding='utf-8') as f: 121 | json.dump(config, f, indent=2, ensure_ascii=False) 122 | return True 123 | except Exception as e: 124 | print(f"保存用户配置失败: {e}") 125 | return False 126 | 127 | 128 | # 登录验证装饰器 129 | def login_required(f): 130 | @wraps(f) 131 | def decorated_function(*args, **kwargs): 132 | if 'user_id' not in session: 133 | return redirect(url_for('login')) 134 | return f(*args, **kwargs) 135 | 136 | return decorated_function 137 | 138 | 139 | # 默认路由重定向到登录页 140 | @app.route('/') 141 | def index(): 142 | if 'user_id' not in session: 143 | return redirect(url_for('login')) 144 | return render_template('index.html') 145 | 146 | 147 | # 登录页面路由 148 | @app.route('/login') 149 | def login(): 150 | return render_template('login.html') 151 | 152 | 153 | # 优化日志配置 154 | def setup_logger(): 155 | """配置日志记录器""" 156 | log_dir = os.path.join(app.root_path, 'data/log') 157 | os.makedirs(log_dir, exist_ok=True) 158 | log_file = os.path.join(log_dir, 'alist_sync.log') 159 | 160 | formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') 161 | 162 | # 文件处理器 163 | file_handler = TimedRotatingFileHandler( 164 | filename=log_file, 165 | when='midnight', 166 | interval=1, 167 | backupCount=7, 168 | encoding='utf-8' 169 | ) 170 | file_handler.setFormatter(formatter) 171 | 172 | # 控制台处理器 173 | console_handler = logging.StreamHandler() 174 | console_handler.setFormatter(formatter) 175 | 176 | # 配置根日志记录器 177 | logger = logging.getLogger() 178 | logger.setLevel(logging.INFO) 179 | logger.handlers.clear() 180 | logger.addHandler(file_handler) 181 | logger.addHandler(console_handler) 182 | 183 | return logger 184 | 185 | 186 | # 优化用户认证相关代码 187 | class UserManager: 188 | def __init__(self, config_file: str): 189 | self.config_file = config_file 190 | self._ensure_config_exists() 191 | 192 | def _ensure_config_exists(self): 193 | """确保用户配置文件存在""" 194 | if not os.path.exists(self.config_file): 195 | default_config = { 196 | "users": [{ 197 | "username": "admin", 198 | "password": hash_password("admin") 199 | }] 200 | } 201 | self.save_config(default_config) 202 | 203 | def load_config(self) -> Dict: 204 | """加载用户配置""" 205 | try: 206 | with open(self.config_file, 'r', encoding='utf-8') as f: 207 | return json.load(f) 208 | except Exception as e: 209 | logger.error(f"加载用户配置失败: {e}") 210 | return {"users": []} 211 | 212 | def save_config(self, config: Dict) -> bool: 213 | """保存用户配置""" 214 | try: 215 | with open(self.config_file, 'w', encoding='utf-8') as f: 216 | json.dump(config, f, indent=2, ensure_ascii=False) 217 | return True 218 | except Exception as e: 219 | logger.error(f"保存用户配置失败: {e}") 220 | return False 221 | 222 | def verify_user(self, username: str, password: str) -> bool: 223 | """验证用户凭据""" 224 | config = self.load_config() 225 | user = next((u for u in config['users'] if u['username'] == username), None) 226 | return user and verify_password(password, user['password']) 227 | 228 | def change_user_password(self, username: str, new_username: str, 229 | old_password: str, new_password: str) -> tuple[bool, str]: 230 | """修改用户密码""" 231 | config = self.load_config() 232 | user = next((u for u in config['users'] if u['username'] == username), None) 233 | 234 | if not user: 235 | return False, "用户不存在" 236 | 237 | if not verify_password(old_password, user['password']): 238 | return False, "原密码错误" 239 | 240 | if username != new_username: 241 | exists_user = next((u for u in config['users'] 242 | if u['username'] == new_username and u != user), None) 243 | if exists_user: 244 | return False, "新用户名已存在" 245 | 246 | user['username'] = new_username 247 | user['password'] = hash_password(new_password) 248 | 249 | if self.save_config(config): 250 | return True, "修改成功" 251 | return False, "保存配置失败" 252 | 253 | 254 | # 创建用户管理器实例 255 | user_manager = UserManager(USER_CONFIG_FILE) 256 | 257 | 258 | # 优化登录接口 259 | @app.route('/api/login', methods=['POST']) 260 | def api_login(): 261 | try: 262 | data = request.get_json() 263 | username = data.get('username') 264 | password = data.get('password') 265 | 266 | if not username or not password: 267 | return jsonify({'code': 400, 'message': '用户名和密码不能为空'}) 268 | 269 | if user_manager.verify_user(username, password): 270 | session['user_id'] = username 271 | return jsonify({'code': 200, 'message': '登录成功'}) 272 | return jsonify({'code': 401, 'message': '用户名或密码错误'}) 273 | 274 | except Exception as e: 275 | logger.error(f"登录失败: {e}") 276 | return jsonify({'code': 500, 'message': '服务器错误'}) 277 | 278 | 279 | # 检查登录状态接口 280 | @app.route('/api/check-login') 281 | def check_login(): 282 | if 'user_id' in session: 283 | return jsonify({'code': 200, 'message': 'logged in'}) 284 | return jsonify({'code': 401, 'message': 'not logged in'}) 285 | 286 | 287 | # 获取当前用户信息接口 288 | @app.route('/api/current-user') 289 | @login_required 290 | def current_user(): 291 | try: 292 | username = session['user_id'] 293 | return jsonify({ 294 | 'code': 200, 295 | 'message': 'success', 296 | 'data': { 297 | 'username': username 298 | } 299 | }) 300 | except Exception as e: 301 | print(f"获取当前用户信息失败: {e}") 302 | return jsonify({'code': 500, 'message': '服务器错误'}) 303 | 304 | 305 | # 优化修改密码接口 306 | @app.route('/api/change-password', methods=['POST']) 307 | @login_required 308 | def change_password(): 309 | try: 310 | data = request.get_json() 311 | if not all(data.get(k) for k in ['oldUsername', 'newUsername', 'oldPassword', 'newPassword']): 312 | return jsonify({'code': 400, 'message': '所有字段都不能为空'}) 313 | 314 | success, message = user_manager.change_user_password( 315 | data['oldUsername'], 316 | data['newUsername'], 317 | data['oldPassword'], 318 | data['newPassword'] 319 | ) 320 | 321 | if success: 322 | if data['oldUsername'] != data['newUsername']: 323 | session['user_id'] = data['newUsername'] 324 | return jsonify({'code': 200, 'message': message}) 325 | return jsonify({'code': 400, 'message': message}) 326 | 327 | except Exception as e: 328 | logger.error(f"修改密码失败: {e}") 329 | return jsonify({'code': 500, 'message': '服务器错误'}) 330 | 331 | 332 | # 登出接口 333 | @app.route('/api/logout') 334 | def logout(): 335 | session.clear() 336 | return jsonify({'code': 200, 'message': 'success'}) 337 | 338 | 339 | # 保存基础连接配置接口 340 | @app.route('/api/save-base-config', methods=['POST']) 341 | @login_required 342 | def save_base_config(): 343 | data = request.get_json() 344 | if config_manager.save('alist_sync_base_config', data): 345 | return jsonify({"code": 200, "message": "基础配置保存成功"}) 346 | return jsonify({"code": 500, "message": "保存失败"}) 347 | 348 | 349 | # 查询基础连接配置接口 350 | @app.route('/api/get-base-config', methods=['GET']) 351 | @login_required 352 | def get_base_config(): 353 | config = config_manager.load('alist_sync_base_config') 354 | if config: 355 | return jsonify({"code": 200, "data": config}) 356 | return jsonify({"code": 404, "message": "配置文件不存在"}) 357 | 358 | 359 | @app.route('/api/get-sync-config', methods=['GET']) 360 | @login_required 361 | def get_sync_config(): 362 | config = config_manager.load('alist_sync_sync_config') 363 | if config: 364 | return jsonify({"code": 200, "data": config}) 365 | return jsonify({"code": 404, "message": "配置文件不存在"}) 366 | 367 | 368 | # 定义超时处理函数 369 | def timeout_handler(signum, frame): 370 | raise TimeoutError("连接测试超时") 371 | 372 | 373 | # 测试连接接口 374 | @app.route('/api/test-connection', methods=['POST']) 375 | @login_required 376 | def test_connection(): 377 | try: 378 | data = request.get_json() 379 | alist = AlistSync( 380 | data.get('baseUrl'), 381 | data.get('username'), 382 | data.get('password'), 383 | data.get('token') 384 | ) 385 | 386 | return jsonify({ 387 | "code": 200 if alist.login() else 500, 388 | "message": "连接测试成功" if alist.login() else "地址或用户名或密码或令牌错误" 389 | }) 390 | except Exception as e: 391 | logger.error(f"连接测试失败: {str(e)}") 392 | return jsonify({"code": 500, "message": f"连接测试失败: {str(e)}"}) 393 | finally: 394 | if 'alist' in locals(): 395 | alist.close() 396 | 397 | 398 | # 添加以下函数来管理定时任务 399 | def schedule_sync_tasks(): 400 | """从配置文件读取并调度所有同步任务""" 401 | scheduler_manager.reload_tasks() 402 | 403 | 404 | # 优化配置管理 405 | class ConfigManager: 406 | def __init__(self, storage_dir: str): 407 | self.storage_dir = storage_dir 408 | os.makedirs(storage_dir, exist_ok=True) 409 | 410 | def load(self, config_name: str) -> Optional[Dict]: 411 | """加载配置文件""" 412 | config_file = os.path.join(self.storage_dir, f'{config_name}.json') 413 | try: 414 | with open(config_file, 'r', encoding='utf-8') as f: 415 | return json.load(f) 416 | except FileNotFoundError: 417 | logger.warning(f"配置文件不存在: {config_file}") 418 | return None 419 | except Exception as e: 420 | logger.error(f"读取配置失败: {str(e)}") 421 | return None 422 | 423 | def save(self, config_name: str, data: Dict) -> bool: 424 | """保存配置文件""" 425 | # 遍历 tasks 列表,检查 syncMode 是否为 file_move,如果是则将 syncDelAction 修改为 none 426 | for task in data.get("tasks", []): 427 | if task.get("syncMode") == "file_move": 428 | task["syncDelAction"] = "none" 429 | 430 | config_file = os.path.join(self.storage_dir, f'{config_name}.json') 431 | try: 432 | with open(config_file, 'w', encoding='utf-8') as f: 433 | json.dump(data, f, indent=2, ensure_ascii=False) 434 | return True 435 | except Exception as e: 436 | logger.error(f"保存配置失败: {str(e)}") 437 | return False 438 | 439 | 440 | # 优化任务执行管理 441 | class TaskManager: 442 | def __init__(self, config_manager: ConfigManager): 443 | self.config_manager = config_manager 444 | 445 | def execute_task(self, task_id: Optional[int] = None) -> bool: 446 | """执行同步任务""" 447 | try: 448 | logger.info("开始执行同步任务") 449 | 450 | # 加载配置 451 | sync_config = self.config_manager.load('alist_sync_sync_config') 452 | base_config = self.config_manager.load('alist_sync_base_config') 453 | 454 | if not sync_config or not base_config: 455 | logger.error("配置为空,无法执行同步任务") 456 | return False 457 | 458 | # 设置基础环境变量 459 | self._setup_env_vars(base_config) 460 | 461 | # 处理任务 462 | tasks = sync_config.get('tasks', []) 463 | if not tasks: 464 | logger.error("没有配置同步任务") 465 | return False 466 | 467 | for task in tasks: 468 | if task_id is not None and task_id != task['id']: 469 | continue 470 | 471 | self._execute_single_task(task) 472 | 473 | return True 474 | 475 | except Exception as e: 476 | logger.error(f"执行同步任务失败: {str(e)}") 477 | return False 478 | 479 | def _setup_env_vars(self, base_config: Dict): 480 | """设置环境变量""" 481 | # 清除旧的环境变量 482 | for key in list(os.environ.keys()): 483 | if key.startswith('DIR_PAIRS'): 484 | del os.environ[key] 485 | 486 | # 设置新的环境变量 487 | os.environ.update({ 488 | 'BASE_URL': base_config.get('baseUrl', ''), 489 | 'USERNAME': base_config.get('username', ''), 490 | 'PASSWORD': base_config.get('password', ''), 491 | 'TOKEN': base_config.get('token', '') 492 | }) 493 | 494 | def _execute_single_task(self, task: Dict): 495 | """执行单个任务""" 496 | task_name = task.get('taskName', '未知任务') 497 | sync_del_action = task.get('syncDelAction', 'none') 498 | logger.info(f"[{task_name}] 开始处理任务,差异处置策略: {sync_del_action}") 499 | 500 | os.environ['SYNC_DELETE_ACTION'] = sync_del_action 501 | os.environ['EXCLUDE_DIRS'] = task.get('excludeDirs', '') 502 | 503 | # 添加正则表达式环境变量 504 | if task.get('regexPatterns'): 505 | os.environ['REGEX_PATTERNS'] = task.get('regexPatterns') 506 | 507 | if task['syncMode'] == 'data': 508 | self._handle_data_sync(task) 509 | elif task['syncMode'] == 'file': 510 | self._handle_file_sync(task) 511 | elif task['syncMode'] == 'file_move': 512 | self._handle_file_move(task) 513 | 514 | def _handle_data_sync(self, task: Dict): 515 | """处理数据同步模式""" 516 | source = task['sourceStorage'] 517 | sync_dirs = task['syncDirs'] 518 | exclude_dirs = task['excludeDirs'] 519 | 520 | if source not in exclude_dirs: 521 | exclude_dirs = f'{source}/{exclude_dirs}' 522 | exclude_dirs = exclude_dirs.replace('//', '/') 523 | 524 | dir_pairs = [] 525 | for target in task['targetStorages']: 526 | if source != target: 527 | dir_pair = f"{source}/{sync_dirs}:{target}/{sync_dirs}".replace('//', '/') 528 | dir_pairs.append(dir_pair) 529 | 530 | if dir_pairs: 531 | os.environ['DIR_PAIRS'] = ';'.join(dir_pairs) 532 | alist_sync.main() 533 | 534 | def _handle_file_sync(self, task: Dict): 535 | """处理文件同步模式""" 536 | dir_pairs = [f"{path['srcPath']}:{path['dstPath']}" for path in task['paths']] 537 | if dir_pairs: 538 | os.environ['DIR_PAIRS'] = ';'.join(dir_pairs) 539 | alist_sync.main() 540 | 541 | def _handle_file_move(self, task: Dict): 542 | """处理文件移动模式""" 543 | dir_pairs = [f"{path['srcPathMove']}:{path['dstPathMove']}" for path in task['paths']] 544 | if dir_pairs: 545 | os.environ['MOVE_FILE'] = 'true' 546 | os.environ['DIR_PAIRS'] = ';'.join(dir_pairs) 547 | alist_sync.main() 548 | 549 | 550 | # 创建管理器实例 551 | config_manager = ConfigManager(STORAGE_DIR) 552 | task_manager = TaskManager(config_manager) 553 | 554 | 555 | # 优化配置相关接口 556 | @app.route('/api/save-sync-config', methods=['POST']) 557 | @login_required 558 | def save_sync_config(): 559 | data = request.get_json() 560 | if config_manager.save('alist_sync_sync_config', data): 561 | schedule_sync_tasks() 562 | return jsonify({"code": 200, "message": "同步配置保存成功并已更新调度"}) 563 | return jsonify({"code": 500, "message": "保存失败"}) 564 | 565 | 566 | @app.route('/api/run-task', methods=['POST']) 567 | @login_required 568 | def run_task(): 569 | try: 570 | task_id = request.get_json().get('id') 571 | if task_manager.execute_task(task_id): 572 | return jsonify({"code": 200, "message": "同步任务执行成功"}) 573 | return jsonify({"code": 500, "message": "同步任务执行失败"}) 574 | except Exception as e: 575 | logger.error(f"执行任务失败: {str(e)}") 576 | return jsonify({"code": 500, "message": f"执行任务时发生错误: {str(e)}"}) 577 | 578 | 579 | # 修改存储列表获取接口 580 | @app.route('/api/storages', methods=['GET']) 581 | @login_required 582 | def get_storages(): 583 | try: 584 | config = config_manager.load('alist_sync_base_config') # 使用 config_manager 替代 load_config 585 | if not config: 586 | return jsonify({"code": 404, "message": "基础配置不存在"}) 587 | 588 | alist = AlistSync( 589 | config.get('baseUrl'), 590 | config.get('username'), 591 | config.get('password'), 592 | config.get('token') 593 | ) 594 | 595 | if alist.login(): 596 | storage_list = alist.get_storage_list() 597 | return jsonify({"code": 200, "data": storage_list}) 598 | return jsonify({"code": 500, "message": "获取存储列表失败:登录失败"}) 599 | 600 | except Exception as e: 601 | logger.error(f"获取存储列表失败: {str(e)}") 602 | return jsonify({"code": 500, "message": f"获取存储列表失败: {str(e)}"}) 603 | finally: 604 | if 'alist' in locals(): 605 | alist.close() 606 | 607 | 608 | # 优化时间处理相关代码 609 | class TimeUtils: 610 | @staticmethod 611 | def get_timestamp() -> int: 612 | """获取当前时间戳""" 613 | return int(time.time()) 614 | 615 | @staticmethod 616 | def datetime_to_timestamp(dt_str: str, fmt: str = "%Y-%m-%d %H:%M:%S") -> int: 617 | """时间字符串转时间戳""" 618 | try: 619 | return int(time.mktime(time.strptime(dt_str, fmt))) 620 | except Exception as e: 621 | logger.error(f"时间转换失败: {e}") 622 | raise 623 | 624 | @staticmethod 625 | def timestamp_to_datetime(ts: int, fmt: str = '%Y-%m-%d %H:%M:%S') -> str: 626 | """时间戳转时间字符串""" 627 | return time.strftime(fmt, time.localtime(ts)) 628 | 629 | @staticmethod 630 | def get_next_run_times(cron_expr: str, count: int = 5) -> List[str]: 631 | """获取下次运行时间列表""" 632 | try: 633 | now = datetime.datetime.now() 634 | cron = croniter.croniter(cron_expr, now) 635 | return [ 636 | cron.get_next(datetime.datetime).strftime("%Y-%m-%d %H:%M:%S") 637 | for _ in range(count) 638 | ] 639 | except Exception as e: 640 | logger.error(f"获取运行时间失败: {e}") 641 | raise 642 | 643 | 644 | # 优化调度器管理 645 | class SchedulerManager: 646 | def __init__(self, config_manager: ConfigManager, task_manager: TaskManager): 647 | self.scheduler = BackgroundScheduler() 648 | self.config_manager = config_manager 649 | self.task_manager = task_manager 650 | 651 | def start(self): 652 | """启动调度器""" 653 | try: 654 | self.scheduler.start() 655 | self.reload_tasks() 656 | logger.info("调度器启动成功") 657 | except Exception as e: 658 | logger.error(f"调度器启动失败: {e}") 659 | raise 660 | 661 | def stop(self): 662 | """停止调度器""" 663 | try: 664 | self.scheduler.shutdown() 665 | logger.info("调度器已停止") 666 | except Exception as e: 667 | logger.error(f"停止调度器失败: {e}") 668 | 669 | def reload_tasks(self): 670 | """重新加载所有任务""" 671 | try: 672 | self.scheduler.remove_all_jobs() 673 | sync_config = self.config_manager.load('alist_sync_sync_config') 674 | 675 | if not sync_config or 'tasks' not in sync_config: 676 | logger.warning("没有找到有效的同步任务配置") 677 | return 678 | 679 | for task in sync_config['tasks']: 680 | self._add_task(task) 681 | 682 | except Exception as e: 683 | logger.error(f"重新加载任务失败: {e}") 684 | 685 | def _add_task(self, task: Dict): 686 | """添加单个任务""" 687 | try: 688 | if 'cron' not in task: 689 | logger.warning(f"任务 {task.get('taskName', 'unknown')} 没有配置cron表达式") 690 | return 691 | 692 | job_id = f"sync_task_{task['id']}" 693 | self.scheduler.add_job( 694 | func=self.task_manager.execute_task, 695 | trigger=CronTrigger.from_crontab(task['cron']), 696 | id=job_id, 697 | replace_existing=True, 698 | args=[task['id']] 699 | ) 700 | logger.info(f"成功添加任务 {task['taskName']}, ID: {job_id}, Cron: {task['cron']}") 701 | 702 | except Exception as e: 703 | logger.error(f"添加任务失败: {e}") 704 | 705 | 706 | # 创建调度器管理器实例 707 | scheduler_manager = SchedulerManager(config_manager, task_manager) 708 | 709 | 710 | # 优化相关接口 711 | @app.route('/api/next-run-time', methods=['POST']) 712 | @login_required 713 | def next_run_time(): 714 | try: 715 | data = request.get_json() 716 | cron_expr = data.get('cron', '').strip() 717 | 718 | # 如果没有提供cron表达式,尝试从配置中获取 719 | if not cron_expr: 720 | task_id = data.get('id') 721 | if task_id is not None: 722 | sync_config = config_manager.load('alist_sync_sync_config') 723 | if sync_config and 'tasks' in sync_config: 724 | task = next((t for t in sync_config['tasks'] if t['id'] == task_id), None) 725 | if task and 'cron' in task: 726 | cron_expr = task['cron'] 727 | 728 | if not cron_expr: 729 | return jsonify({"code": 400, "message": "缺少cron参数"}) 730 | 731 | next_times = TimeUtils.get_next_run_times(cron_expr) 732 | return jsonify({ 733 | "code": 200, 734 | "data": next_times, 735 | "cron": cron_expr # 返回使用的cron表达式 736 | }) 737 | except Exception as e: 738 | logger.error(f"解析cron表达式失败: {e}") 739 | return jsonify({"code": 500, "message": f"解析出错: {str(e)}"}) 740 | 741 | 742 | # 将日志接口移到主函数之前 743 | @app.route('/api/logs', methods=['GET']) 744 | @login_required 745 | def get_logs(): 746 | try: 747 | date_str = request.args.get('date') 748 | log_dir = os.path.join(app.root_path, 'data/log') 749 | 750 | if not date_str or date_str == 'current': 751 | log_file = os.path.join(log_dir, 'alist_sync.log') 752 | date_str = 'current' 753 | else: 754 | log_file = os.path.join(log_dir, f'alist_sync.log.{date_str}') 755 | 756 | if os.path.exists(log_file): 757 | with open(log_file, 'r', encoding='utf-8') as f: 758 | content = f.read() 759 | return jsonify({ 760 | 'code': 200, 761 | 'data': [{ 762 | 'date': date_str, 763 | 'content': content 764 | }] 765 | }) 766 | return jsonify({ 767 | 'code': 404, 768 | 'message': '日志文件不存在' 769 | }) 770 | 771 | except Exception as e: 772 | logger.error(f"获取日志失败: {str(e)}") 773 | return jsonify({ 774 | 'code': 500, 775 | 'message': f"获取日志失败: {str(e)}" 776 | }) 777 | 778 | 779 | # 添加导出配置文件接口 780 | @app.route('/api/export-config', methods=['POST']) 781 | @login_required 782 | def export_config(): 783 | try: 784 | config_type = request.json.get('type') 785 | if not config_type: 786 | return jsonify({'code': 400, 'message': '请指定配置类型'}) 787 | 788 | # 获取当前时间戳 789 | timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') 790 | 791 | # 根据类型确定源文件和目标文件 792 | if config_type == 'sync': 793 | src_file = os.path.join(STORAGE_DIR, 'alist_sync_sync_config.json') 794 | dst_file = f'alist_sync_sync_config_{timestamp}.json' 795 | elif config_type == 'base': 796 | src_file = os.path.join(STORAGE_DIR, 'alist_sync_base_config.json') 797 | dst_file = f'alist_sync_base_config_{timestamp}.json' 798 | else: 799 | return jsonify({'code': 400, 'message': '无效的配置类型'}) 800 | 801 | if not os.path.exists(src_file): 802 | return jsonify({'code': 404, 'message': '配置文件不存在'}) 803 | 804 | # 读取配置文件内容 805 | with open(src_file, 'r', encoding='utf-8') as f: 806 | config_data = json.load(f) 807 | 808 | return jsonify({ 809 | 'code': 200, 810 | 'data': { 811 | 'content': config_data, 812 | 'filename': dst_file 813 | } 814 | }) 815 | 816 | except Exception as e: 817 | logger.error(f"导出配置失败: {str(e)}") 818 | return jsonify({'code': 500, 'message': f'导出配置失败: {str(e)}'}) 819 | 820 | 821 | # 添加导入配置文件接口 822 | @app.route('/api/import-config', methods=['POST']) 823 | @login_required 824 | def import_config(): 825 | try: 826 | data = request.get_json() 827 | config_type = data.get('type') 828 | config_content = data.get('content') 829 | 830 | if not config_type or not config_content: 831 | return jsonify({'code': 400, 'message': '请提供配置类型和内容'}) 832 | 833 | # 检查基础配置文件是否存在 834 | base_config_file = os.path.join(STORAGE_DIR, 'alist_sync_base_config.json') 835 | if config_type == 'sync' and not os.path.exists(base_config_file): 836 | return jsonify({'code': 400, 'message': '请先导入基础配置文件'}) 837 | 838 | # 根据类型确定目标文件 839 | if config_type == 'sync': 840 | dst_file = os.path.join(STORAGE_DIR, 'alist_sync_sync_config.json') 841 | elif config_type == 'base': 842 | dst_file = os.path.join(STORAGE_DIR, 'alist_sync_base_config.json') 843 | else: 844 | return jsonify({'code': 400, 'message': '无效的配置类型'}) 845 | 846 | # 备份原配置文件 847 | backup_file = None 848 | if os.path.exists(dst_file): 849 | timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') 850 | backup_file = f"{dst_file}.{timestamp}.bak" 851 | shutil.copy2(dst_file, backup_file) 852 | 853 | try: 854 | # 写入新配置 855 | with open(dst_file, 'w', encoding='utf-8') as f: 856 | json.dump(config_content, f, indent=2, ensure_ascii=False) 857 | 858 | # 如果是同步配置,需要重新加载调度任务 859 | if config_type == 'sync': 860 | scheduler_manager.reload_tasks() 861 | 862 | # 清理超过7天的备份文件 863 | cleanup_backup_files(STORAGE_DIR) 864 | 865 | return jsonify({'code': 200, 'message': '导入配置成功'}) 866 | 867 | except Exception as e: 868 | # 如果写入失败且有备份,恢复备份 869 | if backup_file and os.path.exists(backup_file): 870 | shutil.copy2(backup_file, dst_file) 871 | raise 872 | 873 | except Exception as e: 874 | logger.error(f"导入配置失败: {str(e)}") 875 | return jsonify({'code': 500, 'message': f'导入配置失败: {str(e)}'}) 876 | 877 | 878 | # 添加清理备份文件的函数 879 | def cleanup_backup_files(directory: str, days: int = 7): 880 | """清理指定目录下超过指定天数的备份文件""" 881 | try: 882 | current_time = datetime.datetime.now() 883 | for filename in os.listdir(directory): 884 | if filename.endswith('.bak'): 885 | file_path = os.path.join(directory, filename) 886 | file_time = datetime.datetime.fromtimestamp(os.path.getctime(file_path)) 887 | if (current_time - file_time).days > days: 888 | try: 889 | os.remove(file_path) 890 | logger.info(f"已删除过期备份文件: {filename}") 891 | except Exception as e: 892 | logger.error(f"删除备份文件失败 {filename}: {str(e)}") 893 | except Exception as e: 894 | logger.error(f"清理备份文件失败: {str(e)}") 895 | 896 | 897 | def get_current_version(): 898 | """获取当前运行版本""" 899 | try: 900 | logger.info("开始获取当前版本...") 901 | 902 | # 1. 尝试从环境变量直接获取 903 | version = os.getenv('VERSION') 904 | if version: 905 | logger.info(f"从环境变量获取到版本号: {version}") 906 | return version.lstrip('v') 907 | 908 | # 2. 如果环境变量没有,则从VERSION文件获取 909 | version_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'VERSION') 910 | logger.info(f"尝试从VERSION文件获取版本号,文件路径: {version_file}") 911 | if os.path.exists(version_file): 912 | with open(version_file, 'r') as f: 913 | version = f.read().strip() 914 | logger.info(f"从VERSION文件获取到版本号: {version}") 915 | return version.lstrip('v') 916 | else: 917 | logger.warning(f"VERSION文件不存在: {version_file}") 918 | 919 | return "unknown" 920 | 921 | except Exception as e: 922 | logger.error(f"获取当前版本失败: {e}") 923 | return "unknown" 924 | 925 | 926 | def load_version_config(): 927 | """加载版本配置""" 928 | try: 929 | with open(VERSION_CONFIG_FILE, 'r', encoding='utf-8') as f: 930 | return json.load(f) 931 | except Exception as e: 932 | logger.error(f"加载版本配置失败: {e}") 933 | return { 934 | "latest_version": "", 935 | "update_time": "", 936 | "source": "github" 937 | } 938 | 939 | 940 | def save_version_config(config): 941 | """保存版本配置""" 942 | try: 943 | with open(VERSION_CONFIG_FILE, 'w', encoding='utf-8') as f: 944 | json.dump(config, f, indent=2, ensure_ascii=False) 945 | return True 946 | except Exception as e: 947 | logger.error(f"保存版本配置失败: {e}") 948 | return False 949 | 950 | 951 | def should_update_version(update_time): 952 | """检查是否需要更新版本信息""" 953 | if not update_time: 954 | return True 955 | try: 956 | last_update = datetime.datetime.fromisoformat(update_time) 957 | now = datetime.datetime.now() 958 | return (now - last_update).days >= 7 959 | except Exception as e: 960 | logger.error(f"检查更新时间失败: {e}") 961 | return True 962 | 963 | 964 | def get_latest_version_from_github(): 965 | """从 GitHub 获取最新版本""" 966 | # 首先尝试从 GitHub 获取 967 | parsed_url = urllib.parse.urlparse("https://api.github.com/repos/xjxjin/alist-sync/tags") 968 | logger.info(f"尝试从GitHub获取: {parsed_url.geturl()}") 969 | conn = http.client.HTTPSConnection(parsed_url.netloc) 970 | 971 | try: 972 | headers = { 973 | 'User-Agent': 'Mozilla/5.0 (compatible; AlistSync/1.0;)' 974 | } 975 | conn.request("GET", parsed_url.path, headers=headers) 976 | response = conn.getresponse() 977 | logger.info(f"GitHub API响应状态码: {response.status}") 978 | 979 | if response.status == 200: 980 | data = json.loads(response.read().decode()) 981 | if data: 982 | version_tags = [] 983 | for tag in data: 984 | tag_name = tag['name'].lstrip('v') 985 | if re.match(r'^\d+\.\d+\.\d+(\.\d+)?$', tag_name): 986 | version_tags.append(tag_name) 987 | if version_tags: 988 | version_tags.sort(key=lambda v: [int(x) for x in v.split('.')]) 989 | latest = version_tags[-1] 990 | logger.info(f"从GitHub获取到最新版本: {latest}") 991 | return latest 992 | logger.warning("GitHub返回数据中没有有效的版本标签") 993 | 994 | except (socket.timeout, TimeoutError) as e: 995 | logger.error(f"从GitHub获取版本超时: {e}") 996 | return None 997 | except Exception as e: 998 | logger.error(f"从GitHub获取版本失败: {e}") 999 | return None 1000 | finally: 1001 | if 'conn' in locals(): 1002 | conn.close() 1003 | 1004 | 1005 | def get_latest_version_from_gitee(): 1006 | """从 Gitee 获取最新版本""" 1007 | try: 1008 | headers = { 1009 | 'User-Agent': 'Mozilla/5.0 (compatible; AlistSync/1.0;)' 1010 | } 1011 | # 如果从 GitHub 获取失败,尝试从 Gitee 获取 1012 | logger.info("从GitHub获取失败,尝试从Gitee获取...") 1013 | parsed_url = urllib.parse.urlparse("https://gitee.com/api/v5/repos/xjxjin/alist-sync/tags") 1014 | logger.info(f"尝试从Gitee获取: {parsed_url.geturl()}") 1015 | conn = http.client.HTTPSConnection(parsed_url.netloc) 1016 | conn.request("GET", parsed_url.path, headers=headers) 1017 | response = conn.getresponse() 1018 | logger.info(f"Gitee API响应状态码: {response.status}") 1019 | 1020 | if response.status == 200: 1021 | data = json.loads(response.read().decode()) 1022 | if data: 1023 | version_tags = [] 1024 | for tag in data: 1025 | tag_name = tag['name'].lstrip('v') 1026 | if re.match(r'^\d+\.\d+\.\d+(\.\d+)?$', tag_name): 1027 | version_tags.append(tag_name) 1028 | if version_tags: 1029 | version_tags.sort(key=lambda v: [int(x) for x in v.split('.')]) 1030 | latest = version_tags[-1] 1031 | logger.info(f"从Gitee获取到最新版本: {latest}") 1032 | return latest 1033 | logger.warning("Gitee返回数据中没有有效的版本标签") 1034 | 1035 | logger.warning("无法从GitHub和Gitee获取最新版本") 1036 | return "unknown" 1037 | except (socket.timeout, TimeoutError) as e: 1038 | logger.error(f"从Gitee获取版本超时: {e}") 1039 | return None 1040 | except Exception as e: 1041 | logger.error(f"从Gitee获取版本失败: {e}") 1042 | return None 1043 | finally: 1044 | if 'conn' in locals(): 1045 | conn.close() 1046 | 1047 | 1048 | def get_latest_version(): 1049 | """获取最新版本号""" 1050 | try: 1051 | logger.info("开始获取最新版本...") 1052 | # 首先尝试从 GitHub 获取 1053 | parsed_url = urllib.parse.urlparse("https://api.github.com/repos/xjxjin/alist-sync/tags") 1054 | logger.info(f"尝试从GitHub获取: {parsed_url.geturl()}") 1055 | conn = http.client.HTTPSConnection(parsed_url.netloc) 1056 | 1057 | try: 1058 | headers = { 1059 | 'User-Agent': 'Mozilla/5.0 (compatible; AlistSync/1.0;)' 1060 | } 1061 | conn.request("GET", parsed_url.path, headers=headers) 1062 | response = conn.getresponse() 1063 | logger.info(f"GitHub API响应状态码: {response.status}") 1064 | 1065 | if response.status == 200: 1066 | data = json.loads(response.read().decode()) 1067 | if data: 1068 | version_tags = [] 1069 | for tag in data: 1070 | tag_name = tag['name'].lstrip('v') 1071 | if re.match(r'^\d+\.\d+\.\d+(\.\d+)?$', tag_name): 1072 | version_tags.append(tag_name) 1073 | if version_tags: 1074 | version_tags.sort(key=lambda v: [int(x) for x in v.split('.')]) 1075 | latest = version_tags[-1] 1076 | logger.info(f"从GitHub获取到最新版本: {latest}") 1077 | return latest 1078 | logger.warning("GitHub返回数据中没有有效的版本标签") 1079 | 1080 | # 如果从 GitHub 获取失败,尝试从 Gitee 获取 1081 | logger.info("从GitHub获取失败,尝试从Gitee获取...") 1082 | parsed_url = urllib.parse.urlparse("https://gitee.com/api/v5/repos/xjxjin/alist-sync/tags") 1083 | logger.info(f"尝试从Gitee获取: {parsed_url.geturl()}") 1084 | conn = http.client.HTTPSConnection(parsed_url.netloc) 1085 | conn.request("GET", parsed_url.path, headers=headers) 1086 | response = conn.getresponse() 1087 | logger.info(f"Gitee API响应状态码: {response.status}") 1088 | 1089 | if response.status == 200: 1090 | data = json.loads(response.read().decode()) 1091 | if data: 1092 | version_tags = [] 1093 | for tag in data: 1094 | tag_name = tag['name'].lstrip('v') 1095 | if re.match(r'^\d+\.\d+\.\d+(\.\d+)?$', tag_name): 1096 | version_tags.append(tag_name) 1097 | if version_tags: 1098 | version_tags.sort(key=lambda v: [int(x) for x in v.split('.')]) 1099 | latest = version_tags[-1] 1100 | logger.info(f"从Gitee获取到最新版本: {latest}") 1101 | return latest 1102 | logger.warning("Gitee返回数据中没有有效的版本标签") 1103 | 1104 | logger.warning("无法从GitHub和Gitee获取最新版本") 1105 | return "unknown" 1106 | 1107 | finally: 1108 | conn.close() 1109 | 1110 | except Exception as e: 1111 | logger.error(f"获取最新版本失败: {e}") 1112 | return "unknown" 1113 | 1114 | 1115 | # 添加新的API路由 1116 | @app.route('/api/version', methods=['GET']) 1117 | def get_version(): 1118 | try: 1119 | current_version = get_current_version() 1120 | # latest_version = get_latest_version() 1121 | source = "github" 1122 | # 检查是否需要更新版本信息 1123 | 1124 | version_config = load_version_config() 1125 | if should_update_version(version_config.get('update_time')): 1126 | latest_version = get_latest_version_from_github() 1127 | if not latest_version: 1128 | latest_version = get_latest_version_from_gitee() 1129 | source = "gitee" 1130 | if latest_version: 1131 | version_config.update({ 1132 | 'latest_version': latest_version, 1133 | 'update_time': datetime.datetime.now().isoformat(), 1134 | 'source': source 1135 | }) 1136 | 1137 | else: 1138 | # 如果获取失败,使用缓存的版本 1139 | latest_version = version_config.get('latest_version', 'unknown') 1140 | save_version_config(version_config) 1141 | else: 1142 | # 使用缓存的版本 1143 | latest_version = version_config.get('latest_version', 'unknown') 1144 | 1145 | return jsonify({ 1146 | 'code': 200, 1147 | 'data': { 1148 | 'current_version': current_version, 1149 | 'latest_version': latest_version 1150 | } 1151 | }) 1152 | except Exception as e: 1153 | logger.error(f"获取版本信息失败: {e}") 1154 | return jsonify({ 1155 | 'code': 500, 1156 | 'message': f"获取版本信息失败: {str(e)}" 1157 | }) 1158 | 1159 | 1160 | # 主函数 1161 | if __name__ == '__main__': 1162 | try: 1163 | # 启动调度器 1164 | scheduler_manager.start() 1165 | # 启动Web服务 1166 | app.run(host='0.0.0.0', port=52441, debug=False) 1167 | except Exception as e: 1168 | logger.error(f"启动失败: {e}") 1169 | finally: 1170 | # 确保调度器正确关闭 1171 | scheduler_manager.stop() 1172 | -------------------------------------------------------------------------------- /alist_sync.py: -------------------------------------------------------------------------------- 1 | import http.client 2 | import json 3 | import re 4 | from datetime import datetime, timedelta 5 | import os 6 | import logging 7 | from typing import List, Dict, Optional, Union 8 | from logging.handlers import TimedRotatingFileHandler 9 | from typing import List, Tuple, Pattern 10 | 11 | 12 | def setup_logger(): 13 | """配置日志记录器""" 14 | # 获取当前文件所在目录 15 | current_dir = os.path.dirname(os.path.abspath(__file__)) 16 | # 创建日志目录 17 | log_dir = os.path.join(current_dir, 'data/log') 18 | os.makedirs(log_dir, exist_ok=True) 19 | 20 | # 设置日志文件路径 21 | log_file = os.path.join(log_dir, 'alist_sync.log') 22 | 23 | # 创建 TimedRotatingFileHandler 24 | file_handler = TimedRotatingFileHandler( 25 | filename=log_file, 26 | when='midnight', 27 | interval=1, 28 | backupCount=7, 29 | encoding='utf-8' 30 | ) 31 | 32 | # 创建控制台处理器 33 | console_handler = logging.StreamHandler() 34 | 35 | # 设置日志格式 36 | formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') 37 | file_handler.setFormatter(formatter) 38 | console_handler.setFormatter(formatter) 39 | 40 | # 配置根日志记录器 41 | logger = logging.getLogger() 42 | logger.setLevel(logging.INFO) 43 | 44 | # 避免重复添加处理器 45 | if not logger.handlers: 46 | logger.addHandler(file_handler) 47 | logger.addHandler(console_handler) 48 | 49 | return logger 50 | 51 | 52 | # 初始化日志记录器 53 | logger = setup_logger() 54 | 55 | 56 | def parse_time_and_adjust_utc(date_str: str) -> datetime: 57 | """ 58 | 解析时间字符串,如果是UTC格式(包含'Z')则加8小时 59 | """ 60 | iso_8601_pattern = r'(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d+)?([+-]\d{2}:\d{2}|Z)?' 61 | match_iso = re.match(iso_8601_pattern, date_str) 62 | if match_iso: 63 | year, month, day, hour, minute, second, microsecond, timezone = match_iso.groups() 64 | if microsecond: 65 | microsecond = int(float(microsecond) * 1000000) 66 | else: 67 | microsecond = 0 68 | dt = datetime(int(year), int(month), int(day), int(hour), int(minute), int(second), microsecond) 69 | if timezone == "Z": 70 | dt = dt + timedelta(hours=8) # UTC时间加8小时 71 | elif timezone: 72 | # 处理其他时区偏移量 73 | sign = 1 if timezone[0] == "+" else -1 74 | hours = int(timezone[1:3]) 75 | minutes = int(timezone[4:6]) 76 | offset = timedelta(hours=sign * hours, minutes=sign * minutes) 77 | dt = dt - offset 78 | return dt 79 | return None 80 | 81 | 82 | class AlistSync: 83 | def __init__(self, base_url: str, username: str = None, password: str = None, token: str = None, 84 | sync_delete_action: str = "none", exclude_list: List[str] = None, move_file_action: bool = False, 85 | regex_patterns_list=None, regex_pattern=None, 86 | task_list: List[str] = None): 87 | """初始化AlistSync类""" 88 | if regex_patterns_list is None: 89 | regex_patterns_list = [] 90 | self.base_url = base_url 91 | self.username = username 92 | self.password = password 93 | self.token = token # 添加token属性 94 | self.sync_delete_action = sync_delete_action.lower() 95 | self.sync_delete = self.sync_delete_action in ["move", "delete"] 96 | self.connection = self._create_connection() 97 | self.task_list = task_list 98 | self.exclude_list = exclude_list 99 | self.move_file_action = move_file_action 100 | self.regex_patterns_list = regex_patterns_list 101 | self.regex_pattern = regex_pattern 102 | 103 | def _create_connection(self) -> Union[http.client.HTTPConnection, http.client.HTTPSConnection]: 104 | """创建HTTP(S)连接""" 105 | try: 106 | match = re.match(r"(?:http[s]?://)?([^:/]+)(?::(\d+))?", self.base_url) 107 | if not match: 108 | raise ValueError("Invalid base URL format") 109 | 110 | host = match.group(1) 111 | port_part = match.group(2) 112 | port = int(port_part) if port_part else (443 if self.base_url.startswith("https://") else 80) 113 | 114 | logger.info(f"创建连接 - 主机: {host}, 端口: {port}") 115 | return (http.client.HTTPSConnection(host, port) 116 | if self.base_url.startswith("https://") 117 | else http.client.HTTPConnection(host, port)) 118 | except Exception as e: 119 | logger.error(f"创建连接失败: {str(e)}") 120 | raise 121 | 122 | def _make_request(self, method: str, path: str, headers: Dict = None, 123 | payload: str = None) -> Optional[Dict]: 124 | """发送HTTP请求并返回JSON响应""" 125 | try: 126 | logger.debug(f"发送请求 - 方法: {method}, 路径: {path}") 127 | self.connection.request(method, path, body=payload, headers=headers) 128 | response = self.connection.getresponse() 129 | result = json.loads(response.read().decode("utf-8")) 130 | logger.debug(f"请求响应: {result}") 131 | return result 132 | except Exception as e: 133 | logger.error(f"请求失败 - 方法: {method}, 路径: {path}, 错误: {str(e)}") 134 | return None 135 | 136 | def login(self) -> bool: 137 | """登录并获取token""" 138 | # 如果已有token,直接返回True 139 | if self.token and self.get_setting(): 140 | return True 141 | 142 | # 否则使用用户名密码登录 143 | if not self.username or not self.password: 144 | logger.error("token或用户名密码不正确") 145 | return False 146 | 147 | payload = json.dumps({"username": self.username, "password": self.password}) 148 | headers = { 149 | "User-Agent": "Apifox/1.0.0 (https://apifox.com)", 150 | "Content-Type": "application/json" 151 | } 152 | response = self._make_request("POST", "/api/auth/login", headers, payload) 153 | if response and response.get("data", {}).get("token"): 154 | self.token = response["data"]["token"] 155 | logger.info("令牌验证成功") 156 | return True 157 | logger.error("获取token失败") 158 | return False 159 | 160 | def get_setting(self) -> bool: 161 | """验证令牌正确性""" 162 | headers = { 163 | "User-Agent": "Apifox/1.0.0 (https://apifox.com)", 164 | "Content-Type": "application/json", 165 | "Authorization": self.token 166 | } 167 | response = self._make_request("GET", "/api/admin/setting/list", headers) 168 | if response and response.get("data", {}): 169 | token_value = None 170 | for item in response["data"]: 171 | if item["key"] == "token": 172 | token_value = item["value"] 173 | logger.info("令牌验证成功") 174 | break 175 | if self.token == token_value: 176 | logger.info("令牌验证成功") 177 | return True 178 | logger.info("令牌验证失败") 179 | return False 180 | 181 | def _directory_operation(self, operation: str, **kwargs) -> Optional[Dict]: 182 | """执行目录操作""" 183 | if not self.token: 184 | if not self.login(): 185 | return None 186 | 187 | headers = { 188 | "Authorization": self.token, 189 | "User-Agent": "Apifox/1.0.0 (https://apifox.com)", 190 | "Content-Type": "application/json" 191 | } 192 | payload = json.dumps(kwargs) 193 | path = f"/api/fs/{operation}" 194 | return self._make_request("POST", path, headers, payload) 195 | 196 | def _task_operation(self, method: str, operation: str, **kwargs) -> Optional[Dict]: 197 | """执行任务操作""" 198 | if not self.token: 199 | if not self.login(): 200 | return None 201 | 202 | headers = { 203 | "Authorization": self.token, 204 | "User-Agent": "Apifox/1.0.0 (https://apifox.com)", 205 | "Content-Type": "application/json" 206 | } 207 | payload = json.dumps(kwargs) 208 | path = f"/api/admin/task/{operation}" 209 | return self._make_request(method, path, headers, payload) 210 | 211 | def get_copy_task_undone(self): 212 | """获取未完成的复制任务""" 213 | response = self._task_operation("GET", "copy/undone") 214 | name_list = [] 215 | if response and response.get("data", []): 216 | for item in response["data"]: 217 | name = item["name"] 218 | try: 219 | result = name.replace("](", "") 220 | name_list.append(result) 221 | except IndexError: 222 | print(f"字符串 '{name}' 未找到匹配的切分字符串。") 223 | self.task_list = name_list 224 | return True 225 | # return name_list 226 | 227 | def get_copy_task_retry_failed(self) -> List[Dict]: 228 | """获取已完成的复制任务""" 229 | response = self._task_operation("POST", "copy/retry_failed") 230 | return response.get("data", []) if response else [] 231 | 232 | def get_copy_task_done(self) -> List[Dict]: 233 | """获取已完成的复制任务""" 234 | response = self._task_operation("GET", "copy/done") 235 | return response.get("data", []) if response else [] 236 | 237 | def get_directory_contents(self, directory_path: str) -> List[Dict]: 238 | """获取目录内容""" 239 | response = self._directory_operation("list", path=directory_path) 240 | return response.get("data", {}).get("content", []) if response else [] 241 | 242 | def create_directory(self, directory_path: str) -> bool: 243 | """创建目录""" 244 | response = self._directory_operation("mkdir", path=directory_path) 245 | if response: 246 | logger.info(f"文件夹【{directory_path}】创建成功") 247 | return True 248 | logger.error("文件夹创建失败") 249 | return False 250 | 251 | def remove_empty_directory(self, directory_path: str) -> bool: 252 | """删除空文件夹""" 253 | response = self._directory_operation("remove_empty_directory", src_dir=directory_path) 254 | if response: 255 | logger.info(f"删除空文件夹【{directory_path}】成功") 256 | return True 257 | logger.error("删除空文件夹失败") 258 | return False 259 | 260 | # 递归删除空文件夹 261 | def _remove_empty_folders(self, base_dir: str, src_dir: str): 262 | if base_dir in src_dir and self.is_path_exists(src_dir): 263 | src_contents = self.get_directory_contents(src_dir) 264 | if src_contents: 265 | for item in src_contents: 266 | if item.get('is_dir', False): 267 | name = item.get('name', '未知项目') 268 | src_path = f"{src_dir}/{name}" 269 | self._remove_empty_folders(base_dir, src_path) 270 | else: 271 | # 文件移动的情况下删除空文件夹 272 | if base_dir != src_dir: 273 | # 找到最后一个 / 的索引 274 | last_slash_index = src_dir.rfind('/') 275 | # 分割字符串 276 | remove_dir = src_dir[:last_slash_index] 277 | remove_names = src_dir[last_slash_index + 1:] 278 | self._directory_operation("remove", dir=remove_dir, names=[remove_names]) 279 | logger.info(f"删除空文件夹【{src_dir}】成功") 280 | self._remove_empty_folders(base_dir, remove_dir) 281 | 282 | def _copy_item(self, src_dir: str, dst_dir: str, item_name: str) -> bool: 283 | """复制文件或目录""" 284 | response = self._directory_operation("copy", 285 | src_dir=src_dir, 286 | dst_dir=dst_dir, 287 | names=[item_name]) 288 | if response: 289 | logger.info(f"文件【{item_name}】复制成功") 290 | return True 291 | logger.error("文件复制失败") 292 | return False 293 | 294 | def _move_item(self, src_dir: str, dst_dir: str, item_name: str) -> bool: 295 | """移动文件或目录""" 296 | response = self._directory_operation("move", 297 | src_dir=src_dir, 298 | dst_dir=dst_dir, 299 | names=[item_name]) 300 | if response: 301 | logger.info(f"文件从【{src_dir}/{item_name}】移动到【{dst_dir}/{item_name}】移动成功") 302 | return True 303 | logger.error("文件移动失败") 304 | return False 305 | 306 | def is_path_exists(self, path: str) -> bool: 307 | """检查路径是否存在""" 308 | response = self._directory_operation("get", path=path) 309 | return bool(response and response.get("message") == "success") 310 | 311 | def get_storage_list(self) -> List[str]: 312 | """获取存储列表""" 313 | if not self.token: 314 | if not self.login(): 315 | return [] 316 | 317 | headers = { 318 | "Authorization": self.token, 319 | "User-Agent": "Apifox/1.0.0 (https://apifox.com)", 320 | "Content-Type": "application/json" 321 | } 322 | response = self._make_request("GET", "/api/admin/storage/list", headers) 323 | if response: 324 | storage_list = response["data"]["content"] 325 | return [item["mount_path"] for item in storage_list] 326 | logger.error("获取存储列表失败") 327 | return [] 328 | 329 | def check_regex(self, path: str) -> bool: 330 | if self.regex_patterns_list: 331 | for regex in self.regex_patterns_list: 332 | if regex.match(path): 333 | return True 334 | if self.regex_pattern and self.regex_pattern.match(path): 335 | return True 336 | return False 337 | 338 | def sync_directories(self, src_dir: str, dst_dir: str) -> bool: 339 | """同步两个目录""" 340 | try: 341 | 342 | # 重试已失败任务 343 | self.get_copy_task_retry_failed() 344 | # 获取正在运行任务 345 | self.get_copy_task_undone() 346 | 347 | logger.info(f"开始同步目录 - 源目录: {src_dir}, 目标目录: {dst_dir}") 348 | if not self.is_path_exists(src_dir): 349 | logger.error(f"源目录【{src_dir}】不存在,停止同步") 350 | return False 351 | result = self._recursive_copy(src_dir, dst_dir) 352 | # 递归删除空文件夹 353 | if self.move_file_action: 354 | self._remove_empty_folders(src_dir, src_dir) 355 | 356 | logger.info(f"目录同步完成 - 源目录: {src_dir}, 目标目录: {dst_dir}, 结果: {'成功' if result else '失败'}") 357 | return result 358 | except Exception as e: 359 | logger.error(f"同步目录失败: {str(e)}") 360 | return False 361 | 362 | def _recursive_copy(self, src_dir: str, dst_dir: str) -> bool: 363 | """递归复制目录内容""" 364 | try: 365 | if src_dir in self.exclude_list: 366 | logger.info(f"排除目录: {src_dir}, 跳过同步") 367 | return True 368 | else: 369 | logger.info(f"开始递归复制 - 源目录: {src_dir}, 目标目录: {dst_dir}") 370 | src_contents = self.get_directory_contents(src_dir) 371 | if not src_contents: 372 | logger.info(f"源目录为空或获取内容失败: {src_dir}") 373 | # return True 374 | 375 | if self.sync_delete: 376 | self._handle_sync_delete(src_dir, dst_dir, src_contents) 377 | if src_contents: 378 | for item in src_contents: 379 | if not self._copy_item_with_check(src_dir, dst_dir, item): 380 | logger.error(f"复制项目失败: {item.get('name', '未知项目')}") 381 | return False 382 | logger.info(f"递归复制完成 - 源目录: {src_dir}, 目标目录: {dst_dir}") 383 | return True 384 | except Exception as e: 385 | logger.error(f"递归复制失败: {str(e)}") 386 | return False 387 | 388 | def _handle_sync_delete(self, src_dir: str, dst_dir: str, src_contents: List[Dict]): 389 | """处理同步删除逻辑""" 390 | try: 391 | dst_contents = self.get_directory_contents(dst_dir) 392 | src_names = {} 393 | if src_contents: 394 | src_names = {item["name"] for item in src_contents} 395 | 396 | dst_names = {} 397 | if dst_contents: 398 | dst_names = {item["name"] for item in dst_contents} 399 | 400 | if src_names: 401 | to_delete = set(dst_names) - set(src_names) 402 | else: 403 | to_delete = dst_names 404 | 405 | if not to_delete: 406 | logger.info("没有需要删除的项目") 407 | return 408 | 409 | for name in to_delete: 410 | if self.sync_delete_action == "move": 411 | logger.info(f"处理同步移动 - 目录: {dst_dir}") 412 | logger.info(f"处理移动项目: {name}") 413 | trash_dir = self._get_trash_dir(dst_dir) 414 | if trash_dir: 415 | if not self.is_path_exists(trash_dir): 416 | logger.info(f"创建回收站目录: {trash_dir}") 417 | self.create_directory(trash_dir) 418 | logger.info(f"移动到回收站: {name}") 419 | self._move_item(dst_dir, trash_dir, name) 420 | else: # delete 421 | logger.info(f"处理同步删除 - 目录: {dst_dir}") 422 | logger.info(f"处理删除项目: {name}") 423 | logger.info(f"直接删除项目: {name}") 424 | self._directory_operation("remove", dir=dst_dir, names=[name]) 425 | except Exception as e: 426 | logger.error(f"处理同步删除失败: {str(e)}") 427 | 428 | def _get_trash_dir(self, dst_dir: str) -> Optional[str]: 429 | """获取回收站目录路径""" 430 | storage_list = self.get_storage_list() 431 | for mount_path in storage_list: 432 | if dst_dir.startswith(mount_path): 433 | return f"{mount_path}/trash{dst_dir[len(mount_path):]}" 434 | return None 435 | 436 | def close(self): 437 | """关闭连接""" 438 | try: 439 | if hasattr(self, 'connection') and self.connection: 440 | self.connection.close() 441 | logger.debug("连接已关闭") 442 | except Exception as e: 443 | logger.error(f"关闭连接时发生错误: {str(e)}") 444 | 445 | def get_file_info(self, path: str) -> Optional[Dict]: 446 | """获取文件信息,包括大小和修改时间""" 447 | response = self._directory_operation("get", path=path) 448 | if response and response.get("message") == "success": 449 | return response.get("data", {}) 450 | return None 451 | 452 | def _copy_item_with_check(self, src_dir: str, dst_dir: str, item: Dict) -> bool: 453 | """复制项目并进行检查""" 454 | try: 455 | item_name = item.get('name') 456 | if not item_name: 457 | logger.error("项目名称为空") 458 | return False 459 | 460 | logger.info(f"处理项目: {item_name}") 461 | if src_dir in self.exclude_list: 462 | logger.info(f"排除目录: {src_dir}, 跳过同步") 463 | return True 464 | 465 | # 处理文件 466 | src_path = f"{src_dir}/{item_name}".replace('//', '/') 467 | dst_path = f"{dst_dir}/{item_name}".replace('//', '/') 468 | 469 | # 如果是目录,递归处理 470 | if item.get('is_dir', False): 471 | 472 | # 确保目标子目录存在 473 | if not self.is_path_exists(dst_path): 474 | logger.info(f"创建目标子目录: {dst_path}") 475 | if not self.create_directory(dst_path): 476 | return False 477 | else: 478 | logger.info(f"文件夹【{dst_path}】已存在,跳过创建") 479 | 480 | # 递归复制子目录 481 | return self._recursive_copy(src_path, dst_path) 482 | else: 483 | 484 | # 判断正则表达式,如果符合正则表达式跳过复制 485 | if(self.regex_patterns_list or self.regex_pattern): 486 | if not self.check_regex(item_name): 487 | logger.info(f"不符合正则表达式: {src_path}, 跳过同步") 488 | return True 489 | 490 | # 检查是否在未完成的任务列表中,如果存在,则跳过 491 | for task_item in self.task_list: 492 | if src_dir in task_item and dst_dir in task_item and src_path in task_item: 493 | logger.info(f"文件【{item_name}】在未完成的任务列表中,跳过复制") 494 | return True 495 | # 检查目标文件是否存在 496 | if not self.is_path_exists(dst_path): 497 | logger.info(f"复制文件: {item_name}") 498 | return self._copy_item(src_dir, dst_dir, item_name) 499 | else: 500 | # 获取源文件和目标文件信息 501 | src_size = item.get("size") 502 | dst_info = self.get_file_info(dst_path) 503 | 504 | if not dst_info: 505 | logger.error(f"获取目标文件信息失败: {dst_path}") 506 | return False 507 | 508 | dst_size = dst_info.get("size") 509 | 510 | # 比较文件大小 511 | if src_size == dst_size: 512 | logger.info(f"文件【{item_name}】已存在且大小相同,跳过复制") 513 | if self.move_file_action: 514 | if not self._directory_operation("remove", dir=src_dir, names=[item_name]): 515 | logger.error(f"删除源文件失败: {src_path}") 516 | return False 517 | else: 518 | logger.info(f"删除源文件成功: {src_path}") 519 | return True 520 | else: 521 | return True 522 | else: 523 | # 比较修改时间 524 | src_modified = parse_time_and_adjust_utc(item.get("modified")) 525 | dst_modified = parse_time_and_adjust_utc(dst_info.get("modified")) 526 | 527 | if src_modified and dst_modified and dst_modified > src_modified: 528 | logger.info(f"文件【{item_name}】目标文件修改时间晚于源文件,跳过复制") 529 | if self.move_file_action: 530 | if not self._directory_operation("remove", dir=src_dir, names=[item_name]): 531 | logger.error(f"删除源文件失败: {src_dir}") 532 | return False 533 | else: 534 | logger.error(f"删除源文件: {src_dir}") 535 | return True 536 | else: 537 | return True 538 | else: 539 | logger.info(f"文件【{item_name}】存在变更,删除并重新复制") 540 | # 删除旧文件 541 | if not self._directory_operation("remove", dir=dst_dir, names=[item_name]): 542 | logger.error(f"删除目标文件失败: {dst_path}") 543 | return False 544 | # 复制新文件 545 | return self._copy_item(src_dir, dst_dir, item_name) 546 | except Exception as e: 547 | logger.error(f"复制项目时发生错误: {str(e)}") 548 | return False 549 | 550 | 551 | def get_dir_pairs_from_env() -> List[str]: 552 | """从环境变量获取目录对列表""" 553 | dir_pairs_list = [] 554 | 555 | # 获取主DIR_PAIRS 556 | if dir_pairs := os.environ.get("DIR_PAIRS"): 557 | dir_pairs_list.extend(dir_pairs.split(";")) 558 | 559 | # 获取DIR_PAIRS1到DIR_PAIRS50 560 | for i in range(1, 51): 561 | if dir_pairs := os.environ.get(f"DIR_PAIRS{i}"): 562 | dir_pairs_list.extend(dir_pairs.split(";")) 563 | 564 | return dir_pairs_list 565 | 566 | 567 | def main(dir_pairs: str = None, sync_del_action: str = None, exclude_dirs: str = None, move_file: bool = False, 568 | regex_patterns: str = None, ): 569 | """主函数,用于命令行执行""" 570 | code_souce() 571 | xiaojin() 572 | 573 | logger.info("开始执行同步任务") 574 | # 从环境变量获取配置 0 575 | base_url = os.environ.get("BASE_URL") 576 | username = os.environ.get("USERNAME") 577 | password = os.environ.get("PASSWORD") 578 | token = os.environ.get("TOKEN") # 添加token环境变量 579 | 580 | # 是否删除目标目录多余文件 581 | if sync_del_action: 582 | sync_delete_action = sync_del_action 583 | else: 584 | sync_delete_action = os.environ.get("SYNC_DELETE_ACTION", "none") 585 | 586 | # 是否删除源目录 587 | if move_file: 588 | move_file_action = move_file 589 | else: 590 | move_file_action = os.environ.get("MOVE_FILE", "false").lower() == "true" 591 | 592 | # 删除源目录和删除多余目标目录无法同时生效 593 | if move_file_action: 594 | sync_delete_action = "none" 595 | 596 | # 排除目录 597 | if exclude_dirs: 598 | exclude_list = exclude_dirs.split(",") 599 | else: 600 | exclude_dirs = os.environ.get("EXCLUDE_DIRS", "") 601 | exclude_list = exclude_dirs.split(",") 602 | 603 | # 正则表达式 604 | if not regex_patterns: 605 | regex_patterns = os.environ.get("REGEX_PATTERNS", None) 606 | # regex_patterns_list = regex_patterns.split(" ") 607 | # else: 608 | # regex_patterns = os.environ.get("REGEX_PATTERNS", None) 609 | # regex_patterns_list = regex_patterns.split(" ") 610 | 611 | # 初始化一个空列表,用于存储编译后的正则表达式对象 612 | regex_and_replace_list: List[Pattern[str]] = [] 613 | # if regex_patterns_list: 614 | # for pattern_replacement in regex_patterns_list: 615 | # try: 616 | # compiled_pattern = re.compile(pattern_replacement) 617 | # regex_and_replace_list.append(compiled_pattern) 618 | # except re.error as e: 619 | # print(f"正则表达式 {pattern_replacement} 编译失败:{e}") 620 | regex_pattern = None 621 | try: 622 | if regex_patterns: 623 | regex_pattern = re.compile(regex_patterns) 624 | # regex_and_replace_list.append(compiled_pattern) 625 | except re.error as e: 626 | print(f"正则表达式 {regex_patterns} 编译失败:{e}") 627 | 628 | if not base_url: 629 | logger.error("服务地址(BASE_URL)环境变量未设置") 630 | return 631 | 632 | # 修改验证逻辑 633 | if not token and not (username and password): 634 | logger.error("需要设置令牌(TOKEN)或者同时设置用户名(USERNAME)和密码(PASSWORD)") 635 | return 636 | 637 | logger.info( 638 | f"配置信息 - URL: {base_url}, 用户名: {username}, 删除动作: {sync_delete_action}, 删除源目录: {move_file_action}") 639 | 640 | # 创建AlistSync实例时添加token参数 641 | alist_sync = AlistSync(base_url, username, password, token, sync_delete_action, exclude_list, move_file_action, 642 | regex_and_replace_list, regex_pattern) 643 | # 验证 token 是否正确 644 | if not alist_sync.login(): 645 | logger.error("令牌或用户名密码不正确") 646 | return False 647 | try: 648 | # 获取同步目录对 649 | dir_pairs_list = [] 650 | if dir_pairs: 651 | dir_pairs_list.extend(dir_pairs.split(";")) 652 | else: 653 | dir_pairs_list = get_dir_pairs_from_env() 654 | 655 | logger.info(f"") 656 | logger.info(f"") 657 | num = 1 658 | for pair in dir_pairs_list: 659 | logger.info(f"No.{num:02d}【{pair}】") 660 | num += 1 661 | 662 | # 执行同步 663 | i = 1 664 | for pair in dir_pairs_list: 665 | src_dir, dst_dir = pair.split(":") 666 | logger.info(f"") 667 | logger.info(f"") 668 | logger.info(f"") 669 | logger.info(f"") 670 | logger.info(f"") 671 | logger.info(f"第 [{i:02d}] 个 同步目录【{src_dir.strip()}】---->【 {dst_dir.strip()}】") 672 | logger.info(f"") 673 | logger.info(f"") 674 | i += 1 675 | alist_sync.sync_directories(src_dir.strip(), dst_dir.strip()) 676 | 677 | logger.info("所有同步任务执行完成") 678 | except Exception as e: 679 | logger.error(f"执行同步任务时发生错误: {str(e)}") 680 | finally: 681 | alist_sync.close() 682 | logger.info("关闭连接,任务结束") 683 | 684 | 685 | def code_souce(): 686 | logger.info("如果好用,请Star!非常感谢! https://gitee.com/xjxjin/alist-sync") 687 | logger.info("如果好用,请Star!非常感谢! https://github.com/xjxjin/alist-sync") 688 | logger.info("如果好用,请Star!非常感谢! https://hub.docker.com/r/xjxjin/alist-sync") 689 | 690 | 691 | def xiaojin(): 692 | pt = """ 693 | 694 | .. 695 | .... 696 | .:----=: 697 | ...:::---==-:::-+. 698 | ..:=====-=+=-::::::== .:-. 699 | .-==*=-:::::::::::::::=*-: .:-=++. 700 | .-==++++-::::::::::::::-++:-==:. .=-=::=-. 701 | ....:::-=-::-++-:::::::::::::::--:::::==: -:.:=..+: 702 | ==-------::::-==-:::::::::::::::::::::::-+-. .=: .:=-.. 703 | ==-::::+-:::::==-:::::::::::::::::::::::::=+.:+- :-: 704 | :--==+*::::::-=-::::::::::::::::::::::::::-*+: .+. 705 | ..-*:::::::==::::::::::::::::::::::::::-+. -+. 706 | -*:::::::-=-:::::::--:::::::::::::::=-. +- 707 | :*::::::::-=::::::-=:::::=:::::::::-: .*. 708 | .+=:::::::::::::::-::::-*-::......:: -- 709 | :+::-:::::::::::::::::*=:-::...... -. 710 | :-:-===-:::::::::::.:+==--:...... .+. 711 | .==:...-+#+::....... . ....... .=- 712 | -*.....::............::-. ...=- 713 | .==-:.. :=-::::::=. ..:+- 714 | .:--===---=-:::-:::--:. ..:+: 715 | =--+=:+*+:. ...... ..-+. 716 | .#. .+#- .:. .::=: 717 | -=:.-: ..::-. 718 | .-=. xjxjin ...:-: 719 | ... ...:- 720 | 721 | 722 | 723 | """ 724 | logger.info(pt) 725 | 726 | 727 | if __name__ == '__main__': 728 | main() 729 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | alist-sync-web: 5 | image: xjxjin/alist-sync:latest 6 | container_name: alist-sync 7 | restart: unless-stopped 8 | ports: 9 | - "52441:52441" 10 | volumes: 11 | - /DATA/AppData/alist-sync/data:/app/data 12 | environment: 13 | - TZ=Asia/Shanghai -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 核心依赖 2 | Flask==3.0.0 3 | APScheduler==3.10.4 4 | croniter==2.0.1 5 | 6 | # Flask 必需的依赖 7 | Werkzeug==3.0.1 8 | Jinja2==3.1.2 9 | -------------------------------------------------------------------------------- /static/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xjxjin/alist-sync/3ff42a37745f735a6769666de4cc34f1e4228d16/static/images/favicon.ico -------------------------------------------------------------------------------- /static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xjxjin/alist-sync/3ff42a37745f735a6769666de4cc34f1e4228d16/static/images/logo.png -------------------------------------------------------------------------------- /static/images/令牌.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xjxjin/alist-sync/3ff42a37745f735a6769666de4cc34f1e4228d16/static/images/令牌.png -------------------------------------------------------------------------------- /static/images/数据同步.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xjxjin/alist-sync/3ff42a37745f735a6769666de4cc34f1e4228d16/static/images/数据同步.png -------------------------------------------------------------------------------- /static/images/文件同步.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xjxjin/alist-sync/3ff42a37745f735a6769666de4cc34f1e4228d16/static/images/文件同步.png -------------------------------------------------------------------------------- /static/images/文件移动.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xjxjin/alist-sync/3ff42a37745f735a6769666de4cc34f1e4228d16/static/images/文件移动.png -------------------------------------------------------------------------------- /static/images/查看任务进度.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xjxjin/alist-sync/3ff42a37745f735a6769666de4cc34f1e4228d16/static/images/查看任务进度.png -------------------------------------------------------------------------------- /static/json/run.json: -------------------------------------------------------------------------------- 1 | { 2 | "code": 0, 3 | "msg": "", 4 | "data": [ 5 | "2020-08-30 12:14:00", 6 | "2020-08-30 14:12:00", 7 | "2020-08-31 10:14:00", 8 | "2020-08-31 18:14:00" 9 | ] 10 | } -------------------------------------------------------------------------------- /static/layui/css/modules/code.css: -------------------------------------------------------------------------------- 1 | html #layuicss-skincodecss{display:none;position:absolute;width:1989px}.layui-code-h3,.layui-code-view{position:relative;font-size:12px}.layui-code-view{display:block;margin:10px 0;padding:0;border:1px solid #eee;border-left-width:6px;background-color:#FAFAFA;color:#333;font-family:Courier New}.layui-code-h3{padding:0 10px;height:40px;line-height:40px;border-bottom:1px solid #eee}.layui-code-h3 a{position:absolute;right:10px;top:0;color:#999}.layui-code-view .layui-code-ol{position:relative;overflow:auto}.layui-code-view .layui-code-ol li{position:relative;margin-left:45px;line-height:20px;padding:0 10px;border-left:1px solid #e2e2e2;list-style-type:decimal-leading-zero;*list-style-type:decimal;background-color:#fff}.layui-code-view .layui-code-ol li:first-child{padding-top:10px}.layui-code-view .layui-code-ol li:last-child{padding-bottom:10px}.layui-code-view pre{margin:0}.layui-code-notepad{border:1px solid #0C0C0C;border-left-color:#3F3F3F;background-color:#0C0C0C;color:#C2BE9E}.layui-code-notepad .layui-code-h3{border-bottom:none}.layui-code-notepad .layui-code-ol li{background-color:#3F3F3F;border-left:none}.layui-code-demo .layui-code{visibility:visible!important;margin:-15px;border-top:none;border-right:none;border-bottom:none}.layui-code-demo .layui-tab-content{padding:15px;border-top:none} -------------------------------------------------------------------------------- /static/layui/css/modules/laydate/default/font.css: -------------------------------------------------------------------------------- 1 | /** 图标字体 **/ 2 | @font-face {font-family: 'laydate-icon'; 3 | src: url('./font/iconfont.eot'); 4 | src: url('./font/iconfont.eot#iefix') format('embedded-opentype'), 5 | url('./font/iconfont.svg#iconfont') format('svg'), 6 | url('./font/iconfont.woff') format('woff'), 7 | url('./font/iconfont.ttf') format('truetype'); 8 | } 9 | 10 | .laydate-icon{ 11 | font-family:"laydate-icon" !important; 12 | font-size: 16px; 13 | font-style: normal; 14 | -webkit-font-smoothing: antialiased; 15 | -moz-osx-font-smoothing: grayscale; 16 | } -------------------------------------------------------------------------------- /static/layui/css/modules/laydate/default/laydate.css: -------------------------------------------------------------------------------- 1 | .laydate-set-ym,.layui-laydate,.layui-laydate *,.layui-laydate-list{box-sizing:border-box}html #layuicss-laydate{display:none;position:absolute;width:1989px}.layui-laydate *{margin:0;padding:0}.layui-laydate{position:absolute;z-index:66666666;margin:5px 0;border-radius:2px;font-size:14px;-webkit-animation-duration:.2s;animation-duration:.2s;-webkit-animation-fill-mode:both;animation-fill-mode:both;animation-name:laydate-downbit}.layui-laydate-main{width:272px}.layui-laydate-content td,.layui-laydate-header *,.layui-laydate-list li{transition-duration:.3s;-webkit-transition-duration:.3s}@keyframes laydate-downbit{0%{opacity:.3;transform:translate3d(0,-5px,0)}100%{opacity:1;transform:translate3d(0,0,0)}}.layui-laydate-static{position:relative;z-index:0;display:inline-block;margin:0;-webkit-animation:none;animation:none}.laydate-ym-show .laydate-next-m,.laydate-ym-show .laydate-prev-m{display:none!important}.laydate-ym-show .laydate-next-y,.laydate-ym-show .laydate-prev-y{display:inline-block!important}.laydate-time-show .laydate-set-ym span[lay-type=month],.laydate-time-show .laydate-set-ym span[lay-type=year],.laydate-time-show .layui-laydate-header .layui-icon,.laydate-ym-show .laydate-set-ym span[lay-type=month]{display:none!important}.layui-laydate-header{position:relative;line-height:30px;padding:10px 70px 5px}.laydate-set-ym span,.layui-laydate-header i{padding:0 5px;cursor:pointer}.layui-laydate-header *{display:inline-block;vertical-align:bottom}.layui-laydate-header i{position:absolute;top:10px;color:#999;font-size:18px}.layui-laydate-header i.laydate-prev-y{left:15px}.layui-laydate-header i.laydate-prev-m{left:45px}.layui-laydate-header i.laydate-next-y{right:15px}.layui-laydate-header i.laydate-next-m{right:45px}.laydate-set-ym{width:100%;text-align:center;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.laydate-time-text{cursor:default!important}.layui-laydate-content{position:relative;padding:10px;-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none}.layui-laydate-content table{border-collapse:collapse;border-spacing:0}.layui-laydate-content td,.layui-laydate-content th{width:36px;height:30px;padding:5px;text-align:center}.layui-laydate-content td{position:relative;cursor:pointer}.laydate-day-mark{position:absolute;left:0;top:0;width:100%;height:100%;line-height:30px;font-size:12px;overflow:hidden}.laydate-day-mark::after{position:absolute;content:'';right:2px;top:2px;width:5px;height:5px;border-radius:50%}.layui-laydate-footer{position:relative;height:46px;line-height:26px;padding:10px 20px}.layui-laydate-footer span{margin-right:15px;display:inline-block;cursor:pointer;font-size:12px}.layui-laydate-footer span:hover{color:#5FB878}.laydate-footer-btns{position:absolute;right:10px;top:10px}.laydate-footer-btns span{height:26px;line-height:26px;margin:0 0 0 -1px;padding:0 10px;border:1px solid #C9C9C9;background-color:#fff;white-space:nowrap;vertical-align:top;border-radius:2px}.layui-laydate-list>li,.layui-laydate-range .layui-laydate-main{display:inline-block;vertical-align:middle}.layui-laydate-list{position:absolute;left:0;top:0;width:100%;height:100%;padding:10px;background-color:#fff}.layui-laydate-list>li{position:relative;width:33.3%;height:36px;line-height:36px;margin:3px 0;text-align:center;cursor:pointer}.laydate-month-list>li{width:25%;margin:17px 0}.laydate-time-list>li{height:100%;margin:0;line-height:normal;cursor:default}.laydate-time-list p{position:relative;top:-4px;line-height:29px}.laydate-time-list ol{height:181px;overflow:hidden}.laydate-time-list>li:hover ol{overflow-y:auto}.laydate-time-list ol li{width:130%;padding-left:33px;line-height:30px;text-align:left;cursor:pointer}.layui-laydate-hint{position:absolute;top:115px;left:50%;width:250px;margin-left:-125px;line-height:20px;padding:15px;text-align:center;font-size:12px}.layui-laydate-range{width:546px}.layui-laydate-range .laydate-main-list-1 .layui-laydate-content,.layui-laydate-range .laydate-main-list-1 .layui-laydate-header{border-left:1px solid #e2e2e2}.layui-laydate,.layui-laydate-hint{border:1px solid #d2d2d2;box-shadow:0 2px 4px rgba(0,0,0,.12);background-color:#fff;color:#666}.layui-laydate-header{border-bottom:1px solid #e2e2e2}.layui-laydate-header i:hover,.layui-laydate-header span:hover{color:#5FB878}.layui-laydate-content{border-top:none 0;border-bottom:none 0}.layui-laydate-content th{font-weight:400;color:#333}.layui-laydate-content td{color:#666}.layui-laydate-content td.laydate-selected{background-color:#B5FFF8}.laydate-selected:hover{background-color:#00F7DE!important}.layui-laydate-content td:hover,.layui-laydate-list li:hover{background-color:#eee;color:#333}.laydate-time-list li ol{margin:0;padding:0;border:1px solid #e2e2e2;border-left-width:0}.laydate-time-list li:first-child ol{border-left-width:1px}.laydate-time-list>li:hover{background:0 0}.layui-laydate-content .laydate-day-next,.layui-laydate-content .laydate-day-prev{color:#d2d2d2}.laydate-selected.laydate-day-next,.laydate-selected.laydate-day-prev{background-color:#f8f8f8!important}.layui-laydate-footer{border-top:1px solid #e2e2e2}.layui-laydate-hint{color:#FF5722}.laydate-day-mark::after{background-color:#5FB878}.layui-laydate-content td.layui-this .laydate-day-mark::after{display:none}.layui-laydate-footer span[lay-type=date]{color:#5FB878}.layui-laydate .layui-this{background-color:#009688!important;color:#fff!important}.layui-laydate .laydate-disabled,.layui-laydate .laydate-disabled:hover{background:0 0!important;color:#d2d2d2!important;cursor:not-allowed!important;-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none}.laydate-theme-molv{border:none}.laydate-theme-molv.layui-laydate-range{width:548px}.laydate-theme-molv .layui-laydate-main{width:274px}.laydate-theme-molv .layui-laydate-header{border:none;background-color:#009688}.laydate-theme-molv .layui-laydate-header i,.laydate-theme-molv .layui-laydate-header span{color:#f6f6f6}.laydate-theme-molv .layui-laydate-header i:hover,.laydate-theme-molv .layui-laydate-header span:hover{color:#fff}.laydate-theme-molv .layui-laydate-content{border:1px solid #e2e2e2;border-top:none;border-bottom:none}.laydate-theme-molv .laydate-main-list-1 .layui-laydate-content{border-left:none}.laydate-theme-grid .laydate-month-list>li,.laydate-theme-grid .laydate-year-list>li,.laydate-theme-grid .layui-laydate-content td,.laydate-theme-grid .layui-laydate-content thead,.laydate-theme-molv .layui-laydate-footer{border:1px solid #e2e2e2}.laydate-theme-grid .laydate-selected,.laydate-theme-grid .laydate-selected:hover{background-color:#f2f2f2!important;color:#009688!important}.laydate-theme-grid .laydate-selected.laydate-day-next,.laydate-theme-grid .laydate-selected.laydate-day-prev{color:#d2d2d2!important}.laydate-theme-grid .laydate-month-list,.laydate-theme-grid .laydate-year-list{margin:1px 0 0 1px}.laydate-theme-grid .laydate-month-list>li,.laydate-theme-grid .laydate-year-list>li{margin:0 -1px -1px 0}.laydate-theme-grid .laydate-year-list>li{height:43px;line-height:43px}.laydate-theme-grid .laydate-month-list>li{height:71px;line-height:71px} -------------------------------------------------------------------------------- /static/layui/css/modules/layer/default/icon-ext.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xjxjin/alist-sync/3ff42a37745f735a6769666de4cc34f1e4228d16/static/layui/css/modules/layer/default/icon-ext.png -------------------------------------------------------------------------------- /static/layui/css/modules/layer/default/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xjxjin/alist-sync/3ff42a37745f735a6769666de4cc34f1e4228d16/static/layui/css/modules/layer/default/icon.png -------------------------------------------------------------------------------- /static/layui/css/modules/layer/default/layer.css: -------------------------------------------------------------------------------- 1 | .layui-layer-imgbar,.layui-layer-imgtit a,.layui-layer-tab .layui-layer-title span,.layui-layer-title{text-overflow:ellipsis;white-space:nowrap}html #layuicss-layer{display:none;position:absolute;width:1989px}.layui-layer,.layui-layer-shade{position:fixed;_position:absolute;pointer-events:auto}.layui-layer-shade{top:0;left:0;width:100%;height:100%;_height:expression(document.body.offsetHeight+"px")}.layui-layer{-webkit-overflow-scrolling:touch;top:150px;left:0;margin:0;padding:0;background-color:#fff;-webkit-background-clip:content;border-radius:2px;box-shadow:1px 1px 50px rgba(0,0,0,.3)}.layui-layer-close{position:absolute}.layui-layer-content{position:relative}.layui-layer-border{border:1px solid #B2B2B2;border:1px solid rgba(0,0,0,.1);box-shadow:1px 1px 5px rgba(0,0,0,.2)}.layui-layer-load{background:url(loading-1.gif) center center no-repeat #eee}.layui-layer-ico{background:url(icon.png) no-repeat}.layui-layer-btn a,.layui-layer-dialog .layui-layer-ico,.layui-layer-setwin a{display:inline-block;*display:inline;*zoom:1;vertical-align:top}.layui-layer-move{display:none;position:fixed;*position:absolute;left:0;top:0;width:100%;height:100%;cursor:move;opacity:0;filter:alpha(opacity=0);background-color:#fff;z-index:2147483647}.layui-layer-resize{position:absolute;width:15px;height:15px;right:0;bottom:0;cursor:se-resize}.layer-anim{-webkit-animation-fill-mode:both;animation-fill-mode:both;-webkit-animation-duration:.3s;animation-duration:.3s}@-webkit-keyframes layer-bounceIn{0%{opacity:0;-webkit-transform:scale(.5);transform:scale(.5)}100%{opacity:1;-webkit-transform:scale(1);transform:scale(1)}}@keyframes layer-bounceIn{0%{opacity:0;-webkit-transform:scale(.5);-ms-transform:scale(.5);transform:scale(.5)}100%{opacity:1;-webkit-transform:scale(1);-ms-transform:scale(1);transform:scale(1)}}.layer-anim-00{-webkit-animation-name:layer-bounceIn;animation-name:layer-bounceIn}@-webkit-keyframes layer-zoomInDown{0%{opacity:0;-webkit-transform:scale(.1) translateY(-2000px);transform:scale(.1) translateY(-2000px);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}60%{opacity:1;-webkit-transform:scale(.475) translateY(60px);transform:scale(.475) translateY(60px);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}}@keyframes layer-zoomInDown{0%{opacity:0;-webkit-transform:scale(.1) translateY(-2000px);-ms-transform:scale(.1) translateY(-2000px);transform:scale(.1) translateY(-2000px);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}60%{opacity:1;-webkit-transform:scale(.475) translateY(60px);-ms-transform:scale(.475) translateY(60px);transform:scale(.475) translateY(60px);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}}.layer-anim-01{-webkit-animation-name:layer-zoomInDown;animation-name:layer-zoomInDown}@-webkit-keyframes layer-fadeInUpBig{0%{opacity:0;-webkit-transform:translateY(2000px);transform:translateY(2000px)}100%{opacity:1;-webkit-transform:translateY(0);transform:translateY(0)}}@keyframes layer-fadeInUpBig{0%{opacity:0;-webkit-transform:translateY(2000px);-ms-transform:translateY(2000px);transform:translateY(2000px)}100%{opacity:1;-webkit-transform:translateY(0);-ms-transform:translateY(0);transform:translateY(0)}}.layer-anim-02{-webkit-animation-name:layer-fadeInUpBig;animation-name:layer-fadeInUpBig}@-webkit-keyframes layer-zoomInLeft{0%{opacity:0;-webkit-transform:scale(.1) translateX(-2000px);transform:scale(.1) translateX(-2000px);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}60%{opacity:1;-webkit-transform:scale(.475) translateX(48px);transform:scale(.475) translateX(48px);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}}@keyframes layer-zoomInLeft{0%{opacity:0;-webkit-transform:scale(.1) translateX(-2000px);-ms-transform:scale(.1) translateX(-2000px);transform:scale(.1) translateX(-2000px);-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}60%{opacity:1;-webkit-transform:scale(.475) translateX(48px);-ms-transform:scale(.475) translateX(48px);transform:scale(.475) translateX(48px);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}}.layer-anim-03{-webkit-animation-name:layer-zoomInLeft;animation-name:layer-zoomInLeft}@-webkit-keyframes layer-rollIn{0%{opacity:0;-webkit-transform:translateX(-100%) rotate(-120deg);transform:translateX(-100%) rotate(-120deg)}100%{opacity:1;-webkit-transform:translateX(0) rotate(0);transform:translateX(0) rotate(0)}}@keyframes layer-rollIn{0%{opacity:0;-webkit-transform:translateX(-100%) rotate(-120deg);-ms-transform:translateX(-100%) rotate(-120deg);transform:translateX(-100%) rotate(-120deg)}100%{opacity:1;-webkit-transform:translateX(0) rotate(0);-ms-transform:translateX(0) rotate(0);transform:translateX(0) rotate(0)}}.layer-anim-04{-webkit-animation-name:layer-rollIn;animation-name:layer-rollIn}@keyframes layer-fadeIn{0%{opacity:0}100%{opacity:1}}.layer-anim-05{-webkit-animation-name:layer-fadeIn;animation-name:layer-fadeIn}@-webkit-keyframes layer-shake{0%,100%{-webkit-transform:translateX(0);transform:translateX(0)}10%,30%,50%,70%,90%{-webkit-transform:translateX(-10px);transform:translateX(-10px)}20%,40%,60%,80%{-webkit-transform:translateX(10px);transform:translateX(10px)}}@keyframes layer-shake{0%,100%{-webkit-transform:translateX(0);-ms-transform:translateX(0);transform:translateX(0)}10%,30%,50%,70%,90%{-webkit-transform:translateX(-10px);-ms-transform:translateX(-10px);transform:translateX(-10px)}20%,40%,60%,80%{-webkit-transform:translateX(10px);-ms-transform:translateX(10px);transform:translateX(10px)}}.layer-anim-06{-webkit-animation-name:layer-shake;animation-name:layer-shake}@-webkit-keyframes fadeIn{0%{opacity:0}100%{opacity:1}}.layui-layer-title{padding:0 80px 0 20px;height:50px;line-height:50px;border-bottom:1px solid #F0F0F0;font-size:14px;color:#333;overflow:hidden;border-radius:2px 2px 0 0}.layui-layer-setwin{position:absolute;right:15px;*right:0;top:17px;font-size:0;line-height:initial}.layui-layer-setwin a{position:relative;width:16px;height:16px;margin-left:10px;font-size:12px;_overflow:hidden}.layui-layer-setwin .layui-layer-min cite{position:absolute;width:14px;height:2px;left:0;top:50%;margin-top:-1px;background-color:#2E2D3C;cursor:pointer;_overflow:hidden}.layui-layer-setwin .layui-layer-min:hover cite{background-color:#2D93CA}.layui-layer-setwin .layui-layer-max{background-position:-32px -40px}.layui-layer-setwin .layui-layer-max:hover{background-position:-16px -40px}.layui-layer-setwin .layui-layer-maxmin{background-position:-65px -40px}.layui-layer-setwin .layui-layer-maxmin:hover{background-position:-49px -40px}.layui-layer-setwin .layui-layer-close1{background-position:1px -40px;cursor:pointer}.layui-layer-setwin .layui-layer-close1:hover{opacity:.7}.layui-layer-setwin .layui-layer-close2{position:absolute;right:-28px;top:-28px;width:30px;height:30px;margin-left:0;background-position:-149px -31px;*right:-18px;_display:none}.layui-layer-setwin .layui-layer-close2:hover{background-position:-180px -31px}.layui-layer-btn{text-align:right;padding:0 15px 12px;pointer-events:auto;user-select:none;-webkit-user-select:none}.layui-layer-btn a{height:28px;line-height:28px;margin:5px 5px 0;padding:0 15px;border:1px solid #dedede;background-color:#fff;color:#333;border-radius:2px;font-weight:400;cursor:pointer;text-decoration:none}.layui-layer-btn a:hover{opacity:.9;text-decoration:none}.layui-layer-btn a:active{opacity:.8}.layui-layer-btn .layui-layer-btn0{border-color:#1E9FFF;background-color:#1E9FFF;color:#fff}.layui-layer-btn-l{text-align:left}.layui-layer-btn-c{text-align:center}.layui-layer-dialog{min-width:300px}.layui-layer-dialog .layui-layer-content{position:relative;padding:20px;line-height:24px;word-break:break-all;overflow:hidden;font-size:14px;overflow-x:hidden;overflow-y:auto}.layui-layer-dialog .layui-layer-content .layui-layer-ico{position:absolute;top:16px;left:15px;_left:-40px;width:30px;height:30px}.layui-layer-ico1{background-position:-30px 0}.layui-layer-ico2{background-position:-60px 0}.layui-layer-ico3{background-position:-90px 0}.layui-layer-ico4{background-position:-120px 0}.layui-layer-ico5{background-position:-150px 0}.layui-layer-ico6{background-position:-180px 0}.layui-layer-rim{border:6px solid #8D8D8D;border:6px solid rgba(0,0,0,.3);border-radius:5px;box-shadow:none}.layui-layer-msg{min-width:180px;border:1px solid #D3D4D3;box-shadow:none}.layui-layer-hui{min-width:100px;background-color:#000;filter:alpha(opacity=60);background-color:rgba(0,0,0,.6);color:#fff;border:none}.layui-layer-hui .layui-layer-content{padding:12px 25px;text-align:center}.layui-layer-dialog .layui-layer-padding{padding:20px 20px 20px 55px;text-align:left}.layui-layer-page .layui-layer-content{position:relative;overflow:auto}.layui-layer-iframe .layui-layer-btn,.layui-layer-page .layui-layer-btn{padding-top:10px}.layui-layer-nobg{background:0 0}.layui-layer-iframe iframe{display:block;width:100%}.layui-layer-loading{border-radius:100%;background:0 0;box-shadow:none;border:none}.layui-layer-loading .layui-layer-content{width:60px;height:24px;background:url(loading-0.gif) no-repeat}.layui-layer-loading .layui-layer-loading1{width:37px;height:37px;background:url(loading-1.gif) no-repeat}.layui-layer-ico16,.layui-layer-loading .layui-layer-loading2{width:32px;height:32px;background:url(loading-2.gif) no-repeat}.layui-layer-tips{background:0 0;box-shadow:none;border:none}.layui-layer-tips .layui-layer-content{position:relative;line-height:22px;min-width:12px;padding:8px 15px;font-size:12px;_float:left;border-radius:2px;box-shadow:1px 1px 3px rgba(0,0,0,.2);background-color:#000;color:#fff}.layui-layer-tips .layui-layer-close{right:-2px;top:-1px}.layui-layer-tips i.layui-layer-TipsG{position:absolute;width:0;height:0;border-width:8px;border-color:transparent;border-style:dashed;*overflow:hidden}.layui-layer-tips i.layui-layer-TipsB,.layui-layer-tips i.layui-layer-TipsT{left:5px;border-right-style:solid;border-right-color:#000}.layui-layer-tips i.layui-layer-TipsT{bottom:-8px}.layui-layer-tips i.layui-layer-TipsB{top:-8px}.layui-layer-tips i.layui-layer-TipsL,.layui-layer-tips i.layui-layer-TipsR{top:5px;border-bottom-style:solid;border-bottom-color:#000}.layui-layer-tips i.layui-layer-TipsR{left:-8px}.layui-layer-tips i.layui-layer-TipsL{right:-8px}.layui-layer-lan[type=dialog]{min-width:280px}.layui-layer-lan .layui-layer-title{background:#4476A7;color:#fff;border:none}.layui-layer-lan .layui-layer-btn{padding:5px 10px 10px;text-align:right;border-top:1px solid #E9E7E7}.layui-layer-lan .layui-layer-btn a{background:#fff;border-color:#E9E7E7;color:#333}.layui-layer-lan .layui-layer-btn .layui-layer-btn1{background:#C9C5C5}.layui-layer-molv .layui-layer-title{background:#009f95;color:#fff;border:none}.layui-layer-molv .layui-layer-btn a{background:#009f95;border-color:#009f95}.layui-layer-molv .layui-layer-btn .layui-layer-btn1{background:#92B8B1}.layui-layer-iconext{background:url(icon-ext.png) no-repeat}.layui-layer-prompt .layui-layer-input{display:block;width:260px;height:36px;margin:0 auto;line-height:30px;padding-left:10px;border:1px solid #e6e6e6;color:#333}.layui-layer-prompt textarea.layui-layer-input{width:300px;height:100px;line-height:20px;padding:6px 10px}.layui-layer-prompt .layui-layer-content{padding:20px}.layui-layer-prompt .layui-layer-btn{padding-top:0}.layui-layer-tab{box-shadow:1px 1px 50px rgba(0,0,0,.4)}.layui-layer-tab .layui-layer-title{padding-left:0;overflow:visible}.layui-layer-tab .layui-layer-title span{position:relative;float:left;min-width:80px;max-width:300px;padding:0 20px;text-align:center;overflow:hidden;cursor:pointer}.layui-layer-tab .layui-layer-title span.layui-this{height:51px;border-left:1px solid #eee;border-right:1px solid #eee;background-color:#fff;z-index:10}.layui-layer-tab .layui-layer-title span:first-child{border-left:none}.layui-layer-tabmain{line-height:24px;clear:both}.layui-layer-tabmain .layui-layer-tabli{display:none}.layui-layer-tabmain .layui-layer-tabli.layui-this{display:block}.layui-layer-photos{-webkit-animation-duration:.8s;animation-duration:.8s}.layui-layer-photos .layui-layer-content{overflow:hidden;text-align:center}.layui-layer-photos .layui-layer-phimg img{position:relative;width:100%;display:inline-block;*display:inline;*zoom:1;vertical-align:top}.layui-layer-imgbar,.layui-layer-imguide{display:none}.layui-layer-imgnext,.layui-layer-imgprev{position:absolute;top:50%;width:27px;_width:44px;height:44px;margin-top:-22px;outline:0;blr:expression(this.onFocus=this.blur())}.layui-layer-imgprev{left:10px;background-position:-5px -5px;_background-position:-70px -5px}.layui-layer-imgprev:hover{background-position:-33px -5px;_background-position:-120px -5px}.layui-layer-imgnext{right:10px;_right:8px;background-position:-5px -50px;_background-position:-70px -50px}.layui-layer-imgnext:hover{background-position:-33px -50px;_background-position:-120px -50px}.layui-layer-imgbar{position:absolute;left:0;bottom:0;width:100%;height:32px;line-height:32px;background-color:rgba(0,0,0,.8);background-color:#000\9;filter:Alpha(opacity=80);color:#fff;overflow:hidden;font-size:0}.layui-layer-imgtit *{display:inline-block;*display:inline;*zoom:1;vertical-align:top;font-size:12px}.layui-layer-imgtit a{max-width:65%;overflow:hidden;color:#fff}.layui-layer-imgtit a:hover{color:#fff;text-decoration:underline}.layui-layer-imgtit em{padding-left:10px;font-style:normal}@-webkit-keyframes layer-bounceOut{100%{opacity:0;-webkit-transform:scale(.7);transform:scale(.7)}30%{-webkit-transform:scale(1.05);transform:scale(1.05)}0%{-webkit-transform:scale(1);transform:scale(1)}}@keyframes layer-bounceOut{100%{opacity:0;-webkit-transform:scale(.7);-ms-transform:scale(.7);transform:scale(.7)}30%{-webkit-transform:scale(1.05);-ms-transform:scale(1.05);transform:scale(1.05)}0%{-webkit-transform:scale(1);-ms-transform:scale(1);transform:scale(1)}}.layer-anim-close{-webkit-animation-name:layer-bounceOut;animation-name:layer-bounceOut;-webkit-animation-fill-mode:both;animation-fill-mode:both;-webkit-animation-duration:.2s;animation-duration:.2s}@media screen and (max-width:1100px){.layui-layer-iframe{overflow-y:auto;-webkit-overflow-scrolling:touch}} -------------------------------------------------------------------------------- /static/layui/css/modules/layer/default/loading-0.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xjxjin/alist-sync/3ff42a37745f735a6769666de4cc34f1e4228d16/static/layui/css/modules/layer/default/loading-0.gif -------------------------------------------------------------------------------- /static/layui/css/modules/layer/default/loading-1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xjxjin/alist-sync/3ff42a37745f735a6769666de4cc34f1e4228d16/static/layui/css/modules/layer/default/loading-1.gif -------------------------------------------------------------------------------- /static/layui/css/modules/layer/default/loading-2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xjxjin/alist-sync/3ff42a37745f735a6769666de4cc34f1e4228d16/static/layui/css/modules/layer/default/loading-2.gif -------------------------------------------------------------------------------- /static/layui/font/iconfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xjxjin/alist-sync/3ff42a37745f735a6769666de4cc34f1e4228d16/static/layui/font/iconfont.eot -------------------------------------------------------------------------------- /static/layui/font/iconfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xjxjin/alist-sync/3ff42a37745f735a6769666de4cc34f1e4228d16/static/layui/font/iconfont.ttf -------------------------------------------------------------------------------- /static/layui/font/iconfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xjxjin/alist-sync/3ff42a37745f735a6769666de4cc34f1e4228d16/static/layui/font/iconfont.woff -------------------------------------------------------------------------------- /static/layui/font/iconfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xjxjin/alist-sync/3ff42a37745f735a6769666de4cc34f1e4228d16/static/layui/font/iconfont.woff2 -------------------------------------------------------------------------------- /static/layui_exts/cron/cron.css: -------------------------------------------------------------------------------- 1 | /** 2 | @ Name:layui.cron Cron表达式解析器 3 | @ Author:贝哥哥 4 | @ License:MIT 5 | */ 6 | 7 | 8 | /* 样式加载完毕的标识 */ 9 | html #layuicss-cron { 10 | display: none; 11 | position: absolute; 12 | width: 1989px; 13 | } 14 | 15 | /* 初始化 */ 16 | .layui-cron * { 17 | margin: 0; 18 | padding: 0; 19 | } 20 | 21 | /* 主体结构 */ 22 | .layui-cron, 23 | .layui-cron * { 24 | box-sizing: border-box; 25 | } 26 | 27 | .layui-cron { 28 | position: absolute; 29 | z-index: 66666666; 30 | margin: 5px 0; 31 | border-radius: 2px; 32 | font-size: 14px; 33 | -webkit-animation-duration: 0.2s; 34 | animation-duration: 0.2s; 35 | -webkit-animation-fill-mode: both; 36 | animation-fill-mode: both; 37 | } 38 | 39 | /* .layui-cron-main{width: 272px;} */ 40 | .layui-cron-header *, 41 | .layui-cron-content .btn { 42 | transition-duration: .3s; 43 | -webkit-transition-duration: .3s; 44 | } 45 | 46 | /* 微微往下滑入 */ 47 | @keyframes cron-downbit { 48 | 0% { 49 | opacity: 0.3; 50 | transform: translate3d(0, -5px, 0); 51 | } 52 | 53 | 100% { 54 | opacity: 1; 55 | transform: translate3d(0, 0, 0); 56 | } 57 | } 58 | 59 | 60 | .layui-cron{animation-name: cron-downbit;} 61 | .layui-cron-static{ position: relative; z-index: 0; display: inline-block; margin: 0; -webkit-animation: none; animation: none;} 62 | 63 | 64 | /* 主体结构 */ 65 | .layui-cron-content{position: relative; padding: 10px; -moz-user-select: none; -webkit-user-select: none; -ms-user-select: none;} 66 | 67 | 68 | /* 底部结构 */ 69 | .layui-cron-footer{position: relative; height: 46px; line-height: 26px; padding: 10px 20px;border-top: 1px solid whitesmoke;} 70 | .layui-cron-footer span{margin-right: 15px; display: inline-block; cursor: pointer; font-size: 12px;} 71 | .layui-cron-footer span:hover{color: #5FB878;} 72 | .cron-footer-btns{position: absolute; right: 10px; top: 10px;} 73 | .cron-footer-btns span{height: 26px; line-height: 26px; margin: 0 0 0 -1px; padding: 0 10px; border: 1px solid #C9C9C9; background-color: #fff; white-space: nowrap; vertical-align: top; border-radius: 2px;} 74 | 75 | 76 | /* 提示 */ 77 | .layui-cron-hint{position: absolute; top: 115px; left: 50%; width: 250px; margin-left: -125px; line-height: 20px; padding: 15px; text-align: center; font-size: 12px; color: #FF5722;} 78 | 79 | 80 | /* 默认简约主题 */ 81 | .layui-cron, .layui-cron-hint{border: 1px solid #d2d2d2; box-shadow: 0 2px 4px rgba(0,0,0,.12); background-color: #fff; color: #666;} 82 | .layui-cron-content{border-top: none 0; border-bottom: none 0;} 83 | 84 | /* tab */ 85 | .layui-cron .layui-tab-card{ 86 | border: none; 87 | box-shadow: none; 88 | } 89 | 90 | .layui-cron .layui-tab-card > .layui-tab-title li{ 91 | min-width: 70px; 92 | margin-left: 0; 93 | margin-right: 0; 94 | } 95 | .layui-cron .layui-tab-content{ 96 | padding: 10px; 97 | height: 230px; 98 | overflow-y: scroll; 99 | } 100 | 101 | /* form */ 102 | .layui-cron .cron-input-mid { 103 | display: inline-block; 104 | vertical-align: middle; 105 | background-color: #e5e5e5; 106 | padding: 0 12px; 107 | height: 28px; 108 | line-height: 28px; 109 | border: 1px solid #ccc; 110 | box-sizing: border-box; 111 | } 112 | .layui-cron .cron-input { 113 | display: inline-block; 114 | vertical-align: middle; 115 | padding: 0 8px; 116 | background-color: #fff; 117 | border: 1px solid #ccc; 118 | height: 28px; 119 | line-height: 28px; 120 | box-sizing: border-box; 121 | width: 48px; 122 | text-align: right; 123 | -webkit-transition: border-color ease-in-out .15s, -webkit-box-shadow ease-in-out .15s; 124 | -o-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s; 125 | transition: border-color ease-in-out .15s, -webkit-box-shadow ease-in-out .15s; 126 | transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s; 127 | transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s, -webkit-box-shadow ease-in-out .15s; 128 | } 129 | 130 | /* 谷歌 */ 131 | .layui-cron input::-webkit-outer-spin-button, 132 | .layui-cron input::-webkit-inner-spin-button { 133 | -webkit-appearance: none; 134 | } 135 | 136 | /* 火狐 */ 137 | .layui-cron input[type="number"]{ 138 | -moz-appearance: textfield; 139 | } 140 | 141 | .layui-cron .cron-input:focus { 142 | outline: 0; 143 | border: 1px solid #01AAED; 144 | box-shadow: inset 0 1px 1px rgb(0 0 0 / 8%), 0 0 4px 0px #01aaed; 145 | translate: 1s; 146 | } 147 | .layui-cron .cron-tips { 148 | color: grey; 149 | line-height: 28px; 150 | height: 28px; 151 | display: inline-block; 152 | vertical-align: middle; 153 | margin-left: 5px; 154 | } 155 | 156 | .layui-cron .layui-form-radio{ 157 | margin-right: 10px; 158 | } 159 | 160 | .layui-cron .cron-row{ 161 | display: flex; 162 | align-items: center; 163 | } 164 | .layui-cron .cron-row+.cron-row{ 165 | margin-top: 10px; 166 | } 167 | 168 | .layui-cron .cron-grid{ 169 | display: flex; 170 | flex-wrap: wrap; 171 | align-items: center; 172 | width: 480px; 173 | padding-left: 10px; 174 | padding-top: 4px; 175 | } 176 | 177 | .layui-cron .cron-grid .layui-form-checkbox{ 178 | padding-left: 22px; 179 | margin-bottom: 4px; 180 | } 181 | .layui-cron .cron-grid .layui-form-checkbox[lay-skin="primary"] span{ 182 | padding-right: 13px; 183 | min-width: 29px; 184 | } 185 | 186 | /* 提示 */ 187 | .layui-cron-hint{position: absolute; top: 115px; left: 50%; width: 250px; margin-left: -125px; line-height: 20px; padding: 15px; text-align: center; font-size: 12px; color: #FF5722;} 188 | 189 | .layui-cron-run-hint{ 190 | max-height: 104px; 191 | overflow-y: scroll; 192 | padding: 10px; 193 | padding-top: 0; 194 | } 195 | .cron-run-list+.cron-run-list{ 196 | margin-top: 4px; 197 | } -------------------------------------------------------------------------------- /static/layui_exts/cron/cron.js: -------------------------------------------------------------------------------- 1 | /** 2 | @ Name:layui.cron Cron表达式解析器 3 | @ Author:贝哥哥 4 | @ License:MIT 5 | */ 6 | 7 | layui.define(['lay', 'element', 'form'], function(exports){ //假如该组件依赖 layui.form 8 | var $ = layui.$ 9 | ,layer = layui.layer 10 | ,lay = layui.lay 11 | ,element = layui.element 12 | ,form = layui.form 13 | 14 | 15 | //字符常量 16 | ,MOD_NAME = 'cron', ELEM = '.layui-cron', THIS = 'layui-this', SHOW = 'layui-show', HIDE = 'layui-hide' 17 | 18 | ,ELEM_STATIC = 'layui-cron-static', ELEM_FOOTER = 'layui-cron-footer', ELEM_CONFIRM = '.cron-btns-confirm', ELEM_HINT = 'layui-cron-hint' 19 | 20 | ,ELEM_RUN_HINT = 'layui-cron-run-hint' 21 | 22 | //外部接口 23 | ,cron = { 24 | v:'2.0.0' // cron 组件当前版本 25 | ,index: layui.cron ? (layui.cron.index + 10000) : 0 // corn 实例标识 26 | 27 | //设置全局项 28 | ,set: function(options){ 29 | var that = this; 30 | that.config = $.extend({}, that.config, options); 31 | return that; 32 | } 33 | 34 | //事件监听 35 | ,on: function(events, callback){ 36 | return layui.onevent.call(this, MOD_NAME, events, callback); 37 | } 38 | 39 | //主体CSS等待事件 40 | ,ready: function (fn) { 41 | var cssPath = layui.cache.base + "cron/cron.css?v=" + cron.v; 42 | layui.link(cssPath, fn, "cron"); //此处的“cron”要对应 cron.css 中的样式: html #layuicss-cron{} 43 | return this; 44 | } 45 | } 46 | 47 | //操作当前实例 48 | ,thisIns = function(){ 49 | var that = this 50 | ,options = that.config 51 | ,id = options.id || options.index; 52 | 53 | return { 54 | //提示框 55 | hint: function(content){ 56 | that.hint.call(that, content); 57 | } 58 | ,config: options 59 | } 60 | } 61 | 62 | //构造器,创建实例 63 | ,Class = function(options){ 64 | var that = this; 65 | that.index = ++cron.index; 66 | that.config = $.extend({}, that.config, cron.config, options); 67 | cron.ready(function () { 68 | that.init(); 69 | }); 70 | }; 71 | 72 | //默认配置 73 | Class.prototype.config = { 74 | value: null // 当前表达式值,每秒执行一次 75 | ,isInitValue: true //用于控制是否自动向元素填充初始值(需配合 value 参数使用) 76 | ,lang: "cn" //语言,只支持cn/en,即中文和英文 77 | ,tabs:[{key:'seconds',range:'0-59'},{key:'minutes',range:'0-59'},{key:'hours',range:'0-23'},{key:'days',range:'1-31'},{key:'months',range:'1-12'},{key:'weeks',range:'1-7'},{key:'years'}] 78 | ,defaultCron: {seconds:"*",minutes:"*",hours:"*",days:"*", months:"*", weeks:"?", years:""} 79 | ,trigger: "click" //呼出控件的事件 80 | ,btns: ['run', 'confirm'] //右下角显示的按钮,会按照数组顺序排列 81 | ,position: null //控件定位方式定位, 默认absolute,支持:fixed/absolute/static 82 | ,zIndex: null //控件层叠顺序 83 | ,show: false //是否直接显示,如果设置 true,则默认直接显示控件 84 | ,showBottom: true //是否显示底部栏 85 | ,done: null //控件选择完毕后的回调,点击运行/确定也均会触发 86 | ,run: null // 最近运行时间接口 87 | }; 88 | 89 | //多语言 90 | Class.prototype.lang = function(){ 91 | var that = this 92 | ,options = that.config 93 | ,text = { 94 | cn: { 95 | tabs: [{title: "秒"} 96 | , {title: "分"} 97 | , {title: "时"} 98 | , {title: "日"} 99 | , {title: "月"} 100 | , {title: "周", rateBegin: "第", rateMid: "周的星期", rateEnd:""} 101 | , {title: "年"}] 102 | , every: "每" 103 | , unspecified: "不指定" 104 | , period: "周期" 105 | , periodFrom: "从" 106 | , rate: "按照" 107 | , rateBegin: "从" 108 | , rateMid: "开始,每" 109 | , rateEnd: "执行一次" 110 | , weekday: "工作日" 111 | , weekdayPrefix: "每月" 112 | , weekdaySuffix: "号最近的那个工作日" 113 | , lastday: "本月最后一日" 114 | , lastweek: "本月最后一个星期" 115 | , custom: "指定" 116 | ,tools: { 117 | confirm: '确定' 118 | ,run: '运行' 119 | } 120 | ,formatError: ['Cron格式不合法', '
已为你重置'] 121 | } 122 | ,en: { 123 | tabs: [{title:"Seconds"} 124 | , {title:"Minutes"} 125 | , {title:"Hours"} 126 | , {title:"Days"} 127 | , {title:"Months"} 128 | , {title:"Weeks"} 129 | , {title:"Years"}] 130 | , every:"Every " 131 | , unspecified:"Unspecified" 132 | , period:"Period" 133 | , periodFrom: "From" 134 | , rate: "According to" 135 | , rateBegin: "begin at" 136 | , rateMid: ", every" 137 | , rateEnd: " execute once" 138 | , weekday: "Weekday" 139 | , weekdayPrefix: "Every month at " 140 | , weekdaySuffix: "号最近的那个工作日" 141 | , lastday: "Last day of the month" 142 | , lastweek: "本月最后一个星期" 143 | , custom: "Custom" 144 | ,tools: { 145 | confirm: 'Confirm' 146 | ,run: 'Run' 147 | } 148 | ,formatError: ['The cron format error', '
It has been reset'] 149 | } 150 | }; 151 | return text[options.lang] || text['cn']; 152 | }; 153 | 154 | //初始准备 155 | Class.prototype.init = function(){ 156 | var that = this 157 | ,options = that.config 158 | ,isStatic = options.position === 'static'; 159 | 160 | options.elem = lay(options.elem); 161 | 162 | options.eventElem = lay(options.eventElem); 163 | 164 | if(!options.elem[0]) return; 165 | 166 | //如果不是input|textarea元素,则默认采用click事件 167 | if(!that.isInput(options.elem[0])){ 168 | if(options.trigger === 'focus'){ 169 | options.trigger = 'click'; 170 | } 171 | } 172 | 173 | // 设置渲染所绑定元素的唯一KEY 174 | if(!options.elem.attr('lay-key')){ 175 | options.elem.attr('lay-key', that.index); 176 | options.eventElem.attr('lay-key', that.index); 177 | } 178 | 179 | // 当前实例主面板ID 180 | that.elemID = 'layui-icon'+ options.elem.attr('lay-key'); 181 | 182 | //默认赋值 183 | if(options.value && options.isInitValue){ 184 | that.setValue(options.value); 185 | } 186 | if(!options.value){ 187 | options.value = options.elem[0].value||''; 188 | } 189 | var cronArr = options.value.split(' '); 190 | if(cronArr.length >= 6){ 191 | options.cron = { 192 | seconds:cronArr[0], 193 | minutes:cronArr[1], 194 | hours:cronArr[2], 195 | days:cronArr[3], 196 | months:cronArr[4], 197 | weeks:cronArr[5], 198 | years:"", 199 | }; 200 | }else{ 201 | options.cron = lay.extend({},options.defaultCron); 202 | } 203 | 204 | 205 | if(options.show || isStatic) that.render(); 206 | isStatic || that.events(); 207 | 208 | 209 | }; 210 | 211 | 212 | // 控件主体渲染 213 | Class.prototype.render = function(){ 214 | var that = this 215 | ,options = that.config 216 | ,lang = that.lang() 217 | ,isStatic = options.position === 'static' 218 | ,tabFilter = 'cron-tab' + options.elem.attr('lay-key') 219 | //主面板 220 | ,elem = that.elem = lay.elem('div', { 221 | id: that.elemID 222 | ,'class': [ 223 | 'layui-cron' 224 | ,isStatic ? (' '+ ELEM_STATIC) : '' 225 | ].join('') 226 | }) 227 | 228 | // tab 内容区域 229 | ,elemTab = that.elemTab = lay.elem('div', { 230 | 'class': 'layui-tab layui-tab-card', 231 | 'lay-filter':tabFilter 232 | }) 233 | ,tabHead = lay.elem('ul',{ 234 | 'class': 'layui-tab-title' 235 | }) 236 | ,tabContent = lay.elem('div',{ 237 | 'class': 'layui-tab-content' 238 | }) 239 | 240 | //底部区域 241 | ,divFooter = that.footer = lay.elem('div', { 242 | 'class': ELEM_FOOTER 243 | }); 244 | 245 | if(options.zIndex) elem.style.zIndex = options.zIndex; 246 | 247 | // 生成tab 内容区域 248 | elemTab.appendChild(tabHead); 249 | elemTab.appendChild(tabContent); 250 | lay.each(lang.tabs, function(i,item){ 251 | // 表头 252 | var li = lay.elem('li',{ 253 | 'class':i===0?THIS:"", 254 | 'lay-id':i 255 | }); 256 | li.innerHTML = item.title; 257 | tabHead.appendChild(li); 258 | 259 | // 表体 260 | tabContent.appendChild(that.getTabContentChildElem(i)); 261 | }); 262 | 263 | // 主区域 264 | elemMain = that.elemMain = lay.elem('div', { 265 | 'class': 'layui-cron-main' 266 | }); 267 | elemMain.appendChild(elemTab); 268 | 269 | //生成底部栏 270 | lay(divFooter).html(function(){ 271 | var html = [], btns = []; 272 | lay.each(options.btns, function(i, item){ 273 | var title = lang.tools[item] || 'btn'; 274 | btns.push(''+ title +''); 275 | }); 276 | html.push(''); 277 | return html.join(''); 278 | }()); 279 | 280 | //插入到主区域 281 | elem.appendChild(elemMain); 282 | 283 | options.showBottom && elem.appendChild(divFooter); 284 | 285 | 286 | //移除上一个控件 287 | that.remove(Class.thisElemCron); 288 | 289 | //如果是静态定位,则插入到指定的容器中,否则,插入到body 290 | isStatic ? options.elem.append(elem) : ( 291 | document.body.appendChild(elem) 292 | ,that.position() 293 | ); 294 | 295 | 296 | that.checkCron(); 297 | 298 | that.elemEvent(); // 主面板事件 299 | 300 | Class.thisElemCron = that.elemID; 301 | 302 | form.render(); 303 | 304 | } 305 | 306 | // 渲染 tab 子控件 307 | Class.prototype.getTabContentChildElem = function(index){ 308 | var that = this, 309 | options = that.config, 310 | tabItem = options.tabs[index], 311 | tabItemKey = tabItem.key, 312 | lang = that.lang(), 313 | tabItemLang = lang.tabs[index], 314 | cron = options.cron, 315 | formFilter = 'cronForm'+tabItemKey+options.elem.attr('lay-key') 316 | ,data = function(){ 317 | if(cron[tabItemKey].indexOf('-') != -1){ 318 | // 周期数据 319 | var arr = cron[tabItemKey].split('-'); 320 | return { 321 | type:'range', 322 | start:arr[0], 323 | end:arr[1] 324 | }; 325 | } 326 | if(cron[tabItemKey].indexOf('/') != -1){ 327 | // 频率数据 328 | var arr = cron[tabItemKey].split('/'); 329 | return { 330 | type:'rate', 331 | begin:arr[0], 332 | rate:arr[1] 333 | }; 334 | } 335 | if(cron[tabItemKey].indexOf(',') != -1 || /^\+?[0-9][0-9]*$/.test(cron[tabItemKey])){ 336 | // 按照指定执行 337 | var arr = cron[tabItemKey].split(',').map(Number); 338 | return { 339 | type:'custom', 340 | values:arr 341 | }; 342 | } 343 | if(cron[tabItemKey].indexOf('W') != -1){ 344 | // 最近的工作日 345 | var value = cron[tabItemKey].replace('W',''); 346 | return { 347 | type:'weekday', 348 | value: value 349 | }; 350 | } 351 | if(index===3 && cron[tabItemKey] === 'L'){ 352 | // 本月最后一日 353 | return { 354 | type:'lastday', 355 | value: 'L' 356 | }; 357 | } 358 | if(index===5 && cron[tabItemKey].indexOf('L') != -1){ 359 | // 本月最后一个周 value 360 | var value = cron[tabItemKey].replace('L',''); 361 | return { 362 | type:'lastweek', 363 | value: value 364 | }; 365 | } 366 | if(cron[tabItemKey] === '*'){ 367 | // 每次 368 | return { 369 | type:'every', 370 | value:'*' 371 | }; 372 | } 373 | if(cron[tabItemKey] === '?'||cron[tabItemKey]===undefined||cron[tabItemKey]===''){ 374 | // 不指定 375 | return { 376 | type:'unspecified', 377 | value:cron[tabItemKey] 378 | }; 379 | } 380 | }() 381 | , rangeData = function(){ 382 | if(tabItem.range){ 383 | var arr = tabItem.range.split('-'); 384 | return { 385 | min:parseInt(arr[0]), 386 | max:parseInt(arr[1]) 387 | }; 388 | } 389 | }(); 390 | var elem = lay.elem('div', { 391 | 'class': 'layui-tab-item layui-form '+(index===0?SHOW:"") 392 | ,'lay-filter': formFilter 393 | }); 394 | 395 | // 每次 396 | elem.appendChild(function(){ 397 | var everyRadio = lay.elem('input',{ 398 | 'name': tabItemKey+'[type]' 399 | ,'type': 'radio' 400 | ,'value': 'every' 401 | ,'title': lang.every+tabItemLang.title 402 | }); 403 | if(data.type === 'every'){ 404 | lay(everyRadio).attr('checked', true); 405 | } 406 | var everyDiv = lay.elem('div',{ 407 | 'class':'cron-row' 408 | }); 409 | everyDiv.appendChild(everyRadio); 410 | return everyDiv; 411 | }()); 412 | 413 | // 不指定,从日开始 414 | if(index >= 3){ 415 | elem.appendChild(function(){ 416 | var unspecifiedRadio = lay.elem('input',{ 417 | 'name': tabItemKey+'[type]' 418 | ,'type': 'radio' 419 | ,'value': 'unspecified' 420 | ,'title': lang.unspecified 421 | }); 422 | if(data.type==='unspecified'){ 423 | lay(unspecifiedRadio).attr('checked', true); 424 | } 425 | var unspecifiedDiv = lay.elem('div',{ 426 | 'class':'cron-row' 427 | }); 428 | unspecifiedDiv.appendChild(unspecifiedRadio); 429 | return unspecifiedDiv; 430 | }()); 431 | } 432 | 433 | // 周期 434 | var rangeChild = [function(){ 435 | var rangeRadio = lay.elem('input',{ 436 | 'name': tabItemKey+'[type]' 437 | ,'type': 'radio' 438 | ,'value': 'range' 439 | ,'title': lang.period 440 | }); 441 | if(data.type === 'range'){ 442 | lay(rangeRadio).attr('checked', true); 443 | } 444 | return rangeRadio; 445 | }(),function(){ 446 | var elem = lay.elem('div',{ 447 | 'class':'cron-input-mid' 448 | }); 449 | elem.innerHTML = lang.periodFrom; 450 | return elem; 451 | }(),function(){ 452 | var elem = lay.elem('input',{ 453 | 'class':'cron-input', 454 | 'type': 'number', 455 | 'name': 'rangeStart', 456 | 'value': data.start||'' 457 | }); 458 | return elem; 459 | }(),function(){ 460 | var elem = lay.elem('div',{ 461 | 'class':'cron-input-mid' 462 | }); 463 | elem.innerHTML = '-'; 464 | return elem; 465 | }(),function(){ 466 | var elem = lay.elem('input',{ 467 | 'class':'cron-input', 468 | 'type': 'number', 469 | 'name': 'rangeEnd', 470 | 'value': data.end||'' 471 | }); 472 | return elem; 473 | }(),function(){ 474 | var elem = lay.elem('div',{ 475 | 'class':'cron-input-mid' 476 | }); 477 | elem.innerHTML = tabItemLang.title; 478 | return elem; 479 | }()] 480 | 481 | ,rangeDiv = lay.elem('div',{ 482 | 'class':'cron-row' 483 | }); 484 | lay.each(rangeChild,function(i,item){ 485 | rangeDiv.appendChild(item); 486 | }); 487 | if(tabItem.range){ 488 | var rangeTip = lay.elem('div',{ 489 | 'class':'cron-tips' 490 | }); 491 | rangeTip.innerHTML = ['(',tabItem.range,')'].join(''); 492 | rangeDiv.appendChild(rangeTip); 493 | } 494 | elem.appendChild(rangeDiv); 495 | 496 | // 频率,年没有 497 | if(index<6){ 498 | var rateChild = [function(){ 499 | var rateRadio = lay.elem('input',{ 500 | 'name': tabItemKey+'[type]' 501 | ,'type': 'radio' 502 | ,'value': 'rate' 503 | ,'title': lang.rate 504 | }); 505 | if(data.type === 'rate'){ 506 | lay(rateRadio).attr('checked', true); 507 | } 508 | return rateRadio; 509 | }(),function(){ 510 | var elem = lay.elem('div',{ 511 | 'class':'cron-input-mid' 512 | }); 513 | elem.innerHTML = tabItemLang.rateBegin || lang.rateBegin; 514 | return elem; 515 | }(),function(){ 516 | var elem = lay.elem('input',{ 517 | 'class':'cron-input', 518 | 'type': 'number', 519 | 'name': 'begin', 520 | 'value': data.begin||'' 521 | }); 522 | return elem; 523 | }(),function(){ 524 | var elem = lay.elem('div',{ 525 | 'class':'cron-input-mid' 526 | }); 527 | elem.innerHTML = tabItemLang.rateMid || (tabItemLang.title+lang.rateMid); 528 | return elem; 529 | }(),function(){ 530 | var elem = lay.elem('input',{ 531 | 'class':'cron-input', 532 | 'type': 'number', 533 | 'name': 'rate', 534 | 'value': data.rate||'' 535 | }); 536 | return elem; 537 | }(),function(){ 538 | var elem = lay.elem('div',{ 539 | 'class':'cron-input-mid' 540 | }); 541 | elem.innerHTML = undefined!=tabItemLang.rateEnd ? tabItemLang.rateEnd:(tabItemLang.title+lang.rateEnd); 542 | if(undefined!=tabItemLang.rateEnd&&tabItemLang.rateEnd===''){ 543 | lay(elem).addClass(HIDE); 544 | } 545 | return elem; 546 | }()] 547 | 548 | ,rateDiv = lay.elem('div',{ 549 | 'class':'cron-row' 550 | }); 551 | lay.each(rateChild,function(i,item){ 552 | rateDiv.appendChild(item); 553 | }); 554 | if(tabItem.range){ 555 | var rateTip = lay.elem('div',{ 556 | 'class':'cron-tips' 557 | }); 558 | if(index===5){ 559 | // 周 560 | rateTip.innerHTML = '(1-4/1-7)'; 561 | }else{ 562 | rateTip.innerHTML = ['(',rangeData.min,'/',(rangeData.max+(index<=2?1:0)),')'].join(''); 563 | } 564 | rateDiv.appendChild(rateTip); 565 | } 566 | elem.appendChild(rateDiv); 567 | } 568 | 569 | // 特殊:日(最近的工作日、最后一日),周(最后一周) 570 | if(index===3){ 571 | // 日 572 | // 最近的工作日 573 | var weekChild = [function(){ 574 | var weekRadio = lay.elem('input',{ 575 | 'name': tabItemKey+'[type]' 576 | ,'type': 'radio' 577 | ,'value': 'weekday' 578 | ,'title': lang.weekday 579 | }); 580 | if(data.type === 'weekday'){ 581 | lay(weekRadio).attr('checked', true); 582 | } 583 | return weekRadio; 584 | }(),function(){ 585 | var elem = lay.elem('div',{ 586 | 'class':'cron-input-mid' 587 | }); 588 | elem.innerHTML = lang.weekdayPrefix; 589 | return elem; 590 | }(),function(){ 591 | var elem = lay.elem('input',{ 592 | 'class':'cron-input', 593 | 'type': 'number', 594 | 'name': 'weekday', 595 | 'value': data.value||'' 596 | }); 597 | return elem; 598 | }(),function(){ 599 | var elem = lay.elem('div',{ 600 | 'class':'cron-input-mid' 601 | }); 602 | elem.innerHTML = lang.weekdaySuffix; 603 | return elem; 604 | }(),function(){ 605 | var elem = lay.elem('div',{ 606 | 'class':'cron-tips' 607 | }); 608 | elem.innerHTML = ['(',tabItem.range,')'].join(''); 609 | return elem; 610 | }()] 611 | 612 | ,weekDiv = lay.elem('div',{ 613 | 'class':'cron-row' 614 | }); 615 | lay.each(weekChild,function(i,item){ 616 | weekDiv.appendChild(item); 617 | }); 618 | elem.appendChild(weekDiv); 619 | 620 | // 本月最后一日 621 | elem.appendChild(function(){ 622 | var lastRadio = lay.elem('input',{ 623 | 'name': tabItemKey+'[type]' 624 | ,'type': 'radio' 625 | ,'value': 'lastday' 626 | ,'title': lang.lastday 627 | }); 628 | if(data.type === 'lastday'){ 629 | lay(lastRadio).attr('checked', true); 630 | } 631 | var lastDiv = lay.elem('div',{ 632 | 'class':'cron-row' 633 | }); 634 | lastDiv.appendChild(lastRadio); 635 | return lastDiv; 636 | }()); 637 | 638 | } 639 | 640 | if(index===5){ 641 | // 本月最后一个周几 642 | var lastWeekChild = [function(){ 643 | var lastWeekRadio = lay.elem('input',{ 644 | 'name': tabItemKey+'[type]' 645 | ,'type': 'radio' 646 | ,'value': 'lastweek' 647 | ,'title': lang.lastweek 648 | }); 649 | if(data.type === 'lastweek'){ 650 | lay(lastWeekRadio).attr('checked', true); 651 | } 652 | return lastWeekRadio; 653 | }(),function(){ 654 | var elem = lay.elem('input',{ 655 | 'class':'cron-input', 656 | 'type': 'number', 657 | 'name': 'lastweek', 658 | 'value': data.value||'' 659 | }); 660 | return elem; 661 | }(),function(){ 662 | var elem = lay.elem('div',{ 663 | 'class':'cron-tips' 664 | }); 665 | elem.innerHTML = ['(',tabItem.range,')'].join(''); 666 | return elem; 667 | }()] 668 | 669 | ,lastWeekDiv = lay.elem('div',{ 670 | 'class':'cron-row' 671 | }); 672 | lay.each(lastWeekChild,function(i,item){ 673 | lastWeekDiv.appendChild(item); 674 | }); 675 | elem.appendChild(lastWeekDiv); 676 | 677 | } 678 | 679 | // 指定 680 | if(index <= 5){ 681 | elem.appendChild(function(){ 682 | var customRadio = lay.elem('input',{ 683 | 'name': tabItemKey+'[type]' 684 | ,'type': 'radio' 685 | ,'value': 'custom' 686 | ,'title': lang.custom 687 | }); 688 | if(data.type === 'custom'){ 689 | lay(customRadio).attr('checked', true); 690 | } 691 | var customDiv = lay.elem('div',{ 692 | 'class':'cron-row' 693 | }); 694 | customDiv.appendChild(customRadio); 695 | return customDiv; 696 | }()); 697 | 698 | // 指定数值,时分秒显示两位数,自动补零 699 | elem.appendChild(function(){ 700 | var customGrid = lay.elem('div',{ 701 | 'class': 'cron-grid' 702 | }); 703 | var i = rangeData.min; 704 | while(i<=rangeData.max){ 705 | // 时分秒显示两位数,自动补零 706 | var gridItemValue = index<=2 ? lay.digit(i,2) : i; 707 | var gridItem = lay.elem('input',{ 708 | 'type': 'checkbox', 709 | 'title': gridItemValue, 710 | 'lay-skin': 'primary', 711 | 'name':tabItemKey+'[custom]', 712 | 'value':i 713 | }); 714 | if(data.values && data.values.includes(i)){ 715 | lay(gridItem).attr('checked',true); 716 | } 717 | customGrid.appendChild(gridItem); 718 | i++; 719 | } 720 | return customGrid; 721 | }()); 722 | } 723 | 724 | 725 | return elem; 726 | } 727 | 728 | //是否输入框 729 | Class.prototype.isInput = function(elem){ 730 | return /input|textarea/.test(elem.tagName.toLocaleLowerCase()); 731 | }; 732 | 733 | // 绑定的元素事件处理 734 | Class.prototype.events = function(){ 735 | var that = this 736 | ,options = that.config 737 | 738 | //绑定呼出控件事件 739 | ,showEvent = function(elem, bind){ 740 | elem.on(options.trigger, function(){ 741 | bind && (that.bindElem = this); 742 | that.render(); 743 | }); 744 | }; 745 | 746 | if(!options.elem[0] || options.elem[0].eventHandler) return; 747 | 748 | showEvent(options.elem, 'bind'); 749 | showEvent(options.eventElem); 750 | 751 | //绑定关闭控件事件 752 | lay(document).on('click', function(e){ 753 | if(e.target === options.elem[0] 754 | || e.target === options.eventElem[0] 755 | || e.target === lay(options.closeStop)[0]){ 756 | return; 757 | } 758 | that.remove(); 759 | }).on('keydown', function(e){ 760 | if(e.keyCode === 13){ 761 | if(lay('#'+ that.elemID)[0] && that.elemID === Class.thisElemDate){ 762 | e.preventDefault(); 763 | lay(that.footer).find(ELEM_CONFIRM)[0].click(); 764 | } 765 | } 766 | }); 767 | 768 | //自适应定位 769 | lay(window).on('resize', function(){ 770 | if(!that.elem || !lay(ELEM)[0]){ 771 | return false; 772 | } 773 | that.position(); 774 | }); 775 | 776 | options.elem[0].eventHandler = true; 777 | }; 778 | 779 | // 主面板事件 780 | Class.prototype.elemEvent = function(){ 781 | var that = this 782 | ,options = that.config 783 | ,tabFilter = 'cron-tab' + options.elem.attr('lay-key'); 784 | 785 | // 阻止主面板点击冒泡,避免因触发文档事件而关闭主面 786 | lay(that.elem).on('click', function(e){ 787 | lay.stope(e); 788 | }); 789 | 790 | // tab选项卡切换 791 | var lis = lay(that.elemTab).find('li'); 792 | lis.on('click',function(){ 793 | var layid = lay(this).attr('lay-id'); 794 | if(undefined === layid){ 795 | return; 796 | } 797 | element.tabChange(tabFilter, layid); 798 | }); 799 | 800 | // cron选项点击 801 | form.on('radio', function(data){ 802 | var $parent = data.othis.parent(); 803 | var formFilter = $parent.parent().attr('lay-filter'); 804 | var formData = form.val(formFilter); 805 | var radioType = data.value; 806 | if('range'===radioType){ 807 | // 范围 808 | form.val(formFilter,{ 809 | rangeStart: formData.rangeStart||0, 810 | rangeEnd: formData.rangeEnd||2 811 | }); 812 | } 813 | if('rate'===radioType){ 814 | // 频率 815 | form.val(formFilter,{ 816 | begin: formData.begin||0, 817 | rate: formData.rate||2 818 | }); 819 | } 820 | if('custom'===radioType){ 821 | // custom 822 | var $grid = $parent.next(); 823 | if($grid.find(':checkbox:checked').length<=0){ 824 | $grid.children(':checkbox:first').next().click() 825 | } 826 | } 827 | if('weekday'===radioType){ 828 | // weekday 829 | form.val(formFilter,{ 830 | weekday: formData.weekday||1 831 | }); 832 | } 833 | if('lastweek'===radioType){ 834 | // lastweek 835 | form.val(formFilter,{ 836 | lastweek: formData.lastweek||1 837 | }); 838 | } 839 | 840 | }); 841 | 842 | //点击底部按钮 843 | lay(that.footer).find('span').on('click', function(){ 844 | var type = lay(this).attr('lay-type'); 845 | that.tool(this, type); 846 | }); 847 | }; 848 | 849 | //底部按钮点击事件 850 | Class.prototype.tool = function(btn, type){ 851 | var that = this 852 | ,options = that.config 853 | ,lang = that.lang() 854 | ,isStatic = options.position === 'static' 855 | ,active = { 856 | //运行 857 | run: function(){ 858 | var value = that.parse(); 859 | var loading = layer.load(); 860 | $.get(options.run,{cron:value},function(res){ 861 | layer.close(loading); 862 | if(res.code !== 0){ 863 | return that.hint(res.msg); 864 | } 865 | that.runHint(res.data); 866 | },'json').fail(function(){ 867 | layer.close(loading); 868 | that.hint('服务器异常!'); 869 | }); 870 | } 871 | 872 | //确定 873 | ,confirm: function(){ 874 | var value = that.parse(); 875 | that.done([value]); 876 | that.setValue(value).remove() 877 | } 878 | }; 879 | active[type] && active[type](); 880 | }; 881 | 882 | //执行 done/change 回调 883 | Class.prototype.done = function(param, type){ 884 | var that = this 885 | ,options = that.config; 886 | 887 | param = param || [that.parse()]; 888 | typeof options[type || 'done'] === 'function' && options[type || 'done'].apply(options, param); 889 | 890 | return that; 891 | }; 892 | 893 | // 解析cron表达式 894 | Class.prototype.parse = function(){ 895 | var that = this 896 | ,options = that.config 897 | ,valueArr = []; 898 | 899 | lay.each(options.tabs, function(index, item){ 900 | var key = item.key; 901 | var formFilter = 'cronForm' + key + options.elem.attr('lay-key'); 902 | var formData = form.val(formFilter); 903 | var radioType = (key+'[type]'); 904 | var current = ""; 905 | if(formData[radioType] === 'every'){ 906 | // 每次 907 | current = "*"; 908 | } 909 | if(formData[radioType] === 'range'){ 910 | // 范围 911 | current = formData.rangeStart + "-" + formData.rangeEnd; 912 | } 913 | if(formData[radioType] === 'rate'){ 914 | // 频率 915 | current = formData.begin + "/" + formData.rate; 916 | } 917 | if(formData[radioType] === 'custom'){ 918 | // 指定 919 | var checkboxName = (item.key+'[custom]'); 920 | var customArr = []; 921 | $('input[name="' + checkboxName + '"]:checked').each(function() { 922 | customArr.push($(this).val()); 923 | }); 924 | current = customArr.join(','); 925 | } 926 | if(formData[radioType] === 'weekday'){ 927 | // 每月 formData.weekday 号最近的那个工作日 928 | current = formData.weekday + "W"; 929 | } 930 | if(formData[radioType] === 'lastday'){ 931 | // 本月最后一日 932 | current = "L"; 933 | } 934 | if(formData[radioType] === 'lastweek'){ 935 | // 本月最后星期 936 | current = formData.lastweek + "L"; 937 | } 938 | 939 | if(formData[radioType] === 'unspecified' && index != 6){ 940 | // 不指定 941 | current = "?"; 942 | } 943 | if(current !== ""){ 944 | valueArr.push(current); 945 | options.cron[key] = current; 946 | } 947 | }); 948 | return valueArr.join(' '); 949 | }; 950 | 951 | //控件移除 952 | Class.prototype.remove = function(prev){ 953 | var that = this 954 | ,options = that.config 955 | ,elem = lay('#'+ (prev || that.elemID)); 956 | if(!elem[0]) return that; 957 | 958 | if(!elem.hasClass(ELEM_STATIC)){ 959 | that.checkCron(function(){ 960 | elem.remove(); 961 | }); 962 | } 963 | return that; 964 | }; 965 | 966 | //定位算法 967 | Class.prototype.position = function(){ 968 | var that = this 969 | ,options = that.config; 970 | lay.position(that.bindElem || options.elem[0], that.elem, { 971 | position: options.position 972 | }); 973 | return that; 974 | }; 975 | 976 | //提示 977 | Class.prototype.hint = function(content){ 978 | var that = this 979 | ,options = that.config 980 | ,div = lay.elem('div', { 981 | 'class': ELEM_HINT 982 | }); 983 | 984 | if(!that.elem) return; 985 | 986 | div.innerHTML = content || ''; 987 | lay(that.elem).find('.'+ ELEM_HINT).remove(); 988 | that.elem.appendChild(div); 989 | 990 | clearTimeout(that.hinTimer); 991 | that.hinTimer = setTimeout(function(){ 992 | lay(that.elem).find('.'+ ELEM_HINT).remove(); 993 | }, 3000); 994 | }; 995 | 996 | //运行提示 997 | Class.prototype.runHint = function(runList){ 998 | var that = this 999 | ,options = that.config 1000 | ,div = lay.elem('div', { 1001 | 'class': ELEM_RUN_HINT 1002 | }); 1003 | // debugger; 1004 | if(!that.elem||!runList||!runList.length) return; 1005 | 1006 | 1007 | lay(div).html(function(){ 1008 | var html = []; 1009 | lay.each(runList, function(i, item){ 1010 | html.push('
'+ item +'
'); 1011 | }); 1012 | return html.join(''); 1013 | }()); 1014 | 1015 | lay(that.elem).find('.'+ ELEM_RUN_HINT).remove(); 1016 | that.elem.appendChild(div); 1017 | }; 1018 | 1019 | //赋值 1020 | Class.prototype.setValue = function(value=''){ 1021 | var that = this 1022 | ,options = that.config 1023 | ,elem = that.bindElem || options.elem[0] 1024 | ,valType = that.isInput(elem) ? 'val' : 'html' 1025 | 1026 | options.position === 'static' || lay(elem)[valType](value || ''); 1027 | 1028 | return this; 1029 | }; 1030 | 1031 | //cron校验 1032 | Class.prototype.checkCron = function(fn){ 1033 | var that = this 1034 | ,options = that.config 1035 | ,lang = that.lang() 1036 | ,elem = that.bindElem || options.elem[0] 1037 | ,value = that.isInput(elem) ? elem.value : (options.position === 'static' ? '' : elem.innerHTML) 1038 | 1039 | ,checkValid = function(value=""){ 1040 | 1041 | }; 1042 | 1043 | // cron 值,多个空格替换为一个空格,去掉首尾空格 1044 | value = value || options.value; 1045 | if(typeof value === 'string'){ 1046 | value = value.replace(/\s+/g, ' ').replace(/^\s|\s$/g, ''); 1047 | } 1048 | 1049 | if(fn==='init') return checkValid(value),that; 1050 | 1051 | value = that.parse(); 1052 | if(value){ 1053 | that.setValue(value); 1054 | } 1055 | fn && fn(); 1056 | return that; 1057 | }; 1058 | 1059 | //核心入口 1060 | cron.render = function(options){ 1061 | var ins = new Class(options); 1062 | return thisIns.call(ins); 1063 | }; 1064 | 1065 | exports('cron', cron); 1066 | }); -------------------------------------------------------------------------------- /static/webfonts/fa-brands-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xjxjin/alist-sync/3ff42a37745f735a6769666de4cc34f1e4228d16/static/webfonts/fa-brands-400.ttf -------------------------------------------------------------------------------- /static/webfonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xjxjin/alist-sync/3ff42a37745f735a6769666de4cc34f1e4228d16/static/webfonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /static/webfonts/fa-regular-400.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xjxjin/alist-sync/3ff42a37745f735a6769666de4cc34f1e4228d16/static/webfonts/fa-regular-400.ttf -------------------------------------------------------------------------------- /static/webfonts/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xjxjin/alist-sync/3ff42a37745f735a6769666de4cc34f1e4228d16/static/webfonts/fa-regular-400.woff2 -------------------------------------------------------------------------------- /static/webfonts/fa-solid-900.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xjxjin/alist-sync/3ff42a37745f735a6769666de4cc34f1e4228d16/static/webfonts/fa-solid-900.ttf -------------------------------------------------------------------------------- /static/webfonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xjxjin/alist-sync/3ff42a37745f735a6769666de4cc34f1e4228d16/static/webfonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /templates/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Alist-Sync 登录 7 | 8 | 9 | 60 | 61 | 62 |
63 | 86 |
87 | 88 | 124 | 125 | -------------------------------------------------------------------------------- /xiaojin.txt: -------------------------------------------------------------------------------- 1 | 2 | .. 3 | .... 4 | .:----=: 5 | ...:::---==-:::-+. 6 | ..:=====-=+=-::::::== .:-. 7 | .-==*=-:::::::::::::::=*-: .:-=++. 8 | .-==++++-::::::::::::::-++:-==:. .=-=::=-. 9 | ....:::-=-::-++-:::::::::::::::--:::::==: -:.:=..+: 10 | ==-------::::-==-:::::::::::::::::::::::-+-. .=: .:=-.. 11 | ==-::::+-:::::==-:::::::::::::::::::::::::=+.:+- :-: 12 | :--==+*::::::-=-::::::::::::::::::::::::::-*+: .+. 13 | ..-*:::::::==::::::::::::::::::::::::::-+. -+. 14 | -*:::::::-=-:::::::--:::::::::::::::=-. +- 15 | :*::::::::-=::::::-=:::::=:::::::::-: .*. 16 | .+=:::::::::::::::-::::-*-::......:: -- 17 | :+::-:::::::::::::::::*=:-::...... -. 18 | :-:-===-:::::::::::.:+==--:...... .+. 19 | .==:...-+#+::....... . ....... .=- 20 | -*.....::............::-. ...=- 21 | .==-:.. :=-::::::=. ..:+- 22 | .:--===---=-:::-:::--:. ..:+: 23 | =--+=:+*+:. ...... ..-+. 24 | .#. .+#- .:. .::=: 25 | -=:.-: ..::-. 26 | .-=. xjxjin ...:-: 27 | ... ...:- 28 | 29 | --------------------------------------------------------------------------------