Repository: whyour/qinglong Branch: develop Commit: 07bf0c705b13 Files: 222 Total size: 1.1 MB Directory structure: gitextract_8pxenqz2/ ├── .editorconfig ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ ├── PULL_REQUEST_TEMPLATE.md │ ├── agents/ │ │ └── ql.agent.md │ ├── config.yml │ └── workflows/ │ └── build-docker-image.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .umirc.ts ├── LICENSE ├── README-en.md ├── README.md ├── SECURITY.md ├── back/ │ ├── api/ │ │ ├── config.ts │ │ ├── cron.ts │ │ ├── dependence.ts │ │ ├── env.ts │ │ ├── health.ts │ │ ├── index.ts │ │ ├── log.ts │ │ ├── open.ts │ │ ├── script.ts │ │ ├── subscription.ts │ │ ├── system.ts │ │ ├── update.ts │ │ └── user.ts │ ├── app.ts │ ├── config/ │ │ ├── const.ts │ │ ├── http.ts │ │ ├── index.ts │ │ ├── serverEnv.ts │ │ ├── share.ts │ │ ├── subscription.ts │ │ └── util.ts │ ├── data/ │ │ ├── cron.ts │ │ ├── cronView.ts │ │ ├── dependence.ts │ │ ├── env.ts │ │ ├── index.ts │ │ ├── notify.ts │ │ ├── open.ts │ │ ├── sock.ts │ │ ├── subscription.ts │ │ └── system.ts │ ├── interface/ │ │ └── schedule.ts │ ├── loaders/ │ │ ├── app.ts │ │ ├── bootAfter.ts │ │ ├── db.ts │ │ ├── depInjector.ts │ │ ├── deps.ts │ │ ├── express.ts │ │ ├── initData.ts │ │ ├── initFile.ts │ │ ├── initTask.ts │ │ ├── logger.ts │ │ ├── server.ts │ │ └── sock.ts │ ├── middlewares/ │ │ └── monitoring.ts │ ├── protos/ │ │ ├── api.proto │ │ ├── api.ts │ │ ├── cron.proto │ │ ├── cron.ts │ │ ├── health.proto │ │ └── health.ts │ ├── schedule/ │ │ ├── addCron.ts │ │ ├── api.ts │ │ ├── client.ts │ │ ├── data.ts │ │ ├── delCron.ts │ │ └── health.ts │ ├── services/ │ │ ├── config.ts │ │ ├── cron.ts │ │ ├── cronView.ts │ │ ├── dependence.ts │ │ ├── env.ts │ │ ├── grpc.ts │ │ ├── health.ts │ │ ├── http.ts │ │ ├── log.ts │ │ ├── metrics.ts │ │ ├── notify.ts │ │ ├── open.ts │ │ ├── schedule.ts │ │ ├── script.ts │ │ ├── sock.ts │ │ ├── sshKey.ts │ │ ├── subscription.ts │ │ ├── system.ts │ │ └── user.ts │ ├── shared/ │ │ ├── auth.ts │ │ ├── interface.ts │ │ ├── logStreamManager.ts │ │ ├── pLimit.ts │ │ ├── runCron.ts │ │ ├── store.ts │ │ └── utils.ts │ ├── token.ts │ ├── tsconfig.json │ ├── types/ │ │ └── express.d.ts │ └── validation/ │ └── schedule.ts ├── docker/ │ ├── 310.Dockerfile │ ├── Dockerfile │ ├── docker-compose.yml │ └── docker-entrypoint.sh ├── ecosystem.config.js ├── nodemon.json ├── package.json ├── sample/ │ ├── auth.sample.json │ ├── config.sample.sh │ ├── extra.sample.sh │ ├── notify.js │ ├── notify.py │ ├── notify.py.save │ ├── ql_sample.js │ ├── ql_sample.py │ ├── task.sample.sh │ └── tool.ts ├── shell/ │ ├── api.sh │ ├── bot.sh │ ├── check.sh │ ├── env.sh │ ├── otask.sh │ ├── preload/ │ │ ├── client.js │ │ ├── client.py │ │ ├── sitecustomize.js │ │ └── sitecustomize.py │ ├── pub.sh │ ├── rmlog.sh │ ├── share.sh │ ├── task.sh │ └── update.sh ├── src/ │ ├── app.ts │ ├── components/ │ │ ├── copy.tsx │ │ ├── iconfont.tsx │ │ ├── index.less │ │ ├── name.tsx │ │ ├── tag.tsx │ │ └── terminal.tsx │ ├── hooks/ │ │ ├── useFilterTreeData.ts │ │ ├── useScrollHeight.ts │ │ └── useTableScrollHeight.ts │ ├── layouts/ │ │ ├── defaultProps.tsx │ │ ├── index.less │ │ └── index.tsx │ ├── loading.tsx │ ├── locales/ │ │ ├── en-US.json │ │ └── zh-CN.json │ ├── pages/ │ │ ├── 404.tsx │ │ ├── config/ │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── crontab/ │ │ │ ├── const.ts │ │ │ ├── detail.tsx │ │ │ ├── index.less │ │ │ ├── index.tsx │ │ │ ├── logModal.tsx │ │ │ ├── modal.tsx │ │ │ ├── type.ts │ │ │ ├── viewCreateModal.tsx │ │ │ └── viewManageModal.tsx │ │ ├── dependence/ │ │ │ ├── index.less │ │ │ ├── index.tsx │ │ │ ├── logModal.tsx │ │ │ ├── modal.tsx │ │ │ └── type.ts │ │ ├── diff/ │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── env/ │ │ │ ├── editNameModal.tsx │ │ │ ├── index.less │ │ │ ├── index.tsx │ │ │ └── modal.tsx │ │ ├── error/ │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── initialization/ │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── log/ │ │ │ ├── index.module.less │ │ │ └── index.tsx │ │ ├── login/ │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── script/ │ │ │ ├── components/ │ │ │ │ └── UnsupportedFilePreview/ │ │ │ │ ├── index.module.less │ │ │ │ └── index.tsx │ │ │ ├── editModal.tsx │ │ │ ├── editNameModal.tsx │ │ │ ├── index.module.less │ │ │ ├── index.tsx │ │ │ ├── renameModal.tsx │ │ │ ├── saveModal.tsx │ │ │ └── setting.tsx │ │ ├── setting/ │ │ │ ├── about.tsx │ │ │ ├── appModal.tsx │ │ │ ├── checkUpdate.tsx │ │ │ ├── dependence.tsx │ │ │ ├── index.less │ │ │ ├── index.tsx │ │ │ ├── loginLog.tsx │ │ │ ├── notification.tsx │ │ │ ├── other.tsx │ │ │ ├── progress.tsx │ │ │ ├── security.tsx │ │ │ └── systemLog.tsx │ │ └── subscription/ │ │ ├── index.less │ │ ├── index.tsx │ │ ├── logModal.tsx │ │ └── modal.tsx │ ├── styles/ │ │ └── variable.less │ └── utils/ │ ├── codemirror/ │ │ └── systemLog.ts │ ├── config.ts │ ├── const.ts │ ├── date.ts │ ├── hooks.ts │ ├── http.tsx │ ├── index.ts │ ├── init.ts │ ├── monaco/ │ │ └── index.ts │ ├── type.ts │ └── websocket.ts ├── tsconfig.json ├── typings.d.ts └── version.yaml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # http://editorconfig.org root = true [*] indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [*.md] trim_trailing_whitespace = false [Makefile] indent_style = tab ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: "\U0001F41E Bug report" description: Create a report to help us improve body: - type: input id: version attributes: label: Qinglong version validations: required: true - type: textarea id: steps-to-reproduce attributes: label: Steps to reproduce description: | What do we need to do after opening your repro in order to make the bug happen? Clear and concise reproduction instructions are important for us to be able to triage your issue in a timely manner. Note that you can use [Markdown](https://guides.github.com/features/mastering-markdown/) to format lists and code. placeholder: Steps to reproduce validations: required: true - type: textarea id: expected attributes: label: What is expected? validations: required: true - type: textarea id: actually-happening attributes: label: What is actually happening? validations: required: true - type: textarea id: system-info attributes: label: System Info description: Output of `npx envinfo --system --binaries --browsers` render: shell placeholder: System, Binaries, Browsers - type: textarea id: additional-comments attributes: label: Any additional comments? description: e.g. some background/context of how you ran into this bug. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Telegram Chat url: https://t.me/jiao_long about: Ask questions and discuss with other Qinglong users in real time. - name: Questions & Discussions url: https://github.com/whyour/qinglong/discussions/new?category=q-a about: Use GitHub discussions for message-board style questions and discussions. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: "\U0001F680 New feature proposal" description: Suggest an idea for this project labels: [":sparkles: feature request"] body: - type: markdown attributes: value: | Thanks for your interest in the project and taking the time to fill out this feature report! - type: textarea id: feature-description attributes: label: Clear and concise description of the problem description: "Explain your use case, context, and rationale behind this feature request. More importantly, what is the **end user experience** you are trying to build that led to the need for this feature?" validations: required: true - type: textarea id: suggested-solution attributes: label: Suggested solution description: "In module [xy] we could provide following implementation..." validations: required: true - type: textarea id: alternative attributes: label: Alternative description: Clear and concise description of any alternative solutions or features you've considered. - type: textarea id: additional-context attributes: label: Additional context description: Any other context or screenshots about the feature request here. - type: checkboxes id: checkboxes attributes: label: Validations description: Before submitting the issue, please make sure you do the following options: - label: Check that there isn't already an issue that request the same feature to avoid creating a duplicate. required: true ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ## PR Type What kind of change does this PR introduce? - [ ] Bugfix - [ ] Feature - [ ] Code style update (formatting, local variables) - [ ] Refactoring (no functional changes, no api changes) - [ ] Other... Please describe: ## What is the current behavior? Issue Number: N/A ## What is the new behavior? ## Does this PR introduce a breaking change? - [ ] Yes - [ ] No ## Other information ================================================ FILE: .github/agents/ql.agent.md ================================================ --- name: Bug Fixer description: Fix this issue following our error handling pattern. --- ================================================ FILE: .github/config.yml ================================================ # Comment to be posted to on PRs from first time contributors in your repository newPRWelcomeComment: | 💖 Thanks for opening this pull request! 💖 Please be patient and we will get back to you as soon as we can. # Comment to be posted to on pull requests merged by a first time user firstPRMergeComment: > Congrats on merging your first pull request! 🎉🎉🎉 ================================================ FILE: .github/workflows/build-docker-image.yml ================================================ name: Build And Push Docker Image on: push: paths-ignore: - "*.md" branches: - "master" - "develop" tags: - "v*" workflow_dispatch: jobs: code_gitlab: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: Yikun/hub-mirror-action@master with: src: github/whyour dst: gitlab/whyour dst_key: ${{ secrets.GITLAB_SSH_PK }} dst_token: ${{ secrets.GITLAB_TOKEN }} static_list: "qinglong" force_update: true code_gitee: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: Yikun/hub-mirror-action@master with: src: github/whyour dst: gitee/whyour dst_key: ${{ secrets.GITLAB_SSH_PK }} dst_token: ${{ secrets.GITEE_TOKEN }} static_list: "qinglong" force_update: true build-static: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 with: version: "8.3.1" - uses: actions/setup-node@v6 with: cache: "pnpm" - name: build front and back run: | pnpm install --frozen-lockfile pnpm build:front pnpm build:back - name: copy to static repo env: GITHUB_REPO: github.com/${{ github.repository_owner }}/qinglong-static GITHUB_BRANCH: ${{ github.ref_name }} REPO_GITEE: git@gitee.com:whyour/qinglong-static.git REPO_GITLAB: git@gitlab.com:whyour/qinglong-static.git PRIVATE_KEY: ${{ secrets.GITLAB_SSH_PK }} run: | mkdir -p tmp cd ./tmp cp -rf ../static/* ./ git init -b ${GITHUB_BRANCH} && git add . git config --local user.name 'github-actions[bot]' git config --local user.email 'github-actions[bot]@users.noreply.github.com' git commit --allow-empty -m "copy static at $(date +'%Y-%m-%d %H:%M:%S')" git push --force --quiet "https://${{ secrets.API_TOKEN }}@${GITHUB_REPO}.git" ${GITHUB_BRANCH}:${GITHUB_BRANCH} static_gitlab: needs: build-static runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: Yikun/hub-mirror-action@master with: src: github/whyour dst: gitlab/whyour dst_key: ${{ secrets.GITLAB_SSH_PK }} dst_token: ${{ secrets.GITLAB_TOKEN }} static_list: "qinglong-static" force_update: true static_gitee: needs: build-static runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: Yikun/hub-mirror-action@master with: src: github/whyour dst: gitee/whyour dst_key: ${{ secrets.GITLAB_SSH_PK }} dst_token: ${{ secrets.GITEE_TOKEN }} static_list: "qinglong-static" force_update: true build: if: ${{ !startsWith(github.ref, 'refs/tags/') }} needs: build-static runs-on: ubuntu-22.04 permissions: packages: write contents: read steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 with: version: "8.3.1" - uses: actions/setup-node@v6 with: cache: "pnpm" - name: Read version from version.yaml id: version run: | VERSION=$(grep '^version:' version.yaml | awk '{print $2}') echo "version=$VERSION" >> $GITHUB_OUTPUT echo "Version: $VERSION" - name: Setup timezone uses: szenius/set-timezone@v2.0 with: timezoneLinux: Asia/Shanghai - name: Login to DockerHub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Login to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v5 with: images: | ${{ github.repository }} ghcr.io/${{ github.repository }} flavor: | latest=false tags: | type=ref,event=branch,enable=${{ github.ref == format('refs/heads/{0}', 'develop') }} type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'master') }} type=raw,value=${{ steps.version.outputs.version }},enable=${{ github.ref == format('refs/heads/{0}', 'master') }} type=semver,pattern={{version}} - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and push id: docker_build uses: docker/build-push-action@v6 with: build-args: | MAINTAINER=${{ github.repository_owner }} QL_BRANCH=${{ github.ref_name }} SOURCE_COMMIT=${{ github.sha }} network: host # linux/s390x npm 暂不可用 platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/386 context: . file: ./docker/Dockerfile push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=registry,ref=whyour/qinglong:cache cache-to: type=registry,ref=whyour/qinglong:cache,mode=max - name: Image digest run: | echo ${{ steps.docker_build.outputs.digest }} build310: if: ${{ github.ref_name == 'master' }} needs: build-static runs-on: ubuntu-22.04 permissions: packages: write contents: read steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 with: version: "8.3.1" - uses: actions/setup-node@v6 with: cache: "pnpm" - name: Read version from version.yaml id: version run: | VERSION=$(grep '^version:' version.yaml | awk '{print $2}') echo "version=$VERSION" >> $GITHUB_OUTPUT echo "Version: $VERSION" - name: Setup timezone uses: szenius/set-timezone@v2.0 with: timezoneLinux: Asia/Shanghai - name: Login to DockerHub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Login to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and push python3.10 id: docker_build_310 uses: docker/build-push-action@v6 with: build-args: | MAINTAINER=${{ github.repository_owner }} QL_BRANCH=${{ github.ref_name }} SOURCE_COMMIT=${{ github.sha }} network: host # linux/s390x npm 暂不可用 platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64,linux/ppc64le,linux/386 context: . file: ./docker/310.Dockerfile push: true tags: | whyour/qinglong:python3.10 whyour/qinglong:${{ steps.version.outputs.version }}-python3.10 cache-from: type=registry,ref=whyour/qinglong:cache-python3.10 cache-to: type=registry,ref=whyour/qinglong:cache-python3.10,mode=max - name: Image digest run: | echo ${{ steps.docker_build_310.outputs.digest }} ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /npm-debug.log* /yarn-error.log /yarn.lock /package-lock.json # production /static /data # misc .DS_Store # umi /src/.umi /src/.umi-production /src/.umi-test /.env.local .env .history .version.ts /.tmp __pycache__ /shell/preload/env.* /shell/preload/notify.* /shell/preload/*-notify.json /shell/preload/__ql_notify__.* ================================================ FILE: .npmrc ================================================ strict-peer-dependencies=false ================================================ FILE: .prettierignore ================================================ **/*.md **/*.svg **/*.ejs **/*.html /.umi /.umi-production /.umi-test /.history /.tmp /node_modules npm-debug.log* yarn-error.log yarn.lock package-lock.json /static /data DS_Store /src/.umi /src/.umi-production /src/.umi-test .env.local .env version.ts /.tmp ================================================ FILE: .prettierrc ================================================ { "singleQuote": true, "trailingComma": "all", "printWidth": 80, "overrides": [ { "files": ".prettierrc", "options": { "parser": "json" } } ] } ================================================ FILE: .umirc.ts ================================================ import { defineConfig } from '@umijs/max'; const CompressionPlugin = require('compression-webpack-plugin'); const baseUrl = process.env.QlBaseUrl || '/'; export default defineConfig({ hash: true, jsMinifier: 'terser', antd: {}, locale: { antd: true, title: true, baseNavigator: true, }, outputPath: 'static/dist', fastRefresh: true, favicons: [`https://qn.whyour.cn/favicon.svg`], publicPath: process.env.NODE_ENV === 'production' ? './' : '/', proxy: { [`${baseUrl}api`]: { target: 'http://127.0.0.1:5700/', changeOrigin: true, ws: true, pathRewrite: { [`^${baseUrl}api`]: '/api' }, }, }, chainWebpack: ((config: any) => { config.plugin('compression-webpack-plugin').use( new CompressionPlugin({ algorithm: 'gzip', test: new RegExp('\\.(js|css)$'), threshold: 10240, minRatio: 0.6, }), ); }) as any, headScripts: [`./api/env.js`], copy: [ { from: 'node_modules/monaco-editor/min/vs', to: 'static/dist/monaco-editor/min/vs', }, ], npmClient: 'pnpm', }); ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "{}" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2021 WHYOUR . Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README-en.md ================================================

Qinglong

[简体中文](./README.md) | English Timed task management platform supporting Python3, JavaScript, Shell, Typescript [![npm version][npm-version-image]][npm-version-url] [![docker pulls][docker-pulls-image]][docker-pulls-url] [![docker stars][docker-stars-image]][docker-stars-url] [![docker image size][docker-image-size-image]][docker-image-size-url] [npm-version-image]: https://img.shields.io/npm/v/@whyour/qinglong?style=flat [npm-version-url]: https://www.npmjs.com/package/@whyour/qinglong?activeTab=readme [docker-pulls-image]: https://img.shields.io/docker/pulls/whyour/qinglong?style=flat [docker-pulls-url]: https://hub.docker.com/r/whyour/qinglong [docker-stars-image]: https://img.shields.io/docker/stars/whyour/qinglong?style=flat [docker-stars-url]: https://hub.docker.com/r/whyour/qinglong [docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat [docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong [Demo](http://demo.qinglong.online:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong) [演示](http://demo.qinglong.online:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
![cover](https://user-images.githubusercontent.com/22700758/244847235-8dc1ca21-e03f-4606-9458-0541fab60413.png) ## Features - Support for multiple scripting languages (python3, javaScript, shell, typescript) - Support online management of scripts, environment variables, configuration files - Support online view task log - Support second-level task setting - Support system level notification - Support dark mode - Support cell phone operation ## Version ### docker The `latest` image is built on `alpine` and the `debian` image is built on `debian-slim`. If you need to use a dependency that is not supported by `alpine`, it is recommended that you use the `debian` image. **⚠️ Important**: If you need to run Docker as a **non-root user**, please use the `debian` image. Alpine's `crond` requires root privileges. ```bash docker pull whyour/qinglong:latest docker pull whyour/qinglong:debian ``` ### npm The npm version supports `debian/ubuntu/alpine` systems and requires `node/npm/python3/pip3/pnpm` to be installed. ```bash npm i @whyour/qinglong ``` ## Deployment [View Documentation](https://qinglong.online/guide/getting-started/installation-guide) ## Built-in API [View Documentation](https://qinglong.online/guide/user-guide/built-in-api) ## Built-in commands [View Documentation](https://qinglong.online/guide/user-guide/basic-explanation) ## Development ```bash git clone https://github.com/whyour/qinglong.git cd qinglong cp .env.example .env # Recommended use pnpm https://pnpm.io/zh/installation npm install -g pnpm@8.3.1 pnpm install pnpm start ``` Open your browser and visit ## Links - [nevinee](https://gitee.com/evine) - [crontab-ui](https://github.com/alseambusher/crontab-ui) - [Ant Design](https://ant.design) - [Ant Design Pro](https://pro.ant.design/) - [Umijs](https://umijs.org) - [darkreader](https://github.com/darkreader/darkreader) - [admin-server](https://github.com/sunpu007/admin-server) ## Name Origin The Green Dragon, also known as the Canglong, is one of the four elephants and one of the [four spirits of the heavens](https://zh.wikipedia.org/wiki/%E5%A4%A9%E4%B9%8B%E5%9B%9B%E7%81%B5) in traditional Chinese culture. According to the Five Elements, it is a spirit animal representing the East as a green dragon, the five elements are wood, and the season represented is spring, with the eight trigrams dominating vibration. Like the Ying Long, the Cang Long has feathered wings. According to the Zhang Guo Xing Zong (Zhang Guo Xing Zong), "a true dragon is one that has complementary wings". In the Book of the Later Han Dynasty (後漢書-律曆志下), it is written: "The sun is in the sky, a cold and a summer, the four seasons are ready, all things are changed, the regency moves, and the green dragon moves to the star, which is called the year. (The Year of the Star) Among the [twenty-eight Chinese constellations](https://zh.wikipedia.org/wiki/%E4%BA%8C%E5%8D%81%E5%85%AB%E5%AE%BF), the Green Dragon is the generic name for the seven eastern constellations (Horn, Hyper, Diao, Fang, Heart, Tail and Minchi). It is known in Taoism as "Mengzhang" and in different Taoist scriptures as "Dijun", "Shengjian", "Shenjian" and He is also known in different Daoist scriptures as "Dijun", "Shengjun", "Shenjun" and "Ghost Catcher"[1], and is the guardian deity of Daoism, together with the White Tiger Supervisor of Soldiers. ================================================ FILE: README.md ================================================

青龙

简体中文 | [English](./README-en.md) 支持 Python3、JavaScript、Shell、Typescript 的定时任务管理平台 Timed task management platform supporting Python3, JavaScript, Shell, Typescript [![npm version][npm-version-image]][npm-version-url] [![docker pulls][docker-pulls-image]][docker-pulls-url] [![docker stars][docker-stars-image]][docker-stars-url] [![docker image size][docker-image-size-image]][docker-image-size-url] [npm-version-image]: https://img.shields.io/npm/v/@whyour/qinglong?style=flat [npm-version-url]: https://www.npmjs.com/package/@whyour/qinglong?activeTab=readme [docker-pulls-image]: https://img.shields.io/docker/pulls/whyour/qinglong?style=flat [docker-pulls-url]: https://hub.docker.com/r/whyour/qinglong [docker-stars-image]: https://img.shields.io/docker/stars/whyour/qinglong?style=flat [docker-stars-url]: https://hub.docker.com/r/whyour/qinglong [docker-image-size-image]: https://img.shields.io/docker/image-size/whyour/qinglong?style=flat [docker-image-size-url]: https://hub.docker.com/r/whyour/qinglong [Demo](http://demo.qinglong.online:4433/) / [Issues](https://github.com/whyour/qinglong/issues) / [Telegram Channel](https://t.me/jiao_long) / [Buy Me a Coffee](https://www.buymeacoffee.com/qinglong) [演示](http://demo.qinglong.online:4433/) / [反馈](https://github.com/whyour/qinglong/issues) / [Telegram 频道](https://t.me/jiao_long) / [打赏开发者](https://user-images.githubusercontent.com/22700758/244744295-29cd0cd1-c8bb-4ea1-adf6-29bd390ad4dd.jpg)
![cover](https://user-images.githubusercontent.com/22700758/244847235-8dc1ca21-e03f-4606-9458-0541fab60413.png) ## 功能 - 支持多种脚本语言(python3、javaScript、shell、typescript) - 支持在线管理脚本、环境变量、配置文件 - 支持在线查看任务日志 - 支持秒级任务设置 - 支持系统级通知 - 支持暗黑模式 - 支持手机端操作 ## 版本 ### docker `latest` 镜像是基于 `alpine` 构建,`debian` 镜像是基于 `debian-slim` 构建。如果需要使用 `alpine` 不支持的依赖,建议使用 `debian` 镜像 **⚠️ 重要提示**: 如果您需要以**非 root 用户**运行 Docker,请使用 `debian` 镜像。Alpine 的 `crond` 需要 root 权限。 ```bash docker pull whyour/qinglong:latest docker pull whyour/qinglong:debian ``` ### npm npm 版本支持 `debian/ubuntu/alpine` 系统,需要自行安装 `node/npm/python3/pip3/pnpm` ```bash npm i @whyour/qinglong ``` ## 部署 [查看文档](https://qinglong.online/guide/getting-started/installation-guide) ## 内置 API [查看文档](https://qinglong.online/guide/user-guide/built-in-api) ## 内置命令 [查看文档](https://qinglong.online/guide/user-guide/basic-explanation) ## 开发 ```bash git clone https://github.com/whyour/qinglong.git cd qinglong cp .env.example .env # 推荐使用 pnpm https://pnpm.io/zh/installation npm install -g pnpm@8.3.1 pnpm install pnpm start ``` 打开你的浏览器,访问 ## 链接 - [nevinee](https://gitee.com/evine) - [crontab-ui](https://github.com/alseambusher/crontab-ui) - [Ant Design](https://ant.design) - [Ant Design Pro](https://pro.ant.design/) - [Umijs](https://umijs.org) - [darkreader](https://github.com/darkreader/darkreader) - [admin-server](https://github.com/sunpu007/admin-server) ## 名称来源 青龙,又名苍龙,在中国传统文化中是四象之一、[天之四灵](https://zh.wikipedia.org/wiki/%E5%A4%A9%E4%B9%8B%E5%9B%9B%E7%81%B5)之一,根据五行学说,它是代表东方的灵兽,为青色的龙,五行属木,代表的季节是春季,八卦主震。苍龙与应龙一样,都是身具羽翼。《张果星宗》称“又有辅翼,方为真龙”。 《后汉书·律历志下》记载:日周于天,一寒一暑,四时备成,万物毕改,摄提迁次,青龙移辰,谓之岁。 在中国[二十八宿](https://zh.wikipedia.org/wiki/%E4%BA%8C%E5%8D%81%E5%85%AB%E5%AE%BF)中,青龙是东方七宿(角、亢、氐、房、心、尾、箕)的总称。 在早期星宿信仰中,祂是最尊贵的天神。 但被道教信仰吸纳入其神系后,神格大跌,道教将其称为“孟章”,在不同的道经中有“帝君”、“圣将”、“神将”和“捕鬼将”等称呼,与白虎监兵神君一起,是道教的护卫天神。 ================================================ FILE: SECURITY.md ================================================ ## Reporting a Vulnerability To report a vulnerability, please open a private vulnerability report at . While the discovery of new vulnerabilities is rare, we also recommend always using the latest versions of Qinglong to ensure your application remains as secure as possible. ================================================ FILE: back/api/config.ts ================================================ import { Router, Request, Response, NextFunction } from 'express'; import { Container } from 'typedi'; import { Logger } from 'winston'; import config from '../config'; import * as fs from 'fs/promises'; import { celebrate, Joi } from 'celebrate'; import { join } from 'path'; import { SAMPLE_FILES } from '../config/const'; import ConfigService from '../services/config'; import { writeFileWithLock } from '../shared/utils'; const route = Router(); export default (app: Router) => { app.use('/configs', route); route.get( '/sample', async (req: Request, res: Response, next: NextFunction) => { try { res.send({ code: 200, data: SAMPLE_FILES, }); } catch (e) { return next(e); } }, ); route.get( '/files', async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const fileList = await fs.readdir(config.configPath, 'utf-8'); res.send({ code: 200, data: fileList .filter((x) => !config.blackFileList.includes(x)) .map((x) => { return { title: x, value: x }; }), }); } catch (e) { return next(e); } }, ); route.get( '/detail', async (req: Request, res: Response, next: NextFunction) => { try { const configService = Container.get(ConfigService); await configService.getFile(req.query.path as string, res); } catch (e) { return next(e); } }, ); route.post( '/save', celebrate({ body: Joi.object({ name: Joi.string().required(), content: Joi.string().allow('').optional(), }), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const { name, content } = req.body; if (config.blackFileList.includes(name)) { res.send({ code: 403, message: '文件无法访问' }); } let path = join(config.configPath, name); if (name.startsWith('data/scripts/')) { path = join(config.rootPath, name); } await writeFileWithLock(path, content); res.send({ code: 200, message: '保存成功' }); } catch (e) { return next(e); } }, ); route.get( '/:file', async (req: Request, res: Response, next: NextFunction) => { try { const configService = Container.get(ConfigService); await configService.getFile(req.params.file, res); } catch (e) { return next(e); } }, ); }; ================================================ FILE: back/api/cron.ts ================================================ import { Router, Request, Response, NextFunction } from 'express'; import { Container } from 'typedi'; import { Logger } from 'winston'; import CronService from '../services/cron'; import CronViewService from '../services/cronView'; import { celebrate, Joi } from 'celebrate'; import { commonCronSchema } from '../validation/schedule'; const route = Router(); export default (app: Router) => { app.use('/crons', route); route.get( '/views', async (req: Request, res: Response, next: NextFunction) => { try { const cronViewService = Container.get(CronViewService); const data = await cronViewService.list(); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.post( '/views', celebrate({ body: Joi.object({ name: Joi.string().required(), sorts: Joi.array().optional().allow(null), filters: Joi.array().optional(), filterRelation: Joi.string().optional(), }), }), async (req: Request, res: Response, next: NextFunction) => { try { const cronViewService = Container.get(CronViewService); const data = await cronViewService.create(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/views', celebrate({ body: Joi.object({ name: Joi.string().required(), id: Joi.number().required(), sorts: Joi.array().optional().allow(null), filters: Joi.array().optional(), filterRelation: Joi.string().optional(), }), }), async (req: Request, res: Response, next: NextFunction) => { try { const cronViewService = Container.get(CronViewService); if (req.body.type === 1) { return res.send({ code: 400, message: '参数错误' }); } else { const data = await cronViewService.update(req.body); return res.send({ code: 200, data }); } } catch (e) { return next(e); } }, ); route.delete( '/views', celebrate({ body: Joi.array().items(Joi.number().required()), }), async (req: Request, res: Response, next: NextFunction) => { try { const cronViewService = Container.get(CronViewService); const data = await cronViewService.remove(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/views/move', celebrate({ body: Joi.object({ fromIndex: Joi.number().required(), toIndex: Joi.number().required(), id: Joi.number().required(), }), }), async (req: Request<{ id: number }>, res: Response, next: NextFunction) => { try { const cronViewService = Container.get(CronViewService); const data = await cronViewService.move(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/views/disable', celebrate({ body: Joi.array().items(Joi.number().required()), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const cronViewService = Container.get(CronViewService); const data = await cronViewService.disabled(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/views/enable', celebrate({ body: Joi.array().items(Joi.number().required()), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const cronViewService = Container.get(CronViewService); const data = await cronViewService.enabled(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.get('/', async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); const data = await cronService.crontabs(req.query as any); return res.send({ code: 200, data }); } catch (e) { logger.error('🔥 error: %o', e); return next(e); } }); route.get( '/detail', async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); const data = await cronService.find(req.query as any); return res.send({ code: 200, data }); } catch (e) { logger.error('🔥 error: %o', e); return next(e); } }, ); route.post( '/', celebrate({ body: Joi.object(commonCronSchema), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); const data = await cronService.create(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/run', celebrate({ body: Joi.array().items(Joi.number().required()), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); const data = await cronService.run(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/stop', celebrate({ body: Joi.array().items(Joi.number().required()), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); const data = await cronService.stop(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.delete( '/labels', celebrate({ body: Joi.object({ ids: Joi.array().items(Joi.number().required()), labels: Joi.array().items(Joi.string().required()), }), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); const data = await cronService.removeLabels( req.body.ids, req.body.labels, ); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.post( '/labels', celebrate({ body: Joi.object({ ids: Joi.array().items(Joi.number().required()), labels: Joi.array().items(Joi.string().required()), }), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); const data = await cronService.addLabels(req.body.ids, req.body.labels); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/disable', celebrate({ body: Joi.array().items(Joi.number().required()), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); const data = await cronService.disabled(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/enable', celebrate({ body: Joi.array().items(Joi.number().required()), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); const data = await cronService.enabled(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.get( '/:id/log', celebrate({ params: Joi.object({ id: Joi.number().required(), }), }), async (req: Request<{ id: number }>, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); const data = await cronService.log(req.params.id); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/', celebrate({ body: Joi.object({ ...commonCronSchema, id: Joi.number().required(), }), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); const data = await cronService.update(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.delete( '/', celebrate({ body: Joi.array().items(Joi.number().required()), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); const data = await cronService.remove(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/pin', celebrate({ body: Joi.array().items(Joi.number().required()), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); const data = await cronService.pin(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/unpin', celebrate({ body: Joi.array().items(Joi.number().required()), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); const data = await cronService.unPin(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.get( '/import', async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); const data = await cronService.importCrontab(); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.get( '/:id', celebrate({ params: Joi.object({ id: Joi.number().required(), }), }), async (req: Request<{ id: number }>, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); const data = await cronService.getDb({ id: req.params.id }); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/status', celebrate({ body: Joi.object({ ids: Joi.array().items(Joi.number().required()), status: Joi.string().required(), pid: Joi.string().optional().allow(null), log_path: Joi.string().optional().allow(null), last_running_time: Joi.number().optional().allow(null), last_execution_time: Joi.number().optional().allow(null), }), }), async (req: Request, res: Response, next: NextFunction) => { try { const cronService = Container.get(CronService); const data = await cronService.status({ ...req.body, status: req.body.status ? parseInt(req.body.status) : undefined, pid: req.body.pid ? parseInt(req.body.pid) : undefined, }); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.get( '/:id/logs', celebrate({ params: Joi.object({ id: Joi.number().required(), }), }), async (req: Request<{ id: number }>, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const cronService = Container.get(CronService); const data = await cronService.logs(req.params.id); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); }; ================================================ FILE: back/api/dependence.ts ================================================ import { Router, Request, Response, NextFunction } from 'express'; import { Container } from 'typedi'; import DependenceService from '../services/dependence'; import { Logger } from 'winston'; import { celebrate, Joi } from 'celebrate'; const route = Router(); export default (app: Router) => { app.use('/dependencies', route); route.get( '/', celebrate({ query: Joi.object({ searchValue: Joi.string().optional().allow(''), type: Joi.string().optional().allow(''), status: Joi.string().optional().allow(''), }).unknown(true), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const dependenceService = Container.get(DependenceService); const data = await dependenceService.dependencies(req.query as any); return res.send({ code: 200, data }); } catch (e) { logger.error('🔥 error: %o', e); return next(e); } }, ); route.post( '/', celebrate({ body: Joi.array().items( Joi.object({ name: Joi.string().required(), type: Joi.number().required(), remark: Joi.string().optional().allow(''), }), ), }), async (req: Request, res: Response, next: NextFunction) => { try { const dependenceService = Container.get(DependenceService); const data = await dependenceService.create(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/', celebrate({ body: Joi.object({ name: Joi.string().required(), id: Joi.number().required(), type: Joi.number().required(), remark: Joi.string().optional().allow(''), }), }), async (req: Request, res: Response, next: NextFunction) => { try { const dependenceService = Container.get(DependenceService); const data = await dependenceService.update(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.delete( '/', celebrate({ body: Joi.array().items(Joi.number().required()), }), async (req: Request, res: Response, next: NextFunction) => { try { const dependenceService = Container.get(DependenceService); const data = await dependenceService.remove(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.delete( '/force', celebrate({ body: Joi.array().items(Joi.number().required()), }), async (req: Request, res: Response, next: NextFunction) => { try { const dependenceService = Container.get(DependenceService); const data = await dependenceService.remove(req.body, true); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.get( '/:id', celebrate({ params: Joi.object({ id: Joi.number().required(), }), }), async (req: Request<{ id: number }>, res: Response, next: NextFunction) => { try { const dependenceService = Container.get(DependenceService); const data = await dependenceService.getDb({ id: req.params.id }); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/reinstall', celebrate({ body: Joi.array().items(Joi.number().required()), }), async (req: Request, res: Response, next: NextFunction) => { try { const dependenceService = Container.get(DependenceService); const data = await dependenceService.reInstall(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/cancel', celebrate({ body: Joi.array().items(Joi.number().required()), }), async (req: Request, res: Response, next: NextFunction) => { try { const dependenceService = Container.get(DependenceService); await dependenceService.cancel(req.body); return res.send({ code: 200 }); } catch (e) { return next(e); } }, ); }; ================================================ FILE: back/api/env.ts ================================================ import { Joi, celebrate } from 'celebrate'; import { NextFunction, Request, Response, Router } from 'express'; import fs from 'fs'; import multer from 'multer'; import { Container } from 'typedi'; import { Logger } from 'winston'; import config from '../config'; import { safeJSONParse } from '../config/util'; import EnvService from '../services/env'; const route = Router(); const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, config.scriptPath); }, filename: function (req, file, cb) { cb(null, file.originalname); }, }); const upload = multer({ storage: storage }); export default (app: Router) => { app.use('/envs', route); route.get('/', async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const envService = Container.get(EnvService); const data = await envService.envs(req.query.searchValue as string); return res.send({ code: 200, data }); } catch (e) { logger.error('🔥 error: %o', e); return next(e); } }); route.post( '/', celebrate({ body: Joi.array().items( Joi.object({ value: Joi.string().required(), name: Joi.string() .required() .pattern(/^[a-zA-Z_][0-9a-zA-Z_]*$/), remarks: Joi.string().optional().allow(''), }), ), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const envService = Container.get(EnvService); if (!req.body?.length) { return res.send({ code: 400, message: '参数不正确' }); } const data = await envService.create(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/', celebrate({ body: Joi.object({ value: Joi.string().required(), name: Joi.string().required(), remarks: Joi.string().optional().allow('').allow(null), id: Joi.number().required(), }), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const envService = Container.get(EnvService); const data = await envService.update(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.delete( '/', celebrate({ body: Joi.array().items(Joi.number().required()), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const envService = Container.get(EnvService); const data = await envService.remove(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/:id/move', celebrate({ params: Joi.object({ id: Joi.number().required(), }), body: Joi.object({ fromIndex: Joi.number().required(), toIndex: Joi.number().required(), }), }), async (req: Request<{ id: number }>, res: Response, next: NextFunction) => { try { const envService = Container.get(EnvService); const data = await envService.move(req.params.id, req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/disable', celebrate({ body: Joi.array().items(Joi.number().required()), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const envService = Container.get(EnvService); const data = await envService.disabled(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/enable', celebrate({ body: Joi.array().items(Joi.number().required()), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const envService = Container.get(EnvService); const data = await envService.enabled(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/name', celebrate({ body: Joi.object({ ids: Joi.array().items(Joi.number().required()), name: Joi.string().required(), }), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const envService = Container.get(EnvService); const data = await envService.updateNames(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.get( '/:id', celebrate({ params: Joi.object({ id: Joi.number().required(), }), }), async (req: Request<{ id: number }>, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const envService = Container.get(EnvService); const data = await envService.getDb({ id: req.params.id }); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/pin', celebrate({ body: Joi.array().items(Joi.number().required()), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const envService = Container.get(EnvService); const data = await envService.pin(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/unpin', celebrate({ body: Joi.array().items(Joi.number().required()), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const envService = Container.get(EnvService); const data = await envService.unPin(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.post( '/upload', upload.single('env'), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const envService = Container.get(EnvService); const fileContent = await fs.promises.readFile(req!.file!.path, 'utf8'); const parseContent = safeJSONParse(fileContent); const data = Array.isArray(parseContent) ? parseContent : [parseContent]; if (data.every((x) => x.name && x.value)) { const result = await envService.create( data.map((x) => ({ name: x.name, value: x.value, remarks: x.remarks, })), ); return res.send({ code: 200, data: result }); } else { return res.send({ code: 400, message: '每条数据 name 或者 value 字段不能为空,参考导出文件格式', }); } } catch (e) { return next(e); } }, ); }; ================================================ FILE: back/api/health.ts ================================================ import { Router } from 'express'; import Logger from '../loaders/logger'; import { HealthService } from '../services/health'; import Container from 'typedi'; const route = Router(); export default (app: Router) => { app.use('/', route); route.get('/health', async (req, res) => { try { const healthService = Container.get(HealthService); const health = await healthService.check(); res.status(200).send({ code: 200, data: health, }); } catch (err: any) { Logger.error('Health check failed:', err); res.status(500).send({ code: 500, message: 'Health check failed', error: err.message, }); } }); }; ================================================ FILE: back/api/index.ts ================================================ import { Router } from 'express'; import user from './user'; import env from './env'; import config from './config'; import log from './log'; import cron from './cron'; import script from './script'; import open from './open'; import dependence from './dependence'; import system from './system'; import subscription from './subscription'; import update from './update'; import health from './health'; export default () => { const app = Router(); user(app); env(app); config(app); log(app); cron(app); script(app); open(app); dependence(app); system(app); subscription(app); update(app); health(app); return app; }; ================================================ FILE: back/api/log.ts ================================================ import { celebrate, Joi } from 'celebrate'; import { NextFunction, Request, Response, Router } from 'express'; import { Container } from 'typedi'; import { Logger } from 'winston'; import config from '../config'; import { getFileContentByName, readDirs, removeAnsi, rmPath, } from '../config/util'; import LogService from '../services/log'; const route = Router(); const blacklist = ['.tmp']; export default (app: Router) => { app.use('/logs', route); route.get('/', async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const result = await readDirs(config.logPath, config.logPath, blacklist); res.send({ code: 200, data: result, }); } catch (e) { logger.error('🔥 error: %o', e); return next(e); } }); route.get( '/detail', async (req: Request, res: Response, next: NextFunction) => { try { const logService = Container.get(LogService); const finalPath = logService.checkFilePath( (req.query.path as string) || '', (req.query.file as string) || '', ); if (!finalPath || blacklist.includes(req.query.path as string)) { return res.send({ code: 403, message: '暂无权限', }); } const content = await getFileContentByName(finalPath); res.send({ code: 200, data: removeAnsi(content) }); } catch (e) { return next(e); } }, ); route.get( '/:file', async (req: Request, res: Response, next: NextFunction) => { try { const logService = Container.get(LogService); const finalPath = logService.checkFilePath( (req.query.path as string) || '', (req.params.file as string) || '', ); if (!finalPath || blacklist.includes(req.query.path as string)) { return res.send({ code: 403, message: '暂无权限', }); } const content = await getFileContentByName(finalPath); res.send({ code: 200, data: content }); } catch (e) { return next(e); } }, ); route.delete( '/', celebrate({ body: Joi.object({ filename: Joi.string().required(), path: Joi.string().allow(''), type: Joi.string().optional(), }), }), async (req: Request, res: Response, next: NextFunction) => { try { let { filename, path } = req.body as { filename: string; path: string; }; const logService = Container.get(LogService); const finalPath = logService.checkFilePath(path, filename); if (!finalPath || blacklist.includes(path)) { return res.send({ code: 403, message: '暂无权限', }); } await rmPath(finalPath); res.send({ code: 200 }); } catch (e) { return next(e); } }, ); route.post( '/download', celebrate({ body: Joi.object({ filename: Joi.string().required(), path: Joi.string().allow(''), }), }), async (req: Request, res: Response, next: NextFunction) => { try { let { filename, path } = req.body as { filename: string; path: string; }; const logService = Container.get(LogService); const filePath = logService.checkFilePath(path, filename); if (!filePath) { return res.send({ code: 403, message: '暂无权限', }); } return res.download(filePath, filename, (err) => { if (err) { return next(err); } }); } catch (e) { return next(e); } }, ); }; ================================================ FILE: back/api/open.ts ================================================ import { Router, Request, Response, NextFunction } from 'express'; import { Container } from 'typedi'; import OpenService from '../services/open'; import { Logger } from 'winston'; import { celebrate, Joi } from 'celebrate'; const route = Router(); export default (app: Router) => { app.use('/', route); route.get( '/apps', async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const openService = Container.get(OpenService); const data = await openService.list(); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.post( '/apps', celebrate({ body: Joi.object({ name: Joi.string().optional().allow('').disallow('system'), scopes: Joi.array().items(Joi.string().required()), }), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const openService = Container.get(OpenService); const data = await openService.create(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/apps', celebrate({ body: Joi.object({ name: Joi.string().optional().allow(''), scopes: Joi.array().items(Joi.string()), id: Joi.number().required(), }), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const openService = Container.get(OpenService); const data = await openService.update(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.delete( '/apps', celebrate({ body: Joi.array().items(Joi.number().required()), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const openService = Container.get(OpenService); const data = await openService.remove(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/apps/:id/reset-secret', celebrate({ params: Joi.object({ id: Joi.number().required(), }), }), async (req: Request<{ id: number }>, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const openService = Container.get(OpenService); const data = await openService.resetSecret(req.params.id); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.get( '/auth/token', celebrate({ query: { client_id: Joi.string().required(), client_secret: Joi.string().required(), }, }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const openService = Container.get(OpenService); const result = await openService.authToken(req.query as any); return res.send(result); } catch (e) { return next(e); } }, ); }; ================================================ FILE: back/api/script.ts ================================================ import { fileExist, readDirs, readDir, rmPath, IFile } from '../config/util'; import { Router, Request, Response, NextFunction } from 'express'; import { Container } from 'typedi'; import { Logger } from 'winston'; import config from '../config'; import * as fs from 'fs/promises'; import { celebrate, Joi } from 'celebrate'; import path, { join, parse } from 'path'; import ScriptService from '../services/script'; import multer from 'multer'; import { writeFileWithLock } from '../shared/utils'; const route = Router(); const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, config.scriptPath); }, filename: function (req, file, cb) { cb(null, file.originalname); }, }); const upload = multer({ storage: storage }); export default (app: Router) => { app.use('/scripts', route); route.get( '/', celebrate({ query: Joi.object({ path: Joi.string().optional().allow(''), }).unknown(true), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { let result: IFile[] = []; const blacklist = [ 'node_modules', '.git', '.pnpm', 'pnpm-lock.yaml', 'yarn.lock', 'package-lock.json', ]; if (req.query.path) { result = await readDir( req.query.path as string, config.scriptPath, blacklist, ); } else { result = await readDirs( config.scriptPath, config.scriptPath, blacklist, (a, b) => { if (a.type === b.type) { return a.title.localeCompare(b.title); } else { return a.type === 'directory' ? -1 : 1; } }, ); } res.send({ code: 200, data: result, }); } catch (e) { logger.error('🔥 error: %o', e); return next(e); } }); route.get( '/detail', celebrate({ query: Joi.object({ path: Joi.string().optional().allow(''), file: Joi.string().required(), }).unknown(true), }), async (req: Request, res: Response, next: NextFunction) => { try { const scriptService = Container.get(ScriptService); const content = await scriptService.getFile( req.query?.path as string || '', req.query.file as string, ); res.send({ code: 200, data: content }); } catch (e) { return next(e); } }, ); route.get( '/:file', celebrate({ params: Joi.object({ file: Joi.string().required(), }), query: Joi.object({ path: Joi.string().optional().allow(''), }).unknown(true), }), async (req: Request, res: Response, next: NextFunction) => { try { const scriptService = Container.get(ScriptService); const content = await scriptService.getFile( req.query?.path as string || '', req.params.file, ); res.send({ code: 200, data: content }); } catch (e) { return next(e); } }, ); route.post( '/', upload.single('file'), celebrate({ body: Joi.object({ filename: Joi.string().required(), path: Joi.string().optional().allow(''), content: Joi.string().optional().allow(''), originFilename: Joi.string().optional().allow(''), directory: Joi.string().optional().allow(''), file: Joi.string().optional().allow(''), }).unknown(true), }), async (req: Request, res: Response, next: NextFunction) => { try { let { filename, path, content, originFilename, directory } = req.body as { filename: string; path: string; content: string; originFilename: string; directory: string; }; if (!path) { path = config.scriptPath; } if (!path.endsWith('/')) { path += '/'; } if (!path.startsWith('/')) { path = join(config.scriptPath, path); } if (config.writePathList.every((x) => !path.startsWith(x))) { return res.send({ code: 403, message: '暂无权限', }); } if (req.file) { await fs.rename(req.file.path, join(path, filename)); return res.send({ code: 200 }); } if (directory) { await fs.mkdir(join(path, directory), { recursive: true }); return res.send({ code: 200 }); } if (!originFilename) { originFilename = filename; } const originFilePath = join( path, `${originFilename.replace(/\//g, '')}`, ); await fs.mkdir(path, { recursive: true }); const filePath = join(path, `${filename.replace(/\//g, '')}`); const fileExists = await fileExist(filePath); if (fileExists) { await fs.copyFile( originFilePath, join(config.bakPath, originFilename.replace(/\//g, '')), ); if (filename !== originFilename) { await rmPath(originFilePath); } } await writeFileWithLock(filePath, content); return res.send({ code: 200 }); } catch (e) { return next(e); } }, ); route.put( '/', celebrate({ body: Joi.object({ filename: Joi.string().required(), path: Joi.string().optional().allow(''), content: Joi.string().required().allow(''), }), }), async (req: Request, res: Response, next: NextFunction) => { try { let { filename, content, path } = req.body as { filename: string; content: string; path: string; }; const scriptService = Container.get(ScriptService); const filePath = scriptService.checkFilePath(path, filename); if (!filePath) { return res.send({ code: 403, message: '暂无权限', }); } await writeFileWithLock(filePath, content); return res.send({ code: 200 }); } catch (e) { return next(e); } }, ); route.delete( '/', celebrate({ body: Joi.object({ filename: Joi.string().required(), path: Joi.string().optional().allow(''), type: Joi.string().optional(), }), }), async (req: Request, res: Response, next: NextFunction) => { try { let { filename, path } = req.body as { filename: string; path: string; }; if (!path) { path = ''; } const scriptService = Container.get(ScriptService); const filePath = scriptService.checkFilePath(path, filename); if (!filePath) { return res.send({ code: 403, message: '暂无权限', }); } await rmPath(filePath); res.send({ code: 200 }); } catch (e) { return next(e); } }, ); route.post( '/download', celebrate({ body: Joi.object({ filename: Joi.string().required(), path: Joi.string().optional().allow(''), }), }), async (req: Request, res: Response, next: NextFunction) => { try { let { filename, path } = req.body as { filename: string; path: string; }; if (!path) { path = ''; } const scriptService = Container.get(ScriptService); const filePath = scriptService.checkFilePath(path, filename); if (!filePath) { return res.send({ code: 403, message: '暂无权限', }); } return res.download(filePath, filename, (err) => { if (err) { return next(err); } }); } catch (e) { return next(e); } }, ); route.put( '/run', celebrate({ body: Joi.object({ filename: Joi.string().required(), content: Joi.string().optional().allow(''), path: Joi.string().optional().allow(''), }), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { let { filename, content, path } = req.body; if (!path) { path = ''; } const { name, ext } = parse(filename); const filePath = join(config.scriptPath, path, `${name}.swap${ext}`); await writeFileWithLock(filePath, content || ''); const scriptService = Container.get(ScriptService); const result = await scriptService.runScript(filePath); res.send(result); } catch (e) { return next(e); } }, ); route.put( '/stop', celebrate({ body: Joi.object({ filename: Joi.string().required(), path: Joi.string().optional().allow(''), pid: Joi.number().optional().allow(''), }), }), async (req: Request, res: Response, next: NextFunction) => { try { let { filename, path, pid } = req.body; if (!path) { path = ''; } const { name, ext } = parse(filename); const filePath = join(config.scriptPath, path, `${name}.swap${ext}`); const logPath = join(config.logPath, path, `${name}.swap`); const scriptService = Container.get(ScriptService); const result = await scriptService.stopScript(filePath, pid); setTimeout(() => { rmPath(logPath); }, 3000); res.send(result); } catch (e) { return next(e); } }, ); route.put( '/rename', celebrate({ body: Joi.object({ filename: Joi.string().required(), path: Joi.string().allow(''), newFilename: Joi.string().required(), }), }), async (req: Request, res: Response, next: NextFunction) => { try { let { filename, path, newFilename } = req.body as { filename: string; path: string; newFilename: string; }; if (!path) { path = ''; } const filePath = join(config.scriptPath, path, filename); const newPath = join(config.scriptPath, path, newFilename); await fs.rename(filePath, newPath); res.send({ code: 200 }); } catch (e) { return next(e); } }, ); }; ================================================ FILE: back/api/subscription.ts ================================================ import { Router, Request, Response, NextFunction } from 'express'; import { Container } from 'typedi'; import { Logger } from 'winston'; import SubscriptionService from '../services/subscription'; import { celebrate, Joi } from 'celebrate'; import CronExpressionParser from 'cron-parser'; const route = Router(); export default (app: Router) => { app.use('/subscriptions', route); route.get('/', async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const subscriptionService = Container.get(SubscriptionService); const data = await subscriptionService.list( req.query.searchValue as string, req.query.ids as string, ); return res.send({ code: 200, data }); } catch (e) { logger.error('🔥 error: %o', e); return next(e); } }); route.post( '/', celebrate({ body: Joi.object({ type: Joi.string().required(), schedule: Joi.string().optional().allow('').allow(null), interval_schedule: Joi.object({ type: Joi.string().required(), value: Joi.number().min(1).required(), }) .optional() .allow('') .allow(null), name: Joi.string().optional().allow('').allow(null), url: Joi.string().required(), whitelist: Joi.string().optional().allow('').allow(null), blacklist: Joi.string().optional().allow('').allow(null), branch: Joi.string().optional().allow('').allow(null), dependences: Joi.string().optional().allow('').allow(null), pull_type: Joi.string().optional().allow('').allow(null), pull_option: Joi.object().optional().allow('').allow(null), extensions: Joi.string().optional().allow('').allow(null), sub_before: Joi.string().optional().allow('').allow(null), sub_after: Joi.string().optional().allow('').allow(null), schedule_type: Joi.string().required(), alias: Joi.string().required(), proxy: Joi.string().optional().allow('').allow(null), autoAddCron: Joi.boolean().optional().allow('').allow(null), autoDelCron: Joi.boolean().optional().allow('').allow(null), }), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { if ( !req.body.schedule || CronExpressionParser.parse(req.body.schedule).hasNext() ) { const subscriptionService = Container.get(SubscriptionService); const data = await subscriptionService.create(req.body); return res.send({ code: 200, data }); } else { return res.send({ code: 400, message: 'param schedule error' }); } } catch (e) { return next(e); } }, ); route.put( '/run', celebrate({ body: Joi.array().items(Joi.number().required()), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const subscriptionService = Container.get(SubscriptionService); const data = await subscriptionService.run(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/stop', celebrate({ body: Joi.array().items(Joi.number().required()), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const subscriptionService = Container.get(SubscriptionService); const data = await subscriptionService.stop(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/disable', celebrate({ body: Joi.array().items(Joi.number().required()), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const subscriptionService = Container.get(SubscriptionService); const data = await subscriptionService.disabled(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/enable', celebrate({ body: Joi.array().items(Joi.number().required()), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const subscriptionService = Container.get(SubscriptionService); const data = await subscriptionService.enabled(req.body); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.get( '/:id/log', celebrate({ params: Joi.object({ id: Joi.number().required(), }), }), async (req: Request<{ id: number }>, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const subscriptionService = Container.get(SubscriptionService); const data = await subscriptionService.log(req.params.id); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/', celebrate({ body: Joi.object({ type: Joi.string().required(), schedule: Joi.string().optional().allow('').allow(null), interval_schedule: Joi.object().optional().allow('').allow(null), name: Joi.string().optional().allow('').allow(null), url: Joi.string().required(), whitelist: Joi.string().optional().allow('').allow(null), blacklist: Joi.string().optional().allow('').allow(null), branch: Joi.string().optional().allow('').allow(null), dependences: Joi.string().optional().allow('').allow(null), pull_type: Joi.string().optional().allow('').allow(null), pull_option: Joi.object().optional().allow('').allow(null), schedule_type: Joi.string().optional().allow('').allow(null), extensions: Joi.string().optional().allow('').allow(null), sub_before: Joi.string().optional().allow('').allow(null), sub_after: Joi.string().optional().allow('').allow(null), alias: Joi.string().required(), proxy: Joi.string().optional().allow('').allow(null), autoAddCron: Joi.boolean().optional().allow('').allow(null), autoDelCron: Joi.boolean().optional().allow('').allow(null), id: Joi.number().required(), }), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { if ( !req.body.schedule || typeof req.body.schedule === 'object' || CronExpressionParser.parse(req.body.schedule).hasNext() ) { const subscriptionService = Container.get(SubscriptionService); const data = await subscriptionService.update(req.body); return res.send({ code: 200, data }); } else { return res.send({ code: 400, message: 'param schedule error' }); } } catch (e) { return next(e); } }, ); route.delete( '/', celebrate({ body: Joi.array().items(Joi.number().required()), query: Joi.object({ force: Joi.boolean().optional(), t: Joi.number(), }), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const subscriptionService = Container.get(SubscriptionService); const data = await subscriptionService.remove(req.body, req.query); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.get( '/:id', celebrate({ params: Joi.object({ id: Joi.number().required(), }), }), async (req: Request<{ id: number }>, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const subscriptionService = Container.get(SubscriptionService); const data = await subscriptionService.getDb({ id: req.params.id }); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/status', celebrate({ body: Joi.object({ ids: Joi.array().items(Joi.number().required()), status: Joi.string().required(), pid: Joi.string().optional(), log_path: Joi.string().optional(), }), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const subscriptionService = Container.get(SubscriptionService); const data = await subscriptionService.status({ ...req.body, status: parseInt(req.body.status), pid: parseInt(req.body.pid) || '', }); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.get( '/:id/logs', celebrate({ params: Joi.object({ id: Joi.number().required(), }), }), async (req: Request<{ id: number }>, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const subscriptionService = Container.get(SubscriptionService); const data = await subscriptionService.logs(req.params.id); return res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); }; ================================================ FILE: back/api/system.ts ================================================ import { Router, Request, Response, NextFunction } from 'express'; import { Container } from 'typedi'; import { Logger } from 'winston'; import * as fs from 'fs/promises'; import config from '../config'; import SystemService from '../services/system'; import { celebrate, Joi } from 'celebrate'; import UserService from '../services/user'; import { getUniqPath, handleLogPath, parseVersion, promiseExec, } from '../config/util'; import dayjs from 'dayjs'; import multer from 'multer'; import { logStreamManager } from '../shared/logStreamManager'; const route = Router(); const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, config.tmpPath); }, filename: function (req, file, cb) { cb(null, 'data.tgz'); }, }); const upload = multer({ storage: storage }); export default (app: Router) => { app.use('/system', route); route.get('/', async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const userService = Container.get(UserService); const authInfo = await userService.getAuthInfo(); const { version, changeLog, changeLogLink, publishTime } = await parseVersion(config.versionFile); let isInitialized = true; if ( Object.keys(authInfo).length === 2 && authInfo.username === 'admin' && authInfo.password === 'admin' ) { isInitialized = false; } res.send({ code: 200, data: { isInitialized, version, publishTime: dayjs(publishTime).unix(), branch: process.env.QL_BRANCH || 'master', changeLog, changeLogLink, }, }); } catch (e) { logger.error('🔥 error: %o', e); return next(e); } }); route.get( '/config', async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const systemService = Container.get(SystemService); const data = await systemService.getSystemConfig(); res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/config/log-remove-frequency', celebrate({ body: Joi.object({ logRemoveFrequency: Joi.number().allow(null), }), }), async (req: Request, res: Response, next: NextFunction) => { try { const systemService = Container.get(SystemService); const result = await systemService.updateLogRemoveFrequency(req.body); res.send(result); } catch (e) { return next(e); } }, ); route.put( '/config/cron-concurrency', celebrate({ body: Joi.object({ cronConcurrency: Joi.number().allow(null), }), }), async (req: Request, res: Response, next: NextFunction) => { try { const systemService = Container.get(SystemService); const result = await systemService.updateCronConcurrency(req.body); res.send(result); } catch (e) { return next(e); } }, ); route.put( '/config/dependence-proxy', celebrate({ body: Joi.object({ dependenceProxy: Joi.string().allow('').allow(null), }), }), async (req: Request, res: Response, next: NextFunction) => { try { const systemService = Container.get(SystemService); const result = await systemService.updateDependenceProxy(req.body); res.send(result); } catch (e) { return next(e); } }, ); route.put( '/config/node-mirror', celebrate({ body: Joi.object({ nodeMirror: Joi.string().allow('').allow(null), }), }), async (req: Request, res: Response, next: NextFunction) => { try { const systemService = Container.get(SystemService); res.setHeader('Content-type', 'application/octet-stream'); await systemService.updateNodeMirror(req.body, res); } catch (e) { return next(e); } }, ); route.put( '/config/python-mirror', celebrate({ body: Joi.object({ pythonMirror: Joi.string().allow('').allow(null), }), }), async (req: Request, res: Response, next: NextFunction) => { try { const systemService = Container.get(SystemService); const result = await systemService.updatePythonMirror(req.body); res.send(result); } catch (e) { return next(e); } }, ); route.put( '/config/linux-mirror', celebrate({ body: Joi.object({ linuxMirror: Joi.string().allow('').allow(null), }), }), async (req: Request, res: Response, next: NextFunction) => { try { const systemService = Container.get(SystemService); res.setHeader('Content-type', 'application/octet-stream'); await systemService.updateLinuxMirror(req.body, res); } catch (e) { return next(e); } }, ); route.put( '/update-check', async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const systemService = Container.get(SystemService); const result = await systemService.checkUpdate(); res.send(result); } catch (e) { return next(e); } }, ); route.put( '/update', async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const systemService = Container.get(SystemService); const result = await systemService.updateSystem(); res.send(result); } catch (e) { return next(e); } }, ); route.put( '/reload', celebrate({ body: Joi.object({ type: Joi.string().optional().allow('').allow(null), }), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const systemService = Container.get(SystemService); const result = await systemService.reloadSystem(req.body.type); res.send(result); } catch (e) { return next(e); } }, ); route.put( '/notify', celebrate({ body: Joi.object({ title: Joi.string().required(), content: Joi.string().required(), }), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const systemService = Container.get(SystemService); const result = await systemService.notify(req.body); res.send(result); } catch (e) { return next(e); } }, ); route.put( '/command-run', celebrate({ body: Joi.object({ command: Joi.string().required(), }), }), async (req: Request, res: Response, next: NextFunction) => { try { const systemService = Container.get(SystemService); const command = req.body.command; const idStr = `cat ${config.crontabFile} | grep -E "${command}" | perl -pe "s|.*ID=(.*) ${command}.*|\\1|" | head -1 | awk -F " " '{print $1}' | xargs echo -n`; let id = await promiseExec(idStr); const uniqPath = await getUniqPath(command, id); const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS'); const logPath = `${uniqPath}/${logTime}.log`; res.setHeader('Content-type', 'application/octet-stream'); await systemService.run( { ...req.body, logPath }, { onStart: async (cp, startTime) => { res.setHeader('QL-Task-Pid', `${cp.pid}`); res.setHeader('QL-Task-Log', `${logPath}`); }, onEnd: async (cp, endTime, diff) => { // Close the stream after task completion await logStreamManager.closeStream(await handleLogPath(logPath)); res.end(); }, onError: async (message: string) => { res.write(message); const absolutePath = await handleLogPath(logPath); await logStreamManager.write(absolutePath, message); }, onLog: async (message: string) => { res.write(message); const absolutePath = await handleLogPath(logPath); await logStreamManager.write(absolutePath, message); }, }, ); } catch (e) { return next(e); } }, ); route.put( '/command-stop', celebrate({ body: Joi.object({ command: Joi.string().optional(), pid: Joi.number().optional(), }), }), async (req: Request, res: Response, next: NextFunction) => { try { const systemService = Container.get(SystemService); const result = await systemService.stop(req.body); res.send(result); } catch (e) { return next(e); } }, ); route.put( '/data/export', celebrate({ body: Joi.object({ type: Joi.array().items(Joi.string()).optional(), }), }), async (req: Request, res: Response, next: NextFunction) => { try { const systemService = Container.get(SystemService); await systemService.exportData(res, req.body.type); } catch (e) { return next(e); } }, ); route.put( '/data/import', upload.single('data'), async (req: Request, res: Response, next: NextFunction) => { try { const systemService = Container.get(SystemService); const result = await systemService.importData(); res.send(result); } catch (e) { return next(e); } }, ); route.get( '/log', celebrate({ query: { startTime: Joi.string().allow('').optional(), endTime: Joi.string().allow('').optional(), t: Joi.string().optional(), }, }), async (req: Request, res: Response, next: NextFunction) => { try { const systemService = Container.get(SystemService); await systemService.getSystemLog( res, req.query as { startTime?: string; endTime?: string; }, ); } catch (e) { return next(e); } }, ); route.delete( '/log', async (req: Request, res: Response, next: NextFunction) => { try { const systemService = Container.get(SystemService); await systemService.deleteSystemLog(); res.send({ code: 200 }); } catch (e) { return next(e); } }, ); route.put( '/auth/reset', celebrate({ body: Joi.object({ retries: Joi.number().optional(), twoFactorActivated: Joi.boolean().optional(), password: Joi.string().optional(), username: Joi.string().optional(), }), }), async (req: Request, res: Response, next: NextFunction) => { try { const userService = Container.get(UserService); await userService.resetAuthInfo(req.body); res.send({ code: 200, message: '更新成功' }); } catch (e) { return next(e); } }, ); route.put( '/config/timezone', celebrate({ body: Joi.object({ timezone: Joi.string().allow('').allow(null), }), }), async (req: Request, res: Response, next: NextFunction) => { try { const systemService = Container.get(SystemService); const result = await systemService.updateTimezone(req.body); res.send(result); } catch (e) { return next(e); } }, ); route.put( '/config/global-ssh-key', celebrate({ body: Joi.object({ globalSshKey: Joi.string().allow('').allow(null), }), }), async (req: Request, res: Response, next: NextFunction) => { try { const systemService = Container.get(SystemService); const result = await systemService.updateGlobalSshKey(req.body); res.send(result); } catch (e) { return next(e); } }, ); route.put( '/config/dependence-clean', celebrate({ body: Joi.object({ type: Joi.string().allow(''), }), }), async (req: Request, res: Response, next: NextFunction) => { try { const systemService = Container.get(SystemService); const result = await systemService.cleanDependence(req.body.type); res.send(result); } catch (e) { return next(e); } }, ); }; ================================================ FILE: back/api/update.ts ================================================ import { NextFunction, Request, Response, Router } from 'express'; import Container from 'typedi'; import Logger from '../loaders/logger'; import SystemService from '../services/system'; const route = Router(); export default (app: Router) => { app.use('/update', route); route.put( '/reload', async (req: Request, res: Response, next: NextFunction) => { try { const systemService = Container.get(SystemService); const result = await systemService.reloadSystem(); res.send(result); } catch (e) { Logger.error('🔥 error: %o', e); return next(e); } }, ); route.put( '/system', async (req: Request, res: Response, next: NextFunction) => { try { const systemService = Container.get(SystemService); const result = await systemService.reloadSystem('system'); res.send(result); } catch (e) { Logger.error('🔥 error: %o', e); return next(e); } }, ); route.put( '/data', async (req: Request, res: Response, next: NextFunction) => { try { const systemService = Container.get(SystemService); const result = await systemService.reloadSystem('data'); res.send(result); } catch (e) { Logger.error('🔥 error: %o', e); return next(e); } }, ); }; ================================================ FILE: back/api/user.ts ================================================ import { Router, Request, Response, NextFunction } from 'express'; import { Container } from 'typedi'; import { Logger } from 'winston'; import UserService from '../services/user'; import { celebrate, Joi } from 'celebrate'; import multer from 'multer'; import path from 'path'; import { v4 as uuidV4 } from 'uuid'; import rateLimit from 'express-rate-limit'; import config from '../config'; import { isDemoEnv, getToken } from '../config/util'; const route = Router(); const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, config.uploadPath); }, filename: function (req, file, cb) { const ext = path.parse(file.originalname).ext; const key = uuidV4(); cb(null, key + ext); }, }); const upload = multer({ storage: storage }); export default (app: Router) => { app.use('/user', route); route.post( '/login', rateLimit({ windowMs: 15 * 60 * 1000, max: 100, }), celebrate({ body: Joi.object({ username: Joi.string().required(), password: Joi.string().required(), }), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const userService = Container.get(UserService); const data = await userService.login({ ...req.body }, req); return res.send(data); } catch (e) { return next(e); } }, ); route.post( '/logout', async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const userService = Container.get(UserService); const token = getToken(req); await userService.logout(req.platform, token); res.send({ code: 200 }); } catch (e) { return next(e); } }, ); route.put( '/', celebrate({ body: Joi.object({ username: Joi.string().required(), password: Joi.string().required(), }), }), async (req: Request, res: Response, next: NextFunction) => { try { if (isDemoEnv()) { return res.send({ code: 450, message: '未知错误' }); } const userService = Container.get(UserService); await userService.updateUsernameAndPassword(req.body); res.send({ code: 200, message: '更新成功' }); } catch (e) { return next(e); } }, ); route.get('/', async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const userService = Container.get(UserService); const authInfo = await userService.getAuthInfo(); res.send({ code: 200, data: { username: authInfo.username, avatar: authInfo.avatar, twoFactorActivated: authInfo.twoFactorActivated, }, }); } catch (e) { logger.error('🔥 error: %o', e); return next(e); } }); route.get( '/two-factor/init', async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const userService = Container.get(UserService); const data = await userService.initTwoFactor(); res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/two-factor/active', celebrate({ body: Joi.object({ code: Joi.string().required(), }), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const userService = Container.get(UserService); const data = await userService.activeTwoFactor(req.body.code); res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/two-factor/deactive', async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const userService = Container.get(UserService); const data = await userService.deactiveTwoFactor(); res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/two-factor/login', celebrate({ body: Joi.object({ code: Joi.string().required(), username: Joi.string().required(), password: Joi.string().required(), }), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const userService = Container.get(UserService); const data = await userService.twoFactorLogin(req.body, req); res.send(data); } catch (e) { return next(e); } }, ); route.get( '/login-log', async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const userService = Container.get(UserService); const data = await userService.getLoginLog(); res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.get( '/notification', async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const userService = Container.get(UserService); const data = await userService.getNotificationMode(); res.send({ code: 200, data }); } catch (e) { return next(e); } }, ); route.put( '/notification', async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const userService = Container.get(UserService); const result = await userService.updateNotificationMode(req.body); res.send(result); } catch (e) { return next(e); } }, ); route.put( '/init', celebrate({ body: Joi.object({ username: Joi.string().required(), password: Joi.string().required(), }), }), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const userService = Container.get(UserService); await userService.updateUsernameAndPassword(req.body); res.send({ code: 200, message: '更新成功' }); } catch (e) { return next(e); } }, ); route.put( '/notification/init', async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const userService = Container.get(UserService); const result = await userService.updateNotificationMode(req.body); res.send(result); } catch (e) { return next(e); } }, ); route.put( '/avatar', upload.single('avatar'), async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { const userService = Container.get(UserService); const result = await userService.updateAvatar(req.file!.filename); res.send(result); } catch (e) { return next(e); } }, ); }; ================================================ FILE: back/app.ts ================================================ import 'reflect-metadata'; import cluster, { type Worker } from 'cluster'; import compression from 'compression'; import cors from 'cors'; import express from 'express'; import helmet from 'helmet'; import { Container } from 'typedi'; import config from './config'; import Logger from './loaders/logger'; import { monitoringMiddleware } from './middlewares/monitoring'; import { type GrpcServerService } from './services/grpc'; import { type HttpServerService } from './services/http'; interface WorkerMetadata { id: number; pid: number; serviceType: string; startTime: Date; } class Application { private app: express.Application; private httpServerService?: HttpServerService; private grpcServerService?: GrpcServerService; private isShuttingDown = false; private workerMetadataMap = new Map(); private httpWorker?: Worker; constructor() { this.app = express(); // 创建一个全局中间件,删除查询参数中的t this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => { if (req.query.t) { delete req.query.t; } next(); }); } async start() { try { if (cluster.isPrimary) { await this.initializeDatabase(); } if (cluster.isPrimary) { this.startMasterProcess(); } else { await this.startWorkerProcess(); } } catch (error) { Logger.error('Failed to start application:', error); process.exit(1); } } private startMasterProcess() { // Fork gRPC worker first and wait for it to be ready const grpcWorker = this.forkWorker('grpc'); // Wait for gRPC worker to signal it's ready before starting HTTP worker this.waitForWorkerReady(grpcWorker, 30000) .then(() => { Logger.info('✌️ gRPC worker is ready, starting HTTP worker'); this.httpWorker = this.forkWorker('http'); }) .catch((error) => { Logger.error('✌️ Failed to wait for gRPC worker:', error); process.exit(1); }); cluster.on('exit', (worker, code, signal) => { const metadata = this.workerMetadataMap.get(worker.id); if (metadata) { if (!this.isShuttingDown) { Logger.error( `✌️ ${metadata.serviceType} worker ${worker.process.pid} died (${signal || code }). Restarting...`, ); // If gRPC worker died, restart it and wait for it to be ready if (metadata.serviceType === 'grpc') { const newGrpcWorker = this.forkWorker('grpc'); this.waitForWorkerReady(newGrpcWorker, 30000) .then(() => { Logger.info('✌️ gRPC worker restarted and ready'); // Re-register cron jobs by notifying the HTTP worker if (this.httpWorker) { try { this.httpWorker.send('reregister-crons'); Logger.info('✌️ Sent reregister-crons message to HTTP worker'); } catch (error) { Logger.error('✌️ Failed to send reregister-crons message:', error); } } }) .catch((error) => { Logger.error('✌️ Failed to restart gRPC worker:', error); process.exit(1); }); } else { // For HTTP worker, just restart it const newWorker = this.forkWorker(metadata.serviceType); this.httpWorker = newWorker; Logger.info(`✌️ Restarted ${metadata.serviceType} worker (PID: ${newWorker.process.pid})`); } } this.workerMetadataMap.delete(worker.id); } }); this.setupMasterShutdown(); } private waitForWorkerReady(worker: Worker, timeoutMs: number): Promise { return new Promise((resolve, reject) => { const messageHandler = (msg: any) => { if (msg === 'ready') { worker.removeListener('message', messageHandler); clearTimeout(timeoutId); resolve(); } }; worker.on('message', messageHandler); // Timeout after specified milliseconds const timeoutId = setTimeout(() => { worker.removeListener('message', messageHandler); reject(new Error(`Worker failed to start within ${timeoutMs / 1000} seconds`)); }, timeoutMs); }); } private forkWorker(serviceType: string): Worker { const worker = cluster.fork({ SERVICE_TYPE: serviceType }); this.workerMetadataMap.set(worker.id, { id: worker.id, pid: worker.process.pid!, serviceType, startTime: new Date(), }); return worker; } private async initializeDatabase() { const dbLoader = await import('./loaders/db'); await dbLoader.default(); } private setupMiddlewares() { this.app.use(helmet({ contentSecurityPolicy: false, })); this.app.use(cors(config.cors)); this.app.use(compression()); this.app.use(monitoringMiddleware); } private setupMasterShutdown() { const shutdown = async () => { if (this.isShuttingDown) return; this.isShuttingDown = true; const workers = Object.values(cluster.workers || {}); const workerPromises: Promise[] = []; workers.forEach((worker) => { if (worker) { const exitPromise = new Promise((resolve) => { worker.once('exit', () => { Logger.info(`✌️ Worker ${worker.process.pid} exited`); resolve(); }); try { worker.send('shutdown'); } catch (error) { Logger.warn( `✌️ Failed to send shutdown to worker ${worker.process.pid}:`, error, ); } }); workerPromises.push(exitPromise); } }); try { await Promise.race([ Promise.all(workerPromises), new Promise((resolve) => { setTimeout(() => { Logger.warn('✌️ Worker shutdown timeout reached'); resolve(); }, 10000); }), ]); process.exit(0); } catch (error) { Logger.error('✌️ Error during worker shutdown:', error); process.exit(1); } }; process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); } private async startWorkerProcess() { const serviceType = process.env.SERVICE_TYPE; if (!serviceType || !['http', 'grpc'].includes(serviceType)) { Logger.error('✌️ Invalid SERVICE_TYPE:', serviceType); process.exit(1); } Logger.info(`✌️ ${serviceType} worker started (PID: ${process.pid})`); try { if (serviceType === 'http') { await this.startHttpService(); } else { await this.startGrpcService(); } process.send?.('ready'); } catch (error) { Logger.error(`✌️ ${serviceType} worker failed:`, error); process.exit(1); } } private async startHttpService() { this.setupMiddlewares(); const { HttpServerService } = await import('./services/http'); this.httpServerService = Container.get(HttpServerService); const appLoader = await import('./loaders/app'); await appLoader.default({ app: this.app }); const server = await this.httpServerService.initialize( this.app, config.port, ); const serverLoader = await import('./loaders/server'); await (serverLoader.default as any)({ server }); this.setupWorkerShutdown('http'); } private async startGrpcService() { const { GrpcServerService } = await import('./services/grpc'); this.grpcServerService = Container.get(GrpcServerService); await this.grpcServerService.initialize(); this.setupWorkerShutdown('grpc'); } private setupWorkerShutdown(serviceType: string) { process.on('message', async (msg) => { if (msg === 'shutdown') { this.gracefulShutdown(serviceType); } else if (msg === 'reregister-crons' && serviceType === 'http') { // Re-register cron jobs when gRPC worker restarts try { Logger.info('✌️ Received reregister-crons message, re-registering cron jobs...'); const CronService = (await import('./services/cron')).default; const cronService = Container.get(CronService); await cronService.autosave_crontab(); Logger.info('✌️ Cron jobs re-registered successfully'); } catch (error) { Logger.error('✌️ Failed to re-register cron jobs:', error); } } }); const shutdown = () => this.gracefulShutdown(serviceType); process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); } private async gracefulShutdown(serviceType: string) { if (this.isShuttingDown) return; this.isShuttingDown = true; try { if (serviceType === 'http') { await this.httpServerService?.shutdown(); } else { await this.grpcServerService?.shutdown(); } process.exit(0); } catch (error) { Logger.error(`✌️ [${serviceType}] Error during shutdown:`, error); process.exit(1); } } } const app = new Application(); app.start().catch((error) => { Logger.error('🙅‍♀️ Application failed to start:', error); process.exit(1); }); ================================================ FILE: back/config/const.ts ================================================ export const LOG_END_SYMBOL = '     '; export const TASK_COMMAND = 'task'; export const QL_COMMAND = 'ql'; export const TASK_PREFIX = `${TASK_COMMAND} `; export const QL_PREFIX = `${QL_COMMAND} `; export const SAMPLE_FILES = [ { title: 'config.sample.sh', value: 'sample/config.sample.sh', target: 'config.sh', }, { title: 'notify.js', value: 'sample/notify.js', target: 'data/scripts/sendNotify.js', }, { title: 'notify.py', value: 'sample/notify.py', target: 'data/scripts/notify.py', }, ]; export const PYTHON_INSTALL_DIR = process.env.PYTHON_HOME; export const NotificationModeStringMap = { 0: 'gotify', 1: 'goCqHttpBot', 2: 'serverChan', 3: 'pushDeer', 4: 'bark', 5: 'chat', 6: 'telegramBot', 7: 'dingtalkBot', 8: 'weWorkBot', 9: 'weWorkApp', 10: 'aibotk', 11: 'iGot', 12: 'pushPlus', 13: 'wePlusBot', 14: 'email', 15: 'pushMe', 16: 'feishu', 17: 'webhook', 18: 'chronocat', 19: 'ntfy', 20: 'wxPusherBot', } as const; ================================================ FILE: back/config/http.ts ================================================ import { request as undiciRequest, Dispatcher } from 'undici'; type RequestBaseOptions = { dispatcher?: Dispatcher; json?: Record; form?: string; headers?: Record; } & Omit, 'origin' | 'path' | 'method'>; type RequestOptionsWithOptions = RequestBaseOptions & Partial>; type ResponseTypeMap = { json: Record; text: string; }; type ResponseTypeKey = keyof ResponseTypeMap; async function request( url: string, options?: RequestOptionsWithOptions, ): Promise> { const { json, form, body, headers = {}, ...rest } = options || {}; const finalHeaders = { ...headers } as Record; let finalBody = body; if (json) { finalHeaders['content-type'] = 'application/json'; finalBody = JSON.stringify(json); } else if (form) { finalBody = form; delete finalHeaders['content-type']; } const res = await undiciRequest(url, { method: 'POST', headers: finalHeaders, body: finalBody, ...rest, }); return res; } async function post( url: string, options?: RequestBaseOptions & { responseType?: T }, ): Promise { const resp = await request(url, { ...options, method: 'POST' }); const rawText = await resp.body.text(); if (options?.responseType === 'text') { return rawText as ResponseTypeMap[T]; } try { return JSON.parse(rawText) as ResponseTypeMap[T]; } catch { return rawText as ResponseTypeMap[T]; } } export const httpClient = { post, request, }; ================================================ FILE: back/config/index.ts ================================================ import dotenv from 'dotenv'; import path from 'path'; import { createRandomString } from './share'; dotenv.config({ path: path.join(__dirname, '../../.env'), }); interface Config { port: number; grpcPort: number; nodeEnv: string; isDevelopment: boolean; isProduction: boolean; jwt: { secret: string; expiresIn?: string; }; cors: { origin: string[]; methods: string[]; }; logs: { level: string; }; api: { prefix: string; }; } const config: Config = { port: parseInt(process.env.BACK_PORT || '5700', 10), grpcPort: parseInt(process.env.GRPC_PORT || '5500', 10), nodeEnv: process.env.NODE_ENV || 'development', isDevelopment: process.env.NODE_ENV === 'development', isProduction: process.env.NODE_ENV === 'production', logs: { level: process.env.LOG_LEVEL || 'silly', }, api: { prefix: '/api', }, jwt: { secret: process.env.JWT_SECRET || 'whyour-secret', expiresIn: process.env.JWT_EXPIRES_IN, }, cors: { origin: process.env.CORS_ORIGIN ? process.env.CORS_ORIGIN.split(',') : ['*'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], }, }; process.env.NODE_ENV = process.env.NODE_ENV || 'development'; if (!process.env.QL_DIR) { let qlHomePath = path.join(__dirname, '../../'); if (qlHomePath.endsWith('/static/')) { qlHomePath = path.join(qlHomePath, '../'); } process.env.QL_DIR = qlHomePath.replace(/\/$/g, ''); } const lastVersionFile = `https://qn.whyour.cn/version.yaml`; // Get and normalize QlBaseUrl let baseUrl = process.env.QlBaseUrl || ''; if (baseUrl) { // Ensure it starts with / if (!baseUrl.startsWith('/')) { baseUrl = `/${baseUrl}`; } // Remove trailing slash for consistency in route definitions if (baseUrl.endsWith('/')) { baseUrl = baseUrl.slice(0, -1); } } const rootPath = process.env.QL_DIR as string; const envFound = dotenv.config({ path: path.join(rootPath, '.env') }); let dataPath = path.join(rootPath, 'data/'); if (process.env.QL_DATA_DIR) { dataPath = process.env.QL_DATA_DIR.replace(/\/$/g, ''); } const shellPath = path.join(rootPath, 'shell/'); const preloadPath = path.join(shellPath, 'preload/'); const tmpPath = path.join(rootPath, '.tmp/'); const samplePath = path.join(rootPath, 'sample/'); const configPath = path.join(dataPath, 'config/'); const scriptPath = path.join(dataPath, 'scripts/'); const repoPath = path.join(dataPath, 'repo/'); const bakPath = path.join(dataPath, 'bak/'); const logPath = path.join(dataPath, 'log/'); const dbPath = path.join(dataPath, 'db/'); const uploadPath = path.join(dataPath, 'upload/'); const sshdPath = path.join(dataPath, 'ssh.d/'); const systemLogPath = path.join(dataPath, 'syslog/'); const dependenceCachePath = path.join(dataPath, 'dep_cache/'); const envFile = path.join(preloadPath, 'env.sh'); const jsEnvFile = path.join(preloadPath, 'env.js'); const pyEnvFile = path.join(preloadPath, 'env.py'); const jsNotifyFile = path.join(preloadPath, '__ql_notify__.js'); const pyNotifyFile = path.join(preloadPath, '__ql_notify__.py'); const confFile = path.join(configPath, 'config.sh'); const crontabFile = path.join(configPath, 'crontab.list'); const authConfigFile = path.join(configPath, 'auth.json'); const extraFile = path.join(configPath, 'extra.sh'); const confBakDir = path.join(dataPath, 'config/bak/'); const sampleFile = path.join(samplePath, 'config.sample.sh'); const sqliteFile = path.join(samplePath, 'database.sqlite'); const authError = '错误的用户名密码,请重试'; const loginFaild = '请先登录!'; const configString = 'config sample crontab shareCode diy'; const versionFile = path.join(rootPath, 'version.yaml'); const dataTgzFile = path.join(tmpPath, 'data.tgz'); const shareShellFile = path.join(shellPath, 'share.sh'); const dependenceProxyFile = path.join(configPath, 'dependence-proxy.sh'); if (envFound.error) { throw new Error("⚠️ Couldn't find .env file ⚠️"); } export default { ...config, jwt: config.jwt, baseUrl, rootPath, tmpPath, dataPath, dataTgzFile, shareShellFile, dependenceProxyFile, configString, loginFaild, authError, logPath, extraFile, authConfigFile, confBakDir, crontabFile, sampleFile, confFile, envFile, jsEnvFile, pyEnvFile, jsNotifyFile, pyNotifyFile, dbPath, uploadPath, configPath, scriptPath, repoPath, samplePath, blackFileList: [ 'auth.json', 'config.sh.sample', 'cookie.sh', 'crontab.list', 'dependence-proxy.sh', 'env.sh', 'env.js', 'env.py', 'token.json', ], writePathList: [configPath, scriptPath], bakPath, apiWhiteList: [ '/api/user/login', '/api/health', '/open/auth/token', '/api/user/two-factor/login', '/api/system', '/api/user/init', '/api/user/notification/init', '/open/user/login', '/open/user/two-factor/login', '/open/system', '/open/user/init', '/open/user/notification/init', ], versionFile, lastVersionFile, sqliteFile, sshdPath, systemLogPath, dependenceCachePath, maxTokensPerPlatform: 10, // Maximum number of concurrent sessions per platform }; ================================================ FILE: back/config/serverEnv.ts ================================================ import { Request, Response } from 'express'; import pick from 'lodash/pick'; let pickedEnv: Record; function getPickedEnv() { if (pickedEnv) return pickedEnv; const picked = pick(process.env, ['QlBaseUrl', 'DeployEnv', 'QL_DIR']); if (picked.QlBaseUrl) { if (!picked.QlBaseUrl.startsWith('/')) { picked.QlBaseUrl = `/${picked.QlBaseUrl}`; } if (!picked.QlBaseUrl.endsWith('/')) { picked.QlBaseUrl = `${picked.QlBaseUrl}/`; } } pickedEnv = picked as Record; return picked; } export function serveEnv(_req: Request, res: Response) { res.type('.js'); res.send( Object.entries(getPickedEnv()) .map(([k, v]) => `window.__ENV__${k}=${JSON.stringify(v)};`) .join('\n'), ); } ================================================ FILE: back/config/share.ts ================================================ export function createRandomString(min: number, max: number): string { const num = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']; const english = [ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', ]; const ENGLISH = [ 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', ]; const special = ['-', '_']; const config = num.concat(english).concat(ENGLISH).concat(special); const arr = []; arr.push(getOne(num)); arr.push(getOne(english)); arr.push(getOne(ENGLISH)); arr.push(getOne(special)); const len = min + Math.floor(Math.random() * (max - min + 1)); for (let i = 4; i < len; i++) { arr.push(config[Math.floor(Math.random() * config.length)]); } const newArr = []; for (let j = 0; j < len; j++) { newArr.push(arr.splice(Math.random() * arr.length, 1)[0]); } function getOne(arr: any[]) { return arr[Math.floor(Math.random() * arr.length)]; } return newArr.join(''); } ================================================ FILE: back/config/subscription.ts ================================================ import { Subscription } from '../data/subscription'; import isNil from 'lodash/isNil'; export function formatUrl(doc: Subscription) { let url = doc.url; let host = ''; if (doc.type === 'private-repo') { if (doc.pull_type === 'ssh-key') { host = doc.url!.replace(/.*\@([^\:]+)\:.*/, '$1'); url = doc.url!.replace(host, doc.alias); } else { host = doc.url!.replace(/.*\:\/\/([^\/]+)\/.*/, '$1'); const { username, password } = doc.pull_option as any; url = doc.url!.replace(host, `${username}:${password}@${host}`); } } return { url, host }; } export function formatCommand(doc: Subscription, url?: string) { let command = `SUB_ID=${doc.id} ql `; let _url = url || formatUrl(doc).url; const { type, whitelist, blacklist, dependences, branch, extensions, proxy, autoAddCron, autoDelCron, } = doc; if (type === 'file') { command += `raw "${_url}" "${proxy || ''}" "${ isNil(autoAddCron) ? true : Boolean(autoAddCron) }" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`; } else { command += `repo "${_url}" "${whitelist || ''}" "${blacklist || ''}" "${ dependences || '' }" "${branch || ''}" "${extensions || ''}" "${proxy || ''}" "${ isNil(autoAddCron) ? true : Boolean(autoAddCron) }" "${isNil(autoDelCron) ? true : Boolean(autoDelCron)}"`; } return command; } ================================================ FILE: back/config/util.ts ================================================ import * as fs from 'fs/promises'; import * as path from 'path'; import { exec } from 'child_process'; import psTreeFun from 'ps-tree'; import { promisify } from 'util'; import { load } from 'js-yaml'; import config from './index'; import { PYTHON_INSTALL_DIR, TASK_COMMAND } from './const'; import Logger from '../loaders/logger'; import { writeFileWithLock } from '../shared/utils'; import { DependenceTypes } from '../data/dependence'; import { FormData } from 'undici'; export * from './share'; export async function getFileContentByName(fileName: string) { const _exsit = await fileExist(fileName); if (_exsit) { return await fs.readFile(fileName, 'utf8'); } return ''; } export function removeAnsi(text: string) { return text.replace(/\x1b\[\d+m/g, ''); } export async function getLastModifyFilePath(dir: string) { let filePath = ''; const _exsit = await fileExist(dir); if (_exsit) { const arr = await fs.readdir(dir); arr.forEach(async (item) => { const fullpath = path.join(dir, item); const stats = await fs.lstat(fullpath); if (stats.isFile()) { if (stats.mtimeMs >= 0) { filePath = fullpath; } } }); } return filePath; } export function getToken(req: any) { const { authorization = '' } = req.headers; if (authorization && authorization.split(' ')[0] === 'Bearer') { return (authorization as string) .replace('Bearer ', '') .replace('mobile-', '') .replace('desktop-', ''); } return ''; } export function getPlatform(userAgent: string): 'mobile' | 'desktop' { const ua = userAgent.toLowerCase(); const testUa = (regexp: RegExp) => regexp.test(ua); const testVs = (regexp: RegExp) => (ua.match(regexp) || []) .toString() .replace(/[^0-9|_.]/g, '') .replace(/_/g, '.'); // 系统 let system = 'unknow'; if (testUa(/windows|win32|win64|wow32|wow64/g)) { system = 'windows'; // windows系统 } else if (testUa(/macintosh|macintel/g)) { system = 'macos'; // macos系统 } else if (testUa(/x11/g)) { system = 'linux'; // linux系统 } else if (testUa(/android|adr/g)) { system = 'android'; // android系统 } else if (testUa(/ios|iphone|ipad|ipod|iwatch/g)) { system = 'ios'; // ios系统 } else if (testUa(/openharmony/g)) { system = 'openharmony'; // openharmony系统 } let platform = 'desktop'; if (system === 'windows' || system === 'macos' || system === 'linux') { platform = 'desktop'; } else if ( system === 'android' || system === 'ios' || system === 'openharmony' || testUa(/mobile/g) ) { platform = 'mobile'; } return platform as 'mobile' | 'desktop'; } export async function fileExist(file: any) { try { await fs.access(file); return true; } catch (error) { return false; } } export async function createFile(file: string, data: string = '') { await fs.mkdir(path.dirname(file), { recursive: true }); await writeFileWithLock(file, data); } export async function handleLogPath( logPath: string, data: string = '', ): Promise { const absolutePath = path.resolve(config.logPath, logPath); const logFileExist = await fileExist(absolutePath); if (!logFileExist) { await createFile(absolutePath, data); } return absolutePath; } export async function concurrentRun( fnList: Array<() => Promise> = [], max = 5, ) { if (!fnList.length) return; const replyList: any[] = []; // 收集任务执行结果 const startTime = new Date().getTime(); // 记录任务执行开始时间 // 任务执行程序 const schedule = async (index: number) => { return new Promise(async (resolve) => { const fn = fnList[index]; if (!fn) return resolve(null); // 执行当前异步任务 const reply = await fn(); replyList[index] = reply; // 执行完当前任务后,继续执行任务池的剩余任务 await schedule(index + max); resolve(null); }); }; // 任务池执行程序 const scheduleList = new Array(max) .fill(0) .map((_, index) => schedule(index)); // 使用 Promise.all 批量执行 const r = await Promise.all(scheduleList); const cost = (new Date().getTime() - startTime) / 1000; return replyList; } enum FileType { 'directory', 'file', } export interface IFile { title: string; key: string; type: 'directory' | 'file'; parent: string; createTime: number; size?: number; children?: IFile[]; } export function dirSort(a: IFile, b: IFile): number { if (a.type === 'file' && b.type === 'file') { return b.createTime - a.createTime; } else if (a.type === 'directory' && b.type === 'directory') { return a.title.localeCompare(b.title); } else { return a.type === 'directory' ? -1 : 1; } } export async function readDirs( dir: string, baseDir: string = '', blacklist: string[] = [], sort: (a: IFile, b: IFile) => number = dirSort, ): Promise { const relativePath = path.relative(baseDir, dir); const files = await fs.readdir(dir); const result: IFile[] = []; for (const file of files) { const subPath = path.join(dir, file); const stats = await fs.lstat(subPath); const key = path.join(relativePath, file); if (blacklist.includes(file) || stats.isSymbolicLink()) { continue; } if (stats.isDirectory()) { const children = await readDirs(subPath, baseDir, blacklist, sort); result.push({ title: file, key, type: 'directory', parent: relativePath, createTime: stats.birthtime.getTime(), children: children.sort(sort), }); } else { result.push({ title: file, type: 'file', key, parent: relativePath, size: stats.size, createTime: stats.birthtime.getTime(), }); } } return result.sort(sort); } export async function readDir( dir: string, baseDir: string = '', blacklist: string[] = [], ): Promise { const absoluteDir = path.join(baseDir, dir); const relativePath = path.relative(baseDir, absoluteDir); try { const files = await fs.readdir(absoluteDir); const result: IFile[] = []; for (const file of files) { const subPath = path.join(absoluteDir, file); const stats = await fs.lstat(subPath); const key = path.join(relativePath, file); if (blacklist.includes(file) || stats.isSymbolicLink()) { continue; } if (stats.isDirectory()) { result.push({ title: file, type: 'directory', key, parent: relativePath, createTime: stats.birthtime.getTime(), children: [], }); } else { result.push({ title: file, type: 'file', key, parent: relativePath, size: stats.size, createTime: stats.birthtime.getTime(), }); } } return result; } catch (error: any) { if (error.code === 'ENOENT') { return []; } throw error; } } export async function promiseExec(command: string): Promise { try { const { stderr, stdout } = await promisify(exec)(command, { maxBuffer: 200 * 1024 * 1024, encoding: 'utf8', }); return stdout || stderr; } catch (error) { return JSON.stringify(error); } } export async function promiseExecSuccess(command: string): Promise { try { const { stdout } = await promisify(exec)(command, { maxBuffer: 200 * 1024 * 1024, encoding: 'utf8', }); return stdout || ''; } catch (error) { return ''; } } export function parseHeaders(headers: string) { if (!headers) return {}; const parsed: any = {}; let key: string; let val: string; let i: number; headers && headers.split('\n').forEach(function parser(line) { i = line.indexOf(':'); key = line.substring(0, i).trim().toLowerCase(); val = line.substring(i + 1).trim(); if (!key) { return; } parsed[key] = parsed[key] ? parsed[key] + ', ' + val : val; }); return parsed; } function parseString( input: string, valueFormatFn?: (v: string) => string, ): Record { const regex = /(\w+):\s*((?:(?!\n\w+:).)*)/g; const matches: Record = {}; let match; while ((match = regex.exec(input)) !== null) { const [, key, value] = match; const _key = key.trim(); if (!_key || matches[_key]) { continue; } let _value = value.trim(); try { _value = valueFormatFn ? valueFormatFn(_value) : _value; const jsonValue = JSON.parse(_value); matches[_key] = jsonValue; } catch (error) { matches[_key] = _value; } } return matches; } export function parseBody( body: string, contentType: | 'application/json' | 'multipart/form-data' | 'application/x-www-form-urlencoded' | 'text/plain', valueFormatFn?: (v: string) => string, ) { if (contentType === 'text/plain' || !body) { return valueFormatFn && body ? valueFormatFn(body) : body; } const parsed = parseString(body, valueFormatFn); switch (contentType) { case 'multipart/form-data': return Object.keys(parsed).reduce((p, c) => { p.append(c, parsed[c]); return p; }, new FormData()); case 'application/x-www-form-urlencoded': return Object.keys(parsed).reduce((p, c) => { return p ? `${p}&${c}=${parsed[c]}` : `${c}=${parsed[c]}`; }); } return parsed; } export function psTree(pid: number): Promise { return new Promise((resolve, reject) => { psTreeFun(pid, (err: any, children) => { if (err) { reject(err); } resolve(children.map((x) => Number(x.PID)).filter((x) => !isNaN(x))); }); }); } export async function killTask(pid: number) { const pids = await psTree(pid); if (pids.length) { try { [pid, ...pids].reverse().forEach((x) => { process.kill(x, 15); }); } catch (error) {} } else { process.kill(pid, 2); } } export async function getPid(cmd: string) { const taskCommand = `ps -eo pid,command | grep "${cmd}" | grep -v grep | awk '{print $1}' | head -1 | xargs echo -n`; const pid = await promiseExec(taskCommand); return pid ? Number(pid) : undefined; } export async function getAllPids(cmd: string): Promise { const taskCommand = `ps -eo pid,command | grep "${cmd}" | grep -v grep | awk '{print $1}'`; const pidsStr = await promiseExec(taskCommand); if (!pidsStr) return []; return pidsStr .split('\n') .map((p) => Number(p.trim())) .filter((p) => !isNaN(p) && p > 0); } export async function killAllTasks(cmd: string): Promise { const pids = await getAllPids(cmd); for (const pid of pids) { try { await killTask(pid); } catch (error) { // Ignore errors if process already terminated } } } interface IVersion { version: string; changeLogLink: string; changeLog: string; publishTime: string; } export async function parseVersion(path: string): Promise { return load(await fs.readFile(path, 'utf8')) as IVersion; } export function parseContentVersion(content: string): IVersion { return load(content) as IVersion; } export async function getUniqPath( command: string, id: string, ): Promise { let suffix = ''; if (/^\d+$/.test(id)) { suffix = `_${id}`; } let items = command.split(/ +/); const maxTimeCommandIndex = items.findIndex((x) => x === '-m'); if (maxTimeCommandIndex !== -1) { items = items.slice(maxTimeCommandIndex + 2); } let str = items[0]; if (items[0] === TASK_COMMAND) { str = items[1]; } const dotIndex = str.lastIndexOf('.'); if (dotIndex !== -1) { str = str.slice(0, dotIndex); } const slashIndex = str.lastIndexOf('/'); let tempStr = ''; if (slashIndex !== -1) { tempStr = str.slice(0, slashIndex); const _slashIndex = tempStr.lastIndexOf('/'); if (_slashIndex !== -1) { tempStr = tempStr.slice(_slashIndex + 1); } str = `${tempStr}_${str.slice(slashIndex + 1)}`; } return `${str}${suffix}`; } export function safeJSONParse(value?: string) { if (!value) { return {}; } try { return JSON.parse(value); } catch (error) { Logger.error('[safeJSONParse失败]', error); return {}; } } export async function rmPath(path: string) { try { const _exsit = await fileExist(path); if (_exsit) { await fs.rm(path, { force: true, recursive: true, maxRetries: 5 }); } } catch (error) { Logger.error('[rmPath失败]', error); } } export async function setSystemTimezone(timezone: string): Promise { try { if (!(await fileExist(`/usr/share/zoneinfo/${timezone}`))) { throw new Error('Invalid timezone'); } await promiseExec(`ln -sf /usr/share/zoneinfo/${timezone} /etc/localtime`); await promiseExec(`echo "${timezone}" > /etc/timezone`); return true; } catch (error) { Logger.error('[setSystemTimezone失败]', error); return false; } } export function getGetCommand(type: DependenceTypes, name: string): string { const baseCommands = { [DependenceTypes.nodejs]: `pnpm ls -g | grep "${name}" | head -1`, [DependenceTypes.python3]: ` python3 -c "exec(''' name='${name}' try: from importlib.metadata import version print(version(name)) except: import importlib.util as u import importlib.metadata as m spec=u.find_spec(name) print(name if spec else '') ''')"`, [DependenceTypes.linux]: `apk info -es ${name}`, }; return baseCommands[type]; } export function getInstallCommand(type: DependenceTypes, name: string): string { const baseCommands = { [DependenceTypes.nodejs]: 'pnpm add -g', [DependenceTypes.python3]: 'pip3 install --disable-pip-version-check --root-user-action=ignore', [DependenceTypes.linux]: 'apk add --no-check-certificate', }; let command = baseCommands[type]; if (type === DependenceTypes.python3 && PYTHON_INSTALL_DIR) { command = `${command} --prefix=${PYTHON_INSTALL_DIR}`; } return `${command} ${name.trim()}`; } export function getUninstallCommand( type: DependenceTypes, name: string, ): string { const baseCommands = { [DependenceTypes.nodejs]: 'pnpm remove -g', [DependenceTypes.python3]: 'pip3 uninstall --disable-pip-version-check --root-user-action=ignore -y', [DependenceTypes.linux]: 'apk del', }; return `${baseCommands[type]} ${name.trim()}`; } export function isDemoEnv() { return process.env.DeployEnv === 'demo'; } ================================================ FILE: back/data/cron.ts ================================================ import { sequelize } from '.'; import { DataTypes, Model, ModelDefined } from 'sequelize'; export class Crontab { name?: string; command: string; schedule?: string; timestamp?: string; saved?: boolean; id?: number; status?: CrontabStatus; isSystem?: 1 | 0; pid?: number; isDisabled?: 1 | 0; log_path?: string; isPinned?: 1 | 0; labels?: string[]; last_running_time?: number; last_execution_time?: number; sub_id?: number; extra_schedules?: Array<{ schedule: string }>; task_before?: string; task_after?: string; log_name?: string; allow_multiple_instances?: 1 | 0; constructor(options: Crontab) { this.name = options.name; this.command = options.command.trim(); this.schedule = options.schedule; this.saved = options.saved; this.id = options.id; this.status = typeof options.status === 'number' && CrontabStatus[options.status] ? options.status : CrontabStatus.idle; this.timestamp = new Date().toString(); this.isSystem = options.isSystem || 0; this.pid = options.pid; this.isDisabled = options.isDisabled || 0; this.log_path = options.log_path || ''; this.isPinned = options.isPinned || 0; this.labels = options.labels || []; this.last_running_time = options.last_running_time || 0; this.last_execution_time = options.last_execution_time || 0; this.sub_id = options.sub_id; this.extra_schedules = options.extra_schedules; this.task_before = options.task_before; this.task_after = options.task_after; this.log_name = options.log_name; this.allow_multiple_instances = options.allow_multiple_instances || 0; } } export enum CrontabStatus { 'running' = 0, 'queued' = 0.5, 'idle' = 1, 'disabled', } export interface CronInstance extends Model, Crontab {} export const CrontabModel = sequelize.define('Crontab', { name: { unique: 'compositeIndex', type: DataTypes.STRING, }, command: { unique: 'compositeIndex', type: DataTypes.STRING, }, schedule: { unique: 'compositeIndex', type: DataTypes.STRING, }, timestamp: DataTypes.STRING, saved: DataTypes.BOOLEAN, status: DataTypes.NUMBER, isSystem: DataTypes.NUMBER, pid: DataTypes.NUMBER, isDisabled: DataTypes.NUMBER, isPinned: DataTypes.NUMBER, log_path: DataTypes.STRING, labels: DataTypes.JSON, last_running_time: DataTypes.NUMBER, last_execution_time: DataTypes.NUMBER, sub_id: { type: DataTypes.NUMBER, allowNull: true }, extra_schedules: DataTypes.JSON, task_before: DataTypes.STRING, task_after: DataTypes.STRING, log_name: DataTypes.STRING, allow_multiple_instances: DataTypes.NUMBER, }); ================================================ FILE: back/data/cronView.ts ================================================ import { sequelize } from '.'; import { DataTypes, Model } from 'sequelize'; export enum CronViewType { '系统' = 1, '个人', } interface SortType { type: 'ASC' | 'DESC'; value: string; } interface FilterType { property: string; operation: string; value: string; } export class CrontabView { name?: string; id?: number; position?: number; isDisabled?: 1 | 0; filters?: FilterType[]; sorts?: SortType[]; filterRelation?: 'and' | 'or'; type?: CronViewType; constructor(options: CrontabView) { this.name = options.name; this.id = options.id; this.position = options.position; this.isDisabled = options.isDisabled || 0; this.filters = options.filters; this.sorts = options.sorts; this.filterRelation = options.filterRelation; this.type = options.type || CronViewType.个人; } } export interface CronViewInstance extends Model, CrontabView {} export const CrontabViewModel = sequelize.define( 'CrontabView', { name: { unique: 'name', type: DataTypes.STRING, }, position: DataTypes.NUMBER, isDisabled: DataTypes.NUMBER, filters: DataTypes.JSON, sorts: DataTypes.JSON, filterRelation: { type: DataTypes.STRING, allowNull: true, }, type: DataTypes.NUMBER, }, ); ================================================ FILE: back/data/dependence.ts ================================================ import { sequelize } from '.'; import { DataTypes, Model, ModelDefined } from 'sequelize'; export class Dependence { timestamp?: string; id?: number; status: DependenceStatus; type: DependenceTypes; name: string; log?: string[]; remark?: string; constructor(options: Dependence) { this.id = options.id; this.status = typeof options.status === 'number' && DependenceStatus[options.status] ? options.status : DependenceStatus.queued; this.type = options.type || DependenceTypes.nodejs; this.timestamp = new Date().toString(); this.name = options.name.trim(); this.log = options.log || []; this.remark = options.remark || ''; } } export enum DependenceStatus { 'installing', 'installed', 'installFailed', 'removing', 'removed', 'removeFailed', 'queued', 'cancelled', } export enum DependenceTypes { 'nodejs', 'python3', 'linux', } export enum versionDependenceCommandTypes { '@', '==', '=', } export interface DependenceInstance extends Model, Dependence {} export const DependenceModel = sequelize.define( 'Dependence', { name: DataTypes.STRING, type: DataTypes.NUMBER, timestamp: DataTypes.STRING, status: DataTypes.NUMBER, log: DataTypes.JSON, remark: DataTypes.STRING, }, ); ================================================ FILE: back/data/env.ts ================================================ import { DataTypes, Model } from 'sequelize'; import { sequelize } from '.'; export class Env { value?: string; timestamp?: string; id?: number; status?: EnvStatus; position?: number; name?: string; remarks?: string; isPinned?: 1 | 0; constructor(options: Env) { this.value = options.value; this.id = options.id; this.status = typeof options.status === 'number' && EnvStatus[options.status] ? options.status : EnvStatus.normal; this.timestamp = new Date().toString(); this.position = options.position; this.name = options.name; this.remarks = options.remarks || ''; this.isPinned = options.isPinned || 0; } } export enum EnvStatus { 'normal', 'disabled', } export const maxPosition = 9000000000000000; export const initPosition = 4500000000000000; export const stepPosition = 10000000000; export const minPosition = 100; export interface EnvInstance extends Model, Env {} export const EnvModel = sequelize.define('Env', { value: { type: DataTypes.STRING, unique: 'compositeIndex' }, timestamp: DataTypes.STRING, status: DataTypes.NUMBER, position: DataTypes.NUMBER, name: { type: DataTypes.STRING, unique: 'compositeIndex' }, remarks: DataTypes.STRING, isPinned: DataTypes.NUMBER, }); ================================================ FILE: back/data/index.ts ================================================ import { Sequelize, Transaction } from 'sequelize'; import config from '../config/index'; import { join } from 'path'; export const sequelize = new Sequelize({ dialect: 'sqlite', storage: join(config.dbPath, 'database.sqlite'), logging: false, retry: { max: 10, match: ['SQLITE_BUSY: database is locked'], }, pool: { max: 5, min: 2, idle: 30000, acquire: 30000, evict: 10000, }, transactionType: Transaction.TYPES.IMMEDIATE, }); export type ResponseType = { code: number; data?: T; message?: string }; ================================================ FILE: back/data/notify.ts ================================================ export enum NotificationMode { 'gotify' = 'gotify', 'goCqHttpBot' = 'goCqHttpBot', 'serverChan' = 'serverChan', 'pushDeer' = 'pushDeer', 'bark' = 'bark', 'chat' = 'chat', 'telegramBot' = 'telegramBot', 'dingtalkBot' = 'dingtalkBot', 'weWorkBot' = 'weWorkBot', 'weWorkApp' = 'weWorkApp', 'aibotk' = 'aibotk', 'iGot' = 'iGot', 'pushPlus' = 'pushPlus', 'wePlusBot' = 'wePlusBot', 'email' = 'email', 'pushMe' = 'pushMe', 'feishu' = 'feishu', 'webhook' = 'webhook', 'chronocat' = 'Chronocat', 'ntfy' = 'ntfy', 'wxPusherBot' = 'wxPusherBot', } abstract class NotificationBaseInfo { public type!: NotificationMode; } export class GotifyNotification extends NotificationBaseInfo { public gotifyUrl = ''; public gotifyToken = ''; public gotifyPriority = 0; } export class GoCqHttpBotNotification extends NotificationBaseInfo { public goCqHttpBotUrl = ''; public goCqHttpBotToken = ''; public goCqHttpBotQq = ''; } export class ServerChanNotification extends NotificationBaseInfo { public serverChanKey = ''; } export class PushDeerNotification extends NotificationBaseInfo { public pushDeerKey = ''; public pushDeerUrl = ''; } export class synologyChatNotification extends NotificationBaseInfo { public synologyChatUrl = ''; } export class BarkNotification extends NotificationBaseInfo { public barkPush = ''; public barkIcon = 'https://qn.whyour.cn/logo.png'; public barkSound = ''; public barkGroup = 'qinglong'; public barkLevel = 'active'; public barkUrl = ''; public barkArchive = ''; } export class TelegramBotNotification extends NotificationBaseInfo { public telegramBotToken = ''; public telegramBotUserId = ''; public telegramBotProxyHost = ''; public telegramBotProxyPort = ''; public telegramBotProxyAuth = ''; public telegramBotApiHost = 'https://api.telegram.org'; } export class DingtalkBotNotification extends NotificationBaseInfo { public dingtalkBotToken = ''; public dingtalkBotSecret = ''; } export class WeWorkBotNotification extends NotificationBaseInfo { public weWorkBotKey = ''; public weWorkOrigin = ''; } export class WeWorkAppNotification extends NotificationBaseInfo { public weWorkAppKey = ''; public weWorkOrigin = ''; } export class AibotkNotification extends NotificationBaseInfo { public aibotkKey: string = ''; public aibotkType: 'room' | 'contact' = 'room'; public aibotkName: string = ''; } export class IGotNotification extends NotificationBaseInfo { public iGotPushKey = ''; } export class PushPlusNotification extends NotificationBaseInfo { public pushPlusToken = ''; public pushPlusUser = ''; public pushPlusTemplate = ''; public pushplusChannel = ''; public pushplusWebhook = ''; public pushplusCallbackUrl = ''; public pushplusTo = ''; } export class WePlusBotNotification extends NotificationBaseInfo { public wePlusBotToken = ''; public wePlusBotReceiver = ''; public wePlusBotVersion = ''; } export class EmailNotification extends NotificationBaseInfo { public emailService: string = ''; public emailUser: string = ''; public emailPass: string = ''; public emailTo: string = ''; } export class PushMeNotification extends NotificationBaseInfo { public pushMeKey: string = ''; public pushMeUrl: string = ''; } export class ChronocatNotification extends NotificationBaseInfo { public chronocatURL: string = ''; public chronocatQQ: string = ''; public chronocatToken: string = ''; } export class WebhookNotification extends NotificationBaseInfo { public webhookHeaders: string = ''; public webhookBody: string = ''; public webhookUrl: string = ''; public webhookMethod: 'GET' | 'POST' | 'PUT' = 'GET'; public webhookContentType: | 'application/json' | 'multipart/form-data' | 'application/x-www-form-urlencoded' = 'application/json'; } export class LarkNotification extends NotificationBaseInfo { public larkKey = ''; public larkSecret = ''; } export class NtfyNotification extends NotificationBaseInfo { public ntfyUrl = ''; public ntfyTopic = ''; public ntfyPriority = ''; public ntfyToken = ''; public ntfyUsername = ''; public ntfyPassword = ''; public ntfyActions = ''; } export class WxPusherBotNotification extends NotificationBaseInfo { public wxPusherBotAppToken = ''; public wxPusherBotTopicIds = ''; public wxPusherBotUids = ''; } export interface NotificationInfo extends GoCqHttpBotNotification, GotifyNotification, ServerChanNotification, PushDeerNotification, synologyChatNotification, BarkNotification, TelegramBotNotification, DingtalkBotNotification, WeWorkBotNotification, WeWorkAppNotification, AibotkNotification, IGotNotification, PushPlusNotification, WePlusBotNotification, EmailNotification, PushMeNotification, WebhookNotification, ChronocatNotification, LarkNotification, NtfyNotification, WxPusherBotNotification {} ================================================ FILE: back/data/open.ts ================================================ import { sequelize } from '.'; import { DataTypes, Model, ModelDefined } from 'sequelize'; export class App { name: string; scopes: AppScope[]; client_id: string; client_secret: string; tokens?: AppToken[]; id?: number; constructor(options: App) { this.name = options.name; this.scopes = options.scopes; this.client_id = options.client_id; this.client_secret = options.client_secret; this.id = options.id; } } export interface AppToken { value: string; type?: 'Bearer'; expiration: number; } export type AppScope = 'envs' | 'crons' | 'configs' | 'scripts' | 'logs' | 'system'; export interface AppInstance extends Model, App {} export const AppModel = sequelize.define('App', { name: { type: DataTypes.STRING, unique: 'name' }, scopes: DataTypes.JSON, client_id: DataTypes.STRING, client_secret: DataTypes.STRING, tokens: DataTypes.JSON, }); ================================================ FILE: back/data/sock.ts ================================================ export class SockMessage { message?: string; type?: SockMessageType; references?: number[]; constructor(options: SockMessage) { this.type = options.type; this.message = options.message; this.references = options.references; } } export type SockMessageType = | 'ping' | 'installDependence' | 'uninstallDependence' | 'updateSystemVersion' | 'manuallyRunScript' | 'runSubscriptionEnd' | 'reloadSystem' | 'updateNodeMirror' | 'updateLinuxMirror'; ================================================ FILE: back/data/subscription.ts ================================================ import { sequelize } from '.'; import { DataTypes, Model, ModelDefined } from 'sequelize'; import { SimpleIntervalSchedule } from 'toad-scheduler'; type SimpleIntervalScheduleUnit = keyof SimpleIntervalSchedule; export class Subscription { id?: number; name?: string; type?: 'public-repo' | 'private-repo' | 'file'; schedule_type?: 'crontab' | 'interval'; schedule?: string; interval_schedule?: { type: SimpleIntervalScheduleUnit; value: number }; url?: string; whitelist?: string; blacklist?: string; dependences?: string; branch?: string; status?: SubscriptionStatus; pull_type?: 'ssh-key' | 'user-pwd'; pull_option?: | { private_key: string } | { username: string; password: string }; pid?: number; is_disabled?: 1 | 0; log_path?: string; alias: string; command?: string; extensions?: string; sub_before?: string; sub_after?: string; proxy?: string; autoAddCron?: 1 | 0; autoDelCron?: 1 | 0; constructor(options: Subscription) { this.id = options.id; this.name = options.name || options.alias; this.type = options.type; this.schedule = options.schedule; this.status = this.status = typeof options.status === 'number' && SubscriptionStatus[options.status] ? options.status : SubscriptionStatus.idle; this.url = options.url; this.whitelist = options.whitelist; this.blacklist = options.blacklist; this.dependences = options.dependences; this.branch = options.branch; this.pull_type = options.pull_type; this.pull_option = options.pull_option; this.pid = options.pid; this.is_disabled = options.is_disabled; this.log_path = options.log_path; this.schedule_type = options.schedule_type; this.alias = options.alias; this.interval_schedule = options.interval_schedule; this.extensions = options.extensions; this.sub_before = options.sub_before; this.sub_after = options.sub_after; this.proxy = options.proxy; this.autoAddCron = options.autoAddCron ? 1 : 0; this.autoDelCron = options.autoDelCron ? 1 : 0; } } export enum SubscriptionStatus { 'running', 'idle', 'disabled', 'queued', } export interface SubscriptionInstance extends Model, Subscription {} export const SubscriptionModel = sequelize.define( 'Subscription', { name: { unique: 'compositeIndex', type: DataTypes.STRING, }, url: { unique: 'compositeIndex', type: DataTypes.STRING, }, schedule: { unique: 'compositeIndex', type: DataTypes.STRING, }, interval_schedule: { unique: 'compositeIndex', type: DataTypes.JSON, }, type: DataTypes.STRING, whitelist: DataTypes.STRING, blacklist: DataTypes.STRING, status: DataTypes.NUMBER, dependences: DataTypes.STRING, extensions: DataTypes.STRING, sub_before: DataTypes.STRING, sub_after: DataTypes.STRING, branch: DataTypes.STRING, pull_type: DataTypes.STRING, pull_option: DataTypes.JSON, pid: DataTypes.NUMBER, is_disabled: DataTypes.NUMBER, log_path: DataTypes.STRING, schedule_type: DataTypes.STRING, alias: { type: DataTypes.STRING, unique: 'alias' }, proxy: { type: DataTypes.STRING, allowNull: true }, autoAddCron: { type: DataTypes.NUMBER, allowNull: true }, autoDelCron: { type: DataTypes.NUMBER, allowNull: true }, }, ); ================================================ FILE: back/data/system.ts ================================================ import { sequelize } from '.'; import { DataTypes, Model, ModelDefined } from 'sequelize'; import { NotificationInfo } from './notify'; export class SystemInfo { ip?: string; type: AuthDataType; info?: SystemModelInfo; id?: number; constructor(options: SystemInfo) { this.ip = options.ip; this.info = options.info; this.type = options.type; this.id = options.id; } } export enum LoginStatus { 'success', 'fail', } export enum AuthDataType { 'loginLog' = 'loginLog', 'authToken' = 'authToken', 'notification' = 'notification', 'removeLogFrequency' = 'removeLogFrequency', 'systemConfig' = 'systemConfig', 'authConfig' = 'authConfig', } export interface SystemConfigInfo { logRemoveFrequency?: number; cronConcurrency?: number; dependenceProxy?: string; nodeMirror?: string; pythonMirror?: string; linuxMirror?: string; timezone?: string; globalSshKey?: string; } export interface LoginLogInfo { timestamp?: number; address?: string; ip?: string; platform?: string; status?: LoginStatus; } export interface TokenInfo { value: string; timestamp: number; ip: string; address: string; platform: string; /** * Token expiration time in seconds since Unix epoch. * If undefined, the token uses JWT's built-in expiration. */ expiration?: number; } export interface AuthInfo { username: string; password: string; retries: number; lastlogon: number; lastip: string; lastaddr: string; platform: string; isTwoFactorChecking: boolean; token: string; tokens: Record; twoFactorActivated: boolean; twoFactorSecret: string; avatar: string; } export type SystemModelInfo = SystemConfigInfo & Partial & LoginLogInfo & Partial; export interface SystemInstance extends Model, SystemInfo {} export const SystemModel = sequelize.define('Auth', { ip: DataTypes.STRING, type: DataTypes.STRING, info: { type: DataTypes.JSON, allowNull: true, }, }); ================================================ FILE: back/interface/schedule.ts ================================================ export enum ScheduleType { BOOT = '@boot', ONCE = '@once', } export type ScheduleValidator = (schedule?: string) => boolean; export type CronSchedulerPayload = { name: string; id: string; schedule: string; command: string; extra_schedules: Array<{ schedule: string }>; }; ================================================ FILE: back/loaders/app.ts ================================================ import expressLoader from './express'; import depInjectorLoader from './depInjector'; import Logger from './logger'; import initData from './initData'; import { Application } from 'express'; import linkDeps from './deps'; import initTask from './initTask'; import initFile from './initFile'; export default async ({ app }: { app: Application }) => { depInjectorLoader(); Logger.info('✌️ Dependency loaded'); await linkDeps(); Logger.info('✌️ Link deps loaded'); initFile(); Logger.info('✌️ Init file loaded'); await initData(); Logger.info('✌️ Init data loaded'); initTask(); Logger.info('✌️ Init task loaded'); expressLoader({ app }); Logger.info('✌️ Express loaded'); }; ================================================ FILE: back/loaders/bootAfter.ts ================================================ import Container from 'typedi'; import CronService from '../services/cron'; export default async () => { const cronService = Container.get(CronService); await cronService.bootTask(); }; ================================================ FILE: back/loaders/db.ts ================================================ import Logger from './logger'; import { EnvModel } from '../data/env'; import { CrontabModel } from '../data/cron'; import { DependenceModel } from '../data/dependence'; import { AppModel } from '../data/open'; import { SystemModel } from '../data/system'; import { SubscriptionModel } from '../data/subscription'; import { CrontabViewModel } from '../data/cronView'; import { sequelize } from '../data'; export default async () => { try { await CrontabModel.sync(); await DependenceModel.sync(); await AppModel.sync(); await SystemModel.sync(); await EnvModel.sync(); await SubscriptionModel.sync(); await CrontabViewModel.sync(); // 初始化新增字段 const migrations = [ { table: 'CrontabViews', column: 'filterRelation', type: 'VARCHAR(255)', }, { table: 'Subscriptions', column: 'proxy', type: 'VARCHAR(255)' }, { table: 'CrontabViews', column: 'type', type: 'NUMBER' }, { table: 'Subscriptions', column: 'autoAddCron', type: 'NUMBER' }, { table: 'Subscriptions', column: 'autoDelCron', type: 'NUMBER' }, { table: 'Crontabs', column: 'sub_id', type: 'NUMBER' }, { table: 'Crontabs', column: 'extra_schedules', type: 'JSON' }, { table: 'Crontabs', column: 'task_before', type: 'TEXT' }, { table: 'Crontabs', column: 'task_after', type: 'TEXT' }, { table: 'Crontabs', column: 'log_name', type: 'VARCHAR(255)' }, { table: 'Crontabs', column: 'allow_multiple_instances', type: 'NUMBER', }, { table: 'Envs', column: 'isPinned', type: 'NUMBER' }, ]; for (const migration of migrations) { try { await sequelize.query( `alter table ${migration.table} add column ${migration.column} ${migration.type}`, ); } catch (error) { // Column already exists or other error, continue } } Logger.info('✌️ DB loaded'); } catch (error) { Logger.error('✌️ DB load failed', error); } }; ================================================ FILE: back/loaders/depInjector.ts ================================================ import { Container } from 'typedi'; import LoggerInstance from './logger'; export default () => { try { Container.set('logger', LoggerInstance); } catch (e) { LoggerInstance.error('🔥 Error on dependency injector loader: %o', e); throw e; } }; ================================================ FILE: back/loaders/deps.ts ================================================ import path from 'path'; import fs from 'fs/promises'; import os from 'os'; import chokidar from 'chokidar'; import config from '../config/index'; import Logger from './logger'; async function linkToNodeModule(src: string, dst?: string) { const target = path.join(config.rootPath, 'node_modules', dst || src); const source = path.join(config.rootPath, src); try { const stats = await fs.lstat(target); if (!stats) { await fs.symlink(source, target, 'dir'); } } catch (error) { } } async function linkCommand() { const homeDir = os.homedir(); let userBinDir = path.join(homeDir, 'bin'); try { await fs.mkdir(userBinDir, { recursive: true }); await linkCommandToDir(userBinDir); } catch (error) { Logger.error('Linking command failed:', error); } } async function linkCommandToDir(commandDir: string) { const linkShell = [ { src: 'update.sh', dest: 'ql', tmp: 'ql_tmp', }, { src: 'task.sh', dest: 'task', tmp: 'task_tmp', }, ]; for (const link of linkShell) { const source = path.join(config.rootPath, 'shell', link.src); const target = path.join(commandDir, link.dest); const tmpTarget = path.join(commandDir, link.tmp); try { const stats = await fs.lstat(tmpTarget); if (stats) { await fs.unlink(tmpTarget); } } catch (error) { } await fs.symlink(source, tmpTarget); await fs.rename(tmpTarget, target); } } export default async (src: string = 'deps') => { await linkCommand(); await linkToNodeModule(src); const source = path.join(config.rootPath, src); const watcher = chokidar.watch(source, { ignored: /(^|[\/\\])\../, // ignore dotfiles persistent: true, }); watcher .on('add', () => linkToNodeModule(src)) .on('change', () => linkToNodeModule(src)); }; ================================================ FILE: back/loaders/express.ts ================================================ import express, { Request, Response, NextFunction, Application } from 'express'; import bodyParser from 'body-parser'; import cors from 'cors'; import routes from '../api'; import config from '../config'; import { UnauthorizedError, expressjwt } from 'express-jwt'; import { getPlatform, getToken } from '../config/util'; import rewrite from 'express-urlrewrite'; import { errors } from 'celebrate'; import { serveEnv } from '../config/serverEnv'; import { IKeyvStore, shareStore } from '../shared/store'; import { isValidToken } from '../shared/auth'; import path from 'path'; export default ({ app }: { app: Application }) => { // Security: Enable strict routing to prevent case-insensitive path bypass app.set('case sensitive routing', true); app.set('strict routing', true); app.set('trust proxy', 'loopback'); app.use(cors()); // Security: Path normalization middleware to prevent case variation attacks app.use((req, res, next) => { const originalPath = req.path; const normalizedPath = originalPath.toLowerCase(); // Block requests with case variations on protected paths if (originalPath !== normalizedPath && (normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/open/'))) { return res.status(400).json({ code: 400, message: 'Invalid path format' }); } next(); }); // Rewrite URLs to strip baseUrl prefix if configured // This allows the rest of the app to work without baseUrl awareness if (config.baseUrl) { app.use(rewrite(`${config.baseUrl}/*`, '/$1')); } app.get(`${config.api.prefix}/env.js`, serveEnv); app.use(`${config.api.prefix}/static`, express.static(config.uploadPath)); app.use(bodyParser.json({ limit: '50mb' })); app.use(bodyParser.urlencoded({ limit: '50mb', extended: true })); const frontendPath = path.join(config.rootPath, 'static/dist'); app.use(express.static(frontendPath)); app.use( expressjwt({ secret: config.jwt.secret, algorithms: ['HS384'], }).unless({ path: [...config.apiWhiteList, /^(\/(?!api\/).*)$/i], }), ); app.use((req: Request, res, next) => { if (!req.headers) { req.platform = 'desktop'; } else { const platform = getPlatform(req.headers['user-agent'] || ''); req.platform = platform; } return next(); }); app.use(async (req: Request, res, next) => { const pathLower = req.path.toLowerCase(); if (!['/open/', '/api/'].some((x) => pathLower.startsWith(x))) { return next(); } const headerToken = getToken(req); if (pathLower.startsWith('/open/')) { const apps = await shareStore.getApps(); const doc = apps?.filter((x) => x.tokens?.find((y) => y.value === headerToken), )?.[0]; if (doc && doc.tokens && doc.tokens.length > 0) { const currentToken = doc.tokens.find((x) => x.value === headerToken); const keyMatch = pathLower.match(/\/open\/([a-z]+)\/*/); const key = keyMatch && keyMatch[1]; if ( doc.scopes.includes(key as any) && currentToken && currentToken.expiration >= Math.round(Date.now() / 1000) ) { return next(); } } } const originPath = `${req.baseUrl}${req.path === '/' ? '' : req.path}`; if ( !headerToken && originPath && config.apiWhiteList.includes(originPath) ) { return next(); } const authInfo = await shareStore.getAuthInfo(); if (isValidToken(authInfo, headerToken, req.platform)) { return next(); } const errorCode = headerToken ? 'invalid_token' : 'credentials_required'; const errorMessage = headerToken ? 'jwt malformed' : 'No authorization token was found'; const err = new UnauthorizedError(errorCode, { message: errorMessage }); next(err); }); app.use(async (req, res, next) => { const pathLower = req.path.toLowerCase(); if ( ![ '/api/user/init', '/api/user/notification/init', '/open/user/init', '/open/user/notification/init', ].includes(req.path) ) { return next(); } const authInfo = (await shareStore.getAuthInfo()) || ({} as IKeyvStore['authInfo']); let isInitialized = true; if ( Object.keys(authInfo).length === 2 && authInfo.username === 'admin' && authInfo.password === 'admin' ) { isInitialized = false; } if (isInitialized) { return res.send({ code: 450, message: '未知错误' }); } else { return next(); } }); app.use(rewrite('/open/*', '/api/$1')); app.use(config.api.prefix, routes()); app.get('*', (_, res, next) => { const indexPath = path.join(frontendPath, 'index.html'); res.sendFile(indexPath, (err) => { if (err) { const err: any = new Error('Not Found'); err['status'] = 404; next(err); } }); }); app.use(errors()); app.use( ( err: Error & { status: number }, req: Request, res: Response, next: NextFunction, ) => { if (err.name === 'UnauthorizedError') { return res .status(err.status) .send({ code: 401, message: err.message }) .end(); } return next(err); }, ); app.use( ( err: Error & { errors: any[] }, req: Request, res: Response, next: NextFunction, ) => { if (err.name.includes('Sequelize')) { return res .status(500) .send({ code: 400, message: `${err.message}`, errors: err.errors, }) .end(); } return next(err); }, ); app.use( ( err: Error & { status: number }, req: Request, res: Response, next: NextFunction, ) => { res.status(err.status || 500); res.json({ code: err.status || 500, message: err.message, }); }, ); }; ================================================ FILE: back/loaders/initData.ts ================================================ import DependenceService from '../services/dependence'; import { exec } from 'child_process'; import { Container } from 'typedi'; import { Crontab, CrontabModel, CrontabStatus } from '../data/cron'; import CronService from '../services/cron'; import EnvService from '../services/env'; import { DependenceModel, DependenceStatus } from '../data/dependence'; import { Op } from 'sequelize'; import config from '../config'; import { CrontabViewModel, CronViewType } from '../data/cronView'; import { initPosition } from '../data/env'; import { AuthDataType, SystemModel } from '../data/system'; import SystemService from '../services/system'; import UserService from '../services/user'; import { writeFile, readFile } from 'fs/promises'; import { createRandomString, fileExist, isDemoEnv, safeJSONParse } from '../config/util'; import OpenService from '../services/open'; import { shareStore } from '../shared/store'; import Logger from './logger'; import { AppModel } from '../data/open'; export default async () => { const cronService = Container.get(CronService); const envService = Container.get(EnvService); const dependenceService = Container.get(DependenceService); const systemService = Container.get(SystemService); const userService = Container.get(UserService); const openService = Container.get(OpenService); // 初始化增加系统配置 let systemApp = ( await AppModel.findOne({ where: { name: 'system' }, }) )?.get({ plain: true }); if (!systemApp) { systemApp = await AppModel.create({ name: 'system', scopes: ['crons', 'system'], client_id: createRandomString(12, 12), client_secret: createRandomString(24, 24), }); } const [systemConfig] = await SystemModel.findOrCreate({ where: { type: AuthDataType.systemConfig }, }); await SystemModel.findOrCreate({ where: { type: AuthDataType.notification }, }); const [authConfig] = await SystemModel.findOrCreate({ where: { type: AuthDataType.authConfig }, }); if (!authConfig?.info || isDemoEnv()) { let authInfo = { username: 'admin', password: 'admin', }; try { const authFileExist = await fileExist(config.authConfigFile); if (authFileExist) { const content = await readFile(config.authConfigFile, 'utf8'); authInfo = safeJSONParse(content); } } catch (error) { Logger.warn('Failed to read auth config file, using default credentials'); } await SystemModel.upsert({ id: authConfig?.id, info: authInfo, type: AuthDataType.authConfig, }); } const installDependencies = async () => { const docs = await DependenceModel.findAll({ where: {}, order: [ ['type', 'DESC'], ['createdAt', 'DESC'], ], raw: true, }); await DependenceModel.update( { status: DependenceStatus.queued, log: [] }, { where: { id: docs.map((x) => x.id!) } }, ); setTimeout(async () => { await dependenceService.installDependenceOneByOne(docs); const bootAfterLoader = await import('./bootAfter'); bootAfterLoader.default(); }, 5000); }; // 初始化更新 linux/python/nodejs 镜像源配置 if (systemConfig.info?.pythonMirror) { systemService.updatePythonMirror({ pythonMirror: systemConfig.info?.pythonMirror, }); } if (systemConfig.info?.linuxMirror) { systemService.updateLinuxMirror( { linuxMirror: systemConfig.info?.linuxMirror, }, undefined, () => installDependencies(), ); } else { installDependencies(); } if (systemConfig.info?.nodeMirror) { systemService.updateNodeMirror({ nodeMirror: systemConfig.info?.nodeMirror, }); } // 初始化新增默认全部任务视图 CrontabViewModel.findAll({ where: { type: CronViewType.系统, name: '全部任务' }, raw: true, }).then((docs) => { if (docs.length === 0) { CrontabViewModel.create({ name: '全部任务', type: CronViewType.系统, position: initPosition / 2, }); } }); // 初始化更新所有任务状态为空闲 await CrontabModel.update({ status: CrontabStatus.idle }, { where: {} }); // 初始化时执行一次所有的 ql repo 任务 CrontabModel.findAll({ where: { isDisabled: { [Op.ne]: 1 }, command: { [Op.or]: [{ [Op.like]: `%ql repo%` }, { [Op.like]: `%ql raw%` }], }, }, }).then((docs) => { for (let i = 0; i < docs.length; i++) { const doc = docs[i]; if (doc) { exec(doc.command); } } }); // 更新2.11.3以前的脚本路径 CrontabModel.findAll({ where: { command: { [Op.or]: [ { [Op.like]: `%\/${config.rootPath}\/scripts\/%` }, { [Op.like]: `%\/${config.rootPath}\/config\/%` }, { [Op.like]: `%\/${config.rootPath}\/log\/%` }, { [Op.like]: `%\/${config.rootPath}\/db\/%` }, ], }, }, }).then(async (docs) => { for (let i = 0; i < docs.length; i++) { const doc = docs[i]; if (doc) { if (doc.command.includes(`${config.rootPath}/scripts/`)) { await CrontabModel.update( { command: doc.command.replace(`${config.rootPath}/scripts/`, '') }, { where: { id: doc.id } }, ); } if (doc.command.includes(`${config.rootPath}/log/`)) { await CrontabModel.update( { command: `${config.dataPath}/log/${doc.command.replace( `${config.rootPath}/log/`, '', )}`, }, { where: { id: doc.id } }, ); } if (doc.command.includes(`${config.rootPath}/config/`)) { await CrontabModel.update( { command: `${config.dataPath}/config/${doc.command.replace( `${config.rootPath}/config/`, '', )}`, }, { where: { id: doc.id } }, ); } if (doc.command.includes(`${config.rootPath}/db/`)) { await CrontabModel.update( { command: `${config.dataPath}/db/${doc.command.replace( `${config.rootPath}/db/`, '', )}`, }, { where: { id: doc.id } }, ); } } } }); // 初始化保存一次ck和定时任务数据 await cronService.autosave_crontab(); await envService.set_envs(); const authInfo = await userService.getAuthInfo(); const apps = await openService.findApps(); await shareStore.updateAuthInfo(authInfo); if (apps?.length) { await shareStore.updateApps(apps); } }; ================================================ FILE: back/loaders/initFile.ts ================================================ import fs from 'fs/promises'; import path from 'path'; import os from 'os'; import Logger from './logger'; import { fileExist } from '../config/util'; import { writeFileWithLock } from '../shared/utils'; const rootPath = process.env.QL_DIR as string; let dataPath = path.join(rootPath, 'data/'); if (process.env.QL_DATA_DIR) { dataPath = process.env.QL_DATA_DIR.replace(/\/$/g, ''); } const preloadPath = path.join(rootPath, 'shell/preload/'); const configPath = path.join(dataPath, 'config/'); const scriptPath = path.join(dataPath, 'scripts/'); const logPath = path.join(dataPath, 'log/'); const uploadPath = path.join(dataPath, 'upload/'); const bakPath = path.join(dataPath, 'bak/'); const samplePath = path.join(rootPath, 'sample/'); const tmpPath = path.join(logPath, '.tmp/'); const confFile = path.join(configPath, 'config.sh'); const sampleConfigFile = path.join(samplePath, 'config.sample.sh'); const sampleTaskShellFile = path.join(samplePath, 'task.sample.sh'); const sampleNotifyJsFile = path.join(samplePath, 'notify.js'); const sampleNotifyPyFile = path.join(samplePath, 'notify.py'); const scriptNotifyJsFile = path.join(scriptPath, 'sendNotify.js'); const scriptNotifyPyFile = path.join(scriptPath, 'notify.py'); const jsNotifyFile = path.join(preloadPath, '__ql_notify__.js'); const pyNotifyFile = path.join(preloadPath, '__ql_notify__.py'); const TaskBeforeFile = path.join(configPath, 'task_before.sh'); const TaskBeforeJsFile = path.join(configPath, 'task_before.js'); const TaskBeforePyFile = path.join(configPath, 'task_before.py'); const TaskAfterFile = path.join(configPath, 'task_after.sh'); const homedir = os.homedir(); const sshPath = path.resolve(homedir, '.ssh'); const sshdPath = path.join(dataPath, 'ssh.d'); const systemLogPath = path.join(dataPath, 'syslog'); const directories = [ configPath, scriptPath, preloadPath, logPath, tmpPath, uploadPath, sshPath, bakPath, sshdPath, systemLogPath, ]; const files = [ { target: confFile, source: sampleConfigFile, checkExistence: true, }, { target: jsNotifyFile, source: sampleNotifyJsFile, checkExistence: false, }, { target: pyNotifyFile, source: sampleNotifyPyFile, checkExistence: false, }, { target: scriptNotifyJsFile, source: sampleNotifyJsFile, checkExistence: true, }, { target: scriptNotifyPyFile, source: sampleNotifyPyFile, checkExistence: true, }, { target: TaskBeforeFile, source: sampleTaskShellFile, checkExistence: true, }, { target: TaskBeforeJsFile, content: '// The JavaScript code that executes before the JavaScript task execution will execute.', checkExistence: true, }, { target: TaskBeforePyFile, content: '# The Python code that executes before the Python task execution will execute.', checkExistence: true, }, { target: TaskAfterFile, source: sampleTaskShellFile, checkExistence: true, }, ]; export default async () => { for (const dirPath of directories) { if (!(await fileExist(dirPath))) { await fs.mkdir(dirPath); } } for (const item of files) { const exists = await fileExist(item.target); if (!item.checkExistence || !exists) { if (!item.content && !item.source) { throw new Error( `Neither content nor source specified for ${item.target}`, ); } const content = item.content || (await fs.readFile(item.source!, { encoding: 'utf-8' })); await writeFileWithLock(item.target, content); } } Logger.info('✌️ Init file down'); }; ================================================ FILE: back/loaders/initTask.ts ================================================ import { Container } from 'typedi'; import SystemService from '../services/system'; import ScheduleService, { ScheduleTaskType } from '../services/schedule'; import SubscriptionService from '../services/subscription'; import SshKeyService from '../services/sshKey'; import config from '../config'; import { fileExist } from '../config/util'; import { join } from 'path'; export default async () => { const systemService = Container.get(SystemService); const scheduleService = Container.get(ScheduleService); const subscriptionService = Container.get(SubscriptionService); const sshKeyService = Container.get(SshKeyService); // 生成内置token let tokenCommand = `ts-node-transpile-only ${join( config.rootPath, 'back/token.ts', )}`; const tokenFile = join(config.rootPath, 'static/build/token.js'); if (await fileExist(tokenFile)) { tokenCommand = `node ${tokenFile}`; } const cron = { id: NaN, name: '生成token', command: tokenCommand, runOrigin: 'system', } as ScheduleTaskType; await scheduleService.cancelIntervalTask(cron); scheduleService.createIntervalTask( cron, { days: 28, }, true, ); // 运行删除日志任务 const data = await systemService.getSystemConfig(); if (data && data.info) { if (data.info.logRemoveFrequency) { const rmlogCron = { id: data.id as number, name: '删除日志', command: `ql rmlog ${data.info.logRemoveFrequency}`, runOrigin: 'system' as const, }; await scheduleService.cancelIntervalTask(rmlogCron); scheduleService.createIntervalTask( rmlogCron, { days: data.info.logRemoveFrequency, }, true, ); } systemService.updateTimezone(data.info); // Apply global SSH key if configured if (data.info.globalSshKey) { await sshKeyService.addGlobalSSHKey(data.info.globalSshKey, 'global'); } } await subscriptionService.setSshConfig(); const subs = await subscriptionService.list(); for (const sub of subs) { subscriptionService.handleTask(sub.get({ plain: true }), !sub.is_disabled); } }; ================================================ FILE: back/loaders/logger.ts ================================================ import winston from 'winston'; import 'winston-daily-rotate-file'; import config from '../config'; import path from 'path'; const levelMap: Record = { info: 'ℹ️', // info图标 warn: '⚠️', // 警告图标 error: '❌', // 错误图标 debug: '🐛', // debug调试图标 }; const baseFormat = [ winston.format.splat(), winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.align(), ]; const consoleFormat = winston.format.combine( winston.format.colorize({ level: true }), ...baseFormat, winston.format.printf((info) => { return `[${info.level} ${info.timestamp}]:${info.message}`; }), ); const plainFormat = winston.format.combine( winston.format.uncolorize(), ...baseFormat, winston.format.printf((info) => { return `[${levelMap[info.level] || ''}${info.level} ${info.timestamp}]:${ info.message }`; }), ); const consoleTransport = new winston.transports.Console({ format: consoleFormat, level: 'debug', }); const fileTransport = new winston.transports.DailyRotateFile({ filename: path.join(config.systemLogPath, '%DATE%.log'), datePattern: 'YYYY-MM-DD', maxSize: '20m', maxFiles: '7d', format: plainFormat, level: config.logs.level || 'info', }); const LoggerInstance = winston.createLogger({ level: 'debug', levels: winston.config.npm.levels, transports: [consoleTransport, fileTransport], exceptionHandlers: [consoleTransport, fileTransport], rejectionHandlers: [consoleTransport, fileTransport], }); LoggerInstance.on('error', (error) => { console.error('Logger error:', error); }); export default LoggerInstance; ================================================ FILE: back/loaders/server.ts ================================================ import { Server } from 'http'; import Logger from './logger'; import Sock from './sock'; export default async ({ server }: { server: Server }) => { await Sock({ server }); Logger.info('✌️ Sock loaded'); process.on('uncaughtException', (error) => { Logger.error('Uncaught exception:', error); }); process.on('unhandledRejection', (reason, promise) => { Logger.error('Unhandled rejection:', reason, promise); }); }; ================================================ FILE: back/loaders/sock.ts ================================================ import sockJs from 'sockjs'; import { Server } from 'http'; import { Container } from 'typedi'; import SockService from '../services/sock'; import { getPlatform } from '../config/util'; import { shareStore } from '../shared/store'; import { isValidToken } from '../shared/auth'; import config from '../config'; export default async ({ server }: { server: Server }) => { const echo = sockJs.createServer({ prefix: `${config.baseUrl}/api/ws`, log: () => { } }); const sockService = Container.get(SockService); echo.on('connection', async (conn) => { if (!conn.headers || !conn.url || !conn.pathname) { conn.close('404'); } const authInfo = await shareStore.getAuthInfo(); const platform = getPlatform(conn.headers['user-agent'] || '') || 'desktop'; const headerToken = conn.url.replace(`${conn.pathname}?token=`, ''); if (isValidToken(authInfo, headerToken, platform)) { sockService.addClient(conn); conn.on('data', (message) => { conn.write(message); }); conn.on('close', function () { sockService.removeClient(conn); }); return; } conn.close('404'); }); echo.installHandlers(server); }; ================================================ FILE: back/middlewares/monitoring.ts ================================================ import { Request, Response, NextFunction } from 'express'; import Logger from '../loaders/logger'; import { performance } from 'perf_hooks'; import { metricsService } from '../services/metrics'; interface RequestMetrics { method: string; path: string; duration: number; statusCode: number; timestamp: number; platform?: string; } const requestMetrics: RequestMetrics[] = []; export const monitoringMiddleware = ( req: Request, res: Response, next: NextFunction, ) => { const start = performance.now(); const originalEnd = res.end; res.end = function (chunk?: any, encoding?: any, cb?: any) { const duration = performance.now() - start; const metric: RequestMetrics = { method: req.method, path: req.path, duration, statusCode: res.statusCode, timestamp: Date.now(), platform: req.platform, }; requestMetrics.push(metric); metricsService.record('http_request', duration, { method: req.method, path: req.path, statusCode: res.statusCode.toString(), ...(req.platform && { platform: req.platform }), }); if (requestMetrics.length > 1000) { requestMetrics.shift(); } if (duration > 1000) { Logger.warn( `Slow request detected: ${req.method} ${ req.path } took ${duration.toFixed(2)}ms`, ); } return originalEnd.call(this, chunk, encoding, cb); }; next(); }; export const getMetrics = () => { return { totalRequests: requestMetrics.length, averageDuration: requestMetrics.reduce((acc, curr) => acc + curr.duration, 0) / requestMetrics.length, requestsByMethod: requestMetrics.reduce((acc, curr) => { acc[curr.method] = (acc[curr.method] || 0) + 1; return acc; }, {} as Record), requestsByPlatform: requestMetrics.reduce((acc, curr) => { if (curr.platform) { acc[curr.platform] = (acc[curr.platform] || 0) + 1; } return acc; }, {} as Record), recentRequests: requestMetrics.slice(-10), }; }; ================================================ FILE: back/protos/api.proto ================================================ syntax = "proto3"; package com.ql.api; message EnvItem { optional int32 id = 1; optional string name = 2; optional string value = 3; optional string remarks = 4; optional int32 status = 5; optional int64 position = 6; } message GetEnvsRequest { string searchValue = 1; } message CreateEnvRequest { repeated EnvItem envs = 1; } message UpdateEnvRequest { EnvItem env = 1; } message DeleteEnvsRequest { repeated int32 ids = 1; } message MoveEnvRequest { int32 id = 1; int32 fromIndex = 2; int32 toIndex = 3; } message DisableEnvsRequest { repeated int32 ids = 1; } message EnableEnvsRequest { repeated int32 ids = 1; } message UpdateEnvNamesRequest { repeated int32 ids = 1; string name = 2; } message GetEnvByIdRequest { int32 id = 1; } message EnvsResponse { int32 code = 1; repeated EnvItem data = 2; optional string message = 3; } message EnvResponse { int32 code = 1; EnvItem data = 2; optional string message = 3; } message Response { int32 code = 1; optional string message = 2; } message ExtraScheduleItem { string schedule = 1; } message CronItem { optional int32 id = 1; optional string command = 2; optional string schedule = 3; optional string name = 4; repeated string labels = 5; optional int32 sub_id = 6; repeated ExtraScheduleItem extra_schedules = 7; optional string task_before = 8; optional string task_after = 9; optional int32 status = 10; optional string log_path = 11; optional int32 pid = 12; optional int64 last_running_time = 13; optional int64 last_execution_time = 14; } message CreateCronRequest { string command = 1; string schedule = 2; optional string name = 3; repeated string labels = 4; optional int32 sub_id = 5; repeated ExtraScheduleItem extra_schedules = 6; optional string task_before = 7; optional string task_after = 8; } message UpdateCronRequest { int32 id = 1; optional string command = 2; optional string schedule = 3; optional string name = 4; repeated string labels = 5; optional int32 sub_id = 6; repeated ExtraScheduleItem extra_schedules = 7; optional string task_before = 8; optional string task_after = 9; } message DeleteCronsRequest { repeated int32 ids = 1; } message GetCronsRequest { optional string searchValue = 1; } message GetCronByIdRequest { int32 id = 1; } message EnableCronsRequest { repeated int32 ids = 1; } message DisableCronsRequest { repeated int32 ids = 1; } message RunCronsRequest { repeated int32 ids = 1; } message CronsResponse { int32 code = 1; repeated CronItem data = 2; optional string message = 3; } message CronResponse { int32 code = 1; CronItem data = 2; optional string message = 3; } message CronDetailRequest { string log_path = 1; } message CronDetailResponse { int32 code = 1; CronItem data = 2; optional string message = 3; } enum NotificationMode { gotify = 0; goCqHttpBot = 1; serverChan = 2; pushDeer = 3; bark = 4; chat = 5; telegramBot = 6; dingtalkBot = 7; weWorkBot = 8; weWorkApp = 9; aibotk = 10; iGot = 11; pushPlus = 12; wePlusBot = 13; email = 14; pushMe = 15; feishu = 16; webhook = 17; chronocat = 18; ntfy = 19; wxPusherBot = 20; } message NotificationInfo { NotificationMode type = 1; optional string gotifyUrl = 2; optional string gotifyToken = 3; optional int32 gotifyPriority = 4; optional string goCqHttpBotUrl = 5; optional string goCqHttpBotToken = 6; optional string goCqHttpBotQq = 7; optional string serverChanKey = 8; optional string pushDeerKey = 9; optional string pushDeerUrl = 10; optional string synologyChatUrl = 11; optional string barkPush = 12; optional string barkIcon = 13; optional string barkSound = 14; optional string barkGroup = 15; optional string barkLevel = 16; optional string barkUrl = 17; optional string barkArchive = 18; optional string telegramBotToken = 19; optional string telegramBotUserId = 20; optional string telegramBotProxyHost = 21; optional string telegramBotProxyPort = 22; optional string telegramBotProxyAuth = 23; optional string telegramBotApiHost = 24; optional string dingtalkBotToken = 25; optional string dingtalkBotSecret = 26; optional string weWorkBotKey = 27; optional string weWorkOrigin = 28; optional string weWorkAppKey = 29; optional string aibotkKey = 30; optional string aibotkType = 31; optional string aibotkName = 32; optional string iGotPushKey = 33; optional string pushPlusToken = 34; optional string pushPlusUser = 35; optional string pushPlusTemplate = 36; optional string pushplusChannel = 37; optional string pushplusWebhook = 38; optional string pushplusCallbackUrl = 39; optional string pushplusTo = 40; optional string wePlusBotToken = 41; optional string wePlusBotReceiver = 42; optional string wePlusBotVersion = 43; optional string emailService = 44; optional string emailUser = 45; optional string emailPass = 46; optional string emailTo = 47; optional string pushMeKey = 48; optional string pushMeUrl = 49; optional string chronocatURL = 50; optional string chronocatQQ = 51; optional string chronocatToken = 52; optional string webhookHeaders = 53; optional string webhookBody = 54; optional string webhookUrl = 55; optional string webhookMethod = 56; optional string webhookContentType = 57; optional string larkKey = 58; optional string larkSecret = 69; optional string ntfyUrl = 59; optional string ntfyTopic = 60; optional string ntfyPriority = 61; optional string ntfyToken = 62; optional string ntfyUsername = 63; optional string ntfyPassword = 64; optional string ntfyActions = 65; optional string wxPusherBotAppToken = 66; optional string wxPusherBotTopicIds = 67; optional string wxPusherBotUids = 68; } message SystemNotifyRequest { string title = 1; string content = 2; optional NotificationInfo notificationInfo = 3; } service Api { rpc GetEnvs(GetEnvsRequest) returns (EnvsResponse) {} rpc CreateEnv(CreateEnvRequest) returns (EnvsResponse) {} rpc UpdateEnv(UpdateEnvRequest) returns (EnvResponse) {} rpc DeleteEnvs(DeleteEnvsRequest) returns (Response) {} rpc MoveEnv(MoveEnvRequest) returns (EnvResponse) {} rpc DisableEnvs(DisableEnvsRequest) returns (Response) {} rpc EnableEnvs(EnableEnvsRequest) returns (Response) {} rpc UpdateEnvNames(UpdateEnvNamesRequest) returns (Response) {} rpc GetEnvById(GetEnvByIdRequest) returns (EnvResponse) {} rpc SystemNotify(SystemNotifyRequest) returns (Response) {} rpc GetCronDetail(CronDetailRequest) returns (CronDetailResponse) {} rpc CreateCron(CreateCronRequest) returns (CronResponse) {} rpc UpdateCron(UpdateCronRequest) returns (CronResponse) {} rpc DeleteCrons(DeleteCronsRequest) returns (Response) {} rpc GetCrons(GetCronsRequest) returns (CronsResponse) {} rpc GetCronById(GetCronByIdRequest) returns (CronResponse) {} rpc EnableCrons(EnableCronsRequest) returns (Response) {} rpc DisableCrons(DisableCronsRequest) returns (Response) {} rpc RunCrons(RunCronsRequest) returns (Response) {} } ================================================ FILE: back/protos/api.ts ================================================ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v2.6.1 // protoc v3.21.12 // source: back/protos/api.proto /* eslint-disable */ import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; import { type CallOptions, ChannelCredentials, Client, type ClientOptions, type ClientUnaryCall, type handleUnaryCall, makeGenericClientConstructor, Metadata, type ServiceError, type UntypedServiceImplementation, } from "@grpc/grpc-js"; export const protobufPackage = "com.ql.api"; export enum NotificationMode { gotify = 0, goCqHttpBot = 1, serverChan = 2, pushDeer = 3, bark = 4, chat = 5, telegramBot = 6, dingtalkBot = 7, weWorkBot = 8, weWorkApp = 9, aibotk = 10, iGot = 11, pushPlus = 12, wePlusBot = 13, email = 14, pushMe = 15, feishu = 16, webhook = 17, chronocat = 18, ntfy = 19, wxPusherBot = 20, UNRECOGNIZED = -1, } export function notificationModeFromJSON(object: any): NotificationMode { switch (object) { case 0: case "gotify": return NotificationMode.gotify; case 1: case "goCqHttpBot": return NotificationMode.goCqHttpBot; case 2: case "serverChan": return NotificationMode.serverChan; case 3: case "pushDeer": return NotificationMode.pushDeer; case 4: case "bark": return NotificationMode.bark; case 5: case "chat": return NotificationMode.chat; case 6: case "telegramBot": return NotificationMode.telegramBot; case 7: case "dingtalkBot": return NotificationMode.dingtalkBot; case 8: case "weWorkBot": return NotificationMode.weWorkBot; case 9: case "weWorkApp": return NotificationMode.weWorkApp; case 10: case "aibotk": return NotificationMode.aibotk; case 11: case "iGot": return NotificationMode.iGot; case 12: case "pushPlus": return NotificationMode.pushPlus; case 13: case "wePlusBot": return NotificationMode.wePlusBot; case 14: case "email": return NotificationMode.email; case 15: case "pushMe": return NotificationMode.pushMe; case 16: case "feishu": return NotificationMode.feishu; case 17: case "webhook": return NotificationMode.webhook; case 18: case "chronocat": return NotificationMode.chronocat; case 19: case "ntfy": return NotificationMode.ntfy; case 20: case "wxPusherBot": return NotificationMode.wxPusherBot; case -1: case "UNRECOGNIZED": default: return NotificationMode.UNRECOGNIZED; } } export function notificationModeToJSON(object: NotificationMode): string { switch (object) { case NotificationMode.gotify: return "gotify"; case NotificationMode.goCqHttpBot: return "goCqHttpBot"; case NotificationMode.serverChan: return "serverChan"; case NotificationMode.pushDeer: return "pushDeer"; case NotificationMode.bark: return "bark"; case NotificationMode.chat: return "chat"; case NotificationMode.telegramBot: return "telegramBot"; case NotificationMode.dingtalkBot: return "dingtalkBot"; case NotificationMode.weWorkBot: return "weWorkBot"; case NotificationMode.weWorkApp: return "weWorkApp"; case NotificationMode.aibotk: return "aibotk"; case NotificationMode.iGot: return "iGot"; case NotificationMode.pushPlus: return "pushPlus"; case NotificationMode.wePlusBot: return "wePlusBot"; case NotificationMode.email: return "email"; case NotificationMode.pushMe: return "pushMe"; case NotificationMode.feishu: return "feishu"; case NotificationMode.webhook: return "webhook"; case NotificationMode.chronocat: return "chronocat"; case NotificationMode.ntfy: return "ntfy"; case NotificationMode.wxPusherBot: return "wxPusherBot"; case NotificationMode.UNRECOGNIZED: default: return "UNRECOGNIZED"; } } export interface EnvItem { id?: number | undefined; name?: string | undefined; value?: string | undefined; remarks?: string | undefined; status?: number | undefined; position?: number | undefined; } export interface GetEnvsRequest { searchValue: string; } export interface CreateEnvRequest { envs: EnvItem[]; } export interface UpdateEnvRequest { env: EnvItem | undefined; } export interface DeleteEnvsRequest { ids: number[]; } export interface MoveEnvRequest { id: number; fromIndex: number; toIndex: number; } export interface DisableEnvsRequest { ids: number[]; } export interface EnableEnvsRequest { ids: number[]; } export interface UpdateEnvNamesRequest { ids: number[]; name: string; } export interface GetEnvByIdRequest { id: number; } export interface EnvsResponse { code: number; data: EnvItem[]; message?: string | undefined; } export interface EnvResponse { code: number; data: EnvItem | undefined; message?: string | undefined; } export interface Response { code: number; message?: string | undefined; } export interface ExtraScheduleItem { schedule: string; } export interface CronItem { id?: number | undefined; command?: string | undefined; schedule?: string | undefined; name?: string | undefined; labels: string[]; sub_id?: number | undefined; extra_schedules: ExtraScheduleItem[]; task_before?: string | undefined; task_after?: string | undefined; status?: number | undefined; log_path?: string | undefined; pid?: number | undefined; last_running_time?: number | undefined; last_execution_time?: number | undefined; } export interface CreateCronRequest { command: string; schedule: string; name?: string | undefined; labels: string[]; sub_id?: number | undefined; extra_schedules: ExtraScheduleItem[]; task_before?: string | undefined; task_after?: string | undefined; } export interface UpdateCronRequest { id: number; command?: string | undefined; schedule?: string | undefined; name?: string | undefined; labels: string[]; sub_id?: number | undefined; extra_schedules: ExtraScheduleItem[]; task_before?: string | undefined; task_after?: string | undefined; } export interface DeleteCronsRequest { ids: number[]; } export interface GetCronsRequest { searchValue?: string | undefined; } export interface GetCronByIdRequest { id: number; } export interface EnableCronsRequest { ids: number[]; } export interface DisableCronsRequest { ids: number[]; } export interface RunCronsRequest { ids: number[]; } export interface CronsResponse { code: number; data: CronItem[]; message?: string | undefined; } export interface CronResponse { code: number; data: CronItem | undefined; message?: string | undefined; } export interface CronDetailRequest { log_path: string; } export interface CronDetailResponse { code: number; data: CronItem | undefined; message?: string | undefined; } export interface NotificationInfo { type: NotificationMode; gotifyUrl?: string | undefined; gotifyToken?: string | undefined; gotifyPriority?: number | undefined; goCqHttpBotUrl?: string | undefined; goCqHttpBotToken?: string | undefined; goCqHttpBotQq?: string | undefined; serverChanKey?: string | undefined; pushDeerKey?: string | undefined; pushDeerUrl?: string | undefined; synologyChatUrl?: string | undefined; barkPush?: string | undefined; barkIcon?: string | undefined; barkSound?: string | undefined; barkGroup?: string | undefined; barkLevel?: string | undefined; barkUrl?: string | undefined; barkArchive?: string | undefined; telegramBotToken?: string | undefined; telegramBotUserId?: string | undefined; telegramBotProxyHost?: string | undefined; telegramBotProxyPort?: string | undefined; telegramBotProxyAuth?: string | undefined; telegramBotApiHost?: string | undefined; dingtalkBotToken?: string | undefined; dingtalkBotSecret?: string | undefined; weWorkBotKey?: string | undefined; weWorkOrigin?: string | undefined; weWorkAppKey?: string | undefined; aibotkKey?: string | undefined; aibotkType?: string | undefined; aibotkName?: string | undefined; iGotPushKey?: string | undefined; pushPlusToken?: string | undefined; pushPlusUser?: string | undefined; pushPlusTemplate?: string | undefined; pushplusChannel?: string | undefined; pushplusWebhook?: string | undefined; pushplusCallbackUrl?: string | undefined; pushplusTo?: string | undefined; wePlusBotToken?: string | undefined; wePlusBotReceiver?: string | undefined; wePlusBotVersion?: string | undefined; emailService?: string | undefined; emailUser?: string | undefined; emailPass?: string | undefined; emailTo?: string | undefined; pushMeKey?: string | undefined; pushMeUrl?: string | undefined; chronocatURL?: string | undefined; chronocatQQ?: string | undefined; chronocatToken?: string | undefined; webhookHeaders?: string | undefined; webhookBody?: string | undefined; webhookUrl?: string | undefined; webhookMethod?: string | undefined; webhookContentType?: string | undefined; larkKey?: string | undefined; larkSecret?: string | undefined; ntfyUrl?: string | undefined; ntfyTopic?: string | undefined; ntfyPriority?: string | undefined; ntfyToken?: string | undefined; ntfyUsername?: string | undefined; ntfyPassword?: string | undefined; ntfyActions?: string | undefined; wxPusherBotAppToken?: string | undefined; wxPusherBotTopicIds?: string | undefined; wxPusherBotUids?: string | undefined; } export interface SystemNotifyRequest { title: string; content: string; notificationInfo?: NotificationInfo | undefined; } function createBaseEnvItem(): EnvItem { return { id: undefined, name: undefined, value: undefined, remarks: undefined, status: undefined, position: undefined, }; } export const EnvItem: MessageFns = { encode(message: EnvItem, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { if (message.id !== undefined) { writer.uint32(8).int32(message.id); } if (message.name !== undefined) { writer.uint32(18).string(message.name); } if (message.value !== undefined) { writer.uint32(26).string(message.value); } if (message.remarks !== undefined) { writer.uint32(34).string(message.remarks); } if (message.status !== undefined) { writer.uint32(40).int32(message.status); } if (message.position !== undefined) { writer.uint32(48).int64(message.position); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): EnvItem { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseEnvItem(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 8) { break; } message.id = reader.int32(); continue; } case 2: { if (tag !== 18) { break; } message.name = reader.string(); continue; } case 3: { if (tag !== 26) { break; } message.value = reader.string(); continue; } case 4: { if (tag !== 34) { break; } message.remarks = reader.string(); continue; } case 5: { if (tag !== 40) { break; } message.status = reader.int32(); continue; } case 6: { if (tag !== 48) { break; } message.position = longToNumber(reader.int64()); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): EnvItem { return { id: isSet(object.id) ? globalThis.Number(object.id) : undefined, name: isSet(object.name) ? globalThis.String(object.name) : undefined, value: isSet(object.value) ? globalThis.String(object.value) : undefined, remarks: isSet(object.remarks) ? globalThis.String(object.remarks) : undefined, status: isSet(object.status) ? globalThis.Number(object.status) : undefined, position: isSet(object.position) ? globalThis.Number(object.position) : undefined, }; }, toJSON(message: EnvItem): unknown { const obj: any = {}; if (message.id !== undefined) { obj.id = Math.round(message.id); } if (message.name !== undefined) { obj.name = message.name; } if (message.value !== undefined) { obj.value = message.value; } if (message.remarks !== undefined) { obj.remarks = message.remarks; } if (message.status !== undefined) { obj.status = Math.round(message.status); } if (message.position !== undefined) { obj.position = Math.round(message.position); } return obj; }, create, I>>(base?: I): EnvItem { return EnvItem.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): EnvItem { const message = createBaseEnvItem(); message.id = object.id ?? undefined; message.name = object.name ?? undefined; message.value = object.value ?? undefined; message.remarks = object.remarks ?? undefined; message.status = object.status ?? undefined; message.position = object.position ?? undefined; return message; }, }; function createBaseGetEnvsRequest(): GetEnvsRequest { return { searchValue: "" }; } export const GetEnvsRequest: MessageFns = { encode(message: GetEnvsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { if (message.searchValue !== "") { writer.uint32(10).string(message.searchValue); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): GetEnvsRequest { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseGetEnvsRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 10) { break; } message.searchValue = reader.string(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): GetEnvsRequest { return { searchValue: isSet(object.searchValue) ? globalThis.String(object.searchValue) : "" }; }, toJSON(message: GetEnvsRequest): unknown { const obj: any = {}; if (message.searchValue !== "") { obj.searchValue = message.searchValue; } return obj; }, create, I>>(base?: I): GetEnvsRequest { return GetEnvsRequest.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): GetEnvsRequest { const message = createBaseGetEnvsRequest(); message.searchValue = object.searchValue ?? ""; return message; }, }; function createBaseCreateEnvRequest(): CreateEnvRequest { return { envs: [] }; } export const CreateEnvRequest: MessageFns = { encode(message: CreateEnvRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { for (const v of message.envs) { EnvItem.encode(v!, writer.uint32(10).fork()).join(); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): CreateEnvRequest { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseCreateEnvRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 10) { break; } message.envs.push(EnvItem.decode(reader, reader.uint32())); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): CreateEnvRequest { return { envs: globalThis.Array.isArray(object?.envs) ? object.envs.map((e: any) => EnvItem.fromJSON(e)) : [] }; }, toJSON(message: CreateEnvRequest): unknown { const obj: any = {}; if (message.envs?.length) { obj.envs = message.envs.map((e) => EnvItem.toJSON(e)); } return obj; }, create, I>>(base?: I): CreateEnvRequest { return CreateEnvRequest.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): CreateEnvRequest { const message = createBaseCreateEnvRequest(); message.envs = object.envs?.map((e) => EnvItem.fromPartial(e)) || []; return message; }, }; function createBaseUpdateEnvRequest(): UpdateEnvRequest { return { env: undefined }; } export const UpdateEnvRequest: MessageFns = { encode(message: UpdateEnvRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { if (message.env !== undefined) { EnvItem.encode(message.env, writer.uint32(10).fork()).join(); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): UpdateEnvRequest { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseUpdateEnvRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 10) { break; } message.env = EnvItem.decode(reader, reader.uint32()); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): UpdateEnvRequest { return { env: isSet(object.env) ? EnvItem.fromJSON(object.env) : undefined }; }, toJSON(message: UpdateEnvRequest): unknown { const obj: any = {}; if (message.env !== undefined) { obj.env = EnvItem.toJSON(message.env); } return obj; }, create, I>>(base?: I): UpdateEnvRequest { return UpdateEnvRequest.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): UpdateEnvRequest { const message = createBaseUpdateEnvRequest(); message.env = (object.env !== undefined && object.env !== null) ? EnvItem.fromPartial(object.env) : undefined; return message; }, }; function createBaseDeleteEnvsRequest(): DeleteEnvsRequest { return { ids: [] }; } export const DeleteEnvsRequest: MessageFns = { encode(message: DeleteEnvsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { writer.uint32(10).fork(); for (const v of message.ids) { writer.int32(v); } writer.join(); return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): DeleteEnvsRequest { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseDeleteEnvsRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag === 8) { message.ids.push(reader.int32()); continue; } if (tag === 10) { const end2 = reader.uint32() + reader.pos; while (reader.pos < end2) { message.ids.push(reader.int32()); } continue; } break; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): DeleteEnvsRequest { return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.Number(e)) : [] }; }, toJSON(message: DeleteEnvsRequest): unknown { const obj: any = {}; if (message.ids?.length) { obj.ids = message.ids.map((e) => Math.round(e)); } return obj; }, create, I>>(base?: I): DeleteEnvsRequest { return DeleteEnvsRequest.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): DeleteEnvsRequest { const message = createBaseDeleteEnvsRequest(); message.ids = object.ids?.map((e) => e) || []; return message; }, }; function createBaseMoveEnvRequest(): MoveEnvRequest { return { id: 0, fromIndex: 0, toIndex: 0 }; } export const MoveEnvRequest: MessageFns = { encode(message: MoveEnvRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { if (message.id !== 0) { writer.uint32(8).int32(message.id); } if (message.fromIndex !== 0) { writer.uint32(16).int32(message.fromIndex); } if (message.toIndex !== 0) { writer.uint32(24).int32(message.toIndex); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): MoveEnvRequest { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseMoveEnvRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 8) { break; } message.id = reader.int32(); continue; } case 2: { if (tag !== 16) { break; } message.fromIndex = reader.int32(); continue; } case 3: { if (tag !== 24) { break; } message.toIndex = reader.int32(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): MoveEnvRequest { return { id: isSet(object.id) ? globalThis.Number(object.id) : 0, fromIndex: isSet(object.fromIndex) ? globalThis.Number(object.fromIndex) : 0, toIndex: isSet(object.toIndex) ? globalThis.Number(object.toIndex) : 0, }; }, toJSON(message: MoveEnvRequest): unknown { const obj: any = {}; if (message.id !== 0) { obj.id = Math.round(message.id); } if (message.fromIndex !== 0) { obj.fromIndex = Math.round(message.fromIndex); } if (message.toIndex !== 0) { obj.toIndex = Math.round(message.toIndex); } return obj; }, create, I>>(base?: I): MoveEnvRequest { return MoveEnvRequest.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): MoveEnvRequest { const message = createBaseMoveEnvRequest(); message.id = object.id ?? 0; message.fromIndex = object.fromIndex ?? 0; message.toIndex = object.toIndex ?? 0; return message; }, }; function createBaseDisableEnvsRequest(): DisableEnvsRequest { return { ids: [] }; } export const DisableEnvsRequest: MessageFns = { encode(message: DisableEnvsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { writer.uint32(10).fork(); for (const v of message.ids) { writer.int32(v); } writer.join(); return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): DisableEnvsRequest { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseDisableEnvsRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag === 8) { message.ids.push(reader.int32()); continue; } if (tag === 10) { const end2 = reader.uint32() + reader.pos; while (reader.pos < end2) { message.ids.push(reader.int32()); } continue; } break; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): DisableEnvsRequest { return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.Number(e)) : [] }; }, toJSON(message: DisableEnvsRequest): unknown { const obj: any = {}; if (message.ids?.length) { obj.ids = message.ids.map((e) => Math.round(e)); } return obj; }, create, I>>(base?: I): DisableEnvsRequest { return DisableEnvsRequest.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): DisableEnvsRequest { const message = createBaseDisableEnvsRequest(); message.ids = object.ids?.map((e) => e) || []; return message; }, }; function createBaseEnableEnvsRequest(): EnableEnvsRequest { return { ids: [] }; } export const EnableEnvsRequest: MessageFns = { encode(message: EnableEnvsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { writer.uint32(10).fork(); for (const v of message.ids) { writer.int32(v); } writer.join(); return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): EnableEnvsRequest { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseEnableEnvsRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag === 8) { message.ids.push(reader.int32()); continue; } if (tag === 10) { const end2 = reader.uint32() + reader.pos; while (reader.pos < end2) { message.ids.push(reader.int32()); } continue; } break; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): EnableEnvsRequest { return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.Number(e)) : [] }; }, toJSON(message: EnableEnvsRequest): unknown { const obj: any = {}; if (message.ids?.length) { obj.ids = message.ids.map((e) => Math.round(e)); } return obj; }, create, I>>(base?: I): EnableEnvsRequest { return EnableEnvsRequest.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): EnableEnvsRequest { const message = createBaseEnableEnvsRequest(); message.ids = object.ids?.map((e) => e) || []; return message; }, }; function createBaseUpdateEnvNamesRequest(): UpdateEnvNamesRequest { return { ids: [], name: "" }; } export const UpdateEnvNamesRequest: MessageFns = { encode(message: UpdateEnvNamesRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { writer.uint32(10).fork(); for (const v of message.ids) { writer.int32(v); } writer.join(); if (message.name !== "") { writer.uint32(18).string(message.name); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): UpdateEnvNamesRequest { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseUpdateEnvNamesRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag === 8) { message.ids.push(reader.int32()); continue; } if (tag === 10) { const end2 = reader.uint32() + reader.pos; while (reader.pos < end2) { message.ids.push(reader.int32()); } continue; } break; } case 2: { if (tag !== 18) { break; } message.name = reader.string(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): UpdateEnvNamesRequest { return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.Number(e)) : [], name: isSet(object.name) ? globalThis.String(object.name) : "", }; }, toJSON(message: UpdateEnvNamesRequest): unknown { const obj: any = {}; if (message.ids?.length) { obj.ids = message.ids.map((e) => Math.round(e)); } if (message.name !== "") { obj.name = message.name; } return obj; }, create, I>>(base?: I): UpdateEnvNamesRequest { return UpdateEnvNamesRequest.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): UpdateEnvNamesRequest { const message = createBaseUpdateEnvNamesRequest(); message.ids = object.ids?.map((e) => e) || []; message.name = object.name ?? ""; return message; }, }; function createBaseGetEnvByIdRequest(): GetEnvByIdRequest { return { id: 0 }; } export const GetEnvByIdRequest: MessageFns = { encode(message: GetEnvByIdRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { if (message.id !== 0) { writer.uint32(8).int32(message.id); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): GetEnvByIdRequest { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseGetEnvByIdRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 8) { break; } message.id = reader.int32(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): GetEnvByIdRequest { return { id: isSet(object.id) ? globalThis.Number(object.id) : 0 }; }, toJSON(message: GetEnvByIdRequest): unknown { const obj: any = {}; if (message.id !== 0) { obj.id = Math.round(message.id); } return obj; }, create, I>>(base?: I): GetEnvByIdRequest { return GetEnvByIdRequest.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): GetEnvByIdRequest { const message = createBaseGetEnvByIdRequest(); message.id = object.id ?? 0; return message; }, }; function createBaseEnvsResponse(): EnvsResponse { return { code: 0, data: [], message: undefined }; } export const EnvsResponse: MessageFns = { encode(message: EnvsResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { if (message.code !== 0) { writer.uint32(8).int32(message.code); } for (const v of message.data) { EnvItem.encode(v!, writer.uint32(18).fork()).join(); } if (message.message !== undefined) { writer.uint32(26).string(message.message); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): EnvsResponse { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseEnvsResponse(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 8) { break; } message.code = reader.int32(); continue; } case 2: { if (tag !== 18) { break; } message.data.push(EnvItem.decode(reader, reader.uint32())); continue; } case 3: { if (tag !== 26) { break; } message.message = reader.string(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): EnvsResponse { return { code: isSet(object.code) ? globalThis.Number(object.code) : 0, data: globalThis.Array.isArray(object?.data) ? object.data.map((e: any) => EnvItem.fromJSON(e)) : [], message: isSet(object.message) ? globalThis.String(object.message) : undefined, }; }, toJSON(message: EnvsResponse): unknown { const obj: any = {}; if (message.code !== 0) { obj.code = Math.round(message.code); } if (message.data?.length) { obj.data = message.data.map((e) => EnvItem.toJSON(e)); } if (message.message !== undefined) { obj.message = message.message; } return obj; }, create, I>>(base?: I): EnvsResponse { return EnvsResponse.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): EnvsResponse { const message = createBaseEnvsResponse(); message.code = object.code ?? 0; message.data = object.data?.map((e) => EnvItem.fromPartial(e)) || []; message.message = object.message ?? undefined; return message; }, }; function createBaseEnvResponse(): EnvResponse { return { code: 0, data: undefined, message: undefined }; } export const EnvResponse: MessageFns = { encode(message: EnvResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { if (message.code !== 0) { writer.uint32(8).int32(message.code); } if (message.data !== undefined) { EnvItem.encode(message.data, writer.uint32(18).fork()).join(); } if (message.message !== undefined) { writer.uint32(26).string(message.message); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): EnvResponse { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseEnvResponse(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 8) { break; } message.code = reader.int32(); continue; } case 2: { if (tag !== 18) { break; } message.data = EnvItem.decode(reader, reader.uint32()); continue; } case 3: { if (tag !== 26) { break; } message.message = reader.string(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): EnvResponse { return { code: isSet(object.code) ? globalThis.Number(object.code) : 0, data: isSet(object.data) ? EnvItem.fromJSON(object.data) : undefined, message: isSet(object.message) ? globalThis.String(object.message) : undefined, }; }, toJSON(message: EnvResponse): unknown { const obj: any = {}; if (message.code !== 0) { obj.code = Math.round(message.code); } if (message.data !== undefined) { obj.data = EnvItem.toJSON(message.data); } if (message.message !== undefined) { obj.message = message.message; } return obj; }, create, I>>(base?: I): EnvResponse { return EnvResponse.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): EnvResponse { const message = createBaseEnvResponse(); message.code = object.code ?? 0; message.data = (object.data !== undefined && object.data !== null) ? EnvItem.fromPartial(object.data) : undefined; message.message = object.message ?? undefined; return message; }, }; function createBaseResponse(): Response { return { code: 0, message: undefined }; } export const Response: MessageFns = { encode(message: Response, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { if (message.code !== 0) { writer.uint32(8).int32(message.code); } if (message.message !== undefined) { writer.uint32(18).string(message.message); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): Response { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseResponse(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 8) { break; } message.code = reader.int32(); continue; } case 2: { if (tag !== 18) { break; } message.message = reader.string(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): Response { return { code: isSet(object.code) ? globalThis.Number(object.code) : 0, message: isSet(object.message) ? globalThis.String(object.message) : undefined, }; }, toJSON(message: Response): unknown { const obj: any = {}; if (message.code !== 0) { obj.code = Math.round(message.code); } if (message.message !== undefined) { obj.message = message.message; } return obj; }, create, I>>(base?: I): Response { return Response.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): Response { const message = createBaseResponse(); message.code = object.code ?? 0; message.message = object.message ?? undefined; return message; }, }; function createBaseExtraScheduleItem(): ExtraScheduleItem { return { schedule: "" }; } export const ExtraScheduleItem: MessageFns = { encode(message: ExtraScheduleItem, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { if (message.schedule !== "") { writer.uint32(10).string(message.schedule); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): ExtraScheduleItem { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseExtraScheduleItem(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 10) { break; } message.schedule = reader.string(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): ExtraScheduleItem { return { schedule: isSet(object.schedule) ? globalThis.String(object.schedule) : "" }; }, toJSON(message: ExtraScheduleItem): unknown { const obj: any = {}; if (message.schedule !== "") { obj.schedule = message.schedule; } return obj; }, create, I>>(base?: I): ExtraScheduleItem { return ExtraScheduleItem.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): ExtraScheduleItem { const message = createBaseExtraScheduleItem(); message.schedule = object.schedule ?? ""; return message; }, }; function createBaseCronItem(): CronItem { return { id: undefined, command: undefined, schedule: undefined, name: undefined, labels: [], sub_id: undefined, extra_schedules: [], task_before: undefined, task_after: undefined, status: undefined, log_path: undefined, pid: undefined, last_running_time: undefined, last_execution_time: undefined, }; } export const CronItem: MessageFns = { encode(message: CronItem, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { if (message.id !== undefined) { writer.uint32(8).int32(message.id); } if (message.command !== undefined) { writer.uint32(18).string(message.command); } if (message.schedule !== undefined) { writer.uint32(26).string(message.schedule); } if (message.name !== undefined) { writer.uint32(34).string(message.name); } for (const v of message.labels) { writer.uint32(42).string(v!); } if (message.sub_id !== undefined) { writer.uint32(48).int32(message.sub_id); } for (const v of message.extra_schedules) { ExtraScheduleItem.encode(v!, writer.uint32(58).fork()).join(); } if (message.task_before !== undefined) { writer.uint32(66).string(message.task_before); } if (message.task_after !== undefined) { writer.uint32(74).string(message.task_after); } if (message.status !== undefined) { writer.uint32(80).int32(message.status); } if (message.log_path !== undefined) { writer.uint32(90).string(message.log_path); } if (message.pid !== undefined) { writer.uint32(96).int32(message.pid); } if (message.last_running_time !== undefined) { writer.uint32(104).int64(message.last_running_time); } if (message.last_execution_time !== undefined) { writer.uint32(112).int64(message.last_execution_time); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): CronItem { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseCronItem(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 8) { break; } message.id = reader.int32(); continue; } case 2: { if (tag !== 18) { break; } message.command = reader.string(); continue; } case 3: { if (tag !== 26) { break; } message.schedule = reader.string(); continue; } case 4: { if (tag !== 34) { break; } message.name = reader.string(); continue; } case 5: { if (tag !== 42) { break; } message.labels.push(reader.string()); continue; } case 6: { if (tag !== 48) { break; } message.sub_id = reader.int32(); continue; } case 7: { if (tag !== 58) { break; } message.extra_schedules.push(ExtraScheduleItem.decode(reader, reader.uint32())); continue; } case 8: { if (tag !== 66) { break; } message.task_before = reader.string(); continue; } case 9: { if (tag !== 74) { break; } message.task_after = reader.string(); continue; } case 10: { if (tag !== 80) { break; } message.status = reader.int32(); continue; } case 11: { if (tag !== 90) { break; } message.log_path = reader.string(); continue; } case 12: { if (tag !== 96) { break; } message.pid = reader.int32(); continue; } case 13: { if (tag !== 104) { break; } message.last_running_time = longToNumber(reader.int64()); continue; } case 14: { if (tag !== 112) { break; } message.last_execution_time = longToNumber(reader.int64()); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): CronItem { return { id: isSet(object.id) ? globalThis.Number(object.id) : undefined, command: isSet(object.command) ? globalThis.String(object.command) : undefined, schedule: isSet(object.schedule) ? globalThis.String(object.schedule) : undefined, name: isSet(object.name) ? globalThis.String(object.name) : undefined, labels: globalThis.Array.isArray(object?.labels) ? object.labels.map((e: any) => globalThis.String(e)) : [], sub_id: isSet(object.sub_id) ? globalThis.Number(object.sub_id) : undefined, extra_schedules: globalThis.Array.isArray(object?.extra_schedules) ? object.extra_schedules.map((e: any) => ExtraScheduleItem.fromJSON(e)) : [], task_before: isSet(object.task_before) ? globalThis.String(object.task_before) : undefined, task_after: isSet(object.task_after) ? globalThis.String(object.task_after) : undefined, status: isSet(object.status) ? globalThis.Number(object.status) : undefined, log_path: isSet(object.log_path) ? globalThis.String(object.log_path) : undefined, pid: isSet(object.pid) ? globalThis.Number(object.pid) : undefined, last_running_time: isSet(object.last_running_time) ? globalThis.Number(object.last_running_time) : undefined, last_execution_time: isSet(object.last_execution_time) ? globalThis.Number(object.last_execution_time) : undefined, }; }, toJSON(message: CronItem): unknown { const obj: any = {}; if (message.id !== undefined) { obj.id = Math.round(message.id); } if (message.command !== undefined) { obj.command = message.command; } if (message.schedule !== undefined) { obj.schedule = message.schedule; } if (message.name !== undefined) { obj.name = message.name; } if (message.labels?.length) { obj.labels = message.labels; } if (message.sub_id !== undefined) { obj.sub_id = Math.round(message.sub_id); } if (message.extra_schedules?.length) { obj.extra_schedules = message.extra_schedules.map((e) => ExtraScheduleItem.toJSON(e)); } if (message.task_before !== undefined) { obj.task_before = message.task_before; } if (message.task_after !== undefined) { obj.task_after = message.task_after; } if (message.status !== undefined) { obj.status = Math.round(message.status); } if (message.log_path !== undefined) { obj.log_path = message.log_path; } if (message.pid !== undefined) { obj.pid = Math.round(message.pid); } if (message.last_running_time !== undefined) { obj.last_running_time = Math.round(message.last_running_time); } if (message.last_execution_time !== undefined) { obj.last_execution_time = Math.round(message.last_execution_time); } return obj; }, create, I>>(base?: I): CronItem { return CronItem.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): CronItem { const message = createBaseCronItem(); message.id = object.id ?? undefined; message.command = object.command ?? undefined; message.schedule = object.schedule ?? undefined; message.name = object.name ?? undefined; message.labels = object.labels?.map((e) => e) || []; message.sub_id = object.sub_id ?? undefined; message.extra_schedules = object.extra_schedules?.map((e) => ExtraScheduleItem.fromPartial(e)) || []; message.task_before = object.task_before ?? undefined; message.task_after = object.task_after ?? undefined; message.status = object.status ?? undefined; message.log_path = object.log_path ?? undefined; message.pid = object.pid ?? undefined; message.last_running_time = object.last_running_time ?? undefined; message.last_execution_time = object.last_execution_time ?? undefined; return message; }, }; function createBaseCreateCronRequest(): CreateCronRequest { return { command: "", schedule: "", name: undefined, labels: [], sub_id: undefined, extra_schedules: [], task_before: undefined, task_after: undefined, }; } export const CreateCronRequest: MessageFns = { encode(message: CreateCronRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { if (message.command !== "") { writer.uint32(10).string(message.command); } if (message.schedule !== "") { writer.uint32(18).string(message.schedule); } if (message.name !== undefined) { writer.uint32(26).string(message.name); } for (const v of message.labels) { writer.uint32(34).string(v!); } if (message.sub_id !== undefined) { writer.uint32(40).int32(message.sub_id); } for (const v of message.extra_schedules) { ExtraScheduleItem.encode(v!, writer.uint32(50).fork()).join(); } if (message.task_before !== undefined) { writer.uint32(58).string(message.task_before); } if (message.task_after !== undefined) { writer.uint32(66).string(message.task_after); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): CreateCronRequest { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseCreateCronRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 10) { break; } message.command = reader.string(); continue; } case 2: { if (tag !== 18) { break; } message.schedule = reader.string(); continue; } case 3: { if (tag !== 26) { break; } message.name = reader.string(); continue; } case 4: { if (tag !== 34) { break; } message.labels.push(reader.string()); continue; } case 5: { if (tag !== 40) { break; } message.sub_id = reader.int32(); continue; } case 6: { if (tag !== 50) { break; } message.extra_schedules.push(ExtraScheduleItem.decode(reader, reader.uint32())); continue; } case 7: { if (tag !== 58) { break; } message.task_before = reader.string(); continue; } case 8: { if (tag !== 66) { break; } message.task_after = reader.string(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): CreateCronRequest { return { command: isSet(object.command) ? globalThis.String(object.command) : "", schedule: isSet(object.schedule) ? globalThis.String(object.schedule) : "", name: isSet(object.name) ? globalThis.String(object.name) : undefined, labels: globalThis.Array.isArray(object?.labels) ? object.labels.map((e: any) => globalThis.String(e)) : [], sub_id: isSet(object.sub_id) ? globalThis.Number(object.sub_id) : undefined, extra_schedules: globalThis.Array.isArray(object?.extra_schedules) ? object.extra_schedules.map((e: any) => ExtraScheduleItem.fromJSON(e)) : [], task_before: isSet(object.task_before) ? globalThis.String(object.task_before) : undefined, task_after: isSet(object.task_after) ? globalThis.String(object.task_after) : undefined, }; }, toJSON(message: CreateCronRequest): unknown { const obj: any = {}; if (message.command !== "") { obj.command = message.command; } if (message.schedule !== "") { obj.schedule = message.schedule; } if (message.name !== undefined) { obj.name = message.name; } if (message.labels?.length) { obj.labels = message.labels; } if (message.sub_id !== undefined) { obj.sub_id = Math.round(message.sub_id); } if (message.extra_schedules?.length) { obj.extra_schedules = message.extra_schedules.map((e) => ExtraScheduleItem.toJSON(e)); } if (message.task_before !== undefined) { obj.task_before = message.task_before; } if (message.task_after !== undefined) { obj.task_after = message.task_after; } return obj; }, create, I>>(base?: I): CreateCronRequest { return CreateCronRequest.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): CreateCronRequest { const message = createBaseCreateCronRequest(); message.command = object.command ?? ""; message.schedule = object.schedule ?? ""; message.name = object.name ?? undefined; message.labels = object.labels?.map((e) => e) || []; message.sub_id = object.sub_id ?? undefined; message.extra_schedules = object.extra_schedules?.map((e) => ExtraScheduleItem.fromPartial(e)) || []; message.task_before = object.task_before ?? undefined; message.task_after = object.task_after ?? undefined; return message; }, }; function createBaseUpdateCronRequest(): UpdateCronRequest { return { id: 0, command: undefined, schedule: undefined, name: undefined, labels: [], sub_id: undefined, extra_schedules: [], task_before: undefined, task_after: undefined, }; } export const UpdateCronRequest: MessageFns = { encode(message: UpdateCronRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { if (message.id !== 0) { writer.uint32(8).int32(message.id); } if (message.command !== undefined) { writer.uint32(18).string(message.command); } if (message.schedule !== undefined) { writer.uint32(26).string(message.schedule); } if (message.name !== undefined) { writer.uint32(34).string(message.name); } for (const v of message.labels) { writer.uint32(42).string(v!); } if (message.sub_id !== undefined) { writer.uint32(48).int32(message.sub_id); } for (const v of message.extra_schedules) { ExtraScheduleItem.encode(v!, writer.uint32(58).fork()).join(); } if (message.task_before !== undefined) { writer.uint32(66).string(message.task_before); } if (message.task_after !== undefined) { writer.uint32(74).string(message.task_after); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): UpdateCronRequest { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseUpdateCronRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 8) { break; } message.id = reader.int32(); continue; } case 2: { if (tag !== 18) { break; } message.command = reader.string(); continue; } case 3: { if (tag !== 26) { break; } message.schedule = reader.string(); continue; } case 4: { if (tag !== 34) { break; } message.name = reader.string(); continue; } case 5: { if (tag !== 42) { break; } message.labels.push(reader.string()); continue; } case 6: { if (tag !== 48) { break; } message.sub_id = reader.int32(); continue; } case 7: { if (tag !== 58) { break; } message.extra_schedules.push(ExtraScheduleItem.decode(reader, reader.uint32())); continue; } case 8: { if (tag !== 66) { break; } message.task_before = reader.string(); continue; } case 9: { if (tag !== 74) { break; } message.task_after = reader.string(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): UpdateCronRequest { return { id: isSet(object.id) ? globalThis.Number(object.id) : 0, command: isSet(object.command) ? globalThis.String(object.command) : undefined, schedule: isSet(object.schedule) ? globalThis.String(object.schedule) : undefined, name: isSet(object.name) ? globalThis.String(object.name) : undefined, labels: globalThis.Array.isArray(object?.labels) ? object.labels.map((e: any) => globalThis.String(e)) : [], sub_id: isSet(object.sub_id) ? globalThis.Number(object.sub_id) : undefined, extra_schedules: globalThis.Array.isArray(object?.extra_schedules) ? object.extra_schedules.map((e: any) => ExtraScheduleItem.fromJSON(e)) : [], task_before: isSet(object.task_before) ? globalThis.String(object.task_before) : undefined, task_after: isSet(object.task_after) ? globalThis.String(object.task_after) : undefined, }; }, toJSON(message: UpdateCronRequest): unknown { const obj: any = {}; if (message.id !== 0) { obj.id = Math.round(message.id); } if (message.command !== undefined) { obj.command = message.command; } if (message.schedule !== undefined) { obj.schedule = message.schedule; } if (message.name !== undefined) { obj.name = message.name; } if (message.labels?.length) { obj.labels = message.labels; } if (message.sub_id !== undefined) { obj.sub_id = Math.round(message.sub_id); } if (message.extra_schedules?.length) { obj.extra_schedules = message.extra_schedules.map((e) => ExtraScheduleItem.toJSON(e)); } if (message.task_before !== undefined) { obj.task_before = message.task_before; } if (message.task_after !== undefined) { obj.task_after = message.task_after; } return obj; }, create, I>>(base?: I): UpdateCronRequest { return UpdateCronRequest.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): UpdateCronRequest { const message = createBaseUpdateCronRequest(); message.id = object.id ?? 0; message.command = object.command ?? undefined; message.schedule = object.schedule ?? undefined; message.name = object.name ?? undefined; message.labels = object.labels?.map((e) => e) || []; message.sub_id = object.sub_id ?? undefined; message.extra_schedules = object.extra_schedules?.map((e) => ExtraScheduleItem.fromPartial(e)) || []; message.task_before = object.task_before ?? undefined; message.task_after = object.task_after ?? undefined; return message; }, }; function createBaseDeleteCronsRequest(): DeleteCronsRequest { return { ids: [] }; } export const DeleteCronsRequest: MessageFns = { encode(message: DeleteCronsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { writer.uint32(10).fork(); for (const v of message.ids) { writer.int32(v); } writer.join(); return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): DeleteCronsRequest { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseDeleteCronsRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag === 8) { message.ids.push(reader.int32()); continue; } if (tag === 10) { const end2 = reader.uint32() + reader.pos; while (reader.pos < end2) { message.ids.push(reader.int32()); } continue; } break; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): DeleteCronsRequest { return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.Number(e)) : [] }; }, toJSON(message: DeleteCronsRequest): unknown { const obj: any = {}; if (message.ids?.length) { obj.ids = message.ids.map((e) => Math.round(e)); } return obj; }, create, I>>(base?: I): DeleteCronsRequest { return DeleteCronsRequest.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): DeleteCronsRequest { const message = createBaseDeleteCronsRequest(); message.ids = object.ids?.map((e) => e) || []; return message; }, }; function createBaseGetCronsRequest(): GetCronsRequest { return { searchValue: undefined }; } export const GetCronsRequest: MessageFns = { encode(message: GetCronsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { if (message.searchValue !== undefined) { writer.uint32(10).string(message.searchValue); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): GetCronsRequest { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseGetCronsRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 10) { break; } message.searchValue = reader.string(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): GetCronsRequest { return { searchValue: isSet(object.searchValue) ? globalThis.String(object.searchValue) : undefined }; }, toJSON(message: GetCronsRequest): unknown { const obj: any = {}; if (message.searchValue !== undefined) { obj.searchValue = message.searchValue; } return obj; }, create, I>>(base?: I): GetCronsRequest { return GetCronsRequest.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): GetCronsRequest { const message = createBaseGetCronsRequest(); message.searchValue = object.searchValue ?? undefined; return message; }, }; function createBaseGetCronByIdRequest(): GetCronByIdRequest { return { id: 0 }; } export const GetCronByIdRequest: MessageFns = { encode(message: GetCronByIdRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { if (message.id !== 0) { writer.uint32(8).int32(message.id); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): GetCronByIdRequest { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseGetCronByIdRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 8) { break; } message.id = reader.int32(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): GetCronByIdRequest { return { id: isSet(object.id) ? globalThis.Number(object.id) : 0 }; }, toJSON(message: GetCronByIdRequest): unknown { const obj: any = {}; if (message.id !== 0) { obj.id = Math.round(message.id); } return obj; }, create, I>>(base?: I): GetCronByIdRequest { return GetCronByIdRequest.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): GetCronByIdRequest { const message = createBaseGetCronByIdRequest(); message.id = object.id ?? 0; return message; }, }; function createBaseEnableCronsRequest(): EnableCronsRequest { return { ids: [] }; } export const EnableCronsRequest: MessageFns = { encode(message: EnableCronsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { writer.uint32(10).fork(); for (const v of message.ids) { writer.int32(v); } writer.join(); return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): EnableCronsRequest { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseEnableCronsRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag === 8) { message.ids.push(reader.int32()); continue; } if (tag === 10) { const end2 = reader.uint32() + reader.pos; while (reader.pos < end2) { message.ids.push(reader.int32()); } continue; } break; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): EnableCronsRequest { return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.Number(e)) : [] }; }, toJSON(message: EnableCronsRequest): unknown { const obj: any = {}; if (message.ids?.length) { obj.ids = message.ids.map((e) => Math.round(e)); } return obj; }, create, I>>(base?: I): EnableCronsRequest { return EnableCronsRequest.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): EnableCronsRequest { const message = createBaseEnableCronsRequest(); message.ids = object.ids?.map((e) => e) || []; return message; }, }; function createBaseDisableCronsRequest(): DisableCronsRequest { return { ids: [] }; } export const DisableCronsRequest: MessageFns = { encode(message: DisableCronsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { writer.uint32(10).fork(); for (const v of message.ids) { writer.int32(v); } writer.join(); return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): DisableCronsRequest { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseDisableCronsRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag === 8) { message.ids.push(reader.int32()); continue; } if (tag === 10) { const end2 = reader.uint32() + reader.pos; while (reader.pos < end2) { message.ids.push(reader.int32()); } continue; } break; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): DisableCronsRequest { return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.Number(e)) : [] }; }, toJSON(message: DisableCronsRequest): unknown { const obj: any = {}; if (message.ids?.length) { obj.ids = message.ids.map((e) => Math.round(e)); } return obj; }, create, I>>(base?: I): DisableCronsRequest { return DisableCronsRequest.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): DisableCronsRequest { const message = createBaseDisableCronsRequest(); message.ids = object.ids?.map((e) => e) || []; return message; }, }; function createBaseRunCronsRequest(): RunCronsRequest { return { ids: [] }; } export const RunCronsRequest: MessageFns = { encode(message: RunCronsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { writer.uint32(10).fork(); for (const v of message.ids) { writer.int32(v); } writer.join(); return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): RunCronsRequest { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseRunCronsRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag === 8) { message.ids.push(reader.int32()); continue; } if (tag === 10) { const end2 = reader.uint32() + reader.pos; while (reader.pos < end2) { message.ids.push(reader.int32()); } continue; } break; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): RunCronsRequest { return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.Number(e)) : [] }; }, toJSON(message: RunCronsRequest): unknown { const obj: any = {}; if (message.ids?.length) { obj.ids = message.ids.map((e) => Math.round(e)); } return obj; }, create, I>>(base?: I): RunCronsRequest { return RunCronsRequest.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): RunCronsRequest { const message = createBaseRunCronsRequest(); message.ids = object.ids?.map((e) => e) || []; return message; }, }; function createBaseCronsResponse(): CronsResponse { return { code: 0, data: [], message: undefined }; } export const CronsResponse: MessageFns = { encode(message: CronsResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { if (message.code !== 0) { writer.uint32(8).int32(message.code); } for (const v of message.data) { CronItem.encode(v!, writer.uint32(18).fork()).join(); } if (message.message !== undefined) { writer.uint32(26).string(message.message); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): CronsResponse { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseCronsResponse(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 8) { break; } message.code = reader.int32(); continue; } case 2: { if (tag !== 18) { break; } message.data.push(CronItem.decode(reader, reader.uint32())); continue; } case 3: { if (tag !== 26) { break; } message.message = reader.string(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): CronsResponse { return { code: isSet(object.code) ? globalThis.Number(object.code) : 0, data: globalThis.Array.isArray(object?.data) ? object.data.map((e: any) => CronItem.fromJSON(e)) : [], message: isSet(object.message) ? globalThis.String(object.message) : undefined, }; }, toJSON(message: CronsResponse): unknown { const obj: any = {}; if (message.code !== 0) { obj.code = Math.round(message.code); } if (message.data?.length) { obj.data = message.data.map((e) => CronItem.toJSON(e)); } if (message.message !== undefined) { obj.message = message.message; } return obj; }, create, I>>(base?: I): CronsResponse { return CronsResponse.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): CronsResponse { const message = createBaseCronsResponse(); message.code = object.code ?? 0; message.data = object.data?.map((e) => CronItem.fromPartial(e)) || []; message.message = object.message ?? undefined; return message; }, }; function createBaseCronResponse(): CronResponse { return { code: 0, data: undefined, message: undefined }; } export const CronResponse: MessageFns = { encode(message: CronResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { if (message.code !== 0) { writer.uint32(8).int32(message.code); } if (message.data !== undefined) { CronItem.encode(message.data, writer.uint32(18).fork()).join(); } if (message.message !== undefined) { writer.uint32(26).string(message.message); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): CronResponse { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseCronResponse(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 8) { break; } message.code = reader.int32(); continue; } case 2: { if (tag !== 18) { break; } message.data = CronItem.decode(reader, reader.uint32()); continue; } case 3: { if (tag !== 26) { break; } message.message = reader.string(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): CronResponse { return { code: isSet(object.code) ? globalThis.Number(object.code) : 0, data: isSet(object.data) ? CronItem.fromJSON(object.data) : undefined, message: isSet(object.message) ? globalThis.String(object.message) : undefined, }; }, toJSON(message: CronResponse): unknown { const obj: any = {}; if (message.code !== 0) { obj.code = Math.round(message.code); } if (message.data !== undefined) { obj.data = CronItem.toJSON(message.data); } if (message.message !== undefined) { obj.message = message.message; } return obj; }, create, I>>(base?: I): CronResponse { return CronResponse.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): CronResponse { const message = createBaseCronResponse(); message.code = object.code ?? 0; message.data = (object.data !== undefined && object.data !== null) ? CronItem.fromPartial(object.data) : undefined; message.message = object.message ?? undefined; return message; }, }; function createBaseCronDetailRequest(): CronDetailRequest { return { log_path: "" }; } export const CronDetailRequest: MessageFns = { encode(message: CronDetailRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { if (message.log_path !== "") { writer.uint32(10).string(message.log_path); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): CronDetailRequest { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseCronDetailRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 10) { break; } message.log_path = reader.string(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): CronDetailRequest { return { log_path: isSet(object.log_path) ? globalThis.String(object.log_path) : "" }; }, toJSON(message: CronDetailRequest): unknown { const obj: any = {}; if (message.log_path !== "") { obj.log_path = message.log_path; } return obj; }, create, I>>(base?: I): CronDetailRequest { return CronDetailRequest.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): CronDetailRequest { const message = createBaseCronDetailRequest(); message.log_path = object.log_path ?? ""; return message; }, }; function createBaseCronDetailResponse(): CronDetailResponse { return { code: 0, data: undefined, message: undefined }; } export const CronDetailResponse: MessageFns = { encode(message: CronDetailResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { if (message.code !== 0) { writer.uint32(8).int32(message.code); } if (message.data !== undefined) { CronItem.encode(message.data, writer.uint32(18).fork()).join(); } if (message.message !== undefined) { writer.uint32(26).string(message.message); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): CronDetailResponse { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseCronDetailResponse(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 8) { break; } message.code = reader.int32(); continue; } case 2: { if (tag !== 18) { break; } message.data = CronItem.decode(reader, reader.uint32()); continue; } case 3: { if (tag !== 26) { break; } message.message = reader.string(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): CronDetailResponse { return { code: isSet(object.code) ? globalThis.Number(object.code) : 0, data: isSet(object.data) ? CronItem.fromJSON(object.data) : undefined, message: isSet(object.message) ? globalThis.String(object.message) : undefined, }; }, toJSON(message: CronDetailResponse): unknown { const obj: any = {}; if (message.code !== 0) { obj.code = Math.round(message.code); } if (message.data !== undefined) { obj.data = CronItem.toJSON(message.data); } if (message.message !== undefined) { obj.message = message.message; } return obj; }, create, I>>(base?: I): CronDetailResponse { return CronDetailResponse.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): CronDetailResponse { const message = createBaseCronDetailResponse(); message.code = object.code ?? 0; message.data = (object.data !== undefined && object.data !== null) ? CronItem.fromPartial(object.data) : undefined; message.message = object.message ?? undefined; return message; }, }; function createBaseNotificationInfo(): NotificationInfo { return { type: 0, gotifyUrl: undefined, gotifyToken: undefined, gotifyPriority: undefined, goCqHttpBotUrl: undefined, goCqHttpBotToken: undefined, goCqHttpBotQq: undefined, serverChanKey: undefined, pushDeerKey: undefined, pushDeerUrl: undefined, synologyChatUrl: undefined, barkPush: undefined, barkIcon: undefined, barkSound: undefined, barkGroup: undefined, barkLevel: undefined, barkUrl: undefined, barkArchive: undefined, telegramBotToken: undefined, telegramBotUserId: undefined, telegramBotProxyHost: undefined, telegramBotProxyPort: undefined, telegramBotProxyAuth: undefined, telegramBotApiHost: undefined, dingtalkBotToken: undefined, dingtalkBotSecret: undefined, weWorkBotKey: undefined, weWorkOrigin: undefined, weWorkAppKey: undefined, aibotkKey: undefined, aibotkType: undefined, aibotkName: undefined, iGotPushKey: undefined, pushPlusToken: undefined, pushPlusUser: undefined, pushPlusTemplate: undefined, pushplusChannel: undefined, pushplusWebhook: undefined, pushplusCallbackUrl: undefined, pushplusTo: undefined, wePlusBotToken: undefined, wePlusBotReceiver: undefined, wePlusBotVersion: undefined, emailService: undefined, emailUser: undefined, emailPass: undefined, emailTo: undefined, pushMeKey: undefined, pushMeUrl: undefined, chronocatURL: undefined, chronocatQQ: undefined, chronocatToken: undefined, webhookHeaders: undefined, webhookBody: undefined, webhookUrl: undefined, webhookMethod: undefined, webhookContentType: undefined, larkKey: undefined, larkSecret: undefined, ntfyUrl: undefined, ntfyTopic: undefined, ntfyPriority: undefined, ntfyToken: undefined, ntfyUsername: undefined, ntfyPassword: undefined, ntfyActions: undefined, wxPusherBotAppToken: undefined, wxPusherBotTopicIds: undefined, wxPusherBotUids: undefined, }; } export const NotificationInfo: MessageFns = { encode(message: NotificationInfo, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { if (message.type !== 0) { writer.uint32(8).int32(message.type); } if (message.gotifyUrl !== undefined) { writer.uint32(18).string(message.gotifyUrl); } if (message.gotifyToken !== undefined) { writer.uint32(26).string(message.gotifyToken); } if (message.gotifyPriority !== undefined) { writer.uint32(32).int32(message.gotifyPriority); } if (message.goCqHttpBotUrl !== undefined) { writer.uint32(42).string(message.goCqHttpBotUrl); } if (message.goCqHttpBotToken !== undefined) { writer.uint32(50).string(message.goCqHttpBotToken); } if (message.goCqHttpBotQq !== undefined) { writer.uint32(58).string(message.goCqHttpBotQq); } if (message.serverChanKey !== undefined) { writer.uint32(66).string(message.serverChanKey); } if (message.pushDeerKey !== undefined) { writer.uint32(74).string(message.pushDeerKey); } if (message.pushDeerUrl !== undefined) { writer.uint32(82).string(message.pushDeerUrl); } if (message.synologyChatUrl !== undefined) { writer.uint32(90).string(message.synologyChatUrl); } if (message.barkPush !== undefined) { writer.uint32(98).string(message.barkPush); } if (message.barkIcon !== undefined) { writer.uint32(106).string(message.barkIcon); } if (message.barkSound !== undefined) { writer.uint32(114).string(message.barkSound); } if (message.barkGroup !== undefined) { writer.uint32(122).string(message.barkGroup); } if (message.barkLevel !== undefined) { writer.uint32(130).string(message.barkLevel); } if (message.barkUrl !== undefined) { writer.uint32(138).string(message.barkUrl); } if (message.barkArchive !== undefined) { writer.uint32(146).string(message.barkArchive); } if (message.telegramBotToken !== undefined) { writer.uint32(154).string(message.telegramBotToken); } if (message.telegramBotUserId !== undefined) { writer.uint32(162).string(message.telegramBotUserId); } if (message.telegramBotProxyHost !== undefined) { writer.uint32(170).string(message.telegramBotProxyHost); } if (message.telegramBotProxyPort !== undefined) { writer.uint32(178).string(message.telegramBotProxyPort); } if (message.telegramBotProxyAuth !== undefined) { writer.uint32(186).string(message.telegramBotProxyAuth); } if (message.telegramBotApiHost !== undefined) { writer.uint32(194).string(message.telegramBotApiHost); } if (message.dingtalkBotToken !== undefined) { writer.uint32(202).string(message.dingtalkBotToken); } if (message.dingtalkBotSecret !== undefined) { writer.uint32(210).string(message.dingtalkBotSecret); } if (message.weWorkBotKey !== undefined) { writer.uint32(218).string(message.weWorkBotKey); } if (message.weWorkOrigin !== undefined) { writer.uint32(226).string(message.weWorkOrigin); } if (message.weWorkAppKey !== undefined) { writer.uint32(234).string(message.weWorkAppKey); } if (message.aibotkKey !== undefined) { writer.uint32(242).string(message.aibotkKey); } if (message.aibotkType !== undefined) { writer.uint32(250).string(message.aibotkType); } if (message.aibotkName !== undefined) { writer.uint32(258).string(message.aibotkName); } if (message.iGotPushKey !== undefined) { writer.uint32(266).string(message.iGotPushKey); } if (message.pushPlusToken !== undefined) { writer.uint32(274).string(message.pushPlusToken); } if (message.pushPlusUser !== undefined) { writer.uint32(282).string(message.pushPlusUser); } if (message.pushPlusTemplate !== undefined) { writer.uint32(290).string(message.pushPlusTemplate); } if (message.pushplusChannel !== undefined) { writer.uint32(298).string(message.pushplusChannel); } if (message.pushplusWebhook !== undefined) { writer.uint32(306).string(message.pushplusWebhook); } if (message.pushplusCallbackUrl !== undefined) { writer.uint32(314).string(message.pushplusCallbackUrl); } if (message.pushplusTo !== undefined) { writer.uint32(322).string(message.pushplusTo); } if (message.wePlusBotToken !== undefined) { writer.uint32(330).string(message.wePlusBotToken); } if (message.wePlusBotReceiver !== undefined) { writer.uint32(338).string(message.wePlusBotReceiver); } if (message.wePlusBotVersion !== undefined) { writer.uint32(346).string(message.wePlusBotVersion); } if (message.emailService !== undefined) { writer.uint32(354).string(message.emailService); } if (message.emailUser !== undefined) { writer.uint32(362).string(message.emailUser); } if (message.emailPass !== undefined) { writer.uint32(370).string(message.emailPass); } if (message.emailTo !== undefined) { writer.uint32(378).string(message.emailTo); } if (message.pushMeKey !== undefined) { writer.uint32(386).string(message.pushMeKey); } if (message.pushMeUrl !== undefined) { writer.uint32(394).string(message.pushMeUrl); } if (message.chronocatURL !== undefined) { writer.uint32(402).string(message.chronocatURL); } if (message.chronocatQQ !== undefined) { writer.uint32(410).string(message.chronocatQQ); } if (message.chronocatToken !== undefined) { writer.uint32(418).string(message.chronocatToken); } if (message.webhookHeaders !== undefined) { writer.uint32(426).string(message.webhookHeaders); } if (message.webhookBody !== undefined) { writer.uint32(434).string(message.webhookBody); } if (message.webhookUrl !== undefined) { writer.uint32(442).string(message.webhookUrl); } if (message.webhookMethod !== undefined) { writer.uint32(450).string(message.webhookMethod); } if (message.webhookContentType !== undefined) { writer.uint32(458).string(message.webhookContentType); } if (message.larkKey !== undefined) { writer.uint32(466).string(message.larkKey); } if (message.larkSecret !== undefined) { writer.uint32(554).string(message.larkSecret); } if (message.ntfyUrl !== undefined) { writer.uint32(474).string(message.ntfyUrl); } if (message.ntfyTopic !== undefined) { writer.uint32(482).string(message.ntfyTopic); } if (message.ntfyPriority !== undefined) { writer.uint32(490).string(message.ntfyPriority); } if (message.ntfyToken !== undefined) { writer.uint32(498).string(message.ntfyToken); } if (message.ntfyUsername !== undefined) { writer.uint32(506).string(message.ntfyUsername); } if (message.ntfyPassword !== undefined) { writer.uint32(514).string(message.ntfyPassword); } if (message.ntfyActions !== undefined) { writer.uint32(522).string(message.ntfyActions); } if (message.wxPusherBotAppToken !== undefined) { writer.uint32(530).string(message.wxPusherBotAppToken); } if (message.wxPusherBotTopicIds !== undefined) { writer.uint32(538).string(message.wxPusherBotTopicIds); } if (message.wxPusherBotUids !== undefined) { writer.uint32(546).string(message.wxPusherBotUids); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): NotificationInfo { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseNotificationInfo(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 8) { break; } message.type = reader.int32() as any; continue; } case 2: { if (tag !== 18) { break; } message.gotifyUrl = reader.string(); continue; } case 3: { if (tag !== 26) { break; } message.gotifyToken = reader.string(); continue; } case 4: { if (tag !== 32) { break; } message.gotifyPriority = reader.int32(); continue; } case 5: { if (tag !== 42) { break; } message.goCqHttpBotUrl = reader.string(); continue; } case 6: { if (tag !== 50) { break; } message.goCqHttpBotToken = reader.string(); continue; } case 7: { if (tag !== 58) { break; } message.goCqHttpBotQq = reader.string(); continue; } case 8: { if (tag !== 66) { break; } message.serverChanKey = reader.string(); continue; } case 9: { if (tag !== 74) { break; } message.pushDeerKey = reader.string(); continue; } case 10: { if (tag !== 82) { break; } message.pushDeerUrl = reader.string(); continue; } case 11: { if (tag !== 90) { break; } message.synologyChatUrl = reader.string(); continue; } case 12: { if (tag !== 98) { break; } message.barkPush = reader.string(); continue; } case 13: { if (tag !== 106) { break; } message.barkIcon = reader.string(); continue; } case 14: { if (tag !== 114) { break; } message.barkSound = reader.string(); continue; } case 15: { if (tag !== 122) { break; } message.barkGroup = reader.string(); continue; } case 16: { if (tag !== 130) { break; } message.barkLevel = reader.string(); continue; } case 17: { if (tag !== 138) { break; } message.barkUrl = reader.string(); continue; } case 18: { if (tag !== 146) { break; } message.barkArchive = reader.string(); continue; } case 19: { if (tag !== 154) { break; } message.telegramBotToken = reader.string(); continue; } case 20: { if (tag !== 162) { break; } message.telegramBotUserId = reader.string(); continue; } case 21: { if (tag !== 170) { break; } message.telegramBotProxyHost = reader.string(); continue; } case 22: { if (tag !== 178) { break; } message.telegramBotProxyPort = reader.string(); continue; } case 23: { if (tag !== 186) { break; } message.telegramBotProxyAuth = reader.string(); continue; } case 24: { if (tag !== 194) { break; } message.telegramBotApiHost = reader.string(); continue; } case 25: { if (tag !== 202) { break; } message.dingtalkBotToken = reader.string(); continue; } case 26: { if (tag !== 210) { break; } message.dingtalkBotSecret = reader.string(); continue; } case 27: { if (tag !== 218) { break; } message.weWorkBotKey = reader.string(); continue; } case 28: { if (tag !== 226) { break; } message.weWorkOrigin = reader.string(); continue; } case 29: { if (tag !== 234) { break; } message.weWorkAppKey = reader.string(); continue; } case 30: { if (tag !== 242) { break; } message.aibotkKey = reader.string(); continue; } case 31: { if (tag !== 250) { break; } message.aibotkType = reader.string(); continue; } case 32: { if (tag !== 258) { break; } message.aibotkName = reader.string(); continue; } case 33: { if (tag !== 266) { break; } message.iGotPushKey = reader.string(); continue; } case 34: { if (tag !== 274) { break; } message.pushPlusToken = reader.string(); continue; } case 35: { if (tag !== 282) { break; } message.pushPlusUser = reader.string(); continue; } case 36: { if (tag !== 290) { break; } message.pushPlusTemplate = reader.string(); continue; } case 37: { if (tag !== 298) { break; } message.pushplusChannel = reader.string(); continue; } case 38: { if (tag !== 306) { break; } message.pushplusWebhook = reader.string(); continue; } case 39: { if (tag !== 314) { break; } message.pushplusCallbackUrl = reader.string(); continue; } case 40: { if (tag !== 322) { break; } message.pushplusTo = reader.string(); continue; } case 41: { if (tag !== 330) { break; } message.wePlusBotToken = reader.string(); continue; } case 42: { if (tag !== 338) { break; } message.wePlusBotReceiver = reader.string(); continue; } case 43: { if (tag !== 346) { break; } message.wePlusBotVersion = reader.string(); continue; } case 44: { if (tag !== 354) { break; } message.emailService = reader.string(); continue; } case 45: { if (tag !== 362) { break; } message.emailUser = reader.string(); continue; } case 46: { if (tag !== 370) { break; } message.emailPass = reader.string(); continue; } case 47: { if (tag !== 378) { break; } message.emailTo = reader.string(); continue; } case 48: { if (tag !== 386) { break; } message.pushMeKey = reader.string(); continue; } case 49: { if (tag !== 394) { break; } message.pushMeUrl = reader.string(); continue; } case 50: { if (tag !== 402) { break; } message.chronocatURL = reader.string(); continue; } case 51: { if (tag !== 410) { break; } message.chronocatQQ = reader.string(); continue; } case 52: { if (tag !== 418) { break; } message.chronocatToken = reader.string(); continue; } case 53: { if (tag !== 426) { break; } message.webhookHeaders = reader.string(); continue; } case 54: { if (tag !== 434) { break; } message.webhookBody = reader.string(); continue; } case 55: { if (tag !== 442) { break; } message.webhookUrl = reader.string(); continue; } case 56: { if (tag !== 450) { break; } message.webhookMethod = reader.string(); continue; } case 57: { if (tag !== 458) { break; } message.webhookContentType = reader.string(); continue; } case 58: { if (tag !== 466) { break; } message.larkKey = reader.string(); continue; } case 69: { if (tag !== 554) { break; } message.larkSecret = reader.string(); continue; } case 59: { if (tag !== 474) { break; } message.ntfyUrl = reader.string(); continue; } case 60: { if (tag !== 482) { break; } message.ntfyTopic = reader.string(); continue; } case 61: { if (tag !== 490) { break; } message.ntfyPriority = reader.string(); continue; } case 62: { if (tag !== 498) { break; } message.ntfyToken = reader.string(); continue; } case 63: { if (tag !== 506) { break; } message.ntfyUsername = reader.string(); continue; } case 64: { if (tag !== 514) { break; } message.ntfyPassword = reader.string(); continue; } case 65: { if (tag !== 522) { break; } message.ntfyActions = reader.string(); continue; } case 66: { if (tag !== 530) { break; } message.wxPusherBotAppToken = reader.string(); continue; } case 67: { if (tag !== 538) { break; } message.wxPusherBotTopicIds = reader.string(); continue; } case 68: { if (tag !== 546) { break; } message.wxPusherBotUids = reader.string(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): NotificationInfo { return { type: isSet(object.type) ? notificationModeFromJSON(object.type) : 0, gotifyUrl: isSet(object.gotifyUrl) ? globalThis.String(object.gotifyUrl) : undefined, gotifyToken: isSet(object.gotifyToken) ? globalThis.String(object.gotifyToken) : undefined, gotifyPriority: isSet(object.gotifyPriority) ? globalThis.Number(object.gotifyPriority) : undefined, goCqHttpBotUrl: isSet(object.goCqHttpBotUrl) ? globalThis.String(object.goCqHttpBotUrl) : undefined, goCqHttpBotToken: isSet(object.goCqHttpBotToken) ? globalThis.String(object.goCqHttpBotToken) : undefined, goCqHttpBotQq: isSet(object.goCqHttpBotQq) ? globalThis.String(object.goCqHttpBotQq) : undefined, serverChanKey: isSet(object.serverChanKey) ? globalThis.String(object.serverChanKey) : undefined, pushDeerKey: isSet(object.pushDeerKey) ? globalThis.String(object.pushDeerKey) : undefined, pushDeerUrl: isSet(object.pushDeerUrl) ? globalThis.String(object.pushDeerUrl) : undefined, synologyChatUrl: isSet(object.synologyChatUrl) ? globalThis.String(object.synologyChatUrl) : undefined, barkPush: isSet(object.barkPush) ? globalThis.String(object.barkPush) : undefined, barkIcon: isSet(object.barkIcon) ? globalThis.String(object.barkIcon) : undefined, barkSound: isSet(object.barkSound) ? globalThis.String(object.barkSound) : undefined, barkGroup: isSet(object.barkGroup) ? globalThis.String(object.barkGroup) : undefined, barkLevel: isSet(object.barkLevel) ? globalThis.String(object.barkLevel) : undefined, barkUrl: isSet(object.barkUrl) ? globalThis.String(object.barkUrl) : undefined, barkArchive: isSet(object.barkArchive) ? globalThis.String(object.barkArchive) : undefined, telegramBotToken: isSet(object.telegramBotToken) ? globalThis.String(object.telegramBotToken) : undefined, telegramBotUserId: isSet(object.telegramBotUserId) ? globalThis.String(object.telegramBotUserId) : undefined, telegramBotProxyHost: isSet(object.telegramBotProxyHost) ? globalThis.String(object.telegramBotProxyHost) : undefined, telegramBotProxyPort: isSet(object.telegramBotProxyPort) ? globalThis.String(object.telegramBotProxyPort) : undefined, telegramBotProxyAuth: isSet(object.telegramBotProxyAuth) ? globalThis.String(object.telegramBotProxyAuth) : undefined, telegramBotApiHost: isSet(object.telegramBotApiHost) ? globalThis.String(object.telegramBotApiHost) : undefined, dingtalkBotToken: isSet(object.dingtalkBotToken) ? globalThis.String(object.dingtalkBotToken) : undefined, dingtalkBotSecret: isSet(object.dingtalkBotSecret) ? globalThis.String(object.dingtalkBotSecret) : undefined, weWorkBotKey: isSet(object.weWorkBotKey) ? globalThis.String(object.weWorkBotKey) : undefined, weWorkOrigin: isSet(object.weWorkOrigin) ? globalThis.String(object.weWorkOrigin) : undefined, weWorkAppKey: isSet(object.weWorkAppKey) ? globalThis.String(object.weWorkAppKey) : undefined, aibotkKey: isSet(object.aibotkKey) ? globalThis.String(object.aibotkKey) : undefined, aibotkType: isSet(object.aibotkType) ? globalThis.String(object.aibotkType) : undefined, aibotkName: isSet(object.aibotkName) ? globalThis.String(object.aibotkName) : undefined, iGotPushKey: isSet(object.iGotPushKey) ? globalThis.String(object.iGotPushKey) : undefined, pushPlusToken: isSet(object.pushPlusToken) ? globalThis.String(object.pushPlusToken) : undefined, pushPlusUser: isSet(object.pushPlusUser) ? globalThis.String(object.pushPlusUser) : undefined, pushPlusTemplate: isSet(object.pushPlusTemplate) ? globalThis.String(object.pushPlusTemplate) : undefined, pushplusChannel: isSet(object.pushplusChannel) ? globalThis.String(object.pushplusChannel) : undefined, pushplusWebhook: isSet(object.pushplusWebhook) ? globalThis.String(object.pushplusWebhook) : undefined, pushplusCallbackUrl: isSet(object.pushplusCallbackUrl) ? globalThis.String(object.pushplusCallbackUrl) : undefined, pushplusTo: isSet(object.pushplusTo) ? globalThis.String(object.pushplusTo) : undefined, wePlusBotToken: isSet(object.wePlusBotToken) ? globalThis.String(object.wePlusBotToken) : undefined, wePlusBotReceiver: isSet(object.wePlusBotReceiver) ? globalThis.String(object.wePlusBotReceiver) : undefined, wePlusBotVersion: isSet(object.wePlusBotVersion) ? globalThis.String(object.wePlusBotVersion) : undefined, emailService: isSet(object.emailService) ? globalThis.String(object.emailService) : undefined, emailUser: isSet(object.emailUser) ? globalThis.String(object.emailUser) : undefined, emailPass: isSet(object.emailPass) ? globalThis.String(object.emailPass) : undefined, emailTo: isSet(object.emailTo) ? globalThis.String(object.emailTo) : undefined, pushMeKey: isSet(object.pushMeKey) ? globalThis.String(object.pushMeKey) : undefined, pushMeUrl: isSet(object.pushMeUrl) ? globalThis.String(object.pushMeUrl) : undefined, chronocatURL: isSet(object.chronocatURL) ? globalThis.String(object.chronocatURL) : undefined, chronocatQQ: isSet(object.chronocatQQ) ? globalThis.String(object.chronocatQQ) : undefined, chronocatToken: isSet(object.chronocatToken) ? globalThis.String(object.chronocatToken) : undefined, webhookHeaders: isSet(object.webhookHeaders) ? globalThis.String(object.webhookHeaders) : undefined, webhookBody: isSet(object.webhookBody) ? globalThis.String(object.webhookBody) : undefined, webhookUrl: isSet(object.webhookUrl) ? globalThis.String(object.webhookUrl) : undefined, webhookMethod: isSet(object.webhookMethod) ? globalThis.String(object.webhookMethod) : undefined, webhookContentType: isSet(object.webhookContentType) ? globalThis.String(object.webhookContentType) : undefined, larkKey: isSet(object.larkKey) ? globalThis.String(object.larkKey) : undefined, larkSecret: isSet(object.larkSecret) ? globalThis.String(object.larkSecret) : undefined, ntfyUrl: isSet(object.ntfyUrl) ? globalThis.String(object.ntfyUrl) : undefined, ntfyTopic: isSet(object.ntfyTopic) ? globalThis.String(object.ntfyTopic) : undefined, ntfyPriority: isSet(object.ntfyPriority) ? globalThis.String(object.ntfyPriority) : undefined, ntfyToken: isSet(object.ntfyToken) ? globalThis.String(object.ntfyToken) : undefined, ntfyUsername: isSet(object.ntfyUsername) ? globalThis.String(object.ntfyUsername) : undefined, ntfyPassword: isSet(object.ntfyPassword) ? globalThis.String(object.ntfyPassword) : undefined, ntfyActions: isSet(object.ntfyActions) ? globalThis.String(object.ntfyActions) : undefined, wxPusherBotAppToken: isSet(object.wxPusherBotAppToken) ? globalThis.String(object.wxPusherBotAppToken) : undefined, wxPusherBotTopicIds: isSet(object.wxPusherBotTopicIds) ? globalThis.String(object.wxPusherBotTopicIds) : undefined, wxPusherBotUids: isSet(object.wxPusherBotUids) ? globalThis.String(object.wxPusherBotUids) : undefined, }; }, toJSON(message: NotificationInfo): unknown { const obj: any = {}; if (message.type !== 0) { obj.type = notificationModeToJSON(message.type); } if (message.gotifyUrl !== undefined) { obj.gotifyUrl = message.gotifyUrl; } if (message.gotifyToken !== undefined) { obj.gotifyToken = message.gotifyToken; } if (message.gotifyPriority !== undefined) { obj.gotifyPriority = Math.round(message.gotifyPriority); } if (message.goCqHttpBotUrl !== undefined) { obj.goCqHttpBotUrl = message.goCqHttpBotUrl; } if (message.goCqHttpBotToken !== undefined) { obj.goCqHttpBotToken = message.goCqHttpBotToken; } if (message.goCqHttpBotQq !== undefined) { obj.goCqHttpBotQq = message.goCqHttpBotQq; } if (message.serverChanKey !== undefined) { obj.serverChanKey = message.serverChanKey; } if (message.pushDeerKey !== undefined) { obj.pushDeerKey = message.pushDeerKey; } if (message.pushDeerUrl !== undefined) { obj.pushDeerUrl = message.pushDeerUrl; } if (message.synologyChatUrl !== undefined) { obj.synologyChatUrl = message.synologyChatUrl; } if (message.barkPush !== undefined) { obj.barkPush = message.barkPush; } if (message.barkIcon !== undefined) { obj.barkIcon = message.barkIcon; } if (message.barkSound !== undefined) { obj.barkSound = message.barkSound; } if (message.barkGroup !== undefined) { obj.barkGroup = message.barkGroup; } if (message.barkLevel !== undefined) { obj.barkLevel = message.barkLevel; } if (message.barkUrl !== undefined) { obj.barkUrl = message.barkUrl; } if (message.barkArchive !== undefined) { obj.barkArchive = message.barkArchive; } if (message.telegramBotToken !== undefined) { obj.telegramBotToken = message.telegramBotToken; } if (message.telegramBotUserId !== undefined) { obj.telegramBotUserId = message.telegramBotUserId; } if (message.telegramBotProxyHost !== undefined) { obj.telegramBotProxyHost = message.telegramBotProxyHost; } if (message.telegramBotProxyPort !== undefined) { obj.telegramBotProxyPort = message.telegramBotProxyPort; } if (message.telegramBotProxyAuth !== undefined) { obj.telegramBotProxyAuth = message.telegramBotProxyAuth; } if (message.telegramBotApiHost !== undefined) { obj.telegramBotApiHost = message.telegramBotApiHost; } if (message.dingtalkBotToken !== undefined) { obj.dingtalkBotToken = message.dingtalkBotToken; } if (message.dingtalkBotSecret !== undefined) { obj.dingtalkBotSecret = message.dingtalkBotSecret; } if (message.weWorkBotKey !== undefined) { obj.weWorkBotKey = message.weWorkBotKey; } if (message.weWorkOrigin !== undefined) { obj.weWorkOrigin = message.weWorkOrigin; } if (message.weWorkAppKey !== undefined) { obj.weWorkAppKey = message.weWorkAppKey; } if (message.aibotkKey !== undefined) { obj.aibotkKey = message.aibotkKey; } if (message.aibotkType !== undefined) { obj.aibotkType = message.aibotkType; } if (message.aibotkName !== undefined) { obj.aibotkName = message.aibotkName; } if (message.iGotPushKey !== undefined) { obj.iGotPushKey = message.iGotPushKey; } if (message.pushPlusToken !== undefined) { obj.pushPlusToken = message.pushPlusToken; } if (message.pushPlusUser !== undefined) { obj.pushPlusUser = message.pushPlusUser; } if (message.pushPlusTemplate !== undefined) { obj.pushPlusTemplate = message.pushPlusTemplate; } if (message.pushplusChannel !== undefined) { obj.pushplusChannel = message.pushplusChannel; } if (message.pushplusWebhook !== undefined) { obj.pushplusWebhook = message.pushplusWebhook; } if (message.pushplusCallbackUrl !== undefined) { obj.pushplusCallbackUrl = message.pushplusCallbackUrl; } if (message.pushplusTo !== undefined) { obj.pushplusTo = message.pushplusTo; } if (message.wePlusBotToken !== undefined) { obj.wePlusBotToken = message.wePlusBotToken; } if (message.wePlusBotReceiver !== undefined) { obj.wePlusBotReceiver = message.wePlusBotReceiver; } if (message.wePlusBotVersion !== undefined) { obj.wePlusBotVersion = message.wePlusBotVersion; } if (message.emailService !== undefined) { obj.emailService = message.emailService; } if (message.emailUser !== undefined) { obj.emailUser = message.emailUser; } if (message.emailPass !== undefined) { obj.emailPass = message.emailPass; } if (message.emailTo !== undefined) { obj.emailTo = message.emailTo; } if (message.pushMeKey !== undefined) { obj.pushMeKey = message.pushMeKey; } if (message.pushMeUrl !== undefined) { obj.pushMeUrl = message.pushMeUrl; } if (message.chronocatURL !== undefined) { obj.chronocatURL = message.chronocatURL; } if (message.chronocatQQ !== undefined) { obj.chronocatQQ = message.chronocatQQ; } if (message.chronocatToken !== undefined) { obj.chronocatToken = message.chronocatToken; } if (message.webhookHeaders !== undefined) { obj.webhookHeaders = message.webhookHeaders; } if (message.webhookBody !== undefined) { obj.webhookBody = message.webhookBody; } if (message.webhookUrl !== undefined) { obj.webhookUrl = message.webhookUrl; } if (message.webhookMethod !== undefined) { obj.webhookMethod = message.webhookMethod; } if (message.webhookContentType !== undefined) { obj.webhookContentType = message.webhookContentType; } if (message.larkKey !== undefined) { obj.larkKey = message.larkKey; } if (message.larkSecret !== undefined) { obj.larkSecret = message.larkSecret; } if (message.ntfyUrl !== undefined) { obj.ntfyUrl = message.ntfyUrl; } if (message.ntfyTopic !== undefined) { obj.ntfyTopic = message.ntfyTopic; } if (message.ntfyPriority !== undefined) { obj.ntfyPriority = message.ntfyPriority; } if (message.ntfyToken !== undefined) { obj.ntfyToken = message.ntfyToken; } if (message.ntfyUsername !== undefined) { obj.ntfyUsername = message.ntfyUsername; } if (message.ntfyPassword !== undefined) { obj.ntfyPassword = message.ntfyPassword; } if (message.ntfyActions !== undefined) { obj.ntfyActions = message.ntfyActions; } if (message.wxPusherBotAppToken !== undefined) { obj.wxPusherBotAppToken = message.wxPusherBotAppToken; } if (message.wxPusherBotTopicIds !== undefined) { obj.wxPusherBotTopicIds = message.wxPusherBotTopicIds; } if (message.wxPusherBotUids !== undefined) { obj.wxPusherBotUids = message.wxPusherBotUids; } return obj; }, create, I>>(base?: I): NotificationInfo { return NotificationInfo.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): NotificationInfo { const message = createBaseNotificationInfo(); message.type = object.type ?? 0; message.gotifyUrl = object.gotifyUrl ?? undefined; message.gotifyToken = object.gotifyToken ?? undefined; message.gotifyPriority = object.gotifyPriority ?? undefined; message.goCqHttpBotUrl = object.goCqHttpBotUrl ?? undefined; message.goCqHttpBotToken = object.goCqHttpBotToken ?? undefined; message.goCqHttpBotQq = object.goCqHttpBotQq ?? undefined; message.serverChanKey = object.serverChanKey ?? undefined; message.pushDeerKey = object.pushDeerKey ?? undefined; message.pushDeerUrl = object.pushDeerUrl ?? undefined; message.synologyChatUrl = object.synologyChatUrl ?? undefined; message.barkPush = object.barkPush ?? undefined; message.barkIcon = object.barkIcon ?? undefined; message.barkSound = object.barkSound ?? undefined; message.barkGroup = object.barkGroup ?? undefined; message.barkLevel = object.barkLevel ?? undefined; message.barkUrl = object.barkUrl ?? undefined; message.barkArchive = object.barkArchive ?? undefined; message.telegramBotToken = object.telegramBotToken ?? undefined; message.telegramBotUserId = object.telegramBotUserId ?? undefined; message.telegramBotProxyHost = object.telegramBotProxyHost ?? undefined; message.telegramBotProxyPort = object.telegramBotProxyPort ?? undefined; message.telegramBotProxyAuth = object.telegramBotProxyAuth ?? undefined; message.telegramBotApiHost = object.telegramBotApiHost ?? undefined; message.dingtalkBotToken = object.dingtalkBotToken ?? undefined; message.dingtalkBotSecret = object.dingtalkBotSecret ?? undefined; message.weWorkBotKey = object.weWorkBotKey ?? undefined; message.weWorkOrigin = object.weWorkOrigin ?? undefined; message.weWorkAppKey = object.weWorkAppKey ?? undefined; message.aibotkKey = object.aibotkKey ?? undefined; message.aibotkType = object.aibotkType ?? undefined; message.aibotkName = object.aibotkName ?? undefined; message.iGotPushKey = object.iGotPushKey ?? undefined; message.pushPlusToken = object.pushPlusToken ?? undefined; message.pushPlusUser = object.pushPlusUser ?? undefined; message.pushPlusTemplate = object.pushPlusTemplate ?? undefined; message.pushplusChannel = object.pushplusChannel ?? undefined; message.pushplusWebhook = object.pushplusWebhook ?? undefined; message.pushplusCallbackUrl = object.pushplusCallbackUrl ?? undefined; message.pushplusTo = object.pushplusTo ?? undefined; message.wePlusBotToken = object.wePlusBotToken ?? undefined; message.wePlusBotReceiver = object.wePlusBotReceiver ?? undefined; message.wePlusBotVersion = object.wePlusBotVersion ?? undefined; message.emailService = object.emailService ?? undefined; message.emailUser = object.emailUser ?? undefined; message.emailPass = object.emailPass ?? undefined; message.emailTo = object.emailTo ?? undefined; message.pushMeKey = object.pushMeKey ?? undefined; message.pushMeUrl = object.pushMeUrl ?? undefined; message.chronocatURL = object.chronocatURL ?? undefined; message.chronocatQQ = object.chronocatQQ ?? undefined; message.chronocatToken = object.chronocatToken ?? undefined; message.webhookHeaders = object.webhookHeaders ?? undefined; message.webhookBody = object.webhookBody ?? undefined; message.webhookUrl = object.webhookUrl ?? undefined; message.webhookMethod = object.webhookMethod ?? undefined; message.webhookContentType = object.webhookContentType ?? undefined; message.larkKey = object.larkKey ?? undefined; message.larkSecret = object.larkSecret ?? undefined; message.ntfyUrl = object.ntfyUrl ?? undefined; message.ntfyTopic = object.ntfyTopic ?? undefined; message.ntfyPriority = object.ntfyPriority ?? undefined; message.ntfyToken = object.ntfyToken ?? undefined; message.ntfyUsername = object.ntfyUsername ?? undefined; message.ntfyPassword = object.ntfyPassword ?? undefined; message.ntfyActions = object.ntfyActions ?? undefined; message.wxPusherBotAppToken = object.wxPusherBotAppToken ?? undefined; message.wxPusherBotTopicIds = object.wxPusherBotTopicIds ?? undefined; message.wxPusherBotUids = object.wxPusherBotUids ?? undefined; return message; }, }; function createBaseSystemNotifyRequest(): SystemNotifyRequest { return { title: "", content: "", notificationInfo: undefined }; } export const SystemNotifyRequest: MessageFns = { encode(message: SystemNotifyRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { if (message.title !== "") { writer.uint32(10).string(message.title); } if (message.content !== "") { writer.uint32(18).string(message.content); } if (message.notificationInfo !== undefined) { NotificationInfo.encode(message.notificationInfo, writer.uint32(26).fork()).join(); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): SystemNotifyRequest { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseSystemNotifyRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 10) { break; } message.title = reader.string(); continue; } case 2: { if (tag !== 18) { break; } message.content = reader.string(); continue; } case 3: { if (tag !== 26) { break; } message.notificationInfo = NotificationInfo.decode(reader, reader.uint32()); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): SystemNotifyRequest { return { title: isSet(object.title) ? globalThis.String(object.title) : "", content: isSet(object.content) ? globalThis.String(object.content) : "", notificationInfo: isSet(object.notificationInfo) ? NotificationInfo.fromJSON(object.notificationInfo) : undefined, }; }, toJSON(message: SystemNotifyRequest): unknown { const obj: any = {}; if (message.title !== "") { obj.title = message.title; } if (message.content !== "") { obj.content = message.content; } if (message.notificationInfo !== undefined) { obj.notificationInfo = NotificationInfo.toJSON(message.notificationInfo); } return obj; }, create, I>>(base?: I): SystemNotifyRequest { return SystemNotifyRequest.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): SystemNotifyRequest { const message = createBaseSystemNotifyRequest(); message.title = object.title ?? ""; message.content = object.content ?? ""; message.notificationInfo = (object.notificationInfo !== undefined && object.notificationInfo !== null) ? NotificationInfo.fromPartial(object.notificationInfo) : undefined; return message; }, }; export type ApiService = typeof ApiService; export const ApiService = { getEnvs: { path: "/com.ql.api.Api/GetEnvs", requestStream: false, responseStream: false, requestSerialize: (value: GetEnvsRequest) => Buffer.from(GetEnvsRequest.encode(value).finish()), requestDeserialize: (value: Buffer) => GetEnvsRequest.decode(value), responseSerialize: (value: EnvsResponse) => Buffer.from(EnvsResponse.encode(value).finish()), responseDeserialize: (value: Buffer) => EnvsResponse.decode(value), }, createEnv: { path: "/com.ql.api.Api/CreateEnv", requestStream: false, responseStream: false, requestSerialize: (value: CreateEnvRequest) => Buffer.from(CreateEnvRequest.encode(value).finish()), requestDeserialize: (value: Buffer) => CreateEnvRequest.decode(value), responseSerialize: (value: EnvsResponse) => Buffer.from(EnvsResponse.encode(value).finish()), responseDeserialize: (value: Buffer) => EnvsResponse.decode(value), }, updateEnv: { path: "/com.ql.api.Api/UpdateEnv", requestStream: false, responseStream: false, requestSerialize: (value: UpdateEnvRequest) => Buffer.from(UpdateEnvRequest.encode(value).finish()), requestDeserialize: (value: Buffer) => UpdateEnvRequest.decode(value), responseSerialize: (value: EnvResponse) => Buffer.from(EnvResponse.encode(value).finish()), responseDeserialize: (value: Buffer) => EnvResponse.decode(value), }, deleteEnvs: { path: "/com.ql.api.Api/DeleteEnvs", requestStream: false, responseStream: false, requestSerialize: (value: DeleteEnvsRequest) => Buffer.from(DeleteEnvsRequest.encode(value).finish()), requestDeserialize: (value: Buffer) => DeleteEnvsRequest.decode(value), responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()), responseDeserialize: (value: Buffer) => Response.decode(value), }, moveEnv: { path: "/com.ql.api.Api/MoveEnv", requestStream: false, responseStream: false, requestSerialize: (value: MoveEnvRequest) => Buffer.from(MoveEnvRequest.encode(value).finish()), requestDeserialize: (value: Buffer) => MoveEnvRequest.decode(value), responseSerialize: (value: EnvResponse) => Buffer.from(EnvResponse.encode(value).finish()), responseDeserialize: (value: Buffer) => EnvResponse.decode(value), }, disableEnvs: { path: "/com.ql.api.Api/DisableEnvs", requestStream: false, responseStream: false, requestSerialize: (value: DisableEnvsRequest) => Buffer.from(DisableEnvsRequest.encode(value).finish()), requestDeserialize: (value: Buffer) => DisableEnvsRequest.decode(value), responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()), responseDeserialize: (value: Buffer) => Response.decode(value), }, enableEnvs: { path: "/com.ql.api.Api/EnableEnvs", requestStream: false, responseStream: false, requestSerialize: (value: EnableEnvsRequest) => Buffer.from(EnableEnvsRequest.encode(value).finish()), requestDeserialize: (value: Buffer) => EnableEnvsRequest.decode(value), responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()), responseDeserialize: (value: Buffer) => Response.decode(value), }, updateEnvNames: { path: "/com.ql.api.Api/UpdateEnvNames", requestStream: false, responseStream: false, requestSerialize: (value: UpdateEnvNamesRequest) => Buffer.from(UpdateEnvNamesRequest.encode(value).finish()), requestDeserialize: (value: Buffer) => UpdateEnvNamesRequest.decode(value), responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()), responseDeserialize: (value: Buffer) => Response.decode(value), }, getEnvById: { path: "/com.ql.api.Api/GetEnvById", requestStream: false, responseStream: false, requestSerialize: (value: GetEnvByIdRequest) => Buffer.from(GetEnvByIdRequest.encode(value).finish()), requestDeserialize: (value: Buffer) => GetEnvByIdRequest.decode(value), responseSerialize: (value: EnvResponse) => Buffer.from(EnvResponse.encode(value).finish()), responseDeserialize: (value: Buffer) => EnvResponse.decode(value), }, systemNotify: { path: "/com.ql.api.Api/SystemNotify", requestStream: false, responseStream: false, requestSerialize: (value: SystemNotifyRequest) => Buffer.from(SystemNotifyRequest.encode(value).finish()), requestDeserialize: (value: Buffer) => SystemNotifyRequest.decode(value), responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()), responseDeserialize: (value: Buffer) => Response.decode(value), }, getCronDetail: { path: "/com.ql.api.Api/GetCronDetail", requestStream: false, responseStream: false, requestSerialize: (value: CronDetailRequest) => Buffer.from(CronDetailRequest.encode(value).finish()), requestDeserialize: (value: Buffer) => CronDetailRequest.decode(value), responseSerialize: (value: CronDetailResponse) => Buffer.from(CronDetailResponse.encode(value).finish()), responseDeserialize: (value: Buffer) => CronDetailResponse.decode(value), }, createCron: { path: "/com.ql.api.Api/CreateCron", requestStream: false, responseStream: false, requestSerialize: (value: CreateCronRequest) => Buffer.from(CreateCronRequest.encode(value).finish()), requestDeserialize: (value: Buffer) => CreateCronRequest.decode(value), responseSerialize: (value: CronResponse) => Buffer.from(CronResponse.encode(value).finish()), responseDeserialize: (value: Buffer) => CronResponse.decode(value), }, updateCron: { path: "/com.ql.api.Api/UpdateCron", requestStream: false, responseStream: false, requestSerialize: (value: UpdateCronRequest) => Buffer.from(UpdateCronRequest.encode(value).finish()), requestDeserialize: (value: Buffer) => UpdateCronRequest.decode(value), responseSerialize: (value: CronResponse) => Buffer.from(CronResponse.encode(value).finish()), responseDeserialize: (value: Buffer) => CronResponse.decode(value), }, deleteCrons: { path: "/com.ql.api.Api/DeleteCrons", requestStream: false, responseStream: false, requestSerialize: (value: DeleteCronsRequest) => Buffer.from(DeleteCronsRequest.encode(value).finish()), requestDeserialize: (value: Buffer) => DeleteCronsRequest.decode(value), responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()), responseDeserialize: (value: Buffer) => Response.decode(value), }, getCrons: { path: "/com.ql.api.Api/GetCrons", requestStream: false, responseStream: false, requestSerialize: (value: GetCronsRequest) => Buffer.from(GetCronsRequest.encode(value).finish()), requestDeserialize: (value: Buffer) => GetCronsRequest.decode(value), responseSerialize: (value: CronsResponse) => Buffer.from(CronsResponse.encode(value).finish()), responseDeserialize: (value: Buffer) => CronsResponse.decode(value), }, getCronById: { path: "/com.ql.api.Api/GetCronById", requestStream: false, responseStream: false, requestSerialize: (value: GetCronByIdRequest) => Buffer.from(GetCronByIdRequest.encode(value).finish()), requestDeserialize: (value: Buffer) => GetCronByIdRequest.decode(value), responseSerialize: (value: CronResponse) => Buffer.from(CronResponse.encode(value).finish()), responseDeserialize: (value: Buffer) => CronResponse.decode(value), }, enableCrons: { path: "/com.ql.api.Api/EnableCrons", requestStream: false, responseStream: false, requestSerialize: (value: EnableCronsRequest) => Buffer.from(EnableCronsRequest.encode(value).finish()), requestDeserialize: (value: Buffer) => EnableCronsRequest.decode(value), responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()), responseDeserialize: (value: Buffer) => Response.decode(value), }, disableCrons: { path: "/com.ql.api.Api/DisableCrons", requestStream: false, responseStream: false, requestSerialize: (value: DisableCronsRequest) => Buffer.from(DisableCronsRequest.encode(value).finish()), requestDeserialize: (value: Buffer) => DisableCronsRequest.decode(value), responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()), responseDeserialize: (value: Buffer) => Response.decode(value), }, runCrons: { path: "/com.ql.api.Api/RunCrons", requestStream: false, responseStream: false, requestSerialize: (value: RunCronsRequest) => Buffer.from(RunCronsRequest.encode(value).finish()), requestDeserialize: (value: Buffer) => RunCronsRequest.decode(value), responseSerialize: (value: Response) => Buffer.from(Response.encode(value).finish()), responseDeserialize: (value: Buffer) => Response.decode(value), }, } as const; export interface ApiServer extends UntypedServiceImplementation { getEnvs: handleUnaryCall; createEnv: handleUnaryCall; updateEnv: handleUnaryCall; deleteEnvs: handleUnaryCall; moveEnv: handleUnaryCall; disableEnvs: handleUnaryCall; enableEnvs: handleUnaryCall; updateEnvNames: handleUnaryCall; getEnvById: handleUnaryCall; systemNotify: handleUnaryCall; getCronDetail: handleUnaryCall; createCron: handleUnaryCall; updateCron: handleUnaryCall; deleteCrons: handleUnaryCall; getCrons: handleUnaryCall; getCronById: handleUnaryCall; enableCrons: handleUnaryCall; disableCrons: handleUnaryCall; runCrons: handleUnaryCall; } export interface ApiClient extends Client { getEnvs( request: GetEnvsRequest, callback: (error: ServiceError | null, response: EnvsResponse) => void, ): ClientUnaryCall; getEnvs( request: GetEnvsRequest, metadata: Metadata, callback: (error: ServiceError | null, response: EnvsResponse) => void, ): ClientUnaryCall; getEnvs( request: GetEnvsRequest, metadata: Metadata, options: Partial, callback: (error: ServiceError | null, response: EnvsResponse) => void, ): ClientUnaryCall; createEnv( request: CreateEnvRequest, callback: (error: ServiceError | null, response: EnvsResponse) => void, ): ClientUnaryCall; createEnv( request: CreateEnvRequest, metadata: Metadata, callback: (error: ServiceError | null, response: EnvsResponse) => void, ): ClientUnaryCall; createEnv( request: CreateEnvRequest, metadata: Metadata, options: Partial, callback: (error: ServiceError | null, response: EnvsResponse) => void, ): ClientUnaryCall; updateEnv( request: UpdateEnvRequest, callback: (error: ServiceError | null, response: EnvResponse) => void, ): ClientUnaryCall; updateEnv( request: UpdateEnvRequest, metadata: Metadata, callback: (error: ServiceError | null, response: EnvResponse) => void, ): ClientUnaryCall; updateEnv( request: UpdateEnvRequest, metadata: Metadata, options: Partial, callback: (error: ServiceError | null, response: EnvResponse) => void, ): ClientUnaryCall; deleteEnvs( request: DeleteEnvsRequest, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; deleteEnvs( request: DeleteEnvsRequest, metadata: Metadata, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; deleteEnvs( request: DeleteEnvsRequest, metadata: Metadata, options: Partial, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; moveEnv( request: MoveEnvRequest, callback: (error: ServiceError | null, response: EnvResponse) => void, ): ClientUnaryCall; moveEnv( request: MoveEnvRequest, metadata: Metadata, callback: (error: ServiceError | null, response: EnvResponse) => void, ): ClientUnaryCall; moveEnv( request: MoveEnvRequest, metadata: Metadata, options: Partial, callback: (error: ServiceError | null, response: EnvResponse) => void, ): ClientUnaryCall; disableEnvs( request: DisableEnvsRequest, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; disableEnvs( request: DisableEnvsRequest, metadata: Metadata, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; disableEnvs( request: DisableEnvsRequest, metadata: Metadata, options: Partial, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; enableEnvs( request: EnableEnvsRequest, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; enableEnvs( request: EnableEnvsRequest, metadata: Metadata, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; enableEnvs( request: EnableEnvsRequest, metadata: Metadata, options: Partial, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; updateEnvNames( request: UpdateEnvNamesRequest, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; updateEnvNames( request: UpdateEnvNamesRequest, metadata: Metadata, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; updateEnvNames( request: UpdateEnvNamesRequest, metadata: Metadata, options: Partial, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; getEnvById( request: GetEnvByIdRequest, callback: (error: ServiceError | null, response: EnvResponse) => void, ): ClientUnaryCall; getEnvById( request: GetEnvByIdRequest, metadata: Metadata, callback: (error: ServiceError | null, response: EnvResponse) => void, ): ClientUnaryCall; getEnvById( request: GetEnvByIdRequest, metadata: Metadata, options: Partial, callback: (error: ServiceError | null, response: EnvResponse) => void, ): ClientUnaryCall; systemNotify( request: SystemNotifyRequest, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; systemNotify( request: SystemNotifyRequest, metadata: Metadata, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; systemNotify( request: SystemNotifyRequest, metadata: Metadata, options: Partial, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; getCronDetail( request: CronDetailRequest, callback: (error: ServiceError | null, response: CronDetailResponse) => void, ): ClientUnaryCall; getCronDetail( request: CronDetailRequest, metadata: Metadata, callback: (error: ServiceError | null, response: CronDetailResponse) => void, ): ClientUnaryCall; getCronDetail( request: CronDetailRequest, metadata: Metadata, options: Partial, callback: (error: ServiceError | null, response: CronDetailResponse) => void, ): ClientUnaryCall; createCron( request: CreateCronRequest, callback: (error: ServiceError | null, response: CronResponse) => void, ): ClientUnaryCall; createCron( request: CreateCronRequest, metadata: Metadata, callback: (error: ServiceError | null, response: CronResponse) => void, ): ClientUnaryCall; createCron( request: CreateCronRequest, metadata: Metadata, options: Partial, callback: (error: ServiceError | null, response: CronResponse) => void, ): ClientUnaryCall; updateCron( request: UpdateCronRequest, callback: (error: ServiceError | null, response: CronResponse) => void, ): ClientUnaryCall; updateCron( request: UpdateCronRequest, metadata: Metadata, callback: (error: ServiceError | null, response: CronResponse) => void, ): ClientUnaryCall; updateCron( request: UpdateCronRequest, metadata: Metadata, options: Partial, callback: (error: ServiceError | null, response: CronResponse) => void, ): ClientUnaryCall; deleteCrons( request: DeleteCronsRequest, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; deleteCrons( request: DeleteCronsRequest, metadata: Metadata, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; deleteCrons( request: DeleteCronsRequest, metadata: Metadata, options: Partial, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; getCrons( request: GetCronsRequest, callback: (error: ServiceError | null, response: CronsResponse) => void, ): ClientUnaryCall; getCrons( request: GetCronsRequest, metadata: Metadata, callback: (error: ServiceError | null, response: CronsResponse) => void, ): ClientUnaryCall; getCrons( request: GetCronsRequest, metadata: Metadata, options: Partial, callback: (error: ServiceError | null, response: CronsResponse) => void, ): ClientUnaryCall; getCronById( request: GetCronByIdRequest, callback: (error: ServiceError | null, response: CronResponse) => void, ): ClientUnaryCall; getCronById( request: GetCronByIdRequest, metadata: Metadata, callback: (error: ServiceError | null, response: CronResponse) => void, ): ClientUnaryCall; getCronById( request: GetCronByIdRequest, metadata: Metadata, options: Partial, callback: (error: ServiceError | null, response: CronResponse) => void, ): ClientUnaryCall; enableCrons( request: EnableCronsRequest, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; enableCrons( request: EnableCronsRequest, metadata: Metadata, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; enableCrons( request: EnableCronsRequest, metadata: Metadata, options: Partial, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; disableCrons( request: DisableCronsRequest, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; disableCrons( request: DisableCronsRequest, metadata: Metadata, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; disableCrons( request: DisableCronsRequest, metadata: Metadata, options: Partial, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; runCrons( request: RunCronsRequest, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; runCrons( request: RunCronsRequest, metadata: Metadata, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; runCrons( request: RunCronsRequest, metadata: Metadata, options: Partial, callback: (error: ServiceError | null, response: Response) => void, ): ClientUnaryCall; } export const ApiClient = makeGenericClientConstructor(ApiService, "com.ql.api.Api") as unknown as { new (address: string, credentials: ChannelCredentials, options?: Partial): ApiClient; service: typeof ApiService; serviceName: string; }; type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; export type DeepPartial = T extends Builtin ? T : T extends globalThis.Array ? globalThis.Array> : T extends ReadonlyArray ? ReadonlyArray> : T extends {} ? { [K in keyof T]?: DeepPartial } : Partial; type KeysOfUnion = T extends T ? keyof T : never; export type Exact = P extends Builtin ? P : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; function longToNumber(int64: { toString(): string }): number { const num = globalThis.Number(int64.toString()); if (num > globalThis.Number.MAX_SAFE_INTEGER) { throw new globalThis.Error("Value is larger than Number.MAX_SAFE_INTEGER"); } if (num < globalThis.Number.MIN_SAFE_INTEGER) { throw new globalThis.Error("Value is smaller than Number.MIN_SAFE_INTEGER"); } return num; } function isSet(value: any): boolean { return value !== null && value !== undefined; } export interface MessageFns { encode(message: T, writer?: BinaryWriter): BinaryWriter; decode(input: BinaryReader | Uint8Array, length?: number): T; fromJSON(object: any): T; toJSON(message: T): unknown; create, I>>(base?: I): T; fromPartial, I>>(object: I): T; } ================================================ FILE: back/protos/cron.proto ================================================ syntax = "proto3"; package com.ql.cron; service Cron { rpc addCron(AddCronRequest) returns (AddCronResponse); rpc delCron(DeleteCronRequest) returns (DeleteCronResponse); } message ISchedule { string schedule = 1; } message ICron { string id = 1; string schedule = 2; string command = 3; repeated ISchedule extra_schedules = 4; string name = 5; } message AddCronRequest { repeated ICron crons = 1; } message AddCronResponse {} message DeleteCronRequest { repeated string ids = 1; } message DeleteCronResponse {} ================================================ FILE: back/protos/cron.ts ================================================ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v2.6.1 // protoc v3.21.12 // source: back/protos/cron.proto /* eslint-disable */ import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; import { type CallOptions, ChannelCredentials, Client, type ClientOptions, type ClientUnaryCall, type handleUnaryCall, makeGenericClientConstructor, Metadata, type ServiceError, type UntypedServiceImplementation, } from "@grpc/grpc-js"; export const protobufPackage = "com.ql.cron"; export interface ISchedule { schedule: string; } export interface ICron { id: string; schedule: string; command: string; extra_schedules: ISchedule[]; name: string; } export interface AddCronRequest { crons: ICron[]; } export interface AddCronResponse { } export interface DeleteCronRequest { ids: string[]; } export interface DeleteCronResponse { } function createBaseISchedule(): ISchedule { return { schedule: "" }; } export const ISchedule: MessageFns = { encode(message: ISchedule, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { if (message.schedule !== "") { writer.uint32(10).string(message.schedule); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): ISchedule { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseISchedule(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 10) { break; } message.schedule = reader.string(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): ISchedule { return { schedule: isSet(object.schedule) ? globalThis.String(object.schedule) : "" }; }, toJSON(message: ISchedule): unknown { const obj: any = {}; if (message.schedule !== "") { obj.schedule = message.schedule; } return obj; }, create, I>>(base?: I): ISchedule { return ISchedule.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): ISchedule { const message = createBaseISchedule(); message.schedule = object.schedule ?? ""; return message; }, }; function createBaseICron(): ICron { return { id: "", schedule: "", command: "", extra_schedules: [], name: "" }; } export const ICron: MessageFns = { encode(message: ICron, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { if (message.id !== "") { writer.uint32(10).string(message.id); } if (message.schedule !== "") { writer.uint32(18).string(message.schedule); } if (message.command !== "") { writer.uint32(26).string(message.command); } for (const v of message.extra_schedules) { ISchedule.encode(v!, writer.uint32(34).fork()).join(); } if (message.name !== "") { writer.uint32(42).string(message.name); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): ICron { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseICron(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 10) { break; } message.id = reader.string(); continue; } case 2: { if (tag !== 18) { break; } message.schedule = reader.string(); continue; } case 3: { if (tag !== 26) { break; } message.command = reader.string(); continue; } case 4: { if (tag !== 34) { break; } message.extra_schedules.push(ISchedule.decode(reader, reader.uint32())); continue; } case 5: { if (tag !== 42) { break; } message.name = reader.string(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): ICron { return { id: isSet(object.id) ? globalThis.String(object.id) : "", schedule: isSet(object.schedule) ? globalThis.String(object.schedule) : "", command: isSet(object.command) ? globalThis.String(object.command) : "", extra_schedules: globalThis.Array.isArray(object?.extra_schedules) ? object.extra_schedules.map((e: any) => ISchedule.fromJSON(e)) : [], name: isSet(object.name) ? globalThis.String(object.name) : "", }; }, toJSON(message: ICron): unknown { const obj: any = {}; if (message.id !== "") { obj.id = message.id; } if (message.schedule !== "") { obj.schedule = message.schedule; } if (message.command !== "") { obj.command = message.command; } if (message.extra_schedules?.length) { obj.extra_schedules = message.extra_schedules.map((e) => ISchedule.toJSON(e)); } if (message.name !== "") { obj.name = message.name; } return obj; }, create, I>>(base?: I): ICron { return ICron.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): ICron { const message = createBaseICron(); message.id = object.id ?? ""; message.schedule = object.schedule ?? ""; message.command = object.command ?? ""; message.extra_schedules = object.extra_schedules?.map((e) => ISchedule.fromPartial(e)) || []; message.name = object.name ?? ""; return message; }, }; function createBaseAddCronRequest(): AddCronRequest { return { crons: [] }; } export const AddCronRequest: MessageFns = { encode(message: AddCronRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { for (const v of message.crons) { ICron.encode(v!, writer.uint32(10).fork()).join(); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): AddCronRequest { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseAddCronRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 10) { break; } message.crons.push(ICron.decode(reader, reader.uint32())); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): AddCronRequest { return { crons: globalThis.Array.isArray(object?.crons) ? object.crons.map((e: any) => ICron.fromJSON(e)) : [] }; }, toJSON(message: AddCronRequest): unknown { const obj: any = {}; if (message.crons?.length) { obj.crons = message.crons.map((e) => ICron.toJSON(e)); } return obj; }, create, I>>(base?: I): AddCronRequest { return AddCronRequest.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): AddCronRequest { const message = createBaseAddCronRequest(); message.crons = object.crons?.map((e) => ICron.fromPartial(e)) || []; return message; }, }; function createBaseAddCronResponse(): AddCronResponse { return {}; } export const AddCronResponse: MessageFns = { encode(_: AddCronResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): AddCronResponse { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseAddCronResponse(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(_: any): AddCronResponse { return {}; }, toJSON(_: AddCronResponse): unknown { const obj: any = {}; return obj; }, create, I>>(base?: I): AddCronResponse { return AddCronResponse.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(_: I): AddCronResponse { const message = createBaseAddCronResponse(); return message; }, }; function createBaseDeleteCronRequest(): DeleteCronRequest { return { ids: [] }; } export const DeleteCronRequest: MessageFns = { encode(message: DeleteCronRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { for (const v of message.ids) { writer.uint32(10).string(v!); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): DeleteCronRequest { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseDeleteCronRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 10) { break; } message.ids.push(reader.string()); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): DeleteCronRequest { return { ids: globalThis.Array.isArray(object?.ids) ? object.ids.map((e: any) => globalThis.String(e)) : [] }; }, toJSON(message: DeleteCronRequest): unknown { const obj: any = {}; if (message.ids?.length) { obj.ids = message.ids; } return obj; }, create, I>>(base?: I): DeleteCronRequest { return DeleteCronRequest.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): DeleteCronRequest { const message = createBaseDeleteCronRequest(); message.ids = object.ids?.map((e) => e) || []; return message; }, }; function createBaseDeleteCronResponse(): DeleteCronResponse { return {}; } export const DeleteCronResponse: MessageFns = { encode(_: DeleteCronResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): DeleteCronResponse { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseDeleteCronResponse(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(_: any): DeleteCronResponse { return {}; }, toJSON(_: DeleteCronResponse): unknown { const obj: any = {}; return obj; }, create, I>>(base?: I): DeleteCronResponse { return DeleteCronResponse.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(_: I): DeleteCronResponse { const message = createBaseDeleteCronResponse(); return message; }, }; export type CronService = typeof CronService; export const CronService = { addCron: { path: "/com.ql.cron.Cron/addCron", requestStream: false, responseStream: false, requestSerialize: (value: AddCronRequest) => Buffer.from(AddCronRequest.encode(value).finish()), requestDeserialize: (value: Buffer) => AddCronRequest.decode(value), responseSerialize: (value: AddCronResponse) => Buffer.from(AddCronResponse.encode(value).finish()), responseDeserialize: (value: Buffer) => AddCronResponse.decode(value), }, delCron: { path: "/com.ql.cron.Cron/delCron", requestStream: false, responseStream: false, requestSerialize: (value: DeleteCronRequest) => Buffer.from(DeleteCronRequest.encode(value).finish()), requestDeserialize: (value: Buffer) => DeleteCronRequest.decode(value), responseSerialize: (value: DeleteCronResponse) => Buffer.from(DeleteCronResponse.encode(value).finish()), responseDeserialize: (value: Buffer) => DeleteCronResponse.decode(value), }, } as const; export interface CronServer extends UntypedServiceImplementation { addCron: handleUnaryCall; delCron: handleUnaryCall; } export interface CronClient extends Client { addCron( request: AddCronRequest, callback: (error: ServiceError | null, response: AddCronResponse) => void, ): ClientUnaryCall; addCron( request: AddCronRequest, metadata: Metadata, callback: (error: ServiceError | null, response: AddCronResponse) => void, ): ClientUnaryCall; addCron( request: AddCronRequest, metadata: Metadata, options: Partial, callback: (error: ServiceError | null, response: AddCronResponse) => void, ): ClientUnaryCall; delCron( request: DeleteCronRequest, callback: (error: ServiceError | null, response: DeleteCronResponse) => void, ): ClientUnaryCall; delCron( request: DeleteCronRequest, metadata: Metadata, callback: (error: ServiceError | null, response: DeleteCronResponse) => void, ): ClientUnaryCall; delCron( request: DeleteCronRequest, metadata: Metadata, options: Partial, callback: (error: ServiceError | null, response: DeleteCronResponse) => void, ): ClientUnaryCall; } export const CronClient = makeGenericClientConstructor(CronService, "com.ql.cron.Cron") as unknown as { new (address: string, credentials: ChannelCredentials, options?: Partial): CronClient; service: typeof CronService; serviceName: string; }; type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; export type DeepPartial = T extends Builtin ? T : T extends globalThis.Array ? globalThis.Array> : T extends ReadonlyArray ? ReadonlyArray> : T extends {} ? { [K in keyof T]?: DeepPartial } : Partial; type KeysOfUnion = T extends T ? keyof T : never; export type Exact = P extends Builtin ? P : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; function isSet(value: any): boolean { return value !== null && value !== undefined; } export interface MessageFns { encode(message: T, writer?: BinaryWriter): BinaryWriter; decode(input: BinaryReader | Uint8Array, length?: number): T; fromJSON(object: any): T; toJSON(message: T): unknown; create, I>>(base?: I): T; fromPartial, I>>(object: I): T; } ================================================ FILE: back/protos/health.proto ================================================ syntax = "proto3"; package com.ql.health; message HealthCheckRequest { string service = 1; } message HealthCheckResponse { enum ServingStatus { UNKNOWN = 0; SERVING = 1; NOT_SERVING = 2; SERVICE_UNKNOWN = 3; } ServingStatus status = 1; } service Health { rpc Check(HealthCheckRequest) returns (HealthCheckResponse); rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse); } ================================================ FILE: back/protos/health.ts ================================================ // Code generated by protoc-gen-ts_proto. DO NOT EDIT. // versions: // protoc-gen-ts_proto v2.6.1 // protoc v3.21.12 // source: back/protos/health.proto /* eslint-disable */ import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; import { type CallOptions, ChannelCredentials, Client, type ClientOptions, type ClientReadableStream, type ClientUnaryCall, type handleServerStreamingCall, type handleUnaryCall, makeGenericClientConstructor, Metadata, type ServiceError, type UntypedServiceImplementation, } from "@grpc/grpc-js"; export const protobufPackage = "com.ql.health"; export interface HealthCheckRequest { service: string; } export interface HealthCheckResponse { status: HealthCheckResponse_ServingStatus; } export enum HealthCheckResponse_ServingStatus { UNKNOWN = 0, SERVING = 1, NOT_SERVING = 2, SERVICE_UNKNOWN = 3, UNRECOGNIZED = -1, } export function healthCheckResponse_ServingStatusFromJSON(object: any): HealthCheckResponse_ServingStatus { switch (object) { case 0: case "UNKNOWN": return HealthCheckResponse_ServingStatus.UNKNOWN; case 1: case "SERVING": return HealthCheckResponse_ServingStatus.SERVING; case 2: case "NOT_SERVING": return HealthCheckResponse_ServingStatus.NOT_SERVING; case 3: case "SERVICE_UNKNOWN": return HealthCheckResponse_ServingStatus.SERVICE_UNKNOWN; case -1: case "UNRECOGNIZED": default: return HealthCheckResponse_ServingStatus.UNRECOGNIZED; } } export function healthCheckResponse_ServingStatusToJSON(object: HealthCheckResponse_ServingStatus): string { switch (object) { case HealthCheckResponse_ServingStatus.UNKNOWN: return "UNKNOWN"; case HealthCheckResponse_ServingStatus.SERVING: return "SERVING"; case HealthCheckResponse_ServingStatus.NOT_SERVING: return "NOT_SERVING"; case HealthCheckResponse_ServingStatus.SERVICE_UNKNOWN: return "SERVICE_UNKNOWN"; case HealthCheckResponse_ServingStatus.UNRECOGNIZED: default: return "UNRECOGNIZED"; } } function createBaseHealthCheckRequest(): HealthCheckRequest { return { service: "" }; } export const HealthCheckRequest: MessageFns = { encode(message: HealthCheckRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { if (message.service !== "") { writer.uint32(10).string(message.service); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): HealthCheckRequest { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseHealthCheckRequest(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 10) { break; } message.service = reader.string(); continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): HealthCheckRequest { return { service: isSet(object.service) ? globalThis.String(object.service) : "" }; }, toJSON(message: HealthCheckRequest): unknown { const obj: any = {}; if (message.service !== "") { obj.service = message.service; } return obj; }, create, I>>(base?: I): HealthCheckRequest { return HealthCheckRequest.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): HealthCheckRequest { const message = createBaseHealthCheckRequest(); message.service = object.service ?? ""; return message; }, }; function createBaseHealthCheckResponse(): HealthCheckResponse { return { status: 0 }; } export const HealthCheckResponse: MessageFns = { encode(message: HealthCheckResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { if (message.status !== 0) { writer.uint32(8).int32(message.status); } return writer; }, decode(input: BinaryReader | Uint8Array, length?: number): HealthCheckResponse { const reader = input instanceof BinaryReader ? input : new BinaryReader(input); let end = length === undefined ? reader.len : reader.pos + length; const message = createBaseHealthCheckResponse(); while (reader.pos < end) { const tag = reader.uint32(); switch (tag >>> 3) { case 1: { if (tag !== 8) { break; } message.status = reader.int32() as any; continue; } } if ((tag & 7) === 4 || tag === 0) { break; } reader.skip(tag & 7); } return message; }, fromJSON(object: any): HealthCheckResponse { return { status: isSet(object.status) ? healthCheckResponse_ServingStatusFromJSON(object.status) : 0 }; }, toJSON(message: HealthCheckResponse): unknown { const obj: any = {}; if (message.status !== 0) { obj.status = healthCheckResponse_ServingStatusToJSON(message.status); } return obj; }, create, I>>(base?: I): HealthCheckResponse { return HealthCheckResponse.fromPartial(base ?? ({} as any)); }, fromPartial, I>>(object: I): HealthCheckResponse { const message = createBaseHealthCheckResponse(); message.status = object.status ?? 0; return message; }, }; export type HealthService = typeof HealthService; export const HealthService = { check: { path: "/com.ql.health.Health/Check", requestStream: false, responseStream: false, requestSerialize: (value: HealthCheckRequest) => Buffer.from(HealthCheckRequest.encode(value).finish()), requestDeserialize: (value: Buffer) => HealthCheckRequest.decode(value), responseSerialize: (value: HealthCheckResponse) => Buffer.from(HealthCheckResponse.encode(value).finish()), responseDeserialize: (value: Buffer) => HealthCheckResponse.decode(value), }, watch: { path: "/com.ql.health.Health/Watch", requestStream: false, responseStream: true, requestSerialize: (value: HealthCheckRequest) => Buffer.from(HealthCheckRequest.encode(value).finish()), requestDeserialize: (value: Buffer) => HealthCheckRequest.decode(value), responseSerialize: (value: HealthCheckResponse) => Buffer.from(HealthCheckResponse.encode(value).finish()), responseDeserialize: (value: Buffer) => HealthCheckResponse.decode(value), }, } as const; export interface HealthServer extends UntypedServiceImplementation { check: handleUnaryCall; watch: handleServerStreamingCall; } export interface HealthClient extends Client { check( request: HealthCheckRequest, callback: (error: ServiceError | null, response: HealthCheckResponse) => void, ): ClientUnaryCall; check( request: HealthCheckRequest, metadata: Metadata, callback: (error: ServiceError | null, response: HealthCheckResponse) => void, ): ClientUnaryCall; check( request: HealthCheckRequest, metadata: Metadata, options: Partial, callback: (error: ServiceError | null, response: HealthCheckResponse) => void, ): ClientUnaryCall; watch(request: HealthCheckRequest, options?: Partial): ClientReadableStream; watch( request: HealthCheckRequest, metadata?: Metadata, options?: Partial, ): ClientReadableStream; } export const HealthClient = makeGenericClientConstructor(HealthService, "com.ql.health.Health") as unknown as { new (address: string, credentials: ChannelCredentials, options?: Partial): HealthClient; service: typeof HealthService; serviceName: string; }; type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; export type DeepPartial = T extends Builtin ? T : T extends globalThis.Array ? globalThis.Array> : T extends ReadonlyArray ? ReadonlyArray> : T extends {} ? { [K in keyof T]?: DeepPartial } : Partial; type KeysOfUnion = T extends T ? keyof T : never; export type Exact = P extends Builtin ? P : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; function isSet(value: any): boolean { return value !== null && value !== undefined; } export interface MessageFns { encode(message: T, writer?: BinaryWriter): BinaryWriter; decode(input: BinaryReader | Uint8Array, length?: number): T; fromJSON(object: any): T; toJSON(message: T): unknown; create, I>>(base?: I): T; fromPartial, I>>(object: I): T; } ================================================ FILE: back/schedule/addCron.ts ================================================ import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js'; import { AddCronRequest, AddCronResponse } from '../protos/cron'; import nodeSchedule from 'node-schedule'; import { scheduleStacks } from './data'; import { runCron } from '../shared/runCron'; import Logger from '../loaders/logger'; const addCron = ( call: ServerUnaryCall, callback: sendUnaryData, ) => { for (const item of call.request.crons) { const { id, schedule, command, extra_schedules, name } = item; if (scheduleStacks.has(id)) { scheduleStacks.get(id)?.forEach((x) => x.cancel()); } Logger.info( '[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s', id, name, schedule, command, ); if (extra_schedules?.length) { extra_schedules.forEach((x) => { Logger.info( '[schedule][创建定时任务] 任务ID: %s, 名称: %s, cron: %s, 执行命令: %s', id, name, x.schedule, command, ); }); } scheduleStacks.set(id, [ nodeSchedule.scheduleJob(id, schedule, async () => { Logger.info(`[schedule][准备运行任务] 命令: ${command}`); runCron(command, item); }), ...(extra_schedules?.length ? extra_schedules.map((x) => nodeSchedule.scheduleJob(id, x.schedule, async () => { Logger.info(`[schedule][准备运行任务] 命令: ${command}`); runCron(command, item); }), ) : []), ]); } callback(null, null); }; export { addCron }; ================================================ FILE: back/schedule/api.ts ================================================ import 'reflect-metadata'; import { Container } from 'typedi'; import EnvService from '../services/env'; import { sendUnaryData, ServerUnaryCall } from '@grpc/grpc-js'; import { CreateEnvRequest, CronItem, DeleteEnvsRequest, DisableEnvsRequest, EnableEnvsRequest, EnvItem, EnvResponse, EnvsResponse, GetEnvByIdRequest, GetEnvsRequest, MoveEnvRequest, Response, SystemNotifyRequest, UpdateEnvNamesRequest, UpdateEnvRequest, } from '../protos/api'; import LoggerInstance from '../loaders/logger'; import pick from 'lodash/pick'; import SystemService from '../services/system'; import CronService from '../services/cron'; import { CronDetailRequest, CronDetailResponse, CreateCronRequest, UpdateCronRequest, DeleteCronsRequest, CronResponse, GetCronsRequest, CronsResponse, GetCronByIdRequest, EnableCronsRequest, DisableCronsRequest, RunCronsRequest, } from '../protos/api'; import { NotificationInfo } from '../data/notify'; Container.set('logger', LoggerInstance); export const getEnvs = async ( call: ServerUnaryCall, callback: sendUnaryData, ) => { try { const envService = Container.get(EnvService); const data = await envService.envs(call.request.searchValue); callback(null, { code: 200, data: data.map((x) => ({ ...x, remarks: x.remarks || '' })), }); } catch (e: any) { callback(null, { code: 500, data: [], message: e.message, }); } }; export const createEnv = async ( call: ServerUnaryCall, callback: sendUnaryData, ) => { try { const envService = Container.get(EnvService); const data = await envService.create(call.request.envs); callback(null, { code: 200, data }); } catch (e: any) { callback(e); } }; export const updateEnv = async ( call: ServerUnaryCall, callback: sendUnaryData, ) => { try { if (!call.request.env?.id) { return callback(null, { code: 400, data: undefined, message: 'id parameter is required', }); } const envService = Container.get(EnvService); const data = await envService.update( pick(call.request.env, ['id', 'name', 'value', 'remarks']) as EnvItem, ); callback(null, { code: 200, data }); } catch (e: any) { callback(e); } }; export const deleteEnvs = async ( call: ServerUnaryCall, callback: sendUnaryData, ) => { try { if (!call.request.ids || call.request.ids.length === 0) { return callback(null, { code: 400, message: 'ids parameter is required', }); } const envService = Container.get(EnvService); await envService.remove(call.request.ids); callback(null, { code: 200 }); } catch (e: any) { callback(e); } }; export const moveEnv = async ( call: ServerUnaryCall, callback: sendUnaryData, ) => { try { if (!call.request.id) { return callback(null, { code: 400, data: undefined, message: 'id parameter is required', }); } const envService = Container.get(EnvService); const data = await envService.move(call.request.id, { fromIndex: call.request.fromIndex, toIndex: call.request.toIndex, }); callback(null, { code: 200, data }); } catch (e: any) { callback(e); } }; export const disableEnvs = async ( call: ServerUnaryCall, callback: sendUnaryData, ) => { try { if (!call.request.ids || call.request.ids.length === 0) { return callback(null, { code: 400, message: 'ids parameter is required', }); } const envService = Container.get(EnvService); await envService.disabled(call.request.ids); callback(null, { code: 200 }); } catch (e: any) { callback(e); } }; export const enableEnvs = async ( call: ServerUnaryCall, callback: sendUnaryData, ) => { try { if (!call.request.ids || call.request.ids.length === 0) { return callback(null, { code: 400, message: 'ids parameter is required', }); } const envService = Container.get(EnvService); await envService.enabled(call.request.ids); callback(null, { code: 200 }); } catch (e: any) { callback(e); } }; export const updateEnvNames = async ( call: ServerUnaryCall, callback: sendUnaryData, ) => { try { if (!call.request.ids || call.request.ids.length === 0) { return callback(null, { code: 400, message: 'ids parameter is required', }); } const envService = Container.get(EnvService); await envService.updateNames({ ids: call.request.ids, name: call.request.name, }); callback(null, { code: 200 }); } catch (e: any) { callback(e); } }; export const getEnvById = async ( call: ServerUnaryCall, callback: sendUnaryData, ) => { try { if (!call.request.id) { return callback(null, { code: 400, data: undefined, message: 'id parameter is required', }); } const envService = Container.get(EnvService); const data = await envService.getDb({ id: call.request.id }); callback(null, { code: 200, data: { ...data, remarks: data.remarks || '' }, }); } catch (e: any) { callback(e); } }; export const systemNotify = async ( call: ServerUnaryCall, callback: sendUnaryData, ) => { try { const systemService = Container.get(SystemService); const data = await systemService.notify({ title: call.request.title, content: call.request.content, notificationInfo: call.request.notificationInfo as unknown as NotificationInfo, }); callback(null, data); } catch (e: any) { callback(e); } }; const normalizeCronData = (data: CronItem | null): CronItem | undefined => { if (!data) return undefined; return { ...data, sub_id: data.sub_id ?? undefined, extra_schedules: data.extra_schedules ?? undefined, pid: data.pid ?? undefined, task_before: data.task_before ?? undefined, task_after: data.task_after ?? undefined, }; }; export const getCronDetail = async ( call: ServerUnaryCall, callback: sendUnaryData, ) => { try { if (!call.request.log_path) { return callback(null, { code: 400, data: undefined, message: 'log_path is required', }); } const cronService = Container.get(CronService); const data = (await cronService.find({ log_path: call.request.log_path, })) as CronItem; callback(null, { code: 200, data: normalizeCronData(data) }); } catch (e: any) { callback(e); } }; export const createCron = async ( call: ServerUnaryCall, callback: sendUnaryData, ) => { try { const cronService = Container.get(CronService); const data = (await cronService.create(call.request)) as CronItem; callback(null, { code: 200, data: normalizeCronData(data) }); } catch (e: any) { callback(e); } }; export const updateCron = async ( call: ServerUnaryCall, callback: sendUnaryData, ) => { try { const cronService = Container.get(CronService); const { id, ...fields } = call.request; const updateRequest = { id, ...Object.entries(fields).reduce((acc: any, [key, value]) => { if (value !== undefined) { acc[key] = value; } return acc; }, {}), } as UpdateCronRequest; const data = (await cronService.update(updateRequest)) as CronItem; callback(null, { code: 200, data: normalizeCronData(data) }); } catch (e: any) { callback(e); } }; export const deleteCrons = async ( call: ServerUnaryCall, callback: sendUnaryData, ) => { try { const cronService = Container.get(CronService); await cronService.remove(call.request.ids); callback(null, { code: 200 }); } catch (e: any) { callback(e); } }; export const getCrons = async ( call: ServerUnaryCall, callback: sendUnaryData, ) => { try { const cronService = Container.get(CronService); const result = await cronService.crontabs({ searchValue: call.request.searchValue || '', page: '0', size: '0', sorter: '', filters: '', queryString: '', }); const data = result.data.map((x) => normalizeCronData(x as CronItem)); callback(null, { code: 200, data: data.filter((x): x is CronItem => x !== undefined), }); } catch (e: any) { callback(null, { code: 500, data: [], message: e.message, }); } }; export const getCronById = async ( call: ServerUnaryCall, callback: sendUnaryData, ) => { try { if (!call.request.id) { return callback(null, { code: 400, data: undefined, message: 'id parameter is required', }); } const cronService = Container.get(CronService); const data = (await cronService.getDb({ id: call.request.id })) as CronItem; callback(null, { code: 200, data: normalizeCronData(data) }); } catch (e: any) { callback(null, { code: 404, data: undefined, message: e.message, }); } }; export const enableCrons = async ( call: ServerUnaryCall, callback: sendUnaryData, ) => { try { if (!call.request.ids || call.request.ids.length === 0) { return callback(null, { code: 400, message: 'ids parameter is required', }); } const cronService = Container.get(CronService); await cronService.enabled(call.request.ids); callback(null, { code: 200 }); } catch (e: any) { callback(e); } }; export const disableCrons = async ( call: ServerUnaryCall, callback: sendUnaryData, ) => { try { if (!call.request.ids || call.request.ids.length === 0) { return callback(null, { code: 400, message: 'ids parameter is required', }); } const cronService = Container.get(CronService); await cronService.disabled(call.request.ids); callback(null, { code: 200 }); } catch (e: any) { callback(e); } }; export const runCrons = async ( call: ServerUnaryCall, callback: sendUnaryData, ) => { try { if (!call.request.ids || call.request.ids.length === 0) { return callback(null, { code: 400, message: 'ids parameter is required', }); } const cronService = Container.get(CronService); await cronService.run(call.request.ids); callback(null, { code: 200 }); } catch (e: any) { callback(e); } }; ================================================ FILE: back/schedule/client.ts ================================================ import { credentials } from '@grpc/grpc-js'; import { AddCronRequest, AddCronResponse, CronClient, DeleteCronRequest, DeleteCronResponse, } from '../protos/cron'; import config from '../config'; class Client { private client = new CronClient( `0.0.0.0:${config.grpcPort}`, credentials.createInsecure(), { 'grpc.enable_http_proxy': 0 }, ); addCron(request: AddCronRequest['crons']): Promise { return new Promise((resolve, reject) => { this.client.addCron({ crons: request }, (err, res) => { if (err) { reject(err); } resolve(res); }); }); } delCron(request: DeleteCronRequest['ids']): Promise { return new Promise((resolve, reject) => { this.client.delCron({ ids: request }, (err, res) => { if (err) { reject(err); } resolve(res); }); }); } } export default new Client(); ================================================ FILE: back/schedule/data.ts ================================================ import nodeSchedule from 'node-schedule'; import { ToadScheduler } from 'toad-scheduler'; export const scheduleStacks = new Map(); export const intervalSchedule = new ToadScheduler(); ================================================ FILE: back/schedule/delCron.ts ================================================ import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js'; import { DeleteCronRequest, DeleteCronResponse } from '../protos/cron'; import { scheduleStacks } from './data'; import Logger from '../loaders/logger'; const delCron = ( call: ServerUnaryCall, callback: sendUnaryData, ) => { for (const id of call.request.ids) { if (scheduleStacks.has(id)) { Logger.info( '[schedule][取消定时任务] 任务ID: %s', id, ); scheduleStacks.get(id)?.forEach(x => x.cancel()); scheduleStacks.delete(id); } } callback(null, null); }; export { delCron }; ================================================ FILE: back/schedule/health.ts ================================================ import { ServerUnaryCall, sendUnaryData } from '@grpc/grpc-js'; import { HealthCheckRequest, HealthCheckResponse } from '../protos/health'; import config from '../config'; import { promiseExec } from '../config/util'; const check = async ( call: ServerUnaryCall, callback: sendUnaryData, ) => { switch (call.request.service) { case 'cron': const res = await promiseExec( `curl -s --noproxy '*' http://0.0.0.0:${config.port}/api/system`, ); if (res.includes('200')) { return callback(null, { status: 1 }); } const qinglongErrLog = await promiseExec( `tail -n 300 ~/.pm2/logs/qinglong-error.log`, ); return callback( new Error(`${qinglongErrLog || ''}\n${res}`.trim()), ); default: return callback(null, { status: 1 }); } }; export { check }; ================================================ FILE: back/services/config.ts ================================================ import { Service, Inject } from 'typedi'; import path, { join } from 'path'; import config from '../config'; import { getFileContentByName } from '../config/util'; import { Response } from 'express'; import { request } from 'undici'; @Service() export default class ConfigService { constructor() {} public async getFile(filePath: string, res: Response) { let content = ''; const avaliablePath = [config.rootPath, config.configPath].map((x) => path.resolve(x, filePath), ); if ( config.blackFileList.includes(filePath) || avaliablePath.every( (x) => !x.startsWith(config.scriptPath) && !x.startsWith(config.configPath), ) || !filePath ) { return res.send({ code: 403, message: '文件无法访问' }); } if (filePath.startsWith('sample/')) { const res = await request( `https://gitlab.com/whyour/qinglong/-/raw/master/${filePath}`, ); content = await res.body.text(); } else if (filePath.startsWith('data/scripts/')) { content = await getFileContentByName(join(config.rootPath, filePath)); } else { content = await getFileContentByName(join(config.configPath, filePath)); } res.send({ code: 200, data: content }); } } ================================================ FILE: back/services/cron.ts ================================================ import { Service, Inject } from 'typedi'; import winston from 'winston'; import config from '../config'; import { Crontab, CrontabModel, CrontabStatus } from '../data/cron'; import { exec, execSync } from 'child_process'; import fs from 'fs/promises'; import CronExpressionParser from 'cron-parser'; import { getFileContentByName, fileExist, killTask, killAllTasks, getUniqPath, safeJSONParse, isDemoEnv, } from '../config/util'; import { Op, where, col as colFn, FindOptions, fn, Order } from 'sequelize'; import path from 'path'; import { TASK_PREFIX, QL_PREFIX } from '../config/const'; import cronClient from '../schedule/client'; import taskLimit from '../shared/pLimit'; import { spawn } from 'cross-spawn'; import dayjs from 'dayjs'; import pickBy from 'lodash/pickBy'; import omit from 'lodash/omit'; import { writeFileWithLock } from '../shared/utils'; import { ScheduleType } from '../interface/schedule'; import { logStreamManager } from '../shared/logStreamManager'; @Service() export default class CronService { constructor(@Inject('logger') private logger: winston.Logger) { } private isNodeCron(cron: Crontab) { const { schedule, extra_schedules } = cron; if (Number(schedule?.split(/ +/).length) > 5 || extra_schedules?.length) { return true; } return false; } private isOnceSchedule(schedule?: string) { return schedule?.startsWith(ScheduleType.ONCE); } private isBootSchedule(schedule?: string) { return schedule?.startsWith(ScheduleType.BOOT); } private isSpecialSchedule(schedule?: string) { return this.isOnceSchedule(schedule) || this.isBootSchedule(schedule); } private async getLogName(cron: Crontab) { const { log_name, command, id } = cron; if (log_name === '/dev/null') { return log_name; } let uniqPath = await getUniqPath(command, `${id}`); if (log_name) { const normalizedLogName = log_name.startsWith('/') ? log_name : path.join(config.logPath, log_name); if (normalizedLogName.startsWith(config.logPath)) { uniqPath = log_name; } } const logDirPath = path.resolve(config.logPath, `${uniqPath}`); await fs.mkdir(logDirPath, { recursive: true }); return uniqPath; } public async create(payload: Crontab): Promise { const tab = new Crontab(payload); tab.saved = false; tab.log_name = await this.getLogName(tab); const doc = await this.insert(tab); if (isDemoEnv()) { return doc; } if (this.isNodeCron(doc) && !this.isSpecialSchedule(doc.schedule)) { await cronClient.addCron([ { name: doc.name || '', id: String(doc.id), schedule: doc.schedule!, command: this.makeCommand(doc), extra_schedules: doc.extra_schedules || [], }, ]); } await this.setCrontab(); return doc; } public async insert(payload: Crontab): Promise { return await CrontabModel.create(payload, { returning: true }); } public async update(payload: Partial): Promise { const doc = await this.getDb({ id: payload.id }); const tab = new Crontab({ ...doc, ...payload }); tab.saved = false; tab.log_name = await this.getLogName(tab); const newDoc = await this.updateDb(tab); if (doc.isDisabled === 1 || isDemoEnv()) { return newDoc; } if (this.isNodeCron(doc)) { await cronClient.delCron([String(doc.id)]); } if (this.isNodeCron(newDoc) && !this.isSpecialSchedule(newDoc.schedule)) { await cronClient.addCron([ { name: doc.name || '', id: String(newDoc.id), schedule: newDoc.schedule!, command: this.makeCommand(newDoc), extra_schedules: newDoc.extra_schedules || [], }, ]); } await this.setCrontab(); return newDoc; } public async updateDb(payload: Crontab): Promise { await CrontabModel.update(payload, { where: { id: payload.id } }); return await this.getDb({ id: payload.id }); } public async status({ ids, status, pid, log_path, last_running_time = 0, last_execution_time = 0, }: { ids: number[]; status: CrontabStatus; pid: number; log_path: string; last_running_time: number; last_execution_time: number; }) { let options: any = { status, pid, log_path, last_execution_time, }; if (last_running_time > 0) { options.last_running_time = last_running_time; } for (const id of ids) { let cron; try { cron = await this.getDb({ id }); } catch (err) { } if (!cron) { continue; } if (status === CrontabStatus.idle && log_path !== cron.log_path) { options = omit(options, ['status', 'log_path', 'pid']); } await CrontabModel.update( { ...pickBy(options, (v) => v === 0 || !!v) }, { where: { id } }, ); } } public async remove(ids: number[]) { await CrontabModel.destroy({ where: { id: ids } }); await cronClient.delCron(ids.map(String)); await this.setCrontab(); } public async pin(ids: number[]) { await CrontabModel.update({ isPinned: 1 }, { where: { id: ids } }); } public async unPin(ids: number[]) { await CrontabModel.update({ isPinned: 0 }, { where: { id: ids } }); } public async addLabels(ids: string[], labels: string[]) { const docs = await CrontabModel.findAll({ where: { id: ids } }); for (const doc of docs) { await CrontabModel.update( { labels: Array.from(new Set((doc.labels || []).concat(labels))), }, { where: { id: doc.id } }, ); } } public async removeLabels(ids: string[], labels: string[]) { const docs = await CrontabModel.findAll({ where: { id: ids } }); for (const doc of docs) { await CrontabModel.update( { labels: (doc.labels || []).filter((label) => !labels.includes(label)), }, { where: { id: doc.id } }, ); } } private formatViewQuery(query: any, viewQuery: any) { if (viewQuery.filters && viewQuery.filters.length > 0) { const primaryOperate = viewQuery.filterRelation === 'or' ? Op.or : Op.and; if (!query[primaryOperate]) { query[primaryOperate] = []; } for (const col of viewQuery.filters) { const { property, value, operation } = col; let q: any = {}; let operate2: any = null; let operate: any = null; switch (operation) { case 'Reg': operate = Op.like; operate2 = Op.or; break; case 'NotReg': operate = Op.notLike; operate2 = Op.and; break; case 'In': if ( property === 'status' && !value.includes(CrontabStatus.disabled) ) { q[Op.and] = [ { [property]: Array.isArray(value) ? value : [value] }, { isDisabled: 0 }, ]; } else { q[Op.or] = [ { [property]: Array.isArray(value) ? value : [value], }, property === 'status' && value.includes(CrontabStatus.disabled) ? { isDisabled: 1 } : {}, ]; } break; case 'Nin': q[Op.and] = [ { [Op.or]: [ { [property]: { [Op.notIn]: Array.isArray(value) ? value : [value], }, }, { [property]: { [Op.is]: null }, }, ], }, property === 'status' && value.includes(2) ? { isDisabled: { [Op.ne]: 1 } } : {}, ]; break; default: break; } if (operate && operate2) { q[property] = { [Op.or]: [ { [operate2]: [ { [operate]: `%${value}%` }, { [operate]: `%${encodeURI(value)}%` }, ], }, { [operate2]: [ where(colFn(property), operate, `%${value}%`), where(colFn(property), operate, `%${encodeURI(value)}%`), ], }, ], }; } query[primaryOperate].push(q); } } } private formatSearchText(query: any, searchText: string | undefined) { if (searchText) { if (!query[Op.and]) { query[Op.and] = []; } let q: any = {}; const textArray = searchText.split(':'); switch (textArray[0]) { case 'name': case 'command': case 'schedule': case 'label': const column = textArray[0] === 'label' ? 'labels' : textArray[0]; q[column] = { [Op.or]: [ { [Op.like]: `%${textArray[1]}%` }, { [Op.like]: `%${encodeURI(textArray[1])}%` }, ], }; break; default: const reg = { [Op.or]: [ { [Op.like]: `%${searchText}%` }, { [Op.like]: `%${encodeURI(searchText)}%` }, ], }; q[Op.or] = [ { name: reg, }, { command: reg, }, { schedule: reg, }, { labels: reg, }, ]; break; } query[Op.and].push(q); } } private formatFilterQuery(query: any, filterQuery: any) { if (filterQuery) { if (!query[Op.and]) { query[Op.and] = []; } const filterKeys: any = Object.keys(filterQuery); for (const key of filterKeys) { let q: any = {}; if (!filterQuery[key]) continue; if (key === 'status') { if (filterQuery[key].includes(CrontabStatus.disabled)) { q = { [Op.or]: [{ [key]: filterQuery[key] }, { isDisabled: 1 }] }; } else { q = { [Op.and]: [{ [key]: filterQuery[key] }, { isDisabled: 0 }] }; } } else { q[key] = filterQuery[key]; } query[Op.and].push(q); } } } private formatViewSort(order: string[][], viewQuery: any) { if (viewQuery.sorts && viewQuery.sorts.length > 0) { for (const { property, type } of viewQuery.sorts) { order.unshift([property, type]); } } } public async find({ log_path, }: { log_path: string; }): Promise { try { const result = await CrontabModel.findOne({ where: { log_path } }); return result?.get({ plain: true }); } catch (error) { throw error; } } public async crontabs(params?: { searchValue: string; page: string; size: string; sorter: string; filters: string; queryString: string; }): Promise<{ data: Crontab[]; total: number }> { const searchText = params?.searchValue; const page = Number(params?.page || '0'); const size = Number(params?.size || '0'); const viewQuery = safeJSONParse(params?.queryString); const filterQuery = safeJSONParse(params?.filters); const sorterQuery = safeJSONParse(params?.sorter); let query: any = {}; let order = [ ['isPinned', 'DESC'], ['isDisabled', 'ASC'], ['status', 'ASC'], ['createdAt', 'DESC'], ]; this.formatViewQuery(query, viewQuery); this.formatSearchText(query, searchText); this.formatFilterQuery(query, filterQuery); this.formatViewSort(order, viewQuery); if (sorterQuery) { const { field, type } = sorterQuery; if (field && type) { order.unshift([field, type]); } } let condition: FindOptions = { where: query, order: order as Order, }; if (page && size) { condition.offset = (page - 1) * size; condition.limit = size; } try { const result = await CrontabModel.findAll(condition); const count = await CrontabModel.count({ where: query }); return { data: result.map((x) => x.get({ plain: true })), total: count }; } catch (error) { throw error; } } public async getDb(query: FindOptions['where']): Promise { const doc: any = await CrontabModel.findOne({ where: { ...query } }); if (!doc) { throw new Error(`Cron ${JSON.stringify(query)} not found`); } return doc.get({ plain: true }); } public async run(ids: number[]) { await CrontabModel.update( { status: CrontabStatus.queued }, { where: { id: ids } }, ); ids.forEach((id) => { this.runSingle(id); }); } public async stop(ids: number[]) { const docs = await CrontabModel.findAll({ where: { id: ids } }); for (const doc of docs) { // Kill all running instances of this task try { if (doc.pid) { await killTask(doc.pid); } const command = doc.command.replace(/\s+/g, ' ').trim(); await killAllTasks(command); this.logger.info( `[panel][停止所有运行中的任务实例] 任务ID: ${doc.id}, 命令: ${command}`, ); } catch (error) { this.logger.error( `[panel][停止任务失败] 任务ID: ${doc.id}, 错误: ${error}`, ); } } await CrontabModel.update( { status: CrontabStatus.idle, pid: undefined }, { where: { id: ids } }, ); } private async runSingle(cronId: number): Promise { return taskLimit.manualRunWithCronLimit(() => { return new Promise(async (resolve: any) => { const cron = await this.getDb({ id: cronId }); const params = { name: cron.name, command: cron.command, schedule: cron.schedule, extra_schedules: cron.extra_schedules, }; if (cron.status !== CrontabStatus.queued) { resolve(params); return; } this.logger.info( `[panel][开始执行任务] 参数: ${JSON.stringify(params)}`, ); let { id, command, log_name } = cron; const uniqPath = log_name === '/dev/null' || !log_name ? await getUniqPath(command, `${id}`) : log_name; const logTime = dayjs().format('YYYY-MM-DD-HH-mm-ss-SSS'); const logDirPath = path.resolve(config.logPath, `${uniqPath}`); await fs.mkdir(logDirPath, { recursive: true }); const logPath = `${uniqPath}/${logTime}.log`; const absolutePath = path.resolve(config.logPath, `${logPath}`); const cp = spawn( `real_log_path=${logPath} no_delay=true ${this.makeCommand( cron, true, )}`, { shell: '/bin/bash' }, ); await CrontabModel.update( { status: CrontabStatus.running, pid: cp.pid, log_path: logPath }, { where: { id } }, ); cp.stdout.on('data', async (data) => { await logStreamManager.write(absolutePath, data.toString()); }); cp.stderr.on('data', async (data) => { this.logger.info( '[panel][执行任务失败] 命令: %s, 错误信息: %j', command, data.toString(), ); await logStreamManager.write(absolutePath, data.toString()); }); cp.on('error', async (err) => { this.logger.error( '[panel][创建任务失败] 命令: %s, 错误信息: %j', command, err, ); await logStreamManager.write(absolutePath, JSON.stringify(err)); }); cp.on('exit', async (code) => { this.logger.info( '[panel][执行任务结束] 参数: %s, 退出码: %j', JSON.stringify(params), code, ); // Close the stream after task completion await logStreamManager.closeStream(absolutePath); await CrontabModel.update( { status: CrontabStatus.idle, pid: undefined }, { where: { id } }, ); resolve({ ...params, pid: cp.pid, code }); }); }); }); } public async disabled(ids: number[]) { await CrontabModel.update({ isDisabled: 1 }, { where: { id: ids } }); await cronClient.delCron(ids.map(String)); await this.setCrontab(); } public async enabled(ids: number[]) { await CrontabModel.update({ isDisabled: 0 }, { where: { id: ids } }); const docs = await CrontabModel.findAll({ where: { id: ids } }); const sixCron = docs .filter((x) => this.isNodeCron(x) && !this.isSpecialSchedule(x.schedule)) .map((doc) => ({ name: doc.name || '', id: String(doc.id), schedule: doc.schedule!, command: this.makeCommand(doc), extra_schedules: doc.extra_schedules || [], })); if (isDemoEnv()) { return; } await cronClient.addCron(sixCron); await this.setCrontab(); } public async log(id: number) { const doc = await this.getDb({ id }); if (!doc) { return ''; } if (doc.log_name === '/dev/null') { return '日志设置为忽略'; } const absolutePath = path.resolve(config.logPath, `${doc.log_path}`); const logFileExist = doc.log_path && (await fileExist(absolutePath)); if (logFileExist) { return await getFileContentByName(`${absolutePath}`); } else { return typeof doc.status === 'number' && [CrontabStatus.queued, CrontabStatus.running].includes(doc.status) ? '运行中...' : '日志不存在...'; } } public async logs(id: number) { const doc = await this.getDb({ id }); if (!doc || !doc.log_path) { return []; } const relativeDir = path.dirname(`${doc.log_path}`); const dir = path.resolve(config.logPath, relativeDir); const dirExist = await fileExist(dir); if (dirExist) { let files = await fs.readdir(dir); return ( await Promise.all( files.map(async (x) => ({ filename: x, directory: relativeDir.replace(config.logPath, ''), time: (await fs.lstat(`${dir}/${x}`)).birthtimeMs, })), ) ).sort((a, b) => b.time - a.time); } else { return []; } } private makeCommand(tab: Crontab, realTime?: boolean) { let command = tab.command.trim(); if (!command.startsWith(TASK_PREFIX) && !command.startsWith(QL_PREFIX)) { command = `${TASK_PREFIX}${tab.command}`; } let commandVariable = `real_time=${Boolean(realTime)} no_tee=true ID=${tab.id} `; // Only include log_name if it has a truthy value to avoid passing null/undefined to shell if (tab.log_name) { commandVariable += `log_name=${tab.log_name} `; } if (tab.task_before) { commandVariable += `task_before='${tab.task_before .replace(/'/g, "'\\''") .replace(/;? *\n/g, ';') .trim()}' `; } if (tab.task_after) { commandVariable += `task_after='${tab.task_after .replace(/'/g, "'\\''") .replace(/;? *\n/g, ';') .trim()}' `; } const crontab_job_string = `${commandVariable}${command}`; return crontab_job_string; } private async setCrontab(data?: { data: Crontab[]; total: number }) { const tabs = data ?? (await this.crontabs()); var crontab_string = ''; tabs.data.forEach((tab) => { if ( tab.isDisabled === 1 || this.isNodeCron(tab) || this.isSpecialSchedule(tab.schedule) ) { crontab_string += '# '; crontab_string += tab.schedule; crontab_string += ' '; crontab_string += this.makeCommand(tab); crontab_string += '\n'; } else { crontab_string += tab.schedule; crontab_string += ' '; crontab_string += this.makeCommand(tab); crontab_string += '\n'; } }); await writeFileWithLock(config.crontabFile, crontab_string); try { execSync(`crontab ${config.crontabFile}`); } catch (error: any) { const errorMsg = error.message || String(error); this.logger.error('[crontab] Failed to update system crontab:', errorMsg); } await CrontabModel.update({ saved: true }, { where: {} }); } public importCrontab() { exec('crontab -l', (error, stdout) => { if (error) { const errorMsg = error.message || String(error); this.logger.error('[crontab] Failed to read system crontab:', errorMsg); } const lines = stdout.split('\n'); const namePrefix = new Date().getTime(); lines.reverse().forEach(async (line, index) => { line = line.replace(/\t+/g, ' '); const regex = /^((\@[a-zA-Z]+\s+)|(([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+))/; const command = line.replace(regex, '').trim(); const schedule = line.replace(command, '').trim(); if ( command && schedule && CronExpressionParser.parse(schedule).hasNext() ) { const name = namePrefix + '_' + index; const _crontab = await CrontabModel.findOne({ where: { command, schedule }, }); if (!_crontab) { await this.create({ name, command, schedule }); } else { _crontab.command = command; _crontab.schedule = schedule; await this.update(_crontab); } } }); }); } public async autosave_crontab() { const tabs = await this.crontabs(); const regularCrons = tabs.data .filter( (x) => x.isDisabled !== 1 && this.isNodeCron(x) && !this.isSpecialSchedule(x.schedule), ) .map((doc) => ({ name: doc.name || '', id: String(doc.id), schedule: doc.schedule!, command: this.makeCommand(doc), extra_schedules: doc.extra_schedules || [], })); if (isDemoEnv()) { await writeFileWithLock(config.crontabFile, ''); return; } await cronClient.addCron(regularCrons); this.setCrontab(tabs); } public async bootTask() { const tabs = await this.crontabs(); const bootTasks = tabs.data.filter( (x) => !x.isDisabled && this.isBootSchedule(x.schedule), ); if (bootTasks.length > 0) { await CrontabModel.update( { status: CrontabStatus.queued }, { where: { id: bootTasks.map((t) => t.id!) } }, ); for (const task of bootTasks) { await this.runSingle(task.id!); } } } } ================================================ FILE: back/services/cronView.ts ================================================ import { Service, Inject } from 'typedi'; import winston from 'winston'; import { CrontabView, CrontabViewModel } from '../data/cronView'; import { initPosition, maxPosition, minPosition, stepPosition, } from '../data/env'; import { FindOptions } from 'sequelize'; @Service() export default class CronViewService { constructor(@Inject('logger') private logger: winston.Logger) {} public async create(payload: CrontabView): Promise { let position = initPosition; const views = await this.list(); if (views && views.length > 0 && views[views.length - 1].position) { position = views[views.length - 1].position as number; } position = position / 2; const tab = new CrontabView({ ...payload, position }); const doc = await this.insert(tab); await this.checkPosition(tab.position!); return doc; } public async insert(payload: CrontabView): Promise { return await CrontabViewModel.create(payload, { returning: true }); } public async update(payload: CrontabView): Promise { const doc = await this.getDb({ id: payload.id }); const tab = new CrontabView({ ...doc, ...payload }); const newDoc = await this.updateDb(tab); return newDoc; } public async updateDb(payload: CrontabView): Promise { await CrontabViewModel.update(payload, { where: { id: payload.id } }); return await this.getDb({ id: payload.id }); } public async remove(ids: number[]) { await CrontabViewModel.destroy({ where: { id: ids } }); } public async list(): Promise { try { const result = await CrontabViewModel.findAll({ where: {}, order: [['position', 'DESC']], }); return result; } catch (error) { throw error; } } public async getDb( query: FindOptions['where'], ): Promise { const doc: any = await CrontabViewModel.findOne({ where: { ...query } }); if (!doc) { throw new Error(`CronView ${JSON.stringify(query)} not found`); } return doc.get({ plain: true }); } public async disabled(ids: number[]) { await CrontabViewModel.update({ isDisabled: 1 }, { where: { id: ids } }); } public async enabled(ids: number[]) { await CrontabViewModel.update({ isDisabled: 0 }, { where: { id: ids } }); } private async checkPosition(position: number) { const precisionPosition = parseFloat(position.toPrecision(16)); if (precisionPosition < minPosition || precisionPosition > maxPosition) { const envs = await this.list(); let position = initPosition; for (const env of envs) { position = position - stepPosition; await this.updateDb({ id: env.id, position }); } } } private getPrecisionPosition(position: number): number { return parseFloat(position.toPrecision(16)); } public async move({ id, fromIndex, toIndex, }: { fromIndex: number; toIndex: number; id: number; }): Promise { let targetPosition: number; const isUpward = fromIndex > toIndex; const views = await this.list(); if (toIndex === 0 || toIndex === views.length - 1) { targetPosition = isUpward ? views[0].position! * 2 : views[toIndex].position! / 2; } else { targetPosition = isUpward ? (views[toIndex].position! + views[toIndex - 1].position!) / 2 : (views[toIndex].position! + views[toIndex + 1].position!) / 2; } const newDoc = await this.update({ id, position: this.getPrecisionPosition(targetPosition), }); await this.checkPosition(targetPosition); return newDoc; } } ================================================ FILE: back/services/dependence.ts ================================================ import { Service, Inject } from 'typedi'; import winston from 'winston'; import config from '../config'; import { Dependence, DependenceStatus, DependenceTypes, DependenceModel, versionDependenceCommandTypes, } from '../data/dependence'; import { spawn } from 'cross-spawn'; import SockService from './sock'; import { FindOptions, Op } from 'sequelize'; import { fileExist, getPid, killTask, promiseExecSuccess, getInstallCommand, getUninstallCommand, getGetCommand, } from '../config/util'; import dayjs from 'dayjs'; import taskLimit from '../shared/pLimit'; @Service() export default class DependenceService { constructor( @Inject('logger') private logger: winston.Logger, private sockService: SockService, ) { } public async create(payloads: Dependence[]): Promise { const tabs = payloads.map((x) => { const tab = new Dependence({ ...x, status: DependenceStatus.queued }); return tab; }); const docs = await this.insert(tabs); this.installDependenceOneByOne(docs); return docs; } public async insert(payloads: Dependence[]): Promise { const docs = await DependenceModel.bulkCreate(payloads); return docs; } public async update( payload: Dependence & { id: string }, ): Promise { const { id, ...other } = payload; const doc = await this.getDb({ id }); const tab = new Dependence({ ...doc, ...other, status: DependenceStatus.queued, }); const newDoc = await this.updateDb(tab); this.installDependenceOneByOne([newDoc]); return newDoc; } private async updateDb(payload: Dependence): Promise { await DependenceModel.update(payload, { where: { id: payload.id } }); return await this.getDb({ id: payload.id }); } public async remove(ids: number[], force = false): Promise { const docs = await DependenceModel.findAll({ where: { id: ids } }); for (const doc of docs) { taskLimit.removeQueuedDependency(doc); } const unInstalledDeps = docs.filter( (x) => x.status !== DependenceStatus.installed, ); const installedDeps = docs.filter( (x) => x.status === DependenceStatus.installed, ); await this.removeDb(unInstalledDeps.map((x) => x.id!)); if (installedDeps.length) { await DependenceModel.update( { status: DependenceStatus.queued, log: [] }, { where: { id: ids } }, ); this.installDependenceOneByOne(docs, false, force); } return docs; } public async removeDb(ids: number[]) { await DependenceModel.destroy({ where: { id: ids } }); } public async dependencies( { searchValue, type, status, }: { searchValue: string; type: keyof typeof DependenceTypes; status: string; }, sort: any = [], query: any = {}, ): Promise { let condition = query; if (type && DependenceTypes[type] !== undefined) { condition.type = DependenceTypes[type]; } if (status) { condition.status = status.split(',').map(Number); } if (searchValue) { const encodeText = encodeURI(searchValue); condition.name = { [Op.or]: [ { [Op.like]: `%${searchValue}%` }, { [Op.like]: `%${encodeText}%` }, ], }; } try { return await this.find(condition, sort); } catch (error) { throw error; } } public installDependenceOneByOne( docs: Dependence[], isInstall: boolean = true, force: boolean = false, ): Promise { docs.forEach((dep) => { this.installOrUninstallDependency(dep, isInstall, force); }); return taskLimit.waitDependencyQueueDone(); } public async reInstall(ids: number[]): Promise { await DependenceModel.update( { status: DependenceStatus.queued, log: [] }, { where: { id: ids } }, ); const docs = await DependenceModel.findAll({ where: { id: ids } }); for (const doc of docs) { taskLimit.removeQueuedDependency(doc); } this.installDependenceOneByOne(docs, true, true); return docs; } public async cancel(ids: number[]) { const docs = await DependenceModel.findAll({ where: { id: ids } }); for (const doc of docs) { taskLimit.removeQueuedDependency(doc); const depInstallCommand = getInstallCommand(doc.type, doc.name); const depUnInstallCommand = getUninstallCommand(doc.type, doc.name); const pids = await Promise.all([ getPid(depInstallCommand), getPid(depUnInstallCommand), ]); for (const pid of pids) { pid && (await killTask(pid)); } } await DependenceModel.update( { status: DependenceStatus.cancelled }, { where: { id: ids } }, ); } private async find(query: any, sort: any = []): Promise { const docs = await DependenceModel.findAll({ where: { ...query }, order: [...sort, ['createdAt', 'DESC']], }); return docs; } public async getDb( query: FindOptions['where'], ): Promise { const doc: any = await DependenceModel.findOne({ where: { ...query } }); if (!doc) { throw new Error(`Dependency ${JSON.stringify(query)} not found`); } return doc.get({ plain: true }); } private async updateLog(ids: number[], log: string): Promise { taskLimit.updateDepLog(async () => { const docs = await DependenceModel.findAll({ where: { id: ids } }); for (const doc of docs) { const newLog = doc?.log ? [...doc.log, log] : [log]; await DependenceModel.update( { log: newLog }, { where: { id: doc.id } }, ); } return null; }); } public installOrUninstallDependency( dependency: Dependence, isInstall: boolean = true, force: boolean = false, ) { return taskLimit.runDependeny(dependency, () => { return new Promise(async (resolve) => { if (taskLimit.firstDependencyId !== dependency.id) { return resolve(null); } taskLimit.removeQueuedDependency(dependency); const depIds = [dependency.id!]; const status = isInstall ? DependenceStatus.installing : DependenceStatus.removing; await DependenceModel.update({ status }, { where: { id: depIds } }); const socketMessageType = isInstall ? 'installDependence' : 'uninstallDependence'; let depName = dependency.name.trim(); const command = isInstall ? getInstallCommand(dependency.type, depName) : getUninstallCommand(dependency.type, depName); const actionText = isInstall ? '安装' : '删除'; const startTime = dayjs(); const message = `开始${actionText}依赖 ${depName},开始时间 ${startTime.format( 'YYYY-MM-DD HH:mm:ss', )}\n\n`; this.sockService.sendMessage({ type: socketMessageType, message, references: depIds, }); this.updateLog(depIds, message); // 判断是否已经安装过依赖 if (isInstall && !force) { const getCommand = getGetCommand(dependency.type, depName); const depVersionStr = versionDependenceCommandTypes[dependency.type]; let depVersion = ''; if (depName.includes(depVersionStr)) { const symbolRegx = new RegExp( `(.*)${depVersionStr}([0-9\\.\\-\\+a-zA-Z]*)`, ); const [, _depName, _depVersion] = depName.match(symbolRegx) || []; if (_depVersion && _depName) { depName = _depName; depVersion = _depVersion; } } const isNodeDependence = dependency.type === DependenceTypes.nodejs; const isLinuxDependence = dependency.type === DependenceTypes.linux; const isPythonDependence = dependency.type === DependenceTypes.python3; const depInfo = (await promiseExecSuccess(getCommand)) .replace(/\s{2,}/, ' ') .replace(/\s+$/, ''); if ( depInfo && ((isNodeDependence && depInfo.split(' ')?.[0] === depName) || (isLinuxDependence && depInfo.toLocaleLowerCase().includes('installed')) || isPythonDependence) && (!depVersion || depInfo.includes(depVersion)) ) { const endTime = dayjs(); const _message = `检测到已经安装 ${depName}\n\n${depInfo}\n\n跳过安装\n\n依赖${actionText}成功,结束时间 ${endTime.format( 'YYYY-MM-DD HH:mm:ss', )},耗时 ${endTime.diff(startTime, 'second')} 秒`; this.sockService.sendMessage({ type: socketMessageType, message: _message, references: depIds, }); this.updateLog(depIds, _message); await DependenceModel.update( { status: DependenceStatus.installed }, { where: { id: depIds } }, ); return resolve(null); } } const dependenceProxyFileExist = await fileExist( config.dependenceProxyFile, ); const proxyStr = dependenceProxyFileExist ? `source ${config.dependenceProxyFile} &&` : ''; const cp = spawn(`${proxyStr} ${command}`, { shell: '/bin/bash', }); cp.stdout.on('data', async (data) => { this.sockService.sendMessage({ type: socketMessageType, message: data.toString(), references: depIds, }); this.updateLog(depIds, data.toString()); }); cp.stderr.on('data', async (data) => { this.sockService.sendMessage({ type: socketMessageType, message: data.toString(), references: depIds, }); this.updateLog(depIds, data.toString()); }); cp.on('error', async (err) => { this.sockService.sendMessage({ type: socketMessageType, message: JSON.stringify(err), references: depIds, }); this.updateLog(depIds, JSON.stringify(err)); }); cp.on('exit', async (code) => { const endTime = dayjs(); const isSucceed = code === 0; const resultText = isSucceed ? '成功' : '失败'; const message = `\n依赖${actionText}${resultText},结束时间 ${endTime.format( 'YYYY-MM-DD HH:mm:ss', )},耗时 ${endTime.diff(startTime, 'second')} 秒`; this.sockService.sendMessage({ type: socketMessageType, message, references: depIds, }); this.updateLog(depIds, message); let status: number; if (isSucceed) { status = isInstall ? DependenceStatus.installed : DependenceStatus.removed; } else { status = isInstall ? DependenceStatus.installFailed : DependenceStatus.removeFailed; } const docs = await DependenceModel.findAll({ where: { id: depIds } }); const _docIds = docs .filter((x) => x.status !== DependenceStatus.cancelled) .map((x) => x.id!); if (_docIds.length > 0) { await DependenceModel.update( { status }, { where: { id: _docIds } }, ); } // 如果删除依赖成功或者强制删除 if ((isSucceed || force) && !isInstall) { this.removeDb(depIds); } resolve(null); }); }); }); } } ================================================ FILE: back/services/env.ts ================================================ import groupBy from 'lodash/groupBy'; import { FindOptions, Op } from 'sequelize'; import { Inject, Service } from 'typedi'; import winston from 'winston'; import config from '../config'; import { Env, EnvModel, EnvStatus, initPosition, maxPosition, minPosition, stepPosition, } from '../data/env'; import { writeFileWithLock } from '../shared/utils'; import { sequelize } from '../data'; @Service() export default class EnvService { constructor(@Inject('logger') private logger: winston.Logger) { } public async create(payloads: Env[]): Promise { const envs = await this.envs(); let position = initPosition; if ( envs && envs.length > 0 && typeof envs[envs.length - 1].position === 'number' ) { position = envs[envs.length - 1].position!; } const tabs = payloads.map((x) => { position = position - stepPosition; const tab = new Env({ ...x, position }); return tab; }); const docs = await this.insert(tabs); await this.set_envs(); await this.checkPosition(tabs[tabs.length - 1].position!); return docs; } public async insert(payloads: Env[]): Promise { const result: Env[] = []; for (const env of payloads) { const doc = await EnvModel.create(env, { returning: true }); result.push(doc); } return result; } public async update(payload: Env): Promise { const doc = await this.getDb({ id: payload.id }); const tab = new Env({ ...doc, ...payload }); const newDoc = await this.updateDb(tab); await this.set_envs(); return newDoc; } private async updateDb(payload: Env): Promise { await EnvModel.update({ ...payload }, { where: { id: payload.id } }); return await this.getDb({ id: payload.id }); } public async remove(ids: number[]) { await EnvModel.destroy({ where: { id: ids } }); await this.set_envs(); } public async move( id: number, { fromIndex, toIndex, }: { fromIndex: number; toIndex: number; }, ): Promise { let targetPosition: number; const isUpward = fromIndex > toIndex; const envs = await this.envs(); if (toIndex === 0 || toIndex === envs.length - 1) { targetPosition = isUpward ? envs[0].position! + stepPosition : envs[toIndex].position! - stepPosition; } else { targetPosition = isUpward ? (envs[toIndex].position! + envs[toIndex - 1].position!) / 2 : (envs[toIndex].position! + envs[toIndex + 1].position!) / 2; } const newDoc = await this.update({ id, position: this.getPrecisionPosition(targetPosition), }); await this.checkPosition(targetPosition, envs[toIndex].position!); return newDoc; } private async checkPosition(position: number, edge: number = 0) { const precisionPosition = parseFloat(position.toPrecision(16)); if ( precisionPosition < minPosition || precisionPosition > maxPosition || Math.abs(precisionPosition - edge) < minPosition ) { const envs = await this.envs(); let position = initPosition; for (const env of envs) { position = position - stepPosition; await this.updateDb({ id: env.id, position }); } } } private getPrecisionPosition(position: number): number { return parseFloat(position.toPrecision(16)); } public async envs(searchText: string = '', query: any = {}): Promise { let condition = { ...query }; if (searchText) { const encodeText = encodeURI(searchText); const reg = { [Op.or]: [ { [Op.like]: `%${searchText}%` }, { [Op.like]: `%${encodeText}%` }, ], }; condition = { ...condition, [Op.or]: [ { name: reg, }, { value: reg, }, { remarks: reg, }, ], }; } try { const result = await this.find(condition, [ [sequelize.literal('COALESCE(`isPinned`, 0)'), 'DESC'], ['position', 'DESC'], ['createdAt', 'ASC'], ]); return result; } catch (error) { throw error; } } private async find(query: any, sort: any = []): Promise { const docs = await EnvModel.findAll({ where: { ...query }, order: [...sort], }); return docs.map((x) => x.get({ plain: true })); } public async getDb(query: FindOptions['where']): Promise { const doc: any = await EnvModel.findOne({ where: { ...query } }); if (!doc) { throw new Error(`Env ${JSON.stringify(query)} not found`); } return doc.get({ plain: true }); } public async disabled(ids: number[]) { await EnvModel.update( { status: EnvStatus.disabled }, { where: { id: ids } }, ); await this.set_envs(); } public async enabled(ids: number[]) { await EnvModel.update({ status: EnvStatus.normal }, { where: { id: ids } }); await this.set_envs(); } public async updateNames({ ids, name }: { ids: number[]; name: string }) { await EnvModel.update({ name }, { where: { id: ids } }); await this.set_envs(); } public async pin(ids: number[]) { await EnvModel.update({ isPinned: 1 }, { where: { id: ids } }); } public async unPin(ids: number[]) { await EnvModel.update({ isPinned: 0 }, { where: { id: ids } }); } public async set_envs() { const envs = await this.envs('', { name: { [Op.not]: null }, status: EnvStatus.normal, }); const groups = groupBy(envs, 'name'); let env_string = ''; let js_env_string = ''; let py_env_string = 'import os\n'; for (const key in groups) { if (Object.prototype.hasOwnProperty.call(groups, key)) { const group = groups[key]; // 忽略不符合bash要求的环境变量名称 if (/^[a-zA-Z_][0-9a-zA-Z_]*$/.test(key)) { let value = group .map((x) => x.value) .join('&') .replace(/'/g, "'\\''") .trim(); env_string += `export ${key}='${value}'\n`; const _env_value = `${group .map((x) => x.value) .join('&') .replace(/\\/g, '\\\\')}`; js_env_string += `process.env.${key}=\`${_env_value.replace( /\`/g, '\\`', )}\`;\n`; py_env_string += `os.environ['${key}']='''${_env_value.replace( /\'/g, "\\'", )}'''\n`; } } } await writeFileWithLock(config.envFile, env_string); await writeFileWithLock(config.jsEnvFile, js_env_string); await writeFileWithLock(config.pyEnvFile, py_env_string); } } ================================================ FILE: back/services/grpc.ts ================================================ import { Server, ServerCredentials } from '@grpc/grpc-js'; import { CronService } from '../protos/cron'; import { HealthService } from '../protos/health'; import { ApiService } from '../protos/api'; import { addCron } from '../schedule/addCron'; import { delCron } from '../schedule/delCron'; import { check } from '../schedule/health'; import * as Api from '../schedule/api'; import Logger from '../loaders/logger'; import { promisify } from 'util'; import config from '../config'; import { metricsService } from './metrics'; import { Service } from 'typedi'; @Service() export class GrpcServerService { private server: Server = new Server({ 'grpc.enable_http_proxy': 0 }); async initialize() { try { this.server.addService(HealthService, { check }); this.server.addService(CronService, { addCron, delCron }); this.server.addService(ApiService, Api); const grpcPort = config.grpcPort; const bindAsync = promisify(this.server.bindAsync).bind(this.server); await bindAsync( `0.0.0.0:${grpcPort}`, ServerCredentials.createInsecure(), ); Logger.debug(`✌️ gRPC service started successfully`); metricsService.record('grpc_service_start', 1, { port: grpcPort.toString(), }); return grpcPort; } catch (err) { Logger.error('Failed to start gRPC service:', err); throw err; } } async shutdown() { try { if (this.server) { await new Promise((resolve) => { this.server.tryShutdown(() => { Logger.debug('gRPC service stopped'); metricsService.record('grpc_service_stop', 1); resolve(null); }); }); } } catch (err) { Logger.error('Error while shutting down gRPC service:', err); throw err; } } getServer() { return this.server; } } ================================================ FILE: back/services/health.ts ================================================ import { Service } from 'typedi'; import Logger from '../loaders/logger'; import { GrpcServerService } from './grpc'; import { HttpServerService } from './http'; interface HealthStatus { status: 'ok' | 'error'; services: { http: boolean; grpc: boolean; }; metrics: { uptime: number; memory: { used: number; total: number; }; }; } @Service() export class HealthService { private startTime = Date.now(); constructor( private grpcServerService: GrpcServerService, private httpServerService: HttpServerService, ) {} async check(): Promise { const status: HealthStatus = { status: 'ok', services: { http: true, grpc: true, }, metrics: { uptime: Math.floor((Date.now() - this.startTime) / 1000), memory: { used: process.memoryUsage().heapUsed, total: process.memoryUsage().heapTotal, }, }, }; try { const httpServer = this.httpServerService.getServer(); if (!httpServer) { status.services.http = false; status.status = 'error'; } } catch (err) { status.services.http = false; status.status = 'error'; Logger.error('HTTP server check failed:', err); } try { const grpcServer = this.grpcServerService.getServer(); if (!grpcServer) { status.services.grpc = false; status.status = 'error'; } } catch (err) { status.services.grpc = false; status.status = 'error'; Logger.error('gRPC server check failed:', err); } return status; } } ================================================ FILE: back/services/http.ts ================================================ import express from 'express'; import Logger from '../loaders/logger'; import { metricsService } from './metrics'; import { Service } from 'typedi'; import { Server } from 'http'; @Service() export class HttpServerService { private server?: Server = undefined; async initialize(expressApp: express.Application, port: number) { try { return new Promise((resolve, reject) => { this.server = expressApp.listen(port, '0.0.0.0', () => { Logger.debug(`✌️ HTTP service started successfully`); metricsService.record('http_service_start', 1, { port: port.toString(), }); resolve(this.server); }); this.server?.on('error', (err: Error) => { Logger.error('Failed to start HTTP service:', err); reject(err); }); }); } catch (err) { Logger.error('Failed to start HTTP service:', err); throw err; } } async shutdown() { try { if (this.server) { await new Promise((resolve) => { this.server?.close(() => { Logger.debug('HTTP service stopped'); metricsService.record('http_service_stop', 1); resolve(null); }); }); } } catch (err) { Logger.error('Error while shutting down HTTP service:', err); throw err; } } getServer() { return this.server; } } ================================================ FILE: back/services/log.ts ================================================ import path from 'path'; import { Inject, Service } from 'typedi'; import winston from 'winston'; import config from '../config'; @Service() export default class LogService { constructor(@Inject('logger') private logger: winston.Logger) {} public checkFilePath(filePath: string, fileName: string) { const finalPath = path.resolve(config.logPath, filePath, fileName); return finalPath.startsWith(config.logPath) ? finalPath : ''; } } ================================================ FILE: back/services/metrics.ts ================================================ import { performance } from 'perf_hooks'; import Logger from '../loaders/logger'; interface Metric { name: string; value: number; timestamp: number; tags?: Record; } class MetricsService { private metrics: Metric[] = []; private static instance: MetricsService; private constructor() { // 定期清理旧数据 setInterval(() => { const oneHourAgo = Date.now() - 3600000; this.metrics = this.metrics.filter(m => m.timestamp > oneHourAgo); }, 60000); } static getInstance(): MetricsService { if (!MetricsService.instance) { MetricsService.instance = new MetricsService(); } return MetricsService.instance; } record(name: string, value: number, tags?: Record) { this.metrics.push({ name, value, timestamp: Date.now(), tags, }); } measure(name: string, fn: () => void, tags?: Record) { const start = performance.now(); try { fn(); } finally { const duration = performance.now() - start; this.record(name, duration, tags); } } async measureAsync(name: string, fn: () => Promise, tags?: Record) { const start = performance.now(); try { await fn(); } finally { const duration = performance.now() - start; this.record(name, duration, tags); } } getMetrics(name?: string, tags?: Record) { let filtered = this.metrics; if (name) { filtered = filtered.filter(m => m.name === name); } if (tags) { filtered = filtered.filter(m => { if (!m.tags) return false; return Object.entries(tags).every(([key, value]) => m.tags![key] === value); }); } return { count: filtered.length, average: filtered.reduce((acc, curr) => acc + curr.value, 0) / filtered.length, min: Math.min(...filtered.map(m => m.value)), max: Math.max(...filtered.map(m => m.value)), metrics: filtered, }; } report() { const report = { timestamp: Date.now(), metrics: this.getMetrics(), }; Logger.info('性能指标报告:', report); return report; } } export const metricsService = MetricsService.getInstance(); ================================================ FILE: back/services/notify.ts ================================================ import crypto from 'crypto'; import nodemailer from 'nodemailer'; import { Inject, Service } from 'typedi'; import { parseBody, parseHeaders } from '../config/util'; import { NotificationInfo } from '../data/notify'; import UserService from './user'; import { httpClient } from '../config/http'; import { ProxyAgent } from 'undici'; @Service() export default class NotificationService { @Inject((type) => UserService) private userService!: UserService; private modeMap = new Map([ ['gotify', this.gotify], ['goCqHttpBot', this.goCqHttpBot], ['serverChan', this.serverChan], ['pushDeer', this.pushDeer], ['chat', this.chat], ['bark', this.bark], ['telegramBot', this.telegramBot], ['dingtalkBot', this.dingtalkBot], ['weWorkBot', this.weWorkBot], ['weWorkApp', this.weWorkApp], ['aibotk', this.aibotk], ['iGot', this.iGot], ['pushPlus', this.pushPlus], ['wePlusBot', this.wePlusBot], ['email', this.email], ['pushMe', this.pushMe], ['webhook', this.webhook], ['lark', this.lark], ['chronocat', this.chronocat], ['ntfy', this.ntfy], ['wxPusherBot', this.wxPusherBot], ]); private title = ''; private content = ''; private params!: Omit; private gotOption = { timeout: 10000, retry: 1, }; constructor() {} public async notify( title: string, content: string, notificationInfo?: NotificationInfo, ): Promise { let { type, ...rest } = await this.userService.getNotificationMode(); if (notificationInfo?.type) { type = notificationInfo?.type; } if (type) { this.title = title; this.content = content; let params = rest; if (notificationInfo) { const { type: _, ...others } = notificationInfo; params = { ...rest, ...others }; } this.params = params; const notificationModeAction = this.modeMap.get(type); try { return await notificationModeAction?.call(this); } catch (error: any) { console.error(error); } } return false; } public async testNotify( info: NotificationInfo, title: string, content: string, ) { const { type, ...rest } = info; if (type) { this.title = title; this.content = content; this.params = rest; const notificationModeAction = this.modeMap.get(type); return await notificationModeAction?.call(this); } return true; } private async gotify() { const { gotifyUrl, gotifyToken, gotifyPriority = 1 } = this.params; try { const res = await httpClient.post( `${gotifyUrl}/message?token=${gotifyToken}`, { ...this.gotOption, body: `title=${encodeURIComponent( this.title, )}&message=${encodeURIComponent( this.content, )}&priority=${gotifyPriority}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }, ); if (typeof res.id === 'number') { return true; } else { throw new Error(JSON.stringify(res)); } } catch (error: any) { throw new Error(error.response ? error.response.body : error); } } private async goCqHttpBot() { const { goCqHttpBotQq, goCqHttpBotToken, goCqHttpBotUrl } = this.params; try { const res = await httpClient.post(`${goCqHttpBotUrl}?${goCqHttpBotQq}`, { ...this.gotOption, json: { message: `${this.title}\n${this.content}` }, headers: { Authorization: 'Bearer ' + goCqHttpBotToken }, }); if (res.retcode === 0) { return true; } else { throw new Error(JSON.stringify(res)); } } catch (error: any) { throw new Error(error.response ? error.response.body : error); } } private async serverChan() { const { serverChanKey } = this.params; const matchResult = serverChanKey.match(/^sctp(\d+)t/i); const url = matchResult && matchResult[1] ? `https://${matchResult[1]}.push.ft07.com/send/${serverChanKey}.send` : `https://sctapi.ftqq.com/${serverChanKey}.send`; try { const res = await httpClient.post(url, { ...this.gotOption, body: `title=${encodeURIComponent( this.title, )}&desp=${encodeURIComponent(this.content)}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }); if (res.errno === 0 || res.data.errno === 0) { return true; } else { throw new Error(JSON.stringify(res)); } } catch (error: any) { throw new Error(error.response ? error.response.body : error); } } private async pushDeer() { const { pushDeerKey, pushDeerUrl } = this.params; const url = pushDeerUrl || `https://api2.pushdeer.com/message/push`; try { const res = await httpClient.post(url, { ...this.gotOption, body: `pushkey=${pushDeerKey}&text=${encodeURIComponent( this.title, )}&desp=${encodeURIComponent(this.content)}&type=markdown`, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }); if ( res.content.result.length !== undefined && res.content.result.length > 0 ) { return true; } else { throw new Error(JSON.stringify(res)); } } catch (error: any) { throw new Error(error.response ? error.response.body : error); } } private async chat() { const { synologyChatUrl } = this.params; try { const res = await httpClient.post(synologyChatUrl, { ...this.gotOption, body: `payload={"text":"${this.title}\n${this.content}"}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }); if (res.success) { return true; } else { throw new Error(JSON.stringify(res)); } } catch (error: any) { throw new Error(error.response ? error.response.body : error); } } private async bark() { let { barkPush, barkIcon = '', barkSound = '', barkGroup = '', barkLevel = '', barkUrl = '', barkArchive = '', } = this.params; if (!barkPush.startsWith('http')) { barkPush = `https://api.day.app/${barkPush}`; } const url = `${barkPush}`; const body = { title: this.title, body: this.content, icon: barkIcon, sound: barkSound, group: barkGroup, isArchive: barkArchive, level: barkLevel, url: barkUrl, }; try { const res = await httpClient.post(url, { ...this.gotOption, json: body, headers: { 'Content-Type': 'application/json' }, }); if (res.code === 200) { return true; } else { throw new Error(JSON.stringify(res)); } } catch (error: any) { throw new Error(error.response ? error.response.body : error); } } private async telegramBot() { const { telegramBotApiHost, telegramBotProxyAuth, telegramBotProxyHost, telegramBotProxyPort, telegramBotToken, telegramBotUserId, } = this.params; const authStr = telegramBotProxyAuth ? `${telegramBotProxyAuth}@` : ''; const url = `${ telegramBotApiHost ? telegramBotApiHost : 'https://api.telegram.org' }/bot${telegramBotToken}/sendMessage`; let agent; if (telegramBotProxyHost && telegramBotProxyPort) { agent = new ProxyAgent({ uri: `http://${authStr}${telegramBotProxyHost}:${telegramBotProxyPort}`, }); } try { const res = await httpClient.post(url, { ...this.gotOption, body: `chat_id=${telegramBotUserId}&text=${this.title}\n\n${this.content}&disable_web_page_preview=true`, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, dispatcher: agent, }); if (res.ok) { return true; } else { throw new Error(JSON.stringify(res)); } } catch (error: any) { throw new Error(error.response ? error.response.body : error); } } private async dingtalkBot() { const { dingtalkBotSecret, dingtalkBotToken } = this.params; let secretParam = ''; if (dingtalkBotSecret) { const dateNow = Date.now(); const hmac = crypto.createHmac('sha256', dingtalkBotSecret); hmac.update(`${dateNow}\n${dingtalkBotSecret}`); const result = encodeURIComponent(hmac.digest('base64')); secretParam = `×tamp=${dateNow}&sign=${result}`; } const url = `https://oapi.dingtalk.com/robot/send?access_token=${dingtalkBotToken}${secretParam}`; try { const res = await httpClient.post(url, { ...this.gotOption, json: { msgtype: 'text', text: { content: ` ${this.title}\n\n${this.content}`, }, }, }); if (res.errcode === 0) { return true; } else { throw new Error(JSON.stringify(res)); } } catch (error: any) { throw new Error(error.response ? error.response.body : error); } } private async weWorkBot() { const { weWorkBotKey, weWorkOrigin = 'https://qyapi.weixin.qq.com' } = this.params; const url = `${weWorkOrigin}/cgi-bin/webhook/send?key=${weWorkBotKey}`; try { const res = await httpClient.post(url, { ...this.gotOption, json: { msgtype: 'text', text: { content: ` ${this.title}\n\n${this.content}`, }, }, }); if (res.errcode === 0) { return true; } else { throw new Error(JSON.stringify(res)); } } catch (error: any) { throw new Error(error.response ? error.response.body : error); } } private async weWorkApp() { const { weWorkAppKey, weWorkOrigin = 'https://qyapi.weixin.qq.com' } = this.params; const [corpid, corpsecret, touser, agentid, thumb_media_id = '1'] = weWorkAppKey.split(','); const url = `${weWorkOrigin}/cgi-bin/gettoken`; const tokenRes = await httpClient.post(url, { ...this.gotOption, json: { corpid, corpsecret, }, }); let options: any = { msgtype: 'mpnews', mpnews: { articles: [ { title: `${this.title}`, thumb_media_id, author: `智能助手`, content_source_url: ``, content: `${this.content.replace(/\n/g, '
')}`, digest: `${this.content}`, }, ], }, }; switch (thumb_media_id) { case '0': options = { msgtype: 'textcard', textcard: { title: `${this.title}`, description: `${this.content}`, url: 'https://github.com/whyour/qinglong', btntxt: '更多', }, }; break; case '1': options = { msgtype: 'text', text: { content: `${this.title}\n\n${this.content}`, }, }; break; } try { const res = await httpClient.post( `${weWorkOrigin}/cgi-bin/message/send?access_token=${tokenRes.access_token}`, { ...this.gotOption, json: { touser, agentid, safe: '0', ...options, }, }, ); if (res.errcode === 0) { return true; } else { throw new Error(JSON.stringify(res)); } } catch (error: any) { throw new Error(error.response ? error.response.body : error); } } private async aibotk() { const { aibotkKey, aibotkType, aibotkName } = this.params; let url = ''; let json = {}; switch (aibotkType) { case 'room': url = 'https://api-bot.aibotk.com/openapi/v1/chat/room'; json = { apiKey: `${aibotkKey}`, roomName: `${aibotkName}`, message: { type: 1, content: `【青龙快讯】\n\n${this.title}\n${this.content}`, }, }; break; case 'contact': url = 'https://api-bot.aibotk.com/openapi/v1/chat/contact'; json = { apiKey: `${aibotkKey}`, name: `${aibotkName}`, message: { type: 1, content: `【青龙快讯】\n\n${this.title}\n${this.content}`, }, }; break; } try { const res = await httpClient.post(url, { ...this.gotOption, json: { ...json, }, }); if (res.code === 0) { return true; } else { throw new Error(JSON.stringify(res)); } } catch (error: any) { throw new Error(error.response ? error.response.body : error); } } private async iGot() { const { iGotPushKey } = this.params; const url = `https://push.hellyw.com/${iGotPushKey.toLowerCase()}`; try { const res = await httpClient.post(url, { ...this.gotOption, body: `title=${this.title}&content=${this.content}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }); if (res.ret === 0) { return true; } else { throw new Error(JSON.stringify(res)); } } catch (error: any) { throw new Error(error.response ? error.response.body : error); } } private async pushPlus() { const { pushPlusToken, pushPlusUser, pushplusWebhook, pushPlusTemplate, pushplusChannel, pushplusCallbackUrl, pushplusTo, } = this.params; const url = `https://www.pushplus.plus/send`; try { let body = { ...this.gotOption, json: { token: `${pushPlusToken}`, title: `${this.title}`, content: `${this.content.replace(/[\n\r]/g, '
')}`, topic: `${pushPlusUser || ''}`, template: `${pushPlusTemplate || 'html'}`, channel: `${pushplusChannel || 'wechat'}`, webhook: `${pushplusWebhook || ''}`, callbackUrl: `${pushplusCallbackUrl || ''}`, to: `${pushplusTo || ''}`, }, }; const res = await httpClient.post(url, body); if (res.code === 200) { return true; } else { throw new Error(JSON.stringify(res)); } } catch (error: any) { throw new Error(error.response ? error.response.body : error); } } private async wePlusBot() { const { wePlusBotToken, wePlusBotReceiver, wePlusBotVersion } = this.params; let content = this.content; let template = 'txt'; if (this.content.length > 800) { template = 'html'; content = content.replace(/[\n\r]/g, '
'); } const url = `https://www.weplusbot.com/send`; try { const res = await httpClient.post(url, { ...this.gotOption, json: { token: `${wePlusBotToken}`, title: `${this.title}`, template: `${template}`, content: `${content}`, receiver: `${wePlusBotReceiver || ''}`, version: `${wePlusBotVersion || 'pro'}`, }, }); if (res.code === 200) { return true; } else { throw new Error(JSON.stringify(res)); } } catch (error: any) { throw new Error(error.response ? error.response.body : error); } } private async lark() { let { larkKey, larkSecret } = this.params; if (!larkKey.startsWith('http')) { larkKey = `https://open.feishu.cn/open-apis/bot/v2/hook/${larkKey}`; } const body: Record = { msg_type: 'text', content: { text: `${this.title}\n\n${this.content}` }, }; // Add signature if secret is provided // Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key // and signs an empty message, which differs from typical HMAC usage if (larkSecret) { const timestamp = Math.floor(Date.now() / 1000).toString(); const stringToSign = `${timestamp}\n${larkSecret}`; const hmac = crypto.createHmac('sha256', stringToSign); const sign = hmac.digest('base64'); body.timestamp = timestamp; body.sign = sign; } try { const res = await httpClient.post(larkKey, { ...this.gotOption, json: body, headers: { 'Content-Type': 'application/json' }, }); if (res.StatusCode === 0 || res.code === 0) { return true; } else { throw new Error(JSON.stringify(res)); } } catch (error: any) { throw new Error(error.response ? error.response.body : error); } } private async email() { const { emailPass, emailService, emailUser, emailTo } = this.params; try { const transporter = nodemailer.createTransport({ service: emailService, auth: { user: emailUser, pass: emailPass, }, }); const info = await transporter.sendMail({ from: `"青龙快讯" <${emailUser}>`, to: emailTo ? emailTo.split(';') : emailUser, subject: `${this.title}`, html: `${this.content.replace(/\n/g, '
')}`, }); transporter.close(); if (info.messageId) { return true; } else { throw new Error(JSON.stringify(info)); } } catch (error: any) { throw error; } } private async pushMe() { const { pushMeKey, pushMeUrl } = this.params; try { const res = await httpClient.post<'text'>( pushMeUrl || 'https://push.i-i.me/', { ...this.gotOption, json: { push_key: pushMeKey, title: this.title, content: this.content, }, headers: { 'Content-Type': 'application/json' }, }, ); if (res === 'success') { return true; } else { throw new Error(res); } } catch (error: any) { throw new Error(error.response ? error.response.body : error); } } private async ntfy() { const { ntfyUrl, ntfyTopic, ntfyPriority, ntfyToken, ntfyUsername, ntfyPassword, ntfyActions, } = this.params; // 编码函数 const encodeRfc2047 = (text: string, charset: string = 'UTF-8'): string => { const encodedText = Buffer.from(text).toString('base64'); return `=?${charset}?B?${encodedText}?=`; }; try { const headers: Record = { Title: encodeRfc2047(this.title), Priority: `${ntfyPriority || '3'}`, Icon: 'https://qn.whyour.cn/logo.png', }; if (ntfyToken) { headers['Authorization'] = `Bearer ${ntfyToken}`; } else if (ntfyUsername && ntfyPassword) { headers['Authorization'] = `Basic ${Buffer.from( `${ntfyUsername}:${ntfyPassword}`, ).toString('base64')}`; } if (ntfyActions) { headers['Actions'] = encodeRfc2047(ntfyActions); } const res = await httpClient.request( `${ntfyUrl || 'https://ntfy.sh'}/${ntfyTopic}`, { ...this.gotOption, body: `${this.content}`, headers: headers, method: 'POST', }, ); if (res.statusCode === 200) { return true; } else { throw new Error(await res.body.text()); } } catch (error: any) { throw new Error(error.response ? error.response.body : error); } } private async wxPusherBot() { const { wxPusherBotAppToken, wxPusherBotTopicIds, wxPusherBotUids } = this.params; // 处理 topicIds,将分号分隔的字符串转为数组 const topicIds = wxPusherBotTopicIds ? wxPusherBotTopicIds .split(';') .map((id) => id.trim()) .filter((id) => id) .map((id) => parseInt(id)) : []; // 处理 uids,将分号分隔的字符串转为数组 const uids = wxPusherBotUids ? wxPusherBotUids .split(';') .map((uid) => uid.trim()) .filter((uid) => uid) : []; // topic_ids 和 uids 至少要有一个 if (!topicIds.length && !uids.length) { throw new Error('wxPusher 服务的 TopicIds 和 Uids 至少配置一个才行'); } const url = `https://wxpusher.zjiecode.com/api/send/message`; try { const res = await httpClient.post(url, { ...this.gotOption, json: { appToken: wxPusherBotAppToken, content: `

${this.title}


${this.content}
`, summary: this.title, contentType: 2, topicIds: topicIds, uids: uids, verifyPayType: 0, }, }); if (res.code === 1000) { return true; } else { throw new Error(JSON.stringify(res)); } } catch (error: any) { throw new Error(error.response ? error.response.body : error); } } private async chronocat() { const { chronocatURL, chronocatQQ, chronocatToken } = this.params; try { const user_ids = chronocatQQ .match(/user_id=(\d+)/g) ?.map((match: any) => match.split('=')[1]); const group_ids = chronocatQQ .match(/group_id=(\d+)/g) ?.map((match: any) => match.split('=')[1]); const url = `${chronocatURL}/api/message/send`; const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${chronocatToken}`, }; for (const [chat_type, ids] of [ [1, user_ids], [2, group_ids], ]) { if (!ids) { continue; } let _ids: any = ids; for (const chat_id of _ids) { const data = { peer: { chatType: chat_type, peerUin: chat_id, }, elements: [ { elementType: 1, textElement: { content: `${this.title}\n\n${this.content}`, }, }, ], }; const res = await httpClient.request(url, { ...this.gotOption, json: data, headers, method: 'POST', }); if (res.statusCode === 200) { return true; } else { throw new Error(await res.body.text()); } } } return false; } catch (error: any) { throw new Error(error.response ? error.response.body : error); } } private async webhook() { const { webhookUrl, webhookBody, webhookHeaders, webhookMethod, webhookContentType, } = this.params; if (!webhookUrl?.includes('$title') && !webhookBody?.includes('$title')) { throw new Error('Url 或者 Body 中必须包含 $title'); } const headers = parseHeaders(webhookHeaders); const body = parseBody(webhookBody, webhookContentType, (v) => v?.replaceAll('$title', this.title)?.replaceAll('$content', this.content), ); const bodyParam = this.formatBody(webhookContentType, body); const options = { method: webhookMethod, headers, ...this.gotOption, allowGetBody: true, ...bodyParam, }; try { const formatUrl = webhookUrl ?.replaceAll('$title', encodeURIComponent(this.title)) ?.replaceAll('$content', encodeURIComponent(this.content)); const res = await httpClient.request(formatUrl, options); const text = await res.body.text(); if (String(res.statusCode).startsWith('20')) { return true; } else { throw new Error(await res.body.text()); } } catch (error: any) { throw new Error(error.response ? error.response.body : error); } } private formatBody(contentType: string, body: any): object { if (!body) return {}; switch (contentType) { case 'application/json': return { json: body }; case 'multipart/form-data': return { form: body }; case 'application/x-www-form-urlencoded': case 'text/plain': return { body }; } return {}; } } ================================================ FILE: back/services/open.ts ================================================ import { Service, Inject } from 'typedi'; import winston from 'winston'; import { createRandomString } from '../config/util'; import { App, AppModel } from '../data/open'; import { v4 as uuidV4 } from 'uuid'; import sequelize, { Op } from 'sequelize'; import { shareStore } from '../shared/store'; @Service() export default class OpenService { constructor(@Inject('logger') private logger: winston.Logger) {} public async findApps(): Promise { const docs = await this.find({}); return docs; } public async create(payload: App): Promise { const tab = { ...payload }; tab.client_id = createRandomString(12, 12); tab.client_secret = createRandomString(24, 24); const doc = await this.insert(tab); const apps = await this.find({}); await shareStore.updateApps(apps); return { ...doc, tokens: [] }; } public async insert(payload: App): Promise { const doc = await AppModel.create(payload, { returning: true }); return doc.get({ plain: true }); } public async update(payload: App): Promise { const newDoc = await this.updateDb({ name: payload.name, scopes: payload.scopes, id: payload.id, } as App); return { ...newDoc, tokens: [] }; } private async updateDb(payload: Partial): Promise { await AppModel.update(payload, { where: { id: payload.id } }); const apps = await this.find({}); await shareStore.updateApps(apps); return apps?.find((x) => x.id === payload.id) as App; } public async getDb(query: Record): Promise { const doc = await AppModel.findOne({ where: query }); if (!doc) { throw new Error(`App ${JSON.stringify(query)} not found`); } return doc.get({ plain: true }); } public async remove(ids: number[]) { await AppModel.destroy({ where: { id: ids } }); const apps = await this.find({}); await shareStore.updateApps(apps); } public async resetSecret(id: number): Promise { const tab: Partial = { client_secret: createRandomString(24, 24), tokens: [], id, }; // const doc = await this.get(id); // const tab = new App({ ...doc }); // tab.client_secret = createRandomString(24, 24); // tab.tokens = []; // const newDoc = await this.updateDb(tab); // return newDoc; const newDoc = await this.updateDb(tab); return newDoc; } public async list( searchText: string = '', sort: any = {}, query: Record = {}, ): Promise { let condition = { ...query }; if (searchText) { const encodeText = encodeURI(searchText); const reg = { [Op.or]: [ { [Op.like]: `%${searchText}%` }, { [Op.like]: `%${encodeText}%` }, ], }; condition = { ...condition, name: reg, }; } try { const result = await this.find(condition); return result .filter((x) => x.name !== 'system') .map((x) => ({ ...x, tokens: [] })); } catch (error) { throw error; } } private async find(query: Record, sort?: any): Promise { const docs = await AppModel.findAll({ where: { ...query } }); return docs.map((x) => x.get({ plain: true })); } public async authToken({ client_id, client_secret, }: { client_id: string; client_secret: string; }): Promise { let token = uuidV4(); const expiration = Math.round(Date.now() / 1000) + 2592000; // 2592000 30天 const doc = await AppModel.findOne({ where: { client_id, client_secret } }); if (doc) { const timestamp = Math.round(Date.now() / 1000); const invalidTokens = (doc.tokens || []).filter( (x) => x.expiration >= timestamp, ); let tokens = invalidTokens; if (invalidTokens.length >= 5) { tokens = [ ...invalidTokens.slice(0, 4), { ...invalidTokens[4], expiration }, ]; token = invalidTokens[4].value; } else { tokens = [...invalidTokens, { value: token, expiration }]; } await AppModel.update( { tokens }, { where: { client_id, client_secret } }, ); const apps = await this.find({}); await shareStore.updateApps(apps); return { code: 200, data: { token, token_type: 'Bearer', expiration, }, }; } else { return { code: 400, message: 'client_id 或 client_seret 有误' }; } } public async generateSystemToken(): Promise<{ value: string; expiration: number; }> { const apps = await shareStore.getApps(); const systemApp = apps?.find((x) => x.name === 'system'); if (!systemApp) { throw new Error('system app not found'); } const now = Math.round(Date.now() / 1000); const currentToken = systemApp.tokens?.find((x) => x.expiration > now); if (currentToken) { return currentToken; } const { data } = await this.authToken({ client_id: systemApp.client_id, client_secret: systemApp.client_secret, }); return { ...data, value: data.token, }; } } ================================================ FILE: back/services/schedule.ts ================================================ import { Service, Inject } from 'typedi'; import winston from 'winston'; import nodeSchedule from 'node-schedule'; import { ChildProcessWithoutNullStreams } from 'child_process'; import { ToadScheduler, LongIntervalJob, SimpleIntervalSchedule, Task, } from 'toad-scheduler'; import dayjs from 'dayjs'; import taskLimit from '../shared/pLimit'; import { spawn } from 'cross-spawn'; export interface ScheduleTaskType { id?: number; command: string; name?: string; schedule?: string; runOrigin: 'subscription' | 'system' | 'script'; } export interface TaskCallbacks { onBefore?: (startTime: dayjs.Dayjs) => Promise; onStart?: ( cp: ChildProcessWithoutNullStreams, startTime: dayjs.Dayjs, ) => Promise; onEnd?: ( cp: ChildProcessWithoutNullStreams, endTime: dayjs.Dayjs, diff: number, ) => Promise; onLog?: (message: string) => Promise; onError?: (message: string) => Promise; } @Service() export default class ScheduleService { private scheduleStacks = new Map(); private intervalSchedule = new ToadScheduler(); private taskLimitMap = { system: 'runWithSystemLimit' as const, script: 'runWithScriptLimit' as const, subscription: 'runWithSubscriptionLimit' as const, }; constructor(@Inject('logger') private logger: winston.Logger) {} async runTask( command: string, callbacks: TaskCallbacks = {}, params: { schedule?: string; name?: string; command?: string; id: string; runOrigin: 'subscription' | 'system' | 'script'; }, completionTime: 'start' | 'end' = 'end', ) { const { runOrigin, ...others } = params; return taskLimit[this.taskLimitMap[runOrigin]](others, () => { return new Promise(async (resolve, reject) => { this.logger.info( `[panel][开始执行任务] 参数: ${JSON.stringify({ ...others, command, })}`, ); try { const startTime = dayjs(); await callbacks.onBefore?.(startTime); const cp = spawn(command, { shell: '/bin/bash' }); callbacks.onStart?.(cp, startTime); completionTime === 'start' && resolve(cp.pid); cp.stdout.on('data', async (data) => { await callbacks.onLog?.(data.toString()); }); cp.stderr.on('data', async (data) => { this.logger.info( '[panel][执行任务失败] 命令: %s, 错误信息: %j', command, data.toString(), ); await callbacks.onError?.(data.toString()); }); cp.on('error', async (err) => { this.logger.error( '[panel][创建任务失败] 命令: %s, 错误信息: %j', command, err, ); await callbacks.onError?.(JSON.stringify(err)); }); cp.on('exit', async (code) => { this.logger.info( '[panel][执行任务结束] 参数: %s, 退出码: %j', JSON.stringify({ ...others, command, }), code, ); const endTime = dayjs(); await callbacks.onEnd?.( cp, endTime, endTime.diff(startTime, 'seconds'), ); resolve({ ...others, pid: cp.pid, code }); }); } catch (error) { this.logger.error( '[panel][执行任务失败] 命令: %s, 错误信息: %j', command, error, ); await callbacks.onError?.(JSON.stringify(error)); } }); }); } async createCronTask( { id = 0, command, name, schedule = '', runOrigin }: ScheduleTaskType, callbacks?: TaskCallbacks, runImmediately = false, ) { const _id = this.formatId(id); this.logger.info( '[panel][创建cron任务] 任务ID: %s, cron: %s, 任务名: %s, 执行命令: %s', _id, schedule, name, command, ); this.scheduleStacks.set( _id, nodeSchedule.scheduleJob(_id, schedule, async () => { this.runTask(command, callbacks, { name, schedule, command, id: _id, runOrigin, }); }), ); if (runImmediately) { this.runTask(command, callbacks, { name, schedule, command, id: _id, runOrigin, }); } } async cancelCronTask({ id = 0, name }: ScheduleTaskType) { const _id = this.formatId(id); this.logger.info('[panel][取消定时任务] 任务名: %s', name); if (this.scheduleStacks.has(_id)) { this.scheduleStacks.get(_id)?.cancel(); this.scheduleStacks.delete(_id); } } async createIntervalTask( { id = 0, command, name = '', runOrigin }: ScheduleTaskType, schedule: SimpleIntervalSchedule, runImmediately = true, callbacks?: TaskCallbacks, ) { const _id = this.formatId(id); this.logger.info( '[panel][创建interval任务] 任务ID: %s, 任务名: %s, 执行命令: %s', _id, name, command, ); const task = new Task( name, () => { this.runTask(command, callbacks, { name, command, id: _id, runOrigin, }); }, (err) => { this.logger.error( '[panel][执行任务失败] 命令: %s, 错误信息: %j', command, err, ); }, ); const job = new LongIntervalJob( { runImmediately: false, ...schedule }, task, { id: _id }, ); this.intervalSchedule.addIntervalJob(job); if (runImmediately) { this.runTask(command, callbacks, { name, command, id: _id, runOrigin, }); } } async cancelIntervalTask({ id = 0, name }: ScheduleTaskType) { const _id = this.formatId(id); this.logger.info( '[panel][取消interval任务] 任务ID: %s, 任务名: %s', _id, name, ); this.intervalSchedule.removeById(_id); } private formatId(id: number): string { return String(id); } } ================================================ FILE: back/services/script.ts ================================================ import { Service, Inject } from 'typedi'; import winston from 'winston'; import path, { join } from 'path'; import SockService from './sock'; import CronService from './cron'; import ScheduleService, { TaskCallbacks } from './schedule'; import config from '../config'; import { TASK_COMMAND } from '../config/const'; import { getFileContentByName, getPid, killTask, rmPath } from '../config/util'; import taskLimit from '../shared/pLimit'; @Service() export default class ScriptService { constructor( @Inject('logger') private logger: winston.Logger, private sockService: SockService, private cronService: CronService, private scheduleService: ScheduleService, ) {} private taskCallbacks(filePath: string): TaskCallbacks { return { onEnd: async (cp, endTime, diff) => { await rmPath(filePath); }, onError: async (message: string) => { this.sockService.sendMessage({ type: 'manuallyRunScript', message, }); }, onLog: async (message: string) => { this.sockService.sendMessage({ type: 'manuallyRunScript', message, }); }, }; } public async runScript(filePath: string) { const relativePath = path.relative(config.scriptPath, filePath); const command = `${TASK_COMMAND} ${relativePath} now`; const pid = await this.scheduleService.runTask( `real_time=true ${command}`, this.taskCallbacks(filePath), { command, id: relativePath.replace(/ /g, '-'), runOrigin: 'script' }, 'start', ); return { code: 200, data: pid }; } public async stopScript(filePath: string, pid: number) { if (!pid) { const relativePath = path.relative(config.scriptPath, filePath); taskLimit.removeQueuedCron(relativePath.replace(/ /g, '-')); pid = (await getPid(`${TASK_COMMAND} ${relativePath} now`)) as number; } try { await killTask(pid); } catch (error) {} return { code: 200 }; } public checkFilePath(filePath: string, fileName: string) { const finalPath = path.resolve(config.scriptPath, filePath, fileName); return finalPath.startsWith(config.scriptPath) ? finalPath : ''; } public async getFile(filePath: string, fileName: string) { const finalPath = this.checkFilePath(filePath, fileName); if (!finalPath) { return ''; } const content = await getFileContentByName(finalPath); return content; } } ================================================ FILE: back/services/sock.ts ================================================ import { Service, Inject } from 'typedi'; import winston from 'winston'; import { Connection } from 'sockjs'; import { SockMessage } from '../data/sock'; @Service() export default class SockService { private clients: Connection[] = []; constructor(@Inject('logger') private logger: winston.Logger) { } public getClients() { return this.clients; } public addClient(conn: Connection) { if (this.clients.indexOf(conn) === -1) { this.clients.push(conn); } } public removeClient(conn: Connection) { const index = this.clients.indexOf(conn); if (index !== -1) { this.clients.splice(index, 1); } } public sendMessage(msg: SockMessage) { this.clients.forEach((x) => { x.write(JSON.stringify(msg)); }); } } ================================================ FILE: back/services/sshKey.ts ================================================ import { Service, Inject } from 'typedi'; import winston from 'winston'; import fs from 'fs/promises'; import os from 'os'; import path from 'path'; import { Subscription } from '../data/subscription'; import { formatUrl } from '../config/subscription'; import config from '../config'; import { fileExist, rmPath } from '../config/util'; import { writeFileWithLock } from '../shared/utils'; @Service() export default class SshKeyService { private homedir = os.homedir(); private sshPath = config.sshdPath; private sshConfigFilePath = path.resolve(this.homedir, '.ssh', 'config'); private sshConfigHeader = `Include ${path.join(this.sshPath, '*.config')}`; constructor(@Inject('logger') private logger: winston.Logger) { this.initSshConfigFile(); } private async initSshConfigFile() { let config = ''; const _exist = await fileExist(this.sshConfigFilePath); if (_exist) { config = await fs.readFile(this.sshConfigFilePath, { encoding: 'utf-8' }); } else { await writeFileWithLock(this.sshConfigFilePath, '', { mode: '600' }); } if (!config.includes(this.sshConfigHeader)) { await writeFileWithLock( this.sshConfigFilePath, `${this.sshConfigHeader}\n\n${config}`, { mode: '600' }, ); } } private async generatePrivateKeyFile( alias: string, key: string, ): Promise { try { await writeFileWithLock( path.join(this.sshPath, alias), `${key}${os.EOL}`, { mode: '400', }, ); } catch (error) { this.logger.error('生成私钥文件失败', error); } } private async removePrivateKeyFile(alias: string): Promise { try { const filePath = path.join(this.sshPath, alias); await rmPath(filePath); } catch (error) { this.logger.error('删除私钥文件失败', error); } } private async generateSingleSshConfig( alias: string, host: string, proxy?: string, ) { if (host === 'github.com') { host = `ssh.github.com\n Port 443\n HostkeyAlgorithms +ssh-rsa`; } const proxyStr = proxy ? ` ProxyCommand nc -v -x ${proxy} %h %p 2>/dev/null\n` : ''; const config = `Host ${alias}\n Hostname ${host}\n IdentityFile ${path.join( this.sshPath, alias, )}\n StrictHostKeyChecking no\n${proxyStr}`; await writeFileWithLock( `${path.join(this.sshPath, `${alias}.config`)}`, config, { encoding: 'utf8', mode: '600', }, ); } private async removeSshConfig(alias: string) { try { const filePath = path.join(this.sshPath, `${alias}.config`); await rmPath(filePath); } catch (error) { this.logger.error(`删除ssh配置文件${alias}失败`, error); } } public async addSSHKey( key: string, alias: string, host: string, proxy?: string, ): Promise { await this.generatePrivateKeyFile(alias, key); await this.generateSingleSshConfig(alias, host, proxy); } public async removeSSHKey( alias: string, host: string, proxy?: string, ): Promise { await this.removePrivateKeyFile(alias); await this.removeSshConfig(alias); } public async setSshConfig(docs: Subscription[]) { for (const doc of docs) { if (doc.type === 'private-repo' && doc.pull_type === 'ssh-key') { const { alias, proxy } = doc; const { host } = formatUrl(doc); await this.removePrivateKeyFile(alias); await this.removeSshConfig(alias); await this.generatePrivateKeyFile( alias, (doc.pull_option as any).private_key, ); await this.generateSingleSshConfig(alias, host, proxy); } } } public async addGlobalSSHKey(key: string, alias: string): Promise { await this.generatePrivateKeyFile(`~global_${alias}`, key); // Create a global SSH config entry that matches all hosts // This allows the key to be used for any Git repository await this.generateGlobalSshConfig(`~global_${alias}`); } public async removeGlobalSSHKey(alias: string): Promise { await this.removePrivateKeyFile(`~global_${alias}`); await this.removeSshConfig(`~global_${alias}`); } private async generateGlobalSshConfig(alias: string) { // Create a config that matches all hosts, making this key globally available const config = `Host *\n IdentityFile ${path.join( this.sshPath, alias, )}\n StrictHostKeyChecking no\n`; await writeFileWithLock( `${path.join(this.sshPath, `${alias}.config`)}`, config, { encoding: 'utf8', mode: '600', }, ); } } ================================================ FILE: back/services/subscription.ts ================================================ import { Service, Inject } from 'typedi'; import winston from 'winston'; import config from '../config'; import { Subscription, SubscriptionInstance, SubscriptionModel, SubscriptionStatus, } from '../data/subscription'; import { ChildProcessWithoutNullStreams } from 'child_process'; import { getFileContentByName, concurrentRun, fileExist, createFile, killTask, handleLogPath, promiseExec, rmPath, } from '../config/util'; import fs from 'fs/promises'; import { FindOptions, Op } from 'sequelize'; import path, { join } from 'path'; import ScheduleService, { TaskCallbacks } from './schedule'; import { SimpleIntervalSchedule } from 'toad-scheduler'; import SockService from './sock'; import SshKeyService from './sshKey'; import dayjs from 'dayjs'; import { LOG_END_SYMBOL } from '../config/const'; import { formatCommand, formatUrl } from '../config/subscription'; import { CrontabModel } from '../data/cron'; import CrontabService from './cron'; import taskLimit from '../shared/pLimit'; import { logStreamManager } from '../shared/logStreamManager'; @Service() export default class SubscriptionService { constructor( @Inject('logger') private logger: winston.Logger, private scheduleService: ScheduleService, private sockService: SockService, private sshKeyService: SshKeyService, private crontabService: CrontabService, ) {} public async list( searchText?: string, ids?: string, ): Promise { let query = {}; const subIds = JSON.parse(ids || '[]'); if (searchText) { const reg = { [Op.or]: [ { [Op.like]: `%${searchText}%` }, { [Op.like]: `%${encodeURI(searchText)}%` }, ], }; query = { [Op.or]: [ { name: reg, }, { url: reg, }, ], }; } try { const result = await SubscriptionModel.findAll({ where: { ...query, ...(ids ? { id: subIds } : undefined) }, order: [ ['is_disabled', 'ASC'], ['createdAt', 'DESC'], ], }); return result; } catch (error) { throw error; } } public async handleTask( doc: Subscription, needCreate = true, runImmediately = false, ) { const { url } = formatUrl(doc); doc.command = formatCommand(doc, url as string); if (doc.schedule_type === 'crontab') { this.scheduleService.cancelCronTask(doc as any); needCreate && (await this.scheduleService.createCronTask( { ...doc, runOrigin: 'subscription' } as any, this.taskCallbacks(doc), runImmediately, )); } else if (doc.interval_schedule) { this.scheduleService.cancelIntervalTask(doc as any); const { type, value } = doc.interval_schedule; needCreate && (await this.scheduleService.createIntervalTask( { ...doc, runOrigin: 'subscription' } as any, { [type]: value } as SimpleIntervalSchedule, runImmediately, this.taskCallbacks(doc), )); } } public async setSshConfig() { const docs = await SubscriptionModel.findAll(); await this.sshKeyService.setSshConfig(docs); } private taskCallbacks(doc: Subscription): TaskCallbacks { return { onBefore: async (startTime) => { const logTime = startTime.format('YYYY-MM-DD-HH-mm-ss'); const logPath = `${doc.alias}/${logTime}.log`; await SubscriptionModel.update( { status: SubscriptionStatus.running, log_path: logPath, }, { where: { id: doc.id } }, ); const absolutePath = await handleLogPath( logPath as string, `## 开始执行... ${startTime.format('YYYY-MM-DD HH:mm:ss')}\n`, ); // 执行sub_before let beforeStr = ''; try { if (doc.sub_before) { await logStreamManager.write(absolutePath, `\n## 执行before命令...\n\n`); beforeStr = await promiseExec(doc.sub_before); } } catch (error: any) { beforeStr = (error.stderr && error.stderr.toString()) || JSON.stringify(error); } if (beforeStr) { await logStreamManager.write(absolutePath, `${beforeStr}\n`); } }, onStart: async (cp: ChildProcessWithoutNullStreams, startTime) => { await SubscriptionModel.update( { pid: cp.pid, }, { where: { id: doc.id } }, ); }, onEnd: async (cp, endTime, diff) => { const sub = await this.getDb({ id: doc.id }); const absolutePath = await handleLogPath(sub.log_path as string); // 执行 sub_after let afterStr = ''; try { if (sub.sub_after) { await logStreamManager.write(absolutePath, `\n\n## 执行after命令...\n\n`); afterStr = await promiseExec(sub.sub_after); } } catch (error: any) { afterStr = (error.stderr && error.stderr.toString()) || JSON.stringify(error); } if (afterStr) { await logStreamManager.write(absolutePath, `${afterStr}\n`); } await logStreamManager.write( absolutePath, `\n## 执行结束... ${endTime.format( 'YYYY-MM-DD HH:mm:ss', )} 耗时 ${diff} 秒${LOG_END_SYMBOL}`, ); // Close the stream after task completion await logStreamManager.closeStream(absolutePath); await SubscriptionModel.update( { status: SubscriptionStatus.idle, pid: undefined }, { where: { id: sub.id } }, ); this.sockService.sendMessage({ type: 'runSubscriptionEnd', message: '订阅执行完成', references: [doc.id as number], }); }, onError: async (message: string) => { const sub = await this.getDb({ id: doc.id }); const absolutePath = await handleLogPath(sub.log_path as string); await logStreamManager.write(absolutePath, `\n${message}`); }, onLog: async (message: string) => { const sub = await this.getDb({ id: doc.id }); const absolutePath = await handleLogPath(sub.log_path as string); await logStreamManager.write(absolutePath, `\n${message}`); }, }; } public async create(payload: Subscription): Promise { const tab = new Subscription(payload); const doc = await this.insert(tab); await this.handleTask(doc.get({ plain: true })); await this.setSshConfig(); return doc; } public async insert(payload: Subscription): Promise { return await SubscriptionModel.create(payload, { returning: true }); } public async update(payload: Subscription): Promise { const doc = await this.getDb({ id: payload.id }); const tab = new Subscription({ ...doc, ...payload }); const newDoc = await this.updateDb(tab); await this.handleTask(newDoc, !newDoc.is_disabled); await this.setSshConfig(); return newDoc; } public async updateDb(payload: Subscription): Promise { await SubscriptionModel.update(payload, { where: { id: payload.id } }); return await this.getDb({ id: payload.id }); } public async status({ ids, status, pid, log_path, last_running_time = 0, last_execution_time = 0, }: { ids: number[]; status: SubscriptionStatus; pid: number; log_path: string; last_running_time: number; last_execution_time: number; }) { const options: any = { status, pid, log_path, last_execution_time, }; if (last_running_time > 0) { options.last_running_time = last_running_time; } return await SubscriptionModel.update( { ...options }, { where: { id: ids } }, ); } public async remove(ids: number[], query: { force?: boolean }) { const docs = await SubscriptionModel.findAll({ where: { id: ids } }); for (const doc of docs) { await this.handleTask(doc.get({ plain: true }), false); } await SubscriptionModel.destroy({ where: { id: ids } }); await this.setSshConfig(); if (query?.force === true) { const crons = await CrontabModel.findAll({ where: { sub_id: ids } }); if (crons?.length) { await this.crontabService.remove(crons.map((x) => x.id!)); } for (const doc of docs) { const filePath = join(config.scriptPath, doc.alias); const repoPath = join(config.repoPath, doc.alias); await rmPath(filePath); await rmPath(repoPath); } } } public async getDb( query: FindOptions['where'], ): Promise { const doc = await SubscriptionModel.findOne({ where: { ...query } }); if (!doc) { throw new Error(`Subscription ${JSON.stringify(query)} not found`); } return doc.get({ plain: true }); } public async run(ids: number[]) { await SubscriptionModel.update( { status: SubscriptionStatus.queued }, { where: { id: ids } }, ); ids.forEach((id) => { this.runSingle(id); }); } public async stop(ids: number[]) { const docs = await SubscriptionModel.findAll({ where: { id: ids } }); for (const doc of docs) { if (doc.pid) { try { await killTask(doc.pid); } catch (error) { this.logger.error(error); } } } await SubscriptionModel.update( { status: SubscriptionStatus.idle, pid: undefined }, { where: { id: ids } }, ); } private async runSingle(subscriptionId: number) { const subscription = await this.getDb({ id: subscriptionId }); if (subscription.status !== SubscriptionStatus.queued) { return; } const command = formatCommand(subscription); this.scheduleService.runTask(command, this.taskCallbacks(subscription), { name: subscription.name, schedule: subscription.schedule, command, id: String(subscription.id), runOrigin: 'subscription', }); } public async disabled(ids: number[]) { await SubscriptionModel.update({ is_disabled: 1 }, { where: { id: ids } }); const docs = await SubscriptionModel.findAll({ where: { id: ids } }); await this.setSshConfig(); for (const doc of docs) { await this.handleTask(doc.get({ plain: true }), false); } } public async enabled(ids: number[]) { await SubscriptionModel.update({ is_disabled: 0 }, { where: { id: ids } }); const docs = await SubscriptionModel.findAll({ where: { id: ids } }); await this.setSshConfig(); for (const doc of docs) { await this.handleTask(doc.get({ plain: true })); } } public async log(id: number) { const doc = await this.getDb({ id }); if (!doc || !doc.log_path) { return ''; } const absolutePath = await handleLogPath(doc.log_path as string); return await getFileContentByName(absolutePath); } public async logs(id: number) { const doc = await this.getDb({ id }); if (!doc) { return []; } if (doc.log_path) { const relativeDir = path.dirname(`${doc.log_path}`); const dir = path.resolve(config.logPath, relativeDir); const _exist = await fileExist(dir); if (_exist) { let files = await fs.readdir(dir); return ( await Promise.all( files.map(async (x) => ({ filename: x, directory: relativeDir.replace(config.logPath, ''), time: (await fs.lstat(`${dir}/${x}`)).birthtimeMs, })), ) ).sort((a, b) => b.time - a.time); } } } } ================================================ FILE: back/services/system.ts ================================================ import { spawn } from 'cross-spawn'; import { Response } from 'express'; import fs from 'fs'; import { Agent, request } from 'undici'; import sum from 'lodash/sum'; import path from 'path'; import { Inject, Service } from 'typedi'; import winston from 'winston'; import config from '../config'; import { NotificationModeStringMap, TASK_COMMAND } from '../config/const'; import { getPid, killTask, parseContentVersion, parseVersion, promiseExec, readDirs, rmPath, setSystemTimezone, } from '../config/util'; import { DependenceModel, DependenceStatus, DependenceTypes, } from '../data/dependence'; import { NotificationInfo } from '../data/notify'; import { AuthDataType, SystemInfo, SystemInstance, SystemModel, SystemModelInfo, } from '../data/system'; import taskLimit from '../shared/pLimit'; import NotificationService from './notify'; import ScheduleService, { TaskCallbacks } from './schedule'; import SockService from './sock'; import os from 'os'; import dayjs from 'dayjs'; @Service() export default class SystemService { @Inject((type) => NotificationService) private notificationService!: NotificationService; constructor( @Inject('logger') private logger: winston.Logger, private scheduleService: ScheduleService, private sockService: SockService, ) { } public async getSystemConfig() { const doc = await this.getDb({ type: AuthDataType.systemConfig }); return { ...doc, info: { ...doc.info, timezone: doc.info?.timezone || 'Asia/Shanghai' }, }; } private async updateAuthDb(payload: SystemInfo): Promise { const { id, ...others } = payload; await SystemModel.update(others, { where: { id } }); const doc = await this.getDb({ id }); return doc; } public async getDb(query: any): Promise { const doc = await SystemModel.findOne({ where: query }); if (!doc) { throw new Error(`System ${JSON.stringify(query)} not found`); } return doc.get({ plain: true }); } public async updateNotificationMode(notificationInfo: NotificationInfo) { const code = Math.random().toString().slice(-6); const isSuccess = await this.notificationService.testNotify( notificationInfo, '青龙', `【蛟龙】测试通知 https://t.me/jiao_long`, ); if (isSuccess) { const result = await this.updateAuthDb({ type: AuthDataType.notification, info: { ...notificationInfo }, }); return { code: 200, data: { ...result, code } }; } else { return { code: 400, message: '通知发送失败,请检查参数' }; } } public async updateLogRemoveFrequency(info: SystemModelInfo) { const oDoc = await this.getSystemConfig(); const result = await this.updateAuthDb({ ...oDoc, info: { ...oDoc.info, ...info }, }); const cron = { id: result.id as number, name: '删除日志', command: `ql rmlog ${info.logRemoveFrequency}`, runOrigin: 'system' as const, }; if (oDoc.info?.logRemoveFrequency) { await this.scheduleService.cancelIntervalTask(cron); } if (info.logRemoveFrequency && info.logRemoveFrequency > 0) { this.scheduleService.createIntervalTask( cron, { days: info.logRemoveFrequency, }, true, ); } return { code: 200, data: info }; } public async updateCronConcurrency(info: SystemModelInfo) { const oDoc = await this.getSystemConfig(); await this.updateAuthDb({ ...oDoc, info: { ...oDoc.info, ...info }, }); if (info.cronConcurrency) { await taskLimit.setCustomLimit(info.cronConcurrency); } return { code: 200, data: info }; } public async updateDependenceProxy(info: SystemModelInfo) { const oDoc = await this.getSystemConfig(); await this.updateAuthDb({ ...oDoc, info: { ...oDoc.info, ...info }, }); if (info.dependenceProxy) { await fs.promises.writeFile( config.dependenceProxyFile, `export http_proxy="${info.dependenceProxy}"\nexport https_proxy="${info.dependenceProxy}"`, ); } else { await fs.promises.rm(config.dependenceProxyFile); } return { code: 200, data: info }; } public async updateNodeMirror(info: SystemModelInfo, res?: Response) { const oDoc = await this.getSystemConfig(); await this.updateAuthDb({ ...oDoc, info: { ...oDoc.info, ...info }, }); let cmd = 'pnpm config delete registry'; if (info.nodeMirror) { cmd = `pnpm config set registry ${info.nodeMirror}`; } let command = `cd && ${cmd}`; const docs = await DependenceModel.findAll({ where: { type: DependenceTypes.nodejs, status: DependenceStatus.installed, }, }); if (docs.length > 0) { command += ` && pnpm i -g`; } this.scheduleService.runTask( command, { onStart: async (cp) => { res?.setHeader('QL-Task-Pid', `${cp.pid}`); res?.end(); }, onEnd: async () => { this.sockService.sendMessage({ type: 'updateNodeMirror', message: 'update node mirror end', }); }, onError: async (message: string) => { this.sockService.sendMessage({ type: 'updateNodeMirror', message }); }, onLog: async (message: string) => { this.sockService.sendMessage({ type: 'updateNodeMirror', message }); }, }, { command, id: 'update-node-mirror', runOrigin: 'system', }, ); } public async updatePythonMirror(info: SystemModelInfo) { const oDoc = await this.getSystemConfig(); await this.updateAuthDb({ ...oDoc, info: { ...oDoc.info, ...info }, }); let cmd = 'pip config unset global.index-url'; if (info.pythonMirror) { cmd = `pip3 config set global.index-url ${info.pythonMirror}`; } await promiseExec(cmd); return { code: 200, data: info }; } public async updateLinuxMirror( info: SystemModelInfo, res?: Response, onEnd?: () => void, ) { const oDoc = await this.getSystemConfig(); await this.updateAuthDb({ ...oDoc, info: { ...oDoc.info, ...info }, }); let defaultDomain = 'https://dl-cdn.alpinelinux.org'; let targetDomain = 'https://dl-cdn.alpinelinux.org'; if (os.platform() !== 'linux') { return; } const content = await fs.promises.readFile('/etc/apk/repositories', { encoding: 'utf-8', }); const domainMatch = content.match(/(http.*)\/alpine\/.*/); if (domainMatch) { defaultDomain = domainMatch[1]; } if (info.linuxMirror) { targetDomain = info.linuxMirror; } const command = `sed -i 's/${defaultDomain.replace( /\//g, '\\/', )}/${targetDomain.replace( /\//g, '\\/', )}/g' /etc/apk/repositories && apk update -f`; this.scheduleService.runTask( command, { onStart: async (cp) => { res?.setHeader('QL-Task-Pid', `${cp.pid}`); res?.end(); }, onEnd: async () => { this.sockService.sendMessage({ type: 'updateLinuxMirror', message: 'update linux mirror end', }); onEnd?.(); }, onError: async (message: string) => { this.sockService.sendMessage({ type: 'updateLinuxMirror', message }); }, onLog: async (message: string) => { this.sockService.sendMessage({ type: 'updateLinuxMirror', message }); }, }, { command, id: 'update-linux-mirror', runOrigin: 'system', }, ); } public async checkUpdate() { try { const currentVersionContent = await parseVersion(config.versionFile); let lastVersionContent; try { const { body } = await request( `${config.lastVersionFile}?t=${Date.now()}`, { dispatcher: new Agent({ keepAliveTimeout: 30000, keepAliveMaxTimeout: 30000, }), }, ); const text = await body.text(); lastVersionContent = parseContentVersion(text); } catch (error) { } if (!lastVersionContent) { lastVersionContent = currentVersionContent; } return { code: 200, data: { hasNewVersion: this.checkHasNewVersion( currentVersionContent.version, lastVersionContent.version, ), lastVersion: lastVersionContent.version, lastLog: lastVersionContent.changeLog, lastLogLink: lastVersionContent.changeLogLink, }, }; } catch (error: any) { return { code: 400, message: error.message, }; } } private checkHasNewVersion(curVersion: string, lastVersion: string) { const curArr = curVersion.split('.').map((x) => parseInt(x, 10)); const lastArr = lastVersion.split('.').map((x) => parseInt(x, 10)); if (curArr[0] < lastArr[0]) { return true; } if (curArr[0] === lastArr[0] && curArr[1] < lastArr[1]) { return true; } if ( curArr[0] === lastArr[0] && curArr[1] === lastArr[1] && curArr[2] < lastArr[2] ) { return true; } return false; } public async updateSystem() { const cp = spawn('real_time=true ql update false', { shell: '/bin/bash' }); cp.stdout.on('data', (data) => { this.sockService.sendMessage({ type: 'updateSystemVersion', message: data.toString(), }); }); cp.stderr.on('data', (data) => { this.sockService.sendMessage({ type: 'updateSystemVersion', message: data.toString(), }); }); cp.on('error', (err) => { this.sockService.sendMessage({ type: 'updateSystemVersion', message: JSON.stringify(err), }); }); return { code: 200 }; } public async reloadSystem(target?: 'system' | 'data') { const cmd = `real_time=true ql reload ${target || ''}`; const cp = spawn(cmd, { shell: '/bin/bash', detached: true, stdio: 'ignore', }); cp.unref(); setTimeout(() => { process.exit(0); }); return { code: 200 }; } public async notify({ title, content, notificationInfo, }: { title: string; content: string; notificationInfo?: NotificationInfo; }) { const typeString = typeof notificationInfo?.type === 'number' ? NotificationModeStringMap[notificationInfo.type] : undefined; if (notificationInfo && typeString) { notificationInfo.type = typeString; } const isSuccess = await this.notificationService.notify( title, content, notificationInfo, ); if (isSuccess) { return { code: 200, message: '通知发送成功' }; } else { return { code: 400, message: '通知发送失败,请检查系统设置/通知配置' }; } } public async run({ command, logPath }: { command: string; logPath?: string }, callback: TaskCallbacks) { if (!command.startsWith(TASK_COMMAND)) { command = `${TASK_COMMAND} ${command}`; } const logPathPrefix = logPath ? `real_log_path=${logPath}` : '' this.scheduleService.runTask(`${logPathPrefix} real_time=true ${command}`, callback, { command, id: command.replace(/ /g, '-'), runOrigin: 'system', }); } public async stop({ command, pid }: { command: string; pid: number }) { if (!pid && !command) { return { code: 400, message: '参数错误' }; } if (pid) { await killTask(pid); return { code: 200 }; } if (!command.startsWith(TASK_COMMAND)) { command = `${TASK_COMMAND} ${command}`; } const _pid = await getPid(command); if (_pid) { await killTask(_pid); return { code: 200 }; } else { return { code: 400, message: '任务未找到' }; } } public async exportData(res: Response, type?: string[]) { try { let dataDirs = ['db', 'upload']; if (type && type.length) { dataDirs = dataDirs.concat(type.filter((x) => x !== 'base')); } const dataPaths = dataDirs.map((dir) => `data/${dir}`); await promiseExec( `cd ${config.dataPath} && cd ../ && tar -zcvf ${config.dataTgzFile } ${dataPaths.join(' ')}`, ); res.download(config.dataTgzFile); } catch (error: any) { return res.send({ code: 400, message: error.message }); } } public async importData() { try { await promiseExec(`rm -rf ${path.join(config.tmpPath, 'data')}`); const res = await promiseExec( `cd ${config.tmpPath} && tar -zxvf ${config.dataTgzFile}`, ); return { code: 200, data: res }; } catch (error: any) { return { code: 400, message: error.message }; } } public async getSystemLog( res: Response, query: { startTime?: string; endTime?: string; }, ) { const startTime = dayjs(query.startTime || undefined) .startOf('d') .valueOf(); const endTime = dayjs(query.endTime || undefined) .endOf('d') .valueOf(); const result = await readDirs(config.systemLogPath, config.systemLogPath); const logs = result .reverse() .filter((x) => x.title.endsWith('.log')) .filter((x) => x.createTime >= startTime && x.createTime <= endTime); res.set({ 'Content-Length': sum(logs.map((x) => x.size)), }); (function sendFiles(res, fileNames) { if (fileNames.length === 0) { res.end(); return; } const currentLog = fileNames.shift(); if (currentLog) { const currentFileStream = fs.createReadStream( path.join(config.systemLogPath, currentLog.title), ); currentFileStream.on('end', () => { sendFiles(res, fileNames); }); currentFileStream.pipe(res, { end: false }); } })(res, logs); } public async deleteSystemLog() { const result = await readDirs(config.systemLogPath, config.systemLogPath); const logs = result.reverse().filter((x) => x.title.endsWith('.log')); for (const log of logs) { await rmPath(path.join(config.systemLogPath, log.title)); } } public async updateTimezone(info: SystemModelInfo) { if (!info.timezone) { info.timezone = 'Asia/Shanghai'; } const oDoc = await this.getSystemConfig(); await this.updateAuthDb({ ...oDoc, info: { ...oDoc.info, ...info }, }); const success = await setSystemTimezone(info.timezone); if (success) { return { code: 200, data: info }; } else { return { code: 400, message: '设置时区失败' }; } } public async updateGlobalSshKey(info: SystemModelInfo) { const oDoc = await this.getSystemConfig(); const result = await this.updateAuthDb({ ...oDoc, info: { ...oDoc.info, ...info }, }); // Apply the global SSH key const SshKeyService = require('./sshKey').default; const Container = require('typedi').Container; const sshKeyService = Container.get(SshKeyService); if (info.globalSshKey) { await sshKeyService.addGlobalSSHKey(info.globalSshKey, 'global'); } else { await sshKeyService.removeGlobalSSHKey('global'); } return { code: 200, data: result }; } public async cleanDependence(type: 'node' | 'python3') { if (!type || !['node', 'python3'].includes(type)) { return { code: 400, message: '参数错误' }; } try { const finalPath = path.join(config.dependenceCachePath, type); await fs.promises.rm(finalPath, { recursive: true }); } catch (error) { } return { code: 200 }; } } ================================================ FILE: back/services/user.ts ================================================ import { Service, Inject } from 'typedi'; import winston from 'winston'; import { createRandomString } from '../config/util'; import config from '../config'; import jwt from 'jsonwebtoken'; import { authenticator } from '@otplib/preset-default'; import { AuthDataType, SystemInfo, SystemModel, SystemModelInfo, LoginStatus, AuthInfo, TokenInfo, } from '../data/system'; import { NotificationInfo } from '../data/notify'; import NotificationService from './notify'; import { Request } from 'express'; import ScheduleService from './schedule'; import SockService from './sock'; import dayjs from 'dayjs'; import IP2Region from 'ip2region'; import requestIp from 'request-ip'; import uniq from 'lodash/uniq'; import pickBy from 'lodash/pickBy'; import isNil from 'lodash/isNil'; import { shareStore } from '../shared/store'; @Service() export default class UserService { @Inject((type) => NotificationService) private notificationService!: NotificationService; constructor( @Inject('logger') private logger: winston.Logger, private scheduleService: ScheduleService, private sockService: SockService, ) {} public async login( payloads: { username: string; password: string; }, req: Request, needTwoFactor = true, ): Promise { let { username, password } = payloads; const content = await this.getAuthInfo(); const timestamp = Date.now(); let { username: cUsername, password: cPassword, retries = 0, lastlogon, lastip, lastaddr, twoFactorActivated, tokens = {}, platform, } = content; const retriesTime = Math.pow(3, retries) * 1000; if (retries > 2 && timestamp - lastlogon < retriesTime) { const waitTime = Math.ceil( (retriesTime - (timestamp - lastlogon)) / 1000, ); return { code: 410, message: `失败次数过多,请${waitTime}秒后重试`, data: waitTime, }; } if ( username === cUsername && password === cPassword && twoFactorActivated && needTwoFactor ) { await this.updateAuthInfo(content, { isTwoFactorChecking: true, }); return { code: 420, message: '', }; } const ip = requestIp.getClientIp(req) || ''; const query = new IP2Region(); const ipAddress = query.search(ip); let address = ''; if (ipAddress) { const { country, province, city, isp } = ipAddress; address = uniq([country, province, city, isp]).filter(Boolean).join(' '); } if (username === cUsername && password === cPassword) { const data = createRandomString(50, 100); const expiration = twoFactorActivated ? '60d' : '20d'; let token = jwt.sign({ data }, config.jwt.secret, { expiresIn: config.jwt.expiresIn || expiration, algorithm: 'HS384', }); const tokenInfo: TokenInfo = { value: token, timestamp, ip, address, platform: req.platform, }; const updatedTokens = this.addTokenToList( tokens, req.platform, tokenInfo, ); await this.updateAuthInfo(content, { token, tokens: updatedTokens, lastlogon: timestamp, retries: 0, lastip: ip, lastaddr: address, platform: req.platform, isTwoFactorChecking: false, }); this.notificationService.notify( '登录通知', `你于${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}在 ${address} ${ req.platform }端 登录成功,ip地址 ${ip}`, ); await this.insertDb({ type: AuthDataType.loginLog, info: { timestamp, address, ip, platform: req.platform, status: LoginStatus.success, }, }); this.getLoginLog(); return { code: 200, data: { token, lastip, lastaddr, lastlogon, retries, platform, }, }; } else { await this.updateAuthInfo(content, { retries: retries + 1, lastlogon: timestamp, lastip: ip, lastaddr: address, platform: req.platform, }); this.notificationService.notify( '登录通知', `你于${dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss')}在 ${address} ${ req.platform }端 登录失败,ip地址 ${ip}`, ); await this.insertDb({ type: AuthDataType.loginLog, info: { timestamp, address, ip, platform: req.platform, status: LoginStatus.fail, }, }); this.getLoginLog(); if (retries > 2) { const waitTime = Math.round(Math.pow(3, retries + 1)); return { code: 410, message: `失败次数过多,请${waitTime}秒后重试`, data: waitTime, }; } else { return { code: 400, message: config.authError }; } } } public async logout(platform: string, tokenValue: string): Promise { if (!platform || !tokenValue) { this.logger.warn('Invalid logout parameters - empty platform or token'); return; } const authInfo = await this.getAuthInfo(); // Verify the token exists before attempting to remove it const tokenExists = this.findTokenInList( authInfo.tokens, platform, tokenValue, ); if (!tokenExists && authInfo.token !== tokenValue) { // Token not found, but don't throw error - user may have already logged out this.logger.info( `Logout attempted for non-existent token on platform: ${platform}`, ); return; } const updatedTokens = this.removeTokenFromList( authInfo.tokens, platform, tokenValue, ); await this.updateAuthInfo(authInfo, { token: authInfo.token === tokenValue ? '' : authInfo.token, tokens: updatedTokens, }); } public async getLoginLog(): Promise> { const docs = await SystemModel.findAll({ where: { type: AuthDataType.loginLog }, }); if (docs && docs.length > 0) { const result = docs.sort( (a, b) => b.info!.timestamp! - a.info!.timestamp!, ); if (result.length > 100) { const ids = result.slice(100).map((x) => x.id!); await SystemModel.destroy({ where: { id: ids }, }); } return result.map((x) => x.info); } return []; } private async insertDb(payload: SystemInfo): Promise { const doc = await SystemModel.create({ ...payload }, { returning: true }); return doc; } public async updateUsernameAndPassword({ username, password, }: { username: string; password: string; }) { if (password === 'admin') { return { code: 400, message: '密码不能设置为admin' }; } const authInfo = await this.getAuthInfo(); await this.updateAuthInfo(authInfo, { username, password }); return { code: 200, message: '更新成功' }; } public async updateAvatar(avatar: string) { const authInfo = await this.getAuthInfo(); await this.updateAuthInfo(authInfo, { avatar }); return { code: 200, data: avatar, message: '更新成功' }; } public async initTwoFactor() { const secret = authenticator.generateSecret(); const authInfo = await this.getAuthInfo(); const otpauth = authenticator.keyuri(authInfo.username, 'qinglong', secret); await this.updateAuthInfo(authInfo, { twoFactorSecret: secret }); return { secret, url: otpauth }; } public async activeTwoFactor(code: string) { const authInfo = await this.getAuthInfo(); const isValid = authenticator.verify({ token: code, secret: authInfo.twoFactorSecret, }); if (isValid) { await this.updateAuthInfo(authInfo, { twoFactorActivated: true }); } return isValid; } public async twoFactorLogin( { username, password, code, }: { username: string; password: string; code: string }, req: any, ) { const authInfo = await this.getAuthInfo(); const { isTwoFactorChecking, twoFactorSecret } = authInfo; if (!isTwoFactorChecking) { return { code: 450, message: '未知错误' }; } const isValid = authenticator.verify({ token: code, secret: twoFactorSecret, }); if (isValid) { return this.login({ username, password }, req, false); } else { const ip = requestIp.getClientIp(req) || ''; const query = new IP2Region(); const ipAddress = query.search(ip); let address = ''; if (ipAddress) { const { country, province, city, isp } = ipAddress; address = uniq([country, province, city, isp]) .filter(Boolean) .join(' '); } await this.updateAuthInfo(authInfo, { lastip: ip, lastaddr: address, platform: req.platform, }); return { code: 430, message: '验证失败' }; } } public async deactiveTwoFactor() { const authInfo = await this.getAuthInfo(); await this.updateAuthInfo(authInfo, { twoFactorActivated: false, twoFactorSecret: '', }); return true; } public async getAuthInfo() { const authInfo = await shareStore.getAuthInfo(); if (authInfo) { return authInfo; } const doc = await this.getDb({ type: AuthDataType.authConfig }); return (doc.info || {}) as AuthInfo; } private async updateAuthInfo(authInfo: AuthInfo, info: Partial) { const result = { ...authInfo, ...info }; await shareStore.updateAuthInfo(result); await this.updateAuthDb({ type: AuthDataType.authConfig, info: result, }); } public async getNotificationMode(): Promise { const doc = await this.getDb({ type: AuthDataType.notification }); return (doc.info || {}) as NotificationInfo; } private async updateAuthDb(payload: SystemInfo): Promise { let doc = await SystemModel.findOne({ where: { type: payload.type } }); if (doc) { const updateResult = await SystemModel.update(payload, { where: { id: doc.id }, returning: true, }); doc = updateResult[1][0]; } else { doc = await SystemModel.create(payload, { returning: true }); } return doc; } public async getDb(query: any): Promise { const doc = await SystemModel.findOne({ where: { ...query } }); if (!doc) { throw new Error(`${JSON.stringify(query)} not found`); } return doc.get({ plain: true }); } public async updateNotificationMode(notificationInfo: NotificationInfo) { const code = Math.random().toString().slice(-6); const isSuccess = await this.notificationService.testNotify( notificationInfo, '青龙', `【蛟龙】测试通知 https://t.me/jiao_long`, ); if (isSuccess) { const result = await this.updateAuthDb({ type: AuthDataType.notification, info: { ...notificationInfo }, }); return { code: 200, data: { ...result, code } }; } else { return { code: 400, message: '通知发送失败,请检查参数' }; } } private normalizeTokens( tokens: Record, ): Record { const normalized: Record = {}; for (const [platform, value] of Object.entries(tokens)) { if (typeof value === 'string') { // Legacy format: convert string token to TokenInfo array if (value) { normalized[platform] = [ { value, timestamp: Date.now(), ip: '', address: '', platform, }, ]; } else { normalized[platform] = []; } } else { // Already in new format normalized[platform] = value || []; } } return normalized; } private addTokenToList( tokens: Record, platform: string, tokenInfo: TokenInfo, maxTokensPerPlatform: number = config.maxTokensPerPlatform, ): Record { // Validate maxTokensPerPlatform parameter if (!Number.isInteger(maxTokensPerPlatform) || maxTokensPerPlatform < 1) { this.logger.warn( `Invalid maxTokensPerPlatform value: ${maxTokensPerPlatform}, using default`, ); maxTokensPerPlatform = config.maxTokensPerPlatform; } const normalized = this.normalizeTokens(tokens); if (!normalized[platform]) { normalized[platform] = []; } // Add new token normalized[platform].unshift(tokenInfo); // Limit the number of active tokens per platform if (normalized[platform].length > maxTokensPerPlatform) { normalized[platform] = normalized[platform].slice( 0, maxTokensPerPlatform, ); } return normalized; } private removeTokenFromList( tokens: Record, platform: string, tokenValue: string, ): Record { const normalized = this.normalizeTokens(tokens); if (normalized[platform]) { normalized[platform] = normalized[platform].filter( (t) => t.value !== tokenValue, ); } return normalized; } private findTokenInList( tokens: Record, platform: string, tokenValue: string, ): TokenInfo | undefined { const normalized = this.normalizeTokens(tokens); if (normalized[platform]) { return normalized[platform].find((t) => t.value === tokenValue); } return undefined; } public async resetAuthInfo(info: Partial) { const { retries, twoFactorActivated, password, username } = info; const authInfo = await this.getAuthInfo(); const payload = pickBy( { retries, twoFactorActivated, password, username, }, (x) => !isNil(x), ); await this.updateAuthInfo(authInfo, payload); } } ================================================ FILE: back/shared/auth.ts ================================================ import { AuthInfo, TokenInfo } from '../data/system'; /** * Validates if a token exists in the authentication info. * Supports both legacy string tokens and new TokenInfo array format. * * @param authInfo - The authentication information * @param headerToken - The token to validate * @param platform - The platform (desktop, mobile) * @returns true if the token is valid, false otherwise */ export function isValidToken( authInfo: AuthInfo | null | undefined, headerToken: string, platform: string, ): boolean { if (!authInfo || !headerToken) { return false; } const { token = '', tokens = {} } = authInfo; // Check legacy token field if (headerToken === token) { return true; } // Check platform-specific tokens (support both legacy string and new TokenInfo[] format) const platformTokens = tokens[platform]; // Handle null/undefined platformTokens if (platformTokens === null || platformTokens === undefined) { return false; } if (typeof platformTokens === 'string') { // Legacy format: single string token return headerToken === platformTokens; } else if (Array.isArray(platformTokens)) { // New format: array of TokenInfo objects return platformTokens.some((t: TokenInfo) => t && t.value === headerToken); } // Unexpected type - log warning and reject return false; } ================================================ FILE: back/shared/interface.ts ================================================ import { Dependence } from '../data/dependence'; import { ICron } from '../protos/cron'; export type Override< T, K extends Partial<{ [P in keyof T]: any }> | string, > = K extends string ? Omit & { [P in keyof T]: T[P] | unknown } : Omit & K; export type TCron = Override, { id: string }>; export interface IDependencyFn { (): Promise; dependency?: Dependence; } export interface ICronFn { (): Promise; cron?: TCron; } export interface ISchedule { schedule?: string; name?: string; command?: string; id: string; } export interface IScheduleFn { (): Promise; schedule?: ISchedule; } ================================================ FILE: back/shared/logStreamManager.ts ================================================ import { createWriteStream, WriteStream } from 'fs'; import { EventEmitter } from 'events'; /** * Manages write streams for log files to improve performance by avoiding repeated file opens */ export class LogStreamManager extends EventEmitter { private streams: Map = new Map(); private pendingWrites: Map> = new Map(); /** * Write data to a log file using a managed stream * @param filePath - Absolute path to the log file * @param data - Data to write to the log file */ async write(filePath: string, data: string): Promise { // Wait for any pending writes to this file to complete const pending = this.pendingWrites.get(filePath); if (pending) { await pending; } // Create a new promise for this write operation const writePromise = new Promise((resolve, reject) => { let stream = this.streams.get(filePath); if (!stream) { // Create a new write stream if one doesn't exist stream = createWriteStream(filePath, { flags: 'a' }); this.streams.set(filePath, stream); // Handle stream errors stream.on('error', (error) => { this.emit('error', { filePath, error }); // Remove the stream from the map on error this.streams.delete(filePath); reject(error); }); } // Write the data const canContinue = stream.write(data, 'utf8', (error) => { if (error) { reject(error); } else { resolve(); } }); // Handle backpressure if (!canContinue) { stream.once('drain', () => { // Stream is ready for more data }); } }); this.pendingWrites.set(filePath, writePromise); try { await writePromise; } finally { this.pendingWrites.delete(filePath); } } /** * Close the stream for a specific file path * @param filePath - Absolute path to the log file */ async closeStream(filePath: string): Promise { // Wait for any pending writes to complete const pending = this.pendingWrites.get(filePath); if (pending) { await pending.catch(() => { // Ignore errors on pending writes during close }); } const stream = this.streams.get(filePath); if (stream) { return new Promise((resolve) => { stream.end(() => { this.streams.delete(filePath); resolve(); }); }); } } /** * Close all open streams */ async closeAll(): Promise { const closePromises = Array.from(this.streams.keys()).map((filePath) => this.closeStream(filePath), ); await Promise.all(closePromises); } /** * Get the number of open streams */ getOpenStreamCount(): number { return this.streams.size; } } // Export a singleton instance for shared use export const logStreamManager = new LogStreamManager(); ================================================ FILE: back/shared/pLimit.ts ================================================ import PQueue, { QueueAddOptions } from 'p-queue-cjs'; import os from 'os'; import { AuthDataType, SystemModel } from '../data/system'; import Logger from '../loaders/logger'; import { Dependence } from '../data/dependence'; import NotificationService from '../services/notify'; import { ICronFn, IDependencyFn, ISchedule, IScheduleFn, TCron, } from './interface'; import config from '../config'; import { credentials } from '@grpc/grpc-js'; import { ApiClient } from '../protos/api'; class TaskLimit { private dependenyLimit = new PQueue({ concurrency: 1 }); private queuedDependencyIds = new Set([]); private queuedCrons = new Map[]>(); private repeatCronNotifyMap = new Map(); private updateLogLimit = new PQueue({ concurrency: 1 }); private cronLimit = new PQueue({ concurrency: Math.max(os.cpus().length, 4), }); private manualCronoLimit = new PQueue({ concurrency: Math.max(os.cpus().length, 4), }); private subscriptionLimit = new PQueue({ concurrency: Math.max(os.cpus().length, 4), }); private scriptLimit = new PQueue({ concurrency: Math.max(os.cpus().length, 4), }); private systemLimit = new PQueue({ concurrency: Math.max(os.cpus().length, 4), }); private client = new ApiClient( `0.0.0.0:${config.grpcPort}`, credentials.createInsecure(), { 'grpc.enable_http_proxy': 0 }, ); get cronLimitActiveCount() { return this.cronLimit.pending; } get cronLimitPendingCount() { return this.cronLimit.size; } get firstDependencyId() { return [...this.queuedDependencyIds.values()][0]; } private notificationService: NotificationService = new NotificationService(); constructor() { this.setCustomLimit(); this.handleEvents(); } private handleEvents() { this.cronLimit.on('add', () => { Logger.info( `[schedule][任务加入队列] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`, ); }); this.cronLimit.on('active', () => { Logger.info( `[schedule][开始处理任务] 运行中任务数: ${ this.cronLimitActiveCount + 1 }, 等待中任务数: ${this.cronLimitPendingCount}`, ); }); this.cronLimit.on('completed', (param) => { Logger.info(`[schedule][任务处理成功] 参数 ${JSON.stringify(param)}`); }); this.cronLimit.on('error', (error) => { Logger.error(`[schedule][任务处理错误] 参数 ${JSON.stringify(error)}`); }); this.cronLimit.on('next', () => { Logger.info( `[schedule][任务处理结束] 运行中任务数: ${this.cronLimitActiveCount}, 等待中任务数: ${this.cronLimitPendingCount}`, ); }); this.cronLimit.on('idle', () => { Logger.info(`[schedule][任务队列] 空闲中...`); }); } public removeQueuedDependency(dependency: Dependence) { if (this.queuedDependencyIds.has(dependency.id!)) { this.queuedDependencyIds.delete(dependency.id!); } } public removeQueuedCron(id: string) { if (this.queuedCrons.has(id)) { const runs = this.queuedCrons.get(id); if (runs && runs.length > 0) { runs.pop(); this.queuedCrons.set(id, runs); } } } public async setCustomLimit(limit?: number) { if (limit) { this.cronLimit.concurrency = limit; this.manualCronoLimit.concurrency = limit; return; } await SystemModel.sync(); const doc = await SystemModel.findOne({ where: { type: AuthDataType.systemConfig }, }); if (doc?.info?.cronConcurrency) { this.cronLimit.concurrency = doc.info.cronConcurrency; this.manualCronoLimit.concurrency = doc.info.cronConcurrency; } } public async runWithCronLimit( cron: TCron, fn: ICronFn, options?: Partial, ): Promise { fn.cron = cron; let runs = this.queuedCrons.get(cron.id); const result = runs?.length ? [...runs, fn] : [fn]; const repeatTimes = this.repeatCronNotifyMap.get(cron.id) || 0; if (result?.length > 5) { if (repeatTimes < 3) { this.repeatCronNotifyMap.set(cron.id, repeatTimes + 1); this.client.systemNotify( { title: '任务重复运行', content: `任务:${cron.name},命令:${cron.command},定时:${cron.schedule},处于运行中的超过 5 个,请检查定时设置`, }, (err, res) => { if (err) { Logger.error( `[schedule][任务重复运行] 通知失败 ${JSON.stringify(err)}`, ); } }, ); } Logger.warn(`[schedule][任务重复运行] 参数 ${JSON.stringify(cron)}`); return; } this.queuedCrons.set(cron.id, result); return this.cronLimit.add(fn, options); } public async manualRunWithCronLimit( fn: () => Promise, options?: Partial, ): Promise { return this.manualCronoLimit.add(fn, options); } public async runWithSubscriptionLimit( schedule: TCron, fn: IScheduleFn, options?: Partial, ): Promise { fn.schedule = schedule; return this.subscriptionLimit.add(fn, options); } public async runWithSystemLimit( schedule: TCron, fn: IScheduleFn, options?: Partial, ): Promise { fn.schedule = schedule; return this.systemLimit.add(fn, options); } public async runWithScriptLimit( schedule: ISchedule, fn: IScheduleFn, options?: Partial, ): Promise { fn.schedule = schedule; return this.scriptLimit.add(fn, options); } public async waitDependencyQueueDone(): Promise { if (this.dependenyLimit.size === 0 && this.dependenyLimit.pending === 0) { return; } return new Promise((resolve) => { const onIdle = () => { this.dependenyLimit.removeListener('idle', onIdle); resolve(); }; this.dependenyLimit.on('idle', onIdle); }); } public runDependeny( dependency: Dependence, fn: IDependencyFn, options?: Partial, ): Promise { this.queuedDependencyIds.add(dependency.id!); fn.dependency = dependency; return this.dependenyLimit.add(fn, options); } public updateDepLog( fn: () => Promise, options?: Partial, ): Promise { return this.updateLogLimit.add(fn, options); } } export default new TaskLimit(); ================================================ FILE: back/shared/runCron.ts ================================================ import { spawn } from 'cross-spawn'; import taskLimit from './pLimit'; import Logger from '../loaders/logger'; import { ICron } from '../protos/cron'; import { CrontabModel, CrontabStatus } from '../data/cron'; import { killTask } from '../config/util'; export function runCron(cmd: string, cron: ICron): Promise { return taskLimit.runWithCronLimit(cron, () => { return new Promise(async (resolve: any) => { // Check if the cron is already running and stop it (only if multiple instances are not allowed) try { const existingCron = await CrontabModel.findOne({ where: { id: Number(cron.id) }, }); // Default to single instance mode (0) for backward compatibility const allowSingleInstances = existingCron?.allow_multiple_instances === 0; if ( allowSingleInstances && existingCron && existingCron.pid && (existingCron.status === CrontabStatus.running || existingCron.status === CrontabStatus.queued) ) { Logger.info( `[schedule][停止已运行任务] 任务ID: ${cron.id}, PID: ${existingCron.pid}`, ); await killTask(existingCron.pid); // Update the status to idle after killing await CrontabModel.update( { status: CrontabStatus.idle, pid: undefined }, { where: { id: Number(cron.id) } }, ); } } catch (error) { Logger.error( `[schedule][检查已运行任务失败] 任务ID: ${cron.id}, 错误: ${error}`, ); } Logger.info( `[schedule][开始执行任务] 参数 ${JSON.stringify({ ...cron, command: cmd, })}`, ); const cp = spawn(cmd, { shell: '/bin/bash' }); cp.stderr.on('data', (data) => { Logger.info( '[schedule][执行任务失败] 命令: %s, 错误信息: %j', cmd, data.toString(), ); }); cp.on('error', (err) => { Logger.error( '[schedule][创建任务失败] 命令: %s, 错误信息: %j', cmd, err, ); }); cp.on('exit', async (code) => { taskLimit.removeQueuedCron(cron.id); Logger.info( '[schedule][执行任务结束] 参数: %s, 退出码: %j', JSON.stringify({ ...cron, command: cmd, }), code, ); resolve({ ...cron, command: cmd, pid: cp.pid, code }); }); }); }); } ================================================ FILE: back/shared/store.ts ================================================ import { AuthInfo } from '../data/system'; import { App } from '../data/open'; import Keyv from 'keyv'; import KeyvSqlite from '@keyv/sqlite'; import config from '../config'; import path from 'path'; export enum EKeyv { 'apps' = 'apps', 'authInfo' = 'authInfo', } export interface IKeyvStore { apps: App[]; authInfo: AuthInfo; } const keyvSqlite = new KeyvSqlite(path.join(config.dbPath, 'keyv.sqlite')); export const keyvStore = new Keyv({ store: keyvSqlite }); export const shareStore = { getAuthInfo() { return keyvStore.get(EKeyv.authInfo); }, updateAuthInfo(value: IKeyvStore['authInfo']) { return keyvStore.set(EKeyv.authInfo, value); }, getApps() { return keyvStore.get(EKeyv.apps); }, updateApps(apps: App[]) { return keyvStore.set(EKeyv.apps, apps); }, }; ================================================ FILE: back/shared/utils.ts ================================================ import { lock } from 'proper-lockfile'; import os from 'os'; import path from 'path'; import { writeFile, open, chmod } from 'fs/promises'; import { fileExist } from '../config/util'; function getUniqueLockPath(filePath: string) { const sanitizedPath = filePath .replace(/[<>:"/\\|?*]/g, '_') .replace(/^_/, ''); return path.join(os.tmpdir(), `${sanitizedPath}.ql_lock`); } export async function writeFileWithLock( filePath: string, content: string, options: Parameters[2] = {}, ) { if (typeof options === 'string') { options = { encoding: options }; } if (!(await fileExist(filePath))) { const fileHandle = await open(filePath, 'w'); fileHandle.close(); } const lockfilePath = getUniqueLockPath(filePath); const release = await lock(filePath, { retries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 3000, }, lockfilePath, }); await writeFile(filePath, content, { encoding: 'utf8', ...options }); if (options?.mode) { await chmod(filePath, options.mode); } await release(); } ================================================ FILE: back/token.ts ================================================ import 'reflect-metadata'; import OpenService from './services/open'; import { Container } from 'typedi'; import LoggerInstance from './loaders/logger'; import config from './config'; import path from 'path'; import os from 'os'; import { writeFileWithLock } from './shared/utils'; const tokenFile = path.join(config.configPath, 'token.json'); async function getToken() { try { Container.set('logger', LoggerInstance); const openService = Container.get(OpenService); const appToken = await openService.generateSystemToken(); console.log(appToken.value); await writeFile({ value: appToken.value, expiration: appToken.expiration, }); } catch (error) { console.log(error); } } async function writeFile(data: any) { await writeFileWithLock(tokenFile, `${JSON.stringify(data)}${os.EOL}`); } getToken(); ================================================ FILE: back/tsconfig.json ================================================ { "compilerOptions": { "target": "es2017", "lib": ["ESNext"], "typeRoots": [ "./types", "../node_modules/celebrate/lib", "../node_modules/@types" ], "allowSyntheticDefaultImports": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "forceConsistentCasingInFileNames": true, "strict": true, "skipLibCheck": true, "moduleResolution": "node", "module": "commonjs", "pretty": true, "sourceMap": true, "outDir": "../static/build", "allowJs": true, "noEmit": false, "esModuleInterop": true }, "include": ["./**/*"], "exclude": ["node_modules"] } ================================================ FILE: back/types/express.d.ts ================================================ /// export {}; declare global { namespace Express { interface Request { platform: 'desktop' | 'mobile'; } } } ================================================ FILE: back/validation/schedule.ts ================================================ import { Joi } from 'celebrate'; import CronExpressionParser from 'cron-parser'; import { ScheduleType } from '../interface/schedule'; import path from 'path'; import config from '../config'; const validateSchedule = (value: string, helpers: any) => { if ( value.startsWith(ScheduleType.ONCE) || value.startsWith(ScheduleType.BOOT) ) { return value; } try { if (CronExpressionParser.parse(value).hasNext()) { return value; } } catch (e) { return helpers.error('any.invalid'); } return helpers.error('any.invalid'); }; export const scheduleSchema = Joi.string() .required() .custom(validateSchedule) .messages({ 'any.invalid': '无效的定时规则', 'string.empty': '定时规则不能为空', }); export const commonCronSchema = { name: Joi.string().optional(), command: Joi.string().required(), schedule: scheduleSchema, labels: Joi.array().optional(), sub_id: Joi.number().optional().allow(null), extra_schedules: Joi.array().optional().allow(null), task_before: Joi.string().optional().allow('').allow(null), task_after: Joi.string().optional().allow('').allow(null), log_name: Joi.string() .optional() .allow('') .allow(null) .custom((value, helpers) => { if (!value) return value; // Check if it's an absolute path if (value.startsWith('/')) { // Allow /dev/null as special case if (value === '/dev/null') { return value; } // For other absolute paths, ensure they are within the safe log directory const normalizedValue = path.normalize(value); const normalizedLogPath = path.normalize(config.logPath); if (!normalizedValue.startsWith(normalizedLogPath)) { return helpers.error('string.unsafePath'); } return value; } if ( !/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test( value, ) ) { return helpers.error('string.pattern.base'); } if (value.length > 100) { return helpers.error('string.max'); } return value; }) .messages({ 'string.pattern.base': '日志名称只能包含字母、数字、下划线和连字符', 'string.max': '日志名称不能超过100个字符', 'string.unsafePath': '绝对路径必须在日志目录内或使用 /dev/null', }), allow_multiple_instances: Joi.number().optional().valid(0, 1).allow(null), }; ================================================ FILE: docker/310.Dockerfile ================================================ FROM python:3.10-alpine3.18 AS builder COPY package.json .npmrc pnpm-lock.yaml /tmp/build/ RUN set -x \ && apk update \ && apk add nodejs npm git \ && npm i -g pnpm@8.3.1 pm2 ts-node \ && cd /tmp/build \ && pnpm install --prod FROM python:3.10-alpine ARG QL_MAINTAINER="whyour" LABEL maintainer="${QL_MAINTAINER}" ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git ARG QL_BRANCH=develop ARG PYTHON_SHORT_VERSION=3.10 ENV QL_DIR=/ql \ QL_BRANCH=${QL_BRANCH} \ LANG=C.UTF-8 \ SHELL=/bin/bash \ PS1="\u@\h:\w \$ " VOLUME /ql/data EXPOSE 5700 COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/ COPY --from=builder /usr/local/bin/. /usr/local/bin/ RUN set -x \ && apk update -f \ && apk upgrade \ && apk --no-cache add -f bash \ coreutils \ git \ curl \ wget \ tzdata \ perl \ openssl \ nodejs \ jq \ openssh \ procps \ netcat-openbsd \ unzip \ npm \ && rm -rf /var/cache/apk/* \ && apk update \ && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ && echo "Asia/Shanghai" > /etc/timezone \ && git config --global user.email "qinglong@users.noreply.github.com" \ && git config --global user.name "qinglong" \ && git config --global http.postBuffer 524288000 \ && rm -rf /root/.cache \ && ulimit -c 0 ARG SOURCE_COMMIT RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \ && cd ${QL_DIR} \ && cp -f .env.example .env \ && chmod 777 ${QL_DIR}/shell/*.sh \ && chmod 777 ${QL_DIR}/docker/*.sh \ && git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /static \ && mkdir -p ${QL_DIR}/static \ && cp -rf /static/* ${QL_DIR}/static \ && rm -rf /static ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \ PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \ PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \ HOME=/root ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \ NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \ PIP_CACHE_DIR=${PYTHON_HOME}/pip \ PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages RUN pip3 install --prefix ${PYTHON_HOME} requests COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/ WORKDIR ${QL_DIR} HEALTHCHECK --interval=5s --timeout=2s --retries=20 \ CMD curl -sf --noproxy '*' http://127.0.0.1:${QlPort:-5700}/api/health || exit 1 ENTRYPOINT ["./docker/docker-entrypoint.sh"] ================================================ FILE: docker/Dockerfile ================================================ FROM python:3.11-alpine3.18 AS builder COPY package.json .npmrc pnpm-lock.yaml /tmp/build/ RUN set -x \ && apk update \ && apk add nodejs npm git \ && npm i -g pnpm@8.3.1 pm2 ts-node \ && cd /tmp/build \ && pnpm install --prod FROM python:3.11-alpine ARG QL_MAINTAINER="whyour" LABEL maintainer="${QL_MAINTAINER}" ARG QL_URL=https://github.com/${QL_MAINTAINER}/qinglong.git ARG QL_BRANCH=develop ARG PYTHON_SHORT_VERSION=3.11 ENV QL_DIR=/ql \ QL_BRANCH=${QL_BRANCH} \ LANG=C.UTF-8 \ SHELL=/bin/bash \ PS1="\u@\h:\w \$ " VOLUME /ql/data EXPOSE 5700 COPY --from=builder /usr/local/lib/node_modules/. /usr/local/lib/node_modules/ COPY --from=builder /usr/local/bin/. /usr/local/bin/ RUN set -x \ && apk update -f \ && apk upgrade \ && apk --no-cache add -f bash \ coreutils \ git \ curl \ wget \ tzdata \ perl \ openssl \ nodejs \ jq \ openssh \ procps \ netcat-openbsd \ unzip \ npm \ && rm -rf /var/cache/apk/* \ && apk update \ && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ && echo "Asia/Shanghai" > /etc/timezone \ && git config --global user.email "qinglong@users.noreply.github.com" \ && git config --global user.name "qinglong" \ && git config --global http.postBuffer 524288000 \ && rm -rf /root/.cache \ && ulimit -c 0 ARG SOURCE_COMMIT RUN git clone --depth=1 -b ${QL_BRANCH} ${QL_URL} ${QL_DIR} \ && cd ${QL_DIR} \ && cp -f .env.example .env \ && chmod 777 ${QL_DIR}/shell/*.sh \ && chmod 777 ${QL_DIR}/docker/*.sh \ && git clone --depth=1 -b ${QL_BRANCH} https://github.com/${QL_MAINTAINER}/qinglong-static.git /static \ && mkdir -p ${QL_DIR}/static \ && cp -rf /static/* ${QL_DIR}/static \ && rm -rf /static ENV PNPM_HOME=${QL_DIR}/data/dep_cache/node \ PYTHON_HOME=${QL_DIR}/data/dep_cache/python3 \ PYTHONUSERBASE=${QL_DIR}/data/dep_cache/python3 \ HOME=/root ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PNPM_HOME}:${PYTHON_HOME}/bin:${HOME}/bin \ NODE_PATH=/usr/local/bin:/usr/local/lib/node_modules:${PNPM_HOME}/global/5/node_modules \ PIP_CACHE_DIR=${PYTHON_HOME}/pip \ PYTHONPATH=${PYTHON_HOME}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}:${PYTHON_HOME}/lib/python${PYTHON_SHORT_VERSION}/site-packages RUN pip3 install --prefix ${PYTHON_HOME} requests COPY --from=builder /tmp/build/node_modules/. /ql/node_modules/ WORKDIR ${QL_DIR} HEALTHCHECK --interval=5s --timeout=2s --retries=20 \ CMD curl -sf --noproxy '*' http://127.0.0.1:${QlPort:-5700}/api/health || exit 1 ENTRYPOINT ["./docker/docker-entrypoint.sh"] ================================================ FILE: docker/docker-compose.yml ================================================ services: web: image: whyour/qinglong:latest # 基于 Debian 的版本:whyour/qinglong:debian volumes: - ./data:/ql/data ports: - "5700:5700" environment: QlBaseUrl: '/' # 部署路径非必须,以斜杠开头和结尾,比如 /test/ restart: unless-stopped ================================================ FILE: docker/docker-entrypoint.sh ================================================ #!/bin/bash dir_shell=/ql/shell . $dir_shell/share.sh export_ql_envs() { export BACK_PORT="${ql_port}" export GRPC_PORT="${ql_grpc_port}" } log_with_style() { local level="$1" local message="$2" local timestamp=$(date '+%Y-%m-%d %H:%M:%S') printf "\n[%s] [%7s] %s\n" "${timestamp}" "${level}" "${message}" } # Fix DNS resolution issues in Alpine Linux # Alpine uses musl libc which has known DNS resolver issues with certain domains # Adding ndots:0 prevents unnecessary search domain appending if [ -f /etc/alpine-release ]; then if ! grep -q "^options ndots:0" /etc/resolv.conf 2>/dev/null; then echo "options ndots:0" >> /etc/resolv.conf log_with_style "INFO" "🔧 0. 已配置 DNS 解析优化 (ndots:0)" fi fi log_with_style "INFO" "🚀 1. 检测配置文件..." load_ql_envs export_ql_envs . $dir_shell/env.sh import_config "$@" fix_config # Try to initialize PM2, but don't fail if it doesn't work pm2 l &>/dev/null || log_with_style "WARN" "PM2 初始化可能失败,将在启动时尝试使用备用方案" log_with_style "INFO" "⚙️ 2. 启动 pm2 服务..." reload_pm2 if [[ $AutoStartBot == true ]]; then log_with_style "INFO" "🤖 3. 启动 bot..." nohup ql bot >$dir_log/bot.log 2>&1 & fi if [[ $EnableExtraShell == true ]]; then log_with_style "INFO" "🛠️ 4. 执行自定义脚本..." nohup ql extra >$dir_log/extra.log 2>&1 & fi log_with_style "SUCCESS" "🎉 容器启动成功!" crond -f >/dev/null exec "$@" ================================================ FILE: ecosystem.config.js ================================================ module.exports = { apps: [ { name: 'qinglong', max_restarts: 5, kill_timeout: 1000, wait_ready: true, listen_timeout: 5000, source_map_support: true, time: true, script: 'static/build/app.js', env: { http_proxy: '', https_proxy: '', HTTP_PROXY: '', HTTPS_PROXY: '', all_proxy: '', ALL_PROXY: '', }, }, ], }; ================================================ FILE: nodemon.json ================================================ { "watch": [ "back", ".env" ], "ext": "js,ts,json", "env": { "NODE_ENV": "development", "TS_NODE_PROJECT": "./back/tsconfig.json" }, "verbose": true, "execMap": { "ts": "node --require ts-node/register" } } ================================================ FILE: package.json ================================================ { "private": true, "packageManager": "pnpm@8.3.1", "scripts": { "start": "concurrently -n w: npm:start:*", "start:back": "nodemon ./back/app.ts", "start:front": "max dev", "build:front": "max build", "build:back": "tsc -p back/tsconfig.json", "panel": "npm run build:back && node static/build/app.js", "gen:proto": "protoc --experimental_allow_proto3_optional --plugin=./node_modules/.bin/protoc-gen-ts_proto ./back/protos/*.proto --ts_proto_out=./ --ts_proto_opt=outputServices=grpc-js,env=node,esModuleInterop=true,snakeToCamel=false", "prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'", "postinstall": "max setup 2>/dev/null || true", "test": "umi-test", "test:coverage": "umi-test --coverage" }, "gitHooks": { "pre-commit": "lint-staged" }, "lint-staged": { "*.{js,jsx,less,md,json}": [ "prettier --write" ], "*.ts?(x)": [ "prettier --parser=typescript --write" ] }, "pnpm": { "peerDependencyRules": { "ignoreMissing": [ "react", "react-dom", "antd", "dva", "postcss", "webpack", "eslint", "stylelint", "redux", "@babel/core", "monaco-editor", "rc-field-form", "@types/lodash.merge", "rollup", "styled-components" ], "allowedVersions": { "react": "18", "react-dom": "18", "dva-core": "2" } }, "overrides": { "sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3" } }, "dependencies": { "@grpc/grpc-js": "^1.14.0", "@grpc/proto-loader": "^0.8.0", "@otplib/preset-default": "^12.0.1", "body-parser": "^1.20.3", "celebrate": "^15.0.3", "chokidar": "^4.0.1", "cors": "^2.8.5", "cron-parser": "^5.4.0", "cross-spawn": "^7.0.6", "dayjs": "^1.11.13", "dotenv": "^16.4.6", "express": "^4.21.2", "express-jwt": "^8.4.1", "express-rate-limit": "^7.4.1", "express-urlrewrite": "^2.0.3", "undici": "^7.9.0", "hpagent": "^1.2.0", "http-proxy-middleware": "^3.0.3", "iconv-lite": "^0.6.3", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", "multer": "2.1.1", "node-schedule": "^2.1.0", "nodemailer": "^8.0.1", "p-queue-cjs": "7.3.4", "@bufbuild/protobuf": "^2.10.0", "ps-tree": "^1.2.0", "reflect-metadata": "^0.2.2", "sequelize": "^6.37.5", "sockjs": "^0.3.24", "sqlite3": "git+https://github.com/whyour/node-sqlite3.git#v1.0.3", "toad-scheduler": "^3.0.1", "typedi": "^0.10.0", "uuid": "^11.0.3", "winston": "^3.17.0", "winston-daily-rotate-file": "^5.0.0", "request-ip": "3.3.0", "ip2region": "2.3.0", "keyv": "^5.2.3", "@keyv/sqlite": "^4.0.1", "proper-lockfile": "^4.1.2", "compression": "^1.7.4", "helmet": "^8.1.0" }, "devDependencies": { "moment": "2.30.1", "@ant-design/icons": "^5.0.1", "@ant-design/pro-layout": "6.38.22", "@codemirror/view": "^6.34.1", "@codemirror/state": "^6.4.1", "@monaco-editor/react": "4.2.1", "@react-hook/resize-observer": "^2.0.2", "react-router-dom": "6.26.1", "@types/body-parser": "^1.19.2", "@types/cors": "^2.8.12", "@types/cross-spawn": "^6.0.2", "@types/express": "^4.17.13", "@types/express-jwt": "^6.0.4", "@types/file-saver": "2.0.2", "@types/js-yaml": "^4.0.5", "@types/jsonwebtoken": "^8.5.8", "@types/lodash": "^4.14.185", "@types/multer": "^1.4.7", "@types/node": "^17.0.21", "@types/node-schedule": "^1.3.2", "@types/nodemailer": "^6.4.4", "@types/qrcode.react": "^1.0.2", "@types/react": "^18.0.20", "@types/react-copy-to-clipboard": "^5.0.4", "@types/react-dom": "^18.0.6", "@types/serve-handler": "^6.1.1", "@types/sockjs": "^0.3.33", "@types/sockjs-client": "^1.5.1", "@types/uuid": "^8.3.4", "@types/request-ip": "0.0.41", "@types/proper-lockfile": "^4.1.4", "@types/ps-tree": "^1.1.6", "@uiw/codemirror-extensions-langs": "^4.21.9", "@uiw/react-codemirror": "^4.21.9", "@umijs/max": "^4.4.4", "@umijs/ssr-darkreader": "^4.9.45", "ahooks": "^3.7.8", "ansi-to-react": "^6.1.6", "antd": "^4.24.16", "antd-img-crop": "^4.23.0", "axios": "^1.4.0", "compression-webpack-plugin": "9.2.0", "concurrently": "^7.0.0", "react-hotkeys-hook": "^4.6.1", "file-saver": "2.0.2", "lint-staged": "^13.0.3", "monaco-editor": "0.33.0", "nodemon": "^3.0.1", "prettier": "^2.5.1", "pretty-bytes": "6.1.1", "qiniu": "^7.4.0", "qrcode.react": "^1.0.1", "query-string": "^7.1.1", "rc-tween-one": "^3.0.6", "rc-virtual-list": "3.15.0", "react": "18.3.1", "react-copy-to-clipboard": "^5.1.0", "react-diff-viewer": "^3.1.1", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "18.3.1", "react-intl-universal": "^2.12.0", "react-split-pane": "^0.1.92", "sockjs-client": "^1.6.0", "ts-node": "^10.9.2", "ts-proto": "^2.6.1", "tslib": "^2.4.0", "typescript": "5.2.2", "vh-check": "^2.0.5", "virtualizedtableforantd4": "1.3.0", "@types/compression": "^1.7.2", "@types/helmet": "^4.0.0" } } ================================================ FILE: sample/auth.sample.json ================================================ { "username": "admin", "password": "admin" } ================================================ FILE: sample/config.sample.sh ================================================ ## 在运行 ql repo 命令时,是否自动删除失效的脚本与定时任务 AutoDelCron="true" ## 在运行 ql repo 命令时,是否自动增加新的本地定时任务 AutoAddCron="true" ## 拉取脚本时默认的定时规则,当匹配不到定时规则时使用,例如: 0 9 * * * DefaultCronRule="" ## ql repo命令拉取脚本时需要拉取的文件后缀,直接写文件后缀名即可 RepoFileExtensions="js mjs py pyc" ## 代理地址,支持HTTP/SOCK5,例如 http://127.0.0.1:7890 ProxyUrl="" ## 资源告警阙值,默认CPU 80%、内存80%、磁盘90% CpuWarn=80 MemoryWarn=80 DiskWarn=90 ## 设置定时任务执行的超时时间,例如1h,后缀"s"代表秒(默认值), "m"代表分, "h"代表小时, "d"代表天 CommandTimeoutTime="" ## 在运行 task 命令时,随机延迟启动任务的最大延迟时间,如 RandomDelay="300" ,表示任务将在 1-300 秒内随机延迟一个秒数,然后再运行,取消延迟赋值为空 RandomDelay="" ## 需要随机延迟运行任务的文件后缀,直接写后缀名即可,多个后缀用空格分开,例如: js py ts ## 默认仅给javascript任务加随机延迟,其它任务按定时规则准点运行。全部任务随机延迟赋值为空 RandomDelayFileExtensions="" ## 每小时的第几分钟准点运行任务,当在这些时间运行任务时将忽略 RandomDelay 配置,不会被随机延迟 ## 默认是第0分钟和第30分钟,例如21:00或21:30分的任务将会准点运行。不需要准点运行赋值为空 RandomDelayIgnoredMinutes="" ## 如果你自己会写shell脚本,并且希望在每次容器启动时,额外运行你的 shell 脚本,请赋值为 "true" EnableExtraShell="" ## 是否自动启动bot,默认不启动,设置为true时自动启动,目前需要自行克隆bot仓库所需代码,存到ql/repo目录下,文件夹命名为dockerbot AutoStartBot="" ## 是否使用第三方bot,默认不使用,使用时填入仓库地址,存到ql/repo目录下,文件夹命名为diybot BotRepoUrl="" ## 通知环境变量 ## 1. Server酱 ## https://sct.ftqq.com/r/13363 ## 下方填写 SCHKEY 值或 SendKey 值 export PUSH_KEY="" ## 2. BARK ## 下方填写app提供的设备码,例如:https://api.day.app/123 那么此处的设备码就是123 export BARK_PUSH="" ## 下方填写推送图标设置,自定义推送图标(需iOS15或以上) export BARK_ICON="https://qn.whyour.cn/logo.png" ## 下方填写推送声音设置,例如choo,具体值请在bark-推送铃声-查看所有铃声 export BARK_SOUND="" ## 下方填写推送消息分组,默认为"QingLong" export BARK_GROUP="QingLong" ## bark 推送时效性 export BARK_LEVEL="active" ## bark 推送是否存档 export BARK_ARCHIVE="" ## bark 推送跳转 URL export BARK_URL="" ## 3. Telegram ## 下方填写自己申请@BotFather的Token,如10xxx4:AAFcqxxxxgER5uw export TG_BOT_TOKEN="" ## 下方填写 @getuseridbot 中获取到的纯数字ID export TG_USER_ID="" ## Telegram 代理IP(选填) ## 下方填写代理IP地址,代理类型为 http,比如您代理是 http://127.0.0.1:1080,则填写 "127.0.0.1" ## 如需使用,请自行解除下一行的注释 export TG_PROXY_HOST="" ## Telegram 代理端口(选填) ## 下方填写代理端口号,代理类型为 http,比如您代理是 http://127.0.0.1:1080,则填写 "1080" ## 如需使用,请自行解除下一行的注释 export TG_PROXY_PORT="" ## Telegram 代理的认证参数(选填) export TG_PROXY_AUTH="" ## Telegram api自建反向代理地址(选填) ## 教程:https://www.hostloc.com/thread-805441-1-1.html ## 如反向代理地址 http://aaa.bbb.ccc 则填写 aaa.bbb.ccc ## 如需使用,请赋值代理地址链接,并自行解除下一行的注释 export TG_API_HOST="" ## 4. 钉钉 ## 官方文档:https://developers.dingtalk.com/document/app/custom-robot-access ## 下方填写token后面的内容,只需 https://oapi.dingtalk.com/robot/send?access_token=XXX 等于=符号后面的XXX即可 export DD_BOT_TOKEN="" export DD_BOT_SECRET="" ## 企业微信反向代理地址 ## (环境变量名 QYWX_ORIGIN) export QYWX_ORIGIN="" ## 5. 企业微信机器人 ## 官方说明文档:https://work.weixin.qq.com/api/doc/90000/90136/91770 ## 下方填写密钥,企业微信推送 webhook 后面的 key export QYWX_KEY="" ## 6. 企业微信应用 ## 参考文档:http://note.youdao.com/s/HMiudGkb ## 下方填写素材库图片id(corpid,corpsecret,touser,agentid),素材库图片填0为图文消息, 填1为纯文本消息 export QYWX_AM="" ## 7. iGot聚合 ## 参考文档:https://wahao.github.io/Bark-MP-helper ## 下方填写iGot的推送key,支持多方式推送,确保消息可达 export IGOT_PUSH_KEY="" ## 8. Push Plus ## 官方网站:http://www.pushplus.plus ## 下方填写您的Token,微信扫码登录后一对一推送或一对多推送下面的token,只填 PUSH_PLUS_TOKEN 默认为一对一推送 export PUSH_PLUS_TOKEN="" ## 一对一多推送(选填) ## 下方填写您的一对多推送的 "群组编码" ,(一对多推送下面->您的群组(如无则新建)->群组编码) ## 1. 需订阅者扫描二维码 2、如果您是创建群组所属人,也需点击“查看二维码”扫描绑定,否则不能接受群组消息推送 export PUSH_PLUS_USER="" ## 发送模板,支持html,txt,json,markdown,cloudMonitor,jenkins,route,pay export PUSH_PLUS_TEMPLATE="html" ## 发送渠道,支持wechat,webhook,cp,mail,sms export PUSH_PLUS_CHANNEL="wechat" ## webhook编码,可在pushplus公众号上扩展配置出更多渠道 export PUSH_PLUS_WEBHOOK="" ## 发送结果回调地址,会把推送最终结果通知到这个地址上 export PUSH_PLUS_CALLBACKURL="" ## 好友令牌,微信公众号渠道填写好友令牌,企业微信渠道填写企业微信用户id export PUSH_PLUS_TO="" ## 9. 微加机器人 ## 官方网站:http://www.weplusbot.com ## 下方填写您的Token;微信扫描登录后在"我的"->"设置"->"令牌"中获取 export WE_PLUS_BOT_TOKEN="" ## 消息接收人; ## 个人版填写接收消息的群编码,不填发送给自己的微信号 ## 专业版不填默认发给机器人自己,发送给好友填写wxid,发送给微信群填写群编码 export WE_PLUS_BOT_RECEIVER="" ## 调用版本;分为专业版和个人版,专业版填写pro,个人版填写personal export WE_PLUS_BOT_VERSION="pro" ## 10. go-cqhttp ## gobot_url 推送到个人QQ: http://127.0.0.1/send_private_msg 群:http://127.0.0.1/send_group_msg ## gobot_token 填写在go-cqhttp文件设置的访问密钥 ## gobot_qq 如果GOBOT_URL设置 /send_private_msg 则需要填入 user_id=个人QQ 相反如果是 /send_group_msg 则需要填入 group_id=QQ群 ## go-cqhttp相关API https://docs.go-cqhttp.org/api export GOBOT_URL="" export GOBOT_TOKEN="" export GOBOT_QQ="" ## 11. gotify ## gotify_url 填写gotify地址,如https://push.example.de:8080 ## gotify_token 填写gotify的消息应用token ## gotify_priority 填写推送消息优先级,默认为0 export GOTIFY_URL="" export GOTIFY_TOKEN="" export GOTIFY_PRIORITY=0 ## 12. PushDeer ## deer_key 填写PushDeer的key export DEER_KEY="" ## 13. Chat ## chat_url 填写synology chat地址,http://IP:PORT/webapi/***token= ## chat_token 填写后面的token export CHAT_URL="" export CHAT_TOKEN="" ## 14. aibotk ## 官方说明文档:http://wechat.aibotk.com/oapi/oapi?from=ql ## aibotk_key (必填)填写智能微秘书个人中心的apikey export AIBOTK_KEY="" ## aibotk_type (必填)填写发送的目标 room 或 contact, 填其他的不生效 export AIBOTK_TYPE="" ## aibotk_name (必填)填写群名或用户昵称,和上面的type类型要对应 export AIBOTK_NAME="" ## 15. CHRONOCAT ## CHRONOCAT_URL 推送 http://127.0.0.1:16530 ## CHRONOCAT_TOKEN 填写在CHRONOCAT文件生成的访问密钥 ## CHRONOCAT_QQ 个人:user_id=个人QQ 群则填入group_id=QQ群 多个用英文;隔开同时支持个人和群 如:user_id=xxx;group_id=xxxx;group_id=xxxxx ## CHRONOCAT相关API https://chronocat.vercel.app/install/docker/official/ export CHRONOCAT_URL="" export CHRONOCAT_QQ="" export CHRONOCAT_TOKEN="" ## 16. SMTP ## JavaScript 参数 ## 邮箱服务名称,比如126、163、Gmail、QQ等,支持列表 https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json export SMTP_SERVICE="" ## Python 参数 ## SMTP 发送邮件服务器,形如 smtp.exmail.qq.com:465 export SMTP_SERVER="" ## SMTP 发送邮件服务器是否使用 SSL,填写 true 或 false export SMTP_SSL="" ## smtp_email 填写 SMTP 收发件邮箱,通知将会由自己发给自己 export SMTP_EMAIL="" ## smtp_password 填写 SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定 export SMTP_PASSWORD="" ## smtp_name 填写 SMTP 收发件人姓名,可随意填写 export SMTP_NAME="" ## 17. PushMe ## 官方说明文档:https://push.i-i.me/ ## PUSHME_KEY (必填)填写PushMe APP上获取的push_key ## PUSHME_URL (选填)填写自建的PushMeServer消息服务接口地址,例如:http://127.0.0.1:3010,不填则使用官方接口服务 export PUSHME_KEY="" export PUSHME_URL="" ## 18. 飞书机器人 ## 官方文档:https://www.feishu.cn/hc/zh-CN/articles/360024984973 ## FSKEY 飞书机器人的 FSKEY export FSKEY="" ## 19. Qmsg酱 ## 官方文档:https://qmsg.zendee.cn/docs/api/ ## qmsg 酱的 QMSG_KEY ## qmsg 酱的 QMSG_TYPE send 为私聊,group 为群聊 export QMSG_KEY="" export QMSG_TYPE="" ## 20.Ntfy ## 官方文档: https://docs.ntfy.sh ## ntfy_url 填写ntfy地址,如https://ntfy.sh ## ntfy_topic 填写ntfy的消息应用topic ## ntfy_priority 填写推送消息优先级,默认为3 ## ntfy_token 填写推送token,可选 ## ntfy_username 填写推送用户名称,可选 ## ntfy_password 填写推送用户密码,可选 ## ntfy_actions 填写推送用户动作,可选 export NTFY_URL="" export NTFY_TOPIC="" export NTFY_PRIORITY="3" export NTFY_TOKEN="" export NTFY_USERNAME="" export NTFY_PASSWORD="" export NTFY_ACTIONS="" ## 21. wxPusher ## 官方文档: https://wxpusher.zjiecode.com/docs/ ## 管理后台: https://wxpusher.zjiecode.com/admin/ ## wxPusher 的 appToken export WXPUSHER_APP_TOKEN="" ## wxPusher 的 topicIds,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行 export WXPUSHER_TOPIC_IDS="" ## wxPusher 的 用户ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行 export WXPUSHER_UIDS="" ## 22. 自定义通知 ## 自定义通知 接收回调的URL export WEBHOOK_URL="" ## WEBHOOK_BODY 和 WEBHOOK_HEADERS 多个参数时,直接换行或者使用 $'\n' 连接多行字符串,比如 export dd="line 1"$'\n'"line 2" export WEBHOOK_BODY="" export WEBHOOK_HEADERS="" ## 支持 GET/POST/PUT export WEBHOOK_METHOD="" ## 支持 text/plain、application/json、multipart/form-data、application/x-www-form-urlencoded export WEBHOOK_CONTENT_TYPE="" ## 其他需要的变量,脚本中需要的变量使用 export 变量名= 声明即可 ================================================ FILE: sample/extra.sample.sh ================================================ #!/usr/bin/env bash ## 添加你需要重启自动执行的任意命令,比如 ql repo ## 安装node依赖使用 pnpm add -g xxx xxx ## 安装python依赖使用 pip3 install xxx ================================================ FILE: sample/notify.js ================================================ const querystring = require('node:querystring'); const { request: undiciRequest, ProxyAgent, FormData } = require('undici'); const timeout = 15000; async function request(url, options = {}) { const { json, form, body, headers = {}, ...rest } = options; const finalHeaders = { ...headers }; let finalBody = body; if (json) { finalHeaders['content-type'] = 'application/json'; finalBody = JSON.stringify(json); } else if (form) { finalBody = form; delete finalHeaders['content-type']; } return undiciRequest(url, { headers: finalHeaders, body: finalBody, ...rest, }); } function post(url, options = {}) { return request(url, { ...options, method: 'POST' }); } function get(url, options = {}) { return request(url, { ...options, method: 'GET' }); } const httpClient = { request, post, get, }; const push_config = { HITOKOTO: true, // 启用一言(随机句子) BARK_PUSH: '', // bark IP 或设备码,例:https://api.day.app/DxHcxxxxxRxxxxxxcm/ BARK_ARCHIVE: '', // bark 推送是否存档 BARK_GROUP: '', // bark 推送分组 BARK_SOUND: '', // bark 推送声音 BARK_ICON: '', // bark 推送图标 BARK_LEVEL: '', // bark 推送时效性 BARK_URL: '', // bark 推送跳转URL DD_BOT_SECRET: '', // 钉钉机器人的 DD_BOT_SECRET DD_BOT_TOKEN: '', // 钉钉机器人的 DD_BOT_TOKEN FSKEY: '', // 飞书机器人的 FSKEY FSSECRET: '', // 飞书机器人的 FSSECRET,对应安全设置里的签名校验密钥 // 推送到个人QQ:http://127.0.0.1/send_private_msg // 群:http://127.0.0.1/send_group_msg GOBOT_URL: '', // go-cqhttp // 推送到个人QQ 填入 user_id=个人QQ // 群 填入 group_id=QQ群 GOBOT_QQ: '', // go-cqhttp 的推送群或用户 GOBOT_TOKEN: '', // go-cqhttp 的 access_token GOTIFY_URL: '', // gotify地址,如https://push.example.de:8080 GOTIFY_TOKEN: '', // gotify的消息应用token GOTIFY_PRIORITY: 0, // 推送消息优先级,默认为0 IGOT_PUSH_KEY: '', // iGot 聚合推送的 IGOT_PUSH_KEY,例如:https://push.hellyw.com/XXXXXXXX PUSH_KEY: '', // server 酱的 PUSH_KEY,兼容旧版与 Turbo 版 DEER_KEY: '', // PushDeer 的 PUSHDEER_KEY DEER_URL: '', // PushDeer 的 PUSHDEER_URL CHAT_URL: '', // synology chat url CHAT_TOKEN: '', // synology chat token // 官方文档:https://www.pushplus.plus/ PUSH_PLUS_TOKEN: '', // pushplus 推送的用户令牌 PUSH_PLUS_USER: '', // pushplus 推送的群组编码 PUSH_PLUS_TEMPLATE: 'html', // pushplus 发送模板,支持html,txt,json,markdown,cloudMonitor,jenkins,route,pay PUSH_PLUS_CHANNEL: 'wechat', // pushplus 发送渠道,支持wechat,webhook,cp,mail,sms PUSH_PLUS_WEBHOOK: '', // pushplus webhook编码,可在pushplus公众号上扩展配置出更多渠道 PUSH_PLUS_CALLBACKURL: '', // pushplus 发送结果回调地址,会把推送最终结果通知到这个地址上 PUSH_PLUS_TO: '', // pushplus 好友令牌,微信公众号渠道填写好友令牌,企业微信渠道填写企业微信用户id // 微加机器人,官方网站:https://www.weplusbot.com/ WE_PLUS_BOT_TOKEN: '', // 微加机器人的用户令牌 WE_PLUS_BOT_RECEIVER: '', // 微加机器人的消息接收人 WE_PLUS_BOT_VERSION: 'pro', //微加机器人调用版本,pro和personal;为空默认使用pro(专业版),个人版填写:personal QMSG_KEY: '', // qmsg 酱的 QMSG_KEY QMSG_TYPE: '', // qmsg 酱的 QMSG_TYPE QYWX_ORIGIN: 'https://qyapi.weixin.qq.com', // 企业微信代理地址 /* 此处填你企业微信应用消息的值(详见文档 https://work.weixin.qq.com/api/doc/90000/90135/90236) 环境变量名 QYWX_AM依次填入 corpid,corpsecret,touser(注:多个成员ID使用|隔开),agentid,消息类型(选填,不填默认文本消息类型) 注意用,号隔开(英文输入法的逗号),例如:wwcff56746d9adwers,B-791548lnzXBE6_BWfxdf3kSTMJr9vFEPKAbh6WERQ,mingcheng,1000001,2COXgjH2UIfERF2zxrtUOKgQ9XklUqMdGSWLBoW_lSDAdafat 可选推送消息类型(推荐使用图文消息(mpnews)): - 文本卡片消息: 0 (数字零) - 文本消息: 1 (数字一) - 图文消息(mpnews): 素材库图片id, 可查看此教程(http://note.youdao.com/s/HMiudGkb)或者(https://note.youdao.com/ynoteshare1/index.html?id=1a0c8aff284ad28cbd011b29b3ad0191&type=note) */ QYWX_AM: '', // 企业微信应用 QYWX_KEY: '', // 企业微信机器人的 webhook(详见文档 https://work.weixin.qq.com/api/doc/90000/90136/91770),例如:693a91f6-7xxx-4bc4-97a0-0ec2sifa5aaa TG_BOT_TOKEN: '', // tg 机器人的 TG_BOT_TOKEN,例:1407203283:AAG9rt-6RDaaX0HBLZQq0laNOh898iFYaRQ TG_USER_ID: '', // tg 机器人的 TG_USER_ID,例:1434078534 TG_API_HOST: 'https://api.telegram.org', // tg 代理 api TG_PROXY_AUTH: '', // tg 代理认证参数 TG_PROXY_HOST: '', // tg 机器人的 TG_PROXY_HOST TG_PROXY_PORT: '', // tg 机器人的 TG_PROXY_PORT AIBOTK_KEY: '', // 智能微秘书 个人中心的apikey 文档地址:http://wechat.aibotk.com/docs/about AIBOTK_TYPE: '', // 智能微秘书 发送目标 room 或 contact AIBOTK_NAME: '', // 智能微秘书 发送群名 或者好友昵称和type要对应好 SMTP_SERVICE: '', // 邮箱服务名称,比如 126、163、Gmail、QQ 等,支持列表 https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json SMTP_EMAIL: '', // SMTP 发件邮箱 SMTP_TO: '', // SMTP 收件邮箱,默认通知将会发给发件邮箱 SMTP_PASSWORD: '', // SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定 SMTP_NAME: '', // SMTP 收发件人姓名,可随意填写 PUSHME_KEY: '', // 官方文档:https://push.i-i.me,PushMe 酱的 PUSHME_KEY // CHRONOCAT API https://chronocat.vercel.app/install/docker/official/ CHRONOCAT_QQ: '', // 个人: user_id=个人QQ 群则填入 group_id=QQ群 多个用英文;隔开同时支持个人和群 CHRONOCAT_TOKEN: '', // 填写在CHRONOCAT文件生成的访问密钥 CHRONOCAT_URL: '', // Red 协议连接地址 例: http://127.0.0.1:16530 WEBHOOK_URL: '', // 自定义通知 请求地址 WEBHOOK_BODY: '', // 自定义通知 请求体 WEBHOOK_HEADERS: '', // 自定义通知 请求头 WEBHOOK_METHOD: '', // 自定义通知 请求方法 WEBHOOK_CONTENT_TYPE: '', // 自定义通知 content-type NTFY_URL: '', // ntfy地址,如https://ntfy.sh,默认为https://ntfy.sh NTFY_TOPIC: '', // ntfy的消息应用topic NTFY_PRIORITY: '3', // 推送消息优先级,默认为3 NTFY_TOKEN: '', // 推送token,可选 NTFY_USERNAME: '', // 推送用户名称,可选 NTFY_PASSWORD: '', // 推送用户密码,可选 NTFY_ACTIONS: '', // 推送用户动作,可选 // 官方文档: https://wxpusher.zjiecode.com/docs/ // 管理后台: https://wxpusher.zjiecode.com/admin/ WXPUSHER_APP_TOKEN: '', // wxpusher 的 appToken WXPUSHER_TOPIC_IDS: '', // wxpusher 的 主题ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行 WXPUSHER_UIDS: '', // wxpusher 的 用户ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行 }; for (const key in push_config) { const v = process.env[key]; if (v) { push_config[key] = v; } } const $ = { post: (params, callback) => { const { url, ...others } = params; httpClient.post(url, others).then( async (res) => { let body = await res.body.text(); try { body = JSON.parse(body); } catch (error) {} callback(null, res, body); }, (err) => { callback(err?.response?.body || err); }, ); }, get: (params, callback) => { const { url, ...others } = params; httpClient.get(url, others).then( async (res) => { let body = await res.body.text(); try { body = JSON.parse(body); } catch (error) {} callback(null, res, body); }, (err) => { callback(err?.response?.body || err); }, ); }, logErr: console.log, }; async function one() { const url = 'https://v1.hitokoto.cn/'; const res = await httpClient.request(url); const body = await res.body.json(); return `${body.hitokoto} ----${body.from}`; } function gotifyNotify(text, desp) { return new Promise((resolve) => { const { GOTIFY_URL, GOTIFY_TOKEN, GOTIFY_PRIORITY } = push_config; if (GOTIFY_URL && GOTIFY_TOKEN) { const options = { url: `${GOTIFY_URL}/message?token=${GOTIFY_TOKEN}`, body: `title=${encodeURIComponent(text)}&message=${encodeURIComponent( desp, )}&priority=${GOTIFY_PRIORITY}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }; $.post(options, (err, resp, data) => { try { if (err) { console.log('Gotify 发送通知调用API失败😞\n', err); } else { if (data.id) { console.log('Gotify 发送通知消息成功🎉\n'); } else { console.log(`Gotify 发送通知调用API失败😞 ${data.message}\n`); } } } catch (e) { $.logErr(e, resp); } finally { resolve(); } }); } else { resolve(); } }); } function gobotNotify(text, desp) { return new Promise((resolve) => { const { GOBOT_URL, GOBOT_TOKEN, GOBOT_QQ } = push_config; if (GOBOT_URL) { const options = { url: `${GOBOT_URL}?access_token=${GOBOT_TOKEN}&${GOBOT_QQ}`, json: { message: `${text}\n${desp}` }, headers: { 'Content-Type': 'application/json', }, timeout, }; $.post(options, (err, resp, data) => { try { if (err) { console.log('Go-cqhttp 通知调用API失败😞\n', err); } else { if (data.retcode === 0) { console.log('Go-cqhttp 发送通知消息成功🎉\n'); } else if (data.retcode === 100) { console.log(`Go-cqhttp 发送通知消息异常 ${data.errmsg}\n`); } else { console.log(`Go-cqhttp 发送通知消息异常 ${JSON.stringify(data)}`); } } } catch (e) { $.logErr(e, resp); } finally { resolve(data); } }); } else { resolve(); } }); } function serverNotify(text, desp) { return new Promise((resolve) => { const { PUSH_KEY } = push_config; if (PUSH_KEY) { // 微信server酱推送通知一个\n不会换行,需要两个\n才能换行,故做此替换 desp = desp.replace(/[\n\r]/g, '\n\n'); const matchResult = PUSH_KEY.match(/^sctp(\d+)t/i); const options = { url: matchResult && matchResult[1] ? `https://${matchResult[1]}.push.ft07.com/send/${PUSH_KEY}.send` : `https://sctapi.ftqq.com/${PUSH_KEY}.send`, body: `text=${encodeURIComponent(text)}&desp=${encodeURIComponent( desp, )}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, timeout, }; $.post(options, (err, resp, data) => { try { if (err) { console.log('Server 酱发送通知调用API失败😞\n', err); } else { // server酱和Server酱·Turbo版的返回json格式不太一样 if (data.errno === 0 || data.data.errno === 0) { console.log('Server 酱发送通知消息成功🎉\n'); } else if (data.errno === 1024) { // 一分钟内发送相同的内容会触发 console.log(`Server 酱发送通知消息异常 ${data.errmsg}\n`); } else { console.log(`Server 酱发送通知消息异常 ${JSON.stringify(data)}`); } } } catch (e) { $.logErr(e, resp); } finally { resolve(data); } }); } else { resolve(); } }); } function pushDeerNotify(text, desp) { return new Promise((resolve) => { const { DEER_KEY, DEER_URL } = push_config; if (DEER_KEY) { // PushDeer 建议对消息内容进行 urlencode desp = encodeURI(desp); const options = { url: DEER_URL || `https://api2.pushdeer.com/message/push`, body: `pushkey=${DEER_KEY}&text=${text}&desp=${desp}&type=markdown`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, timeout, }; $.post(options, (err, resp, data) => { try { if (err) { console.log('PushDeer 通知调用API失败😞\n', err); } else { // 通过返回的result的长度来判断是否成功 if ( data.content.result.length !== undefined && data.content.result.length > 0 ) { console.log('PushDeer 发送通知消息成功🎉\n'); } else { console.log( `PushDeer 发送通知消息异常😞 ${JSON.stringify(data)}`, ); } } } catch (e) { $.logErr(e, resp); } finally { resolve(data); } }); } else { resolve(); } }); } function chatNotify(text, desp) { return new Promise((resolve) => { const { CHAT_URL, CHAT_TOKEN } = push_config; if (CHAT_URL && CHAT_TOKEN) { // 对消息内容进行 urlencode desp = encodeURI(desp); const options = { url: `${CHAT_URL}${CHAT_TOKEN}`, body: `payload={"text":"${text}\n${desp}"}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }; $.post(options, (err, resp, data) => { try { if (err) { console.log('Chat 发送通知调用API失败😞\n', err); } else { if (data.success) { console.log('Chat 发送通知消息成功🎉\n'); } else { console.log(`Chat 发送通知消息异常 ${JSON.stringify(data)}`); } } } catch (e) { $.logErr(e); } finally { resolve(data); } }); } else { resolve(); } }); } function barkNotify(text, desp, params = {}) { return new Promise((resolve) => { let { BARK_PUSH, BARK_ICON, BARK_SOUND, BARK_GROUP, BARK_LEVEL, BARK_ARCHIVE, BARK_URL, } = push_config; if (BARK_PUSH) { // 兼容BARK本地用户只填写设备码的情况 if (!BARK_PUSH.startsWith('http')) { BARK_PUSH = `https://api.day.app/${BARK_PUSH}`; } const options = { url: `${BARK_PUSH}`, json: { title: text, body: desp, icon: BARK_ICON, sound: BARK_SOUND, group: BARK_GROUP, isArchive: BARK_ARCHIVE, level: BARK_LEVEL, url: BARK_URL, ...params, }, headers: { 'Content-Type': 'application/json', }, timeout, }; $.post(options, (err, resp, data) => { try { if (err) { console.log('Bark APP 发送通知调用API失败😞\n', err); } else { if (data.code === 200) { console.log('Bark APP 发送通知消息成功🎉\n'); } else { console.log(`Bark APP 发送通知消息异常 ${data.message}\n`); } } } catch (e) { $.logErr(e, resp); } finally { resolve(); } }); } else { resolve(); } }); } function tgBotNotify(text, desp) { return new Promise((resolve) => { const { TG_BOT_TOKEN, TG_USER_ID, TG_PROXY_HOST, TG_PROXY_PORT, TG_API_HOST, TG_PROXY_AUTH, } = push_config; if (TG_BOT_TOKEN && TG_USER_ID) { let options = { url: `${TG_API_HOST}/bot${TG_BOT_TOKEN}/sendMessage`, json: { chat_id: `${TG_USER_ID}`, text: `${text}\n\n${desp}`, disable_web_page_preview: true, }, headers: { 'Content-Type': 'application/json', }, timeout, }; if (TG_PROXY_HOST && TG_PROXY_PORT) { let proxyHost = TG_PROXY_HOST; if (TG_PROXY_AUTH && !TG_PROXY_HOST.includes('@')) { proxyHost = `${TG_PROXY_AUTH}@${TG_PROXY_HOST}`; } let agent; agent = new ProxyAgent({ uri: `http://${proxyHost}:${TG_PROXY_PORT}`, }); options.dispatcher = agent; } $.post(options, (err, resp, data) => { try { if (err) { console.log('Telegram 发送通知消息失败😞\n', err); } else { if (data.ok) { console.log('Telegram 发送通知消息成功🎉。\n'); } else if (data.error_code === 400) { console.log( '请主动给bot发送一条消息并检查接收用户ID是否正确。\n', ); } else if (data.error_code === 401) { console.log('Telegram bot token 填写错误。\n'); } } } catch (e) { $.logErr(e, resp); } finally { resolve(data); } }); } else { resolve(); } }); } function ddBotNotify(text, desp) { return new Promise((resolve) => { const { DD_BOT_TOKEN, DD_BOT_SECRET } = push_config; const options = { url: `https://oapi.dingtalk.com/robot/send?access_token=${DD_BOT_TOKEN}`, json: { msgtype: 'text', text: { content: `${text}\n\n${desp}`, }, }, headers: { 'Content-Type': 'application/json', }, timeout, }; if (DD_BOT_TOKEN && DD_BOT_SECRET) { const crypto = require('crypto'); const dateNow = Date.now(); const hmac = crypto.createHmac('sha256', DD_BOT_SECRET); hmac.update(`${dateNow}\n${DD_BOT_SECRET}`); const result = encodeURIComponent(hmac.digest('base64')); options.url = `${options.url}×tamp=${dateNow}&sign=${result}`; $.post(options, (err, resp, data) => { try { if (err) { console.log('钉钉发送通知消息失败😞\n', err); } else { if (data.errcode === 0) { console.log('钉钉发送通知消息成功🎉\n'); } else { console.log(`钉钉发送通知消息异常 ${data.errmsg}\n`); } } } catch (e) { $.logErr(e, resp); } finally { resolve(data); } }); } else if (DD_BOT_TOKEN) { $.post(options, (err, resp, data) => { try { if (err) { console.log('钉钉发送通知消息失败😞\n', err); } else { if (data.errcode === 0) { console.log('钉钉发送通知消息成功🎉\n'); } else { console.log(`钉钉发送通知消息异常 ${data.errmsg}\n`); } } } catch (e) { $.logErr(e, resp); } finally { resolve(data); } }); } else { resolve(); } }); } function qywxBotNotify(text, desp) { return new Promise((resolve) => { const { QYWX_ORIGIN, QYWX_KEY } = push_config; const options = { url: `${QYWX_ORIGIN}/cgi-bin/webhook/send?key=${QYWX_KEY}`, json: { msgtype: 'text', text: { content: `${text}\n\n${desp}`, }, }, headers: { 'Content-Type': 'application/json', }, timeout, }; if (QYWX_KEY) { $.post(options, (err, resp, data) => { try { if (err) { console.log('企业微信发送通知消息失败😞\n', err); } else { if (data.errcode === 0) { console.log('企业微信发送通知消息成功🎉。\n'); } else { console.log(`企业微信发送通知消息异常 ${data.errmsg}\n`); } } } catch (e) { $.logErr(e, resp); } finally { resolve(data); } }); } else { resolve(); } }); } function ChangeUserId(desp) { const { QYWX_AM } = push_config; const QYWX_AM_AY = QYWX_AM.split(','); if (QYWX_AM_AY[2]) { const userIdTmp = QYWX_AM_AY[2].split('|'); let userId = ''; for (let i = 0; i < userIdTmp.length; i++) { const count = '账号' + (i + 1); const count2 = '签到号 ' + (i + 1); if (desp.match(count2)) { userId = userIdTmp[i]; } } if (!userId) userId = QYWX_AM_AY[2]; return userId; } else { return '@all'; } } async function qywxamNotify(text, desp) { const MAX_LENGTH = 900; if (desp.length > MAX_LENGTH) { let d = desp.substr(0, MAX_LENGTH) + '\n==More=='; await do_qywxamNotify(text, d); await qywxamNotify(text, desp.substr(MAX_LENGTH)); } else { return await do_qywxamNotify(text, desp); } } function do_qywxamNotify(text, desp) { return new Promise((resolve) => { const { QYWX_AM, QYWX_ORIGIN } = push_config; if (QYWX_AM) { const QYWX_AM_AY = QYWX_AM.split(','); const options_accesstoken = { url: `${QYWX_ORIGIN}/cgi-bin/gettoken`, json: { corpid: `${QYWX_AM_AY[0]}`, corpsecret: `${QYWX_AM_AY[1]}`, }, headers: { 'Content-Type': 'application/json', }, timeout, }; $.post(options_accesstoken, (err, resp, json) => { let html = desp.replace(/\n/g, '
'); let accesstoken = json.access_token; let options; switch (QYWX_AM_AY[4]) { case '0': options = { msgtype: 'textcard', textcard: { title: `${text}`, description: `${desp}`, url: 'https://github.com/whyour/qinglong', btntxt: '更多', }, }; break; case '1': options = { msgtype: 'text', text: { content: `${text}\n\n${desp}`, }, }; break; default: options = { msgtype: 'mpnews', mpnews: { articles: [ { title: `${text}`, thumb_media_id: `${QYWX_AM_AY[4]}`, author: `智能助手`, content_source_url: ``, content: `${html}`, digest: `${desp}`, }, ], }, }; } if (!QYWX_AM_AY[4]) { // 如不提供第四个参数,则默认进行文本消息类型推送 options = { msgtype: 'text', text: { content: `${text}\n\n${desp}`, }, }; } options = { url: `${QYWX_ORIGIN}/cgi-bin/message/send?access_token=${accesstoken}`, json: { touser: `${ChangeUserId(desp)}`, agentid: `${QYWX_AM_AY[3]}`, safe: '0', ...options, }, headers: { 'Content-Type': 'application/json', }, }; $.post(options, (err, resp, data) => { try { if (err) { console.log( '成员ID:' + ChangeUserId(desp) + '企业微信应用消息发送通知消息失败😞\n', err, ); } else { if (data.errcode === 0) { console.log( '成员ID:' + ChangeUserId(desp) + '企业微信应用消息发送通知消息成功🎉。\n', ); } else { console.log( `企业微信应用消息发送通知消息异常 ${data.errmsg}\n`, ); } } } catch (e) { $.logErr(e, resp); } finally { resolve(data); } }); }); } else { resolve(); } }); } function iGotNotify(text, desp, params = {}) { return new Promise((resolve) => { const { IGOT_PUSH_KEY } = push_config; if (IGOT_PUSH_KEY) { // 校验传入的IGOT_PUSH_KEY是否有效 const IGOT_PUSH_KEY_REGX = new RegExp('^[a-zA-Z0-9]{24}$'); if (!IGOT_PUSH_KEY_REGX.test(IGOT_PUSH_KEY)) { console.log('您所提供的 IGOT_PUSH_KEY 无效\n'); resolve(); return; } const options = { url: `https://push.hellyw.com/${IGOT_PUSH_KEY.toLowerCase()}`, body: `title=${text}&content=${desp}&${querystring.stringify(params)}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, timeout, }; $.post(options, (err, resp, data) => { try { if (err) { console.log('IGot 发送通知调用API失败😞\n', err); } else { if (data.ret === 0) { console.log('IGot 发送通知消息成功🎉\n'); } else { console.log(`IGot 发送通知消息异常 ${data.errMsg}\n`); } } } catch (e) { $.logErr(e, resp); } finally { resolve(data); } }); } else { resolve(); } }); } function pushPlusNotify(text, desp) { return new Promise((resolve) => { const { PUSH_PLUS_TOKEN, PUSH_PLUS_USER, PUSH_PLUS_TEMPLATE, PUSH_PLUS_CHANNEL, PUSH_PLUS_WEBHOOK, PUSH_PLUS_CALLBACKURL, PUSH_PLUS_TO, } = push_config; if (PUSH_PLUS_TOKEN) { desp = desp.replace(/[\n\r]/g, '
'); // 默认为html, 不支持plaintext const body = { token: `${PUSH_PLUS_TOKEN}`, title: `${text}`, content: `${desp}`, topic: `${PUSH_PLUS_USER}`, template: `${PUSH_PLUS_TEMPLATE}`, channel: `${PUSH_PLUS_CHANNEL}`, webhook: `${PUSH_PLUS_WEBHOOK}`, callbackUrl: `${PUSH_PLUS_CALLBACKURL}`, to: `${PUSH_PLUS_TO}`, }; const options = { url: `https://www.pushplus.plus/send`, body: JSON.stringify(body), headers: { 'Content-Type': ' application/json', }, timeout, }; $.post(options, (err, resp, data) => { try { if (err) { console.log( `pushplus 发送${ PUSH_PLUS_USER ? '一对多' : '一对一' }通知消息失败😞\n`, err, ); } else { if (data.code === 200) { console.log( `pushplus 发送${ PUSH_PLUS_USER ? '一对多' : '一对一' }通知请求成功🎉,可根据流水号查询推送结果:${ data.data }\n注意:请求成功并不代表推送成功,如未收到消息,请到pushplus官网使用流水号查询推送最终结果`, ); } else { console.log( `pushplus 发送${ PUSH_PLUS_USER ? '一对多' : '一对一' }通知消息异常 ${data.msg}\n`, ); } } } catch (e) { $.logErr(e, resp); } finally { resolve(data); } }); } else { resolve(); } }); } function wePlusBotNotify(text, desp) { return new Promise((resolve) => { const { WE_PLUS_BOT_TOKEN, WE_PLUS_BOT_RECEIVER, WE_PLUS_BOT_VERSION } = push_config; if (WE_PLUS_BOT_TOKEN) { let template = 'txt'; if (desp.length > 800) { desp = desp.replace(/[\n\r]/g, '
'); template = 'html'; } const body = { token: `${WE_PLUS_BOT_TOKEN}`, title: `${text}`, content: `${desp}`, template: `${template}`, receiver: `${WE_PLUS_BOT_RECEIVER}`, version: `${WE_PLUS_BOT_VERSION}`, }; const options = { url: `https://www.weplusbot.com/send`, body: JSON.stringify(body), headers: { 'Content-Type': ' application/json', }, timeout, }; $.post(options, (err, resp, data) => { try { if (err) { console.log(`微加机器人发送通知消息失败😞\n`, err); } else { if (data.code === 200) { console.log(`微加机器人发送通知消息完成🎉\n`); } else { console.log(`微加机器人发送通知消息异常 ${data.msg}\n`); } } } catch (e) { $.logErr(e, resp); } finally { resolve(data); } }); } else { resolve(); } }); } function aibotkNotify(text, desp) { return new Promise((resolve) => { const { AIBOTK_KEY, AIBOTK_TYPE, AIBOTK_NAME } = push_config; if (AIBOTK_KEY && AIBOTK_TYPE && AIBOTK_NAME) { let json = {}; let url = ''; switch (AIBOTK_TYPE) { case 'room': url = 'https://api-bot.aibotk.com/openapi/v1/chat/room'; json = { apiKey: `${AIBOTK_KEY}`, roomName: `${AIBOTK_NAME}`, message: { type: 1, content: `【青龙快讯】\n\n${text}\n${desp}`, }, }; break; case 'contact': url = 'https://api-bot.aibotk.com/openapi/v1/chat/contact'; json = { apiKey: `${AIBOTK_KEY}`, name: `${AIBOTK_NAME}`, message: { type: 1, content: `【青龙快讯】\n\n${text}\n${desp}`, }, }; break; } const options = { url: url, json, headers: { 'Content-Type': 'application/json', }, timeout, }; $.post(options, (err, resp, data) => { try { if (err) { console.log('智能微秘书发送通知消息失败😞\n', err); } else { if (data.code === 0) { console.log('智能微秘书发送通知消息成功🎉。\n'); } else { console.log(`智能微秘书发送通知消息异常 ${data.error}\n`); } } } catch (e) { $.logErr(e, resp); } finally { resolve(data); } }); } else { resolve(); } }); } function fsBotNotify(text, desp) { return new Promise((resolve) => { const { FSKEY, FSSECRET } = push_config; if (FSKEY) { const body = { msg_type: 'text', content: { text: `${text}\n\n${desp}` }, }; // Add signature if secret is provided // Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key // and signs an empty message, which differs from typical HMAC usage if (FSSECRET) { const crypto = require('crypto'); const timestamp = Math.floor(Date.now() / 1000).toString(); const stringToSign = `${timestamp}\n${FSSECRET}`; const hmac = crypto.createHmac('sha256', stringToSign); const sign = hmac.digest('base64'); body.timestamp = timestamp; body.sign = sign; } const options = { url: `https://open.feishu.cn/open-apis/bot/v2/hook/${FSKEY}`, json: body, headers: { 'Content-Type': 'application/json', }, timeout, }; $.post(options, (err, resp, data) => { try { if (err) { console.log('飞书发送通知调用API失败😞\n', err); } else { if (data.StatusCode === 0 || data.code === 0) { console.log('飞书发送通知消息成功🎉\n'); } else { console.log(`飞书发送通知消息异常 ${data.msg}\n`); } } } catch (e) { $.logErr(e, resp); } finally { resolve(data); } }); } else { resolve(); } }); } async function smtpNotify(text, desp) { const { SMTP_EMAIL, SMTP_TO, SMTP_PASSWORD, SMTP_SERVICE, SMTP_NAME } = push_config; if (![SMTP_EMAIL, SMTP_PASSWORD].every(Boolean) || !SMTP_SERVICE) { return; } try { const nodemailer = require('nodemailer'); const transporter = nodemailer.createTransport({ service: SMTP_SERVICE, auth: { user: SMTP_EMAIL, pass: SMTP_PASSWORD, }, }); const addr = SMTP_NAME ? `"${SMTP_NAME}" <${SMTP_EMAIL}>` : SMTP_EMAIL; const info = await transporter.sendMail({ from: addr, to: SMTP_TO ? SMTP_TO.split(';') : addr, subject: text, html: `${desp.replace(/\n/g, '
')}`, }); transporter.close(); if (info.messageId) { console.log('SMTP 发送通知消息成功🎉\n'); return true; } console.log('SMTP 发送通知消息失败😞\n'); } catch (e) { console.log('SMTP 发送通知消息出现异常😞\n', e); } } function pushMeNotify(text, desp, params = {}) { return new Promise((resolve) => { const { PUSHME_KEY, PUSHME_URL } = push_config; if (PUSHME_KEY) { const options = { url: PUSHME_URL || 'https://push.i-i.me', json: { push_key: PUSHME_KEY, title: text, content: desp, ...params }, headers: { 'Content-Type': 'application/json', }, timeout, }; $.post(options, (err, resp, data) => { try { if (err) { console.log('PushMe 发送通知调用API失败😞\n', err); } else { if (data === 'success') { console.log('PushMe 发送通知消息成功🎉\n'); } else { console.log(`PushMe 发送通知消息异常 ${data}\n`); } } } catch (e) { $.logErr(e, resp); } finally { resolve(data); } }); } else { resolve(); } }); } function chronocatNotify(title, desp) { return new Promise((resolve) => { const { CHRONOCAT_TOKEN, CHRONOCAT_QQ, CHRONOCAT_URL } = push_config; if (!CHRONOCAT_TOKEN || !CHRONOCAT_QQ || !CHRONOCAT_URL) { resolve(); return; } const user_ids = CHRONOCAT_QQ.match(/user_id=(\d+)/g)?.map( (match) => match.split('=')[1], ); const group_ids = CHRONOCAT_QQ.match(/group_id=(\d+)/g)?.map( (match) => match.split('=')[1], ); const url = `${CHRONOCAT_URL}/api/message/send`; const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${CHRONOCAT_TOKEN}`, }; for (const [chat_type, ids] of [ [1, user_ids], [2, group_ids], ]) { if (!ids) { continue; } for (const chat_id of ids) { const data = { peer: { chatType: chat_type, peerUin: chat_id, }, elements: [ { elementType: 1, textElement: { content: `${title}\n\n${desp}`, }, }, ], }; const options = { url: url, json: data, headers, timeout, }; $.post(options, (err, resp, data) => { try { if (err) { console.log('Chronocat 发送QQ通知消息失败😞\n', err); } else { if (chat_type === 1) { console.log(`Chronocat 个人消息 ${ids}推送成功🎉`); } else { console.log(`Chronocat 群消息 ${ids}推送成功🎉`); } } } catch (e) { $.logErr(e, resp); } finally { resolve(data); } }); } } }); } function qmsgNotify(text, desp) { return new Promise((resolve) => { const { QMSG_KEY, QMSG_TYPE } = push_config; if (QMSG_KEY && QMSG_TYPE) { const options = { url: `https://qmsg.zendee.cn/${QMSG_TYPE}/${QMSG_KEY}`, body: `msg=${text}\n\n${desp.replace('----', '-')}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, timeout, }; $.post(options, (err, resp, data) => { try { if (err) { console.log('Qmsg 发送通知调用API失败😞\n', err); } else { if (data.code === 0) { console.log('Qmsg 发送通知消息成功🎉\n'); } else { console.log(`Qmsg 发送通知消息异常 ${data}\n`); } } } catch (e) { $.logErr(e, resp); } finally { resolve(data); } }); } else { resolve(); } }); } function webhookNotify(text, desp) { return new Promise((resolve) => { const { WEBHOOK_URL, WEBHOOK_BODY, WEBHOOK_HEADERS, WEBHOOK_CONTENT_TYPE, WEBHOOK_METHOD, } = push_config; if ( !WEBHOOK_METHOD || !WEBHOOK_URL || (!WEBHOOK_URL.includes('$title') && !WEBHOOK_BODY.includes('$title')) ) { resolve(); return; } const headers = parseHeaders(WEBHOOK_HEADERS); const body = parseBody(WEBHOOK_BODY, WEBHOOK_CONTENT_TYPE, (v) => v ?.replaceAll('$title', text?.replaceAll('\n', '\\n')) ?.replaceAll('$content', desp?.replaceAll('\n', '\\n')), ); const bodyParam = formatBodyFun(WEBHOOK_CONTENT_TYPE, body); const options = { method: WEBHOOK_METHOD, headers, allowGetBody: true, ...bodyParam, timeout, retry: 1, }; const formatUrl = WEBHOOK_URL.replaceAll( '$title', encodeURIComponent(text), ).replaceAll('$content', encodeURIComponent(desp)); httpClient.request(formatUrl, options).then(async (resp) => { const body = await resp.body.text(); try { if (resp.statusCode !== 200) { console.log(`自定义发送通知消息失败😞 ${body}\n`); } else { console.log(`自定义发送通知消息成功🎉 ${body}\n`); } } catch (e) { $.logErr(e, resp); } finally { resolve(body); } }); }); } function ntfyNotify(text, desp) { function encodeRFC2047(text) { const encodedBase64 = Buffer.from(text).toString('base64'); return `=?utf-8?B?${encodedBase64}?=`; } return new Promise((resolve) => { const { NTFY_URL, NTFY_TOPIC, NTFY_PRIORITY, NTFY_TOKEN, NTFY_USERNAME, NTFY_PASSWORD, NTFY_ACTIONS, } = push_config; if (NTFY_TOPIC) { const options = { url: `${NTFY_URL || 'https://ntfy.sh'}/${NTFY_TOPIC}`, body: `${desp}`, headers: { Title: `${encodeRFC2047(text)}`, Priority: NTFY_PRIORITY || '3', Icon: 'https://qn.whyour.cn/logo.png', }, timeout, }; if (NTFY_TOKEN) { options.headers['Authorization'] = `Bearer ${NTFY_TOKEN}`; } else if (NTFY_USERNAME && NTFY_PASSWORD) { options.headers['Authorization'] = `Basic ${Buffer.from(`${NTFY_USERNAME}:${NTFY_PASSWORD}`).toString('base64')}`; } if (NTFY_ACTIONS) { options.headers['Actions'] = encodeRFC2047(NTFY_ACTIONS); } $.post(options, (err, resp, data) => { try { if (err) { console.log('Ntfy 通知调用API失败😞\n', err); } else { if (data.id) { console.log('Ntfy 发送通知消息成功🎉\n'); } else { console.log(`Ntfy 发送通知消息异常 ${JSON.stringify(data)}`); } } } catch (e) { $.logErr(e, resp); } finally { resolve(data); } }); } else { resolve(); } }); } function wxPusherNotify(text, desp) { return new Promise((resolve) => { const { WXPUSHER_APP_TOKEN, WXPUSHER_TOPIC_IDS, WXPUSHER_UIDS } = push_config; if (WXPUSHER_APP_TOKEN) { // 处理topic_ids,将分号分隔的字符串转为数组 const topicIds = WXPUSHER_TOPIC_IDS ? WXPUSHER_TOPIC_IDS.split(';') .map((id) => id.trim()) .filter((id) => id) .map((id) => parseInt(id)) : []; // 处理uids,将分号分隔的字符串转为数组 const uids = WXPUSHER_UIDS ? WXPUSHER_UIDS.split(';') .map((uid) => uid.trim()) .filter((uid) => uid) : []; // topic_ids uids 至少有一个 if (!topicIds.length && !uids.length) { console.log( 'wxpusher 服务的 WXPUSHER_TOPIC_IDS 和 WXPUSHER_UIDS 至少设置一个!!', ); return resolve(); } const body = { appToken: WXPUSHER_APP_TOKEN, content: `

${text}


${desp}
`, summary: text, contentType: 2, topicIds: topicIds, uids: uids, verifyPayType: 0, }; const options = { url: 'https://wxpusher.zjiecode.com/api/send/message', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json', }, timeout, }; $.post(options, (err, resp, data) => { try { if (err) { console.log('wxpusher发送通知消息失败!\n', err); } else { if (data.code === 1000) { console.log('wxpusher发送通知消息完成!'); } else { console.log(`wxpusher发送通知消息异常:${data.msg}`); } } } catch (e) { $.logErr(e, resp); } finally { resolve(data); } }); } else { resolve(); } }); } function parseString(input, valueFormatFn) { const regex = /(\w+):\s*((?:(?!\n\w+:).)*)/g; const matches = {}; let match; while ((match = regex.exec(input)) !== null) { const [, key, value] = match; const _key = key.trim(); if (!_key || matches[_key]) { continue; } let _value = value.trim(); try { _value = valueFormatFn ? valueFormatFn(_value) : _value; const jsonValue = JSON.parse(_value); matches[_key] = jsonValue; } catch (error) { matches[_key] = _value; } } return matches; } function parseHeaders(headers) { if (!headers) return {}; const parsed = {}; let key; let val; let i; headers && headers.split('\n').forEach(function parser(line) { i = line.indexOf(':'); key = line.substring(0, i).trim().toLowerCase(); val = line.substring(i + 1).trim(); if (!key) { return; } parsed[key] = parsed[key] ? parsed[key] + ', ' + val : val; }); return parsed; } function parseBody(body, contentType, valueFormatFn) { if (contentType === 'text/plain' || !body) { return valueFormatFn && body ? valueFormatFn(body) : body; } const parsed = parseString(body, valueFormatFn); switch (contentType) { case 'multipart/form-data': return Object.keys(parsed).reduce((p, c) => { p.append(c, parsed[c]); return p; }, new FormData()); case 'application/x-www-form-urlencoded': return Object.keys(parsed).reduce((p, c) => { return p ? `${p}&${c}=${parsed[c]}` : `${c}=${parsed[c]}`; }); } return parsed; } function formatBodyFun(contentType, body) { if (!body) return {}; switch (contentType) { case 'application/json': return { json: body }; case 'multipart/form-data': return { form: body }; case 'application/x-www-form-urlencoded': case 'text/plain': return { body }; } return {}; } /** * sendNotify 推送通知功能 * @param text 通知头 * @param desp 通知体 * @param params 某些推送通知方式点击弹窗可跳转, 例:{ url: 'https://abc.com' } * @returns {Promise} */ async function sendNotify(text, desp, params = {}) { // 根据标题跳过一些消息推送,环境变量:SKIP_PUSH_TITLE 用回车分隔 let skipTitle = process.env.SKIP_PUSH_TITLE; if (skipTitle) { if (skipTitle.split('\n').includes(text)) { console.info(text + '在 SKIP_PUSH_TITLE 环境变量内,跳过推送'); return; } } if (push_config.HITOKOTO !== 'false') { desp += '\n\n' + (await one()); } await Promise.all([ serverNotify(text, desp), // 微信server酱 pushPlusNotify(text, desp), // pushplus wePlusBotNotify(text, desp), // 微加机器人 barkNotify(text, desp, params), // iOS Bark APP tgBotNotify(text, desp), // telegram 机器人 ddBotNotify(text, desp), // 钉钉机器人 qywxBotNotify(text, desp), // 企业微信机器人 qywxamNotify(text, desp), // 企业微信应用消息推送 iGotNotify(text, desp, params), // iGot gobotNotify(text, desp), // go-cqhttp gotifyNotify(text, desp), // gotify chatNotify(text, desp), // synolog chat pushDeerNotify(text, desp), // PushDeer aibotkNotify(text, desp), // 智能微秘书 fsBotNotify(text, desp), // 飞书机器人 smtpNotify(text, desp), // SMTP 邮件 pushMeNotify(text, desp, params), // PushMe chronocatNotify(text, desp), // Chronocat webhookNotify(text, desp), // 自定义通知 qmsgNotify(text, desp), // 自定义通知 ntfyNotify(text, desp), // Ntfy wxPusherNotify(text, desp), // wxpusher ]); } module.exports = { sendNotify, }; ================================================ FILE: sample/notify.py ================================================ #!/usr/bin/env python3 # _*_ coding:utf-8 _*_ import base64 import hashlib import hmac import json import os import re import threading import time import urllib.parse import smtplib from email.mime.text import MIMEText from email.header import Header from email.utils import formataddr import requests # 原先的 print 函数和主线程的锁 _print = print mutex = threading.Lock() # 定义新的 print 函数 def print(text, *args, **kw): """ 使输出有序进行,不出现多线程同一时间输出导致错乱的问题。 """ with mutex: _print(text, *args, **kw) # 通知服务 # fmt: off push_config = { 'HITOKOTO': True, # 启用一言(随机句子) 'BARK_PUSH': '', # bark IP 或设备码,例:https://api.day.app/DxHcxxxxxRxxxxxxcm/ 'BARK_ARCHIVE': '', # bark 推送是否存档 'BARK_GROUP': '', # bark 推送分组 'BARK_SOUND': '', # bark 推送声音 'BARK_ICON': '', # bark 推送图标 'BARK_LEVEL': '', # bark 推送时效性 'BARK_URL': '', # bark 推送跳转URL 'CONSOLE': False, # 控制台输出 'DD_BOT_SECRET': '', # 钉钉机器人的 DD_BOT_SECRET 'DD_BOT_TOKEN': '', # 钉钉机器人的 DD_BOT_TOKEN 'FSKEY': '', # 飞书机器人的 FSKEY 'FSSECRET': '', # 飞书机器人的 FSSECRET,对应安全设置里的签名校验密钥 'GOBOT_URL': '', # go-cqhttp # 推送到个人QQ:http://127.0.0.1/send_private_msg # 群:http://127.0.0.1/send_group_msg 'GOBOT_QQ': '', # go-cqhttp 的推送群或用户 # GOBOT_URL 设置 /send_private_msg 时填入 user_id=个人QQ # /send_group_msg 时填入 group_id=QQ群 'GOBOT_TOKEN': '', # go-cqhttp 的 access_token 'GOTIFY_URL': '', # gotify地址,如https://push.example.de:8080 'GOTIFY_TOKEN': '', # gotify的消息应用token 'GOTIFY_PRIORITY': 0, # 推送消息优先级,默认为0 'IGOT_PUSH_KEY': '', # iGot 聚合推送的 IGOT_PUSH_KEY 'PUSH_KEY': '', # server 酱的 PUSH_KEY,兼容旧版与 Turbo 版 'DEER_KEY': '', # PushDeer 的 PUSHDEER_KEY 'DEER_URL': '', # PushDeer 的 PUSHDEER_URL 'CHAT_URL': '', # synology chat url 'CHAT_TOKEN': '', # synology chat token 'PUSH_PLUS_TOKEN': '', # pushplus 推送的用户令牌 'PUSH_PLUS_USER': '', # pushplus 推送的群组编码 'PUSH_PLUS_TEMPLATE': 'html', # pushplus 发送模板,支持html,txt,json,markdown,cloudMonitor,jenkins,route,pay 'PUSH_PLUS_CHANNEL': 'wechat', # pushplus 发送渠道,支持wechat,webhook,cp,mail,sms 'PUSH_PLUS_WEBHOOK': '', # pushplus webhook编码,可在pushplus公众号上扩展配置出更多渠道 'PUSH_PLUS_CALLBACKURL': '', # pushplus 发送结果回调地址,会把推送最终结果通知到这个地址上 'PUSH_PLUS_TO': '', # pushplus 好友令牌,微信公众号渠道填写好友令牌,企业微信渠道填写企业微信用户id 'WE_PLUS_BOT_TOKEN': '', # 微加机器人的用户令牌 'WE_PLUS_BOT_RECEIVER': '', # 微加机器人的消息接收者 'WE_PLUS_BOT_VERSION': 'pro', # 微加机器人的调用版本 'QMSG_KEY': '', # qmsg 酱的 QMSG_KEY 'QMSG_TYPE': '', # qmsg 酱的 QMSG_TYPE 'QYWX_ORIGIN': '', # 企业微信代理地址 'QYWX_AM': '', # 企业微信应用 'QYWX_KEY': '', # 企业微信机器人 'TG_BOT_TOKEN': '', # tg 机器人的 TG_BOT_TOKEN,例:1407203283:AAG9rt-6RDaaX0HBLZQq0laNOh898iFYaRQ 'TG_USER_ID': '', # tg 机器人的 TG_USER_ID,例:1434078534 'TG_API_HOST': '', # tg 代理 api 'TG_PROXY_AUTH': '', # tg 代理认证参数 'TG_PROXY_HOST': '', # tg 机器人的 TG_PROXY_HOST 'TG_PROXY_PORT': '', # tg 机器人的 TG_PROXY_PORT 'AIBOTK_KEY': '', # 智能微秘书 个人中心的apikey 文档地址:http://wechat.aibotk.com/docs/about 'AIBOTK_TYPE': '', # 智能微秘书 发送目标 room 或 contact 'AIBOTK_NAME': '', # 智能微秘书 发送群名 或者好友昵称和type要对应好 'SMTP_SERVER': '', # SMTP 发送邮件服务器,形如 smtp.exmail.qq.com:465 'SMTP_SSL': 'false', # SMTP 发送邮件服务器是否使用 SSL,填写 true 或 false 'SMTP_EMAIL': '', # SMTP 收发件邮箱,通知将会由自己发给自己 'SMTP_PASSWORD': '', # SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定 'SMTP_NAME': '', # SMTP 收发件人姓名,可随意填写 'PUSHME_KEY': '', # PushMe 的 PUSHME_KEY 'PUSHME_URL': '', # PushMe 的 PUSHME_URL 'CHRONOCAT_QQ': '', # qq号 'CHRONOCAT_TOKEN': '', # CHRONOCAT 的token 'CHRONOCAT_URL': '', # CHRONOCAT的url地址 'WEBHOOK_URL': '', # 自定义通知 请求地址 'WEBHOOK_BODY': '', # 自定义通知 请求体 'WEBHOOK_HEADERS': '', # 自定义通知 请求头 'WEBHOOK_METHOD': '', # 自定义通知 请求方法 'WEBHOOK_CONTENT_TYPE': '', # 自定义通知 content-type 'NTFY_URL': '', # ntfy地址,如https://ntfy.sh 'NTFY_TOPIC': '', # ntfy的消息应用topic 'NTFY_PRIORITY':'3', # 推送消息优先级,默认为3 'NTFY_TOKEN': '', # 推送token,可选 'NTFY_USERNAME': '', # 推送用户名称,可选 'NTFY_PASSWORD': '', # 推送用户密码,可选 'NTFY_ACTIONS': '', # 推送用户动作,可选 'WXPUSHER_APP_TOKEN': '', # wxpusher 的 appToken 官方文档: https://wxpusher.zjiecode.com/docs/ 管理后台: https://wxpusher.zjiecode.com/admin/ 'WXPUSHER_TOPIC_IDS': '', # wxpusher 的 主题ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行 'WXPUSHER_UIDS': '', # wxpusher 的 用户ID,多个用英文分号;分隔 topic_ids 与 uids 至少配置一个才行 } # fmt: on for k in push_config: if os.getenv(k): v = os.getenv(k) push_config[k] = v def bark(title: str, content: str) -> None: """ 使用 bark 推送消息。 """ if not push_config.get("BARK_PUSH"): return print("bark 服务启动") if push_config.get("BARK_PUSH").startswith("http"): url = f'{push_config.get("BARK_PUSH")}' else: url = f'https://api.day.app/{push_config.get("BARK_PUSH")}' bark_params = { "BARK_ARCHIVE": "isArchive", "BARK_GROUP": "group", "BARK_SOUND": "sound", "BARK_ICON": "icon", "BARK_LEVEL": "level", "BARK_URL": "url", } data = { "title": title, "body": content, } for pair in filter( lambda pairs: pairs[0].startswith("BARK_") and pairs[0] != "BARK_PUSH" and pairs[1] and bark_params.get(pairs[0]), push_config.items(), ): data[bark_params.get(pair[0])] = pair[1] headers = {"Content-Type": "application/json;charset=utf-8"} response = requests.post( url=url, data=json.dumps(data), headers=headers, timeout=15 ).json() if response["code"] == 200: print("bark 推送成功!") else: print("bark 推送失败!") def console(title: str, content: str) -> None: """ 使用 控制台 推送消息。 """ print(f"{title}\n\n{content}") def dingding_bot(title: str, content: str) -> None: """ 使用 钉钉机器人 推送消息。 """ if not push_config.get("DD_BOT_SECRET") or not push_config.get("DD_BOT_TOKEN"): return print("钉钉机器人 服务启动") timestamp = str(round(time.time() * 1000)) secret_enc = push_config.get("DD_BOT_SECRET").encode("utf-8") string_to_sign = "{}\n{}".format(timestamp, push_config.get("DD_BOT_SECRET")) string_to_sign_enc = string_to_sign.encode("utf-8") hmac_code = hmac.new( secret_enc, string_to_sign_enc, digestmod=hashlib.sha256 ).digest() sign = urllib.parse.quote_plus(base64.b64encode(hmac_code)) url = f'https://oapi.dingtalk.com/robot/send?access_token={push_config.get("DD_BOT_TOKEN")}×tamp={timestamp}&sign={sign}' headers = {"Content-Type": "application/json;charset=utf-8"} data = {"msgtype": "text", "text": {"content": f"{title}\n\n{content}"}} response = requests.post( url=url, data=json.dumps(data), headers=headers, timeout=15 ).json() if not response["errcode"]: print("钉钉机器人 推送成功!") else: print("钉钉机器人 推送失败!") def feishu_bot(title: str, content: str) -> None: """ 使用 飞书机器人 推送消息。 """ if not push_config.get("FSKEY"): return print("飞书 服务启动") url = f'https://open.feishu.cn/open-apis/bot/v2/hook/{push_config.get("FSKEY")}' data = {"msg_type": "text", "content": {"text": f"{title}\n\n{content}"}} # Add signature if secret is provided # Note: Feishu's signature algorithm uses timestamp+"\n"+secret as the HMAC key # and signs an empty message, which differs from typical HMAC usage if push_config.get("FSSECRET"): timestamp = str(int(time.time())) string_to_sign = f'{timestamp}\n{push_config.get("FSSECRET")}' hmac_code = hmac.new( string_to_sign.encode("utf-8"), digestmod=hashlib.sha256 ).digest() sign = base64.b64encode(hmac_code).decode("utf-8") data["timestamp"] = timestamp data["sign"] = sign response = requests.post(url, data=json.dumps(data)).json() if response.get("StatusCode") == 0 or response.get("code") == 0: print("飞书 推送成功!") else: print("飞书 推送失败!错误信息如下:\n", response) def go_cqhttp(title: str, content: str) -> None: """ 使用 go_cqhttp 推送消息。 """ if not push_config.get("GOBOT_URL") or not push_config.get("GOBOT_QQ"): return print("go-cqhttp 服务启动") url = f'{push_config.get("GOBOT_URL")}?access_token={push_config.get("GOBOT_TOKEN")}&{push_config.get("GOBOT_QQ")}&message=标题:{title}\n内容:{content}' response = requests.get(url).json() if response["status"] == "ok": print("go-cqhttp 推送成功!") else: print("go-cqhttp 推送失败!") def gotify(title: str, content: str) -> None: """ 使用 gotify 推送消息。 """ if not push_config.get("GOTIFY_URL") or not push_config.get("GOTIFY_TOKEN"): return print("gotify 服务启动") url = f'{push_config.get("GOTIFY_URL")}/message?token={push_config.get("GOTIFY_TOKEN")}' data = { "title": title, "message": content, "priority": push_config.get("GOTIFY_PRIORITY"), } response = requests.post(url, data=data).json() if response.get("id"): print("gotify 推送成功!") else: print("gotify 推送失败!") def iGot(title: str, content: str) -> None: """ 使用 iGot 推送消息。 """ if not push_config.get("IGOT_PUSH_KEY"): return print("iGot 服务启动") url = f'https://push.hellyw.com/{push_config.get("IGOT_PUSH_KEY")}' data = {"title": title, "content": content} headers = {"Content-Type": "application/x-www-form-urlencoded"} response = requests.post(url, data=data, headers=headers).json() if response["ret"] == 0: print("iGot 推送成功!") else: print(f'iGot 推送失败!{response["errMsg"]}') def serverJ(title: str, content: str) -> None: """ 通过 serverJ 推送消息。 """ if not push_config.get("PUSH_KEY"): return print("serverJ 服务启动") data = {"text": title, "desp": content.replace("\n", "\n\n")} match = re.match(r"sctp(\d+)t", push_config.get("PUSH_KEY")) if match: num = match.group(1) url = f'https://{num}.push.ft07.com/send/{push_config.get("PUSH_KEY")}.send' else: url = f'https://sctapi.ftqq.com/{push_config.get("PUSH_KEY")}.send' response = requests.post(url, data=data).json() if response.get("errno") == 0 or response.get("code") == 0: print("serverJ 推送成功!") else: print(f'serverJ 推送失败!错误码:{response["message"]}') def pushdeer(title: str, content: str) -> None: """ 通过PushDeer 推送消息 """ if not push_config.get("DEER_KEY"): return print("PushDeer 服务启动") data = { "text": title, "desp": content, "type": "markdown", "pushkey": push_config.get("DEER_KEY"), } url = "https://api2.pushdeer.com/message/push" if push_config.get("DEER_URL"): url = push_config.get("DEER_URL") response = requests.post(url, data=data).json() if len(response.get("content").get("result")) > 0: print("PushDeer 推送成功!") else: print("PushDeer 推送失败!错误信息:", response) def chat(title: str, content: str) -> None: """ 通过Chat 推送消息 """ if not push_config.get("CHAT_URL") or not push_config.get("CHAT_TOKEN"): return print("chat 服务启动") data = "payload=" + json.dumps({"text": title + "\n" + content}) url = push_config.get("CHAT_URL") + push_config.get("CHAT_TOKEN") response = requests.post(url, data=data) if response.status_code == 200: print("Chat 推送成功!") else: print("Chat 推送失败!错误信息:", response) def pushplus_bot(title: str, content: str) -> None: """ 通过 pushplus 推送消息。 """ if not push_config.get("PUSH_PLUS_TOKEN"): return print("PUSHPLUS 服务启动") url = "https://www.pushplus.plus/send" data = { "token": push_config.get("PUSH_PLUS_TOKEN"), "title": title, "content": content, "topic": push_config.get("PUSH_PLUS_USER"), "template": push_config.get("PUSH_PLUS_TEMPLATE"), "channel": push_config.get("PUSH_PLUS_CHANNEL"), "webhook": push_config.get("PUSH_PLUS_WEBHOOK"), "callbackUrl": push_config.get("PUSH_PLUS_CALLBACKURL"), "to": push_config.get("PUSH_PLUS_TO"), } body = json.dumps(data).encode(encoding="utf-8") headers = {"Content-Type": "application/json"} response = requests.post(url=url, data=body, headers=headers).json() code = response["code"] if code == 200: print("PUSHPLUS 推送请求成功,可根据流水号查询推送结果:" + response["data"]) print( "注意:请求成功并不代表推送成功,如未收到消息,请到pushplus官网使用流水号查询推送最终结果" ) elif code == 900 or code == 903 or code == 905 or code == 999: print(response["msg"]) else: url_old = "http://pushplus.hxtrip.com/send" headers["Accept"] = "application/json" response = requests.post(url=url_old, data=body, headers=headers).json() if response["code"] == 200: print("PUSHPLUS(hxtrip) 推送成功!") else: print("PUSHPLUS 推送失败!") def weplus_bot(title: str, content: str) -> None: """ 通过 微加机器人 推送消息。 """ if not push_config.get("WE_PLUS_BOT_TOKEN"): return print("微加机器人 服务启动") template = "txt" if len(content) > 800: template = "html" url = "https://www.weplusbot.com/send" data = { "token": push_config.get("WE_PLUS_BOT_TOKEN"), "title": title, "content": content, "template": template, "receiver": push_config.get("WE_PLUS_BOT_RECEIVER"), "version": push_config.get("WE_PLUS_BOT_VERSION"), } body = json.dumps(data).encode(encoding="utf-8") headers = {"Content-Type": "application/json"} response = requests.post(url=url, data=body, headers=headers).json() if response["code"] == 200: print("微加机器人 推送成功!") else: print("微加机器人 推送失败!") def qmsg_bot(title: str, content: str) -> None: """ 使用 qmsg 推送消息。 """ if not push_config.get("QMSG_KEY") or not push_config.get("QMSG_TYPE"): return print("qmsg 服务启动") url = f'https://qmsg.zendee.cn/{push_config.get("QMSG_TYPE")}/{push_config.get("QMSG_KEY")}' payload = {"msg": f'{title}\n\n{content.replace("----", "-")}'.encode("utf-8")} response = requests.post(url=url, params=payload).json() if response["code"] == 0: print("qmsg 推送成功!") else: print(f'qmsg 推送失败!{response["reason"]}') def wecom_app(title: str, content: str) -> None: """ 通过 企业微信 APP 推送消息。 """ if not push_config.get("QYWX_AM"): return QYWX_AM_AY = re.split(",", push_config.get("QYWX_AM")) if 4 < len(QYWX_AM_AY) > 5: print("QYWX_AM 设置错误!!") return print("企业微信 APP 服务启动") corpid = QYWX_AM_AY[0] corpsecret = QYWX_AM_AY[1] touser = QYWX_AM_AY[2] agentid = QYWX_AM_AY[3] try: media_id = QYWX_AM_AY[4] except IndexError: media_id = "" wx = WeCom(corpid, corpsecret, agentid) # 如果没有配置 media_id 默认就以 text 方式发送 if not media_id: message = title + "\n\n" + content response = wx.send_text(message, touser) else: response = wx.send_mpnews(title, content, media_id, touser) if response == "ok": print("企业微信推送成功!") else: print("企业微信推送失败!错误信息如下:\n", response) class WeCom: def __init__(self, corpid, corpsecret, agentid): self.CORPID = corpid self.CORPSECRET = corpsecret self.AGENTID = agentid self.ORIGIN = "https://qyapi.weixin.qq.com" if push_config.get("QYWX_ORIGIN"): self.ORIGIN = push_config.get("QYWX_ORIGIN") def get_access_token(self): url = f"{self.ORIGIN}/cgi-bin/gettoken" values = { "corpid": self.CORPID, "corpsecret": self.CORPSECRET, } req = requests.post(url, params=values) data = json.loads(req.text) return data["access_token"] def send_text(self, message, touser="@all"): send_url = ( f"{self.ORIGIN}/cgi-bin/message/send?access_token={self.get_access_token()}" ) send_values = { "touser": touser, "msgtype": "text", "agentid": self.AGENTID, "text": {"content": message}, "safe": "0", } send_msges = bytes(json.dumps(send_values), "utf-8") respone = requests.post(send_url, send_msges) respone = respone.json() return respone["errmsg"] def send_mpnews(self, title, message, media_id, touser="@all"): send_url = ( f"{self.ORIGIN}/cgi-bin/message/send?access_token={self.get_access_token()}" ) send_values = { "touser": touser, "msgtype": "mpnews", "agentid": self.AGENTID, "mpnews": { "articles": [ { "title": title, "thumb_media_id": media_id, "author": "Author", "content_source_url": "", "content": message.replace("\n", "
"), "digest": message, } ] }, } send_msges = bytes(json.dumps(send_values), "utf-8") respone = requests.post(send_url, send_msges) respone = respone.json() return respone["errmsg"] def wecom_bot(title: str, content: str) -> None: """ 通过 企业微信机器人 推送消息。 """ if not push_config.get("QYWX_KEY"): return print("企业微信机器人服务启动") origin = "https://qyapi.weixin.qq.com" if push_config.get("QYWX_ORIGIN"): origin = push_config.get("QYWX_ORIGIN") url = f"{origin}/cgi-bin/webhook/send?key={push_config.get('QYWX_KEY')}" headers = {"Content-Type": "application/json;charset=utf-8"} data = {"msgtype": "text", "text": {"content": f"{title}\n\n{content}"}} response = requests.post( url=url, data=json.dumps(data), headers=headers, timeout=15 ).json() if response["errcode"] == 0: print("企业微信机器人推送成功!") else: print("企业微信机器人推送失败!") def telegram_bot(title: str, content: str) -> None: """ 使用 telegram 机器人 推送消息。 """ if not push_config.get("TG_BOT_TOKEN") or not push_config.get("TG_USER_ID"): return print("tg 服务启动") if push_config.get("TG_API_HOST"): url = f"{push_config.get('TG_API_HOST')}/bot{push_config.get('TG_BOT_TOKEN')}/sendMessage" else: url = ( f"https://api.telegram.org/bot{push_config.get('TG_BOT_TOKEN')}/sendMessage" ) headers = {"Content-Type": "application/x-www-form-urlencoded"} payload = { "chat_id": str(push_config.get("TG_USER_ID")), "text": f"{title}\n\n{content}", "disable_web_page_preview": "true", } proxies = None if push_config.get("TG_PROXY_HOST") and push_config.get("TG_PROXY_PORT"): if push_config.get("TG_PROXY_AUTH") is not None and "@" not in push_config.get( "TG_PROXY_HOST" ): push_config["TG_PROXY_HOST"] = ( push_config.get("TG_PROXY_AUTH") + "@" + push_config.get("TG_PROXY_HOST") ) proxyStr = "http://{}:{}".format( push_config.get("TG_PROXY_HOST"), push_config.get("TG_PROXY_PORT") ) proxies = {"http": proxyStr, "https": proxyStr} response = requests.post( url=url, headers=headers, params=payload, proxies=proxies ).json() if response["ok"]: print("tg 推送成功!") else: print("tg 推送失败!") def aibotk(title: str, content: str) -> None: """ 使用 智能微秘书 推送消息。 """ if ( not push_config.get("AIBOTK_KEY") or not push_config.get("AIBOTK_TYPE") or not push_config.get("AIBOTK_NAME") ): return print("智能微秘书 服务启动") if push_config.get("AIBOTK_TYPE") == "room": url = "https://api-bot.aibotk.com/openapi/v1/chat/room" data = { "apiKey": push_config.get("AIBOTK_KEY"), "roomName": push_config.get("AIBOTK_NAME"), "message": {"type": 1, "content": f"【青龙快讯】\n\n{title}\n{content}"}, } else: url = "https://api-bot.aibotk.com/openapi/v1/chat/contact" data = { "apiKey": push_config.get("AIBOTK_KEY"), "name": push_config.get("AIBOTK_NAME"), "message": {"type": 1, "content": f"【青龙快讯】\n\n{title}\n{content}"}, } body = json.dumps(data).encode(encoding="utf-8") headers = {"Content-Type": "application/json"} response = requests.post(url=url, data=body, headers=headers).json() print(response) if response["code"] == 0: print("智能微秘书 推送成功!") else: print(f'智能微秘书 推送失败!{response["error"]}') def smtp(title: str, content: str) -> None: """ 使用 SMTP 邮件 推送消息。 """ if ( not push_config.get("SMTP_SERVER") or not push_config.get("SMTP_SSL") or not push_config.get("SMTP_EMAIL") or not push_config.get("SMTP_PASSWORD") or not push_config.get("SMTP_NAME") ): return print("SMTP 邮件 服务启动") message = MIMEText(content, "plain", "utf-8") message["From"] = formataddr( ( Header(push_config.get("SMTP_NAME"), "utf-8").encode(), push_config.get("SMTP_EMAIL"), ) ) message["To"] = formataddr( ( Header(push_config.get("SMTP_NAME"), "utf-8").encode(), push_config.get("SMTP_EMAIL"), ) ) message["Subject"] = Header(title, "utf-8") try: smtp_server = ( smtplib.SMTP_SSL(push_config.get("SMTP_SERVER")) if push_config.get("SMTP_SSL") == "true" else smtplib.SMTP(push_config.get("SMTP_SERVER")) ) smtp_server.login( push_config.get("SMTP_EMAIL"), push_config.get("SMTP_PASSWORD") ) smtp_server.sendmail( push_config.get("SMTP_EMAIL"), push_config.get("SMTP_EMAIL"), message.as_bytes(), ) smtp_server.close() print("SMTP 邮件 推送成功!") except Exception as e: print(f"SMTP 邮件 推送失败!{e}") def pushme(title: str, content: str) -> None: """ 使用 PushMe 推送消息。 """ if not push_config.get("PUSHME_KEY"): return print("PushMe 服务启动") url = ( push_config.get("PUSHME_URL") if push_config.get("PUSHME_URL") else "https://push.i-i.me/" ) data = { "push_key": push_config.get("PUSHME_KEY"), "title": title, "content": content, "date": push_config.get("date") if push_config.get("date") else "", "type": push_config.get("type") if push_config.get("type") else "", } response = requests.post(url, data=data) if response.status_code == 200 and response.text == "success": print("PushMe 推送成功!") else: print(f"PushMe 推送失败!{response.status_code} {response.text}") def chronocat(title: str, content: str) -> None: """ 使用 CHRONOCAT 推送消息。 """ if ( not push_config.get("CHRONOCAT_URL") or not push_config.get("CHRONOCAT_QQ") or not push_config.get("CHRONOCAT_TOKEN") ): return print("CHRONOCAT 服务启动") user_ids = re.findall(r"user_id=(\d+)", push_config.get("CHRONOCAT_QQ")) group_ids = re.findall(r"group_id=(\d+)", push_config.get("CHRONOCAT_QQ")) url = f'{push_config.get("CHRONOCAT_URL")}/api/message/send' headers = { "Content-Type": "application/json", "Authorization": f'Bearer {push_config.get("CHRONOCAT_TOKEN")}', } for chat_type, ids in [(1, user_ids), (2, group_ids)]: if not ids: continue for chat_id in ids: data = { "peer": {"chatType": chat_type, "peerUin": chat_id}, "elements": [ { "elementType": 1, "textElement": {"content": f"{title}\n\n{content}"}, } ], } response = requests.post(url, headers=headers, data=json.dumps(data)) if response.status_code == 200: if chat_type == 1: print(f"QQ个人消息:{ids}推送成功!") else: print(f"QQ群消息:{ids}推送成功!") else: if chat_type == 1: print(f"QQ个人消息:{ids}推送失败!") else: print(f"QQ群消息:{ids}推送失败!") def ntfy(title: str, content: str) -> None: """ 通过 Ntfy 推送消息 """ def encode_rfc2047(text: str) -> str: """将文本编码为符合 RFC 2047 标准的格式""" encoded_bytes = base64.b64encode(text.encode("utf-8")) encoded_str = encoded_bytes.decode("utf-8") return f"=?utf-8?B?{encoded_str}?=" if not push_config.get("NTFY_TOPIC"): return print("ntfy 服务启动") priority = "3" if not push_config.get("NTFY_PRIORITY"): print("ntfy 服务的NTFY_PRIORITY 未设置!!默认设置为3") else: priority = push_config.get("NTFY_PRIORITY") # 使用 RFC 2047 编码 title encoded_title = encode_rfc2047(title) data = content.encode(encoding="utf-8") headers = {"Title": encoded_title, "Priority": priority, "Icon": "https://qn.whyour.cn/logo.png"} # 使用编码后的 title if push_config.get("NTFY_TOKEN"): headers['Authorization'] = "Bearer " + push_config.get("NTFY_TOKEN") elif push_config.get("NTFY_USERNAME") and push_config.get("NTFY_PASSWORD"): authStr = push_config.get("NTFY_USERNAME") + ":" + push_config.get("NTFY_PASSWORD") headers['Authorization'] = "Basic " + base64.b64encode(authStr.encode('utf-8')).decode('utf-8') if push_config.get("NTFY_ACTIONS"): headers['Actions'] = encode_rfc2047(push_config.get("NTFY_ACTIONS")) url = push_config.get("NTFY_URL") + "/" + push_config.get("NTFY_TOPIC") response = requests.post(url, data=data, headers=headers) if response.status_code == 200: # 使用 response.status_code 进行检查 print("Ntfy 推送成功!") else: print("Ntfy 推送失败!错误信息:", response.text) def wxpusher_bot(title: str, content: str) -> None: """ 通过 wxpusher 推送消息。 支持的环境变量: - WXPUSHER_APP_TOKEN: appToken - WXPUSHER_TOPIC_IDS: 主题ID, 多个用英文分号;分隔 - WXPUSHER_UIDS: 用户ID, 多个用英文分号;分隔 """ if not push_config.get("WXPUSHER_APP_TOKEN"): return url = "https://wxpusher.zjiecode.com/api/send/message" # 处理topic_ids和uids,将分号分隔的字符串转为数组 topic_ids = [] if push_config.get("WXPUSHER_TOPIC_IDS"): topic_ids = [ int(id.strip()) for id in push_config.get("WXPUSHER_TOPIC_IDS").split(";") if id.strip() ] uids = [] if push_config.get("WXPUSHER_UIDS"): uids = [ uid.strip() for uid in push_config.get("WXPUSHER_UIDS").split(";") if uid.strip() ] # topic_ids uids 至少有一个 if not topic_ids and not uids: print("wxpusher 服务的 WXPUSHER_TOPIC_IDS 和 WXPUSHER_UIDS 至少设置一个!!") return print("wxpusher 服务启动") data = { "appToken": push_config.get("WXPUSHER_APP_TOKEN"), "content": f"

{title}


{content}
", "summary": title, "contentType": 2, "topicIds": topic_ids, "uids": uids, "verifyPayType": 0, } headers = {"Content-Type": "application/json"} response = requests.post(url=url, json=data, headers=headers).json() if response.get("code") == 1000: print("wxpusher 推送成功!") else: print(f"wxpusher 推送失败!错误信息:{response.get('msg')}") def parse_headers(headers): if not headers: return {} parsed = {} lines = headers.split("\n") for line in lines: i = line.find(":") if i == -1: continue key = line[:i].strip().lower() val = line[i + 1 :].strip() parsed[key] = parsed.get(key, "") + ", " + val if key in parsed else val return parsed def parse_string(input_string, value_format_fn=None): matches = {} pattern = r"(\w+):\s*((?:(?!\n\w+:).)*)" regex = re.compile(pattern) for match in regex.finditer(input_string): key, value = match.group(1).strip(), match.group(2).strip() try: value = value_format_fn(value) if value_format_fn else value json_value = json.loads(value) matches[key] = json_value except: matches[key] = value return matches def parse_body(body, content_type, value_format_fn=None): if not body or content_type == "text/plain": return value_format_fn(body) if value_format_fn and body else body parsed = parse_string(body, value_format_fn) if content_type == "application/x-www-form-urlencoded": data = urllib.parse.urlencode(parsed, doseq=True) return data if content_type == "application/json": data = json.dumps(parsed) return data return parsed def custom_notify(title: str, content: str) -> None: """ 通过 自定义通知 推送消息。 """ if not push_config.get("WEBHOOK_URL") or not push_config.get("WEBHOOK_METHOD"): return print("自定义通知服务启动") WEBHOOK_URL = push_config.get("WEBHOOK_URL") WEBHOOK_METHOD = push_config.get("WEBHOOK_METHOD") WEBHOOK_CONTENT_TYPE = push_config.get("WEBHOOK_CONTENT_TYPE") WEBHOOK_BODY = push_config.get("WEBHOOK_BODY") WEBHOOK_HEADERS = push_config.get("WEBHOOK_HEADERS") if "$title" not in WEBHOOK_URL and "$title" not in WEBHOOK_BODY: print("请求头或者请求体中必须包含 $title 和 $content") return headers = parse_headers(WEBHOOK_HEADERS) body = parse_body( WEBHOOK_BODY, WEBHOOK_CONTENT_TYPE, lambda v: v.replace("$title", title.replace("\n", "\\n")).replace( "$content", content.replace("\n", "\\n") ), ) formatted_url = WEBHOOK_URL.replace( "$title", urllib.parse.quote_plus(title) ).replace("$content", urllib.parse.quote_plus(content)) response = requests.request( method=WEBHOOK_METHOD, url=formatted_url, headers=headers, timeout=15, data=body ) if response.status_code == 200: print("自定义通知推送成功!") else: print(f"自定义通知推送失败!{response.status_code} {response.text}") def one() -> str: """ 获取一条一言。 :return: """ url = "https://v1.hitokoto.cn/" res = requests.get(url).json() return res["hitokoto"] + " ----" + res["from"] def add_notify_function(): notify_function = [] if push_config.get("BARK_PUSH"): notify_function.append(bark) if push_config.get("CONSOLE"): notify_function.append(console) if push_config.get("DD_BOT_TOKEN") and push_config.get("DD_BOT_SECRET"): notify_function.append(dingding_bot) if push_config.get("FSKEY"): notify_function.append(feishu_bot) if push_config.get("GOBOT_URL") and push_config.get("GOBOT_QQ"): notify_function.append(go_cqhttp) if push_config.get("GOTIFY_URL") and push_config.get("GOTIFY_TOKEN"): notify_function.append(gotify) if push_config.get("IGOT_PUSH_KEY"): notify_function.append(iGot) if push_config.get("PUSH_KEY"): notify_function.append(serverJ) if push_config.get("DEER_KEY"): notify_function.append(pushdeer) if push_config.get("CHAT_URL") and push_config.get("CHAT_TOKEN"): notify_function.append(chat) if push_config.get("PUSH_PLUS_TOKEN"): notify_function.append(pushplus_bot) if push_config.get("WE_PLUS_BOT_TOKEN"): notify_function.append(weplus_bot) if push_config.get("QMSG_KEY") and push_config.get("QMSG_TYPE"): notify_function.append(qmsg_bot) if push_config.get("QYWX_AM"): notify_function.append(wecom_app) if push_config.get("QYWX_KEY"): notify_function.append(wecom_bot) if push_config.get("TG_BOT_TOKEN") and push_config.get("TG_USER_ID"): notify_function.append(telegram_bot) if ( push_config.get("AIBOTK_KEY") and push_config.get("AIBOTK_TYPE") and push_config.get("AIBOTK_NAME") ): notify_function.append(aibotk) if ( push_config.get("SMTP_SERVER") and push_config.get("SMTP_SSL") and push_config.get("SMTP_EMAIL") and push_config.get("SMTP_PASSWORD") and push_config.get("SMTP_NAME") ): notify_function.append(smtp) if push_config.get("PUSHME_KEY"): notify_function.append(pushme) if ( push_config.get("CHRONOCAT_URL") and push_config.get("CHRONOCAT_QQ") and push_config.get("CHRONOCAT_TOKEN") ): notify_function.append(chronocat) if push_config.get("WEBHOOK_URL") and push_config.get("WEBHOOK_METHOD"): notify_function.append(custom_notify) if push_config.get("NTFY_TOPIC"): notify_function.append(ntfy) if push_config.get("WXPUSHER_APP_TOKEN") and ( push_config.get("WXPUSHER_TOPIC_IDS") or push_config.get("WXPUSHER_UIDS") ): notify_function.append(wxpusher_bot) if not notify_function: print(f"无推送渠道,请检查通知变量是否正确") return notify_function def send(title: str, content: str, ignore_default_config: bool = False, **kwargs): if kwargs: global push_config if ignore_default_config: push_config = kwargs # 清空从环境变量获取的配置 else: push_config.update(kwargs) if not content: print(f"{title} 推送内容为空!") return # 根据标题跳过一些消息推送,环境变量:SKIP_PUSH_TITLE 用回车分隔 skipTitle = os.getenv("SKIP_PUSH_TITLE") if skipTitle: if title in re.split("\n", skipTitle): print(f"{title} 在SKIP_PUSH_TITLE环境变量内,跳过推送!") return hitokoto = push_config.get("HITOKOTO") content += "\n\n" + one() if hitokoto != "false" else "" notify_function = add_notify_function() ts = [ threading.Thread(target=mode, args=(title, content), name=mode.__name__) for mode in notify_function ] [t.start() for t in ts] [t.join() for t in ts] def main(): send("title", "content") if __name__ == "__main__": main() ================================================ FILE: sample/notify.py.save ================================================ #!/usr/bin/env python3 # _*_ coding:utf-8 _*_ import base64 import hashlib import hmac import json import os import re import threading import time import urllib.parse import smtplib from email.mime.text import MIMEText from email.header import Header from email.utils import formataddr import requests # 原先的 print 函数和主线程的锁 _print = print mutex = threading.Lock() # 定义新的 print 函数 def print(text, *args, **kw): """ 使输出有序进行,不出现多线程同一时间输出导致错乱的问题。 """ with mutex: _print(text, *args, **kw) # 通知服务 # fmt: off push_config = { 'HITOKOTO': True, # 启用一言(随机句子) 'BARK_PUSH': '', # bark IP 或设备码,例:https://api.day.app/DxHcxxxxxRxxxxxxcm/ 'BARK_ARCHIVE': '', # bark 推送是否存档 'BARK_GROUP': '', # bark 推送分组 'BARK_SOUND': '', # bark 推送声音 'BARK_ICON': '', # bark 推送图标 'BARK_LEVEL': '', # bark 推送时效性 'BARK_URL': '', # bark 推送跳转URL 'CONSOLE': False, # 控制台输出 'DD_BOT_SECRET': '', # 钉钉机器人的 DD_BOT_SECRET 'DD_BOT_TOKEN': '', # 钉钉机器人的 DD_BOT_TOKEN 'FSKEY': '', # 飞书机器人的 FSKEY 'GOBOT_URL': '', # go-cqhttp # 推送到个人QQ:http://127.0.0.1/send_private_msg # 群:http://127.0.0.1/send_group_msg 'GOBOT_QQ': '', # go-cqhttp 的推送群或用户 # GOBOT_URL 设置 /send_private_msg 时填入 user_id=个人QQ # /send_group_msg 时填入 group_id=QQ群 'GOBOT_TOKEN': '', # go-cqhttp 的 access_token 'GOTIFY_URL': '', # gotify地址,如https://push.example.de:8080 'GOTIFY_TOKEN': '', # gotify的消息应用token 'GOTIFY_PRIORITY': 0, # 推送消息优先级,默认为0 'IGOT_PUSH_KEY': '', # iGot 聚合推送的 IGOT_PUSH_KEY 'PUSH_KEY': '', # server 酱的 PUSH_KEY,兼容旧版与 Turbo 版 'DEER_KEY': '', # PushDeer 的 PUSHDEER_KEY 'DEER_URL': '', # PushDeer 的 PUSHDEER_URL 'CHAT_URL': '', # synology chat url 'CHAT_TOKEN': '', # synology chat token 'PUSH_PLUS_TOKEN': '', # push+ 微信推送的用户令牌 'PUSH_PLUS_USER': '', # push+ 微信推送的群组编码 'WE_PLUS_BOT_TOKEN': '', # 微加机器人的用户令牌 'WE_PLUS_BOT_RECEIVER': '', # 微加机器人的消息接收者 'WE_PLUS_BOT_VERSION': 'pro', # 微加机器人的调用版本 'QMSG_KEY': '', # qmsg 酱的 QMSG_KEY 'QMSG_TYPE': '', # qmsg 酱的 QMSG_TYPE 'QYWX_ORIGIN': '', # 企业微信代理地址 'QYWX_AM': '', # 企业微信应用 'QYWX_KEY': '', # 企业微信机器人 'TG_BOT_TOKEN': '', # tg 机器人的 TG_BOT_TOKEN,例:1407203283:AAG9rt-6RDaaX0HBLZQq0laNOh898iFYaRQ 'TG_USER_ID': '', # tg 机器人的 TG_USER_ID,例:1434078534 'TG_API_HOST': '', # tg 代理 api 'TG_PROXY_AUTH': '', # tg 代理认证参数 'TG_PROXY_HOST': '', # tg 机器人的 TG_PROXY_HOST 'TG_PROXY_PORT': '', # tg 机器人的 TG_PROXY_PORT 'AIBOTK_KEY': '', # 智能微秘书 个人中心的apikey 文档地址:http://wechat.aibotk.com/docs/about 'AIBOTK_TYPE': '', # 智能微秘书 发送目标 room 或 contact 'AIBOTK_NAME': '', # 智能微秘书 发送群名 或者好友昵称和type要对应好 'SMTP_SERVER': '', # SMTP 发送邮件服务器,形如 smtp.exmail.qq.com:465 'SMTP_SSL': 'false', # SMTP 发送邮件服务器是否使用 SSL,填写 true 或 false 'SMTP_EMAIL': '', # SMTP 收发件邮箱,通知将会由自己发给自己 'SMTP_PASSWORD': '', # SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定 'SMTP_NAME': '', # SMTP 收发件人姓名,可随意填写 'PUSHME_KEY': '', # PushMe 的 PUSHME_KEY 'PUSHME_URL': '', # PushMe 的 PUSHME_URL 'CHRONOCAT_QQ': '', # qq号 'CHRONOCAT_TOKEN': '', # CHRONOCAT 的token 'CHRONOCAT_URL': '', # CHRONOCAT的url地址 'WEBHOOK_URL': '', # 自定义通知 请求地址 'WEBHOOK_BODY': '', # 自定义通知 请求体 'WEBHOOK_HEADERS': '', # 自定义通知 请求头 'WEBHOOK_METHOD': '', # 自定义通知 请求方法 'WEBHOOK_CONTENT_TYPE': '' # 自定义通知 content-type 'NTFY_URL': '', # ntfy地址,如https://ntfy.sh 'NTFY_TOPIC': '', # ntfy的消息应用topic 'NTFY_PRIORITY': '3', # 推送消息优先级,默认为3 } # fmt: on for k in push_config: if os.getenv(k): v = os.getenv(k) push_config[k] = v def bark(title: str, content: str) -> None: """ 使用 bark 推送消息。 """ if not push_config.get("BARK_PUSH"): print("bark 服务的 BARK_PUSH 未设置!!\n取消推送") return print("bark 服务启动") if push_config.get("BARK_PUSH").startswith("http"): url = f'{push_config.get("BARK_PUSH")}' else: url = f'https://api.day.app/{push_config.get("BARK_PUSH")}' bark_params = { "BARK_ARCHIVE": "isArchive", "BARK_GROUP": "group", "BARK_SOUND": "sound", "BARK_ICON": "icon", "BARK_LEVEL": "level", "BARK_URL": "url", } data = { "title": title, "body": content, } for pair in filter( lambda pairs: pairs[0].startswith("BARK_") and pairs[0] != "BARK_PUSH" and pairs[1] and bark_params.get(pairs[0]), push_config.items(), ): data[bark_params.get(pair[0])] = pair[1] headers = {"Content-Type": "application/json;charset=utf-8"} response = requests.post( url=url, data=json.dumps(data), headers=headers, timeout=15 ).json() if response["code"] == 200: print("bark 推送成功!") else: print("bark 推送失败!") def console(title: str, content: str) -> None: """ 使用 控制台 推送消息。 """ print(f"{title}\n\n{content}") def dingding_bot(title: str, content: str) -> None: """ 使用 钉钉机器人 推送消息。 """ if not push_config.get("DD_BOT_SECRET") or not push_config.get("DD_BOT_TOKEN"): print("钉钉机器人 服务的 DD_BOT_SECRET 或者 DD_BOT_TOKEN 未设置!!\n取消推送") return print("钉钉机器人 服务启动") timestamp = str(round(time.time() * 1000)) secret_enc = push_config.get("DD_BOT_SECRET").encode("utf-8") string_to_sign = "{}\n{}".format(timestamp, push_config.get("DD_BOT_SECRET")) string_to_sign_enc = string_to_sign.encode("utf-8") hmac_code = hmac.new( secret_enc, string_to_sign_enc, digestmod=hashlib.sha256 ).digest() sign = urllib.parse.quote_plus(base64.b64encode(hmac_code)) url = f'https://oapi.dingtalk.com/robot/send?access_token={push_config.get("DD_BOT_TOKEN")}×tamp={timestamp}&sign={sign}' headers = {"Content-Type": "application/json;charset=utf-8"} data = {"msgtype": "text", "text": {"content": f"{title}\n\n{content}"}} response = requests.post( url=url, data=json.dumps(data), headers=headers, timeout=15 ).json() if not response["errcode"]: print("钉钉机器人 推送成功!") else: print("钉钉机器人 推送失败!") def feishu_bot(title: str, content: str) -> None: """ 使用 飞书机器人 推送消息。 """ if not push_config.get("FSKEY"): print("飞书 服务的 FSKEY 未设置!!\n取消推送") return print("飞书 服务启动") url = f'https://open.feishu.cn/open-apis/bot/v2/hook/{push_config.get("FSKEY")}' data = {"msg_type": "text", "content": {"text": f"{title}\n\n{content}"}} response = requests.post(url, data=json.dumps(data)).json() if response.get("StatusCode") == 0 or response.get("code") == 0: print("飞书 推送成功!") else: print("飞书 推送失败!错误信息如下:\n", response) def go_cqhttp(title: str, content: str) -> None: """ 使用 go_cqhttp 推送消息。 """ if not push_config.get("GOBOT_URL") or not push_config.get("GOBOT_QQ"): print("go-cqhttp 服务的 GOBOT_URL 或 GOBOT_QQ 未设置!!\n取消推送") return print("go-cqhttp 服务启动") url = f'{push_config.get("GOBOT_URL")}?access_token={push_config.get("GOBOT_TOKEN")}&{push_config.get("GOBOT_QQ")}&message=标题:{title}\n内容:{content}' response = requests.get(url).json() if response["status"] == "ok": print("go-cqhttp 推送成功!") else: print("go-cqhttp 推送失败!") def gotify(title: str, content: str) -> None: """ 使用 gotify 推送消息。 """ if not push_config.get("GOTIFY_URL") or not push_config.get("GOTIFY_TOKEN"): print("gotify 服务的 GOTIFY_URL 或 GOTIFY_TOKEN 未设置!!\n取消推送") return print("gotify 服务启动") url = f'{push_config.get("GOTIFY_URL")}/message?token={push_config.get("GOTIFY_TOKEN")}' data = { "title": title, "message": content, "priority": push_config.get("GOTIFY_PRIORITY"), } response = requests.post(url, data=data).json() if response.get("id"): print("gotify 推送成功!") else: print("gotify 推送失败!") def iGot(title: str, content: str) -> None: """ 使用 iGot 推送消息。 """ if not push_config.get("IGOT_PUSH_KEY"): print("iGot 服务的 IGOT_PUSH_KEY 未设置!!\n取消推送") return print("iGot 服务启动") url = f'https://push.hellyw.com/{push_config.get("IGOT_PUSH_KEY")}' data = {"title": title, "content": content} headers = {"Content-Type": "application/x-www-form-urlencoded"} response = requests.post(url, data=data, headers=headers).json() if response["ret"] == 0: print("iGot 推送成功!") else: print(f'iGot 推送失败!{response["errMsg"]}') def serverJ(title: str, content: str) -> None: """ 通过 serverJ 推送消息。 """ if not push_config.get("PUSH_KEY"): print("serverJ 服务的 PUSH_KEY 未设置!!\n取消推送") return print("serverJ 服务启动") data = {"text": title, "desp": content.replace("\n", "\n\n")} if push_config.get("PUSH_KEY").startswith("sctp"): url = f'https://{push_config.get("PUSH_KEY")}.push.ft07.com/send' else: url = f'https://sctapi.ftqq.com/{push_config.get("PUSH_KEY")}.send' response = requests.post(url, data=data).json() if response.get("errno") == 0 or response.get("code") == 0: print("serverJ 推送成功!") else: print(f'serverJ 推送失败!错误码:{response["message"]}') def pushdeer(title: str, content: str) -> None: """ 通过PushDeer 推送消息 """ if not push_config.get("DEER_KEY"): print("PushDeer 服务的 DEER_KEY 未设置!!\n取消推送") return print("PushDeer 服务启动") data = { "text": title, "desp": content, "type": "markdown", "pushkey": push_config.get("DEER_KEY"), } url = "https://api2.pushdeer.com/message/push" if push_config.get("DEER_URL"): url = push_config.get("DEER_URL") response = requests.post(url, data=data).json() if len(response.get("content").get("result")) > 0: print("PushDeer 推送成功!") else: print("PushDeer 推送失败!错误信息:", response) def chat(title: str, content: str) -> None: """ 通过Chat 推送消息 """ if not push_config.get("CHAT_URL") or not push_config.get("CHAT_TOKEN"): print("chat 服务的 CHAT_URL或CHAT_TOKEN 未设置!!\n取消推送") return print("chat 服务启动") data = "payload=" + json.dumps({"text": title + "\n" + content}) url = push_config.get("CHAT_URL") + push_config.get("CHAT_TOKEN") response = requests.post(url, data=data) if response.status_code == 200: print("Chat 推送成功!") else: print("Chat 推送失败!错误信息:", response) def pushplus_bot(title: str, content: str) -> None: """ 通过 push+ 推送消息。 """ if not push_config.get("PUSH_PLUS_TOKEN"): print("PUSHPLUS 服务的 PUSH_PLUS_TOKEN 未设置!!\n取消推送") return print("PUSHPLUS 服务启动") url = "http://www.pushplus.plus/send" data = { "token": push_config.get("PUSH_PLUS_TOKEN"), "title": title, "content": content, "topic": push_config.get("PUSH_PLUS_USER"), } body = json.dumps(data).encode(encoding="utf-8") headers = {"Content-Type": "application/json"} response = requests.post(url=url, data=body, headers=headers).json() if response["code"] == 200: print("PUSHPLUS 推送成功!") else: url_old = "http://pushplus.hxtrip.com/send" headers["Accept"] = "application/json" response = requests.post(url=url_old, data=body, headers=headers).json() if response["code"] == 200: print("PUSHPLUS(hxtrip) 推送成功!") else: print("PUSHPLUS 推送失败!") def weplus_bot(title: str, content: str) -> None: """ 通过 微加机器人 推送消息。 """ if not push_config.get("WE_PLUS_BOT_TOKEN"): print("微加机器人 服务的 WE_PLUS_BOT_TOKEN 未设置!!\n取消推送") return print("微加机器人 服务启动") template = "txt" if len(content) > 800: template = "html" url = "https://www.weplusbot.com/send" data = { "token": push_config.get("WE_PLUS_BOT_TOKEN"), "title": title, "content": content, "template": template, "receiver": push_config.get("WE_PLUS_BOT_RECEIVER"), "version": push_config.get("WE_PLUS_BOT_VERSION"), } body = json.dumps(data).encode(encoding="utf-8") headers = {"Content-Type": "application/json"} response = requests.post(url=url, data=body, headers=headers).json() if response["code"] == 200: print("微加机器人 推送成功!") else: print("微加机器人 推送失败!") def qmsg_bot(title: str, content: str) -> None: """ 使用 qmsg 推送消息。 """ if not push_config.get("QMSG_KEY") or not push_config.get("QMSG_TYPE"): print("qmsg 的 QMSG_KEY 或者 QMSG_TYPE 未设置!!\n取消推送") return print("qmsg 服务启动") url = f'https://qmsg.zendee.cn/{push_config.get("QMSG_TYPE")}/{push_config.get("QMSG_KEY")}' payload = {"msg": f'{title}\n\n{content.replace("----", "-")}'.encode("utf-8")} response = requests.post(url=url, params=payload).json() if response["code"] == 0: print("qmsg 推送成功!") else: print(f'qmsg 推送失败!{response["reason"]}') def wecom_app(title: str, content: str) -> None: """ 通过 企业微信 APP 推送消息。 """ if not push_config.get("QYWX_AM"): print("QYWX_AM 未设置!!\n取消推送") return QYWX_AM_AY = re.split(",", push_config.get("QYWX_AM")) if 4 < len(QYWX_AM_AY) > 5: print("QYWX_AM 设置错误!!\n取消推送") return print("企业微信 APP 服务启动") corpid = QYWX_AM_AY[0] corpsecret = QYWX_AM_AY[1] touser = QYWX_AM_AY[2] agentid = QYWX_AM_AY[3] try: media_id = QYWX_AM_AY[4] except IndexError: media_id = "" wx = WeCom(corpid, corpsecret, agentid) # 如果没有配置 media_id 默认就以 text 方式发送 if not media_id: message = title + "\n\n" + content response = wx.send_text(message, touser) else: response = wx.send_mpnews(title, content, media_id, touser) if response == "ok": print("企业微信推送成功!") else: print("企业微信推送失败!错误信息如下:\n", response) class WeCom: def __init__(self, corpid, corpsecret, agentid): self.CORPID = corpid self.CORPSECRET = corpsecret self.AGENTID = agentid self.ORIGIN = "https://qyapi.weixin.qq.com" if push_config.get("QYWX_ORIGIN"): self.ORIGIN = push_config.get("QYWX_ORIGIN") def get_access_token(self): url = f"{self.ORIGIN}/cgi-bin/gettoken" values = { "corpid": self.CORPID, "corpsecret": self.CORPSECRET, } req = requests.post(url, params=values) data = json.loads(req.text) return data["access_token"] def send_text(self, message, touser="@all"): send_url = ( f"{self.ORIGIN}/cgi-bin/message/send?access_token={self.get_access_token()}" ) send_values = { "touser": touser, "msgtype": "text", "agentid": self.AGENTID, "text": {"content": message}, "safe": "0", } send_msges = bytes(json.dumps(send_values), "utf-8") respone = requests.post(send_url, send_msges) respone = respone.json() return respone["errmsg"] def send_mpnews(self, title, message, media_id, touser="@all"): send_url = ( f"{self.ORIGIN}/cgi-bin/message/send?access_token={self.get_access_token()}" ) send_values = { "touser": touser, "msgtype": "mpnews", "agentid": self.AGENTID, "mpnews": { "articles": [ { "title": title, "thumb_media_id": media_id, "author": "Author", "content_source_url": "", "content": message.replace("\n", "
"), "digest": message, } ] }, } send_msges = bytes(json.dumps(send_values), "utf-8") respone = requests.post(send_url, send_msges) respone = respone.json() return respone["errmsg"] def wecom_bot(title: str, content: str) -> None: """ 通过 企业微信机器人 推送消息。 """ if not push_config.get("QYWX_KEY"): print("企业微信机器人 服务的 QYWX_KEY 未设置!!\n取消推送") return print("企业微信机器人服务启动") origin = "https://qyapi.weixin.qq.com" if push_config.get("QYWX_ORIGIN"): origin = push_config.get("QYWX_ORIGIN") url = f"{origin}/cgi-bin/webhook/send?key={push_config.get('QYWX_KEY')}" headers = {"Content-Type": "application/json;charset=utf-8"} data = {"msgtype": "text", "text": {"content": f"{title}\n\n{content}"}} response = requests.post( url=url, data=json.dumps(data), headers=headers, timeout=15 ).json() if response["errcode"] == 0: print("企业微信机器人推送成功!") else: print("企业微信机器人推送失败!") def telegram_bot(title: str, content: str) -> None: """ 使用 telegram 机器人 推送消息。 """ if not push_config.get("TG_BOT_TOKEN") or not push_config.get("TG_USER_ID"): print("tg 服务的 bot_token 或者 user_id 未设置!!\n取消推送") return print("tg 服务启动") if push_config.get("TG_API_HOST"): url = f"{push_config.get('TG_API_HOST')}/bot{push_config.get('TG_BOT_TOKEN')}/sendMessage" else: url = ( f"https://api.telegram.org/bot{push_config.get('TG_BOT_TOKEN')}/sendMessage" ) headers = {"Content-Type": "application/x-www-form-urlencoded"} payload = { "chat_id": str(push_config.get("TG_USER_ID")), "text": f"{title}\n\n{content}", "disable_web_page_preview": "true", } proxies = None if push_config.get("TG_PROXY_HOST") and push_config.get("TG_PROXY_PORT"): if push_config.get("TG_PROXY_AUTH") is not None and "@" not in push_config.get( "TG_PROXY_HOST" ): push_config["TG_PROXY_HOST"] = ( push_config.get("TG_PROXY_AUTH") + "@" + push_config.get("TG_PROXY_HOST") ) proxyStr = "http://{}:{}".format( push_config.get("TG_PROXY_HOST"), push_config.get("TG_PROXY_PORT") ) proxies = {"http": proxyStr, "https": proxyStr} response = requests.post( url=url, headers=headers, params=payload, proxies=proxies ).json() if response["ok"]: print("tg 推送成功!") else: print("tg 推送失败!") def aibotk(title: str, content: str) -> None: """ 使用 智能微秘书 推送消息。 """ if ( not push_config.get("AIBOTK_KEY") or not push_config.get("AIBOTK_TYPE") or not push_config.get("AIBOTK_NAME") ): print( "智能微秘书 的 AIBOTK_KEY 或者 AIBOTK_TYPE 或者 AIBOTK_NAME 未设置!!\n取消推送" ) return print("智能微秘书 服务启动") if push_config.get("AIBOTK_TYPE") == "room": url = "https://api-bot.aibotk.com/openapi/v1/chat/room" data = { "apiKey": push_config.get("AIBOTK_KEY"), "roomName": push_config.get("AIBOTK_NAME"), "message": {"type": 1, "content": f"【青龙快讯】\n\n{title}\n{content}"}, } else: url = "https://api-bot.aibotk.com/openapi/v1/chat/contact" data = { "apiKey": push_config.get("AIBOTK_KEY"), "name": push_config.get("AIBOTK_NAME"), "message": {"type": 1, "content": f"【青龙快讯】\n\n{title}\n{content}"}, } body = json.dumps(data).encode(encoding="utf-8") headers = {"Content-Type": "application/json"} response = requests.post(url=url, data=body, headers=headers).json() print(response) if response["code"] == 0: print("智能微秘书 推送成功!") else: print(f'智能微秘书 推送失败!{response["error"]}') def smtp(title: str, content: str) -> None: """ 使用 SMTP 邮件 推送消息。 """ if ( not push_config.get("SMTP_SERVER") or not push_config.get("SMTP_SSL") or not push_config.get("SMTP_EMAIL") or not push_config.get("SMTP_PASSWORD") or not push_config.get("SMTP_NAME") ): print( "SMTP 邮件 的 SMTP_SERVER 或者 SMTP_SSL 或者 SMTP_EMAIL 或者 SMTP_PASSWORD 或者 SMTP_NAME 未设置!!\n取消推送" ) return print("SMTP 邮件 服务启动") message = MIMEText(content, "plain", "utf-8") message["From"] = formataddr( ( Header(push_config.get("SMTP_NAME"), "utf-8").encode(), push_config.get("SMTP_EMAIL"), ) ) message["To"] = formataddr( ( Header(push_config.get("SMTP_NAME"), "utf-8").encode(), push_config.get("SMTP_EMAIL"), ) ) message["Subject"] = Header(title, "utf-8") try: smtp_server = ( smtplib.SMTP_SSL(push_config.get("SMTP_SERVER")) if push_config.get("SMTP_SSL") == "true" else smtplib.SMTP(push_config.get("SMTP_SERVER")) ) smtp_server.login( push_config.get("SMTP_EMAIL"), push_config.get("SMTP_PASSWORD") ) smtp_server.sendmail( push_config.get("SMTP_EMAIL"), push_config.get("SMTP_EMAIL"), message.as_bytes(), ) smtp_server.close() print("SMTP 邮件 推送成功!") except Exception as e: print(f"SMTP 邮件 推送失败!{e}") def pushme(title: str, content: str) -> None: """ 使用 PushMe 推送消息。 """ if not push_config.get("PUSHME_KEY"): print("PushMe 服务的 PUSHME_KEY 未设置!!\n取消推送") return print("PushMe 服务启动") url = ( push_config.get("PUSHME_URL") if push_config.get("PUSHME_URL") else "https://push.i-i.me/" ) data = { "push_key": push_config.get("PUSHME_KEY"), "title": title, "content": content, "date": push_config.get("date") if push_config.get("date") else "", "type": push_config.get("type") if push_config.get("type") else "", } response = requests.post(url, data=data) if response.status_code == 200 and response.text == "success": print("PushMe 推送成功!") else: print(f"PushMe 推送失败!{response.status_code} {response.text}") def chronocat(title: str, content: str) -> None: """ 使用 CHRONOCAT 推送消息。 """ if ( not push_config.get("CHRONOCAT_URL") or not push_config.get("CHRONOCAT_QQ") or not push_config.get("CHRONOCAT_TOKEN") ): print("CHRONOCAT 服务的 CHRONOCAT_URL 或 CHRONOCAT_QQ 未设置!!\n取消推送") return print("CHRONOCAT 服务启动") user_ids = re.findall(r"user_id=(\d+)", push_config.get("CHRONOCAT_QQ")) group_ids = re.findall(r"group_id=(\d+)", push_config.get("CHRONOCAT_QQ")) url = f'{push_config.get("CHRONOCAT_URL")}/api/message/send' headers = { "Content-Type": "application/json", "Authorization": f'Bearer {push_config.get("CHRONOCAT_TOKEN")}', } for chat_type, ids in [(1, user_ids), (2, group_ids)]: if not ids: continue for chat_id in ids: data = { "peer": {"chatType": chat_type, "peerUin": chat_id}, "elements": [ { "elementType": 1, "textElement": {"content": f"{title}\n\n{content}"}, } ], } response = requests.post(url, headers=headers, data=json.dumps(data)) if response.status_code == 200: if chat_type == 1: print(f"QQ个人消息:{ids}推送成功!") else: print(f"QQ群消息:{ids}推送成功!") else: if chat_type == 1: print(f"QQ个人消息:{ids}推送失败!") else: print(f"QQ群消息:{ids}推送失败!") def ntfy(title: str, content: str) -> None: """ 使用 Ntfy 推送消息。 """ if not push_config.get("Ntfy_T"): print("PushMe 服务的 PUSHME_KEY 未设置!!\n取消推送") return print("PushMe 服务启动") url = push_config.get("PUSHME_URL") if push_config.get("PUSHME_URL") else "https://push.i-i.me/" data = { "push_key": push_config.get("PUSHME_KEY"), "title": title, "content": content, "date": push_config.get("date") if push_config.get("date") else "", "type": push_config.get("type") if push_config.get("type") else "", } response = requests.post(url, data=data) if response.status_code == 200 and response.text == "success": print("PushMe 推送成功!") else: print(f"PushMe 推送失败!{response.status_code} {response.text}") def parse_headers(headers): if not headers: return {} parsed = {} lines = headers.split("\n") for line in lines: i = line.find(":") if i == -1: continue key = line[:i].strip().lower() val = line[i + 1 :].strip() parsed[key] = parsed.get(key, "") + ", " + val if key in parsed else val return parsed def parse_string(input_string, value_format_fn=None): matches = {} pattern = r"(\w+):\s*((?:(?!\n\w+:).)*)" regex = re.compile(pattern) for match in regex.finditer(input_string): key, value = match.group(1).strip(), match.group(2).strip() try: value = value_format_fn(value) if value_format_fn else value json_value = json.loads(value) matches[key] = json_value except: matches[key] = value return matches def parse_body(body, content_type, value_format_fn=None): if not body or content_type == "text/plain": return value_format_fn(body) if value_format_fn and body else body parsed = parse_string(body, value_format_fn) if content_type == "application/x-www-form-urlencoded": data = urllib.parse.urlencode(parsed, doseq=True) return data if content_type == "application/json": data = json.dumps(parsed) return data return parsed def custom_notify(title: str, content: str) -> None: """ 通过 自定义通知 推送消息。 """ if not push_config.get("WEBHOOK_URL") or not push_config.get("WEBHOOK_METHOD"): print("自定义通知的 WEBHOOK_URL 或 WEBHOOK_METHOD 未设置!!\n取消推送") return print("自定义通知服务启动") WEBHOOK_URL = push_config.get("WEBHOOK_URL") WEBHOOK_METHOD = push_config.get("WEBHOOK_METHOD") WEBHOOK_CONTENT_TYPE = push_config.get("WEBHOOK_CONTENT_TYPE") WEBHOOK_BODY = push_config.get("WEBHOOK_BODY") WEBHOOK_HEADERS = push_config.get("WEBHOOK_HEADERS") if "$title" not in WEBHOOK_URL and "$title" not in WEBHOOK_BODY: print("请求头或者请求体中必须包含 $title 和 $content") return headers = parse_headers(WEBHOOK_HEADERS) body = parse_body( WEBHOOK_BODY, WEBHOOK_CONTENT_TYPE, lambda v: v.replace("$title", title.replace("\n", "\\n")).replace( "$content", content.replace("\n", "\\n") ), ) formatted_url = WEBHOOK_URL.replace( "$title", urllib.parse.quote_plus(title) ).replace("$content", urllib.parse.quote_plus(content)) response = requests.request( method=WEBHOOK_METHOD, url=formatted_url, headers=headers, timeout=15, data=body ) if response.status_code == 200: print("自定义通知推送成功!") else: print(f"自定义通知推送失败!{response.status_code} {response.text}") def one() -> str: """ 获取一条一言。 :return: """ url = "https://v1.hitokoto.cn/" res = requests.get(url).json() return res["hitokoto"] + " ----" + res["from"] def add_notify_function(): notify_function = [] if push_config.get("BARK_PUSH"): notify_function.append(bark) if push_config.get("CONSOLE"): notify_function.append(console) if push_config.get("DD_BOT_TOKEN") and push_config.get("DD_BOT_SECRET"): notify_function.append(dingding_bot) if push_config.get("FSKEY"): notify_function.append(feishu_bot) if push_config.get("GOBOT_URL") and push_config.get("GOBOT_QQ"): notify_function.append(go_cqhttp) if push_config.get("GOTIFY_URL") and push_config.get("GOTIFY_TOKEN"): notify_function.append(gotify) if push_config.get("IGOT_PUSH_KEY"): notify_function.append(iGot) if push_config.get("PUSH_KEY"): notify_function.append(serverJ) if push_config.get("DEER_KEY"): notify_function.append(pushdeer) if push_config.get("CHAT_URL") and push_config.get("CHAT_TOKEN"): notify_function.append(chat) if push_config.get("PUSH_PLUS_TOKEN"): notify_function.append(pushplus_bot) if push_config.get("WE_PLUS_BOT_TOKEN"): notify_function.append(weplus_bot) if push_config.get("QMSG_KEY") and push_config.get("QMSG_TYPE"): notify_function.append(qmsg_bot) if push_config.get("QYWX_AM"): notify_function.append(wecom_app) if push_config.get("QYWX_KEY"): notify_function.append(wecom_bot) if push_config.get("TG_BOT_TOKEN") and push_config.get("TG_USER_ID"): notify_function.append(telegram_bot) if ( push_config.get("AIBOTK_KEY") and push_config.get("AIBOTK_TYPE") and push_config.get("AIBOTK_NAME") ): notify_function.append(aibotk) if ( push_config.get("SMTP_SERVER") and push_config.get("SMTP_SSL") and push_config.get("SMTP_EMAIL") and push_config.get("SMTP_PASSWORD") and push_config.get("SMTP_NAME") ): notify_function.append(smtp) if push_config.get("PUSHME_KEY"): notify_function.append(pushme) if ( push_config.get("CHRONOCAT_URL") and push_config.get("CHRONOCAT_QQ") and push_config.get("CHRONOCAT_TOKEN") ): notify_function.append(chronocat) if push_config.get("WEBHOOK_URL") and push_config.get("WEBHOOK_METHOD"): notify_function.append(custom_notify) if not notify_function: print(f"无推送渠道,请检查通知变量是否正确") return notify_function def send(title: str, content: str, ignore_default_config: bool = False, **kwargs): if kwargs: global push_config if ignore_default_config: push_config = kwargs # 清空从环境变量获取的配置 else: push_config.update(kwargs) if not content: print(f"{title} 推送内容为空!") return # 根据标题跳过一些消息推送,环境变量:SKIP_PUSH_TITLE 用回车分隔 skipTitle = os.getenv("SKIP_PUSH_TITLE") if skipTitle: if title in re.split("\n", skipTitle): print(f"{title} 在SKIP_PUSH_TITLE环境变量内,跳过推送!") return hitokoto = push_config.get("HITOKOTO") content += "\n\n" + one() if hitokoto != "false" else "" notify_function = add_notify_function() ts = [ threading.Thread(target=mode, args=(title, content), name=mode.__name__) for mode in notify_function ] [t.start() for t in ts] [t.join() for t in ts] def main(): send("title", "content") if __name__ == "__main__": main() ================================================ FILE: sample/ql_sample.js ================================================ /** * 任务名称 * name: script name * 定时规则 * cron: 1 9 * * * */ console.log('test scripts'); QLAPI.notify('test scripts', 'test desc'); QLAPI.getEnvs({ searchValue: 'dddd' }).then((x) => { console.log('getEnvs', x); }); QLAPI.systemNotify({ title: '123', content: '231' }).then((x) => { console.log('systemNotify', x); }); // 查询定时任务 (Query cron tasks) QLAPI.getCrons({ searchValue: 'test' }).then((x) => { console.log('getCrons', x); }); // 通过ID查询定时任务 (Get cron by ID) QLAPI.getCronById({ id: 1 }).then((x) => { console.log('getCronById', x); }).catch((err) => { console.log('getCronById error', err); }); // 启用定时任务 (Enable cron tasks) QLAPI.enableCrons({ ids: [1, 2] }).then((x) => { console.log('enableCrons', x); }); // 禁用定时任务 (Disable cron tasks) QLAPI.disableCrons({ ids: [1, 2] }).then((x) => { console.log('disableCrons', x); }); // 手动执行定时任务 (Run cron tasks manually) QLAPI.runCrons({ ids: [1] }).then((x) => { console.log('runCrons', x); }); console.log('test desc'); ================================================ FILE: sample/ql_sample.py ================================================ """ 任务名称 name: script name 定时规则 cron: 1 9 * * * """ print("test script") print(QLAPI.notify("test script", "test desc")) print("test systemNotify") print(QLAPI.systemNotify({"title": "test script", "content": "dddd"})) print("test getEnvs") print(QLAPI.getEnvs({"searchValue": "1"})) print("test desc") ================================================ FILE: sample/task.sample.sh ================================================ #!/usr/bin/env bash ================================================ FILE: sample/tool.ts ================================================ import * as qiniu from 'qiniu'; import dotenv from 'dotenv'; const envFound = dotenv.config(); const accessKey = process.env.QINIU_AK; const secretKey = process.env.QINIU_SK; const mac = new qiniu.auth.digest.Mac(accessKey, secretKey); const key = 'version.yaml'; const options = { scope: `${process.env.QINIU_SCOPE}:${key}`, }; const putPolicy = new qiniu.rs.PutPolicy(options); const uploadToken = putPolicy.uploadToken(mac); const localFile = 'version.yaml'; const config = new qiniu.conf.Config({ zone: qiniu.zone.Zone_z1 }); const formUploader = new qiniu.form_up.FormUploader(config); const putExtra = new qiniu.form_up.PutExtra( '', {}, 'text/plain; charset=utf-8', ); // 文件上传 formUploader.putFile( uploadToken, key, localFile, putExtra, function (respErr, respBody, respInfo) { if (respErr) { throw respErr; } if (respInfo.statusCode == 200) { console.log(respBody); } else { console.log(respInfo.statusCode); console.log(respBody); } }, ); ================================================ FILE: shell/api.sh ================================================ #!/usr/bin/env bash create_token() { local token_command="ts-node-transpile-only ${dir_root}/back/token.ts" local token_file="${dir_root}/static/build/token.js" if [[ -f $token_file ]]; then token_command="node ${token_file}" fi __ql_token__=$(eval "$token_command") } get_token() { if [[ -f $file_auth_token ]]; then __ql_token__=$(cat $file_auth_token | jq -r .value) local expiration=$(cat $file_auth_token | jq -r .expiration) local currentTimeStamp=$(date +%s) if [[ $currentTimeStamp -ge $expiration ]]; then create_token fi else create_token fi } add_cron_api() { local currentTimeStamp=$(date +%s) if [[ $# -eq 1 ]]; then local schedule=$(echo "$1" | awk -F ":" '{print $1}') local command=$(echo "$1" | awk -F ":" '{print $2}') local name=$(echo "$1" | awk -F ":" '{print $3}') local sub_id=$(echo "$1" | awk -F ":" '{print $4}') else local schedule="$1" local command="$2" local name="$3" local sub_id="$4" fi if [[ ! $sub_id ]]; then sub_id="null" fi local api=$( curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \ -H "Authorization: Bearer ${__ql_token__}" \ -H "Content-Type: application/json;charset=UTF-8" \ --data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"sub_id\":$sub_id}" \ --compressed ) code=$(echo "$api" | jq -r .code) message=$(echo "$api" | jq -r .message) if [[ $code == 200 ]]; then echo -e "$name -> 添加成功" else echo -e "$name -> 添加失败(${message})" fi } update_cron_api() { local currentTimeStamp=$(date +%s) if [[ $# -eq 1 ]]; then local schedule=$(echo "$1" | awk -F ":" '{print $1}') local command=$(echo "$1" | awk -F ":" '{print $2}') local name=$(echo "$1" | awk -F ":" '{print $3}') local id=$(echo "$1" | awk -F ":" '{print $4}') else local schedule="$1" local command="$2" local name="$3" local id="$4" fi local api=$( curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \ -X 'PUT' \ -H "Authorization: Bearer ${__ql_token__}" \ -H "Content-Type: application/json;charset=UTF-8" \ --data-raw "{\"name\":\"${name//\"/\\\"}\",\"command\":\"${command//\"/\\\"}\",\"schedule\":\"$schedule\",\"id\":\"$id\"}" \ --compressed ) code=$(echo "$api" | jq -r .code) message=$(echo "$api" | jq -r .message) if [[ $code == 200 ]]; then echo -e "$name -> 更新成功" else echo -e "$name -> 更新失败(${message})" fi } update_cron_command_api() { local currentTimeStamp=$(date +%s) if [[ $# -eq 1 ]]; then local command=$(echo "$1" | awk -F ":" '{print $1}') local id=$(echo "$1" | awk -F ":" '{print $2}') else local command="$1" local id="$2" fi local api=$( curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \ -X 'PUT' \ -H "Authorization: Bearer ${__ql_token__}" \ -H "Content-Type: application/json;charset=UTF-8" \ --data-raw "{\"command\":\"${command//\"/\\\"}\",\"id\":\"$id\"}" \ --compressed ) code=$(echo "$api" | jq -r .code) message=$(echo "$api" | jq -r .message) if [[ $code == 200 ]]; then echo -e "$command -> 更新成功" else echo -e "$command -> 更新失败(${message})" fi } del_cron_api() { local ids="$1" local currentTimeStamp=$(date +%s) local api=$( curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons?t=$currentTimeStamp" \ -X 'DELETE' \ -H "Authorization: Bearer ${__ql_token__}" \ -H "Content-Type: application/json;charset=UTF-8" \ --data-raw "[$ids]" \ --compressed ) code=$(echo "$api" | jq -r .code) message=$(echo "$api" | jq -r .message) if [[ $code == 200 ]]; then echo -e "成功" else echo -e "失败(${message})" fi } update_cron() { local ids="$1" local status="$2" local pid="${3:-''}" local logPath="$4" local lastExecutingTime="${5:-0}" local runningTime="${6:-0}" local currentTimeStamp=$(date +%s) local api=$( curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons/status?t=$currentTimeStamp" \ -X 'PUT' \ -H "Authorization: Bearer ${__ql_token__}" \ -H "Content-Type: application/json;charset=UTF-8" \ --data-raw "{\"ids\":[$ids],\"status\":\"$status\",\"pid\":\"$pid\",\"log_path\":\"$logPath\",\"last_execution_time\":$lastExecutingTime,\"last_running_time\":$runningTime}" \ --compressed ) code=$(echo "$api" | jq -r .code) message=$(echo "$api" | jq -r .message) if [[ $code != 200 ]]; then if [[ ! $message ]]; then message="$api" fi echo -e "${message}" fi } notify_api() { local title="$1" local content="$2" local currentTimeStamp=$(date +%s) local api=$( curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/system/notify?t=$currentTimeStamp" \ -X 'PUT' \ -H "Authorization: Bearer ${__ql_token__}" \ -H "Content-Type: application/json;charset=UTF-8" \ --data-raw "{\"title\":\"${title//\"/\\\"}\",\"content\":\"${content//\"/\\\"}\"}" \ --compressed ) code=$(echo "$api" | jq -r .code) message=$(echo "$api" | jq -r .message) if [[ $code == 200 ]]; then echo -e "通知发送成功🎉" else echo -e "通知失败(${message})" fi } find_cron_api() { local params="$1" local currentTimeStamp=$(date +%s) local api=$( curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/crons/detail?$params&t=$currentTimeStamp" \ -H "Authorization: Bearer ${__ql_token__}" \ -H "Content-Type: application/json;charset=UTF-8" \ --compressed ) data=$(echo "$api" | jq -r .data) if [[ $data == 'null' ]]; then echo -e "" else name=$(echo "$api" | jq -r .data.name) echo -e "$name" fi } update_auth_config() { local body="$1" local tip="$2" local currentTimeStamp=$(date +%s) local api=$( curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/open/system/auth/reset?t=$currentTimeStamp" \ -X 'PUT' \ -H "Authorization: Bearer ${__ql_token__}" \ -H "Content-Type: application/json;charset=UTF-8" \ --data-raw "{$body}" \ --compressed ) code=$(echo "$api" | jq -r .code) message=$(echo "$api" | jq -r .message) if [[ $code == 200 ]]; then echo -e "${tip}成功🎉" else echo -e "${tip}失败(${message})" fi } get_token ================================================ FILE: shell/bot.sh ================================================ #!/usr/bin/env bash if [[ -z ${BotRepoUrl} ]]; then url="https://github.com/SuMaiKaDe/bot.git" repo_path="${dir_repo}/dockerbot" else url=${BotRepoUrl} repo_path="${dir_repo}/diybot" fi echo -e "\n1、安装bot依赖...\n" apk --no-cache add -f zlib-dev gcc jpeg-dev python3-dev musl-dev freetype-dev echo -e "\nbot依赖安装成功...\n" echo -e "2、下载bot所需文件...\n" if [[ ! -d ${repo_path}/.git ]]; then rm -rf ${repo_path} git_clone_scripts ${url} ${repo_path} "main" fi cp -rf "$repo_path/jbot" $dir_data if [[ ! -f "$dir_config/bot.json" ]]; then cp -f "$repo_path/config/bot.json" "$dir_config" fi echo -e "\nbot文件下载成功...\n" echo -e "3、安装python3依赖...\n" cp -f "$repo_path/jbot/requirements.txt" "$dir_data" cd $dir_data cat requirements.txt | while read LREAD; do if [[ ! $(pip3 show "${LREAD%%=*}" 2>/dev/null) ]]; then pip3 --default-timeout=100 install ${LREAD} fi done echo -e "\npython3依赖安装成功...\n" echo -e "4、启动bot程序...\n" make_dir $dir_log/bot cd $dir_data ps -eo pid,command | grep "python3 -m jbot" | grep -v grep | awk '{print $1}' | xargs kill -9 2>/dev/null nohup python3 -m jbot >$dir_log/bot/nohup.log 2>&1 & echo -e "bot启动成功...\n" ================================================ FILE: shell/check.sh ================================================ #!/usr/bin/env bash reset_env() { echo -e "---> 1. 开始检测配置文件\n" fix_config echo -e "---> 配置文件检测完成\n" echo -e "---> 2. 开始安装青龙依赖\n" npm_install_2 $dir_root echo -e "---> 青龙依赖安装完成\n" echo -e "---> 脚本依赖安装完成\n" } copy_dep() { echo -e "---> 1. 复制通知文件\n" echo -e "---> 复制一份 $file_notify_py_sample 为 $file_notify_py\n" cp -fv $file_notify_py_sample $file_notify_py echo echo -e "---> 复制一份 $file_notify_js_sample 为 $file_notify_js\n" cp -fv $file_notify_js_sample $file_notify_js echo -e "---> 通知文件复制完成\n" } pm2_log() { echo -e "---> pm2日志" local panelOut="/root/.pm2/logs/qinglong-out.log" local panelError="/root/.pm2/logs/qinglong-error.log" tail -n 300 "$panelOut" tail -n 300 "$panelError" } check_ql() { local api=$(curl -s --noproxy "*" "http://0.0.0.0:${ql_port}") echo -e "\n=====> 检测面板\n\n$api\n" if [[ $api =~ "
" ]]; then echo -e "=====> 面板服务启动正常\n" fi } check_pm2() { pm2_log local currentTimeStamp=$(date +%s) local api=$( curl -s --noproxy "*" "http://0.0.0.0:${ql_port}/api/system?t=$currentTimeStamp" \ -H 'Accept: */*' \ -H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36' \ -H "Referer: http://0.0.0.0:${ql_port}/crontab" \ -H 'Accept-Language: en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7' \ --compressed ) echo -e "\n=====> 检测后台\n\n$api\n" if [[ $api =~ "{\"code\"" ]]; then echo -e "=====> 后台服务启动正常\n" fi } main() { echo -e "=====> 开始检测" npm i -g pnpm@8.3.1 pm2 ts-node reset_env copy_dep check_ql check_pm2 reload_pm2 echo -e "\n=====> 检测结束\n" } main ================================================ FILE: shell/env.sh ================================================ #!/usr/bin/env bash store_env_vars() { initial_vars=($(env | cut -d= -f1)) } restore_env_vars() { for key in $(env | cut -d= -f1); do if ! [[ " ${initial_vars[@]} " =~ " $key " ]]; then unset "$key" fi done } store_env_vars ================================================ FILE: shell/otask.sh ================================================ #!/usr/bin/env bash random_delay() { local random_delay_max=$RandomDelay if [[ $random_delay_max ]] && [[ $random_delay_max -gt 0 ]]; then local file_param=$1 local file_extensions=${RandomDelayFileExtensions-"js"} local ignored_minutes=${RandomDelayIgnoredMinutes-"0 30"} if [[ -n $file_extensions ]]; then if ! echo "$file_param" | grep -qE "\.${file_extensions// /$|\\.}$"; then # echo -e "\n当前文件需要准点运行, 放弃随机延迟\n" return fi fi local current_min current_min=$(date "+%-M") for minute in $ignored_minutes; do if [[ $current_min -eq $minute ]]; then # echo -e "\n当前时间需要准点运行, 放弃随机延迟\n" return fi done local delay_second=$(($(gen_random_num "$random_delay_max") + 1)) echo -e "任务随机延迟 $delay_second 秒,配置文件参数 RandomDelay 置空可取消延迟 \n" sleep $delay_second fi } ## scripts目录下所有可运行脚本数组 gen_array_scripts() { local dir_current=$(pwd) local i="-1" cd $dir_scripts for file in $(ls); do if [[ -f $file ]] && [[ $file == *.js && $file != sendNotify.js ]]; then let i++ array_scripts[i]=$(echo "$file" | perl -pe "s|$dir_scripts/||g") array_scripts_name[i]=$(grep "new Env" $file | awk -F "\(" '{print $2}' | awk -F "\)" '{print $1}' | sed 's:.*\('\''\|"\)\([^"'\'']*\)\('\''\|"\).*:\2:' | sed 's:"::g' | sed "s:'::g" | head -1) [[ -z ${array_scripts_name[i]} ]] && array_scripts_name[i]="<未识别出活动名称>" fi done cd $dir_current } ## 使用说明 usage() { gen_array_scripts echo -e "task命令运行本程序自动添加进crontab的脚本,需要输入脚本的绝对路径或去掉 “$dir_scripts/” 目录后的相对路径(定时任务中请写作相对路径),用法为:" echo -e "1.$cmd_task # 依次执行,如果设置了随机延迟,将随机延迟一定秒数" echo -e "2.$cmd_task now # 依次执行,无论是否设置了随机延迟,均立即运行,前台会输出日志,同时记录在日志文件中" echo -e "3.$cmd_task conc <环境变量名称> <账号编号,空格分隔>(可选的) # 并发执行,无论是否设置了随机延迟,均立即运行,前台不产生日志,直接记录在日志文件中,且可指定账号执行" echo -e "4.$cmd_task desi <环境变量名称> <账号编号,空格分隔> # 指定账号执行,无论是否设置了随机延迟,均立即运行" if [[ ${#array_scripts[*]} -gt 0 ]]; then echo -e "\n当前有以下脚本可以运行:" for ((i = 0; i < ${#array_scripts[*]}; i++)); do echo -e "$(($i + 1)). ${array_scripts_name[i]}:${array_scripts[i]}" done else echo -e "\n暂无脚本可以执行" fi } ## run nohup,$1:文件名,不含路径,带后缀 run_nohup() { local file_name=$1 nohup node $file_name &>$log_path & } env_str_to_array() { . $file_env local IFS="&" read -ra array <<<"${!env_param}" array_length=${#array[@]} clear_env } clear_non_sh_env() { if [[ $file_param != *.sh ]]; then clear_env fi } ## 正常运行单个脚本,$1:传入参数 run_normal() { local file_param=$1 if [[ $# -eq 1 ]] && [[ "$real_time" != "true" ]] && [[ "$no_delay" != "true" ]]; then random_delay "$file_param" fi cd $dir_scripts local relative_path="${file_param%/*}" if [[ ${file_param} != /* ]] && [[ ! -z ${relative_path} ]] && [[ ${file_param} =~ "/" ]]; then cd ${relative_path} file_param=${file_param/$relative_path\//} fi if [[ $isJsOrPythonFile == 'false' ]]; then clear_non_sh_env fi $timeoutCmd $which_program $file_param "${script_params[@]}" } handle_env_split() { if [[ ! $num_param ]]; then num_param="1-max" fi env_str_to_array local tempArr=$(echo $num_param | sed "s/-max/-${array_length}/g" | sed "s/max-/${array_length}-/g" | perl -pe "s|(\d+)(-\|~\|_)(\d+)|{\1..\3}|g") local runArr=($(eval echo $tempArr)) array_run=($(awk -v RS=' ' '!a[$1]++' <<<${runArr[@]})) } ## 并发执行时,设定的 RandomDelay 不会生效,即所有任务立即执行 run_concurrent() { local file_param="$1" local env_param="$2" local num_param=$(echo "$3" | perl -pe "s|.*$2(.*)|\1|" | awk '{$1=$1};1') if [[ ! $env_param ]]; then echo -e "\n 缺少并发运行的环境变量参数" exit 1 fi handle_env_split time=$(date "+$mtime_format") single_log_time=$(format_log_time "$mtime_format" "$time") cd $dir_scripts local relative_path="${file_param%/*}" if [[ ! -z ${relative_path} ]] && [[ ${file_param} =~ "/" ]]; then cd ${relative_path} file_param=${file_param/$relative_path\//} fi local j=0 for i in ${array_run[@]}; do single_log_path="$dir_log/$log_dir/${single_log_time}_$((j + 1)).log" let j++ if [[ $isJsOrPythonFile == 'false' ]]; then export "${env_param}=${array[$i - 1]}" clear_non_sh_env fi eval envParam="${env_param}" numParam="${i}" $timeoutCmd $which_program $file_param "${script_params[@]}" &>$single_log_path & done wait local k=0 for i in ${array_run[@]}; do single_log_path="$dir_log/$log_dir/${single_log_time}_$((k + 1)).log" let k++ cat $single_log_path [[ -f $single_log_path ]] && rm -f $single_log_path done } run_designated() { local file_param="$1" local env_param="$2" local num_param=$(echo "$3" | perl -pe "s|.*$2(.*)|\1|" | awk '{$1=$1};1') if [[ ! $env_param ]]; then echo -e "\n 缺少单独运行的参数 task xxx.js desi Test" exit 1 fi handle_env_split if [[ $isJsOrPythonFile == 'false' ]]; then local n=0 for i in ${array_run[@]}; do array_str[n]=${array[$i - 1]} let n++ done local envStr=$( IFS="&" echo "${array_str[*]}" ) [[ ! -z $envStr ]] && export "${env_param}=${envStr}" clear_non_sh_env fi cd $dir_scripts local relative_path="${file_param%/*}" if [[ ! -z ${relative_path} ]] && [[ ${file_param} =~ "/" ]]; then cd ${relative_path} file_param=${file_param/$relative_path\//} fi envParam="${env_param}" numParam="${num_param}" $timeoutCmd $which_program $file_param "${script_params[@]}" } ## 运行其他命令 run_else() { local file_param="$1" cd $dir_scripts local relative_path="${file_param%/*}" if [[ ! -z ${relative_path} ]] && [[ ${file_param} =~ "/" ]]; then cd ${relative_path} file_param=${file_param/$relative_path\//.\/} fi shift clear_non_sh_env $timeoutCmd $which_program $file_param "$@" } check_file() { isJsOrPythonFile="false" if [[ $1 == *.js ]] || [[ $1 == *.mjs ]] || [[ $1 == *.py ]] || [[ $1 == *.pyc ]] || [[ $1 == *.ts ]]; then isJsOrPythonFile="true" fi if [[ -f $file_env ]]; then get_env_array if [[ $isJsOrPythonFile == 'true' ]]; then export PREV_NODE_OPTIONS="${NODE_OPTIONS:=}" export PREV_PYTHONPATH="${PYTHONPATH:=}" if [[ $1 == *.js ]] || [[ $1 == *.ts ]] || [[ $1 == *.mjs ]]; then export NODE_OPTIONS="-r ${file_preload_js} ${NODE_OPTIONS}" else export PYTHONPATH="${dir_preload}:${dir_config}:${PYTHONPATH}" fi else . $file_env fi fi } check_nounset() { local output=$(set -o) while read -r line; do if [[ "$line" =~ "nounset" ]] && [[ "$line" =~ "on" ]]; then set_u_on="true" set +u break fi done <<<"$output" } main() { if [[ $1 == *.js ]] || [[ $1 == *.py ]] || [[ $1 == *.pyc ]] || [[ $1 == *.sh ]] || [[ $1 == *.ts ]]; then if [[ $1 == *.sh ]]; then timeoutCmd="" fi case $# in 1) run_normal "$1" ;; *) case $2 in now) run_normal "$1" "$2" ;; conc) run_concurrent "$1" "$3" "$*" ;; desi) run_designated "$1" "$3" "$*" ;; *) run_else "$@" ;; esac ;; esac elif [[ $# -eq 0 ]]; then echo usage else run_else "$@" fi } handle_task_start "${task_shell_params[@]}" check_file "${task_shell_params[@]}" if [[ $isJsOrPythonFile == 'false' ]]; then run_task_before "${task_shell_params[@]}" fi set_u_on="false" check_nounset main "${task_shell_params[@]}" if [[ "$set_u_on" == 'true' ]]; then set -u fi if [[ $isJsOrPythonFile == 'true' ]]; then export NODE_OPTIONS="${PREV_NODE_OPTIONS}" export PYTHONPATH="${PREV_PYTHONPATH}" fi run_task_after "${task_shell_params[@]}" clear_env handle_task_end "${task_shell_params[@]}" ================================================ FILE: shell/preload/client.js ================================================ const grpc = require('@grpc/grpc-js'); const protoLoader = require('@grpc/proto-loader'); const { join } = require('path'); class GrpcClient { static #config = { protoPath: join(process.env.QL_DIR, 'back/protos/api.proto'), serverAddress: `0.0.0.0:${process.env.GRPC_PORT || '5500'}`, protoOptions: { keepCase: true, longs: String, enums: String, defaults: true, }, grpcOptions: { 'grpc.enable_http_proxy': 0, }, defaultTimeout: 30000, }; static #methods = [ 'getEnvs', 'createEnv', 'updateEnv', 'deleteEnvs', 'moveEnv', 'disableEnvs', 'enableEnvs', 'updateEnvNames', 'getEnvById', 'systemNotify', 'getCronDetail', 'createCron', 'updateCron', 'deleteCrons', 'getCrons', 'getCronById', 'enableCrons', 'disableCrons', 'runCrons', ]; #client; #api = {}; constructor() { this.#initializeClient(); this.#bindMethods(); } #initializeClient() { try { const { protoPath, protoOptions, serverAddress, grpcOptions } = GrpcClient.#config; const packageDefinition = protoLoader.loadSync(protoPath, protoOptions); const apiProto = grpc.loadPackageDefinition(packageDefinition).com.ql.api; this.#client = new apiProto.Api( serverAddress, grpc.credentials.createInsecure(), grpcOptions, ); } catch (error) { console.error('Failed to initialize gRPC client:', error); process.exit(1); } } #promisifyMethod(methodName) { const capitalizedMethod = methodName.charAt(0).toUpperCase() + methodName.slice(1); const method = this.#client[capitalizedMethod].bind(this.#client); return async (params = {}) => { return new Promise((resolve, reject) => { const metadata = new grpc.Metadata(); const deadline = new Date( Date.now() + GrpcClient.#config.defaultTimeout, ); method(params, metadata, { deadline }, (error, response) => { if (error) { return reject(error); } resolve(response); }); }); }; } #bindMethods() { GrpcClient.#methods.forEach((method) => { this.#api[method] = this.#promisifyMethod(method); }); } getApi() { return { ...this.#api, close: this.close.bind(this), }; } close() { if (this.#client) { this.#client.close(); this.#client = null; } } } const grpcClient = new GrpcClient(); process.on('SIGTERM', () => { grpcClient.close(); process.exit(0); }); process.on('SIGINT', () => { grpcClient.close(); process.exit(0); }); process.on('unhandledRejection', (reason, promise) => { if (reason instanceof Error) { if (reason.stack) { const relevantStack = reason.stack .split('\n') .filter((line) => { return ( !line.includes('node:internal') && !line.includes('node_modules/@grpc') && !line.includes('processTicksAndRejections') ); }) .join('\n'); console.error(relevantStack); } } else { console.error(reason); } }); module.exports = grpcClient.getApi(); ================================================ FILE: shell/preload/client.py ================================================ import subprocess import json import tempfile import os from typing import Dict, List, TypedDict, Optional from functools import wraps def error_handler(func): @wraps(func) def wrapper(*args, **kwargs): try: return func(*args, **kwargs) except TypeError as e: if "missing" in str(e): func_name = func.__name__ annotations = func.__annotations__ param_type = next( (t for name, t in annotations.items() if name != "return"), None ) if param_type and hasattr(param_type, "__annotations__"): required_fields = { k: v for k, v in param_type.__annotations__.items() if not getattr(param_type, "__total__", True) or k in getattr(param_type, "__required_keys__", set()) } fields_str = ", ".join( f'"{k}": {v.__name__}' for k, v in required_fields.items() ) raise Exception( f"{func_name}() requires a dictionary with parameters: {{{fields_str}}}" ) from None raise Exception(f"{str(e)}") from None except Exception as e: error_msg = str(e) if "Error:" in error_msg: error_msg = error_msg.split("Error:")[-1].split("\n")[0].strip() raise Exception(f"{error_msg}") from None return wrapper class EnvItem(TypedDict, total=False): id: Optional[int] name: Optional[str] value: Optional[str] remarks: Optional[str] status: Optional[int] position: Optional[int] class GetEnvsParams(TypedDict, total=False): searchValue: str class CreateEnvParams(TypedDict): envs: List[EnvItem] class UpdateEnvParams(TypedDict): env: EnvItem class DeleteEnvsParams(TypedDict): ids: List[int] class MoveEnvParams(TypedDict): id: int fromIndex: int toIndex: int class DisableEnvsParams(TypedDict): ids: List[int] class EnableEnvsParams(TypedDict): ids: List[int] class UpdateEnvNamesParams(TypedDict): ids: List[int] name: str class GetEnvByIdParams(TypedDict): id: int class SystemNotifyParams(TypedDict): title: str content: str class EnvsResponse(TypedDict): code: int data: List[EnvItem] message: Optional[str] class EnvResponse(TypedDict): code: int data: EnvItem message: Optional[str] class Response(TypedDict): code: int message: Optional[str] class ExtraScheduleItem(TypedDict, total=False): schedule: Optional[str] class CronItem(TypedDict, total=False): id: Optional[int] command: Optional[str] schedule: Optional[str] name: Optional[str] labels: List[str] sub_id: Optional[int] extra_schedules: List[ExtraScheduleItem] task_before: Optional[str] task_after: Optional[str] status: Optional[int] log_path: Optional[str] pid: Optional[int] last_running_time: Optional[int] last_execution_time: Optional[int] class CreateCronParams(TypedDict): command: str schedule: str name: Optional[str] labels: List[str] sub_id: Optional[int] extra_schedules: List[ExtraScheduleItem] task_before: Optional[str] task_after: Optional[str] class UpdateCronParams(TypedDict): id: int command: str schedule: str name: Optional[str] labels: List[str] sub_id: Optional[int] extra_schedules: List[ExtraScheduleItem] task_before: Optional[str] task_after: Optional[str] class DeleteCronsParams(TypedDict): ids: List[int] class CronDetailParams(TypedDict): log_path: str class CronsResponse(TypedDict): code: int data: List[CronItem] message: Optional[str] class CronResponse(TypedDict): code: int data: CronItem message: Optional[str] class Client: def __init__(self): self.temp_dir = tempfile.mkdtemp(prefix="node_client_") self.temp_script = os.path.join(self.temp_dir, "temp_script.js") def __del__(self): try: if os.path.exists(self.temp_script): os.remove(self.temp_script) os.rmdir(self.temp_dir) except Exception: pass @error_handler def _execute_node(self, method: str, params: Dict = None) -> Dict: node_code = f""" const api = require('{os.getenv("QL_DIR")}/shell/preload/client.js'); (async () => {{ try {{ const result = await api.{method}({json.dumps(params) if params else ''}); console.log(JSON.stringify(result)); }} catch (error) {{ console.error(JSON.stringify({{ error: error.message, stack: error.stack, name: error.name }})); process.exit(1); }} }})(); """ with open(self.temp_script, "w", encoding="utf-8") as f: f.write(node_code) result = subprocess.run( ["node", self.temp_script], capture_output=True, text=True, timeout=30, ) if result.returncode != 0: error_data = json.loads(result.stderr) raise Exception( f"{error_data.get('name', 'Error')}: {error_data.get('stack')}" ) return json.loads(result.stdout) @error_handler def getEnvs(self, params: GetEnvsParams = None) -> EnvsResponse: return self._execute_node("getEnvs", params) @error_handler def createEnv(self, data: CreateEnvParams) -> EnvsResponse: return self._execute_node("createEnv", data) @error_handler def updateEnv(self, data: UpdateEnvParams) -> EnvResponse: return self._execute_node("updateEnv", data) @error_handler def deleteEnvs(self, data: DeleteEnvsParams) -> Response: return self._execute_node("deleteEnvs", data) @error_handler def moveEnv(self, data: MoveEnvParams) -> EnvResponse: return self._execute_node("moveEnv", data) @error_handler def disableEnvs(self, data: DisableEnvsParams) -> Response: return self._execute_node("disableEnvs", data) @error_handler def enableEnvs(self, data: EnableEnvsParams) -> Response: return self._execute_node("enableEnvs", data) @error_handler def updateEnvNames(self, data: UpdateEnvNamesParams) -> Response: return self._execute_node("updateEnvNames", data) @error_handler def getEnvById(self, data: GetEnvByIdParams) -> EnvResponse: return self._execute_node("getEnvById", data) @error_handler def systemNotify(self, data: SystemNotifyParams) -> Response: return self._execute_node("systemNotify", data) @error_handler def getCronDetail(self, data: CronDetailParams) -> CronResponse: return self._execute_node("getCronDetail", data) @error_handler def createCron(self, data: CreateCronParams) -> CronResponse: return self._execute_node("createCron", data) @error_handler def updateCron(self, data: UpdateCronParams) -> CronResponse: return self._execute_node("updateCron", data) @error_handler def deleteCrons(self, data: DeleteCronsParams) -> Response: return self._execute_node("deleteCrons", data) ================================================ FILE: shell/preload/sitecustomize.js ================================================ const { execSync } = require('child_process'); const client = require('./client.js'); require(`./env.js`); function expandRange(rangeStr, max) { const tempRangeStr = rangeStr .trim() .replace(/-max/g, `-${max}`) .replace(/max-/g, `${max}-`); return tempRangeStr.split(' ').flatMap((part) => { const rangeMatch = part.match(/^(\d+)([-~_])(\d+)$/); if (rangeMatch) { const [, start, , end] = rangeMatch.map(Number); const step = start < end ? 1 : -1; return Array.from( { length: Math.abs(end - start) + 1 }, (_, i) => start + i * step, ); } return Number(part); }); } function run() { const { envParam, numParam, file_task_before, file_task_before_js, dir_scripts, task_before, PREV_NODE_OPTIONS, } = process.env; try { process.env.NODE_OPTIONS = PREV_NODE_OPTIONS; const splitStr = '__sitecustomize__'; const fileName = process.argv[1].replace(`${dir_scripts}/`, ''); const tempFile = `/tmp/env_${process.pid}.json`; const commands = [ `source ${file_task_before} ${fileName}`, task_before ? `eval '${task_before.replace(/'/g, "'\\''")}'` : null, `echo -e '${splitStr}'`, `node -e "require('fs').writeFileSync('${tempFile}', JSON.stringify(process.env))"`, ].filter(Boolean); if (task_before) { console.log('执行前置命令\n'); } const res = execSync(commands.join(' && '), { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024, shell: '/bin/bash', }); const [output] = res.split(splitStr); try { const envStr = require('fs').readFileSync(tempFile, 'utf-8'); const newEnvObject = JSON.parse(envStr); if (typeof newEnvObject === 'object' && newEnvObject !== null) { for (const key in newEnvObject) { if (Object.prototype.hasOwnProperty.call(newEnvObject, key)) { process.env[key] = newEnvObject[key]; } } } require('fs').unlinkSync(tempFile); } catch (jsonError) { console.log( '\ue926 Failed to parse environment variables:', jsonError.message, ); try { require('fs').unlinkSync(tempFile); } catch (e) {} } if (output) { console.log(output); } if (task_before) { console.log('执行前置命令结束\n'); } } catch (error) { if (!error.message.includes('spawnSync /bin/bash E2BIG')) { console.log(`\ue926 run task before error: `, error); } else { // environment variable is too large } if (task_before) { console.log('执行前置命令结束\n'); } } require(file_task_before_js); if (envParam && numParam) { const array = (process.env[envParam] || '').split('&'); const runArr = expandRange(numParam, array.length); const arrayRun = runArr.map((i) => array[i - 1]); const envStr = arrayRun.join('&'); process.env[envParam] = envStr; } } try { if (!process.argv[1]) { return; } process.on('SIGTERM', (code) => { process.exit(15); }); run(); const { sendNotify } = require('./__ql_notify__.js'); global.QLAPI = { notify: sendNotify, ...client, }; } catch (error) { console.log(`run builtin code error: `, error, '\n'); } ================================================ FILE: shell/preload/sitecustomize.py ================================================ import os import re import subprocess import json import builtins import sys import env import signal from client import Client def try_parse_int(value): try: return int(value) except ValueError: return None def expand_range(range_str, max_value): temp_range_str = ( range_str.strip() .replace("-max", f"-{max_value}") .replace("max-", f"{max_value}-") ) result = [] for part in temp_range_str.split(" "): range_match = re.match(r"^(\d+)([-~_])(\d+)$", part) if range_match: start, _, end = map(try_parse_int, range_match.groups()) step = 1 if start < end else -1 result.extend(range(start, end + step, step)) else: result.append(int(part)) return result def run(): try: prev_pythonpath = os.getenv("PREV_PYTHONPATH", "") os.environ["PYTHONPATH"] = prev_pythonpath split_str = "__sitecustomize__" file_name = sys.argv[0].replace(f"{os.getenv('dir_scripts')}/", "") # 创建临时文件路径 temp_file = f"/tmp/env_{os.getpid()}.json" # 构建命令数组 commands = [ f'source {os.getenv("file_task_before")} {file_name}' ] task_before = os.getenv("task_before") if task_before: escaped_task_before = task_before.replace('"', '\\"').replace("$", "\\$") commands.append(f"eval '{escaped_task_before}'") print("执行前置命令\n") commands.append(f"echo -e '{split_str}'") # 修改 Python 命令,使用单行并正确处理引号 python_cmd = f"python3 -c 'import os,json; f=open(\\\"{temp_file}\\\",\\\"w\\\"); json.dump(dict(os.environ),f); f.close()'" commands.append(python_cmd) command = " && ".join(cmd for cmd in commands if cmd) command = f'bash -c "{command}"' res = subprocess.check_output(command, shell=True, encoding="utf-8") output = res.split(split_str)[0] try: with open(temp_file, 'r') as f: env_json = json.loads(f.read()) for key, value in env_json.items(): os.environ[key] = value os.unlink(temp_file) except Exception as json_error: print(f"\ue926 Failed to parse environment variables: {json_error}") try: os.unlink(temp_file) except: pass if len(output) > 0: print(output) if task_before: print("执行前置命令结束\n") except subprocess.CalledProcessError as error: print(f"\ue926 run task before error: {error}") if task_before: print("执行前置命令结束\n") except OSError as error: error_message = str(error) if "Argument list too long" not in error_message: print(f"\ue926 run task before error: {error}") # else: # environment variable is too large if task_before: print("执行前置命令结束\n") except Exception as error: print(f"\ue926 run task before error: {error}") if task_before: print("执行前置命令结束\n") import task_before env_param = os.getenv("envParam") num_param = os.getenv("numParam") if env_param and num_param: array = (os.getenv(env_param) or "").split("&") run_arr = expand_range(num_param, len(array)) array_run = [array[i - 1] for i in run_arr if i - 1 < len(array) and i > 0] env_str = "&".join(array_run) os.environ[env_param] = env_str def handle_sigterm(signum, frame): sys.exit(15) try: signal.signal(signal.SIGTERM, handle_sigterm) run() from __ql_notify__ import send class BaseApi(Client): def notify(self, *args, **kwargs): return send(*args, **kwargs) QLAPI = BaseApi() builtins.QLAPI = QLAPI except Exception as error: print(f"run builtin code error: {error}\n") ================================================ FILE: shell/pub.sh ================================================ #!/usr/bin/env bash echo -e "开始发布" echo -e "切换master分支" git branch -D master git checkout -b master git push --set-upstream origin master -f echo -e "更新cdn文件" ts-node-transpile-only sample/tool.ts string=$(cat version.yaml | grep "version" | egrep "[^ ]*" -o | egrep "\d\.*") version="v$string" echo -e "当前版本$version" echo -e "删除已经存在的本地tag" git tag -d "$version" &>/dev/null echo -e "删除已经存在的远程tag" git push origin :refs/tags/$version &>/dev/null echo -e "创建新tag" git tag -a "$version" -m "release $version" echo -e "提交tag" git push --tags echo -e "完成发布" ================================================ FILE: shell/rmlog.sh ================================================ #!/usr/bin/env bash days=$1 remove_js_log() { local log_full_path_list=$(find $dir_log -name "*.log") local diff_time for log in $log_full_path_list; do local log_date=$(echo $log | awk -F "/" '{print $NF}' | cut -c1-10) if ! [[ $log_date =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then if [[ $is_macos -eq 1 ]]; then log_date=$(stat -f %Sm -t "%Y-%m-%d" "$log") else log_date=$(stat -c %y "$log" | cut -d ' ' -f 1) fi fi if [[ $is_macos -eq 1 ]]; then diff_time=$(($(date +%s) - $(date -j -f "%Y-%m-%d" "$log_date" +%s))) else diff_time=$(($(date +%s) - $(date +%s -d "$log_date"))) fi if [[ $diff_time -gt $((${days} * 86400)) ]]; then local log_path=$(echo "$log" | sed "s,${dir_log}/,,g") local result=$(find_cron_api "log_path=$log_path") echo -e "查询文件 $log_path" if [[ -z $result ]]; then echo -e "删除中~" rm -vf $log else echo -e "正在被 $result 使用,跳过~" fi fi done } remove_empty_dir() { cd $dir_log for dir in $(ls); do if [[ -d $dir ]] && [[ -z $(ls $dir) ]]; then rm -rf $dir fi done } if [[ ${days} ]]; then echo -e "查找旧日志文件中...\n" remove_js_log remove_empty_dir echo -e "删除旧日志执行完毕\n" fi ================================================ FILE: shell/share.sh ================================================ #!/usr/bin/env bash ## 目录 export dir_root=$QL_DIR export dir_tmp=$dir_root/.tmp export dir_data=$dir_root/data if [[ ${QL_DATA_DIR:=} ]]; then export dir_data="${QL_DATA_DIR%/}" fi export dir_shell=$dir_root/shell export dir_preload=$dir_shell/preload export dir_sample=$dir_root/sample export dir_static=$dir_root/static export dir_config=$dir_data/config export dir_scripts=$dir_data/scripts export dir_repo=$dir_data/repo export dir_raw=$dir_data/raw export dir_log=$dir_data/log export dir_db=$dir_data/db export dir_dep=$dir_data/deps export dir_list_tmp=$dir_log/.tmp export dir_update_log=$dir_log/update export ql_static_repo=$dir_repo/static ## 文件 export file_config_sample=$dir_sample/config.sample.sh export file_env=$dir_preload/env.sh export file_preload_js=$dir_preload/sitecustomize.js export file_sharecode=$dir_config/sharecode.sh export file_config_user=$dir_config/config.sh export file_auth_sample=$dir_sample/auth.sample.json export file_auth_user=$dir_config/auth.json export file_auth_token=$dir_config/token.json export file_extra_shell=$dir_config/extra.sh export file_task_before=$dir_config/task_before.sh export file_task_before_js=$dir_config/task_before.js export file_task_before_py=$dir_config/task_before.py export file_task_after=$dir_config/task_after.sh export file_task_sample=$dir_sample/task.sample.sh export file_extra_sample=$dir_sample/extra.sample.sh export file_notify_js_sample=$dir_sample/notify.js export file_notify_py_sample=$dir_sample/notify.py export file_test_js_sample=$dir_sample/ql_sample.js export file_test_py_sample=$dir_sample/ql_sample.py export file_notify_py=$dir_scripts/notify.py export file_notify_js=$dir_scripts/sendNotify.js export file_test_js=$dir_scripts/ql_sample.js export file_test_py=$dir_scripts/ql_sample.py export dep_notify_py=$dir_dep/notify.py export dep_notify_js=$dir_dep/sendNotify.js ## 清单文件 list_crontab_user=$dir_config/crontab.list list_crontab_sample=$dir_sample/crontab.sample.list list_own_scripts=$dir_list_tmp/own_scripts.list list_own_user=$dir_list_tmp/own_user.list list_own_add=$dir_list_tmp/own_add.list list_own_drop=$dir_list_tmp/own_drop.list link_name=( task ql ) init_env() { local pnpm_global_path=$(pnpm root -g 2>/dev/null) export NODE_PATH="/usr/local/bin:/usr/local/lib/node_modules${pnpm_global_path:+:${pnpm_global_path}}" # 如果存在 pnpm 全局路径,创建软链接 if [[ -n "$pnpm_global_path" ]]; then # 确保目标目录存在 mkdir -p "${dir_root}/node_modules" # 链接全局模块到项目的 node_modules ln -sf "${pnpm_global_path}/"* "${dir_root}/node_modules/" 2>/dev/null || true fi export PYTHONUNBUFFERED=1 } load_ql_envs() { ql_base_url=${QlBaseUrl:-"/"} ql_port=${QlPort:-"5700"} ql_grpc_port=${QlGrpcPort:-"5500"} current_branch=${QL_BRANCH:-""} } import_config() { [[ -f $file_config_user ]] && . $file_config_user load_ql_envs command_timeout_time=${CommandTimeoutTime:-""} file_extensions=${RepoFileExtensions:-"js py"} proxy_url=${ProxyUrl:-""} if [[ -n "${DefaultCronRule}" ]]; then default_cron="${DefaultCronRule}" else default_cron="$(random_range 0 59) $(random_range 0 23) * * *" fi } set_proxy() { local proxy="$1" if [[ $proxy ]]; then proxy_url="$proxy" fi if [[ $proxy_url ]]; then export http_proxy="${proxy_url}" export https_proxy="${proxy_url}" fi } unset_proxy() { unset http_proxy unset https_proxy } make_dir() { local dir=$1 if [[ ! -d $dir ]]; then mkdir -p $dir fi } detect_termux() { if [[ $PATH == *com.termux* ]]; then is_termux=1 else is_termux=0 fi } detect_macos() { [[ $(uname -s) == Darwin ]] && is_macos=1 || is_macos=0 } gen_random_num() { local divi=$1 echo $((${RANDOM} % $divi)) } define_cmd() { local cmd_prefix cmd_suffix if type task &>/dev/null; then cmd_suffix="" if [[ -f "$dir_shell/task.sh" ]]; then cmd_prefix="" else cmd_prefix="bash " fi else cmd_suffix=".sh" if [[ -f "$dir_shell/task.sh" ]]; then cmd_prefix="$dir_shell/" else cmd_prefix="bash $dir_shell/" fi fi for ((i = 0; i < ${#link_name[*]}; i++)); do export cmd_${link_name[i]}="${cmd_prefix}${link_name[i]}${cmd_suffix}" done } fix_config() { make_dir $dir_tmp make_dir $dir_static make_dir $dir_data make_dir $dir_config make_dir $dir_log make_dir $dir_db make_dir $dir_scripts make_dir $dir_list_tmp make_dir $dir_repo make_dir $dir_raw make_dir $dir_update_log make_dir $dir_dep if [[ ! -s $file_config_user ]]; then cp -f $file_config_sample $file_config_user fi if [[ ! -f $file_task_before ]]; then cp -f $file_task_sample $file_task_before fi if [[ ! -f $file_task_after ]]; then cp -f $file_task_sample $file_task_after fi if [[ ! -f $file_extra_shell ]]; then cp -f $file_extra_sample $file_extra_shell fi if [[ ! -s $file_notify_py ]]; then cp -f $file_notify_py_sample $file_notify_py fi if [[ ! -s $file_notify_js ]]; then cp -f $file_notify_js_sample $file_notify_js fi if [[ ! -s $file_test_js ]]; then cp -f $file_test_js_sample $file_test_js fi if [[ ! -s $file_test_py ]]; then cp -f $file_test_py_sample $file_test_py fi if [[ ! -s $dep_notify_js ]]; then cp -f $file_notify_js_sample $dep_notify_js fi if [[ ! -s $dep_notify_py ]]; then cp -f $file_notify_py_sample $dep_notify_py fi } npm_install_sub() { if [ $is_termux -eq 1 ]; then npm install --production --no-bin-links elif ! type pnpm &>/dev/null; then npm install --production else pnpm install --loglevel error --production fi exit_status=$? } npm_install_2() { local dir_current=$(pwd) local dir_work=$1 cd $dir_work echo -e "安装 $dir_work 依赖包...\n" npm_install_sub cd $dir_current } diff_and_copy() { local copy_source=$1 local copy_to=$2 if [[ ! -s $copy_to ]] || [[ $(diff $copy_source $copy_to) ]]; then cp -f $copy_source $copy_to fi } git_clone_scripts() { local url="$1" local dir="$2" local branch="$3" local proxy="$4" [[ $branch ]] && local part_cmd="-b $branch " echo -e "开始拉取仓库 ${uniq_path} 到 $dir\n" set_proxy "$proxy" git clone -q --depth=1 $part_cmd $url $dir exit_status=$? unset_proxy } random_range() { local beg=$1 local end=$2 echo $((RANDOM % ($end - $beg) + $beg)) } delete_pm2() { cd $dir_root # Try to delete PM2 processes, but don't fail if PM2 is not available pm2 delete ecosystem.config.js 2>/dev/null || true # Also try to kill any directly spawned node processes pkill -f "node.*static/build/app.js" 2>/dev/null || true } reload_pm2() { cd $dir_root restore_env_vars # Try to start PM2, but handle failures gracefully if pm2 flush &>/dev/null && pm2 startOrGracefulReload ecosystem.config.js --update-env; then return 0 else local exit_code=$? echo "警告: PM2 启动失败 (退出码: $exit_code),可能是由于硬件不兼容" echo "正在尝试直接使用 Node.js 启动服务..." # Kill any existing node processes for qinglong pkill -f "node.*static/build/app.js" 2>/dev/null || true # Start node directly in the background nohup node static/build/app.js > $dir_log/qinglong.log 2>&1 & local node_pid=$! echo "已使用 Node.js 直接启动服务 (PID: $node_pid)" echo "注意: 使用此模式时,部分 PM2 管理功能将不可用" return 0 fi } diff_time() { local format="$1" local begin_time="$2" local end_time="$3" if [[ $is_macos -eq 1 ]]; then diff_time=$(($(date -j -f "$format" "$end_time" +%s) - $(date -j -f "$format" "$begin_time" +%s))) else diff_time=$(($(date +%s -d "$end_time") - $(date +%s -d "$begin_time"))) fi echo "$diff_time" } format_time() { local format="$1" local time="$2" if [[ $is_macos -eq 1 ]]; then echo $(date -j -f "$format" "$time" "+%Y-%m-%d %H:%M:%S") else echo $(date -d "$time" "+%Y-%m-%d %H:%M:%S") fi } format_log_time() { local format="$1" local time="$2" if [[ $is_macos -eq 1 ]]; then echo $(python3 -c 'from datetime import datetime; print(datetime.now().strftime("%Y-%m-%d-%H-%M-%S-%f")[:-3])') else echo $(date -d "$time" "+%Y-%m-%d-%H-%M-%S-%3N") fi } format_timestamp() { local format="$1" local time="$2" if [[ $is_macos -eq 1 ]]; then echo $(date -j -f "$format" "$time" "+%s") else echo $(date -d "$time" "+%s") fi } get_env_array() { exported_variables=() while IFS= read -r line; do exported_variables+=("$line") done < <(grep '^export ' $file_env | awk '{print $2}' | cut -d= -f1) } clear_env() { for var in "${exported_variables[@]}"; do unset "$var" done } handle_task_start() { local error_message="" if [[ $ID ]]; then local error=$(update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp") if [[ $error ]]; then error_message=", 任务状态更新失败(${error})" fi fi echo -e "## 开始执行... ${begin_time}${error_message}\n" } run_task_before() { . $file_task_before "$@" if [[ ${task_before:=} ]]; then echo -e "执行前置命令\n" eval "${task_before%;}" echo -e "\n执行前置命令结束\n" fi } run_task_after() { . $file_task_after "$@" if [[ ${task_after:=} ]]; then echo -e "\n执行后置命令\n" eval "${task_after%;}" echo -e "\n执行后置命令结束" fi } handle_task_end() { local etime=$(date "+$time_format") local end_time=$(format_time "$time_format" "$etime") local end_timestamp=$(format_timestamp "$time_format" "$etime") local diff_time=$(($end_timestamp - $begin_timestamp)) local suffix="" [[ "${MANUAL:=}" == "true" ]] && suffix="(手动停止)" [[ "$diff_time" == 0 ]] && diff_time=1 if [[ $ID ]]; then local error=$(update_cron "\"$ID\"" "1" "$$" "$log_path" "$begin_timestamp" "$diff_time") if [[ $error ]]; then error_message=", 任务状态更新失败(${error})" fi fi echo -e "\n## 执行结束$suffix... $end_time 耗时 $diff_time 秒${error_message:=}     " } init_env detect_termux detect_macos define_cmd ================================================ FILE: shell/task.sh ================================================ #!/usr/bin/env bash dir_shell=$QL_DIR/shell . $dir_shell/share.sh . $dir_shell/api.sh trap "single_hanle" 2 3 20 15 14 19 1 single_hanle() { eval MANUAL=true handle_task_end "$@" "$cmd" exit 1 } define_program() { local file_param=$1 if [[ $file_param == *.js ]] || [[ $file_param == *.mjs ]]; then which_program="node" elif [[ $file_param == *.py ]] || [[ $file_param == *.pyc ]]; then which_program="python3" elif [[ $file_param == *.sh ]]; then which_program="." elif [[ $file_param == *.ts ]]; then which_program="ts-node-transpile-only" else which_program="" fi } handle_log_path() { local file_param=$1 if [[ -z $file_param ]]; then file_param="task" fi if [[ -z ${ID:=} ]]; then ID=$(cat $list_crontab_user | grep -E "$cmd_task.* $file_param" | perl -pe "s|.*ID=(.*) $cmd_task.* $file_param\.*|\1|" | head -1 | awk -F " " '{print $1}') fi local suffix="" if [[ ! -z $ID ]]; then if [[ "$ID" -gt 0 ]] 2>/dev/null; then suffix="_${ID}" else ID="" fi fi time=$(date "+$mtime_format") log_time=$(format_log_time "$mtime_format" "$time") if [[ -z $log_name ]]; then log_dir_tmp="${file_param##*/}" if [[ $file_param =~ "/" ]]; then if [[ $file_param == /* ]]; then log_dir_tmp_path="${file_param:1}" else log_dir_tmp_path="${file_param}" fi fi log_dir_tmp_path="${log_dir_tmp_path%/*}" log_dir_tmp_path="${log_dir_tmp_path##*/}" [[ $log_dir_tmp_path ]] && log_dir_tmp="${log_dir_tmp_path}_${log_dir_tmp}" log_dir="${log_dir_tmp%.*}${suffix}" else log_dir="$log_name" fi log_path="$log_dir/$log_time.log" if [[ ${real_log_path:=} ]]; then log_path="$real_log_path" fi cmd="2>&1 | tee -a $dir_log/$log_path" make_dir "$dir_log/$log_dir" if [[ "${no_tee:=}" == "true" ]]; then cmd=">> $dir_log/$log_path 2>&1" fi if [[ "${real_time:=}" == "true" ]]; then cmd="" fi if [[ "${log_dir:=}" == "/dev/null" ]]; then cmd=">> /dev/null" log_path="/dev/null" fi } format_params() { time_format="%Y-%m-%d %H:%M:%S" if [[ $is_macos -eq 1 ]]; then mtime_format=$time_format else mtime_format="%Y-%m-%d %H:%M:%S.%3N" fi timeoutCmd="" if [[ $command_timeout_time ]]; then if type timeout &>/dev/null; then timeoutCmd="timeout --foreground -s 2 -k 10s $command_timeout_time " fi fi # params=$(echo "$@" | sed -E 's/([^ ])&([^ ])/\1\\\&\2/g') # 分割 task 内置参数和脚本参数 task_shell_params=() script_params=() found_double_dash=false for arg in "$@"; do if $found_double_dash; then script_params+=("$arg") elif [ "$arg" == "--" ]; then found_double_dash=true else task_shell_params+=("$arg") fi done } init_begin_time() { begin_time=$(format_time "$time_format" "$time") begin_timestamp=$(format_timestamp "$time_format" "$time") } import_config "$@" while getopts ":lm:" opt; do case $opt in l) show_log="true" ;; m) max_time="$OPTARG" ;; esac done [[ ${show_log:=} ]] && shift $(($OPTIND - 1)) if [[ ${max_time:=} ]]; then shift $(($OPTIND - 1)) command_timeout_time="$max_time" fi format_params "$@" define_program "${task_shell_params[@]}" handle_log_path "${task_shell_params[@]}" init_begin_time eval . $dir_shell/otask.sh "$cmd" exit 0 ================================================ FILE: shell/update.sh ================================================ #!/usr/bin/env bash dir_shell=$QL_DIR/shell . $dir_shell/share.sh . $dir_shell/api.sh load_ql_envs . $dir_shell/env.sh send_mark=$dir_shell/send_mark ## 检测cron的差异,$1:脚本清单文件路径,$2:cron任务清单文件路径,$3:增加任务清单文件路径,$4:删除任务清单文件路径 diff_cron() { local list_scripts="$1" local list_task="$2" local list_add="$3" local list_drop="$4" if [[ -s $list_task ]] && [[ -s $list_scripts ]]; then grep -vwf $list_task $list_scripts >$list_add grep -vwf $list_scripts $list_task >$list_drop fi if [[ ! -s $list_task ]] && [[ -s $list_scripts ]]; then cp -f $list_scripts $list_add fi if [[ ! -s $list_scripts ]] && [[ -s $list_task ]]; then cp -f $list_task $list_drop fi } ## 输出是否有新的或失效的定时任务,$1:新的或失效的任务清单文件路径,$2:新/失效 output_list_add_drop() { local list=$1 local type=$2 if [[ -s $list ]]; then echo -e "检测到有${type}的定时任务:" cat $list fi } ## 自动删除失效的脚本与定时任务,需要:1.AutoDelCron 设置为 true;2.正常更新js脚本,没有报错;3.存在失效任务 ## $1:失效任务清单文件路径 del_cron() { local list_drop=$1 local path=$2 local detail="" local ids="" echo -e "\n开始尝试自动删除失效的定时任务..." for cron in $(cat $list_drop); do local id=$(cat $list_crontab_user | grep -E "$cmd_task.* $cron" | perl -pe "s|.*ID=(.*) $cmd_task.* $cron\.*|\1|" | head -1 | awk -F " " '{print $1}') if [[ $ids ]]; then ids="$ids,\"$id\"" else ids="\"$id\"" fi cron_file="$dir_scripts/${cron}" if [[ -f $cron_file ]]; then cron_name=$(grep "new Env" $cron_file | awk -F "\(" '{print $2}' | awk -F "\)" '{print $1}' | sed 's:.*\('\''\|"\)\([^"'\'']*\)\('\''\|"\).*:\2:' | sed 's:"::g' | sed "s:'::g" | head -1) rm -f $cron_file fi [[ -z $cron_name ]] && cron_name="$cron" if [[ $detail ]]; then detail="${detail}\n${cron_name}" else detail="${cron_name}" fi done if [[ $ids ]]; then result=$(del_cron_api "$ids") notify_api "$path 删除任务${result}" "$detail" fi } ## 自动增加定时任务,需要:1.AutoAddCron 设置为 true;2.正常更新js脚本,没有报错;3.存在新任务;4.crontab.list存在并且不为空 ## $1:新任务清单文件路径 add_cron() { local list_add=$1 local path=$2 echo -e "\n开始尝试自动添加定时任务..." local detail="" cd $dir_scripts for file in $(cat $list_add); do local file_name=${file/${path}\//} file_name=${file_name/${path}\_/} if [[ -f $file ]]; then cron_line=$( perl -ne "{ print if /.*([\d\*]*[\*-\/,\d]*[\d\*] ){4,5}[\d\*]*[\*-\/,\d]*[\d\*]( |,|\").*$file_name/ }" $file 2>/dev/null | perl -pe "{ s|[^\d\*]*(([\d\*]*[\*-\/,\d]*[\d\*] ){4,5}[\d\*]*[\*-\/,\d]*[\d\*])( \|,\|\").*/?$file_name.*|\1|g; s|\*([\d\*])(.*)|\1\2|g; s| | |g; }" 2>/dev/null | sort -u | head -1 ) [[ -z $cron_line ]] && cron_line=$(grep "cron:" $file | awk -F ":" '{print $2}' | head -1 | xargs) [[ -z $cron_line ]] && cron_line=$(grep "cron " $file | awk -F "cron \"" '{print $2}' | awk -F "\" " '{print $1}' | head -1 | xargs) [[ -z $cron_line ]] && cron_line="$default_cron" cron_name=$(grep "new Env" $file | awk -F "\(" '{print $2}' | awk -F "\)" '{print $1}' | sed 's:.*\('\''\|"\)\([^"'\'']*\)\('\''\|"\).*:\2:' | sed 's:"::g' | sed "s:'::g" | head -1) [[ -z $cron_name ]] && cron_name=$(grep "name:" $file | awk -F ":" '{print $2}' | head -1 | xargs) [[ -z $cron_name ]] && cron_name=$(basename "$file_name") result=$(add_cron_api "${cron_line}:${cmd_task} ${file}:${cron_name}:${SUB_ID}") echo -e "$result" if [[ $detail ]]; then detail="${detail}${result}\n" else detail="${result}\n" fi fi done notify_api "$path 新增任务" "$detail" } ## 更新仓库 update_repo() { local url="$1" local path="$2" local blackword="$3" local dependence="$4" local branch="$5" local extensions="$6" local proxy="$7" local autoAddCron="$8" local autoDelCron="$9" local tmp="${url%/*}" local authorTmp1="${tmp##*/}" local authorTmp2="${authorTmp1##*:}" local author="${authorTmp2##*.}" local repo_path="${dir_repo}/${uniq_path}" make_dir "${dir_scripts}/${uniq_path}" local formatUrl="$url" rm -rf ${repo_path} &>/dev/null git_clone_scripts "${formatUrl}" ${repo_path} "${branch}" "${proxy}" if [[ $exit_status -eq 0 ]]; then echo -e "拉取 ${uniq_path} 成功...\n" diff_scripts "$repo_path" "$author" "$path" "$blackword" "$dependence" "$extensions" "$autoAddCron" "$autoDelCron" else echo -e "拉取 ${uniq_path} 失败,请检查日志...\n" fi } ## 更新所有 raw 文件 update_raw() { local url="$1" local proxy="$2" local autoAddCron="$3" local autoDelCron="$4" if [[ ! $autoAddCron ]]; then autoAddCron=${AutoAddCron} fi if [[ ! $autoDelCron ]]; then autoDelCron=${AutoDelCron} fi local raw_url="$url" local suffix="${raw_url##*.}" local raw_file_name="${uniq_path}.${suffix}" echo -e "开始下载:${raw_url} \n\n保存路径:$dir_raw/${raw_file_name}\n" set_proxy "$proxy" wget -q --no-check-certificate -O "$dir_raw/${raw_file_name}.new" ${raw_url} exit_status=$? unset_proxy if [[ $? -eq 0 ]]; then mv "$dir_raw/${raw_file_name}.new" "$dir_raw/${raw_file_name}" echo -e "下载 ${raw_file_name} 成功...\n" cd $dir_raw local filename="raw_${raw_file_name}" local cron_id=$(cat $list_crontab_user | grep -E "$cmd_task.* $filename" | perl -pe "s|.*ID=(.*) $cmd_task.* $filename\.*|\1|" | head -1 | awk -F " " '{print $1}') cp -f $raw_file_name $dir_scripts/${filename} if [[ -z $cron_id ]] && [[ ${autoAddCron} == true ]]; then cron_line=$( perl -ne "{ print if /.*([\d\*]*[\*-\/,\d]*[\d\*] ){4,5}[\d\*]*[\*-\/,\d]*[\d\*]( |,|\").*$raw_file_name/ }" $raw_file_name | perl -pe "{ s|[^\d\*]*(([\d\*]*[\*-\/,\d]*[\d\*] ){4,5}[\d\*]*[\*-\/,\d]*[\d\*])( \|,\|\").*/?$raw_file_name.*|\1|g; s|\*([\d\*])(.*)|\1\2|g; s| | |g; }" | sort -u | head -1 ) cron_name=$(grep "new Env" $raw_file_name | awk -F "\(" '{print $2}' | awk -F "\)" '{print $1}' | sed 's:.*\('\''\|"\)\([^"'\'']*\)\('\''\|"\).*:\2:' | sed 's:"::g' | sed "s:'::g" | head -1) [[ -z $cron_name ]] && cron_name="$raw_file_name" [[ -z $cron_line ]] && cron_line=$(grep "cron:" $raw_file_name | awk -F ":" '{print $2}' | head -1 | xargs) [[ -z $cron_line ]] && cron_line=$(grep "cron " $raw_file_name | awk -F "cron \"" '{print $2}' | awk -F "\" " '{print $1}' | head -1 | xargs) [[ -z $cron_line ]] && cron_line="$default_cron" result=$(add_cron_api "${cron_line}:${cmd_task} ${filename}:${cron_name}:${SUB_ID}") echo -e "$result\n" notify_api "新增任务通知" "\n$result" # update_cron_api "$cron_line:$cmd_task $filename:$cron_name:$cron_id" fi else echo -e "下载 ${raw_file_name} 失败,保留之前正常下载的版本...\n" [[ -f "$dir_raw/${raw_file_name}.new" ]] && rm -f "$dir_raw/${raw_file_name}.new" fi } ## 调用用户自定义的extra.sh run_extra_shell() { if [[ -f $file_extra_shell ]]; then . $file_extra_shell else echo -e "$file_extra_shell文件不存在,跳过执行...\n" fi } ## 脚本用法 usage() { echo -e "$cmd_update 命令使用方法:" echo -e "1. $cmd_update update # 更新并重启青龙" echo -e "2. $cmd_update extra # 运行自定义脚本" echo -e "3. $cmd_update raw # 更新单个脚本文件" echo -e "4. $cmd_update repo # 更新单个仓库的脚本" echo -e "5. $cmd_update rmlog # 删除旧日志" echo -e "6. $cmd_update bot # 启动tg-bot" echo -e "7. $cmd_update check # 检测青龙环境并修复" echo -e "8. $cmd_update resetlet # 重置登录错误次数" echo -e "9. $cmd_update resettfa # 禁用两步登录" echo -e "10. $cmd_update resetpwd # 修改登录密码" echo -e "11. $cmd_update resetname # 修改登录用户名" } reload_qinglong() { echo -e "[reload_qinglong] deleting Triggered at $(date)" >>${dir_log}/reload.log sleep 3 delete_pm2 echo -e "[reload_qinglong] deleted Triggered at $(date)" >>${dir_log}/reload.log local reload_target="${1}" local primary_branch="master" if [[ "${QL_BRANCH}" == "develop" ]] || [[ "${QL_BRANCH}" == "debian" ]] || [[ "${QL_BRANCH}" == "debian-dev" ]]; then primary_branch="${QL_BRANCH}" fi if [[ "$reload_target" == 'system' ]]; then rm -rf ${dir_root}/back ${dir_root}/cli ${dir_root}/docker ${dir_root}/sample ${dir_root}/shell ${dir_root}/src mv -f ${dir_tmp}/qinglong-${primary_branch}/* ${dir_root}/ rm -rf $dir_static/* mv -f ${dir_tmp}/qinglong-static-${primary_branch}/* ${dir_static}/ cp -f $file_config_sample $dir_config/config.sample.sh fi if [[ "$reload_target" == 'data' ]]; then rm -rf ${dir_data}/* mv -f ${dir_tmp}/data/* ${dir_data}/ fi echo -e "[reload_qinglong] starting Triggered at $(date)" >>${dir_log}/reload.log reload_pm2 echo -e "[reload_qinglong] started Triggered at $(date)\n" >>${dir_log}/reload.log } ## 更新 qinglong update_qinglong() { rm -rf ${dir_tmp}/* local mirror="gitee" local downloadQLUrl="https://gitee.com/whyour/qinglong/repository/archive" local downloadStaticUrl="https://gitee.com/whyour/qinglong-static/repository/archive" local githubStatus=$(curl -s --noproxy "*" -m 2 -IL "https://google.com" | grep 200) if [[ ! -z $githubStatus ]]; then mirror="github" downloadQLUrl="https://github.com/whyour/qinglong/archive/refs/heads" downloadStaticUrl="https://github.com/whyour/qinglong-static/archive/refs/heads" fi echo -e "使用 ${mirror} 源更新...\n" local primary_branch="master" if [[ "${QL_BRANCH}" == "develop" ]] || [[ "${QL_BRANCH}" == "debian" ]] || [[ "${QL_BRANCH}" == "debian-dev" ]]; then primary_branch="${QL_BRANCH}" fi wget -cqO "${dir_tmp}/ql.zip" "${downloadQLUrl}/${primary_branch}.zip" exit_status=$? if [[ $exit_status -eq 0 ]]; then echo -e "更新青龙源文件成功...\n" unzip -oq ${dir_tmp}/ql.zip -d ${dir_tmp} update_qinglong_static else echo -e "更新青龙源文件失败,请检查网络...\n" fi } update_qinglong_static() { wget -cqO "${dir_tmp}/static.zip" "${downloadStaticUrl}/${primary_branch}.zip" exit_status=$? if [[ $exit_status -eq 0 ]]; then echo -e "更新青龙静态资源成功...\n" unzip -oq ${dir_tmp}/static.zip -d ${dir_tmp} check_update_dep else echo -e "更新青龙静态资源失败,请检查网络...\n" fi } check_update_dep() { echo -e "\n开始检测依赖...\n" if [[ $(diff $dir_root/package.json ${dir_tmp}/qinglong-${primary_branch}/package.json) ]]; then npm_install_2 "${dir_tmp}/qinglong-${primary_branch}" fi if [[ $exit_status -eq 0 ]]; then echo -e "\n依赖检测安装成功...\n" echo -e "更新包下载成功..." if [[ "$needRestart" == 'true' ]]; then reload_qinglong "system" fi else echo -e "\n依赖检测安装失败,请检查网络...\n" fi } ## 对比脚本 diff_scripts() { local dir_current=$(pwd) local repo_path="$1" local author="$2" local path="$3" local blackword="$4" local dependence="$5" local extensions="$6" local autoAddCron="$7" local autoDelCron="$8" if [[ ! $autoAddCron ]]; then autoAddCron=${AutoAddCron} fi if [[ ! $autoDelCron ]]; then autoDelCron=${AutoDelCron} fi gen_list_repo "$repo_path" "$author" "$path" "$blackword" "$dependence" "$extensions" local list_add="$dir_list_tmp/${uniq_path}_add.list" local list_drop="$dir_list_tmp/${uniq_path}_drop.list" diff_cron "$dir_list_tmp/${uniq_path}_scripts.list" "$dir_list_tmp/${uniq_path}_user.list" $list_add $list_drop if [[ -s $list_drop ]]; then output_list_add_drop $list_drop "失效" if [[ ${autoDelCron} == true ]]; then del_cron $list_drop $uniq_path fi fi if [[ -s $list_add ]]; then output_list_add_drop $list_add "新" if [[ ${autoAddCron} == true ]]; then add_cron $list_add $uniq_path fi fi cd $dir_current } ## 生成脚本的路径清单文件 gen_list_repo() { local dir_current=$(pwd) local repo_path="$1" local author="$2" local path="$3" local blackword="$4" local dependence="$5" rm -f $dir_list_tmp/${uniq_path}*.list &>/dev/null cd ${repo_path} local cmd="find ." local index=0 if [[ $6 ]]; then file_extensions="$6" if [[ $file_extensions =~ "|" ]]; then file_extensions=$(echo $file_extensions | sed 's/|/ /g') fi fi for extension in $file_extensions; do if [[ $index -eq 0 ]]; then cmd="${cmd} -name \"*.${extension}\"" else cmd="${cmd} -o -name \"*.${extension}\"" fi let index+=1 done files=$(eval $cmd | sed 's/^..//') if [[ $path ]]; then files=$(echo "$files" | egrep "$path") fi if [[ $blackword ]]; then files=$(echo "$files" | egrep -v "$blackword") fi cp -f $file_notify_js "${dir_scripts}/${uniq_path}" cp -f $file_notify_py "${dir_scripts}/${uniq_path}" if [[ $dependence ]]; then cd ${repo_path} results=$(eval $cmd | sed 's/^..//' | egrep "$dependence") for _file in ${results}; do file_path=$(dirname $_file) make_dir "${dir_scripts}/${uniq_path}/${file_path}" cp -f $_file "${dir_scripts}/${uniq_path}/${file_path}" done fi if [[ -d $dir_dep ]]; then cp -rf $dir_dep/* "${dir_scripts}/${uniq_path}" &>/dev/null fi for file in ${files}; do dirPath=$(dirname "$file") filename=$(basename "$file") filePath="${uniq_path}/${filename}" if [[ $dirPath ]] && [[ $dirPath != '.' ]]; then mkdir -p "${dir_scripts}/${uniq_path}/${dirPath}" filePath="${uniq_path}/${dirPath}/${filename}" fi cp -f $file "${dir_scripts}/$filePath" echo "$filePath" >>"$dir_list_tmp/${uniq_path}_scripts.list" # cron_id=$(cat $list_crontab_user | grep -E "$cmd_task.* ${uniq_path}_${filename}" | perl -pe "s|.*ID=(.*) $cmd_task.* ${uniq_path}_${filename}\.*|\1|" | head -1 | awk -F " " '{print $1}') # if [[ $cron_id ]]; then # result=$(update_cron_command_api "$cmd_task ${uniq_path}/${filename}:$cron_id") # fi done grep -E "${cmd_task}.* ${uniq_path}" ${list_crontab_user} | perl -pe "s|.*ID=(.*) ${cmd_task}.* (${uniq_path}.*)\.*|\2|" | awk -F " " '{print $1}' | sort -u >"$dir_list_tmp/${uniq_path}_user.list" cd $dir_current } get_uniq_path() { local url="$1" local branch="$2" local urlTmp="${url%*/}" local repoTmp="${urlTmp##*/}" local repo="${repoTmp%.*}" local tmp="${url%/*}" local authorTmp1="${tmp##*/}" local authorTmp2="${authorTmp1##*:}" local author="${authorTmp2##*.}" uniq_path="${author}_${repo}" [[ $branch ]] && uniq_path="${uniq_path}_${branch}" } main() { ## for ql update show_log="false" while getopts ":l" opt; do case $opt in l) show_log="true" ;; esac done [[ "$show_log" == "true" ]] && shift $(($OPTIND - 1)) local p1="${1}" local p2="${2}" local p3="${3}" local p4="${4}" local p5="${5}" local p6="${6}" local p7="${7}" local p8="${8}" local p9="${9}" local p10="${10}" local log_dir="${p1}" make_dir "$dir_log/$log_dir" local log_time=$(date "+%Y-%m-%d-%H-%M-%S") local log_path="${log_dir}/${log_time}.log" local file_path="$dir_log/$log_path" cmd="2>&1 | tee -a $file_path" if [[ "$no_tee" == "true" ]]; then cmd=">> $file_path 2>&1" fi if [[ "$real_time" == "true" ]]; then cmd="" fi local time_format="%Y-%m-%d %H:%M:%S" local time=$(date "+$time_format") local begin_timestamp=$(format_timestamp "$time_format" "$time") local begin_time=$(format_time "$time_format" "$time") if [[ "$p1" != "repo" ]] && [[ "$p1" != "raw" ]]; then eval echo -e "\#\# 开始执行... $begin_time\\\n" $cmd fi [[ $ID ]] && update_cron "\"$ID\"" "0" "$$" "$log_path" "$begin_timestamp" case $p1 in update) fix_config local needRestart=${p2:-"true"} eval update_qinglong $cmd ;; reload) eval reload_qinglong "$p2" $cmd ;; extra) eval run_extra_shell $cmd ;; repo) get_uniq_path "$p2" "$p6" if [[ -n $p2 ]]; then update_repo "$p2" "$p3" "$p4" "$p5" "$p6" "$p7" "$p8" "$p9" "$p10" else eval echo -e "命令输入错误...\\\n" $cmd eval usage $cmd fi ;; raw) get_uniq_path "$p2" if [[ -n $p2 ]]; then update_raw "$p2" "$p3" "$p4" "$p5" else eval echo -e "命令输入错误...\\\n" $cmd eval usage $cmd fi ;; rmlog) eval . $dir_shell/rmlog.sh "$p2" $cmd ;; bot) eval . $dir_shell/bot.sh $cmd ;; check) eval . $dir_shell/check.sh $cmd ;; resetlet) eval update_auth_config "\\\"retries\\\":0" "重置登录错误次数" $cmd ;; resettfa) eval update_auth_config "\\\"twoFactorActivated\\\":false" "禁用两步验证" $cmd ;; resetpwd) eval update_auth_config "\\\"password\\\":\\\"$p2\\\"" "重置密码" $cmd ;; resetname) eval update_auth_config "\\\"username\\\":\\\"$p2\\\"" "重置用户名" $cmd ;; *) eval echo -e "命令输入错误...\\\n" $cmd eval usage $cmd ;; esac local etime=$(date "+$time_format") local end_time=$(format_time "$time_format" "$etime") local end_timestamp=$(format_timestamp "$time_format" "$etime") local diff_time=$(($end_timestamp - $begin_timestamp)) [[ $ID ]] && update_cron "\"$ID\"" "1" "$$" "$log_path" "$begin_timestamp" "$diff_time" if [[ "$p1" != "repo" ]] && [[ "$p1" != "raw" ]]; then eval echo -e "\\\n\#\# 执行结束... $end_time 耗时 $diff_time 秒     " $cmd fi } import_config "$@" main "$@" exit 0 ================================================ FILE: src/app.ts ================================================ const baseUrl = window.__ENV__QlBaseUrl || '/'; import { setLocale } from '@umijs/max'; import intl from 'react-intl-universal'; export function rootContainer(container: any) { const locales = { 'en': require('./locales/en-US.json'), 'zh': require('./locales/zh-CN.json'), }; let currentLocale: string; try { currentLocale = intl.determineLocale({ urlLocaleKey: 'lang', cookieLocaleKey: 'lang', localStorageLocaleKey: 'lang', }).slice(0, 2); } catch (e: unknown) { // Handle decodeURIComponent errors from malformed cookies console.warn('Failed to determine locale from cookies:', e); currentLocale = ''; } if (!currentLocale || !Object.keys(locales).includes(currentLocale)) { currentLocale = 'zh'; } intl.init({ currentLocale, locales }); setLocale(currentLocale === 'zh' ? 'zh-CN' : 'en-US'); return container; } export function modifyClientRenderOpts(memo: any) { return { ...memo, publicPath: baseUrl, basename: baseUrl, }; } export function modifyContextOpts(memo: any) { return { ...memo, basename: baseUrl, }; } ================================================ FILE: src/components/copy.tsx ================================================ import intl from 'react-intl-universal'; import React, { useRef, useState, useEffect } from 'react'; import { Tooltip, Typography, message } from 'antd'; import { CopyOutlined, CheckOutlined } from '@ant-design/icons'; import { CopyToClipboard } from 'react-copy-to-clipboard'; const { Link } = Typography; const Copy = ({ text }: { text: string }) => { const [copied, setCopied] = useState(false); const copyIdRef = useRef(); const handleCopy = (text: string, result: boolean) => { if (result) { setCopied(true); message.success(intl.get('复制成功')); cleanCopyId(); copyIdRef.current = window.setTimeout(() => { setCopied(false); }, 3000); } }; const handleClick = (e?: React.MouseEvent) => { e?.preventDefault(); e?.stopPropagation(); }; const cleanCopyId = () => { window.clearTimeout(copyIdRef.current!); }; return ( {copied ? : } ); }; export default Copy; ================================================ FILE: src/components/iconfont.tsx ================================================ import { createFromIconfontCN } from '@ant-design/icons'; const IconFont = createFromIconfontCN({ scriptUrl: ['//at.alicdn.com/t/c/font_3354854_lc939gab1iq.js'], }); export default IconFont; ================================================ FILE: src/components/index.less ================================================ .react-terminal-wrapper { width: 100%; background: #252a33; color: #eee; font-size: 18px; font-family: 'Fira Mono', Consolas, Menlo, Monaco, 'Courier New', Courier, monospace; border-radius: 4px; padding: 75px 45px 35px; position: relative; -webkit-box-sizing: border-box; box-sizing: border-box; } .react-terminal { overflow: auto; display: flex; flex-direction: column; } .react-terminal-wrapper.react-terminal-light { background: #ddd; color: #1a1e24; } .react-terminal-wrapper:before { content: ''; position: absolute; top: 15px; left: 15px; display: inline-block; width: 15px; height: 15px; border-radius: 50%; background: #d9515d; -webkit-box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930; box-shadow: 25px 0 0 #f4c025, 50px 0 0 #3ec930; } .react-terminal-wrapper:after { content: attr(data-terminal-name); position: absolute; color: #a2a2a2; top: 5px; left: 0; width: 100%; text-align: center; } .react-terminal-wrapper.react-terminal-light:after { color: #d76d77; } .react-terminal-line { display: block; line-height: 1.5; } .react-terminal-line:before { content: ''; display: inline-block; vertical-align: middle; color: #a2a2a2; } .react-terminal-light .react-terminal-line:before { color: #d76d77; } .react-terminal-input:before { margin-right: 0.75em; content: '$'; } .react-terminal-input[data-terminal-prompt]:before { content: attr(data-terminal-prompt); } ================================================ FILE: src/components/name.tsx ================================================ import { useRequest } from 'ahooks'; import { Service, Options } from 'ahooks/lib/useRequest/src/types'; import { Spin, Typography } from 'antd'; export default function Name< TData extends { data?: { name: string } }, TParams, >({ service, options, }: { service: Service; options: Options; }) { const { loading, data } = useRequest(service, options); return ( {data?.data?.name} ); } ================================================ FILE: src/components/tag.tsx ================================================ import intl from 'react-intl-universal'; import { Tag, Input } from 'antd'; import { TweenOneGroup } from 'rc-tween-one'; import { PlusOutlined } from '@ant-design/icons'; import { useEffect, useRef, useState } from 'react'; const EditableTagGroup = ({ value, onChange, }: { value?: string[]; onChange?: (tags: string[]) => void; }) => { const [inputValue, setInputValue] = useState(''); const [inputVisible, setInputVisible] = useState(false); const [tags, setTags] = useState([]); const saveInputRef = useRef(); const handleClose = (removedTag: string) => { const _tags = tags.filter((tag) => tag !== removedTag); setTags(_tags); onChange?.(_tags); }; const showInput = () => { setInputVisible(true); }; const handleInputChange = (e) => { setInputValue(e.target.value); }; const handleInputConfirm = () => { if (inputValue && !tags.includes(inputValue)) { setTags([...tags, inputValue]); onChange?.([...tags, inputValue]); } setInputVisible(false); setInputValue(''); }; const tagChild = tags.map((tag) => { const tagElem = ( { e.preventDefault(); handleClose(tag); }} > {tag} ); return ( {tagElem} ); }); useEffect(() => { if (inputVisible && saveInputRef) { saveInputRef.current.focus(); } }, [inputVisible]); useEffect(() => { if (value) { setTags(value); } }, [value]); return ( <> {tagChild} {inputVisible && ( )} {!inputVisible && ( {intl.get('新建')} )} ); }; export default EditableTagGroup; ================================================ FILE: src/components/terminal.tsx ================================================ import React, { useEffect, useRef } from 'react'; import './index.less'; export enum LineType { Input, Output, } export enum ColorMode { Light, Dark, } export interface Props { name?: string; prompt?: string; colorMode?: ColorMode; lineData: Array<{ type: LineType; value: string | React.ReactNode }>; startingInputValue?: string; } const Terminal = ({ name, prompt, colorMode, lineData, startingInputValue = '', }: Props) => { const lastLineRef = useRef(null); // An effect that handles scrolling into view the last line of terminal input or output const performScrolldown = useRef(false); useEffect(() => { if (performScrolldown.current) { // skip scrolldown when the component first loads setTimeout( () => lastLineRef?.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest', }), 500, ); } performScrolldown.current = true; }, [lineData.length]); const renderedLineData = lineData.map((ld, i) => { const classes = ['react-terminal-line']; if (ld.type === LineType.Input) { classes.push('react-terminal-input'); } // `lastLineRef` is used to ensure the terminal scrolls into view to the last line; make sure to add the ref to the last if (lineData.length === i + 1) { return ( {ld.value} ); } else { return ( {ld.value} ); } }); const classes = ['react-terminal-wrapper']; if (colorMode === ColorMode.Light) { classes.push('react-terminal-light'); } return (
{renderedLineData}
); }; export default Terminal; ================================================ FILE: src/hooks/useFilterTreeData.ts ================================================ import { useMemo, useState } from 'react'; export default ( treeData: any[], searchValue: string, { treeNodeFilterProp, }: { treeNodeFilterProp: string; }, ) => { return useMemo(() => { const keys: string[] = []; if (!searchValue) { return { treeData, keys }; } const upperStr = searchValue.toUpperCase(); function filterOptionFunc(_: string, dataNode: any[]) { const value = dataNode[treeNodeFilterProp as any]; return String(value).toUpperCase().includes(upperStr); } function dig(list: any[], keepAll: boolean = false): any[] { return list .map((dataNode) => { const children = dataNode.children; const match = keepAll || filterOptionFunc!(searchValue, dataNode); const childList = dig(children || [], match); if (match || childList.length) { childList.length && keys.push(dataNode.key); return { ...dataNode, children: childList, }; } return null; }) .filter((node) => node); } return { treeData: dig(treeData), keys }; }, [treeData, searchValue, treeNodeFilterProp]); }; ================================================ FILE: src/hooks/useScrollHeight.ts ================================================ import { RefObject, useState } from 'react'; import useResizeObserver from '@react-hook/resize-observer'; export default (target: RefObject) => { const [height, setHeight] = useState(0); useResizeObserver(target, (entry) => { let _height = entry.target.clientHeight; if (height !== _height) { setHeight(_height); } }); return height; }; ================================================ FILE: src/hooks/useTableScrollHeight.ts ================================================ import { RefObject, useState } from 'react'; import useResizeObserver from '@react-hook/resize-observer'; import { getTableScroll } from '@/utils'; export default ( target: RefObject, extraHeight?: number, ) => { const [height, setHeight] = useState(0); useResizeObserver(target, (entry) => { let _target = entry.target as any; if (!_target.classList.contains('ant-table-wrapper')) { _target = entry.target.querySelector('.ant-table-wrapper'); } setHeight(getTableScroll({ extraHeight, target: _target as HTMLElement })); }); return height; }; ================================================ FILE: src/layouts/defaultProps.tsx ================================================ import intl from 'react-intl-universal'; import { SettingOutlined } from '@ant-design/icons'; import IconFont from '@/components/iconfont'; import { BasicLayoutProps } from '@ant-design/pro-layout'; export default { route: { routes: [ { name: intl.get('登录'), path: '/login', hideInMenu: true, component: '@/pages/login/index', }, { name: intl.get('初始化'), path: '/initialization', hideInMenu: true, component: '@/pages/initialization/index', }, { name: intl.get('错误'), path: '/error', hideInMenu: true, component: '@/pages/error/index', }, { path: '/crontab', name: intl.get('定时任务'), icon: , component: '@/pages/crontab/index', }, { path: '/subscription', name: intl.get('订阅管理'), icon: , component: '@/pages/subscription/index', }, { path: '/env', name: intl.get('环境变量'), icon: , component: '@/pages/env/index', }, { path: '/config', name: intl.get('配置文件'), icon: , component: '@/pages/config/index', }, { path: '/script', name: intl.get('脚本管理'), icon: , component: '@/pages/script/index', }, { path: '/dependence', name: intl.get('依赖管理'), icon: , component: '@/pages/dependence/index', }, { path: '/log', name: intl.get('日志管理'), icon: , component: '@/pages/log/index', }, { path: '/diff', name: intl.get('对比工具'), icon: , component: '@/pages/diff/index', }, { path: '/setting', name: intl.get('系统设置'), icon: , component: '@/pages/password/index', }, ], }, navTheme: 'light', fixSiderbar: true, contentWidth: 'Fixed', splitMenus: false, siderWidth: 180, } as BasicLayoutProps; ================================================ FILE: src/layouts/index.less ================================================ @import '~antd/es/style/themes/default.less'; @import '~@/styles/variable.less'; @font-face { font-family: 'Source Code Pro'; src: url('../assets/fonts/SourceCodePro-Regular.ttf.woff2') format('woff2'), url('../assets/fonts/SourceCodePro-Regular.otf.woff') format('woff'), url('../assets/fonts/SourceCodePro-Regular.ttf') format('truetype'); } @font-face { font-family: Log; src: url('../assets/fonts/log.woff2') format('woff2'), url('../assets/fonts/log.woff') format('woff'), url('../assets/fonts/log.ttf') format('truetype'); } body { // 禁止手机页面下拉刷新 overflow: hidden; // 禁止手机页面弹簧效果 position: fixed; top: 0; left: 0; } #root { height: 100vh; height: calc(100vh - var(--vh-offset, 0px)); -webkit-overflow-scrolling: touch; } .ant-modal-header { padding-right: 54px; } .ant-modal-body { max-height: calc(80vh - 110px); max-height: calc(80vh - var(--vh-offset, 110px)); overflow-y: auto; } .log-modal { &.ant-modal { max-width: 1000px !important; width: 80vw !important; } .ant-modal-body { overflow-y: auto; min-height: 300px; max-height: calc(80vh - 110px); max-height: calc(80vh - var(--vh-offset, 110px)); padding: 0; display: flex; .log-container { width: 100%; padding: 24px; overflow-y: auto; code, span { font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace, Log; } } } pre { white-space: break-spaces; line-height: 17px; margin-bottom: 0; overflow: hidden; } } .ͼ1 .cm-scroller { font-family: monospace, Log; } .monaco-editor:not(.rename-box) { height: calc(100vh - 128px) !important; height: calc(100vh - var(--vh-offset, 0px) - 128px) !important; .view-overlays .current-line { border-width: 0; } } .rename-box { height: 0; .rename-input { height: 0; padding: 0 !important; display: none !important; } } .ant-pro-grid-content.wide { max-width: unset !important; overflow: auto; .ant-pro-page-container-children-content { overflow: auto; height: 100%; background-color: @component-background; padding: 12px; } } .ql-container-wrapper-has-tab { .ant-pro-grid-content.wide .ant-pro-page-container-children-content { padding-top: 0; } } .ant-table-cell-ellipsis { text-align: left !important; } .ant-tooltip { max-width: 300px !important; .ant-tooltip-inner { word-break: break-all !important; max-height: 300px !important; overflow-y: auto !important; } } .env-wrapper { th { white-space: nowrap; } } .log-wrapper { .log-select { width: 250px; } .ant-page-header-heading-left { min-width: 100px; } } .config-wrapper { .config-select { width: 250px; } .ant-page-header-heading-left { min-width: 100px; } } .ant-tree { .ant-tree-treenode { width: 100%; padding-right: 8px !important; } .ant-tree-node-content-wrapper { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; } } .ant-select-tree { .ant-select-tree-treenode { width: 100%; padding-right: 8px !important; } .ant-select-tree-node-content-wrapper { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; } } .ant-pro-page-container.ql-container-wrapper { display: flex; flex-direction: column; height: calc(100vh - 48px); height: calc(100vh - var(--vh-offset, 0px) - 48px); .ant-pro-grid-content.wide { flex: 1; .ant-pro-grid-content-children { height: 100%; > div, .log-container, .react-codemirror2, .CodeMirror { height: 100%; overflow: auto; } } } } @media (max-width: 768px) { .ql-container-wrapper { &.crontab-wrapper, &.log-wrapper, &.env-wrapper, &.config-wrapper { .CodeMirror { width: calc(100vw - 24px); } } } .log-modal { &.ant-modal { width: calc(100vw - 16px) !important; } } .ant-tooltip { max-width: 300px !important; } } .ant-layout-content.ant-pro-basicLayout-content.ant-pro-basicLayout-has-header { margin-bottom: 0 !important; min-height: calc(100vh - 72px); min-height: calc(100vh - var(--vh-offset, 0px) - 72px); } .Resizer { background: @component-background; opacity: 0.8; z-index: 100; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; -moz-background-clip: padding; -webkit-background-clip: padding; background-clip: padding-box; border-left: 5px solid rgba(42, 161, 255, 0); border-right: 5px solid rgba(42, 161, 255, 0); } .Resizer:hover { -webkit-transition: all 2s ease; transition: all 2s ease; } .Resizer.horizontal { height: 11px; margin: -5px 0; cursor: row-resize; width: 100%; } .Resizer.vertical { width: 11px; margin: 0 -5px; cursor: col-resize; } .Resizer.horizontal:hover, .Resizer.vertical:hover { border-left-color: rgba(42, 161, 255, 0.5); border-right-color: rgba(42, 161, 255, 0.5); } .Resizer.disabled { cursor: not-allowed; } .Resizer.disabled:hover { border-color: transparent; } .edit-modal { .ant-drawer-body { padding: 0; } } .inline-countdown.ant-statistic { display: inline-block; .ant-statistic-content { font-size: 14px; padding: 0 3px; } } .ant-form-item-extra { word-break: break-all; font-size: 13px; } /* Change autocomplete styles in WebKit */ input:-webkit-autofill, input:-webkit-autofill:hover, input:-webkit-autofill:focus, textarea:-webkit-autofill, textarea:-webkit-autofill:hover, textarea:-webkit-autofill:focus, select:-webkit-autofill, select:-webkit-autofill:hover, select:-webkit-autofill:focus { box-shadow: none; transition: background-color 5000s ease-in-out 0s; -webkit-text-fill-color: @text-color; caret-color: @text-color; color: @text-color; } ::placeholder { opacity: 0.5 !important; } .ant-select-selection-placeholder { opacity: 0.5 !important; } .ant-pro-basicLayout-content { margin: 12px; .ant-pro-page-container { margin: -12px; } .ant-pro-page-container-warp .ant-page-header { border-bottom: 1px solid #eee; } .ant-pro-page-container-children-content { margin: 0; } } .ant-pro-global-header { box-shadow: 0 4px 8px rgba(0, 0, 0, 0.03); } .ant-menu-item.ant-pro-sider-collapsed-button { margin: 0; } .side-menu-container { display: flex; justify-content: space-between; align-items: center; color: @text-color; .side-menu-collapse-button:hover { color: #1890ff; } .side-menu-user-wrapper { display: flex; align-items: center; } } .side-menu-user-drop-menu { position: relative; text-align: left; padding: 2px 10px; overflow: auto; } .ant-pro-sider-logo { padding-inline: 5px !important; .title { display: flex; height: 32px; margin: 0 5px; font-weight: 600; font-size: 16px; line-height: 32px; vertical-align: middle; animation: pro-layout-title-hide 0.3s; a { display: inline-flex; align-items: center; } } img { width: 32px !important; border-radius: 52% !important; } } .ant-pro-global-header-logo { a img { // 移动端logo被拉伸 width: auto !important; } .ant-image { display: inline-flex; } } pre { word-break: break-all; white-space: break-spaces; padding: 0; } .virtuallist { .ant-table-tbody > tr > td > div { white-space: unset !important; } } .virtuallist .ant-table-tbody > tr > td > div { box-sizing: border-box; white-space: nowrap; vertical-align: middle; overflow: hidden; text-overflow: ellipsis; width: 100%; } .virtuallist .ant-table-tbody > tr > td.ant-table-row-expand-icon-cell > div { overflow: inherit; } .ant-table-bordered .virtuallist > table > .ant-table-tbody > tr > td { border-right: 1px solid #f0f0f0; } .ant-table-column-title { flex: unset; } .ant-table-column-sorters, .ant-table-filter-column { justify-content: unset; } textarea.ant-input { word-break: break-all; } .ant-pro-sider-collapsed, body[data-mode='phone'] header { .title { display: none; } } .ant-design-pro .ant-pro-sider.ant-layout-sider.ant-pro-sider-fixed { box-shadow: none; border-right: 1px solid #eee; } .ant-table.ant-table-middle .ant-table-title, .ant-table.ant-table-middle .ant-table-footer, .ant-table.ant-table-middle .ant-table-thead > tr > th, .ant-table.ant-table-middle .ant-table-tbody > tr > td, .ant-table.ant-table-middle tfoot > tr > th, .ant-table.ant-table-middle tfoot > tr > td { padding: 12px 16px; } body[data-dark='true'] { .ant-popover-arrow-content { --antd-arrow-background-color: rgb(24, 26, 27); } } .ant-tabs-content-holder { flex: 1; overflow-y: auto; } ================================================ FILE: src/layouts/index.tsx ================================================ import config from '@/utils/config'; import { useCtx, useTheme } from '@/utils/hooks'; import { request } from '@/utils/http'; import { LogoutOutlined, MenuFoldOutlined, MenuUnfoldOutlined, UserOutlined, } from '@ant-design/icons'; import ProLayout, { PageLoading } from '@ant-design/pro-layout'; import { history, Link, Outlet, useLocation } from '@umijs/max'; import * as DarkReader from '@umijs/ssr-darkreader'; import { Avatar, Badge, Dropdown, Image, MenuProps, Tooltip } from 'antd'; import React, { useEffect, useState } from 'react'; import intl from 'react-intl-universal'; import vhCheck from 'vh-check'; import defaultProps from './defaultProps'; import './index.less'; import { init } from '../utils/init'; import WebSocketManager from '../utils/websocket'; export interface SharedContext { headerStyle: React.CSSProperties; isPhone: boolean; theme: 'vs' | 'vs-dark'; user: any; reloadUser: (needLoading?: boolean) => void; reloadTheme: () => void; systemInfo: TSystemInfo; } interface TSystemInfo { branch: 'develop' | 'master'; isInitialized: boolean; publishTime: number; version: string; changeLog: string; changeLogLink: string; } export default function () { const location = useLocation(); const ctx = useCtx(); const { theme, reloadTheme } = useTheme(); const [user, setUser] = useState({}); const [loading, setLoading] = useState(true); const [systemInfo, setSystemInfo] = useState(); const [collapsed, setCollapsed] = useState(false); const [initLoading, setInitLoading] = useState(true); const { enable: enableDarkMode, disable: disableDarkMode, exportGeneratedCSS: collectCSS, setFetchMethod, auto: followSystemColorScheme, } = DarkReader || {}; const logout = () => { request.post(`${config.apiPrefix}user/logout`).then(() => { localStorage.removeItem(config.authKey); history.push('/login'); }); }; const getSystemInfo = () => { request .get(`${config.apiPrefix}system`) .then(({ code, data }) => { if (code === 200) { setSystemInfo(data); if (!data.isInitialized) { history.push('/initialization'); } else { init(data.version); getUser(); } } }) .catch((error) => { console.log(error); }); }; const getUser = (needLoading = true) => { needLoading && setLoading(true); request .get(`${config.apiPrefix}user`) .then(({ code, data }) => { if (code === 200 && data.username) { setUser(data); if (location.pathname === '/') { history.push('/crontab'); } } needLoading && setLoading(false); }) .catch((error) => { console.log(error); }); }; const getHealthStatus = () => { request .get(`${config.apiPrefix}health`) .then((res) => { if (res?.data?.status === 'ok') { getSystemInfo(); } else { history.push('/error'); } }) .catch((error) => { const responseStatus = error.response.status; if (responseStatus !== 401) { history.push('/error'); } else { window.location.reload(); } }) .finally(() => setInitLoading(false)); }; const reloadUser = (needLoading = false) => { getUser(needLoading); }; useEffect(() => { if (systemInfo && systemInfo.isInitialized && !user) { getUser(); } }, [location.pathname]); useEffect(() => { getHealthStatus(); }, []); useEffect(() => { if (theme === 'vs-dark') { document.body.setAttribute('data-dark', 'true'); } else { document.body.setAttribute('data-dark', 'false'); } }, [theme]); useEffect(() => { vhCheck(); const _theme = localStorage.getItem('qinglong_dark_theme') || 'auto'; if (typeof window === 'undefined') return; if (typeof window.matchMedia === 'undefined') return; if (!DarkReader) { return () => null; } setFetchMethod(fetch); if (_theme === 'dark') { enableDarkMode({}); } else if (_theme === 'light') { disableDarkMode(); } else { followSystemColorScheme({}); } return () => { disableDarkMode(); }; }, []); useEffect(() => { if (!user || !user.username) return; const ws = WebSocketManager.getInstance( `${window.location.origin}${ config.apiPrefix }ws?token=${localStorage.getItem(config.authKey)}`, ); return () => { ws.close(); }; }, [user]); useEffect(() => { window.onload = () => { const timing = performance.timing; console.log(`白屏时间: ${timing.responseStart - timing.navigationStart}`); console.log( `请求完毕至DOM加载: ${timing.domInteractive - timing.responseEnd}`, ); console.log( `解释dom树耗时: ${timing.domComplete - timing.domInteractive}`, ); console.log( `从开始至load总耗时: ${timing.loadEventEnd - timing.navigationStart}`, ); }; }, []); if (initLoading) { return ; } if (['/login', '/initialization', '/error'].includes(location.pathname)) { if (systemInfo?.isInitialized && location.pathname === '/initialization') { history.push('/crontab'); } if (systemInfo || location.pathname === '/error') { return ( ); } } const isFirefox = navigator.userAgent.includes('Firefox'); const isSafari = navigator.userAgent.includes('Safari') && !navigator.userAgent.includes('Chrome'); const isQQBrowser = navigator.userAgent.includes('QQBrowser'); const menu: MenuProps = { items: [ { label: intl.get('退出登录'), className: 'side-menu-user-drop-menu', onClick: logout, key: 'logout', icon: , }, ], }; return loading ? ( ) : (
{intl.get('青龙')} { e.stopPropagation(); window.open(systemInfo?.changeLogLink, '_blank'); }} > v{systemInfo?.version}
} title={false} menuItemRender={(menuItemProps: any, defaultDom: any) => { if ( menuItemProps.isUrl || !menuItemProps.path || location.pathname === menuItemProps.path ) { return defaultDom; } return {defaultDom}; }} pageTitleRender={(props, pageName, info) => { const title = (config.documentTitleMap as any)[location.pathname] || intl.get('未找到'); return `${title} - ${intl.get('青龙')}`; }} onCollapse={setCollapsed} collapsed={collapsed} rightContentRender={() => ctx.isPhone && ( } src={ user.avatar ? `${config.apiPrefix}static/${user.avatar}` : '' } /> {user.username} ) } collapsedButtonRender={(collapsed) => ( { e.preventDefault(); e.stopPropagation(); }} > {!collapsed && !ctx.isPhone && ( } src={ user.avatar ? `${config.apiPrefix}static/${user.avatar}` : '' } /> {user.username} )} setCollapsed(!collapsed)} > {collapsed ? : } )} {...defaultProps} >
); } ================================================ FILE: src/loading.tsx ================================================ import { PageLoading } from '@ant-design/pro-layout'; const NewPageLoading = () => { return ; }; export default NewPageLoading; ================================================ FILE: src/locales/en-US.json ================================================ { "复制成功": "Copy successful", "复制": "Copy", "新建": "New", "登录": "Login", "初始化": "Initialize", "错误": "Error", "定时任务": "Scheduled Tasks", "订阅管理": "Subscription Management", "环境变量": "Environment Variables", "配置文件": "Configuration Files", "脚本管理": "Script Management", "依赖管理": "Dependency Management", "日志管理": "Log Management", "对比工具": "Comparison Tool", "系统设置": "System Settings", "退出登录": "Logout", "青龙": "Qinglong", "返回首页": "Return to Home", "保存": "Save", "日志": "Log", "脚本": "Script", "确认保存文件": "Confirm to Save File", ",保存后不可恢复": ", it can't be recovered after saving.", "确认运行": "Confirm to Run", "确认运行定时任务": "Confirm to Run Scheduled Task", "吗": "?", "确认停止": "Confirm to Stop", "确认停止定时任务": "Confirm to Stop Scheduled Task", "确认": "Confirm", "任务": "Task", "状态": "Status", "空闲中": "Idle", "运行中": "Running", "队列中": "In Queue", "已禁用": "Disabled", "定时": "Schedule", "最后运行时间": "Last Run Time", "最后运行时长": "Last Run Duration", "下次运行时间": "Next Run Time", "名称": "Name", "命令/脚本": "Command/Script", "定时规则": "Schedule Rule", "操作": "Action", "确认删除": "Confirm to Delete", "确认删除定时任务": "Confirm to Delete Scheduled Task", "编辑": "Edit", "删除": "Delete", "确认删除选中的定时任务吗": "Confirm to delete the selected scheduled tasks?", "选中的定时任务吗": "selected scheduled tasks?", "创建视图": "Create View", "视图管理": "View Management", "请输入名称或者关键词": "Please enter a name or keyword", "创建任务": "Create Task", "更多": "More", "批量删除": "Batch Delete", "批量启用": "Batch Enable", "批量禁用": "Batch Disable", "批量运行": "Batch Run", "批量停止": "Batch Stop", "批量置顶": "Batch Top", "批量取消置顶": "Batch Un-top", "批量修改标签": "Batch Modify Tags", "已选择": "Selected", "项": "items", "知道了": "Got it", "请输入任务名称": "Please enter the task name", "支持输入脚本路径/任意系统可执行命令/task 脚本路径": "Supports input of script paths / any system executable commands / task script paths", "秒(可选) 分 时 天 月 周": "Seconds (optional) Minutes Hours Day Month Week", "标签": "Tags", "取消": "Cancel", "添加": "Add", "启用": "Enable", "禁用": "Disable", "运行": "Run", "停止": "Stop", "置顶": "Top", "取消置顶": "Un-top", "命令": "Command", "包含": "Include", "不包含": "Exclude", "属于": "Belong to", "不属于": "Not Belong to", "顺序": "Order", "倒序": "Reverse", "且": "And", "或": "Or", "输入后回车增加自定义选项": "Press Enter to add custom options", "视图名称": "View Name", "请输入视图名称": "Please enter the view name", "请输入内容": "Please enter the content", "新增筛选条件": "Add Filter", "新增排序方式": "Add Sort", "类型": "Type", "显示": "Display", "确认删除视图": "Confirm to delete the view", "安装中": "Installing", "已安装": "Installed", "安装失败": "Installation Failed", "删除中": "Deleting", "已删除": "Deleted", "删除失败": "Deletion Failed", "已取消": "Cancelled", "序号": "Number", "备注": "Remarks", "更新时间": "Update Time", "创建时间": "Created Time", "确认删除依赖": "Confirm to delete the dependency", "确认重新安装": "Confirm to reinstall", "确认取消安装": "Confirm to cancel install", "确认删除选中的依赖吗": "Confirm to delete the selected dependencies?", "确认重新安装选中的依赖吗": "Confirm to reinstall the selected dependencies?", "请输入名称": "Please enter a name", "创建依赖": "Create Dependency", "批量安装": "Batch Install", "批量强制删除": "Batch Force Delete", "日志 -": "Log -", "依赖类型": "Dependency Type", "自动拆分": "Auto Split", "多个依赖是否换行分割": "Whether to separate multiple dependencies with new lines", "是": "Yes", "否": "No", "请输入依赖名称,支持指定版本": "Please enter the dependency name, version specification is supported", "请输入依赖名称": "Please enter the dependency name", "请输入备注": "Please enter remarks", "源文件": "Source File", "当前文件": "Current File", "修改环境变量名称": "Modify Environment Variable Name", "请输入新的环境变量名称": "Please enter the new environment variable name", "已启用": "Enabled", "值": "Value", "确认删除变量": "Confirm to delete the variable", "确认删除选中的变量吗": "Confirm to delete the selected variables?", "选中的变量吗": "selected variables?", "请输入名称/值/备注": "Please enter name/value/remarks", "导入": "Import", "创建变量": "Create Variable", "批量修改变量名称": "Batch Modify Variable Names", "批量导出": "Batch Export", "请输入环境变量名称": "Please enter the environment variable name", "只能输入字母数字下划线,且不能以数字开头": "Only letters, numbers, and underscores are allowed, and cannot start with a number", "请输入环境变量值": "Please enter the environment variable value", "服务启动超时": "Service startup timeout", "请先按如下方式修复:": "Please fix it as follows:", "1. 宿主机执行 docker run --rm -v\n /var/run/docker.sock:/var/run/docker.sock\n containrrr/watchtower -cR <容器名>": "1. Execute 'docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR ' on the host machine", "2. 容器内执行 ql check、ql update": "2. Execute 'ql check' and 'ql update' inside the container", "3. 如果无法解决,容器内执行 pm2 logs,拷贝执行结果": "3. If the problem persists, execute 'pm2 logs' inside the container and copy the results", "提交 issue": "Submit an issue", "启动中,请稍后...": "Starting, please wait...", "欢迎使用": "Welcome to use", "欢迎使用青龙": "Welcome to use Qinglong", "支持python3、javascript、shell、typescript 的定时任务管理面板": "A scheduling task management panel that supports python3, javascript, shell, and typescript", "开始安装": "Start Installation", "账户设置": "Account Settings", "用户名": "Username", "密码": "Password", "密码不能为admin": "The password cannot be 'admin'", "确认密码": "Confirm Password", "您输入的两个密码不匹配!": "The two passwords you entered do not match!", "提交": "Submit", "通知设置": "Notification Settings", "通知方式": "Notification Method", "请选择通知方式": "Please select a notification method", "跳过": "Skip", "完成安装": "Installation Completed", "恭喜安装完成!": "Congratulations, the installation is completed!", "Telegram频道": "Telegram Channel", "去登录": "Go to Login", "初始化配置": "Initialize Configuration", "文件": "File", ",删除后不可恢复": ", it can't be recovered after deletion", "请选择日志": "Please select a log", "请输入日志名": "Please enter the log name", "暂无日志": "No logs available", "登录成功!": "Login successful!", "上次登录时间:": "Last login time: ", "上次登录地点:": "Last login location: ", "上次登录IP:": "Last login IP: ", "上次登录设备:": "Last login device: ", "上次登录状态:": "Last login status: ", "验证码": "Verification Code", "验证码为6位数字": "Verification code is a 6-digit number", "6位数字": "6-digit number", "验证": "Verify", "请": "Please", "秒后重试": "Retry after seconds", "在您的设备上打开两步验证应用程序以查看您的身份验证代码并验证您的身份。": "Open the two-factor authentication application on your device to view your authentication code and verify your identity.", "请选择脚本文件": "Please select a script file", "当前文件不支持预览": "Current file type is not supported for preview", "清空日志": "Clear Logs", "设置": "Settings", "退出": "Exit", "空文件": "Empty File", "本地文件": "Local File", "文件夹": "Folder", "文件名": "File Name", "请输入文件名": "Please enter the file name", "文件名不能包含斜杠": "File names cannot contain slashes", "文件夹名": "Folder Name", "请输入文件夹名": "Please enter the folder name", "父目录": "Parent Directory", "请选择父目录": "Please select a parent directory", "点击或者拖拽文件到此区域上传": "Click or drag files here to upload", "当前修改未保存,确定离开吗": "The current changes are not saved. Are you sure you want to leave?", "退出编辑": "Exit Editing", "重命名": "Rename", "请选择脚本": "Please select a script", "调试": "Debug", "请输入脚本名": "Please enter the script name", "暂无脚本": "No scripts available", "请输入新名称": "Please enter a new name", "保存文件": "Save File", "保存目录": "Save Directory", "请输入保存目录,默认scripts目录": "Please enter the save directory, default is 'scripts'", "运行设置": "Run Settings", "待开发": "To Be Developed", "开发版": "Developer Edition", "正式版": "Official Edition", "版本": "Version", "更新日志": "Changelog", "查看": "View", "提交BUG": "Submit Bug", "名称不能为保留关键字": "The name cannot be a reserved keyword", "请输入应用名称": "Please enter the application name", "权限": "Permission", "请选择模块权限": "Please select module permissions", "更新": "Update", "已经是最新版了!": "It is already the latest version!", "是目前检测到的最新可用版本了。": "It is the latest available version currently detected.", "重新下载": "Redownload", "更新可用": "Update Available", "新版本": "Create Version", "可用,你使用的版本为": "available, the version you are using is", "下载更新": "Download Update", "以后再说": "Later", "下载更新中...": "Downloading Update...", "确认重启": "Confirm to Restart", "系统安装包下载成功,确认重启": "System installation package downloaded successfully, confirm to restart", "重启": "Restart", "系统将在": "The system will restart in", "秒后自动刷新": "seconds and automatically refresh", "检查更新": "Check for Updates", "重新启动": "Reboot", "确认删除应用": "Confirm to delete the application", "确认重置": "Confirm to reset", "确认重置应用": "Confirm to reset the application", "的Secret吗": "'s Secret?", "重置Secret会让当前应用所有token失效": "Resetting the Secret will invalidate all tokens for the current application", "创建应用": "Create Application", "安全设置": "Security Settings", "应用设置": "Application Settings", "登录日志": "Login Logs", "其他设置": "Other Settings", "关于": "About", "成功": "Successfully", "失败": "Failure", "登录时间": "Login Time", "登录地址": "Login Address", "登录IP": "Login IP", "登录设备": "Login Device", "登录状态": "Login Status", "亮色": "Light", "暗色": "Dark", "跟随系统": "Follow System", "备份数据上传成功,确认覆盖数据": "Data backup uploaded successfully, confirm data overwrite", "主题设置": "Theme Settings", "日志删除频率": "Log Deletion Frequency", "每x天自动删除x天以前的日志": "Automatically delete logs older than x days every x days", "每": "Every", "天": "day(s)", "定时任务并发数": "Concurrent Scheduled Tasks", "数据备份还原": "Data Backup & Restore", "还原数据": "Restore Data", "第一步": "Step 1", "下载两步验证手机应用,比如 Google Authenticator 、": "Download a two-factor authentication mobile app, like Google Authenticator,", "第二步": "Step 2", "使用手机应用扫描二维码,或者输入秘钥": "Scan the QR code with the mobile app or enter the key", "第三步": "Step 3", "输入手机应用上的6位数字": "Enter the 6-digit code from the mobile app", "完成设置": "Finish Setup", "修改用户名密码": "Change Username and Password", "两步验证": "Two-Factor Authentication", "头像": "Profile Picture", "更换头像": "Change Profile Picture", "时": "hour(s)", "分": "minute(s)", "秒": "second(s)", "私有仓库": "Private Repository", "公开仓库": "Public Repository", "单文件": "Single File", "链接": "Link", "分支": "Branch", "确认删除定时订阅": "Confirm Deletion of Scheduled Subscription", "定时订阅": "Scheduled Subscription", "创建订阅": "Create Subscription", "私钥": "Private Key", "请输入私钥": "Please enter the private key", "请输入认证用户名": "Please enter the authentication username", "Github已不支持密码认证,请使用Token方式": "Github no longer supports password authentication. Please use the Token method.", "密码/Token": "Password/Token", "请输入密码或者Token": "Please enter the password or Token", "支持拷贝 ql repo/raw 命令,粘贴导入": "Supports copying ql repo/raw command for import", "请输入订阅链接": "Please enter the subscription link", "请输入分支": "Please enter the branch", "唯一值": "Unique Value", "唯一值用于日志目录和私钥别名": "Unique value used for log directory and private key alias", "自动生成": "Auto-generated", "拉取方式": "Pull Method", "用户名密码/Token": "Username/Password or Token", "定时类型": "Schedule Type", "白名单": "Whitelist", "多个关键词竖线分割,支持正则表达式": "Multiple keywords separated by vertical lines (|), supports regular expressions", "请输入脚本筛选白名单关键词,多个关键词竖线分割": "Please enter script filtering whitelist keywords, multiple keywords separated by vertical lines (|)", "黑名单": "Blacklist", "请输入脚本筛选黑名单关键词,多个关键词竖线分割": "Please enter script filtering blacklist keywords, multiple keywords separated by vertical lines (|)", "依赖文件": "Dependency Files", "请输入脚本依赖文件关键词,多个关键词竖线分割": "Please enter script dependency file keywords, multiple keywords separated by vertical lines (|)", "文件后缀": "File Extension", "仓库需要拉取的文件后缀,多个后缀空格分隔,默认使用配置文件中的RepoFileExtensions": "Repository requires pulling specific file extensions, multiple extensions separated by spaces, uses RepoFileExtensions from the configuration file by default", "请输入文件后缀": "Please enter the file extension", "执行前": "Before Execution", "运行订阅前执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "Run commands before executing the subscription, e.g., cp/mv/python3 xxx.py/node xxx.js", "请输入运行订阅前要执行的命令": "Please enter the command to run before executing the subscription", "执行后": "After Execution", "运行订阅后执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "Run commands after executing the subscription, e.g., cp/mv/python3 xxx.py/node xxx.js", "请输入运行订阅后要执行的命令": "Please enter the command to run after executing the subscription", "代理": "Proxy", "公开仓库支持HTTP/SOCK5代理,私有仓库支持SOCK5代理": "Public repositories support HTTP/SOCK5 proxies, private repositories support SOCK5 proxies", "自动添加任务": "Automatically Add Tasks", "自动删除任务": "Automatically Delete Tasks", "中文": "Chinese", "系统信息": "System Information", "Server酱": "ServerChan", "Telegram机器人": "Telegram Bot", "钉钉机器人": "DingTalk Bot", "企业微信机器人": "WeChat Work Bot", "企业微信应用": "WeChat Work App", "智能微秘书": "Smart WeChat Assistant", "群晖chat": "Synology Chat", "微加机器人": "WePlusBot", "邮箱": "Email", "飞书机器人": "Feishu Bot", "自定义通知": "Custom Notification", "已关闭": "Disabled", "gotify的url地址,例如 https://push.example.de:8080": "gotify URL address, e.g., https://push.example.de:8080", "gotify的消息应用token码": "gotify message application token code", "推送消息的优先级": "Priority of Push Messages", "synologyChat的url地址": "Synology Chat Webhook URL address", "chat的token码": "Chat token code", "推送到个人QQ: http://127.0.0.1/send_private_msg,群:http://127.0.0.1/send_group_msg": "Push to personal QQ: http://127.0.0.1/send_private_msg, group: http://127.0.0.1/send_group_msg", "访问密钥": "Access Key", "如果GOBOT_URL设置 /send_private_msg 则需要填入 user_id=个人QQ 相反如果是 /send_group_msg 则需要填入 group_id=QQ群": "If GOBOT_URL is set to /send_private_msg, enter user_id=personal QQ; if set to /send_group_msg, enter group_id=QQ group", "Server酱SENDKEY": "ServerChan SENDKEY, https://sct.ftqq.com/r/13363", "PushDeer的Key,https://github.com/easychen/pushdeer": "PushDeer Key, https://github.com/easychen/pushdeer", "PushDeer的自架API endpoint,默认是 https://api2.pushdeer.com/message/push": "PushDeer's self-hosted API endpoint, default is https://api2.pushdeer.com/message/push", "Bark的信息IP/设备码,例如:https://api.day.app/XXXXXXXX": "Bark information IP/device code, e.g., https://api.day.app/XXXXXXXX", "BARK推送图标,自定义推送图标 (需iOS15或以上才能显示)": "BARK push icon, custom push icon (requires iOS 15 or above to display)", "BARK推送铃声,铃声列表去APP查看复制填写": "BARK push ringtone, check and copy from the APP's ringtone list", "BARK推送消息的分组,默认为qinglong": "BARK push message grouping, default is qinglong", "BARK推送消息的时效性,默认为active": "BARK push message redirecting URL", "BARK推送消息的跳转URL": "BARK push message grouping, default is qinglong", "BARK是否保存推送消息": "Does BARK save push messages", "telegram机器人的token,例如:1077xxx4424:AAFjv0FcqxxxxxxgEMGfi22B4yh15R5uw": "Telegram Bot token, e.g., 1077xxx4424:AAFjv0FcqxxxxxxgEMGfi22B4yh15R5uw", "telegram用户的id,例如:129xxx206": "Telegram user ID, e.g., 129xxx206", "代理IP": "Proxy IP", "代理端口": "Proxy Port", "telegram代理配置认证参数,用户名与密码用英文冒号连接 user:password": "Telegram proxy configuration authentication parameters, connect username and password with a colon, e.g., user:password", "telegram api自建的反向代理地址,默认tg官方api": "Telegram API's self-built reverse proxy address, default is official tg API", "钉钉机器人webhook token,例如:5a544165465465645d0f31dca676e7bd07415asdasd": "DingTalk Bot webhook token, e.g., 5a544165465465645d0f31dca676e7bd07415asdasd", "密钥,机器人安全设置页面,加签一栏下面显示的SEC开头的字符串": "Secret key, shown below the signing section on the robot's security settings page, starts with SEC", "企业微信机器人的webhook(详见文档 https://work.weixin.qq.com/api/doc/90000/90136/91770),例如:693a91f6-7xxx-4bc4-97a0-0ec2sifa5aaa": "WeChat Work Bot webhook (see documentation at https://work.weixin.qq.com/api/doc/90000/90136/91770), e.g., 693a91f6-7xxx-4bc4-97a0-0ec2sifa5aaa", "企业微信代理地址": "WeChat Work Proxy Address", "corpid、corpsecret、touser(注:多个成员ID使用|隔开)、agentid、消息类型(选填,不填默认文本消息类型) 注意用,号隔开(英文输入法的逗号),例如:wwcfrs,B-76WERQ,qinglong,1000001,2COat": "corpid, corpsecret, touser (note: separate multiple member IDs with |), agentid, message type (optional, defaults to text message type) – separated by commas (`,`), e.g., wwcfrs, B-76WERQ, qinglong, 1000001, 2COat", "密钥key,智能微秘书个人中心获取apikey,申请地址:https://wechat.aibotk.com/signup?from=ql": "Key, obtain the API key from the Smart WeChat Assistant's personal center, apply at: https://wechat.aibotk.com/signup?from=ql", "发送的目标,群组或者好友": "Recipient, group, or friend", "请输入要发送的目标": "Please enter the recipient's name (group or friend)", "群聊": "Group Chat", "好友": "Friend", "要发送的用户昵称或群名,如果目标是群,需要填群名,如果目标是好友,需要填好友昵称": "Enter the recipient's nickname if it's a friend or the group name if it's a group", "iGot的信息推送key,例如:https://push.hellyw.com/XXXXXXXX": "iGot information push key, e.g., https://push.hellyw.com/XXXXXXXX", "微信扫码登录后一对一推送或一对多推送下面的token(您的Token),不提供PUSH_PLUS_USER则默认为一对一推送,参考 https://www.pushplus.plus/": "After WeChat scan login, one-to-one or one-to-many push using the provided token (your Token). If PUSH_PLUS_USER is not provided, it defaults to one-to-one push. See reference at https://www.pushplus.plus/", "一对多推送的“群组编码”(一对多推送下面->您的群组(如无则创建)->群组编码,如果您是创建群组人。也需点击“查看二维码”扫描绑定,否则不能接受群组消息推送)": "The 'group code' for one-to-many push (one-to-many push -> your group (if none, create one) -> group code). If you are the creator of the group, you need to click 'View QR code' to scan and bind, otherwise, you won't receive group messages.", "发送模板": "send template, can use type: 'html,txt,json,markdown,cloudMonitor,jenkins,route,pay'", "发送渠道": "send channel, can use type: 'wechat,webhook,cp,mail,sms'", "webhook编码": "webhook code", "发送结果回调地址": "send result callback url", "好友令牌": "friend token", "用户令牌,扫描登录后 我的—>设置->令牌 中获取,参考 https://www.weplusbot.com/": "Token, which can be obtained after scanning and logging in, is available under 'My Account' -> 'Settings' -> 'Tokens'. Please refer to the instructions for detailed steps: https://www.weplusbot.com/", "消息接收人": "message recipient", "调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版": "Version, you can specify 'pro' for the Professional version and 'personal' for the Personal version. If left blank, it will default to the Professional version.", "飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973": "Feishu group bot: https://www.feishu.cn/hc/zh-CN/articles/360024984973", "飞书群组机器人加签密钥,安全设置中开启签名校验后获得": "Feishu group bot signature secret, obtained after enabling signature verification in security settings", "邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json": "Email service name, e.g., 126, 163, Gmail, QQ, etc. Supported list: https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json", "邮箱地址": "Email Address", "SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定": "The SMTP login password may also be a special passphrase, depending on the specific email service provider's instructions", "PushMe的Key,https://push.i-i.me/": "PushMe key, https://push.i-i.me/", "自建的PushMeServer消息接口地址,例如:http://127.0.0.1:3010,不填则使用官方消息接口": "The self built PushMeServer message interface address, for example: http://127.0.0.1:3010 If left blank, use the official message interface", "ntfy的url地址,例如 https://ntfy.sh": "The URL address of ntfy, for example, https://ntfy.sh.", "ntfy应用topic": "The topic for ntfy's application.", "ntfy应用token": "The token for ntfy's application, see https://docs.ntfy.sh/config/#access-tokens", "ntfy应用用户名": "The username for ntfy's application, see https://docs.ntfy.sh/config/#users-and-roles", "ntfy应用密码": "The password for ntfy's application, see https://docs.ntfy.sh/config/#users-and-roles", "ntfy用户动作": "The user actions for ntfy's application, up to three actions, see https://docs.ntfy.sh/publish/?h=actions#action-buttons", "wxPusherBot的appToken": "wxPusherBot's appToken, obtain according to docs https://wxpusher.zjiecode.com/docs/", "wxPusherBot的topicIds": "wxPusherBot's topicIds, at least one of topicIds or uids must be configured", "wxPusherBot的uids": "wxPusherBot's uids, at least one of topicIds or uids must be configured", "请求方法": "Request Method", "请求头Content-Type": "Request Header Content-Type", "请求链接以http或者https开头。url或者body中必须包含$title,$content可选,对应api内容的位置": "Request URL should start with http or https. URL or body must contain $title, $content is optional and corresponds to the API content position.", "请求头格式Custom-Header1: Header1,多个换行分割": "Request header format: Custom-Header1: Header1 (separate multiple headers with line breaks)", "请求体格式key1: value1,多个换行分割。url或者body中必须包含$title,$content可选,对应api内容的位置": "Request body format: key1: value1 (separate multiple keys with line breaks). URL or body must contain $title, $content is optional and corresponds to the API content position.", "错误日志": "Error Log", "执行结束": "Execution Finished", "备份": "Backup", "生成数据中...": "Generating data...", "请选择日志文件": "Please select a log file", "筛选条件": "Filter Conditions", "系统": "System", "个人": "Personal", "重新安装": "Reinstall", "取消安装": "Cancel Install", "强制删除": "Force Delete", "全部任务": "All Tasks", "关联订阅": "Associate Subscription", "订阅": "Subscription", "创建": "Create", "同时删除关联任务和脚本": "Delete associated tasks and scripts as well", "未找到": "Not Found", "保存成功": "Saved successfully", "删除成功": "Deleted successfully", "批量删除成功": "Batch deleted successfully", "启动中...": "Starting...", "任务未运行": "Task not running", "更新任务成功": "Task updated successfully", "创建任务成功": "Task created successfully", "编辑任务": "Edit Task", "Cron表达式格式有误": "Incorrect Cron Expression Format", "添加Labels成功": "Labels added successfully", "删除Labels成功": "Labels deleted successfully", "编辑视图": "Edit View", "排序方式": "Sort Order", "开始时间": "Start Time", "安装": "Install", "结束时间": "End Time", "编辑依赖": "Edit Dependency", "更新环境变量名称成功": "Environment Variable Name updated successfully", "更新变量成功": "Variable updated successfully", "创建变量成功": "Variable created successfully", "编辑变量": "Edit Variable", "加载中...": "Loading...", "夹下所有日志": "Folder and All Subfiles", "创建文件夹成功": "Folder created successfully", "创建文件成功": "File created successfully", "夹及其子文件": "Folder and Its Subfiles", "更新名称成功": "Name updated successfully", "保存文件成功": "File saved successfully", "更新应用成功": "Application updated successfully", "创建应用成功": "Application created successfully", "编辑应用": "Edit Application", "检查更新中...": "Checking for updates...", "失败,请检查": "Failed, please check", "更新失败,请检查网络及日志或稍后再试": "Update failed, please check network and logs or try again later", "更新包下载成功": "Update package download successful", "重置secret": "Reset secret", "重置成功": "Reset successful", "通知发送成功": "Notification sent successfully", "通知关闭成功": "Notification closed successfully", "测试中...": "Testing...", "上传": "Upload", "下载": "Download", "更新成功": "Update successful", "激活成功": "Activation successful", "验证失败": "Validation failed", "更新订阅成功": "Subscription updated successfully", "创建订阅成功": "Subscription created successfully", "编辑订阅": "Edit Subscription", "Subscription表达式格式有误": "Incorrect Subscription Expression Format", "一对多推送的“群组编码”(一对多推送下面->您的群组(如无则新建)->群组编码,如果您是创建群组人。也需点击“查看二维码”扫描绑定,否则不能接受群组消息推送)": "The 'Group Code' for One-to-Many Push (Below One-to-Many Push->Your Group (if not, create one)->Group Code, if you are the creator of the group, you also need to click 'View QR Code' to scan and bind, otherwise you cannot receive group messages)", "登录已过期,请重新登录": "Login session has expired, please log in again", "系统日志": "System Logs", "主题": "Theme", "语言": "Language", "中...": "ing...", "请选择操作符": "Please select operator", "新增定时规则": "Add Timing Rules", "运行任务前执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "Run commands before executing the task, e.g., cp/mv/python3 xxx.py/node xxx.js", "运行任务后执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "Run commands after executing the task, e.g., cp/mv/python3 xxx.py/node xxx.js", "请输入运行任务前要执行的命令,不能包含 task 命令": "Please enter the command to run before executing the task, cannot contain task commands", "请输入运行任务后要执行的命令,不能包含 task 命令": "Please enter the command to run after executing the task, cannot contain task commands", "不能包含 task 命令": "Cannot contain task commands", "Chronocat Red 服务的连接地址 https://chronocat.vercel.app/install/docker/official/": "Connection address of the Chronocat Red service https://chronocat.vercel.app/install/docker/official/", "个人:user_id=个人QQ 群则填入group_id=QQ群 多个用英文;隔开同时支持个人和群 如:user_id=xxx;group_id=xxxx;group_id=xxxxx": "Individuals: user_id=individual QQ Groups fill in group_id=QQ Groups more than one with English; separated by the same time to support individuals and groups such as: user_id=xxx;group_id=xxxx;group_id=xxxxx", "docker安装在持久化config目录下的chronocat.yml文件可找到": "The docker installation can be found in the persistence config directory in the chronocat.yml file", "请选择": "Please select", "请输入": "Please input", "依赖设置": "Dependence Settings", "Node 软件包镜像源": "Node Software Package Mirror Source", "Python 软件包镜像源": "Python Software Package Mirror Source", "Linux 软件包镜像源": "Linux Software Package Mirror Source", "代理与镜像源二选一即可": "Either Proxy or Mirror Source can be chosen", "代理地址, 支持HTTP(S)/SOCK5": "Proxy Address, supports HTTP(S)/SOCK5", "NPM 镜像源": "NPM Mirror Source", "PyPI 镜像源": "PyPI Mirror Source", "alpine linux 镜像源": "Alpine Linux Mirror Source", "如果恢复失败,可进入容器执行": "If recovery fails, you can enter the container and execute", "常规定时": "Normal Timing", "手动运行": "Manual Run", "开机运行": "Boot Run", "时区": "Timezone", "强制打开": "Force Open", "强制打开可能会导致编辑器显示异常": "Force opening may cause display issues in the editor", "确认离开": "Confirm Leave", "当前文件未保存,确认离开吗": "Current file is not saved, are you sure to leave?", "收件邮箱地址,多个分号分隔,默认发送给发件邮箱地址": "Receiving email address, multiple semicolon separated, sent to the sending email address by default", "选择备份模块": "Select backup module", "开始备份": "Start backup", "基础数据": "Basic data", "脚本文件": "Script files", "日志文件": "Log files", "依赖缓存": "Dependency cache", "远程脚本缓存": "Remote script cache", "远程仓库缓存": "Remote repository cache", "SSH 文件缓存": "SSH file cache", "清除依赖缓存": "Clean dependency cache", "清除成功": "Clean successful", "日志名称": "Log Name", "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate", "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate. Supports absolute paths like /dev/null", "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内": "Custom log folder name to distinguish logs from different tasks. Leave blank to auto-generate. Supports /dev/null to discard logs, other absolute paths must be within log directory", "请输入自定义日志文件夹名称": "Please enter a custom log folder name", "请输入自定义日志文件夹名称或绝对路径": "Please enter a custom log folder name or absolute path", "请输入自定义日志文件夹名称或 /dev/null": "Please enter a custom log folder name or /dev/null", "日志名称只能包含字母、数字、下划线和连字符": "Log name can only contain letters, numbers, underscores and hyphens", "日志名称不能超过100个字符": "Log name cannot exceed 100 characters", "未启用": "Not enabled", "默认为 CPU 个数": "Default is the number of CPUs", "Minimum is 4": "Minimum is 4", "实例模式": "Instance Mode", "单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例": "Single instance mode: automatically stop old task before starting new scheduled task; Multi-instance mode: allow multiple task instances to run simultaneously", "请选择实例模式": "Please select instance mode", "单实例": "Single Instance", "多实例": "Multi-Instance", "SSH密钥": "SSH Keys", "别名": "Alias", "编辑SSH密钥": "Edit SSH Key", "创建SSH密钥": "Create SSH Key", "更新SSH密钥成功": "SSH key updated successfully", "创建SSH密钥成功": "SSH key created successfully", "请输入SSH密钥别名": "Please enter SSH key alias", "请输入SSH私钥": "Please enter SSH private key", "请输入SSH私钥内容(以 -----BEGIN 开头)": "Please enter SSH private key content (starts with -----BEGIN)", "确认删除SSH密钥": "Confirm to delete SSH key", "批量": "Batch", "全局SSH私钥": "Global SSH Private Key", "用于访问所有私有仓库的全局SSH私钥": "Global SSH private key for accessing all private repositories", "请输入完整的SSH私钥内容": "Please enter the complete SSH private key content" } ================================================ FILE: src/locales/zh-CN.json ================================================ { "复制成功": "复制成功", "复制": "复制", "新建": "新建", "登录": "登录", "初始化": "初始化", "错误": "错误", "定时任务": "定时任务", "订阅管理": "订阅管理", "环境变量": "环境变量", "配置文件": "配置文件", "脚本管理": "脚本管理", "依赖管理": "依赖管理", "日志管理": "日志管理", "对比工具": "对比工具", "系统设置": "系统设置", "退出登录": "退出登录", "青龙": "青龙", "返回首页": "返回首页", "保存": "保存", "日志": "日志", "脚本": "脚本", "确认保存文件": "确认保存文件", ",保存后不可恢复": ",保存后不可恢复", "确认运行": "确认运行", "确认运行定时任务": "确认运行定时任务", "吗": "吗", "确认停止": "确认停止", "确认停止定时任务": "确认停止定时任务", "确认": "确认", "任务": "任务", "状态": "状态", "空闲中": "空闲中", "运行中": "运行中", "队列中": "队列中", "已禁用": "已禁用", "定时": "定时", "最后运行时间": "最后运行时间", "最后运行时长": "最后运行时长", "下次运行时间": "下次运行时间", "名称": "名称", "命令/脚本": "命令/脚本", "定时规则": "定时规则", "操作": "操作", "确认删除": "确认删除", "确认删除定时任务": "确认删除定时任务", "编辑": "编辑", "删除": "删除", "确认删除选中的定时任务吗": "确认删除选中的定时任务吗", "选中的定时任务吗": "选中的定时任务吗", "创建视图": "创建视图", "视图管理": "视图管理", "请输入名称或者关键词": "请输入名称或者关键词", "创建任务": "创建任务", "更多": "更多", "批量删除": "批量删除", "批量启用": "批量启用", "批量禁用": "批量禁用", "批量运行": "批量运行", "批量停止": "批量停止", "批量置顶": "批量置顶", "批量取消置顶": "批量取消置顶", "批量修改标签": "批量修改标签", "已选择": "已选择", "项": "项", "知道了": "知道了", "请输入任务名称": "请输入任务名称", "支持输入脚本路径/任意系统可执行命令/task 脚本路径": "支持输入脚本路径/任意系统可执行命令/task 脚本路径", "秒(可选) 分 时 天 月 周": "秒(可选) 分 时 天 月 周", "标签": "标签", "取消": "取消", "添加": "添加", "启用": "启用", "禁用": "禁用", "运行": "运行", "停止": "停止", "置顶": "置顶", "取消置顶": "取消置顶", "命令": "命令", "包含": "包含", "不包含": "不包含", "属于": "属于", "不属于": "不属于", "顺序": "顺序", "倒序": "倒序", "且": "且", "或": "或", "输入后回车增加自定义选项": "输入后回车增加自定义选项", "视图名称": "视图名称", "请输入视图名称": "请输入视图名称", "请输入内容": "请输入内容", "新增筛选条件": "新增筛选条件", "新增排序方式": "新增排序方式", "类型": "类型", "显示": "显示", "确认删除视图": "确认删除视图", "安装中": "安装中", "已安装": "已安装", "安装失败": "安装失败", "删除中": "删除中", "已删除": "已删除", "删除失败": "删除失败", "已取消": "已取消", "序号": "序号", "备注": "备注", "更新时间": "更新时间", "创建时间": "创建时间", "确认删除依赖": "确认删除依赖", "确认重新安装": "确认重新安装", "确认取消安装": "确认取消安装", "确认删除选中的依赖吗": "确认删除选中的依赖吗", "确认重新安装选中的依赖吗": "确认重新安装选中的依赖吗", "请输入名称": "请输入名称", "创建依赖": "创建依赖", "批量安装": "批量安装", "批量强制删除": "批量强制删除", "日志 -": "日志 -", "依赖类型": "依赖类型", "自动拆分": "自动拆分", "多个依赖是否换行分割": "多个依赖是否换行分割", "是": "是", "否": "否", "请输入依赖名称,支持指定版本": "请输入依赖名称,支持指定版本", "请输入依赖名称": "请输入依赖名称", "请输入备注": "请输入备注", "源文件": "源文件", "当前文件": "当前文件", "修改环境变量名称": "修改环境变量名称", "请输入新的环境变量名称": "请输入新的环境变量名称", "已启用": "已启用", "值": "值", "确认删除变量": "确认删除变量", "确认删除选中的变量吗": "确认删除选中的变量吗", "选中的变量吗": "选中的变量吗", "请输入名称/值/备注": "请输入名称/值/备注", "导入": "导入", "创建变量": "创建变量", "批量修改变量名称": "批量修改变量名称", "批量导出": "批量导出", "请输入环境变量名称": "请输入环境变量名称", "只能输入字母数字下划线,且不能以数字开头": "只能输入字母数字下划线,且不能以数字开头", "请输入环境变量值": "请输入环境变量值", "服务启动超时": "服务启动超时", "请先按如下方式修复:": "请先按如下方式修复:", "1. 宿主机执行 docker run --rm -v\n /var/run/docker.sock:/var/run/docker.sock\n containrrr/watchtower -cR <容器名>": "1. 宿主机执行 docker run --rm -v\n /var/run/docker.sock:/var/run/docker.sock\n containrrr/watchtower -cR <容器名>", "2. 容器内执行 ql check、ql update": "2. 容器内执行 ql check、ql update", "3. 如果无法解决,容器内执行 pm2 logs,拷贝执行结果": "3. 如果无法解决,容器内执行 pm2 logs,拷贝执行结果", "提交 issue": "提交 issue", "启动中,请稍后...": "启动中,请稍后...", "欢迎使用": "欢迎使用", "欢迎使用青龙": "欢迎使用青龙", "支持python3、javascript、shell、typescript 的定时任务管理面板": "支持python3、javascript、shell、typescript 的定时任务管理面板", "开始安装": "开始安装", "账户设置": "账户设置", "用户名": "用户名", "密码": "密码", "密码不能为admin": "密码不能为admin", "确认密码": "确认密码", "您输入的两个密码不匹配!": "您输入的两个密码不匹配!", "提交": "提交", "通知设置": "通知设置", "通知方式": "通知方式", "请选择通知方式": "请选择通知方式", "跳过": "跳过", "完成安装": "完成安装", "恭喜安装完成!": "恭喜安装完成!", "Telegram频道": "Telegram频道", "去登录": "去登录", "初始化配置": "初始化配置", "文件": "文件", ",删除后不可恢复": ",删除后不可恢复", "请选择日志": "请选择日志", "请输入日志名": "请输入日志名", "暂无日志": "暂无日志", "登录成功!": "登录成功!", "上次登录时间:": "上次登录时间:", "上次登录地点:": "上次登录地点:", "上次登录IP:": "上次登录IP:", "上次登录设备:": "上次登录设备:", "上次登录状态:": "上次登录状态:", "验证码": "验证码", "验证码为6位数字": "验证码为6位数字", "6位数字": "6位数字", "验证": "验证", "请": "请", "秒后重试": "秒后重试", "在您的设备上打开两步验证应用程序以查看您的身份验证代码并验证您的身份。": "在您的设备上打开两步验证应用程序以查看您的身份验证代码并验证您的身份。", "请选择脚本文件": "请选择脚本文件", "当前文件不支持预览": "当前文件不支持预览", "清空日志": "清空日志", "设置": "设置", "退出": "退出", "空文件": "空文件", "本地文件": "本地文件", "文件夹": "文件夹", "文件名": "文件名", "请输入文件名": "请输入文件名", "文件名不能包含斜杠": "文件名不能包含斜杠", "文件夹名": "文件夹名", "请输入文件夹名": "请输入文件夹名", "父目录": "父目录", "请选择父目录": "请选择父目录", "点击或者拖拽文件到此区域上传": "点击或者拖拽文件到此区域上传", "当前修改未保存,确定离开吗": "当前修改未保存,确定离开吗", "退出编辑": "退出编辑", "重命名": "重命名", "请选择脚本": "请选择脚本", "调试": "调试", "请输入脚本名": "请输入脚本名", "暂无脚本": "暂无脚本", "请输入新名称": "请输入新名称", "保存文件": "保存文件", "保存目录": "保存目录", "请输入保存目录,默认scripts目录": "请输入保存目录,默认scripts目录", "运行设置": "运行设置", "待开发": "待开发", "开发版": "开发版", "正式版": "正式版", "版本": "版本", "更新日志": "更新日志", "查看": "查看", "提交BUG": "提交BUG", "名称不能为保留关键字": "名称不能为保留关键字", "请输入应用名称": "请输入应用名称", "权限": "权限", "请选择模块权限": "请选择模块权限", "更新": "更新", "已经是最新版了!": "已经是最新版了!", "是目前检测到的最新可用版本了。": "是目前检测到的最新可用版本了。", "重新下载": "重新下载", "更新可用": "更新可用", "新版本": "新版本", "可用,你使用的版本为": "可用,你使用的版本为", "下载更新": "下载更新", "以后再说": "以后再说", "下载更新中...": "下载更新中...", "确认重启": "确认重启", "系统安装包下载成功,确认重启": "系统安装包下载成功,确认重启", "重启": "重启", "系统将在": "系统将在", "秒后自动刷新": "秒后自动刷新", "检查更新": "检查更新", "重新启动": "重新启动", "确认删除应用": "确认删除应用", "确认重置": "确认重置", "确认重置应用": "确认重置应用", "的Secret吗": "的Secret吗", "重置Secret会让当前应用所有token失效": "重置Secret会让当前应用所有token失效", "创建应用": "创建应用", "安全设置": "安全设置", "应用设置": "应用设置", "登录日志": "登录日志", "其他设置": "其他设置", "关于": "关于", "成功": "成功", "失败": "失败", "登录时间": "登录时间", "登录地址": "登录地址", "登录IP": "登录IP", "登录设备": "登录设备", "登录状态": "登录状态", "亮色": "亮色", "暗色": "暗色", "跟随系统": "跟随系统", "备份数据上传成功,确认覆盖数据": "备份数据上传成功,确认覆盖数据", "主题设置": "主题设置", "日志删除频率": "日志删除频率", "每x天自动删除x天以前的日志": "每x天自动删除x天以前的日志", "每": "每", "天": "天", "定时任务并发数": "定时任务并发数", "数据备份还原": "数据备份还原", "还原数据": "还原数据", "第一步": "第一步", "下载两步验证手机应用,比如 Google Authenticator 、": "下载两步验证手机应用,比如 Google Authenticator 、", "第二步": "第二步", "使用手机应用扫描二维码,或者输入秘钥": "使用手机应用扫描二维码,或者输入秘钥", "第三步": "第三步", "输入手机应用上的6位数字": "输入手机应用上的6位数字", "完成设置": "完成设置", "修改用户名密码": "修改用户名密码", "两步验证": "两步验证", "头像": "头像", "更换头像": "更换头像", "时": "时", "分": "分", "秒": "秒", "私有仓库": "私有仓库", "公开仓库": "公开仓库", "单文件": "单文件", "链接": "链接", "分支": "分支", "确认删除定时订阅": "确认删除定时订阅", "定时订阅": "定时订阅", "创建订阅": "创建订阅", "私钥": "私钥", "请输入私钥": "请输入私钥", "请输入认证用户名": "请输入认证用户名", "Github已不支持密码认证,请使用Token方式": "Github已不支持密码认证,请使用Token方式", "密码/Token": "密码/Token", "请输入密码或者Token": "请输入密码或者Token", "支持拷贝 ql repo/raw 命令,粘贴导入": "支持拷贝 ql repo/raw 命令,粘贴导入", "请输入订阅链接": "请输入订阅链接", "请输入分支": "请输入分支", "唯一值": "唯一值", "唯一值用于日志目录和私钥别名": "唯一值用于日志目录和私钥别名", "自动生成": "自动生成", "拉取方式": "拉取方式", "用户名密码/Token": "用户名密码/Token", "定时类型": "定时类型", "白名单": "白名单", "多个关键词竖线分割,支持正则表达式": "多个关键词竖线分割,支持正则表达式", "请输入脚本筛选白名单关键词,多个关键词竖线分割": "请输入脚本筛选白名单关键词,多个关键词竖线分割", "黑名单": "黑名单", "请输入脚本筛选黑名单关键词,多个关键词竖线分割": "请输入脚本筛选黑名单关键词,多个关键词竖线分割", "依赖文件": "依赖文件", "请输入脚本依赖文件关键词,多个关键词竖线分割": "请输入脚本依赖文件关键词,多个关键词竖线分割", "文件后缀": "文件后缀", "仓库需要拉取的文件后缀,多个后缀空格分隔,默认使用配置文件中的RepoFileExtensions": "仓库需要拉取的文件后缀,多个后缀空格分隔,默认使用配置文件中的RepoFileExtensions", "请输入文件后缀": "请输入文件后缀", "执行前": "执行前", "运行订阅前执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "运行订阅前执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js", "请输入运行订阅前要执行的命令": "请输入运行订阅前要执行的命令", "执行后": "执行后", "运行订阅后执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "运行订阅后执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js", "请输入运行订阅后要执行的命令": "请输入运行订阅后要执行的命令", "代理": "代理", "公开仓库支持HTTP/SOCK5代理,私有仓库支持SOCK5代理": "公开仓库支持HTTP/SOCK5代理,私有仓库支持SOCK5代理", "自动添加任务": "自动添加任务", "自动删除任务": "自动删除任务", "中文": "中文", "系统信息": "系统信息", "Server酱": "Server酱", "Telegram机器人": "Telegram机器人", "钉钉机器人": "钉钉机器人", "企业微信机器人": "企业微信机器人", "企业微信应用": "企业微信应用", "智能微秘书": "智能微秘书", "群晖chat": "群晖chat", "微加机器人": "微加机器人", "邮箱": "邮箱", "飞书机器人": "飞书机器人", "自定义通知": "自定义通知", "已关闭": "已关闭", "gotify的url地址,例如 https://push.example.de:8080": "gotify的url地址,例如 https://push.example.de:8080", "gotify的消息应用token码": "gotify的消息应用token码", "推送消息的优先级": "推送消息的优先级", "synologyChat的url地址": "synologyChat的webhook url地址", "chat的token码": "chat的token码", "推送到个人QQ: http://127.0.0.1/send_private_msg,群:http://127.0.0.1/send_group_msg": "推送到个人QQ: http://127.0.0.1/send_private_msg,群:http://127.0.0.1/send_group_msg", "访问密钥": "访问密钥", "如果GOBOT_URL设置 /send_private_msg 则需要填入 user_id=个人QQ 相反如果是 /send_group_msg 则需要填入 group_id=QQ群": "如果GOBOT_URL设置 /send_private_msg 则需要填入 user_id=个人QQ 相反如果是 /send_group_msg 则需要填入 group_id=QQ群", "Server酱SENDKEY": "Server 酱 SENDKEY,https://sct.ftqq.com/r/13363", "PushDeer的Key,https://github.com/easychen/pushdeer": "PushDeer的Key,https://github.com/easychen/pushdeer", "PushDeer的自架API endpoint,默认是 https://api2.pushdeer.com/message/push": "PushDeer的自架API endpoint,默认是 https://api2.pushdeer.com/message/push", "Bark的信息IP/设备码,例如:https://api.day.app/XXXXXXXX": "Bark的信息IP/设备码,例如:https://api.day.app/XXXXXXXX", "BARK推送图标,自定义推送图标 (需iOS15或以上才能显示)": "BARK推送图标,自定义推送图标 (需iOS15或以上才能显示)", "BARK推送铃声,铃声列表去APP查看复制填写": "BARK推送铃声,铃声列表去APP查看复制填写", "BARK推送消息的分组,默认为qinglong": "BARK推送消息的分组,默认为qinglong", "BARK推送消息的时效性,默认为active": "BARK推送消息的时效性,默认为active", "BARK推送消息的跳转URL": "BARK推送消息的跳转URL", "BARK是否保存推送消息": "BARK是否保存推送消息", "telegram机器人的token,例如:1077xxx4424:AAFjv0FcqxxxxxxgEMGfi22B4yh15R5uw": "telegram机器人的token,例如:1077xxx4424:AAFjv0FcqxxxxxxgEMGfi22B4yh15R5uw", "telegram用户的id,例如:129xxx206": "telegram用户的id,例如:129xxx206", "代理IP": "代理IP", "代理端口": "代理端口", "telegram代理配置认证参数,用户名与密码用英文冒号连接 user:password": "telegram代理配置认证参数, 用户名与密码用英文冒号连接 user:password", "telegram api自建的反向代理地址,默认tg官方api": "telegram api自建的反向代理地址,默认tg官方api", "钉钉机器人webhook token,例如:5a544165465465645d0f31dca676e7bd07415asdasd": "钉钉机器人webhook token,例如:5a544165465465645d0f31dca676e7bd07415asdasd", "密钥,机器人安全设置页面,加签一栏下面显示的SEC开头的字符串": "密钥,机器人安全设置页面,加签一栏下面显示的SEC开头的字符串", "企业微信机器人的webhook(详见文档 https://work.weixin.qq.com/api/doc/90000/90136/91770),例如:693a91f6-7xxx-4bc4-97a0-0ec2sifa5aaa": "企业微信机器人的 webhook(详见文档 https://work.weixin.qq.com/api/doc/90000/90136/91770),例如:693a91f6-7xxx-4bc4-97a0-0ec2sifa5aaa", "企业微信代理地址": "企业微信代理地址", "corpid、corpsecret、touser(注:多个成员ID使用|隔开)、agentid、消息类型(选填,不填默认文本消息类型) 注意用,号隔开(英文输入法的逗号),例如:wwcfrs,B-76WERQ,qinglong,1000001,2COat": "corpid,corpsecret,touser(注:多个成员ID使用|隔开),agentid,消息类型(选填,不填默认文本消息类型) 注意用,号隔开(英文输入法的逗号),例如:wwcfrs,B-76WERQ,qinglong,1000001,2COat", "密钥key,智能微秘书个人中心获取apikey,申请地址:https://wechat.aibotk.com/signup?from=ql": "密钥key,智能微秘书个人中心获取apikey,申请地址:https://wechat.aibotk.com/signup?from=ql", "发送的目标,群组或者好友": "发送的目标,群组或者好友", "请输入要发送的目标": "请输入要发送的目标", "群聊": "群聊", "好友": "好友", "要发送的用户昵称或群名,如果目标是群,需要填群名,如果目标是好友,需要填好友昵称": "要发送的用户昵称或群名,如果目标是群,需要填群名,如果目标是好友,需要填好友昵称", "iGot的信息推送key,例如:https://push.hellyw.com/XXXXXXXX": "iGot的信息推送key,例如:https://push.hellyw.com/XXXXXXXX", "微信扫码登录后一对一推送或一对多推送下面的token(您的Token),不提供PUSH_PLUS_USER则默认为一对一推送,参考 https://www.pushplus.plus/": "微信扫码登录后一对一推送或一对多推送下面的token(你的Token),不提供PUSH_PLUS_USER则默认为一对一推送,参考 https://www.pushplus.plus/", "一对多推送的“群组编码”(一对多推送下面->您的群组(如无则创建)->群组编码,如果您是创建群组人。也需点击“查看二维码”扫描绑定,否则不能接受群组消息推送)": "一对多推送的“群组编码”(一对多推送下面->您的群组(如无则创建)->群组编码,如果您是创建群组人。也需点击“查看二维码”扫描绑定,否则不能接受群组消息推送)", "发送模板": "发送模板,支持html,txt,json,markdown,cloudMonitor,jenkins,route,pay", "发送渠道": "发送渠道,支持wechat,webhook,cp,mail,sms", "webhook编码": "webhook编码,可在pushplus公众号上扩展配置出更多渠道", "发送结果回调地址": "发送结果回调地址,会把推送最终结果通知到这个地址上", "好友令牌": "好友令牌,微信公众号渠道填写好友令牌,企业微信渠道填写企业微信用户id", "用户令牌,扫描登录后 我的—>设置->令牌 中获取,参考 https://www.weplusbot.com/": "用户令牌,扫描登录后 我的—>设置->令牌 中获取,参考 https://www.weplusbot.com/", "消息接收人": "消息接收人", "调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版": "调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版", "飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973": "飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973", "飞书群组机器人加签密钥,安全设置中开启签名校验后获得": "飞书群组机器人加签密钥,安全设置中开启签名校验后获得", "邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json": "邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json", "邮箱地址": "邮箱地址", "SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定": "SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定", "PushMe的Key,https://push.i-i.me/": "PushMe的Key,https://push.i-i.me/", "自建的PushMeServer消息接口地址,例如:http://127.0.0.1:3010,不填则使用官方消息接口": "自建的PushMeServer消息接口地址,例如:http://127.0.0.1:3010,不填则使用官方消息接口", "ntfy的url地址,例如 https://ntfy.sh": "ntfy的url地址,例如 https://ntfy.sh", "ntfy应用topic": "ntfy应用topic", "ntfy应用token": "ntfy应用token,参考 https://docs.ntfy.sh/config/#access-tokens", "ntfy应用用户名": "ntfy应用用户名,参考 https://docs.ntfy.sh/config/#users-and-roles", "ntfy应用密码": "ntfy应用密码,参考 https://docs.ntfy.sh/config/#users-and-roles", "ntfy用户动作": "ntfy用户动作,最多三个动作,参考 https://docs.ntfy.sh/publish/?h=actions#action-buttons", "wxPusherBot的appToken": "wxPusherBot的appToken, 按照文档获取 https://wxpusher.zjiecode.com/docs/", "wxPusherBot的topicIds": "wxPusherBot的topicIds, topicIds 和 uids 至少配置一个才行", "wxPusherBot的uids": "wxPusherBot的uids, topicIds 和 uids 至少配置一个才行", "请求方法": "请求方法", "请求头Content-Type": "请求头Content-Type", "请求链接以http或者https开头。url或者body中必须包含$title,$content可选,对应api内容的位置": "请求链接以http或者https开头。url或者body中必须包含$title,$content可选,对应api内容的位置", "请求头格式Custom-Header1: Header1,多个换行分割": "请求头格式Custom-Header1: Header1,多个换行分割", "请求体格式key1: value1,多个换行分割。url或者body中必须包含$title,$content可选,对应api内容的位置": "请求体格式key1: value1,多个换行分割。url或者body中必须包含$title,$content可选,对应api内容的位置", "错误日志": "错误日志", "执行结束": "执行结束", "备份": "备份", "生成数据中...": "生成数据中...", "请选择日志文件": "请选择日志文件", "筛选条件": "筛选条件", "系统": "系统", "个人": "个人", "重新安装": "重新安装", "取消安装": "取消安装", "强制删除": "强制删除", "全部任务": "全部任务", "关联订阅": "关联订阅", "订阅": "订阅", "创建": "创建", "同时删除关联任务和脚本": "同时删除关联任务和脚本", "未找到": "未找到", "保存成功": "保存成功", "删除成功": "删除成功", "批量删除成功": "批量删除成功", "启动中...": "启动中...", "任务未运行": "任务未运行", "更新任务成功": "更新任务成功", "创建任务成功": "创建任务成功", "编辑任务": "编辑任务", "Cron表达式格式有误": "Cron表达式格式有误", "添加Labels成功": "添加Labels成功", "删除Labels成功": "删除Labels成功", "编辑视图": "编辑视图", "排序方式": "排序方式", "开始时间": "开始时间", "安装": "安装", "结束时间": "结束时间", "编辑依赖": "编辑依赖", "更新环境变量名称成功": "更新环境变量名称成功", "更新变量成功": "更新变量成功", "创建变量成功": "创建变量成功", "编辑变量": "编辑变量", "加载中...": "加载中...", "夹下所有日志": "夹下所有日志", "创建文件夹成功": "创建文件夹成功", "创建文件成功": "创建文件成功", "夹及其子文件": "夹及其子文件", "更新名称成功": "更新名称成功", "保存文件成功": "保存文件成功", "更新应用成功": "更新应用成功", "创建应用成功": "创建应用成功", "编辑应用": "编辑应用", "检查更新中...": "检查更新中...", "失败,请检查": "失败,请检查", "更新失败,请检查网络及日志或稍后再试": "更新失败,请检查网络及日志或稍后再试", "更新包下载成功": "更新包下载成功", "重置secret": "重置secret", "重置成功": "重置成功", "通知发送成功": "通知发送成功", "通知关闭成功": "通知关闭成功", "测试中...": "测试中...", "上传": "上传", "下载": "下载", "更新成功": "更新成功", "激活成功": "激活成功", "验证失败": "验证失败", "更新订阅成功": "更新订阅成功", "创建订阅成功": "创建订阅成功", "编辑订阅": "编辑订阅", "Subscription表达式格式有误": "Subscription表达式格式有误", "一对多推送的“群组编码”(一对多推送下面->您的群组(如无则新建)->群组编码,如果您是创建群组人。也需点击“查看二维码”扫描绑定,否则不能接受群组消息推送)": "一对多推送的“群组编码”(一对多推送下面->您的群组(如无则新建)->群组编码,如果您是创建群组人。也需点击“查看二维码”扫描绑定,否则不能接受群组消息推送)", "登录已过期,请重新登录": "登录已过期,请重新登录", "系统日志": "系统日志", "主题": "主题", "语言": "语言", "中...": "中...", "请选择操作符": "请选择操作符", "新增定时规则": "新增定时规则", "运行任务前执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "运行任务前执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js", "运行任务后执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js": "运行任务后执行的命令,比如 cp/mv/python3 xxx.py/node xxx.js", "请输入运行任务前要执行的命令,不能包含 task 命令": "请输入运行任务前要执行的命令,不能包含 task 命令", "请输入运行任务后要执行的命令,不能包含 task 命令": "请输入运行任务后要执行的命令,不能包含 task 命令", "不能包含 task 命令": "不能包含 task 命令", "Chronocat Red 服务的连接地址 https://chronocat.vercel.app/install/docker/official/": "Chronocat Red 服务的连接地址 https://chronocat.vercel.app/install/docker/official/", "个人:user_id=个人QQ 群则填入group_id=QQ群 多个用英文;隔开同时支持个人和群 如:user_id=xxx;group_id=xxxx;group_id=xxxxx": "个人:user_id=个人QQ 群则填入group_id=QQ群 多个用英文;隔开同时支持个人和群 如:user_id=xxx;group_id=xxxx;group_id=xxxxx", "docker安装在持久化config目录下的chronocat.yml文件可找到": "docker安装在持久化config目录下的chronocat.yml文件可找到", "请选择": "请选择", "请输入": "请输入", "依赖设置": "依赖设置", "Node 软件包镜像源": "Node 软件包镜像源", "Python 软件包镜像源": "Python 软件包镜像源", "Linux 软件包镜像源": "Linux 软件包镜像源", "代理与镜像源二选一即可": "代理与镜像源二选一即可", "代理地址, 支持HTTP(S)/SOCK5": "代理地址, 支持HTTP(S)/SOCK5", "NPM 镜像源": "NPM 镜像源", "PyPI 镜像源": "PyPI 镜像源", "alpine linux 镜像源": "alpine linux 镜像源", "如果恢复失败,可进入容器执行": "如果恢复失败,可进入容器执行", "常规定时": "常规定时", "手动运行": "手动运行", "开机运行": "开机运行", "时区": "时区", "强制打开": "强制打开", "强制打开可能会导致编辑器显示异常": "强制打开可能会导致编辑器显示异常", "确认离开": "确认离开", "当前文件未保存,确认离开吗": "当前文件未保存,确认离开吗", "收件邮箱地址,多个分号分隔,默认发送给发件邮箱地址": "收件邮箱地址,多个分号分隔,默认发送给发件邮箱地址", "选择备份模块": "选择备份模块", "开始备份": "开始备份", "基础数据": "基础数据", "脚本文件": "脚本文件", "日志文件": "日志文件", "依赖缓存": "依赖缓存", "远程脚本缓存": "远程脚本缓存", "远程仓库缓存": "远程仓库缓存", "SSH 文件缓存": "SSH 文件缓存", "清除依赖缓存": "清除依赖缓存", "清除成功": "清除成功", "日志名称": "日志名称", "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成", "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持绝对路径如 /dev/null", "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内": "自定义日志文件夹名称,用于区分不同任务的日志,留空则自动生成。支持 /dev/null 丢弃日志,其他绝对路径必须在日志目录内", "请输入自定义日志文件夹名称": "请输入自定义日志文件夹名称", "请输入自定义日志文件夹名称或绝对路径": "请输入自定义日志文件夹名称或绝对路径", "请输入自定义日志文件夹名称或 /dev/null": "请输入自定义日志文件夹名称或 /dev/null", "日志名称只能包含字母、数字、下划线和连字符": "日志名称只能包含字母、数字、下划线和连字符", "日志名称不能超过100个字符": "日志名称不能超过100个字符", "未启用": "未启用", "默认为 CPU 个数": "默认为 CPU 个数", "最小是 4": "最小是 4", "实例模式": "实例模式", "单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例": "单实例模式:定时启动新任务前会自动停止旧任务;多实例模式:允许同时运行多个任务实例", "请选择实例模式": "请选择实例模式", "单实例": "单实例", "多实例": "多实例", "SSH密钥": "SSH密钥", "别名": "别名", "编辑SSH密钥": "编辑SSH密钥", "创建SSH密钥": "创建SSH密钥", "更新SSH密钥成功": "更新SSH密钥成功", "创建SSH密钥成功": "创建SSH密钥成功", "请输入SSH密钥别名": "请输入SSH密钥别名", "请输入SSH私钥": "请输入SSH私钥", "请输入SSH私钥内容(以 -----BEGIN 开头)": "请输入SSH私钥内容(以 -----BEGIN 开头)", "确认删除SSH密钥": "确认删除SSH密钥", "批量": "批量", "全局SSH私钥": "全局SSH私钥", "用于访问所有私有仓库的全局SSH私钥": "用于访问所有私有仓库的全局SSH私钥", "请输入完整的SSH私钥内容": "请输入完整的SSH私钥内容" } ================================================ FILE: src/pages/404.tsx ================================================ import intl from 'react-intl-universal'; import React from 'react'; import { Button, Result, Typography } from 'antd'; const { Link } = Typography; const NotFound: React.FC = () => ( {intl.get('返回首页')} } /> ); export default NotFound; ================================================ FILE: src/pages/config/index.less ================================================ ================================================ FILE: src/pages/config/index.tsx ================================================ import intl from 'react-intl-universal'; import React, { PureComponent, Fragment, useState, useEffect, useRef, } from 'react'; import { Button, message, Modal, TreeSelect } from 'antd'; import config from '@/utils/config'; import { PageContainer } from '@ant-design/pro-layout'; import { request } from '@/utils/http'; import Editor from '@monaco-editor/react'; import CodeMirror from '@uiw/react-codemirror'; import { useOutletContext } from '@umijs/max'; import { SharedContext } from '@/layouts'; import { langs } from '@uiw/codemirror-extensions-langs'; import { useHotkeys } from 'react-hotkeys-hook'; import { getEditorMode } from '@/utils'; const Config = () => { const { headerStyle, isPhone, theme } = useOutletContext(); const [value, setValue] = useState(''); const [loading, setLoading] = useState(true); const [title, setTitle] = useState('config.sh'); const [select, setSelect] = useState('config.sh'); const [data, setData] = useState([]); const editorRef = useRef(null); const [confirmLoading, setConfirmLoading] = useState(false); const [language, setLanguage] = useState('shell'); const getConfig = (name: string) => { request .get(`${config.apiPrefix}configs/detail?path=${encodeURIComponent(name)}`) .then(({ code, data }) => { if (code === 200) { setValue(data); } }); }; const getFiles = () => { setLoading(true); request .get(`${config.apiPrefix}configs/files`) .then(({ code, data }) => { if (code === 200) { setData(data); } }) .finally(() => setLoading(false)); }; const updateConfig = () => { setConfirmLoading(true); const content = editorRef.current ? editorRef.current.getValue().replace(/\r\n/g, '\n') : value; request .post(`${config.apiPrefix}configs/save`, { content, name: select }) .then(({ code, data }) => { if (code === 200) { message.success(intl.get('保存成功')); } setConfirmLoading(false); }); }; const onSelect = (value: any, node: any) => { setSelect(value); setTitle(node.value); getConfig(node.value); const newMode = getEditorMode(value); setLanguage(newMode); }; useHotkeys( 'mod+s', (e) => { updateConfig(); }, { enableOnFormTags: ['textarea'], preventDefault: true }, ); useEffect(() => { getFiles(); getConfig('config.sh'); }, []); return ( , , ]} header={{ style: headerStyle, }} > {isPhone ? ( { setValue(value); }} /> ) : ( { editorRef.current = editor; }} /> )} ); }; export default Config; ================================================ FILE: src/pages/crontab/const.ts ================================================ import { ScheduleType } from './type'; export const scheduleTypeMap = { [ScheduleType.Normal]: '', [ScheduleType.Once]: '@once', [ScheduleType.Boot]: '@boot', }; export const getScheduleType = (schedule?: string): ScheduleType => { if (schedule?.startsWith('@once')) return ScheduleType.Once; if (schedule?.startsWith('@boot')) return ScheduleType.Boot; return ScheduleType.Normal; }; ================================================ FILE: src/pages/crontab/detail.tsx ================================================ import intl from 'react-intl-universal'; import React, { useEffect, useRef, useState } from 'react'; import { Modal, message, Input, Form, Button, Card, Tag, List, Divider, Typography, Tooltip, } from 'antd'; import { ClockCircleOutlined, CloseCircleOutlined, FieldTimeOutlined, Loading3QuartersOutlined, FileOutlined, PlayCircleOutlined, PauseCircleOutlined, FullscreenOutlined, } from '@ant-design/icons'; import { CrontabStatus } from './type'; import { diffTime } from '@/utils/date'; import { request } from '@/utils/http'; import config from '@/utils/config'; import CronLogModal from './logModal'; import Editor from '@monaco-editor/react'; import IconFont from '@/components/iconfont'; import { getCommandScript, getEditorMode } from '@/utils'; import VirtualList from 'rc-virtual-list'; import useScrollHeight from '@/hooks/useScrollHeight'; import dayjs from 'dayjs'; const { Text } = Typography; const tabList = [ { key: 'log', tab: intl.get('日志'), }, { key: 'script', tab: intl.get('脚本'), }, ]; interface LogItem { directory: string; filename: string; } const CronDetailModal = ({ cron = {}, handleCancel, theme, isPhone, }: { cron?: any; handleCancel: (needUpdate?: boolean) => void; theme: string; isPhone: boolean; }) => { const [activeTabKey, setActiveTabKey] = useState('log'); const [loading, setLoading] = useState(true); const [logs, setLogs] = useState([]); const [log, setLog] = useState(''); const [value, setValue] = useState(''); const [isLogModalVisible, setIsLogModalVisible] = useState(false); const editorRef = useRef(null); const [scriptInfo, setScriptInfo] = useState({}); const [logUrl, setLogUrl] = useState(''); const [validTabs, setValidTabs] = useState(tabList); const [currentCron, setCurrentCron] = useState({}); const listRef = useRef(null); const tableScrollHeight = useScrollHeight(listRef); const contentList: any = { log: (
{(item) => ( onClickItem(item)}> {item.directory}/{item.filename} )}
), script: scriptInfo.filename && ( { editorRef.current = editor; }} /> ), }; const onClickItem = (item: LogItem) => { const url = `${config.apiPrefix}logs/detail?file=${item.filename}&path=${ item.directory || '' }`; localStorage.setItem('logCron', url); setLogUrl(url); request.get(url).then(({ code, data }) => { if (code === 200) { setLog(data); setIsLogModalVisible(true); } }); }; const onTabChange = (key: string) => { setActiveTabKey(key); }; const getLogs = () => { setLoading(true); request .get(`${config.apiPrefix}crons/${cron.id}/logs`) .then(({ code, data }) => { if (code === 200) { setLogs(data); } }) .finally(() => setLoading(false)); }; const getScript = () => { const result = getCommandScript(cron.command); if (Array.isArray(result)) { setValidTabs(validTabs); const [s, p] = result; setScriptInfo({ parent: p, filename: s }); request .get(`${config.apiPrefix}scripts/detail?file=${s}&path=${p || ''}`) .then(({ code, data }) => { if (code === 200) { setValue(data); } }); } else { setValidTabs([validTabs[0]]); setActiveTabKey('log'); } }; const saveFile = () => { Modal.confirm({ title: `确认保存`, content: ( <> {intl.get('确认保存文件')} {' '} {scriptInfo.filename} {intl.get(',保存后不可恢复')} ), onOk() { const content = editorRef.current ? editorRef.current.getValue().replace(/\r\n/g, '\n') : value; return new Promise((resolve, reject) => { request .put(`${config.apiPrefix}scripts`, { filename: scriptInfo.filename, path: scriptInfo.parent || '', content, }) .then(({ code, data }) => { if (code === 200) { setValue(content); message.success(`保存成功`); } resolve(null); }) .catch((e) => reject(e)); }); }, }); }; const runCron = () => { Modal.confirm({ title: intl.get('确认运行'), content: ( <> {intl.get('确认运行定时任务')}{' '} {currentCron.name} {' '} {intl.get('吗')} ), onOk() { request .put(`${config.apiPrefix}crons/run`, [currentCron.id]) .then(({ code, data }) => { if (code === 200) { setCurrentCron({ ...currentCron, status: CrontabStatus.running }); setTimeout(() => { getLogs(); }, 1000); } }); }, }); }; const stopCron = () => { Modal.confirm({ title: intl.get('确认停止'), content: ( <> {intl.get('确认停止定时任务')}{' '} {currentCron.name} {' '} {intl.get('吗')} ), onOk() { request .put(`${config.apiPrefix}crons/stop`, [currentCron.id]) .then(({ code, data }) => { if (code === 200) { setCurrentCron({ ...currentCron, status: CrontabStatus.idle }); } }); }, }); }; const enabledOrDisabledCron = () => { Modal.confirm({ title: `确认${ currentCron.isDisabled === 1 ? intl.get('启用') : intl.get('禁用') }`, content: ( <> {intl.get('确认')} {currentCron.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')} {intl.get('定时任务')}{' '} {currentCron.name} {' '} {intl.get('吗')} ), onOk() { request .put( `${config.apiPrefix}crons/${ currentCron.isDisabled === 1 ? 'enable' : 'disable' }`, [currentCron.id], ) .then(({ code, data }) => { if (code === 200) { setCurrentCron({ ...currentCron, isDisabled: currentCron.isDisabled === 1 ? 0 : 1, }); } }); }, }); }; const pinOrUnPinCron = () => { Modal.confirm({ title: `确认${ currentCron.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶') }`, content: ( <> {intl.get('确认')} {currentCron.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')} {intl.get('定时任务')}{' '} {currentCron.name} {' '} {intl.get('吗')} ), onOk() { request .put( `${config.apiPrefix}crons/${ currentCron.isPinned === 1 ? 'unpin' : 'pin' }`, [currentCron.id], ) .then(({ code, data }) => { if (code === 200) { setCurrentCron({ ...currentCron, isPinned: currentCron.isPinned === 1 ? 0 : 1, }); } }); }, }); }; const fullscreen = () => { const editorElement = editorRef.current._domElement as HTMLElement; editorElement.parentElement?.requestFullscreen(); }; useEffect(() => { if (cron && cron.id) { setCurrentCron(cron); getLogs(); getScript(); } }, [cron]); return (
{currentCron.name} {currentCron.labels?.length > 0 && currentCron.labels[0] !== '' && ( )} {currentCron.labels?.length > 0 && currentCron.labels[0] !== '' && currentCron.labels?.map((label: string, i: number) => ( {label} ))}
} centered open={true} forceRender footer={false} onCancel={() => handleCancel()} wrapClassName="crontab-detail" width={!isPhone ? '80vw' : ''} >
{intl.get('任务')}
{currentCron.command}
{intl.get('状态')}
{(!currentCron.isDisabled || currentCron.status !== CrontabStatus.idle) && ( <> {currentCron.status === CrontabStatus.idle && ( } color="default"> {intl.get('空闲中')} )} {currentCron.status === CrontabStatus.running && ( } color="processing" > {intl.get('运行中')} )} {currentCron.status === CrontabStatus.queued && ( } color="default"> {intl.get('队列中')} )} )} {currentCron.isDisabled === 1 && currentCron.status === CrontabStatus.idle && ( } color="error"> {intl.get('已禁用')} )}
{intl.get('定时')}
{currentCron.schedule}
{currentCron.extra_schedules?.map((x) => (
{x.schedule}
))}
{intl.get('最后运行时间')}
{currentCron.last_execution_time ? dayjs(currentCron.last_execution_time * 1000).format( 'YYYY-MM-DD HH:mm:ss', ) : '-'}
{intl.get('最后运行时长')}
{currentCron.last_running_time ? diffTime(currentCron.last_running_time) : '-'}
{intl.get('下次运行时间')}
{currentCron.nextRunTime && dayjs(currentCron.nextRunTime).format('YYYY-MM-DD HH:mm:ss')}
{ onTabChange(key); }} tabBarExtraContent={ activeTabKey === 'script' && ( <>
{isLogModalVisible && ( { setIsLogModalVisible(false); }} cron={cron} data={log} logUrl={logUrl} /> )}
); }; export default CronDetailModal; ================================================ FILE: src/pages/crontab/index.less ================================================ .ant-table-pagination.ant-pagination { margin-bottom: 0 !important; } .crontab-detail { .card-wrapper { .ant-card:last-child { .ant-card-body { min-height: 0; height: calc(80vh - 314px); height: calc(80vh - var(--vh-offset, 0px) - 314px); overflow-y: auto; > div { height: 100%; } } } } .ant-modal-body { background: #eee; padding: 12px; max-height: calc(80vh - 57px); max-height: calc(80vh - var(--vh-offset, 57px)); word-wrap: unset; } .ant-card-body { padding: 18px; } .ant-card-head { padding: 0 18px; } .ant-card:first-child { max-height: 66px; overflow: auto; .ant-card-body { min-width: 1000px; } .cron-detail-info-item { display: flex; .cron-detail-info-title { width: 50px; } .cron-detail-info-value { flex: 1; margin-top: 0; } } } .ant-card:nth-child(2) { overflow-x: auto; .ant-card-body { display: flex; justify-content: space-between; min-width: 1000px; } } .cron-detail-info-item { flex: auto; .cron-detail-info-title { color: #888; } .cron-detail-info-value { margin-top: 12px; } } .crontab-title-wrapper { display: flex; align-items: center; justify-content: space-between; gap: 24px; .operations { display: flex; align-items: center; .ant-btn:not(:first-child) { margin-left: 8px; } } } } .log-item { cursor: pointer; &:hover { background: #f2f2f2; } } .crontab-view { .ant-tabs-nav-wrap { flex: unset !important; } .ant-tabs-nav-operations { position: absolute; visibility: hidden; pointer-events: none; } .view-more { margin-left: 32px; padding: 8px 0; cursor: pointer; .ant-tabs-ink-bar { width: 0; } &:hover, &:focus-visible { color: #1890ff; .ant-tabs-ink-bar { width: 50px; } } &.active { color: #1890ff; .ant-tabs-ink-bar { width: 50px; } } } &.more-active { .ant-tabs-nav-list { .ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn { color: unset; } .ant-tabs-ink-bar { width: 0 !important; } } } } .view-create-modal-filters { display: flex; .ant-space-item:nth-child(3) { flex: 1; } } .view-create-modal-sorts { display: flex; .ant-space-item:nth-child(1) { flex: 1; } } tr.drop-over-downward td { border-bottom: 2px dashed #1890ff; } tr.drop-over-upward td { border-top: 2px dashed #1890ff; } .view-manage-modal { .ant-modal-body { padding-top: 10px; } } .view-filters-container.active { .filter-item > div > .ant-form-item-control { margin-left: 40px; width: calc(100% - 40px); } } body[data-mode='desktop'] { .crontab-wrapper { tbody .ant-table-cell { height: 69px !important; } } } .cron.pinned-cron > td { background: #f2f2f2; } ================================================ FILE: src/pages/crontab/index.tsx ================================================ import useTableScrollHeight from '@/hooks/useTableScrollHeight'; import { SharedContext } from '@/layouts'; import { getCommandScript, getCrontabsNextDate } from '@/utils'; import config from '@/utils/config'; import { diffTime } from '@/utils/date'; import { request } from '@/utils/http'; import { CheckCircleOutlined, CheckOutlined, ClockCircleOutlined, CloseCircleOutlined, CopyOutlined, DeleteOutlined, DownOutlined, EditOutlined, EllipsisOutlined, FieldTimeOutlined, Loading3QuartersOutlined, PlusOutlined, PushpinOutlined, SettingOutlined, StopOutlined, UnorderedListOutlined, } from '@ant-design/icons'; import { PageContainer } from '@ant-design/pro-layout'; import { history, useOutletContext } from '@umijs/max'; import { Button, Dropdown, Input, MenuProps, message, Modal, Space, Table, TablePaginationConfig, Tabs, Tag, Typography, } from 'antd'; import { ColumnProps } from 'antd/lib/table'; import { FilterValue, SorterResult } from 'antd/lib/table/interface'; import dayjs from 'dayjs'; import { noop, omit } from 'lodash'; import React, { useEffect, useRef, useState } from 'react'; import intl from 'react-intl-universal'; import { useVT } from 'virtualizedtableforantd4'; import { getScheduleType } from './const'; import CronDetailModal from './detail'; import './index.less'; import CronLogModal from './logModal'; import CronModal, { CronLabelModal } from './modal'; import { CrontabStatus, ICrontab, OperationName, OperationPath, ScheduleType, } from './type'; import ViewCreateModal from './viewCreateModal'; import ViewManageModal from './viewManageModal'; const { Text, Paragraph, Link } = Typography; const { Search } = Input; const SHOW_TAB_COUNT = 10; const Crontab = () => { const { headerStyle, isPhone, theme } = useOutletContext(); const [allSubscriptions, setAllSubscriptions] = useState([]); const columns: ColumnProps[] = [ { title: intl.get('名称'), dataIndex: 'name', key: 'name', fixed: 'left', width: 120, render: (text: string, record: any) => ( { setDetailCron(record); setIsDetailModalVisible(true); }} > {record.name || '-'} ), sorter: { compare: (a, b) => a?.name?.localeCompare(b?.name), }, }, { title: intl.get('命令/脚本'), dataIndex: 'command', key: 'command', width: 240, render: (text, record) => { return ( { goToScriptManager(record); }} > {text} ); }, sorter: { compare: (a: any, b: any) => a.command.localeCompare(b.command), }, }, { title: intl.get('状态'), key: 'status', dataIndex: 'status', width: 100, filters: [ { text: intl.get('运行中'), value: CrontabStatus.running, }, { text: intl.get('空闲中'), value: CrontabStatus.idle, }, { text: intl.get('已禁用'), value: CrontabStatus.disabled, }, { text: intl.get('队列中'), value: CrontabStatus.queued, }, ], render: (text, record) => ( <> {(!record.isDisabled || record.status !== CrontabStatus.idle) && ( <> {record.status === CrontabStatus.idle && ( } color="default"> {intl.get('空闲中')} )} {record.status === CrontabStatus.running && ( } color="processing" > {intl.get('运行中')} )} {record.status === CrontabStatus.queued && ( } color="default"> {intl.get('队列中')} )} )} {record.isDisabled === 1 && record.status === CrontabStatus.idle && ( } color="error"> {intl.get('已禁用')} )} ), }, { title: intl.get('定时规则'), dataIndex: 'schedule', key: 'schedule', width: 150, sorter: { compare: (a, b) => a.schedule.localeCompare(b.schedule), }, render: (text, record) => { return (
{text}
{record.extra_schedules?.map((x) => (
{x.schedule}
))} ), }, rows: 2, }} > {text}
); }, }, { title: intl.get('最后运行时长'), width: 167, dataIndex: 'last_running_time', key: 'last_running_time', sorter: { compare: (a: any, b: any) => { return a.last_running_time - b.last_running_time; }, }, render: (text, record) => { return record.last_running_time ? diffTime(record.last_running_time) : '-'; }, }, { title: intl.get('最后运行时间'), dataIndex: 'last_execution_time', key: 'last_execution_time', width: 141, sorter: { compare: (a, b) => { return (a.last_execution_time || 0) - (b.last_execution_time || 0); }, }, render: (text, record) => { return ( {record.last_execution_time ? dayjs(record.last_execution_time * 1000).format( 'YYYY-MM-DD HH:mm:ss', ) : '-'} ); }, }, { title: intl.get('下次运行时间'), width: 144, sorter: { compare: (a: any, b: any) => { return a.nextRunTime - b.nextRunTime; }, }, render: (text, record) => { return record.nextRunTime ? dayjs(record.nextRunTime).format('YYYY-MM-DD HH:mm:ss') : '-'; }, }, { title: intl.get('关联订阅'), width: 185, render: (text, record: any) => record?.subscription?.name || '-', key: 'sub_id', dataIndex: 'sub_id', filters: allSubscriptions.map((sub) => ({ text: sub.name || sub.alias, value: sub.id, })), }, { title: intl.get('操作'), key: 'action', width: 140, fixed: isPhone ? undefined : 'right', render: (text, record, index) => { const isPc = !isPhone; return ( {record.status === CrontabStatus.idle && ( { e.stopPropagation(); runCron(record, index); }} > {intl.get('运行')} )} {record.status !== CrontabStatus.idle && ( { e.stopPropagation(); stopCron(record, index); }} > {intl.get('停止')} )} { e.stopPropagation(); setLogCron({ ...record, timestamp: Date.now() }); }} > {intl.get('日志')} ); }, }, ]; const [value, setValue] = useState([]); const [loading, setLoading] = useState(true); const [isModalVisible, setIsModalVisible] = useState(false); const [isLabelModalVisible, setIsLabelModalVisible] = useState(false); const [editedCron, setEditedCron] = useState(); const [searchText, setSearchText] = useState(''); const [isLogModalVisible, setIsLogModalVisible] = useState(false); const [logCron, setLogCron] = useState(); const [selectedRowIds, setSelectedRowIds] = useState([]); const [pageConf, setPageConf] = useState<{ page: number; size: number; sorter: any; filters: any; }>({} as any); const [viewConf, setViewConf] = useState(); const [isDetailModalVisible, setIsDetailModalVisible] = useState(false); const [detailCron, setDetailCron] = useState(); const [searchValue, setSearchValue] = useState(''); const [total, setTotal] = useState(); const [isCreateViewModalVisible, setIsCreateViewModalVisible] = useState(false); const [isViewManageModalVisible, setIsViewManageModalVisible] = useState(false); const [cronViews, setCronViews] = useState([]); const [enabledCronViews, setEnabledCronViews] = useState([]); const [moreMenuActive, setMoreMenuActive] = useState(false); const tableRef = useRef(null); const tableScrollHeight = useTableScrollHeight(tableRef); const [activeKey, setActiveKey] = useState(''); const goToScriptManager = (record: any) => { const result = getCommandScript(record.command); if (Array.isArray(result)) { const [s, p] = result; history.push(`/script?p=${p}&s=${s}`); } else if (result) { location.href = result; } }; const getCrons = () => { setLoading(true); const { page, size, sorter, filters } = pageConf; let url = `${config.apiPrefix }crons?searchValue=${searchText}&page=${page}&size=${size}&filters=${JSON.stringify( filters, )}`; if (sorter && sorter.column && sorter.order) { url += `&sorter=${JSON.stringify({ field: sorter.column.key, type: sorter.order === 'ascend' ? 'ASC' : 'DESC', })}`; } if (viewConf) { url += `&queryString=${JSON.stringify({ filters: viewConf.filters, sorts: viewConf.sorts, filterRelation: viewConf.filterRelation || 'and', })}`; } request .get(url) .then(async ({ code, data: _data }) => { if (code === 200) { const { data, total } = _data; const subscriptions = await request.get( `${config.apiPrefix}subscriptions?ids=${JSON.stringify([ ...new Set(data.map((x) => x.sub_id).filter(Boolean)), ])}`, { onError: noop, }, ); const subscriptionMap = Object.fromEntries( subscriptions?.data?.map((x) => [x.id, x]), ); setValue( data.map((x) => { const scheduleType = getScheduleType(x.schedule); const nextRunTime = scheduleType === ScheduleType.Normal ? getCrontabsNextDate(x.schedule, x.extra_schedules) : null; return { ...x, nextRunTime, subscription: subscriptionMap?.[x.sub_id], }; }), ); setTotal(total); } }) .finally(() => setLoading(false)); }; const addCron = () => { setEditedCron(null as any); setIsModalVisible(true); }; const editCron = (record: any, index: number) => { setEditedCron(record); setIsModalVisible(true); }; const delCron = (record: any, index: number) => { Modal.confirm({ title: intl.get('确认删除'), content: ( <> {intl.get('确认删除定时任务')}{' '} {record.name} {' '} {intl.get('吗')} ), onOk() { request .delete(`${config.apiPrefix}crons`, { data: [record.id] }) .then(({ code, data }) => { if (code === 200) { message.success(intl.get('删除成功')); const result = [...value]; const i = result.findIndex((x) => x.id === record.id); if (i !== -1) { result.splice(i, 1); setValue(result); } } }); }, }); }; const runCron = (record: any, index: number) => { Modal.confirm({ title: intl.get('确认运行'), content: ( <> {intl.get('确认运行定时任务')}{' '} {record.name} {' '} {intl.get('吗')} ), onOk() { request .put(`${config.apiPrefix}crons/run`, [record.id]) .then(({ code, data }) => { if (code === 200) { const result = [...value]; const i = result.findIndex((x) => x.id === record.id); if (i !== -1) { result.splice(i, 1, { ...record, status: CrontabStatus.running, }); setValue(result); } } }); }, }); }; const stopCron = (record: any, index: number) => { Modal.confirm({ title: intl.get('确认停止'), content: ( <> {intl.get('确认停止定时任务')}{' '} {record.name} {' '} {intl.get('吗')} ), onOk() { request .put(`${config.apiPrefix}crons/stop`, [record.id]) .then(({ code, data }) => { if (code === 200) { const result = [...value]; const i = result.findIndex((x) => x.id === record.id); if (i !== -1) { result.splice(i, 1, { ...record, pid: null, status: CrontabStatus.idle, }); setValue(result); } } }); }, }); }; const enabledOrDisabledCron = (record: any, index: number) => { Modal.confirm({ title: `确认${record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用') }`, content: ( <> {intl.get('确认')} {record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')} {intl.get('定时任务')}{' '} {record.name} {' '} {intl.get('吗')} ), onOk() { request .put( `${config.apiPrefix}crons/${record.isDisabled === 1 ? 'enable' : 'disable' }`, [record.id], ) .then(({ code, data }) => { if (code === 200) { const newStatus = record.isDisabled === 1 ? 0 : 1; const result = [...value]; const i = result.findIndex((x) => x.id === record.id); if (i !== -1) { result.splice(i, 1, { ...record, isDisabled: newStatus, }); setValue(result); } } }); }, }); }; const pinOrUnPinCron = (record: any, index: number) => { Modal.confirm({ title: `确认${record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶') }`, content: ( <> {intl.get('确认')} {record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')} {intl.get('定时任务')}{' '} {record.name} {' '} {intl.get('吗')} ), onOk() { request .put( `${config.apiPrefix}crons/${record.isPinned === 1 ? 'unpin' : 'pin' }`, [record.id], ) .then(({ code, data }) => { if (code === 200) { const newStatus = record.isPinned === 1 ? 0 : 1; const result = [...value]; const i = result.findIndex((x) => x.id === record.id); if (i !== -1) { result.splice(i, 1, { ...record, isPinned: newStatus, }); setValue(result); } } }); }, }); }; const getMenuItems = (record: any) => { return [ { label: intl.get('编辑'), key: 'edit', icon: }, { label: record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用'), key: 'enableOrDisable', icon: record.isDisabled === 1 ? : , }, { label: intl.get('复制'), key: 'copy', icon: }, { label: intl.get('删除'), key: 'delete', icon: }, { label: record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶'), key: 'pinOrUnPin', icon: record.isPinned === 1 ? : , }, ]; }; const MoreBtn: React.FC<{ record: any; index: number; }> = ({ record, index }) => ( { domEvent.stopPropagation(); action(key, record, index); }, }} > e.stopPropagation()}> ); const action = (key: string | number, record: any, index: number) => { switch (key) { case 'edit': editCron(record, index); break; case 'copy': editCron(omit(record, 'id'), index); break; case 'enableOrDisable': enabledOrDisabledCron(record, index); break; case 'delete': delCron(record, index); break; case 'pinOrUnPin': pinOrUnPinCron(record, index); break; default: break; } }; const handleCancel = () => { setIsModalVisible(false); getCrons(); }; const onSearch = (value: string) => { setSearchText(value.trim()); }; const getCronDetail = (cron: any) => { request .get(`${config.apiPrefix}crons/${cron.id}`) .then(({ code, data }) => { if (code === 200) { const index = value.findIndex((x) => x.id === cron.id); const result = [...value]; data.nextRunTime = getCrontabsNextDate( data.schedule, data.extra_schedules, ); if (index !== -1) { result.splice(index, 1, { ...cron, ...data, }); setValue(result); } } }) .finally(() => setLoading(false)); }; const onSelectChange = (selectedIds: any[]) => { setSelectedRowIds(selectedIds); }; const rowSelection = { selectedRowKeys: selectedRowIds, onChange: onSelectChange, }; const delCrons = () => { Modal.confirm({ title: intl.get('确认删除'), content: <>{intl.get('确认删除选中的定时任务吗')}, onOk() { request .delete(`${config.apiPrefix}crons`, { data: selectedRowIds }) .then(({ code, data }) => { if (code === 200) { message.success(intl.get('批量删除成功')); setSelectedRowIds([]); getCrons(); } }); }, }); }; const operateCrons = (operationStatus: number) => { Modal.confirm({ title: `确认${OperationName[operationStatus]}`, content: ( <> {intl.get('确认')} {OperationName[operationStatus]} {intl.get('选中的定时任务吗')} ), onOk() { request .put( `${config.apiPrefix}crons/${OperationPath[operationStatus]}`, selectedRowIds, ) .then(({ code, data }) => { if (code === 200) { getCrons(); } }); }, }); }; const onPageChange = ( pagination: TablePaginationConfig, filters: Record, sorter: SorterResult | SorterResult[], ) => { const { current, pageSize } = pagination; setPageConf({ page: current as number, size: pageSize as number, sorter, filters, }); localStorage.setItem('pageSize', String(pageSize)); }; const getRowClassName = (record: any, index: number) => { return record.isPinned ? 'pinned-cron cron' : 'cron'; }; useEffect(() => { if (logCron) { localStorage.setItem('logCron', logCron.id); setIsLogModalVisible(true); } }, [logCron]); useEffect(() => { setPageConf({ ...pageConf, page: 1 }); }, [searchText]); useEffect(() => { if (pageConf.page && pageConf.size) { getCrons(); } if (viewConf && viewConf.id) { setActiveKey(viewConf.id); } }, [pageConf, viewConf]); useEffect(() => { if (viewConf && enabledCronViews && enabledCronViews.length > 0) { const view = enabledCronViews .slice(SHOW_TAB_COUNT) .find((x) => x.id === viewConf.id); setMoreMenuActive(!!view); } }, [viewConf, enabledCronViews]); const getAllSubscriptions = () => { request .get(`${config.apiPrefix}subscriptions`) .then(({ code, data }) => { if (code === 200) { setAllSubscriptions(data || []); } }) .catch(() => {}); }; useEffect(() => { getCronViews(); getAllSubscriptions(); }, []); const viewAction = (key: string) => { switch (key) { case 'new': setIsCreateViewModalVisible(true); break; case 'manage': setIsViewManageModalVisible(true); break; default: tabClick(key); break; } }; const menu: MenuProps = { onClick: ({ key, domEvent }) => { domEvent.stopPropagation(); viewAction(key); }, items: [ ...[...enabledCronViews].slice(SHOW_TAB_COUNT).map((x) => ({ label: ( {x.name} {viewConf?.id === x.id && ( )} ), key: x.id, icon: , })), { type: 'divider' as 'group', }, { label: intl.get('创建视图'), key: 'new', icon: , }, { label: intl.get('视图管理'), key: 'manage', icon: , }, ], style: { maxHeight: 350, overflowY: 'auto', }, }; const getCronViews = () => { setLoading(true); request .get(`${config.apiPrefix}crons/views`) .then(({ code, data }) => { if (code === 200) { setCronViews(data); const firstEnableView = data .filter((x) => !x.isDisabled) .map((x) => ({ ...x, name: x.name === '全部任务' ? intl.get('全部任务') : x.name, })); setEnabledCronViews(firstEnableView); setPageConf({ page: 1, size: parseInt(localStorage.getItem('pageSize') || '20'), sorter: {}, filters: {}, }); setViewConf({ ...firstEnableView[0], }); } }) .finally(() => { setLoading(false); }); }; const tabClick = (key: string) => { const view = enabledCronViews.find((x) => x.id == key); setSelectedRowIds([]); setPageConf({ ...pageConf, page: 1 }); setViewConf(view ? view : null); }; const [vt] = useVT( () => ({ scroll: { y: tableScrollHeight } }), [tableScrollHeight], ); return ( setSearchValue(e.target.value)} onSearch={onSearch} />, , ]} header={{ style: headerStyle, }} >
{intl.get('更多')}
} onTabClick={tabClick} items={[ ...[...enabledCronViews].slice(0, SHOW_TAB_COUNT).map((x) => ({ key: x.id, label: x.name, })), ]} />
{selectedRowIds.length > 0 && (
{intl.get('已选择')} {selectedRowIds?.length} {intl.get('项')}
)} `第 ${range[0]}-${range[1]} 条/总共 ${total} 条`, pageSizeOptions: [10, 20, 50, 100, 200, 500, total || 10000].sort( (a, b) => a - b, ), }} dataSource={value} rowKey="id" size="middle" scroll={{ x: 1200, y: tableScrollHeight }} loading={loading} rowSelection={rowSelection} rowClassName={getRowClassName} onChange={onPageChange} components={isPhone || pageConf.size < 50 ? undefined : vt} /> {isLogModalVisible && ( { getCronDetail(logCron); setIsLogModalVisible(false); }} cron={logCron} /> )} {isModalVisible && ( )} {isLabelModalVisible && ( { setIsLabelModalVisible(false); if (needUpdate) { getCrons(); } }} ids={selectedRowIds} /> )} {isDetailModalVisible && ( { setIsDetailModalVisible(false); }} cron={detailCron} theme={theme} isPhone={isPhone} /> )} {isCreateViewModalVisible && ( { setIsCreateViewModalVisible(false); getCronViews(); }} /> )} {isViewManageModalVisible && ( { setIsViewManageModalVisible(false); }} cronViewChange={(data) => { getCronViews(); }} /> )} ); }; export default Crontab; ================================================ FILE: src/pages/crontab/logModal.tsx ================================================ import intl from "react-intl-universal"; import React, { useEffect, useRef, useState } from "react"; import { Modal, message, Input, Form, Statistic, Button, Typography, } from "antd"; import { request } from "@/utils/http"; import config from "@/utils/config"; import { Loading3QuartersOutlined, CheckCircleOutlined, } from "@ant-design/icons"; import { PageLoading } from "@ant-design/pro-layout"; import { logEnded } from "@/utils"; import { CrontabStatus } from "./type"; import Ansi from "ansi-to-react"; const { Countdown } = Statistic; const CronLogModal = ({ cron, handleCancel, data, logUrl, }: { cron?: any; handleCancel: () => void; data?: string; logUrl?: string; }) => { const [value, setValue] = useState(intl.get("启动中...")); const [loading, setLoading] = useState(true); const [executing, setExecuting] = useState(true); const [isPhone, setIsPhone] = useState(false); const scrollInfoRef = useRef({ value: 0, down: true }); const uniqPath = logUrl ? logUrl : String(cron?.id); const getCronLog = (isFirst?: boolean) => { if (isFirst) { setLoading(true); } request .get(logUrl ? logUrl : `${config.apiPrefix}crons/${cron.id}/log`) .then(({ code, data }) => { if ( code === 200 && localStorage.getItem("logCron") === uniqPath && data !== value ) { const log = data as string; setValue(log || intl.get("暂无日志")); const hasNext = Boolean( log && !logEnded(log) && !log.includes("日志不存在") && !log.includes("日志设置为忽略"), ); if (!hasNext && !logEnded(value) && value !== intl.get("启动中...")) { setTimeout(() => { autoScroll(); }); } setExecuting(hasNext); if (hasNext) { setTimeout(() => { autoScroll(); getCronLog(); }, 2000); } } }) .finally(() => { if (isFirst) { setLoading(false); } }); }; const autoScroll = () => { if (!scrollInfoRef.current.down) { return; } setTimeout(() => { document .querySelector("#log-flag") ?.scrollIntoView({ behavior: "smooth" }); }, 600); }; const cancel = () => { localStorage.removeItem("logCron"); handleCancel(); }; const handleScroll: React.UIEventHandler = (e) => { const sTop = (e.target as HTMLDivElement).scrollTop; if (scrollInfoRef.current.down) { scrollInfoRef.current = { value: sTop, down: sTop - scrollInfoRef.current.value > -5 || !sTop, }; } }; const titleElement = () => { return (
{(executing || loading) && } {!executing && !loading && } {cron && cron.name}
); }; useEffect(() => { if (cron && cron.id) { getCronLog(true); } }, [cron]); useEffect(() => { if (data) { setValue(data); } }, [data]); useEffect(() => { setIsPhone(document.body.clientWidth < 768); }, []); return ( cancel()} onCancel={() => cancel()} footer={[ , ]} >
{loading ? ( ) : (
            {value}
          
)}
); }; export default CronLogModal; ================================================ FILE: src/pages/crontab/modal.tsx ================================================ import EditableTagGroup from '@/components/tag'; import config from '@/utils/config'; import { request } from '@/utils/http'; import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; import { Button, Form, Input, Modal, Select, Space, message } from 'antd'; import CronExpressionParser from 'cron-parser'; import { useEffect, useState } from 'react'; import intl from 'react-intl-universal'; import { getScheduleType, scheduleTypeMap } from './const'; import { ScheduleType } from './type'; const CronModal = ({ cron, handleCancel, }: { cron?: any; handleCancel: (needUpdate?: boolean) => void; }) => { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const [scheduleType, setScheduleType] = useState( cron ? getScheduleType(cron.schedule) : ScheduleType.Normal, ); const handleOk = async (values: any) => { setLoading(true); try { const method = cron?.id ? 'put' : 'post'; const payload = { ...values, schedule: scheduleType !== ScheduleType.Normal ? scheduleTypeMap[scheduleType] : values.schedule, }; if (cron?.id) { payload.id = cron.id; } const { code, data } = await request[method]( `${config.apiPrefix}crons`, payload, ); if (code === 200) { message.success( cron?.id ? intl.get('更新任务成功') : intl.get('创建任务成功'), ); handleCancel(data); } } catch (error: any) { console.error(error); } finally { setLoading(false); } }; const handleScheduleTypeChange = (type: ScheduleType) => { setScheduleType(type); form.setFieldValue('schedule', ''); }; const renderScheduleOptions = () => ( ); const renderScheduleFields = () => { if (scheduleType !== ScheduleType.Normal) return null; return ( <> { try { if (!value || CronExpressionParser.parse(value).hasNext()) { return Promise.resolve(); } return Promise.reject(intl.get('Cron表达式格式有误')); } catch (e) { return Promise.reject(intl.get('Cron表达式格式有误')); } }, }, ]} > {(fields, { add, remove }, { errors }) => ( <> {fields.map(({ key, name, ...restField }) => ( remove(name)} /> ))} add({ schedule: '' })}> {intl.get('新增定时规则')} )} ); }; return ( form.validateFields().then(handleOk)} onCancel={() => handleCancel()} confirmLoading={loading} >
{renderScheduleOptions()} {renderScheduleFields()} { if (!value) return Promise.resolve(); if (value === '/dev/null') return Promise.resolve(); if (value.length > 100) { return Promise.reject(intl.get('日志名称不能超过100个字符')); } if ( !/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))(?:\/)?(?:[\w.-]+\/)*[\w.-]+\/?$/.test( value, ) ) { return Promise.reject( intl.get('日志名称只能包含字母、数字、下划线和连字符'), ); } return Promise.resolve(); }, }, ]} >
); }; const CronLabelModal = ({ ids, handleCancel, }: { ids: Array; handleCancel: (needUpdate?: boolean) => void; }) => { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const update = async (action: 'delete' | 'post') => { form .validateFields() .then(async (values) => { setLoading(true); const payload = { ids, labels: values.labels }; try { const { code, data } = await request[action]( `${config.apiPrefix}crons/labels`, payload, ); if (code === 200) { message.success( action === 'post' ? intl.get('添加Labels成功') : intl.get('删除Labels成功'), ); handleCancel(true); } setLoading(false); } catch (error) { setLoading(false); } }) .catch((info) => { console.log('Validate Failed:', info); }); }; const buttons = [ , , , ]; return ( handleCancel(false)} confirmLoading={loading} >
); }; export { CronLabelModal, CronModal as default }; ================================================ FILE: src/pages/crontab/type.ts ================================================ export enum CrontabStatus { 'running' = 0, 'queued' = 0.5, 'idle' = 1, 'disabled', } export enum OperationName { '启用', '禁用', '运行', '停止', '置顶', '取消置顶', } export enum OperationPath { 'enable', 'disable', 'run', 'stop', 'pin', 'unpin', } export interface ICrontab { name: string; command: string; schedule: string; id: number; status: number; isDisabled?: 1 | 0; isPinned?: 1 | 0; labels?: string[]; last_running_time?: number; last_execution_time?: number; nextRunTime: Date; sub_id: number; extra_schedules?: Array<{ schedule: string }>; allow_multiple_instances?: 1 | 0; } export enum ScheduleType { Normal = 'normal', Once = 'once', Boot = 'boot', } ================================================ FILE: src/pages/crontab/viewCreateModal.tsx ================================================ import intl from 'react-intl-universal'; import React, { useEffect, useState } from 'react'; import { Modal, message, Input, Form, Statistic, Button, Space, Select, } from 'antd'; import { request } from '@/utils/http'; import config from '@/utils/config'; import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; import IconFont from '@/components/iconfont'; import { CrontabStatus } from './type'; import { useRequest } from 'ahooks'; const PROPERTIES = [ { name: intl.get('命令'), value: 'command' }, { name: intl.get('名称'), value: 'name' }, { name: intl.get('定时规则'), value: 'schedule' }, { name: intl.get('状态'), value: 'status', onlySelect: true }, { name: intl.get('标签'), value: 'labels' }, { name: intl.get('订阅'), value: 'sub_id', onlySelect: true }, ]; const EOperation: any = { Reg: '', NotReg: '', In: 'select', Nin: 'select', }; const OPERATIONS = [ { name: intl.get('包含'), value: 'Reg' }, { name: intl.get('不包含'), value: 'NotReg' }, { name: intl.get('属于'), value: 'In', type: 'select' }, { name: intl.get('不属于'), value: 'Nin', type: 'select' }, // { name: '等于', value: 'Eq' }, // { name: '不等于', value: 'Ne' }, // { name: '为空', value: 'IsNull' }, // { name: '不为空', value: 'NotNull' }, ]; const SORTTYPES = [ { name: intl.get('顺序'), value: 'ASC' }, { name: intl.get('倒序'), value: 'DESC' }, ]; enum ViewFilterRelation { 'and' = '且', 'or' = '或', } const ViewCreateModal = ({ view, handleCancel, }: { view?: any; handleCancel: (param?: any) => void; }) => { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const [filterRelation, setFilterRelation] = useState<'and' | 'or'>('and'); const filtersValue = Form.useWatch('filters', form); const { data } = useRequest( () => request.get(`${config.apiPrefix}subscriptions`), { cacheKey: 'subscriptions', }, ); const STATUS_MAP = { status: [ { name: intl.get('运行中'), value: CrontabStatus.running }, { name: intl.get('空闲中'), value: CrontabStatus.idle }, { name: intl.get('已禁用'), value: CrontabStatus.disabled }, ], sub_id: data?.data.map((x) => ({ name: x.name, value: x.id })), }; const handleOk = async (values: any) => { setLoading(true); values.filterRelation = filterRelation; const method = view ? 'put' : 'post'; try { const { code, data } = await request[method]( `${config.apiPrefix}crons/views`, view ? { ...values, id: view.id } : values, ); if (code === 200) { handleCancel(data); } setLoading(false); } catch (error: any) { setLoading(false); } }; const OperationElement = ({ name, ...others }: { name: number }) => { const property = form.getFieldValue(['filters', name, 'property']); return ( ); }; const propertyElement = (props: any, style: React.CSSProperties = {}) => { return ( ); }; const typeElement = ( ); const statusElement = (property: keyof typeof STATUS_MAP) => { return ( ); }; return ( { form .validateFields() .then((values) => { handleOk(values); }) .catch((info) => { console.log('Validate Failed:', info); }); }} onCancel={() => handleCancel()} confirmLoading={loading} >
{(fields, { add, remove }, { errors }) => (
1 ? 'active' : '' }`} > {fields.length > 1 && (
)}
{fields.map(({ key, name, ...restField }) => ( {propertyElement(PROPERTIES, { width: 120 })} {EOperation[filtersValue?.[name]['operation']] === 'select' ? ( statusElement(filtersValue?.[name]['property']) ) : ( )} {name !== 0 && ( remove(name)} /> )} ))} add({ property: 'command', operation: 'Reg' }) } > {intl.get('新增筛选条件')}
)}
{(fields, { add, remove }, { errors }) => (
1 ? 'active' : '' }`} > {fields.length > 1 && (
)}
{fields.map(({ key, name, ...restField }) => ( {propertyElement(PROPERTIES)} {typeElement} remove(name)} /> ))} add({ property: 'command', type: 'ASC' })}> {intl.get('新增排序方式')}
)}
); }; export default ViewCreateModal; ================================================ FILE: src/pages/crontab/viewManageModal.tsx ================================================ import intl from 'react-intl-universal'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Modal, message, Space, Table, Tag, Typography, Button, Switch, } from 'antd'; import { request } from '@/utils/http'; import config from '@/utils/config'; import { DeleteOutlined, EditOutlined } from '@ant-design/icons'; import { DndProvider, useDrag, useDrop } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import ViewCreateModal from './viewCreateModal'; const { Text } = Typography; const type = 'DragableBodyRow'; const DragableBodyRow = ({ index, moveRow, className, style, ...restProps }: any) => { const ref = useRef(); const [{ isOver, dropClassName }, drop] = useDrop({ accept: type, collect: (monitor) => { const { index: dragIndex } = (monitor.getItem() as any) || {}; if (dragIndex === index) { return {}; } return { isOver: monitor.isOver(), dropClassName: dragIndex < index ? ' drop-over-downward' : ' drop-over-upward', }; }, drop: (item: any) => { moveRow(item.index, index); }, }); const [, drag] = useDrag({ type, item: { index }, collect: (monitor) => ({ isDragging: monitor.isDragging(), }), }); drop(drag(ref)); return (
); }; const ViewManageModal = ({ cronViews, handleCancel, cronViewChange, }: { cronViews: any[]; handleCancel: () => void; cronViewChange: (data?: any) => void; }) => { const islastEnableView = (record) => { return list.filter((x) => !x.isDisabled).length <= 1 && !record.isDisabled; }; const columns: any = [ { title: intl.get('名称'), dataIndex: 'name', key: 'name', render: (v) => (v === '全部任务' ? intl.get('全部任务') : v), }, { title: intl.get('类型'), dataIndex: 'type', key: 'type', render: (v) => (v === 1 ? intl.get('系统') : intl.get('个人')), }, { title: intl.get('显示'), key: 'isDisabled', dataIndex: 'isDisabled', width: 100, render: (text: string, record: any, index: number) => { return ( onShowChange(checked, record, index)} /> ); }, }, { title: intl.get('操作'), key: 'action', width: 100, render: (text: string, record: any, index: number) => { return record.type !== 1 ? ( editView(record, index)}> {!islastEnableView(record) && ( deleteView(record, index)}> )} ) : ( '-' ); }, }, ]; const [list, setList] = useState([]); const [isCreateViewModalVisible, setIsCreateViewModalVisible] = useState(false); const [editedView, setEditedView] = useState(null); const editView = (record: any, index: number) => { setEditedView(record); setIsCreateViewModalVisible(true); }; const deleteView = (record: any, index: number) => { Modal.confirm({ title: intl.get('确认删除'), content: ( <> {intl.get('确认删除视图')}{' '} {record.name} {' '} {intl.get('吗')} ), onOk() { request .delete(`${config.apiPrefix}crons/views`, { data: [record.id] }) .then(({ code, data }) => { if (code === 200) { message.success(intl.get('删除成功')); cronViewChange(); } }); }, }); }; const onShowChange = (checked: boolean, record: any, index: number) => { request .put(`${config.apiPrefix}crons/views/${checked ? 'enable' : 'disable'}`, [ record.id, ]) .then(({ code, data }) => { if (code === 200) { const _list = [...list]; _list.splice(index, 1, { ...list[index], isDisabled: !checked }); setList(_list); cronViewChange(); } }); }; const components = { body: { row: DragableBodyRow, }, }; const moveRow = useCallback( (dragIndex, hoverIndex) => { if (dragIndex === hoverIndex) { return; } const dragRow = list[dragIndex]; request .put(`${config.apiPrefix}crons/views/move`, { fromIndex: dragIndex, toIndex: hoverIndex, id: dragRow.id, }) .then(({ code, data }) => { if (code === 200) { const newData = [...list]; newData.splice(dragIndex, 1); newData.splice(hoverIndex, 0, { ...dragRow, ...data }); setList(newData); cronViewChange(); } }); }, [list], ); useEffect(() => { setList(cronViews); }, [cronViews]); return ( handleCancel()} className="view-manage-modal" forceRender footer={false} maskClosable={false} >
{ return { index, moveRow, } as any; }} /> {isCreateViewModalVisible && ( { setIsCreateViewModalVisible(false); cronViewChange(data); }} /> )} ); }; export default ViewManageModal; ================================================ FILE: src/pages/dependence/index.less ================================================ ================================================ FILE: src/pages/dependence/index.tsx ================================================ import intl from 'react-intl-universal'; import React, { useCallback, useRef, useState, useEffect } from 'react'; import { Button, message, Modal, Table, Tag, Space, Typography, Tooltip, Input, Tabs, } from 'antd'; import { EditOutlined, DeleteOutlined, SyncOutlined, CheckCircleOutlined, DeleteFilled, BugOutlined, FileTextOutlined, CloseCircleOutlined, ClockCircleOutlined, MinusCircleOutlined, } from '@ant-design/icons'; import config from '@/utils/config'; import { PageContainer } from '@ant-design/pro-layout'; import { request } from '@/utils/http'; import DependenceModal from './modal'; import { DndProvider, useDrag, useDrop } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import './index.less'; import DependenceLogModal from './logModal'; import { useOutletContext } from '@umijs/max'; import { SharedContext } from '@/layouts'; import useTableScrollHeight from '@/hooks/useTableScrollHeight'; import dayjs from 'dayjs'; import WebSocketManager from '@/utils/websocket'; import { DependenceStatus, Status } from './type'; import IconFont from '@/components/iconfont'; import useResizeObserver from '@react-hook/resize-observer'; const { Text } = Typography; const { Search } = Input; enum StatusColor { 'processing', 'success', 'error', } const StatusMap: Record = { 0: { icon: , color: 'processing', }, 1: { icon: , color: 'success', }, 2: { icon: , color: 'error', }, 3: { icon: , color: 'processing', }, 4: { icon: , color: 'success', }, 5: { icon: , color: 'error', }, 6: { icon: , color: 'default', }, 7: { icon: , color: 'default', }, }; const Dependence = () => { const { headerStyle, isPhone } = useOutletContext(); const columns: any = [ { title: intl.get('序号'), width: 90, render: (text: string, record: any, index: number) => { return {index + 1} ; }, }, { title: intl.get('名称'), dataIndex: 'name', width: 180, key: 'name', }, { title: intl.get('状态'), key: 'status', width: 120, dataIndex: 'status', filters: [ { text: intl.get('队列中'), value: DependenceStatus.queued, }, { text: intl.get('安装中'), value: DependenceStatus.installing, }, { text: intl.get('已安装'), value: DependenceStatus.installed, }, { text: intl.get('安装失败'), value: DependenceStatus.installFailed, }, { text: intl.get('删除中'), value: DependenceStatus.removing, }, { text: intl.get('已删除'), value: DependenceStatus.removed, }, { text: intl.get('删除失败'), value: DependenceStatus.removeFailed, }, { text: intl.get('已取消'), value: DependenceStatus.cancelled, }, ], render: (text: string, record: any, index: number) => { return ( {intl.get(Status[record.status])} ); }, }, { title: intl.get('备注'), dataIndex: 'remark', width: 100, key: 'remark', }, { title: intl.get('更新时间'), key: 'updatedAt', dataIndex: 'updatedAt', width: 150, render: (text: string) => { return {dayjs(text).format('YYYY-MM-DD HH:mm:ss')}; }, }, { title: intl.get('创建时间'), key: 'createdAt', dataIndex: 'createdAt', width: 150, render: (text: string) => { return {dayjs(text).format('YYYY-MM-DD HH:mm:ss')}; }, }, { title: intl.get('操作'), key: 'action', width: 140, render: (text: string, record: any, index: number) => { const isPc = !isPhone; return ( {![Status.队列中, Status.已取消].includes(record.status) && ( { setLogDependence({ ...record, timestamp: Date.now() }); }} > )} {[Status.队列中, Status.安装中, Status.删除中].includes( record.status, ) ? ( cancelDependence(record)}> ) : ( <> reInstallDependence(record, index)}> {Status.已安装 === record.status && ( deleteDependence(record, index)}> )} deleteDependence(record, index, true)}> )} ); }, }, ]; const [value, setValue] = useState([]); const [loading, setLoading] = useState(true); const [isModalVisible, setIsModalVisible] = useState(false); const [editedDependence, setEditedDependence] = useState(); const [selectedRowIds, setSelectedRowIds] = useState([]); const [searchText, setSearchText] = useState(''); const [logDependence, setLogDependence] = useState(); const [isLogModalVisible, setIsLogModalVisible] = useState(false); const [type, setType] = useState('nodejs'); const tableRef = useRef(null); const [height, setHeight] = useState(0); useResizeObserver(tableRef, (entry) => { const _height = entry.target?.parentElement?.parentElement?.parentElement?.offsetHeight; let threshold = 113; if (selectedRowIds.length) { threshold += 53; } if (_height && height !== _height - threshold) { setHeight(_height - threshold); } }); const getDependencies = (status?: number[]) => { setLoading(true); request .get( `${ config.apiPrefix }dependencies?searchValue=${searchText}&type=${type}&status=${ status || '' }`, ) .then(({ code, data }) => { if (code === 200) { setValue(data); } }) .finally(() => setLoading(false)); }; const addDependence = () => { setEditedDependence(null as any); setIsModalVisible(true); }; const editDependence = (record: any, index: number) => { setEditedDependence(record); setIsModalVisible(true); }; const deleteDependence = ( record: any, index: number, force: boolean = false, ) => { Modal.confirm({ title: intl.get('确认删除'), content: ( <> {intl.get('确认删除依赖')}{' '} {record.name} {' '} {intl.get('吗')} ), onOk() { request .delete(`${config.apiPrefix}dependencies${force ? '/force' : ''}`, { data: [record.id], }) .then(({ code, data }) => { if (code === 200 && force) { const i = value.findIndex((x) => x.id === data[0].id); if (i !== -1) { const result = [...value]; result.splice(i, 1); setValue(result); } } }); }, }); }; const reInstallDependence = (record: any, index: number) => { Modal.confirm({ title: intl.get('确认重新安装'), content: ( <> {intl.get('确认重新安装')}{' '} {record.name} {' '} {intl.get('吗')} ), onOk() { request .put(`${config.apiPrefix}dependencies/reinstall`, [record.id]) .then(({ code, data }) => { if (code === 200) { handleDependence(data[0]); } }); }, }); }; const cancelDependence = (record: any) => { Modal.confirm({ title: intl.get('确认取消安装'), content: ( <> {intl.get('确认取消安装')}{' '} {record.name} {' '} {intl.get('吗')} ), onOk() { request .put(`${config.apiPrefix}dependencies/cancel`, [record.id]) .then(() => { getDependencies(); }); }, }); }; const handleCancel = (dependence?: any[]) => { setIsModalVisible(false); dependence && handleDependence(dependence); }; const handleDependence = (dependence: any) => { const result = [...value]; if (Array.isArray(dependence)) { result.unshift(...dependence); } else { const index = value.findIndex((x) => x.id === dependence.id); if (index !== -1) { result.splice(index, 1, { ...dependence, }); } } setValue(result); }; const onSelectChange = (selectedIds: any[]) => { setSelectedRowIds(selectedIds); }; const rowSelection = { selectedRowKeys: selectedRowIds, onChange: onSelectChange, }; const delDependencies = (force: boolean) => { const forceUrl = force ? '/force' : ''; Modal.confirm({ title: intl.get('确认删除'), content: <>{intl.get('确认删除选中的依赖吗')}, onOk() { request .delete(`${config.apiPrefix}dependencies${forceUrl}`, { data: selectedRowIds, }) .then(({ code, data }) => { if (code === 200) { setSelectedRowIds([]); getDependencies(); } }); }, }); }; const handlereInstallDependencies = () => { Modal.confirm({ title: intl.get('确认重新安装'), content: <>{intl.get('确认重新安装选中的依赖吗')}, onOk() { request .put(`${config.apiPrefix}dependencies/reinstall`, selectedRowIds) .then(({ code, data }) => { if (code === 200) { setSelectedRowIds([]); getDependencies(); } }); }, }); }; const getDependenceDetail = (dependence: any) => { request .get(`${config.apiPrefix}dependencies/${dependence.id}`) .then(({ code, data }) => { if (code === 200) { const index = value.findIndex((x) => x.id === dependence.id); const result = [...value]; if (index !== -1) { result.splice(index, 1, { ...dependence, ...data, }); setValue(result); } } }) .finally(() => setLoading(false)); }; const onSearch = (value: string) => { setSearchText(value.trim()); }; useEffect(() => { getDependencies(); }, [searchText, type]); useEffect(() => { if (logDependence) { localStorage.setItem('logDependence', logDependence.id); setIsLogModalVisible(true); } }, [logDependence]); const handleMessage = useCallback((payload: any) => { const { message, references } = payload; let status: number | undefined = undefined; if (message.includes('开始时间') && references.length > 0) { status = message.includes('安装') ? Status.安装中 : Status.删除中; } if (message.includes('结束时间') && references.length > 0) { if (message.includes('安装')) { status = message.includes('成功') ? Status.已安装 : Status.安装失败; } else { status = message.includes('成功') ? Status.已删除 : Status.删除失败; } if (status === Status.已删除) { setTimeout(() => { setValue((p) => { const _result = [...p]; for (let i = 0; i < references.length; i++) { const index = p.findIndex((x) => x.id === references[i]); if (index !== -1) { _result.splice(index, 1); } } return _result; }); }, 300); return; } } if (typeof status === 'number') { setValue((p) => { const result = [...p]; for (let i = 0; i < references.length; i++) { const index = p.findIndex((x) => x.id === references[i]); if (index !== -1) { result.splice(index, 1, { ...p[index], status, }); } } return result; }); } }, []); useEffect(() => { const ws = WebSocketManager.getInstance(); ws.subscribe('installDependence', handleMessage); ws.subscribe('uninstallDependence', handleMessage); return () => { ws.unsubscribe('installDependence', handleMessage); ws.unsubscribe('uninstallDependence', handleMessage); }; }, []); const onTabChange = (activeKey: string) => { setSelectedRowIds([]); setType(activeKey); }; const children = (
{selectedRowIds.length > 0 && (
{intl.get('已选择')} {selectedRowIds?.length} {intl.get('项')}
)}
{ getDependencies(filters?.status as number[]); }} /> ); return ( , , ]} header={{ style: headerStyle, }} > {children} {isModalVisible && ( )} {logDependence && isLogModalVisible && ( { setIsLogModalVisible(false); if (needRemove) { const index = value.findIndex((x) => x.id === logDependence.id); const result = [...value]; if (index !== -1) { result.splice(index, 1); setValue(result); } } else if ([...value].map((x) => x.id).includes(logDependence.id)) { getDependenceDetail(logDependence); } }} dependence={logDependence} /> )} ); }; export default Dependence; ================================================ FILE: src/pages/dependence/logModal.tsx ================================================ import intl from 'react-intl-universal'; import React, { useEffect, useState } from 'react'; import { Modal, message, Input, Form, Statistic, Button } from 'antd'; import { request } from '@/utils/http'; import config from '@/utils/config'; import { Loading3QuartersOutlined, CheckCircleOutlined, } from '@ant-design/icons'; import { PageLoading } from '@ant-design/pro-layout'; import Ansi from 'ansi-to-react'; import WebSocketManager from '@/utils/websocket'; import { Status } from './type'; const DependenceLogModal = ({ dependence, handleCancel, }: { dependence?: any; handleCancel: (needRemove?: boolean) => void; }) => { const [value, setValue] = useState(''); const [executing, setExecuting] = useState(true); const [isPhone, setIsPhone] = useState(false); const [loading, setLoading] = useState(true); const [isRemoveFailed, setIsRemoveFailed] = useState(false); const [removeLoading, setRemoveLoading] = useState(false); const cancel = (needRemove: boolean = false) => { localStorage.removeItem('logDependence'); handleCancel(needRemove); }; const titleElement = () => { return ( <> {executing && } {!executing && } {intl.get('日志 -')} {dependence && dependence.name} {' '} ); }; const getDependenceLog = () => { setLoading(true); request .get(`${config.apiPrefix}dependencies/${dependence.id}`) .then(({ code, data }) => { if ( code === 200 && localStorage.getItem('logDependence') === String(dependence.id) ) { const log = (data?.log || []).join('') as string; setValue(log); setExecuting(!log.includes('结束时间')); setIsRemoveFailed(log.includes('删除失败')); } }) .finally(() => { setLoading(false); }); }; const forceRemoveDependence = () => { setRemoveLoading(true); request .delete(`${config.apiPrefix}dependencies/force`, { data: [dependence.id], }) .then(({ code, data }) => { if (code === 200) { cancel(true); } }) .finally(() => { setRemoveLoading(false); }); }; const footerClick = () => { if (isRemoveFailed) { forceRemoveDependence(); } else { cancel(); } }; useEffect(() => { if (dependence) { getDependenceLog(); } }, [dependence]); const handleMessage = (payload: any) => { const { message, references } = payload; if ( references.length > 0 && references.includes(dependence.id) && [Status.删除中, Status.安装中].includes(dependence.status) ) { if (message.includes('结束时间')) { setExecuting(false); setIsRemoveFailed(message.includes('删除失败')); } setValue((p) => `${p}${message}`); } }; useEffect(() => { const ws = WebSocketManager.getInstance(); ws.subscribe('installDependence', handleMessage); ws.subscribe('uninstallDependence', handleMessage); return () => { ws.unsubscribe('installDependence', handleMessage); ws.unsubscribe('uninstallDependence', handleMessage); }; }, [dependence]); useEffect(() => { setIsPhone(document.body.clientWidth < 768); }, []); return ( cancel()} onCancel={() => cancel()} footer={[ , ]} >
{loading ? ( ) : (
            {value}
          
)}
); }; export default DependenceLogModal; ================================================ FILE: src/pages/dependence/modal.tsx ================================================ import intl from 'react-intl-universal'; import React, { useEffect, useState } from 'react'; import { Modal, message, Input, Form, Radio, Select } from 'antd'; import { request } from '@/utils/http'; import config from '@/utils/config'; const { Option } = Select; enum DependenceTypes { 'nodejs', 'python3', 'linux', } const DependenceModal = ({ dependence, handleCancel, defaultType, }: { dependence?: any; handleCancel: (cks?: any[]) => void; defaultType: string; }) => { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const handleOk = async (values: any) => { setLoading(true); const { name, split, type, remark } = values; const method = dependence ? 'put' : 'post'; let payload; if (!dependence) { if (split === '1') { const symbol = name.includes('&') ? '&' : '\n'; payload = name.split(symbol).map((x: any) => { return { name: x, type, remark, }; }); } else { payload = [{ name, type, remark }]; } } else { payload = { ...values, id: dependence.id }; } try { const { code, data } = await request[method]( `${config.apiPrefix}dependencies`, payload, ); if (code === 200) { handleCancel(data); } setLoading(false); } catch (error) { setLoading(false); } }; return ( { form .validateFields() .then((values) => { handleOk(values); }) .catch((info) => { console.log('Validate Failed:', info); }); }} onCancel={() => handleCancel()} confirmLoading={loading} >
{!dependence && ( {intl.get('是')} {intl.get('否')} )}
); }; export default DependenceModal; ================================================ FILE: src/pages/dependence/type.ts ================================================ export enum DependenceStatus { 'installing', 'installed', 'installFailed', 'removing', 'removed', 'removeFailed', 'queued', 'cancelled', } export enum Status { '安装中', '已安装', '安装失败', '删除中', '已删除', '删除失败', '队列中', '已取消', } ================================================ FILE: src/pages/diff/index.less ================================================ .d2h-files-diff { height: calc(100vh - 130px); height: calc(100vh - var(--vh-offset, 0px) - 130px); overflow: auto; } .d2h-code-side-linenumber { position: relative; } .d2h-code-side-line { padding: 0 0.5em; } .diff-switch-file { min-width: 768px; .ant-form-item { margin-bottom: 8px; } + section { height: calc(100% - 40px) !important; } } ================================================ FILE: src/pages/diff/index.tsx ================================================ import intl from 'react-intl-universal'; import React, { PureComponent, useRef, useState, useEffect } from 'react'; import { Button, message, Select, Form, Row, Col } from 'antd'; import config from '@/utils/config'; import { PageContainer } from '@ant-design/pro-layout'; import { request } from '@/utils/http'; import './index.less'; import { DiffEditor } from '@monaco-editor/react'; import ReactDiffViewer from 'react-diff-viewer'; import { useOutletContext } from '@umijs/max'; import { SharedContext } from '@/layouts'; import { getEditorMode } from '@/utils'; const { Option } = Select; const Diff = () => { const { headerStyle, isPhone, theme } = useOutletContext(); const [origin, setOrigin] = useState('sample/config.sample.sh'); const [current, setCurrent] = useState('config.sh'); const [originValue, setOriginValue] = useState(''); const [currentValue, setCurrentValue] = useState(''); const [loading, setLoading] = useState(true); const [files, setFiles] = useState([]); const editorRef = useRef(null); const [language, setLanguage] = useState('shell'); const getConfig = () => { request .get(`${config.apiPrefix}configs/detail?path=${encodeURIComponent(current)}`) .then(({ code, data }) => { if (code === 200) { setCurrentValue(data); } }); }; const getSample = () => { request .get(`${config.apiPrefix}configs/detail?path=${encodeURIComponent(origin)}`) .then(({ code, data }) => { if (code === 200) { setOriginValue(data); } }); }; const updateConfig = () => { const content = editorRef.current ? editorRef.current.getModel().modified.getValue().replace(/\r\n/g, '\n') : currentValue; request .post(`${config.apiPrefix}configs/save`, { content, name: current, }) .then(({ code, data }) => { if (code === 200) { message.success(intl.get('保存成功')); } }); }; const getFiles = () => { setLoading(true); request .get(`${config.apiPrefix}configs/sample`) .then(({ code, data }) => { if (code === 200) { setFiles(data); } }) .finally(() => setLoading(false)); }; const originFileChange = (value: string, op) => { setCurrent(op.extra.target); setOrigin(value); const newMode = getEditorMode(value); setLanguage(newMode); }; useEffect(() => { getFiles(); }, []); useEffect(() => { getSample(); }, [origin]); useEffect(() => { getConfig(); }, [current]); return ( {intl.get('保存')} , ] } >
{current} {isPhone ? ( ) : ( { editorRef.current = editor; }} /> )} ); }; export default Diff; ================================================ FILE: src/pages/env/editNameModal.tsx ================================================ import intl from 'react-intl-universal' import React, { useEffect, useState } from 'react'; import { Modal, message, Input, Form } from 'antd'; import { request } from '@/utils/http'; import config from '@/utils/config'; const EditNameModal = ({ ids, handleCancel, }: { ids?: string[]; handleCancel: () => void; }) => { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const handleOk = async (values: any) => { setLoading(true); try { const { code, data } = await request.put(`${config.apiPrefix}envs/name`, { ids, name: values.name, }); if (code === 200) { message.success(intl.get('更新环境变量名称成功')); handleCancel(); } setLoading(false); } catch (error) { setLoading(false); } }; return ( { form .validateFields() .then((values) => { handleOk(values); }) .catch((info) => { console.log('Validate Failed:', info); }); }} onCancel={() => handleCancel()} confirmLoading={loading} >
); }; export default EditNameModal; ================================================ FILE: src/pages/env/index.less ================================================ tr.drop-over-downward td { border-bottom: 2px dashed #1890ff; } tr.drop-over-upward td { border-top: 2px dashed #1890ff; } .text-ellipsis { text-overflow: ellipsis; white-space: nowrap; overflow: hidden; } ================================================ FILE: src/pages/env/index.tsx ================================================ import useTableScrollHeight from '@/hooks/useTableScrollHeight'; import { SharedContext } from '@/layouts'; import config from '@/utils/config'; import { request } from '@/utils/http'; import { exportJson } from '@/utils/index'; import { CheckCircleOutlined, DeleteOutlined, EditOutlined, PushpinFilled, PushpinOutlined, StopOutlined, UploadOutlined, } from '@ant-design/icons'; import { PageContainer } from '@ant-design/pro-layout'; import { useOutletContext } from '@umijs/max'; import { Button, Input, Modal, Space, Table, Tag, Tooltip, Typography, Upload, UploadProps, message, } from 'antd'; import dayjs from 'dayjs'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { DndProvider, useDrag, useDrop } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import intl from 'react-intl-universal'; import { useVT } from 'virtualizedtableforantd4'; import Copy from '../../components/copy'; import EditNameModal from './editNameModal'; import './index.less'; import EnvModal from './modal'; const { Paragraph } = Typography; const { Search } = Input; enum Status { '已启用', '已禁用', } enum StatusColor { 'success', 'error', } enum OperationName { '启用', '禁用', '置顶', '取消置顶', } enum OperationPath { 'enable', 'disable', 'pin', 'unpin', } const type = 'DragableBodyRow'; const Env = () => { const { headerStyle, isPhone, theme } = useOutletContext(); const columns: any = [ { title: intl.get('序号'), width: 80, render: (text: string, record: any, index: number) => { return {index + 1} ; }, }, { title: intl.get('名称'), dataIndex: 'name', key: 'name', sorter: (a: any, b: any) => a.name.localeCompare(b.name), render: (text: string, record: any) => { return (
{text}
); }, }, { title: intl.get('值'), dataIndex: 'value', key: 'value', width: '35%', render: (text: string, record: any) => { return (
{text}
); }, }, { title: intl.get('备注'), dataIndex: 'remarks', key: 'remarks', render: (text: string, record: any) => { return (
{text}
); }, }, { title: intl.get('更新时间'), dataIndex: 'timestamp', key: 'timestamp', width: 165, ellipsis: { showTitle: false, }, sorter: { compare: (a: any, b: any) => { const updatedAtA = new Date(a.updatedAt || a.timestamp).getTime(); const updatedAtB = new Date(b.updatedAt || b.timestamp).getTime(); return updatedAtA - updatedAtB; }, }, render: (text: string, record: any) => { const date = dayjs(record.updatedAt || record.timestamp).format( 'YYYY-MM-DD HH:mm:ss', ); return ( {date} ); }, }, { title: intl.get('状态'), key: 'status', dataIndex: 'status', width: 100, filters: [ { text: intl.get('已启用'), value: 0, }, { text: intl.get('已禁用'), value: 1, }, ], onFilter: (value: number, record: any) => record.status === value, render: (text: string, record: any, index: number) => { return ( {intl.get(Status[record.status])} ); }, }, { title: intl.get('操作'), key: 'action', width: 160, render: (text: string, record: any, index: number) => { const isPc = !isPhone; return ( editEnv(record, index)}> enabledOrDisabledEnv(record, index)}> {record.status === Status.已禁用 ? ( ) : ( )} pinOrUnpinEnv(record, index)}> {record.isPinned === 1 ? ( ) : ( )} deleteEnv(record, index)}> ); }, }, ]; const [value, setValue] = useState([]); const [loading, setLoading] = useState(true); const [isModalVisible, setIsModalVisible] = useState(false); const [isEditNameModalVisible, setIsEditNameModalVisible] = useState(false); const [editedEnv, setEditedEnv] = useState(); const [selectedRowIds, setSelectedRowIds] = useState([]); const [searchText, setSearchText] = useState(''); const [importLoading, setImportLoading] = useState(false); const tableRef = useRef(null); const tableScrollHeight = useTableScrollHeight(tableRef, 59); const getEnvs = () => { setLoading(true); request .get(`${config.apiPrefix}envs?searchValue=${searchText}`) .then(({ code, data }) => { if (code === 200) { setValue(data); } }) .finally(() => setLoading(false)); }; const enabledOrDisabledEnv = (record: any, index: number) => { Modal.confirm({ title: `确认${ record.status === Status.已禁用 ? intl.get('启用') : intl.get('禁用') }`, content: ( <> {intl.get('确认')} {record.status === Status.已禁用 ? intl.get('启用') : intl.get('禁用')} Env{' '} {record.value} {' '} {intl.get('吗')} ), onOk() { request .put( `${config.apiPrefix}envs/${ record.status === Status.已禁用 ? 'enable' : 'disable' }`, [record.id], ) .then(({ code, data }) => { if (code === 200) { message.success( `${ record.status === Status.已禁用 ? intl.get('启用') : intl.get('禁用') }${intl.get('成功')}`, ); const newStatus = record.status === Status.已禁用 ? Status.已启用 : Status.已禁用; const result = [...value]; result.splice(index, 1, { ...record, status: newStatus, }); setValue(result); } }); }, }); }; const addEnv = () => { setEditedEnv(null as any); setIsModalVisible(true); }; const editEnv = (record: any, index: number) => { setEditedEnv(record); setIsModalVisible(true); }; const pinOrUnpinEnv = (record: any, index: number) => { Modal.confirm({ title: `确认${ record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶') }`, content: ( <> {intl.get('确认')} {record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')} Env{' '} {record.name}: {record.value} {' '} {intl.get('吗')} ), onOk() { request .put( `${config.apiPrefix}envs/${ record.isPinned === 1 ? 'unpin' : 'pin' }`, [record.id], ) .then(({ code, data }) => { if (code === 200) { message.success( `${ record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶') }${intl.get('成功')}`, ); getEnvs(); } }); }, }); }; const deleteEnv = (record: any, index: number) => { Modal.confirm({ title: intl.get('确认删除'), content: ( <> {intl.get('确认删除变量')}{' '} {record.name}: {record.value} {' '} {intl.get('吗')} ), onOk() { request .delete(`${config.apiPrefix}envs`, { data: [record.id] }) .then(({ code, data }) => { if (code === 200) { message.success(intl.get('删除成功')); const result = [...value]; result.splice(index, 1); setValue(result); } }); }, }); }; const handleCancel = (env?: any[]) => { setIsModalVisible(false); getEnvs(); }; const handleEditNameCancel = (env?: any[]) => { setIsEditNameModalVisible(false); getEnvs(); }; const [vt, setVT] = useVT( () => ({ scroll: { y: tableScrollHeight } }), [tableScrollHeight], ); const DragableBodyRow = React.forwardRef((props: any, ref) => { const { index, moveRow, className, style, ...restProps } = props; const [{ isOver, dropClassName }, drop] = useDrop({ accept: type, collect: (monitor) => { const { index: dragIndex } = (monitor.getItem() as any) || {}; if (dragIndex === index) { return {}; } return { isOver: monitor.isOver(), dropClassName: dragIndex < index ? ' drop-over-downward' : ' drop-over-upward', }; }, drop: (item: any) => { moveRow(item.index, index); }, }); const [, drag] = useDrag({ type, item: { index }, collect: (monitor) => ({ isDragging: monitor.isDragging(), }), }); useEffect(() => { drop(drag(ref)); }, [ref]); return (
); }); useEffect( () => setVT({ body: { row: DragableBodyRow, }, }), [], ); const moveRow = useCallback( (dragIndex: number, hoverIndex: number) => { if (dragIndex === hoverIndex) { return; } const dragRow = value[dragIndex]; request .put(`${config.apiPrefix}envs/${dragRow.id}/move`, { fromIndex: dragIndex, toIndex: hoverIndex, }) .then(({ code, data }) => { if (code === 200) { const newData = [...value]; newData.splice(dragIndex, 1); newData.splice(hoverIndex, 0, { ...dragRow, ...data }); setValue([...newData]); } }); }, [value], ); const onSelectChange = (selectedIds: any[]) => { setSelectedRowIds(selectedIds); }; const rowSelection = { selectedRowKeys: selectedRowIds, onChange: onSelectChange, }; const delEnvs = () => { Modal.confirm({ title: intl.get('确认删除'), content: <>{intl.get('确认删除选中的变量吗')}, onOk() { request .delete(`${config.apiPrefix}envs`, { data: selectedRowIds }) .then(({ code, data }) => { if (code === 200) { message.success(intl.get('批量删除成功')); setSelectedRowIds([]); getEnvs(); } }); }, }); }; const operateEnvs = (operationStatus: number) => { Modal.confirm({ title: `确认${OperationName[operationStatus]}`, content: ( <> {intl.get('确认')} {OperationName[operationStatus]} {intl.get('选中的变量吗')} ), onOk() { request .put( `${config.apiPrefix}envs/${OperationPath[operationStatus]}`, selectedRowIds, ) .then(({ code, data }) => { if (code === 200) { getEnvs(); } }); }, }); }; const exportEnvs = () => { const envs = value .filter((x) => selectedRowIds.includes(x.id)) .map((x) => ({ value: x.value, name: x.name, remarks: x.remarks })); exportJson('env.json', JSON.stringify(envs)); }; const modifyName = () => { setIsEditNameModalVisible(true); }; const onSearch = (value: string) => { setSearchText(value.trim()); }; const uploadProps: UploadProps = { accept: 'application/json', beforeUpload: async (file) => { const formData = new FormData(); formData.append('env', file); setImportLoading(true); try { const { code, data } = await request.post( `${config.apiPrefix}envs/upload`, formData, ); if (code === 200) { message.success(`成功上传${data.length}个环境变量`); getEnvs(); } setImportLoading(false); } catch (error: any) { setImportLoading(false); } return false; }, fileList: [], }; useEffect(() => { getEnvs(); }, [searchText]); return ( , , , ]} header={{ style: headerStyle, }} >
{selectedRowIds.length > 0 && (
{intl.get('已选择')} {selectedRowIds?.length} {intl.get('项')}
)}
{ return { index, moveRow, } as any; }} /> {isModalVisible && ( )} {isEditNameModalVisible && ( )} ); }; export default Env; ================================================ FILE: src/pages/env/modal.tsx ================================================ import intl from 'react-intl-universal'; import React, { useEffect, useState } from 'react'; import { Modal, message, Input, Form, Radio } from 'antd'; import { request } from '@/utils/http'; import config from '@/utils/config'; const EnvModal = ({ env, handleCancel, }: { env?: any; handleCancel: (cks?: any[]) => void; }) => { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const handleOk = async (values: any) => { setLoading(true); const { value, split, name, remarks } = values; const method = env ? 'put' : 'post'; let payload; if (!env) { if (split === '1') { const symbol = value.includes('&') ? '&' : '\n'; payload = value.split(symbol).map((x: any) => { return { name: name, value: x, remarks: remarks, }; }); } else { payload = [{ value, name, remarks }]; } } else { payload = { ...values, id: env.id }; } try { const { code, data } = await request[method]( `${config.apiPrefix}envs`, payload, ); if (code === 200) { message.success( env ? intl.get('更新变量成功') : intl.get('创建变量成功'), ); handleCancel(data); } setLoading(false); } catch (error: any) { setLoading(false); } }; return ( { form .validateFields() .then((values) => { handleOk(values); }) .catch((info) => { console.log('Validate Failed:', info); }); }} onCancel={() => handleCancel()} confirmLoading={loading} >
{!env && ( {intl.get('是')} {intl.get('否')} )}
); }; export default EnvModal; ================================================ FILE: src/pages/error/index.less ================================================ .error-wrapper { display: flex; justify-content: center; height: 100vh; .react-terminal-wrapper { max-width: 90%; height: calc(100vh - 80px); overflow-y: auto; } .code-box { position: relative; display: inline-block; width: 80vw; height: 90vh; margin: 16px; background-color: #ffffff; border: 1px solid rgba(5, 5, 5, 0.06); border-radius: 6px; -webkit-transition: all 0.2s; transition: all 0.2s; border-radius: 6px 6px 0 0; color: rgba(0, 0, 0, 0.88); border-bottom: 1px solid rgba(5, 5, 5, 0.06); .browser-markup { position: relative; border-top: 2em solid rgba(230, 230, 230, 0.7); border-radius: 3px 3px 0 0; &::before { position: absolute; top: -1.25em; left: 1em; display: block; width: 0.5em; height: 0.5em; background-color: #f44; border-radius: 50%; box-shadow: 0 0 0 2px #f44, 1.5em 0 0 2px #9b3, 3em 0 0 2px #fb5; content: ''; } } .log { height: calc(90vh - 150px); overflow-y: auto; padding: 12px; white-space: pre-line; } } } ================================================ FILE: src/pages/error/index.tsx ================================================ import intl from 'react-intl-universal'; import React, { useState, useEffect, useRef } from 'react'; import config from '@/utils/config'; import { request } from '@/utils/http'; import { PageLoading } from '@ant-design/pro-layout'; import { history, useOutletContext } from '@umijs/max'; import './index.less'; import { SharedContext } from '@/layouts'; import { Alert, Typography } from 'antd'; const Error = () => { const { user } = useOutletContext(); const [loading, setLoading] = useState(false); const [data, setData] = useState(intl.get('暂无日志')); const retryTimes = useRef(1); const loopStatus = (message: string) => { if (retryTimes.current > 3) { setData(message); return; } retryTimes.current += 1; setTimeout(() => { getHealthStatus(false); }, 3000); }; const getHealthStatus = (needLoading: boolean = true) => { needLoading && setLoading(true); request .get(`${config.apiPrefix}health`) .then(({ error, data }) => { if (data?.status === 'ok') { if (retryTimes.current > 1) { setTimeout(() => { window.location.reload(); }); } return; } loopStatus(error?.details); }) .catch((error) => { const responseStatus = error.response.status; if (responseStatus === 401) { history.push('/login'); } else { loopStatus(error.response?.message || error?.message); } }) .finally(() => needLoading && setLoading(false)); }; useEffect(() => { if (user && user.username) { history.push('/crontab'); } }, [user]); useEffect(() => { getHealthStatus(); }, []); return (
{loading ? ( ) : retryTimes.current > 3 ? (
{intl.get('服务启动超时')} } description={
{intl.get('请先按如下方式修复:')}
1. 宿主机执行 docker run --rm -v /var/run/docker.sock:/var/run/docker.sock containrrr/watchtower -cR <容器名>
{intl.get('2. 容器内执行 ql check、ql update')}
{intl.get( '3. 如果无法解决,容器内执行 pm2 logs,拷贝执行结果', )} {intl.get('提交 issue')}
} banner /> {data}
) : ( )}
); }; export default Error; ================================================ FILE: src/pages/initialization/index.less ================================================ @import '~antd/es/style/themes/default.less'; .container { display: flex; flex-direction: column; align-items: center; height: 100vh; height: calc(100vh - var(--vh-offset, 0px)); overflow: auto; background: @layout-body-background; padding-top: 70px; } @media (min-width: @screen-md-min) { .container { background-image: url('https://gw.alipayobjects.com/zos/rmsportal/TVYTbAXWheQpRcWDaDMu.svg'); background-repeat: no-repeat; background-position: center 110px; background-size: 100%; } } .top { text-align: center; } .header { display: flex; align-items: center; flex-direction: column; } .logo { width: 48px; display: block; margin-bottom: 24px; margin-top: 20px; } .title { font-size: 20px; margin-bottom: 16px; } .desc { margin-top: 12px; margin-bottom: 40px; color: @text-color-secondary; font-size: @font-size-base; } .main { padding: 20px; border-radius: 6px; background-color: #f6f8fa; border: 1px solid #ebedef; display: flex; max-width: 500px; width: 90%; height: 400px; .ant-steps { width: 35%; min-width: 110px; display: flex; align-items: center; position: relative; top: 6%; } .steps-container { flex: 1; overflow-y: auto; } } .extra { margin-top: 20px; } ================================================ FILE: src/pages/initialization/index.tsx ================================================ import intl from 'react-intl-universal'; import React, { Fragment, useEffect, useState } from 'react'; import { Button, Row, Input, Form, message, Typography, Steps, Select, } from 'antd'; import config from '@/utils/config'; import { history } from '@umijs/max'; import styles from './index.less'; import { request } from '@/utils/http'; const FormItem = Form.Item; const { Step } = Steps; const { Option } = Select; const { Link } = Typography; const Initialization = () => { const [loading, setLoading] = useState(false); const [current, setCurrent] = React.useState(0); const [fields, setFields] = useState([]); const next = () => { setCurrent(current + 1); }; const prev = () => { setCurrent(current - 1); }; const submitAccountSetting = (values: any) => { setLoading(true); request .put(`${config.apiPrefix}user/init`, { username: values.username, password: values.password, }) .then(({ code, data }) => { if (code === 200) { next(); } }) .finally(() => setLoading(false)); }; const submitNotification = (values: any) => { setLoading(true); request .put(`${config.apiPrefix}user/notification/init`, values) .then(({ code, data }) => { if (code === 200) { next(); } }) .finally(() => setLoading(false)); }; const notificationModeChange = (value: string) => { const _fields = (config.notificationModeMap as any)[value]; setFields(_fields || []); }; useEffect(() => { localStorage.removeItem(config.authKey); }, []); const steps = [ { title: intl.get('欢迎使用'), content: (
{intl.get('欢迎使用青龙')} {intl.get( '支持python3、javascript、shell、typescript 的定时任务管理面板', )}
), }, { title: intl.get('通知设置'), content: (
{fields.map((x) => ( ))} ), }, { title: intl.get('账户设置'), content: (
({ validator(_, value) { if (!value || getFieldValue('password') === value) { return Promise.resolve(); } return Promise.reject( new Error(intl.get('您输入的两个密码不匹配!')), ); }, }), ]} > ), }, { title: intl.get('完成安装'), content: (
{intl.get('恭喜安装完成!')} Github {intl.get('Telegram频道')}
), }, ]; return (
logo {intl.get('初始化配置')}
{steps.map((item) => ( ))}
{steps[current].content}
); }; export default Initialization; ================================================ FILE: src/pages/log/index.module.less ================================================ @import '~antd/es/style/themes/default.less'; @import '~@/styles/variable.less'; .left-tree { &-container { overflow: hidden; position: relative; background-color: @component-background; height: 100%; display: flex; flex-direction: column; } &-scroller { flex: 1; overflow: auto; padding-top: 6px; } } .log-container { display: flex; position: relative; } :global { .Pane.vertical.Pane1 { padding-right: 5px; border-right: 1px dashed @text-color; } } ================================================ FILE: src/pages/log/index.tsx ================================================ import useFilterTreeData from '@/hooks/useFilterTreeData'; import { SharedContext } from '@/layouts'; import { depthFirstSearch } from '@/utils'; import config from '@/utils/config'; import { request } from '@/utils/http'; import { CloudDownloadOutlined, DeleteOutlined } from '@ant-design/icons'; import { PageContainer } from '@ant-design/pro-layout'; import Editor from '@monaco-editor/react'; import CodeMirror from '@uiw/react-codemirror'; import { useOutletContext } from '@umijs/max'; import { Button, Empty, Input, message, Modal, Tooltip, Tree, TreeSelect, Typography, } from 'antd'; import { saveAs } from 'file-saver'; import debounce from 'lodash/debounce'; import uniq from 'lodash/uniq'; import prettyBytes from 'pretty-bytes'; import { Key, useCallback, useEffect, useRef, useState } from 'react'; import intl from 'react-intl-universal'; import SplitPane from 'react-split-pane'; import styles from './index.module.less'; const { Text } = Typography; const Log = () => { const { headerStyle, isPhone, theme } = useOutletContext(); const [value, setValue] = useState(intl.get('请选择日志文件')); const [select, setSelect] = useState(intl.get('请选择日志文件')); const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const [height, setHeight] = useState(); const treeDom = useRef(); const [expandedKeys, setExpandedKeys] = useState([]); const [currentNode, setCurrentNode] = useState(); const [searchValue, setSearchValue] = useState(''); const getLogs = () => { setLoading(true); request .get(`${config.apiPrefix}logs`) .then(({ code, data }) => { if (code === 200) { setData(data); } }) .finally(() => setLoading(false)); }; const getLog = (node: any) => { request .get( `${config.apiPrefix}logs/detail?file=${node.title}&path=${ node.parent || '' }`, ) .then(({ code, data }) => { if (code === 200) { setValue(data); } }); }; const downloadLog = () => { request .post( `${config.apiPrefix}logs/download`, { filename: currentNode.title, path: currentNode.parent || '', }, { responseType: 'blob' }, ) .then((res) => { saveAs(res, currentNode.title); }); }; const onSelect = (value: any, node: any) => { if (node.key === select || !value) { return; } setCurrentNode(node); setSelect(value); if (node.type === 'directory') { setValue(intl.get('请选择日志文件')); return; } setValue(intl.get('加载中...')); getLog(node); }; const onTreeSelect = useCallback((keys: Key[], e: any) => { onSelect(keys[0], e.node); }, []); const onSearch = useCallback( (e) => { const keyword = e.target.value; debounceSearch(keyword); }, [data], ); const debounceSearch = useCallback( debounce((keyword) => { setSearchValue(keyword); }, 300), [data], ); const { treeData: filterData, keys: searchExpandedKeys } = useFilterTreeData( data, searchValue, { treeNodeFilterProp: 'title' }, ); useEffect(() => { setExpandedKeys(uniq([...expandedKeys, ...searchExpandedKeys])); }, [searchExpandedKeys]); const deleteFile = () => { Modal.confirm({ title: `确认删除`, content: ( <> {intl.get('确认删除')} {' '} {select}{' '} {intl.get('文件')} {currentNode.type === 'directory' ? intl.get('夹下所有日志') : ''} {intl.get(',删除后不可恢复')} ), onOk() { request .delete(`${config.apiPrefix}logs`, { data: { filename: currentNode.title, path: currentNode.parent || '', type: currentNode.type, }, }) .then(({ code }) => { if (code === 200) { message.success(`删除成功`); let newData = [...data]; if (currentNode.parent) { newData = depthFirstSearch( newData, (c) => c.key === currentNode.key, ); } else { const index = newData.findIndex( (x) => x.key === currentNode.key, ); if (index !== -1) { newData.splice(index, 1); } } setData(newData); initState(); } }); }, }); }; const initState = () => { setSelect(''); setCurrentNode(null); setValue(intl.get('请选择脚本文件')); }; const onExpand = (expKeys: any) => { setExpandedKeys(expKeys); }; useEffect(() => { getLogs(); }, []); useEffect(() => { if (treeDom.current) { setHeight(treeDom.current.clientHeight); } }, [treeDom.current, data]); return ( {select} {currentNode?.type === 'file' && ( {prettyBytes(currentNode.size)} )} } loading={loading} extra={ isPhone ? [ , ] : [ ) : (
{waitTime ? ( ) : ( )} )}
{twoFactor ? (
{intl.get( '在您的设备上打开两步验证应用程序以查看您的身份验证代码并验证您的身份。', )}
) : ( '' )}
); }; export default Login; ================================================ FILE: src/pages/script/components/UnsupportedFilePreview/index.module.less ================================================ .container { height: 100%; display: flex; align-items: center; justify-content: center; background: var(--background-color); padding: 16px; } .content { text-align: center; background: var(--card-background); padding: 24px; border-radius: 12px; max-width: 390px; width: 100%; transition: all 0.3s ease; } .iconWrapper { display: inline-flex; align-items: center; justify-content: center; width: 64px; height: 64px; border-radius: 50%; background: var(--background-color); margin-bottom: 16px; } .icon { font-size: 32px; color: var(--text-color-secondary); } .message { font-size: 16px; color: var(--text-color); margin-bottom: 16px; font-weight: 500; line-height: 1.5; } .actionArea { width: 100%; } .button { min-width: 140px; height: 36px; font-size: 14px; } .warning { font-size: 13px; color: var(--text-color-secondary); line-height: 1.5; display: flex; align-items: center; justify-content: center; gap: 6px; } .warningIcon { font-size: 14px; color: #faad14; } ================================================ FILE: src/pages/script/components/UnsupportedFilePreview/index.tsx ================================================ import React from 'react'; import { Button, Space } from 'antd'; import { FileUnknownOutlined, WarningOutlined } from '@ant-design/icons'; import intl from 'react-intl-universal'; import styles from './index.module.less'; interface UnsupportedFilePreviewProps { onForceOpen: () => void; } const UnsupportedFilePreview: React.FC = ({ onForceOpen, }) => { return (
{intl.get('当前文件不支持预览')}
{intl.get('强制打开可能会导致编辑器显示异常')}
); }; export default UnsupportedFilePreview; ================================================ FILE: src/pages/script/editModal.tsx ================================================ import intl from 'react-intl-universal'; import React, { useEffect, useState, useRef, useCallback, useReducer, } from 'react'; import { Drawer, Button, Tabs, Badge, Select, TreeSelect } from 'antd'; import { request } from '@/utils/http'; import config from '@/utils/config'; import SplitPane from 'react-split-pane'; import Editor from '@monaco-editor/react'; import SaveModal from './saveModal'; import SettingModal from './setting'; import { useTheme } from '@/utils/hooks'; import { getEditorMode, logEnded } from '@/utils'; import WebSocketManager from '@/utils/websocket'; import Ansi from 'ansi-to-react'; const { Option } = Select; const EditModal = ({ treeData, currentNode, content, handleCancel, }: { treeData?: any; content?: string; currentNode: any; handleCancel: () => void; }) => { const [value, setValue] = useState(''); const [language, setLanguage] = useState(); const [cNode, setCNode] = useState(); const [selectedKey, setSelectedKey] = useState(); const [saveModalVisible, setSaveModalVisible] = useState(false); const [settingModalVisible, setSettingModalVisible] = useState(false); const [log, setLog] = useState(''); const { theme } = useTheme(); const editorRef = useRef(null); const [isRunning, setIsRunning] = useState(false); const [currentPid, setCurrentPid] = useState(null); const cancel = () => { handleCancel(); }; const onSelect = (value: any, node: any) => { if (node.key === selectedKey || !value) { return; } if (node.type === 'directory') { return; } const newMode = getEditorMode(value); setCNode(node); setLanguage(newMode); getDetail(node); setSelectedKey(node.key); }; const getDetail = (node: any) => { request .get( `${config.apiPrefix}scripts/detail?file=${node.title}&path=${ node.parent || '' }`, ) .then(({ code, data }) => { if (code === 200) { setValue(data); } }); }; const run = () => { setLog(''); const content = editorRef.current.getValue().replace(/\r\n/g, '\n'); request .put(`${config.apiPrefix}scripts/run`, { filename: cNode.title, path: cNode.parent || '', content, }) .then(({ code, data }) => { if (code === 200) { setIsRunning(true); setCurrentPid(data); } }); }; const stop = () => { if (!cNode || !cNode.title || !currentPid) { return; } request .put(`${config.apiPrefix}scripts/stop`, { filename: cNode.title, path: cNode.parent || '', pid: currentPid, }) .then(({ code, data }) => { if (code === 200) { setIsRunning(false); } }); }; const handleMessage = useCallback((payload: any) => { let { message: _message } = payload; if (logEnded(_message)) { setTimeout(() => { setIsRunning(false); }, 300); } setLog((p) => `${p}${_message}`); }, []); useEffect(() => { const ws = WebSocketManager.getInstance(); ws.subscribe('manuallyRunScript', handleMessage); return () => { ws.unsubscribe('manuallyRunScript', handleMessage); }; }, []); useEffect(() => { setLog(''); if (currentNode) { setCNode(currentNode); setValue(content as string); setSelectedKey(currentNode.key); const newMode = getEditorMode(currentNode.title); setLanguage(newMode); } }, [content, currentNode]); return ( {/* */} } width={'100%'} headerStyle={{ padding: '11px 24px' }} onClose={cancel} open={true} > {/* @ts-ignore */} { editorRef.current = editor; }} />
          {log}
        
{saveModalVisible && ( { setSaveModalVisible(false); }} file={{ content: editorRef.current && editorRef.current.getValue().replace(/\r\n/g, '\n'), ...cNode, }} /> )} {settingModalVisible && ( { setSettingModalVisible(false); }} /> )}
); }; export default EditModal; ================================================ FILE: src/pages/script/editNameModal.tsx ================================================ import intl from 'react-intl-universal'; import React, { useEffect, useState } from 'react'; import { Modal, message, Input, Form, Select, Upload, Radio, TreeSelect, } from 'antd'; import { request } from '@/utils/http'; import config from '@/utils/config'; import { UploadOutlined } from '@ant-design/icons'; const { Option } = Select; const EditScriptNameModal = ({ handleCancel, treeData, }: { treeData: any[]; handleCancel: (file?: { filename: string; path: string; key: string; type: string; }) => void; }) => { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const [dirs, setDirs] = useState([]); const [file, setFile] = useState(); const [type, setType] = useState<'blank' | 'upload' | 'directory'>('blank'); const handleOk = async (values: any) => { setLoading(true); const { path = '', filename: inputFilename, directory = '' } = values; const formData = new FormData(); formData.append('file', file || ''); formData.append('filename', file?.name || inputFilename); formData.append('path', path); formData.append('content', ''); formData.append('directory', directory); request .post(`${config.apiPrefix}scripts`, formData) .then(({ code, data }) => { if (code === 200) { message.success( directory ? intl.get('创建文件夹成功') : intl.get('创建文件成功'), ); const key = path ? `${path}/` : ''; const filename = file ? file.name : directory || inputFilename; handleCancel({ filename, path, key: `${key}${filename}`, type: directory ? 'directory' : 'file', }); } setLoading(false); }) .finally(() => setLoading(false)); }; const beforeUpload = (file: File) => { setFile(file); return false; }; const typeChange = (e) => { setType(e.target.value); }; const getDirs = (data) => { for (const item of data) { if (item.children && item.children.length > 0) { item.children = item.children .filter((x) => x.type === 'directory') .map((x) => ({ ...x, disabled: false })); getDirs(item.children); } } return data; }; useEffect(() => { const originDirs = treeData .filter((x) => x.type === 'directory') .map((x) => ({ ...x, disabled: false })); const dirs = getDirs(originDirs); setDirs(dirs); }, [treeData]); return ( { form .validateFields() .then((values) => { handleOk(values); }) .catch((info) => { console.log('Validate Failed:', info); }); }} onCancel={() => handleCancel()} confirmLoading={loading} >
{intl.get('空文件')} {intl.get('本地文件')} {intl.get('文件夹')} {type === 'blank' && ( value.includes('/') ? Promise.reject(new Error(intl.get('文件名不能包含斜杠'))) : Promise.resolve(), }, ]} > )} {type === 'directory' && ( )} {type === 'upload' && (

{intl.get('点击或者拖拽文件到此区域上传')}

)}
); }; export default EditScriptNameModal; ================================================ FILE: src/pages/script/index.module.less ================================================ @import '~antd/es/style/themes/default.less'; @import '~@/styles/variable.less'; .left-tree { &-container { overflow: hidden; position: relative; background-color: @component-background; height: 100%; display: flex; flex-direction: column; } &-scroller { flex: 1; overflow: auto; padding-top: 6px; } } .log-container { display: flex; position: relative; } :global { .Pane.vertical.Pane1 { padding-right: 5px; border-right: 1px dashed @text-color; } } ================================================ FILE: src/pages/script/index.tsx ================================================ import IconFont from '@/components/iconfont'; import useFilterTreeData from '@/hooks/useFilterTreeData'; import { SharedContext } from '@/layouts'; import { depthFirstSearch, findNode, getEditorMode } from '@/utils'; import config from '@/utils/config'; import { request } from '@/utils/http'; import { canPreviewInMonaco } from '@/utils/monaco'; import { CloudDownloadOutlined, DeleteOutlined, EditOutlined, EllipsisOutlined, PlusOutlined, } from '@ant-design/icons'; import { PageContainer } from '@ant-design/pro-layout'; import Editor from '@monaco-editor/react'; import { langs } from '@uiw/codemirror-extensions-langs'; import CodeMirror from '@uiw/react-codemirror'; import { history, useOutletContext } from '@umijs/max'; import { Button, Dropdown, Empty, Input, MenuProps, message, Modal, Tooltip, Tree, TreeSelect, Typography, } from 'antd'; import { saveAs } from 'file-saver'; import debounce from 'lodash/debounce'; import uniq from 'lodash/uniq'; import prettyBytes from 'pretty-bytes'; import { parse } from 'query-string'; import { Key, useCallback, useEffect, useRef, useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import intl from 'react-intl-universal'; import SplitPane from 'react-split-pane'; import EditModal from './editModal'; import EditScriptNameModal from './editNameModal'; import styles from './index.module.less'; import RenameModal from './renameModal'; import UnsupportedFilePreview from './components/UnsupportedFilePreview'; const { Text } = Typography; const Script = () => { const { headerStyle, isPhone, theme } = useOutletContext(); const [value, setValue] = useState(intl.get('请选择脚本文件')); const [select, setSelect] = useState(intl.get('请选择脚本文件')); const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const [mode, setMode] = useState(''); const [height, setHeight] = useState(); const treeDom = useRef(); const [isLogModalVisible, setIsLogModalVisible] = useState(false); const [searchValue, setSearchValue] = useState(''); const [isEditing, setIsEditing] = useState(false); const editorRef = useRef(null); const [isAddFileModalVisible, setIsAddFileModalVisible] = useState(false); const [isRenameFileModalVisible, setIsRenameFileModalVisible] = useState(false); const [currentNode, setCurrentNode] = useState(); const [expandedKeys, setExpandedKeys] = useState([]); const [showMonaco, setShowMonaco] = useState(true); const handleIsEditing = (filename: string, value: boolean) => { setIsEditing(value && canPreviewInMonaco(filename)); }; const getScripts = (needLoading: boolean = true) => { needLoading && setLoading(true); request .get(`${config.apiPrefix}scripts`) .then(({ code, data }) => { if (code === 200) { setData(data); initState(); initGetScript(data); } }) .finally(() => needLoading && setLoading(false)); }; const getDetail = (node: any, options: any = {}) => { request .get( `${config.apiPrefix}scripts/detail?file=${encodeURIComponent( node.title, )}&path=${node.parent || ''}`, ) .then(({ code, data }) => { if (code === 200) { setValue(data); if (options.callback) { options.callback(); } } }); }; const downloadScript = () => { request .post( `${config.apiPrefix}scripts/download`, { filename: currentNode.title, path: currentNode.parent || '', }, { responseType: 'blob' }, ) .then((res) => { saveAs(res, currentNode.title); }); }; const initGetScript = (_data: any) => { const { p, s } = parse(history.location.search); if (s) { const vkey = `${p}/${s}`; const obj = { node: { title: s, key: p ? vkey : s, parent: p, }, }; const item = findNode(_data, (c) => c.key === obj.node.key); if (item) { obj.node = item; setExpandedKeys([p as string]); onTreeSelect([vkey], obj); } } }; const onSelect = (value: any, node: any) => { if (node.key === select || !value) { return; } setSelect(node.key); setCurrentNode(node); if (node.type === 'directory') { setValue(intl.get('请选择脚本文件')); setShowMonaco(true); return; } if (!canPreviewInMonaco(node.title)) { setShowMonaco(false); return; } setShowMonaco(true); const newMode = getEditorMode(value); setMode(isPhone && newMode === 'typescript' ? 'javascript' : newMode); setValue(intl.get('加载中...')); getDetail(node, { callback: () => { if (isEditing) { setIsEditing(true); } }, }); }; const onTreeSelect = useCallback( (keys: Key[], e: any) => { const node = e.node; if (node.key === select && isEditing) { return; } const currentContent = editorRef.current ? editorRef.current.getValue().replace(/\r\n/g, '\n') : value; const originalContent = value.replace(/\r\n/g, '\n'); if (currentContent !== originalContent && isEditing) { Modal.confirm({ title: intl.get('确认离开'), content: <>{intl.get('当前文件未保存,确认离开吗')}, onOk() { onSelect(keys[0], e.node); handleIsEditing(e.node.title, false); }, }); } else { handleIsEditing(e.node.title, false); onSelect(keys[0], e.node); } }, [value, select, isEditing], ); const onSearch = useCallback( (e) => { const keyword = e.target.value; debounceSearch(keyword); }, [data], ); const debounceSearch = useCallback( debounce((keyword) => { setSearchValue(keyword); }, 300), [data], ); const { treeData: filterData, keys: searchExpandedKeys } = useFilterTreeData( data, searchValue, { treeNodeFilterProp: 'title' }, ); useEffect(() => { setExpandedKeys(uniq([...expandedKeys, ...searchExpandedKeys])); }, [searchExpandedKeys]); const onExpand = (expKeys: any) => { setExpandedKeys(expKeys); }; const onDoubleClick = (e: any, node: any) => { if (node.type === 'file') { setSelect(node.key); setCurrentNode(node); handleIsEditing(node.title, true); } }; const editFile = () => { setTimeout(() => { handleIsEditing(currentNode.title, true); }, 300); }; const cancelEdit = () => { handleIsEditing(currentNode.title, false); setValue(intl.get('加载中...')); getDetail(currentNode); }; const saveFile = () => { Modal.confirm({ title: `确认保存`, content: ( <> {intl.get('确认保存文件')} {' '} {currentNode.title} {intl.get(',保存后不可恢复')} ), onOk() { const content = editorRef.current ? editorRef.current.getValue().replace(/\r\n/g, '\n') : value; return new Promise((resolve, reject) => { request .put(`${config.apiPrefix}scripts`, { filename: currentNode.title, path: currentNode.parent || '', content, }) .then(({ code, data }) => { if (code === 200) { message.success(`保存成功`); setValue(content); handleIsEditing(currentNode.title, false); } resolve(null); }) .catch((e) => reject(e)); }); }, }); }; const deleteFile = () => { Modal.confirm({ title: `确认删除`, content: ( <> {intl.get('确认删除')} {' '} {select}{' '} {intl.get('文件')} {currentNode.type === 'directory' ? intl.get('夹及其子文件') : ''} {intl.get(',删除后不可恢复')} ), onOk() { request .delete(`${config.apiPrefix}scripts`, { data: { filename: currentNode.title, path: currentNode.parent || '', type: currentNode.type, }, }) .then(({ code }) => { if (code === 200) { message.success(`删除成功`); let newData = [...data]; if (currentNode.parent) { newData = depthFirstSearch( newData, (c) => c.key === currentNode.key, ); } else { const index = newData.findIndex( (x) => x.key === currentNode.key, ); if (index !== -1) { newData.splice(index, 1); } } setData(newData); initState(); } }); }, }); }; const renameFile = () => { setIsRenameFileModalVisible(true); }; const handleRenameFileCancel = () => { setIsRenameFileModalVisible(false); getScripts(false); }; const addFile = () => { setIsAddFileModalVisible(true); }; const addFileModalClose = async ( { filename, path, key, type, }: { filename: string; path: string; key: string; type?: string } = { filename: '', path: '', key: '', }, ) => { if (filename) { const res = await request.get(`${config.apiPrefix}scripts`); let newData = res.data; if (type === 'directory' && filename.includes('/')) { const parts = filename.split('/'); parts.pop(); const parentPath = parts.join('/'); path = path ? `${path}/${parentPath}` : parentPath; } const item = findNode(newData, (c) => c.key === key); if (path) { const keys = path.split('/'); const sKeys: string[] = []; keys.reduce((p, c) => { sKeys.push(p); return `${p}/${c}`; }); setExpandedKeys([...expandedKeys, ...sKeys, path]); } setData(newData); onSelect(item.title, item); handleIsEditing(item.title, true); } setIsAddFileModalVisible(false); }; const initState = () => { setSelect(intl.get('请选择脚本文件')); setCurrentNode(null); setValue(intl.get('请选择脚本文件')); }; useEffect(() => { getScripts(); }, []); useEffect(() => { if (treeDom.current) { setHeight(treeDom.current.clientHeight - 6); } }, [treeDom.current, data]); useHotkeys( 'mod+s', (e) => { if (isEditing) { saveFile(); } }, { enableOnFormTags: ['textarea'], preventDefault: true }, ); useHotkeys( 'mod+d', (e) => { if (currentNode.title) { deleteFile(); } }, { preventDefault: true }, ); useHotkeys( 'mod+o', (e) => { if (!isEditing) { addFile(); } }, { preventDefault: true }, ); useHotkeys( 'mod+e', (e) => { if (currentNode.title) { cancelEdit(); } }, { preventDefault: true }, ); const action = (key: string | number) => { switch (key) { case 'save': saveFile(); break; case 'exit': cancelEdit(); break; default: break; } }; const menuAction = (key: string | number) => { switch (key) { case 'add': addFile(); break; case 'edit': editFile(); break; case 'delete': deleteFile(); break; case 'rename': renameFile(); break; default: break; } }; const menu: MenuProps = isEditing ? { items: [ { label: intl.get('保存'), key: 'save', icon: }, { label: intl.get('退出编辑'), key: 'exit', icon: }, ], onClick: ({ key, domEvent }) => { domEvent.stopPropagation(); action(key); }, } : { items: [ { label: intl.get('创建'), key: 'add', icon: }, { label: intl.get('编辑'), key: 'edit', icon: , disabled: !currentNode, }, { label: intl.get('重命名'), key: 'rename', icon: , disabled: !currentNode, }, { label: intl.get('删除'), key: 'delete', icon: , disabled: !currentNode, }, ], onClick: ({ key, domEvent }) => { domEvent.stopPropagation(); menuAction(key); }, }; const handleForceOpen = () => { if (!currentNode) return; setMode('plaintext'); setValue(intl.get('加载中...')); setShowMonaco(true); getDetail(currentNode, { callback: () => { setIsEditing(true); }, }); }; return ( {select} {currentNode?.type === 'file' && ( {prettyBytes(currentNode.size)} )} } loading={loading} extra={ isPhone ? [ , , , ] : [ , ] } header={{ style: headerStyle, }} >
{!isPhone && ( /*// @ts-ignore*/
{data.length > 0 ? ( <>
) : (
)}
{showMonaco ? ( { editorRef.current = editor; }} /> ) : ( )}
)} {isPhone && ( { setValue(value); }} /> )} {isLogModalVisible && isLogModalVisible && ( { setIsLogModalVisible(false); }} /> )} {isAddFileModalVisible && ( )} {isRenameFileModalVisible && ( )}
); }; export default Script; ================================================ FILE: src/pages/script/renameModal.tsx ================================================ import intl from 'react-intl-universal'; import React, { useEffect, useState } from 'react'; import { Modal, message, Input, Form } from 'antd'; import { request } from '@/utils/http'; import config from '@/utils/config'; const RenameModal = ({ currentNode, handleCancel, }: { currentNode?: any; handleCancel: () => void; }) => { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const handleOk = async (values: any) => { setLoading(true); try { const { code, data } = await request.put( `${config.apiPrefix}scripts/rename`, { filename: currentNode.title, path: currentNode.parent || '', newFilename: values.name, }, ); if (code === 200) { message.success(intl.get('更新名称成功')); handleCancel(); } setLoading(false); } catch (error) { setLoading(false); } }; return ( { form .validateFields() .then((values) => { handleOk(values); }) .catch((info) => { console.log('Validate Failed:', info); }); }} onCancel={() => handleCancel()} confirmLoading={loading} >
); }; export default RenameModal; ================================================ FILE: src/pages/script/saveModal.tsx ================================================ import intl from 'react-intl-universal'; import React, { useEffect, useState } from 'react'; import { Modal, message, Input, Form } from 'antd'; import { request } from '@/utils/http'; import config from '@/utils/config'; const SaveModal = ({ file, handleCancel, }: { file?: any; handleCancel: (cks?: any[]) => void; }) => { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const handleOk = async (values: any) => { setLoading(true); const payload = { ...values, originFilename: file.title, content: file.content }; request .post(`${config.apiPrefix}scripts`, payload) .then(({ code, data }) => { if (code === 200) { message.success(intl.get('保存文件成功')); handleCancel(data); } }) .finally(() => { setLoading(false); }); }; return ( { form .validateFields() .then((values) => { handleOk(values); }) .catch((info) => { console.log('Validate Failed:', info); }); }} onCancel={() => handleCancel()} confirmLoading={loading} >
); }; export default SaveModal; ================================================ FILE: src/pages/script/setting.tsx ================================================ import intl from 'react-intl-universal'; import React, { useEffect, useState } from 'react'; import { Modal, message, Input, Form } from 'antd'; import { request } from '@/utils/http'; import config from '@/utils/config'; const SettingModal = ({ file, handleCancel, }: { file?: any; handleCancel: (cks?: any[]) => void; }) => { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const handleOk = async (values: any) => { setLoading(true); const payload = { ...file, ...values }; request .post(`${config.apiPrefix}scripts`, payload) .then(({ code, data }) => { if (code === 200) { message.success(intl.get('保存文件成功')); handleCancel(data); } setLoading(false); }); }; return ( handleCancel()} >
); }; export default SettingModal; ================================================ FILE: src/pages/setting/about.tsx ================================================ import intl from 'react-intl-universal'; import React, { useEffect, useState } from 'react'; import { Typography, Input, Form, Button, message, Descriptions } from 'antd'; import styles from './index.less'; import { SharedContext } from '@/layouts'; import dayjs from 'dayjs'; const { Link } = Typography; const About = ({ systemInfo }: { systemInfo: SharedContext['systemInfo'] }) => { return (
logo
{intl.get('青龙')} {intl.get( '支持python3、javascript、shell、typescript 的定时任务管理面板', )} {systemInfo?.branch === 'develop' ? intl.get('开发版') : intl.get('正式版')}{' '} v{systemInfo.version} {dayjs(systemInfo.publishTime * 1000).format('YYYY-MM-DD HH:mm')} {intl.get('查看')}
Github {intl.get('Telegram频道')} {intl.get('提交BUG')}
); }; export default About; ================================================ FILE: src/pages/setting/appModal.tsx ================================================ import intl from 'react-intl-universal'; import React, { useEffect, useState } from 'react'; import { Modal, message, Input, Form, Select } from 'antd'; import { request } from '@/utils/http'; import config from '@/utils/config'; const AppModal = ({ app, handleCancel, }: { app?: any; handleCancel: (needUpdate?: boolean) => void; }) => { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const handleOk = async (values: any) => { setLoading(true); const method = app ? 'put' : 'post'; const payload = { ...values }; if (app) { payload.id = app.id; } try { const { code, data } = await request[method]( `${config.apiPrefix}apps`, payload, ); if (code === 200) { message.success( app ? intl.get('更新应用成功') : intl.get('创建应用成功'), ); handleCancel(data); } setLoading(false); } catch (error) { setLoading(false); } }; return ( { form .validateFields() .then((values) => { handleOk(values); }) .catch((info) => { console.log('Validate Failed:', info); }); }} onCancel={() => handleCancel()} confirmLoading={loading} >
['system'].includes(value) ? Promise.reject(new Error(intl.get('名称不能为保留关键字'))) : Promise.resolve(), }, ]} >
); }; export default AppModal; ================================================ FILE: src/pages/setting/checkUpdate.tsx ================================================ import { disableBody } from "@/utils"; import config from "@/utils/config"; import { request } from "@/utils/http"; import WebSocketManager from "@/utils/websocket"; import Ansi from "ansi-to-react"; import { Button, Modal, Statistic, message } from "antd"; import { useCallback, useEffect, useRef, useState } from "react"; import intl from "react-intl-universal"; const { Countdown } = Statistic; const CheckUpdate = ({ systemInfo }: any) => { const [updateLoading, setUpdateLoading] = useState(false); const [value, setValue] = useState(""); const modalRef = useRef(); const checkUpgrade = () => { if (updateLoading) return; setUpdateLoading(true); message.loading(intl.get("检查更新中..."), 0); request .put(`${config.apiPrefix}system/update-check`) .then(({ code, data }) => { message.destroy(); if (code === 200) { if (data.hasNewVersion) { showConfirmUpdateModal(data); } else { showForceUpdateModal(data); } } }) .catch((error: any) => { message.destroy(); console.log(error); }) .finally(() => { setUpdateLoading(false); }); }; const showForceUpdateModal = (data: any) => { Modal.confirm({ width: 500, title: intl.get("更新"), content: ( <>
{intl.get("已经是最新版了!")}
{intl.get("青龙")} {data.lastVersion}{" "} {intl.get("是目前检测到的最新可用版本了。")}
), okText: intl.get("重新下载"), onOk() { showUpdatingModal(); request .put(`${config.apiPrefix}system/update`) .then((_data: any) => { }) .catch((error: any) => { console.log(error); }); }, }); }; const showConfirmUpdateModal = (data: any) => { const { lastVersion, lastLog } = data; Modal.confirm({ width: 500, title: ( <>
{intl.get("更新可用")}
{intl.get("新版本")} {lastVersion}{" "} {intl.get("可用,你使用的版本为")} {systemInfo.version}。
), content: (
          {lastLog}
        
), okText: intl.get("下载更新"), cancelText: intl.get("以后再说"), onOk() { showUpdatingModal(); request .put(`${config.apiPrefix}system/update`) .then((_data: any) => { }) .catch((error: any) => { console.log(error); }); }, }); }; const showUpdatingModal = () => { setValue(""); modalRef.current = Modal.info({ width: 600, maskClosable: false, closable: false, keyboard: false, okButtonProps: { disabled: true }, title: intl.get("下载更新中..."), centered: true, content: (
          {value}
        
), }); }; const reloadSystem = (type?: string) => { request .put(`${config.apiPrefix}update/${type}`) .then((_data: any) => { message.success({ content: ( {intl.get("系统将在")} {intl.get("秒后自动刷新")} ), duration: 30, }); disableBody(); setTimeout(() => { window.location.reload(); }, 30000); }) .catch((error: any) => { console.log(error); }); }; const showReloadModal = () => { Modal.confirm({ width: 600, maskClosable: false, title: intl.get("确认重启"), centered: true, content: intl.get("系统安装包下载成功,确认重启"), okText: intl.get("重启"), onOk() { reloadSystem("system"); }, onCancel() { modalRef.current.update({ maskClosable: true, closable: true, okButtonProps: { disabled: false }, }); }, }); }; useEffect(() => { if (!value) return; const updateFailed = value.includes("失败,请检查"); modalRef.current.update({ maskClosable: updateFailed, closable: updateFailed, okButtonProps: { disabled: !updateFailed }, content: ( <>
            {value}
          
), }); }, [value]); const handleMessage = useCallback((payload: any) => { let { message: _message } = payload; const updateFailed = _message.includes("失败,请检查"); if (updateFailed) { message.error(intl.get("更新失败,请检查网络及日志或稍后再试")); } setTimeout(() => { document .querySelector("#log-identifier") ?.scrollIntoView({ behavior: "smooth" }); }, 600); if (_message.includes("更新包下载成功")) { setTimeout(() => { showReloadModal(); }, 1000); } setValue((p) => `${p}${_message}`); }, []); useEffect(() => { const ws = WebSocketManager.getInstance(); ws.subscribe("updateSystemVersion", handleMessage); return () => { ws.unsubscribe("updateSystemVersion", handleMessage); }; }, []); return ( <> ); }; export default CheckUpdate; ================================================ FILE: src/pages/setting/dependence.tsx ================================================ import intl from 'react-intl-universal'; import React, { useState, useEffect, useRef } from 'react'; import { Button, InputNumber, Form, message, Input, Alert, Select } from 'antd'; import config from '@/utils/config'; import { request } from '@/utils/http'; import './index.less'; import Ansi from 'ansi-to-react'; import pick from 'lodash/pick'; import WebSocketManager from '@/utils/websocket'; const dataMap = { 'dependence-proxy': 'dependenceProxy', 'node-mirror': 'nodeMirror', 'python-mirror': 'pythonMirror', 'linux-mirror': 'linuxMirror', }; const Dependence = () => { const [systemConfig, setSystemConfig] = useState<{ dependenceProxy?: string; nodeMirror?: string; pythonMirror?: string; linuxMirror?: string; }>(); const [form] = Form.useForm(); const [log, setLog] = useState(''); const [loading, setLoading] = useState(false); const [cleanType, setCleanType] = useState('node'); const getSystemConfig = () => { request .get(`${config.apiPrefix}system/config`) .then(({ code, data }) => { if (code === 200 && data.info) { setSystemConfig(data.info); } }) .catch((error: any) => { console.log(error); }); }; const updateSystemConfigStream = (path: keyof typeof dataMap) => { setLoading(true); setLog('in progress...\n'); request .put( `${config.apiPrefix}system/config/${path}`, pick(systemConfig, dataMap[path]), ) .then((res) => {}) .catch(() => { setLoading(false); setLog((p) => `${p}update mirror error`); }); }; const updateSystemConfig = (path: keyof typeof dataMap) => { setLoading(true); setLog(''); request .put( `${config.apiPrefix}system/config/${path}`, pick(systemConfig, dataMap[path]), ) .then(({ code, data }) => { if (code === 200) { message.success(intl.get('更新成功')); } }) .catch((error: any) => { console.log(error); }) .finally(() => setLoading(false)); }; const handleMessage = (payload: any) => { const { message } = payload; setLog((p) => `${p}${message}`); if ( message.includes('update node mirror end') || message.includes('update linux mirror end') ) { setLoading(false); } }; const cleanDependenceCache = (type: string) => { setLoading(true); setLog(''); request .put(`${config.apiPrefix}system/config/dependence-clean`, { type, }) .then(({ code, data }) => { if (code === 200) { message.success(intl.get('清除成功')); } }) .catch((error: any) => { console.log(error); }) .finally(() => setLoading(false)); }; useEffect(() => { const ws = WebSocketManager.getInstance(); ws.subscribe('updateNodeMirror', handleMessage); ws.subscribe('updateLinuxMirror', handleMessage); return () => { ws.subscribe('updateNodeMirror', handleMessage); ws.unsubscribe('updateLinuxMirror', handleMessage); }; }, []); useEffect(() => { getSystemConfig(); }, []); return (
{ setSystemConfig({ ...systemConfig, dependenceProxy: e.target.value, }); }} /> { setSystemConfig({ ...systemConfig, nodeMirror: e.target.value, }); }} /> { setSystemConfig({ ...systemConfig, pythonMirror: e.target.value, }); }} /> { setSystemConfig({ ...systemConfig, linuxMirror: e.target.value, }); }} />
), }, { key: 'notification', label: intl.get('通知设置'), children: , }, { key: 'syslog', label: intl.get('系统日志'), children: , }, { key: 'login', label: intl.get('登录日志'), children: , }, { key: 'dependence', label: intl.get('依赖设置'), children: , }, { key: 'other', label: intl.get('其他设置'), children: ( ), }, { key: 'about', label: intl.get('关于'), children: , }, ]} /> {isModalVisible && ( )} ); }; export default Setting; ================================================ FILE: src/pages/setting/loginLog.tsx ================================================ import intl from 'react-intl-universal'; import React, { useEffect, useState } from 'react'; import { Typography, Table, Tag, Button, Spin, message } from 'antd'; import { request } from '@/utils/http'; import config from '@/utils/config'; import dayjs from 'dayjs'; const { Text, Link } = Typography; enum LoginStatus { '成功', '失败', } enum LoginStatusColor { 'success', 'error', } const columns = [ { title: intl.get('序号'), width: 50, render: (text: string, record: any, index: number) => { return index + 1; }, }, { title: intl.get('登录时间'), dataIndex: 'timestamp', key: 'timestamp', width: 120, render: (text: string, record: any) => { return dayjs(record.timestamp).format('YYYY-MM-DD HH:mm:ss'); }, }, { title: intl.get('登录地址'), dataIndex: 'address', width: 120, key: 'address', }, { title: intl.get('登录IP'), dataIndex: 'ip', width: 100, key: 'ip', }, { title: intl.get('登录设备'), dataIndex: 'platform', key: 'platform', width: 80, }, { title: intl.get('登录状态'), dataIndex: 'status', key: 'status', width: 80, render: (text: string, record: any) => { return ( {intl.get(LoginStatus[record.status])} ); }, }, ]; const LoginLog = ({ data, height, }: { data: Array; height: number; }) => { return ( <>
); }; export default LoginLog; ================================================ FILE: src/pages/setting/notification.tsx ================================================ import intl from 'react-intl-universal'; import React, { useEffect, useState } from 'react'; import { Typography, Input, Form, Button, Select, message } from 'antd'; import { request } from '@/utils/http'; import config from '@/utils/config'; const { Option } = Select; const NotificationSetting = ({ data }: any) => { const [loading, setLoading] = useState(false); const [notificationMode, setNotificationMode] = useState('closed'); const [fields, setFields] = useState([]); const [form] = Form.useForm(); const handleOk = (values: any) => { setLoading(true); const { type } = values; if (type == 'closed') { values.type = ''; } request .put(`${config.apiPrefix}user/notification`, values) .then(({ code, data }) => { if (code === 200) { message.success( values.type ? intl.get('通知发送成功') : intl.get('通知关闭成功'), ); } }) .catch((error: any) => { console.log(error); }) .finally(() => setLoading(false)); }; const notificationModeChange = (value: string) => { setNotificationMode(value); const _fields = (config.notificationModeMap as any)[value]; setFields(_fields || []); }; useEffect(() => { if (data && data.type) { notificationModeChange(data.type); form.setFieldsValue({ ...data }); } }, [data]); return (
{fields.map((x) => ( {x.items ? ( ) : ( )} ))}
); }; export default NotificationSetting; ================================================ FILE: src/pages/setting/other.tsx ================================================ import intl from 'react-intl-universal'; import React, { useState, useEffect, useRef } from 'react'; import { Button, InputNumber, Form, Radio, message, Input, Upload, Modal, Select, Checkbox, } from 'antd'; import * as DarkReader from '@umijs/ssr-darkreader'; import config from '@/utils/config'; import { request } from '@/utils/http'; import CheckUpdate from './checkUpdate'; import { SharedContext } from '@/layouts'; import { saveAs } from 'file-saver'; import './index.less'; import { UploadOutlined } from '@ant-design/icons'; import Countdown from 'antd/lib/statistic/Countdown'; import useProgress from './progress'; import pick from 'lodash/pick'; import { disableBody } from '@/utils'; import { TIMEZONES } from '@/utils/const'; const dataMap = { 'log-remove-frequency': 'logRemoveFrequency', 'cron-concurrency': 'cronConcurrency', timezone: 'timezone', 'global-ssh-key': 'globalSshKey', }; const exportModules = [ { value: 'base', label: intl.get('基础数据'), disabled: true }, { value: 'config', label: intl.get('配置文件') }, { value: 'scripts', label: intl.get('脚本文件') }, { value: 'log', label: intl.get('日志文件') }, { value: 'deps', label: intl.get('依赖文件') }, { value: 'syslog', label: intl.get('系统日志') }, { value: 'dep_cache', label: intl.get('依赖缓存') }, { value: 'raw', label: intl.get('远程脚本缓存') }, { value: 'repo', label: intl.get('远程仓库缓存') }, { value: 'ssh.d', label: intl.get('SSH 文件缓存') }, ]; const Other = ({ systemInfo, reloadTheme, }: Pick) => { const defaultTheme = localStorage.getItem('qinglong_dark_theme') || 'auto'; const [systemConfig, setSystemConfig] = useState<{ logRemoveFrequency?: number | null; cronConcurrency?: number | null; timezone?: string | null; globalSshKey?: string | null; }>(); const [form] = Form.useForm(); const [exportLoading, setExportLoading] = useState(false); const showUploadProgress = useProgress(intl.get('上传')); const showDownloadProgress = useProgress(intl.get('下载')); const [visible, setVisible] = useState(false); const [selectedModules, setSelectedModules] = useState(['base']); const { enable: enableDarkMode, disable: disableDarkMode, exportGeneratedCSS: collectCSS, setFetchMethod, auto: followSystemColorScheme, } = DarkReader || {}; const themeChange = (e: any) => { const _theme = e.target.value; localStorage.setItem('qinglong_dark_theme', e.target.value); setFetchMethod(fetch); if (_theme === 'dark') { enableDarkMode({}); } else if (_theme === 'light') { disableDarkMode(); } else { followSystemColorScheme({}); } reloadTheme(); }; const handleLangChange = (v: string) => { localStorage.setItem('lang', v); setTimeout(() => { window.location.reload(); }, 500); }; const getSystemConfig = () => { request .get(`${config.apiPrefix}system/config`) .then(({ code, data }) => { if (code === 200 && data.info) { setSystemConfig(data.info); } }) .catch((error: any) => { console.log(error); }); }; const updateSystemConfig = (path: keyof typeof dataMap) => { request .put( `${config.apiPrefix}system/config/${path}`, pick(systemConfig, dataMap[path]), ) .then(({ code, data }) => { if (code === 200) { message.success(intl.get('更新成功')); } }) .catch((error: any) => { console.log(error); }); }; const exportData = () => { setExportLoading(true); request .put( `${config.apiPrefix}system/data/export`, { type: selectedModules }, { responseType: 'blob', timeout: 86400000, onDownloadProgress: (e) => { if (e.progress) { showDownloadProgress(parseFloat((e.progress * 100).toFixed(1))); } }, }, ) .then((res) => { saveAs(res, 'data.tgz'); }) .catch((error: any) => { console.log(error); }) .finally(() => { setExportLoading(false); setVisible(false); }); }; const showReloadModal = () => { Modal.confirm({ width: 600, maskClosable: false, title: intl.get('确认重启'), centered: true, content: ( <>
{intl.get('备份数据上传成功,确认覆盖数据')}
{intl.get('如果恢复失败,可进入容器执行')} ql reload data
), okText: intl.get('重启'), onOk() { request .put(`${config.apiPrefix}update/data`) .then(() => { message.success({ content: ( {intl.get('系统将在')} {intl.get('秒后自动刷新')} ), duration: 30, }); disableBody(); setTimeout(() => { window.location.reload(); }, 30000); }) .catch((error: any) => { console.log(error); }); }, }); }; useEffect(() => { getSystemConfig(); }, []); return ( <>
{intl.get('亮色')} {intl.get('暗色')} {intl.get('跟随系统')} { setSystemConfig({ ...systemConfig, logRemoveFrequency: value }); }} /> { setSystemConfig({ ...systemConfig, cronConcurrency: value }); }} /> { if (event?.percent) { showUploadProgress( Math.min(parseFloat(event?.percent.toFixed(1)), 99), ); } if (file.status === 'done') { showUploadProgress(100); showReloadModal(); } if (file.status === 'error') { message.error('上传失败'); } }} name="data" headers={{ Authorization: `Bearer ${localStorage.getItem(config.authKey)}`, }} > setVisible(false)} okText={intl.get('开始备份')} cancelText={intl.get('取消')} okButtonProps={{ loading: exportLoading }} // 绑定加载状态到按钮 > { setSelectedModules(v as string[]); }} style={{ width: '100%', display: 'flex', flexWrap: 'wrap', gap: '8px 16px', }} > {exportModules.map((module) => ( {module.label} ))} ); }; export default Other; ================================================ FILE: src/pages/setting/progress.tsx ================================================ import intl from 'react-intl-universal'; import { Modal, Progress } from 'antd'; import { useRef } from 'react'; const ProgressElement = ({ percent }: { percent: number }) => ( ); export default function useProgress(title: string) { const modalRef = useRef | null>(); const showProgress = (percent: number) => { if (modalRef.current) { modalRef.current.update({ title: `${title}${ percent >= 100 ? intl.get('成功') : intl.get('中...') }`, content: , okButtonProps: { disabled: percent !== 100 }, }); if (percent === 100) { setTimeout(() => { modalRef.current?.destroy(); modalRef.current = null; }); } } else { modalRef.current = Modal.info({ width: 600, maskClosable: false, title: `${title}${ percent >= 100 ? intl.get('成功') : intl.get('中...') }`, centered: true, content: , okButtonProps: { disabled: true }, }); } }; return showProgress; } ================================================ FILE: src/pages/setting/security.tsx ================================================ import intl from 'react-intl-universal'; import React, { useEffect, useState } from 'react'; import { Typography, Input, Form, Button, message, Avatar, Upload } from 'antd'; import { request } from '@/utils/http'; import config from '@/utils/config'; import { history } from '@umijs/max'; import QRCode from 'qrcode.react'; import { PageLoading } from '@ant-design/pro-layout'; import { UploadOutlined, UserOutlined } from '@ant-design/icons'; import ImgCrop from 'antd-img-crop'; import 'antd/es/slider/style'; const { Title, Link } = Typography; const SecuritySettings = ({ user, userChange }: any) => { const [loading, setLoading] = useState(false); const [twoFactorActivated, setTwoFactorActivated] = useState(); const [twoFactoring, setTwoFactoring] = useState(false); const [twoFactorInfo, setTwoFactorInfo] = useState(); const [code, setCode] = useState(); const [avatar, setAvatar] = useState(); const handleOk = (values: any) => { request .put(`${config.apiPrefix}user`, { username: values.username, password: values.password, }) .then(({ code, data }) => { if (code === 200) { localStorage.removeItem(config.authKey); history.push('/login'); } }) .catch((error: any) => { console.log(error); }); }; const activeOrDeactiveTwoFactor = () => { if (twoFactorActivated) { deactiveTowFactor(); } else { getTwoFactorInfo(); setTwoFactoring(true); } }; const deactiveTowFactor = () => { request .put(`${config.apiPrefix}user/two-factor/deactive`) .then(({ code, data }) => { if (code === 200 && data) { setTwoFactorActivated(false); userChange(); } }) .catch((error: any) => { console.log(error); }); }; const completeTowFactor = () => { setLoading(true); request .put(`${config.apiPrefix}user/two-factor/active`, { code }) .then(({ code, data }) => { if (code === 200) { if (data) { message.success(intl.get('激活成功')); setTwoFactoring(false); setTwoFactorActivated(true); userChange(); } else { message.success(intl.get('验证失败')); } } }) .catch((error: any) => { console.log(error); }) .finally(() => setLoading(false)); }; const getTwoFactorInfo = () => { request .get(`${config.apiPrefix}user/two-factor/init`) .then(({ code, data }) => { if (code === 200) { setTwoFactorInfo(data); } }) .catch((error: any) => { console.log(error); }); }; const onChange = (e) => { if (e.file && e.file.response) { setAvatar( `${config.apiPrefix}static/${e.file.response.data}`, ); userChange(); } }; useEffect(() => { setTwoFactorActivated(user && user.twoFactorActivated); setAvatar(user.avatar && `${config.apiPrefix}static/${user.avatar}`); }, [user]); return twoFactoring ? ( <> {twoFactorInfo ? (
{intl.get('第一步')} {intl.get('下载两步验证手机应用,比如 Google Authenticator 、')} Microsoft Authenticator 、 Authy 、 1Password 、 LastPass Authenticator {intl.get('第二步')} {intl.get('使用手机应用扫描二维码,或者输入秘钥')}{' '} {twoFactorInfo?.secret}
{intl.get('第三步')} {intl.get('输入手机应用上的6位数字')} setCode(e.target.value)} placeholder="123456" />
) : ( )} ) : ( <>
{intl.get('修改用户名密码')}
{intl.get('两步验证')}
{intl.get('头像')}
} src={avatar} /> ); }; export default SecuritySettings; ================================================ FILE: src/pages/setting/systemLog.tsx ================================================ import intl from 'react-intl-universal'; import React, { useRef, useState } from 'react'; import CodeMirror from '@uiw/react-codemirror'; import { Button, DatePicker, Empty, message, Spin } from 'antd'; import { VerticalAlignBottomOutlined, VerticalAlignTopOutlined, } from '@ant-design/icons'; import { request } from '@/utils/http'; import config from '@/utils/config'; import { useRequest } from 'ahooks'; import moment from 'moment'; import { systemLogDebugHighlightPlugin, systemLogErrorHighlightPlugin, systemLogInfoHighlightPlugin, systemLogTheme, systemLogWarnHighlightPlugin, } from '@/utils/codemirror/systemLog'; const { RangePicker } = DatePicker; const SystemLog = ({ height, theme }: any) => { const editorRef = useRef(null); const panelVisiableRef = useRef<[string, string] | false>(); const [range, setRange] = useState(['', '']); const [systemLogData, setSystemLogData] = useState(''); const { loading, refresh } = useRequest( () => { return request.get( `${config.apiPrefix}system/log?startTime=${range[0]}&endTime=${range[1]}`, { responseType: 'blob', }, ); }, { refreshDeps: [range], async onSuccess(res) { setSystemLogData(await res.text()); }, }, ); const scrollTo = (position: 'start' | 'end') => { editorRef.current.scrollDOM.scrollTo({ top: position === 'start' ? 0 : editorRef.current.scrollDOM.scrollHeight, }); }; const deleteLog = () => { request.delete(`${config.apiPrefix}system/log`).then((x) => { message.success('删除成功'); refresh(); }); }; return (
date > moment() || date < moment().subtract(7, 'days') } defaultValue={[moment(), moment()]} onOpenChange={(v) => { panelVisiableRef.current = v ? ['', ''] : false; }} onCalendarChange={(_, dates, { range }) => { if ( !panelVisiableRef.current || typeof panelVisiableRef.current === 'boolean' ) { return; } if (range === 'start') { panelVisiableRef.current[0] = dates[0]; } if (range === 'end') { panelVisiableRef.current[1] = dates[1]; } if (panelVisiableRef.current[0] && panelVisiableRef.current[1]) { setRange(dates); } }} />
{systemLogData ? ( <> { editorRef.current = view; }} extensions={[ systemLogDebugHighlightPlugin, systemLogErrorHighlightPlugin, systemLogInfoHighlightPlugin, systemLogWarnHighlightPlugin, systemLogTheme, ]} readOnly={true} theme={theme.includes('dark') ? 'dark' : 'light'} />
) : loading ? ( ) : ( )}
); }; export default SystemLog; ================================================ FILE: src/pages/subscription/index.less ================================================ .inline-form-item { margin-bottom: 0; .ant-form-item { display: inline-block; width: 50%; margin-bottom: 0px; } } ================================================ FILE: src/pages/subscription/index.tsx ================================================ import intl from 'react-intl-universal'; import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Button, message, Modal, Table, Tag, Space, Dropdown, Menu, Typography, Input, Tooltip, Checkbox, } from 'antd'; import { ClockCircleOutlined, Loading3QuartersOutlined, CloseCircleOutlined, EllipsisOutlined, CheckCircleOutlined, EditOutlined, StopOutlined, DeleteOutlined, FileTextOutlined, PauseCircleOutlined, PlayCircleOutlined, } from '@ant-design/icons'; import config from '@/utils/config'; import { PageContainer } from '@ant-design/pro-layout'; import { request } from '@/utils/http'; import SubscriptionModal from './modal'; import { history, useOutletContext } from '@umijs/max'; import './index.less'; import SubscriptionLogModal from './logModal'; import { SharedContext } from '@/layouts'; import useTableScrollHeight from '@/hooks/useTableScrollHeight'; import WebSocketManager from '@/utils/websocket'; const { Text, Paragraph } = Typography; const { Search } = Input; export enum SubscriptionStatus { 'running', 'idle', 'disabled', 'queued', } export enum IntervalSchedule { 'days' = '天', 'hours' = '时', 'minutes' = '分', 'seconds' = '秒', } export enum SubscriptionType { 'private-repo' = '私有仓库', 'public-repo' = '公开仓库', 'file' = '单文件', } const Subscription = () => { const { headerStyle, isPhone } = useOutletContext(); const columns: any = [ { title: intl.get('名称'), dataIndex: 'name', key: 'name', width: 150, sorter: { compare: (a: any, b: any) => a.name.localeCompare(b.name), multiple: 2, }, }, { title: intl.get('链接'), dataIndex: 'url', key: 'url', sorter: { compare: (a: any, b: any) => a.name.localeCompare(b.name), multiple: 2, }, render: (text: string, record: any) => { return ( {text} ); }, }, { title: intl.get('类型'), dataIndex: 'type', key: 'type', width: 130, render: (text: string, record: any) => { return (SubscriptionType as any)[record.type]; }, }, { title: intl.get('分支'), dataIndex: 'branch', key: 'branch', width: 130, render: (text: string, record: any) => { return record.branch || '-'; }, }, { title: intl.get('定时规则'), width: 180, render: (text: string, record: any) => { if (record.schedule_type === 'interval') { const { type, value } = record.interval_schedule; return `每${value}${(IntervalSchedule as any)[type]}`; } return record.schedule; }, }, { title: intl.get('状态'), key: 'status', dataIndex: 'status', width: 110, filters: [ { text: intl.get('运行中'), value: 0, }, { text: intl.get('空闲中'), value: 1, }, { text: intl.get('已禁用'), value: 2, }, ], onFilter: (value: number, record: any) => { if (record.is_disabled && record.status !== 0) { return value === 2; } else { return record.status === value; } }, render: (text: string, record: any) => ( <> {(!record.is_disabled || record.status !== SubscriptionStatus.idle) && ( <> {record.status === SubscriptionStatus.idle && ( } color="default"> {intl.get('空闲中')} )} {record.status === SubscriptionStatus.running && ( } color="processing" > {intl.get('运行中')} )} )} {record.is_disabled === 1 && record.status === SubscriptionStatus.idle && ( } color="error"> {intl.get('已禁用')} )} ), }, { title: intl.get('操作'), key: 'action', width: 140, render: (text: string, record: any, index: number) => { const isPc = !isPhone; return ( {record.status === SubscriptionStatus.idle && ( { e.stopPropagation(); runSubscription(record, index); }} > {intl.get('运行')} )} {record.status !== SubscriptionStatus.idle && ( { e.stopPropagation(); stopSubsciption(record, index); }} > {intl.get('停止')} )} { e.stopPropagation(); setLogSubscription({ ...record, timestamp: Date.now() }); }} > {intl.get('日志')} ); }, }, ]; const [value, setValue] = useState([]); const [loading, setLoading] = useState(true); const [isModalVisible, setIsModalVisible] = useState(false); const [editedSubscription, setEditedSubscription] = useState(); const [searchText, setSearchText] = useState(''); const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [isLogModalVisible, setIsLogModalVisible] = useState(false); const [logSubscription, setLogSubscription] = useState(); const tableRef = useRef(null); const tableScrollHeight = useTableScrollHeight(tableRef); const deleteCheckRef = useRef(false); const runSubscription = (record: any, index: number) => { Modal.confirm({ title: intl.get('确认运行'), content: ( <> {intl.get('确认运行定时任务')}{' '} {record.name} {' '} {intl.get('吗')} ), onOk() { request .put(`${config.apiPrefix}subscriptions/run`, [record.id]) .then(({ code, data }) => { if (code === 200) { const result = [...value]; const i = result.findIndex((x) => x.id === record.id); if (i !== -1) { result.splice(i, 1, { ...record, status: SubscriptionStatus.running, }); setValue(result); } } }); }, }); }; const stopSubsciption = (record: any, index: number) => { Modal.confirm({ title: intl.get('确认停止'), content: ( <> {intl.get('确认停止定时任务')}{' '} {record.name} {' '} {intl.get('吗')} ), onOk() { request .put(`${config.apiPrefix}subscriptions/stop`, [record.id]) .then(({ code, data }) => { if (code === 200) { const result = [...value]; const i = result.findIndex((x) => x.id === record.id); if (i !== -1) { result.splice(i, 1, { ...record, pid: null, status: SubscriptionStatus.idle, }); setValue(result); } } }); }, }); }; const getSubscriptions = () => { setLoading(true); request .get(`${config.apiPrefix}subscriptions?searchValue=${searchText}`) .then(({ code, data }) => { if (code === 200) { setValue(data); setCurrentPage(1); } }) .finally(() => setLoading(false)); }; const addSubscription = () => { setEditedSubscription(null as any); setIsModalVisible(true); }; const editSubscription = (record: any, index: number) => { setEditedSubscription(record); setIsModalVisible(true); }; const onCheckChange = (e) => { deleteCheckRef.current = e.target.checked; }; const delSubscription = (record: any, index: number) => { Modal.confirm({ title: intl.get('确认删除'), content: ( <> {intl.get('确认删除定时订阅')}{' '} {record.name} {' '} {intl.get('吗')}
{intl.get('同时删除关联任务和脚本')}
), onOk() { request .delete(`${config.apiPrefix}subscriptions`, { data: [record.id], params: { force: deleteCheckRef.current }, }) .then(({ code, data }) => { if (code === 200) { message.success(intl.get('删除成功')); const result = [...value]; const i = result.findIndex((x) => x.id === record.id); if (i !== -1) { result.splice(i, 1); setValue(result); } } }); }, }); }; const enabledOrDisabledSubscription = (record: any, index: number) => { Modal.confirm({ title: `确认${ record.is_disabled === 1 ? intl.get('启用') : intl.get('禁用') }`, content: ( <> {intl.get('确认')} {record.is_disabled === 1 ? intl.get('启用') : intl.get('禁用')} {intl.get('定时订阅')}{' '} {record.name} {' '} {intl.get('吗')} ), onOk() { request .put( `${config.apiPrefix}subscriptions/${ record.is_disabled === 1 ? 'enable' : 'disable' }`, [record.id], ) .then(({ code, data }) => { if (code === 200) { const newStatus = record.is_disabled === 1 ? 0 : 1; const result = [...value]; const i = result.findIndex((x) => x.id === record.id); if (i !== -1) { result.splice(i, 1, { ...record, is_disabled: newStatus, }); setValue(result); } } }); }, }); }; const MoreBtn: React.FC<{ record: any; index: number; }> = ({ record, index }) => ( }, { label: record.is_disabled === 1 ? intl.get('启用') : intl.get('禁用'), key: 'enableOrDisable', icon: record.is_disabled === 1 ? ( ) : ( ), }, { label: intl.get('删除'), key: 'delete', icon: }, ], onClick: ({ key, domEvent }) => { domEvent.stopPropagation(); action(key, record, index); }, }} > e.stopPropagation()}> ); const action = (key: string | number, record: any, index: number) => { switch (key) { case 'edit': editSubscription(record, index); break; case 'enableOrDisable': enabledOrDisabledSubscription(record, index); break; case 'delete': delSubscription(record, index); break; default: break; } }; const handleCancel = (subscription?: any) => { setIsModalVisible(false); if (subscription) { handleSubscriptions(subscription); } }; const onSearch = (value: string) => { setSearchText(value.trim()); }; const handleSubscriptions = (subscription: any) => { const index = value.findIndex((x) => x.id === subscription.id); const result = [...value]; if (index === -1) { result.unshift(subscription); } else { result.splice(index, 1, { ...subscription, }); } setValue(result); }; const onPageChange = (page: number, pageSize: number | undefined) => { setCurrentPage(page); setPageSize(pageSize as number); localStorage.setItem('pageSize', pageSize + ''); }; const getRowClassName = (record: any, index: number) => { return record.isPinned ? 'pinned-subscription subscription' : 'subscription'; }; const handleMessage = useCallback((payload: any) => { const { message, references } = payload; setValue((p) => { const result = [...p]; for (let i = 0; i < references.length; i++) { const index = p.findIndex((x) => x.id === references[i]); if (index !== -1) { result.splice(index, 1, { ...p[index], status: SubscriptionStatus.idle, }); } } return result; }); }, []); useEffect(() => { const ws = WebSocketManager.getInstance(); ws.subscribe('runSubscriptionEnd', handleMessage); return () => { ws.unsubscribe('runSubscriptionEnd', handleMessage); }; }, []); useEffect(() => { if (logSubscription) { localStorage.setItem('logSubscription', logSubscription.id); setIsLogModalVisible(true); } }, [logSubscription]); useEffect(() => { getSubscriptions(); }, [searchText]); useEffect(() => { setPageSize(parseInt(localStorage.getItem('pageSize') || '20')); }, []); return ( , , ]} header={{ style: headerStyle, }} >
`第 ${range[0]}-${range[1]} 条/总共 ${total} 条`, pageSizeOptions: [20, 100, 500, 1000] as any, }} dataSource={value} rowKey="id" size="middle" scroll={{ x: 1000, y: tableScrollHeight }} loading={loading} rowClassName={getRowClassName} /> {isModalVisible && ( )} {isLogModalVisible && ( { setIsLogModalVisible(false); }} subscription={logSubscription} /> )} ); }; export default Subscription; ================================================ FILE: src/pages/subscription/logModal.tsx ================================================ import intl from 'react-intl-universal'; import React, { useEffect, useState } from 'react'; import { Modal, message, Input, Form, Statistic, Button } from 'antd'; import { request } from '@/utils/http'; import config from '@/utils/config'; import { Loading3QuartersOutlined, CheckCircleOutlined, } from '@ant-design/icons'; import { PageLoading } from '@ant-design/pro-layout'; import { logEnded } from '@/utils'; import Ansi from 'ansi-to-react'; const SubscriptionLogModal = ({ subscription, handleCancel, data, logUrl, }: { subscription?: any; handleCancel: () => void; data?: string; logUrl?: string; }) => { const [value, setValue] = useState(intl.get('启动中...')); const [loading, setLoading] = useState(true); const [executing, setExecuting] = useState(true); const [isPhone, setIsPhone] = useState(false); const getCronLog = (isFirst?: boolean) => { if (isFirst) { setLoading(true); } request .get( logUrl ? logUrl : `${config.apiPrefix}subscriptions/${subscription.id}/log`, ) .then(({ code, data }) => { if ( code === 200 && localStorage.getItem('logSubscription') === String(subscription.id) ) { const log = data as string; setValue(log || intl.get('暂无日志')); setExecuting(log && !logEnded(log)); if (log && !logEnded(log)) { setTimeout(() => { getCronLog(); }, 2000); } } }) .finally(() => { if (isFirst) { setLoading(false); } }); }; const cancel = () => { localStorage.removeItem('logSubscription'); handleCancel(); }; const titleElement = () => { return ( <> {(executing || loading) && } {!executing && !loading && } {subscription && subscription.name} ); }; useEffect(() => { if (subscription && subscription.id) { getCronLog(true); } }, [subscription]); useEffect(() => { if (data) { setValue(data); } }, [data]); useEffect(() => { setIsPhone(document.body.clientWidth < 768); }, []); return ( cancel()} onCancel={() => cancel()} footer={[ , ]} >
{loading ? ( ) : (
            {value}
          
)}
); }; export default SubscriptionLogModal; ================================================ FILE: src/pages/subscription/modal.tsx ================================================ import intl from 'react-intl-universal'; import React, { useCallback, useEffect, useState } from 'react'; import { Modal, message, InputNumber, Form, Radio, Select, Input, Switch, } from 'antd'; import { request } from '@/utils/http'; import config from '@/utils/config'; import CronExpressionParser from 'cron-parser'; import isNil from 'lodash/isNil'; const { Option } = Select; const repoUrlRegx = /([^\/\:]+\/[^\/]+)(?=\.git)/; const fileUrlRegx = /([^\/\:]+\/[^\/\.]+)\.[a-z]+$/; const SubscriptionModal = ({ subscription, handleCancel, }: { subscription?: any; handleCancel: (needUpdate?: boolean) => void; }) => { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const [type, setType] = useState(subscription?.type || 'public-repo'); const [scheduleType, setScheduleType] = useState( subscription?.schedule_type || 'crontab', ); const [pullType, setPullType] = useState<'ssh-key' | 'user-pwd'>( subscription?.pull_type || 'ssh-key', ); const handleOk = async (values: any) => { setLoading(true); const method = subscription ? 'put' : 'post'; const payload = { ...values, autoAddCron: Boolean(values.autoAddCron), autoDelCron: Boolean(values.autoDelCron), }; if (subscription) { payload.id = subscription.id; } try { const { code, data } = await request[method]( `${config.apiPrefix}subscriptions`, payload, ); if (code === 200) { message.success( subscription ? intl.get('更新订阅成功') : intl.get('创建订阅成功'), ); handleCancel(data); } setLoading(false); } catch (error: any) { setLoading(false); } }; const typeChange = (e) => { setType(e.target.value); const _url = form.getFieldValue('url'); const _branch = form.getFieldValue('branch'); form.setFieldsValue({ alias: formatAlias(_url, _branch, e.target.value), }); if (_url) { form.validateFields(['url']); } }; const scheduleTypeChange = (e) => { setScheduleType(e.target.value); form.setFieldsValue({ schedule: '' }); }; const pullTypeChange = (e) => { setPullType(e.target.value); }; const onUrlChange = (e) => { const _branch = form.getFieldValue('branch'); form.setFieldsValue({ alias: formatAlias(e.target.value, _branch), }); }; const onBranchChange = (e) => { const _url = form.getFieldValue('url'); form.setFieldsValue({ alias: formatAlias(_url, e.target.value), }); }; const formatAlias = (_url: string, _branch: string, _type = type) => { let _alias = ''; const _regx = _type === 'file' ? fileUrlRegx : repoUrlRegx; if (_regx.test(_url)) { _alias = _url.match(_regx)![1].replaceAll('/', '_').replaceAll('.', '_'); } if (_branch) { _alias = _alias + '_' + _branch; } return _alias; }; const IntervalSelect = ({ value, onChange, }: { value?: any; onChange?: (param: any) => void; }) => { const [intervalType, setIntervalType] = useState('days'); const [intervalNumber, setIntervalNumber] = useState(); const intervalTypeChange = (type: string) => { setIntervalType(type); if (intervalNumber && intervalNumber > 0) { onChange?.({ type, value: intervalNumber }); } }; const numberChange = (value: number | null) => { setIntervalNumber(value || 1); if (!value) { onChange?.(null); } else { onChange?.({ type: intervalType, value }); } }; useEffect(() => { if (value) { setIntervalType(value.type); setIntervalNumber(value.value); } }, [value]); return ( ); }; const PullOptions = ({ value, onChange, type, }: { value?: any; type: 'ssh-key' | 'user-pwd'; onChange?: (param: any) => void; }) => { return type === 'ssh-key' ? ( ) : ( <> ); }; const onPaste = useCallback((e: any) => { const text = e.clipboardData.getData('text') as string; if (text.startsWith('ql ')) { const [ , type, url, whitelist, blacklist, dependences, branch, extensions, ] = text .split(' ') .map((x) => x.trim().replace(/\"/g, '').replace(/\'/, '')); const _type = type === 'raw' ? 'file' : url.startsWith('http') ? 'public-repo' : 'private-repo'; form.setFieldsValue({ type: _type, url, whitelist, blacklist, dependences, branch, extensions, alias: formatAlias(url, branch, _type), }); setType(_type); } }, []); const onNamePaste = useCallback((e) => { const text = e.clipboardData.getData('text') as string; if (text.includes('ql repo') || text.includes('ql raw')) { e.preventDefault(); } }, []); const formatParams = (sub) => { return { ...sub, autoAddCron: isNil(sub?.autoAddCron) ? true : Boolean(sub?.autoAddCron), autoDelCron: isNil(sub?.autoDelCron) ? true : Boolean(sub?.autoDelCron), }; }; useEffect(() => { window.addEventListener('paste', onPaste); return () => { window.removeEventListener('paste', onPaste); }; }, []); return ( { form .validateFields() .then((values) => { handleOk(values); }) .catch((info) => { console.log('Validate Failed:', info); }); }} onCancel={() => handleCancel()} confirmLoading={loading} >
{intl.get('公开仓库')} {intl.get('私有仓库')} {intl.get('单文件')} {type !== 'file' && ( )} {type === 'private-repo' && ( <> {intl.get('私钥')} {intl.get('用户名密码/Token')} )} crontab interval { try { if ( scheduleType === 'interval' || !value || CronExpressionParser.parse(value).hasNext() ) { return Promise.resolve(); } else { return Promise.reject(intl.get('Subscription表达式格式有误')); } } catch (e) { return Promise.reject(intl.get('Subscription表达式格式有误')); } }, }, ]} > {scheduleType === 'interval' ? ( ) : ( )} {type !== 'file' && ( <> )}
); }; export default SubscriptionModal; ================================================ FILE: src/styles/variable.less ================================================ @tree-width: 300px; ================================================ FILE: src/utils/codemirror/systemLog.ts ================================================ import { Decoration, EditorView, ViewPlugin, ViewUpdate, } from '@codemirror/view'; import { RangeSet, RangeSetBuilder } from '@codemirror/state'; const infoWord = /\[ℹ️info/g; const debugWord = /\[⚠️debug/g; const warnWord = /\[❌warn/g; const errorWord = /\[🐛error/g; const customWordClassMap = { info: 'system-log-info', warn: 'system-warn-info', error: 'system-error-info', debug: 'system-debug-info', }; export const systemLogInfoHighlightPlugin = ViewPlugin.fromClass( class { decorations: RangeSet; constructor(view: EditorView) { this.decorations = this.getDecorations(view); } update(update: ViewUpdate) { if (update.docChanged) { this.decorations = this.getDecorations(update.view); } } getDecorations(view: EditorView) { const builder = new RangeSetBuilder(); const doc = view.state.doc.toString(); let match; while ((match = infoWord.exec(doc)) !== null) { const deco = Decoration.mark({ class: customWordClassMap.info, }); builder.add(match.index, match.index + match[0].length, deco); } return builder.finish(); } }, { decorations: (v) => v.decorations, }, ); export const systemLogWarnHighlightPlugin = ViewPlugin.fromClass( class { decorations: RangeSet; constructor(view: EditorView) { this.decorations = this.getDecorations(view); } update(update: ViewUpdate) { if (update.docChanged) { this.decorations = this.getDecorations(update.view); } } getDecorations(view: EditorView) { const builder = new RangeSetBuilder(); const doc = view.state.doc.toString(); let match; while ((match = warnWord.exec(doc)) !== null) { const deco = Decoration.mark({ class: customWordClassMap.warn, }); builder.add(match.index, match.index + match[0].length, deco); } return builder.finish(); } }, { decorations: (v) => v.decorations, }, ); export const systemLogDebugHighlightPlugin = ViewPlugin.fromClass( class { decorations: RangeSet; constructor(view: EditorView) { this.decorations = this.getDecorations(view); } update(update: ViewUpdate) { if (update.docChanged) { this.decorations = this.getDecorations(update.view); } } getDecorations(view: EditorView) { const builder = new RangeSetBuilder(); const doc = view.state.doc.toString(); let match; while ((match = debugWord.exec(doc)) !== null) { const deco = Decoration.mark({ class: customWordClassMap.debug, }); builder.add(match.index, match.index + match[0].length, deco); } return builder.finish(); } }, { decorations: (v) => v.decorations, }, ); export const systemLogErrorHighlightPlugin = ViewPlugin.fromClass( class { decorations: RangeSet; constructor(view: EditorView) { this.decorations = this.getDecorations(view); } update(update: ViewUpdate) { if (update.docChanged) { this.decorations = this.getDecorations(update.view); } } getDecorations(view: EditorView) { const builder = new RangeSetBuilder(); const doc = view.state.doc.toString(); let match; while ((match = errorWord.exec(doc)) !== null) { const deco = Decoration.mark({ class: customWordClassMap.error, }); builder.add(match.index, match.index + match[0].length, deco); } return builder.finish(); } }, { decorations: (v) => v.decorations, }, ); export const systemLogTheme = EditorView.baseTheme({ '.system-log-info': { color: '#2196F3', }, '.system-warn-info': { color: '#FFB827', }, '.system-error-info': { color: '#FA5151', }, '.system-debug-info': { color: '#009A29', }, }); ================================================ FILE: src/utils/config.ts ================================================ import intl from 'react-intl-universal'; const baseUrl = window.__ENV__QlBaseUrl || '/'; export default { siteName: intl.get('青龙'), baseUrl, apiPrefix: `${baseUrl}api/`, authKey: 'token', /* Layout configuration, specify which layout to use for route. */ layouts: [ { name: 'primary', include: [/.*/], exclude: [/(\/(en|zh))*\/login/], }, ], /* I18n configuration, `languages` and `defaultLanguage` are required currently. */ i18n: { /* Countrys flags: https://www.flaticon.com/packs/countrys-flags */ languages: [ { key: 'pt-br', title: 'Português', flag: '/portugal.svg', }, { key: 'en', title: 'English', flag: '/america.svg', }, { key: 'zh', title: intl.get('中文'), flag: '/china.svg', }, ], defaultLanguage: 'en', }, scopes: [ { name: intl.get('定时任务'), value: 'crons', }, { name: intl.get('环境变量'), value: 'envs', }, { name: intl.get('订阅管理'), value: 'subscriptions', }, { name: intl.get('配置文件'), value: 'configs', }, { name: intl.get('脚本管理'), value: 'scripts', }, { name: intl.get('日志管理'), value: 'logs', }, { name: intl.get('依赖管理'), value: 'dependencies', }, { name: intl.get('系统信息'), value: 'system', }, ], scopesMap: { crons: intl.get('定时任务'), envs: intl.get('环境变量'), subscriptions: intl.get('订阅管理'), configs: intl.get('配置文件'), scripts: intl.get('脚本管理'), logs: intl.get('日志管理'), dependencies: intl.get('依赖管理'), system: intl.get('系统信息'), }, notificationModes: [ { value: 'gotify', label: 'Gotify' }, { value: 'ntfy', label: 'Ntfy' }, { value: 'goCqHttpBot', label: 'GoCqHttpBot' }, { value: 'serverChan', label: intl.get('Server酱') }, { value: 'pushDeer', label: 'PushDeer' }, { value: 'bark', label: 'Bark' }, { value: 'telegramBot', label: intl.get('Telegram机器人') }, { value: 'dingtalkBot', label: intl.get('钉钉机器人') }, { value: 'weWorkBot', label: intl.get('企业微信机器人') }, { value: 'weWorkApp', label: intl.get('企业微信应用') }, { value: 'aibotk', label: intl.get('智能微秘书') }, { value: 'iGot', label: 'IGot' }, { value: 'pushPlus', label: 'PushPlus' }, { value: 'wePlusBot', label: intl.get('微加机器人') }, { value: 'wxPusherBot', label: 'wxPusher' }, { value: 'chat', label: intl.get('群晖chat') }, { value: 'email', label: intl.get('邮箱') }, { value: 'lark', label: intl.get('飞书机器人') }, { value: 'pushMe', label: 'PushMe' }, { value: 'chronocat', label: 'Chronocat' }, { value: 'webhook', label: intl.get('自定义通知') }, { value: 'closed', label: intl.get('已关闭') }, ], notificationModeMap: { gotify: [ { label: 'gotifyUrl', tip: intl.get('gotify的url地址,例如 https://push.example.de:8080'), required: true, }, { label: 'gotifyToken', tip: intl.get('gotify的消息应用token码'), required: true, }, { label: 'gotifyPriority', tip: intl.get('推送消息的优先级') }, ], ntfy: [ { label: 'ntfyUrl', tip: intl.get('ntfy的url地址,例如 https://ntfy.sh'), required: true, }, { label: 'ntfyTopic', tip: intl.get('ntfy应用topic'), required: true, }, { label: 'ntfyPriority', tip: intl.get('推送消息的优先级') }, { label: 'ntfyToken', tip: intl.get('ntfy应用token') }, { label: 'ntfyUsername', tip: intl.get('ntfy应用用户名') }, { label: 'ntfyPassword', tip: intl.get('ntfy应用密码') }, { label: 'ntfyActions', tip: intl.get('ntfy用户动作') }, ], chat: [ { label: 'synologyChatUrl', tip: intl.get('synologyChat的url地址'), required: true, }, ], goCqHttpBot: [ { label: 'goCqHttpBotUrl', tip: intl.get( '推送到个人QQ: http://127.0.0.1/send_private_msg,群:http://127.0.0.1/send_group_msg', ), required: true, }, { label: 'goCqHttpBotToken', tip: intl.get('访问密钥'), required: true }, { label: 'goCqHttpBotQq', tip: intl.get( '如果GOBOT_URL设置 /send_private_msg 则需要填入 user_id=个人QQ 相反如果是 /send_group_msg 则需要填入 group_id=QQ群', ), required: true, }, ], serverChan: [ { label: 'serverChanKey', tip: intl.get('Server酱SENDKEY'), required: true, }, ], pushDeer: [ { label: 'pushDeerKey', tip: intl.get('PushDeer的Key,https://github.com/easychen/pushdeer'), required: true, }, { label: 'pushDeerUrl', tip: intl.get( 'PushDeer的自架API endpoint,默认是 https://api2.pushdeer.com/message/push', ), }, ], bark: [ { label: 'barkPush', tip: intl.get( 'Bark的信息IP/设备码,例如:https://api.day.app/XXXXXXXX', ), required: true, }, { label: 'barkIcon', tip: intl.get('BARK推送图标,自定义推送图标 (需iOS15或以上才能显示)'), }, { label: 'barkSound', tip: intl.get('BARK推送铃声,铃声列表去APP查看复制填写'), }, { label: 'barkGroup', tip: intl.get('BARK推送消息的分组,默认为qinglong'), }, { label: 'barkLevel', tip: intl.get('BARK推送消息的时效性,默认为active'), }, { label: 'barkUrl', tip: intl.get('BARK推送消息的跳转URL'), }, { label: 'barkArchive', tip: intl.get('BARK是否保存推送消息'), }, ], telegramBot: [ { label: 'telegramBotToken', tip: intl.get( 'telegram机器人的token,例如:1077xxx4424:AAFjv0FcqxxxxxxgEMGfi22B4yh15R5uw', ), required: true, }, { label: 'telegramBotUserId', tip: intl.get('telegram用户的id,例如:129xxx206'), required: true, }, { label: 'telegramBotProxyHost', tip: intl.get('代理IP') }, { label: 'telegramBotProxyPort', tip: intl.get('代理端口') }, { label: 'telegramBotProxyAuth', tip: intl.get( 'telegram代理配置认证参数,用户名与密码用英文冒号连接 user:password', ), }, { label: 'telegramBotApiHost', tip: intl.get('telegram api自建的反向代理地址,默认tg官方api'), }, ], dingtalkBot: [ { label: 'dingtalkBotToken', tip: intl.get( '钉钉机器人webhook token,例如:5a544165465465645d0f31dca676e7bd07415asdasd', ), required: true, }, { label: 'dingtalkBotSecret', tip: intl.get( '密钥,机器人安全设置页面,加签一栏下面显示的SEC开头的字符串', ), }, ], weWorkBot: [ { label: 'weWorkBotKey', tip: intl.get( '企业微信机器人的webhook(详见文档 https://work.weixin.qq.com/api/doc/90000/90136/91770),例如:693a91f6-7xxx-4bc4-97a0-0ec2sifa5aaa', ), required: true, }, { label: 'weWorkOrigin', tip: intl.get('企业微信代理地址'), }, ], weWorkApp: [ { label: 'weWorkAppKey', tip: intl.get( 'corpid、corpsecret、touser(注:多个成员ID使用|隔开)、agentid、消息类型(选填,不填默认文本消息类型) 注意用,号隔开(英文输入法的逗号),例如:wwcfrs,B-76WERQ,qinglong,1000001,2COat', ), required: true, }, { label: 'weWorkOrigin', tip: intl.get('企业微信代理地址'), }, ], aibotk: [ { label: 'aibotkKey', tip: intl.get( '密钥key,智能微秘书个人中心获取apikey,申请地址:https://wechat.aibotk.com/signup?from=ql', ), required: true, }, { label: 'aibotkType', tip: intl.get('发送的目标,群组或者好友'), required: true, placeholder: intl.get('请输入要发送的目标'), items: [ { value: 'room', label: intl.get('群聊') }, { value: 'contact', label: intl.get('好友') }, ], }, { label: 'aibotkName', tip: intl.get( '要发送的用户昵称或群名,如果目标是群,需要填群名,如果目标是好友,需要填好友昵称', ), required: true, }, ], iGot: [ { label: 'iGotPushKey', tip: intl.get( 'iGot的信息推送key,例如:https://push.hellyw.com/XXXXXXXX', ), required: true, }, ], pushPlus: [ { label: 'pushPlusToken', tip: intl.get( '微信扫码登录后一对一推送或一对多推送下面的token(您的Token),不提供PUSH_PLUS_USER则默认为一对一推送,参考 https://www.pushplus.plus/', ), required: true, }, { label: 'pushPlusUser', tip: intl.get( '一对多推送的“群组编码”(一对多推送下面->您的群组(如无则创建)->群组编码,如果您是创建群组人。也需点击“查看二维码”扫描绑定,否则不能接受群组消息推送)', ), }, { label: 'pushplusTemplate', tip: intl.get('发送模板'), }, { label: 'pushplusChannel', tip: intl.get('发送渠道'), }, { label: 'pushplusWebhook', tip: intl.get('webhook编码'), }, { label: 'pushplusCallbackUrl', tip: intl.get('发送结果回调地址'), }, { label: 'pushplusTo', tip: intl.get('好友令牌'), }, ], wePlusBot: [ { label: 'wePlusBotToken', tip: intl.get( '用户令牌,扫描登录后 我的—>设置->令牌 中获取,参考 https://www.weplusbot.com/', ), required: true, }, { label: 'wePlusBotReceiver', tip: intl.get('消息接收人'), }, { label: 'wePlusBotVersion', tip: intl.get( '调用版本;专业版填写pro,个人版填写personal,为空默认使用专业版', ), }, ], wxPusherBot: [ { label: 'wxPusherBotAppToken', tip: intl.get('wxPusherBot的appToken'), required: true, }, { label: 'wxPusherBotTopicIds', tip: intl.get('wxPusherBot的topicIds'), required: false, }, { label: 'wxPusherBotUids', tip: intl.get('wxPusherBot的uids'), required: false, }, ], lark: [ { label: 'larkKey', tip: intl.get( '飞书群组机器人:https://www.feishu.cn/hc/zh-CN/articles/360024984973', ), required: true, }, { label: 'larkSecret', tip: intl.get( '飞书群组机器人加签密钥,安全设置中开启签名校验后获得', ), }, ], email: [ { label: 'emailService', tip: intl.get( '邮箱服务名称,比如126、163、Gmail、QQ等,支持列表https://github.com/nodemailer/nodemailer/blob/master/lib/well-known/services.json', ), required: true, }, { label: 'emailUser', tip: intl.get('邮箱认证地址'), required: true }, { label: 'emailPass', tip: intl.get( 'SMTP 登录密码,也可能为特殊口令,视具体邮件服务商说明而定', ), required: true, }, { label: 'emailTo', tip: intl.get('收件邮箱地址,多个分号分隔,默认发送给发件邮箱地址'), }, ], pushMe: [ { label: 'pushMeKey', tip: intl.get('PushMe的Key,https://push.i-i.me/'), required: true, }, { label: 'pushMeUrl', tip: intl.get( '自建的PushMeServer消息接口地址,例如:http://127.0.0.1:3010,不填则使用官方消息接口', ), required: false, }, ], chronocat: [ { label: 'chronocatURL', tip: intl.get( 'Chronocat Red 服务的连接地址 https://chronocat.vercel.app/install/docker/official/', ), required: true, }, { label: 'chronocatQQ', tip: intl.get( '个人:user_id=个人QQ 群则填入group_id=QQ群 多个用英文;隔开同时支持个人和群 如:user_id=xxx;group_id=xxxx;group_id=xxxxx', ), required: true, }, { label: 'chronocatToken', tip: intl.get( 'docker安装在持久化config目录下的chronocat.yml文件可找到', ), required: true, }, ], webhook: [ { label: 'webhookMethod', tip: intl.get('请求方法'), required: true, items: [{ value: 'GET' }, { value: 'POST' }, { value: 'PUT' }], }, { label: 'webhookContentType', tip: intl.get('请求头Content-Type'), required: true, items: [ { value: 'text/plain' }, { value: 'application/json' }, { value: 'multipart/form-data' }, { value: 'application/x-www-form-urlencoded' }, ], }, { label: 'webhookUrl', tip: intl.get( '请求链接以http或者https开头。url或者body中必须包含$title,$content可选,对应api内容的位置', ), required: true, placeholder: 'https://xxx.cn/api?content=$title\n', }, { label: 'webhookHeaders', tip: intl.get('请求头格式Custom-Header1: Header1,多个换行分割'), placeholder: 'Custom-Header1: Header1\nCustom-Header2: Header2', }, { label: 'webhookBody', tip: intl.get( '请求体格式key1: value1,多个换行分割。url或者body中必须包含$title,$content可选,对应api内容的位置', ), placeholder: 'key1: $title\nkey2: $content', }, ], }, documentTitleMap: { '/login': intl.get('登录'), '/initialization': intl.get('初始化'), '/crontab': intl.get('定时任务'), '/env': intl.get('环境变量'), '/subscription': intl.get('订阅管理'), '/config': intl.get('配置文件'), '/script': intl.get('脚本管理'), '/diff': intl.get('对比工具'), '/log': intl.get('日志管理'), '/setting': intl.get('系统设置'), '/error': intl.get('错误日志'), '/dependence': intl.get('依赖管理'), }, dependenceTypes: ['nodejs', 'python3', 'linux'], }; ================================================ FILE: src/utils/const.ts ================================================ export const LOG_END_SYMBOL = '     '; export const LANG_MAP = { '.py': 'python', '.js': 'javascript', '.mjs': 'javascript', '.sh': 'shell', '.ts': 'typescript', '.ini': 'ini', '.json': 'json', }; export const TIMEZONES = [ 'UTC', 'Africa/Abidjan', 'Africa/Accra', 'Africa/Addis Ababa', 'Africa/Algiers', 'Africa/Asmara', 'Africa/Bamako', 'Africa/Bangui', 'Africa/Banjul', 'Africa/Bissau', 'Africa/Blantyre', 'Africa/Brazzaville', 'Africa/Bujumbura', 'Africa/Cairo', 'Africa/Casablanca', 'Africa/Ceuta', 'Africa/Conakry', 'Africa/Dakar', 'Africa/Dar es Salaam', 'Africa/Djibouti', 'Africa/Douala', 'Africa/El Aaiun', 'Africa/Freetown', 'Africa/Gaborone', 'Africa/Harare', 'Africa/Johannesburg', 'Africa/Juba', 'Africa/Kampala', 'Africa/Khartoum', 'Africa/Kigali', 'Africa/Kinshasa', 'Africa/Lagos', 'Africa/Libreville', 'Africa/Lome', 'Africa/Luanda', 'Africa/Lubumbashi', 'Africa/Lusaka', 'Africa/Malabo', 'Africa/Maputo', 'Africa/Maseru', 'Africa/Mbabane', 'Africa/Mogadishu', 'Africa/Monrovia', 'Africa/Nairobi', 'Africa/Ndjamena', 'Africa/Niamey', 'Africa/Nouakchott', 'Africa/Ouagadougou', 'Africa/Porto-Novo', 'Africa/Sao Tome', 'Africa/Tripoli', 'Africa/Tunis', 'Africa/Windhoek', 'America/Adak', 'America/Anchorage', 'America/Anguilla', 'America/Antigua', 'America/Araguaina', 'America/Argentina/Buenos Aires', 'America/Argentina/Catamarca', 'America/Argentina/Cordoba', 'America/Argentina/Jujuy', 'America/Argentina/La Rioja', 'America/Argentina/Mendoza', 'America/Argentina/Rio Gallegos', 'America/Argentina/Salta', 'America/Argentina/San Juan', 'America/Argentina/San Luis', 'America/Argentina/Tucuman', 'America/Argentina/Ushuaia', 'America/Aruba', 'America/Asuncion', 'America/Atikokan', 'America/Bahia', 'America/Bahia Banderas', 'America/Barbados', 'America/Belem', 'America/Belize', 'America/Blanc-Sablon', 'America/Boa Vista', 'America/Bogota', 'America/Boise', 'America/Cambridge Bay', 'America/Campo Grande', 'America/Cancun', 'America/Caracas', 'America/Cayenne', 'America/Cayman', 'America/Chicago', 'America/Chihuahua', 'America/Ciudad Juarez', 'America/Costa Rica', 'America/Creston', 'America/Cuiaba', 'America/Curacao', 'America/Danmarkshavn', 'America/Dawson', 'America/Dawson Creek', 'America/Denver', 'America/Detroit', 'America/Dominica', 'America/Edmonton', 'America/Eirunepe', 'America/El Salvador', 'America/Fort Nelson', 'America/Fortaleza', 'America/Glace Bay', 'America/Goose Bay', 'America/Grand Turk', 'America/Grenada', 'America/Guadeloupe', 'America/Guatemala', 'America/Guayaquil', 'America/Guyana', 'America/Halifax', 'America/Havana', 'America/Hermosillo', 'America/Indiana/Indianapolis', 'America/Indiana/Knox', 'America/Indiana/Marengo', 'America/Indiana/Petersburg', 'America/Indiana/Tell City', 'America/Indiana/Vevay', 'America/Indiana/Vincennes', 'America/Indiana/Winamac', 'America/Inuvik', 'America/Iqaluit', 'America/Jamaica', 'America/Juneau', 'America/Kentucky/Louisville', 'America/Kentucky/Monticello', 'America/Kralendijk', 'America/La Paz', 'America/Lima', 'America/Los Angeles', 'America/Lower Princes', 'America/Maceio', 'America/Managua', 'America/Manaus', 'America/Marigot', 'America/Martinique', 'America/Matamoros', 'America/Mazatlan', 'America/Menominee', 'America/Merida', 'America/Metlakatla', 'America/Mexico City', 'America/Miquelon', 'America/Moncton', 'America/Monterrey', 'America/Montevideo', 'America/Montserrat', 'America/Nassau', 'America/New York', 'America/Nome', 'America/Noronha', 'America/North Dakota/Beulah', 'America/North Dakota/Center', 'America/North Dakota/New Salem', 'America/Nuuk', 'America/Ojinaga', 'America/Panama', 'America/Paramaribo', 'America/Phoenix', 'America/Port of Spain', 'America/Port-au-Prince', 'America/Porto Velho', 'America/Puerto Rico', 'America/Punta Arenas', 'America/Rankin Inlet', 'America/Recife', 'America/Regina', 'America/Resolute', 'America/Rio Branco', 'America/Santarem', 'America/Santiago', 'America/Santo Domingo', 'America/Sao Paulo', 'America/Scoresbysund', 'America/Sitka', 'America/St Barthelemy', 'America/St Johns', 'America/St Kitts', 'America/St Lucia', 'America/St Thomas', 'America/St Vincent', 'America/Swift Current', 'America/Tegucigalpa', 'America/Thule', 'America/Tijuana', 'America/Toronto', 'America/Tortola', 'America/Vancouver', 'America/Whitehorse', 'America/Winnipeg', 'America/Yakutat', 'Antarctica/Casey', 'Antarctica/Davis', 'Antarctica/DumontDUrville', 'Antarctica/Macquarie', 'Antarctica/Mawson', 'Antarctica/McMurdo', 'Antarctica/Palmer', 'Antarctica/Rothera', 'Antarctica/Syowa', 'Antarctica/Troll', 'Antarctica/Vostok', 'Arctic/Longyearbyen', 'Asia/Aden', 'Asia/Almaty', 'Asia/Amman', 'Asia/Anadyr', 'Asia/Aqtau', 'Asia/Aqtobe', 'Asia/Ashgabat', 'Asia/Atyrau', 'Asia/Baghdad', 'Asia/Bahrain', 'Asia/Baku', 'Asia/Bangkok', 'Asia/Barnaul', 'Asia/Beirut', 'Asia/Bishkek', 'Asia/Brunei', 'Asia/Chita', 'Asia/Choibalsan', 'Asia/Colombo', 'Asia/Damascus', 'Asia/Dhaka', 'Asia/Dili', 'Asia/Dubai', 'Asia/Dushanbe', 'Asia/Famagusta', 'Asia/Gaza', 'Asia/Hebron', 'Asia/Ho Chi Minh', 'Asia/Hong Kong', 'Asia/Hovd', 'Asia/Irkutsk', 'Asia/Jakarta', 'Asia/Jayapura', 'Asia/Jerusalem', 'Asia/Kabul', 'Asia/Kamchatka', 'Asia/Karachi', 'Asia/Kathmandu', 'Asia/Khandyga', 'Asia/Kolkata', 'Asia/Krasnoyarsk', 'Asia/Kuala Lumpur', 'Asia/Kuching', 'Asia/Kuwait', 'Asia/Macau', 'Asia/Magadan', 'Asia/Makassar', 'Asia/Manila', 'Asia/Muscat', 'Asia/Nicosia', 'Asia/Novokuznetsk', 'Asia/Novosibirsk', 'Asia/Omsk', 'Asia/Oral', 'Asia/Phnom Penh', 'Asia/Pontianak', 'Asia/Pyongyang', 'Asia/Qatar', 'Asia/Qostanay', 'Asia/Qyzylorda', 'Asia/Riyadh', 'Asia/Sakhalin', 'Asia/Samarkand', 'Asia/Seoul', 'Asia/Shanghai', 'Asia/Singapore', 'Asia/Srednekolymsk', 'Asia/Taipei', 'Asia/Tashkent', 'Asia/Tbilisi', 'Asia/Tehran', 'Asia/Thimphu', 'Asia/Tokyo', 'Asia/Tomsk', 'Asia/Ulaanbaatar', 'Asia/Urumqi', 'Asia/Ust-Nera', 'Asia/Vientiane', 'Asia/Vladivostok', 'Asia/Yakutsk', 'Asia/Yangon', 'Asia/Yekaterinburg', 'Asia/Yerevan', 'Atlantic/Azores', 'Atlantic/Bermuda', 'Atlantic/Canary', 'Atlantic/Cape Verde', 'Atlantic/Faroe', 'Atlantic/Madeira', 'Atlantic/Reykjavik', 'Atlantic/South Georgia', 'Atlantic/St Helena', 'Atlantic/Stanley', 'Australia/Adelaide', 'Australia/Brisbane', 'Australia/Broken Hill', 'Australia/Darwin', 'Australia/Eucla', 'Australia/Hobart', 'Australia/Lindeman', 'Australia/Lord Howe', 'Australia/Melbourne', 'Australia/Perth', 'Australia/Sydney', 'Etc/GMT', 'Etc/GMT+1', 'Etc/GMT+10', 'Etc/GMT+11', 'Etc/GMT+12', 'Etc/GMT+2', 'Etc/GMT+3', 'Etc/GMT+4', 'Etc/GMT+5', 'Etc/GMT+6', 'Etc/GMT+7', 'Etc/GMT+8', 'Etc/GMT+9', 'Etc/GMT-1', 'Etc/GMT-10', 'Etc/GMT-11', 'Etc/GMT-12', 'Etc/GMT-13', 'Etc/GMT-14', 'Etc/GMT-2', 'Etc/GMT-3', 'Etc/GMT-4', 'Etc/GMT-5', 'Etc/GMT-6', 'Etc/GMT-7', 'Etc/GMT-8', 'Etc/GMT-9', 'Europe/Amsterdam', 'Europe/Andorra', 'Europe/Astrakhan', 'Europe/Athens', 'Europe/Belgrade', 'Europe/Berlin', 'Europe/Bratislava', 'Europe/Brussels', 'Europe/Bucharest', 'Europe/Budapest', 'Europe/Busingen', 'Europe/Chisinau', 'Europe/Copenhagen', 'Europe/Dublin', 'Europe/Gibraltar', 'Europe/Guernsey', 'Europe/Helsinki', 'Europe/Isle of Man', 'Europe/Istanbul', 'Europe/Jersey', 'Europe/Kaliningrad', 'Europe/Kirov', 'Europe/Kyiv', 'Europe/Lisbon', 'Europe/Ljubljana', 'Europe/London', 'Europe/Luxembourg', 'Europe/Madrid', 'Europe/Malta', 'Europe/Mariehamn', 'Europe/Minsk', 'Europe/Monaco', 'Europe/Moscow', 'Europe/Oslo', 'Europe/Paris', 'Europe/Podgorica', 'Europe/Prague', 'Europe/Riga', 'Europe/Rome', 'Europe/Samara', 'Europe/San Marino', 'Europe/Sarajevo', 'Europe/Saratov', 'Europe/Simferopol', 'Europe/Skopje', 'Europe/Sofia', 'Europe/Stockholm', 'Europe/Tallinn', 'Europe/Tirane', 'Europe/Ulyanovsk', 'Europe/Vaduz', 'Europe/Vatican', 'Europe/Vienna', 'Europe/Vilnius', 'Europe/Volgograd', 'Europe/Warsaw', 'Europe/Zagreb', 'Europe/Zurich', 'Indian/Antananarivo', 'Indian/Chagos', 'Indian/Christmas', 'Indian/Cocos', 'Indian/Comoro', 'Indian/Kerguelen', 'Indian/Mahe', 'Indian/Maldives', 'Indian/Mauritius', 'Indian/Mayotte', 'Indian/Reunion', 'Pacific/Apia', 'Pacific/Auckland', 'Pacific/Bougainville', 'Pacific/Chatham', 'Pacific/Chuuk', 'Pacific/Easter', 'Pacific/Efate', 'Pacific/Fakaofo', 'Pacific/Fiji', 'Pacific/Funafuti', 'Pacific/Galapagos', 'Pacific/Gambier', 'Pacific/Guadalcanal', 'Pacific/Guam', 'Pacific/Honolulu', 'Pacific/Kanton', 'Pacific/Kiritimati', 'Pacific/Kosrae', 'Pacific/Kwajalein', 'Pacific/Majuro', 'Pacific/Marquesas', 'Pacific/Midway', 'Pacific/Nauru', 'Pacific/Niue', 'Pacific/Norfolk', 'Pacific/Noumea', 'Pacific/Pago Pago', 'Pacific/Palau', 'Pacific/Pitcairn', 'Pacific/Pohnpei', 'Pacific/Port Moresby', 'Pacific/Rarotonga', 'Pacific/Saipan', 'Pacific/Tahiti', 'Pacific/Tarawa', 'Pacific/Tongatapu', 'Pacific/Wake', 'Pacific/Wallis', ]; ================================================ FILE: src/utils/date.ts ================================================ import Intl from 'react-intl-universal'; export function diffTime(num: number) { const diff = num * 1000; const days = Math.floor(diff / (24 * 3600 * 1000)); const leave1 = diff % (24 * 3600 * 1000); const hours = Math.floor(leave1 / (3600 * 1000)); const leave2 = leave1 % (3600 * 1000); const minutes = Math.floor(leave2 / (60 * 1000)); const leave3 = leave2 % (60 * 1000); const seconds = Math.round(leave3 / 1000); let returnStr = `${seconds} ${Intl.get('秒')}`; if (minutes > 0) { returnStr = `${minutes} ${Intl.get('分')} ` + returnStr; } if (hours > 0) { returnStr = `${hours} ${Intl.get('时')} ` + returnStr; } if (days > 0) { returnStr = `${days} ${Intl.get('天')} ` + returnStr; } return returnStr; } ================================================ FILE: src/utils/hooks.ts ================================================ import { useState, useEffect, useMemo } from 'react'; import browserType from './index'; export const useCtx = () => { const [width, setWidth] = useState('100%'); const [marginLeft, setMarginLeft] = useState(0); const [marginTop, setMarginTop] = useState(-48); const [isPhone, setIsPhone] = useState(false); const { platform } = useMemo(() => browserType(), []); useEffect(() => { if (platform === 'mobile' && document.body.offsetWidth < 768) { setWidth('auto'); setMarginLeft(0); setMarginTop(0); setIsPhone(true); document.body.setAttribute('data-mode', 'phone'); } else { setWidth('100%'); setMarginLeft(0); setMarginTop(-48); setIsPhone(false); document.body.setAttribute('data-mode', 'desktop'); } }, []); return { headerStyle: { padding: '4px 16px 4px 15px', position: 'sticky', top: 0, left: 0, zIndex: 20, marginTop, width, marginLeft, } as any, isPhone, }; }; export const useTheme = () => { const [theme, setTheme] = useState<'vs' | 'vs-dark'>(); const reloadTheme = () => { const media = window.matchMedia('(prefers-color-scheme: dark)'); const storageTheme = localStorage.getItem('qinglong_dark_theme'); const isDark = (media.matches && storageTheme !== 'light') || storageTheme === 'dark'; setTheme(isDark ? 'vs-dark' : 'vs'); }; useEffect(() => { const media = window.matchMedia('(prefers-color-scheme: dark)'); const storageTheme = localStorage.getItem('qinglong_dark_theme'); const isDark = (media.matches && storageTheme !== 'light') || storageTheme === 'dark'; setTheme(isDark ? 'vs-dark' : 'vs'); const cb = (e: any) => { if (storageTheme === 'auto' || !storageTheme) { if (e.matches) { setTheme('vs-dark'); } else { setTheme('vs'); } } }; if (typeof media.addEventListener === 'function') { media.addEventListener('change', cb); } else if (typeof media.addListener === 'function') { media.addListener(cb); } }, []); return { theme, reloadTheme }; }; ================================================ FILE: src/utils/http.tsx ================================================ import intl from 'react-intl-universal'; import { message, notification } from 'antd'; import config from './config'; import { history } from '@umijs/max'; import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig, } from 'axios'; export interface IResponseData { code?: number; data?: any; message?: string; error?: any; } export type Override< T, K extends Partial<{ [P in keyof T]: any }> | string, > = K extends string ? Omit & { [P in keyof T]: T[P] | unknown } : Omit & K; export interface ICustomConfig { onError?: (res: AxiosResponse) => void; } message.config({ duration: 2, }); const time = Date.now(); const errorHandler = function ( error: Override< AxiosError, { config: InternalAxiosRequestConfig & ICustomConfig } >, ) { if (error.response) { const msg = error.response.data ? error.response.data.message || error.message : error.response.statusText; const responseStatus = error.response.status; if ([502, 504].includes(responseStatus)) { history.push('/error'); } else if (responseStatus === 401) { if (history.location.pathname !== '/login') { message.error(intl.get('登录已过期,请重新登录')); localStorage.removeItem(config.authKey); history.push('/login'); } } else { if (typeof error.config?.onError === 'function') { return error.config?.onError(error.response); } msg && notification.error({ message: msg, description: error.response?.data?.errors ? ( <> {error.response?.data?.errors?.map((item: any) => (
{item.message} ({item.value})
))} ) : undefined, }); } } else { console.log(error.message); } return Promise.reject(error); }; let _request = axios.create({ timeout: 60000, params: { t: time }, }); const apiWhiteList = [ `${config.baseUrl}api/user/login`, `${config.baseUrl}open/auth/token`, `${config.baseUrl}api/user/two-factor/login`, `${config.baseUrl}api/system`, `${config.baseUrl}api/user/init`, `${config.baseUrl}api/user/notification/init`, ]; _request.interceptors.request.use((_config) => { const token = localStorage.getItem(config.authKey); if (token && !apiWhiteList.includes(_config.url!)) { _config.headers.Authorization = `Bearer ${token}`; return _config; } return _config; }); _request.interceptors.response.use(async (response) => { const responseStatus = response.status; if ([502, 504].includes(responseStatus)) { history.push('/error'); } else if (responseStatus === 401) { if (history.location.pathname !== '/login') { localStorage.removeItem(config.authKey); history.push('/login'); } } else { try { const res = response.data; if (res.code !== 200) { const msg = res.message || res.data; msg && notification.error({ message: msg, description: res?.errors ? ( <> {res?.errors.map((item: any) => (
{item.message}
))} ) : undefined, }); } return res; } catch (error) {} return response; } return response; }, errorHandler); export const request = _request as Override< AxiosInstance, { get( url: string, config?: AxiosRequestConfig & ICustomConfig, ): Promise; delete( url: string, config?: AxiosRequestConfig & ICustomConfig, ): Promise; post( url: string, data?: D, config?: AxiosRequestConfig & ICustomConfig, ): Promise; put( url: string, data?: D, config?: AxiosRequestConfig & ICustomConfig, ): Promise; } >; ================================================ FILE: src/utils/index.ts ================================================ import intl from 'react-intl-universal'; import { LANG_MAP, LOG_END_SYMBOL } from './const'; import CronExpressionParser from 'cron-parser'; import { ICrontab } from '@/pages/crontab/type'; export default function browserType() { // 权重:系统 + 系统版本 > 平台 > 内核 + 载体 + 内核版本 + 载体版本 > 外壳 + 外壳版本 const ua = navigator.userAgent.toLowerCase(); const testUa = (regexp: RegExp) => regexp.test(ua); const testVs = (regexp: RegExp) => (ua.match(regexp) || []) .toString() .replace(/[^0-9|_.]/g, '') .replace(/_/g, '.'); // 系统 let system = 'unknow'; if (testUa(/windows|win32|win64|wow32|wow64/g)) { system = 'windows'; // windows系统 } else if (testUa(/macintosh|macintel/g)) { system = 'macos'; // macos系统 } else if (testUa(/x11/g)) { system = 'linux'; // linux系统 } else if (testUa(/android|adr/g)) { system = 'android'; // android系统 } else if (testUa(/ios|iphone|ipad|ipod|iwatch/g)) { system = 'ios'; // ios系统 } // 系统版本 let systemVs = 'unknow'; if (system === 'windows') { if (testUa(/windows nt 5.0|windows 2000/g)) { systemVs = '2000'; } else if (testUa(/windows nt 5.1|windows xp/g)) { systemVs = 'xp'; } else if (testUa(/windows nt 5.2|windows 2003/g)) { systemVs = '2003'; } else if (testUa(/windows nt 6.0|windows vista/g)) { systemVs = 'vista'; } else if (testUa(/windows nt 6.1|windows 7/g)) { systemVs = '7'; } else if (testUa(/windows nt 6.2|windows 8/g)) { systemVs = '8'; } else if (testUa(/windows nt 6.3|windows 8.1/g)) { systemVs = '8.1'; } else if (testUa(/windows nt 10.0|windows 10/g)) { systemVs = '10'; } } else if (system === 'macos') { systemVs = testVs(/os x [\d._]+/g); } else if (system === 'android') { systemVs = testVs(/android [\d._]+/g); } else if (system === 'ios') { systemVs = testVs(/os [\d._]+/g); } // 平台 let platform = 'unknow'; if (system === 'windows' || system === 'macos' || system === 'linux') { platform = 'desktop'; // 桌面端 } else if (system === 'android' || system === 'ios' || testUa(/mobile/g)) { platform = 'mobile'; // 移动端 } // 内核和载体 let engine = 'unknow'; let supporter = 'unknow'; if (testUa(/applewebkit/g)) { engine = 'webkit'; // webkit内核 if (testUa(/edge/g)) { supporter = 'edge'; // edge浏览器 } else if (testUa(/opr/g)) { supporter = 'opera'; // opera浏览器 } else if (testUa(/chrome/g)) { supporter = 'chrome'; // chrome浏览器 } else if (testUa(/safari/g)) { supporter = 'safari'; // safari浏览器 } } else if (testUa(/gecko/g) && testUa(/firefox/g)) { engine = 'gecko'; // gecko内核 supporter = 'firefox'; // firefox浏览器 } else if (testUa(/presto/g)) { engine = 'presto'; // presto内核 supporter = 'opera'; // opera浏览器 } else if (testUa(/trident|compatible|msie/g)) { engine = 'trident'; // trident内核 supporter = 'iexplore'; // iexplore浏览器 } // 内核版本 let engineVs = 'unknow'; if (engine === 'webkit') { engineVs = testVs(/applewebkit\/[\d._]+/g); } else if (engine === 'gecko') { engineVs = testVs(/gecko\/[\d._]+/g); } else if (engine === 'presto') { engineVs = testVs(/presto\/[\d._]+/g); } else if (engine === 'trident') { engineVs = testVs(/trident\/[\d._]+/g); } // 载体版本 let supporterVs = 'unknow'; if (supporter === 'chrome') { supporterVs = testVs(/chrome\/[\d._]+/g); } else if (supporter === 'safari') { supporterVs = testVs(/version\/[\d._]+/g); } else if (supporter === 'firefox') { supporterVs = testVs(/firefox\/[\d._]+/g); } else if (supporter === 'opera') { supporterVs = testVs(/opr\/[\d._]+/g); } else if (supporter === 'iexplore') { supporterVs = testVs(/(msie [\d._]+)|(rv:[\d._]+)/g); } else if (supporter === 'edge') { supporterVs = testVs(/edge\/[\d._]+/g); } // 外壳和外壳版本 let shell = 'none'; let shellVs = 'unknow'; if (testUa(/micromessenger/g)) { shell = 'wechat'; // 微信浏览器 shellVs = testVs(/micromessenger\/[\d._]+/g); } else if (testUa(/qqbrowser/g)) { shell = 'qq'; // QQ浏览器 shellVs = testVs(/qqbrowser\/[\d._]+/g); } else if (testUa(/ucbrowser/g)) { shell = 'uc'; // UC浏览器 shellVs = testVs(/ucbrowser\/[\d._]+/g); } else if (testUa(/qihu 360se/g)) { shell = '360'; // 360浏览器(无版本) } else if (testUa(/2345explorer/g)) { shell = '2345'; // 2345浏览器 shellVs = testVs(/2345explorer\/[\d._]+/g); } else if (testUa(/metasr/g)) { shell = 'sougou'; // 搜狗浏览器(无版本) } else if (testUa(/lbbrowser/g)) { shell = 'liebao'; // 猎豹浏览器(无版本) } else if (testUa(/maxthon/g)) { shell = 'maxthon'; // 遨游浏览器 shellVs = testVs(/maxthon\/[\d._]+/g); } const result = Object.assign( { engine, // webkit gecko presto trident engineVs, platform, // desktop mobile supporter, // chrome safari firefox opera iexplore edge supporterVs, system, // windows macos linux android ios systemVs, }, shell === 'none' ? {} : { shell, // wechat qq uc 360 2345 sougou liebao maxthon shellVs, }, ); console.log( "%c\n .d88b. d888888b d8b db d888b db .d88b. d8b db d888b \n.8P Y8. `88' 888o 88 88' Y8b 88 .8P Y8. 888o 88 88' Y8b \n88 88 88 88V8o 88 88 88 88 88 88V8o 88 88 \n88 88 88 88 V8o88 88 ooo 88 88 88 88 V8o88 88 ooo \n`8P d8' .88. 88 V888 88. ~8~ 88booo. `8b d8' 88 V888 88. ~8~ \n `Y88'Y8 Y888888P VP V8P Y888P Y88888P `Y88P' VP V8P Y888P \n \n \n", 'color: blue;font-size: 14px;', ); console.log( '%c忘形雨笠烟蓑,知心牧唱樵歌。明月清风共我,闲人三个,从他今古消磨。\n', 'color: yellow;font-size: 18px;', ); console.log( `%c青龙运行环境:\n\n系统:${result.system}/${result.systemVs}\n浏览器:${result.supporter}/${result.supporterVs}\n内核:${result.engine}/${result.engineVs}`, 'color: green;font-size: 14px;font-weight: bold;', ); return result; } /** * 获取第一个表格的可视化高度 * @param {*} extraHeight 额外的高度(表格底部的内容高度 Number类型,默认为74) * @param {*} id 当前页面中有多个table时需要制定table的id */ export function getTableScroll({ extraHeight, target, }: { extraHeight?: number; target?: HTMLElement } = {}) { if (typeof extraHeight === 'undefined') { // 47 + 40 + 12 extraHeight = 99; } let tHeader = null; if (target) { tHeader = target; } else { tHeader = document.querySelector('.ant-table-wrapper'); } //表格内容距离顶部的距离 let mainTop = 0; if (tHeader) { mainTop = tHeader.getBoundingClientRect().top; } //窗体高度-表格内容顶部的高度-表格内容底部的高度 let height = document.body.clientHeight - mainTop - extraHeight; return height; } // 自动触发点击事件 function automaticClick(elment: HTMLElement) { const ev = document.createEvent('MouseEvents'); ev.initMouseEvent( 'click', true, false, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null, ); elment.dispatchEvent(ev); } // 导出文件 export function exportJson(name: string, data: string) { const urlObject = window.URL || window.webkitURL || window; const export_blob = new Blob([data]); const createA = document.createElementNS( 'http://www.w3.org/1999/xhtml', 'a', ) as any; createA.href = urlObject.createObjectURL(export_blob); createA.download = name; automaticClick(createA); } export function depthFirstSearch< T extends Record & { children?: T[] }, >(children: T[], condition: (column: T) => boolean, item?: T) { const c = [...children]; const keys = []; (function find(cls: T[] | undefined) { if (!cls) return; for (let i = 0; i < cls?.length; i++) { if (condition(cls[i])) { if (!item) { cls.splice(i, 1); return; } if (cls[i].children) { cls[i].children!.unshift(item); } else { cls[i].children = [item]; } return; } if (cls[i].children) { keys.push(cls[i].key); find(cls[i].children); } } })(c); return c; } export function findNode & { children?: T[] }>( children: T[], condition: (column: T) => boolean, ) { const c = [...children]; let item; function find(cls: T[] | undefined): T | undefined { if (!cls) return; for (let i = 0; i < cls?.length; i++) { if (condition(cls[i])) { item = cls[i]; } else if (cls[i].children) { find(cls[i].children); } } } find(c); return item as T | undefined; } export function logEnded(log: string): boolean { const endTips = [LOG_END_SYMBOL, intl.get('执行结束')]; return endTips.some((x) => log.includes(x)); } export function getCommandScript( command: string, ): [string, string] | string | undefined { const cmd = command.split(' ') as string[]; if (cmd[1] === 'repo' || cmd[1] === 'raw') { return cmd[2]; } let scriptsPart = cmd.find((x) => ['.js', '.ts', '.sh', '.py'].some((y) => x.endsWith(y)), ); if (!scriptsPart) return; const scriptDir = `${window.__ENV__QL_DIR}/data/scripts`; if (scriptsPart.startsWith(scriptDir)) { scriptsPart = scriptsPart.replace(scriptDir, ''); } let p: string, s: string; let index = scriptsPart.lastIndexOf('/'); if (index >= 0) { s = scriptsPart.slice(index + 1); p = scriptsPart.slice(0, index); } else { s = scriptsPart; p = ''; } return [s, p]; } export function parseCrontab(schedule: string): Date | null { try { const time = CronExpressionParser.parse(schedule); if (time) { return time.next().toDate(); } } catch (error) { } return null; } export function getCrontabsNextDate( schedule: string, extra_schedules: ICrontab['extra_schedules'], ): Date | null { let date = parseCrontab(schedule); if (extra_schedules?.length) { extra_schedules.forEach((x) => { const _date = parseCrontab(x.schedule); if (_date && (!date || _date < date)) { date = _date; } }); } return date; } export function getExtension(filename: string) { if (!filename) return ''; const arr = filename.split('.'); return `.${arr[arr.length - 1]}`; } export function getEditorMode(filename: string) { const extension = getExtension(filename) as keyof typeof LANG_MAP; return LANG_MAP[extension]; } export function disableBody() { const overlay = document.createElement('div'); overlay.style.position = 'fixed'; overlay.style.top = '0px'; overlay.style.left = '0px'; overlay.style.width = '100%'; overlay.style.height = '100%'; overlay.style.backgroundColor = 'transparent'; overlay.style.zIndex = '9999'; document.body.appendChild(overlay); overlay.addEventListener('click', function (event) { event.stopPropagation(); event.preventDefault(); }); document.body.style.overflow = 'hidden'; } ================================================ FILE: src/utils/init.ts ================================================ import { loader } from '@monaco-editor/react'; import config from './config'; export function init(version: string) { // monaco 编辑器配置cdn和locale loader.config({ paths: { vs: `${config.baseUrl}monaco-editor/min/vs`, }, 'vs/nls': { availableLanguages: { '*': 'zh-cn', }, }, }); } ================================================ FILE: src/utils/monaco/index.ts ================================================ import * as monaco from 'monaco-editor'; interface FileTypeConfig { extensions?: string[]; // 文件扩展名 filenames?: string[]; // 完整文件名 patterns?: RegExp[]; // 文件名正则匹配 startsWith?: string[]; // 文件名前缀匹配 endsWith?: string[]; // 文件名后缀匹配 } // 文件类型分类配置(只包含特殊文件类型) const fileTypeConfigs: Record = { // 前端特殊文件 frontend: { extensions: [ '.json5', // JSON5 '.vue', // Vue '.svelte', // Svelte '.astro', // Astro '.wxss', // 微信小程序样式 '.pcss', // PostCSS '.acss', // 支付宝小程序样式 ], patterns: [ /\.env\.(local|development|production|test)$/, /\.module\.(css|less|scss|sass)$/, /\.d\.ts$/, /\.config\.(js|ts|json)$/, ], }, // 小程序相关 miniprogram: { extensions: [ '.wxml', // 微信小程序 '.wxs', // 微信小程序 '.axml', // 支付宝小程序 '.sjs', // 支付宝小程序 '.swan', // 百度小程序 '.ttml', // 字节跳动小程序 '.ttss', // 字节跳动小程序 '.wxl', // 微信小程序语言包 '.qml', // QQ小程序 '.qss', // QQ小程序 '.ksml', // 快手小程序 '.kss', // 快手小程序 ], }, // 开发工具相关 devtools: { extensions: [ '.prisma', // Prisma '.mdx', // MDX '.swagger', // Swagger '.openapi', // OpenAPI ], }, // 锁文件 lock: { filenames: [ 'yarn.lock', 'pnpm-lock.yaml', 'package-lock.json', 'composer.lock', 'Gemfile.lock', 'poetry.lock', 'Cargo.lock', ], }, // 无后缀配置文件 noExtension: { filenames: [ '.dockerignore', '.gitignore', '.npmignore', '.browserslistrc', '.czrc', '.huskyrc', '.lintstagedrc', '.nvmrc', '.gcloudignore', '.htaccess', ], patterns: [ /^\.env\./, ], }, // CI/CD 配置 cicd: { patterns: [ /^\.github\/workflows\/.*\.yml$/, /^\.gitlab\/.*\.yml$/, /^\.circleci\/.*\.yml$/, ], }, }; /** * 检查文件是否可以在 Monaco 编辑器中预览 * @param fileName 文件名 * @returns boolean */ export function canPreviewInMonaco(fileName: string): boolean { if (!fileName) return false; // 获取 Monaco 支持的语言 const supportedLanguages = monaco.languages.getLanguages(); const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase(); const lowercaseFileName = fileName.toLowerCase(); // 检查 Monaco 原生支持 if (supportedLanguages.some((lang) => lang.extensions?.includes(ext) || (lang.filenames?.includes(lowercaseFileName)) )) { return true; } // 检查额外支持的文件类型 return Object.values(fileTypeConfigs).some(config => { return ( (config.extensions?.includes(ext)) || (config.filenames?.includes(lowercaseFileName)) || (config.patterns?.some(pattern => pattern.test(lowercaseFileName))) || (config.startsWith?.some(prefix => lowercaseFileName.startsWith(prefix))) || (config.endsWith?.some(suffix => lowercaseFileName.endsWith(suffix))) ); }); } /** * 获取文件类型分类 * @param fileName 文件名 * @returns string 文件类型分类名称 */ export function getFileCategory(fileName: string): string { if (!fileName) return 'unknown'; const lowercaseFileName = fileName.toLowerCase(); const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase(); for (const [category, config] of Object.entries(fileTypeConfigs)) { if ( (config.extensions?.includes(ext)) || (config.filenames?.includes(lowercaseFileName)) || (config.patterns?.some(pattern => pattern.test(lowercaseFileName))) || (config.startsWith?.some(prefix => lowercaseFileName.startsWith(prefix))) || (config.endsWith?.some(suffix => lowercaseFileName.endsWith(suffix))) ) { return category; } } // 检查 Monaco 原生支持 const supportedLanguages = monaco.languages.getLanguages(); if (supportedLanguages.some((lang) => lang.extensions?.includes(ext) || (lang.filenames?.includes(lowercaseFileName)) )) { return 'monaco-native'; } return 'unknown'; } ================================================ FILE: src/utils/type.ts ================================================ export type SockMessageType = | 'ping' | 'installDependence' | 'uninstallDependence' | 'updateSystemVersion' | 'manuallyRunScript' | 'runSubscriptionEnd' | 'reloadSystem' | 'updateNodeMirror' | 'updateLinuxMirror'; ================================================ FILE: src/utils/websocket.ts ================================================ import SockJS from 'sockjs-client'; import { SockMessageType } from './type'; class WebSocketManager { private static instance: WebSocketManager | null = null; private url: string; private socket: WebSocket | null = null; private subscriptions: Map void>> = new Map(); private options: { maxReconnectAttempts: number; reconnectInterval: number; heartbeatInterval: number; }; private reconnectAttempts: number = 0; private heartbeatTimeout: NodeJS.Timeout | null = null; private state: 'closed' | 'connecting' | 'open' = 'closed'; constructor(url: string, options: Partial = {}) { this.url = url; this.options = { maxReconnectAttempts: options.maxReconnectAttempts || 5, reconnectInterval: options.reconnectInterval || 3000, heartbeatInterval: options.heartbeatInterval || 30000, }; this.init(); } public static getInstance(url: string = '', options?: Partial): WebSocketManager { if (!WebSocketManager.instance) { WebSocketManager.instance = new WebSocketManager(url, options); } return WebSocketManager.instance; } private async init() { try { this.state = 'connecting'; this.emit('connecting'); while (this.reconnectAttempts < this.options.maxReconnectAttempts) { this.socket = new SockJS(this.url); this.setupEventListeners(); this.startHeartbeat(); await this.waitForClose(); this.stopHeartbeat(); this.socket = null; this.reconnectAttempts++; await new Promise((resolve) => setTimeout(resolve, this.options.reconnectInterval)); } } catch (error) { this.handleError(error); } } private setupEventListeners() { if (!this.socket) return; this.socket.onopen = () => { this.state = 'open'; this.emit('open'); }; this.socket.onmessage = (event) => { const message = JSON.parse(event.data); this.dispatchMessage(message); }; this.socket.onclose = () => { this.state = 'closed'; this.emit('close'); }; } private async waitForClose() { while (this.socket?.readyState !== SockJS.CLOSED) { await new Promise((resolve) => setTimeout(resolve, 100)); } } public subscribe(topic: SockMessageType, callback: (v: any) => void) { const topicSubscriptions = this.subscriptions.get(topic) || new Set(); if (!topicSubscriptions.has(callback)) { topicSubscriptions.add(callback); this.subscriptions.set(topic, topicSubscriptions); const subscriptionMessage = { action: 'subscribe', topic }; this.send(subscriptionMessage); } } public unsubscribe(topic: SockMessageType, callback: (v: any) => void) { const topicSubscriptions = this.subscriptions.get(topic) || new Set(); if (topicSubscriptions.has(callback)) { topicSubscriptions.delete(callback); const unsubscribeMessage = { action: 'unsubscribe', topic }; this.send(unsubscribeMessage); } } public send(message: any) { if (this.socket?.readyState === SockJS.OPEN) { this.socket.send(JSON.stringify(message)); } } private dispatchMessage(message: any) { const { type, ...others } = message; const topicSubscriptions = this.subscriptions.get(type) || new Set(); [...topicSubscriptions].forEach((callback) => callback(others)); } private startHeartbeat() { this.heartbeatTimeout = setInterval(() => { if (this.socket?.readyState === SockJS.OPEN) { this.socket.send(JSON.stringify({ type: 'heartbeat' })); } }, this.options.heartbeatInterval); } private stopHeartbeat() { if (this.heartbeatTimeout) { clearInterval(this.heartbeatTimeout); } } public close() { if (this.socket) { this.state = 'closed'; this.stopHeartbeat(); this.socket.close(); this.emit('close'); } } private handleError(error: any) { console.error('WebSocket错误:', error); this.emit('error', error); } public on(event: string, listener: Function) { // this.addListener(event, listener); } public emit(event: string, data?: any) { // this.listeners(event).forEach((listener) => listener(data)); } } export default WebSocketManager; ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "ESNext", "module": "commonjs", "moduleResolution": "node", "importHelpers": true, "jsx": "react-jsx", "esModuleInterop": true, "sourceMap": true, "baseUrl": "./", "strict": true, "paths": { "@/*": ["src/*"], "@@/*": ["src/.umi/*"] }, "lib": ["dom", "es2021", "esnext.asynciterable"], "allowSyntheticDefaultImports": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "forceConsistentCasingInFileNames": true, "pretty": true, "allowJs": true, "noEmit": false }, "include": ["src/**/*", ".umirc.ts", "typings.d.ts"], "exclude": ["node_modules", "static", "data"] } ================================================ FILE: typings.d.ts ================================================ declare module '*.css'; declare module '*.less'; declare module '*.png'; declare module '*.svg' { export function ReactComponent( props: React.SVGProps, ): React.ReactElement; const url: string; export default url; } interface Window { __ENV__QlBaseUrl: string; __ENV__DeployEnv: string; __ENV__QL_DIR: string; } ================================================ FILE: version.yaml ================================================ version: 2.20.2 changeLogLink: https://t.me/jiao_long/434 publishTime: 2026-03-01 1800 changeLog: | 1. 修复 path 安全漏洞(重要)